From 1cdf5dc503c8feef37c8b4da3c79839561050179 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Tue, 19 May 2026 18:39:42 +0100 Subject: [PATCH 01/48] Add named resource roots and rename RootFolder Reworks ResourceKey to support optional named roots in the form "root:path" with an implicit default root "project". Introduces DefaultRoot, Root, Path and FullKey properties; improves parsing and validation (TryParse/IsValidKey), preserves round-trip and equality semantics, and updates hashing/comparison. Renames API surface from RootFolder to ProjectFolder and updates all usages in explorer, menus, drag/drop, viewmodels, file tools, registry, and tests to reflect the new project-root semantics. Adds/adjusts unit tests to cover root parsing, equality, Combine/GetParent behavior, and descendant logic. --- .../Celbridge.Foundation/Core/ResourceKey.cs | 223 ++++++++++++++---- .../Resources/IResourceRegistry.cs | 6 +- .../Tools/File/FileTools.Search.cs | 2 +- .../Dialogs/ResourcePickerDialogViewModel.cs | 2 +- .../Tests/Explorer/OpenWithMenuOptionTests.cs | 6 +- Source/Tests/Resources/ResourceKeyTests.cs | 118 +++++++++ .../Tests/Resources/ResourceRegistryTests.cs | 4 +- .../Commands/CollapseAllCommand.cs | 2 +- .../Menu/ExplorerMenuContext.cs | 16 +- .../Menu/Options/AddFileMenuOption.cs | 2 +- .../Menu/Options/AddFolderMenuOption.cs | 2 +- .../Menu/Options/ArchiveMenuOption.cs | 2 +- .../Menu/Options/CopyMenuOption.cs | 4 +- .../Menu/Options/CopyPathMenuOption.cs | 4 +- .../Menu/Options/CopyResourceKeyMenuOption.cs | 6 +- .../Menu/Options/CutMenuOption.cs | 4 +- .../Menu/Options/DeleteMenuOption.cs | 4 +- .../Options/OpenFileExplorerMenuOption.cs | 4 +- .../Menu/Options/PasteMenuOption.cs | 2 +- .../Menu/Options/RenameMenuOption.cs | 10 +- .../Models/ResourceViewItem.cs | 22 +- .../ViewModels/ResourceTreeViewModel.cs | 44 ++-- .../Views/ResourceTree.ContextMenu.cs | 18 +- .../Views/ResourceTree.DragDrop.cs | 8 +- .../Views/ResourceTree.Keyboard.cs | 4 +- .../Views/ResourceTree.xaml | 12 +- .../Views/ResourceTree.xaml.cs | 24 +- .../Services/ResourceRegistry.cs | 30 +-- .../Services/ResourceRegistryDumper.cs | 2 +- 29 files changed, 415 insertions(+), 172 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs index a02c5acf0..d8a60671b 100644 --- a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs +++ b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs @@ -2,24 +2,38 @@ namespace Celbridge.Core; /// /// A unique identifier for project resources. -/// This key is based on the relative path of the resource in the project folder. +/// A resource key has the optional URI-style form "root:path"; when no root prefix +/// is supplied, the key resolves under the implicit "project" root. /// Construction validates the key format; invalid strings throw ArgumentException. /// Use TryCreate() for non-throwing validation of untrusted input. /// public readonly struct ResourceKey : IEquatable, IComparable { - // As this is a struct, if a ResourceKey member variable is not explicitly initialized, - // the key here will be null regardless of any value we assign to it here or in the constructor. - // The safest approach is to make this member variable nullable. - private readonly string? _key; + /// + /// The implicit root name used when a resource key has no root prefix. + /// + public const string DefaultRoot = "project"; + + // _root is null when this key uses the default "project" root; this lets + // default(ResourceKey) round-trip with the same semantics as ResourceKey.Empty. + // _path is null/empty for a root-only key (e.g. "temp:" or the default ""). + private readonly string? _root; + private readonly string? _path; public ResourceKey(string key) { - if (!IsValidKey(key)) + if (!TryParse(key, out var parsedRoot, out var parsedPath)) { throw new ArgumentException($"Invalid resource key: '{key}'", nameof(key)); } - _key = key; + _root = parsedRoot; + _path = parsedPath; + } + + private ResourceKey(string? root, string? path) + { + _root = root; + _path = path; } /// @@ -43,24 +57,46 @@ public static ResourceKey Create(string key) /// public static bool TryCreate(string key, out ResourceKey result) { - if (IsValidKey(key)) + if (TryParse(key, out var parsedRoot, out var parsedPath)) { - result = new ResourceKey(key); + result = new ResourceKey(parsedRoot, parsedPath); return true; } result = Empty; return false; } + /// + /// The root name for this key (e.g. "project", "temp", "logs"). Always non-empty; + /// defaults to "project" when the source string had no root prefix. + /// + public string Root => _root ?? DefaultRoot; + + /// + /// The path portion of this key, with the root prefix stripped. May be empty for a root-only key. + /// + public string Path => _path ?? string.Empty; + + /// + /// The canonical "root:path" form of this key. Always carries the explicit root prefix, + /// even for the default "project" root. Use for serialisation and unambiguous diagnostics. + /// + public string FullKey => (_root ?? DefaultRoot) + ":" + (_path ?? string.Empty); + public override string ToString() { - return _key ?? string.Empty; + // Display form: bare path for the default "project" root, "root:path" otherwise. + if (_root is null) + { + return _path ?? string.Empty; + } + return _root + ":" + (_path ?? string.Empty); } /// - /// Returns true if the resource key is empty. + /// Returns true if the resource key's path portion is empty (root-only key). /// - public bool IsEmpty => string.IsNullOrEmpty(_key); + public bool IsEmpty => string.IsNullOrEmpty(_path); public override bool Equals(object? obj) { @@ -71,17 +107,18 @@ obj is ResourceKey other && public bool Equals(ResourceKey other) { - return _key == other._key; + return Root == other.Root && + Path == other.Path; } public override int GetHashCode() { - return ToString().GetHashCode(); + return HashCode.Combine(Root, Path); } public int CompareTo(ResourceKey other) { - return string.Compare(_key, other._key, StringComparison.Ordinal); + return string.Compare(FullKey, other.FullKey, StringComparison.Ordinal); } public static bool operator ==(ResourceKey left, ResourceKey right) @@ -106,24 +143,25 @@ public int CompareTo(ResourceKey other) public static implicit operator string(ResourceKey resource) => resource.ToString(); /// - /// Returns the resource name. This is the last segment of the resource key. + /// Returns the resource name. This is the last segment of the resource key's path. /// public string ResourceName { get { - if (string.IsNullOrEmpty(_key)) + var path = _path; + if (string.IsNullOrEmpty(path)) { return string.Empty; } - int lastIndex = _key.LastIndexOf('/'); + int lastIndex = path.LastIndexOf('/'); if (lastIndex == -1) { - return _key; + return path; } - return _key.Substring(lastIndex + 1); + return path.Substring(lastIndex + 1); } } @@ -147,44 +185,55 @@ public string ResourceNameNoExtension } /// - /// Returns the parent resource key for the specified resource key. + /// Returns the parent resource key for this key. The root is preserved; the path + /// loses its last segment. The parent of a root-only key is the same root-only key. /// public ResourceKey GetParent() { - if (string.IsNullOrEmpty(_key)) + var path = _path; + if (string.IsNullOrEmpty(path)) { - return Empty; + return new ResourceKey(_root, null); } - int lastSlashIndex = _key.LastIndexOf('/'); + int lastSlashIndex = path.LastIndexOf('/'); if (lastSlashIndex == -1) { - return Empty; + return new ResourceKey(_root, null); } - var parentKey = _key.Substring(0, lastSlashIndex); - return new ResourceKey(parentKey); + var parentPath = path.Substring(0, lastSlashIndex); + return new ResourceKey(_root, parentPath); } /// /// Returns true if this resource is a descendant of the specified folder. - /// A resource is a descendant if its path starts with the folder path followed by "/". + /// A resource is a descendant if it shares the same root and its path starts with + /// the folder path followed by "/". The root-only key (empty path) is the ancestor + /// of every non-empty key under the same root. /// public bool IsDescendantOf(ResourceKey folderKey) { - var folderPath = folderKey.ToString().TrimEnd('/'); + if (Root != folderKey.Root) + { + return false; + } + + var folderPath = (folderKey._path ?? string.Empty).TrimEnd('/'); if (string.IsNullOrEmpty(folderPath)) { - // Everything is a descendant of the root folder (except empty keys) - return !string.IsNullOrEmpty(_key); + // Everything under the same root is a descendant of the root-only key + // (except a root-only key itself, which has no path). + return !string.IsNullOrEmpty(_path); } - return _key?.StartsWith(folderPath + "/", StringComparison.Ordinal) ?? false; + return _path?.StartsWith(folderPath + "/", StringComparison.Ordinal) ?? false; } /// /// Returns a new ResourceKey that is the combination of the current key and the specified segment. + /// The root is preserved; the segment is appended to the path. /// public ResourceKey Combine(string segment) { @@ -200,8 +249,8 @@ public ResourceKey Combine(string segment) throw new ArgumentException($"Segment must not contain path separators: '{segment}'", nameof(segment)); } - var combinedKey = string.IsNullOrEmpty(_key) ? segment : _key + "/" + segment; - return new ResourceKey(combinedKey); + var combinedPath = string.IsNullOrEmpty(_path) ? segment : _path + "/" + segment; + return new ResourceKey(_root, combinedPath); } /// @@ -217,7 +266,7 @@ public static bool IsValidSegment(string segment) // The GetInvalidFileNameChars() method returns an array of characters that are not allowed in file names. // Unfortunately, this array is different on different platforms. For example, on Windows, ':' is not allowed. // On Linux, ':' is a valid character in a file name. This could cause problems for some cross-platform projects. - var invalidChars = Path.GetInvalidFileNameChars(); + var invalidChars = System.IO.Path.GetInvalidFileNameChars(); foreach (var c in segment) { @@ -232,56 +281,132 @@ public static bool IsValidSegment(string segment) /// /// Returns true if the string represents a valid resource key. - /// Resource keys look similar to regular file paths but with additional constraints: - /// - Specified relative to the project folder. + /// Resource keys have the optional form "root:path" with the following constraints: + /// - The optional root prefix matches "[a-z][a-z0-9_]+:" (at least two characters before the colon). + /// - The path is relative to the root's backing folder. /// - Absolute paths, parent and same directory references are not supported. - /// - '/' is used as the path separator on all platforms, backslashes are not allowed. + /// - '/' is used as the path separator on all platforms; backslashes are not allowed. /// public static bool IsValidKey(string key) { + return TryParse(key, out _, out _); + } + + private static bool TryParse(string key, out string? root, out string? path) + { + root = null; + path = null; + if (key.Length == 0) { - // An empty resource key is valid, and refers to the project folder. + // An empty resource key is valid (default "project" root, empty path). return true; } + // Strip an optional root prefix of the form "[a-z][a-z0-9_]+:". + // The shortest legal root is two characters (e.g. "ab:"). Empty roots, + // single-character roots, and uppercase roots are rejected. + var pathPortion = key; + var colonIndex = key.IndexOf(':'); + if (colonIndex != -1) + { + var rootCandidate = key.Substring(0, colonIndex); + if (!IsValidRoot(rootCandidate)) + { + return false; + } + + if (rootCandidate != DefaultRoot) + { + root = rootCandidate; + } + + pathPortion = key.Substring(colonIndex + 1); + } + + if (pathPortion.Length == 0) + { + // Root-only form (e.g. "project:", "temp:") is valid; path is empty. + path = null; + return true; + } + + if (!IsValidPath(pathPortion)) + { + return false; + } + + path = pathPortion; + return true; + } + + private static bool IsValidRoot(string rootCandidate) + { + // [a-z][a-z0-9_]+ — first char must be a lowercase letter, total length at least 2. + if (rootCandidate.Length < 2) + { + return false; + } + + var first = rootCandidate[0]; + if (first < 'a' || first > 'z') + { + return false; + } + + for (int i = 1; i < rootCandidate.Length; i++) + { + var c = rootCandidate[i]; + bool isLower = c >= 'a' && c <= 'z'; + bool isDigit = c >= '0' && c <= '9'; + bool isUnderscore = c == '_'; + if (!isLower && !isDigit && !isUnderscore) + { + return false; + } + } + + return true; + } + + private static bool IsValidPath(string path) + { // Backslashes are not permitted - if (key.Contains("\\")) + if (path.Contains('\\')) { return false; } // Empty segments are not permitted - if (key.Contains("//")) + if (path.Contains("//")) { return false; } // Resource keys must represent a relative path - if (Path.IsPathRooted(key)) + if (System.IO.Path.IsPathRooted(path)) { return false; } // Resource keys may not contain parent or same directory references - if (key.Contains("..") || - key.Contains("./") || - key.Contains(".\\")) + if (path.Contains("..") || + path.Contains("./")) { return false; } // Resource keys may not start or end with a separator character - if (key[0] == '/' || key[^1] == '/') + if (path[0] == '/' || path[^1] == '/') { return false; } - // Each segment in the resource key must be a valid filename + // Each segment in the resource key path must be a valid filename. // Note: This constraint may prove to be too restrictive for cross-platform projects which // work with exotic file names. If this proves to be a problem we could relax this constraint in the future. - var resourceKeySegments = key.Split('/'); - foreach (var segment in resourceKeySegments) + var segments = path.Split('/'); + foreach (var segment in segments) { if (!IsValidSegment(segment)) { diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs index 0e5572163..1b478166c 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs @@ -11,9 +11,9 @@ public interface IResourceRegistry string ProjectFolderPath { get; set; } /// - /// The root folder resource that contains all the resources in the project. + /// The project folder resource that contains all the resources in the project. /// - IFolderResource RootFolder { get; } + IFolderResource ProjectFolder { get; } /// /// Returns the resource key for a resource. @@ -77,7 +77,7 @@ public interface IResourceRegistry /// Returns the folder resource associated with the context menu item for a resource. /// If the resource is a folder, then the folder is returned. /// If the resource is a file, then the file's parent folder is returned. - /// If the resource is null, then the root folder is returned. + /// If the resource is null, then the project folder is returned. /// ResourceKey GetContextMenuItemFolder(IResource? resource); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs index 867551428..8a4167ee1 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs @@ -28,7 +28,7 @@ public partial CallToolResult Search(string pattern, bool includeMetadata = fals if (isFolderSearch) { var folderKeys = new List(); - CollectFolderResources(resourceRegistry.RootFolder, resourceRegistry, folderKeys); + CollectFolderResources(resourceRegistry.ProjectFolder, resourceRegistry, folderKeys); var matchingFolders = folderKeys .Where(key => regex.IsMatch(key.ToString())) diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs index 6ca709ca9..5f6a3a999 100644 --- a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs +++ b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs @@ -110,7 +110,7 @@ private void UpdatePreview() private List BuildFlatList(IResourceRegistry registry) { var items = new List(); - CollectFileResources(registry.RootFolder, registry, items); + CollectFileResources(registry.ProjectFolder, registry, items); items.Sort((a, b) => string.Compare(a.DisplayText, b.DisplayText, StringComparison.OrdinalIgnoreCase)); return items; } diff --git a/Source/Tests/Explorer/OpenWithMenuOptionTests.cs b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs index 7d1fbc086..292b38efc 100644 --- a/Source/Tests/Explorer/OpenWithMenuOptionTests.cs +++ b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs @@ -56,12 +56,12 @@ private OpenWithMenuOption CreateOption() private static ExplorerMenuContext ContextFor(IResource? clickedResource) { - var rootFolder = Substitute.For(); + var projectFolder = Substitute.For(); return new ExplorerMenuContext( ClickedResource: clickedResource, SelectedResources: clickedResource is null ? Array.Empty() : new[] { clickedResource }, - RootFolder: rootFolder, - IsRootFolderTargeted: false, + ProjectFolder: projectFolder, + IsProjectFolderTargeted: false, HasClipboardData: false, ClipboardContentType: ClipboardContentType.None, ClipboardOperation: ClipboardContentOperation.None); diff --git a/Source/Tests/Resources/ResourceKeyTests.cs b/Source/Tests/Resources/ResourceKeyTests.cs index dbd8827b5..0ec361da0 100644 --- a/Source/Tests/Resources/ResourceKeyTests.cs +++ b/Source/Tests/Resources/ResourceKeyTests.cs @@ -185,4 +185,122 @@ public void EmptyKeyIsValid() emptyKey.IsEmpty.Should().BeTrue(); emptyKey.ToString().Should().Be(""); } + + [Test] + public void ImplicitProjectRootRoundTripsCleanly() + { + // Regression guard: ResourceKey "project:foo" round-trips through the implicit + // string operator without throwing. Today's pre-redesign IsValidKey rejected the + // ':' character via Path.GetInvalidFileNameChars() on Windows. + ResourceKey rk = "project:foo"; + rk.Root.Should().Be("project"); + rk.Path.Should().Be("foo"); + rk.FullKey.Should().Be("project:foo"); + rk.ToString().Should().Be("foo"); + } + + [Test] + public void RootAccessorReturnsParsedOrDefaultRoot() + { + new ResourceKey("foo/bar").Root.Should().Be("project"); + new ResourceKey("project:foo/bar").Root.Should().Be("project"); + new ResourceKey("temp:staging/foo").Root.Should().Be("temp"); + new ResourceKey("logs:session.log").Root.Should().Be("logs"); + ResourceKey.Empty.Root.Should().Be("project"); + } + + [Test] + public void PathAccessorReturnsPathPortionOnly() + { + new ResourceKey("foo/bar").Path.Should().Be("foo/bar"); + new ResourceKey("project:foo/bar").Path.Should().Be("foo/bar"); + new ResourceKey("temp:staging/foo").Path.Should().Be("staging/foo"); + new ResourceKey("temp:").Path.Should().Be(""); + ResourceKey.Empty.Path.Should().Be(""); + } + + [Test] + public void FullKeyAlwaysCarriesRootPrefix() + { + new ResourceKey("foo/bar").FullKey.Should().Be("project:foo/bar"); + new ResourceKey("project:foo/bar").FullKey.Should().Be("project:foo/bar"); + new ResourceKey("temp:staging/foo").FullKey.Should().Be("temp:staging/foo"); + new ResourceKey("temp:").FullKey.Should().Be("temp:"); + ResourceKey.Empty.FullKey.Should().Be("project:"); + } + + [Test] + public void ToStringEmitsDisplayForm() + { + // The "project:" prefix is suppressed in display form; other roots are shown explicitly. + new ResourceKey("foo/bar").ToString().Should().Be("foo/bar"); + new ResourceKey("project:foo/bar").ToString().Should().Be("foo/bar"); + new ResourceKey("temp:staging/foo").ToString().Should().Be("temp:staging/foo"); + new ResourceKey("temp:").ToString().Should().Be("temp:"); + } + + [Test] + public void ImplicitAndExplicitProjectRootKeysAreEqual() + { + // "", "project:", and ResourceKey.Empty are equivalent forms. + var bareEmpty = new ResourceKey(""); + var explicitProject = new ResourceKey("project:"); + bareEmpty.Should().Be(explicitProject); + bareEmpty.Should().Be(ResourceKey.Empty); + + // "foo" and "project:foo" are equivalent forms. + new ResourceKey("foo").Should().Be(new ResourceKey("project:foo")); + new ResourceKey("foo/bar").GetHashCode().Should().Be(new ResourceKey("project:foo/bar").GetHashCode()); + } + + [Test] + public void InvalidRootsAreRejected() + { + // Empty root + ResourceKey.IsValidKey(":foo").Should().BeFalse(); + // Uppercase root + ResourceKey.IsValidKey("Project:foo").Should().BeFalse(); + // Single-character root + ResourceKey.IsValidKey("a:foo").Should().BeFalse(); + // Root with leading digit + ResourceKey.IsValidKey("1ab:foo").Should().BeFalse(); + // Root with invalid character + ResourceKey.IsValidKey("te-mp:foo").Should().BeFalse(); + + // Valid: lowercase letter followed by [a-z0-9_]+ + ResourceKey.IsValidKey("temp:foo").Should().BeTrue(); + ResourceKey.IsValidKey("logs:foo").Should().BeTrue(); + ResourceKey.IsValidKey("a1:foo").Should().BeTrue(); + ResourceKey.IsValidKey("a_b:foo").Should().BeTrue(); + } + + [Test] + public void CombineAndGetParentPreserveRoot() + { + var temp = new ResourceKey("temp:staging"); + var combined = temp.Combine("file.txt"); + combined.Root.Should().Be("temp"); + combined.Path.Should().Be("staging/file.txt"); + combined.ToString().Should().Be("temp:staging/file.txt"); + + var parent = combined.GetParent(); + parent.Root.Should().Be("temp"); + parent.Path.Should().Be("staging"); + parent.ToString().Should().Be("temp:staging"); + } + + [Test] + public void IsDescendantOfRequiresSameRoot() + { + var tempFile = new ResourceKey("temp:staging/file.txt"); + tempFile.IsDescendantOf(new ResourceKey("temp:staging")).Should().BeTrue(); + + // Different roots are never in a descendant relationship. + tempFile.IsDescendantOf(new ResourceKey("staging")).Should().BeFalse(); + tempFile.IsDescendantOf(new ResourceKey("logs:staging")).Should().BeFalse(); + + // Project-root parent of project-root child still works. + new ResourceKey("foo/bar").IsDescendantOf(new ResourceKey("foo")).Should().BeTrue(); + new ResourceKey("project:foo/bar").IsDescendantOf(new ResourceKey("foo")).Should().BeTrue(); + } } diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 1ae52a10b..9eda36beb 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -79,7 +79,7 @@ public void ICanUpdateTheResourceTree() // Check the scanned resources match the files and folders we created earlier. // - var resources = resourceRegistry.RootFolder.Children; + var resources = resourceRegistry.ProjectFolder.Children; resources.Count.Should().Be(2); (resources[0] is FolderResource).Should().BeTrue(); @@ -126,7 +126,7 @@ public void ICanExpandAFolderResource() expandedFoldersOut.Count.Should().Be(1); expandedFoldersOut[0].Should().Be(FolderNameA); - var folderResource = (resourceRegistry.RootFolder.Children[0] as FolderResource)!; + var folderResource = (resourceRegistry.ProjectFolder.Children[0] as FolderResource)!; var folderPath = resourceRegistry.GetResourceKey(folderResource); folderStateService.IsExpanded(folderPath).Should().BeTrue(); } diff --git a/Source/Workspace/Celbridge.Explorer/Commands/CollapseAllCommand.cs b/Source/Workspace/Celbridge.Explorer/Commands/CollapseAllCommand.cs index 54e6197ae..871328762 100644 --- a/Source/Workspace/Celbridge.Explorer/Commands/CollapseAllCommand.cs +++ b/Source/Workspace/Celbridge.Explorer/Commands/CollapseAllCommand.cs @@ -21,7 +21,7 @@ public override async Task ExecuteAsync() var folderStateService = _workspaceWrapper.WorkspaceService.ExplorerService.FolderStateService; - CollapseAllFolders(resourceRegistry.RootFolder, resourceRegistry, folderStateService); + CollapseAllFolders(resourceRegistry.ProjectFolder, resourceRegistry, folderStateService); await Task.CompletedTask; diff --git a/Source/Workspace/Celbridge.Explorer/Menu/ExplorerMenuContext.cs b/Source/Workspace/Celbridge.Explorer/Menu/ExplorerMenuContext.cs index c409e5221..df34967eb 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/ExplorerMenuContext.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/ExplorerMenuContext.cs @@ -9,8 +9,8 @@ namespace Celbridge.Explorer.Menu; public record ExplorerMenuContext( IResource? ClickedResource, IReadOnlyList SelectedResources, - IFolderResource RootFolder, - bool IsRootFolderTargeted, + IFolderResource ProjectFolder, + bool IsProjectFolderTargeted, bool HasClipboardData, ClipboardContentType ClipboardContentType, ClipboardContentOperation ClipboardOperation @@ -27,9 +27,9 @@ ClipboardContentOperation ClipboardOperation public bool HasAnySelection => SelectedResources.Count > 0; /// - /// True when exactly one item is selected OR the root folder is targeted via right-click. + /// True when exactly one item is selected OR the project folder is targeted via right-click. /// - public bool IsSingleItemOrRootTargeted => HasSingleSelection || IsRootFolderTargeted; + public bool IsSingleItemOrProjectFolderTargeted => HasSingleSelection || IsProjectFolderTargeted; /// /// Gets the single selected resource, or null if zero or multiple items are selected. @@ -37,13 +37,13 @@ ClipboardContentOperation ClipboardOperation public IResource? SingleSelectedResource => HasSingleSelection ? SelectedResources[0] : null; /// - /// True if any selected resource is the root folder. + /// True if any selected resource is the project folder. /// - public bool SelectionContainsRootFolder => SelectedResources.Any(r => r == RootFolder); + public bool SelectionContainsProjectFolder => SelectedResources.Any(r => r == ProjectFolder); /// /// Resolves the target folder for operations based on the clicked resource or selection. - /// Returns the clicked/selected folder, the parent folder of a clicked/selected file, or the root folder. + /// Returns the clicked/selected folder, the parent folder of a clicked/selected file, or the project folder. /// public IFolderResource GetTargetFolder() { @@ -58,6 +58,6 @@ public IFolderResource GetTargetFolder() return fileResource.ParentFolder; } - return RootFolder; + return ProjectFolder; } } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFileMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFileMenuOption.cs index 55dc99b3b..1be8d76e6 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFileMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFileMenuOption.cs @@ -38,7 +38,7 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { return new MenuItemState( - IsVisible: context.IsSingleItemOrRootTargeted, + IsVisible: context.IsSingleItemOrProjectFolderTargeted, IsEnabled: true); } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFolderMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFolderMenuOption.cs index da38fb29b..932935eca 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFolderMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/AddFolderMenuOption.cs @@ -37,7 +37,7 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { return new MenuItemState( - IsVisible: context.IsSingleItemOrRootTargeted, + IsVisible: context.IsSingleItemOrProjectFolderTargeted, IsEnabled: true); } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/ArchiveMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/ArchiveMenuOption.cs index a9439444f..e879a8091 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/ArchiveMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/ArchiveMenuOption.cs @@ -38,7 +38,7 @@ public MenuItemState GetState(ExplorerMenuContext context) { var isSingleFolder = context.HasSingleSelection && context.SingleSelectedResource is IFolderResource && - !context.SelectionContainsRootFolder; + !context.SelectionContainsProjectFolder; return new MenuItemState( IsVisible: isSingleFolder, diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyMenuOption.cs index 186b782f3..0ac4215a3 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyMenuOption.cs @@ -37,13 +37,13 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { - var canCopy = context.HasAnySelection && !context.SelectionContainsRootFolder; + var canCopy = context.HasAnySelection && !context.SelectionContainsProjectFolder; return new MenuItemState(IsVisible: true, IsEnabled: canCopy); } public void Execute(ExplorerMenuContext context) { - if (!context.HasAnySelection || context.SelectionContainsRootFolder) + if (!context.HasAnySelection || context.SelectionContainsProjectFolder) { return; } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs index 6ff817f71..9af6cdeb4 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs @@ -36,13 +36,13 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { return new MenuItemState( - IsVisible: context.IsSingleItemOrRootTargeted, + IsVisible: context.IsSingleItemOrProjectFolderTargeted, IsEnabled: true); } public void Execute(ExplorerMenuContext context) { - var target = context.ClickedResource ?? context.RootFolder; + var target = context.ClickedResource ?? context.ProjectFolder; var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var resourceKey = resourceRegistry.GetResourceKey(target); diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyResourceKeyMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyResourceKeyMenuOption.cs index 7df108616..52305bb68 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyResourceKeyMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyResourceKeyMenuOption.cs @@ -35,8 +35,8 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { - // Don't show for root folder (it has an empty ResourceKey) - var canCopy = context.ClickedResource != null && context.ClickedResource != context.RootFolder; + // Don't show for project folder (it has an empty ResourceKey) + var canCopy = context.ClickedResource != null && context.ClickedResource != context.ProjectFolder; return new MenuItemState( IsVisible: context.ClickedResource != null, IsEnabled: canCopy); @@ -44,7 +44,7 @@ public MenuItemState GetState(ExplorerMenuContext context) public void Execute(ExplorerMenuContext context) { - if (context.ClickedResource == null || context.ClickedResource == context.RootFolder) + if (context.ClickedResource == null || context.ClickedResource == context.ProjectFolder) { return; } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/CutMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/CutMenuOption.cs index 14bbd6799..aeeaf3eb0 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/CutMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/CutMenuOption.cs @@ -37,13 +37,13 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { - var canCut = context.HasAnySelection && !context.SelectionContainsRootFolder; + var canCut = context.HasAnySelection && !context.SelectionContainsProjectFolder; return new MenuItemState(IsVisible: true, IsEnabled: canCut); } public void Execute(ExplorerMenuContext context) { - if (!context.HasAnySelection || context.SelectionContainsRootFolder) + if (!context.HasAnySelection || context.SelectionContainsProjectFolder) { return; } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/DeleteMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/DeleteMenuOption.cs index 4e3d31f16..409eda0ac 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/DeleteMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/DeleteMenuOption.cs @@ -36,13 +36,13 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { - var canDelete = context.HasAnySelection && !context.SelectionContainsRootFolder; + var canDelete = context.HasAnySelection && !context.SelectionContainsProjectFolder; return new MenuItemState(IsVisible: true, IsEnabled: canDelete); } public void Execute(ExplorerMenuContext context) { - if (!context.HasAnySelection || context.SelectionContainsRootFolder) + if (!context.HasAnySelection || context.SelectionContainsProjectFolder) { return; } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenFileExplorerMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenFileExplorerMenuOption.cs index 29fd5ea31..a8f6d565b 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenFileExplorerMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenFileExplorerMenuOption.cs @@ -35,13 +35,13 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { return new MenuItemState( - IsVisible: context.IsSingleItemOrRootTargeted, + IsVisible: context.IsSingleItemOrProjectFolderTargeted, IsEnabled: true); } public void Execute(ExplorerMenuContext context) { - var target = context.ClickedResource ?? context.RootFolder; + var target = context.ClickedResource ?? context.ProjectFolder; var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var resourceKey = resourceRegistry.GetResourceKey(target); diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/PasteMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/PasteMenuOption.cs index fc4b42993..2ebdb9f63 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/PasteMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/PasteMenuOption.cs @@ -38,7 +38,7 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { return new MenuItemState( - IsVisible: context.IsSingleItemOrRootTargeted, + IsVisible: context.IsSingleItemOrProjectFolderTargeted, IsEnabled: context.HasClipboardData); } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/RenameMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/RenameMenuOption.cs index 586d9f175..684685710 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/RenameMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/RenameMenuOption.cs @@ -36,10 +36,10 @@ public MenuItemDisplayInfo GetDisplayInfo(ExplorerMenuContext context) public MenuItemState GetState(ExplorerMenuContext context) { - // Cannot rename root folder (whether clicked directly or in selection) - var canRename = context.ClickedResource != null && - !context.IsRootFolderTargeted && - !context.SelectionContainsRootFolder; + // Cannot rename the project folder (whether clicked directly or in selection) + var canRename = context.ClickedResource != null && + !context.IsProjectFolderTargeted && + !context.SelectionContainsProjectFolder; return new MenuItemState( IsVisible: context.ClickedResource != null, IsEnabled: canRename); @@ -47,7 +47,7 @@ public MenuItemState GetState(ExplorerMenuContext context) public void Execute(ExplorerMenuContext context) { - if (context.ClickedResource == null || context.SelectionContainsRootFolder) + if (context.ClickedResource == null || context.SelectionContainsProjectFolder) { return; } diff --git a/Source/Workspace/Celbridge.Explorer/Models/ResourceViewItem.cs b/Source/Workspace/Celbridge.Explorer/Models/ResourceViewItem.cs index 1622502df..d66aefeac 100644 --- a/Source/Workspace/Celbridge.Explorer/Models/ResourceViewItem.cs +++ b/Source/Workspace/Celbridge.Explorer/Models/ResourceViewItem.cs @@ -36,41 +36,41 @@ public partial class ResourceViewItem : ObservableObject /// /// The margin used for visual indentation based on tree depth. - /// Root folder gets negative margin to align with left edge of panel. + /// Project folder gets negative margin to align with left edge of panel. /// - public Thickness IndentMargin => IsRootFolder - ? new Thickness(-32, 0, 0, 0) // Shift root folder left to align with panel edge + public Thickness IndentMargin => IsProjectFolder + ? new Thickness(-32, 0, 0, 0) // Shift project folder left to align with panel edge : new Thickness(IndentLevel * 20, 0, 0, 0); /// /// The name of the resource for display. - /// For root folder, this returns the project folder name. + /// For the project folder, this returns the project folder name. /// public string Name { get; } /// - /// Whether this item is the root project folder. - /// Root folder has special handling. + /// Whether this item is the project folder. + /// The project folder has special handling. /// - public bool IsRootFolder { get; } + public bool IsProjectFolder { get; } /// /// Visibility for the expand/collapse chevron. - /// Hidden for root folder and files + /// Hidden for the project folder and files. /// public Visibility ChevronVisibility => - IsRootFolder ? Visibility.Collapsed : (HasChildren ? Visibility.Visible : Visibility.Collapsed); + IsProjectFolder ? Visibility.Collapsed : (HasChildren ? Visibility.Visible : Visibility.Collapsed); /// /// Creates a new ResourceViewItem for the given resource. /// - public ResourceViewItem(IResource resource, int indentLevel, bool isExpanded, bool hasChildren, bool isRootFolder = false, string? displayName = null) + public ResourceViewItem(IResource resource, int indentLevel, bool isExpanded, bool hasChildren, bool isProjectFolder = false, string? displayName = null) { Resource = resource; IndentLevel = indentLevel; _isExpanded = isExpanded; HasChildren = hasChildren; - IsRootFolder = isRootFolder; + IsProjectFolder = isProjectFolder; Name = displayName ?? resource.Name; } } diff --git a/Source/Workspace/Celbridge.Explorer/ViewModels/ResourceTreeViewModel.cs b/Source/Workspace/Celbridge.Explorer/ViewModels/ResourceTreeViewModel.cs index defed6159..5e4f709d0 100644 --- a/Source/Workspace/Celbridge.Explorer/ViewModels/ResourceTreeViewModel.cs +++ b/Source/Workspace/Celbridge.Explorer/ViewModels/ResourceTreeViewModel.cs @@ -39,9 +39,9 @@ public partial class ResourceTreeViewModel : ObservableObject public List SelectedItems { get; private set; } = []; /// - /// The root folder resource. + /// The project folder resource. /// - public IFolderResource RootFolder => _resourceRegistry.RootFolder; + public IFolderResource ProjectFolder => _resourceRegistry.ProjectFolder; /// /// Raised when the view should update the selected resources. @@ -157,22 +157,22 @@ public void RebuildResourceTree(List? selectedResources = null) private List BuildResourceViewItems() { var items = new List(); - var rootFolder = _resourceRegistry.RootFolder; + var projectFolder = _resourceRegistry.ProjectFolder; - // Add the root folder as the first item (always expanded, never collapsible) - var hasChildren = rootFolder.Children.Count > 0; + // Add the project folder as the first item (always expanded, never collapsible) + var hasChildren = projectFolder.Children.Count > 0; var projectName = Path.GetFileName(_resourceRegistry.ProjectFolderPath); - var rootItem = new ResourceViewItem( - rootFolder, + var projectFolderItem = new ResourceViewItem( + projectFolder, indentLevel: 0, isExpanded: true, hasChildren, - isRootFolder: true, + isProjectFolder: true, displayName: projectName); - items.Add(rootItem); + items.Add(projectFolderItem); - // Add children at indent level 0 (root uses negative margin, so children at 0 align correctly) - BuildResourceViewItemsRecursive(rootFolder.Children, items, indentLevel: 0); + // Add children at indent level 0 (project folder uses negative margin, so children at 0 align correctly) + BuildResourceViewItemsRecursive(projectFolder.Children, items, indentLevel: 0); return items; } @@ -296,12 +296,12 @@ public bool SelectParentFolder() // /// - /// Toggles the expansion state of a folder item (except root folder). + /// Toggles the expansion state of a folder item (except the project folder). /// public void ToggleExpand(ResourceViewItem item) { - // Don't allow toggling root folder expansion - if (!item.IsFolder || !item.HasChildren || item.IsRootFolder) + // Don't allow toggling project folder expansion + if (!item.IsFolder || !item.HasChildren || item.IsProjectFolder) { return; } @@ -351,8 +351,8 @@ public void ExpandItem(ResourceViewItem item) /// public void CollapseItem(ResourceViewItem item) { - // Don't allow collapsing the root folder - if (!item.IsFolder || !item.IsExpanded || item.IsRootFolder) + // Don't allow collapsing the project folder + if (!item.IsFolder || !item.IsExpanded || item.IsProjectFolder) { return; } @@ -404,10 +404,10 @@ public void CollapseAllFolders() foreach (var item in TreeItems.ToList()) { - // Skip root folder - it should never be collapsed + // Skip the project folder - it should never be collapsed if (item.IsFolder && item.IsExpanded && - !item.IsRootFolder) + !item.IsProjectFolder) { item.IsExpanded = false; if (item.Resource is IFolderResource folderResource) @@ -548,13 +548,13 @@ public List GetSiblingItems(ResourceViewItem? selectedItem = n // Determine the parent folder key: // - If an item is provided/selected, use its parent folder's key - // - If nothing is selected, use the root folder's key (for root-level items) + // - If nothing is selected, use the project folder's key (for project-level items) var targetParentKey = item != null ? GetParentKey(item.Resource.ParentFolder) - : _resourceRegistry.GetResourceKey(RootFolder); + : _resourceRegistry.GetResourceKey(ProjectFolder); return TreeItems - .Where(i => !i.IsRootFolder && GetParentKey(i.Resource.ParentFolder) == targetParentKey) + .Where(i => !i.IsProjectFolder && GetParentKey(i.Resource.ParentFolder) == targetParentKey) .ToList(); } @@ -562,6 +562,6 @@ private ResourceKey GetParentKey(IFolderResource? parentFolder) { return parentFolder != null ? _resourceRegistry.GetResourceKey(parentFolder) - : _resourceRegistry.GetResourceKey(RootFolder); + : _resourceRegistry.GetResourceKey(ProjectFolder); } } diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.ContextMenu.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.ContextMenu.cs index e3c28d278..4e6e94bfe 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.ContextMenu.cs +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.ContextMenu.cs @@ -11,7 +11,7 @@ private async void ListView_RightTapped(object sender, RightTappedRoutedEventArg { // If right-clicking on an already-selected item, preserve the multi-selection // If right-clicking on an unselected item, select only that item - // If right-clicking on root folder or empty space, track root as the clicked resource + // If right-clicking on the project folder or empty space, track the project folder as the clicked resource var position = e.GetPosition(ResourceListView); var clickedItem = FindItemAtPosition(position); @@ -20,9 +20,9 @@ private async void ListView_RightTapped(object sender, RightTappedRoutedEventArg { clickedResource = clickedItem.Resource; - if (clickedItem.IsRootFolder) + if (clickedItem.IsProjectFolder) { - // Root folder is not selectable, clear selection + // Project folder is not selectable, clear selection ResourceListView.SelectedItems.Clear(); } else @@ -36,8 +36,8 @@ private async void ListView_RightTapped(object sender, RightTappedRoutedEventArg } else { - // Right-clicking empty space - target root folder - clickedResource = ViewModel.RootFolder; + // Right-clicking empty space - target the project folder + clickedResource = ViewModel.ProjectFolder; ResourceListView.SelectedItems.Clear(); } @@ -69,8 +69,8 @@ private async Task ShowContextMenuAsync(Point position, IResource? clickedResour private async Task BuildMenuContext(IResource? clickedResource) { var selectedResources = ViewModel.GetSelectedResources(); - var rootFolder = ViewModel.RootFolder; - var isRootFolderTargeted = clickedResource == rootFolder; + var projectFolder = ViewModel.ProjectFolder; + var isProjectFolderTargeted = clickedResource == projectFolder; // Check clipboard state var contentDescription = _dataTransferService.GetClipboardContentDescription(); @@ -79,8 +79,8 @@ private async Task BuildMenuContext(IResource? clickedResou var context = new ExplorerMenuContext( ClickedResource: clickedResource, SelectedResources: selectedResources, - RootFolder: rootFolder, - IsRootFolderTargeted: isRootFolderTargeted, + ProjectFolder: projectFolder, + IsProjectFolderTargeted: isProjectFolderTargeted, HasClipboardData: hasClipboardData, ClipboardContentType: contentDescription.ContentType, ClipboardOperation: contentDescription.ContentOperation diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs index 8898294f4..69a1fee59 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs @@ -12,17 +12,17 @@ public sealed partial class ResourceTree private void ListView_DragItemsStarting(object sender, DragItemsStartingEventArgs e) { - // Store the dragged items for later use, excluding root folder + // Store the dragged items for later use, excluding the project folder var draggedResources = new List(); foreach (var item in e.Items) { - if (item is ResourceViewItem treeItem && !treeItem.IsRootFolder) + if (item is ResourceViewItem treeItem && !treeItem.IsProjectFolder) { draggedResources.Add(treeItem.Resource); } } - // Cancel drag if no valid items (e.g., only root folder was selected) + // Cancel drag if no valid items (e.g., only the project folder was selected) if (draggedResources.Count == 0) { e.Cancel = true; @@ -74,7 +74,7 @@ private void ListView_DragOver(object sender, DragEventArgs e) // Check for external drag (from File Explorer, etc.) if (e.DataView?.Contains(StandardDataFormats.StorageItems) == true) { - // External drag - allow drop on folder, file (uses parent), or empty space (root folder) + // External drag - allow drop on folder, file (uses parent), or empty space (project folder) return (CanDrop: true, IsInternalDrag: false); } diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs index c8cb8a1f1..15c7d2277 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs @@ -50,7 +50,7 @@ private bool HandleDelete(List selectedResources) private bool HandleRename(ResourceViewItem? selectedItem) { - if (selectedItem == null || selectedItem.IsRootFolder) + if (selectedItem == null || selectedItem.IsProjectFolder) { return false; } @@ -122,7 +122,7 @@ private bool HandleSelectAll(ResourceViewItem? selectedItem) private bool HandleDuplicate(ResourceViewItem? selectedItem) { - if (selectedItem == null || selectedItem.IsRootFolder) + if (selectedItem == null || selectedItem.IsProjectFolder) { return false; } diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml index d9d5b3fe3..068b79886 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml @@ -26,14 +26,14 @@ - + - + - + public const string WorkspaceSettingsFile = "workspace_settings.db"; + + /// + /// Hidden folder that contains all Celbridge-internal storage in the new layout. + /// + public const string CelbridgeFolder = ".celbridge"; + + /// + /// Sub-folder of .celbridge/ that backs the temp: virtual root. + /// + public const string CelbridgeTempFolder = "temp"; + + /// + /// Sub-folder of .celbridge/ that backs the logs: virtual root. + /// + public const string CelbridgeLogsFolder = "logs"; + + /// + /// Sub-folder of .celbridge/ for soft-deleted files. Cleared on every workspace load. + /// + public const string CelbridgeTrashFolder = "trash"; } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs index e004e4c31..2f766b3a7 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs @@ -36,4 +36,11 @@ public interface IResourceRootHandler /// Fails if the path escapes the backing location or traverses a reparse point. /// Result Resolve(ResourceKey key); + + /// + /// Builds the resource key that addresses an absolute filesystem path under this root. + /// Fails if the path is not under the backing location, or if the relative form + /// produces an invalid key segment. + /// + Result GetResourceKey(string absolutePath); } diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 2cba4a754..5b505c1d6 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -1,7 +1,9 @@ using Celbridge.Explorer.Services; using Celbridge.Messaging.Services; +using Celbridge.Resources.Helpers; using Celbridge.Resources.Models; using Celbridge.Resources.Services; +using Celbridge.Resources.Services.Roots; using Celbridge.UserInterface.Services; using Celbridge.Workspace; @@ -378,4 +380,44 @@ public void RegisterRootHandlerReplacesExistingHandler() } } } + + [Test] + public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() + { + Guard.IsNotNull(_resourceFolderPath); + + var messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + resourceRegistry.ProjectFolderPath = _resourceFolderPath; + + // Register a temp root whose backing folder is nested inside the project folder. + // A path under the nested folder should match the temp root (longer prefix), + // not the project root (shorter prefix). + var tempBacking = Path.Combine(_resourceFolderPath, ".celbridge", "temp"); + Directory.CreateDirectory(tempBacking); + + var pathValidator = new PathValidator(); + resourceRegistry.RegisterRootHandler(new TempRootHandler(tempBacking, pathValidator)); + + // A path under the project tree but outside .celbridge/temp/ goes to project. + var projectFilePath = Path.Combine(_resourceFolderPath, FileNameA); + var projectKeyResult = resourceRegistry.GetResourceKey(projectFilePath); + projectKeyResult.IsSuccess.Should().BeTrue(); + projectKeyResult.Value.Root.Should().Be(ResourceKey.DefaultRoot); + projectKeyResult.Value.Path.Should().Be(FileNameA); + + // A path under .celbridge/temp/ dispatches to the temp handler. + var tempFilePath = Path.Combine(tempBacking, "staging", "foo.txt"); + var tempKeyResult = resourceRegistry.GetResourceKey(tempFilePath); + tempKeyResult.IsSuccess.Should().BeTrue(); + tempKeyResult.Value.Root.Should().Be("temp"); + tempKeyResult.Value.Path.Should().Be("staging/foo.txt"); + + // A path outside any registered root fails clearly. + var outsidePath = Path.Combine(Path.GetTempPath(), "somewhere_else", "file.txt"); + var outsideKeyResult = resourceRegistry.GetResourceKey(outsidePath); + outsideKeyResult.IsFailure.Should().BeTrue(); + outsideKeyResult.FirstErrorMessage.Should().Contain("not under any registered resource root"); + } } diff --git a/Source/Tests/Resources/VirtualRootHandlerTests.cs b/Source/Tests/Resources/VirtualRootHandlerTests.cs new file mode 100644 index 000000000..50fb4cd46 --- /dev/null +++ b/Source/Tests/Resources/VirtualRootHandlerTests.cs @@ -0,0 +1,157 @@ +using Celbridge.Resources.Helpers; +using Celbridge.Resources.Services.Roots; + +namespace Celbridge.Tests.Resources; + +[TestFixture] +public class VirtualRootHandlerTests +{ + private string? _tempBacking; + private string? _logsBacking; + + [SetUp] + public void Setup() + { + _tempBacking = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(VirtualRootHandlerTests)}_temp"); + _logsBacking = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(VirtualRootHandlerTests)}_logs"); + + foreach (var path in new[] { _tempBacking, _logsBacking }) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + Directory.CreateDirectory(path); + } + } + + [TearDown] + public void TearDown() + { + foreach (var path in new[] { _tempBacking, _logsBacking }) + { + if (path is not null && Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + } + + [Test] + public void TempRootHandlerResolvesUnderBackingLocation() + { + Guard.IsNotNull(_tempBacking); + var validator = new PathValidator(); + var handler = new TempRootHandler(_tempBacking, validator); + + handler.RootName.Should().Be("temp"); + handler.BackingLocation.Should().Be(_tempBacking); + handler.Capabilities.IsWritable.Should().BeTrue(); + handler.Capabilities.IsWatched.Should().BeTrue(); + + var resolveResult = handler.Resolve(ResourceKey.Create("temp:staging/foo/bar.txt")); + resolveResult.IsSuccess.Should().BeTrue(); + resolveResult.Value.Should().Be( + Path.GetFullPath(Path.Combine(_tempBacking, "staging", "foo", "bar.txt"))); + } + + [Test] + public void LogsRootHandlerResolvesUnderBackingLocation() + { + Guard.IsNotNull(_logsBacking); + var validator = new PathValidator(); + var handler = new LogsRootHandler(_logsBacking, validator); + + handler.RootName.Should().Be("logs"); + handler.BackingLocation.Should().Be(_logsBacking); + handler.Capabilities.IsWritable.Should().BeTrue(); + handler.Capabilities.IsWatched.Should().BeTrue(); + + var resolveResult = handler.Resolve(ResourceKey.Create("logs:session.log")); + resolveResult.IsSuccess.Should().BeTrue(); + resolveResult.Value.Should().Be( + Path.GetFullPath(Path.Combine(_logsBacking, "session.log"))); + } + + [Test] + public void TempRootHandlerResolvesRootOnlyKeyToBackingFolder() + { + Guard.IsNotNull(_tempBacking); + var validator = new PathValidator(); + var handler = new TempRootHandler(_tempBacking, validator); + + var resolveResult = handler.Resolve(ResourceKey.Create("temp:")); + resolveResult.IsSuccess.Should().BeTrue(); + resolveResult.Value.Should().Be( + Path.GetFullPath(_tempBacking).TrimEnd(Path.DirectorySeparatorChar)); + } + + [Test] + public void HandlersShareValidatorWithoutCrossContamination() + { + Guard.IsNotNull(_tempBacking); + Guard.IsNotNull(_logsBacking); + + // Both handlers share a single PathValidator instance, just like ResourceService wires them in production. + var validator = new PathValidator(); + var tempHandler = new TempRootHandler(_tempBacking, validator); + var logsHandler = new LogsRootHandler(_logsBacking, validator); + + // Same path-portion key resolves under each handler to that handler's backing location. + var key = ResourceKey.Create("session.log"); + var resolveTemp = tempHandler.Resolve(key); + var resolveLogs = logsHandler.Resolve(key); + + resolveTemp.IsSuccess.Should().BeTrue(); + resolveLogs.IsSuccess.Should().BeTrue(); + resolveTemp.Value.Should().StartWith(Path.GetFullPath(_tempBacking)); + resolveLogs.Value.Should().StartWith(Path.GetFullPath(_logsBacking)); + } + + [Test] + public void GetResourceKeyOnHandlerReturnsRootPrefixedKey() + { + Guard.IsNotNull(_tempBacking); + var validator = new PathValidator(); + var handler = new TempRootHandler(_tempBacking, validator); + + var absolutePath = Path.Combine(_tempBacking, "staging", "foo", "bar.txt"); + var keyResult = handler.GetResourceKey(absolutePath); + + keyResult.IsSuccess.Should().BeTrue(); + keyResult.Value.Root.Should().Be("temp"); + keyResult.Value.Path.Should().Be("staging/foo/bar.txt"); + keyResult.Value.FullKey.Should().Be("temp:staging/foo/bar.txt"); + } + + [Test] + public void GetResourceKeyReturnsRootOnlyKeyWhenPathIsBackingLocation() + { + Guard.IsNotNull(_logsBacking); + var validator = new PathValidator(); + var handler = new LogsRootHandler(_logsBacking, validator); + + var keyResult = handler.GetResourceKey(_logsBacking); + + keyResult.IsSuccess.Should().BeTrue(); + keyResult.Value.Root.Should().Be("logs"); + keyResult.Value.Path.Should().Be(""); + keyResult.Value.FullKey.Should().Be("logs:"); + } + + [Test] + public void GetResourceKeyFailsForPathOutsideBackingLocation() + { + Guard.IsNotNull(_tempBacking); + var validator = new PathValidator(); + var handler = new TempRootHandler(_tempBacking, validator); + + // A path under the logs backing folder is not under temp's backing. + Guard.IsNotNull(_logsBacking); + var absolutePath = Path.Combine(_logsBacking, "foo.txt"); + + var keyResult = handler.GetResourceKey(absolutePath); + keyResult.IsFailure.Should().BeTrue(); + keyResult.FirstErrorMessage.Should().Contain("not under root 'temp'"); + } +} diff --git a/Source/Workspace/Celbridge.Python/Services/PythonService.cs b/Source/Workspace/Celbridge.Python/Services/PythonService.cs index 147847a19..021b1ee8c 100644 --- a/Source/Workspace/Celbridge.Python/Services/PythonService.cs +++ b/Source/Workspace/Celbridge.Python/Services/PythonService.cs @@ -160,7 +160,7 @@ public async Task InitializePython() var configuration = environmentInfo.Configuration; var celbridgeVersion = configuration == "Debug" ? $"{appVersion} (Debug)" : $"{appVersion}"; - var pythonLogFolder = Path.Combine(workingDir, ProjectConstants.MetaDataFolder, ProjectConstants.LogsFolder); + var pythonLogFolder = Path.Combine(workingDir, ProjectConstants.CelbridgeFolder, ProjectConstants.CelbridgeLogsFolder); // Find a free TCP port for JSON-RPC communication var rpcPort = GetAvailableTcpPort(); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs index 7602d0f6a..3c388070e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs @@ -6,13 +6,27 @@ namespace Celbridge.Resources.Services; /// -/// Monitors file system changes in the project folder and schedules resource updates. -/// Includes debouncing to coalesce rapid file system events and external update requests. +/// Monitors file system changes across every registered root with the IsWatched +/// capability and schedules resource updates. Each watched root gets its own +/// FileSystemWatcher; all watchers feed into a single shared debounce timer so +/// rapid bursts of events coalesce. /// public class ResourceMonitor : IResourceMonitor, IDisposable { private const int UpdateDebounceMs = 250; + private sealed class WatchedRoot + { + public IResourceRootHandler Handler { get; } + public FileSystemWatcher Watcher { get; } + + public WatchedRoot(IResourceRootHandler handler, FileSystemWatcher watcher) + { + Handler = handler; + Watcher = watcher; + } + } + private readonly ILogger _logger; private readonly IProjectService _projectService; private readonly IMessengerService _messengerService; @@ -22,7 +36,7 @@ public class ResourceMonitor : IResourceMonitor, IDisposable private readonly string _projectFolderPath; private readonly object _updateLock = new(); - private FileSystemWatcher? _fileSystemWatcher; + private readonly List _watchedRoots = new(); private Timer? _updateDebounceTimer; private bool _isDisposed; @@ -54,32 +68,29 @@ public Result Initialize() try { - if (!Directory.Exists(_projectFolderPath)) + // Spin up one FileSystemWatcher per registered root that opted in via Capabilities.IsWatched. + // The registry is expected to have all handlers registered before Initialize() runs. + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + foreach (var handler in registry.RootHandlers.Values) { - return Result.Fail($"Project folder does not exist: {_projectFolderPath}"); - } - - /// Start monitoring the project folder for file system changes. - _fileSystemWatcher = new FileSystemWatcher(_projectFolderPath) - { - NotifyFilter = NotifyFilters.FileName - | NotifyFilters.DirectoryName - | NotifyFilters.LastWrite - | NotifyFilters.Size, - IncludeSubdirectories = true, - EnableRaisingEvents = false - }; + if (!handler.Capabilities.IsWatched) + { + continue; + } - _fileSystemWatcher.Created += OnFileSystemCreated; - _fileSystemWatcher.Changed += OnFileSystemChanged; - _fileSystemWatcher.Deleted += OnFileSystemDeleted; - _fileSystemWatcher.Renamed += OnFileSystemRenamed; - _fileSystemWatcher.Error += OnFileSystemError; + if (!Directory.Exists(handler.BackingLocation)) + { + _logger.LogWarning( + $"Backing folder for root '{handler.RootName}' does not exist: {handler.BackingLocation}"); + continue; + } - // Start raising events once initialization has completed - _fileSystemWatcher.EnableRaisingEvents = true; + var watcher = CreateWatcher(handler); + _watchedRoots.Add(new WatchedRoot(handler, watcher)); - _logger.LogDebug($"Resource monitoring started for: {_projectFolderPath}"); + _logger.LogDebug( + $"Resource monitoring started for root '{handler.RootName}' at: {handler.BackingLocation}"); + } return Result.Ok(); } @@ -90,6 +101,29 @@ public Result Initialize() } } + private FileSystemWatcher CreateWatcher(IResourceRootHandler handler) + { + var watcher = new FileSystemWatcher(handler.BackingLocation) + { + NotifyFilter = NotifyFilters.FileName + | NotifyFilters.DirectoryName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + IncludeSubdirectories = true, + EnableRaisingEvents = false + }; + + // Closures capture the handler so each event knows which root it belongs to. + watcher.Created += (sender, e) => OnFileSystemCreated(handler, e); + watcher.Changed += (sender, e) => OnFileSystemChanged(handler, e); + watcher.Deleted += (sender, e) => OnFileSystemDeleted(handler, e); + watcher.Renamed += (sender, e) => OnFileSystemRenamed(handler, e); + watcher.Error += OnFileSystemError; + + watcher.EnableRaisingEvents = true; + return watcher; + } + public void Shutdown() { if (_isDisposed) @@ -105,17 +139,13 @@ public void Shutdown() _updateDebounceTimer = null; } - if (_fileSystemWatcher != null) + foreach (var watchedRoot in _watchedRoots) { - _fileSystemWatcher.EnableRaisingEvents = false; - _fileSystemWatcher.Created -= OnFileSystemCreated; - _fileSystemWatcher.Changed -= OnFileSystemChanged; - _fileSystemWatcher.Deleted -= OnFileSystemDeleted; - _fileSystemWatcher.Renamed -= OnFileSystemRenamed; - _fileSystemWatcher.Error -= OnFileSystemError; - _fileSystemWatcher.Dispose(); - _fileSystemWatcher = null; + var watcher = watchedRoot.Watcher; + watcher.EnableRaisingEvents = false; + watcher.Dispose(); } + _watchedRoots.Clear(); _logger.LogDebug($"Resource monitoring stopped for: {_projectFolderPath}"); } @@ -125,73 +155,68 @@ public void Shutdown() } } - #region FileSystemWatcher Event Handlers - - private void OnFileSystemCreated(object sender, FileSystemEventArgs e) + private void OnFileSystemCreated(IResourceRootHandler handler, FileSystemEventArgs e) { - if (ShouldIgnorePath(e.FullPath)) + if (ShouldIgnorePath(handler, e.FullPath)) { return; } // Send granular notification for listeners (e.g., document editors) - OnResourceCreated(e.FullPath); + OnResourceCreated(handler, e.FullPath); // Also notify as changed because some save patterns (atomic temp-write // followed by replace of an existing destination, used by the in-app // file writer and many external editors) surface as a Created event on // the destination rather than a Changed or Renamed event. Listeners // that watch for content changes need to react in that case too. - OnResourceChanged(e.FullPath); + OnResourceChanged(handler, e.FullPath); - ScheduleResourceUpdate(); + ScheduleResourceUpdateIfProjectRoot(handler); } - private void OnFileSystemChanged(object sender, FileSystemEventArgs e) + private void OnFileSystemChanged(IResourceRootHandler handler, FileSystemEventArgs e) { - if (ShouldIgnorePath(e.FullPath)) + if (ShouldIgnorePath(handler, e.FullPath)) { return; } - // Send granular notification for listeners (e.g., document editors) - OnResourceChanged(e.FullPath); + OnResourceChanged(handler, e.FullPath); - ScheduleResourceUpdate(); + ScheduleResourceUpdateIfProjectRoot(handler); } - private void OnFileSystemDeleted(object sender, FileSystemEventArgs e) + private void OnFileSystemDeleted(IResourceRootHandler handler, FileSystemEventArgs e) { - if (ShouldIgnorePath(e.FullPath)) + if (ShouldIgnorePath(handler, e.FullPath)) { return; } - // Send granular notification for listeners (e.g., document editors) - OnResourceDeleted(e.FullPath); + OnResourceDeleted(handler, e.FullPath); - ScheduleResourceUpdate(); + ScheduleResourceUpdateIfProjectRoot(handler); } - private void OnFileSystemRenamed(object sender, RenamedEventArgs e) + private void OnFileSystemRenamed(IResourceRootHandler handler, RenamedEventArgs e) { // Only check the new path for ignore rules. The old path may no longer exist on disk // (the rename has already completed), so File.GetAttributes would throw and cause the // event to be incorrectly ignored. This is critical for editors and coding agents that // use a "write temp, delete original, rename temp" save pattern. - if (ShouldIgnorePath(e.FullPath)) + if (ShouldIgnorePath(handler, e.FullPath)) { return; } - // Send granular notifications for listeners - OnResourceRenamed(e.OldFullPath, e.FullPath); + OnResourceRenamed(handler, e.OldFullPath, e.FullPath); // Also notify as changed since content may have been updated // (handles applications that use rename as part of save, e.g., Excel) - OnResourceChanged(e.FullPath); + OnResourceChanged(handler, e.FullPath); - ScheduleResourceUpdate(); + ScheduleResourceUpdateIfProjectRoot(handler); } private void OnFileSystemError(object sender, ErrorEventArgs e) @@ -200,6 +225,17 @@ private void OnFileSystemError(object sender, ErrorEventArgs e) _logger.LogError($"File system watcher error: {exception?.Message ?? "Unknown error"}"); } + private void ScheduleResourceUpdateIfProjectRoot(IResourceRootHandler handler) + { + // The project tree sync (Registry.UpdateResourceRegistry) is project-scoped. + // Events from non-project roots (temp:, logs:) don't touch the project tree, + // so they skip the debounce. + if (handler.RootName == ResourceKey.DefaultRoot) + { + ScheduleResourceUpdate(); + } + } + public void ScheduleResourceUpdate() { if (!_workspaceWrapper.IsWorkspacePageLoaded) @@ -252,16 +288,14 @@ private void OnDebounceTimerElapsed(object? sender, System.Timers.ElapsedEventAr }); } - #endregion - - #region Change Notifications - - private void OnResourceCreated(string fullPath) + private void OnResourceCreated(IResourceRootHandler handler, string fullPath) { - var resourceKey = GetResourceKey(fullPath); - if (resourceKey.IsEmpty) + var resourceKey = BuildResourceKey(handler, fullPath); + if (resourceKey.IsEmpty && + handler.RootName == ResourceKey.DefaultRoot) { - // Path is not in project folder - this shouldn't happen due to ShouldIgnorePath checks + // Project root with no path means the event was outside the backing folder. + // For non-project roots, a root-only key is unusual but legal; only flag the project case. return; } @@ -274,12 +308,12 @@ private void OnResourceCreated(string fullPath) }); } - private void OnResourceChanged(string fullPath) + private void OnResourceChanged(IResourceRootHandler handler, string fullPath) { - var resourceKey = GetResourceKey(fullPath); - if (resourceKey.IsEmpty) + var resourceKey = BuildResourceKey(handler, fullPath); + if (resourceKey.IsEmpty && + handler.RootName == ResourceKey.DefaultRoot) { - // Path is not in project folder - this shouldn't happen due to ShouldIgnorePath checks return; } @@ -292,12 +326,12 @@ private void OnResourceChanged(string fullPath) }); } - private void OnResourceDeleted(string fullPath) + private void OnResourceDeleted(IResourceRootHandler handler, string fullPath) { - var resourceKey = GetResourceKey(fullPath); - if (resourceKey.IsEmpty) + var resourceKey = BuildResourceKey(handler, fullPath); + if (resourceKey.IsEmpty && + handler.RootName == ResourceKey.DefaultRoot) { - // Path is not in project folder - this shouldn't happen due to ShouldIgnorePath checks return; } @@ -310,14 +344,14 @@ private void OnResourceDeleted(string fullPath) }); } - private void OnResourceRenamed(string oldFullPath, string newFullPath) + private void OnResourceRenamed(IResourceRootHandler handler, string oldFullPath, string newFullPath) { - var oldResourceKey = GetResourceKey(oldFullPath); - var newResourceKey = GetResourceKey(newFullPath); + var oldResourceKey = BuildResourceKey(handler, oldFullPath); + var newResourceKey = BuildResourceKey(handler, newFullPath); - if (oldResourceKey.IsEmpty || newResourceKey.IsEmpty) + if ((oldResourceKey.IsEmpty || newResourceKey.IsEmpty) && + handler.RootName == ResourceKey.DefaultRoot) { - // One or both paths are not in project folder - this shouldn't happen due to ShouldIgnorePath checks return; } @@ -330,11 +364,7 @@ private void OnResourceRenamed(string oldFullPath, string newFullPath) }); } - #endregion - - #region Helper Methods - - private bool ShouldIgnorePath(string fullPath) + private bool ShouldIgnorePath(IResourceRootHandler handler, string fullPath) { var fileName = Path.GetFileName(fullPath); @@ -384,20 +414,25 @@ private bool ShouldIgnorePath(string fullPath) } } - // Check if path is directly under project root and matches ignored folders - var projectFolder = _projectFolderPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var relativePath = fullPath.StartsWith(projectFolder, StringComparison.OrdinalIgnoreCase) - ? fullPath.Substring(projectFolder.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - : string.Empty; - - if (!string.IsNullOrEmpty(relativePath)) + // Only the project watcher needs to suppress the visible legacy "celbridge" folder + // and the new hidden ".celbridge" folder at the project root. Non-project watchers + // are already scoped inside their own backing location. + if (handler.RootName == ResourceKey.DefaultRoot) { - var firstSegment = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0]; + var projectFolder = handler.BackingLocation.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var relativePath = fullPath.StartsWith(projectFolder, StringComparison.OrdinalIgnoreCase) + ? fullPath.Substring(projectFolder.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + : string.Empty; - // Only ignore "celbridge" folder if it's directly in the project root - if (firstSegment.Equals("celbridge", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(relativePath)) { - return true; + var firstSegment = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0]; + + if (firstSegment.Equals(ProjectConstants.MetaDataFolder, StringComparison.OrdinalIgnoreCase) || + firstSegment.Equals(ProjectConstants.CelbridgeFolder, StringComparison.OrdinalIgnoreCase)) + { + return true; + } } } @@ -415,22 +450,17 @@ private bool ShouldIgnorePath(string fullPath) return false; } - private ResourceKey GetResourceKey(string fullPath) + private ResourceKey BuildResourceKey(IResourceRootHandler handler, string fullPath) { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var result = resourceRegistry.GetResourceKey(fullPath); + var result = handler.GetResourceKey(fullPath); if (result.IsFailure) { - _logger.LogWarning($"Path is not in project folder: {fullPath}"); + _logger.LogWarning($"Could not build resource key for path: {fullPath} ({result.FirstErrorMessage})"); return ResourceKey.Empty; } return result.Value; } - #endregion - - #region IDisposable - public void Dispose() { Dispose(true); @@ -454,6 +484,4 @@ protected virtual void Dispose(bool disposing) { Dispose(false); } - - #endregion } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 48a6ad3de..c125456bd 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -46,7 +46,7 @@ public ResourceOperationService( /// Gets the path to the trash folder for soft-deleted files. /// private string TrashFolderPath => - Path.Combine(ProjectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TrashFolder); + Path.Combine(ProjectFolderPath, ProjectConstants.CelbridgeFolder, ProjectConstants.CelbridgeTrashFolder); public async Task CreateFileAsync(string path, byte[] content) { diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index 742fa0ff5..b03877f7c 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -98,24 +98,44 @@ public Result GetResourceKey(string resourcePath) { try { + // Cross-root dispatch: find the registered handler whose backing location is + // the longest prefix (left substring) of the absolute path. Longest-prefix-wins + // so that a path under .celbridge/temp/ matches the temp handler rather + // than the project handler (which has the shorter / prefix). e.g. + // C:\proj\ (project root, length 8) + // C:\proj\.celbridge\temp\ (temp root, length 23) var normalizedPath = Path.GetFullPath(resourcePath); - var normalizedProjectPath = Path.GetFullPath(ProjectFolderPath); - if (!normalizedPath.StartsWith(normalizedProjectPath)) + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + IResourceRootHandler? bestHandler = null; + int bestPrefixLength = -1; + + foreach (var handler in _rootHandlers.Values) { - return Result.Fail($"The path '{resourcePath}' is not in the project folder '{ProjectFolderPath}'."); - } + var backing = Path.GetFullPath(handler.BackingLocation) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + bool isBackingRoot = normalizedPath.Equals(backing, comparison); + bool isUnderBacking = normalizedPath.StartsWith( + backing + Path.DirectorySeparatorChar, comparison); - var relativeKey = normalizedPath.Substring(ProjectFolderPath.Length) - .Replace('\\', '/') - .Trim('/'); + if ((isBackingRoot || isUnderBacking) && backing.Length > bestPrefixLength) + { + bestHandler = handler; + bestPrefixLength = backing.Length; + } + } - if (!ResourceKey.TryCreate(relativeKey, out var resourceKey)) + if (bestHandler is null) { - return Result.Fail($"The path '{resourcePath}' produces an invalid resource key: '{relativeKey}'."); + return Result.Fail( + $"The path '{resourcePath}' is not under any registered resource root."); } - return Result.Ok(resourceKey); + return bestHandler.GetResourceKey(normalizedPath); } catch (Exception ex) { @@ -355,10 +375,10 @@ private void SynchronizeFolder(FolderResource folderResource, string folderPath) { var fileName = Path.GetFileName(filePath); var fileExtension = Path.GetExtension(filePath).TrimStart('.'); - + var getIconResult = _fileIconService.GetFileIconForExtension(fileExtension); - var iconDefinition = getIconResult.IsSuccess - ? getIconResult.Value + var iconDefinition = getIconResult.IsSuccess + ? getIconResult.Value : _fileIconService.DefaultFileIcon; folderResource.AddChild(new FileResource(fileName, folderResource, iconDefinition)); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index 825028654..14246e450 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -1,6 +1,8 @@ using Celbridge.Commands; using Celbridge.Logging; using Celbridge.Projects; +using Celbridge.Resources.Helpers; +using Celbridge.Resources.Services.Roots; using Celbridge.UserInterface; using Celbridge.Workspace; @@ -49,18 +51,35 @@ public ResourceService( OperationService = resourceOperationService; FileWriter = resourceFileWriter; - // Set the project folder path on the registry - Registry.ProjectFolderPath = _projectService.CurrentProject!.ProjectFolderPath; - - // Clean up the trash folder from previous sessions. - // The trash folder contains soft-deleted files and folders from previous delete operations. + // Set the project folder path on the registry. This also auto-registers the + // ProjectRootHandler for the project: root via the setter. var projectFolderPath = _projectService.CurrentProject!.ProjectFolderPath; - var trashFolderPath = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TrashFolder); - if (Directory.Exists(trashFolderPath)) + Registry.ProjectFolderPath = projectFolderPath; + + // Build the new .celbridge/ hidden folder layout: temp/, logs/, trash/. + // These need to exist before downstream services start reading or watching them. + var celbridgeFolder = Path.Combine(projectFolderPath, ProjectConstants.CelbridgeFolder); + var celbridgeTempFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTempFolder); + var celbridgeLogsFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeLogsFolder); + var celbridgeTrashFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTrashFolder); + + Directory.CreateDirectory(celbridgeTempFolder); + Directory.CreateDirectory(celbridgeLogsFolder); + + // Trash is cleared on every workspace load; undo history lives in memory only, + // so previous-session trash content has no live handles. + TryClearFolderContents(celbridgeTrashFolder); + Directory.CreateDirectory(celbridgeTrashFolder); + + // Legacy /celbridge/.trash/ from before this redesign: discard. + // The other legacy /celbridge/.temp/, .cache/, .logs/ folders are + // left alone (no live data; they retire alongside the entity service). + var legacyTrashFolder = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TrashFolder); + if (Directory.Exists(legacyTrashFolder)) { try { - Directory.Delete(trashFolderPath, true); + Directory.Delete(legacyTrashFolder, true); } catch { @@ -68,8 +87,9 @@ public ResourceService( } } - // Clean up the temp folder from previous sessions. - // The temp folder stages in-flight atomic writes; orphans here are from a prior crash. + // Clean up the legacy temp folder from previous sessions. + // ResourceFileWriter still stages in-flight atomic writes here; orphans are from a prior crash. + // (Moves to .celbridge/staging-fs/ when the FS-layer chokepoint lands.) var tempFolderPath = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TempFolder); if (Directory.Exists(tempFolderPath)) { @@ -83,7 +103,15 @@ public ResourceService( } } - // Initialize the resource monitor to start watching for file system changes + // Register the temp: and logs: root handlers. These share a single PathValidator + // instance so their reparse-point caches stay coherent across the two roots. + var handlerPathValidator = new PathValidator(); + Registry.RegisterRootHandler(new TempRootHandler(celbridgeTempFolder, handlerPathValidator)); + Registry.RegisterRootHandler(new LogsRootHandler(celbridgeLogsFolder, handlerPathValidator)); + + // Initialize the resource monitor to start watching for file system changes. + // Initialize runs after handler registration so the monitor sees the full set + // of registered roots and can create one watcher per IsWatched: true root. var initResult = Monitor.Initialize(); if (initResult.IsFailure) { @@ -152,7 +180,10 @@ protected virtual void Dispose(bool disposing) // Clean up the trash folder on project close. // This ensures deleted files don't persist after the project is closed. - var trashFolderPath = Path.Combine(Registry.ProjectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TrashFolder); + var trashFolderPath = Path.Combine( + Registry.ProjectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeTrashFolder); if (Directory.Exists(trashFolderPath)) { try @@ -174,4 +205,38 @@ protected virtual void Dispose(bool disposing) { Dispose(false); } + + // Removes every child item under the given folder while leaving the folder itself in place. + // Used to clear .celbridge/trash/ on every workspace load without disturbing the folder layout. + private static void TryClearFolderContents(string folderPath) + { + if (!Directory.Exists(folderPath)) + { + return; + } + + foreach (var file in Directory.EnumerateFiles(folderPath)) + { + try + { + File.Delete(file); + } + catch + { + // Best effort - ignore errors + } + } + + foreach (var subFolder in Directory.EnumerateDirectories(folderPath)) + { + try + { + Directory.Delete(subFolder, true); + } + catch + { + // Best effort - ignore errors + } + } + } } diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs new file mode 100644 index 000000000..2821f65cc --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs @@ -0,0 +1,28 @@ +using Celbridge.Resources.Helpers; + +namespace Celbridge.Resources.Services.Roots; + +/// +/// Resource root handler for the logs: virtual root. Backs operational diagnostic +/// output from the host, Python scripts, agents, and console session loggers +/// under .celbridge/logs/. +/// +public class LogsRootHandler : ResourceRootHandlerBase +{ + /// + /// The root name for the logs: virtual root. + /// + public const string Name = "logs"; + + private static readonly ResourceRootCapabilities LogsCapabilities = new( + IsWritable: true, + IsWatched: true); + + public override string RootName => Name; + public override ResourceRootCapabilities Capabilities => LogsCapabilities; + + public LogsRootHandler(string backingLocation, PathValidator pathValidator) + : base(backingLocation, pathValidator) + { + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs index 26ad46f65..c6dacc9f0 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs @@ -6,27 +6,17 @@ namespace Celbridge.Resources.Services.Roots; /// Resource root handler for the visible project tree. Backs the implicit "project" /// root used for every key without an explicit root prefix. /// -public class ProjectRootHandler : IResourceRootHandler +public class ProjectRootHandler : ResourceRootHandlerBase { private static readonly ResourceRootCapabilities ProjectCapabilities = new( IsWritable: true, IsWatched: true); - private readonly PathValidator _pathValidator; - private readonly string _projectFolderPath; - - public string RootName => ResourceKey.DefaultRoot; - public string BackingLocation => _projectFolderPath; - public ResourceRootCapabilities Capabilities => ProjectCapabilities; + public override string RootName => ResourceKey.DefaultRoot; + public override ResourceRootCapabilities Capabilities => ProjectCapabilities; public ProjectRootHandler(string projectFolderPath, PathValidator pathValidator) + : base(projectFolderPath, pathValidator) { - _projectFolderPath = projectFolderPath; - _pathValidator = pathValidator; - } - - public Result Resolve(ResourceKey key) - { - return _pathValidator.ValidateAndResolve(RootName, BackingLocation, key); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs new file mode 100644 index 000000000..9c7bce400 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs @@ -0,0 +1,79 @@ +using Celbridge.Resources.Helpers; + +namespace Celbridge.Resources.Services.Roots; + +/// +/// Shared implementation for resource root handlers. Holds the backing location and +/// path validator, and provides the common Resolve and GetResourceKey logic. +/// Concrete subclasses supply the root name and capability flags. +/// +public abstract class ResourceRootHandlerBase : IResourceRootHandler +{ + private readonly PathValidator _pathValidator; + private readonly string _backingLocation; + + protected ResourceRootHandlerBase(string backingLocation, PathValidator pathValidator) + { + _backingLocation = backingLocation; + _pathValidator = pathValidator; + } + + public abstract string RootName { get; } + + public string BackingLocation => _backingLocation; + + public abstract ResourceRootCapabilities Capabilities { get; } + + public Result Resolve(ResourceKey key) + { + return _pathValidator.ValidateAndResolve(RootName, BackingLocation, key); + } + + public Result GetResourceKey(string absolutePath) + { + try + { + var normalizedPath = Path.GetFullPath(absolutePath); + var normalizedBacking = Path.GetFullPath(BackingLocation) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + bool isBackingRoot = normalizedPath.Equals(normalizedBacking, comparison); + bool isUnderBacking = normalizedPath.StartsWith( + normalizedBacking + Path.DirectorySeparatorChar, comparison); + + if (!isBackingRoot && !isUnderBacking) + { + return Result.Fail( + $"Path '{absolutePath}' is not under root '{RootName}' backing location '{BackingLocation}'."); + } + + var relativePart = isBackingRoot + ? string.Empty + : normalizedPath + .Substring(normalizedBacking.Length) + .Replace('\\', '/') + .Trim('/'); + + var keyString = string.IsNullOrEmpty(relativePart) + ? RootName + ":" + : RootName + ":" + relativePart; + + if (!ResourceKey.TryCreate(keyString, out var resourceKey)) + { + return Result.Fail( + $"Path '{absolutePath}' produces an invalid resource key: '{keyString}'."); + } + + return Result.Ok(resourceKey); + } + catch (Exception ex) + { + return Result.Fail($"An exception occurred when getting the resource key for '{absolutePath}'.") + .WithException(ex); + } + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs new file mode 100644 index 000000000..6421870ae --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs @@ -0,0 +1,28 @@ +using Celbridge.Resources.Helpers; + +namespace Celbridge.Resources.Services.Roots; + +/// +/// Resource root handler for the temp: virtual root. Backs scratch space and intermediate +/// artifacts under .celbridge/temp/. Host policy puts sub-folder conventions on top +/// (temp:staging/..., temp:scratch/..., temp:cache/...). +/// +public class TempRootHandler : ResourceRootHandlerBase +{ + /// + /// The root name for the temp: virtual root. + /// + public const string Name = "temp"; + + private static readonly ResourceRootCapabilities TempCapabilities = new( + IsWritable: true, + IsWatched: true); + + public override string RootName => Name; + public override ResourceRootCapabilities Capabilities => TempCapabilities; + + public TempRootHandler(string backingLocation, PathValidator pathValidator) + : base(backingLocation, pathValidator) + { + } +} From 4d8c9dae7ec2995e655383c29d69daa8d85a1150 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Tue, 19 May 2026 21:48:20 +0100 Subject: [PATCH 04/48] Clarify resource-key roots and canonicalize outputs Update resource key docs to describe named roots (project:, temp:, logs:), canonical output rules, and stricter validation (lowercase multi-char roots). Propagate canonical resource-key reporting into runtime: switch error messages to echo the resourceKey, emit canonical per-entry keys in ReadMany, and adjust package/spreadsheet tooling to report keys consistently. Add tests ensuring non-project roots are reported with their root prefix (e.g. temp:...) while project-root keys are reported as bare paths. This makes diagnostics unambiguous across multiple roots and fixes related regressions. --- .../Resources/IResourceMonitor.cs | 3 +- .../Guides/Concepts/resource_keys.md | 37 +++++++++++++++---- .../troubleshoot_resource_key.md | 11 +++--- .../troubleshoot_resource_not_found.md | 4 +- .../Tools/File/FileTools.Read.cs | 4 +- .../Tools/File/FileTools.ReadBinary.cs | 4 +- .../Tools/File/FileTools.ReadImage.cs | 6 +-- .../Tools/File/FileTools.ReadMany.cs | 14 ++++--- .../Tools/Package/PackageTools.Publish.cs | 4 +- .../Tools/Spreadsheet/SpreadsheetTools.cs | 4 +- Source/Tests/Tools/FileToolTests.cs | 37 +++++++++++++++++++ .../Services/ResourceMonitor.cs | 14 ++----- .../Services/ResourceService.cs | 11 ++---- .../Services/WorkspaceLoader.cs | 13 ++++++- 14 files changed, 115 insertions(+), 51 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs index 4ac66ac1b..cbb360bd2 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs @@ -6,7 +6,8 @@ namespace Celbridge.Resources; public interface IResourceMonitor { /// - /// Initializes the resource monitor and starts watching for file system changes. + /// Initializes the resource monitor and starts watching for file system changes + /// across every registered root whose Capabilities.IsWatched is true. /// Result Initialize(); diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index cec7c77c9..93d9dcb8c 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -1,19 +1,42 @@ # Resource keys -All file and folder references in Celbridge tools use **resource keys**: forward-slash paths relative to the project content root. +All file and folder references in Celbridge tools use **resource keys**: forward-slash paths under a named root. The default root is the project tree; other roots address host scratch space and diagnostic logs. + +## Form + +A resource key has the optional `root:path` form. When no root prefix is given, the key resolves under the implicit `project:` root. | Key | What it refers to | |---|---| -| `readme.md` | A file at the top level | -| `Scripts/hello.py` | A nested file | -| `Data` | A subfolder | -| `` (empty string) | The top level itself | +| `readme.md` | A file at the top of the project tree | +| `Scripts/hello.py` | A nested file in the project tree | +| `Data` | A subfolder in the project tree | +| `project:` (or `""`) | The project root itself | +| `temp:staging/pkg/v1/file.txt` | A file under the `temp:` scratch root | +| `logs:session.log` | A file under the `logs:` diagnostic root | + +## Roots + +- `project:` — the visible project tree. The default root; the prefix is optional in input and omitted in output. Use for all user content. +- `temp:` — host scratch space (`.celbridge/temp/`). Hidden from the resource tree. Used by host tools, scripts, and agents for transient artifacts and staging output. Contents are not version-controlled. Conventional sub-folders include `temp:staging/...`, `temp:scratch/...`, and `temp:cache/...`. +- `logs:` — host diagnostic logs (`.celbridge/logs/`). Hidden from the resource tree. Used by the host engine, Python scripts, agents, and Console panel session loggers. + +## Output canonical form + +When a tool reports a resource key in its result or in an error message, it uses the canonical form: + +- `project:` keys are reported as bare paths (e.g. `Scripts/hello.py`), never with the explicit `project:` prefix. +- Non-`project:` keys are reported with their full root prefix (e.g. `temp:staging/pkg/file.txt`). + +So `file_read` against a missing `temp:foo/bar` reports `temp:foo/bar` in the error, never bare `foo/bar`. ## Rules - Forward slashes only. Backslashes are rejected. - No leading slash. `/readme.md` is invalid. -- No absolute paths or drive letters. The key is always relative to the content root. +- No absolute paths or drive letters. The key is always relative to its root's backing folder. +- Root prefixes are lowercase and match `[a-z][a-z0-9_]+`. Single-character roots and uppercase roots are rejected. +- An undeclared root (e.g. `unknown:foo`) is an error, not a missing-file failure. - Case sensitivity follows the underlying filesystem; on Windows the system is case-preserving but case-insensitive. -When in doubt about which keys exist, call `file_get_tree("")` to list the top level, or pass a folder key to list its contents. +When in doubt about which keys exist, call `file_get_tree("")` to list the top level of the project tree, or pass a folder key to list its contents. diff --git a/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_key.md b/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_key.md index 7cea03f76..90ff0ef69 100644 --- a/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_key.md +++ b/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_key.md @@ -1,16 +1,17 @@ # Troubleshoot: invalid resource key -The tool rejected the value as a resource key. Resource keys are forward-slash paths relative to the project content root, never absolute paths or backslash-separated. +The tool rejected the value as a resource key. Resource keys are forward-slash paths under a named root, never absolute paths or backslash-separated. See the `resource_keys` concept guide for the full rule set. ## Recovering - **Backslashes.** Replace every `\` with `/`. Windows-style paths (e.g. `Scripts\hello.py`) do not parse; the canonical form is `Scripts/hello.py`. -- **Leading slash.** Strip any leading `/`. `/readme.md` is invalid; `readme.md` is the top-level file. -- **Drive letters and absolute paths.** Resource keys never include `C:\...` or any disk path. The key is the path inside the project tree only. +- **Leading slash.** Strip any leading `/`. `/readme.md` is invalid; `readme.md` is the top-level file under the project root. +- **Drive letters and absolute paths.** Resource keys never include `C:\...` or any disk path. The key is the path inside a registered root only. +- **Invalid root prefix.** A `root:` prefix must be lowercase and at least two characters, matching `[a-z][a-z0-9_]+`. Uppercase roots (`Project:foo`), empty roots (`:foo`), and single-character roots (`a:foo`) are rejected. - **Stray surrounding whitespace.** Trim the input; leading and trailing spaces are not stripped. -If you intended the project root, pass an empty string `""` rather than `/` or `.`. +If you intended the project root, pass an empty string `""`, the bare path, or `project:` followed by the path; the explicit prefix is optional for `project:`. ## Verifying the corrected key exists -After fixing the syntax, the resource may still be missing on disk. Use `file_get_tree("")` to list the top level, or pass a folder key to list its contents. The `resource_keys` concept guide carries the full rule set if you need a refresher. +After fixing the syntax, the resource may still be missing on disk. Use `file_get_tree("")` to list the top level of the project tree, or pass a folder key to list its contents. The `resource_keys` concept guide carries the full rule set if you need a refresher. diff --git a/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_not_found.md b/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_not_found.md index 156f41804..5957e72f9 100644 --- a/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_not_found.md +++ b/Source/Core/Celbridge.Tools/Guides/Troubleshooters/troubleshoot_resource_not_found.md @@ -1,6 +1,6 @@ # Troubleshoot: resource not found -The resource key parsed correctly as a path, but no file or folder lives at that key in the loaded project. This is distinct from an invalid resource key — the syntax was fine; the target just is not there. +The resource key parsed correctly as a path, but no file or folder lives at that key under the named root. This is distinct from an invalid resource key — the syntax was fine; the target just is not there. ## Recovering @@ -8,6 +8,6 @@ The resource key parsed correctly as a path, but no file or folder lives at that - **List the parent.** Pass the parent folder's key to `file_list_contents` (immediate children only) or `file_get_tree` (recursive). If the user is referring to a file by an inexact name, check sibling entries for typos and case differences. - **Ask the workspace first for ambiguous references.** If the user said "this file" without a name, prefer `document_get_state` (the active document, then other open documents) and `explorer_get_state` (the explorer's selection) before searching the whole project. See `workspace_panels`. - **Check case.** On Windows the filesystem is case-preserving but case-insensitive; resource keys round-trip whatever case the file actually has. If the registry reports the file at a different casing, use the casing it returns. -- **Check the project root.** A resource passed as `Scripts/foo.py` resolves under the project content root, not the workspace folder, the .celbridge config folder, or the agent's working directory. Files outside the content root cannot be addressed via resource key. +- **Check the root.** A resource key is always relative to its root's backing folder. `Scripts/foo.py` (project root) resolves under the project content folder, not the workspace folder or the agent's working directory. `temp:foo` resolves under `.celbridge/temp/`, not the project tree. Files outside any registered root cannot be addressed via resource key. If the user explicitly intended to create the resource, switch to `file_write`, `file_write_binary`, `explorer_create_file`, or `explorer_create_folder`. The file-writing tools create the target if missing; the explorer tools fail if the resource already exists. diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs index b7d7806da..aeb6a5169 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs @@ -27,13 +27,13 @@ public async partial Task Read(string resource, int offset = 0, var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); } var resourcePath = resolveResult.Value; if (!File.Exists(resourcePath)) { - return ToolResponse.Error($"Resource not found in project: '{resource}'. Note that file_read addresses project resources, not arbitrary disk paths — files outside the project content root cannot be read."); + return ToolResponse.Error($"Resource not found: '{resourceKey}'. file_read addresses resources by resource key, not arbitrary disk paths — only files under a registered root (e.g. 'project:', 'temp:', 'logs:') can be read."); } var fileText = await File.ReadAllTextAsync(resourcePath); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs index a5f74ba79..ac11c8c00 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs @@ -28,13 +28,13 @@ public async partial Task ReadBinary(string resource) var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); } var resourcePath = resolveResult.Value; if (!File.Exists(resourcePath)) { - return ToolResponse.Error($"File not found: '{resource}'"); + return ToolResponse.Error($"File not found: '{resourceKey}'"); } var bytes = await File.ReadAllBytesAsync(resourcePath); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs index a29315179..b38bb01fd 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs @@ -41,13 +41,13 @@ public async partial Task ReadImage(string resource) var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); } var resourcePath = resolveResult.Value; if (!File.Exists(resourcePath)) { - return ToolResponse.Error($"File not found: '{resource}'"); + return ToolResponse.Error($"File not found: '{resourceKey}'"); } var extension = Path.GetExtension(resourcePath).ToLowerInvariant(); @@ -63,7 +63,7 @@ public async partial Task ReadImage(string resource) if (fileInfo.Length > MaxInlineImageBytes) { return ToolResponse.Error( - $"Image '{resource}' is {fileInfo.Length} bytes, which exceeds the {MaxInlineImageBytes}-byte inline cap. " + + $"Image '{resourceKey}' is {fileInfo.Length} bytes, which exceeds the {MaxInlineImageBytes}-byte inline cap. " + $"Resize or recompress the image (or capture a smaller screenshot via webview_screenshot with maxEdge) " + $"before calling file_read_image."); } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs index c9ebd20a9..32169af99 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs @@ -49,17 +49,21 @@ public async partial Task ReadMany(string resources, int offset continue; } + // Echo the canonical form of the resource key in per-entry output so that + // entries for different roots are unambiguous regardless of how the agent typed them. + var canonicalResource = resourceKey.ToString(); + var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - entries.Add(new ReadManyFileEntry(resourceString, Error: $"Failed to resolve path for resource: '{resourceString}'")); + entries.Add(new ReadManyFileEntry(canonicalResource, Error: $"Failed to resolve path for resource: '{canonicalResource}'")); continue; } var resourcePath = resolveResult.Value; if (!File.Exists(resourcePath)) { - entries.Add(new ReadManyFileEntry(resourceString, Error: $"File not found: '{resourceString}'")); + entries.Add(new ReadManyFileEntry(canonicalResource, Error: $"File not found: '{canonicalResource}'")); continue; } @@ -69,7 +73,7 @@ public async partial Task ReadMany(string resources, int offset if (offset == 0 && limit == 0) { // Preserve raw line endings as they exist on disk. - entries.Add(new ReadManyFileEntry(resourceString, Content: fileText, TotalLineCount: totalLineCount)); + entries.Add(new ReadManyFileEntry(canonicalResource, Content: fileText, TotalLineCount: totalLineCount)); } else { @@ -81,13 +85,13 @@ public async partial Task ReadMany(string resources, int offset if (startIndex >= allLines.Count) { - entries.Add(new ReadManyFileEntry(resourceString, Content: string.Empty, TotalLineCount: totalLineCount)); + entries.Add(new ReadManyFileEntry(canonicalResource, Content: string.Empty, TotalLineCount: totalLineCount)); } else { var selectedLines = allLines.Skip(startIndex).Take(count); var content = string.Join(fileSeparator, selectedLines); - entries.Add(new ReadManyFileEntry(resourceString, Content: content, TotalLineCount: totalLineCount)); + entries.Add(new ReadManyFileEntry(canonicalResource, Content: content, TotalLineCount: totalLineCount)); } } } diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs index 3fe357e3d..b03fbfb12 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs @@ -69,13 +69,13 @@ public async partial Task Publish(string resource, string packag var resolveSourceResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveSourceResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); } var sourcePath = resolveSourceResult.Value; if (!Directory.Exists(sourcePath)) { - return ToolResponse.Error($"Folder not found: '{resource}'"); + return ToolResponse.Error($"Folder not found: '{resourceKey}'"); } // Validate that the package manifest exists and is valid diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs index 26f8728d1..8a50b3f62 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs @@ -35,13 +35,13 @@ private Result ResolveWorkbookPath(string resource) var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'"); + return Result.Fail($"Failed to resolve path for resource: '{resourceKey}'"); } var workbookPath = resolveResult.Value; if (!File.Exists(workbookPath)) { - return Result.Fail($"File not found: '{resource}'"); + return Result.Fail($"File not found: '{resourceKey}'"); } return workbookPath; diff --git a/Source/Tests/Tools/FileToolTests.cs b/Source/Tests/Tools/FileToolTests.cs index 59548778a..b274c5a82 100644 --- a/Source/Tests/Tools/FileToolTests.cs +++ b/Source/Tests/Tools/FileToolTests.cs @@ -596,6 +596,43 @@ public async Task WriteBinary_DispatchesCommand_AndReturnsOk() result.IsError.Should().NotBe(true); } + [Test] + public async Task Read_MissingFileUnderNonProjectRoot_EmitsCanonicalRootPath() + { + // Regression for vr-4: when a resource under a non-project root is missing, the + // error must echo the canonical "root:path" form so the agent can see which root + // failed. A bare path is reserved for the project root and is ambiguous otherwise. + var resourceKey = ResourceKey.Create("temp:missing/file.txt"); + var resourcePath = Path.Combine(_tempFolder, "missing", "file.txt"); + _resourceRegistry.ResolveResourcePath(resourceKey).Returns(Result.Ok(resourcePath)); + + var tools = new FileTools(_services); + var result = await tools.Read("temp:missing/file.txt"); + + result.IsError.Should().BeTrue(); + var text = result.Content.OfType().Single().Text; + text.Should().Contain("temp:missing/file.txt"); + text.Should().NotContain("'missing/file.txt'"); + } + + [Test] + public async Task Read_MissingFileUnderProjectRoot_EmitsBarePath() + { + // Counterpart to the temp: test: project-root keys must be reported as bare paths, + // never with the explicit "project:" prefix. + var resourceKey = ResourceKey.Create("Scripts/missing.py"); + var resourcePath = Path.Combine(_tempFolder, "Scripts", "missing.py"); + _resourceRegistry.ResolveResourcePath(resourceKey).Returns(Result.Ok(resourcePath)); + + var tools = new FileTools(_services); + var result = await tools.Read("project:Scripts/missing.py"); + + result.IsError.Should().BeTrue(); + var text = result.Content.OfType().Single().Text; + text.Should().Contain("Scripts/missing.py"); + text.Should().NotContain("project:Scripts/missing.py"); + } + private static JsonElement ParseResult(CallToolResult result) { var json = result.Content.OfType().Single().Text; diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs index 3c388070e..429335713 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs @@ -28,12 +28,10 @@ public WatchedRoot(IResourceRootHandler handler, FileSystemWatcher watcher) } private readonly ILogger _logger; - private readonly IProjectService _projectService; private readonly IMessengerService _messengerService; private readonly IDispatcher _dispatcher; private readonly IWorkspaceWrapper _workspaceWrapper; - private readonly string _projectFolderPath; private readonly object _updateLock = new(); private readonly List _watchedRoots = new(); @@ -43,20 +41,13 @@ public WatchedRoot(IResourceRootHandler handler, FileSystemWatcher watcher) public ResourceMonitor( ILogger logger, IDispatcher dispatcher, - IProjectService projectService, IMessengerService messengerService, IWorkspaceWrapper workspaceWrapper) { _logger = logger; _dispatcher = dispatcher; - _projectService = projectService; _messengerService = messengerService; _workspaceWrapper = workspaceWrapper; - - var project = _projectService.CurrentProject; - Guard.IsNotNull(project); - - _projectFolderPath = project.ProjectFolderPath; } public Result Initialize() @@ -69,7 +60,8 @@ public Result Initialize() try { // Spin up one FileSystemWatcher per registered root that opted in via Capabilities.IsWatched. - // The registry is expected to have all handlers registered before Initialize() runs. + // WorkspaceLoader calls Initialize after the workspace finishes constructing, so the wrapper + // returns the configured registry instance here. var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; foreach (var handler in registry.RootHandlers.Values) { @@ -147,7 +139,7 @@ public void Shutdown() } _watchedRoots.Clear(); - _logger.LogDebug($"Resource monitoring stopped for: {_projectFolderPath}"); + _logger.LogDebug("Resource monitoring stopped"); } catch (Exception ex) { diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index 14246e450..dbeefcb74 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -109,14 +109,9 @@ public ResourceService( Registry.RegisterRootHandler(new TempRootHandler(celbridgeTempFolder, handlerPathValidator)); Registry.RegisterRootHandler(new LogsRootHandler(celbridgeLogsFolder, handlerPathValidator)); - // Initialize the resource monitor to start watching for file system changes. - // Initialize runs after handler registration so the monitor sees the full set - // of registered roots and can create one watcher per IsWatched: true root. - var initResult = Monitor.Initialize(); - if (initResult.IsFailure) - { - _logger.LogWarning(initResult, "Failed to initialize resource monitor"); - } + // Monitor.Initialize() is called from WorkspaceLoader after construction completes; + // the monitor looks up its registry through IWorkspaceWrapper, which is only populated + // once the WorkspaceService finishes constructing. _messengerService.Register(this, OnMainWindowActivatedMessage); _messengerService.Register(this, OnResourceUpdateRequestedMessage); diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs index 36fc919b9..e0783d2b0 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs @@ -109,8 +109,19 @@ public async Task LoadWorkspaceAsync() // Restore previous state of expanded folders before populating resources await folderStateService.LoadAsync(); - // Update resource registry immediately to ensure we are up to date var resourceService = workspaceService.ResourceService; + + // Start file system watchers now that the wrapper is fully populated. + // The monitor cannot be initialized in ResourceService's constructor because + // it reaches into the workspace via IWorkspaceWrapper, which is only set up + // once construction completes. + var initMonitorResult = resourceService.Monitor.Initialize(); + if (initMonitorResult.IsFailure) + { + _logger.LogWarning(initMonitorResult, "Failed to initialize resource monitor"); + } + + // Update resource registry immediately to ensure we are up to date var updateResult = resourceService.UpdateResources(); if (updateResult.IsFailure) { From 72fc5145ecbb192bfafc5bd8ff3ae7ac49439472 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 20 May 2026 11:21:37 +0100 Subject: [PATCH 05/48] Replace ResourceFileWriter with ResourceFileSystem Introduce a new IResourceFileSystem chokepoint and implementation, replacing the previous IResourceFileWriter. The new interface exposes read/write/open stream APIs plus structural ops (Move/Copy/Delete) and sidecar-aware result types (SidecarOutcome, MoveResult, CopyResult, DeleteResult). Added ResourceFileSystem service implementation and comprehensive ResourceFileSystemTests; removed the old ResourceFileWriter and its tests. Updated DI registration and IWorkspaceService to expose the new file-system layer, migrated callers across the workspace (commands, viewmodels, tools, tests) to use the new API (including async writes and atomic staging behavior). Added a staging folder constant (CelbridgeStagingFsFolder) used for atomic temp-file staging. Also adjusted PackageTools to use the file-system layer for atomic writes and made related async fixes. --- .../Projects/ProjectConstants.cs | 7 + .../Resources/IResourceFileSystem.cs | 113 ++++++ .../Resources/IResourceFileWriter.cs | 26 -- .../Resources/IResourceService.cs | 7 +- .../Workspace/IWorkspaceService.cs | 5 + .../Tools/Package/PackageTools.Create.cs | 35 +- .../Tools/Package/PackageTools.Install.cs | 24 +- .../Tests/Documents/DocumentViewModelTests.cs | 33 +- .../Resources/ApplyRangeEditsCommandTests.cs | 4 +- .../Tests/Resources/EditFileCommandTests.cs | 4 +- .../Resources/MultiEditFileCommandTests.cs | 4 +- .../Resources/ReplaceFileCommandTests.cs | 4 +- .../Resources/ResourceFileSystemTests.cs | 306 ++++++++++++++++ .../Resources/ResourceFileWriterTests.cs | 144 -------- .../Resources/WriteBinaryFileCommandTests.cs | 4 +- .../Tests/Resources/WriteFileCommandTests.cs | 4 +- .../ViewModels/DocumentViewModel.cs | 16 +- .../ViewModels/WebInspectorViewModel.cs | 29 +- .../Commands/ApplyRangeEditsCommand.cs | 16 +- .../Commands/EditFileCommand.cs | 8 +- .../Commands/MultiEditFileCommand.cs | 8 +- .../Commands/ReplaceFileCommand.cs | 12 +- .../Commands/WriteBinaryFileCommand.cs | 7 +- .../Commands/WriteFileCommand.cs | 7 +- .../ServiceConfiguration.cs | 2 +- .../Services/ResourceFileSystem.cs | 327 ++++++++++++++++++ .../Services/ResourceFileWriter.cs | 158 --------- .../Services/ResourceService.cs | 31 +- .../Services/SearchService.cs | 46 +-- .../Services/WorkspaceService.cs | 2 + 30 files changed, 912 insertions(+), 481 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs delete mode 100644 Source/Core/Celbridge.Foundation/Resources/IResourceFileWriter.cs create mode 100644 Source/Tests/Resources/ResourceFileSystemTests.cs delete mode 100644 Source/Tests/Resources/ResourceFileWriterTests.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs delete mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceFileWriter.cs diff --git a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs index 928016e20..196ed6224 100644 --- a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs +++ b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs @@ -65,4 +65,11 @@ public static class ProjectConstants /// Sub-folder of .celbridge/ for soft-deleted files. Cleared on every workspace load. /// public const string CelbridgeTrashFolder = "trash"; + + /// + /// Sub-folder of .celbridge/ that stages in-flight temp files for atomic + /// writes performed by the resource file-system chokepoint. Wiped on + /// workspace load to clear orphans left by previous crashes. + /// + public const string CelbridgeStagingFsFolder = "staging-fs"; } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs new file mode 100644 index 000000000..83b26d960 --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs @@ -0,0 +1,113 @@ +namespace Celbridge.Resources; + +/// +/// The outcome of the sidecar cascade attached to a structural operation. +/// +public enum SidecarOutcome +{ + /// + /// No sidecar file existed alongside the source; nothing to cascade. + /// + NotPresent, + + /// + /// A sidecar existed and the operation applied to it successfully. + /// + Cascaded, + + /// + /// A sidecar existed but the cascade step failed. The parent operation still succeeded. + /// + Failed, +} + +/// +/// Result of an integrity-aware move: the list of resources whose references +/// were rewritten and the outcome of the paired-sidecar cascade. +/// +public record MoveResult( + IReadOnlyList UpdatedReferencers, + SidecarOutcome Sidecar); + +/// +/// Result of an integrity-aware copy: the outcome of the paired-sidecar cascade. +/// +public record CopyResult( + SidecarOutcome Sidecar); + +/// +/// Result of an integrity-aware delete: the outcome of the paired-sidecar cascade. +/// +public record DeleteResult( + SidecarOutcome Sidecar); + +/// +/// The chokepoint for disk reads, writes, and structural operations on project +/// resources. Callers pass a ResourceKey; the layer resolves it through +/// IResourceRegistry so containment and symlink validation run automatically. +/// Bytes and text writes are atomic via temp-file rename with bounded retry on +/// transient IO failures. Structural operations include reference rewrites and +/// the paired-sidecar cascade as part of their definition. +/// +public interface IResourceFileSystem +{ + /// + /// Reads the full byte content of the resource. + /// + Task> ReadAllBytesAsync(ResourceKey resource); + + /// + /// Reads the full text content of the resource. Files are decoded as UTF-8. + /// + Task> ReadAllTextAsync(ResourceKey resource); + + /// + /// Opens a read-only stream over the resource. The caller owns the stream lifetime. + /// + Task> OpenReadAsync(ResourceKey resource); + + /// + /// Writes raw bytes to the resource. The destination's parent folder is + /// created if it does not exist. Atomic via temp-file rename, with bounded + /// retry on transient IOException. + /// + Task WriteAllBytesAsync(ResourceKey resource, byte[] bytes); + + /// + /// Writes UTF-8 text (no BOM) to the resource. The destination's parent + /// folder is created if it does not exist. Atomic via temp-file rename, + /// with bounded retry on transient IOException. Callers are responsible + /// for selecting line endings appropriate to the target file. + /// + Task WriteAllTextAsync(ResourceKey resource, string content); + + /// + /// Opens an exclusive write stream over the resource. Creates the parent + /// folder if missing and truncates the destination on open. The caller + /// owns the stream lifetime. Writes are not atomic via this path; a crash + /// mid-write leaves the file partially written. + /// + Task> OpenWriteAsync(ResourceKey resource); + + /// + /// Moves the resource and cascades reference rewrites and the paired + /// sidecar. Cross-root moves are not supported. + /// + Task> MoveAsync(ResourceKey source, ResourceKey destination); + + /// + /// Copies the resource and cascades the paired sidecar to the destination. + /// References inside the copied content keep pointing at their original targets. + /// + Task> CopyAsync(ResourceKey source, ResourceKey destination); + + /// + /// Deletes the resource and cascades the paired sidecar. + /// + Task> DeleteAsync(ResourceKey source); + + /// + /// Returns true if a file or folder exists at the resolved path of the resource key. + /// + Task> ExistsAsync(ResourceKey resource); +} diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceFileWriter.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceFileWriter.cs deleted file mode 100644 index 69cadd556..000000000 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceFileWriter.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Celbridge.Resources; - -/// -/// Writes file content to project resources. The single chokepoint for disk -/// writes inside the project folder: callers pass a ResourceKey and the writer -/// resolves it through IResourceRegistry.ResolveResourcePath, so containment -/// and symlink validation run automatically. Writes are atomic via temp-file -/// rename and retry on transient IO failures. -/// -public interface IResourceFileWriter -{ - /// - /// Writes raw bytes to the resource. The destination's parent folder is - /// created if it does not exist. Atomic via temp-file rename, with bounded - /// retry on transient IOException. - /// - Task WriteAllBytesAsync(ResourceKey resource, byte[] bytes); - - /// - /// Writes UTF-8 text (no BOM) to the resource. The destination's parent - /// folder is created if it does not exist. Atomic via temp-file rename, - /// with bounded retry on transient IOException. Callers are responsible - /// for selecting line endings appropriate to the target file. - /// - Task WriteAllTextAsync(ResourceKey resource, string content); -} diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs index a5a638652..b8e8015a3 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs @@ -27,12 +27,7 @@ public interface IResourceService IResourceOperationService OperationService { get; } /// - /// Returns the Resource File Writer used to write file content to the project folder. - /// - IResourceFileWriter FileWriter { get; } - - /// - /// Schedules a resource update. + /// Schedules a resource update. /// The update occurs after a short quiet period to coalesce rapid calls from multiple sources. /// void ScheduleResourceUpdate(); diff --git a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs index af2415a37..47d4fa398 100644 --- a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs +++ b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs @@ -46,6 +46,11 @@ void SetPanels( /// IResourceService ResourceService { get; } + /// + /// Returns the chokepoint file-system layer for project resources. + /// + IResourceFileSystem ResourceFileSystem { get; } + /// /// Returns the Explorer Service associated with the workspace. /// diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs index 86b8e635d..dc6406c92 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs @@ -3,8 +3,6 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Directory = System.IO.Directory; -using File = System.IO.File; -using Path = System.IO.Path; namespace Celbridge.Tools; @@ -19,7 +17,7 @@ public partial class PackageTools [McpServerTool(Name = "package_create", Destructive = true)] [ToolAlias("package.create")] [RelatedGuides("packages_overview")] - public partial CallToolResult Create(string packageName) + public async partial Task Create(string packageName) { if (!IsValidPackageName(packageName)) { @@ -29,7 +27,9 @@ public partial CallToolResult Create(string packageName) } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var workspaceService = workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var packageResource = ResourceKey.Create($"packages/{packageName}"); var resolveResult = resourceRegistry.ResolveResourcePath(packageResource); @@ -46,24 +46,19 @@ public partial CallToolResult Create(string packageName) return ToolResponse.Error($"Package already exists: 'packages/{packageName}'"); } - try - { - Directory.CreateDirectory(packageFolderPath); - - var manifestContent = new StringBuilder(); - manifestContent.AppendLine("[package]"); - manifestContent.AppendLine($"id = \"{packageName}\""); - manifestContent.AppendLine($"name = \"{packageName}\""); - manifestContent.AppendLine("version = \"1.0.0\""); - manifestContent.AppendLine(); - manifestContent.AppendLine("[contributes]"); + var manifestContent = new StringBuilder(); + manifestContent.AppendLine("[package]"); + manifestContent.AppendLine($"id = \"{packageName}\""); + manifestContent.AppendLine($"name = \"{packageName}\""); + manifestContent.AppendLine("version = \"1.0.0\""); + manifestContent.AppendLine(); + manifestContent.AppendLine("[contributes]"); - var manifestPath = Path.Combine(packageFolderPath, ManifestFileName); - File.WriteAllText(manifestPath, manifestContent.ToString()); - } - catch (System.IO.IOException exception) + var manifestResource = ResourceKey.Create($"packages/{packageName}/{ManifestFileName}"); + var writeManifestResult = await fileSystem.WriteAllTextAsync(manifestResource, manifestContent.ToString()); + if (writeManifestResult.IsFailure) { - return ToolResponse.Error($"Failed to create package: {exception.Message}"); + return ToolResponse.Error($"Failed to create package: {writeManifestResult.FirstErrorMessage}"); } var result = new PackageCreateResult( diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs index e6d0e588e..0c4ea7876 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs @@ -1,9 +1,7 @@ using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using Directory = System.IO.Directory; using File = System.IO.File; -using Path = System.IO.Path; namespace Celbridge.Tools; @@ -73,9 +71,12 @@ public async partial Task Install(string packageName, bool confi } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var workspaceService = workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; - // Write the downloaded zip to a temporary cache file in the project + // Write the downloaded zip to a temporary cache file in the project. + // The FS layer ensures the parent folder exists and writes atomically. var tempArchiveResource = ResourceKey.Create($".celbridge/.cache/{packageName}.zip"); var resolveTempResult = resourceRegistry.ResolveResourcePath(tempArchiveResource); if (resolveTempResult.IsFailure) @@ -86,19 +87,10 @@ public async partial Task Install(string packageName, bool confi } var tempArchivePath = resolveTempResult.Value; - var tempFolder = Path.GetDirectoryName(tempArchivePath); - if (!string.IsNullOrEmpty(tempFolder) && !Directory.Exists(tempFolder)) + var writeArchiveResult = await fileSystem.WriteAllBytesAsync(tempArchiveResource, downloadResult.Value); + if (writeArchiveResult.IsFailure) { - Directory.CreateDirectory(tempFolder); - } - - try - { - await File.WriteAllBytesAsync(tempArchivePath, downloadResult.Value); - } - catch (System.IO.IOException exception) - { - return ToolResponse.Error($"Failed to write downloaded package: {exception.Message}"); + return ToolResponse.Error($"Failed to write downloaded package: {writeArchiveResult.FirstErrorMessage}"); } var destinationResource = ResourceKey.Create($"packages/{packageName}"); diff --git a/Source/Tests/Documents/DocumentViewModelTests.cs b/Source/Tests/Documents/DocumentViewModelTests.cs index 84d3affef..733f17c5f 100644 --- a/Source/Tests/Documents/DocumentViewModelTests.cs +++ b/Source/Tests/Documents/DocumentViewModelTests.cs @@ -16,7 +16,7 @@ namespace Celbridge.Tests.Documents; public class DocumentViewModelTests { private IMessengerService _messengerService = null!; - private IResourceFileWriter _fileWriter = null!; + private IResourceFileSystem _fileSystem = null!; private TestDocumentViewModel _vm = null!; private string _tempFolder = null!; private string _tempFilePath = null!; @@ -32,9 +32,9 @@ public void Setup() _tempFilePath = Path.Combine(_tempFolder, "test.md"); File.WriteAllText(_tempFilePath, string.Empty); - // Wire a real ResourceFileWriter over a substituted workspace hierarchy + // Wire a real ResourceFileSystem over a substituted workspace hierarchy // whose registry maps the test's resource key to the temp file path. The - // writer's atomic write + retry semantics are exercised directly against + // layer's atomic write + retry semantics are exercised directly against // the temp folder. var resourceRegistry = Substitute.For(); resourceRegistry.ProjectFolderPath.Returns(_tempFolder); @@ -49,14 +49,15 @@ public void Setup() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - _fileWriter = new ResourceFileWriter(Substitute.For>(), workspaceWrapper); + _fileSystem = new ResourceFileSystem(Substitute.For>(), workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(_fileSystem); var services = new ServiceCollection(); services.AddSingleton(_messengerService); services.AddSingleton(workspaceWrapper); ServiceLocator.Initialize(services.BuildServiceProvider()); - _vm = new TestDocumentViewModel(_fileWriter); + _vm = new TestDocumentViewModel(_fileSystem); _vm.FileResource = new ResourceKey("test.md"); _vm.FilePath = _tempFilePath; } @@ -135,9 +136,9 @@ public async Task SaveDocumentContent_ReturnsFailure_WhenWriterFails() var failingWrapper = Substitute.For(); failingWrapper.WorkspaceService.Returns(failingWorkspaceService); - var failingWriter = new ResourceFileWriter(Substitute.For>(), failingWrapper); + var failingFileSystem = new ResourceFileSystem(Substitute.For>(), failingWrapper); - var failingVm = new TestDocumentViewModel(failingWriter) + var failingVm = new TestDocumentViewModel(failingFileSystem) { FileResource = new ResourceKey("test.md"), FilePath = _tempFilePath @@ -234,7 +235,7 @@ public async Task Save_RaisesReloadRequested_WhenPostWriteDiskHashDiffersFromInt // immediately after we call WriteAllBytesAsync but before // UpdateFileTrackingInfo runs. var externalContent = "external content that overrode our save"; - var savingVm = new ExternalWriteDocumentViewModel(_fileWriter, _tempFilePath, externalContent); + var savingVm = new ExternalWriteDocumentViewModel(_fileSystem, _tempFilePath, externalContent); savingVm.FileResource = new ResourceKey("interleave.md"); savingVm.FilePath = _tempFilePath; @@ -255,11 +256,11 @@ public async Task Save_RaisesReloadRequested_WhenPostWriteDiskHashDiffersFromInt /// private sealed class TestDocumentViewModel : DocumentViewModel { - private readonly IResourceFileWriter _writer; + private readonly IResourceFileSystem _fileSystem; - public TestDocumentViewModel(IResourceFileWriter writer) + public TestDocumentViewModel(IResourceFileSystem fileSystem) { - _writer = writer; + _fileSystem = fileSystem; EnableFileChangeMonitoring(); } @@ -281,7 +282,7 @@ public void OnTextChanged() SaveTimer = SaveDelay; } - protected override IResourceFileWriter GetFileWriter() => _writer; + protected override IResourceFileSystem GetFileSystem() => _fileSystem; } /// @@ -293,14 +294,14 @@ public void OnTextChanged() /// private sealed class ExternalWriteDocumentViewModel : DocumentViewModel { - private readonly IResourceFileWriter _writer; + private readonly IResourceFileSystem _fileSystem; private readonly string _injectedFilePath; private readonly string _externalContent; private bool _hasInjected; - public ExternalWriteDocumentViewModel(IResourceFileWriter writer, string filePath, string externalContent) + public ExternalWriteDocumentViewModel(IResourceFileSystem fileSystem, string filePath, string externalContent) { - _writer = writer; + _fileSystem = fileSystem; _injectedFilePath = filePath; _externalContent = externalContent; EnableFileChangeMonitoring(); @@ -313,7 +314,7 @@ public Task SaveDocumentContent(string text) return SaveTextToFileAsync(text); } - protected override IResourceFileWriter GetFileWriter() => _writer; + protected override IResourceFileSystem GetFileSystem() => _fileSystem; protected override void UpdateFileTrackingInfo() { diff --git a/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs index 25d00f945..127e6fdba 100644 --- a/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs +++ b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs @@ -36,8 +36,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] diff --git a/Source/Tests/Resources/EditFileCommandTests.cs b/Source/Tests/Resources/EditFileCommandTests.cs index d77087eff..846e2379c 100644 --- a/Source/Tests/Resources/EditFileCommandTests.cs +++ b/Source/Tests/Resources/EditFileCommandTests.cs @@ -35,8 +35,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] diff --git a/Source/Tests/Resources/MultiEditFileCommandTests.cs b/Source/Tests/Resources/MultiEditFileCommandTests.cs index cb2b23b16..c975bcb96 100644 --- a/Source/Tests/Resources/MultiEditFileCommandTests.cs +++ b/Source/Tests/Resources/MultiEditFileCommandTests.cs @@ -35,8 +35,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] diff --git a/Source/Tests/Resources/ReplaceFileCommandTests.cs b/Source/Tests/Resources/ReplaceFileCommandTests.cs index c93625d8a..0487c4f92 100644 --- a/Source/Tests/Resources/ReplaceFileCommandTests.cs +++ b/Source/Tests/Resources/ReplaceFileCommandTests.cs @@ -35,8 +35,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] diff --git a/Source/Tests/Resources/ResourceFileSystemTests.cs b/Source/Tests/Resources/ResourceFileSystemTests.cs new file mode 100644 index 000000000..ef4f6adcf --- /dev/null +++ b/Source/Tests/Resources/ResourceFileSystemTests.cs @@ -0,0 +1,306 @@ +using Celbridge.Projects; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for ResourceFileSystem — atomic writes, retry on transient IO, +/// parent-folder creation, ResolveResourcePath integration, reads, and +/// stream-open happy paths. +/// +[TestFixture] +public class ResourceFileSystemTests +{ + private string _tempFolder = null!; + private IResourceRegistry _resourceRegistry = null!; + private ResourceFileSystem _fileSystem = null!; + + [SetUp] + public void Setup() + { + _tempFolder = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(ResourceFileSystemTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempFolder); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _fileSystem = new ResourceFileSystem( + Substitute.For>(), + workspaceWrapper); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempFolder)) + { + Directory.Delete(_tempFolder, true); + } + } + + [Test] + public async Task WriteAllBytesAsync_WritesContent_WhenFileDoesNotExist() + { + var resource = new ResourceKey("new.bin"); + var path = Path.Combine(_tempFolder, "new.bin"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var bytes = new byte[] { 0x01, 0x02, 0x03 }; + + var result = await _fileSystem.WriteAllBytesAsync(resource, bytes); + + result.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeTrue(); + (await File.ReadAllBytesAsync(path)).Should().Equal(bytes); + } + + [Test] + public async Task WriteAllTextAsync_WritesContent_WhenFileDoesNotExist() + { + var resource = new ResourceKey("new.txt"); + var path = Path.Combine(_tempFolder, "new.txt"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.WriteAllTextAsync(resource, "hello world"); + + result.IsSuccess.Should().BeTrue(); + (await File.ReadAllTextAsync(path)).Should().Be("hello world"); + } + + [Test] + public async Task WriteAllTextAsync_OverwritesExistingFile() + { + var resource = new ResourceKey("existing.txt"); + var path = Path.Combine(_tempFolder, "existing.txt"); + await File.WriteAllTextAsync(path, "old"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.WriteAllTextAsync(resource, "new"); + + result.IsSuccess.Should().BeTrue(); + (await File.ReadAllTextAsync(path)).Should().Be("new"); + } + + [Test] + public async Task WriteAllTextAsync_CreatesIntermediateFolders() + { + var resource = new ResourceKey("nested/deeper/file.txt"); + var path = Path.Combine(_tempFolder, "nested", "deeper", "file.txt"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.WriteAllTextAsync(resource, "deep content"); + + result.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeTrue(); + (await File.ReadAllTextAsync(path)).Should().Be("deep content"); + } + + [Test] + public async Task WriteAllTextAsync_ReturnsFailure_WhenResolveFails() + { + var resource = new ResourceKey("bad.txt"); + _resourceRegistry.ResolveResourcePath(resource) + .Returns(Result.Fail("simulated resolve failure")); + + var result = await _fileSystem.WriteAllTextAsync(resource, "anything"); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task WriteAllBytesAsync_StagesTempInCelbridgeStagingFolder_AndLeavesNoOrphan() + { + // Atomic writes stage temp files in /.celbridge/staging-fs/, not + // alongside the destination. After a successful write the staging folder + // exists (next caller may use it) but contains no leftover .tmp file. + var resource = new ResourceKey("clean.bin"); + var path = Path.Combine(_tempFolder, "clean.bin"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + await _fileSystem.WriteAllBytesAsync(resource, new byte[] { 0x42 }); + + // No sibling temp file next to the destination. + File.Exists(path + ".tmp").Should().BeFalse(); + + // Central staging folder exists but is empty. + var stagingFolder = Path.Combine( + _tempFolder, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeStagingFsFolder); + Directory.Exists(stagingFolder).Should().BeTrue(); + Directory.GetFiles(stagingFolder).Should().BeEmpty(); + } + + [Test] + public async Task ReadAllBytesAsync_ReturnsContent_WhenFileExists() + { + var resource = new ResourceKey("read.bin"); + var path = Path.Combine(_tempFolder, "read.bin"); + var expected = new byte[] { 0x10, 0x20, 0x30 }; + await File.WriteAllBytesAsync(path, expected); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.ReadAllBytesAsync(resource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Equal(expected); + } + + [Test] + public async Task ReadAllTextAsync_ReturnsContent_WhenFileExists() + { + var resource = new ResourceKey("read.txt"); + var path = Path.Combine(_tempFolder, "read.txt"); + await File.WriteAllTextAsync(path, "the content"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.ReadAllTextAsync(resource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("the content"); + } + + [Test] + public async Task ReadAllBytesAsync_ReturnsFailure_WhenFileMissing() + { + var resource = new ResourceKey("missing.bin"); + var path = Path.Combine(_tempFolder, "missing.bin"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.ReadAllBytesAsync(resource); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task OpenReadAsync_ReturnsStreamWithFileContent() + { + var resource = new ResourceKey("openread.bin"); + var path = Path.Combine(_tempFolder, "openread.bin"); + var expected = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD }; + await File.WriteAllBytesAsync(path, expected); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var openResult = await _fileSystem.OpenReadAsync(resource); + + openResult.IsSuccess.Should().BeTrue(); + await using var stream = openResult.Value; + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + buffer.ToArray().Should().Equal(expected); + } + + [Test] + public async Task OpenWriteAsync_WritesContentThroughStream() + { + var resource = new ResourceKey("openwrite.bin"); + var path = Path.Combine(_tempFolder, "openwrite.bin"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var openResult = await _fileSystem.OpenWriteAsync(resource); + + openResult.IsSuccess.Should().BeTrue(); + var content = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + await using (var stream = openResult.Value) + { + await stream.WriteAsync(content); + } + + File.Exists(path).Should().BeTrue(); + (await File.ReadAllBytesAsync(path)).Should().Equal(content); + } + + [Test] + public async Task OpenWriteAsync_CreatesParentFolder() + { + var resource = new ResourceKey("nested/folder/openwrite.bin"); + var path = Path.Combine(_tempFolder, "nested", "folder", "openwrite.bin"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var openResult = await _fileSystem.OpenWriteAsync(resource); + + openResult.IsSuccess.Should().BeTrue(); + await using (var stream = openResult.Value) + { + stream.WriteByte(0x99); + } + + File.Exists(path).Should().BeTrue(); + } + + [Test] + public async Task ExistsAsync_ReturnsTrue_WhenFilePresent() + { + var resource = new ResourceKey("present.txt"); + var path = Path.Combine(_tempFolder, "present.txt"); + await File.WriteAllTextAsync(path, "content"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.ExistsAsync(resource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public async Task ExistsAsync_ReturnsFalse_WhenFileMissing() + { + var resource = new ResourceKey("missing.txt"); + var path = Path.Combine(_tempFolder, "missing.txt"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.ExistsAsync(resource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeFalse(); + } + + [Test] + public async Task ExistsAsync_ReturnsFailure_WhenResolveFails() + { + var resource = new ResourceKey("bad.txt"); + _resourceRegistry.ResolveResourcePath(resource) + .Returns(Result.Fail("simulated resolve failure")); + + var result = await _fileSystem.ExistsAsync(resource); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public void MoveAsync_ThrowsNotImplementedException_InPhase1a() + { + // Structural operations land in fs-1b. + Assert.ThrowsAsync( + () => _fileSystem.MoveAsync(new ResourceKey("a"), new ResourceKey("b"))); + } + + [Test] + public void CopyAsync_ThrowsNotImplementedException_InPhase1a() + { + Assert.ThrowsAsync( + () => _fileSystem.CopyAsync(new ResourceKey("a"), new ResourceKey("b"))); + } + + [Test] + public void DeleteAsync_ThrowsNotImplementedException_InPhase1a() + { + Assert.ThrowsAsync( + () => _fileSystem.DeleteAsync(new ResourceKey("a"))); + } +} diff --git a/Source/Tests/Resources/ResourceFileWriterTests.cs b/Source/Tests/Resources/ResourceFileWriterTests.cs deleted file mode 100644 index 29875ca47..000000000 --- a/Source/Tests/Resources/ResourceFileWriterTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Celbridge.Projects; -using Celbridge.Resources; -using Celbridge.Resources.Services; -using Celbridge.Workspace; - -namespace Celbridge.Tests.Resources; - -/// -/// Tests for ResourceFileWriter — atomic writes, retry on transient IO, -/// parent-folder creation, and ResolveResourcePath integration. -/// -[TestFixture] -public class ResourceFileWriterTests -{ - private string _tempFolder = null!; - private IResourceRegistry _resourceRegistry = null!; - private ResourceFileWriter _writer = null!; - - [SetUp] - public void Setup() - { - _tempFolder = Path.Combine( - Path.GetTempPath(), - "Celbridge", - nameof(ResourceFileWriterTests), - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempFolder); - - _resourceRegistry = Substitute.For(); - _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); - - var resourceService = Substitute.For(); - resourceService.Registry.Returns(_resourceRegistry); - - var workspaceService = Substitute.For(); - workspaceService.ResourceService.Returns(resourceService); - - var workspaceWrapper = Substitute.For(); - workspaceWrapper.WorkspaceService.Returns(workspaceService); - - _writer = new ResourceFileWriter( - Substitute.For>(), - workspaceWrapper); - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_tempFolder)) - { - Directory.Delete(_tempFolder, true); - } - } - - [Test] - public async Task WriteAllBytesAsync_WritesContent_WhenFileDoesNotExist() - { - var resource = new ResourceKey("new.bin"); - var path = Path.Combine(_tempFolder, "new.bin"); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - - var bytes = new byte[] { 0x01, 0x02, 0x03 }; - - var result = await _writer.WriteAllBytesAsync(resource, bytes); - - result.IsSuccess.Should().BeTrue(); - File.Exists(path).Should().BeTrue(); - (await File.ReadAllBytesAsync(path)).Should().Equal(bytes); - } - - [Test] - public async Task WriteAllTextAsync_WritesContent_WhenFileDoesNotExist() - { - var resource = new ResourceKey("new.txt"); - var path = Path.Combine(_tempFolder, "new.txt"); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - - var result = await _writer.WriteAllTextAsync(resource, "hello world"); - - result.IsSuccess.Should().BeTrue(); - (await File.ReadAllTextAsync(path)).Should().Be("hello world"); - } - - [Test] - public async Task WriteAllTextAsync_OverwritesExistingFile() - { - var resource = new ResourceKey("existing.txt"); - var path = Path.Combine(_tempFolder, "existing.txt"); - await File.WriteAllTextAsync(path, "old"); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - - var result = await _writer.WriteAllTextAsync(resource, "new"); - - result.IsSuccess.Should().BeTrue(); - (await File.ReadAllTextAsync(path)).Should().Be("new"); - } - - [Test] - public async Task WriteAllTextAsync_CreatesIntermediateFolders() - { - var resource = new ResourceKey("nested/deeper/file.txt"); - var path = Path.Combine(_tempFolder, "nested", "deeper", "file.txt"); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - - var result = await _writer.WriteAllTextAsync(resource, "deep content"); - - result.IsSuccess.Should().BeTrue(); - File.Exists(path).Should().BeTrue(); - (await File.ReadAllTextAsync(path)).Should().Be("deep content"); - } - - [Test] - public async Task WriteAllTextAsync_ReturnsFailure_WhenResolveFails() - { - var resource = new ResourceKey("bad.txt"); - _resourceRegistry.ResolveResourcePath(resource) - .Returns(Result.Fail("simulated resolve failure")); - - var result = await _writer.WriteAllTextAsync(resource, "anything"); - - result.IsFailure.Should().BeTrue(); - } - - [Test] - public async Task WriteAllBytesAsync_StagesTempInCelbridgeTempFolder_AndLeavesNoOrphan() - { - // Atomic writes stage temp files in /celbridge/.temp/, not - // alongside the destination. After a successful write the temp folder - // exists (next caller may use it) but contains no leftover .tmp file. - var resource = new ResourceKey("clean.bin"); - var path = Path.Combine(_tempFolder, "clean.bin"); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - - await _writer.WriteAllBytesAsync(resource, new byte[] { 0x42 }); - - // No sibling temp file next to the destination. - File.Exists(path + ".tmp").Should().BeFalse(); - - // Central temp folder exists but is empty. - var centralTempFolder = Path.Combine(_tempFolder, ProjectConstants.MetaDataFolder, ProjectConstants.TempFolder); - Directory.Exists(centralTempFolder).Should().BeTrue(); - Directory.GetFiles(centralTempFolder).Should().BeEmpty(); - } -} diff --git a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs index 70ce833e7..f93370b7d 100644 --- a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs +++ b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs @@ -34,8 +34,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] diff --git a/Source/Tests/Resources/WriteFileCommandTests.cs b/Source/Tests/Resources/WriteFileCommandTests.cs index 6f2016c32..2572cd85c 100644 --- a/Source/Tests/Resources/WriteFileCommandTests.cs +++ b/Source/Tests/Resources/WriteFileCommandTests.cs @@ -34,8 +34,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index 7c6c68a7a..c1c551338 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -177,7 +177,7 @@ protected async Task SaveBinaryToFileAsync(string base64Content) } /// - /// Routes the save through IResourceFileWriter (atomic write + bounded retry + /// Routes the save through IResourceFileSystem (atomic write + bounded retry /// on transient IO) and raises ReloadRequested when external interleaving is /// detected either before the write (pre-write hash check) or between the /// write completing and our tracking-hash read (post-write check). Updates @@ -192,8 +192,8 @@ private async Task SaveBytesToFileAsync(byte[] bytes) return Result.Ok(); } - var writer = GetFileWriter(); - var writeResult = await writer.WriteAllBytesAsync(FileResource, bytes); + var fileSystem = GetFileSystem(); + var writeResult = await fileSystem.WriteAllBytesAsync(FileResource, bytes); if (writeResult.IsFailure) { return writeResult; @@ -253,14 +253,14 @@ private bool TryDetectPreWriteExternalChange() } /// - /// Acquires the resource file writer. Overridable so tests can substitute - /// a writer wired to a temp folder without going through the workspace - /// service hierarchy. + /// Acquires the resource file-system layer. Overridable so tests can + /// substitute a layer wired to a temp folder without going through the + /// workspace service hierarchy. /// - protected virtual IResourceFileWriter GetFileWriter() + protected virtual IResourceFileSystem GetFileSystem() { var workspaceWrapper = ServiceLocator.AcquireService(); - return workspaceWrapper.WorkspaceService.ResourceService.FileWriter; + return workspaceWrapper.WorkspaceService.ResourceFileSystem; } /// diff --git a/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs b/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs index d54992675..ea5da2dfb 100644 --- a/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs +++ b/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs @@ -2,6 +2,7 @@ using Celbridge.Documents; using Celbridge.Logging; using Celbridge.Messaging; +using Celbridge.Resources; using Celbridge.WebHost; using Celbridge.Workspace; using CommunityToolkit.Mvvm.ComponentModel; @@ -16,6 +17,7 @@ public partial class WebInspectorViewModel : InspectorViewModel private readonly IStringLocalizer _stringLocalizer; private readonly IMessengerService _messengerService; private readonly IResourceRegistry _resourceRegistry; + private readonly IResourceFileSystem _resourceFileSystem; private readonly IWebViewService _webViewService; [ObservableProperty] @@ -72,6 +74,7 @@ public WebInspectorViewModel( _stringLocalizer = stringLocalizer; _messengerService = messengerService; _resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + _resourceFileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; _webViewService = webViewService; _messengerService.Register(this, OnWebViewNavigationStateChanged); @@ -172,18 +175,7 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro } else if (e.PropertyName == nameof(SourceUrl) && !_suppressSaving) { - var resolveSaveResult = _resourceRegistry.ResolveResourcePath(Resource); - if (resolveSaveResult.IsFailure) - { - _logger.LogError(resolveSaveResult, $"Failed to resolve path for resource: '{Resource}'"); - return; - } - var saveResult = SaveWebView(resolveSaveResult.Value, SourceUrl); - if (saveResult.IsFailure) - { - _logger.LogError(saveResult, $"Failed to save .webview file: {resolveSaveResult.Value}"); - return; - } + _ = SaveWebViewAsync(Resource, SourceUrl); } } @@ -220,7 +212,7 @@ private Result LoadWebView(string webFilePath) } } - private Result SaveWebView(string webFilePath, string sourceUrl) + private async Task SaveWebViewAsync(ResourceKey resource, string sourceUrl) { try { @@ -229,14 +221,15 @@ private Result SaveWebView(string webFilePath, string sourceUrl) ["sourceUrl"] = sourceUrl }; - File.WriteAllText(webFilePath, jsonObject.ToJsonString()); - - return Result.Ok(); + var writeResult = await _resourceFileSystem.WriteAllTextAsync(resource, jsonObject.ToJsonString()); + if (writeResult.IsFailure) + { + _logger.LogError(writeResult, $"Failed to save .webview file: '{resource}'"); + } } catch (Exception ex) { - return Result.Fail($"An exception occurred when saving .webview file: {webFilePath}") - .WithException(ex); + _logger.LogError(ex, $"An exception occurred when saving .webview file: '{resource}'"); } } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs index 9b025a46f..52ce490d8 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs @@ -34,7 +34,9 @@ public override async Task ExecuteAsync() return Result.Ok(); } - var resourceService = _workspaceWrapper.WorkspaceService.ResourceService; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var failedResources = new List(); var failureDetails = new List(); @@ -43,7 +45,7 @@ public override async Task ExecuteAsync() { var resource = fileEdit.Resource; - var applyResult = await ApplyEditsToDisk(resourceService, resource, fileEdit.Edits); + var applyResult = await ApplyEditsToDisk(resourceRegistry, fileSystem, resource, fileEdit.Edits); if (applyResult.IsFailure) { _logger.LogWarning($"Failed to apply edits to file on disk: {resource}"); @@ -83,10 +85,12 @@ public override async Task ExecuteAsync() return Result.Ok(); } - private static async Task ApplyEditsToDisk(IResourceService resourceService, ResourceKey resource, List edits) + private static async Task ApplyEditsToDisk( + IResourceRegistry resourceRegistry, + IResourceFileSystem fileSystem, + ResourceKey resource, + List edits) { - var resourceRegistry = resourceService.Registry; - var resolveResult = resourceRegistry.ResolveResourcePath(resource); if (resolveResult.IsFailure) { @@ -169,7 +173,7 @@ private static async Task ApplyEditsToDisk(IResourceService resourceServ output += originalSeparator; } - var writeResult = await resourceService.FileWriter.WriteAllTextAsync(resource, output); + var writeResult = await fileSystem.WriteAllTextAsync(resource, output); if (writeResult.IsFailure) { return Result.Fail($"Failed to write edits to file: '{resource}'") diff --git a/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs index 481df37db..cd2e389d7 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs @@ -32,9 +32,11 @@ public override async Task ExecuteAsync() return Result.Fail("oldString must be non-empty. To append to a file, anchor on the existing last line and concatenate the new content in newString. To overwrite or create a file, use file_write."); } - var resourceService = _workspaceWrapper.WorkspaceService.ResourceService; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceService.Registry.ResolveResourcePath(FileResource); + var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); if (resolveResult.IsFailure) { return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") @@ -69,7 +71,7 @@ public override async Task ExecuteAsync() var newContent = buildResult.NewContent; var replacementStarts = buildResult.ReplacementStarts; - var writeResult = await resourceService.FileWriter.WriteAllTextAsync(FileResource, newContent); + var writeResult = await fileSystem.WriteAllTextAsync(FileResource, newContent); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs index 7fd09c7d2..8315fb86e 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs @@ -37,9 +37,11 @@ public override async Task ExecuteAsync() return Result.Ok(); } - var resourceService = _workspaceWrapper.WorkspaceService.ResourceService; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceService.Registry.ResolveResourcePath(FileResource); + var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); if (resolveResult.IsFailure) { return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") @@ -106,7 +108,7 @@ public override async Task ExecuteAsync() buffer = applyResult.NewContent; } - var writeResult = await resourceService.FileWriter.WriteAllTextAsync(FileResource, buffer); + var writeResult = await fileSystem.WriteAllTextAsync(FileResource, buffer); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs index b0e575053..f24d088b4 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs @@ -37,9 +37,11 @@ public override async Task ExecuteAsync() return Result.Fail("Search text cannot be empty"); } - var resourceService = _workspaceWrapper.WorkspaceService.ResourceService; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceService.Registry.ResolveResourcePath(FileResource); + var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); if (resolveResult.IsFailure) { return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") @@ -52,10 +54,10 @@ public override async Task ExecuteAsync() return Result.Fail($"File not found: '{FileResource}'"); } - return await ReplaceOnDisk(resourceService, resourcePath); + return await ReplaceOnDisk(fileSystem, resourcePath); } - private async Task ReplaceOnDisk(IResourceService resourceService, string resourcePath) + private async Task ReplaceOnDisk(IResourceFileSystem fileSystem, string resourcePath) { var content = await File.ReadAllTextAsync(resourcePath); @@ -80,7 +82,7 @@ private async Task ReplaceOnDisk(IResourceService resourceService, strin if (replacementCount > 0) { - var writeResult = await resourceService.FileWriter.WriteAllTextAsync(FileResource, newContent); + var writeResult = await fileSystem.WriteAllTextAsync(FileResource, newContent); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs index af19fc271..b705015fb 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs @@ -32,8 +32,9 @@ public override async Task ExecuteAsync() return Result.Fail("Invalid base64 content"); } - var resourceService = _workspaceWrapper.WorkspaceService.ResourceService; - var resourceRegistry = resourceService.Registry; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); if (resolveResult.IsFailure) @@ -43,7 +44,7 @@ public override async Task ExecuteAsync() } var isNewFile = !File.Exists(resolveResult.Value); - var writeResult = await resourceService.FileWriter.WriteAllBytesAsync(FileResource, bytes); + var writeResult = await fileSystem.WriteAllBytesAsync(FileResource, bytes); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs index 9017a8914..43bc7b448 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs @@ -22,8 +22,9 @@ public WriteFileCommand( public override async Task ExecuteAsync() { - var resourceService = _workspaceWrapper.WorkspaceService.ResourceService; - var resourceRegistry = resourceService.Registry; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); if (resolveResult.IsFailure) @@ -50,7 +51,7 @@ public override async Task ExecuteAsync() var contentToWrite = LineEndingHelper.ConvertLineEndings(Content, targetSeparator); - var writeResult = await resourceService.FileWriter.WriteAllTextAsync(FileResource, contentToWrite); + var writeResult = await fileSystem.WriteAllTextAsync(FileResource, contentToWrite); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index ab062ba7f..dc70d15ed 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -21,7 +21,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); // diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs new file mode 100644 index 000000000..7c83d547a --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -0,0 +1,327 @@ +using System.Text; +using Celbridge.Logging; +using Celbridge.Projects; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +public sealed class ResourceFileSystem : IResourceFileSystem +{ + // Bounded retry for transient IO failures (file briefly locked by AV, + // backup software, sync clients, concurrent writers, etc.). Total + // worst-case wait across all attempts is BaseRetryDelayMs * (1 + 2 + ... + // + (MaxAttempts - 1)) = 150ms with the values below. + private const int MaxAttempts = 3; + private const int BaseRetryDelayMs = 50; + + // Buffer size used when opening file streams. Matches the default System.IO + // FileStream buffer size when none is supplied. + private const int StreamBufferSize = 4096; + + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; + + // The resource registry is workspace-scoped and transient: a constructor- + // injected instance is a different object from the one held by ResourceService, + // and only the ResourceService instance has ProjectFolderPath set. The + // file-system layer resolves the live registry through the workspace wrapper + // at call time. + public ResourceFileSystem( + ILogger logger, + IWorkspaceWrapper workspaceWrapper) + { + _logger = logger; + _workspaceWrapper = workspaceWrapper; + } + + public async Task> ReadAllBytesAsync(ResourceKey resource) + { + var resolveResult = ResolvePath(resource); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + } + var resourcePath = resolveResult.Value; + + try + { + var bytes = await File.ReadAllBytesAsync(resourcePath); + return Result.Ok(bytes); + } + catch (Exception ex) + { + return Result.Fail($"Failed to read file: '{resource}'") + .WithException(ex); + } + } + + public async Task> ReadAllTextAsync(ResourceKey resource) + { + var resolveResult = ResolvePath(resource); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + } + var resourcePath = resolveResult.Value; + + try + { + var text = await File.ReadAllTextAsync(resourcePath); + return Result.Ok(text); + } + catch (Exception ex) + { + return Result.Fail($"Failed to read file: '{resource}'") + .WithException(ex); + } + } + + public Task> OpenReadAsync(ResourceKey resource) + { + var resolveResult = ResolvePath(resource); + if (resolveResult.IsFailure) + { + var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + return Task.FromResult(failure); + } + var resourcePath = resolveResult.Value; + + try + { + var stream = new FileStream( + resourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + StreamBufferSize, + useAsync: true); + return Task.FromResult(Result.Ok(stream)); + } + catch (Exception ex) + { + var failure = Result.Fail($"Failed to open read stream for resource: '{resource}'") + .WithException(ex); + return Task.FromResult(failure); + } + } + + public Task WriteAllBytesAsync(ResourceKey resource, byte[] bytes) + { + return WriteWithRetryAsync(resource, bytes); + } + + public Task WriteAllTextAsync(ResourceKey resource, string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + return WriteWithRetryAsync(resource, bytes); + } + + public Task> OpenWriteAsync(ResourceKey resource) + { + var resolveResult = ResolvePath(resource); + if (resolveResult.IsFailure) + { + var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + return Task.FromResult(failure); + } + var resourcePath = resolveResult.Value; + + var ensureParentResult = EnsureParentFolderExists(resourcePath, resource); + if (ensureParentResult.IsFailure) + { + var failure = Result.Fail(ensureParentResult.FirstErrorMessage) + .WithErrors(ensureParentResult); + return Task.FromResult(failure); + } + + try + { + // FileShare.None (not FileShare.Read) is deliberate: while a write + // stream is open no other process can read partial bytes. The + // trade-off is that another reader hitting the file mid-write sees + // a sharing-violation IOException, not stale-or-partial content. + var stream = new FileStream( + resourcePath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + StreamBufferSize, + useAsync: true); + return Task.FromResult(Result.Ok(stream)); + } + catch (Exception ex) + { + var failure = Result.Fail($"Failed to open write stream for resource: '{resource}'") + .WithException(ex); + return Task.FromResult(failure); + } + } + + public Task> MoveAsync(ResourceKey source, ResourceKey destination) + { + throw new NotImplementedException("Structural operations land in Phase 1b (fs-1b)."); + } + + public Task> CopyAsync(ResourceKey source, ResourceKey destination) + { + throw new NotImplementedException("Structural operations land in Phase 1b (fs-1b)."); + } + + public Task> DeleteAsync(ResourceKey source) + { + throw new NotImplementedException("Structural operations land in Phase 1b (fs-1b)."); + } + + public Task> ExistsAsync(ResourceKey resource) + { + var resolveResult = ResolvePath(resource); + if (resolveResult.IsFailure) + { + var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + return Task.FromResult(failure); + } + var resourcePath = resolveResult.Value; + + var exists = File.Exists(resourcePath) || Directory.Exists(resourcePath); + return Task.FromResult(Result.Ok(exists)); + } + + private Result ResolvePath(ResourceKey resource) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + return resourceRegistry.ResolveResourcePath(resource); + } + + private async Task WriteWithRetryAsync(ResourceKey resource, byte[] bytes) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveResult = resourceRegistry.ResolveResourcePath(resource); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + } + var resourcePath = resolveResult.Value; + + var ensureParentResult = EnsureParentFolderExists(resourcePath, resource); + if (ensureParentResult.IsFailure) + { + return ensureParentResult; + } + + // Stage all in-flight temp files in /.celbridge/staging-fs/. + // Centralising them keeps user-visible folders clean of orphans after + // a crash, and the workspace wipes the folder on load to clear any + // stragglers from a prior session. The .celbridge folder is filtered + // by ResourceMonitor, so no spurious watcher events fire for the + // intermediate write. + var stagingFolder = Path.Combine( + resourceRegistry.ProjectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeStagingFsFolder); + try + { + Directory.CreateDirectory(stagingFolder); + } + catch (Exception ex) + { + return Result.Fail($"Failed to create staging folder: '{stagingFolder}'") + .WithException(ex); + } + + IOException? lastException = null; + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + await WriteAtomicAsync(resourcePath, stagingFolder, bytes); + if (attempt > 1) + { + // A retry should be unusual — the workspace owns the project + // folder and we use an atomic temp+rename. Surface success- + // after-retry as a warning so unusual disk contention (AV + // scans, sync clients, external locks) is visible in logs. + _logger.LogWarning($"Write succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); + } + return Result.Ok(); + } + catch (IOException ex) + { + lastException = ex; + if (attempt < MaxAttempts) + { + var delay = BaseRetryDelayMs * attempt; + _logger.LogWarning(ex, $"Write attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); + await Task.Delay(delay); + } + } + catch (Exception ex) + { + return Result.Fail($"Failed to write file: '{resourcePath}'") + .WithException(ex); + } + } + + return Result.Fail($"Failed to write file after {MaxAttempts} attempts: '{resourcePath}'") + .WithException(lastException!); + } + + private static Result EnsureParentFolderExists(string resourcePath, ResourceKey resource) + { + var parentFolder = Path.GetDirectoryName(resourcePath); + if (string.IsNullOrEmpty(parentFolder) + || Directory.Exists(parentFolder)) + { + return Result.Ok(); + } + + try + { + Directory.CreateDirectory(parentFolder); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to create parent folder for resource: '{resource}'") + .WithException(ex); + } + } + + // Writes bytes to a uniquely-named temp file inside the project's central + // staging folder, then atomically replaces the destination via File.Move. + // A unique filename per write prevents concurrent writers to the same + // destination from clobbering each other's intermediate state. + private static async Task WriteAtomicAsync(string resourcePath, string stagingFolder, byte[] bytes) + { + var tempPath = Path.Combine(stagingFolder, Guid.NewGuid().ToString("N") + ".tmp"); + + try + { + await File.WriteAllBytesAsync(tempPath, bytes); + File.Move(tempPath, resourcePath, overwrite: true); + } + catch + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best-effort cleanup. The original exception describes the real failure. + } + + throw; + } + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileWriter.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileWriter.cs deleted file mode 100644 index db2d0ba94..000000000 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileWriter.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.Text; -using Celbridge.Logging; -using Celbridge.Projects; -using Celbridge.Workspace; - -namespace Celbridge.Resources.Services; - -public sealed class ResourceFileWriter : IResourceFileWriter -{ - // Bounded retry for transient IO failures (file briefly locked by AV, - // backup software, sync clients, concurrent writers, etc.). Total - // worst-case wait across all attempts is BaseRetryDelayMs * (1 + 2 + ... - // + (MaxAttempts - 1)) = 150ms with the values below. - private const int MaxAttempts = 3; - private const int BaseRetryDelayMs = 50; - - private readonly ILogger _logger; - private readonly IWorkspaceWrapper _workspaceWrapper; - - // The resource registry is workspace-scoped and transient: a constructor- - // injected instance is a different object from the one held by ResourceService, - // and only the ResourceService instance has ProjectFolderPath set. The writer - // resolves the live registry through the workspace wrapper at call time. - public ResourceFileWriter( - ILogger logger, - IWorkspaceWrapper workspaceWrapper) - { - _logger = logger; - _workspaceWrapper = workspaceWrapper; - } - - public Task WriteAllBytesAsync(ResourceKey resource, byte[] bytes) - { - return WriteWithRetryAsync(resource, bytes); - } - - public Task WriteAllTextAsync(ResourceKey resource, string content) - { - var bytes = Encoding.UTF8.GetBytes(content); - return WriteWithRetryAsync(resource, bytes); - } - - private async Task WriteWithRetryAsync(ResourceKey resource, byte[] bytes) - { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveResult = resourceRegistry.ResolveResourcePath(resource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") - .WithErrors(resolveResult); - } - var resourcePath = resolveResult.Value; - - var parentFolder = Path.GetDirectoryName(resourcePath); - if (!string.IsNullOrEmpty(parentFolder) - && !Directory.Exists(parentFolder)) - { - try - { - Directory.CreateDirectory(parentFolder); - } - catch (Exception ex) - { - return Result.Fail($"Failed to create parent folder for resource: '{resource}'") - .WithException(ex); - } - } - - // Stage all in-flight temp files in /celbridge/.temp/. Centralising - // them keeps user-visible folders clean of orphans after a crash, and the - // workspace wipes the folder on load to clear any stragglers from a prior - // session. Both the celbridge folder and the .tmp extension are filtered - // by ResourceMonitor, so no spurious watcher events fire for the - // intermediate write. - var tempFolder = Path.Combine( - resourceRegistry.ProjectFolderPath, - ProjectConstants.MetaDataFolder, - ProjectConstants.TempFolder); - try - { - Directory.CreateDirectory(tempFolder); - } - catch (Exception ex) - { - return Result.Fail($"Failed to create temp folder: '{tempFolder}'") - .WithException(ex); - } - - IOException? lastException = null; - - for (var attempt = 1; attempt <= MaxAttempts; attempt++) - { - try - { - await WriteAtomicAsync(resourcePath, tempFolder, bytes); - if (attempt > 1) - { - // A retry should be unusual — the workspace owns the project - // folder and we use an atomic temp+rename. Surface success- - // after-retry as a warning so unusual disk contention (AV - // scans, sync clients, external locks) is visible in logs. - _logger.LogWarning($"Write succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); - } - return Result.Ok(); - } - catch (IOException ex) - { - lastException = ex; - if (attempt < MaxAttempts) - { - var delay = BaseRetryDelayMs * attempt; - _logger.LogWarning(ex, $"Write attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); - await Task.Delay(delay); - } - } - catch (Exception ex) - { - return Result.Fail($"Failed to write file: '{resourcePath}'") - .WithException(ex); - } - } - - return Result.Fail($"Failed to write file after {MaxAttempts} attempts: '{resourcePath}'") - .WithException(lastException!); - } - - // Writes bytes to a uniquely-named temp file inside the project's central - // temp folder, then atomically replaces the destination via File.Move. - // A unique filename per write prevents concurrent writers to the same - // destination from clobbering each other's intermediate state. - private static async Task WriteAtomicAsync(string resourcePath, string tempFolder, byte[] bytes) - { - var tempPath = Path.Combine(tempFolder, Guid.NewGuid().ToString("N") + ".tmp"); - - try - { - await File.WriteAllBytesAsync(tempPath, bytes); - File.Move(tempPath, resourcePath, overwrite: true); - } - catch - { - try - { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - } - catch - { - // Best-effort cleanup. The original exception describes the real failure. - } - - throw; - } - } -} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index dbeefcb74..14b0158bc 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -23,7 +23,6 @@ public class ResourceService : IResourceService, IDisposable public IResourceMonitor Monitor { get; } public IResourceTransferService TransferService { get; } public IResourceOperationService OperationService { get; } - public IResourceFileWriter FileWriter { get; } public ResourceService( ILogger logger, @@ -34,8 +33,7 @@ public ResourceService( IResourceRegistry resourceRegistry, IResourceMonitor resourceMonitor, IResourceTransferService resourceTransferService, - IResourceOperationService resourceOperationService, - IResourceFileWriter resourceFileWriter) + IResourceOperationService resourceOperationService) { // Only the workspace service is allowed to instantiate this service Guard.IsFalse(workspaceWrapper.IsWorkspacePageLoaded); @@ -49,19 +47,20 @@ public ResourceService( Monitor = resourceMonitor; TransferService = resourceTransferService; OperationService = resourceOperationService; - FileWriter = resourceFileWriter; // Set the project folder path on the registry. This also auto-registers the // ProjectRootHandler for the project: root via the setter. var projectFolderPath = _projectService.CurrentProject!.ProjectFolderPath; Registry.ProjectFolderPath = projectFolderPath; - // Build the new .celbridge/ hidden folder layout: temp/, logs/, trash/. - // These need to exist before downstream services start reading or watching them. + // Build the new .celbridge/ hidden folder layout: temp/, logs/, trash/, + // staging-fs/. These need to exist before downstream services start reading + // or watching them. var celbridgeFolder = Path.Combine(projectFolderPath, ProjectConstants.CelbridgeFolder); var celbridgeTempFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTempFolder); var celbridgeLogsFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeLogsFolder); var celbridgeTrashFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTrashFolder); + var celbridgeStagingFsFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeStagingFsFolder); Directory.CreateDirectory(celbridgeTempFolder); Directory.CreateDirectory(celbridgeLogsFolder); @@ -71,8 +70,14 @@ public ResourceService( TryClearFolderContents(celbridgeTrashFolder); Directory.CreateDirectory(celbridgeTrashFolder); + // staging-fs/ holds in-flight temp files for the resource file-system + // chokepoint. Wipe orphans from a prior session crash before downstream + // services start writing. + TryClearFolderContents(celbridgeStagingFsFolder); + Directory.CreateDirectory(celbridgeStagingFsFolder); + // Legacy /celbridge/.trash/ from before this redesign: discard. - // The other legacy /celbridge/.temp/, .cache/, .logs/ folders are + // The other legacy /celbridge/.cache/, .logs/ folders are // left alone (no live data; they retire alongside the entity service). var legacyTrashFolder = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TrashFolder); if (Directory.Exists(legacyTrashFolder)) @@ -87,15 +92,15 @@ public ResourceService( } } - // Clean up the legacy temp folder from previous sessions. - // ResourceFileWriter still stages in-flight atomic writes here; orphans are from a prior crash. - // (Moves to .celbridge/staging-fs/ when the FS-layer chokepoint lands.) - var tempFolderPath = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TempFolder); - if (Directory.Exists(tempFolderPath)) + // Clean up the legacy temp folder from previous sessions. The atomic-write + // staging area has moved to .celbridge/staging-fs/; any orphans here are + // from before the chokepoint landed. + var legacyTempFolder = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TempFolder); + if (Directory.Exists(legacyTempFolder)) { try { - Directory.Delete(tempFolderPath, true); + Directory.Delete(legacyTempFolder, true); } catch { diff --git a/Source/Workspace/Celbridge.Search/Services/SearchService.cs b/Source/Workspace/Celbridge.Search/Services/SearchService.cs index 415d851fe..f4186ced6 100644 --- a/Source/Workspace/Celbridge.Search/Services/SearchService.cs +++ b/Source/Workspace/Celbridge.Search/Services/SearchService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Celbridge.Logging; +using Celbridge.Resources; using Celbridge.Workspace; using Path = System.IO.Path; @@ -66,8 +67,7 @@ public async Task SearchAsync( var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var projectFolder = resourceRegistry.ProjectFolderPath; - if (string.IsNullOrEmpty(projectFolder) || - !Directory.Exists(projectFolder)) + if (string.IsNullOrEmpty(projectFolder)) { return new SearchResults(searchTerm, fileResults, 0, 0, false, false); } @@ -283,7 +283,9 @@ public async Task ReplaceInFileAsync( return new ReplaceResult(false, 0); } - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var resolveReplaceResult = resourceRegistry.ResolveResourcePath(resource); if (resolveReplaceResult.IsFailure) { @@ -293,13 +295,15 @@ public async Task ReplaceInFileAsync( try { - return await Task.Run(() => ReplaceInFile( + return await ReplaceInFileAsync( + resource, filePath, + fileSystem, searchText, replaceText, matchCase, wholeWord, - cancellationToken), cancellationToken); + cancellationToken); } catch (OperationCanceledException) { @@ -317,8 +321,10 @@ public async Task ReplaceInFileAsync( } } - private ReplaceResult ReplaceInFile( + private async Task ReplaceInFileAsync( + ResourceKey resource, string filePath, + IResourceFileSystem fileSystem, string searchText, string replaceText, bool matchCase, @@ -349,11 +355,8 @@ private ReplaceResult ReplaceInFile( return new ReplaceResult(true, 0); } - try - { - File.WriteAllText(filePath, newContent); - } - catch (IOException) + var writeResult = await fileSystem.WriteAllTextAsync(resource, newContent); + if (writeResult.IsFailure) { return new ReplaceResult(false, 0); } @@ -381,7 +384,9 @@ public async Task ReplaceMatchAsync( return new ReplaceMatchResult(false); } - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var resolveMatchResult = resourceRegistry.ResolveResourcePath(resource); if (resolveMatchResult.IsFailure) { @@ -391,15 +396,17 @@ public async Task ReplaceMatchAsync( try { - return await Task.Run(() => ReplaceMatch( + return await ReplaceMatchAsync( + resource, filePath, + fileSystem, searchText, replaceText, lineNumber, originalMatchStart, matchCase, wholeWord, - cancellationToken), cancellationToken); + cancellationToken); } catch (OperationCanceledException) { @@ -417,8 +424,10 @@ public async Task ReplaceMatchAsync( } } - private ReplaceMatchResult ReplaceMatch( + private async Task ReplaceMatchAsync( + ResourceKey resource, string filePath, + IResourceFileSystem fileSystem, string searchText, string replaceText, int lineNumber, @@ -453,11 +462,8 @@ private ReplaceMatchResult ReplaceMatch( return new ReplaceMatchResult(false); } - try - { - File.WriteAllText(filePath, newContent); - } - catch (IOException) + var writeResult = await fileSystem.WriteAllTextAsync(resource, newContent); + if (writeResult.IsFailure) { return new ReplaceMatchResult(false); } diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index de7c9b7a4..6a425ccc4 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -23,6 +23,7 @@ public class WorkspaceService : IWorkspaceService, IDisposable public IWorkspaceSettings WorkspaceSettings => WorkspaceSettingsService.WorkspaceSettings!; public IPackageService PackageService { get; } public IResourceService ResourceService { get; } + public IResourceFileSystem ResourceFileSystem { get; } public IExplorerService ExplorerService { get; } public IDocumentsService DocumentsService { get; } public IInspectorService InspectorService { get; } @@ -57,6 +58,7 @@ public WorkspaceService( WorkspaceSettingsService = serviceProvider.GetRequiredService(); PackageService = serviceProvider.GetRequiredService(); ResourceService = serviceProvider.GetRequiredService(); + ResourceFileSystem = serviceProvider.GetRequiredService(); ExplorerService = serviceProvider.GetRequiredService(); DocumentsService = serviceProvider.GetRequiredService(); InspectorService = serviceProvider.GetRequiredService(); From bcdfe36007942ea267fb5853f65fc477aa7af231 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 20 May 2026 14:08:41 +0100 Subject: [PATCH 06/48] Add IPythonInstaller and move file-hash utilities Introduce IPythonInstaller (Foundation) and move FileHashHelper to Celbridge.Utilities (core) while removing the old Workspace copy. Convert PythonInstaller from a static helper to an injectable class implementing IPythonInstaller, add logging, and register it in DI. Update PythonService to depend on IPythonInstaller, use the shared FileHashHelper, and add a stable install-state hash (ComputeInstallStateHash) to the offline/online fingerprinting logic with additional debug logging and disk-state snapshots. --- .../Python/IPythonInstaller.cs | 16 ++ .../Celbridge.Utilities/FileHashHelper.cs | 120 +++++++++++++++ .../Celbridge.Python/ServiceConfiguration.cs | 1 + .../Services/FileHashHelper.cs | 77 ---------- .../Services/PythonInstaller.cs | 29 +++- .../Services/PythonService.cs | 145 ++++++++++++++++-- 6 files changed, 293 insertions(+), 95 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Python/IPythonInstaller.cs create mode 100644 Source/Core/Celbridge.Utilities/FileHashHelper.cs delete mode 100644 Source/Workspace/Celbridge.Python/Services/FileHashHelper.cs diff --git a/Source/Core/Celbridge.Foundation/Python/IPythonInstaller.cs b/Source/Core/Celbridge.Foundation/Python/IPythonInstaller.cs new file mode 100644 index 000000000..7d39e6ddc --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Python/IPythonInstaller.cs @@ -0,0 +1,16 @@ +namespace Celbridge.Python; + +/// +/// Installs and refreshes the bundled Python support files (uv binary, wheels, +/// installer version marker) into the app's local data folder. +/// +public interface IPythonInstaller +{ + /// + /// Ensures the Python support files are installed for the given app version, + /// performing a full reinstall if the on-disk version marker is missing or + /// differs from the bundled assets. Returns the absolute path to the Python + /// folder on success. + /// + Task> InstallPythonAsync(string appVersion); +} diff --git a/Source/Core/Celbridge.Utilities/FileHashHelper.cs b/Source/Core/Celbridge.Utilities/FileHashHelper.cs new file mode 100644 index 000000000..4c09591a5 --- /dev/null +++ b/Source/Core/Celbridge.Utilities/FileHashHelper.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using System.Text; +using Path = System.IO.Path; + +namespace Celbridge.Utilities; + +/// +/// Utility methods for computing SHA256 hashes of files, strings, and folder +/// structures. +/// +public static class FileHashHelper +{ + /// + /// Computes a SHA256 hash of a file's contents. Returns empty string + /// if the file doesn't exist or can't be read. + /// + public static string HashFileContents(string filePath) + { + try + { + if (File.Exists(filePath)) + { + var fileBytes = File.ReadAllBytes(filePath); + return HashBytes(fileBytes); + } + } + catch + { + // Non-critical: callers handle empty hash gracefully. + } + + return string.Empty; + } + + /// + /// Computes a SHA256 hash of a UTF-8 string. + /// + public static string HashString(string content) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + return Convert.ToHexString(bytes); + } + + /// + /// Computes a SHA256 hash of a byte array. + /// + public static string HashBytes(byte[] bytes) + { + var hashBytes = SHA256.HashData(bytes); + return Convert.ToHexString(hashBytes); + } + + /// + /// Computes a fingerprint of a folder's structure by walking the tree up to + /// the specified depth and recording each entry's relative path and file size. + /// Detects files being added, removed, renamed, or replaced in place without + /// reading file contents. The depth cap keeps the scan bounded on deep trees + /// like Python's Lib/site-packages while still surfacing the changes that + /// matter for install-state validation. + /// + public static string HashFolderStructure(string folderPath, int maxDepth = 3) + { + if (!Directory.Exists(folderPath)) + { + return string.Empty; + } + + var entries = new List(); + var stack = new Stack<(string Path, int Depth)>(); + stack.Push((folderPath, 0)); + + while (stack.Count > 0) + { + var (currentPath, depth) = stack.Pop(); + if (depth >= maxDepth) + { + continue; + } + + string[] children; + try + { + children = Directory.GetFileSystemEntries(currentPath); + } + catch + { + // Best effort: a child we cannot enumerate is treated as + // contributing nothing to the hash. Same as it being absent. + continue; + } + + foreach (var child in children) + { + var relativePath = Path.GetRelativePath(folderPath, child); + if (Directory.Exists(child)) + { + entries.Add($"D|{relativePath}"); + stack.Push((child, depth + 1)); + } + else + { + long size = 0; + try + { + size = new FileInfo(child).Length; + } + catch + { + // Treat unreadable file metadata as size 0; the entry's + // presence still contributes to the hash. + } + entries.Add($"F|{relativePath}|{size}"); + } + } + } + + entries.Sort(StringComparer.Ordinal); + return HashString(string.Join("\n", entries)); + } +} diff --git a/Source/Workspace/Celbridge.Python/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Python/ServiceConfiguration.cs index 88c30a4e8..9d6e90d65 100644 --- a/Source/Workspace/Celbridge.Python/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Python/ServiceConfiguration.cs @@ -7,6 +7,7 @@ public static class ServiceConfiguration public static void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); } } diff --git a/Source/Workspace/Celbridge.Python/Services/FileHashHelper.cs b/Source/Workspace/Celbridge.Python/Services/FileHashHelper.cs deleted file mode 100644 index 9203270f4..000000000 --- a/Source/Workspace/Celbridge.Python/Services/FileHashHelper.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Celbridge.Python.Services; - -/// -/// Utility methods for computing SHA256 hashes of files and strings. -/// Used by PythonInstaller and PythonService to detect when Python -/// assets have changed and need to be reinstalled. -/// -public static class FileHashHelper -{ - /// - /// Computes a SHA256 hash of a file's contents. Returns empty string - /// if the file doesn't exist or can't be read. - /// - public static string HashFileContents(string filePath) - { - try - { - if (File.Exists(filePath)) - { - var fileBytes = File.ReadAllBytes(filePath); - return HashBytes(fileBytes); - } - } - catch - { - // Non-critical: callers handle empty hash gracefully. - } - - return string.Empty; - } - - /// - /// Computes a SHA256 hash of a UTF-8 string. - /// - public static string HashString(string content) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content)); - return Convert.ToHexString(bytes); - } - - /// - /// Computes a SHA256 hash of a byte array. - /// - public static string HashBytes(byte[] bytes) - { - var hashBytes = SHA256.HashData(bytes); - return Convert.ToHexString(hashBytes); - } - - /// - /// Computes a fingerprint of a folder by hashing the relative path, size, - /// and last-write timestamp of every file. Detects any file being added, - /// deleted, renamed, or modified without reading file contents. - /// - public static string HashFolderStructure(string folderPath) - { - if (!Directory.Exists(folderPath)) - { - return string.Empty; - } - - var entries = Directory.GetFiles(folderPath, "*", SearchOption.AllDirectories) - .Select(filePath => - { - var relativePath = Path.GetRelativePath(folderPath, filePath); - var fileInfo = new FileInfo(filePath); - return $"{relativePath}|{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}"; - }) - .OrderBy(entry => entry, StringComparer.Ordinal); - - var combined = string.Join("\n", entries); - return HashString(combined); - } -} diff --git a/Source/Workspace/Celbridge.Python/Services/PythonInstaller.cs b/Source/Workspace/Celbridge.Python/Services/PythonInstaller.cs index b3d44b293..ed7afad7a 100644 --- a/Source/Workspace/Celbridge.Python/Services/PythonInstaller.cs +++ b/Source/Workspace/Celbridge.Python/Services/PythonInstaller.cs @@ -1,10 +1,11 @@ using System.IO.Compression; using System.Runtime.Versioning; +using Celbridge.Logging; +using Celbridge.Utilities; namespace Celbridge.Python.Services; -[SupportedOSPlatform("windows10.0.10240.0")] -public static class PythonInstaller +public class PythonInstaller : IPythonInstaller { private const string PythonFolderName = "Python"; private const string PythonAssetsFolder = "Assets\\Python"; @@ -13,10 +14,15 @@ public static class PythonInstaller private const string WheelFilePattern = "celbridge-*.whl"; private const string UVTempFileName = "uv.zip"; - /// - /// Installs Python support files if needed. - /// - public static async Task> InstallPythonAsync(string appVersion) + private readonly ILogger _logger; + + public PythonInstaller(ILogger logger) + { + _logger = logger; + } + + [SupportedOSPlatform("windows10.0.10240.0")] + public async Task> InstallPythonAsync(string appVersion) { try { @@ -27,7 +33,9 @@ public static async Task> InstallPythonAsync(string appVersion) if (needsReinstall) { + _logger.LogInformation("Running full Python reinstall at {Path}", pythonFolderPath); await ReinstallAsync(localFolder, pythonFolderPath, appVersion); + _logger.LogInformation("Python reinstall completed"); } return Result.Ok(pythonFolderPath); @@ -39,11 +47,12 @@ public static async Task> InstallPythonAsync(string appVersion) } } - private static bool IsInstallRequired(string pythonFolderPath, string currentVersion) + private bool IsInstallRequired(string pythonFolderPath, string currentVersion) { // If the python folder doesn't exist, we need to install if (!Directory.Exists(pythonFolderPath)) { + _logger.LogDebug("Python reinstall required: pythonFolder does not exist at {Path}", pythonFolderPath); return true; } @@ -52,6 +61,7 @@ private static bool IsInstallRequired(string pythonFolderPath, string currentVer // If version file doesn't exist, we need to install if (!File.Exists(installedVersionPath)) { + _logger.LogDebug("Python reinstall required: installed_version.txt missing at {Path}", installedVersionPath); return true; } @@ -63,6 +73,10 @@ private static bool IsInstallRequired(string pythonFolderPath, string currentVer if (!string.Equals(expectedVersionContent, installedVersionContent, StringComparison.Ordinal)) { + _logger.LogDebug( + "Python reinstall required: installed_version.txt mismatch. Installed='{Installed}' Expected='{Expected}'", + installedVersionContent.Replace("\n", "\\n"), + expectedVersionContent.Replace("\n", "\\n")); return true; } @@ -97,6 +111,7 @@ private static string GetVersionContent(string appVersion) return $"{appVersion}\n{wheelHash}"; } + [SupportedOSPlatform("windows10.0.10240.0")] private static async Task ReinstallAsync(StorageFolder localFolder, string pythonFolderPath, string currentVersion) { // Delete existing folder if it exists (handles upgrade scenario). diff --git a/Source/Workspace/Celbridge.Python/Services/PythonService.cs b/Source/Workspace/Celbridge.Python/Services/PythonService.cs index 021b1ee8c..b0cc98f1b 100644 --- a/Source/Workspace/Celbridge.Python/Services/PythonService.cs +++ b/Source/Workspace/Celbridge.Python/Services/PythonService.cs @@ -4,12 +4,13 @@ using System.Runtime.Versioning; using System.Text; using Celbridge.ApplicationEnvironment; -using Celbridge.Server; using Celbridge.Console; +using Celbridge.Logging; using Celbridge.Messaging; using Celbridge.Projects; -using Celbridge.Logging; +using Celbridge.Server; using Celbridge.Settings; +using Celbridge.Utilities; using Celbridge.Workspace; namespace Celbridge.Python.Services; @@ -35,6 +36,7 @@ public class PythonService : IPythonService, IDisposable private readonly IServerService _serverService; private readonly IMessengerService _messengerService; private readonly IFeatureFlags _featureFlags; + private readonly IPythonInstaller _pythonInstaller; private readonly ILogger _logger; private readonly ITcpTransport _tcpTransport; private CancellationTokenSource? _rpcCancellationTokenSource; @@ -50,6 +52,7 @@ public PythonService( IServerService serverService, IMessengerService messengerService, IFeatureFlags featureFlags, + IPythonInstaller pythonInstaller, ILogger logger, ITcpTransport tcpTransport) { @@ -59,6 +62,7 @@ public PythonService( _serverService = serverService; _messengerService = messengerService; _featureFlags = featureFlags; + _pythonInstaller = pythonInstaller; _logger = logger; _tcpTransport = tcpTransport; } @@ -119,7 +123,7 @@ public async Task InitializePython() DeleteInstalledVersionMarker(pythonFolderForCleanup); } - var installResult = await PythonInstaller.InstallPythonAsync(appVersion); + var installResult = await _pythonInstaller.InstallPythonAsync(appVersion); if (installResult.IsFailure) { var errorMessage = new ConsoleErrorMessage( @@ -213,12 +217,28 @@ public async Task InitializePython() } // Determine if we can use offline mode (no network required). - // The fingerprint includes the config and a hash of the wheel file contents. - // A change to any of these forces online mode to re-download everything. + // The fingerprint includes the config, a hash of the wheel file contents, + // and a structural hash of the stable parts of the AppData Python folder + // (uv binary + installed Python interpreter set). Volatile folders that + // uv writes to during normal operation (uv_cache, uv_tools, uv_bin, and + // per-interpreter __pycache__) are deliberately excluded so the hash is + // stable across sessions. var wheelHash = FileHashHelper.HashFileContents(celbridgeWheelPath); - var currentFingerprint = ComputeConfigFingerprint(appVersion, pythonVersion!, celbridgeWheelPath, wheelHash, pythonPackages); + var installStateHash = ComputeInstallStateHash(pythonFolder); + var currentFingerprint = ComputeConfigFingerprint(appVersion, pythonVersion!, celbridgeWheelPath, wheelHash, pythonPackages, installStateHash); var useOfflineMode = currentFingerprint == savedFingerprint; + // Fingerprint values are logged at debug level so diagnosis of + // unexpected offline/online transitions does not require new code, + // while normal operation does not spam the info log. + _logger.LogDebug( + "Python fingerprint: wheelHash='{WheelHash}' installStateHash='{InstallStateHash}' current='{Current}' saved='{Saved}' useOfflineMode={Offline}", + wheelHash, + installStateHash, + currentFingerprint, + savedFingerprint ?? "", + useOfflineMode); + if (useOfflineMode) { _logger.LogInformation("Python config unchanged since last run, using offline mode"); @@ -310,6 +330,38 @@ await InstallCelbridgeToolAsync( } }; + // Snapshot of the uv-managed Python install dir and the full uv command + // at launch time. Logged at debug level so a "Python REPL won't start" + // report can be correlated with what was actually on disk and what was + // passed to uv, without spamming the info log on every load. + try + { + if (Directory.Exists(uvPythonInstallDir)) + { + var installEntries = Directory.GetDirectories(uvPythonInstallDir) + .Select(d => Path.GetFileName(d)) + .ToList(); + _logger.LogDebug( + "uv_python_installs ('{Path}') contains [{Entries}]", + uvPythonInstallDir, + string.Join(", ", installEntries)); + } + else + { + _logger.LogDebug("uv_python_installs ('{Path}') does not exist at launch", uvPythonInstallDir); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate uv_python_installs at '{Path}'", uvPythonInstallDir); + } + + _logger.LogDebug( + "Launching uv: UV_PYTHON_INSTALL_DIR='{InstallDir}' offlineMode={Offline} uvCommand={UvCommand}", + uvPythonInstallDir, + useOfflineMode, + uvCommand); + // Start the terminal process with per-process environment variables terminal.Start(commandLine, workingDir, terminalEnvironment); _logger.LogInformation("Python terminal started successfully"); @@ -442,17 +494,86 @@ private static int GetAvailableTcpPort() } /// - /// Computes a fingerprint of the current Python configuration. - /// Includes a hash of the wheel file contents so that any change to the app - /// version, wheel content, or dependencies causes a fingerprint mismatch - /// that forces online mode. + /// Computes a hash of the stable parts of the AppData Python folder. Mismatch + /// against a previously-saved value indicates the install has drifted and + /// offline mode is unsafe. + /// + private static string ComputeInstallStateHash(string pythonFolder) + { + var sb = new StringBuilder(); + + // uv binary file size catches an app update that bundles a new uv. + var uvExeName = OperatingSystem.IsWindows() ? UVExecutableNameWindows : UVExecutableName; + var uvExePath = Path.Combine(pythonFolder, uvExeName); + if (File.Exists(uvExePath)) + { + try + { + sb.AppendLine($"uv|{new FileInfo(uvExePath).Length}"); + } + catch + { + sb.AppendLine("uv|?"); + } + } + else + { + sb.AppendLine("uv|missing"); + } + + // Depth 1 over uv_python_installs/ enumerates the interpreter folder names + // (e.g. cpython-3.13.6-windows-x86_64-none) without descending into Lib/ + // where __pycache__ writes would destabilise the hash. + var installsHash = FileHashHelper.HashFolderStructure( + Path.Combine(pythonFolder, UVPythonInstallsFolderName), + maxDepth: 1); + sb.AppendLine($"installs|{installsHash}"); + + // Wheel cache lives under uv_cache/wheels-v/, where the version suffix + // bumps with uv releases — hash each wheels-v* separately so a suffix + // change is also captured. Depth 3 reaches package-name granularity + // (wheels-v5/index///) without descending into per-version + // wheels, keeping the hash stable when uv touches deeper cache metadata. + // Other uv_cache subfolders (environments-v*, sdists-*, etc.) and the + // regenerated uv_tools / uv_bin are deliberately excluded as volatile. + var uvCacheDir = Path.Combine(pythonFolder, UVCacheFolderName); + if (Directory.Exists(uvCacheDir)) + { + string[] wheelsFolders; + try + { + wheelsFolders = Directory.GetDirectories(uvCacheDir, "wheels-v*"); + Array.Sort(wheelsFolders, StringComparer.Ordinal); + } + catch + { + wheelsFolders = Array.Empty(); + } + + foreach (var wheelsFolder in wheelsFolders) + { + var folderName = Path.GetFileName(wheelsFolder); + var wheelsHash = FileHashHelper.HashFolderStructure(wheelsFolder, maxDepth: 3); + sb.AppendLine($"wheels|{folderName}|{wheelsHash}"); + } + } + + return FileHashHelper.HashString(sb.ToString()); + } + + /// + /// Computes a fingerprint of the current Python configuration combined with a + /// stable-state hash of the AppData Python folder. Any change to the app version, + /// wheel content, dependencies, or the installed interpreter set causes a + /// fingerprint mismatch that forces online mode. /// private static string ComputeConfigFingerprint( string appVersion, string pythonVersion, string celbridgeWheelPath, string wheelHash, - IReadOnlyList? dependencies) + IReadOnlyList? dependencies, + string installStateHash) { var sb = new StringBuilder(); sb.AppendLine(appVersion); @@ -468,6 +589,8 @@ private static string ComputeConfigFingerprint( } } + sb.AppendLine(installStateHash); + return FileHashHelper.HashString(sb.ToString()); } From acb4db9cccf49b886c0b6b249bd7937b2cd343bd Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 20 May 2026 14:57:43 +0100 Subject: [PATCH 07/48] Move utility helpers into Helpers folder Reorganize Celbridge.Utilities by relocating helper classes into a centralized Helpers directory. Files moved (no code changes): FileHashHelper.cs, GlobHelper.cs, LineEndingHelper.cs (from root) and PathHelper.cs (from Services) to Source/Core/Celbridge.Utilities/Helpers to improve project structure and discoverability. --- Source/Core/Celbridge.Utilities/{ => Helpers}/FileHashHelper.cs | 0 Source/Core/Celbridge.Utilities/{ => Helpers}/GlobHelper.cs | 0 Source/Core/Celbridge.Utilities/{ => Helpers}/LineEndingHelper.cs | 0 .../Core/Celbridge.Utilities/{Services => Helpers}/PathHelper.cs | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename Source/Core/Celbridge.Utilities/{ => Helpers}/FileHashHelper.cs (100%) rename Source/Core/Celbridge.Utilities/{ => Helpers}/GlobHelper.cs (100%) rename Source/Core/Celbridge.Utilities/{ => Helpers}/LineEndingHelper.cs (100%) rename Source/Core/Celbridge.Utilities/{Services => Helpers}/PathHelper.cs (100%) diff --git a/Source/Core/Celbridge.Utilities/FileHashHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs similarity index 100% rename from Source/Core/Celbridge.Utilities/FileHashHelper.cs rename to Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs diff --git a/Source/Core/Celbridge.Utilities/GlobHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/GlobHelper.cs similarity index 100% rename from Source/Core/Celbridge.Utilities/GlobHelper.cs rename to Source/Core/Celbridge.Utilities/Helpers/GlobHelper.cs diff --git a/Source/Core/Celbridge.Utilities/LineEndingHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/LineEndingHelper.cs similarity index 100% rename from Source/Core/Celbridge.Utilities/LineEndingHelper.cs rename to Source/Core/Celbridge.Utilities/Helpers/LineEndingHelper.cs diff --git a/Source/Core/Celbridge.Utilities/Services/PathHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/PathHelper.cs similarity index 100% rename from Source/Core/Celbridge.Utilities/Services/PathHelper.cs rename to Source/Core/Celbridge.Utilities/Helpers/PathHelper.cs From 928cbd650931b6b1b2a68f5cbf3b765dc77852b0 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 20 May 2026 19:07:22 +0100 Subject: [PATCH 08/48] Add sidecar parsing, metadata service, tests Introduce sidecar/frontmatter support and a workspace metadata index. Adds SidecarHelper (TOML frontmatter parsing/composing using Tomlyn), SidecarInfo/SidecarStatus and SidecarReport types, and IResourceMetaData interface plus a ResourceMetaData implementation that builds a reference graph and manages frontmatter operations. Wire the service into DI and expose it via IWorkspaceService; update FileResource to track its paired sidecar. Extend IResourceRegistry with GetSidecarParent and GetSidecarReport. Add many unit tests for parsing, classification, tracking and metadata scanning, and update tests to supply an ILogger to ResourceRegistry. Also add Tomlyn package reference. --- CLAUDE.md | 2 +- .../Resources/IFileResource.cs | 23 + .../Resources/IResourceMetaData.cs | 114 ++++ .../Resources/IResourceRegistry.cs | 30 + .../Workspace/IWorkspaceService.cs | 6 + .../Tests/Resources/ResourceCommandTests.cs | 2 +- .../Tests/Resources/ResourceMetaDataTests.cs | 208 ++++++ .../Tests/Resources/ResourceRegistryTests.cs | 28 +- .../Resources/SidecarClassificationTests.cs | 154 +++++ Source/Tests/Resources/SidecarHelperTests.cs | 124 ++++ .../Tests/Resources/SidecarTrackingTests.cs | 174 ++++++ .../Celbridge.Resources.csproj | 1 + .../Helpers/SidecarHelper.cs | 319 ++++++++++ .../Models/FileResource.cs | 4 +- .../ServiceConfiguration.cs | 1 + .../Services/ResourceMetaData.cs | 591 ++++++++++++++++++ .../Services/ResourceRegistry.cs | 199 ++++++ .../Services/WorkspaceLoader.cs | 11 + .../Services/WorkspaceService.cs | 3 + 19 files changed, 1977 insertions(+), 17 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/IResourceMetaData.cs create mode 100644 Source/Tests/Resources/ResourceMetaDataTests.cs create mode 100644 Source/Tests/Resources/SidecarClassificationTests.cs create mode 100644 Source/Tests/Resources/SidecarHelperTests.cs create mode 100644 Source/Tests/Resources/SidecarTrackingTests.cs create mode 100644 Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs diff --git a/CLAUDE.md b/CLAUDE.md index e4e30166c..84960a45a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ python run_tests.py ## Architecture - Workspace-scoped services are transient and must NOT be injected via constructor DI. Access them through `_workspaceWrapper.WorkspaceService`: - - IWorkspaceSettingsService, IWorkspaceSettings, IResourceRegistry, IResourceTransferService, IResourceOperationService, IPythonService, IConsoleService, IDocumentsService, IExplorerService, IInspectorService, IDataTransferService, IEntityService, IGenerativeAIService, IActivityService + - IWorkspaceSettingsService, IWorkspaceSettings, IResourceRegistry, IResourceFileSystem, IResourceTransferService, IResourceOperationService, IPythonService, IConsoleService, IDocumentsService, IExplorerService, IInspectorService, IDataTransferService, IEntityService, IGenerativeAIService, IActivityService - Project configuration: use `IProjectService.CurrentProject` (singleton) to access the current project, and `project.Config` for its config. To parse `.celbridge` files outside of project loading, use `ProjectConfigParser.ParseFromFile()` - The Foundation project (`Core\Celbridge.Foundation`) should only contain abstractions (interfaces, abstract classes), never concrete implementations - Never bypass `ICommandService` to call methods directly. Every important operation goes through the command service for automation and auditing support. If a command-based flow has a bug, fix it within the command service pattern (e.g., add new command options or fix the command handling logic) diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs b/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs index 39d5a6220..5495e3343 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs @@ -2,6 +2,23 @@ namespace Celbridge.Resources; +/// +/// The state of a paired .cel sidecar's content. Healthy means the frontmatter +/// parses cleanly; Broken means it does not (malformed TOML, merge-conflict +/// markers, missing fences, or any other parse failure). Absence of a sidecar +/// is expressed by a null SidecarInfo on the parent resource. +/// +public enum SidecarStatus +{ + Healthy, + Broken, +} + +/// +/// Identifies a paired sidecar and its current parse state. +/// +public partial record SidecarInfo(ResourceKey Key, SidecarStatus Status); + /// /// A file resource in the project folder. /// @@ -11,4 +28,10 @@ public interface IFileResource : IResource /// The icon to display for the file resource. /// public FileIconDefinition Icon { get; } + + /// + /// The paired sidecar for this file, or null if no sidecar exists. + /// Null also applies to .cel files (which do not have sidecars of their own). + /// + public SidecarInfo? Sidecar { get; } } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceMetaData.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceMetaData.cs new file mode 100644 index 000000000..0755f4d7a --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceMetaData.cs @@ -0,0 +1,114 @@ +namespace Celbridge.Resources; + +/// +/// Summary statistics emitted by IResourceMetaData.RebuildAsync. +/// +public record MetaDataScanReport( + int FilesScanned, + int FilesSkipped, + int ReferencesFound, + int FrontmatterEntries, + TimeSpan Elapsed); + +/// +/// Workspace-scoped service that maintains the reference graph between project +/// resources and the frontmatter index for .cel sidecars. Indexes are rebuilt +/// from disk at workspace load and updated incrementally from ResourceMonitor +/// watcher events. Persistence is a load-time optimisation; the service is +/// correct without it. +/// +public interface IResourceMetaData +{ + /// + /// True once the initial rebuild has completed and the in-memory indexes + /// reflect the on-disk state. Operations that depend on the indexes should + /// await ReadyAsync before reading. + /// + bool IsReady { get; } + + /// + /// Completes once IsReady becomes true. Returns immediately if already ready. + /// + Task WaitUntilReadyAsync(); + + /// + /// Drains the queue of pending watcher-driven rescans before returning, so + /// that subsequent reads reflect every change observed up to the call. + /// Used by structural operations and integration tests that need synchronous + /// post-write consistency. + /// + Task WaitForPendingUpdatesAsync(); + + /// + /// Rebuilds the in-memory indexes from disk. Replaces existing state on + /// completion. Intended for use at workspace load and from diagnostic tools. + /// + Task> RebuildAsync(); + + /// + /// Returns the resource keys of every file that contains a project:target + /// reference, as detected by the permissive scan. + /// + IReadOnlyList GetReferencers(ResourceKey target); + + /// + /// Returns the resource keys named by every project: reference found inside + /// the source file. + /// + IReadOnlyList GetReferences(ResourceKey source); + + /// + /// Returns the union of every key that appears as a target in the graph. + /// Used by metadata_check_project to enumerate candidate targets without + /// walking every file's reference list. + /// + IReadOnlyList GetAllReferencedTargets(); + + /// + /// Returns the parsed top-level fields of the resource's sidecar frontmatter + /// as an immutable dictionary. Fails if the resource has no sidecar or the + /// sidecar is in a non-Healthy state. + /// + Result> GetFrontmatter(ResourceKey resource); + + /// + /// Writes a single field to the resource's sidecar frontmatter. Creates the + /// sidecar if one does not exist. + /// + Task SetFrontmatterFieldAsync(ResourceKey resource, string field, object value); + + /// + /// Removes a field from the resource's sidecar frontmatter. The sidecar file + /// remains even if the resulting frontmatter is empty. + /// + Task RemoveFrontmatterFieldAsync(ResourceKey resource, string field); + + /// + /// Returns every resource whose frontmatter contains the field matching the + /// value. Scalar fields match by equality; list-of-scalar fields match by + /// contains. + /// + IReadOnlyList FindByMetaData(string field, object value); + + /// + /// Returns the tag list for the resource. Empty if the resource has no + /// sidecar or no tags entry. + /// + IReadOnlyList GetTags(ResourceKey resource); + + /// + /// Appends a tag to the resource's sidecar frontmatter. Creates the sidecar + /// if one does not exist. Idempotent. + /// + Task AddTagAsync(ResourceKey resource, string tag); + + /// + /// Removes a tag from the resource's sidecar frontmatter. Idempotent. + /// + Task RemoveTagAsync(ResourceKey resource, string tag); + + /// + /// Returns every resource whose tags list contains the specified tag. + /// + IReadOnlyList FindByTag(string tag); +} diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs index 1e64fb1a0..5a5606c46 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs @@ -1,5 +1,20 @@ namespace Celbridge.Resources; +/// +/// Snapshot of every .cel-shaped file the registry knows about, partitioned by +/// parse state and orphan-ness. Used for project-load diagnostics and by +/// metadata_check_project to surface attention states. +/// +/// Parse state (Healthy / Broken) and orphan-ness are orthogonal dimensions: +/// an orphan sidecar with malformed content appears in both Broken and Orphan. +/// Files whose names end in .cel.cel are classified as Broken and never as a +/// regular sidecar. +/// +public record SidecarReport( + IReadOnlyList Healthy, + IReadOnlyList Broken, + IReadOnlyList Orphan); + /// /// A data structure representing the resources in the project folder. /// @@ -115,4 +130,19 @@ public interface IResourceRegistry /// Returns an empty list for roots without indexed tree state. /// List<(ResourceKey Resource, string Path)> GetAllFileResources(string root); + + /// + /// Returns the parent file resource of a sidecar key, or a failure result + /// if the sidecar has no corresponding parent. Sidecars at "foo.png.cel" + /// resolve to "foo.png"; sidecars whose name ends in ".cel.cel" are invalid + /// and never have a parent. + /// + Result GetSidecarParent(ResourceKey sidecar); + + /// + /// Returns a snapshot of every sidecar the registry knows about, partitioned + /// by parse state, orphan-ness, and the .cel.cel invalid category. Used for + /// project-load diagnostics and by metadata_check_project. + /// + SidecarReport GetSidecarReport(); } diff --git a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs index 47d4fa398..421cb7a98 100644 --- a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs +++ b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs @@ -51,6 +51,12 @@ void SetPanels( /// IResourceFileSystem ResourceFileSystem { get; } + /// + /// Returns the metadata service that maintains the reference graph and + /// frontmatter index for project resources. + /// + IResourceMetaData ResourceMetaData { get; } + /// /// Returns the Explorer Service associated with the workspace. /// diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index a13c2a360..30a169a64 100644 --- a/Source/Tests/Resources/ResourceCommandTests.cs +++ b/Source/Tests/Resources/ResourceCommandTests.cs @@ -46,7 +46,7 @@ public void Setup() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - _resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); _resourceRegistry.ProjectFolderPath = _projectFolderPath; _resourceRegistry.UpdateResourceRegistry(); diff --git a/Source/Tests/Resources/ResourceMetaDataTests.cs b/Source/Tests/Resources/ResourceMetaDataTests.cs new file mode 100644 index 000000000..ab53fcb8e --- /dev/null +++ b/Source/Tests/Resources/ResourceMetaDataTests.cs @@ -0,0 +1,208 @@ +using Celbridge.Explorer.Services; +using Celbridge.Messaging; +using Celbridge.Messaging.Services; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; +using Celbridge.Utilities; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for the reference-graph subsystem of IResourceMetaData. The +/// frontmatter-index methods are not yet implemented and throw +/// NotImplementedException; those scenarios are covered separately. +/// +[TestFixture] +public class ResourceMetaDataTests +{ + private string _projectFolderPath = null!; + private ResourceRegistry _resourceRegistry = null!; + private ResourceMetaData _metaData = null!; + private IMessengerService _messengerService = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(ResourceMetaDataTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + _resourceRegistry = new ResourceRegistry( + Substitute.For>(), + _messengerService, + fileIconService); + _resourceRegistry.ProjectFolderPath = _projectFolderPath; + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.IsWorkspacePageLoaded.Returns(true); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _metaData = new ResourceMetaData( + Substitute.For>(), + _messengerService, + _workspaceWrapper, + new TextBinarySniffer()); + } + + [TearDown] + public void TearDown() + { + _metaData.Dispose(); + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public void ScanTextForReferences_FindsAllValidProjectReferences() + { + var text = "Some text with \"project:foo/bar.md\" and project:other/file.txt embedded."; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().Contain(new ResourceKey("project:foo/bar.md")); + references.Should().Contain(new ResourceKey("project:other/file.txt")); + } + + [Test] + public void ScanTextForReferences_SkipsInvalidCandidates() + { + // project: followed by an invalid character sequence (double slashes) should + // not produce a reference. + var text = "garbage project://invalid more garbage"; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().BeEmpty(); + } + + [Test] + public void ScanTextForReferences_StopsAtKeyTerminators() + { + // The closing quote should terminate the candidate; bar.md is the full key. + var text = "see \"project:foo/bar.md\" for details"; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().HaveCount(1); + references.Should().Contain(new ResourceKey("project:foo/bar.md")); + } + + [Test] + public async Task RebuildAsync_ProducesReferenceGraph() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "This file references \"project:target.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target file."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var rebuildResult = await _metaData.RebuildAsync(); + + rebuildResult.IsSuccess.Should().BeTrue(); + rebuildResult.Value.ReferencesFound.Should().Be(1); + + var referencers = _metaData.GetReferencers(new ResourceKey("target.md")); + referencers.Should().Contain(new ResourceKey("source.md")); + + var references = _metaData.GetReferences(new ResourceKey("source.md")); + references.Should().Contain(new ResourceKey("target.md")); + } + + [Test] + public async Task RebuildAsync_SkipsBinaryFiles() + { + // A PNG containing the literal bytes "project:foo" should be skipped. + // We synthesise a minimal PNG-ish binary file (8-byte signature + arbitrary bytes + // including the "project:foo" string). + var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + var marker = System.Text.Encoding.UTF8.GetBytes("project:foo"); + var pngBytes = new byte[pngSignature.Length + marker.Length]; + Buffer.BlockCopy(pngSignature, 0, pngBytes, 0, pngSignature.Length); + Buffer.BlockCopy(marker, 0, pngBytes, pngSignature.Length, marker.Length); + File.WriteAllBytes(Path.Combine(_projectFolderPath, "image.png"), pngBytes); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + await _metaData.RebuildAsync(); + + _metaData.GetReferencers(new ResourceKey("foo")).Should().BeEmpty(); + } + + [Test] + public async Task RebuildAsync_SkipsOversizeFiles() + { + var oversize = new string('x', 11 * 1024 * 1024); // > 10MB + File.WriteAllText(Path.Combine(_projectFolderPath, "big.txt"), oversize); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var rebuildResult = await _metaData.RebuildAsync(); + + rebuildResult.IsSuccess.Should().BeTrue(); + rebuildResult.Value.FilesSkipped.Should().BeGreaterThan(0); + } + + [Test] + public async Task RebuildAsync_MarksServiceReady() + { + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + await _metaData.RebuildAsync(); + + _metaData.IsReady.Should().BeTrue(); + await _metaData.WaitUntilReadyAsync(); + } + + [Test] + public async Task GetAllReferencedTargets_ReturnsUnionOfTargets() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "a.md"), + "Refers to \"project:x.md\" and \"project:y.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.md"), + "Refers to \"project:y.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "x.md"), string.Empty); + File.WriteAllText(Path.Combine(_projectFolderPath, "y.md"), string.Empty); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + await _metaData.RebuildAsync(); + + var targets = _metaData.GetAllReferencedTargets(); + targets.Should().Contain(new ResourceKey("x.md")); + targets.Should().Contain(new ResourceKey("y.md")); + } + + [Test] + public void FrontmatterMethods_ThrowNotImplementedException() + { + // The frontmatter index methods are not yet implemented and throw. + var resource = new ResourceKey("foo.md"); + + Assert.Throws(() => _metaData.GetFrontmatter(resource)); + Assert.Throws(() => _metaData.FindByMetaData("tags", "x")); + Assert.Throws(() => _metaData.GetTags(resource)); + Assert.Throws(() => _metaData.FindByTag("x")); + } +} diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 5b505c1d6..cc4cb9d6e 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -71,7 +71,7 @@ public void ICanUpdateTheResourceTree() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var updateResult = resourceRegistry.UpdateResourceRegistry(); @@ -110,7 +110,7 @@ public void ICanExpandAFolderResource() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var workspaceWrapper = Substitute.For(); @@ -140,7 +140,7 @@ public void ResolveResourcePathReturnsCorrectAbsolutePath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Create(FileNameA)); @@ -156,7 +156,7 @@ public void ResolveResourcePathWithEmptyKeyReturnsProjectFolder() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Empty); @@ -172,7 +172,7 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var resolveResult = resourceRegistry.ResolveResourcePath( @@ -190,7 +190,7 @@ public void ResolveResourcePathAcceptsNonExistentPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; // Non-existent files should still resolve without error @@ -207,7 +207,7 @@ public void ResolveResourcePathRoundTripsWithGetResourceKey() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var filePath = Path.Combine(_resourceFolderPath, FileNameA); @@ -248,7 +248,7 @@ public void ResolveResourcePathRejectsSymlinksWithinProject() { var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var resolveResult = resourceRegistry.ResolveResourcePath( @@ -276,7 +276,7 @@ public void ProjectRootHandlerIsRegisteredOnProjectFolderPathSet() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); // Before ProjectFolderPath is set, no handler is registered. resourceRegistry.RootHandlers.Should().BeEmpty(); @@ -298,7 +298,7 @@ public void IsResolvableReturnsTrueForProjectRootAndFalseForUnknownRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; resourceRegistry.IsResolvable(ResourceKey.Create("foo/bar")).Should().BeTrue(); @@ -315,7 +315,7 @@ public void ResolveResourcePathFailsClearlyForUnregisteredRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var resolveResult = resourceRegistry.ResolveResourcePath( @@ -332,7 +332,7 @@ public void GetAllFileResourcesScopesToProjectRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; resourceRegistry.UpdateResourceRegistry(); @@ -355,7 +355,7 @@ public void RegisterRootHandlerReplacesExistingHandler() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; var originalHandler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; @@ -388,7 +388,7 @@ public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); resourceRegistry.ProjectFolderPath = _resourceFolderPath; // Register a temp root whose backing folder is nested inside the project folder. diff --git a/Source/Tests/Resources/SidecarClassificationTests.cs b/Source/Tests/Resources/SidecarClassificationTests.cs new file mode 100644 index 000000000..065f01ef2 --- /dev/null +++ b/Source/Tests/Resources/SidecarClassificationTests.cs @@ -0,0 +1,154 @@ +using Celbridge.Explorer.Services; +using Celbridge.Messaging.Services; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests that the registry pairing pass classifies sidecar files cleanly into +/// Healthy or Broken across a range of input shapes, and that broken bytes are +/// never modified on disk. The user is responsible for repairing broken +/// sidecars by hand. +/// +[TestFixture] +public class SidecarClassificationTests +{ + private string _projectFolderPath = null!; + private ResourceRegistry _registry = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(SidecarClassificationTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _registry = new ResourceRegistry( + Substitute.For>(), + new MessengerService(), + new FileIconService()); + _registry.ProjectFolderPath = _projectFolderPath; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + private SidecarInfo? GetParentSidecar(string parentName) + { + var resource = _registry.GetResource(new ResourceKey(parentName)).Value as IFileResource; + return resource!.Sidecar; + } + + [Test] + public void NoFences_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = "loose body text with no fences at all"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + } + + [Test] + public void MissingClosingFence_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = "+++\nkey = \"value\"\nno closing fence"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + } + + [Test] + public void MalformedToml_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = "+++\nkey = \"unterminated\nstring = true\n+++\nbody"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + } + + [Test] + public void MergeConflictMarkers_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = + "+++\n" + + "<<<<<<< HEAD\n" + + "tags = [\"theirs\"]\n" + + "=======\n" + + "tags = [\"ours\"]\n" + + ">>>>>>> branch\n" + + "+++\n"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + _registry.GetSidecarReport().Broken.Should().Contain(new ResourceKey("foo.png.cel")); + } + + [Test] + public void BomAndCrlf_ClassifiedAsHealthy() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var content = "+++\r\nkey = \"value\"\r\n+++\r\n"; + File.WriteAllText(sidecarPath, content); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Healthy); + } + + [Test] + public void ProjectLoads_EvenWhenSidecarStateIsBroken() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "good.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "good.png.cel"), + "+++\ntags = [\"x\"]\n+++\n"); + + File.WriteAllText(Path.Combine(_projectFolderPath, "bad.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "bad.png.cel"), + "+++\nmalformed = \n+++\n"); + + var result = _registry.UpdateResourceRegistry(); + result.IsSuccess.Should().BeTrue(); + + GetParentSidecar("good.png")!.Status.Should().Be(SidecarStatus.Healthy); + GetParentSidecar("bad.png")!.Status.Should().Be(SidecarStatus.Broken); + } +} diff --git a/Source/Tests/Resources/SidecarHelperTests.cs b/Source/Tests/Resources/SidecarHelperTests.cs new file mode 100644 index 000000000..6da8b7de2 --- /dev/null +++ b/Source/Tests/Resources/SidecarHelperTests.cs @@ -0,0 +1,124 @@ +using Celbridge.Resources.Helpers; + +namespace Celbridge.Tests.Resources; + +[TestFixture] +public class SidecarHelperTests +{ + [Test] + public void Parse_RoundTripsFrontmatterPlusBody() + { + var text = "+++\ntags = [\"a\", \"b\"]\npriority = \"high\"\n+++\n# Body\n\nContent here."; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + var parsed = result.Value; + parsed.Frontmatter.Should().ContainKey("priority"); + parsed.Frontmatter["priority"].Should().Be("high"); + parsed.Body.Should().StartWith("# Body"); + } + + [Test] + public void Parse_AcceptsFrontmatterOnlyFile() + { + var text = "+++\ntags = [\"meeting\"]\n+++\n"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Frontmatter.Should().ContainKey("tags"); + result.Value.Body.Should().Be(string.Empty); + } + + [Test] + public void Parse_AcceptsFrontmatterOnlyWithoutTrailingNewline() + { + var text = "+++\nkey = \"value\"\n+++"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Frontmatter["key"].Should().Be("value"); + result.Value.Body.Should().Be(string.Empty); + } + + [Test] + public void Parse_RejectsContentWithoutOpeningFence() + { + var text = "just a body, no fence at all"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeFalse(); + } + + [Test] + public void Parse_RejectsContentWithoutClosingFence() + { + var text = "+++\nkey = \"value\"\nno closing fence here"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeFalse(); + } + + [Test] + public void Parse_TreatsBodyVerbatimIncludingInternalFences() + { + // Only the *first* closing +++ terminates the frontmatter. Any further + // +++ lines belong to the body verbatim. + var text = "+++\nkey = \"value\"\n+++\nbody line 1\n+++\nbody line 2"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Body.Should().Contain("body line 1"); + result.Value.Body.Should().Contain("+++"); + result.Value.Body.Should().Contain("body line 2"); + } + + [Test] + public void Compose_OutputRoundTripsThroughParse() + { + var frontmatter = new Dictionary + { + ["title"] = "My Notes", + ["tags"] = new List { "meeting", "todo" }, + }; + var body = "# Meeting notes\n\nDecisions here."; + + var composed = SidecarHelper.Compose(frontmatter, body); + var parseResult = SidecarHelper.Parse(composed); + + parseResult.IsSuccess.Should().BeTrue(); + parseResult.Value.Frontmatter["title"].Should().Be("My Notes"); + parseResult.Value.Body.TrimEnd().Should().Be(body.TrimEnd()); + } + + [Test] + public void Compose_HandlesEmptyBody() + { + var frontmatter = new Dictionary + { + ["editor_id"] = "celbridge.notes", + }; + + var composed = SidecarHelper.Compose(frontmatter, string.Empty); + var parseResult = SidecarHelper.Parse(composed); + + parseResult.IsSuccess.Should().BeTrue(); + parseResult.Value.Body.Should().Be(string.Empty); + } + + [Test] + public void Compose_HandlesEmptyFrontmatter() + { + var composed = SidecarHelper.Compose(new Dictionary(), "hello body"); + var parseResult = SidecarHelper.Parse(composed); + + parseResult.IsSuccess.Should().BeTrue(); + parseResult.Value.Frontmatter.Should().BeEmpty(); + parseResult.Value.Body.Should().Contain("hello body"); + } +} diff --git a/Source/Tests/Resources/SidecarTrackingTests.cs b/Source/Tests/Resources/SidecarTrackingTests.cs new file mode 100644 index 000000000..016cefaf3 --- /dev/null +++ b/Source/Tests/Resources/SidecarTrackingTests.cs @@ -0,0 +1,174 @@ +using Celbridge.Explorer.Services; +using Celbridge.Messaging.Services; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; +using Celbridge.Utilities; + +namespace Celbridge.Tests.Resources; + +[TestFixture] +public class SidecarTrackingTests +{ + private string _projectFolderPath = null!; + private ResourceRegistry _registry = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(SidecarTrackingTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _registry = new ResourceRegistry( + Substitute.For>(), + new MessengerService(), + new FileIconService()); + _registry.ProjectFolderPath = _projectFolderPath; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public void FileWithNoSidecar_HasNullSidecar() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "fake-png-bytes"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var resourceResult = _registry.GetResource(new ResourceKey("foo.png")); + resourceResult.IsSuccess.Should().BeTrue(); + var fileResource = resourceResult.Value as IFileResource; + fileResource!.Sidecar.Should().BeNull(); + } + + [Test] + public void HealthySidecar_IsPairedWithStatusHealthy() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "fake-png-bytes"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "+++\ntags = [\"meeting\"]\n+++\n"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var resourceResult = _registry.GetResource(new ResourceKey("foo.png")); + resourceResult.IsSuccess.Should().BeTrue(); + var fileResource = resourceResult.Value as IFileResource; + fileResource!.Sidecar.Should().NotBeNull(); + fileResource.Sidecar!.Key.Should().Be(new ResourceKey("foo.png.cel")); + fileResource.Sidecar.Status.Should().Be(SidecarStatus.Healthy); + + var parentResult = _registry.GetSidecarParent(new ResourceKey("foo.png.cel")); + parentResult.IsSuccess.Should().BeTrue(); + parentResult.Value.Name.Should().Be("foo.png"); + } + + [Test] + public void GetSidecarParent_FailsForNonSidecarKey() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var result = _registry.GetSidecarParent(new ResourceKey("foo.png")); + + result.IsSuccess.Should().BeFalse(); + result.FirstErrorMessage.Should().Contain("not a sidecar key"); + } + + [Test] + public void OrphanSidecar_AppearsInReportOrphan() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "+++\ntags = [\"x\"]\n+++\n"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = _registry.GetSidecarReport(); + report.Orphan.Should().Contain(new ResourceKey("foo.png.cel")); + } + + [Test] + public void CelCelFile_AppearsInReportBroken() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "+++\ntags = [\"a\"]\n+++\n"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel.cel"), + "+++\nshould = \"not be paired\"\n+++\n"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = _registry.GetSidecarReport(); + report.Broken.Should().Contain(new ResourceKey("foo.png.cel.cel")); + + // foo.png.cel is still healthy and paired with foo.png; the .cel.cel + // file is not considered its sidecar. + report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); + var fooPng = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; + fooPng!.Sidecar!.Status.Should().Be(SidecarStatus.Healthy); + } + + [Test] + public void UnparseableSidecar_AppearsInReportBroken() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "no fences here, just loose text"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = _registry.GetSidecarReport(); + report.Broken.Should().Contain(new ResourceKey("foo.png.cel")); + + var parent = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; + parent!.Sidecar!.Status.Should().Be(SidecarStatus.Broken); + } + + [Test] + public void DeletingSidecar_FlipsParentToNullSidecar() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + File.WriteAllText(sidecarPath, "+++\ntags = [\"x\"]\n+++\n"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var parent1 = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; + parent1!.Sidecar!.Status.Should().Be(SidecarStatus.Healthy); + + File.Delete(sidecarPath); + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var parent2 = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; + parent2!.Sidecar.Should().BeNull(); + } + + [Test] + public void BrokenOrphan_AppearsInBothBrokenAndOrphan() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "lonely.cel"), "loose text, no fences"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = _registry.GetSidecarReport(); + report.Broken.Should().Contain(new ResourceKey("lonely.cel")); + report.Orphan.Should().Contain(new ResourceKey("lonely.cel")); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Celbridge.Resources.csproj b/Source/Workspace/Celbridge.Resources/Celbridge.Resources.csproj index f60dc0ced..c32a1bbf4 100644 --- a/Source/Workspace/Celbridge.Resources/Celbridge.Resources.csproj +++ b/Source/Workspace/Celbridge.Resources/Celbridge.Resources.csproj @@ -24,6 +24,7 @@ + diff --git a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs new file mode 100644 index 000000000..28f3300a7 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs @@ -0,0 +1,319 @@ +using Celbridge.Logging; +using Tomlyn; +using Tomlyn.Model; + +namespace Celbridge.Resources.Helpers; + +/// +/// The parsed result of a sidecar file: the frontmatter dictionary and the body string. +/// +public record SidecarParseResult( + IReadOnlyDictionary Frontmatter, + string Body); + +/// +/// Parse, compose, and on-disk inspection for the TOML-frontmatter-plus-body +/// sidecar format used by .cel files. The frontmatter is fenced by lines +/// containing only +++; the body that follows is opaque text. +/// +public static class SidecarHelper +{ + /// + /// The file extension for sidecar files. + /// + public const string Extension = ".cel"; + + /// + /// The fence delimiter for the frontmatter section. + /// + public const string Delimiter = "+++"; + + /// + /// Parses sidecar content into its frontmatter dictionary and body string. + /// Frontmatter is parsed as TOML; the body is returned verbatim. + /// Fails if the leading +++ fence is missing or no closing fence is found. + /// + public static Result Parse(string text) + { + if (text is null) + { + return Result.Fail("Sidecar content is null."); + } + + // Strip an optional UTF-8 BOM so the fence detector sees the +++ on the + // first byte. Tomlyn is BOM-tolerant; this normalises the body too. + if (text.Length > 0 + && text[0] == '') + { + text = text.Substring(1); + } + + // Find the opening fence. The fence must be the first non-empty content + // in the file; leading whitespace before the fence is not permitted. + var openingFenceLineEnd = FindFenceLineEnd(text, startIndex: 0); + if (openingFenceLineEnd < 0) + { + return Result.Fail("Sidecar content does not start with a '+++' fence line."); + } + + // The frontmatter starts immediately after the opening fence's line terminator. + var frontmatterStart = openingFenceLineEnd; + var closingFenceStart = FindClosingFence(text, frontmatterStart); + if (closingFenceStart < 0) + { + return Result.Fail("Sidecar content has no closing '+++' fence."); + } + + var frontmatterToml = text.Substring(frontmatterStart, closingFenceStart - frontmatterStart); + + // Advance past the closing fence line to find the body start. + var closingFenceLineEnd = FindFenceLineEnd(text, closingFenceStart); + if (closingFenceLineEnd < 0) + { + // The closing fence is the final line of the file with no trailing + // line terminator. The body is empty. + closingFenceLineEnd = text.Length; + } + + var body = text.Substring(closingFenceLineEnd); + + var parseResult = ParseFrontmatterToml(frontmatterToml); + if (parseResult.IsFailure) + { + return Result.Fail(parseResult.FirstErrorMessage) + .WithErrors(parseResult); + } + + return Result.Ok(new SidecarParseResult(parseResult.Value, body)); + } + + /// + /// Composes a sidecar text from a frontmatter dictionary and a body string. + /// The frontmatter is emitted as TOML between '+++' fence lines; the body + /// follows the closing fence. + /// + public static string Compose(IReadOnlyDictionary frontmatter, string body) + { + ArgumentNullException.ThrowIfNull(frontmatter); + body ??= string.Empty; + + var tomlTable = new TomlTable(); + foreach (var (key, value) in frontmatter) + { + tomlTable[key] = ConvertToTomlValue(value); + } + + var tomlText = Toml.FromModel(tomlTable); + + // Toml.FromModel emits a trailing newline; trim trailing whitespace so + // the composed output has predictable fence-line spacing. + tomlText = tomlText.TrimEnd('\r', '\n'); + + var separator = "\n"; + var hasBody = body.Length > 0; + + var composed = Delimiter + separator; + if (tomlText.Length > 0) + { + composed += tomlText + separator; + } + composed += Delimiter + separator; + if (hasBody) + { + composed += body; + } + + return composed; + } + + /// + /// Reads a sidecar file at absolutePath and classifies it as Healthy + /// (frontmatter parses cleanly) or Broken (any parse or read failure). + /// The bytes on disk are never modified. + /// + public static SidecarStatus Inspect(string absolutePath, ILogger logger) + { + string text; + try + { + text = File.ReadAllText(absolutePath); + } + catch (Exception ex) + { + logger.LogWarning(ex, $"sidecar pairing: failed to read '{absolutePath}'"); + return SidecarStatus.Broken; + } + + var parseResult = Parse(text); + if (parseResult.IsFailure) + { + logger.LogWarning($"sidecar pairing: '{absolutePath}' has unparseable frontmatter"); + return SidecarStatus.Broken; + } + + return SidecarStatus.Healthy; + } + + // Returns the position immediately after the line terminator of the fence + // line that starts at startIndex, or -1 if no fence line is found there. + // A fence line is "+++" followed by an optional line terminator. + private static int FindFenceLineEnd(string text, int startIndex) + { + if (startIndex < 0 + || startIndex > text.Length) + { + return -1; + } + + if (startIndex + Delimiter.Length > text.Length) + { + return -1; + } + + if (string.CompareOrdinal(text, startIndex, Delimiter, 0, Delimiter.Length) != 0) + { + return -1; + } + + int after = startIndex + Delimiter.Length; + + // Allow trailing whitespace on the fence line up to but not including a + // line terminator. Anything else after the +++ is not a fence line. + while (after < text.Length) + { + var current = text[after]; + if (current == '\r' + || current == '\n') + { + break; + } + if (current == ' ' + || current == '\t') + { + after++; + continue; + } + return -1; + } + + if (after >= text.Length) + { + return after; + } + + if (text[after] == '\r') + { + after++; + if (after < text.Length + && text[after] == '\n') + { + after++; + } + return after; + } + + if (text[after] == '\n') + { + after++; + return after; + } + + return after; + } + + // Returns the start position of the next fence line at column 0, or -1 if + // no closing fence is found. + private static int FindClosingFence(string text, int searchStart) + { + int lineStart = searchStart; + while (lineStart < text.Length) + { + if (FindFenceLineEnd(text, lineStart) >= 0) + { + return lineStart; + } + + // Advance to the next line. + int newlineIndex = text.IndexOfAny(new[] { '\r', '\n' }, lineStart); + if (newlineIndex < 0) + { + return -1; + } + + if (text[newlineIndex] == '\r') + { + if (newlineIndex + 1 < text.Length + && text[newlineIndex + 1] == '\n') + { + lineStart = newlineIndex + 2; + continue; + } + lineStart = newlineIndex + 1; + continue; + } + + lineStart = newlineIndex + 1; + } + + return -1; + } + + private static Result> ParseFrontmatterToml(string tomlText) + { + try + { + if (string.IsNullOrWhiteSpace(tomlText)) + { + return Result>.Ok( + new Dictionary()); + } + + var parseResult = Toml.Parse(tomlText); + if (parseResult.HasErrors) + { + var diagnostics = string.Join("; ", parseResult.Diagnostics.Select(d => d.ToString())); + return Result>.Fail($"TOML parse error(s): {diagnostics}"); + } + + var table = (TomlTable)parseResult.ToModel(); + var dictionary = new Dictionary(); + foreach (var (key, value) in table) + { + dictionary[key] = value!; + } + + return Result>.Ok(dictionary); + } + catch (Exception ex) + { + return Result>.Fail("An exception occurred when parsing TOML frontmatter.") + .WithException(ex); + } + } + + private static object ConvertToTomlValue(object value) + { + switch (value) + { + case TomlTable: + case TomlArray: + return value; + case IReadOnlyDictionary dictionary: + var table = new TomlTable(); + foreach (var (key, child) in dictionary) + { + table[key] = ConvertToTomlValue(child); + } + return table; + case System.Collections.IEnumerable enumerable when value is not string: + var array = new TomlArray(); + foreach (var item in enumerable) + { + array.Add(ConvertToTomlValue(item!)); + } + return array; + default: + return value; + } + } +} diff --git a/Source/Workspace/Celbridge.Resources/Models/FileResource.cs b/Source/Workspace/Celbridge.Resources/Models/FileResource.cs index b9441f473..ceb47fb4e 100644 --- a/Source/Workspace/Celbridge.Resources/Models/FileResource.cs +++ b/Source/Workspace/Celbridge.Resources/Models/FileResource.cs @@ -6,7 +6,9 @@ public class FileResource : Resource, IFileResource { public FileIconDefinition Icon { get; } - public FileResource(string name, IFolderResource parentFolder, FileIconDefinition icon) + public SidecarInfo? Sidecar { get; set; } + + public FileResource(string name, IFolderResource parentFolder, FileIconDefinition icon) : base(name, parentFolder) { Icon = icon; diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index dc70d15ed..8086dc1f4 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -22,6 +22,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); // diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs new file mode 100644 index 000000000..212b9fe96 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs @@ -0,0 +1,591 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using Celbridge.Logging; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +/// +/// Workspace-scoped reference graph and frontmatter index for project resources. +/// +public sealed class ResourceMetaData : IResourceMetaData, IDisposable +{ + // Files larger than this byte budget are skipped during the scan. + private const long MaxScanFileSizeBytes = 10 * 1024 * 1024; + + // Characters that cannot legally appear inside a resource key; the scanner + // stops accumulating candidate-key bytes at the first one it sees. + // Whitespace and control chars are handled separately via char.IsWhiteSpace + // and char.IsControl so this set only enumerates the printable terminators. + private static readonly HashSet KeyTerminators = new() + { + '"', '\'', '`', '(', ')', '<', '>', ',', ';', ']', '}', + }; + + private const string ReferenceMarker = "project:"; + + private readonly ILogger _logger; + private readonly IMessengerService _messengerService; + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly ITextBinarySniffer _textBinarySniffer; + + private readonly object _indexLock = new(); + private readonly Dictionary> _referencersByTarget = new(); + private readonly Dictionary> _referencesBySource = new(); + + // The pending-rescan queue. Watcher events push file keys onto this; the + // background worker drains them. WaitForPendingUpdatesAsync awaits the + // worker when it sees a non-empty queue. + private readonly ConcurrentQueue _pendingRescans = new(); + private readonly SemaphoreSlim _workerSignal = new(0); + private Task? _workerTask; + + private TaskCompletionSource _readyCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private bool _isReady; + private volatile bool _isShuttingDown; + private bool _isDisposed; + + public bool IsReady => _isReady; + + public ResourceMetaData( + ILogger logger, + IMessengerService messengerService, + IWorkspaceWrapper workspaceWrapper, + ITextBinarySniffer textBinarySniffer) + { + _logger = logger; + _messengerService = messengerService; + _workspaceWrapper = workspaceWrapper; + _textBinarySniffer = textBinarySniffer; + + _messengerService.Register(this, OnResourceCreated); + _messengerService.Register(this, OnResourceChanged); + _messengerService.Register(this, OnResourceDeleted); + _messengerService.Register(this, OnResourceRenamed); + + _workerTask = Task.Run(WorkerLoopAsync); + } + + public Task WaitUntilReadyAsync() + { + if (_isReady) + { + return Task.CompletedTask; + } + + return _readyCompletionSource.Task; + } + + public async Task WaitForPendingUpdatesAsync() + { + // Spin-wait while the queue still has items or the worker is mid-flight. + // The worker drains items in order, so once the queue is empty and the + // worker is idle, every prior watcher event has been applied. + while (!_pendingRescans.IsEmpty) + { + await Task.Delay(10); + } + } + + public async Task> RebuildAsync() + { + try + { + var stopwatch = Stopwatch.StartNew(); + + // WorkspaceService is available as soon as the wrapper has been populated, + // which happens before the workspace page UI loads. The rebuild can run + // during that window. WorkspaceService throws InvalidOperationException if + // no workspace is present; that's caught by the outer try. + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); + + var newReferencersByTarget = new Dictionary>(); + var newReferencesBySource = new Dictionary>(); + + int filesScanned = 0; + int filesSkipped = 0; + int referencesFound = 0; + + foreach (var (resourceKey, absolutePath) in files) + { + var scanResult = await ScanTextFileAsync(resourceKey, absolutePath); + if (scanResult.WasSkipped) + { + filesSkipped++; + continue; + } + + filesScanned++; + + if (scanResult.References.Count == 0) + { + continue; + } + + referencesFound += scanResult.References.Count; + ApplyReferences(newReferencersByTarget, newReferencesBySource, resourceKey, scanResult.References); + } + + lock (_indexLock) + { + _referencersByTarget.Clear(); + foreach (var entry in newReferencersByTarget) + { + _referencersByTarget[entry.Key] = entry.Value; + } + _referencesBySource.Clear(); + foreach (var entry in newReferencesBySource) + { + _referencesBySource[entry.Key] = entry.Value; + } + } + + stopwatch.Stop(); + + MarkReady(); + + // FrontmatterEntries is always zero because frontmatter scanning is + // not yet implemented on this service. + var report = new MetaDataScanReport( + FilesScanned: filesScanned, + FilesSkipped: filesSkipped, + ReferencesFound: referencesFound, + FrontmatterEntries: 0, + Elapsed: stopwatch.Elapsed); + + _logger.LogDebug($"Metadata rebuild complete: {filesScanned} scanned, {filesSkipped} skipped, {referencesFound} references in {stopwatch.ElapsedMilliseconds}ms"); + + return Result.Ok(report); + } + catch (Exception ex) + { + return Result.Fail("An exception occurred during the metadata rebuild.") + .WithException(ex); + } + } + + public IReadOnlyList GetReferencers(ResourceKey target) + { + lock (_indexLock) + { + if (_referencersByTarget.TryGetValue(target, out var set)) + { + return set.ToList(); + } + return Array.Empty(); + } + } + + public IReadOnlyList GetReferences(ResourceKey source) + { + lock (_indexLock) + { + if (_referencesBySource.TryGetValue(source, out var set)) + { + return set.ToList(); + } + return Array.Empty(); + } + } + + public IReadOnlyList GetAllReferencedTargets() + { + lock (_indexLock) + { + return _referencersByTarget.Keys.ToList(); + } + } + + public Result> GetFrontmatter(ResourceKey resource) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public Task SetFrontmatterFieldAsync(ResourceKey resource, string field, object value) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public Task RemoveFrontmatterFieldAsync(ResourceKey resource, string field) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public IReadOnlyList FindByMetaData(string field, object value) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public IReadOnlyList GetTags(ResourceKey resource) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public Task AddTagAsync(ResourceKey resource, string tag) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public Task RemoveTagAsync(ResourceKey resource, string tag) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + public IReadOnlyList FindByTag(string tag) + { + throw new NotImplementedException("The frontmatter index is not yet implemented."); + } + + // Walks file text for "project:" candidate references. Returns the unique set + // of valid keys found; invalid candidates are silently dropped. + public static HashSet ScanTextForReferences(string text) + { + var references = new HashSet(); + int searchStart = 0; + + while (true) + { + int markerIndex = text.IndexOf(ReferenceMarker, searchStart, StringComparison.Ordinal); + if (markerIndex < 0) + { + break; + } + + int keyStart = markerIndex + ReferenceMarker.Length; + int keyEnd = keyStart; + while (keyEnd < text.Length) + { + var current = text[keyEnd]; + if (char.IsWhiteSpace(current) + || char.IsControl(current) + || KeyTerminators.Contains(current)) + { + break; + } + keyEnd++; + } + + if (keyEnd > keyStart) + { + var candidate = text.Substring(markerIndex, keyEnd - markerIndex); + if (ResourceKey.TryCreate(candidate, out var key)) + { + references.Add(key); + } + } + + searchStart = keyEnd > markerIndex ? keyEnd : markerIndex + ReferenceMarker.Length; + } + + return references; + } + + private record FileScanResult(bool WasSkipped, HashSet References); + + private async Task ScanTextFileAsync(ResourceKey resourceKey, string absolutePath) + { + try + { + var fileInfo = new FileInfo(absolutePath); + if (!fileInfo.Exists) + { + return new FileScanResult(WasSkipped: true, References: new HashSet()); + } + + if (fileInfo.Length > MaxScanFileSizeBytes) + { + _logger.LogInformation($"metadata scan: skipping {resourceKey} (size {fileInfo.Length} bytes exceeds limit)"); + return new FileScanResult(WasSkipped: true, References: new HashSet()); + } + + var extension = Path.GetExtension(absolutePath); + if (!string.IsNullOrEmpty(extension) + && _textBinarySniffer.IsBinaryExtension(extension)) + { + return new FileScanResult(WasSkipped: true, References: new HashSet()); + } + + var isTextResult = _textBinarySniffer.IsTextFile(absolutePath); + if (isTextResult.IsFailure || !isTextResult.Value) + { + return new FileScanResult(WasSkipped: true, References: new HashSet()); + } + + string text; + try + { + text = await File.ReadAllTextAsync(absolutePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"metadata scan: failed to read {resourceKey}"); + return new FileScanResult(WasSkipped: true, References: new HashSet()); + } + + var references = ScanTextForReferences(text); + return new FileScanResult(WasSkipped: false, References: references); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"metadata scan: failed to process {resourceKey}"); + return new FileScanResult(WasSkipped: true, References: new HashSet()); + } + } + + private static void ApplyReferences( + Dictionary> referencersByTarget, + Dictionary> referencesBySource, + ResourceKey source, + HashSet references) + { + if (!referencesBySource.TryGetValue(source, out var sourceSet)) + { + sourceSet = new HashSet(); + referencesBySource[source] = sourceSet; + } + + foreach (var target in references) + { + sourceSet.Add(target); + + if (!referencersByTarget.TryGetValue(target, out var targetSet)) + { + targetSet = new HashSet(); + referencersByTarget[target] = targetSet; + } + targetSet.Add(source); + } + } + + private void RemoveSourceFromIndexes(ResourceKey source) + { + lock (_indexLock) + { + if (_referencesBySource.TryGetValue(source, out var oldTargets)) + { + foreach (var target in oldTargets) + { + if (_referencersByTarget.TryGetValue(target, out var referencers)) + { + referencers.Remove(source); + if (referencers.Count == 0) + { + _referencersByTarget.Remove(target); + } + } + } + _referencesBySource.Remove(source); + } + } + } + + private void UpdateSourceInIndexes(ResourceKey source, HashSet references) + { + lock (_indexLock) + { + // Strip any prior referrals from this source first so the new set + // fully replaces the old set. + if (_referencesBySource.TryGetValue(source, out var oldTargets)) + { + foreach (var target in oldTargets) + { + if (_referencersByTarget.TryGetValue(target, out var referencers)) + { + referencers.Remove(source); + if (referencers.Count == 0) + { + _referencersByTarget.Remove(target); + } + } + } + } + + if (references.Count == 0) + { + _referencesBySource.Remove(source); + return; + } + + _referencesBySource[source] = new HashSet(references); + foreach (var target in references) + { + if (!_referencersByTarget.TryGetValue(target, out var targetSet)) + { + targetSet = new HashSet(); + _referencersByTarget[target] = targetSet; + } + targetSet.Add(source); + } + } + } + + private void OnResourceCreated(object recipient, MonitoredResourceCreatedMessage message) + { + QueueRescan(message.Resource); + } + + private void OnResourceChanged(object recipient, MonitoredResourceChangedMessage message) + { + QueueRescan(message.Resource); + } + + private void OnResourceDeleted(object recipient, MonitoredResourceDeletedMessage message) + { + if (message.Resource.Root != ResourceKey.DefaultRoot) + { + return; + } + RemoveSourceFromIndexes(message.Resource); + } + + private void OnResourceRenamed(object recipient, MonitoredResourceRenamedMessage message) + { + if (message.OldResource.Root == ResourceKey.DefaultRoot) + { + RemoveSourceFromIndexes(message.OldResource); + } + QueueRescan(message.NewResource); + } + + private void QueueRescan(ResourceKey resource) + { + // Only project: resources contribute to the index. Watcher messages from + // temp: and logs: roots are ignored. + if (resource.Root != ResourceKey.DefaultRoot + || resource.IsEmpty) + { + return; + } + + _pendingRescans.Enqueue(resource); + try + { + _workerSignal.Release(); + } + catch (SemaphoreFullException) + { + // The signal count is unbounded in practice; ignore the rare overflow. + } + } + + private async Task WorkerLoopAsync() + { + // The worker waits on the semaphore for new work and checks _isShuttingDown + // after every wake. Dispose sets the flag and releases the semaphore once, + // so the worker exits cleanly without raising an OperationCanceledException. + while (!_isShuttingDown) + { + try + { + await _workerSignal.WaitAsync(); + } + catch (ObjectDisposedException) + { + return; + } + + if (_isShuttingDown) + { + return; + } + + while (_pendingRescans.TryDequeue(out var resource)) + { + if (_isShuttingDown) + { + return; + } + + await ProcessRescanAsync(resource); + } + } + } + + private async Task ProcessRescanAsync(ResourceKey resource) + { + try + { + if (_isShuttingDown) + { + return; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = registry.ResolveResourcePath(resource); + if (resolveResult.IsFailure) + { + RemoveSourceFromIndexes(resource); + return; + } + var absolutePath = resolveResult.Value; + + if (!File.Exists(absolutePath)) + { + RemoveSourceFromIndexes(resource); + return; + } + + var scanResult = await ScanTextFileAsync(resource, absolutePath); + if (scanResult.WasSkipped) + { + RemoveSourceFromIndexes(resource); + return; + } + + UpdateSourceInIndexes(resource, scanResult.References); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"metadata scan: failed to rescan {resource}"); + } + } + + private void MarkReady() + { + if (_isReady) + { + return; + } + _isReady = true; + _readyCompletionSource.TrySetResult(true); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + _isDisposed = true; + + _messengerService.UnregisterAll(this); + + // Signal the worker to exit, then nudge the semaphore so it observes the + // flag and returns. The worker checks _isShuttingDown after every wake. + _isShuttingDown = true; + try + { + _workerSignal.Release(); + } + catch (SemaphoreFullException) + { + // Worker is already pending wake-up; nothing more to do. + } + catch (ObjectDisposedException) + { + // Already disposed; nothing more to do. + } + + try + { + _workerTask?.Wait(TimeSpan.FromSeconds(2)); + } + catch (Exception) + { + // Worker shutdown is best-effort; never let dispose throw. + } + + _workerSignal.Dispose(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index b03877f7c..0f65d6cb1 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -1,4 +1,5 @@ using System.Text; +using Celbridge.Logging; using Celbridge.Projects; using Celbridge.Resources.Helpers; using Celbridge.Resources.Services.Roots; @@ -8,11 +9,22 @@ namespace Celbridge.Resources.Services; public class ResourceRegistry : IResourceRegistry { + private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IFileIconService _fileIconService; private readonly PathValidator _pathValidator = new(); private readonly Dictionary _rootHandlers = new(StringComparer.Ordinal); + // Sidecar tracking state, refreshed on each UpdateResourceRegistry pass. + // The report is rebuilt atomically per pass so readers always see a coherent + // snapshot. + private readonly object _sidecarLock = new(); + private SidecarReport _sidecarReport = new( + Healthy: Array.Empty(), + Broken: Array.Empty(), + Orphan: Array.Empty()); + private readonly Dictionary _sidecarToParent = new(); + private string _projectFolderPath = string.Empty; public string ProjectFolderPath @@ -37,9 +49,11 @@ public string ProjectFolderPath public IReadOnlyDictionary RootHandlers => _rootHandlers; public ResourceRegistry( + ILogger logger, IMessengerService messengerService, IFileIconService fileIconService) { + _logger = logger; _messengerService = messengerService; _fileIconService = fileIconService; } @@ -318,6 +332,7 @@ public Result UpdateResourceRegistry() // visible before the new reference (a no-op on x64, required on ARM64). var newRoot = new FolderResource(string.Empty, null); SynchronizeFolder(newRoot, ProjectFolderPath); + UpdateSidecarPairings(newRoot); Volatile.Write(ref _projectFolder, newRoot); _pathValidator.InvalidateCache(); @@ -439,6 +454,190 @@ private void CollectFileResources( } } + public Result GetSidecarParent(ResourceKey sidecar) + { + if (!sidecar.Path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return Result.Fail( + $"Resource key '{sidecar}' is not a sidecar key (does not end in '{SidecarHelper.Extension}')."); + } + + lock (_sidecarLock) + { + if (!_sidecarToParent.TryGetValue(sidecar, out var parentKey)) + { + return Result.Fail( + $"No parent file is paired with sidecar '{sidecar}'."); + } + + var resourceResult = GetResource(parentKey); + if (resourceResult.IsFailure) + { + return Result.Fail( + $"Failed to resolve parent file '{parentKey}' for sidecar '{sidecar}'.") + .WithErrors(resourceResult); + } + + if (resourceResult.Value is not IFileResource fileResource) + { + return Result.Fail( + $"Parent of sidecar '{sidecar}' is not a file resource."); + } + + return Result.Ok(fileResource); + } + } + + public SidecarReport GetSidecarReport() + { + lock (_sidecarLock) + { + return _sidecarReport; + } + } + + // Walks the newly-built tree pairing parent files with their .cel sidecars. + // Runs after SynchronizeFolder so the tree shape is final; sets each + // FileResource.Sidecar in place and rebuilds the report snapshot. + private void UpdateSidecarPairings(FolderResource projectRoot) + { + var healthy = new List(); + var broken = new List(); + var orphan = new List(); + var newSidecarToParent = new Dictionary(); + + ProcessFolder(projectRoot); + + var newReport = new SidecarReport( + Healthy: healthy, + Broken: broken, + Orphan: orphan); + + lock (_sidecarLock) + { + _sidecarToParent.Clear(); + foreach (var entry in newSidecarToParent) + { + _sidecarToParent[entry.Key] = entry.Value; + } + _sidecarReport = newReport; + } + + void ProcessFolder(FolderResource folder) + { + // Build a name lookup for siblings so the pairing checks are O(1) per file. + var siblingByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var child in folder.Children) + { + siblingByName[child.Name] = child; + } + + foreach (var child in folder.Children) + { + if (child is FolderResource subFolder) + { + ProcessFolder(subFolder); + continue; + } + + if (child is not FileResource fileResource) + { + continue; + } + + ClassifyFile(fileResource, siblingByName); + } + } + + void ClassifyFile( + FileResource fileResource, + Dictionary siblingByName) + { + var name = fileResource.Name; + + // Files ending in .cel.cel are never paired with anything. They are + // surfaced as Broken so the user can resolve them. + if (name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) + { + fileResource.Sidecar = null; + broken.Add(GetResourceKey(fileResource)); + return; + } + + if (name.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + ClassifySidecarFile(fileResource, siblingByName); + return; + } + + // Non-sidecar file: pair with the sibling .cel if it exists. + var sidecarName = name + SidecarHelper.Extension; + if (siblingByName.TryGetValue(sidecarName, out var sibling) + && sibling is FileResource siblingFile + && !siblingFile.Name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) + { + var sidecarKey = GetResourceKey(siblingFile); + + // The sidecar's classification may not have run yet; populate a + // placeholder Healthy entry now and let ClassifySidecarFile + // overwrite it with the inspected status when it runs. + var existingStatus = fileResource.Sidecar?.Status ?? SidecarStatus.Healthy; + fileResource.Sidecar = new SidecarInfo(sidecarKey, existingStatus); + return; + } + + fileResource.Sidecar = null; + } + + void ClassifySidecarFile( + FileResource sidecarFile, + Dictionary siblingByName) + { + var sidecarName = sidecarFile.Name; + var parentName = sidecarName.Substring(0, sidecarName.Length - SidecarHelper.Extension.Length); + + var sidecarKey = GetResourceKey(sidecarFile); + + // Inspect the .cel file's content to determine its status. Broken + // bytes are never modified on disk; the user repairs them by hand. + var resolveResult = ResolveResourcePath(sidecarKey); + SidecarStatus status; + if (resolveResult.IsFailure) + { + _logger.LogWarning($"sidecar pairing: failed to resolve path for '{sidecarKey}'"); + status = SidecarStatus.Broken; + } + else + { + status = SidecarHelper.Inspect(resolveResult.Value, _logger); + } + + // A .cel file has no sidecar of its own (sidecars don't have sidecars). + sidecarFile.Sidecar = null; + + // Pair with the parent if present. + if (siblingByName.TryGetValue(parentName, out var parentSibling) + && parentSibling is FileResource parentFile) + { + newSidecarToParent[sidecarKey] = GetResourceKey(parentFile); + parentFile.Sidecar = new SidecarInfo(sidecarKey, status); + } + else + { + orphan.Add(sidecarKey); + } + + if (status == SidecarStatus.Healthy) + { + healthy.Add(sidecarKey); + } + else + { + broken.Add(sidecarKey); + } + } + } + public Result NormalizeResourceKey(ResourceKey resourceKey) { try diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs index e0783d2b0..d726219d8 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs @@ -128,6 +128,17 @@ public async Task LoadWorkspaceAsync() return Result.Fail("Failed to update resources") .WithErrors(updateResult); } + + // Rebuild the metadata index synchronously before downstream steps + // (package discovery, activity service, Python init) get a chance to + // touch files on disk. Running concurrently risks scanning a file + // mid-write and recording stale references or mtime stamps. + var metaData = workspaceService.ResourceMetaData; + var rebuildResult = await metaData.RebuildAsync(); + if (rebuildResult.IsFailure) + { + _logger.LogWarning(rebuildResult, "Failed to rebuild resource metadata index"); + } } catch (Exception ex) { diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index 6a425ccc4..ab1bac85b 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -24,6 +24,7 @@ public class WorkspaceService : IWorkspaceService, IDisposable public IPackageService PackageService { get; } public IResourceService ResourceService { get; } public IResourceFileSystem ResourceFileSystem { get; } + public IResourceMetaData ResourceMetaData { get; } public IExplorerService ExplorerService { get; } public IDocumentsService DocumentsService { get; } public IInspectorService InspectorService { get; } @@ -59,6 +60,7 @@ public WorkspaceService( PackageService = serviceProvider.GetRequiredService(); ResourceService = serviceProvider.GetRequiredService(); ResourceFileSystem = serviceProvider.GetRequiredService(); + ResourceMetaData = serviceProvider.GetRequiredService(); ExplorerService = serviceProvider.GetRequiredService(); DocumentsService = serviceProvider.GetRequiredService(); InspectorService = serviceProvider.GetRequiredService(); @@ -186,6 +188,7 @@ protected virtual void Dispose(bool disposing) // Dispose resource service first to stop file system monitoring (ResourceService as IDisposable)?.Dispose(); + (ResourceMetaData as IDisposable)?.Dispose(); (WorkspaceSettingsService as IDisposable)!.Dispose(); (PythonService as IDisposable)!.Dispose(); (ConsoleService as IDisposable)!.Dispose(); From c30f0dc6813024a6b56e25a5369efc18ff15d4da Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 21 May 2026 20:01:16 +0100 Subject: [PATCH 09/48] Return structured results for resource ops Introduce structured result types and error details for resource operations: add CopyCommandResult, DeleteCommandResult, DeleteResourceResult, DeleteBatchOutcome, DeleteResourceOutcome, DeleteReferencePolicy, SkippedReferencer and ReferencerSkipReason; extend MoveResult with skipped referencers. Update ICopyResourceCommand/IDeleteResourceCommand and IResourceOperationService method return types to surface rich outcomes (CopyResult/MoveResult payloads). Update explorer tools to emit compact "ok" for clean cases and structured JSON for partial failures or skipped referencers, add reference-policy parsing for deletes, and implement silent duplicate behavior using a new ResourceNameHelper. Update tests to exercise move/copy/delete cascades, referencer rewrites, and duplicate naming. Documentation/guides updated to describe canonical quoted references, policies, and tool responses. --- .../Resources/ICopyResourceCommand.cs | 14 +- .../Resources/IDeleteResourceCommand.cs | 89 +- .../Resources/IResourceFileSystem.cs | 30 +- .../Resources/IResourceOperationService.cs | 8 +- .../Guides/Concepts/resource_keys.md | 112 +++ .../Guides/Tools/explorer_copy.md | 15 +- .../Guides/Tools/explorer_delete.md | 51 +- .../Guides/Tools/explorer_duplicate.md | 24 +- .../Guides/Tools/explorer_move.md | 25 +- .../Guides/Tools/explorer_rename.md | 2 + .../Tools/Explorer/ExplorerTools.Copy.cs | 21 +- .../Tools/Explorer/ExplorerTools.Delete.cs | 67 +- .../Tools/Explorer/ExplorerTools.Duplicate.cs | 64 +- .../Tools/Explorer/ExplorerTools.Move.cs | 36 +- .../Helpers/ResourceNameHelper.cs | 65 ++ .../Resources/ResourceFileSystemTests.cs | 425 +++++++++- .../Tests/Resources/ResourceMetaDataTests.cs | 190 ++++- .../DuplicateResourceDialogCommand.cs | 64 +- .../Commands/CopyResourceCommand.cs | 109 ++- .../Commands/DeleteResourceCommand.cs | 226 +++++- .../Services/ReferenceLiteralRules.cs | 172 ++++ .../Services/ResourceFileSystem.cs | 758 +++++++++++++++++- .../Services/ResourceMetaData.cs | 265 ++++-- .../Services/ResourceMonitor.cs | 22 +- .../Services/ResourceOperationService.cs | 266 +++++- .../Services/ResourceOperations.cs | 542 +++++++++---- 26 files changed, 3229 insertions(+), 433 deletions(-) create mode 100644 Source/Core/Celbridge.Utilities/Helpers/ResourceNameHelper.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs diff --git a/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs b/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs index 5cc1ffa54..4949ac222 100644 --- a/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs @@ -3,10 +3,22 @@ namespace Celbridge.Resources; +/// +/// Aggregated result of a CopyResourceCommand batch. UpdatedReferencers and +/// SkippedReferencers are aggregated across every move in the batch (empty +/// for copy-mode batches because copy does not rewrite references). FailedResources +/// identifies the source resources whose bytes operation failed; their entries +/// do not appear in UpdatedReferencers or SkippedReferencers. +/// +public record CopyCommandResult( + IReadOnlyList UpdatedReferencers, + IReadOnlyList SkippedReferencers, + IReadOnlyList FailedResources); + /// /// Copy one or more resources to a different location in the project. /// -public interface ICopyResourceCommand : IExecutableCommand +public interface ICopyResourceCommand : IExecutableCommand { /// /// Resources to be copied. diff --git a/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs index 289c0052d..dfef12173 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs @@ -2,13 +2,100 @@ namespace Celbridge.Resources; +/// +/// How DeleteResourceCommand should respond when the resources being deleted are +/// referenced by other project resources. RequireConfirmation prompts the user +/// via IDialogService; FailIfReferenced refuses the batch and reports +/// the conflicting referencers; BreakReferences proceeds without prompting, +/// leaving the existing references dangling. +/// +public enum DeleteReferencePolicy +{ + RequireConfirmation, + FailIfReferenced, + BreakReferences, +} + +/// +/// Aggregate outcome of a DeleteResourceCommand batch. +/// DeletedAll means every resource in the batch was deleted successfully. +/// DeletedSome means the policy gate passed and execution ran but at least +/// one resource failed mechanically (file locked, IO error, etc.); inspect +/// ResourceResults to see which. This also covers the rare edge where every +/// resource failed — when zero of N succeed, the batch is still classified +/// DeletedSome rather than carving out a separate "none succeeded" value, +/// since the agent's next step (inspect ResourceResults) is the same either way. +/// CancelledByUser and BlockedByReferences are policy-gate failures that leave +/// the filesystem untouched. +/// +public enum DeleteBatchOutcome +{ + DeletedAll, + DeletedSome, + CancelledByUser, + BlockedByReferences, +} + +/// +/// Per-resource outcome inside a DeleteResourceCommand batch. The non-Deleted +/// values are typed so an agent can branch on the cause without parsing +/// FailureMessage: NotFound is the no-op success case (the resource is already +/// gone); Locked means another process holds the file (often fixable by closing +/// the editor or stopping the antivirus); PermissionDenied is an ACL / POSIX +/// denial (needs the right account or admin); IOFailure is the catch-all for +/// disk full, network share gone, hardware error, and any other mechanical +/// failure that doesn't fit the more specific reasons. +/// +public enum DeleteResourceOutcome +{ + Deleted, + NotFound, + Locked, + PermissionDenied, + IOFailure, +} + +/// +/// Per-resource result entry inside a DeleteCommandResult. The three fields are +/// semantically independent: Outcome carries the typed result of deleting the +/// parent resource; Sidecar carries the outcome of the best-effort .cel +/// cascade; FailureMessage carries the human-readable diagnostic for the +/// parent failure (null when Outcome is Deleted). Sidecar cascade failures are +/// best-effort and surface through Sidecar + the host log, not through +/// FailureMessage. +/// +public record DeleteResourceResult( + ResourceKey Resource, + DeleteResourceOutcome Outcome, + SidecarOutcome Sidecar, + string? FailureMessage); + +/// +/// Structured result produced by DeleteResourceCommand. Referencers maps each +/// input resource to the resources outside the batch that referenced it; it is +/// populated when external referencers were detected, whether the batch +/// proceeded (BreakReferences) or was gated (CancelledByUser / BlockedByReferences). +/// References from one batch resource to another are filtered out — those go +/// away alongside their target so they cannot block or be reported as dangling. +/// +public record DeleteCommandResult( + DeleteBatchOutcome BatchOutcome, + IReadOnlyList ResourceResults, + IReadOnlyDictionary> Referencers); + /// /// Delete one or more file or folder resources from the project. /// -public interface IDeleteResourceCommand : IExecutableCommand +public interface IDeleteResourceCommand : IExecutableCommand { /// /// Resources to delete. /// List Resources { get; set; } + + /// + /// Policy applied across the batch when one or more resources are referenced + /// by other project resources. Defaults to RequireConfirmation. + /// + DeleteReferencePolicy ReferencePolicy { get; set; } } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs index 83b26d960..95a1ab609 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs @@ -21,12 +21,40 @@ public enum SidecarOutcome Failed, } +/// +/// Why a referencer could not be rewritten during a move's cascade. +/// ReadOnly is the DOS read-only attribute (trivially clearable); +/// PermissionDenied is an ACL / POSIX denial (needs the right account or admin). +/// ReadFailed and WriteFailed are catch-alls. Inspect SkippedReferencer.Message +/// for the specific cause. +/// +public enum ReferencerSkipReason +{ + ReadFailed, + WriteFailed, + ReadOnly, + PermissionDenied, +} + +/// +/// A referencer the move could not rewrite. The reference is left stale and +/// will surface via metadata_check_project; a re-run of the rename after the +/// underlying issue clears (close the editor, remove the read-only attribute) +/// picks up the residual rewrite because the FS layer is idempotent. +/// +public record SkippedReferencer( + ResourceKey Resource, + ReferencerSkipReason Reason, + string Message); + /// /// Result of an integrity-aware move: the list of resources whose references -/// were rewritten and the outcome of the paired-sidecar cascade. +/// were rewritten, the list of referencers the cascade had to skip (with a +/// reason for each), and the outcome of the paired-sidecar cascade. /// public record MoveResult( IReadOnlyList UpdatedReferencers, + IReadOnlyList SkippedReferencers, SidecarOutcome Sidecar); /// diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs index 6dfba2ffe..3b7abf5fd 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs @@ -20,12 +20,12 @@ public interface IResourceOperationService /// /// Copy a file from source to destination path. /// - Task CopyFileAsync(string sourcePath, string destPath); + Task> CopyFileAsync(string sourcePath, string destPath); /// /// Move a file from source to destination path. /// - Task MoveFileAsync(string sourcePath, string destPath); + Task> MoveFileAsync(string sourcePath, string destPath); /// /// Delete a file at the specified path. @@ -35,12 +35,12 @@ public interface IResourceOperationService /// /// Copy a folder from source to destination path. /// - Task CopyFolderAsync(string sourcePath, string destPath); + Task> CopyFolderAsync(string sourcePath, string destPath); /// /// Move a folder from source to destination path. /// - Task MoveFolderAsync(string sourcePath, string destPath); + Task> MoveFolderAsync(string sourcePath, string destPath); /// /// Delete a folder at the specified path. diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index 93d9dcb8c..00b14d486 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -40,3 +40,115 @@ So `file_read` against a missing `temp:foo/bar` reports `temp:foo/bar` in the er - Case sensitivity follows the underlying filesystem; on Windows the system is case-preserving but case-insensitive. When in doubt about which keys exist, call `file_get_tree("")` to list the top level of the project tree, or pass a folder key to list its contents. + +## Writing references that the cascade can track + +Celbridge maintains a reference graph so that rename, move, and delete operations can update other files that point at the affected resource (see `explorer_move`, `explorer_delete`). To participate in the graph, every reference must be written in one canonical form: the `project:` prefix immediately wrapped in ASCII double or single quotes. + +The wrapping quotes are mandatory. The scanner does not detect references in unquoted prose, because heuristic "find the end of the key" rules are ambiguous in arbitrary text and produce silent miss-tracking on subtle edge cases. A single rule — always quoted — is the only form that survives every text format reliably. + +### The canonical form + +A tracked reference is exactly one of these byte sequences in the file: + +``` +"project:" +``` + +``` +'project:' +``` + +The opening quote sits immediately before `project:`. The matching close quote ends the key. Strict matching applies — the bytes between the quotes are taken verbatim as the key. + +### The escaped form (for references inside already-quoted strings) + +When the reference sits inside a string that has already been quoted by the host format — most commonly a JSON, TOML basic, or C-family string literal — the quote that opens it has been escaped as `\"` or `\'`. The scanner recognises both two-character forms and the matching escaped close, so a reference embedded in a JSON string is tracked end-to-end: + +``` +"description": "See \"project:foo.md\" for details" +``` + +``` +"description": 'See \'project:foo.md\' for details' +``` + +### Examples by host format + +In TOML, the format's own string quotes are the wrapping quotes: + +```toml +target = "project:docs/intro.md" +``` + +In JSON, same — the string-value quotes are the wrapping quotes: + +```json +{"target": "project:docs/intro.md"} +``` + +In source code, the language's string-literal quotes are the wrapping quotes: + +```csharp +var target = "project:docs/intro.md"; +``` + +In markdown body prose, write the reference in quotes: + +``` +See "project:docs/intro.md" for details. +``` + +In a `.cel` frontmatter field, TOML's string quotes again: + +``` ++++ +target = "project:docs/intro.md" ++++ +``` + +### Keys containing whitespace + +The rule is the same: wrap in `"..."` or `'...'`. The bytes between the wrapping quotes (including any spaces) are taken as the key. + +``` +"project:docs/My Document.md" +``` + +``` +'project:docs/My Document.md' +``` + +``` +"See \"project:docs/My Document.md\" thanks" +``` + +Strict matching means surrounding whitespace inside the wrapping quotes is part of the key. Write `"project:foo.md"`, not `" project:foo.md "`, or the recorded reference won't match the file. + +### Forms that are NOT tracked + +| Form | Why not | +|---|---| +| `project:docs/intro.md` (bare, no quotes) | References must be quoted; bare prose is not scanned | +| `[project:docs/intro.md]` (brackets only) | Brackets are not delimiters; only `"` and `'` open a tracked key | +| `(project:docs/intro.md)` (parens only) | Same — only `"` and `'` are delimiters | +| `` `project:docs/intro.md` `` (backticks only) | Same — backticks are not delimiters | +| `docs/intro.md` (no `project:` prefix) | The `project:` marker is what the scanner looks for | +| `temp:scratch/notes.md` | Only `project:` references are tracked; `temp:` and `logs:` are not | +| `https://example.com/foo` | External URLs are not resource keys | + +### Known limitations + +- **Unicode "smart quotes" (curly forms of `"` and `'`) are not recognised** — only the ASCII forms (`"` U+0022 and `'` U+0027) count. Pasted content from Word, chat apps, or auto-formatting editors may carry visually-identical curly quotes that the scanner ignores; check the raw bytes if a reference silently fails to track. +- **JSON `\/` escape**: a reference written as `"project:foo\/bar.md"` (representing `project:foo/bar.md`) is not tracked — the scanner sees the literal `\` and treats it as a key boundary. JSON serialisers almost never emit `\/`; write `/` directly. +- **Markdown link URLs with whitespace** are not tracked through this scanner. Markdown links use their own paths-relative-to-the-document scheme and are the subject of a future format-aware scanner. Until then, write the reference outside the link target (in prose) or use a hyphen / underscore / camelCase name for the file. + +## Where the scanner looks + +The reference scanner reads the full text of every text file in the project (skipping binary files via extension and content sniffing). Quoted `project:` references are tracked wherever they appear: + +- **Plain text and source files** — markdown bodies, code, TOML/JSON/YAML configs, etc. +- **Sidecar (`.cel`) frontmatter** — quoted `project:` references in the TOML frontmatter of a sidecar are tracked the same as anywhere else. +- **Sidecar (`.cel`) body** — and so is the opaque body section. Either location works equally for editor data that needs to track resources. + +What's not scanned: binary files (PNG, XLSX, PDF, etc.). A reference baked into a binary asset won't participate in the cascade — those workflows must use sidecar frontmatter or a paired text file instead. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_copy.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_copy.md index f0f2bf3e0..51140bfa3 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_copy.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_copy.md @@ -2,6 +2,8 @@ Copies a single resource (file or folder) to a new location in the project tree. The original resource is left in place. Folder copies are recursive. The copy is recorded on the explorer undo stack and can be reversed with `explorer_undo`. +A paired `.cel` sidecar is copied alongside its parent. References inside the copied content are *not* rewritten — the copy points at the same targets as the original. If you want the copied content to reference the copies of its dependencies, edit the references after the copy (or rename them through `explorer_move`). + ## destinationResource resolution Resolved against the source: @@ -11,4 +13,15 @@ Resolved against the source: ## Returns -`"ok"` on success. On any failure the destination is not created and an error is returned. +For a clean copy, returns `"ok"`. On any failure the destination is not created and an error is returned. + +When one or more resources in a batch failed mechanically (file locked, IO error), returns a JSON payload: + +```json +{ + "status": "partial_failure", + "failedResources": ["project:source.txt", ...] +} +``` + +Other resources in the batch are still copied; the failed ones are named so you can retry just those. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md index 1dc503e3c..33d0033b2 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md @@ -2,15 +2,64 @@ Deletes a resource from the project tree. Folder deletes are recursive. The delete is recorded on the explorer undo stack and can be reversed with `explorer_undo`, but undo only restores resources that the application itself deleted, so do not rely on it as a substitute for source control. +A paired `.cel` sidecar (e.g. `foo.png.cel` next to `foo.png`) is deleted alongside its parent and restored alongside its parent on undo. + ## showDialog When `false` (the default), the deletion proceeds silently. When `true`, a confirmation dialog opens and the tool waits for the user to confirm or cancel. Prefer the dialog form when the user has not explicitly approved this deletion in the current turn, especially for folders. +## referencePolicy + +Controls what happens when the resource you're deleting is referenced by other resources in the project (via quoted `"project:"` literals in their text content — see [resource_keys](../Concepts/resource_keys.md) for the exact form). Three values: + +- `"require_confirmation"` (default) — if external references exist, a confirmation dialog lists them and waits for the user. Decline cancels the delete. If no references exist, deletes silently. +- `"break_references"` — proceeds without prompting; the references in other files are left as-is and become dangling. Use when the agent has already gathered the user's intent and the dangling state is acceptable (e.g. the user is about to clean up the references themselves). +- `"fail_if_referenced"` — refuses to delete if external references exist; returns an error naming the referencers. Use for batch deletes where the agent needs to know about conflicts before proceeding. + +Intra-batch references are filtered out: deleting `[a, b]` where `a` references `b` does not block on `b`'s referencer because `a` is also going away. + ## Returns -`"ok"` on success. If the user cancels the confirmation dialog, the result is still success — nothing happened, and the project is unchanged. +For a clean delete (every resource deleted successfully, the sidecar cascade went through where one existed, and no external references were touched), returns `"ok"`. If the user cancels either confirmation dialog (the `showDialog` one or the reference-conflict one), the result is still success — nothing happened, and the project is unchanged. + +A JSON payload is returned whenever the response carries information the agent may need to act on, specifically any of: + +- At least one resource failed mechanically (typed `outcome` other than `Deleted`). +- A sidecar cascade reported `Failed`. +- The policy gate refused the batch (`CancelledByUser` / `BlockedByReferences`). +- External references were detected — whether they were broken (`break_references` policy) or blocked the batch (`fail_if_referenced`). The `referencers` field enumerates which files now have dangling references (`break_references`) or which files block the delete (`fail_if_referenced`). + +The JSON shape is: + +```json +{ + "batchOutcome": "DeletedAll" | "DeletedSome" | "CancelledByUser" | "BlockedByReferences", + "resourceResults": [ + { + "resource": "project:doc.md", + "outcome": "Deleted" | "NotFound" | "Locked" | "PermissionDenied" | "IOFailure", + "sidecar": "NotPresent" | "Cascaded" | "Failed", + "failureMessage": "in use by another process (file may be locked by an editor or antivirus)" + } + ], + "referencers": { + "project:doc.md": ["project:other.md", "project:third.md"] + } +} +``` + +- `batchOutcome` summarises the whole batch. `DeletedAll` and `DeletedSome` mean execution ran (the policy gate passed); `CancelledByUser` and `BlockedByReferences` mean the gate refused before any filesystem changes. `DeletedSome` also covers the rare edge where every resource in the batch failed mechanically — inspect `resourceResults` for the per-resource detail in any non-`DeletedAll` case. +- `resourceResults` carries one entry per input resource. `outcome` is typed so the agent can branch on the cause without parsing strings: + - `NotFound` — the resource was already gone on disk. Treat as success — the user's intent is already satisfied. + - `Locked` — another process holds the file (open editor, antivirus, indexer). The fix is usually to close the holding process and re-run. + - `PermissionDenied` — an ACL / POSIX denial. The DOS read-only attribute is cleared before delete, so this is a genuine permissions problem that needs the right account or admin. + - `IOFailure` — catch-all for disk full, network share gone, hardware error, and anything else not fitting the more specific reasons. +- `sidecar` reports the outcome of the paired `.cel` cascade per resource: `NotPresent` (no sidecar existed, or the parent delete didn't run), `Cascaded` (the sidecar was deleted alongside its parent), `Failed` (the sidecar cascade encountered an error — surfaced to the log). +- `failureMessage` is the human-readable detail. `null` when `outcome` is `Deleted`. +- `referencers` maps each input resource to the resources outside the batch that referenced it. Populated when external references were detected, whether the batch proceeded (`BreakReferences` policy) or was gated (`CancelledByUser` / `BlockedByReferences`). ## Gotchas - A delete that targets the document currently open in the editor closes that tab. Document-level state (Monaco undo history, view position) is lost. - Programmatic file edits made before the delete cannot be recovered through Monaco's undo, only through `explorer_undo`. +- Read-only files can be deleted; the read-only attribute is cleared before the operation. The cleared state persists through undo — a restored file is writable. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md index a3aef25ba..29cdd4aaf 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md @@ -1,7 +1,27 @@ # explorer_duplicate -Creates a copy of a resource alongside the original. Always interactive — the rename dialog opens preseeded with a default name, and the user confirms or types a different one before the copy is committed. Use `explorer_copy` for a silent copy to a known destination. +Creates a copy of a resource alongside the original. Silent by default — picks a unique name like `"foo - Copy.md"` (or `"foo - Copy (2).md"`, etc. on collision) in the same folder, performs the copy, and returns the new resource key. Pass `showDialog: true` for the interactive form where the rename dialog opens preseeded and the user confirms or types a different name. + +The copy runs the same cascade as `explorer_copy` — a paired `.cel` sidecar is copied alongside the parent. References inside the duplicated content are *not* rewritten; they keep pointing at the original targets. + +## showDialog + +When `false` (the default), an auto-generated name is used and the duplicate happens without UI. When `true`, the rename dialog opens for the user to confirm or change the name. ## Returns -`"ok"` on success. If the user cancels the dialog, the result is still success and nothing is copied. +Silent form: a JSON payload with the new resource key: + +```json +{ + "status": "ok", + "createdResource": "notes/foo - Copy.md" +} +``` + +Dialog form: `"ok"` on success. If the user cancels the dialog, the result is still success and nothing is duplicated. + +## Gotchas + +- Auto-naming convention is Windows-style: `"foo - Copy.ext"`, then `"foo - Copy (2).ext"`, `"foo - Copy (3).ext"`, etc. A file with no extension (`README`) becomes `"README - Copy"`. A dotfile (`.gitignore`) becomes `".gitignore - Copy"` rather than `" - Copy.gitignore"`. +- For per-resource refinement (rename after duplicate, retarget references to point at the copy instead of the original), follow up with `explorer_move` or text edits. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md index f747a8b44..892291646 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md @@ -2,6 +2,8 @@ Moves a single resource (file or folder) to a new location in the project tree. The original is removed. This is also the silent rename path — pass a destination with a different name in the same parent folder to rename. Folder moves are recursive. The move is recorded on the explorer undo stack. +A paired `.cel` sidecar moves alongside its parent. Every quoted `"project:"` reference to the moved resource (and, for folder moves, every `"project:/"` reference under it) is rewritten in place across the project, so other files keep pointing at the new location. References must be in the canonical quoted form to participate — see [resource_keys](../Concepts/resource_keys.md). + ## destinationResource resolution Resolved against the source: @@ -11,9 +13,30 @@ Resolved against the source: ## Returns -`"ok"` on success. +For a clean move (no skipped referencers, no resource-level failures), returns `"ok"`. + +When the cascade was incomplete or a resource failed, returns a JSON payload with this shape: + +```json +{ + "status": "ok_with_skipped_referencers" | "partial_failure", + "updatedReferencers": ["project:doc.md", ...], + "skippedReferencers": [ + { "resource": "project:locked.md", "reason": "ReadOnly", "message": "file is read-only" }, + ... + ], + "failedResources": ["project:source.txt", ...] +} +``` + +- `status` is `"ok_with_skipped_referencers"` when the move itself completed but the cascade left some references stale; `"partial_failure"` when one or more resources in the batch failed mechanically. +- `updatedReferencers` lists the files whose references were rewritten. +- `skippedReferencers` lists the files the cascade couldn't update. `reason` is one of `ReadFailed` / `WriteFailed` / `ReadOnly` / `PermissionDenied`. `ReadOnly` is the DOS read-only attribute (trivially clearable); `PermissionDenied` is an ACL / POSIX denial (needs the right account or admin). The reference is left as-is and will surface via `metadata_check_project` (Phase 5). Re-running the move after the blocker clears (clear the read-only flag, grant write access, close the editor that holds the lock) completes the cascade idempotently. +- `failedResources` lists source resources whose bytes operation failed. ## Gotchas - Moving the document currently open in the editor updates the tab to point at the new path; the tab does not close. - Renaming a folder that contains open documents updates each open tab's resource path automatically. +- Read-only on the source itself is cleared before the move; read-only on a referencer is *not* cleared (the user invoked move on the source, not on incidental referencers). The referencer is reported in `skippedReferencers` with `reason: "ReadOnly"`. +- A re-run after fixing a blocker completes the residual rewrites; the FS layer is idempotent under partial completion. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_rename.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_rename.md index d1986c025..11f630a1d 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_rename.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_rename.md @@ -2,6 +2,8 @@ Shows the rename dialog for a file or folder. The dialog opens preseeded with the current name and the user types the new name. Use `explorer_move` to rename silently to a known new name without surfacing UI. +The rename runs the same cascade as `explorer_move` — references to the renamed resource are rewritten in place across the project, and a paired `.cel` sidecar moves alongside its parent. Use `explorer_move` instead of this dialog when you need the structured response (skipped referencers, partial failures) — the dialog form returns only `"ok"` or cancel. + ## Returns `"ok"` on success. If the user cancels the dialog, the result is still success and nothing is renamed. diff --git a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Copy.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Copy.cs index bdb272166..567afe409 100644 --- a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Copy.cs +++ b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Copy.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -20,7 +21,7 @@ public async partial Task Copy(string sourceResource, string des return ToolResponse.InvalidResourceKey(destinationResource); } - var copyResult = await ExecuteCommandAsync(command => + var copyResult = await ExecuteCommandAsync(command => { command.SourceResources = new List { sourceResourceKey }; command.DestResource = destinationResourceKey; @@ -31,6 +32,22 @@ public async partial Task Copy(string sourceResource, string des return ToolResponse.Error(copyResult); } - return ToolResponse.Success("ok"); + var detail = copyResult.Value; + + // Copy doesn't rewrite references, so SkippedReferencers is always empty + // here. FailedResources still matters: a batch where one resource failed + // mechanically (file locked etc.) surfaces the partial outcome. + if (detail.FailedResources.Count == 0) + { + return ToolResponse.Success("ok"); + } + + var payload = new + { + status = "partial_failure", + failedResources = detail.FailedResources.Select(r => r.ToString()).ToArray(), + }; + + return ToolResponse.Success(JsonSerializer.Serialize(payload, JsonOptions)); } } diff --git a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Delete.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Delete.cs index d3577014d..5664ef465 100644 --- a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Delete.cs +++ b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Delete.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -9,13 +10,18 @@ public partial class ExplorerTools [McpServerTool(Name = "explorer_delete", Destructive = true)] [ToolAlias("explorer.delete")] [RelatedGuides("resource_keys", "undo_semantics")] - public async partial Task Delete(string resource, bool showDialog = false) + public async partial Task Delete(string resource, bool showDialog = false, string referencePolicy = "require_confirmation") { if (!ResourceKey.TryCreate(resource, out var resourceKey)) { return ToolResponse.InvalidResourceKey(resource); } + if (!TryParseDeleteReferencePolicy(referencePolicy, out var policy)) + { + return ToolResponse.Error($"Invalid reference_policy value: '{referencePolicy}'. Valid values: require_confirmation, fail_if_referenced, break_references."); + } + if (showDialog) { var dialogResult = await ExecuteCommandAsync(command => @@ -30,15 +36,70 @@ public async partial Task Delete(string resource, bool showDialo return ToolResponse.Success("ok"); } - var deleteResult = await ExecuteCommandAsync(command => + var deleteResult = await ExecuteCommandAsync(command => { command.Resources = new List { resourceKey }; + command.ReferencePolicy = policy; }); if (deleteResult.IsFailure) { return ToolResponse.Error(deleteResult); } - return ToolResponse.Success("ok"); + var detail = deleteResult.Value; + + // The typical case (single resource, deleted cleanly, no external + // references broken) returns "ok" so the response stays compact. A + // structured JSON payload is emitted when the agent has actionable + // information to consume: + // - per-resource failures with typed reasons (NotFound, Locked, etc.) + // - batch outcomes that aren't a clean success + // - a sidecar that didn't cascade alongside its parent + // - external references that were touched (under break_references, + // these are now dangling; under any policy, the agent may want to + // follow up on them). + if (detail.BatchOutcome == DeleteBatchOutcome.DeletedAll + && detail.ResourceResults.All(r => r.Outcome == DeleteResourceOutcome.Deleted + && r.Sidecar != SidecarOutcome.Failed) + && detail.Referencers.Count == 0) + { + return ToolResponse.Success("ok"); + } + + var payload = new + { + batchOutcome = detail.BatchOutcome.ToString(), + resourceResults = detail.ResourceResults.Select(r => new + { + resource = r.Resource.ToString(), + outcome = r.Outcome.ToString(), + sidecar = r.Sidecar.ToString(), + failureMessage = r.FailureMessage, + }).ToArray(), + referencers = detail.Referencers.ToDictionary( + entry => entry.Key.ToString(), + entry => entry.Value.Select(r => r.ToString()).ToArray()), + }; + + return ToolResponse.Success(JsonSerializer.Serialize(payload, JsonOptions)); + } + + private static bool TryParseDeleteReferencePolicy(string value, out DeleteReferencePolicy policy) + { + switch (value) + { + case "require_confirmation": + policy = DeleteReferencePolicy.RequireConfirmation; + return true; + case "fail_if_referenced": + policy = DeleteReferencePolicy.FailIfReferenced; + return true; + case "break_references": + policy = DeleteReferencePolicy.BreakReferences; + return true; + default: + policy = DeleteReferencePolicy.RequireConfirmation; + return false; + } } } diff --git a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Duplicate.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Duplicate.cs index 84bf69450..726c6d282 100644 --- a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Duplicate.cs +++ b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Duplicate.cs @@ -1,3 +1,9 @@ +using System.Text.Json; +using Celbridge.DataTransfer; +using Celbridge.Explorer; +using Celbridge.Resources; +using Celbridge.Utilities; +using Celbridge.Workspace; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -5,26 +11,70 @@ namespace Celbridge.Tools; public partial class ExplorerTools { - /// Duplicate a resource in place via the interactive rename dialog (user picks the new name). + /// Duplicate a resource in place; silent by default (auto-generates a unique name). [McpServerTool(Name = "explorer_duplicate")] [ToolAlias("explorer.duplicate")] [RelatedGuides("resource_keys", "undo_semantics")] - public async partial Task Duplicate(string resource) + public async partial Task Duplicate(string resource, bool showDialog = false) { if (!ResourceKey.TryCreate(resource, out var resourceKey)) { return ToolResponse.InvalidResourceKey(resource); } - var duplicateResult = await ExecuteCommandAsync(command => + if (showDialog) { - command.Resource = resourceKey; + var dialogResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + }); + if (dialogResult.IsFailure) + { + return ToolResponse.Error(dialogResult); + } + return ToolResponse.Success("ok"); + } + + var workspaceWrapper = GetRequiredService(); + if (!workspaceWrapper.IsWorkspacePageLoaded) + { + return ToolResponse.Error("Workspace is not loaded."); + } + var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var getResult = resourceRegistry.GetResource(resourceKey); + if (getResult.IsFailure) + { + return ToolResponse.Error($"Cannot duplicate resource '{resourceKey}': resource does not exist."); + } + + var destKeyResult = ResourceNameHelper.GenerateUniqueDuplicateKey(resourceKey, resourceRegistry); + if (destKeyResult.IsFailure) + { + return ToolResponse.Error(destKeyResult); + } + var destResource = destKeyResult.Value; + + // Issue Copy directly rather than wrapping it in another command that + // would have to await it from inside its executor. The command queue + // is single-threaded; a command's body awaiting another command via + // ExecuteAsync deadlocks the queue. + var copyResult = await ExecuteCommandAsync(command => + { + command.SourceResources = new List { resourceKey }; + command.DestResource = destResource; + command.TransferMode = DataTransferMode.Copy; }); - if (duplicateResult.IsFailure) + if (copyResult.IsFailure) { - return ToolResponse.Error(duplicateResult); + return ToolResponse.Error(copyResult); } - return ToolResponse.Success("ok"); + var payload = new + { + status = "ok", + createdResource = destResource.ToString(), + }; + return ToolResponse.Success(JsonSerializer.Serialize(payload, JsonOptions)); } } diff --git a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs index 472e736d1..ae34ce94a 100644 --- a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs +++ b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -20,17 +21,44 @@ public async partial Task Move(string sourceResource, string des return ToolResponse.InvalidResourceKey(destinationResource); } - var copyResult = await ExecuteCommandAsync(command => + var moveResult = await ExecuteCommandAsync(command => { command.SourceResources = new List { sourceResourceKey }; command.DestResource = destinationResourceKey; command.TransferMode = DataTransferMode.Move; }); - if (copyResult.IsFailure) + if (moveResult.IsFailure) { - return ToolResponse.Error(copyResult); + return ToolResponse.Error(moveResult); } - return ToolResponse.Success("ok"); + var detail = moveResult.Value; + + // For the typical case (clean rename with no skipped referencers and no + // failed resources), return the simple "ok" so the response stays compact. + // Surface a structured JSON payload only when there is actionable + // information for the agent: skipped referencers (the cascade left a + // stale reference because the file was read-only or locked) or failed + // resources (the move itself didn't apply for a resource in the batch). + if (detail.SkippedReferencers.Count == 0 + && detail.FailedResources.Count == 0) + { + return ToolResponse.Success("ok"); + } + + var payload = new + { + status = detail.FailedResources.Count == 0 ? "ok_with_skipped_referencers" : "partial_failure", + updatedReferencers = detail.UpdatedReferencers.Select(r => r.ToString()).ToArray(), + skippedReferencers = detail.SkippedReferencers.Select(s => new + { + resource = s.Resource.ToString(), + reason = s.Reason.ToString(), + message = s.Message, + }).ToArray(), + failedResources = detail.FailedResources.Select(r => r.ToString()).ToArray(), + }; + + return ToolResponse.Success(JsonSerializer.Serialize(payload, JsonOptions)); } } diff --git a/Source/Core/Celbridge.Utilities/Helpers/ResourceNameHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/ResourceNameHelper.cs new file mode 100644 index 000000000..3d94fbdc2 --- /dev/null +++ b/Source/Core/Celbridge.Utilities/Helpers/ResourceNameHelper.cs @@ -0,0 +1,65 @@ +using Celbridge.Resources; + +namespace Celbridge.Utilities; + +/// +/// Helpers for working with resource names. Currently provides duplicate-name +/// generation shared by the silent and dialog duplicate paths so both follow +/// the same auto-naming convention. +/// +public static class ResourceNameHelper +{ + // Bounded so a pathological folder full of "X - Copy (N)" entries can't + // spin the search loop indefinitely. 1000 attempts covers any realistic + // ceiling and still surfaces a clean failure if exceeded. + private const int MaxNameCollisionAttempts = 1000; + + /// + /// Generates a unique destination key in the same folder as the source by + /// trying " - Copy" first, then " - Copy (2)", + /// " - Copy (3)", etc. until an unused name is found. Returns + /// a failure Result when MaxNameCollisionAttempts is exhausted (rare in + /// practice; would require a folder containing 1000 existing copies of + /// the same source name). + /// + /// A dot at the very start of the name is treated as part of the basename + /// (so ".gitignore" → ".gitignore - Copy" rather than " - Copy.gitignore"). + /// + public static Result GenerateUniqueDuplicateKey(ResourceKey source, IResourceRegistry registry) + { + var parent = source.GetParent(); + var resourceName = source.ResourceName; + + int extensionIndex = resourceName.LastIndexOf('.'); + string baseName; + string extension; + if (extensionIndex > 0) + { + baseName = resourceName.Substring(0, extensionIndex); + extension = resourceName.Substring(extensionIndex); + } + else + { + baseName = resourceName; + extension = string.Empty; + } + + var firstCandidate = parent.Combine($"{baseName} - Copy{extension}"); + if (registry.GetResource(firstCandidate).IsFailure) + { + return firstCandidate; + } + + for (int attempt = 2; attempt <= MaxNameCollisionAttempts; attempt++) + { + var candidate = parent.Combine($"{baseName} - Copy ({attempt}){extension}"); + if (registry.GetResource(candidate).IsFailure) + { + return candidate; + } + } + + return Result.Fail( + $"Could not generate a unique duplicate name for '{source}' after {MaxNameCollisionAttempts} attempts."); + } +} diff --git a/Source/Tests/Resources/ResourceFileSystemTests.cs b/Source/Tests/Resources/ResourceFileSystemTests.cs index ef4f6adcf..e05c14c61 100644 --- a/Source/Tests/Resources/ResourceFileSystemTests.cs +++ b/Source/Tests/Resources/ResourceFileSystemTests.cs @@ -15,6 +15,7 @@ public class ResourceFileSystemTests { private string _tempFolder = null!; private IResourceRegistry _resourceRegistry = null!; + private IResourceMetaData _resourceMetaData = null!; private ResourceFileSystem _fileSystem = null!; [SetUp] @@ -29,12 +30,20 @@ public void Setup() _resourceRegistry = Substitute.For(); _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + _resourceRegistry.RootHandlers.Returns(new Dictionary()); + + _resourceMetaData = Substitute.For(); + _resourceMetaData.WaitUntilReadyAsync().Returns(Task.CompletedTask); + _resourceMetaData.WaitForPendingUpdatesAsync().Returns(Task.CompletedTask); + _resourceMetaData.GetReferencers(Arg.Any()).Returns(Array.Empty()); + _resourceMetaData.GetAllReferencedTargets().Returns(Array.Empty()); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); var workspaceService = Substitute.For(); workspaceService.ResourceService.Returns(resourceService); + workspaceService.ResourceMetaData.Returns(_resourceMetaData); var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); @@ -283,24 +292,420 @@ public async Task ExistsAsync_ReturnsFailure_WhenResolveFails() } [Test] - public void MoveAsync_ThrowsNotImplementedException_InPhase1a() + public async Task MoveAsync_MovesFile_WhenNoReferencersAndNoSidecar() { - // Structural operations land in fs-1b. - Assert.ThrowsAsync( - () => _fileSystem.MoveAsync(new ResourceKey("a"), new ResourceKey("b"))); + var sourceKey = new ResourceKey("a.txt"); + var destKey = new ResourceKey("b.txt"); + var sourcePath = Path.Combine(_tempFolder, "a.txt"); + var destPath = Path.Combine(_tempFolder, "b.txt"); + await File.WriteAllTextAsync(sourcePath, "hello"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + var sidecarSource = new ResourceKey("a.txt.cel"); + var sidecarDest = new ResourceKey("b.txt.cel"); + _resourceRegistry.ResolveResourcePath(sidecarSource).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(sidecarDest).Returns(Result.Ok(destPath + ".cel")); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + File.Exists(sourcePath).Should().BeFalse(); + File.Exists(destPath).Should().BeTrue(); + (await File.ReadAllTextAsync(destPath)).Should().Be("hello"); + result.Value.UpdatedReferencers.Should().BeEmpty(); + result.Value.Sidecar.Should().Be(SidecarOutcome.NotPresent); } [Test] - public void CopyAsync_ThrowsNotImplementedException_InPhase1a() + public async Task MoveAsync_RejectsCrossRootMove() { - Assert.ThrowsAsync( - () => _fileSystem.CopyAsync(new ResourceKey("a"), new ResourceKey("b"))); + var sourceKey = new ResourceKey("project:a.txt"); + var destKey = new ResourceKey("temp:a.txt"); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("Cross-root"); } [Test] - public void DeleteAsync_ThrowsNotImplementedException_InPhase1a() + public async Task MoveAsync_CascadesSidecarWithFile() { - Assert.ThrowsAsync( - () => _fileSystem.DeleteAsync(new ResourceKey("a"))); + var sourceKey = new ResourceKey("a.txt"); + var destKey = new ResourceKey("b.txt"); + var sidecarSource = new ResourceKey("a.txt.cel"); + var sidecarDest = new ResourceKey("b.txt.cel"); + + var sourcePath = Path.Combine(_tempFolder, "a.txt"); + var destPath = Path.Combine(_tempFolder, "b.txt"); + var sourceSidecarPath = sourcePath + ".cel"; + var destSidecarPath = destPath + ".cel"; + await File.WriteAllTextAsync(sourcePath, "hello"); + await File.WriteAllTextAsync(sourceSidecarPath, "+++\n+++\n"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(sidecarSource).Returns(Result.Ok(sourceSidecarPath)); + _resourceRegistry.ResolveResourcePath(sidecarDest).Returns(Result.Ok(destSidecarPath)); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.Sidecar.Should().Be(SidecarOutcome.Cascaded); + File.Exists(sourcePath).Should().BeFalse(); + File.Exists(sourceSidecarPath).Should().BeFalse(); + File.Exists(destPath).Should().BeTrue(); + File.Exists(destSidecarPath).Should().BeTrue(); + } + + [Test] + public async Task MoveAsync_FailsWhenDestinationExists() + { + var sourceKey = new ResourceKey("a.txt"); + var destKey = new ResourceKey("b.txt"); + var sourcePath = Path.Combine(_tempFolder, "a.txt"); + var destPath = Path.Combine(_tempFolder, "b.txt"); + await File.WriteAllTextAsync(sourcePath, "src"); + await File.WriteAllTextAsync(destPath, "dst"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsFailure.Should().BeTrue(); + // Source still in place; destination unchanged. + (await File.ReadAllTextAsync(sourcePath)).Should().Be("src"); + (await File.ReadAllTextAsync(destPath)).Should().Be("dst"); + } + + [Test] + public async Task MoveAsync_RewritesReferencers() + { + var sourceKey = new ResourceKey("source.txt"); + var destKey = new ResourceKey("dest.txt"); + var referencerKey = new ResourceKey("doc.md"); + + var sourcePath = Path.Combine(_tempFolder, "source.txt"); + var destPath = Path.Combine(_tempFolder, "dest.txt"); + var referencerPath = Path.Combine(_tempFolder, "doc.md"); + await File.WriteAllTextAsync(sourcePath, "data"); + await File.WriteAllTextAsync(referencerPath, "See \"project:source.txt\" for details."); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(referencerKey).Returns(Result.Ok(referencerPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("source.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("dest.txt.cel")).Returns(Result.Ok(destPath + ".cel")); + + _resourceMetaData.GetReferencers(sourceKey).Returns(new[] { referencerKey }); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.UpdatedReferencers.Should().Contain(referencerKey); + (await File.ReadAllTextAsync(referencerPath)).Should().Be("See \"project:dest.txt\" for details."); + } + + [Test] + public async Task MoveAsync_DoesNotRewriteUnquotedOccurrencesAtFileBoundaries() + { + // The rewrite cascade visits files the scanner indexed. Within a + // visited file, IndexOf may also match incidental occurrences of the + // source literal that aren't quoted references. Boundary checks must + // reject those — including at position 0 and end-of-text, where an + // earlier implementation short-circuited the check and silently + // rewrote unquoted byte sequences. + var sourceKey = new ResourceKey("source.txt"); + var destKey = new ResourceKey("dest.txt"); + var referencerKey = new ResourceKey("doc.md"); + + var sourcePath = Path.Combine(_tempFolder, "source.txt"); + var destPath = Path.Combine(_tempFolder, "dest.txt"); + var referencerPath = Path.Combine(_tempFolder, "doc.md"); + await File.WriteAllTextAsync(sourcePath, "data"); + + // The file contains the literal "project:source.txt" at three positions: + // 1. Start of file, no leading quote (incidental, must NOT rewrite). + // 2. Middle, properly quoted (real reference, MUST rewrite). + // 3. End of file, no trailing quote (incidental, must NOT rewrite). + var initialContent = "project:source.txt at start. See \"project:source.txt\" here. Trailing project:source.txt"; + await File.WriteAllTextAsync(referencerPath, initialContent); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(referencerKey).Returns(Result.Ok(referencerPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("source.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("dest.txt.cel")).Returns(Result.Ok(destPath + ".cel")); + + _resourceMetaData.GetReferencers(sourceKey).Returns(new[] { referencerKey }); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.UpdatedReferencers.Should().Contain(referencerKey); + + // Only the middle (quoted) occurrence is rewritten. The unquoted ones + // at the start and end of the file remain pointing at the old name. + var expected = "project:source.txt at start. See \"project:dest.txt\" here. Trailing project:source.txt"; + (await File.ReadAllTextAsync(referencerPath)).Should().Be(expected); + } + + [Test] + public async Task MoveAsync_RewritesQuotedReferencerWithSpaceInKey() + { + // A reference inside ASCII double quotes — the only delimiter that + // allows whitespace in the key under Option C — must be rewritten by + // the cascade with the same delimiter-aware boundary check used by + // detection. + var sourceKey = new ResourceKey("My Document.md"); + var destKey = new ResourceKey("My Renamed Document.md"); + var referencerKey = new ResourceKey("doc.md"); + + var sourcePath = Path.Combine(_tempFolder, "My Document.md"); + var destPath = Path.Combine(_tempFolder, "My Renamed Document.md"); + var referencerPath = Path.Combine(_tempFolder, "doc.md"); + await File.WriteAllTextAsync(sourcePath, "data"); + await File.WriteAllTextAsync(referencerPath, + "See \"project:My Document.md\" and also 'project:My Document.md' as well."); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(referencerKey).Returns(Result.Ok(referencerPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("My Document.md.cel")).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("My Renamed Document.md.cel")).Returns(Result.Ok(destPath + ".cel")); + + _resourceMetaData.GetReferencers(sourceKey).Returns(new[] { referencerKey }); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.UpdatedReferencers.Should().Contain(referencerKey); + (await File.ReadAllTextAsync(referencerPath)).Should().Be( + "See \"project:My Renamed Document.md\" and also 'project:My Renamed Document.md' as well."); + } + + [Test] + public async Task MoveAsync_RewritesJsonEscapedReferencer() + { + // The reference sits inside a JSON-escape sequence \"project:...\" + // (e.g. an MCP tool response stored as a JSON string). The scanner + // detects it via the two-char \" opener and the cascade must rewrite + // it via the matching trailing-\\ boundary on IsNonKeyBoundary. + var sourceKey = new ResourceKey("foo.md"); + var destKey = new ResourceKey("bar.md"); + var referencerKey = new ResourceKey("payload.json"); + + var sourcePath = Path.Combine(_tempFolder, "foo.md"); + var destPath = Path.Combine(_tempFolder, "bar.md"); + var referencerPath = Path.Combine(_tempFolder, "payload.json"); + await File.WriteAllTextAsync(sourcePath, "data"); + await File.WriteAllTextAsync(referencerPath, + "{\"description\": \"See \\\"project:foo.md\\\" for details\"}"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(referencerKey).Returns(Result.Ok(referencerPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("foo.md.cel")).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("bar.md.cel")).Returns(Result.Ok(destPath + ".cel")); + + _resourceMetaData.GetReferencers(sourceKey).Returns(new[] { referencerKey }); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.UpdatedReferencers.Should().Contain(referencerKey); + (await File.ReadAllTextAsync(referencerPath)).Should().Be( + "{\"description\": \"See \\\"project:bar.md\\\" for details\"}"); + } + + [Test] + public async Task CopyAsync_CopiesFile_AndCascadesSidecar() + { + var sourceKey = new ResourceKey("a.txt"); + var destKey = new ResourceKey("b.txt"); + var sourcePath = Path.Combine(_tempFolder, "a.txt"); + var destPath = Path.Combine(_tempFolder, "b.txt"); + var sourceSidecarPath = sourcePath + ".cel"; + var destSidecarPath = destPath + ".cel"; + + await File.WriteAllTextAsync(sourcePath, "hello"); + await File.WriteAllTextAsync(sourceSidecarPath, "+++\n+++\n"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("a.txt.cel")).Returns(Result.Ok(sourceSidecarPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("b.txt.cel")).Returns(Result.Ok(destSidecarPath)); + + var result = await _fileSystem.CopyAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.Sidecar.Should().Be(SidecarOutcome.Cascaded); + File.Exists(sourcePath).Should().BeTrue(); + File.Exists(destPath).Should().BeTrue(); + File.Exists(sourceSidecarPath).Should().BeTrue(); + File.Exists(destSidecarPath).Should().BeTrue(); + } + + [Test] + public async Task DeleteAsync_DeletesFile_AndCascadesSidecar() + { + var sourceKey = new ResourceKey("a.txt"); + var sourcePath = Path.Combine(_tempFolder, "a.txt"); + var sourceSidecarPath = sourcePath + ".cel"; + await File.WriteAllTextAsync(sourcePath, "hello"); + await File.WriteAllTextAsync(sourceSidecarPath, "+++\n+++\n"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("a.txt.cel")).Returns(Result.Ok(sourceSidecarPath)); + + var result = await _fileSystem.DeleteAsync(sourceKey); + + result.IsSuccess.Should().BeTrue(); + result.Value.Sidecar.Should().Be(SidecarOutcome.Cascaded); + File.Exists(sourcePath).Should().BeFalse(); + File.Exists(sourceSidecarPath).Should().BeFalse(); + } + + [Test] + public async Task DeleteAsync_FailsWhenSourceMissing() + { + var sourceKey = new ResourceKey("missing.txt"); + var sourcePath = Path.Combine(_tempFolder, "missing.txt"); + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + + var result = await _fileSystem.DeleteAsync(sourceKey); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task ReadAllTextAsync_RetriesAndSucceeds_WhenLockReleasesQuickly() + { + var resource = new ResourceKey("locked.txt"); + var path = Path.Combine(_tempFolder, "locked.txt"); + await File.WriteAllTextAsync(path, "after release"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + // Briefly hold the file with FileShare.None so the first read attempt + // hits a sharing violation, then release it before the retry budget + // expires. The retry should succeed and return the file content. + var lockStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); + var releaseTask = Task.Run(async () => + { + await Task.Delay(75); + lockStream.Dispose(); + }); + + var result = await _fileSystem.ReadAllTextAsync(resource); + await releaseTask; + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("after release"); + } + + [Test] + public async Task DeleteAsync_DeletesReadOnlyFile() + { + var sourceKey = new ResourceKey("readonly.txt"); + var sourcePath = Path.Combine(_tempFolder, "readonly.txt"); + await File.WriteAllTextAsync(sourcePath, "content"); + new FileInfo(sourcePath).IsReadOnly = true; + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("readonly.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); + + var result = await _fileSystem.DeleteAsync(sourceKey); + + result.IsSuccess.Should().BeTrue(); + File.Exists(sourcePath).Should().BeFalse(); + } + + [Test] + public async Task MoveAsync_MovesReadOnlyFile() + { + var sourceKey = new ResourceKey("readonly.txt"); + var destKey = new ResourceKey("renamed.txt"); + var sourcePath = Path.Combine(_tempFolder, "readonly.txt"); + var destPath = Path.Combine(_tempFolder, "renamed.txt"); + await File.WriteAllTextAsync(sourcePath, "content"); + new FileInfo(sourcePath).IsReadOnly = true; + + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("readonly.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("renamed.txt.cel")).Returns(Result.Ok(destPath + ".cel")); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + File.Exists(sourcePath).Should().BeFalse(); + File.Exists(destPath).Should().BeTrue(); + } + + [Test] + public async Task MoveAsync_SkipsReadOnlyReferencer_AndReportsItInResult() + { + var sourceKey = new ResourceKey("target.txt"); + var destKey = new ResourceKey("target2.txt"); + var referencerKey = new ResourceKey("doc.md"); + + var sourcePath = Path.Combine(_tempFolder, "target.txt"); + var destPath = Path.Combine(_tempFolder, "target2.txt"); + var referencerPath = Path.Combine(_tempFolder, "doc.md"); + await File.WriteAllTextAsync(sourcePath, "data"); + await File.WriteAllTextAsync(referencerPath, "See \"project:target.txt\" for details."); + new FileInfo(referencerPath).IsReadOnly = true; + + try + { + _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); + _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); + _resourceRegistry.ResolveResourcePath(referencerKey).Returns(Result.Ok(referencerPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("target.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("target2.txt.cel")).Returns(Result.Ok(destPath + ".cel")); + _resourceRegistry.ResolveResourcePath(new ResourceKey("doc.md")).Returns(Result.Ok(referencerPath)); + + _resourceMetaData.GetReferencers(sourceKey).Returns(new[] { referencerKey }); + + var result = await _fileSystem.MoveAsync(sourceKey, destKey); + + result.IsSuccess.Should().BeTrue(); + // Parent move completed even though the referencer was read-only. + File.Exists(destPath).Should().BeTrue(); + // The referencer is reported in SkippedReferencers with the right reason. + result.Value.SkippedReferencers.Should().HaveCount(1); + result.Value.SkippedReferencers[0].Resource.Should().Be(referencerKey); + result.Value.SkippedReferencers[0].Reason.Should().Be(ReferencerSkipReason.ReadOnly); + result.Value.UpdatedReferencers.Should().BeEmpty(); + } + finally + { + // Tear-down needs the file to be writable so the temp-folder delete works. + if (File.Exists(referencerPath)) + { + new FileInfo(referencerPath).IsReadOnly = false; + } + } + } + + [Test] + public async Task ReadAllBytesAsync_FailsImmediately_WhenFileMissing_WithoutRetry() + { + // FileNotFoundException is permanent; the retry budget should not be + // spent on it. The test verifies fast failure by measuring elapsed time + // — well under the total retry-budget upper bound (50+100+150 = 300ms). + var resource = new ResourceKey("missing.bin"); + var path = Path.Combine(_tempFolder, "missing.bin"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await _fileSystem.ReadAllBytesAsync(resource); + stopwatch.Stop(); + + result.IsFailure.Should().BeTrue(); + stopwatch.ElapsedMilliseconds.Should().BeLessThan(50); } } diff --git a/Source/Tests/Resources/ResourceMetaDataTests.cs b/Source/Tests/Resources/ResourceMetaDataTests.cs index ab53fcb8e..0a28adefc 100644 --- a/Source/Tests/Resources/ResourceMetaDataTests.cs +++ b/Source/Tests/Resources/ResourceMetaDataTests.cs @@ -76,22 +76,39 @@ public void TearDown() } [Test] - public void ScanTextForReferences_FindsAllValidProjectReferences() + public void ScanTextForReferences_FindsQuotedReferences() { - var text = "Some text with \"project:foo/bar.md\" and project:other/file.txt embedded."; + // Both double-quoted and single-quoted references are detected; the + // unquoted "project:other/file.txt" between them is not a tracked + // reference because references must always be quoted. + var text = "Some text with \"project:foo/bar.md\" and project:other/file.txt and 'project:third/file.md' embedded."; var references = ResourceMetaData.ScanTextForReferences(text); references.Should().Contain(new ResourceKey("project:foo/bar.md")); - references.Should().Contain(new ResourceKey("project:other/file.txt")); + references.Should().Contain(new ResourceKey("project:third/file.md")); + references.Should().NotContain(new ResourceKey("project:other/file.txt")); } [Test] - public void ScanTextForReferences_SkipsInvalidCandidates() + public void ScanTextForReferences_SkipsBareReferences() { - // project: followed by an invalid character sequence (double slashes) should - // not produce a reference. - var text = "garbage project://invalid more garbage"; + // A "project:" marker not preceded by an ASCII quote is not a tracked + // reference, even if it parses as a valid key. This is the contract + // that lets the scanner avoid false positives in arbitrary prose. + var text = "see project:foo/bar.md and project:another/file.txt for details"; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().BeEmpty(); + } + + [Test] + public void ScanTextForReferences_SkipsInvalidQuotedCandidates() + { + // Quoted but structurally invalid (double slashes are not legal in a + // resource key path). The candidate is dropped silently. + var text = "garbage \"project://invalid\" more garbage"; var references = ResourceMetaData.ScanTextForReferences(text); @@ -110,6 +127,121 @@ public void ScanTextForReferences_StopsAtKeyTerminators() references.Should().Contain(new ResourceKey("project:foo/bar.md")); } + [TestCase('"')] + [TestCase('\'')] + public void ScanTextForReferences_FindsKeyWithSpacesInsideAsciiQuotes(char quote) + { + var text = $"target = {quote}project:docs/My Document.md{quote}"; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().HaveCount(1); + references.Should().Contain(new ResourceKey("project:docs/My Document.md")); + } + + [TestCase('"')] + [TestCase('\'')] + public void ScanTextForReferences_FindsJsonEscapedQuotedReference(char quote) + { + // JSON / TOML basic / C-family escape sequence \" or \'. The two-char + // opener takes precedence over the single-char opener so the delimited + // region closes on the matching \" or \' two-char sequence. + var text = $"text = \"See \\{quote}project:docs/My Doc.md\\{quote} thanks\""; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().Contain(new ResourceKey("project:docs/My Doc.md")); + } + + [Test] + public void ScanTextForReferences_RejectsBracketWrappedReferences() + { + // Only ASCII " and ' open a delimited region. A reference wrapped in + // brackets (or any other char) is not a tracked reference. There is + // no bare-scan fallback, so nothing is detected. + var text = "see [project:docs/My Document.md] and (project:foo) for details"; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().BeEmpty(); + } + + [Test] + public void ScanTextForReferences_RejectsReferencesWithUnmatchedQuote() + { + // The opening " starts a delimited scan looking for the closing ". + // The line ends before the close is found, so the candidate is dropped. + // No bare fallback, no phantom reference. + var text = "see \"project:docs/foo and more text"; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().BeEmpty(); + } + + [Test] + public void ScanTextForReferences_RejectsReferencesThatSpanNewlines() + { + // A newline inside the supposed delimited region aborts the scan and + // the candidate is dropped. References do not span lines. + var text = "\"project:docs/foo\nbar.md\""; + + var references = ResourceMetaData.ScanTextForReferences(text); + + references.Should().BeEmpty(); + } + + [Test] + public void ScannerAndRewrite_AgreeOnDetectedReferencePositions() + { + // Symmetry property: for every reference the scanner detects, the + // rewrite cascade's leading and trailing boundary checks must accept + // the same position. If this test fails, the two code paths have + // drifted and the cascade will silently skip references the index + // thinks exist. + var samples = new[] + { + "double-quoted: target = \"project:foo.md\"", + "single-quoted: target = 'project:foo.md'", + "double-quoted with space: target = \"project:docs/My Doc.md\"", + "single-quoted with space: target = 'project:docs/My Doc.md'", + "json-escaped: text = \"See \\\"project:foo.md\\\" thanks\"", + "json-escaped with space: text = \"See \\\"project:docs/My Doc.md\\\" thanks\"", + "two refs: \"project:first.md\" then \"project:second.md\"", + "double then single: \"project:a.md\" plus 'project:b.md'", + "back-to-back: \"project:x.md\"\"project:y.md\"", + "reference at start of file: \"project:start.md\" and the rest", + }; + + foreach (var sample in samples) + { + var detected = ResourceMetaData.ScanTextForReferences(sample); + + // Every sample is constructed to contain at least one detectable + // reference; an empty result means the scanner has regressed. + detected.Should().NotBeEmpty($"sample '{sample}' has at least one tracked reference"); + + foreach (var key in detected) + { + var sourceLiteral = ReferenceLiteralRules.ReferenceMarker + key.Path; + int matchIndex = sample.IndexOf(sourceLiteral, StringComparison.Ordinal); + matchIndex.Should().BeGreaterThanOrEqualTo(0, + $"scanner detected '{key}' in '{sample}' but the literal isn't there"); + + bool leadingOk = matchIndex == 0 + || ReferenceLiteralRules.IsNonKeyBoundary(sample[matchIndex - 1]); + leadingOk.Should().BeTrue( + $"leading boundary check would reject the rewrite for '{key}' in '{sample}'"); + + int afterMatch = matchIndex + sourceLiteral.Length; + bool trailingOk = afterMatch == sample.Length + || ReferenceLiteralRules.IsNonKeyBoundary(sample[afterMatch]); + trailingOk.Should().BeTrue( + $"trailing boundary check would reject the rewrite for '{key}' in '{sample}'"); + } + } + } + [Test] public async Task RebuildAsync_ProducesReferenceGraph() { @@ -134,11 +266,13 @@ public async Task RebuildAsync_ProducesReferenceGraph() [Test] public async Task RebuildAsync_SkipsBinaryFiles() { - // A PNG containing the literal bytes "project:foo" should be skipped. - // We synthesise a minimal PNG-ish binary file (8-byte signature + arbitrary bytes - // including the "project:foo" string). + // A PNG containing a quoted reference literal must still be skipped — + // binary files don't participate in the reference graph regardless of + // their bytes. Using a quoted form (rather than bare bytes) ensures the + // skip behaviour is exercised against a literal the scanner would + // otherwise track if the file were text. var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; - var marker = System.Text.Encoding.UTF8.GetBytes("project:foo"); + var marker = System.Text.Encoding.UTF8.GetBytes("\"project:foo\""); var pngBytes = new byte[pngSignature.Length + marker.Length]; Buffer.BlockCopy(pngSignature, 0, pngBytes, 0, pngSignature.Length); Buffer.BlockCopy(marker, 0, pngBytes, pngSignature.Length, marker.Length); @@ -205,4 +339,38 @@ public void FrontmatterMethods_ThrowNotImplementedException() Assert.Throws(() => _metaData.GetTags(resource)); Assert.Throws(() => _metaData.FindByTag("x")); } + + [Test] + public async Task TransientReadFailure_PreservesExistingIndexEntries() + { + // Index a file with a known reference, then lock it exclusively to + // simulate an external editor holding the file open mid-write. A + // change event during the lock must not drop the existing reference + // entries — the transient failure should be retried, not converted to + // "this file has no references". + var sourcePath = Path.Combine(_projectFolderPath, "source.md"); + var targetPath = Path.Combine(_projectFolderPath, "target.md"); + File.WriteAllText(sourcePath, "Refers to \"project:target.md\"."); + File.WriteAllText(targetPath, "Target file."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + var sourceKey = new ResourceKey("source.md"); + var targetKey = new ResourceKey("target.md"); + _metaData.GetReferencers(targetKey).Should().Contain(sourceKey); + + using (var lockStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + // While the file is locked, send a change event. The worker will + // attempt to read source.md, fail with an IOException, and classify + // the failure as transient. + _messengerService.Send(new MonitoredResourceChangedMessage(sourceKey)); + await _metaData.WaitForPendingUpdatesAsync(); + await Task.Delay(150); + + // The existing index entry for source.md → target.md must survive. + _metaData.GetReferencers(targetKey).Should().Contain(sourceKey); + } + } } diff --git a/Source/Workspace/Celbridge.Explorer/Commands/DuplicateResourceDialogCommand.cs b/Source/Workspace/Celbridge.Explorer/Commands/DuplicateResourceDialogCommand.cs index f49ad55ef..37a40f287 100644 --- a/Source/Workspace/Celbridge.Explorer/Commands/DuplicateResourceDialogCommand.cs +++ b/Source/Workspace/Celbridge.Explorer/Commands/DuplicateResourceDialogCommand.cs @@ -1,5 +1,8 @@ using Celbridge.Commands; +using Celbridge.DataTransfer; using Celbridge.Dialog; +using Celbridge.Resources; +using Celbridge.Utilities; using Celbridge.Workspace; using Microsoft.Extensions.Localization; @@ -52,21 +55,28 @@ private async Task ShowDuplicateResourceDialogAsync() } var resource = getResult.Value; - var resourceName = resource.Name; - - // Select only the filename part without the extension - var extensionIndex = resourceName.LastIndexOf('.'); + // Pre-populate the dialog with the auto-generated name the silent + // duplicate path would have chosen (e.g. "foo - Copy.md"). Matches + // Windows Explorer / macOS Finder behaviour and saves keystrokes in + // the common case; the user can still clear and type something else. + // If the helper somehow can't produce a unique name (very rare; would + // mean 1000+ existing copies of this name) we fall back to the + // original name and let the validator reject it on dialog submit. + var defaultKeyResult = ResourceNameHelper.GenerateUniqueDuplicateKey(Resource, resourceRegistry); + var defaultText = defaultKeyResult.IsSuccess + ? defaultKeyResult.Value.ResourceName + : resource.Name; + + // Select only the filename part without the extension so the user can + // type a replacement basename immediately. + var extensionIndex = defaultText.LastIndexOf('.'); var selectedRange = extensionIndex > 0 ? 0..extensionIndex : ..; - var duplicateResourceString = _stringLocalizer.GetString("ResourceTree_DuplicateResource", resourceName); - - var defaultText = resourceName; + var duplicateResourceString = _stringLocalizer.GetString("ResourceTree_DuplicateResource", resource.Name); + var enterNameString = _stringLocalizer.GetString("ResourceTree_DuplicateResourceEnterName"); var validator = _serviceProvider.GetRequiredService(); validator.ParentFolder = resource.ParentFolder; - validator.ValidNames.Add(resourceName); // The original name is always valid when renaming - - var enterNameString = _stringLocalizer.GetString("ResourceTree_DuplicateResourceEnterName"); var showResult = await _dialogService.ShowInputTextDialogAsync( duplicateResourceString, @@ -78,33 +88,27 @@ private async Task ShowDuplicateResourceDialogAsync() if (showResult.IsSuccess) { var inputText = showResult.Value; + var destResource = Resource.GetParent().Combine(inputText); - var sourceParentResource = Resource.GetParent(); - var destResource = sourceParentResource.Combine(inputText); - - if (Resource == destResource) + // Preserve folder-expansion state across the copy so a duplicated + // expanded folder lands expanded in the tree. + bool isExpandedFolder = false; + if (resource is IFolderResource) { - // Choosing the original name is treated as a cancel. - return Result.Ok(); + var folderStateService = _workspaceWrapper.WorkspaceService.ExplorerService.FolderStateService; + isExpandedFolder = folderStateService.IsExpanded(Resource); } - bool isFolderResource = resource is IFolderResource; - - // Maintain the expanded state of folders after rename - var folderStateService = _workspaceWrapper.WorkspaceService.ExplorerService.FolderStateService; - bool isExpandedFolder = isFolderResource && - folderStateService.IsExpanded(Resource); - - // Execute a command to copy the resource to perform the duplication + // Issue the copy as a top-level command rather than wrapping it in + // another command that would await it from inside the executor. The + // command queue is single-threaded; a command's body awaiting + // another command via ExecuteAsync deadlocks the queue. _commandService.Execute(command => { - command.SourceResources = [Resource]; + command.SourceResources = new List { Resource }; command.DestResource = destResource; - - if (isExpandedFolder) - { - command.ExpandCopiedFolder = true; - } + command.TransferMode = DataTransferMode.Copy; + command.ExpandCopiedFolder = isExpandedFolder; }); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 56c90f621..02903c15e 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -7,6 +7,17 @@ namespace Celbridge.Resources.Commands; +// Per-resource outcome of CopySingleResourceAsync. ParentFolder is the +// expandable parent of the destination (null when nothing should expand). +// CopiedFolder is set when a folder was copied and the caller wants to track +// it for end-of-batch expansion. MoveDetail carries the FS-layer's structured +// result when the operation was a move; null for copy operations. +internal record CopyResourceOutcome( + Result Result, + ResourceKey? ParentFolder, + ResourceKey? CopiedFolder, + MoveResult? MoveDetail); + public class CopyResourceCommand : CommandBase, ICopyResourceCommand { public override CommandFlags CommandFlags => CommandFlags.UpdateResources; @@ -16,6 +27,11 @@ public class CopyResourceCommand : CommandBase, ICopyResourceCommand public DataTransferMode TransferMode { get; set; } public bool ExpandCopiedFolder { get; set; } + public CopyCommandResult ResultValue { get; private set; } = new( + Array.Empty(), + Array.Empty(), + Array.Empty()); + private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IProjectService _projectService; @@ -57,6 +73,11 @@ public override async Task ExecuteAsync() return Result.Fail("Project folder path is empty."); } + // Hoist the workspace-scoped service lookups out of the per-resource + // loop. Acquiring them inside ExecuteAsync (rather than via constructor + // injection) honours the workspace-scoped DI rule — the workspace can + // be swapped between executions, but it cannot change while a single + // command runs, so caching for the duration of this call is safe. var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; @@ -68,29 +89,37 @@ public override async Task ExecuteAsync() // Begin batch for single undo operation resourceOpService.BeginBatch(); - List failedItems = new(); + List failedResources = new(); List copiedFolders = new(); + List aggregatedUpdated = new(); + List aggregatedSkipped = new(); ResourceKey? lastParentFolder = null; try { foreach (var sourceResource in filteredResources) { - var (result, parentFolder) = await CopySingleResourceAsync( - sourceResource, - projectFolderPath, - resourceRegistry, - resourceOpService, - copiedFolders); - - if (result.IsFailure) + var outcome = await CopySingleResourceAsync(sourceResource, projectFolderPath, resourceRegistry, resourceOpService); + + if (outcome.Result.IsFailure) + { + _logger.LogError(outcome.Result.DiagnosticReport); + failedResources.Add(sourceResource); + } + else if (outcome.ParentFolder.HasValue) { - _logger.LogError(result.DiagnosticReport); - failedItems.Add(sourceResource.ResourceName); + lastParentFolder = outcome.ParentFolder; } - else if (parentFolder.HasValue) + + if (outcome.CopiedFolder.HasValue) { - lastParentFolder = parentFolder; + copiedFolders.Add(outcome.CopiedFolder.Value); + } + + if (outcome.MoveDetail is not null) + { + aggregatedUpdated.AddRange(outcome.MoveDetail.UpdatedReferencers); + aggregatedSkipped.AddRange(outcome.MoveDetail.SkippedReferencers); } } } @@ -100,6 +129,11 @@ public override async Task ExecuteAsync() resourceOpService.CommitBatch(); } + ResultValue = new CopyCommandResult( + aggregatedUpdated, + aggregatedSkipped, + failedResources); + // Expand destination folder once at end (not per-item) if (lastParentFolder.HasValue && !lastParentFolder.Value.IsEmpty) { @@ -123,9 +157,14 @@ public override async Task ExecuteAsync() } } - if (failedItems.Count > 0) + if (failedResources.Count > 0) { - var failedList = string.Join(", ", failedItems); + // ResourceOperationFailedMessage is a UI display channel and takes + // a list of strings for the toast/banner. Convert from typed keys + // to display names at this boundary; the structured CopyCommandResult + // above keeps the typed ResourceKey list for programmatic callers. + var failedDisplayNames = failedResources.Select(r => r.ResourceName).ToList(); + var failedList = string.Join(", ", failedDisplayNames); var operation = TransferMode == DataTransferMode.Copy ? "copy" : "move"; _logger.LogWarning($"CopyResourceCommand completed with failures: {failedList}"); @@ -133,7 +172,7 @@ public override async Task ExecuteAsync() var operationType = TransferMode == DataTransferMode.Copy ? ResourceOperationType.Copy : ResourceOperationType.Move; - var message = new ResourceOperationFailedMessage(operationType, failedItems); + var message = new ResourceOperationFailedMessage(operationType, failedDisplayNames); _messengerService.Send(message); return Result.Fail($"Failed to {operation}: {failedList}"); @@ -142,12 +181,11 @@ public override async Task ExecuteAsync() return Result.Ok(); } - private async Task<(Result result, ResourceKey? parentFolder)> CopySingleResourceAsync( + private async Task CopySingleResourceAsync( ResourceKey sourceResource, string projectFolderPath, IResourceRegistry resourceRegistry, - IResourceOperationService resourceOpService, - List copiedFolders) + IResourceOperationService resourceOpService) { // Resolve destination to handle folder drops var resolvedDestResource = resourceRegistry.ResolveDestinationResource(sourceResource, DestResource); @@ -162,10 +200,15 @@ public override async Task ExecuteAsync() if (!isFile && !isFolder) { - return (Result.Fail($"Resource does not exist: {sourcePath}"), null); + return new CopyResourceOutcome( + Result.Fail($"Resource does not exist: {sourcePath}"), + ParentFolder: null, + CopiedFolder: null, + MoveDetail: null); } Result result; + MoveResult? moveDetail = null; if (isFile) { @@ -175,7 +218,12 @@ public override async Task ExecuteAsync() } else { - result = await resourceOpService.MoveFileAsync(sourcePath, destPath); + var moveResult = await resourceOpService.MoveFileAsync(sourcePath, destPath); + result = moveResult; + if (moveResult.IsSuccess) + { + moveDetail = moveResult.Value; + } } } else @@ -186,16 +234,17 @@ public override async Task ExecuteAsync() } else { - result = await resourceOpService.MoveFolderAsync(sourcePath, destPath); - } - - if (result.IsSuccess) - { - copiedFolders.Add(resolvedDestResource); + var moveResult = await resourceOpService.MoveFolderAsync(sourcePath, destPath); + result = moveResult; + if (moveResult.IsSuccess) + { + moveDetail = moveResult.Value; + } } } ResourceKey? parentFolder = null; + ResourceKey? copiedFolder = null; if (result.IsSuccess) { // Track the parent folder for expansion at the end @@ -204,9 +253,13 @@ public override async Task ExecuteAsync() { parentFolder = newParentFolder; } + if (isFolder) + { + copiedFolder = resolvedDestResource; + } } - return (result, parentFolder); + return new CopyResourceOutcome(result, parentFolder, copiedFolder, moveDetail); } /// diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index b989c2ce4..9d9ce0e67 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -1,4 +1,6 @@ +using System.Text; using Celbridge.Commands; +using Celbridge.Dialog; using Celbridge.Logging; using Celbridge.Workspace; @@ -10,18 +12,28 @@ public class DeleteResourceCommand : CommandBase, IDeleteResourceCommand public List Resources { get; set; } = new(); + public DeleteReferencePolicy ReferencePolicy { get; set; } = DeleteReferencePolicy.RequireConfirmation; + + public DeleteCommandResult ResultValue { get; private set; } = new( + DeleteBatchOutcome.DeletedAll, + Array.Empty(), + new Dictionary>()); + private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly IDialogService _dialogService; public DeleteResourceCommand( ILogger logger, IMessengerService messengerService, - IWorkspaceWrapper workspaceWrapper) + IWorkspaceWrapper workspaceWrapper, + IDialogService dialogService) { _logger = logger; _messengerService = messengerService; _workspaceWrapper = workspaceWrapper; + _dialogService = dialogService; } public override async Task ExecuteAsync() @@ -33,18 +45,83 @@ public override async Task ExecuteAsync() if (Resources.Count == 0) { + ResultValue = new DeleteCommandResult( + DeleteBatchOutcome.DeletedAll, + Array.Empty(), + new Dictionary>()); return Result.Ok(); } var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var metaData = workspaceService.ResourceMetaData; - // Begin batch for single undo operation - resourceOpService.BeginBatch(); + await metaData.WaitUntilReadyAsync(); - List failedItems = new(); + // Phase A: aggregate referencers external to the batch. References + // from one doomed resource to another are filtered out so an internal + // dependency doesn't block the batch. + var batchSet = new HashSet(Resources); + var externalReferencers = new Dictionary>(); + foreach (var resource in Resources) + { + var referencers = metaData.GetReferencers(resource); + var externalOnly = new List(); + foreach (var referencer in referencers) + { + if (!batchSet.Contains(referencer)) + { + externalOnly.Add(referencer); + } + } + if (externalOnly.Count > 0) + { + externalReferencers[resource] = externalOnly; + } + } + + // Phase B: policy gate. No filesystem effects in this phase. + if (externalReferencers.Count > 0) + { + switch (ReferencePolicy) + { + case DeleteReferencePolicy.FailIfReferenced: + ResultValue = new DeleteCommandResult( + DeleteBatchOutcome.BlockedByReferences, + Array.Empty(), + externalReferencers); + return Result.Ok(); + + case DeleteReferencePolicy.RequireConfirmation: + var dialogResult = await _dialogService.ShowConfirmationDialogAsync( + titleText: "Delete resources with existing references?", + messageText: BuildConfirmationMessage(Resources, externalReferencers)); + if (dialogResult.IsFailure + || !dialogResult.Value) + { + ResultValue = new DeleteCommandResult( + DeleteBatchOutcome.CancelledByUser, + Array.Empty(), + externalReferencers); + return Result.Ok(); + } + break; + + case DeleteReferencePolicy.BreakReferences: + break; + } + } + + // Phase C: execute. Per-resource best-effort; mechanical failures do + // not poison the batch. The soft-delete-to-trash path on + // IResourceOperationService preserves undo and cascades the paired + // sidecar alongside the parent. + var resourceResults = new List(Resources.Count); + var failedItems = new List(); + + resourceOpService.BeginBatch(); try { foreach (var resource in Resources) @@ -53,13 +130,19 @@ public override async Task ExecuteAsync() if (resolveResult.IsFailure) { _logger.LogWarning($"Cannot delete resource because path could not be resolved: '{resource}'"); + resourceResults.Add(new DeleteResourceResult( + resource, + DeleteResourceOutcome.IOFailure, + SidecarOutcome.NotPresent, + FailureMessage: resolveResult.FirstErrorMessage)); failedItems.Add(resource.ResourceName); continue; } var resourcePath = resolveResult.Value; - Result deleteResult; + bool sidecarPresent = SidecarExistsForResource(resourceRegistry, resource); + Result deleteResult; if (File.Exists(resourcePath)) { deleteResult = await resourceOpService.DeleteFileAsync(resourcePath); @@ -71,37 +154,158 @@ public override async Task ExecuteAsync() else { _logger.LogWarning($"Cannot delete resource because it does not exist: '{resource}'"); + resourceResults.Add(new DeleteResourceResult( + resource, + DeleteResourceOutcome.NotFound, + SidecarOutcome.NotPresent, + FailureMessage: $"Resource does not exist: '{resource}'")); failedItems.Add(resource.ResourceName); continue; } if (deleteResult.IsFailure) { + var classification = ClassifyDeleteFailure(deleteResult); _logger.LogError($"Failed to delete resource '{resource}': {deleteResult.DiagnosticReport}"); + resourceResults.Add(new DeleteResourceResult( + resource, + classification.Outcome, + SidecarOutcome.NotPresent, + FailureMessage: classification.Message)); failedItems.Add(resource.ResourceName); + continue; } + + // The FS layer's DeleteFileOperation handles the parent and the + // sidecar as one transactional unit — either both end up in the + // trash or the whole delete fails. So on success, the sidecar + // outcome is Cascaded if a sidecar existed, NotPresent if not. + resourceResults.Add(new DeleteResourceResult( + resource, + DeleteResourceOutcome.Deleted, + sidecarPresent ? SidecarOutcome.Cascaded : SidecarOutcome.NotPresent, + FailureMessage: null)); } } finally { - // Always commit batch - partial success is acceptable resourceOpService.CommitBatch(); } + // Distinguish "every resource deleted cleanly" from "policy gate passed + // but at least one resource failed mechanically". A human (or agent) + // reading BatchOutcome should not have to inspect ResourceResults to learn + // whether the batch was actually clean. + var batchOutcome = failedItems.Count == 0 + ? DeleteBatchOutcome.DeletedAll + : DeleteBatchOutcome.DeletedSome; + + ResultValue = new DeleteCommandResult( + batchOutcome, + resourceResults, + externalReferencers); + if (failedItems.Count > 0) { - var failedList = string.Join(", ", failedItems); - - // Notify the UI about the failure + // Notify the UI about per-resource failures via the toast/banner + // channel so the user gets a visible signal even if the calling + // surface ignores ResultValue. var message = new ResourceOperationFailedMessage(ResourceOperationType.Delete, failedItems); _messengerService.Send(message); - - return Result.Fail($"Failed to delete: {failedList}"); } + // The command itself succeeded — the batch ran end-to-end (the policy + // gate cleared, every resource was attempted). Per-resource failures + // are surfaced through ResultValue.ResourceResults with typed outcomes + // (NotFound, Locked, PermissionDenied, IOFailure) rather than collapsed + // into a single Result.Fail string. Result.Fail at this layer is + // reserved for cases where the batch couldn't run at all (workspace + // not loaded, dialog dispatch failed, etc., handled earlier in this + // method). return Result.Ok(); } + // Maps the exception attached to a failed delete result onto a typed + // DeleteResourceOutcome reason. Mirrors the cascade's + // ClassifyReferencerWriteFailure pattern so the agent gets the same + // granularity for delete failures as for rename-cascade skips. + // + // The DOS read-only attribute is cleared by DeleteFileOperation before the + // move into the trash folder, so any UnauthorizedAccessException reaching + // this point is a genuine ACL / POSIX denial rather than a clearable flag. + private static (DeleteResourceOutcome Outcome, string Message) ClassifyDeleteFailure(Result deleteResult) + { + var exception = deleteResult.FirstException; + + if (exception is FileNotFoundException + || exception is DirectoryNotFoundException) + { + return (DeleteResourceOutcome.NotFound, "resource does not exist on disk"); + } + + if (exception is UnauthorizedAccessException) + { + return (DeleteResourceOutcome.PermissionDenied, "permission denied (no write access)"); + } + + if (exception is IOException) + { + // The most common cause of an IOException during delete is a + // sharing violation (file held open by an editor, antivirus, + // backup tool). The hedged message points the user at where to + // look without overcommitting to a cause we can't always confirm. + return (DeleteResourceOutcome.Locked, "in use by another process (file may be locked by an editor or antivirus)"); + } + + return (DeleteResourceOutcome.IOFailure, deleteResult.FirstErrorMessage); + } + + private static bool SidecarExistsForResource(IResourceRegistry registry, ResourceKey resource) + { + if (resource.IsEmpty) + { + return false; + } + + var sidecarKey = new ResourceKey(resource.Root + ":" + resource.Path + Helpers.SidecarHelper.Extension); + var resolveResult = registry.ResolveResourcePath(sidecarKey); + if (resolveResult.IsFailure) + { + return false; + } + + return File.Exists(resolveResult.Value); + } + + private static string BuildConfirmationMessage( + IReadOnlyList resources, + IReadOnlyDictionary> externalReferencers) + { + var builder = new StringBuilder(); + if (resources.Count == 1) + { + builder.AppendLine($"The resource '{resources[0]}' is referenced by other project files:"); + } + else + { + builder.AppendLine($"{externalReferencers.Count} of the {resources.Count} resources you are deleting are referenced by other project files:"); + } + builder.AppendLine(); + + foreach (var entry in externalReferencers) + { + builder.AppendLine($" '{entry.Key}' is referenced by:"); + foreach (var referencer in entry.Value) + { + builder.AppendLine($" - {referencer}"); + } + } + + builder.AppendLine(); + builder.Append("Deleting them will leave the existing references broken. Continue?"); + return builder.ToString(); + } + // // Static methods for scripting support. // diff --git a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs b/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs new file mode 100644 index 000000000..44189d9f6 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs @@ -0,0 +1,172 @@ +using Celbridge.Core; + +namespace Celbridge.Resources.Services; + +/// +/// One reference parse result: the half-open byte range [StartIndex, EndIndex) +/// in the original text that holds the reference literal, plus the validated +/// resource key it encodes. +/// +public sealed partial record ParsedReference(int StartIndex, int EndIndex, ResourceKey Key); + +/// +/// Shared rules for parsing "project:" reference literals in text. The +/// detection pass in and the rewrite cascade in +/// both consume this module so they cannot +/// drift on what constitutes a valid reference. A symmetry test in +/// Celbridge.Tests asserts that every position the scanner records is a +/// position the rewrite primitive accepts. +/// +public static class ReferenceLiteralRules +{ + /// + /// The literal that marks the start of a reference. Always the bytes of the + /// default-root prefix; non-project: roots are not tracked. + /// + public const string ReferenceMarker = "project:"; + + // Single-character openers that enter delimited-scan mode. Per the agreed + // design (Option C in the resources redesign), references must always be + // wrapped in ASCII double or single quotes — there is no bare-prose form + // of a reference. A "project:" marker not preceded by an opener is not a + // tracked reference, even if it parses as a valid ResourceKey. + private static readonly char[] SingleCharOpeners = { '"', '\'' }; + + // Two-character openers — the escaped-quote forms used by JSON, TOML basic + // strings, and every C-family string literal. The closer is the same + // two-char sequence. These take precedence over the single-char openers + // (checked first), so a "project:" preceded by \" is treated as the + // escaped-quote case, not the plain-quote case. + private static readonly (char First, char Second)[] EscapedQuoteOpeners = + { + ('\\', '"'), + ('\\', '\''), + }; + + /// + /// Returns true if the character can legitimately sit immediately before + /// or after a tracked reference literal. Only the characters that wrap a + /// quoted or escaped-quoted reference qualify: + /// '"' / '\'' — the single-char openers and closers. + /// '\\' — the first char of a \" or \' escape closer. + /// Other characters (whitespace, brackets, parens, etc.) are NOT boundaries, + /// because references must always be quoted — anything not adjacent to a + /// quote is not a reference by definition. + /// + public static bool IsNonKeyBoundary(char c) + { + switch (c) + { + case '"': + case '\'': + case '\\': + return true; + default: + return false; + } + } + + /// + /// Attempts to parse a single reference at the given marker position. The + /// marker index must point at the 'p' of a "project:" literal in the text. + /// Returns null if no valid ResourceKey can be extracted (invalid key + /// syntax, unterminated delimited region, empty key, etc.). + /// + public static ParsedReference? TryParseReferenceAt(string text, int markerIndex) + { + int keyStart = markerIndex + ReferenceMarker.Length; + int keyEnd = -1; + + // Two-char escaped quote takes precedence over single-char quote. + if (markerIndex >= 2) + { + char prevPrev = text[markerIndex - 2]; + char prev = text[markerIndex - 1]; + foreach (var opener in EscapedQuoteOpeners) + { + if (prevPrev == opener.First + && prev == opener.Second) + { + keyEnd = ScanForTwoCharCloser(text, keyStart, opener.First, opener.Second); + break; + } + } + } + + if (keyEnd < 0 + && markerIndex >= 1 + && Array.IndexOf(SingleCharOpeners, text[markerIndex - 1]) >= 0) + { + char closer = text[markerIndex - 1]; + keyEnd = ScanForSingleCharCloser(text, keyStart, closer); + } + + // No bare fallback: a marker without a preceding opener is not a + // tracked reference. References must always be quoted. + if (keyEnd < 0 + || keyEnd <= keyStart) + { + return null; + } + + var candidate = text.Substring(markerIndex, keyEnd - markerIndex); + if (!ResourceKey.TryCreate(candidate, out var key)) + { + return null; + } + + return new ParsedReference(markerIndex, keyEnd, key); + } + + // Walks until the matching closing delimiter and returns its index (the + // end-exclusive boundary of the key). Returns -1 if the region is + // unterminated — newline, control char, or end-of-text reached first. + private static int ScanForSingleCharCloser(string text, int start, char closer) + { + int cursor = start; + while (cursor < text.Length) + { + char current = text[cursor]; + if (current == '\r' + || current == '\n' + || char.IsControl(current)) + { + return -1; + } + if (current == closer) + { + return cursor; + } + cursor++; + } + return -1; + } + + // Walks until the two-char closing sequence and returns the index of its + // first character (the end-exclusive boundary of the key). Returns -1 if + // the region is unterminated. Used for the escaped-quote case where a + // literal \" or \' both opens and closes the delimited region. + private static int ScanForTwoCharCloser(string text, int start, char first, char second) + { + int cursor = start; + while (cursor < text.Length) + { + char current = text[cursor]; + if (current == '\r' + || current == '\n' + || char.IsControl(current)) + { + return -1; + } + if (current == first + && cursor + 1 < text.Length + && text[cursor + 1] == second) + { + return cursor; + } + cursor++; + } + return -1; + } + +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 7c83d547a..3b846da5e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -1,6 +1,7 @@ using System.Text; using Celbridge.Logging; using Celbridge.Projects; +using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Services; @@ -44,16 +45,10 @@ public async Task> ReadAllBytesAsync(ResourceKey resource) } var resourcePath = resolveResult.Value; - try - { - var bytes = await File.ReadAllBytesAsync(resourcePath); - return Result.Ok(bytes); - } - catch (Exception ex) - { - return Result.Fail($"Failed to read file: '{resource}'") - .WithException(ex); - } + return await ReadWithRetryAsync( + resource, + resourcePath, + path => File.ReadAllBytesAsync(path)); } public async Task> ReadAllTextAsync(ResourceKey resource) @@ -66,16 +61,10 @@ public async Task> ReadAllTextAsync(ResourceKey resource) } var resourcePath = resolveResult.Value; - try - { - var text = await File.ReadAllTextAsync(resourcePath); - return Result.Ok(text); - } - catch (Exception ex) - { - return Result.Fail($"Failed to read file: '{resource}'") - .WithException(ex); - } + return await ReadWithRetryAsync( + resource, + resourcePath, + path => File.ReadAllTextAsync(path)); } public Task> OpenReadAsync(ResourceKey resource) @@ -161,19 +150,245 @@ public Task> OpenWriteAsync(ResourceKey resource) } } - public Task> MoveAsync(ResourceKey source, ResourceKey destination) + public async Task> MoveAsync(ResourceKey source, ResourceKey destination) { - throw new NotImplementedException("Structural operations land in Phase 1b (fs-1b)."); + if (source.Root != destination.Root) + { + return Result.Fail($"Cross-root move not supported: '{source}' to '{destination}'"); + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveSourceResult = registry.ResolveResourcePath(source); + if (resolveSourceResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for source resource: '{source}'") + .WithErrors(resolveSourceResult); + } + var sourcePath = resolveSourceResult.Value; + + var resolveDestResult = registry.ResolveResourcePath(destination); + if (resolveDestResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") + .WithErrors(resolveDestResult); + } + var destPath = resolveDestResult.Value; + + bool sourceIsFile = File.Exists(sourcePath); + bool sourceIsFolder = Directory.Exists(sourcePath); + if (!sourceIsFile + && !sourceIsFolder) + { + return Result.Fail($"Source resource does not exist: '{source}'"); + } + + if (!IsRootWritable(registry, destination)) + { + return Result.Fail($"Root '{destination.Root}' is read-only."); + } + + bool isSameLocation = string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase); + if (!isSameLocation + && (File.Exists(destPath) || Directory.Exists(destPath))) + { + return Result.Fail($"Destination already exists: '{destination}'"); + } + + var updatedReferencers = new List(); + var skippedReferencers = new List(); + + if (source.Root == ResourceKey.DefaultRoot) + { + var rewriteResult = await RewriteReferencesForMoveAsync(source, destination, sourceIsFolder, updatedReferencers, skippedReferencers); + if (rewriteResult.IsFailure) + { + return Result.Fail(rewriteResult.FirstErrorMessage) + .WithErrors(rewriteResult); + } + } + + try + { + var destParent = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destParent) + && !Directory.Exists(destParent)) + { + Directory.CreateDirectory(destParent); + } + + if (sourceIsFile) + { + // Clear read-only so the move itself is not blocked by an + // attribute the user has explicitly chosen to override by + // invoking a move on the file. + ClearReadOnlyIfSet(sourcePath); + File.Move(sourcePath, destPath); + } + else + { + Directory.Move(sourcePath, destPath); + } + } + catch (UnauthorizedAccessException ex) + { + return Result.Fail($"Failed to move resource '{source}' to '{destination}': access denied (permissions or file in use).") + .WithException(ex); + } + catch (Exception ex) + { + return Result.Fail($"Failed to move resource: '{source}' to '{destination}'") + .WithException(ex); + } + + var sidecarOutcome = TryCascadeSidecarMove(source, destination); + + if (source.Root == ResourceKey.DefaultRoot) + { + var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitForPendingUpdatesAsync(); + } + + return Result.Ok(new MoveResult(updatedReferencers, skippedReferencers, sidecarOutcome)); } - public Task> CopyAsync(ResourceKey source, ResourceKey destination) + public async Task> CopyAsync(ResourceKey source, ResourceKey destination) { - throw new NotImplementedException("Structural operations land in Phase 1b (fs-1b)."); + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveSourceResult = registry.ResolveResourcePath(source); + if (resolveSourceResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for source resource: '{source}'") + .WithErrors(resolveSourceResult); + } + var sourcePath = resolveSourceResult.Value; + + var resolveDestResult = registry.ResolveResourcePath(destination); + if (resolveDestResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") + .WithErrors(resolveDestResult); + } + var destPath = resolveDestResult.Value; + + bool sourceIsFile = File.Exists(sourcePath); + bool sourceIsFolder = Directory.Exists(sourcePath); + if (!sourceIsFile + && !sourceIsFolder) + { + return Result.Fail($"Source resource does not exist: '{source}'"); + } + + if (!IsRootWritable(registry, destination)) + { + return Result.Fail($"Root '{destination.Root}' is read-only."); + } + + if (File.Exists(destPath) + || Directory.Exists(destPath)) + { + return Result.Fail($"Destination already exists: '{destination}'"); + } + + try + { + var destParent = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destParent) + && !Directory.Exists(destParent)) + { + Directory.CreateDirectory(destParent); + } + + if (sourceIsFile) + { + File.Copy(sourcePath, destPath); + } + else + { + CopyFolderRecursive(sourcePath, destPath); + } + } + catch (Exception ex) + { + return Result.Fail($"Failed to copy resource: '{source}' to '{destination}'") + .WithException(ex); + } + + var sidecarOutcome = TryCascadeSidecarCopy(source, destination); + + if (destination.Root == ResourceKey.DefaultRoot) + { + var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitForPendingUpdatesAsync(); + } + + return Result.Ok(new CopyResult(sidecarOutcome)); } - public Task> DeleteAsync(ResourceKey source) + public async Task> DeleteAsync(ResourceKey source) { - throw new NotImplementedException("Structural operations land in Phase 1b (fs-1b)."); + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveResult = registry.ResolveResourcePath(source); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{source}'") + .WithErrors(resolveResult); + } + var sourcePath = resolveResult.Value; + + bool sourceIsFile = File.Exists(sourcePath); + bool sourceIsFolder = Directory.Exists(sourcePath); + if (!sourceIsFile + && !sourceIsFolder) + { + return Result.Fail($"Resource does not exist: '{source}'"); + } + + if (!IsRootWritable(registry, source)) + { + return Result.Fail($"Root '{source.Root}' is read-only."); + } + + var sidecarOutcome = TryCascadeSidecarDelete(source); + + try + { + if (sourceIsFile) + { + // Clear read-only so File.Delete doesn't trip on the attribute. + // Matches OS Explorer's "delete read-only file?" behaviour + // (proceed when the user explicitly invokes delete). + ClearReadOnlyIfSet(sourcePath); + File.Delete(sourcePath); + } + else + { + // Recursive delete fails on any contained read-only file, so + // strip the attribute throughout the subtree first. + ClearReadOnlyRecursive(sourcePath); + Directory.Delete(sourcePath, recursive: true); + } + } + catch (UnauthorizedAccessException ex) + { + return Result.Fail($"Failed to delete resource '{source}': access denied (permissions or file in use).") + .WithException(ex); + } + catch (Exception ex) + { + return Result.Fail($"Failed to delete resource: '{source}'") + .WithException(ex); + } + + if (source.Root == ResourceKey.DefaultRoot) + { + var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitForPendingUpdatesAsync(); + } + + return Result.Ok(new DeleteResult(sidecarOutcome)); } public Task> ExistsAsync(ResourceKey resource) @@ -197,6 +412,497 @@ private Result ResolvePath(ResourceKey resource) return resourceRegistry.ResolveResourcePath(resource); } + // Roots that don't have a registered handler are assumed writable — the + // default project root falls into this category and is always writable. + private static bool IsRootWritable(IResourceRegistry registry, ResourceKey key) + { + return !registry.RootHandlers.TryGetValue(key.Root, out var handler) + || handler.Capabilities.IsWritable; + } + + // Re-writes every "project:" literal in every referencer of source + // (and, for folders, every "project:/" literal). The rewrite is + // performed via this layer's own ReadAllTextAsync / WriteAllTextAsync so the + // atomic-write semantics apply to each touched file. On any failure the + // operation aborts; previously-rewritten files are left at their new state + // and the source bytes are still in place, so a re-run completes the work. + private async Task RewriteReferencesForMoveAsync( + ResourceKey source, + ResourceKey destination, + bool sourceIsFolder, + List updatedReferencers, + List skippedReferencers) + { + var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + // Drain any watcher events queued by prior operations (e.g. the delete + // half of an undone copy) before reading the referencer list. Without + // this, GetReferencers can return a stale entry for a file that was + // just deleted; the cascade then tries to read it and surfaces a + // phantom ReadFailed in SkippedReferencers. + await metaData.WaitForPendingUpdatesAsync(); + + var referencerSet = new HashSet(); + foreach (var referencer in metaData.GetReferencers(source)) + { + referencerSet.Add(referencer); + } + + if (sourceIsFolder) + { + // Children of source contribute prefix-form references; gather every + // referencer of every descendant target so the prefix rewrite reaches + // each file that names a child key. + foreach (var target in metaData.GetAllReferencedTargets()) + { + if (target.IsDescendantOf(source)) + { + foreach (var referencer in metaData.GetReferencers(target)) + { + referencerSet.Add(referencer); + } + } + } + } + + var sourceLiteral = source.FullKey; + var destLiteral = destination.FullKey; + + var orderedReferencers = referencerSet + .OrderBy(r => r.ToString(), StringComparer.Ordinal) + .ToList(); + + // Per-referencer failures (typically file locked by an external editor + // for a moment, or marked read-only by the user) are logged and skipped + // rather than aborting the whole move. The parent move still completes; + // metadata_check_project (Phase 5) surfaces any references that + // remained stale, and a subsequent rerun of the rename picks up the + // residual rewrites because the FS layer is idempotent under partial + // completion (the source bytes are still in place between the rewrite + // loop and the parent move, and the reference graph re-derives on the + // next watcher event). + foreach (var referencer in orderedReferencers) + { + var readResult = await ReadAllTextAsync(referencer); + if (readResult.IsFailure) + { + var message = $"read failed for '{referencer}'"; + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {message}. The reference is left as-is and will surface via metadata_check_project."); + skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadFailed, message)); + continue; + } + var originalText = readResult.Value; + + var rewritten = RewriteReferenceLiterals(originalText, sourceLiteral, destLiteral, sourceIsFolder); + if (rewritten == originalText) + { + continue; + } + + var writeResult = await WriteAllTextAsync(referencer, rewritten); + if (writeResult.IsFailure) + { + // The referencer cascade does not override user-set read-only + // or ACL permissions: the user invoked a move on `source`, not + // on this incidental referencer. Skip with a clear message so + // the user (or the calling agent) knows exactly why and can + // decide whether to fix the permissions and rerun the rename. + var classification = ClassifyReferencerWriteFailure(referencer, writeResult); + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {classification.Message}. The reference is left as-is and will surface via metadata_check_project."); + skippedReferencers.Add(new SkippedReferencer(referencer, classification.Reason, classification.Message)); + continue; + } + + updatedReferencers.Add(referencer); + } + + return Result.Ok(); + } + + private (ReferencerSkipReason Reason, string Message) ClassifyReferencerWriteFailure(ResourceKey referencer, Result writeResult) + { + var resolveResult = ResolvePath(referencer); + if (resolveResult.IsFailure) + { + return (ReferencerSkipReason.WriteFailed, "write failed (could not resolve path)"); + } + + // Check the DOS read-only attribute first. We split it from the ACL / + // POSIX denial case because the fixes are different: read-only is + // trivially clearable ("uncheck the read-only flag"), whereas an ACL + // deny typically needs the right user account or admin rights. Agents + // that want to auto-clear read-only-and-retry can switch on ReadOnly; + // agents that want a coarse "permissions thing" check can match both. + try + { + var info = new FileInfo(resolveResult.Value); + if (info.Exists + && info.IsReadOnly) + { + return (ReferencerSkipReason.ReadOnly, "file is read-only"); + } + } + catch + { + // Fall through to the exception-based classification. + } + + // UnauthorizedAccessException from the underlying File.Move (after the + // atomic temp-write) typically means an ACL deny on Windows or a POSIX + // permission failure on Unix. + if (writeResult.FirstException is UnauthorizedAccessException) + { + return (ReferencerSkipReason.PermissionDenied, "permission denied (no write access to file)"); + } + + // Catch-all for any other write failure: actual file locks, disk full, + // quota exceeded, network share gone, antivirus interference. The + // hedged message tells the user where to look without overcommitting + // to a specific cause we can't detect. + return (ReferencerSkipReason.WriteFailed, "write failed (file may be locked or another IO issue)"); + } + + // Replaces every quoted occurrence of sourceLiteral with destLiteral. The + // boundary check (ReferenceLiteralRules.IsNonKeyBoundary on the bytes + // immediately before and after the match) keeps incidental substring + // matches untouched — only the canonical quoted form gets rewritten. + // + // Both sides of the match must have a real boundary character; matches at + // position 0 or at end-of-text are not eligible because under the + // always-quoted contract every tracked reference is wrapped in a quote + // (or its \" / \' escape) on both sides. + // + // Folder cascade: the trailing-boundary check also accepts '/' so a folder + // rename rewrites descendant references via prefix substitution — + // "project:/" becomes "project:/" because + // sourceLiteral matched only the "" prefix. + private static string RewriteReferenceLiterals(string text, string sourceLiteral, string destLiteral, bool sourceIsFolder) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + var builder = new StringBuilder(text.Length); + int cursor = 0; + + while (cursor < text.Length) + { + int matchIndex = text.IndexOf(sourceLiteral, cursor, StringComparison.Ordinal); + if (matchIndex < 0) + { + builder.Append(text, cursor, text.Length - cursor); + break; + } + + builder.Append(text, cursor, matchIndex - cursor); + + int afterMatch = matchIndex + sourceLiteral.Length; + + bool leadingOk = matchIndex > 0 + && ReferenceLiteralRules.IsNonKeyBoundary(text[matchIndex - 1]); + bool trailingExact = afterMatch < text.Length + && ReferenceLiteralRules.IsNonKeyBoundary(text[afterMatch]); + bool trailingFolderPrefix = sourceIsFolder + && afterMatch < text.Length + && text[afterMatch] == '/'; + + if (leadingOk + && (trailingExact || trailingFolderPrefix)) + { + builder.Append(destLiteral); + cursor = afterMatch; + } + else + { + // Boundary check failed. Preserve the matched byte and advance + // by one so the next scan can find an overlapping occurrence. + builder.Append(text[matchIndex]); + cursor = matchIndex + 1; + } + } + + return builder.ToString(); + } + + private SidecarOutcome TryCascadeSidecarMove(ResourceKey source, ResourceKey destination) + { + var sourceSidecar = AppendSidecarSuffix(source); + var destSidecar = AppendSidecarSuffix(destination); + if (sourceSidecar is null + || destSidecar is null) + { + return SidecarOutcome.NotPresent; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveSourceResult = registry.ResolveResourcePath(sourceSidecar.Value); + if (resolveSourceResult.IsFailure) + { + return SidecarOutcome.NotPresent; + } + var sourceSidecarPath = resolveSourceResult.Value; + if (!File.Exists(sourceSidecarPath)) + { + return SidecarOutcome.NotPresent; + } + + var resolveDestResult = registry.ResolveResourcePath(destSidecar.Value); + if (resolveDestResult.IsFailure) + { + _logger.LogWarning($"Failed to resolve sidecar destination '{destSidecar}' for move from '{source}'. Sidecar bytes remain at the source path."); + return SidecarOutcome.Failed; + } + var destSidecarPath = resolveDestResult.Value; + + if (File.Exists(destSidecarPath)) + { + _logger.LogWarning($"Sidecar destination '{destSidecar}' already exists. Parent move completed but sidecar was not cascaded."); + return SidecarOutcome.Failed; + } + + try + { + var destFolder = Path.GetDirectoryName(destSidecarPath); + if (!string.IsNullOrEmpty(destFolder) + && !Directory.Exists(destFolder)) + { + Directory.CreateDirectory(destFolder); + } + + File.Move(sourceSidecarPath, destSidecarPath); + return SidecarOutcome.Cascaded; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to cascade sidecar move from '{sourceSidecar}' to '{destSidecar}'."); + return SidecarOutcome.Failed; + } + } + + private SidecarOutcome TryCascadeSidecarCopy(ResourceKey source, ResourceKey destination) + { + var sourceSidecar = AppendSidecarSuffix(source); + var destSidecar = AppendSidecarSuffix(destination); + if (sourceSidecar is null + || destSidecar is null) + { + return SidecarOutcome.NotPresent; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveSourceResult = registry.ResolveResourcePath(sourceSidecar.Value); + if (resolveSourceResult.IsFailure) + { + return SidecarOutcome.NotPresent; + } + var sourceSidecarPath = resolveSourceResult.Value; + if (!File.Exists(sourceSidecarPath)) + { + return SidecarOutcome.NotPresent; + } + + var resolveDestResult = registry.ResolveResourcePath(destSidecar.Value); + if (resolveDestResult.IsFailure) + { + _logger.LogWarning($"Failed to resolve sidecar destination '{destSidecar}' for copy from '{source}'."); + return SidecarOutcome.Failed; + } + var destSidecarPath = resolveDestResult.Value; + + if (File.Exists(destSidecarPath)) + { + _logger.LogWarning($"Sidecar destination '{destSidecar}' already exists. Parent copy completed but sidecar was not cascaded."); + return SidecarOutcome.Failed; + } + + try + { + var destFolder = Path.GetDirectoryName(destSidecarPath); + if (!string.IsNullOrEmpty(destFolder) + && !Directory.Exists(destFolder)) + { + Directory.CreateDirectory(destFolder); + } + + File.Copy(sourceSidecarPath, destSidecarPath); + return SidecarOutcome.Cascaded; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to cascade sidecar copy from '{sourceSidecar}' to '{destSidecar}'."); + return SidecarOutcome.Failed; + } + } + + private SidecarOutcome TryCascadeSidecarDelete(ResourceKey source) + { + var sourceSidecar = AppendSidecarSuffix(source); + if (sourceSidecar is null) + { + return SidecarOutcome.NotPresent; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveResult = registry.ResolveResourcePath(sourceSidecar.Value); + if (resolveResult.IsFailure) + { + return SidecarOutcome.NotPresent; + } + var sidecarPath = resolveResult.Value; + if (!File.Exists(sidecarPath)) + { + return SidecarOutcome.NotPresent; + } + + try + { + File.Delete(sidecarPath); + return SidecarOutcome.Cascaded; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to cascade sidecar delete for '{sourceSidecar}'."); + return SidecarOutcome.Failed; + } + } + + // Returns a new ResourceKey with ".cel" appended to the path portion, or + // null for a root-only key (no path to append to). + private static ResourceKey? AppendSidecarSuffix(ResourceKey key) + { + if (key.IsEmpty) + { + return null; + } + + return new ResourceKey(key.Root + ":" + key.Path + SidecarHelper.Extension); + } + + // Clears the read-only attribute from a file before the FS layer performs + // a move or delete. User intent to move or delete a file overrides the + // read-only marker the same way Windows Explorer's "delete" prompt does. + // Best-effort: any IO failure surfaces when the subsequent move/delete + // itself fails; we don't pre-flight check. + private static void ClearReadOnlyIfSet(string path) + { + try + { + var info = new FileInfo(path); + if (info.Exists + && info.IsReadOnly) + { + info.IsReadOnly = false; + } + } + catch + { + // Best effort; surface the underlying issue from the caller's operation. + } + } + + // Recursive read-only clear for folder delete. Directory.Delete(recursive: true) + // fails if any contained file is read-only, so traverse first. + private static void ClearReadOnlyRecursive(string folder) + { + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + { + ClearReadOnlyIfSet(file); + } + } + catch + { + // Best effort. + } + } + + // Recursive folder copy. Mirrors ResourceUtils.CopyFolder but stays internal + // to the FS layer so the chokepoint owns the destination structure. + private static void CopyFolderRecursive(string sourceFolder, string destFolder) + { + Directory.CreateDirectory(destFolder); + + foreach (var file in Directory.GetFiles(sourceFolder)) + { + var fileName = Path.GetFileName(file); + var destFile = Path.Combine(destFolder, fileName); + File.Copy(file, destFile); + } + + foreach (var subFolder in Directory.GetDirectories(sourceFolder)) + { + var folderName = Path.GetFileName(subFolder); + var destSubFolder = Path.Combine(destFolder, folderName); + CopyFolderRecursive(subFolder, destSubFolder); + } + } + + // Bounded retry on transient IO failures. Mirrors WriteWithRetryAsync: a + // file briefly held open by an external editor, antivirus, or backup + // product clears within milliseconds, so 3 attempts at 50/100/150ms backoff + // catches the common cases without imposing meaningful latency on the + // typical-case success. FileNotFoundException and DirectoryNotFoundException + // are explicitly not retried — the file is genuinely missing and retrying + // won't change that. UnauthorizedAccessException is also not retried: for + // reads it almost always means a permission issue (e.g. an ACL the user + // can't get past), not a transient lock, and the metadata scanner has its + // own retry budget for the rarer transient cases. + private async Task> ReadWithRetryAsync( + ResourceKey resource, + string resourcePath, + Func> readOperation) + where T : notnull + { + IOException? lastException = null; + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + var value = await readOperation(resourcePath); + if (attempt > 1) + { + _logger.LogWarning($"Read succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); + } + return Result.Ok(value); + } + catch (FileNotFoundException ex) + { + return Result.Fail($"Failed to read file: '{resource}'") + .WithException(ex); + } + catch (DirectoryNotFoundException ex) + { + return Result.Fail($"Failed to read file: '{resource}'") + .WithException(ex); + } + catch (IOException ex) + { + lastException = ex; + if (attempt < MaxAttempts) + { + var delay = BaseRetryDelayMs * attempt; + _logger.LogWarning(ex, $"Read attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); + await Task.Delay(delay); + } + } + catch (Exception ex) + { + return Result.Fail($"Failed to read file: '{resource}'") + .WithException(ex); + } + } + + return Result.Fail($"Failed to read file after {MaxAttempts} attempts: '{resource}'") + .WithException(lastException!); + } + private async Task WriteWithRetryAsync(ResourceKey resource, byte[] bytes) { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs index 212b9fe96..c81e2a932 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs @@ -13,16 +13,22 @@ public sealed class ResourceMetaData : IResourceMetaData, IDisposable // Files larger than this byte budget are skipped during the scan. private const long MaxScanFileSizeBytes = 10 * 1024 * 1024; - // Characters that cannot legally appear inside a resource key; the scanner - // stops accumulating candidate-key bytes at the first one it sees. - // Whitespace and control chars are handled separately via char.IsWhiteSpace - // and char.IsControl so this set only enumerates the printable terminators. - private static readonly HashSet KeyTerminators = new() + // Re-queue delays for a transient rescan failure (file locked by external + // writer, antivirus, etc.). The retry attempt counter resets when any + // watcher event arrives for the resource, so normal user activity always + // gets a fresh budget. After MaxScanRetryAttempts consecutive transient + // failures the rescan is dropped (logged) until the next watcher event. + private const int MaxScanRetryAttempts = 3; + private static readonly TimeSpan[] ScanRetryDelays = { - '"', '\'', '`', '(', ')', '<', '>', ',', ';', ']', '}', + TimeSpan.FromMilliseconds(500), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), }; - private const string ReferenceMarker = "project:"; + // Delimiter and boundary rules live in ReferenceLiteralRules so the scanner + // and the rewrite cascade in ResourceFileSystem cannot drift on what + // constitutes a valid reference. private readonly ILogger _logger; private readonly IMessengerService _messengerService; @@ -40,6 +46,11 @@ public sealed class ResourceMetaData : IResourceMetaData, IDisposable private readonly SemaphoreSlim _workerSignal = new(0); private Task? _workerTask; + // Per-resource counter for consecutive transient rescan failures. + // Cleared on a successful scan, a permanent exclusion, or a new watcher + // event. Used by ScheduleRetryAfterTransientFailure to cap the retry chain. + private readonly ConcurrentDictionary _transientFailureCounts = new(); + private TaskCompletionSource _readyCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _isReady; private volatile bool _isShuttingDown; @@ -102,6 +113,7 @@ public async Task> RebuildAsync() var newReferencersByTarget = new Dictionary>(); var newReferencesBySource = new Dictionary>(); + var transientFailures = new List(); int filesScanned = 0; int filesSkipped = 0; @@ -110,21 +122,28 @@ public async Task> RebuildAsync() foreach (var (resourceKey, absolutePath) in files) { var scanResult = await ScanTextFileAsync(resourceKey, absolutePath); - if (scanResult.WasSkipped) - { - filesSkipped++; - continue; - } - - filesScanned++; - - if (scanResult.References.Count == 0) + switch (scanResult.Outcome) { - continue; + case ScanOutcome.TransientFailure: + // Re-queue once the swap below is complete so the worker + // picks it up. Don't include in the new index yet. + transientFailures.Add(resourceKey); + filesSkipped++; + continue; + + case ScanOutcome.ExcludedPermanently: + filesSkipped++; + continue; + + case ScanOutcome.Indexed: + filesScanned++; + if (scanResult.References.Count > 0) + { + referencesFound += scanResult.References.Count; + ApplyReferences(newReferencersByTarget, newReferencesBySource, resourceKey, scanResult.References); + } + break; } - - referencesFound += scanResult.References.Count; - ApplyReferences(newReferencersByTarget, newReferencesBySource, resourceKey, scanResult.References); } lock (_indexLock) @@ -143,6 +162,14 @@ public async Task> RebuildAsync() stopwatch.Stop(); + // Enqueue the transient failures after the index swap so the + // worker's retry attempts mutate the freshly-installed index, not + // the prior one. + foreach (var failed in transientFailures) + { + QueueRescan(failed); + } + MarkReady(); // FrontmatterEntries is always zero because frontmatter scanning is @@ -154,7 +181,7 @@ public async Task> RebuildAsync() FrontmatterEntries: 0, Elapsed: stopwatch.Elapsed); - _logger.LogDebug($"Metadata rebuild complete: {filesScanned} scanned, {filesSkipped} skipped, {referencesFound} references in {stopwatch.ElapsedMilliseconds}ms"); + _logger.LogDebug($"Metadata rebuild complete: {filesScanned} scanned, {filesSkipped} skipped ({transientFailures.Count} transient retries queued), {referencesFound} references in {stopwatch.ElapsedMilliseconds}ms"); return Result.Ok(report); } @@ -237,8 +264,10 @@ public IReadOnlyList FindByTag(string tag) throw new NotImplementedException("The frontmatter index is not yet implemented."); } - // Walks file text for "project:" candidate references. Returns the unique set - // of valid keys found; invalid candidates are silently dropped. + // Walks file text for "project:" candidate references. Returns the unique + // set of valid keys found; invalid candidates are silently dropped. Parsing + // logic is delegated to ReferenceLiteralRules so the scanner and the + // rewrite cascade share one definition of what counts as a reference. public static HashSet ScanTextForReferences(string text) { var references = new HashSet(); @@ -246,91 +275,122 @@ public static HashSet ScanTextForReferences(string text) while (true) { - int markerIndex = text.IndexOf(ReferenceMarker, searchStart, StringComparison.Ordinal); + int markerIndex = text.IndexOf(ReferenceLiteralRules.ReferenceMarker, searchStart, StringComparison.Ordinal); if (markerIndex < 0) { break; } - int keyStart = markerIndex + ReferenceMarker.Length; - int keyEnd = keyStart; - while (keyEnd < text.Length) + var parsed = ReferenceLiteralRules.TryParseReferenceAt(text, markerIndex); + if (parsed is not null) { - var current = text[keyEnd]; - if (char.IsWhiteSpace(current) - || char.IsControl(current) - || KeyTerminators.Contains(current)) - { - break; - } - keyEnd++; + references.Add(parsed.Key); + searchStart = parsed.EndIndex; } - - if (keyEnd > keyStart) + else { - var candidate = text.Substring(markerIndex, keyEnd - markerIndex); - if (ResourceKey.TryCreate(candidate, out var key)) - { - references.Add(key); - } + searchStart = markerIndex + ReferenceLiteralRules.ReferenceMarker.Length; } - - searchStart = keyEnd > markerIndex ? keyEnd : markerIndex + ReferenceMarker.Length; } return references; } - private record FileScanResult(bool WasSkipped, HashSet References); + private enum ScanOutcome + { + // Scan succeeded; References reflects what the file contains right now. + Indexed, + + // File is deliberately not indexable in its current shape (deleted, + // oversize, binary). Prior index entries should be dropped. + ExcludedPermanently, + + // Scan failed in a way that may resolve itself (file locked by another + // process, transient IO error). Prior index entries should be preserved + // and the rescan should be retried after a short delay. + TransientFailure, + } + + private record FileScanResult(ScanOutcome Outcome, HashSet References); + + private static readonly HashSet EmptyReferenceSet = new(); private async Task ScanTextFileAsync(ResourceKey resourceKey, string absolutePath) { + FileInfo fileInfo; try { - var fileInfo = new FileInfo(absolutePath); + fileInfo = new FileInfo(absolutePath); if (!fileInfo.Exists) { - return new FileScanResult(WasSkipped: true, References: new HashSet()); + return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); } - if (fileInfo.Length > MaxScanFileSizeBytes) { _logger.LogInformation($"metadata scan: skipping {resourceKey} (size {fileInfo.Length} bytes exceeds limit)"); - return new FileScanResult(WasSkipped: true, References: new HashSet()); - } - - var extension = Path.GetExtension(absolutePath); - if (!string.IsNullOrEmpty(extension) - && _textBinarySniffer.IsBinaryExtension(extension)) - { - return new FileScanResult(WasSkipped: true, References: new HashSet()); + return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); } + } + catch (Exception ex) when (IsTransientIoFailure(ex)) + { + _logger.LogDebug($"metadata scan: transient stat failure for {resourceKey} ({ex.GetType().Name}): {ex.Message}"); + return new FileScanResult(ScanOutcome.TransientFailure, EmptyReferenceSet); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"metadata scan: failed to stat {resourceKey}"); + return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); + } - var isTextResult = _textBinarySniffer.IsTextFile(absolutePath); - if (isTextResult.IsFailure || !isTextResult.Value) - { - return new FileScanResult(WasSkipped: true, References: new HashSet()); - } + var extension = Path.GetExtension(absolutePath); + if (!string.IsNullOrEmpty(extension) + && _textBinarySniffer.IsBinaryExtension(extension)) + { + return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); + } - string text; - try - { - text = await File.ReadAllTextAsync(absolutePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"metadata scan: failed to read {resourceKey}"); - return new FileScanResult(WasSkipped: true, References: new HashSet()); - } + var isTextResult = _textBinarySniffer.IsTextFile(absolutePath); + if (isTextResult.IsFailure) + { + // The sniffer's failure surface doesn't distinguish locked-file from + // genuinely-unreadable. Treat as transient: a real permanent failure + // exhausts MaxScanRetryAttempts and gets dropped; a transient one + // succeeds on retry. Worst case is three short retries. + _logger.LogDebug($"metadata scan: sniffer failure for {resourceKey} - treating as transient"); + return new FileScanResult(ScanOutcome.TransientFailure, EmptyReferenceSet); + } + if (!isTextResult.Value) + { + return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); + } - var references = ScanTextForReferences(text); - return new FileScanResult(WasSkipped: false, References: references); + string text; + try + { + text = await File.ReadAllTextAsync(absolutePath); + } + catch (Exception ex) when (IsTransientIoFailure(ex)) + { + _logger.LogDebug($"metadata scan: transient read failure for {resourceKey} ({ex.GetType().Name}): {ex.Message}"); + return new FileScanResult(ScanOutcome.TransientFailure, EmptyReferenceSet); } catch (Exception ex) { - _logger.LogWarning(ex, $"metadata scan: failed to process {resourceKey}"); - return new FileScanResult(WasSkipped: true, References: new HashSet()); + _logger.LogWarning(ex, $"metadata scan: failed to read {resourceKey}"); + return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); } + + var references = ScanTextForReferences(text); + return new FileScanResult(ScanOutcome.Indexed, references); + } + + // IOException covers file-locked, sharing-violation, network-share blip; + // UnauthorizedAccessException can fire transiently on Windows while an + // antivirus or backup product holds the file. Both are worth retrying. + private static bool IsTransientIoFailure(Exception ex) + { + return ex is IOException + || ex is UnauthorizedAccessException; } private static void ApplyReferences( @@ -422,11 +482,16 @@ private void UpdateSourceInIndexes(ResourceKey source, HashSet refe private void OnResourceCreated(object recipient, MonitoredResourceCreatedMessage message) { + // A fresh watcher event means the file's state is changing; reset the + // retry budget so a file that previously gave up after MaxScanRetryAttempts + // gets re-scanned with full budget on its next legitimate change. + _transientFailureCounts.TryRemove(message.Resource, out _); QueueRescan(message.Resource); } private void OnResourceChanged(object recipient, MonitoredResourceChangedMessage message) { + _transientFailureCounts.TryRemove(message.Resource, out _); QueueRescan(message.Resource); } @@ -437,6 +502,7 @@ private void OnResourceDeleted(object recipient, MonitoredResourceDeletedMessage return; } RemoveSourceFromIndexes(message.Resource); + _transientFailureCounts.TryRemove(message.Resource, out _); } private void OnResourceRenamed(object recipient, MonitoredResourceRenamedMessage message) @@ -444,7 +510,9 @@ private void OnResourceRenamed(object recipient, MonitoredResourceRenamedMessage if (message.OldResource.Root == ResourceKey.DefaultRoot) { RemoveSourceFromIndexes(message.OldResource); + _transientFailureCounts.TryRemove(message.OldResource, out _); } + _transientFailureCounts.TryRemove(message.NewResource, out _); QueueRescan(message.NewResource); } @@ -516,6 +584,7 @@ private async Task ProcessRescanAsync(ResourceKey resource) if (resolveResult.IsFailure) { RemoveSourceFromIndexes(resource); + _transientFailureCounts.TryRemove(resource, out _); return; } var absolutePath = resolveResult.Value; @@ -523,17 +592,29 @@ private async Task ProcessRescanAsync(ResourceKey resource) if (!File.Exists(absolutePath)) { RemoveSourceFromIndexes(resource); + _transientFailureCounts.TryRemove(resource, out _); return; } var scanResult = await ScanTextFileAsync(resource, absolutePath); - if (scanResult.WasSkipped) + switch (scanResult.Outcome) { - RemoveSourceFromIndexes(resource); - return; - } + case ScanOutcome.Indexed: + UpdateSourceInIndexes(resource, scanResult.References); + _transientFailureCounts.TryRemove(resource, out _); + break; - UpdateSourceInIndexes(resource, scanResult.References); + case ScanOutcome.ExcludedPermanently: + RemoveSourceFromIndexes(resource); + _transientFailureCounts.TryRemove(resource, out _); + break; + + case ScanOutcome.TransientFailure: + // Preserve existing index entries; the file is briefly + // unreadable but the prior data is still our best guess. + ScheduleRetryAfterTransientFailure(resource); + break; + } } catch (Exception ex) { @@ -541,6 +622,30 @@ private async Task ProcessRescanAsync(ResourceKey resource) } } + private void ScheduleRetryAfterTransientFailure(ResourceKey resource) + { + var attempt = _transientFailureCounts.AddOrUpdate(resource, 1, (_, previous) => previous + 1); + if (attempt > MaxScanRetryAttempts) + { + _logger.LogWarning($"metadata scan: giving up on {resource} after {MaxScanRetryAttempts} transient failures. The next watcher event for this file will reset the retry budget."); + _transientFailureCounts.TryRemove(resource, out _); + return; + } + + var delay = ScanRetryDelays[attempt - 1]; + + // Detached background continuation; nothing awaits this task. If the + // service is disposed mid-delay the worker exits early via _isShuttingDown. + _ = Task.Delay(delay).ContinueWith(_ => + { + if (_isShuttingDown) + { + return; + } + QueueRescan(resource); + }, TaskScheduler.Default); + } + private void MarkReady() { if (_isReady) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs index 429335713..02a98fa35 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs @@ -363,7 +363,27 @@ private bool ShouldIgnorePath(IResourceRootHandler handler, string fullPath) // Cross-platform: Unix hidden files start with '.' if (fileName.StartsWith(".") || fileName.StartsWith("~") || - fileName.EndsWith(".tmp")) + fileName.EndsWith(".tmp") || + fileName.EndsWith("~")) // Emacs/many editors' backup files (e.g. "foo.md~") + { + return true; + } + + // External-editor atomic-write temp files commonly use the shape + // ".tmp.." (e.g. "foo.md.tmp.5912.c2e6892eb512" from + // Claude Code's editor). EndsWith(".tmp") above doesn't catch these, so + // match ".tmp." anywhere in the filename. + if (fileName.Contains(".tmp.", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Common editor swap/lock/partial-download patterns from other tools. + if (fileName.EndsWith(".swp", StringComparison.OrdinalIgnoreCase) || // Vim swap + fileName.EndsWith(".swo", StringComparison.OrdinalIgnoreCase) || // Vim swap (second) + fileName.EndsWith(".swn", StringComparison.OrdinalIgnoreCase) || // Vim swap (third) + fileName.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) || // Chrome/Edge partial download + fileName.EndsWith(".part", StringComparison.OrdinalIgnoreCase)) // Firefox/wget partial download { return true; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index c125456bd..8e98d1fc5 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -2,6 +2,7 @@ using Celbridge.Entities; using Celbridge.Logging; using Celbridge.Projects; +using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Services; @@ -39,6 +40,9 @@ public ResourceOperationService( private IResourceRegistry? ResourceRegistry => _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.ResourceService.Registry : null; + private IResourceFileSystem? FileSystem => + _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.ResourceFileSystem : null; + private string ProjectFolderPath => _workspaceWrapper.IsWorkspacePageLoaded ? ResourceRegistry!.ProjectFolderPath : string.Empty; @@ -78,39 +82,146 @@ public async Task CreateFolderAsync(string path) return result; } - public async Task CopyFileAsync(string sourcePath, string destPath) + // Default outcome returned when the FS-layer cascade did not run (e.g. + // external import via the path-based fallback). Callers treating the empty + // structure as "no cascade work was applicable" stay symmetric with the + // real-cascade case. + private static readonly CopyResult EmptyCopyResult = new(SidecarOutcome.NotPresent); + private static readonly MoveResult EmptyMoveResult = new( + Array.Empty(), + Array.Empty(), + SidecarOutcome.NotPresent); + + public async Task> CopyFileAsync(string sourcePath, string destPath) { sourcePath = Path.GetFullPath(sourcePath); destPath = Path.GetFullPath(destPath); - var operation = new CopyFileOperation(sourcePath, destPath, EntityService, ResourceRegistry); - var result = await operation.ExecuteAsync(); + // External-import callers (TransferResourcesCommand.AddExternalResourceAsync, + // AddResourceHelper) supply a source path outside the project folder. The + // FS-layer cascade does not apply: the implicit ".cel" sidecar + // lookup is rooted in resource keys and can't address external paths, and + // external bytes have no inbound references in this project. Sidecars that + // are explicitly selected (file-by-file) or contained in a copied folder + // come along as ordinary bytes via the path-based fallback; the registry's + // pairing pass picks them up on the next sync. Stale "project:" references + // inside imported sidecar bodies surface via metadata_check_project (ri-2). + if (!IsInProjectFolder(sourcePath)) + { + return await CopyExternalFileAsync(sourcePath, destPath); + } - if (result.IsSuccess) + var keyResult = ResolveOperationKeys(sourcePath, destPath); + if (keyResult.IsFailure) { - AddOperation(operation); + return Result.Fail(keyResult.FirstErrorMessage) + .WithErrors(keyResult); + } + var fileSystem = FileSystem; + if (fileSystem is null) + { + return Result.Fail("Workspace is not loaded; resource file system is unavailable."); } - return result; + var operation = new CopyFileOperation( + sourcePath, + destPath, + keyResult.Value.Source, + keyResult.Value.Destination, + EntityService, + ResourceRegistry, + fileSystem); + var execResult = await operation.ExecuteAsync(); + + if (execResult.IsFailure) + { + return Result.Fail(execResult.FirstErrorMessage) + .WithErrors(execResult); + } + + AddOperation(operation); + return Result.Ok(operation.LastCopyResult ?? EmptyCopyResult); + } + + private async Task> CopyExternalFileAsync(string sourcePath, string destPath) + { + var operation = new CopyExternalFileOperation(sourcePath, destPath); + var execResult = await operation.ExecuteAsync(); + if (execResult.IsFailure) + { + return Result.Fail(execResult.FirstErrorMessage) + .WithErrors(execResult); + } + AddOperation(operation); + return Result.Ok(EmptyCopyResult); + } + + private async Task> CopyExternalFolderAsync(string sourcePath, string destPath) + { + var operation = new CopyExternalFolderOperation(sourcePath, destPath); + var execResult = await operation.ExecuteAsync(); + if (execResult.IsFailure) + { + return Result.Fail(execResult.FirstErrorMessage) + .WithErrors(execResult); + } + AddOperation(operation); + return Result.Ok(EmptyCopyResult); + } + + private bool IsInProjectFolder(string absolutePath) + { + var projectFolderPath = ProjectFolderPath; + if (string.IsNullOrEmpty(projectFolderPath)) + { + return false; + } + + var normalizedProject = Path.GetFullPath(projectFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedPath = Path.GetFullPath(absolutePath); + return normalizedPath.StartsWith(normalizedProject + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || string.Equals(normalizedPath, normalizedProject, StringComparison.OrdinalIgnoreCase); } - public async Task MoveFileAsync(string sourcePath, string destPath) + public async Task> MoveFileAsync(string sourcePath, string destPath) { sourcePath = Path.GetFullPath(sourcePath); destPath = Path.GetFullPath(destPath); - var operation = new MoveFileOperation(sourcePath, destPath, EntityService, ResourceRegistry); - var result = await operation.ExecuteAsync(); - - if (result.IsSuccess) + var keyResult = ResolveOperationKeys(sourcePath, destPath); + if (keyResult.IsFailure) { - AddOperation(operation); + return Result.Fail(keyResult.FirstErrorMessage) + .WithErrors(keyResult); + } + var fileSystem = FileSystem; + if (fileSystem is null) + { + return Result.Fail("Workspace is not loaded; resource file system is unavailable."); + } + + var operation = new MoveFileOperation( + sourcePath, + destPath, + keyResult.Value.Source, + keyResult.Value.Destination, + EntityService, + ResourceRegistry, + fileSystem); + var execResult = await operation.ExecuteAsync(); - // Notify opened documents that the file has moved - SendResourceKeyChangedMessage(sourcePath, destPath); + if (execResult.IsFailure) + { + return Result.Fail(execResult.FirstErrorMessage) + .WithErrors(execResult); } - return result; + AddOperation(operation); + + // Notify opened documents that the file has moved + SendResourceKeyChangedMessage(sourcePath, destPath); + + return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); } public async Task DeleteFileAsync(string path) @@ -145,7 +256,19 @@ public async Task DeleteFileAsync(string path) } } - var operation = new DeleteFileOperation(path, trashPath, entityDataPath, entityDataTrashPath); + // Pre-compute sidecar paths so the cascade can land in the same trash + // batch as the parent file. The sibling lookup is a pure filename check + // (matches the FS-layer cascade rule); it does not consult the registry. + string? sidecarPath = null; + string? sidecarTrashPath = null; + var siblingSidecar = path + SidecarHelper.Extension; + if (File.Exists(siblingSidecar)) + { + sidecarPath = siblingSidecar; + sidecarTrashPath = trashPath + SidecarHelper.Extension; + } + + var operation = new DeleteFileOperation(path, trashPath, entityDataPath, entityDataTrashPath, sidecarPath, sidecarTrashPath); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -156,39 +279,91 @@ public async Task DeleteFileAsync(string path) return result; } - public async Task CopyFolderAsync(string sourcePath, string destPath) + public async Task> CopyFolderAsync(string sourcePath, string destPath) { sourcePath = Path.GetFullPath(sourcePath); destPath = Path.GetFullPath(destPath); - var operation = new CopyFolderOperation(sourcePath, destPath, EntityService, ResourceRegistry); - var result = await operation.ExecuteAsync(); + // External-import callers supply a source folder outside the project. + // The FS-layer cascade does not apply (see CopyFileAsync for the full + // rationale). Sidecars inside the source folder come along as ordinary + // bytes via the recursive copy; the registry pairing pass picks them up. + if (!IsInProjectFolder(sourcePath)) + { + return await CopyExternalFolderAsync(sourcePath, destPath); + } - if (result.IsSuccess) + var keyResult = ResolveOperationKeys(sourcePath, destPath); + if (keyResult.IsFailure) { - AddOperation(operation); + return Result.Fail(keyResult.FirstErrorMessage) + .WithErrors(keyResult); + } + var fileSystem = FileSystem; + if (fileSystem is null) + { + return Result.Fail("Workspace is not loaded; resource file system is unavailable."); } - return result; + var operation = new CopyFolderOperation( + sourcePath, + destPath, + keyResult.Value.Source, + keyResult.Value.Destination, + EntityService, + ResourceRegistry, + fileSystem); + var execResult = await operation.ExecuteAsync(); + + if (execResult.IsFailure) + { + return Result.Fail(execResult.FirstErrorMessage) + .WithErrors(execResult); + } + + AddOperation(operation); + return Result.Ok(operation.LastCopyResult ?? EmptyCopyResult); } - public async Task MoveFolderAsync(string sourcePath, string destPath) + public async Task> MoveFolderAsync(string sourcePath, string destPath) { sourcePath = Path.GetFullPath(sourcePath); destPath = Path.GetFullPath(destPath); - var operation = new MoveFolderOperation(sourcePath, destPath, EntityService, ResourceRegistry); - var result = await operation.ExecuteAsync(); - - if (result.IsSuccess) + var keyResult = ResolveOperationKeys(sourcePath, destPath); + if (keyResult.IsFailure) { - AddOperation(operation); + return Result.Fail(keyResult.FirstErrorMessage) + .WithErrors(keyResult); + } + var fileSystem = FileSystem; + if (fileSystem is null) + { + return Result.Fail("Workspace is not loaded; resource file system is unavailable."); + } - // Notify opened documents that resources in this folder have moved - SendFolderResourceKeyChangedMessages(sourcePath, destPath); + var operation = new MoveFolderOperation( + sourcePath, + destPath, + keyResult.Value.Source, + keyResult.Value.Destination, + EntityService, + ResourceRegistry, + fileSystem); + var execResult = await operation.ExecuteAsync(); + + if (execResult.IsFailure) + { + return Result.Fail(execResult.FirstErrorMessage) + .WithErrors(execResult); } - return result; + AddOperation(operation); + + // Notify opened documents that resources in this folder have moved + SendFolderResourceKeyChangedMessages(sourcePath, destPath); + + return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); } public async Task DeleteFolderAsync(string path) @@ -347,6 +522,35 @@ public async Task RedoAsync() return result; } + // Maps a pair of project-folder absolute paths to ResourceKey form so the + // FS-layer-backed operations can address sources and destinations via key. + // The registry returns generated keys even for destinations that don't exist + // on disk yet, which is exactly the move/copy case. + private Result<(ResourceKey Source, ResourceKey Destination)> ResolveOperationKeys(string sourcePath, string destPath) + { + var registry = ResourceRegistry; + if (registry is null) + { + return Result<(ResourceKey, ResourceKey)>.Fail("Workspace is not loaded; resource registry is unavailable."); + } + + var sourceKeyResult = registry.GetResourceKey(sourcePath); + if (sourceKeyResult.IsFailure) + { + return Result<(ResourceKey, ResourceKey)>.Fail($"Failed to compute resource key for source path: '{sourcePath}'") + .WithErrors(sourceKeyResult); + } + + var destKeyResult = registry.GetResourceKey(destPath); + if (destKeyResult.IsFailure) + { + return Result<(ResourceKey, ResourceKey)>.Fail($"Failed to compute resource key for destination path: '{destPath}'") + .WithErrors(destKeyResult); + } + + return Result<(ResourceKey, ResourceKey)>.Ok((sourceKeyResult.Value, destKeyResult.Value)); + } + private void AddOperation(FileOperation operation) { if (_currentBatch != null) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index 2ea8527ff..b97d3ceb6 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -60,152 +60,130 @@ public override async Task UndoAsync() } /// -/// Undoable copy file operation. -/// Undo deletes the copied file. Redo copies it again. +/// Undoable copy file operation. The bytes-and-sidecar cascade runs through +/// IResourceFileSystem.CopyAsync; entity-data cascade rides alongside via +/// EntityFileHelper. /// internal class CopyFileOperation : FileOperation { private readonly string _sourcePath; private readonly string _destPath; + private readonly ResourceKey _sourceKey; + private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - - public CopyFileOperation(string sourcePath, string destPath, IEntityService? entityService, IResourceRegistry? resourceRegistry) + private readonly IResourceFileSystem _fileSystem; + + public CopyResult? LastCopyResult { get; private set; } + + public CopyFileOperation( + string sourcePath, + string destPath, + ResourceKey sourceKey, + ResourceKey destKey, + IEntityService? entityService, + IResourceRegistry? resourceRegistry, + IResourceFileSystem fileSystem) { _sourcePath = sourcePath; _destPath = destPath; + _sourceKey = sourceKey; + _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _fileSystem = fileSystem; } public override async Task ExecuteAsync() { - await Task.CompletedTask; + _entityHelper.CopyEntityDataFile(_sourcePath, _destPath); - try + var copyResult = await _fileSystem.CopyAsync(_sourceKey, _destKey); + if (copyResult.IsFailure) { - if (!File.Exists(_sourcePath)) - { - return Result.Fail($"Source file does not exist: {_sourcePath}"); - } - - if (File.Exists(_destPath)) - { - return Result.Fail($"Destination file already exists: {_destPath}"); - } - - var destFolder = Path.GetDirectoryName(_destPath); - if (!Directory.Exists(destFolder)) - { - return Result.Fail($"Destination folder does not exist: {destFolder}"); - } - - _entityHelper.CopyEntityDataFile(_sourcePath, _destPath); - File.Copy(_sourcePath, _destPath); - - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to copy file: {_sourcePath} to {_destPath}") - .WithException(ex); + return Result.Fail(copyResult.FirstErrorMessage) + .WithErrors(copyResult); } + + LastCopyResult = copyResult.Value; + return Result.Ok(); } public override async Task UndoAsync() { - await Task.CompletedTask; - - try - { - _entityHelper.DeleteEntityDataFile(_destPath); + _entityHelper.DeleteEntityDataFile(_destPath); - if (File.Exists(_destPath)) - { - File.Delete(_destPath); - } - return Result.Ok(); - } - catch (Exception ex) + var deleteResult = await _fileSystem.DeleteAsync(_destKey); + if (deleteResult.IsFailure) { - return Result.Fail($"Failed to undo copy file: {_destPath}") - .WithException(ex); + return Result.Fail(deleteResult.FirstErrorMessage) + .WithErrors(deleteResult); } + + return Result.Ok(); } } /// -/// Undoable move file operation. -/// Undo moves the file back. Redo moves it again. +/// Undoable move file operation. Bytes, reference rewrites, and sidecar cascade +/// run through IResourceFileSystem.MoveAsync; the inverse re-walks the reference +/// graph in the opposite direction so undo restores references too. /// internal class MoveFileOperation : FileOperation { private readonly string _sourcePath; private readonly string _destPath; + private readonly ResourceKey _sourceKey; + private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - - public MoveFileOperation(string sourcePath, string destPath, IEntityService? entityService, IResourceRegistry? resourceRegistry) + private readonly IResourceFileSystem _fileSystem; + + public MoveResult? LastMoveResult { get; private set; } + + public MoveFileOperation( + string sourcePath, + string destPath, + ResourceKey sourceKey, + ResourceKey destKey, + IEntityService? entityService, + IResourceRegistry? resourceRegistry, + IResourceFileSystem fileSystem) { _sourcePath = sourcePath; _destPath = destPath; + _sourceKey = sourceKey; + _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _fileSystem = fileSystem; } public override async Task ExecuteAsync() { - await Task.CompletedTask; + // Entity-data cascade runs before the bytes move so the source path + // still resolves while EntityFileHelper computes the destination key. + _entityHelper.MoveEntityDataFile(_sourcePath, _destPath); - try + var moveResult = await _fileSystem.MoveAsync(_sourceKey, _destKey); + if (moveResult.IsFailure) { - if (!File.Exists(_sourcePath)) - { - return Result.Fail($"Source file does not exist: {_sourcePath}"); - } - - // Allow move to same location with different case (case-only rename) - bool isSameFile = string.Equals(_sourcePath, _destPath, StringComparison.OrdinalIgnoreCase); - if (File.Exists(_destPath) && !isSameFile) - { - return Result.Fail($"Destination file already exists: {_destPath}"); - } - - var destFolder = Path.GetDirectoryName(_destPath); - if (!Directory.Exists(destFolder)) - { - return Result.Fail($"Destination folder does not exist: {destFolder}"); - } - - _entityHelper.MoveEntityDataFile(_sourcePath, _destPath); - File.Move(_sourcePath, _destPath); - - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to move file: {_sourcePath} to {_destPath}") - .WithException(ex); + return Result.Fail(moveResult.FirstErrorMessage) + .WithErrors(moveResult); } + + LastMoveResult = moveResult.Value; + return Result.Ok(); } public override async Task UndoAsync() { - await Task.CompletedTask; + _entityHelper.MoveEntityDataFile(_destPath, _sourcePath); - try - { - if (!File.Exists(_destPath)) - { - return Result.Fail($"File no longer exists at destination: {_destPath}"); - } - - _entityHelper.MoveEntityDataFile(_destPath, _sourcePath); - File.Move(_destPath, _sourcePath); - - return Result.Ok(); - } - catch (Exception ex) + var moveResult = await _fileSystem.MoveAsync(_destKey, _sourceKey); + if (moveResult.IsFailure) { - return Result.Fail($"Failed to undo move file: {_destPath} back to {_sourcePath}") - .WithException(ex); + return Result.Fail(moveResult.FirstErrorMessage) + .WithErrors(moveResult); } + + return Result.Ok(); } } @@ -218,13 +196,23 @@ internal class DeleteFileOperation : FileOperation private readonly string _trashPath; private readonly string? _entityDataOriginalPath; private readonly string? _entityDataTrashPath; - - public DeleteFileOperation(string originalPath, string trashPath, string? entityDataOriginalPath, string? entityDataTrashPath) + private readonly string? _sidecarOriginalPath; + private readonly string? _sidecarTrashPath; + + public DeleteFileOperation( + string originalPath, + string trashPath, + string? entityDataOriginalPath, + string? entityDataTrashPath, + string? sidecarOriginalPath, + string? sidecarTrashPath) { _originalPath = originalPath; _trashPath = trashPath; _entityDataOriginalPath = entityDataOriginalPath; _entityDataTrashPath = entityDataTrashPath; + _sidecarOriginalPath = sidecarOriginalPath; + _sidecarTrashPath = sidecarTrashPath; } public override async Task ExecuteAsync() @@ -238,6 +226,14 @@ public override async Task ExecuteAsync() return Result.Fail($"File does not exist: {_originalPath}"); } + // Clear read-only so the soft-delete (a File.Move into the trash + // folder) is not blocked by an attribute the user has explicitly + // chosen to override by invoking delete. The cleared state persists + // through undo — restoring a previously-read-only file produces a + // writable copy. A user who needs the read-only attribute back can + // re-apply it via the OS file properties dialog. + ClearReadOnlyIfSet(_originalPath); + FileSystemHelper.MoveFileWithDirectoryCreation(_originalPath, _trashPath); // Also move entity data file to trash if it exists @@ -245,9 +241,19 @@ public override async Task ExecuteAsync() !string.IsNullOrEmpty(_entityDataTrashPath) && File.Exists(_entityDataOriginalPath)) { + ClearReadOnlyIfSet(_entityDataOriginalPath); FileSystemHelper.MoveFileWithDirectoryCreation(_entityDataOriginalPath, _entityDataTrashPath); } + // Also move the paired sidecar to trash if it exists + if (!string.IsNullOrEmpty(_sidecarOriginalPath) && + !string.IsNullOrEmpty(_sidecarTrashPath) && + File.Exists(_sidecarOriginalPath)) + { + ClearReadOnlyIfSet(_sidecarOriginalPath); + FileSystemHelper.MoveFileWithDirectoryCreation(_sidecarOriginalPath, _sidecarTrashPath); + } + return Result.Ok(); } catch (Exception ex) @@ -257,6 +263,23 @@ public override async Task ExecuteAsync() } } + private static void ClearReadOnlyIfSet(string path) + { + try + { + var info = new FileInfo(path); + if (info.Exists + && info.IsReadOnly) + { + info.IsReadOnly = false; + } + } + catch + { + // Best effort; surface from the subsequent move/delete failure. + } + } + public override async Task UndoAsync() { await Task.CompletedTask; @@ -278,6 +301,14 @@ public override async Task UndoAsync() FileSystemHelper.MoveFileWithDirectoryCreation(_entityDataTrashPath, _entityDataOriginalPath); } + // Also restore the paired sidecar if it was trashed + if (!string.IsNullOrEmpty(_sidecarOriginalPath) && + !string.IsNullOrEmpty(_sidecarTrashPath) && + File.Exists(_sidecarTrashPath)) + { + FileSystemHelper.MoveFileWithDirectoryCreation(_sidecarTrashPath, _sidecarOriginalPath); + } + FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); return Result.Ok(); @@ -303,6 +334,11 @@ public void CleanupTrashFile() FileSystemHelper.DeleteFileIfExists(_entityDataTrashPath); } + if (!string.IsNullOrEmpty(_sidecarTrashPath)) + { + FileSystemHelper.DeleteFileIfExists(_sidecarTrashPath); + } + FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); } catch @@ -313,139 +349,130 @@ public void CleanupTrashFile() } /// -/// Undoable copy folder operation. +/// Undoable copy folder operation. Bytes-and-sidecar cascade runs through +/// IResourceFileSystem.CopyAsync; entity-data cascade rides alongside via +/// EntityFileHelper. /// internal class CopyFolderOperation : FileOperation { private readonly string _sourcePath; private readonly string _destPath; + private readonly ResourceKey _sourceKey; + private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - - public CopyFolderOperation(string sourcePath, string destPath, IEntityService? entityService, IResourceRegistry? resourceRegistry) + private readonly IResourceFileSystem _fileSystem; + + public CopyResult? LastCopyResult { get; private set; } + + public CopyFolderOperation( + string sourcePath, + string destPath, + ResourceKey sourceKey, + ResourceKey destKey, + IEntityService? entityService, + IResourceRegistry? resourceRegistry, + IResourceFileSystem fileSystem) { _sourcePath = sourcePath; _destPath = destPath; + _sourceKey = sourceKey; + _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _fileSystem = fileSystem; } public override async Task ExecuteAsync() { - await Task.CompletedTask; - - try + var copyResult = await _fileSystem.CopyAsync(_sourceKey, _destKey); + if (copyResult.IsFailure) { - if (!Directory.Exists(_sourcePath)) - { - return Result.Fail($"Source folder does not exist: {_sourcePath}"); - } - - if (Directory.Exists(_destPath)) - { - return Result.Fail($"Destination folder already exists: {_destPath}"); - } + return Result.Fail(copyResult.FirstErrorMessage) + .WithErrors(copyResult); + } - ResourceUtils.CopyFolder(_sourcePath, _destPath); - _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destPath); + _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destPath); + LastCopyResult = copyResult.Value; - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to copy folder: {_sourcePath} to {_destPath}") - .WithException(ex); - } + return Result.Ok(); } public override async Task UndoAsync() { - await Task.CompletedTask; + _entityHelper.DeleteFolderEntityDataFiles(_destPath); - try + var deleteResult = await _fileSystem.DeleteAsync(_destKey); + if (deleteResult.IsFailure) { - if (Directory.Exists(_destPath)) - { - _entityHelper.DeleteFolderEntityDataFiles(_destPath); - Directory.Delete(_destPath, recursive: true); - } - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to undo copy folder: {_destPath}") - .WithException(ex); + return Result.Fail(deleteResult.FirstErrorMessage) + .WithErrors(deleteResult); } + + return Result.Ok(); } } /// -/// Undoable move folder operation. +/// Undoable move folder operation. Bytes, reference rewrites, and sidecar +/// cascade run through IResourceFileSystem.MoveAsync; the inverse re-walks the +/// reference graph in the opposite direction. /// internal class MoveFolderOperation : FileOperation { private readonly string _sourcePath; private readonly string _destPath; + private readonly ResourceKey _sourceKey; + private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - - public MoveFolderOperation(string sourcePath, string destPath, IEntityService? entityService, IResourceRegistry? resourceRegistry) + private readonly IResourceFileSystem _fileSystem; + + public MoveResult? LastMoveResult { get; private set; } + + public MoveFolderOperation( + string sourcePath, + string destPath, + ResourceKey sourceKey, + ResourceKey destKey, + IEntityService? entityService, + IResourceRegistry? resourceRegistry, + IResourceFileSystem fileSystem) { _sourcePath = sourcePath; _destPath = destPath; + _sourceKey = sourceKey; + _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _fileSystem = fileSystem; } public override async Task ExecuteAsync() { - await Task.CompletedTask; + // Move entity data files first (while source folder still exists for enumeration). + _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destPath); - try + var moveResult = await _fileSystem.MoveAsync(_sourceKey, _destKey); + if (moveResult.IsFailure) { - if (!Directory.Exists(_sourcePath)) - { - return Result.Fail($"Source folder does not exist: {_sourcePath}"); - } - - // Allow move to same location with different case (case-only rename) - bool isSameFolder = string.Equals(_sourcePath, _destPath, StringComparison.OrdinalIgnoreCase); - if (Directory.Exists(_destPath) && !isSameFolder) - { - return Result.Fail($"Destination folder already exists: {_destPath}"); - } - - // Move entity data files first (while source folder still exists for enumeration) - _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destPath); - Directory.Move(_sourcePath, _destPath); - - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to move folder: {_sourcePath} to {_destPath}") - .WithException(ex); + return Result.Fail(moveResult.FirstErrorMessage) + .WithErrors(moveResult); } + + LastMoveResult = moveResult.Value; + return Result.Ok(); } public override async Task UndoAsync() { - await Task.CompletedTask; + // Move entity data files back first (while dest folder still exists for enumeration). + _entityHelper.MoveFolderEntityDataFiles(_destPath, _sourcePath); - try + var moveResult = await _fileSystem.MoveAsync(_destKey, _sourceKey); + if (moveResult.IsFailure) { - if (!Directory.Exists(_destPath)) - { - return Result.Fail($"Folder no longer exists at destination: {_destPath}"); - } - - // Move entity data files back first (while dest folder still exists for enumeration) - _entityHelper.MoveFolderEntityDataFiles(_destPath, _sourcePath); - Directory.Move(_destPath, _sourcePath); - - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to undo move folder: {_destPath} back to {_sourcePath}") - .WithException(ex); + return Result.Fail(moveResult.FirstErrorMessage) + .WithErrors(moveResult); } + + return Result.Ok(); } } @@ -482,6 +509,12 @@ public override async Task ExecuteAsync() return Result.Fail($"Folder does not exist: {_originalPath}"); } + // Clear read-only on every contained file so the folder move into + // trash (or the empty-folder Directory.Delete) is not blocked by an + // attribute the user has explicitly chosen to override by invoking + // delete on the parent folder. + ClearReadOnlyRecursive(_originalPath); + if (FileSystemHelper.IsDirectoryEmpty(_originalPath)) { Directory.Delete(_originalPath); @@ -504,6 +537,33 @@ public override async Task ExecuteAsync() } } + private static void ClearReadOnlyRecursive(string folder) + { + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + { + try + { + var info = new FileInfo(file); + if (info.Exists + && info.IsReadOnly) + { + info.IsReadOnly = false; + } + } + catch + { + // Best effort per file; surface aggregate via the delete failure. + } + } + } + catch + { + // Best effort traversal. + } + } + public override async Task UndoAsync() { await Task.CompletedTask; @@ -589,6 +649,134 @@ private void RestoreEntityDataFiles() } } +/// +/// Undoable copy of bytes from outside the project folder (file). External +/// imports carry no inbound references or sidecars, so the cascade does not +/// apply; this operation does a direct File.Copy and tracks undo as a delete. +/// +internal class CopyExternalFileOperation : FileOperation +{ + private readonly string _sourcePath; + private readonly string _destPath; + + public CopyExternalFileOperation(string sourcePath, string destPath) + { + _sourcePath = sourcePath; + _destPath = destPath; + } + + public override async Task ExecuteAsync() + { + await Task.CompletedTask; + + try + { + if (!File.Exists(_sourcePath)) + { + return Result.Fail($"Source file does not exist: {_sourcePath}"); + } + if (File.Exists(_destPath)) + { + return Result.Fail($"Destination file already exists: {_destPath}"); + } + + var destFolder = Path.GetDirectoryName(_destPath); + if (!string.IsNullOrEmpty(destFolder) + && !Directory.Exists(destFolder)) + { + Directory.CreateDirectory(destFolder); + } + + File.Copy(_sourcePath, _destPath); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to copy external file: {_sourcePath} to {_destPath}") + .WithException(ex); + } + } + + public override async Task UndoAsync() + { + await Task.CompletedTask; + + try + { + if (File.Exists(_destPath)) + { + File.Delete(_destPath); + } + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to undo external file copy: {_destPath}") + .WithException(ex); + } + } +} + +/// +/// Undoable copy of bytes from outside the project folder (folder). Mirrors +/// CopyExternalFileOperation; no cascade applies. +/// +internal class CopyExternalFolderOperation : FileOperation +{ + private readonly string _sourcePath; + private readonly string _destPath; + + public CopyExternalFolderOperation(string sourcePath, string destPath) + { + _sourcePath = sourcePath; + _destPath = destPath; + } + + public override async Task ExecuteAsync() + { + await Task.CompletedTask; + + try + { + if (!Directory.Exists(_sourcePath)) + { + return Result.Fail($"Source folder does not exist: {_sourcePath}"); + } + if (Directory.Exists(_destPath)) + { + return Result.Fail($"Destination folder already exists: {_destPath}"); + } + + ResourceUtils.CopyFolder(_sourcePath, _destPath); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to copy external folder: {_sourcePath} to {_destPath}") + .WithException(ex); + } + } + + public override async Task UndoAsync() + { + await Task.CompletedTask; + + try + { + if (Directory.Exists(_destPath)) + { + Directory.Delete(_destPath, recursive: true); + } + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to undo external folder copy: {_destPath}") + .WithException(ex); + } + } +} + /// /// Undoable create file operation. /// Undo deletes the created file. Redo recreates it. From 748b0e8d3b3488c6ed1a2b4ce5a3e482c968650b Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 21 May 2026 21:07:56 +0100 Subject: [PATCH 10/48] Use Resource* messages; eager notify on FS ops Rename MonitoredResource* messages to Resource* and update all registrations/handlers and tests to use the new message types. Inject IMessengerService into ResourceFileSystem (and use it in ResourceOperationService) to synchronously broadcast ResourceDeletedMessage for moved/deleted keys (including captured descendant file keys) so subscribers can update immediately (watcher events remain idempotent). Improve folder-aware referencer handling in DeleteResourceCommand (expand folder targets, avoid blocking internal deletions) and aggregate per-resource failure Results in CopyResourceCommand so callers receive detailed errors. Update tests to supply a messenger mock and use the new message types. Add helper to enumerate descendant keys and several explanatory comments. --- .../Resources/ResourceMessages.cs | 20 +++--- .../Tests/Documents/DocumentViewModelTests.cs | 16 ++--- .../Resources/ApplyRangeEditsCommandTests.cs | 3 +- .../Tests/Resources/EditFileCommandTests.cs | 3 +- .../Resources/MultiEditFileCommandTests.cs | 3 +- .../Resources/ReplaceFileCommandTests.cs | 3 +- .../Resources/ResourceFileSystemTests.cs | 2 + .../Tests/Resources/ResourceMetaDataTests.cs | 2 +- .../Resources/WriteBinaryFileCommandTests.cs | 3 +- .../Tests/Resources/WriteFileCommandTests.cs | 3 +- .../ViewModels/ConsolePanelViewModel.cs | 4 +- .../ViewModels/DocumentViewModel.cs | 6 +- .../Commands/CopyResourceCommand.cs | 13 +++- .../Commands/DeleteResourceCommand.cs | 70 +++++++++++++++++-- .../Services/ResourceFileSystem.cs | 65 +++++++++++++++++ .../Services/ResourceMetaData.cs | 26 +++---- .../Services/ResourceMonitor.cs | 8 +-- .../Services/ResourceOperationService.cs | 48 +++++++++++++ .../ViewModels/SearchPanelViewModel.cs | 21 +++--- 19 files changed, 259 insertions(+), 60 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs b/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs index 17f885fe4..9a3b0392a 100644 --- a/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs +++ b/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs @@ -45,21 +45,25 @@ public record SelectedResourceChangedMessage(ResourceKey Resource); public record ResourceOperationFailedMessage(ResourceOperationType OperationType, List FailedItems); /// -/// A message sent when a monitored resource has been created in the file system. +/// Broadcast when a resource has appeared at the given key. Fired by the +/// filesystem watcher and by structural operations that have already applied +/// the change on disk. /// -public record MonitoredResourceCreatedMessage(ResourceKey Resource); +public record ResourceCreatedMessage(ResourceKey Resource); /// -/// A message sent when a monitored resource has been modified in the file system. +/// Broadcast when an existing resource's bytes have changed. /// -public record MonitoredResourceChangedMessage(ResourceKey Resource); +public record ResourceChangedMessage(ResourceKey Resource); /// -/// A message sent when a monitored resource has been deleted from the file system. +/// Broadcast when a resource has been removed from the given key. Fired by the +/// filesystem watcher and by structural operations that have already applied +/// the change on disk. /// -public record MonitoredResourceDeletedMessage(ResourceKey Resource); +public record ResourceDeletedMessage(ResourceKey Resource); /// -/// A message sent when a monitored resource has been renamed or moved in the file system. +/// Broadcast when a resource has moved from one key to another. /// -public record MonitoredResourceRenamedMessage(ResourceKey OldResource, ResourceKey NewResource); +public record ResourceRenamedMessage(ResourceKey OldResource, ResourceKey NewResource); diff --git a/Source/Tests/Documents/DocumentViewModelTests.cs b/Source/Tests/Documents/DocumentViewModelTests.cs index 733f17c5f..9e9a72c2e 100644 --- a/Source/Tests/Documents/DocumentViewModelTests.cs +++ b/Source/Tests/Documents/DocumentViewModelTests.cs @@ -49,7 +49,7 @@ public void Setup() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - _fileSystem = new ResourceFileSystem(Substitute.For>(), workspaceWrapper); + _fileSystem = new ResourceFileSystem(Substitute.For>(), _messengerService, workspaceWrapper); workspaceService.ResourceFileSystem.Returns(_fileSystem); var services = new ServiceCollection(); @@ -136,7 +136,7 @@ public async Task SaveDocumentContent_ReturnsFailure_WhenWriterFails() var failingWrapper = Substitute.For(); failingWrapper.WorkspaceService.Returns(failingWorkspaceService); - var failingFileSystem = new ResourceFileSystem(Substitute.For>(), failingWrapper); + var failingFileSystem = new ResourceFileSystem(Substitute.For>(), _messengerService, failingWrapper); var failingVm = new TestDocumentViewModel(failingFileSystem) { @@ -161,37 +161,37 @@ public void OnTextChanged_SetsUnsavedChanges_AndResetsSaveTimer() } [Test] - public void MonitoredResourceChanged_TriggersReload_WhenFileChangedExternally() + public void ResourceChanged_TriggersReload_WhenFileChangedExternally() { // With no prior load/save the hash is null, so any change is treated as external var reloadRequested = false; _vm.ReloadRequested += (_, _) => reloadRequested = true; - var message = new MonitoredResourceChangedMessage(_vm.FileResource); + var message = new ResourceChangedMessage(_vm.FileResource); _messengerService.Send(message); reloadRequested.Should().BeTrue(); } [Test] - public void OnMonitoredResourceChanged_ResetsSaveTimer_WhenExternalChangeArrives() + public void OnResourceChanged_ResetsSaveTimer_WhenExternalChangeArrives() { _vm.HasUnsavedChanges = true; _vm.SaveTimer = 0.5; - var message = new MonitoredResourceChangedMessage(_vm.FileResource); + var message = new ResourceChangedMessage(_vm.FileResource); _messengerService.Send(message); _vm.SaveTimer.Should().Be(0); } [Test] - public void OnMonitoredResourceChanged_ResetsHasUnsavedChanges_WhenExternalChangeArrives() + public void OnResourceChanged_ResetsHasUnsavedChanges_WhenExternalChangeArrives() { _vm.HasUnsavedChanges = true; _vm.SaveTimer = 0.5; - var message = new MonitoredResourceChangedMessage(_vm.FileResource); + var message = new ResourceChangedMessage(_vm.FileResource); _messengerService.Send(message); _vm.HasUnsavedChanges.Should().BeFalse(); diff --git a/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs index 127e6fdba..7bfb09178 100644 --- a/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs +++ b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs @@ -1,4 +1,5 @@ using Celbridge.Dialog; +using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Commands; using Celbridge.Resources.Services; @@ -36,7 +37,7 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.ResourceFileSystem.Returns(fileSystem); } diff --git a/Source/Tests/Resources/EditFileCommandTests.cs b/Source/Tests/Resources/EditFileCommandTests.cs index 846e2379c..a56dba322 100644 --- a/Source/Tests/Resources/EditFileCommandTests.cs +++ b/Source/Tests/Resources/EditFileCommandTests.cs @@ -1,3 +1,4 @@ +using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Commands; using Celbridge.Resources.Services; @@ -35,7 +36,7 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.ResourceFileSystem.Returns(fileSystem); } diff --git a/Source/Tests/Resources/MultiEditFileCommandTests.cs b/Source/Tests/Resources/MultiEditFileCommandTests.cs index c975bcb96..ead150048 100644 --- a/Source/Tests/Resources/MultiEditFileCommandTests.cs +++ b/Source/Tests/Resources/MultiEditFileCommandTests.cs @@ -1,3 +1,4 @@ +using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Commands; using Celbridge.Resources.Services; @@ -35,7 +36,7 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.ResourceFileSystem.Returns(fileSystem); } diff --git a/Source/Tests/Resources/ReplaceFileCommandTests.cs b/Source/Tests/Resources/ReplaceFileCommandTests.cs index 0487c4f92..39593a666 100644 --- a/Source/Tests/Resources/ReplaceFileCommandTests.cs +++ b/Source/Tests/Resources/ReplaceFileCommandTests.cs @@ -1,3 +1,4 @@ +using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Commands; using Celbridge.Resources.Services; @@ -35,7 +36,7 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.ResourceFileSystem.Returns(fileSystem); } diff --git a/Source/Tests/Resources/ResourceFileSystemTests.cs b/Source/Tests/Resources/ResourceFileSystemTests.cs index e05c14c61..56ed56493 100644 --- a/Source/Tests/Resources/ResourceFileSystemTests.cs +++ b/Source/Tests/Resources/ResourceFileSystemTests.cs @@ -1,3 +1,4 @@ +using Celbridge.Messaging; using Celbridge.Projects; using Celbridge.Resources; using Celbridge.Resources.Services; @@ -50,6 +51,7 @@ public void Setup() _fileSystem = new ResourceFileSystem( Substitute.For>(), + Substitute.For(), workspaceWrapper); } diff --git a/Source/Tests/Resources/ResourceMetaDataTests.cs b/Source/Tests/Resources/ResourceMetaDataTests.cs index 0a28adefc..3d3c7d8ae 100644 --- a/Source/Tests/Resources/ResourceMetaDataTests.cs +++ b/Source/Tests/Resources/ResourceMetaDataTests.cs @@ -365,7 +365,7 @@ public async Task TransientReadFailure_PreservesExistingIndexEntries() // While the file is locked, send a change event. The worker will // attempt to read source.md, fail with an IOException, and classify // the failure as transient. - _messengerService.Send(new MonitoredResourceChangedMessage(sourceKey)); + _messengerService.Send(new ResourceChangedMessage(sourceKey)); await _metaData.WaitForPendingUpdatesAsync(); await Task.Delay(150); diff --git a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs index f93370b7d..944cff064 100644 --- a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs +++ b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs @@ -1,3 +1,4 @@ +using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Commands; using Celbridge.Resources.Services; @@ -34,7 +35,7 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.ResourceFileSystem.Returns(fileSystem); } diff --git a/Source/Tests/Resources/WriteFileCommandTests.cs b/Source/Tests/Resources/WriteFileCommandTests.cs index 2572cd85c..e0f06bae0 100644 --- a/Source/Tests/Resources/WriteFileCommandTests.cs +++ b/Source/Tests/Resources/WriteFileCommandTests.cs @@ -1,3 +1,4 @@ +using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Commands; using Celbridge.Resources.Services; @@ -34,7 +35,7 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), _workspaceWrapper); + var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.ResourceFileSystem.Returns(fileSystem); } diff --git a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs index f34f70c98..e8d947730 100644 --- a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs +++ b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs @@ -96,7 +96,7 @@ public ConsolePanelViewModel( _messengerService.Register(this, OnConsoleError); // Register for resource change messages to monitor project file changes - _messengerService.Register(this, OnMonitoredResourceChanged); + _messengerService.Register(this, OnResourceChanged); // Register for console maximized state changes _messengerService.Register(this, OnConsoleMaximizedChanged); @@ -217,7 +217,7 @@ private void ShowConsolePanel() }); } - private void OnMonitoredResourceChanged(object recipient, MonitoredResourceChangedMessage message) + private void OnResourceChanged(object recipient, ResourceChangedMessage message) { // Check if the changed resource is the .celbridge project file var projectFilePath = _projectService?.CurrentProject?.ProjectFilePath; diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index c1c551338..cca528b23 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -76,7 +76,7 @@ protected void RaiseReloadRequested() /// /// Enables file-change monitoring for this document. - /// Registers for MonitoredResourceChangedMessage and DocumentSaveCompletedMessage. + /// Registers for ResourceChangedMessage and DocumentSaveCompletedMessage. /// Call this in the ViewModel constructor for editors that need external file change detection. /// protected void EnableFileChangeMonitoring() @@ -94,11 +94,11 @@ protected void EnableFileChangeMonitoring() // Logger may not be available in test environments } - _messengerService.Register(this, OnMonitoredResourceChanged); + _messengerService.Register(this, OnResourceChanged); _messengerService.Register(this, OnDocumentSaveCompleted); } - private void OnMonitoredResourceChanged(object recipient, MonitoredResourceChangedMessage message) + private void OnResourceChanged(object recipient, ResourceChangedMessage message) { if (message.Resource != FileResource) { diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 02903c15e..50938c2f9 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -90,6 +90,7 @@ public override async Task ExecuteAsync() resourceOpService.BeginBatch(); List failedResources = new(); + List failedOutcomes = new(); List copiedFolders = new(); List aggregatedUpdated = new(); List aggregatedSkipped = new(); @@ -105,6 +106,7 @@ public override async Task ExecuteAsync() { _logger.LogError(outcome.Result.DiagnosticReport); failedResources.Add(sourceResource); + failedOutcomes.Add(outcome.Result); } else if (outcome.ParentFolder.HasValue) { @@ -175,7 +177,16 @@ public override async Task ExecuteAsync() var message = new ResourceOperationFailedMessage(operationType, failedDisplayNames); _messengerService.Send(message); - return Result.Fail($"Failed to {operation}: {failedList}"); + // Propagate every per-resource failure into the bubble-up Result so + // the agent sees the FS-layer's specific message (e.g. + // "Destination already exists: ''") via MessageChain rather + // than just the resource name. + var aggregated = Result.Fail($"Failed to {operation}: {failedList}"); + foreach (var failedOutcome in failedOutcomes) + { + aggregated.WithErrors(failedOutcome); + } + return aggregated; } return Result.Ok(); diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index 9d9ce0e67..538779692 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -61,18 +61,68 @@ public override async Task ExecuteAsync() // Phase A: aggregate referencers external to the batch. References // from one doomed resource to another are filtered out so an internal - // dependency doesn't block the batch. + // dependency doesn't block the batch. Folder resources expand to every + // descendant key the reference graph knows about, so a folder delete + // surfaces incoming references to anything inside the folder, not just + // references to the folder key itself (which are usually none). var batchSet = new HashSet(Resources); + var folderResources = new List(); + foreach (var resource in Resources) + { + if (IsFolderResource(resourceRegistry, resource)) + { + folderResources.Add(resource); + } + } + bool IsInsideBatch(ResourceKey candidate) + { + if (batchSet.Contains(candidate)) + { + return true; + } + foreach (var folder in folderResources) + { + if (candidate.IsDescendantOf(folder)) + { + return true; + } + } + return false; + } + var externalReferencers = new Dictionary>(); foreach (var resource in Resources) { - var referencers = metaData.GetReferencers(resource); + var keysToCheck = new List { resource }; + if (folderResources.Contains(resource)) + { + // The reference graph keys descendants by file, not by folder. + // Walk every indexed target and pull in those that live under + // this folder so we surface every incoming reference that the + // recursive delete will leave dangling. + foreach (var target in metaData.GetAllReferencedTargets()) + { + if (target.IsDescendantOf(resource)) + { + keysToCheck.Add(target); + } + } + } + var externalOnly = new List(); - foreach (var referencer in referencers) + var seen = new HashSet(); + foreach (var key in keysToCheck) { - if (!batchSet.Contains(referencer)) + foreach (var referencer in metaData.GetReferencers(key)) { - externalOnly.Add(referencer); + if (IsInsideBatch(referencer)) + { + continue; + } + if (seen.Add(referencer)) + { + externalOnly.Add(referencer); + } } } if (externalOnly.Count > 0) @@ -260,6 +310,16 @@ private static (DeleteResourceOutcome Outcome, string Message) ClassifyDeleteFai return (DeleteResourceOutcome.IOFailure, deleteResult.FirstErrorMessage); } + private static bool IsFolderResource(IResourceRegistry registry, ResourceKey resource) + { + var resolveResult = registry.ResolveResourcePath(resource); + if (resolveResult.IsFailure) + { + return false; + } + return Directory.Exists(resolveResult.Value); + } + private static bool SidecarExistsForResource(IResourceRegistry registry, ResourceKey resource) { if (resource.IsEmpty) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 3b846da5e..e1023b5a8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -20,6 +20,7 @@ public sealed class ResourceFileSystem : IResourceFileSystem private const int StreamBufferSize = 4096; private readonly ILogger _logger; + private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; // The resource registry is workspace-scoped and transient: a constructor- @@ -29,9 +30,11 @@ public sealed class ResourceFileSystem : IResourceFileSystem // at call time. public ResourceFileSystem( ILogger logger, + IMessengerService messengerService, IWorkspaceWrapper workspaceWrapper) { _logger = logger; + _messengerService = messengerService; _workspaceWrapper = workspaceWrapper; } @@ -208,6 +211,14 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey } } + // Capture descendant keys (folders only) before the disk move so the + // post-move eager-notify can drop their stale source-side index + // entries. After Directory.Move the source path is gone and the + // enumeration is no longer possible. + var sourceDescendantKeys = sourceIsFolder + ? EnumerateDescendantKeys(registry, sourcePath) + : Array.Empty(); + try { var destParent = Path.GetDirectoryName(destPath); @@ -245,6 +256,17 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey if (source.Root == ResourceKey.DefaultRoot) { + // Announce the source removal synchronously so subscribers update + // before control returns. The watcher's own delete event still + // arrives later via UI-thread dispatch; subscribers must treat + // these messages as idempotent. + var sourceRemovedMessage = new ResourceDeletedMessage(source); + _messengerService.Send(sourceRemovedMessage); + foreach (var key in sourceDescendantKeys) + { + var descendantRemovedMessage = new ResourceDeletedMessage(key); + _messengerService.Send(descendantRemovedMessage); + } var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; await metaData.WaitForPendingUpdatesAsync(); } @@ -353,6 +375,12 @@ public async Task> DeleteAsync(ResourceKey source) var sidecarOutcome = TryCascadeSidecarDelete(source); + // Capture descendant keys (folders only) before the disk delete so the + // post-delete eager-notify can drop their stale index entries too. + var descendantKeys = sourceIsFolder + ? EnumerateDescendantKeys(registry, sourcePath) + : Array.Empty(); + try { if (sourceIsFile) @@ -384,6 +412,17 @@ public async Task> DeleteAsync(ResourceKey source) if (source.Root == ResourceKey.DefaultRoot) { + // Announce the removal synchronously so subscribers update before + // control returns. The watcher's own delete event still arrives + // later via UI-thread dispatch; subscribers must treat these + // messages as idempotent. + var sourceRemovedMessage = new ResourceDeletedMessage(source); + _messengerService.Send(sourceRemovedMessage); + foreach (var key in descendantKeys) + { + var descendantRemovedMessage = new ResourceDeletedMessage(key); + _messengerService.Send(descendantRemovedMessage); + } var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; await metaData.WaitForPendingUpdatesAsync(); } @@ -391,6 +430,32 @@ public async Task> DeleteAsync(ResourceKey source) return Result.Ok(new DeleteResult(sidecarOutcome)); } + // Returns the resource keys of every file inside a folder that exists on + // disk. Used to capture descendant keys before a recursive delete or move + // so eager-notify can drop their stale entries from the reference index. + private static IReadOnlyList EnumerateDescendantKeys(IResourceRegistry registry, string folderPath) + { + var keys = new List(); + try + { + foreach (var file in Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories)) + { + var keyResult = registry.GetResourceKey(file); + if (keyResult.IsSuccess) + { + keys.Add(keyResult.Value); + } + } + } + catch + { + // Best effort. A failure here just means descendant keys won't be + // eager-notified; the watcher events still arrive eventually and + // clean up the index. + } + return keys; + } + public Task> ExistsAsync(ResourceKey resource) { var resolveResult = ResolvePath(resource); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs index c81e2a932..ca24a77df 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs @@ -69,10 +69,10 @@ public ResourceMetaData( _workspaceWrapper = workspaceWrapper; _textBinarySniffer = textBinarySniffer; - _messengerService.Register(this, OnResourceCreated); - _messengerService.Register(this, OnResourceChanged); - _messengerService.Register(this, OnResourceDeleted); - _messengerService.Register(this, OnResourceRenamed); + _messengerService.Register(this, OnResourceCreated); + _messengerService.Register(this, OnResourceChanged); + _messengerService.Register(this, OnResourceDeleted); + _messengerService.Register(this, OnResourceRenamed); _workerTask = Task.Run(WorkerLoopAsync); } @@ -480,24 +480,26 @@ private void UpdateSourceInIndexes(ResourceKey source, HashSet refe } } - private void OnResourceCreated(object recipient, MonitoredResourceCreatedMessage message) + private void OnResourceCreated(object recipient, ResourceCreatedMessage message) { - // A fresh watcher event means the file's state is changing; reset the - // retry budget so a file that previously gave up after MaxScanRetryAttempts - // gets re-scanned with full budget on its next legitimate change. + // A fresh lifecycle event means the file's state is changing; reset + // the retry budget so a file that previously gave up after + // MaxScanRetryAttempts gets re-scanned with full budget on its next + // legitimate change. _transientFailureCounts.TryRemove(message.Resource, out _); QueueRescan(message.Resource); } - private void OnResourceChanged(object recipient, MonitoredResourceChangedMessage message) + private void OnResourceChanged(object recipient, ResourceChangedMessage message) { _transientFailureCounts.TryRemove(message.Resource, out _); QueueRescan(message.Resource); } - private void OnResourceDeleted(object recipient, MonitoredResourceDeletedMessage message) + private void OnResourceDeleted(object recipient, ResourceDeletedMessage message) { - if (message.Resource.Root != ResourceKey.DefaultRoot) + if (message.Resource.Root != ResourceKey.DefaultRoot + || message.Resource.IsEmpty) { return; } @@ -505,7 +507,7 @@ private void OnResourceDeleted(object recipient, MonitoredResourceDeletedMessage _transientFailureCounts.TryRemove(message.Resource, out _); } - private void OnResourceRenamed(object recipient, MonitoredResourceRenamedMessage message) + private void OnResourceRenamed(object recipient, ResourceRenamedMessage message) { if (message.OldResource.Root == ResourceKey.DefaultRoot) { diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs index 02a98fa35..06d6dd9f7 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs @@ -295,7 +295,7 @@ private void OnResourceCreated(IResourceRootHandler handler, string fullPath) _dispatcher.TryEnqueue(() => { - var message = new MonitoredResourceCreatedMessage(resourceKey); + var message = new ResourceCreatedMessage(resourceKey); _messengerService.Send(message); }); } @@ -313,7 +313,7 @@ private void OnResourceChanged(IResourceRootHandler handler, string fullPath) _dispatcher.TryEnqueue(() => { - var message = new MonitoredResourceChangedMessage(resourceKey); + var message = new ResourceChangedMessage(resourceKey); _messengerService.Send(message); }); } @@ -331,7 +331,7 @@ private void OnResourceDeleted(IResourceRootHandler handler, string fullPath) _dispatcher.TryEnqueue(() => { - var message = new MonitoredResourceDeletedMessage(resourceKey); + var message = new ResourceDeletedMessage(resourceKey); _messengerService.Send(message); }); } @@ -351,7 +351,7 @@ private void OnResourceRenamed(IResourceRootHandler handler, string oldFullPath, _dispatcher.TryEnqueue(() => { - var message = new MonitoredResourceRenamedMessage(oldResourceKey, newResourceKey); + var message = new ResourceRenamedMessage(oldResourceKey, newResourceKey); _messengerService.Send(message); }); } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 8e98d1fc5..8e946b2cd 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -274,6 +274,20 @@ public async Task DeleteFileAsync(string path) if (result.IsSuccess) { AddOperation(operation); + + // Announce the removal synchronously so subscribers update before + // control returns. The watcher's own delete event still arrives + // later via UI-thread dispatch; subscribers must treat these + // messages as idempotent. + if (ResourceRegistry is not null) + { + var keyResult = ResourceRegistry.GetResourceKey(path); + if (keyResult.IsSuccess) + { + var removedMessage = new ResourceDeletedMessage(keyResult.Value); + _messengerService.Send(removedMessage); + } + } } return result; @@ -409,12 +423,46 @@ public async Task DeleteFolderAsync(string path) } } + // Capture descendant keys (folders only) before the disk delete so the + // post-delete eager-notify can drop their stale entries too. + var descendantKeys = new List(); + if (!wasEmpty && ResourceRegistry is not null) + { + foreach (var filePath in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) + { + var keyResult = ResourceRegistry.GetResourceKey(filePath); + if (keyResult.IsSuccess) + { + descendantKeys.Add(keyResult.Value); + } + } + } + var operation = new DeleteFolderOperation(path, trashPath, wasEmpty, entityDataFiles); var result = await operation.ExecuteAsync(); if (result.IsSuccess) { AddOperation(operation); + + // Announce the removal synchronously so subscribers update before + // control returns. The folder key and every captured descendant are + // broadcast; the watcher events still arrive later via UI-thread + // dispatch and are idempotent against the prior notification. + if (ResourceRegistry is not null) + { + var folderKeyResult = ResourceRegistry.GetResourceKey(path); + if (folderKeyResult.IsSuccess) + { + var folderRemovedMessage = new ResourceDeletedMessage(folderKeyResult.Value); + _messengerService.Send(folderRemovedMessage); + } + foreach (var key in descendantKeys) + { + var descendantRemovedMessage = new ResourceDeletedMessage(key); + _messengerService.Send(descendantRemovedMessage); + } + } } return result; diff --git a/Source/Workspace/Celbridge.Search/ViewModels/SearchPanelViewModel.cs b/Source/Workspace/Celbridge.Search/ViewModels/SearchPanelViewModel.cs index d53594459..77c048d92 100644 --- a/Source/Workspace/Celbridge.Search/ViewModels/SearchPanelViewModel.cs +++ b/Source/Workspace/Celbridge.Search/ViewModels/SearchPanelViewModel.cs @@ -174,30 +174,31 @@ public SearchPanelViewModel( // Listen for workspace loaded to load search/replace history from workspace settings _messengerService.Register(this, OnWorkspaceLoaded); - // Listen for file system changes to refresh search results - // This catches all modifications: user edits, external editors, scripts, agents, etc. - _messengerService.Register(this, OnResourceChanged); - _messengerService.Register(this, OnResourceCreated); - _messengerService.Register(this, OnResourceDeleted); - _messengerService.Register(this, OnResourceRenamed); + // Listen for resource lifecycle events to refresh search results. + // Catches every source of modification: the filesystem watcher (user + // edits, external editors) and explicit operations (commands, agents). + _messengerService.Register(this, OnResourceChanged); + _messengerService.Register(this, OnResourceCreated); + _messengerService.Register(this, OnResourceDeleted); + _messengerService.Register(this, OnResourceRenamed); } - private void OnResourceChanged(object recipient, MonitoredResourceChangedMessage message) + private void OnResourceChanged(object recipient, ResourceChangedMessage message) { ScheduleSearch(preserveExpandedState: true, raiseRefreshEvents: true); } - private void OnResourceCreated(object recipient, MonitoredResourceCreatedMessage message) + private void OnResourceCreated(object recipient, ResourceCreatedMessage message) { ScheduleSearch(preserveExpandedState: true, raiseRefreshEvents: true); } - private void OnResourceDeleted(object recipient, MonitoredResourceDeletedMessage message) + private void OnResourceDeleted(object recipient, ResourceDeletedMessage message) { ScheduleSearch(preserveExpandedState: true, raiseRefreshEvents: true); } - private void OnResourceRenamed(object recipient, MonitoredResourceRenamedMessage message) + private void OnResourceRenamed(object recipient, ResourceRenamedMessage message) { ScheduleSearch(preserveExpandedState: true, raiseRefreshEvents: true); } From 0bbbabcb34d3f5355ca877600c8e9cf15297fd77 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 21 May 2026 22:07:01 +0100 Subject: [PATCH 11/48] Propagate inner Result errors via Result.Fail(Result) Add Result.Fail(Result) to create a failure that re-uses an inner Result's error chain, and update callers to use it rather than creating new wrapper failures and calling WithErrors. Many command and filesystem paths (CommandService, ResourceOperationService, ResourceOperations, ResourceFileSystem, SidecarHelper, DocumentStateProvider, PrintPropertyCommand, etc.) now return Result.Fail(inner) to preserve original messages. Also make some FS methods genuinely async and simplify Task.FromResult returns, adjust move tool logic and docs to emit a compact "ok" only for no-side-effect moves and return a structured status payload for other outcomes, and tweak a few variable/notification names in resource commands. --- .../Services/CommandService.cs | 6 ++-- .../Core/Celbridge.Foundation/Core/Result.cs | 12 +++++++ .../Guides/Tools/explorer_move.md | 11 ++++--- .../Tools/Document/DocumentStateProvider.cs | 2 +- .../Tools/Explorer/ExplorerTools.Move.cs | 32 ++++++++++++++----- .../Commands/PrintPropertyCommand.cs | 2 +- .../Commands/CopyResourceCommand.cs | 13 ++++---- .../Commands/DeleteResourceCommand.cs | 24 +++++++------- .../Helpers/SidecarHelper.cs | 3 +- .../Services/ResourceFileSystem.cs | 27 ++++++++-------- .../Services/ResourceOperationService.cs | 30 ++++++----------- .../Services/ResourceOperations.cs | 24 +++++--------- 12 files changed, 98 insertions(+), 88 deletions(-) diff --git a/Source/Core/Celbridge.Commands/Services/CommandService.cs b/Source/Core/Celbridge.Commands/Services/CommandService.cs index 121c15e9a..2110e3711 100644 --- a/Source/Core/Celbridge.Commands/Services/CommandService.cs +++ b/Source/Core/Celbridge.Commands/Services/CommandService.cs @@ -106,8 +106,7 @@ public async Task ExecuteAsync( if (executionResult.IsFailure) { - return Result.Fail($"Command execution failed") - .WithErrors(executionResult); + return Result.Fail(executionResult); } return Result.Ok(); @@ -134,8 +133,7 @@ public async Task> ExecuteAsync( if (result.IsFailure) { - return Result.Fail(result.FirstErrorMessage) - .WithErrors(result); + return Result.Fail(result); } // The command populated ResultValue during its ExecuteAsync(). diff --git a/Source/Core/Celbridge.Foundation/Core/Result.cs b/Source/Core/Celbridge.Foundation/Core/Result.cs index 9f10790be..6bda29f7f 100644 --- a/Source/Core/Celbridge.Foundation/Core/Result.cs +++ b/Source/Core/Celbridge.Foundation/Core/Result.cs @@ -219,6 +219,18 @@ public static FailureResult Fail(string error, [CallerFilePath] string fileName return new FailureResult(error, fileName, lineNumber); } + /// + /// Creates a failure result that propagates the errors of an inner result. + /// Use this to bubble a failure up across differing generic payloads + /// (e.g. Result -> Result) without restating the first error. + /// + public static FailureResult Fail(Result inner) + { + var failure = new FailureResult(string.Empty, string.Empty, 0); + failure.WithErrors(inner); + return failure; + } + /// /// Creates a success result. /// diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md index 892291646..653a63a82 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md @@ -13,13 +13,11 @@ Resolved against the source: ## Returns -For a clean move (no skipped referencers, no resource-level failures), returns `"ok"`. - -When the cascade was incomplete or a resource failed, returns a JSON payload with this shape: +The compact `"ok"` is reserved for the no-side-effect case: the move touched no references, no referencers were skipped, and no resources failed mechanically. Whenever the move actually rewrote references, left a cascade incomplete, or had a per-resource failure, the response is the JSON payload below — so an agent that needs to report what changed gets the rewritten-referencer list without a follow-up grep. ```json { - "status": "ok_with_skipped_referencers" | "partial_failure", + "status": "ok" | "ok_with_skipped_referencers" | "partial_failure", "updatedReferencers": ["project:doc.md", ...], "skippedReferencers": [ { "resource": "project:locked.md", "reason": "ReadOnly", "message": "file is read-only" }, @@ -29,7 +27,10 @@ When the cascade was incomplete or a resource failed, returns a JSON payload wit } ``` -- `status` is `"ok_with_skipped_referencers"` when the move itself completed but the cascade left some references stale; `"partial_failure"` when one or more resources in the batch failed mechanically. +- `status`: + - `"ok"` — every cascade step succeeded; `updatedReferencers` may be non-empty. + - `"ok_with_skipped_referencers"` — the move itself completed but the cascade left some references stale (see `skippedReferencers`). + - `"partial_failure"` — one or more resources in the batch failed mechanically (see `failedResources`). - `updatedReferencers` lists the files whose references were rewritten. - `skippedReferencers` lists the files the cascade couldn't update. `reason` is one of `ReadFailed` / `WriteFailed` / `ReadOnly` / `PermissionDenied`. `ReadOnly` is the DOS read-only attribute (trivially clearable); `PermissionDenied` is an ACL / POSIX denial (needs the right account or admin). The reference is left as-is and will surface via `metadata_check_project` (Phase 5). Re-running the move after the blocker clears (clear the read-only flag, grant write access, close the editor that holds the lock) completes the cascade idempotently. - `failedResources` lists source resources whose bytes operation failed. diff --git a/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs b/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs index 3eefa913e..2bc0f0fd3 100644 --- a/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs +++ b/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs @@ -46,7 +46,7 @@ public async Task> GetStateAsync() var snapshotResult = await _commandService.ExecuteAsync(); if (snapshotResult.IsFailure) { - return Result.Fail().WithErrors(snapshotResult); + return Result.Fail(snapshotResult); } var snapshot = snapshotResult.Value; diff --git a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs index ae34ce94a..b187d7c86 100644 --- a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs +++ b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs @@ -34,21 +34,37 @@ public async partial Task Move(string sourceResource, string des var detail = moveResult.Value; - // For the typical case (clean rename with no skipped referencers and no - // failed resources), return the simple "ok" so the response stays compact. - // Surface a structured JSON payload only when there is actionable - // information for the agent: skipped referencers (the cascade left a - // stale reference because the file was read-only or locked) or failed - // resources (the move itself didn't apply for a resource in the batch). - if (detail.SkippedReferencers.Count == 0 + // The compact "ok" response is reserved for the no-side-effect case: a + // move that touched no references, had no skipped referencers, and no + // failed resources. Whenever the move actually cascaded references or + // produced any structured outcome the agent might want to act on, emit + // the JSON payload — including the list of referencers that were + // rewritten so the agent can report what changed without a follow-up + // grep. + if (detail.UpdatedReferencers.Count == 0 + && detail.SkippedReferencers.Count == 0 && detail.FailedResources.Count == 0) { return ToolResponse.Success("ok"); } + string status; + if (detail.FailedResources.Count > 0) + { + status = "partial_failure"; + } + else if (detail.SkippedReferencers.Count > 0) + { + status = "ok_with_skipped_referencers"; + } + else + { + status = "ok"; + } + var payload = new { - status = detail.FailedResources.Count == 0 ? "ok_with_skipped_referencers" : "partial_failure", + status, updatedReferencers = detail.UpdatedReferencers.Select(r => r.ToString()).ToArray(), skippedReferencers = detail.SkippedReferencers.Select(s => new { diff --git a/Source/Workspace/Celbridge.Entities/Commands/PrintPropertyCommand.cs b/Source/Workspace/Celbridge.Entities/Commands/PrintPropertyCommand.cs index 8ad5d529a..f0ad9bc84 100644 --- a/Source/Workspace/Celbridge.Entities/Commands/PrintPropertyCommand.cs +++ b/Source/Workspace/Celbridge.Entities/Commands/PrintPropertyCommand.cs @@ -27,7 +27,7 @@ public override async Task ExecuteAsync() var getResult = entityService.GetProperty(ComponentKey, PropertyPath); if (getResult.IsFailure) { - return Result.Fail().WithErrors(getResult); + return Result.Fail(getResult); } var valueJSON = getResult.Value; diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 50938c2f9..969198463 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -167,21 +167,22 @@ public override async Task ExecuteAsync() // above keeps the typed ResourceKey list for programmatic callers. var failedDisplayNames = failedResources.Select(r => r.ResourceName).ToList(); var failedList = string.Join(", ", failedDisplayNames); - var operation = TransferMode == DataTransferMode.Copy ? "copy" : "move"; _logger.LogWarning($"CopyResourceCommand completed with failures: {failedList}"); // Notify the UI about the failure var operationType = TransferMode == DataTransferMode.Copy ? ResourceOperationType.Copy : ResourceOperationType.Move; - var message = new ResourceOperationFailedMessage(operationType, failedDisplayNames); - _messengerService.Send(message); + var failedMessage = new ResourceOperationFailedMessage(operationType, failedDisplayNames); + _messengerService.Send(failedMessage); // Propagate every per-resource failure into the bubble-up Result so // the agent sees the FS-layer's specific message (e.g. - // "Destination already exists: ''") via MessageChain rather - // than just the resource name. - var aggregated = Result.Fail($"Failed to {operation}: {failedList}"); + // "Destination already exists: ''") via MessageChain. No outer + // wrapper is added; the inner messages already identify which + // resource(s) failed, and a generic summary string at the top would + // duplicate that detail. + var aggregated = Result.Fail(); foreach (var failedOutcome in failedOutcomes) { aggregated.WithErrors(failedOutcome); diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index 538779692..d1196de26 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -109,25 +109,25 @@ bool IsInsideBatch(ResourceKey candidate) } } - var externalOnly = new List(); - var seen = new HashSet(); + // Emit one entry per specifically-referenced key (the folder key + // itself or any descendant). Agents that act on a recursive folder + // delete need to know which individual descendant file has external + // references — collapsing to a single entry under the folder key + // loses that granularity. foreach (var key in keysToCheck) { + var perKeyReferencers = new List(); foreach (var referencer in metaData.GetReferencers(key)) { - if (IsInsideBatch(referencer)) + if (!IsInsideBatch(referencer)) { - continue; - } - if (seen.Add(referencer)) - { - externalOnly.Add(referencer); + perKeyReferencers.Add(referencer); } } - } - if (externalOnly.Count > 0) - { - externalReferencers[resource] = externalOnly; + if (perKeyReferencers.Count > 0) + { + externalReferencers[key] = perKeyReferencers; + } } } diff --git a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs index 28f3300a7..0f81c8435 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs @@ -80,8 +80,7 @@ public static Result Parse(string text) var parseResult = ParseFrontmatterToml(frontmatterToml); if (parseResult.IsFailure) { - return Result.Fail(parseResult.FirstErrorMessage) - .WithErrors(parseResult); + return Result.Fail(parseResult); } return Result.Ok(new SidecarParseResult(parseResult.Value, body)); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index e1023b5a8..4bbd75e66 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -70,14 +70,16 @@ public async Task> ReadAllTextAsync(ResourceKey resource) path => File.ReadAllTextAsync(path)); } - public Task> OpenReadAsync(ResourceKey resource) + public async Task> OpenReadAsync(ResourceKey resource) { + await Task.CompletedTask; + var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); - return Task.FromResult(failure); + return failure; } var resourcePath = resolveResult.Value; @@ -90,13 +92,13 @@ public Task> OpenReadAsync(ResourceKey resource) FileShare.Read, StreamBufferSize, useAsync: true); - return Task.FromResult(Result.Ok(stream)); + return Result.Ok(stream); } catch (Exception ex) { var failure = Result.Fail($"Failed to open read stream for resource: '{resource}'") .WithException(ex); - return Task.FromResult(failure); + return failure; } } @@ -111,23 +113,23 @@ public Task WriteAllTextAsync(ResourceKey resource, string content) return WriteWithRetryAsync(resource, bytes); } - public Task> OpenWriteAsync(ResourceKey resource) + public async Task> OpenWriteAsync(ResourceKey resource) { + await Task.CompletedTask; + var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); - return Task.FromResult(failure); + return failure; } var resourcePath = resolveResult.Value; var ensureParentResult = EnsureParentFolderExists(resourcePath, resource); if (ensureParentResult.IsFailure) { - var failure = Result.Fail(ensureParentResult.FirstErrorMessage) - .WithErrors(ensureParentResult); - return Task.FromResult(failure); + return Result.Fail(ensureParentResult); } try @@ -143,13 +145,13 @@ public Task> OpenWriteAsync(ResourceKey resource) FileShare.None, StreamBufferSize, useAsync: true); - return Task.FromResult(Result.Ok(stream)); + return Result.Ok(stream); } catch (Exception ex) { var failure = Result.Fail($"Failed to open write stream for resource: '{resource}'") .WithException(ex); - return Task.FromResult(failure); + return failure; } } @@ -206,8 +208,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey var rewriteResult = await RewriteReferencesForMoveAsync(source, destination, sourceIsFolder, updatedReferencers, skippedReferencers); if (rewriteResult.IsFailure) { - return Result.Fail(rewriteResult.FirstErrorMessage) - .WithErrors(rewriteResult); + return Result.Fail(rewriteResult); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 8e946b2cd..5e4ba1a80 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -114,8 +114,7 @@ public async Task> CopyFileAsync(string sourcePath, string de var keyResult = ResolveOperationKeys(sourcePath, destPath); if (keyResult.IsFailure) { - return Result.Fail(keyResult.FirstErrorMessage) - .WithErrors(keyResult); + return Result.Fail(keyResult); } var fileSystem = FileSystem; if (fileSystem is null) @@ -135,8 +134,7 @@ public async Task> CopyFileAsync(string sourcePath, string de if (execResult.IsFailure) { - return Result.Fail(execResult.FirstErrorMessage) - .WithErrors(execResult); + return Result.Fail(execResult); } AddOperation(operation); @@ -149,8 +147,7 @@ private async Task> CopyExternalFileAsync(string sourcePath, var execResult = await operation.ExecuteAsync(); if (execResult.IsFailure) { - return Result.Fail(execResult.FirstErrorMessage) - .WithErrors(execResult); + return Result.Fail(execResult); } AddOperation(operation); return Result.Ok(EmptyCopyResult); @@ -162,8 +159,7 @@ private async Task> CopyExternalFolderAsync(string sourcePath var execResult = await operation.ExecuteAsync(); if (execResult.IsFailure) { - return Result.Fail(execResult.FirstErrorMessage) - .WithErrors(execResult); + return Result.Fail(execResult); } AddOperation(operation); return Result.Ok(EmptyCopyResult); @@ -191,8 +187,7 @@ public async Task> MoveFileAsync(string sourcePath, string de var keyResult = ResolveOperationKeys(sourcePath, destPath); if (keyResult.IsFailure) { - return Result.Fail(keyResult.FirstErrorMessage) - .WithErrors(keyResult); + return Result.Fail(keyResult); } var fileSystem = FileSystem; if (fileSystem is null) @@ -212,8 +207,7 @@ public async Task> MoveFileAsync(string sourcePath, string de if (execResult.IsFailure) { - return Result.Fail(execResult.FirstErrorMessage) - .WithErrors(execResult); + return Result.Fail(execResult); } AddOperation(operation); @@ -310,8 +304,7 @@ public async Task> CopyFolderAsync(string sourcePath, string var keyResult = ResolveOperationKeys(sourcePath, destPath); if (keyResult.IsFailure) { - return Result.Fail(keyResult.FirstErrorMessage) - .WithErrors(keyResult); + return Result.Fail(keyResult); } var fileSystem = FileSystem; if (fileSystem is null) @@ -331,8 +324,7 @@ public async Task> CopyFolderAsync(string sourcePath, string if (execResult.IsFailure) { - return Result.Fail(execResult.FirstErrorMessage) - .WithErrors(execResult); + return Result.Fail(execResult); } AddOperation(operation); @@ -347,8 +339,7 @@ public async Task> MoveFolderAsync(string sourcePath, string var keyResult = ResolveOperationKeys(sourcePath, destPath); if (keyResult.IsFailure) { - return Result.Fail(keyResult.FirstErrorMessage) - .WithErrors(keyResult); + return Result.Fail(keyResult); } var fileSystem = FileSystem; if (fileSystem is null) @@ -368,8 +359,7 @@ public async Task> MoveFolderAsync(string sourcePath, string if (execResult.IsFailure) { - return Result.Fail(execResult.FirstErrorMessage) - .WithErrors(execResult); + return Result.Fail(execResult); } AddOperation(operation); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index b97d3ceb6..f9a1da979 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -99,8 +99,7 @@ public override async Task ExecuteAsync() var copyResult = await _fileSystem.CopyAsync(_sourceKey, _destKey); if (copyResult.IsFailure) { - return Result.Fail(copyResult.FirstErrorMessage) - .WithErrors(copyResult); + return Result.Fail(copyResult); } LastCopyResult = copyResult.Value; @@ -114,8 +113,7 @@ public override async Task UndoAsync() var deleteResult = await _fileSystem.DeleteAsync(_destKey); if (deleteResult.IsFailure) { - return Result.Fail(deleteResult.FirstErrorMessage) - .WithErrors(deleteResult); + return Result.Fail(deleteResult); } return Result.Ok(); @@ -164,8 +162,7 @@ public override async Task ExecuteAsync() var moveResult = await _fileSystem.MoveAsync(_sourceKey, _destKey); if (moveResult.IsFailure) { - return Result.Fail(moveResult.FirstErrorMessage) - .WithErrors(moveResult); + return Result.Fail(moveResult); } LastMoveResult = moveResult.Value; @@ -179,8 +176,7 @@ public override async Task UndoAsync() var moveResult = await _fileSystem.MoveAsync(_destKey, _sourceKey); if (moveResult.IsFailure) { - return Result.Fail(moveResult.FirstErrorMessage) - .WithErrors(moveResult); + return Result.Fail(moveResult); } return Result.Ok(); @@ -386,8 +382,7 @@ public override async Task ExecuteAsync() var copyResult = await _fileSystem.CopyAsync(_sourceKey, _destKey); if (copyResult.IsFailure) { - return Result.Fail(copyResult.FirstErrorMessage) - .WithErrors(copyResult); + return Result.Fail(copyResult); } _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destPath); @@ -403,8 +398,7 @@ public override async Task UndoAsync() var deleteResult = await _fileSystem.DeleteAsync(_destKey); if (deleteResult.IsFailure) { - return Result.Fail(deleteResult.FirstErrorMessage) - .WithErrors(deleteResult); + return Result.Fail(deleteResult); } return Result.Ok(); @@ -452,8 +446,7 @@ public override async Task ExecuteAsync() var moveResult = await _fileSystem.MoveAsync(_sourceKey, _destKey); if (moveResult.IsFailure) { - return Result.Fail(moveResult.FirstErrorMessage) - .WithErrors(moveResult); + return Result.Fail(moveResult); } LastMoveResult = moveResult.Value; @@ -468,8 +461,7 @@ public override async Task UndoAsync() var moveResult = await _fileSystem.MoveAsync(_destKey, _sourceKey); if (moveResult.IsFailure) { - return Result.Fail(moveResult.FirstErrorMessage) - .WithErrors(moveResult); + return Result.Fail(moveResult); } return Result.Ok(); From 83803ae2f30d1d3d9bfc14e7154b7d77b8d9d772 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 21 May 2026 22:18:33 +0100 Subject: [PATCH 12/48] Clarify explorer delete/move referencer behavior Update explorer_delete.md to state that resourceResults contains one entry per input resource (e.g. one entry for a folder delete, not per descendant) and that per-descendant external-reference detail is exposed in referencers. Update explorer_move.md to note that the cascade will rewrite any project-referencing file (including test fixtures, scripts, or docs that quote "project:") and so such files may be updated in-place, which can surprise authors. These clarifications reduce confusion about what is rewritten and where reference details appear. --- Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md | 2 +- Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md index 33d0033b2..6d6f6bb14 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md @@ -49,7 +49,7 @@ The JSON shape is: ``` - `batchOutcome` summarises the whole batch. `DeletedAll` and `DeletedSome` mean execution ran (the policy gate passed); `CancelledByUser` and `BlockedByReferences` mean the gate refused before any filesystem changes. `DeletedSome` also covers the rare edge where every resource in the batch failed mechanically — inspect `resourceResults` for the per-resource detail in any non-`DeletedAll` case. -- `resourceResults` carries one entry per input resource. `outcome` is typed so the agent can branch on the cause without parsing strings: +- `resourceResults` carries one entry per input resource — for a folder delete that means one entry for the folder itself, not one entry per descendant file. The per-descendant breakdown of external references lives in `referencers` (see below). `outcome` is typed so the agent can branch on the cause without parsing strings: - `NotFound` — the resource was already gone on disk. Treat as success — the user's intent is already satisfied. - `Locked` — another process holds the file (open editor, antivirus, indexer). The fix is usually to close the holding process and re-run. - `PermissionDenied` — an ACL / POSIX denial. The DOS read-only attribute is cleared before delete, so this is a genuine permissions problem that needs the right account or admin. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md index 653a63a82..31068b610 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md @@ -41,3 +41,4 @@ The compact `"ok"` is reserved for the no-side-effect case: the move touched no - Renaming a folder that contains open documents updates each open tab's resource path automatically. - Read-only on the source itself is cleared before the move; read-only on a referencer is *not* cleared (the user invoked move on the source, not on incidental referencers). The referencer is reported in `skippedReferencers` with `reason: "ReadOnly"`. - A re-run after fixing a blocker completes the residual rewrites; the FS layer is idempotent under partial completion. +- The cascade does not distinguish a calling script, test prompt, or documentation file from a regular content file. Any file in the project whose body contains a quoted `"project:"` reference — including the file driving the operation — appears in `updatedReferencers` and its bytes are rewritten in place. This is correct per spec but can surprise authors of test fixtures or how-to docs that quote reference paths as examples. From c9d4e9701024e1cecd8f13123274782196773a58 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 22 May 2026 11:38:11 +0100 Subject: [PATCH 13/48] Add metadata sidecar tools and project checks Introduce first-class support for .cel sidecar metadata: add MetaDataTools MCP toolset (metadata_get/list/set/remove, metadata_add_tag/remove_tag, metadata_find, metadata_check_project) with JSON parsing and index wait semantics. Extend file info snapshots and the file_get_info result to expose paired sidecar key and a SidecarStatus. Add IProjectCheckCommand and ProjectCheckReport for project-wide consistency checks, plus a ResourceMetaDataCache service and ProjectConstants for the .celbridge/cache/metadata.json cache file. Add documentation guides for the metadata namespace and each tool, and include unit tests covering metadata cache persistence and project-check behavior; wire up related resource/service code to surface the new functionality. --- .../Projects/ProjectConstants.cs | 13 + .../Resources/IGetFileInfoCommand.cs | 8 +- .../Resources/IProjectCheckCommand.cs | 44 + .../Guides/Namespaces/metadata.md | 47 + .../Guides/Tools/metadata_add_tag.md | 23 + .../Guides/Tools/metadata_check_project.md | 39 + .../Guides/Tools/metadata_find.md | 24 + .../Guides/Tools/metadata_get.md | 18 + .../Guides/Tools/metadata_list.md | 19 + .../Guides/Tools/metadata_remove.md | 21 + .../Guides/Tools/metadata_remove_tag.md | 22 + .../Guides/Tools/metadata_set.md | 25 + .../Tools/File/FileTools.GetInfo.cs | 27 +- .../Tools/MetaData/MetaDataTools.AddTag.cs | 37 + .../MetaData/MetaDataTools.CheckProject.cs | 40 + .../Tools/MetaData/MetaDataTools.Find.cs | 44 + .../Tools/MetaData/MetaDataTools.Get.cs | 42 + .../Tools/MetaData/MetaDataTools.List.cs | 35 + .../Tools/MetaData/MetaDataTools.Remove.cs | 37 + .../Tools/MetaData/MetaDataTools.RemoveTag.cs | 37 + .../Tools/MetaData/MetaDataTools.Set.cs | 46 + .../Tools/MetaData/MetaDataTools.cs | 88 ++ .../Resources/MetaDataCheckProjectTests.cs | 204 +++ .../ResourceMetaDataPersistenceTests.cs | 270 ++++ .../Tests/Resources/ResourceMetaDataTests.cs | 247 +++- .../Python/celbridge-0.1.0-py3-none-any.whl | Bin 39851 -> 43053 bytes .../celbridge/integration_tests/conftest.py | 5 + .../integration_tests/test_explorer.py | 41 + .../integration_tests/test_metadata.py | 228 +++ .../Commands/GetFileInfoCommand.cs | 26 +- .../Commands/ProjectCheckCommand.cs | 78 + .../ServiceConfiguration.cs | 1 + .../Services/ResourceMetaData.cs | 1317 ++++++++++++++++- .../Services/ResourceMetaDataCache.cs | 126 ++ .../Services/WorkspaceLoader.cs | 47 + 35 files changed, 3286 insertions(+), 40 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs create mode 100644 Source/Core/Celbridge.Tools/Guides/Namespaces/metadata.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_add_tag.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_check_project.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_find.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_get.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_list.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove_tag.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_set.md create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.AddTag.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.CheckProject.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Find.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Get.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.List.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Remove.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.RemoveTag.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Set.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.cs create mode 100644 Source/Tests/Resources/MetaDataCheckProjectTests.cs create mode 100644 Source/Tests/Resources/ResourceMetaDataPersistenceTests.cs create mode 100644 Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_metadata.py create mode 100644 Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs diff --git a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs index 196ed6224..681cb1d16 100644 --- a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs +++ b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs @@ -72,4 +72,17 @@ public static class ProjectConstants /// workspace load to clear orphans left by previous crashes. /// public const string CelbridgeStagingFsFolder = "staging-fs"; + + /// + /// Sub-folder of .celbridge/ that holds host-private caches. Files in this + /// folder are managed directly by their owning services (e.g. the metadata + /// cache) rather than through IResourceFileSystem. + /// + public const string CelbridgeCacheFolder = "cache"; + + /// + /// Filename of the resource-metadata cache inside CelbridgeCacheFolder. + /// JSON document; mtime + size validated per-entry on load. + /// + public const string MetaDataCacheFileName = "metadata.json"; } diff --git a/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs index 0aec01d24..a1ff6ca27 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs @@ -5,7 +5,9 @@ namespace Celbridge.Resources; /// /// Snapshot of metadata for a single file or folder resource, produced by IGetFileInfoCommand. /// Exists is false when the resource cannot be resolved. IsFile distinguishes file from folder -/// when Exists is true. IsText and LineCount are only populated for text files. +/// when Exists is true. IsText and LineCount are only populated for text files. Sidecar* fields +/// describe the paired .cel sidecar when one is registered on the parent file; they remain null +/// for files without a sidecar and for folders. /// public record class FileInfoSnapshot( bool Exists, @@ -14,7 +16,9 @@ public record class FileInfoSnapshot( DateTime ModifiedUtc, string Extension, bool IsText, - int? LineCount); + int? LineCount, + string? SidecarKey, + SidecarStatus? SidecarStatus); /// /// Read-only query that captures metadata for a single file or folder resource in a snapshot. diff --git a/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs new file mode 100644 index 000000000..e61a89648 --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs @@ -0,0 +1,44 @@ +using Celbridge.Commands; + +namespace Celbridge.Resources; + +/// +/// A single project: reference that does not resolve to an existing resource. +/// Source is the file that contains the reference literal; MissingTarget is the +/// resource key the literal points to. +/// +public record BrokenReference(ResourceKey Source, ResourceKey MissingTarget); + +/// +/// A .cel file that the registry tracks as not paired with a parent file. The +/// user / agent resolves orphans by deleting them, renaming the parent to claim +/// the sidecar, or creating a new file at the parent path. +/// +public record OrphanSidecar(ResourceKey Sidecar); + +/// +/// A .cel file whose frontmatter does not parse cleanly. Covers merge-conflict +/// markers, malformed TOML, missing fences, and any other parse failure — the +/// host does not differentiate between these post-Phase-1 of the redesign. +/// Files ending in .cel.cel are also classified Broken via this category. +/// +public record BrokenSidecar(ResourceKey Sidecar); + +/// +/// Structured project health report produced by IProjectCheckCommand. Empty +/// lists mean the corresponding invariant holds. The command does not repair +/// any of the surfaced issues; it is a pure read. +/// +public record ProjectCheckReport( + IReadOnlyList BrokenReferences, + IReadOnlyList OrphanSidecars, + IReadOnlyList BrokenSidecars); + +/// +/// Read-only check that surfaces dangling project: references and any sidecar +/// in an attention state (orphan, broken). Invoked at workspace load and +/// exposed as the metadata_check_project MCP tool. +/// +public interface IProjectCheckCommand : IExecutableCommand +{ +} diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/metadata.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/metadata.md new file mode 100644 index 000000000..818e2da39 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/metadata.md @@ -0,0 +1,47 @@ +# metadata + +The `metadata` namespace reads and writes per-resource information stored in `.cel` sidecar files. A sidecar lives alongside its parent file (`foo.png.cel` next to `foo.png`) and carries TOML frontmatter plus an optional body. The host indexes the frontmatter so resources can be looked up by field value or by tag, and the indexes hydrate from a persisted cache on workspace load. + +## Must-knows + +- **Sidecars are addressed by their parent resource.** `metadata_get docs/notes.md priority` consults the sidecar at `docs/notes.md.cel`. The sidecar's own resource key (`docs/notes.md.cel`) is only used by direct file-tool reads, not by the metadata API. +- **Sidecars are created on first write.** `metadata_set` and `metadata_add_tag` create the sidecar when missing. `metadata_remove` and `metadata_remove_tag` never create files and never delete sidecars (empty sidecars are kept). +- **Values are JSON-encoded.** `metadata_set` and `metadata_find` accept the value as a JSON string so types pass through cleanly: `"high"`, `42`, `true`, `["a", "b"]`. Nested objects are stored in the file but not queryable. +- **Tags are a standardised list-of-string field.** Prefer `metadata_add_tag` / `metadata_remove_tag` over `metadata_set "tags" ...` so concurrent edits don't clobber each other's append. + +## Tools + +**Per-resource read.** + +- `metadata_get` — read a single field value from a resource's sidecar. +- `metadata_list` — return the full frontmatter as a JSON object (empty object when the resource has no sidecar). + +**Per-resource write.** + +- `metadata_set` — write a single field, creating the sidecar if missing. +- `metadata_remove` — remove a single field; no-op when absent. + +**Tag affordances.** + +- `metadata_add_tag` — append a tag, creating the sidecar if missing. +- `metadata_remove_tag` — remove a tag; no-op when absent. + +**Project-wide search.** + +- `metadata_find` — find every resource whose frontmatter matches a field/value pair. +- `metadata_check_project` — report broken project: references, orphan sidecars, and any sidecar that fails to parse cleanly. + +## When to use which surface + +- "What metadata does this resource carry?" → `metadata_list`. +- "What does this specific field hold?" → `metadata_get`. +- "What resources are tagged X?" → `metadata_find "tags" "X"`. +- "What resources have `priority = high`?" → `metadata_find "priority" "high"`. +- "Add a tag to this resource so a future agent can find it" → `metadata_add_tag`. +- "Is the project in a consistent state?" → `metadata_check_project`. + +## Notes + +- Reads await the metadata index's first rebuild; the call blocks briefly during workspace startup until the index is ready. +- Writes are followed by a synchronous drain of the watcher / rescan queue, so the next `metadata_get` / `metadata_find` always sees the new state. +- Sidecars can also be read and written directly through the `file` namespace (`file_read docs/notes.md.cel`). Use the file tools when you need to inspect or repair sidecar contents by hand; use the metadata tools for normal indexed field access. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_add_tag.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_add_tag.md new file mode 100644 index 000000000..706cc1898 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_add_tag.md @@ -0,0 +1,23 @@ +# metadata_add_tag + +Appends a tag to a resource's standardised `tags` list inside its `.cel` sidecar frontmatter. Creates the sidecar if missing. Idempotent — adding a tag that is already present is a no-op success. + +## Arguments + +- `resource` — the resource key of the parent file (e.g. `"docs/notes.md"`). +- `tag` — the tag string. Case-sensitive. Non-empty. + +## Returns + +`"ok"` on success. + +## Side effects + +- Creates the sidecar at `.cel` if no sidecar previously existed, with a single `tags` field containing the new tag. +- Existing tags in the list are preserved; the new tag is appended only when not already present. +- Other frontmatter fields are preserved unchanged. + +## Notes + +- Use this rather than `metadata_set` with `field="tags"` and `value_json="[\"new\"]"` so concurrent agents adding different tags don't clobber each other's append. +- Use `metadata_find "tags" ""` to look up resources by tag. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_check_project.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_check_project.md new file mode 100644 index 000000000..caebb1104 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_check_project.md @@ -0,0 +1,39 @@ +# metadata_check_project + +Reports project-wide consistency findings: dangling `project:` references and any sidecar in an attention state. Pure read — surfaces issues, does not repair them. + +## Arguments + +None. + +## Returns + +A JSON object with three lists. Empty lists mean the corresponding invariant holds. + +```json +{ + "brokenReferences": [ + { "source": "docs/index.md", "missingTarget": "drafts/gone.md" } + ], + "orphanSidecars": [ + "archive/old.png.cel" + ], + "brokenSidecars": [ + "weird.cel.cel", + "docs/notes.md.cel" + ] +} +``` + +## Categories + +- **`brokenReferences`** — every quoted `"project:"` literal that does not resolve to an existing resource. `source` is the file containing the literal; `missingTarget` is the resource key the literal points to. A single source file can contribute multiple entries (one per missing target). A single missing target can be referenced by multiple sources (one entry per (source, target) pair). +- **`orphanSidecars`** — every `.cel` file the registry tracked but for which no parent file exists. Typical fixes: delete the orphan, rename a parent to claim it, or create the missing parent file. Some orphans are legitimate (a content type that uses standalone `.cel` files); the agent should not auto-delete. +- **`brokenSidecars`** — every `.cel` file whose frontmatter does not parse. Covers merge-conflict markers, malformed TOML, missing fences, and the `.cel.cel` invalid-suffix case. The bytes are left on disk for the user to inspect and repair — typically via `file_read` + `file_write` of the affected sidecar, or by opening the file in another editor. + +## Notes + +- The check runs in memory after the metadata service's initial rebuild completes. Latency is sub-second on a typical project. +- The MCP tool returns the same data the workspace surfaces at load time. Re-running after a fix is the right way to confirm the fix landed. +- Case sensitivity: a literal `"project:Foo"` does not match a resource whose canonical key is `"project:foo"` — case-mismatched references appear as `brokenReferences`. This is intentional and matches the rest of the resource system. +- Cross-root references (`temp:`, `logs:`) are not tracked; only `project:` references carry referential integrity. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_find.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_find.md new file mode 100644 index 000000000..2f18c511d --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_find.md @@ -0,0 +1,24 @@ +# metadata_find + +Finds every resource whose `.cel` sidecar frontmatter contains the given field with the given value. + +## Arguments + +- `field` — the top-level frontmatter field name. Case-sensitive. +- `value_json` — the query value as a JSON-encoded string. Must be a scalar (`"string"`, `42`, `true`). Lists are not accepted — list-of-scalar fields match by element, so pass the scalar you want to match against. + +## Returns + +A JSON array of resource keys (`["docs/notes.md", "drafts/idea.md"]`). Empty array when no resource matches. + +## Match semantics + +- Scalar fields match by equality. `metadata_find "priority" "high"` returns every resource with `priority = "high"` in its frontmatter. +- List-of-scalar fields match by contains. `metadata_find "tags" "flagged"` returns every resource whose `tags` list contains `"flagged"` (alongside any other tags it may carry). +- Object fields and lists of non-scalars are not indexed. To filter on those, read each candidate via `metadata_list` and filter locally. +- Strings match case-sensitively. Numeric queries are normalised so an `int` query matches a `long` cached value. + +## Notes + +- Tag-specific affordances `metadata_add_tag` / `metadata_remove_tag` exist alongside this generic tool. For "find resources tagged X" specifically, `metadata_find "tags" "X"` and `metadata_find` with `field="tags"` are the same call. +- Results are unordered. Callers needing a stable sort should sort the response array client-side. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_get.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_get.md new file mode 100644 index 000000000..5df449a31 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_get.md @@ -0,0 +1,18 @@ +# metadata_get + +Reads a single field from a resource's `.cel` sidecar frontmatter. Returns the value as a JSON-encoded string — strings come back as `"value"`, numbers as `42`, booleans as `true`, lists as `["a", "b"]`. + +## Arguments + +- `resource` — the resource key of the parent file (e.g. `"docs/notes.md"`, not the sidecar key). +- `field` — the top-level frontmatter field name. Case-sensitive. + +## Returns + +The JSON-encoded value on success. If the resource has no sidecar (or its sidecar is broken), an error explains that no frontmatter is indexed. If the sidecar exists but the field is absent, the response is an error naming the field — there is no "not found" sentinel value. + +## Notes + +- Frontmatter is keyed by the parent resource, not by the sidecar key. Reading `"docs/notes.md"` consults the sidecar at `"docs/notes.md.cel"`. +- Nested object values (`[section]` tables in TOML) can be returned but are not queryable via `metadata_find`. Use `metadata_list` to inspect the full structure. +- The metadata index reflects the on-disk state at the last completed scan; recent writes through `metadata_set` are visible synchronously because the tool awaits the post-write rescan. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_list.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_list.md new file mode 100644 index 000000000..ce37ba10d --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_list.md @@ -0,0 +1,19 @@ +# metadata_list + +Returns the full frontmatter for a resource's `.cel` sidecar as a JSON object. + +## Arguments + +- `resource` — the resource key of the parent file (e.g. `"docs/notes.md"`). + +## Returns + +A JSON object containing every top-level frontmatter field. Scalar fields appear as JSON primitives; lists appear as JSON arrays; tables appear as nested objects. + +If the resource has no sidecar (or its sidecar is broken), returns `{}` — the empty object — so callers can iterate uniformly without branching on absence. + +## Notes + +- Resources without sidecars are not an error: a clean object is returned. +- Object-valued fields (`[section]` tables in TOML) are included but are not queryable via `metadata_find`. Callers needing to filter on nested values should read this response and filter locally. +- The response reflects the in-memory index, which is updated synchronously after `metadata_set` / `metadata_add_tag` / `metadata_remove` / `metadata_remove_tag` calls. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove.md new file mode 100644 index 000000000..6914f0d73 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove.md @@ -0,0 +1,21 @@ +# metadata_remove + +Removes a single field from a resource's `.cel` sidecar frontmatter. Idempotent — removing a field that is not present returns success without modifying the file. + +## Arguments + +- `resource` — the resource key of the parent file (e.g. `"docs/notes.md"`). +- `field` — the top-level frontmatter field name. Case-sensitive. + +## Returns + +`"ok"` on success (including the no-op case). + +## Side effects + +- The sidecar file remains on disk even when removing the last field. Empty sidecars are valid; the editor or user that created the file owns its lifetime. +- If no sidecar exists for the resource, the call is a no-op success — there is nothing to remove. + +## Notes + +- Use `metadata_remove_tag` to take a single value out of the standardised `tags` list. `metadata_remove` deletes the entire field. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove_tag.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove_tag.md new file mode 100644 index 000000000..c71b7f578 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove_tag.md @@ -0,0 +1,22 @@ +# metadata_remove_tag + +Removes a tag from a resource's standardised `tags` list inside its `.cel` sidecar frontmatter. Idempotent — removing a tag that is not present (or removing from a resource that has no sidecar) is a no-op success. + +## Arguments + +- `resource` — the resource key of the parent file (e.g. `"docs/notes.md"`). +- `tag` — the tag string to remove. Case-sensitive. + +## Returns + +`"ok"` on success (including the no-op case). + +## Side effects + +- When the removed tag was the last entry in `tags`, the entire `tags` field is dropped rather than leaving an empty array. +- The sidecar file itself is never deleted, even when removing the last tag leaves an empty frontmatter — empty sidecars are valid. +- Other frontmatter fields are preserved unchanged. + +## Notes + +- Use this rather than `metadata_set "tags" "[]"` so concurrent agents removing different tags don't clobber each other's edit. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/metadata_set.md b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_set.md new file mode 100644 index 000000000..8022f35ea --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/metadata_set.md @@ -0,0 +1,25 @@ +# metadata_set + +Writes a single field to a resource's `.cel` sidecar frontmatter. Creates the sidecar if it does not already exist. + +## Arguments + +- `resource` — the resource key of the parent file (e.g. `"docs/notes.md"`). +- `field` — the top-level frontmatter field name. Case-sensitive. +- `value_json` — the value as a JSON-encoded string. Supported shapes: scalars (`"string"`, `42`, `3.14`, `true`) and lists of scalars (`["a", "b"]`). Nested objects are rejected with a clear error. + +## Returns + +`"ok"` on success. + +## Side effects + +- The sidecar is created at `.cel` if missing. No envelope (`[celbridge]` table) is written unless the caller has previously set `celbridge.editor_id`. +- An existing sidecar's other fields and body are preserved; only the target field is replaced. +- The mutation is followed by a synchronous drain of the metadata-index update queue, so subsequent `metadata_get` / `metadata_find` calls see the new state without an extra wait. + +## Notes + +- For the standardised `tags` field, prefer `metadata_add_tag` / `metadata_remove_tag` so concurrent mutations don't clobber each other's append. +- Numbers parse as 64-bit integers when possible, otherwise as `double`. Cached values reload with the same canonicalisation, so a `42` written today still matches a `42` query after a project reload. +- Writing a value of an unsupported shape (nested object, mixed-type array) fails before any sidecar write occurs — the file on disk is never partially-mutated. diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs index 655e13bb7..d73789145 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs @@ -4,9 +4,21 @@ namespace Celbridge.Tools; /// -/// Result returned by file_get_info for file resources. +/// Result returned by file_get_info for file resources. Sidecar fields are +/// populated when the file has a paired .cel sidecar; SidecarStatus is +/// "healthy" when the sidecar's frontmatter parses cleanly, "broken" +/// otherwise. Absence is signalled by sidecar_status = "none" with sidecar +/// = null. /// -public record class FileInfoResult(string Type, long Size, string Modified, string Extension, bool IsText, int? LineCount); +public record class FileInfoResult( + string Type, + long Size, + string Modified, + string Extension, + bool IsText, + int? LineCount, + string? Sidecar, + string SidecarStatus); /// /// Result returned by file_get_info for folder resources. @@ -44,13 +56,22 @@ public async partial Task GetInfo(string resource) if (snapshot.IsFile) { + var sidecarStatusText = snapshot.SidecarStatus switch + { + Celbridge.Resources.SidecarStatus.Healthy => "healthy", + Celbridge.Resources.SidecarStatus.Broken => "broken", + _ => "none", + }; + var fileResult = new FileInfoResult( "file", snapshot.Size, snapshot.ModifiedUtc.ToString("o"), snapshot.Extension, snapshot.IsText, - snapshot.LineCount); + snapshot.LineCount, + snapshot.SidecarKey, + sidecarStatusText); return ToolResponse.Success(SerializeJson(fileResult)); } diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.AddTag.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.AddTag.cs new file mode 100644 index 000000000..8e3776772 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.AddTag.cs @@ -0,0 +1,37 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Append a tag to a resource's tags list (creates the sidecar if missing; idempotent). + [McpServerTool(Name = "metadata_add_tag")] + [ToolAlias("metadata.add_tag")] + [RelatedGuides("resource_keys")] + public async partial Task AddTag(string resource, string tag) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + if (string.IsNullOrEmpty(tag)) + { + return ToolResponse.Error("tag must be a non-empty string."); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var addResult = await metaData.AddTagAsync(resourceKey, tag); + if (addResult.IsFailure) + { + return ToolResponse.Error(addResult); + } + + await metaData.WaitForPendingUpdatesAsync(); + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.CheckProject.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.CheckProject.cs new file mode 100644 index 000000000..2808a5cc1 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.CheckProject.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Report broken project: references, orphan sidecars, and any sidecar that fails to parse cleanly. + [McpServerTool(Name = "metadata_check_project", ReadOnly = true)] + [ToolAlias("metadata.check_project")] + [RelatedGuides("resource_keys")] + public async partial Task CheckProject() + { + var checkResult = await ExecuteCommandAsync(); + if (checkResult.IsFailure) + { + return ToolResponse.Error(checkResult); + } + var report = checkResult.Value; + + var payload = new + { + brokenReferences = report.BrokenReferences + .Select(b => new + { + source = b.Source.ToString(), + missingTarget = b.MissingTarget.ToString(), + }) + .ToArray(), + orphanSidecars = report.OrphanSidecars + .Select(o => o.Sidecar.ToString()) + .ToArray(), + brokenSidecars = report.BrokenSidecars + .Select(b => b.Sidecar.ToString()) + .ToArray(), + }; + + return ToolResponse.Success(SerializeJson(payload)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Find.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Find.cs new file mode 100644 index 000000000..aee312e6f --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Find.cs @@ -0,0 +1,44 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Find every resource whose frontmatter has the given field matching the given value (scalar equality or list-of-scalar contains). + [McpServerTool(Name = "metadata_find", ReadOnly = true)] + [ToolAlias("metadata.find")] + [RelatedGuides("resource_keys")] + public async partial Task Find(string field, string valueJson) + { + await Task.CompletedTask; + + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var parsed = TryParseJsonValue(valueJson); + if (!parsed.Success) + { + return ToolResponse.Error(parsed.Error!); + } + + // A list-of-scalar value isn't meaningful as a query argument — the + // index matches values element-wise. Callers pass a single scalar even + // for list-of-scalar fields. + if (parsed.Value is System.Collections.IEnumerable + && parsed.Value is not string) + { + return ToolResponse.Error("value_json must be a scalar (string, number, boolean) for find queries; list-of-scalar fields are matched by element."); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var matches = metaData.FindByMetaData(field, parsed.Value!); + var keys = matches.Select(m => m.ToString()).ToArray(); + return ToolResponse.Success(SerializeJson(keys)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Get.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Get.cs new file mode 100644 index 000000000..da155976c --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Get.cs @@ -0,0 +1,42 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Read a single frontmatter field from a resource's .cel sidecar. + [McpServerTool(Name = "metadata_get", ReadOnly = true)] + [ToolAlias("metadata.get")] + [RelatedGuides("resource_keys")] + public async partial Task Get(string resource, string field) + { + await Task.CompletedTask; + + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var frontmatterResult = metaData.GetFrontmatter(resourceKey); + if (frontmatterResult.IsFailure) + { + return ToolResponse.Error(frontmatterResult); + } + + if (!frontmatterResult.Value.TryGetValue(field, out var value)) + { + return ToolResponse.Error($"Field '{field}' is not set on resource '{resource}'."); + } + + return ToolResponse.Success(SerializeJson(value)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.List.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.List.cs new file mode 100644 index 000000000..1e2c53deb --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.List.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// List the full frontmatter for a resource's .cel sidecar as a JSON object. + [McpServerTool(Name = "metadata_list", ReadOnly = true)] + [ToolAlias("metadata.list")] + [RelatedGuides("resource_keys")] + public async partial Task List(string resource) + { + await Task.CompletedTask; + + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var frontmatterResult = metaData.GetFrontmatter(resourceKey); + if (frontmatterResult.IsFailure) + { + // No frontmatter is documented as an empty object, not an error, + // so callers can iterate uniformly. + return ToolResponse.Success("{}"); + } + + return ToolResponse.Success(SerializeJson(frontmatterResult.Value)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Remove.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Remove.cs new file mode 100644 index 000000000..1a5bc2c8b --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Remove.cs @@ -0,0 +1,37 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Remove a single frontmatter field from a resource's .cel sidecar (no-op if absent). + [McpServerTool(Name = "metadata_remove", Destructive = true)] + [ToolAlias("metadata.remove")] + [RelatedGuides("resource_keys")] + public async partial Task Remove(string resource, string field) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var removeResult = await metaData.RemoveFrontmatterFieldAsync(resourceKey, field); + if (removeResult.IsFailure) + { + return ToolResponse.Error(removeResult); + } + + await metaData.WaitForPendingUpdatesAsync(); + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.RemoveTag.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.RemoveTag.cs new file mode 100644 index 000000000..20fc908b1 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.RemoveTag.cs @@ -0,0 +1,37 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Remove a tag from a resource's tags list (no-op if absent; idempotent). + [McpServerTool(Name = "metadata_remove_tag")] + [ToolAlias("metadata.remove_tag")] + [RelatedGuides("resource_keys")] + public async partial Task RemoveTag(string resource, string tag) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + if (string.IsNullOrEmpty(tag)) + { + return ToolResponse.Error("tag must be a non-empty string."); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var removeResult = await metaData.RemoveTagAsync(resourceKey, tag); + if (removeResult.IsFailure) + { + return ToolResponse.Error(removeResult); + } + + await metaData.WaitForPendingUpdatesAsync(); + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Set.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Set.cs new file mode 100644 index 000000000..2d5bd893c --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Set.cs @@ -0,0 +1,46 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class MetaDataTools +{ + /// Write a single frontmatter field on a resource's .cel sidecar (creates the sidecar if missing). + [McpServerTool(Name = "metadata_set")] + [ToolAlias("metadata.set")] + [RelatedGuides("resource_keys")] + public async partial Task Set(string resource, string field, string valueJson) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var parsed = TryParseJsonValue(valueJson); + if (!parsed.Success) + { + return ToolResponse.Error(parsed.Error!); + } + + var workspaceWrapper = GetRequiredService(); + var metaData = workspaceWrapper.WorkspaceService.ResourceMetaData; + await metaData.WaitUntilReadyAsync(); + + var setResult = await metaData.SetFrontmatterFieldAsync(resourceKey, field, parsed.Value!); + if (setResult.IsFailure) + { + return ToolResponse.Error(setResult); + } + + // After the sidecar write the registry / metadata indexes catch up via + // the watcher event. Wait for that drain so the agent's next call sees + // the new state. + await metaData.WaitForPendingUpdatesAsync(); + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.cs b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.cs new file mode 100644 index 000000000..07affc2d0 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +/// +/// MCP tools for resource sidecar metadata: per-resource frontmatter read / +/// write, tag affordances, and project-wide search by indexed field. +/// +[McpServerToolType] +public partial class MetaDataTools : AgentToolBase +{ + public MetaDataTools(IApplicationServiceProvider services) : base(services) { } + + private static string SerializeJson(object? value) + { + return JsonSerializer.Serialize(value, JsonOptions); + } + + /// + /// Parses a JSON value string into a CLR object the metadata service can + /// accept (scalar or list-of-scalar). Returns null for unsupported shapes + /// (nested objects, mixed-type arrays). Used by the Set and Find tools so + /// callers can pass typed values through a single string parameter. + /// + private static (bool Success, object? Value, string? Error) TryParseJsonValue(string valueJson) + { + if (string.IsNullOrWhiteSpace(valueJson)) + { + return (false, null, "value_json must be a non-empty JSON-encoded value."); + } + + JsonElement element; + try + { + element = JsonSerializer.Deserialize(valueJson); + } + catch (JsonException ex) + { + return (false, null, $"value_json is not valid JSON: {ex.Message}"); + } + + var converted = ConvertJsonElement(element); + if (converted is null) + { + return (false, null, "value_json must be a scalar (string, number, boolean) or list of scalars; nested objects are not supported."); + } + + return (true, converted, null); + } + + private static object? ConvertJsonElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + if (element.TryGetInt64(out var l)) + { + return l; + } + if (element.TryGetDouble(out var d)) + { + return d; + } + return null; + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Array: + var list = new List(); + foreach (var item in element.EnumerateArray()) + { + var converted = ConvertJsonElement(item); + if (converted is null) + { + return null; + } + list.Add(converted); + } + return list; + default: + return null; + } + } +} diff --git a/Source/Tests/Resources/MetaDataCheckProjectTests.cs b/Source/Tests/Resources/MetaDataCheckProjectTests.cs new file mode 100644 index 000000000..2ba157f13 --- /dev/null +++ b/Source/Tests/Resources/MetaDataCheckProjectTests.cs @@ -0,0 +1,204 @@ +using Celbridge.Explorer.Services; +using Celbridge.Messaging; +using Celbridge.Messaging.Services; +using Celbridge.Resources; +using Celbridge.Resources.Commands; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; +using Celbridge.Utilities; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for ProjectCheckCommand — the engine behind the +/// metadata_check_project MCP tool. The command is a pure read over the +/// metadata service's reference graph and the registry's sidecar report; the +/// tests configure each subsystem and assert the resulting report shape. +/// +[TestFixture] +public class MetaDataCheckProjectTests +{ + private string _projectFolderPath = null!; + private ResourceRegistry _resourceRegistry = null!; + private ResourceMetaData _metaData = null!; + private IMessengerService _messengerService = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + private ProjectCheckCommand _command = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(MetaDataCheckProjectTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + _resourceRegistry = new ResourceRegistry( + Substitute.For>(), + _messengerService, + fileIconService); + _resourceRegistry.ProjectFolderPath = _projectFolderPath; + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + _metaData = new ResourceMetaData( + Substitute.For>(), + _messengerService, + Substitute.For(), + new TextBinarySniffer()); + + workspaceService.ResourceMetaData.Returns(_metaData); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.IsWorkspacePageLoaded.Returns(true); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + // Re-create the metadata service with the wrapper that returns it, + // because the rebuild path resolves the registry through the wrapper. + _metaData.Dispose(); + _metaData = new ResourceMetaData( + Substitute.For>(), + _messengerService, + _workspaceWrapper, + new TextBinarySniffer()); + workspaceService.ResourceMetaData.Returns(_metaData); + + _command = new ProjectCheckCommand(_workspaceWrapper); + } + + [TearDown] + public void TearDown() + { + _metaData.Dispose(); + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public async Task CleanProject_AllReportListsAreEmpty() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "a.md"), "Body A."); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.md"), + "Refers to \"project:a.md\"."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenReferences.Should().BeEmpty(); + _command.ResultValue.OrphanSidecars.Should().BeEmpty(); + _command.ResultValue.BrokenSidecars.Should().BeEmpty(); + } + + [Test] + public async Task BrokenReference_IsReportedWithSourceAndTarget() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "Refers to \"project:missing.md\" which is not present."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenReferences.Should().HaveCount(1); + var entry = _command.ResultValue.BrokenReferences[0]; + entry.Source.Should().Be(new ResourceKey("source.md")); + entry.MissingTarget.Should().Be(new ResourceKey("missing.md")); + } + + [Test] + public async Task OrphanSidecar_AppearsInReport() + { + // foo.png is the would-be parent; only the sidecar exists. + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "+++\ntags = [\"orphaned\"]\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.OrphanSidecars + .Should().Contain(o => o.Sidecar == new ResourceKey("foo.png.cel")); + } + + [Test] + public async Task BrokenSidecar_AppearsInReport() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md"), "Body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md.cel"), + "+++\nthis is not valid toml ###\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenSidecars + .Should().Contain(b => b.Sidecar == new ResourceKey("doc.md.cel")); + } + + [Test] + public async Task InvalidSidecarSuffix_AppearsInBrokenList() + { + // .cel.cel files are classified Broken per the as-built sidecar API. + File.WriteAllText(Path.Combine(_projectFolderPath, "weird.cel.cel"), + "+++\ntags = [\"x\"]\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenSidecars + .Should().Contain(b => b.Sidecar == new ResourceKey("weird.cel.cel")); + } + + [Test] + public async Task MultipleBrokenReferences_OrderedDeterministically() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "a.md"), + "Refers \"project:zzz.md\" and \"project:aaa.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.md"), + "Also refers \"project:zzz.md\"."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + // Three entries: aaa.md from a.md; zzz.md from a.md and b.md. + // The ordering is by missingTarget then by source. + _command.ResultValue.BrokenReferences.Should().HaveCount(3); + + var keys = _command.ResultValue.BrokenReferences + .Select(r => (r.MissingTarget.ToString(), r.Source.ToString())) + .ToList(); + + keys[0].Item1.Should().Be("aaa.md"); + keys[1].Item1.Should().Be("zzz.md"); + keys[2].Item1.Should().Be("zzz.md"); + keys[1].Item2.Should().Be("a.md"); + keys[2].Item2.Should().Be("b.md"); + } +} diff --git a/Source/Tests/Resources/ResourceMetaDataPersistenceTests.cs b/Source/Tests/Resources/ResourceMetaDataPersistenceTests.cs new file mode 100644 index 000000000..9a8a25e6d --- /dev/null +++ b/Source/Tests/Resources/ResourceMetaDataPersistenceTests.cs @@ -0,0 +1,270 @@ +using Celbridge.Explorer.Services; +using Celbridge.Messaging; +using Celbridge.Messaging.Services; +using Celbridge.Projects; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; +using Celbridge.Utilities; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for the ResourceMetaData cache file at .celbridge/cache/metadata.json. +/// Each test sets up a temporary project folder, runs a metadata instance through +/// some operations, disposes it (which flushes the cache), and then constructs a +/// second instance against the same folder to verify the persisted state hydrates +/// correctly. +/// +[TestFixture] +public class ResourceMetaDataPersistenceTests +{ + private string _projectFolderPath = null!; + private IMessengerService _messengerService = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(ResourceMetaDataPersistenceTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _messengerService = new MessengerService(); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public async Task SecondLoad_HydratesFromCache_WhenFilesUnchanged() + { + // First load: populate the index and dispose to flush the cache. + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "References \"project:target.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target body."); + + await RunMetaDataAsync(metaData => + { + var sourceKey = new ResourceKey("source.md"); + var targetKey = new ResourceKey("target.md"); + metaData.GetReferencers(targetKey).Should().Contain(sourceKey); + return Task.CompletedTask; + }); + + // The cache file should have been created. + var cachePath = Path.Combine( + _projectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeCacheFolder, + ProjectConstants.MetaDataCacheFileName); + File.Exists(cachePath).Should().BeTrue("the cache file should be persisted on dispose"); + + // Second load: the rebuild should pick up the cache. Since the files + // are unchanged on disk, hydration validates mtime + size and skips + // the scan; the resulting index entries match the first load. + await RunMetaDataAsync(metaData => + { + var sourceKey = new ResourceKey("source.md"); + var targetKey = new ResourceKey("target.md"); + metaData.GetReferencers(targetKey).Should().Contain(sourceKey); + metaData.GetReferences(sourceKey).Should().Contain(targetKey); + return Task.CompletedTask; + }); + } + + [Test] + public async Task SecondLoad_RescansFile_WhenContentChanged() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "References \"project:target.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target body."); + + await RunMetaDataAsync(metaData => + { + metaData.GetReferencers(new ResourceKey("target.md")) + .Should().Contain(new ResourceKey("source.md")); + return Task.CompletedTask; + }); + + // Mutate source.md to point at a different target. The mtime + size + // both change, so the cache hydration must reject the cached entry and + // fall through to a fresh scan. + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "Refers to \"project:other.md\" now."); + // Make the mtime move forward so the comparison rejects the entry on + // systems where the write is fast enough to keep the same tick. + File.SetLastWriteTimeUtc( + Path.Combine(_projectFolderPath, "source.md"), + DateTime.UtcNow.AddSeconds(1)); + + await RunMetaDataAsync(metaData => + { + metaData.GetReferencers(new ResourceKey("target.md")).Should().BeEmpty(); + metaData.GetReferencers(new ResourceKey("other.md")) + .Should().Contain(new ResourceKey("source.md")); + return Task.CompletedTask; + }); + } + + [Test] + public async Task SecondLoad_DropsCachedEntry_WhenFileMissingOnDisk() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "References \"project:target.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target body."); + + await RunMetaDataAsync(metaData => + { + metaData.GetReferencers(new ResourceKey("target.md")) + .Should().Contain(new ResourceKey("source.md")); + return Task.CompletedTask; + }); + + // Delete source.md so the cache entry no longer corresponds to a real + // file. Hydration should drop the entry and the index reflects the + // missing source. + File.Delete(Path.Combine(_projectFolderPath, "source.md")); + + await RunMetaDataAsync(metaData => + { + metaData.GetReferencers(new ResourceKey("target.md")).Should().BeEmpty(); + return Task.CompletedTask; + }); + } + + [Test] + public async Task SecondLoad_FullRebuild_WhenCacheJsonIsCorrupt() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "References \"project:target.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target body."); + + await RunMetaDataAsync(_ => Task.CompletedTask); + + // Corrupt the cache file. + var cachePath = Path.Combine( + _projectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeCacheFolder, + ProjectConstants.MetaDataCacheFileName); + File.WriteAllText(cachePath, "this is not valid json {{{ "); + + // Should still load correctly via full rebuild. + await RunMetaDataAsync(metaData => + { + metaData.GetReferencers(new ResourceKey("target.md")) + .Should().Contain(new ResourceKey("source.md")); + return Task.CompletedTask; + }); + } + + [Test] + public async Task SecondLoad_FullRebuild_WhenCacheVersionMismatch() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), + "References \"project:target.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target body."); + + await RunMetaDataAsync(_ => Task.CompletedTask); + + // Write a cache file with a different version field. Should be + // silently discarded and full rebuild runs. + var cachePath = Path.Combine( + _projectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeCacheFolder, + ProjectConstants.MetaDataCacheFileName); + File.WriteAllText(cachePath, + "{\"version\":99,\"files\":{}}"); + + await RunMetaDataAsync(metaData => + { + metaData.GetReferencers(new ResourceKey("target.md")) + .Should().Contain(new ResourceKey("source.md")); + return Task.CompletedTask; + }); + } + + [Test] + public async Task SecondLoad_HydratesSidecarFrontmatter() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md.cel"), + "+++\ntags = [\"flagged\", \"todo\"]\npriority = \"high\"\n+++\n"); + + await RunMetaDataAsync(metaData => + { + metaData.FindByTag("flagged").Should().Contain(new ResourceKey("notes.md")); + return Task.CompletedTask; + }); + + await RunMetaDataAsync(metaData => + { + // After reload via cache, the inverted index still returns the + // resource for the same tag query. + metaData.FindByTag("flagged").Should().Contain(new ResourceKey("notes.md")); + metaData.FindByMetaData("priority", "high") + .Should().Contain(new ResourceKey("notes.md")); + return Task.CompletedTask; + }); + } + + // Creates a fully-initialised ResourceMetaData against the test project + // folder, awaits an initial rebuild, runs the test body, and disposes the + // service (which flushes the cache). Each call simulates a workspace load / + // unload cycle. + private async Task RunMetaDataAsync(Func body) + { + var fileIconService = new FileIconService(); + var registry = new ResourceRegistry( + Substitute.For>(), + _messengerService, + fileIconService); + registry.ProjectFolderPath = _projectFolderPath; + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(registry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.IsWorkspacePageLoaded.Returns(true); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var metaData = new ResourceMetaData( + Substitute.For>(), + _messengerService, + workspaceWrapper, + new TextBinarySniffer()); + + try + { + (await metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + await body(metaData); + } + finally + { + metaData.Dispose(); + } + } +} diff --git a/Source/Tests/Resources/ResourceMetaDataTests.cs b/Source/Tests/Resources/ResourceMetaDataTests.cs index 3d3c7d8ae..1cd921f7a 100644 --- a/Source/Tests/Resources/ResourceMetaDataTests.cs +++ b/Source/Tests/Resources/ResourceMetaDataTests.cs @@ -51,6 +51,14 @@ public void Setup() _workspaceWrapper.IsWorkspacePageLoaded.Returns(true); _workspaceWrapper.WorkspaceService.Returns(workspaceService); + // The mutation tools route writes through IResourceFileSystem, so the + // fixture wires a real implementation against the temp project folder. + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + _messengerService, + _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + _metaData = new ResourceMetaData( Substitute.For>(), _messengerService, @@ -329,15 +337,127 @@ public async Task GetAllReferencedTargets_ReturnsUnionOfTargets() } [Test] - public void FrontmatterMethods_ThrowNotImplementedException() + public async Task RebuildAsync_IndexesSidecarFrontmatter() + { + // A .cel sidecar contributes its top-level frontmatter to the + // per-resource snapshot keyed against the parent file. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Notes body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md.cel"), + "+++\ntags = [\"meeting\", \"follow-up\"]\npriority = \"high\"\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + var rebuildResult = await _metaData.RebuildAsync(); + + rebuildResult.IsSuccess.Should().BeTrue(); + rebuildResult.Value.FrontmatterEntries.Should().Be(1); + + var parent = new ResourceKey("notes.md"); + var frontmatterResult = _metaData.GetFrontmatter(parent); + frontmatterResult.IsSuccess.Should().BeTrue(); + frontmatterResult.Value["priority"].Should().Be("high"); + + _metaData.GetTags(parent).Should().Contain("meeting").And.Contain("follow-up"); + + _metaData.FindByMetaData("priority", "high").Should().Contain(parent); + _metaData.FindByTag("meeting").Should().Contain(parent); + } + + [Test] + public async Task RebuildAsync_BrokenSidecar_ProducesNoFrontmatterEntries() { - // The frontmatter index methods are not yet implemented and throw. - var resource = new ResourceKey("foo.md"); + // Frontmatter that fails to parse is dropped silently — the registry's + // pairing pass classifies the sidecar as Broken via SidecarReport and + // the metadata service simply records no index entries for it. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Notes body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md.cel"), + "+++\nthis is not valid toml === yikes\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + var rebuildResult = await _metaData.RebuildAsync(); - Assert.Throws(() => _metaData.GetFrontmatter(resource)); - Assert.Throws(() => _metaData.FindByMetaData("tags", "x")); - Assert.Throws(() => _metaData.GetTags(resource)); - Assert.Throws(() => _metaData.FindByTag("x")); + rebuildResult.IsSuccess.Should().BeTrue(); + rebuildResult.Value.FrontmatterEntries.Should().Be(0); + + var parent = new ResourceKey("notes.md"); + _metaData.GetFrontmatter(parent).IsFailure.Should().BeTrue(); + _metaData.GetTags(parent).Should().BeEmpty(); + } + + [Test] + public async Task RebuildAsync_SidecarWithReferencesAndFrontmatter_ContributesToBothIndexes() + { + // A .cel file can carry both project: references inside its frontmatter + // (e.g. a "related" field) and indexable scalar fields. Both indexes + // must reflect the file. + File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md"), "Doc body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "target.md"), "Target."); + File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md.cel"), + "+++\npriority = \"high\"\nrelated = \"project:target.md\"\n+++\nBody text."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + await _metaData.RebuildAsync(); + + var parent = new ResourceKey("doc.md"); + var sidecarKey = new ResourceKey("doc.md.cel"); + + // The frontmatter index keys against the parent. + _metaData.FindByMetaData("priority", "high").Should().Contain(parent); + + // The reference graph indexes the sidecar as a source (it's the file + // that contains the literal), pointing at target.md. + _metaData.GetReferencers(new ResourceKey("target.md")).Should().Contain(sidecarKey); + } + + [Test] + public async Task FindByMetaData_ListField_MatchesByContains() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "a.md"), "Body A."); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.md"), "Body B."); + File.WriteAllText(Path.Combine(_projectFolderPath, "a.md.cel"), + "+++\ntags = [\"alpha\", \"beta\"]\n+++\n"); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.md.cel"), + "+++\ntags = [\"alpha\", \"gamma\"]\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + await _metaData.RebuildAsync(); + + _metaData.FindByMetaData("tags", "alpha") + .Should().Contain(new ResourceKey("a.md")) + .And.Contain(new ResourceKey("b.md")); + + _metaData.FindByMetaData("tags", "beta") + .Should().Contain(new ResourceKey("a.md")) + .And.NotContain(new ResourceKey("b.md")); + } + + [Test] + public async Task FindByMetaData_NumericQuery_FindsLongTypedScalar() + { + // TOML integers parse as long; the query must canonicalise an int + // argument into the same key form so the lookup succeeds. + File.WriteAllText(Path.Combine(_projectFolderPath, "task.md"), "Task body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "task.md.cel"), + "+++\nestimate = 42\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + await _metaData.RebuildAsync(); + + _metaData.FindByMetaData("estimate", 42).Should().Contain(new ResourceKey("task.md")); + _metaData.FindByMetaData("estimate", (long)42).Should().Contain(new ResourceKey("task.md")); + } + + [Test] + public async Task RebuildAsync_InvalidSidecarFile_DoesNotIndexFrontmatter() + { + // A file ending in .cel.cel is the invalid-sidecar marker; the registry + // refuses to pair it and the metadata service must follow suit. + File.WriteAllText(Path.Combine(_projectFolderPath, "weird.png.cel.cel"), + "+++\ntags = [\"should-not-index\"]\n+++\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + await _metaData.RebuildAsync(); + + _metaData.FindByMetaData("tags", "should-not-index").Should().BeEmpty(); } [Test] @@ -373,4 +493,117 @@ public async Task TransientReadFailure_PreservesExistingIndexEntries() _metaData.GetReferencers(targetKey).Should().Contain(sourceKey); } } + + [Test] + public async Task SetFrontmatterField_UpdatesIndex_BeforeWatcherEventDelivers() + { + // The file watcher delivers ResourceChangedMessage asynchronously via + // the UI dispatcher, so the post-write index update must run inline + // inside the mutation path. Otherwise an agent that calls set followed + // by get on the same call sees a stale "no frontmatter indexed" error. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Body."); + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + var resource = new ResourceKey("notes.md"); + var setResult = await _metaData.SetFrontmatterFieldAsync(resource, "priority", "high"); + setResult.IsSuccess.Should().BeTrue(); + + // No WaitForPendingUpdatesAsync / Task.Delay here — the index must + // reflect the write the moment SetFrontmatterFieldAsync returns. + var frontmatterResult = _metaData.GetFrontmatter(resource); + frontmatterResult.IsSuccess.Should().BeTrue(); + frontmatterResult.Value.Should().ContainKey("priority"); + frontmatterResult.Value["priority"].Should().Be("high"); + + _metaData.FindByMetaData("priority", "high").Should().Contain(resource); + } + + [Test] + public async Task AddTag_UpdatesIndex_BeforeWatcherEventDelivers() + { + // Same race as Set, exercised through the tag-specific surface. The + // FindByTag lookup is a thin alias for FindByMetaData against "tags", + // so this also covers the list-of-scalar indexing path. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Body."); + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + var resource = new ResourceKey("notes.md"); + var addResult = await _metaData.AddTagAsync(resource, "flagged"); + addResult.IsSuccess.Should().BeTrue(); + + _metaData.GetTags(resource).Should().Contain("flagged"); + _metaData.FindByTag("flagged").Should().Contain(resource); + } + + [Test] + public async Task SpuriousDeleteEvent_DoesNotClearIndex_WhenFileStillOnDisk() + { + // Third race: ResourceFileSystem.WriteAtomicAsync uses + // File.Move(overwrite: true) to replace existing files, which on + // Windows fires a FileSystemWatcher delete event for the + // destination followed by a create event. If OnResourceDeleted + // clears the index unconditionally, the synchronous entry from + // MutateSidecarFrontmatterAsync gets clobbered in the window + // before the rescan reads the file back. Sending a delete event + // for a file that is still on disk simulates the spurious case. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Body."); + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + var resource = new ResourceKey("notes.md"); + (await _metaData.AddTagAsync(resource, "alpha")).IsSuccess.Should().BeTrue(); + (await _metaData.AddTagAsync(resource, "beta")).IsSuccess.Should().BeTrue(); + + // The sidecar file is on disk and the index has both tags. Fire a + // delete event for the sidecar key while the file still exists — + // this is the shape WriteAtomicAsync's overwrite produces. + var sidecarKey = new ResourceKey("notes.md.cel"); + _messengerService.Send(new ResourceDeletedMessage(sidecarKey)); + + // The index must still reflect both tags. The companion create + // event would normally arrive next; we don't send it here, since + // the assertion target is that the spurious delete alone does + // not clear the entry. + var frontmatterResult = _metaData.GetFrontmatter(resource); + frontmatterResult.IsSuccess.Should().BeTrue(); + var tags = (System.Collections.IEnumerable)frontmatterResult.Value["tags"]; + tags.Cast().Select(t => t?.ToString()).Should().Contain(new[] { "alpha", "beta" }); + } + + [Test] + public async Task WatcherRescan_DoesNotClobberFreshIndex_WhenRegistryLags() + { + // Second race: after MutateSidecarFrontmatterAsync writes a new + // sidecar and synchronously seeds the index, the file watcher's + // ResourceCreatedMessage arrives at the worker. If the worker + // looked up the sidecar's parent via the registry, the lookup would + // fail (the registry's pairing table is only refreshed by + // UpdateResourceRegistry, which lags the watcher) and the fresh + // index entry would be dropped. The fix derives the parent key by + // stripping ".cel" directly so the worker can refresh the entry + // without depending on the registry catching up. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Body."); + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + (await _metaData.RebuildAsync()).IsSuccess.Should().BeTrue(); + + var resource = new ResourceKey("notes.md"); + (await _metaData.SetFrontmatterFieldAsync(resource, "priority", "high")).IsSuccess.Should().BeTrue(); + + // Deliberately skip UpdateResourceRegistry so the registry still + // does not know about notes.md.cel. Send the watcher event the + // way ResourceMonitor would have. + var sidecarKey = new ResourceKey("notes.md.cel"); + _messengerService.Send(new ResourceCreatedMessage(sidecarKey)); + await _metaData.WaitForPendingUpdatesAsync(); + await Task.Delay(50); + + // The frontmatter entry seeded by SetFrontmatterFieldAsync must + // survive the rescan, even though the registry has not paired the + // sidecar yet. + var frontmatterResult = _metaData.GetFrontmatter(resource); + frontmatterResult.IsSuccess.Should().BeTrue(); + frontmatterResult.Value["priority"].Should().Be("high"); + } } diff --git a/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl b/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl index 35f2c54005cf55adeaa6d34df1859c7276d2ab88..00b40fe05194524120f9d84d5b5a9eaa7eff4cb9 100644 GIT binary patch delta 5950 zcmZXYbyU^e(#PpgQo2FnAOcbc1nDl1bRN1>NLYIgLJpl z^?BBNpS$jT_dm1evuD<; zHR6}{n+ol?@Mt-9nUe<6%<&_nEnpQnK0rvT$(E6{?bhXabQ89rS{evF{=dz!8` zRB{iCV3wyInk20{bc=z4vPc0BRNx2n3?0^9lKQ_mlb}T=k=Bg(GF1&i9^X{ej_n(2 zL_Vbh&G*H{uCrJtKeQ^=uR8EK6~Moen(sne-KS%{6Q4UVz2Ktu^>z1!+b5vPRZpas z^A@njK}%(Dn{#eSx^rig8SK{Z!)Xz?7+UNfao><6PpxjH#dnP&>FJ|CBBIz1Yt1V_m!w5XS2(sCpeRP?4voA@xyN_@CEG)17F zwl8+z0%#FgWVM_ui|giSxB$e;L4}R&pQU0U(UA0@8%k;39pm0JbWl^M;SkXKFcVWFPT9%`> zTG`KhdNqA1HCCBGaZJ7^sC@Wlqf}%@tM|oEg4Tb@21WT4{-WQ1moaUDQ+}y&0eTv_ zc9^8uuJWl7Z8qvFufY53!LNFcWsNf|DHp(^Rd4pg@q;FvI`@SbR$8j*D!X>bzN}oY z$a}xO1P>Ty?2^1&{s?GYG$+BKxl%1Ma9dUN+`9CzPbv`(!~5O@Z zF-~c)-xs)6HpyjuSL+;Gxq9wV0^&Yen$R0sZJQ=|;K|R6A#>(&{Eo}=3ZvLGf0~?i z-QX^CN?bq$FJ>wS3vDE^(d;7oXa_W`aCvs2LdZ6vC$JwY_6i`wDhskAJ@eG#8h5C3 z?MYN`j(57hQKuY#*)YoUfcCsw8MHI2>K|Wh#I#Qa<6a%&dY8A*p$lG>r=Zrh%_LI; z%kt1@<6u3wKzMLt4h#d@yB3mM^gtl9Ky}w$>ttwO&QX zj4-EhVT?TYXlZo$tzW=fvV}`qwT$z~eNxbKm?aHIe2&rwr{qt>P2|vuw0tkJJ&MAh zL2wKf3Az!*GtvnwX%jC+e8Oz5;SG7XCh9z?YPRMo22eS`9F+%WKCDFIALH-lOf0xF z*&iAkv>6ds+_$oCa2#0P>3p|w+IhS`K`)lJuIyqklIw5kBY!I!%%<(&N;B0Et4ueg znDlE^eW@b-O|^cOA!$C_!>_sZ zE*!jqHOzu^LG(iu{F7`U6VI zgd_zhSxL0~`l$S%Rkbd?mG^S^r<>VR8VH- z&V*iJfiL`5V99fWyUi-4B`P$*gxm{ZQ}JH~QA0ZXBK`#uDUumBJw&G}z87lytY2=z zFtets9n*L^_)>niH2+`%Mnn&FUjmll(>(UoM_hx+R>yA`^@9tnpY?3oY}&Nd z)Oi5As0?a_$Sl{i6M3Agv3U)pB7~3a%7)3x!cRg|wWZtwleaJ0Ias%LA=|GMPH!U@ zPf4Xp<*i?o{{mBPDDrir1cpz8le)3)kGC5x)p(th**^JpT4KS5)FKiG%^4BhaSQI# zwx-en)n%C69d&%L(a{`s5s5YJ^19$9SRBBjb*10HGP@-eZeIFcXZc#cLK}P=rAhjk zf}hhbVpX?1pPyta5l2V0?1!*Tzc70HD;vh%bdQ!)%}qXz=&*$3h{J8}F5uw&`Wz;%(U-0bEi~ zdxNyb6h9R4B9c*S$@5QfovZmq)@2XS`LV=-i_QD9>soj6c%4(zhj`pT>Vv#_DpSL0 zJv}q%_jX0MR=HtL9|L3dtzfjkreLStMC#JCsS{rhNPW~wiEJN_4U92~SrC6&Y-fgq z1gTf`s2)dex*q0xd`AI;+$1prKhImm#{2CT`bBiapg~f;ebiY3(4~srpUtn?ep*}a z2wgjIBJfGNTup8*ewQhB?7y?r%GHc?7J{tpzteKHXymL&<{p!$LqFRdS`}R!q^Gde zjV*krwh^1hO9##$@SS;F_%{wVWQ77BMqqn!?N~a@GdTdhzu+b6DRnErSr7P;a7IzF zKQp-E6Tf$;I)cJU9!F{~n|yd=ee?*2nv!neZD!oA?4?BX=L20@axtPkq!~ve@Luug z$dlXqXmGKE`%=%Js`UN{Ou@?>eVZWo3uBjt|JVNxvt&RrPLpjbiZ~W zMg#nW^;TrK;^kWc(<8)zlwtQxTYZnrMbf3rk6Kla~0TuWm0Roz(mrb=5Jw$r@} zo4U7ebT#w^TodGI!WJjJW=qOoZ`XyqI1Fr(d`fV#SOFekVN|_`W7o`AUfK zbzA|-N5SK`v=Me(xW04~-?%Le*XuYE!sj2KE}d!TObYpNFOwLU0?RXjaqW`1qq3$d z*4dUJ;R-L?K|4q2s>Rf?9pt@dqADrR#kKfY4$#i4(x<_EuTvt7zYSH4PbpLHh>1x1 z{Vuo?cdRjR&u?H7ItK75k}v(6lwtcN)77E zL{vcn`I%WRRRz4i1Efp(=3Yp>R#O#&`aBji7 zX|Us(CJ4mvV?dUZA)RF0og3TkTRX2(x1+&?IGnHJUJ{>{NeH5g-G_^CpThF-XrB#L zT%7JlVmt^ll3St7pTN$=mMYLLeJ@yK5)1SU*GRGkKAsE%#q*gWeN2W!I{8E9*+mi= zZPa$ZM9g!zsqFkC66G3p$LDR7HJE##sRl}C)d zCZx<(-wYxMv^MZLQC<41vF|`!5)#RNWmRd#SXo0)+h2F`m)Xlvd_QC^zWjC$|5L*V z$h2sK`@C2V#|h?^A0H;yH)uu8=bwU-dn^zipbfI>2UW}+7&TWBk4I}xD}LL#%GbLa zoJfWcV{>f}jMcxJN!QJ_ao5#dALAU98JlkuIQt1OXw!A#K6VA`?9hLyOVL3nA&suC zG4?$PH}BO_AH6%qaKg{_vzPxCaOrnS$Tcst+!ve1eRVEASYuxF)0Aj zhWYxGRsHZvM6zhGmR#J{mJ4STa4+W)hDHhNR^!yyl(=$HTOyHk*4M{-kO~umvPV`^ zwVZ&qu2xi4`8W~L#kzdfPXO1X^`QK8MZOb%>5-~qv9@MBuF*RZn4`a0y76RYw$uJV zSvPjvyipS!!;KONH%)nEu9nh?)sEG#?1{`ho)zNj4UOJmw>eb@=puhdt#~<`*DRax zTBoRtFX;mE(?P5lAdoEd6+ybe%YaPSk(s_^-qp-{X*jl zmcQ?p9;kCx*s{Ts7$m1IR(~0m*R>6^BFCklqq4PUSARo9Rysv9YqnVxMH~bPiUbNQ z!Jd<(LbWScb@PJsd|sD@xRPtR1?Z-HWm>)cH8Lw9Di$fKuJ1@~jNc;nE&MnFsg!SK z_xbDGXT{yKg%dL^ZN)GK-6BM)2@Kzeic;iQm&Pu@mLFRBV?y}pJJJ%<8V*)b@Omic zZQ~{7;8Dn!1FLq-mJQ@7FPG3+5Fq^OOMYm_BO(9#I9~m+zM+pEBTF{UEJ34JW>J+- zw9q$S-t`d@bY>d2$83E8QMxsxv0qK>_S?TOf?5fU<1mT&NVWwN9LdD+c8aKNxtdlE zGh1Rw6eTR%`BDudUgh70aZZeCR206Sh_=fRsM;16mEa+EOGcuaVLut%VF%PIZac6m zXzn-8Gv9_5Po0!?ppu&jeF?^w+7!u3wX>JJyv!KXSwp*jJtDgVD?k94;Ee&XLN5rTK^m)wougqrXvgc&z` zyE1nzoc?BGX-(U@kpC*Qb`I=<-Y@!3IgZRAk1t^T1o@+Po=HQ;ZX) z_*@pVPy7T2n_K?#q2-+huZK@u^K?0%kh&8jC%t)wNRJTqCZ?Tr-lR{Cr+CIKXr&zE zRO_z{%sM;o#~wm3k<*1UTlcMN`fTihJzeD zpjKPdB;0|+2G}x`xqCi|4|khw0(1sL@3#~Ct^!?B^NGJ?7|6!=(Q8ZH`Uqv_@=lM> zS8vt`Ic8Fmd9BQxuaSpyJM{MOIpLp!`B!=#?TIhUMM%3-r&wigTb}3s67^AN{Y4LX zU^AsWA4N(Gz3$5QvnraLXKCdkOv2cNMTdVj`LGFQ@KUy-$9PcTtX zqW*jl8Q}no4j!F`1;D2J<7x$IFqi!*FujsP<;YFjd$1;Zk~?U^r2_D9Qm9PsOqQMk zTgoMEi)v*V=swYTq*F@RwP?3gazq_W;o?WiXI852wM(<*s%h*D;BJvx}bh8rPN!)aZ086vZQt3w!Yfb9V=9Tc$ zuNDR&bmd?c%2YBz11O4s>^sq%I*f%LJo1x0?Jx0Et|c7qcJ_H3AN=XhX9jC!1i;aS zHKT#!u}QoSy3)OxOYR`%|q;oc+usKwyf*7TI^RemlI>l)_{bg}|$(5bGfD9yL`$scaz@6`Tg+~AT)l3x{quu^DO=5fg8*4n(P7boA|F!%2=E>`RbhL z@SkJd^?cjjG|<0vwr&+uF^QeVDNiZ*wWH8CnYykxu<5l;-&ezkp))&zk%LNVB-&0c zhrShh-zGALy`VX$KlpKrw#YK^_^ZG2^JgX5w4N&qE6RxGs?eUNxPi>UZ|_Vdf1lSM zUmy1wi~t`FoL9#_b;)eAm1v`6@-bFnk_CjeVUP?k+$O|O9vik|fUy>mrq#)a72}q+ zUC$eXbqZr5?v>+I+vrRC;D;J|W&I$LEhZ`Tx-+haU8-*Hp9E|?6nnGB(;1D(GLt8A zh`e<=TD_?CtCtC9aoB}3G?N)wdMnueGX)l>2!IcCFHJb`JJK&R+er9xx=r94q>=V{ zae~i=v{52&{swmgPAXa7QfL6?52s73w;+^(Vz<5V?F?O19ujBQI#MK_!NE9HPtZ<7 z2ifk$8sv!d{9_F5j46rB3c8kqSRw)hq?qT%cg?yv2bPLq$h7yGwtUxpX^&Qi6Kj2) z31G@He*B4;fAP78X191dHhVvD@xyE1--yXkGMYibjU}TK{o$Hy@G0TGu96(IJECji5E0Mp1S)8eNxz5~Px zC!~6^^`j1Fbf1cKqq{GMRJvUam#4zTbgOb`MAxj@vU_XmeAhG}9Te;R`;toRKr;QD73C*EmH+swLTGRx?|Z2(3ZO6I zw5TP%>;ArxS|^KUvV5e=_XEf_kqJ;kP_d9;oLqphH%63k~#72FSLFNj;WlR zpk7t;u?P#rd}`0N+jKLJNKSZw?nr5kNb4`^Ocr zA%N$laG?DR`Uf$3J%Zn*;QhT-@KR~eL=)fvFj}}=D(T+{`UiC<|BVt-iT_5$e~@v? zAH+$hrh(xpkrK4UClkR@v~G4@cx45RIrB}-*0Ly@g4V=uBr zS)z2amu_}pm@G-Mgo&=)|9zf&|K~jC`QGn6?^&PcobM~^1((!<%WbiUv$qO}P~mkQ z4hRG~$vBNY4BWQzZ^Z~-`wku@_bR)*~XfzP*ez22WJw{hxvj~~sy3Q%kFn|_S6KajEsT zfN)o);AVIPC#(vIEKFYn3IYOggc)>iMMie60PxD`V*m>SzjAm5z>E_3(U)c5g7k$Y zL3)FV{L`AD`*k%HDH>agLb~S5xz<7RRFZ{U!m4?;!YR9#-F`P-B88FGihXZ}=1^o= zC&#adJh<1nQdJ@2lSx=Z1J{xTTg}lZb$60{b6(i(8`MXLLg<7nSL@N_mWRtvXQz&P z0~pecJpuwS6`Q?+(phJ@mSPQ2i2|SvJ6DGX3d} z?v~(l0gk2Ck6Y$*$k|6Doc@$zjU~zUbTa@3OMuj!Mn*rC-S%c&_oM00$QXXRha1rT zCcKhwM=NoaQ=%-rUnjgp(K>I?(l$7Qhg<>(+#<#Az7MtAeQMsHR^8TcvKKQKRDIN7 zA=t}Eg;Y^I2E_H0Ed^(z%-prkukB0FpY#{-m4#SHHIK-dte~;W@_eJ4U$WLEa3;&TncoIzuOpgbUuJQ<4%{GW` z8|N>tPXzm~+Z_zT)%dEmwILfzuT#xZTa{#oG{6->W!F`rm((UWT^c!i=V|(W7z)pF zk14I=YjvCM9V!0Rncwn*_1dpZ>HxLPt3MqY9~|g(_OxBwV;(3cfz{XhlMxOX#X@bYayFJ4r7WAQ3nx*F{PNITz8F_oyqt5p&2xzPh~pw`@5#Bu zlEE=3Cbxa~l%W~V`JgoAN#0MW)gHPF8KZx!c^cnfe~@-={zG!pi*o5Od!TOb(=sf= zj&KcfPn`ug1?tVa)Z~2bGM}q)VXFH`rr0e;9@n`N6jhJztN-(Nw2kU}AIX_EKL1dy zZ^h1}bXz~9i=&iwQRwr~4EgE}v+fRx$TMK6pg^mP7|hZ9VM;y^j?2-U!ICZyVHDbkk$fp#WAQV9WTMS}$8?{^Ff+9-At5cydfJ(VzhdQ8^d5RxcCPM6J~;n+J()7hxJ-jdWaYMUd?f44dkrn z3wh16t_Xp9qmJ+yjK{)F;_X-5See@6`z|Gbiph>9WtFc*fnR=&ckb*0Mwa*?iy2+N6ECHr8U#2G9M%F&=7@$%y=P;;@w98gFU1o#~{XszV5mehm(}j?)n|9r1qg)HG z7`+M)#io%?L>tK1;#QoAm|ZaXRw8{r{F<8ECboE$74T_URW28U7c{<;#;2k0c(?nEbDYH};p2hky$xb9Z}s=tXpCR=C?Dl;UvOHd%>X#E;y5 zv9TdOI+QypFA*E+_6^%D8^ie`t9SO=U6K23mfo&glbBK??N5G@9{7S!bqy5ZfT zLVTubz_=nwPb2#j+tO_bupMacrNTzoxy{Om%LY)9B+0(c1(*AaoM-jKp~T(z31xi)eNOK04EoG{~=qwGDO z-%xXx_m2+r`6*)Vt2Mq|_Sy7+vkb^5%m zX+`(p)fTmv3*--L>AjXI>!k
    wbjaeAeh*QZ6ZU`V zn`ACh+1M7|C8TlhQgCUSp2Q6$;1lQdT5Np1r^T}-ZwyBQp#=z=mezcfSW z39+QRBNIJ-#v*%#K0~(5nlaM2bm~XyAR9f#28J>-6Q|vyaBa3$hADVBe8eI(%sHgL zWALiRAM>zhl)PKxN?T$JSza!j+1Mf>9NnWDNFKvq@jX#8FR6V*TRm(FcLx5O-brE{ zLagO-KEPZU2;%yT(-o9q>1_XYn|$@aB25-CVia#xTE)z=15FqD_2PUa%B3X=+Tz6U z3$dJgS1Y0oGrP|;^dDqN#KR4J49<*ax;v%kQPCGM(khb2=kxR8(^lo5o%P@zUMO&* z5X2D#{OuqBVng5pi-JUV5->i{mkZ%UkodXE8`%-y|I5upa(uvXIEF~5G@}E@&v=-l z44%X9xblpzIT)@vhFq?$?0?f15a{=vN&hJ|k!OzY%CmDdz`Yi`a3l8!_>kQ$Jdvjf z-X#7HLK%o(=9gE>As@Cf4oQyDxFw#)8|=4Sv*`0kfZ zXG($b35eG9+hfl5Mr9DHZ* z{5wtYpEK;(n#j?=2MOIrFy0ivW&aaAU?7m}zr MCP server -> C# tool -> workspace +service round-trip so the alias mapping, JSON marshalling, and underlying +service behaviour all stay in sync. +""" +import json + +import pytest + +import celbridge +from celbridge.cel_proxy import CelError + +from .helpers import delete_if_exists + + +def assert_project_clean(extra_broken_references=None, extra_orphan_sidecars=None): + """Run metadata_check_project and assert no unexpected attention items. + + Pass ``extra_*`` for entries the caller knows about (e.g. a deliberate + broken reference left by a destructive test). The default expects every + list to be empty. + """ + report = celbridge.metadata.check_project() + + extra_broken = set(extra_broken_references or []) + extra_orphan = set(extra_orphan_sidecars or []) + + actual_broken = { + (entry["source"], entry["missingTarget"]) + for entry in report.get("brokenReferences", []) + } + unexpected_broken = actual_broken - extra_broken + assert not unexpected_broken, ( + f"Unexpected broken references: {unexpected_broken}; expected only {extra_broken}" + ) + + actual_orphan = set(report.get("orphanSidecars", [])) + unexpected_orphan = actual_orphan - extra_orphan + assert not unexpected_orphan, ( + f"Unexpected orphan sidecars: {unexpected_orphan}; expected only {extra_orphan}" + ) + + broken_sidecars = report.get("brokenSidecars", []) + assert broken_sidecars == [], ( + f"Unexpected broken sidecars: {broken_sidecars}" + ) + + +@pytest.fixture(autouse=True) +def workspace(explorer, file): + delete_if_exists(explorer, "TestMetaData") + explorer.create_folder("TestMetaData") + file.write("TestMetaData/notes.md", "Notes body.\n") + file.write("TestMetaData/other.md", "Other body.\n") + yield + delete_if_exists(explorer, "TestMetaData") + + +class TestMetaData: + + def test_set_creates_sidecar_and_list_returns_field(self, metadata): + # Set a field on a resource that has no sidecar. The sidecar should be + # created and the new field should appear in the list response. + metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) + listed = metadata.list("TestMetaData/notes.md") + assert listed.get("priority") == "high" + + def test_get_returns_field_value(self, metadata): + metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) + value = metadata.get("TestMetaData/notes.md", "priority") + assert value == "high" + + def test_get_missing_field_returns_error(self, metadata): + metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) + with pytest.raises(CelError): + metadata.get("TestMetaData/notes.md", "missing_field") + + def test_find_returns_resources_matching_scalar(self, metadata): + metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) + metadata.set("TestMetaData/other.md", "priority", json.dumps("low")) + + high = metadata.find("priority", json.dumps("high")) + assert "TestMetaData/notes.md" in high + assert "TestMetaData/other.md" not in high + + def test_set_accepts_list_of_scalars(self, metadata): + metadata.set( + "TestMetaData/notes.md", + "categories", + json.dumps(["alpha", "beta"]), + ) + listed = metadata.list("TestMetaData/notes.md") + assert listed.get("categories") == ["alpha", "beta"] + + def test_set_rejects_nested_object(self, metadata): + with pytest.raises(CelError): + metadata.set( + "TestMetaData/notes.md", + "complex", + json.dumps({"nested": "value"}), + ) + + def test_add_tag_creates_sidecar_when_missing(self, metadata): + metadata.add_tag("TestMetaData/notes.md", "flagged") + tags = metadata.list("TestMetaData/notes.md").get("tags", []) + assert "flagged" in tags + + def test_add_tag_appends_and_is_idempotent(self, metadata): + metadata.add_tag("TestMetaData/notes.md", "alpha") + metadata.add_tag("TestMetaData/notes.md", "beta") + metadata.add_tag("TestMetaData/notes.md", "alpha") + tags = metadata.list("TestMetaData/notes.md").get("tags", []) + # Tags appear once each; ordering reflects insertion order. + assert tags.count("alpha") == 1 + assert "beta" in tags + + def test_find_by_tag_returns_resource(self, metadata): + metadata.add_tag("TestMetaData/notes.md", "flagged") + matches = metadata.find("tags", json.dumps("flagged")) + assert "TestMetaData/notes.md" in matches + + def test_remove_tag_drops_entry(self, metadata): + metadata.add_tag("TestMetaData/notes.md", "flagged") + metadata.remove_tag("TestMetaData/notes.md", "flagged") + matches = metadata.find("tags", json.dumps("flagged")) + assert "TestMetaData/notes.md" not in matches + + def test_remove_tag_idempotent_when_missing(self, metadata): + # No sidecar yet; removing a tag is a no-op success. + metadata.remove_tag("TestMetaData/notes.md", "nope") + + def test_remove_field_is_no_op_when_absent(self, metadata): + # Returns success without touching disk. + metadata.remove("TestMetaData/notes.md", "nope") + + def test_list_returns_empty_object_when_no_sidecar(self, metadata): + result = metadata.list("TestMetaData/notes.md") + assert result == {} + + def test_set_field_visible_through_file_read(self, metadata, file): + metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) + sidecar_text = file.read("TestMetaData/notes.md.cel")["content"] + assert "priority" in sidecar_text + assert "high" in sidecar_text + + def test_invalid_resource_key_fails(self, metadata): + with pytest.raises(CelError): + metadata.set("\\invalid", "priority", json.dumps("high")) + + def test_find_with_non_scalar_value_fails(self, metadata): + with pytest.raises(CelError): + metadata.find("tags", json.dumps(["a", "b"])) + + +class TestMetaDataCheckProject: + + def test_clean_project_returns_empty_lists(self, metadata): + report = metadata.check_project() + # The autouse workspace fixture leaves only TestMetaData behind, which + # doesn't carry references. Any unrelated project state shows up here + # too; we assert only the report shape so the test is robust to other + # content in the demo project. + assert isinstance(report.get("brokenReferences"), list) + assert isinstance(report.get("orphanSidecars"), list) + assert isinstance(report.get("brokenSidecars"), list) + + def test_broken_reference_detected_after_target_deleted_with_break_references(self, metadata, file, explorer): + # Create a source that references a target, then delete the target + # under break_references so the reference is left dangling. The check + # tool reports the resulting broken reference. + file.write("TestMetaData/source.md", "Refers to \"project:TestMetaData/target.md\".\n") + file.write("TestMetaData/target.md", "Target body.\n") + + explorer.delete("TestMetaData/target.md", reference_policy="break_references") + + report = metadata.check_project() + broken = [ + entry for entry in report.get("brokenReferences", []) + if entry["missingTarget"] == "TestMetaData/target.md" + ] + assert len(broken) == 1 + assert broken[0]["source"] == "TestMetaData/source.md" + + def test_orphan_sidecar_detected_when_parent_missing(self, metadata, file): + # Write a sidecar whose parent does not exist on disk. The pairing + # pass classifies it as an orphan. + file.write( + "TestMetaData/orphaned.png.cel", + "+++\ntags = [\"orphan\"]\n+++\n", + ) + report = metadata.check_project() + assert "TestMetaData/orphaned.png.cel" in report.get("orphanSidecars", []) + + def test_broken_sidecar_detected_when_frontmatter_unparseable(self, metadata, file): + # Write a sidecar whose frontmatter is malformed TOML between fences. + file.write("TestMetaData/notes.md.cel", "+++\nthis is not = valid // toml\n+++\n") + report = metadata.check_project() + assert "TestMetaData/notes.md.cel" in report.get("brokenSidecars", []) + + def test_move_preserves_invariant(self, metadata, explorer, file): + # A reference rewrite during a move must leave the project in a + # consistent state — no broken references remain. + file.write("TestMetaData/src.md", "Refers to \"project:TestMetaData/old.md\".\n") + file.write("TestMetaData/old.md", "Old body.\n") + + explorer.move("TestMetaData/old.md", "TestMetaData/new.md") + + report = metadata.check_project() + broken = [ + entry for entry in report.get("brokenReferences", []) + if entry["source"].startswith("TestMetaData/") + or entry["missingTarget"].startswith("TestMetaData/") + ] + assert broken == [], f"Move broke references: {broken}" + + def test_delete_without_referencers_leaves_clean_state(self, metadata, explorer, file): + # Delete a resource that nothing references; the broken-references list + # should not gain any entries scoped to our test folder. + file.write("TestMetaData/standalone.md", "No incoming references.\n") + explorer.delete("TestMetaData/standalone.md") + + report = metadata.check_project() + broken = [ + entry for entry in report.get("brokenReferences", []) + if entry["missingTarget"].startswith("TestMetaData/") + ] + assert broken == [] diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs index 5934284bd..b525ca43d 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs @@ -21,7 +21,9 @@ public class GetFileInfoCommand : CommandBase, IGetFileInfoCommand ModifiedUtc: DateTime.MinValue, Extension: string.Empty, IsText: false, - LineCount: null); + LineCount: null, + SidecarKey: null, + SidecarStatus: null); public GetFileInfoCommand( IWorkspaceWrapper workspaceWrapper, @@ -55,6 +57,20 @@ public override async Task ExecuteAsync() lineCount = File.ReadAllLines(resourcePath).Length; } + // Surface the paired sidecar's key and current parse state when + // the registry has recorded one for this file. Sidecars belong to + // file resources only; folders don't have their own sidecars in v1. + string? sidecarKey = null; + SidecarStatus? sidecarStatus = null; + var resourceResult = resourceRegistry.GetResource(Resource); + if (resourceResult.IsSuccess + && resourceResult.Value is IFileResource fileResource + && fileResource.Sidecar is not null) + { + sidecarKey = fileResource.Sidecar.Key.ToString(); + sidecarStatus = fileResource.Sidecar.Status; + } + ResultValue = new FileInfoSnapshot( Exists: true, IsFile: true, @@ -62,7 +78,9 @@ public override async Task ExecuteAsync() ModifiedUtc: fileInfo.LastWriteTimeUtc, Extension: fileInfo.Extension, IsText: isText, - LineCount: lineCount); + LineCount: lineCount, + SidecarKey: sidecarKey, + SidecarStatus: sidecarStatus); return Result.Ok(); } @@ -78,7 +96,9 @@ public override async Task ExecuteAsync() ModifiedUtc: directoryInfo.LastWriteTimeUtc, Extension: string.Empty, IsText: false, - LineCount: null); + LineCount: null, + SidecarKey: null, + SidecarStatus: null); return Result.Ok(); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs new file mode 100644 index 000000000..91a18b794 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs @@ -0,0 +1,78 @@ +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Builds a ProjectCheckReport from the metadata service's reference graph and +/// the registry's sidecar pairing snapshot. Pure read; no FS mutation. +/// +public sealed class ProjectCheckCommand : CommandBase, IProjectCheckCommand +{ + private readonly IWorkspaceWrapper _workspaceWrapper; + + public ProjectCheckCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public ProjectCheckReport ResultValue { get; private set; } = new ProjectCheckReport( + BrokenReferences: Array.Empty(), + OrphanSidecars: Array.Empty(), + BrokenSidecars: Array.Empty()); + + public override async Task ExecuteAsync() + { + var workspaceService = _workspaceWrapper.WorkspaceService; + var registry = workspaceService.ResourceService.Registry; + var metaData = workspaceService.ResourceMetaData; + + // Reference graph and sidecar report are both in-memory after the + // initial rebuild completes. Block the call on readiness so the check + // never returns a partial view of the project. + await metaData.WaitUntilReadyAsync(); + + var brokenReferences = new List(); + foreach (var target in metaData.GetAllReferencedTargets()) + { + var resourceResult = registry.GetResource(target); + if (resourceResult.IsSuccess) + { + continue; + } + foreach (var source in metaData.GetReferencers(target)) + { + brokenReferences.Add(new BrokenReference(source, target)); + } + } + + // Deterministic ordering so test assertions and human readers see the + // same shape every time. + brokenReferences.Sort((a, b) => + { + var byTarget = string.Compare(a.MissingTarget.ToString(), b.MissingTarget.ToString(), StringComparison.Ordinal); + if (byTarget != 0) + { + return byTarget; + } + return string.Compare(a.Source.ToString(), b.Source.ToString(), StringComparison.Ordinal); + }); + + var sidecarReport = registry.GetSidecarReport(); + var orphanSidecars = sidecarReport.Orphan + .OrderBy(k => k.ToString(), StringComparer.Ordinal) + .Select(k => new OrphanSidecar(k)) + .ToList(); + var brokenSidecars = sidecarReport.Broken + .OrderBy(k => k.ToString(), StringComparer.Ordinal) + .Select(k => new BrokenSidecar(k)) + .ToList(); + + ResultValue = new ProjectCheckReport( + BrokenReferences: brokenReferences, + OrphanSidecars: orphanSidecars, + BrokenSidecars: brokenSidecars); + + return Result.Ok(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index 8086dc1f4..9c833d696 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -44,6 +44,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs index ca24a77df..f783f22a8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs @@ -1,7 +1,10 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Text.Json; using Celbridge.Logging; +using Celbridge.Resources.Helpers; using Celbridge.Workspace; +using Tomlyn.Model; namespace Celbridge.Resources.Services; @@ -13,6 +16,10 @@ public sealed class ResourceMetaData : IResourceMetaData, IDisposable // Files larger than this byte budget are skipped during the scan. private const long MaxScanFileSizeBytes = 10 * 1024 * 1024; + // The standardised list-of-string field exposed via metadata_add_tag / + // metadata_remove_tag / FindByTag. + private const string TagsField = "tags"; + // Re-queue delays for a transient rescan failure (file locked by external // writer, antivirus, etc.). The retry attempt counter resets when any // watcher event arrives for the resource, so normal user activity always @@ -39,6 +46,20 @@ public sealed class ResourceMetaData : IResourceMetaData, IDisposable private readonly Dictionary> _referencersByTarget = new(); private readonly Dictionary> _referencesBySource = new(); + // Per-resource snapshot of the parsed sidecar frontmatter as a top-level + // field map. Keyed on the parent resource (e.g. "foo.png"), not the sidecar + // key. Absent entries mean "no frontmatter indexed for this parent" — either + // the parent has no sidecar, the sidecar is unparseable, or the sidecar's + // top-level fields are all non-indexable shapes. + private readonly Dictionary> _frontmatterByResource = new(); + + // Inverted index from field -> indexed value -> set of resources carrying + // that value in their sidecar frontmatter. Scalar fields contribute their + // value directly; list-of-scalar fields contribute each element. Object / + // nested fields are stored in _frontmatterByResource but not indexed here. + private readonly Dictionary>> _resourcesByMetaDataField = + new(StringComparer.Ordinal); + // The pending-rescan queue. Watcher events push file keys onto this; the // background worker drains them. WaitForPendingUpdatesAsync awaits the // worker when it sees a non-empty queue. @@ -51,11 +72,27 @@ public sealed class ResourceMetaData : IResourceMetaData, IDisposable // event. Used by ScheduleRetryAfterTransientFailure to cap the retry chain. private readonly ConcurrentDictionary _transientFailureCounts = new(); + // Per-file mtime + size + isText stamp, captured at scan time and persisted + // to the cache file. The dictionary key is the file's resource key relative + // to the project root; the value is the stamp at the last successful scan. + // Kept in sync with the index dictionaries (entries are added when a file + // is indexed, removed when it is dropped). Guarded by _indexLock. + private readonly Dictionary _cacheStamps = new(); + private TaskCompletionSource _readyCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _isReady; private volatile bool _isShuttingDown; private bool _isDisposed; + // Debounced cache-save tracker. _isDirty is set to true on every index + // mutation; the worker checks it after draining the pending-rescan queue + // and, if enough time has passed since the last save, persists a snapshot. + private volatile bool _isDirty; + private DateTime _lastCacheSaveUtc = DateTime.MinValue; + private static readonly TimeSpan MinCacheSaveInterval = TimeSpan.FromSeconds(30); + + private record CacheStamp(long MtimeUtcTicks, long Size, bool IsText); + public bool IsReady => _isReady; public ResourceMetaData( @@ -111,16 +148,48 @@ public async Task> RebuildAsync() var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); + // Try to hydrate from the persisted cache first. Entries whose + // mtime + size match the on-disk stat populate the indexes + // directly; entries that don't validate fall through to a full + // scan. The cache is host-private and may be missing or stale — + // both cases produce correct behaviour after fallback. + var cacheDocument = LoadCacheDocument(registry); + var newReferencersByTarget = new Dictionary>(); var newReferencesBySource = new Dictionary>(); + var newFrontmatterByResource = new Dictionary>(); + var newResourcesByMetaDataField = new Dictionary>>(StringComparer.Ordinal); + var newCacheStamps = new Dictionary(); var transientFailures = new List(); int filesScanned = 0; int filesSkipped = 0; + int filesHydratedFromCache = 0; int referencesFound = 0; + int frontmatterEntries = 0; foreach (var (resourceKey, absolutePath) in files) { + // Cache hit path: stat the file and compare against the cached + // mtime+size. A match means the cached data is good and we can + // skip the scan entirely. + if (TryHydrateFromCache( + cacheDocument, + registry, + resourceKey, + absolutePath, + newReferencersByTarget, + newReferencesBySource, + newFrontmatterByResource, + newResourcesByMetaDataField, + newCacheStamps, + ref referencesFound, + ref frontmatterEntries)) + { + filesHydratedFromCache++; + continue; + } + var scanResult = await ScanTextFileAsync(resourceKey, absolutePath); switch (scanResult.Outcome) { @@ -133,6 +202,10 @@ public async Task> RebuildAsync() case ScanOutcome.ExcludedPermanently: filesSkipped++; + // Capture the stamp even for excluded files so the + // cache can record "this is binary, don't re-sniff" + // and the next load skips the sniff cost. + TryRecordExcludedStamp(absolutePath, resourceKey, newCacheStamps); continue; case ScanOutcome.Indexed: @@ -142,6 +215,31 @@ public async Task> RebuildAsync() referencesFound += scanResult.References.Count; ApplyReferences(newReferencersByTarget, newReferencesBySource, resourceKey, scanResult.References); } + + if (IsSidecarPath(absolutePath)) + { + // For sidecars, the frontmatter is indexed against + // the parent resource (the file the sidecar + // describes), not the sidecar key itself. The + // pairing pass in ResourceRegistry derives the + // parent for us — but only ".cel" files (not + // ".cel.cel") are valid sidecars. + var parentResult = registry.GetSidecarParent(resourceKey); + if (parentResult.IsSuccess) + { + var parentKey = registry.GetResourceKey(parentResult.Value); + var parsed = TryParseSidecarFrontmatter(absolutePath, scanResult.SidecarText); + if (parsed is not null + && parsed.Count > 0) + { + newFrontmatterByResource[parentKey] = parsed; + ApplyFrontmatter(newResourcesByMetaDataField, parentKey, parsed); + frontmatterEntries++; + } + } + } + + TryRecordScannedStamp(absolutePath, resourceKey, isText: true, newCacheStamps); break; } } @@ -158,6 +256,23 @@ public async Task> RebuildAsync() { _referencesBySource[entry.Key] = entry.Value; } + + _frontmatterByResource.Clear(); + foreach (var entry in newFrontmatterByResource) + { + _frontmatterByResource[entry.Key] = entry.Value; + } + _resourcesByMetaDataField.Clear(); + foreach (var entry in newResourcesByMetaDataField) + { + _resourcesByMetaDataField[entry.Key] = entry.Value; + } + + _cacheStamps.Clear(); + foreach (var entry in newCacheStamps) + { + _cacheStamps[entry.Key] = entry.Value; + } } stopwatch.Stop(); @@ -172,16 +287,18 @@ public async Task> RebuildAsync() MarkReady(); - // FrontmatterEntries is always zero because frontmatter scanning is - // not yet implemented on this service. var report = new MetaDataScanReport( FilesScanned: filesScanned, FilesSkipped: filesSkipped, ReferencesFound: referencesFound, - FrontmatterEntries: 0, + FrontmatterEntries: frontmatterEntries, Elapsed: stopwatch.Elapsed); - _logger.LogDebug($"Metadata rebuild complete: {filesScanned} scanned, {filesSkipped} skipped ({transientFailures.Count} transient retries queued), {referencesFound} references in {stopwatch.ElapsedMilliseconds}ms"); + _logger.LogInformation($"Metadata rebuild complete: {filesScanned} scanned, {filesHydratedFromCache} hydrated from cache, {filesSkipped} skipped ({transientFailures.Count} transient retries queued), {referencesFound} references, {frontmatterEntries} sidecars in {stopwatch.ElapsedMilliseconds}ms"); + + // First save after rebuild — schedule for the next worker tick so + // we don't block the project-load path on disk I/O. + MarkDirty(); return Result.Ok(report); } @@ -192,6 +309,380 @@ public async Task> RebuildAsync() } } + // Returns the on-disk cache file path for the current project. Null when + // the workspace has no project folder configured. + private string? GetCacheFilePath() + { + try + { + var projectFolder = _workspaceWrapper.WorkspaceService.ResourceService.Registry.ProjectFolderPath; + if (string.IsNullOrEmpty(projectFolder)) + { + return null; + } + return Path.Combine( + projectFolder, + Celbridge.Projects.ProjectConstants.CelbridgeFolder, + Celbridge.Projects.ProjectConstants.CelbridgeCacheFolder, + Celbridge.Projects.ProjectConstants.MetaDataCacheFileName); + } + catch + { + return null; + } + } + + private MetaDataCacheDocument? LoadCacheDocument(IResourceRegistry registry) + { + var path = GetCacheFilePath(); + if (string.IsNullOrEmpty(path)) + { + return null; + } + return ResourceMetaDataCache.TryLoad(path); + } + + // Looks up the file's cache entry, validates mtime + size, and applies the + // cached references / frontmatter into the new index dictionaries. Returns + // true on a clean hydration; false otherwise (the caller falls back to a + // fresh scan). + private bool TryHydrateFromCache( + MetaDataCacheDocument? cacheDocument, + IResourceRegistry registry, + ResourceKey resourceKey, + string absolutePath, + Dictionary> referencersByTarget, + Dictionary> referencesBySource, + Dictionary> frontmatterByResource, + Dictionary>> resourcesByMetaDataField, + Dictionary cacheStamps, + ref int referencesFound, + ref int frontmatterEntries) + { + if (cacheDocument is null) + { + return false; + } + + if (!cacheDocument.Files.TryGetValue(resourceKey.ToString(), out var entry)) + { + return false; + } + + long mtimeTicks; + long size; + try + { + var fileInfo = new FileInfo(absolutePath); + if (!fileInfo.Exists) + { + return false; + } + mtimeTicks = fileInfo.LastWriteTimeUtc.Ticks; + size = fileInfo.Length; + } + catch + { + return false; + } + + if (entry.MtimeUtcTicks != mtimeTicks + || entry.Size != size) + { + return false; + } + + cacheStamps[resourceKey] = new CacheStamp(mtimeTicks, size, entry.IsText); + + if (!entry.IsText) + { + // Binary entry: stamp only, no index population. + return true; + } + + if (entry.References is { Count: > 0 }) + { + var references = new HashSet(); + foreach (var raw in entry.References) + { + if (ResourceKey.TryCreate(raw, out var key)) + { + references.Add(key); + } + } + if (references.Count > 0) + { + referencesFound += references.Count; + ApplyReferences(referencersByTarget, referencesBySource, resourceKey, references); + } + } + + if (entry.Frontmatter is { Count: > 0 }) + { + // Frontmatter index entries are keyed against the parent resource. + var parentResult = registry.GetSidecarParent(resourceKey); + if (parentResult.IsSuccess) + { + var parentKey = registry.GetResourceKey(parentResult.Value); + var normalised = NormaliseJsonFrontmatter(entry.Frontmatter); + if (normalised.Count > 0) + { + frontmatterByResource[parentKey] = normalised; + ApplyFrontmatter(resourcesByMetaDataField, parentKey, normalised); + frontmatterEntries++; + } + } + } + + return true; + } + + // Walks the cached frontmatter dictionary (deserialised from JSON, so + // numbers come out as JsonElement or boxed long/double) and normalises + // each value into the same CLR shape produced by the live TOML parse. + private static IReadOnlyDictionary NormaliseJsonFrontmatter(IReadOnlyDictionary raw) + { + var normalised = new Dictionary(raw.Count, StringComparer.Ordinal); + foreach (var (key, value) in raw) + { + var converted = NormaliseJsonValue(value); + if (converted is not null) + { + normalised[key] = converted; + } + } + return normalised; + } + + private static object? NormaliseJsonValue(object? value) + { + if (value is null) + { + return null; + } + if (value is JsonElement element) + { + return NormaliseJsonElement(element); + } + return value; + } + + private static object? NormaliseJsonElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + if (element.TryGetInt64(out var l)) + { + return l; + } + if (element.TryGetDouble(out var d)) + { + return d; + } + return null; + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Array: + var list = new List(); + foreach (var item in element.EnumerateArray()) + { + var converted = NormaliseJsonElement(item); + if (converted is not null) + { + list.Add(converted); + } + } + return list; + case JsonValueKind.Object: + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + var converted = NormaliseJsonElement(property.Value); + if (converted is not null) + { + dict[property.Name] = converted; + } + } + return dict; + default: + return null; + } + } + + // Records a stamp for a file that was excluded permanently (binary or + // oversize). Stat failures here are non-fatal; the file simply isn't + // stamped and the next load re-sniffs it. + private static void TryRecordExcludedStamp( + string absolutePath, + ResourceKey resourceKey, + Dictionary stamps) + { + try + { + var info = new FileInfo(absolutePath); + if (info.Exists) + { + stamps[resourceKey] = new CacheStamp(info.LastWriteTimeUtc.Ticks, info.Length, IsText: false); + } + } + catch + { + // No stamp recorded. + } + } + + private static void TryRecordScannedStamp( + string absolutePath, + ResourceKey resourceKey, + bool isText, + Dictionary stamps) + { + try + { + var info = new FileInfo(absolutePath); + if (info.Exists) + { + stamps[resourceKey] = new CacheStamp(info.LastWriteTimeUtc.Ticks, info.Length, isText); + } + } + catch + { + // No stamp recorded. + } + } + + // Marks the in-memory state as ahead of the persisted cache. The worker + // checks this flag periodically and persists when the debounce window has + // elapsed. + private void MarkDirty() + { + _isDirty = true; + } + + // Persists the current in-memory state to the cache file. Skipped when a + // transient-failure retry is queued so the cache never reflects partial + // state. Best-effort: any failure logs a warning and leaves the existing + // cache file untouched. + private void PersistCache() + { + var path = GetCacheFilePath(); + if (string.IsNullOrEmpty(path)) + { + return; + } + + if (!_transientFailureCounts.IsEmpty) + { + // Defer the write until the retry queue empties so the cache + // doesn't snapshot a known-stale partial state. + return; + } + + MetaDataCacheDocument document; + try + { + document = SnapshotForCache(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Metadata cache: failed to snapshot in-memory state"); + return; + } + + var saved = ResourceMetaDataCache.TrySave(path, document); + if (saved) + { + _isDirty = false; + _lastCacheSaveUtc = DateTime.UtcNow; + } + else + { + _logger.LogWarning("Metadata cache: failed to write cache file '{path}'", path); + } + } + + // Builds a cache document from the current in-memory state. The frontmatter + // entries are keyed by the parent resource (matching the in-memory shape); + // the on-disk cache stores them under the sidecar's resource key because + // that's the file the mtime + size stamp refers to. The hydration path + // reverses the mapping via GetSidecarParent. + private MetaDataCacheDocument SnapshotForCache() + { + var files = new Dictionary(); + + IReadOnlyDictionary stampsSnapshot; + IReadOnlyDictionary> referencesSnapshot; + IReadOnlyDictionary> frontmatterSnapshot; + + lock (_indexLock) + { + stampsSnapshot = new Dictionary(_cacheStamps); + referencesSnapshot = _referencesBySource.ToDictionary( + kvp => kvp.Key, + kvp => new HashSet(kvp.Value)); + frontmatterSnapshot = new Dictionary>(_frontmatterByResource); + } + + // Reverse map: sidecar resource key -> parent's frontmatter. We need to + // walk the parent -> frontmatter map and emit the entry against the + // sidecar's key under which the mtime + size stamp lives. + var parentToSidecar = new Dictionary(); + foreach (var parent in frontmatterSnapshot.Keys) + { + if (parent.IsEmpty) + { + continue; + } + var sidecarKey = new ResourceKey(parent.Root + ":" + parent.Path + SidecarHelper.Extension); + parentToSidecar[parent] = sidecarKey; + } + + foreach (var (resourceKey, stamp) in stampsSnapshot) + { + List? referencesList = null; + if (referencesSnapshot.TryGetValue(resourceKey, out var refSet) + && refSet.Count > 0) + { + referencesList = refSet.Select(r => r.ToString()).ToList(); + } + + Dictionary? frontmatterDict = null; + // If this is a sidecar key and its parent has frontmatter, embed + // the frontmatter in this entry so reload hydrates both stamp and + // index from the same record. + if (IsSidecarKey(resourceKey)) + { + var parent = StripSidecarSuffix(resourceKey); + if (parent.HasValue + && frontmatterSnapshot.TryGetValue(parent.Value, out var fm) + && fm.Count > 0) + { + frontmatterDict = new Dictionary(fm, StringComparer.Ordinal); + } + } + + files[resourceKey.ToString()] = new MetaDataCacheEntry + { + MtimeUtcTicks = stamp.MtimeUtcTicks, + Size = stamp.Size, + IsText = stamp.IsText, + References = referencesList, + Frontmatter = frontmatterDict, + }; + } + + return new MetaDataCacheDocument + { + Version = ResourceMetaDataCache.CurrentVersion, + Files = files, + }; + } + public IReadOnlyList GetReferencers(ResourceKey target) { lock (_indexLock) @@ -226,42 +717,166 @@ public IReadOnlyList GetAllReferencedTargets() public Result> GetFrontmatter(ResourceKey resource) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + lock (_indexLock) + { + if (!_frontmatterByResource.TryGetValue(resource, out var frontmatter)) + { + return Result>.Fail( + $"No frontmatter is indexed for resource '{resource}'. The resource may have no sidecar or its sidecar may be broken."); + } + // Return a snapshot copy so callers cannot mutate our state. + var snapshot = new Dictionary(frontmatter, StringComparer.Ordinal); + return Result>.Ok(snapshot); + } } - public Task SetFrontmatterFieldAsync(ResourceKey resource, string field, object value) + public async Task SetFrontmatterFieldAsync(ResourceKey resource, string field, object value) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + if (string.IsNullOrEmpty(field)) + { + return Result.Fail("The 'field' argument must be a non-empty string."); + } + + if (!IsIndexableValue(value)) + { + return Result.Fail($"Field '{field}' value is not indexable. Only scalar (string/number/bool) and list-of-scalar values are supported."); + } + + return await MutateSidecarFrontmatterAsync(resource, mutate: dict => dict[field] = value); } - public Task RemoveFrontmatterFieldAsync(ResourceKey resource, string field) + public async Task RemoveFrontmatterFieldAsync(ResourceKey resource, string field) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + if (string.IsNullOrEmpty(field)) + { + return Result.Fail("The 'field' argument must be a non-empty string."); + } + + return await MutateSidecarFrontmatterAsync( + resource, + mutate: dict => { dict.Remove(field); }, + createSidecarIfMissing: false); } public IReadOnlyList FindByMetaData(string field, object value) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + if (string.IsNullOrEmpty(field) || value is null) + { + return Array.Empty(); + } + + lock (_indexLock) + { + if (!_resourcesByMetaDataField.TryGetValue(field, out var byValue)) + { + return Array.Empty(); + } + + // Normalise the query value into the same canonical form used when + // populating the index so an int query against a long-typed scalar + // still finds the entry. + var canonical = CanonicaliseScalar(value); + if (canonical is null) + { + return Array.Empty(); + } + + if (!byValue.TryGetValue(canonical, out var resources)) + { + return Array.Empty(); + } + + return resources.ToList(); + } } public IReadOnlyList GetTags(ResourceKey resource) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + lock (_indexLock) + { + if (!_frontmatterByResource.TryGetValue(resource, out var frontmatter)) + { + return Array.Empty(); + } + + if (!frontmatter.TryGetValue(TagsField, out var tagsValue)) + { + return Array.Empty(); + } + + return ExtractStringList(tagsValue); + } } - public Task AddTagAsync(ResourceKey resource, string tag) + public async Task AddTagAsync(ResourceKey resource, string tag) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + if (string.IsNullOrEmpty(tag)) + { + return Result.Fail("Tag value must be a non-empty string."); + } + + return await MutateSidecarFrontmatterAsync(resource, mutate: dict => + { + var existing = dict.TryGetValue(TagsField, out var value) + ? ExtractStringList(value) + : Array.Empty(); + + if (existing.Contains(tag, StringComparer.Ordinal)) + { + // Idempotent: no change needed. + return; + } + + var updated = new List(existing.Count + 1); + updated.AddRange(existing); + updated.Add(tag); + dict[TagsField] = updated; + }); } - public Task RemoveTagAsync(ResourceKey resource, string tag) + public async Task RemoveTagAsync(ResourceKey resource, string tag) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + if (string.IsNullOrEmpty(tag)) + { + return Result.Fail("Tag value must be a non-empty string."); + } + + return await MutateSidecarFrontmatterAsync( + resource, + mutate: dict => + { + if (!dict.TryGetValue(TagsField, out var value)) + { + return; + } + + var existing = ExtractStringList(value); + if (!existing.Contains(tag, StringComparer.Ordinal)) + { + return; + } + + var updated = existing.Where(t => !string.Equals(t, tag, StringComparison.Ordinal)).ToList(); + if (updated.Count == 0) + { + // Drop the field entirely when the list goes empty rather + // than leaving an empty array in the file. + dict.Remove(TagsField); + } + else + { + dict[TagsField] = updated; + } + }, + createSidecarIfMissing: false); } public IReadOnlyList FindByTag(string tag) { - throw new NotImplementedException("The frontmatter index is not yet implemented."); + // FindByTag is a thin alias for FindByMetaData against the standardised + // tags field; the inverted index already records list-of-scalar fields + // element-wise, so a tag query is just a value lookup. + return FindByMetaData(TagsField, tag); } // Walks file text for "project:" candidate references. Returns the unique @@ -311,7 +926,9 @@ private enum ScanOutcome TransientFailure, } - private record FileScanResult(ScanOutcome Outcome, HashSet References); + // SidecarText is populated for .cel files so the caller can parse the + // frontmatter without re-reading the bytes; null for non-sidecar paths. + private record FileScanResult(ScanOutcome Outcome, HashSet References, string? SidecarText = null); private static readonly HashSet EmptyReferenceSet = new(); @@ -381,7 +998,29 @@ private async Task ScanTextFileAsync(ResourceKey resourceKey, st } var references = ScanTextForReferences(text); - return new FileScanResult(ScanOutcome.Indexed, references); + + // Capture the file content for sidecar files so the caller can parse + // the frontmatter without a second disk read. Non-sidecar files leave + // SidecarText null. + var sidecarText = IsSidecarPath(absolutePath) ? text : null; + return new FileScanResult(ScanOutcome.Indexed, references, sidecarText); + } + + // True when the path's filename ends in ".cel" but not ".cel.cel". The + // ".cel.cel" form is reserved as the invalid-sidecar marker per + // file_metadata_sidecars.md and is never paired with a parent. + private static bool IsSidecarPath(string absolutePath) + { + var fileName = Path.GetFileName(absolutePath); + if (!fileName.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (fileName.EndsWith(SidecarHelper.Extension + SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + return true; } // IOException covers file-locked, sharing-violation, network-share blip; @@ -393,6 +1032,365 @@ private static bool IsTransientIoFailure(Exception ex) || ex is UnauthorizedAccessException; } + // Returns a normalised top-level frontmatter dictionary suitable for the + // index, or null when the sidecar bytes cannot be parsed. The TOML model + // values from Tomlyn are normalised via NormaliseTomlValue so the index + // entries stay consistent across reload paths (cache hydration, fresh + // parse) and the equality semantics of the dictionary keys are + // case-sensitive ordinal. + private IReadOnlyDictionary? TryParseSidecarFrontmatter(string absolutePath, string? text) + { + if (text is null) + { + return null; + } + + var parseResult = SidecarHelper.Parse(text); + if (parseResult.IsFailure) + { + _logger.LogWarning($"metadata scan: sidecar at '{absolutePath}' has unparseable frontmatter and will not be indexed."); + return null; + } + + var raw = parseResult.Value.Frontmatter; + if (raw.Count == 0) + { + return null; + } + + var normalised = new Dictionary(raw.Count, StringComparer.Ordinal); + foreach (var (key, value) in raw) + { + var converted = NormaliseTomlValue(value); + if (converted is null) + { + continue; + } + normalised[key] = converted; + } + + return normalised.Count == 0 ? null : normalised; + } + + // Converts a Tomlyn model value into the plain CLR shapes the index + // expects: strings stay strings, TomlArray becomes List, TomlTable + // becomes Dictionary. Scalars come out as their underlying + // CLR type (long, double, bool, DateTime, etc.). Returns null only for + // truly unrepresentable input. + private static object? NormaliseTomlValue(object? value) + { + switch (value) + { + case null: + return null; + case TomlTable table: + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (k, v) in table) + { + var converted = NormaliseTomlValue(v); + if (converted is null) + { + continue; + } + dict[k] = converted; + } + return dict; + case TomlArray array: + var list = new List(array.Count); + foreach (var item in array) + { + var converted = NormaliseTomlValue(item); + if (converted is null) + { + continue; + } + list.Add(converted); + } + return list; + default: + return value; + } + } + + // Populates the inverted index for one resource's frontmatter. Scalar + // fields contribute their value as a single index entry; list-of-scalar + // fields contribute each element. Non-indexable shapes (nested tables, + // arrays of arrays) are stored in _frontmatterByResource but not indexed. + private static void ApplyFrontmatter( + Dictionary>> resourcesByMetaDataField, + ResourceKey resource, + IReadOnlyDictionary frontmatter) + { + foreach (var (field, value) in frontmatter) + { + foreach (var indexedValue in EnumerateIndexValues(value)) + { + if (!resourcesByMetaDataField.TryGetValue(field, out var byValue)) + { + byValue = new Dictionary>(); + resourcesByMetaDataField[field] = byValue; + } + if (!byValue.TryGetValue(indexedValue, out var set)) + { + set = new HashSet(); + byValue[indexedValue] = set; + } + set.Add(resource); + } + } + } + + // Yields the values to index for a given frontmatter field. Scalars yield + // themselves (canonicalised); list-of-scalar yields each canonicalised + // element. Nested objects and lists-of-non-scalars yield nothing — they + // remain available via GetFrontmatter but not via FindByMetaData. + private static IEnumerable EnumerateIndexValues(object value) + { + if (value is IReadOnlyList objectList + && value is not string) + { + foreach (var item in objectList) + { + if (item is null) + { + continue; + } + var canonical = CanonicaliseScalar(item); + if (canonical is not null) + { + yield return canonical; + } + } + yield break; + } + + if (value is System.Collections.IEnumerable enumerable + && value is not string) + { + foreach (var item in enumerable) + { + if (item is null) + { + continue; + } + var canonical = CanonicaliseScalar(item); + if (canonical is not null) + { + yield return canonical; + } + } + yield break; + } + + var scalar = CanonicaliseScalar(value); + if (scalar is not null) + { + yield return scalar; + } + } + + // True when the value is a shape we can serialise back to TOML and index: + // strings, numeric scalars, booleans, datetimes, and lists of those. The + // service rejects nested-object frontmatter writes at the mutation surface + // so callers get a clear error rather than a silent drop. + private static bool IsIndexableValue(object? value) + { + if (value is null) + { + return false; + } + if (IsScalar(value)) + { + return true; + } + if (value is IReadOnlyList objectList + && value is not string) + { + return objectList.All(item => item is not null && IsScalar(item)); + } + if (value is System.Collections.IEnumerable enumerable + && value is not string) + { + foreach (var item in enumerable) + { + if (item is null + || !IsScalar(item)) + { + return false; + } + } + return true; + } + return false; + } + + private static bool IsScalar(object value) + { + return value is string + || value is bool + || value is long + || value is int + || value is double + || value is float + || value is decimal + || value is DateTime + || value is DateTimeOffset + || value is DateOnly + || value is TimeOnly; + } + + // Normalises a scalar into the form used as a dictionary key in the + // inverted index, so a long-typed cached value and an int-typed query + // value still compare equal. Returns null for unrepresentable input. + private static object? CanonicaliseScalar(object? value) + { + switch (value) + { + case null: + return null; + case string s: + return s; + case bool b: + return b; + case int i: + return (long)i; + case long l: + return l; + case short sh: + return (long)sh; + case byte by: + return (long)by; + case sbyte sb: + return (long)sb; + case uint ui: + return (long)ui; + case ulong ul: + return (long)ul; + case float f: + return (double)f; + case double d: + return d; + case decimal dec: + return (double)dec; + case DateTime dt: + return dt.ToUniversalTime(); + case DateTimeOffset dto: + return dto.UtcDateTime; + default: + return null; + } + } + + // Returns the value as a list of strings when possible (a TOML "tags" + // array contributes a list of strings); empty otherwise. + private static IReadOnlyList ExtractStringList(object value) + { + var result = new List(); + if (value is string) + { + return result; + } + if (value is System.Collections.IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item is string s) + { + result.Add(s); + } + } + } + return result; + } + + // Reads the resource's sidecar (creating it if missing), applies the + // mutation to a working copy of the frontmatter dictionary, and writes the + // result back through IResourceFileSystem so atomic-write + watcher event + // semantics apply. Returns success even when the mutation is a no-op. + private async Task MutateSidecarFrontmatterAsync( + ResourceKey resource, + Action> mutate, + bool createSidecarIfMissing = true) + { + if (resource.IsEmpty) + { + return Result.Fail("Cannot set frontmatter on an empty resource key."); + } + + var sidecarKey = new ResourceKey(resource.Root + ":" + resource.Path + SidecarHelper.Extension); + + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var existsResult = await fileSystem.ExistsAsync(sidecarKey); + if (existsResult.IsFailure) + { + return Result.Fail($"Failed to check sidecar existence for resource '{resource}'.") + .WithErrors(existsResult); + } + + Dictionary working; + string body = string.Empty; + + if (existsResult.Value) + { + var readResult = await fileSystem.ReadAllTextAsync(sidecarKey); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read sidecar '{sidecarKey}'.") + .WithErrors(readResult); + } + + var parseResult = SidecarHelper.Parse(readResult.Value); + if (parseResult.IsFailure) + { + return Result.Fail($"Cannot mutate sidecar '{sidecarKey}': frontmatter does not parse.") + .WithErrors(parseResult); + } + working = new Dictionary(parseResult.Value.Frontmatter, StringComparer.Ordinal); + body = parseResult.Value.Body; + } + else + { + if (!createSidecarIfMissing) + { + // Removing a field from a non-existent sidecar is a no-op success. + return Result.Ok(); + } + working = new Dictionary(StringComparer.Ordinal); + } + + mutate(working); + + var composed = SidecarHelper.Compose(working, body); + var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, composed); + if (writeResult.IsFailure) + { + return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") + .WithErrors(writeResult); + } + + // The file watcher delivers ResourceChangedMessage asynchronously + // through the UI dispatcher, so by the time this method returns the + // background worker has not yet seen the write. Apply the index + // update synchronously here so the caller's next read sees the new + // state. The watcher's eventual rescan re-applies the same parsed + // frontmatter against the in-memory dictionaries; that pass is + // idempotent. + var parsedForIndex = TryParseSidecarFrontmatter("", composed); + UpdateFrontmatterInIndexes(resource, parsedForIndex); + + // Refresh the cache stamp for the sidecar file so a subsequent + // workspace load can hydrate from the cache instead of rescanning. + // Stat failures here are absorbed by UpdateCacheStamp's catch. + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveSidecar = registry.ResolveResourcePath(sidecarKey); + if (resolveSidecar.IsSuccess) + { + UpdateCacheStamp(sidecarKey, resolveSidecar.Value, isText: true); + } + + return Result.Ok(); + } + private static void ApplyReferences( Dictionary> referencersByTarget, Dictionary> referencesBySource, @@ -437,7 +1435,76 @@ private void RemoveSourceFromIndexes(ResourceKey source) } _referencesBySource.Remove(source); } + _cacheStamps.Remove(source); + } + MarkDirty(); + } + + // Strips a parent resource's frontmatter from both the per-resource snapshot + // and the inverted index. Called when a sidecar disappears or its content + // becomes unparseable. The argument is the parent key (e.g. "foo.png"), not + // the sidecar key. + private void RemoveFrontmatterFromIndexes(ResourceKey parentResource) + { + bool changed = false; + lock (_indexLock) + { + if (!_frontmatterByResource.TryGetValue(parentResource, out var existing)) + { + return; + } + _frontmatterByResource.Remove(parentResource); + changed = true; + + foreach (var (field, value) in existing) + { + if (!_resourcesByMetaDataField.TryGetValue(field, out var byValue)) + { + continue; + } + foreach (var indexedValue in EnumerateIndexValues(value)) + { + if (!byValue.TryGetValue(indexedValue, out var set)) + { + continue; + } + set.Remove(parentResource); + if (set.Count == 0) + { + byValue.Remove(indexedValue); + } + } + if (byValue.Count == 0) + { + _resourcesByMetaDataField.Remove(field); + } + } + } + if (changed) + { + MarkDirty(); + } + } + + // Replaces the frontmatter snapshot and inverted-index entries for one + // parent resource. Empty/null frontmatter behaves as a removal so the + // caller doesn't have to branch when a sidecar transitions to broken. + private void UpdateFrontmatterInIndexes(ResourceKey parentResource, IReadOnlyDictionary? frontmatter) + { + RemoveFrontmatterFromIndexes(parentResource); + + if (frontmatter is null + || frontmatter.Count == 0) + { + return; + } + + lock (_indexLock) + { + _frontmatterByResource[parentResource] = frontmatter; + ApplyFrontmatter(_resourcesByMetaDataField, parentResource, frontmatter); } + MarkDirty(); } private void UpdateSourceInIndexes(ResourceKey source, HashSet references) @@ -464,20 +1531,48 @@ private void UpdateSourceInIndexes(ResourceKey source, HashSet refe if (references.Count == 0) { _referencesBySource.Remove(source); - return; } + else + { + _referencesBySource[source] = new HashSet(references); + foreach (var target in references) + { + if (!_referencersByTarget.TryGetValue(target, out var targetSet)) + { + targetSet = new HashSet(); + _referencersByTarget[target] = targetSet; + } + targetSet.Add(source); + } + } + } + MarkDirty(); + } - _referencesBySource[source] = new HashSet(references); - foreach (var target in references) + // Records a per-file stamp captured after a successful incremental scan. + private void UpdateCacheStamp(ResourceKey resource, string absolutePath, bool isText) + { + try + { + var info = new FileInfo(absolutePath); + if (!info.Exists) { - if (!_referencersByTarget.TryGetValue(target, out var targetSet)) + lock (_indexLock) { - targetSet = new HashSet(); - _referencersByTarget[target] = targetSet; + _cacheStamps.Remove(resource); } - targetSet.Add(source); + return; + } + var stamp = new CacheStamp(info.LastWriteTimeUtc.Ticks, info.Length, isText); + lock (_indexLock) + { + _cacheStamps[resource] = stamp; } } + catch + { + // Best-effort; the next watcher event will re-stamp. + } } private void OnResourceCreated(object recipient, ResourceCreatedMessage message) @@ -503,7 +1598,54 @@ private void OnResourceDeleted(object recipient, ResourceDeletedMessage message) { return; } + + // Atomic temp + rename writes (ResourceFileSystem.WriteAtomicAsync) + // briefly remove the destination during File.Move(overwrite: true), + // which fires a FileSystemWatcher delete event immediately followed + // by a create event. By the time the dispatcher delivers the delete + // here the file is back on disk; clearing the index would clobber + // the synchronous entry MutateSidecarFrontmatterAsync just installed. + // The companion create event still triggers a rescan, which would + // re-establish the entry — but list / get / find calls landing in + // the window between the spurious delete and the rescan would see + // empty results. Skipping the removal when the file is still on + // disk closes that window. A genuine deletion has the file gone by + // the time the event arrives, so File.Exists is false and we fall + // through to the original removal logic. + try + { + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = registry.ResolveResourcePath(message.Resource); + if (resolveResult.IsSuccess + && File.Exists(resolveResult.Value)) + { + return; + } + } + catch + { + // If resolution itself fails, fall through to the conservative + // removal so a true deletion still clears the index. + } + RemoveSourceFromIndexes(message.Resource); + + // A deleted parent file drops its own frontmatter entry; a deleted + // sidecar drops the entry under its parent key. The sidecar cascade + // path runs both events, so handle both shapes here. + if (IsSidecarKey(message.Resource)) + { + var parentKey = StripSidecarSuffix(message.Resource); + if (parentKey.HasValue) + { + RemoveFrontmatterFromIndexes(parentKey.Value); + } + } + else + { + RemoveFrontmatterFromIndexes(message.Resource); + } + _transientFailureCounts.TryRemove(message.Resource, out _); } @@ -512,12 +1654,79 @@ private void OnResourceRenamed(object recipient, ResourceRenamedMessage message) if (message.OldResource.Root == ResourceKey.DefaultRoot) { RemoveSourceFromIndexes(message.OldResource); + + if (IsSidecarKey(message.OldResource)) + { + var oldParent = StripSidecarSuffix(message.OldResource); + if (oldParent.HasValue) + { + RemoveFrontmatterFromIndexes(oldParent.Value); + } + } + else + { + RemoveFrontmatterFromIndexes(message.OldResource); + } + _transientFailureCounts.TryRemove(message.OldResource, out _); } _transientFailureCounts.TryRemove(message.NewResource, out _); QueueRescan(message.NewResource); } + // True when the key's path ends in ".cel" but not ".cel.cel". Mirrors the + // file-path test used during the rebuild scan. + private static bool IsSidecarKey(ResourceKey key) + { + if (key.IsEmpty) + { + return false; + } + var path = key.Path; + if (!path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + if (path.EndsWith(SidecarHelper.Extension + SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + return true; + } + + // Strips the trailing ".cel" from a sidecar key to recover its parent key. + // Returns null when the result would be empty (e.g. a hypothetical bare + // ".cel" key with no path component). + private static ResourceKey? StripSidecarSuffix(ResourceKey sidecarKey) + { + var path = sidecarKey.Path; + if (!path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + var parentPath = path.Substring(0, path.Length - SidecarHelper.Extension.Length); + if (string.IsNullOrEmpty(parentPath)) + { + return null; + } + return new ResourceKey(sidecarKey.Root + ":" + parentPath); + } + + // Helper used by the rescan paths to drop frontmatter entries when a + // sidecar key disappears or stops parsing. No-op for non-sidecar keys. + private void MaybeDropFrontmatterForSidecar(ResourceKey resource) + { + if (!IsSidecarKey(resource)) + { + return; + } + var parentKey = StripSidecarSuffix(resource); + if (parentKey.HasValue) + { + RemoveFrontmatterFromIndexes(parentKey.Value); + } + } + private void QueueRescan(ResourceKey resource) { // Only project: resources contribute to the index. Watcher messages from @@ -569,6 +1778,15 @@ private async Task WorkerLoopAsync() await ProcessRescanAsync(resource); } + + // Debounced cache write: persist once the queue is empty and the + // last save was long enough ago that we won't thrash. Skipping when + // _isDirty is false avoids re-saving an already-current snapshot. + if (_isDirty + && (DateTime.UtcNow - _lastCacheSaveUtc) >= MinCacheSaveInterval) + { + PersistCache(); + } } } @@ -586,6 +1804,7 @@ private async Task ProcessRescanAsync(ResourceKey resource) if (resolveResult.IsFailure) { RemoveSourceFromIndexes(resource); + MaybeDropFrontmatterForSidecar(resource); _transientFailureCounts.TryRemove(resource, out _); return; } @@ -594,6 +1813,7 @@ private async Task ProcessRescanAsync(ResourceKey resource) if (!File.Exists(absolutePath)) { RemoveSourceFromIndexes(resource); + MaybeDropFrontmatterForSidecar(resource); _transientFailureCounts.TryRemove(resource, out _); return; } @@ -603,11 +1823,42 @@ private async Task ProcessRescanAsync(ResourceKey resource) { case ScanOutcome.Indexed: UpdateSourceInIndexes(resource, scanResult.References); + if (IsSidecarPath(absolutePath)) + { + // Derive the parent key by stripping the .cel suffix + // rather than querying the registry's pairing table. + // The pairing table is refreshed only by + // UpdateResourceRegistry, which lags watcher events + // for newly-created sidecars; relying on it here + // would race with the synchronous index update in + // MutateSidecarFrontmatterAsync. Parent existence is + // checked on disk so a true orphan still drops the + // index entry. + var parentKey = StripSidecarSuffix(resource); + if (parentKey.HasValue) + { + var parentResolve = registry.ResolveResourcePath(parentKey.Value); + var parentExists = parentResolve.IsSuccess + && File.Exists(parentResolve.Value); + if (parentExists) + { + var parsed = TryParseSidecarFrontmatter(absolutePath, scanResult.SidecarText); + UpdateFrontmatterInIndexes(parentKey.Value, parsed); + } + else + { + RemoveFrontmatterFromIndexes(parentKey.Value); + } + } + } + UpdateCacheStamp(resource, absolutePath, isText: true); _transientFailureCounts.TryRemove(resource, out _); break; case ScanOutcome.ExcludedPermanently: RemoveSourceFromIndexes(resource); + MaybeDropFrontmatterForSidecar(resource); + UpdateCacheStamp(resource, absolutePath, isText: false); _transientFailureCounts.TryRemove(resource, out _); break; @@ -668,6 +1919,22 @@ public void Dispose() _messengerService.UnregisterAll(this); + // Persist the cache before signalling shutdown so the in-memory state + // survives the next workspace load. Best-effort: a failure here is + // logged inside PersistCache and the next load falls back to a full + // rebuild. + if (_isDirty) + { + try + { + PersistCache(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Metadata cache: failed to persist on dispose"); + } + } + // Signal the worker to exit, then nudge the semaphore so it observes the // flag and returns. The worker checks _isShuttingDown after every wake. _isShuttingDown = true; diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs new file mode 100644 index 000000000..ef0cb20db --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Celbridge.Resources.Services; + +/// +/// On-disk JSON shape for the resource-metadata cache. Mirrors the in-memory +/// reference graph and frontmatter index entries plus the per-file mtime + size +/// stamp used to validate cache entries on load. Version is bumped whenever the +/// shape changes incompatibly so older caches discard cleanly on first read. +/// +internal record MetaDataCacheDocument +{ + [JsonPropertyName("version")] + public int Version { get; init; } + + [JsonPropertyName("files")] + public Dictionary Files { get; init; } = new(); +} + +/// +/// One entry per indexed file. References / Frontmatter / IsText are optional +/// so a binary entry can be a stat-only record and a sidecar entry can carry +/// frontmatter without forcing the reference list to be present. +/// +internal record MetaDataCacheEntry +{ + [JsonPropertyName("mtimeUtcTicks")] + public long MtimeUtcTicks { get; init; } + + [JsonPropertyName("size")] + public long Size { get; init; } + + [JsonPropertyName("isText")] + public bool IsText { get; init; } + + [JsonPropertyName("references")] + public List? References { get; init; } + + [JsonPropertyName("frontmatter")] + public Dictionary? Frontmatter { get; init; } +} + +/// +/// Read/write logic for the .celbridge/cache/metadata.json file. The file is +/// host-private and does not flow through IResourceFileSystem — the FS layer's +/// structural operations depend on the metadata service, so routing the cache +/// through that layer would introduce a circular dependency. +/// +internal static class ResourceMetaDataCache +{ + /// + /// Cache format version. Bump whenever the on-disk shape changes such that + /// older readers cannot parse newer files (or vice versa). Mismatched + /// versions are discarded silently and trigger a full rebuild. + /// + public const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNameCaseInsensitive = false, + }; + + /// + /// Loads the cache document at the given path. Returns null on any failure + /// (missing file, malformed JSON, version mismatch) so the caller can fall + /// back to a full rebuild without distinguishing the failure cause. + /// + public static MetaDataCacheDocument? TryLoad(string cacheFilePath) + { + if (!File.Exists(cacheFilePath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(cacheFilePath); + var document = JsonSerializer.Deserialize(stream, JsonOptions); + if (document is null + || document.Version != CurrentVersion) + { + return null; + } + return document; + } + catch + { + // Any read or parse failure is treated as a missing cache. The + // service is correct without the cache; falling back to a full + // rebuild is always safe. + return null; + } + } + + /// + /// Writes the cache document atomically via temp + move. Best-effort; a + /// crash or IO failure leaves the cache untouched and the next load runs + /// the full rebuild. Returns true on success, false otherwise. + /// + public static bool TrySave(string cacheFilePath, MetaDataCacheDocument document) + { + try + { + var folder = Path.GetDirectoryName(cacheFilePath); + if (!string.IsNullOrEmpty(folder) + && !Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + var tempPath = cacheFilePath + ".tmp"; + using (var stream = File.Create(tempPath)) + { + JsonSerializer.Serialize(stream, document, JsonOptions); + } + File.Move(tempPath, cacheFilePath, overwrite: true); + return true; + } + catch + { + return false; + } + } +} diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs index d726219d8..d88fa09f3 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs @@ -2,6 +2,7 @@ using Celbridge.Console; using Celbridge.Logging; using Celbridge.Projects; +using Celbridge.Resources; using Celbridge.Server; using Celbridge.Settings; using Celbridge.UserInterface; @@ -139,6 +140,12 @@ public async Task LoadWorkspaceAsync() { _logger.LogWarning(rebuildResult, "Failed to rebuild resource metadata index"); } + + // Fire-and-forget the project-health check so banner-worthy findings + // surface in the host log without blocking workspace load. The + // command awaits the metadata index internally and then walks the + // reference graph; on a clean project the result is empty. + _ = Task.Run(() => RunProjectCheckAsync()); } catch (Exception ex) { @@ -231,6 +238,46 @@ public async Task LoadWorkspaceAsync() return Result.Ok(); } + // Runs metadata_check_project in the background and writes a one-line + // summary per non-empty category to the host log. The check is read-only + // and does not repair anything; surfacing the issues here lets the user + // notice them without having to invoke the MCP tool by hand. + private async Task RunProjectCheckAsync() + { + try + { + var commandService = ServiceLocator.AcquireService(); + var reportResult = await commandService.ExecuteAsync(); + if (reportResult.IsFailure) + { + _logger.LogWarning(reportResult, "Project consistency check failed."); + return; + } + var report = reportResult.Value; + + if (report.BrokenReferences.Count > 0) + { + _logger.LogWarning( + $"Project consistency check: {report.BrokenReferences.Count} broken project: reference(s). Run metadata_check_project for the full list."); + } + if (report.OrphanSidecars.Count > 0) + { + _logger.LogWarning( + $"Project consistency check: {report.OrphanSidecars.Count} orphan sidecar(s). Run metadata_check_project for the full list."); + } + if (report.BrokenSidecars.Count > 0) + { + _logger.LogWarning( + $"Project consistency check: {report.BrokenSidecars.Count} broken sidecar(s). Run metadata_check_project for the full list."); + } + } + catch (Exception ex) + { + // Never let the background check tear down the workspace load path. + _logger.LogWarning(ex, "Project consistency check threw an unexpected exception."); + } + } + private async Task TryInitializePythonAsync(IWorkspaceService workspaceService) { var projectService = ServiceLocator.AcquireService(); From 23310274165f3851fef77d04be83f014a3a295e0 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 22 May 2026 17:58:51 +0100 Subject: [PATCH 14/48] Phase 5.5: Add data APIs and sidecar services Introduce a new data surface for .cel sidecar handling: adds on-demand IResourceScanner, ISidecarService, and many ICommand interfaces for reading/writing fields, tags and named blocks (add/find/get/set/remove/read/write). Replace the older metadata-focused guides and tools with the data namespace and move tool implementations accordingly. Update IWorkspaceService to expose ResourceScanner and SidecarService (remove the prior metadata persistence surface), add ProjectCheckError and console strings for reporting findings, and flip feature flags for note-editor and mcp-tools. Also remove legacy metadata cache/constants and adjust tests, workspace commands, and service implementations to match the new data/sidecar API. --- .../Resources/Strings/en-US/Resources.resw | 8 +- .../Console/ConsoleMessages.cs | 7 + .../Projects/ProjectConstants.cs | 13 - .../Resources/IAddTagCommand.cs | 21 + .../Resources/IFindTagCommand.cs | 17 + .../Resources/IGetFieldCommand.cs | 22 + .../Resources/IGetInfoCommand.cs | 33 + .../Resources/IProjectCheckCommand.cs | 2 +- .../Resources/IReadBlockCommand.cs | 22 + .../Resources/IRemoveBlockCommand.cs | 21 + .../Resources/IRemoveFieldCommand.cs | 21 + .../Resources/IRemoveTagCommand.cs | 21 + .../Resources/IResourceFileSystem.cs | 2 +- .../Resources/IResourceMetaData.cs | 114 - .../Resources/IResourceRegistry.cs | 4 +- .../Resources/IResourceScanner.cs | 36 + .../Resources/ISetFieldCommand.cs | 29 + .../Resources/ISidecarService.cs | 111 + .../Resources/IWriteBlockCommand.cs | 29 + .../Workspace/IWorkspaceService.cs | 12 +- .../Celbridge.Tools/Guides/Namespaces/data.md | 80 + .../Guides/Namespaces/metadata.md | 47 - .../Guides/Tools/data_add_tag.md | 5 + .../Guides/Tools/data_check_project.md | 21 + .../Guides/Tools/data_find_tag.md | 7 + .../Guides/Tools/data_get_field.md | 5 + .../Guides/Tools/data_get_info.md | 23 + .../Guides/Tools/data_read_block.md | 7 + .../Guides/Tools/data_remove_block.md | 3 + .../Guides/Tools/data_remove_field.md | 3 + .../Guides/Tools/data_remove_tag.md | 3 + .../Guides/Tools/data_set_field.md | 7 + .../Guides/Tools/data_write_block.md | 5 + .../Guides/Tools/explorer_move.md | 2 +- .../Guides/Tools/metadata_add_tag.md | 23 - .../Guides/Tools/metadata_check_project.md | 39 - .../Guides/Tools/metadata_find.md | 24 - .../Guides/Tools/metadata_get.md | 18 - .../Guides/Tools/metadata_list.md | 19 - .../Guides/Tools/metadata_remove.md | 21 - .../Guides/Tools/metadata_remove_tag.md | 22 - .../Guides/Tools/metadata_set.md | 25 - .../DataTools.AddTag.cs} | 27 +- .../DataTools.CheckProject.cs} | 6 +- .../Tools/Data/DataTools.FindTag.cs | 31 + .../Tools/Data/DataTools.GetField.cs | 40 + .../Tools/Data/DataTools.GetInfo.cs | 47 + .../Tools/Data/DataTools.ReadBlock.cs | 41 + .../Tools/Data/DataTools.RemoveBlock.cs | 41 + .../Tools/Data/DataTools.RemoveField.cs | 40 + .../DataTools.RemoveTag.cs} | 27 +- .../Tools/Data/DataTools.SetField.cs | 54 + .../Tools/Data/DataTools.WriteBlock.cs | 43 + .../MetaDataTools.cs => Data/DataTools.cs} | 42 +- .../Tools/Explorer/ExplorerTools.Delete.cs | 2 +- .../Tools/File/FileTools.Write.cs | 2 +- .../Tools/File/FileTools.WriteBinary.cs | 2 +- .../Tools/MetaData/MetaDataTools.Find.cs | 44 - .../Tools/MetaData/MetaDataTools.Get.cs | 42 - .../Tools/MetaData/MetaDataTools.List.cs | 35 - .../Tools/MetaData/MetaDataTools.Remove.cs | 37 - .../Tools/MetaData/MetaDataTools.Set.cs | 46 - ...ojectTests.cs => DataCheckProjectTests.cs} | 49 +- .../Resources/ResourceFileSystemTests.cs | 25 +- .../ResourceMetaDataPersistenceTests.cs | 270 --- .../Tests/Resources/ResourceMetaDataTests.cs | 609 ----- .../Resources/SidecarClassificationTests.cs | 37 +- Source/Tests/Resources/SidecarHelperTests.cs | 191 +- .../Tests/Resources/SidecarTrackingTests.cs | 14 +- .../ViewModels/ConsolePanelViewModel.cs | 26 + .../Celbridge.Console/Views/ConsolePanel.xaml | 12 +- .../Python/celbridge-0.1.0-py3-none-any.whl | Bin 43053 -> 43216 bytes .../celbridge/src/celbridge/cel_proxy.py | 1 + .../celbridge/integration_tests/conftest.py | 4 +- .../celbridge/integration_tests/test_data.py | 246 +++ .../integration_tests/test_explorer.py | 10 +- .../integration_tests/test_metadata.py | 228 -- .../Commands/AddTagCommand.cs | 48 + .../Commands/DeleteResourceCommand.cs | 23 +- .../Commands/FindTagCommand.cs | 36 + .../Commands/GetFieldCommand.cs | 58 + .../Commands/GetInfoCommand.cs | 59 + .../Commands/ProjectCheckCommand.cs | 17 +- .../Commands/ReadBlockCommand.cs | 59 + .../Commands/RemoveBlockCommand.cs | 42 + .../Commands/RemoveFieldCommand.cs | 31 + .../Commands/RemoveTagCommand.cs | 55 + .../Commands/SetFieldCommand.cs | 44 + .../Commands/WriteBlockCommand.cs | 55 + .../Helpers/SidecarHelper.cs | 444 ++-- .../ServiceConfiguration.cs | 15 +- .../Services/ReferenceLiteralRules.cs | 2 +- .../Services/ResourceFileSystem.cs | 70 +- .../Services/ResourceMetaData.cs | 1965 ----------------- .../Services/ResourceMetaDataCache.cs | 126 -- .../Services/ResourceOperationService.cs | 2 +- .../Services/ResourceScanner.cs | 360 +++ .../Services/SidecarService.cs | 191 ++ .../ServiceConfiguration.cs | 1 + .../Services/ProjectCheckReporter.cs | 109 + .../Services/WorkspaceLoader.cs | 46 +- .../Services/WorkspaceService.cs | 7 +- 102 files changed, 3003 insertions(+), 4187 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/IAddTagCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IFindTagCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IGetFieldCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IGetInfoCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IReadBlockCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IRemoveBlockCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IRemoveFieldCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IRemoveTagCommand.cs delete mode 100644 Source/Core/Celbridge.Foundation/Resources/IResourceMetaData.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IResourceScanner.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/ISetFieldCommand.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/ISidecarService.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IWriteBlockCommand.cs create mode 100644 Source/Core/Celbridge.Tools/Guides/Namespaces/data.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Namespaces/metadata.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_add_tag.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_find_tag.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_get_field.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_read_block.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_remove_block.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_remove_field.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_remove_tag.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_set_field.md create mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/data_write_block.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_add_tag.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_check_project.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_find.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_get.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_list.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_remove_tag.md delete mode 100644 Source/Core/Celbridge.Tools/Guides/Tools/metadata_set.md rename Source/Core/Celbridge.Tools/Tools/{MetaData/MetaDataTools.AddTag.cs => Data/DataTools.AddTag.cs} (55%) rename Source/Core/Celbridge.Tools/Tools/{MetaData/MetaDataTools.CheckProject.cs => Data/DataTools.CheckProject.cs} (89%) create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.FindTag.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetField.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.ReadBlock.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveBlock.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveField.cs rename Source/Core/Celbridge.Tools/Tools/{MetaData/MetaDataTools.RemoveTag.cs => Data/DataTools.RemoveTag.cs} (54%) create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.SetField.cs create mode 100644 Source/Core/Celbridge.Tools/Tools/Data/DataTools.WriteBlock.cs rename Source/Core/Celbridge.Tools/Tools/{MetaData/MetaDataTools.cs => Data/DataTools.cs} (53%) delete mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Find.cs delete mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Get.cs delete mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.List.cs delete mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Remove.cs delete mode 100644 Source/Core/Celbridge.Tools/Tools/MetaData/MetaDataTools.Set.cs rename Source/Tests/Resources/{MetaDataCheckProjectTests.cs => DataCheckProjectTests.cs} (77%) delete mode 100644 Source/Tests/Resources/ResourceMetaDataPersistenceTests.cs delete mode 100644 Source/Tests/Resources/ResourceMetaDataTests.cs create mode 100644 Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py delete mode 100644 Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_metadata.py create mode 100644 Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/FindTagCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/ReadBlockCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs create mode 100644 Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs delete mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs delete mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/SidecarService.cs create mode 100644 Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs diff --git a/Source/Celbridge/Resources/Strings/en-US/Resources.resw b/Source/Celbridge/Resources/Strings/en-US/Resources.resw index 3ba33e6a1..849f4628a 100644 --- a/Source/Celbridge/Resources/Strings/en-US/Resources.resw +++ b/Source/Celbridge/Resources/Strings/en-US/Resources.resw @@ -1,4 +1,4 @@ - + diff --git a/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl b/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl index 00b40fe05194524120f9d84d5b5a9eaa7eff4cb9..b43192723846de995dc81672f47069e1343b04b9 100644 GIT binary patch delta 10135 zcmZ8{V{m0%w{>iHl8$ZLwyloMj{3wpv3+9OcG59B=yYt`Hg7-A``xPVy?gzbW6m{Z z)n5D095vRio`RT~hNv4vL}oB)(eJ=80<%X21FHc|BN78Y9oJeCKiB;~!vZ47eui|~ zZ*KctyL!uHWBQ`n?8*Zk{hS~}=zm`{93*;CSXlkJ`t(a9rA*LW9v#@86sFLPAV3Mb z-DhWKh}@Z1jS8yDZ$6kse>3#xRjzhE+CrEg9+==BL-_iN=O?q4%)p9HE+!fJM|l*HQt7w-~JFyUrY$dirVUVG<1_Y0be| z-cm1Y1h9(+65DB1n$MU%qhG{yxxQDNwDAekM+QPsqgK}w;kB>Rwd4AI9!C@T(KN2g zb`@^x*`cUBN;hKg&vmO6%-$0*-t9iSYtm11da#YOxMC^Q5P#NG*FFu6>R`C;$1!&J zo$UbMkidFvoTHMtH5pOL#;$(M%Fb&jU3lcCb=fPNcP4wKb|tSg>%4+^tLqFC$uz6j z<_~m`Z(_w;hKYiSjb;&^OCOFNrE;Tn5(#9BtWDO(-I~QeLpK@ea#S@1F5arN`s+D6 zTNajvPL(-Z2A6Nx7_=~ll>14IKLcD_q_BW72sRmJi3g0?O{S4HuZ3u7^ zb>=C3;uXZxgdiVoDPDli3)@#?Ti6x9(ih=dyb2$O?2unkl+s;+jWVC2@Zq1j)p@QQ zTO48gQ%)l|$N0AyKn+_DUzTxuN4fwMUw_13X`=TQfBU{;;t(+MRT%rreg~BSF0>$@ z(R7R4LLgQYmM!$7H@>t@i6RONKnOq;S)Ea(W*jmq?2wgD!6BVqAy<5kV2FZ=WxfQ z=oY7c_J$o|GA--Oc?*-T9orcSNHpPtKrJAIof}+~vX5{@{K#-nYCkfoUE4qMjocn}+ zKSF&)E2vk+>M=sXguI4d-;$<_4AX=@wFy30rd~xsyw{=d^)NzTXv)v1xsH zpMHIa*dB(|I6o%@TuDcE?2a225zEK(+smYo+_6&f!QlpzPko6FTN6`;F0w)iD-*d! zYt^gj>J)c{0*Cs6bycn77u6N-I)pavI>S{4t5+(eF5*E zmc(F#4RkcEK$9G+A-n(mbNASV9t^Su z5zd(@_9;xXz|fKyWcu-7=b$8uLj8y3TCDk|jcRKER`_TSaNY~NY~L^iNL*<^xRqc7 zv~v4(Z_n2eZDe~yTrZ)=y_$L67Du_=M%qHypcw~cf;k{xo`R%h(n_p8FPy$h)0IfoTfeGq5)ZgR2 zKs*^}CW|w7UOEHi;8h97Xh*-8EGcOdWgFGmXz7&;tZ*K?M2>n5T?Dq>f|6EtCt<8* zih5mVzBK(g?2$esgew+XBGCDf3dJP90HXO2R6v=@FaT(N*>c;>e7F`r!*m1t$!g7`ri%h`{i1U%D z4kMf*HbFWd5ug5yHIw*vvhvV^x2H7KnthT>0(>Z*+`U^iB3b>lA2}s~0(t8Q3%`c8 z@x5%ja*)xajl`nJt9IsHPeUqXs=lpsV@{^`-*DYUv(qc65I96&GPYKuR_yD@S4Cse_q*xMC8=1>l}- zc@4$>cY;@|ML_42GQ%Wo8&9{hQX0IAOnl(5&BAoWk1;am7C(}@LTl25vK(8=bmdgG zD>j@8;Ad(m(=f9ym^G#`HFOLW>M@jpGf@EBzp)8tdxC1OO%m-WE^Pc$ZzrP~e`YNy z2>lvaKV(>iXxq7qSN{37{n{hYFD~|mj*WV3Nc4K=n_d_b?bYJ;v0q9X+2!MPHfscN zd&W-Iq|Y$W7EW%D)VriWx}S>v+nCS?rpGsyLcAbTw;eS>7VK#H&ukhjHq}l9&JQ!- z+v}Na{zVdzpuXOToQ^Z`mKv~on2#>{?fkK|XTKEp+Uh!tNehTq{?f>Cm_zo3amrV-|cHHoB>9FYFzstdp5Y70oUG5@$BTzw#~!NKFx%@JEX9fwV&95rVc1gufrOIJ(oGO;UT?-YJMkkDV*vXeiF3V zku=*UJmL7x;PG?PrDd3k;EzVg42LQ=tF6A%NjBOK!hm_5LrZ#uqT+`IrvdXkU@qA= zJ}EjXOFU7xh7s|OWQGP!N`jM?7vWe~feZr4kKc?X^(2Z-?%-uq`>%60fC?Agbo>K( zZqBMd$#XNukf(tD^!)T?t}3ga>eS9wu@6>cgmhs>-EaD^XTed=1e~RKRhEQfyy_59 z6L{km@Tih^216E;E4$|WW@82+K+d);)^G0JESL!KqTFJPDJ;fDQEv(XGUoU9#uPI{ z1m#FIP7nyVb{6m31Y~23n3aJVmNje&!x9jCj`WLk$6tpkXtrNVDQ2{5Jr91ngE~A0 zahKCB+2bESEfkeghi^D7ZC8~`ii~Osd};ZczMEttP!aPsE-k4km1d_j08CMjd*9$J8>yjfM0stdW*0~Ck81FWWym%F zqNKSXUVvA}Se)RMClY|7Q^;d7d=Eg>2&%=T9@#N!IY(lSZns&eHEC9^agz|k!#ZSN ziZ|N#w&0DM>J|xG(dJ1_;4GnTDohJTk%P+Xy-^Bi!Mv&XP;Y#KrZkL%$92_>j_dTY zJd%RLqn{`PshxNn2nxOzS{ zgc-}{2(Ab?bh~%*VDLbNjU5f>9W1>2O1zN2iht8AjX~FXN)pa@phoXOiO-XPfnY8);1VSz?3X95Aj7%H8B7LPWVL(*N}(Y4;$R&= zQpO`IY248@We)iRiOkHA7Nt#bxsC@d%7?t^mKz#LL{@CNUx)bCykM)HqlrUI>%5~z zyv^{@+`gVT!c%l9g+AoA8vycxW3n~rt9VGDFO4D9#DkhKNO2WTvRXu69ZM@&+gFbG zDaPUb$kt3NXbZ1kp6F(ion*I`wia}Pkuh+bf(Ok|JVYOXn+I-*+;KT)^naRt!Uzx4 zhZiN`xoJrVx{(~VOS<3{FO07lWo@!PO&o07)|*4aOin19Oe|h7y|w^zM6(+7hMRa9 zqXU_r75YTI8m4R0D{^6F zbDn*ZrlD;h%#u%+@C$^{=17>yYn+pEM3#z+Vj#cEkoqk#%mNYn>~ZpU%N=+Eyk=iy z)KTzjgyA0|uNwW?Duh5=3`g+lB5 z@nH8PG5H`&PPO+q+kf}DLinCf%w|c{Ggt|gJh6P>uKw`DH9C?fU`Tn>G&2i>KAEk+ zIbbH5W@o9tU*To~ai1UMXyqwYHPW* zb1@{rl#H~T@g5l}})E!dV5Jl5+m9>aaNxgk;I!By>&}(RAsP)aFWpD(|vk*@G zzemWDNT2m0|5$t2HUZg&t@mpQ)Twd~TsG>E{o>NBbbYHt8V!7Oh&;F(KFnfud9y~@ zh+`df(a|aQ5<`WR&C>1kt%&QGGiUMij5>qPUKcut!xO|6WL_t!Rn1)M>9vtOH(w}h ztG@pvUhOFZK)Qt7#y%W4Z@N2(#HjQ1@wJ07BRs)sa@sZePD5Ax)~bYUOc1e$LXiQq z=-VEfJ*gsqfyvNhE*b~#`7S(-xa1^I+|5#i4?;0VfX`C zQwRjQR{2vf`VsMk3cLV@cW}(rf@+=EP~f+vh6)T@ z00Qv!Wt@76%}k!E_%nnSrFqACPpM2%+BSyrCwd20ZxPh#5=t<{Pjyc(pCpRW>x3n( z6`rSgTS+m_xcOKh)aiSCp+0^ew}+1gPS<(PEP(~~;CGSD!naWzd$`qs63Q`zm>4hu zO1=q+gzfCQNsO&)^bv@(W^00kS1ds4K1RCkRyI3>9=>l39lK7RXmX+8XAxG8n9h6& zUHVwnRvP=4g%qn1X)rL^M4xi3c;v>;!8g8{M_oj{z!?=FeP<<$`>z1ru(8)br?%#e zHHj1UM(l21MDLHQ^GtUPjmhxg2VM{cvOc}9T|l+#xpBZT6||IE6er#R%M=2HC_S1C z13{HL(Hx)pq0(BOdz}TsS4xPH!1=_rn58CvZfm!IG|Yv7**d9S`+YYXdaozbDZ7tO zEAGo#NM8nEit1$d*fs$}2QR%pS6zqh!*n1cj5YaflMHywevM_r>V?poLM}9|d#hYk zjbiNReEH&oL-O~KW-)uFwBevC*RkjHryYN zmU)?>pGRU2a$A_#f(wa27a^{e9>Jhj_?Lm7!z;glqV(-SuOAPnF|H#S1Uh-Ofpu5S ztl}yng#jB2j4$!}n1TXAR9^mRU%N8TkzF_3zm&>@&fanZ*=n)n&(@I{2qO^!I+*LNdRbArZ8PH6-$B-v0=qJFq$^Ue#$}&i;w-t zw(vWcflks;`5#!-pLe_gwMYH|Vc^yN0exuhz~1Bu(NTut2+JU}#7tN;YzW9s<@g&! z3+Rwm^O37L&2vOk|y8!yl=ny*cCUl3?tQVx58!x?ONwrAD^g)m! zanGn{9xnH~bVJw-0dk-|We#kne#q_IMMn_`!!0w-ayG*<@BJ)v6wm7WRR!B?%!?Q? zP{O4mAaoKukW0243#T0Eg;AmltLD3H%N$FE#MgR+)63(s68v=SO zb~+I@19L8`L|pH*De=jNdbYq{=>st(wsxI!>VN1=8t8CG>!n5#D=knTfNoS?RnO|I z+dJr#cttnkmI&2<{Oycl&b7Azih`x*zyS`Trkd8|!Joc%g3X)M0q=WA7(nyHuEL&*)fZ_s!-~% zU}8>i*}vW`iSn&WxTLkmD9GnX7}(O<%u5Z%H&~w7Of%Gki<>{c&6HPmPaNQwA{-Fk znS}STjS^YP_lC{Ed?qI^&DV_&0M0nNK(|Z+n&yN(a?Qe9RH9OlS8SM?9oaHj+;qvS#*SjZ%C~$jIO+fm__6l zylW$H0GLNq%-f>L+G88n`zsl`Zgj0GO3NCKj*TSTbvL%4Dxe+C#)M%PFfEFAYMaoe zZ?jbZR=r`)M?R&JVli?pH8T2o)Y|<%ribX5w(QUxfompNg&;46pa8s>e zJwm8Ib=QtrM>%@Vgst5fWf+u=*n!*SKH=~P|qJ+dnL$6Idausm`Vb$-I&y-p@(4} zzEf!&AVz3UwI7D$G~FcJP;vxreOzk+tSO^UxwuGD;;BiUn~pQTg{1) z$*;9=AzM7EuO6EOKgM|8-U7PsV{e;sA9G4ja|f7S3Wuz!?OEhvFIvwN6YNc-8Pt~N_Q%=6 zHnY)`^TRpcnG*uwfGtJ5ss^GFFt2-A3zu8ih14EYWhP6%EakOK2vAk+7paIIhbZiz_w?`*o`%dkym(+{L{^xv;5@XJsh zkC6m(<*-c`6jd{R*7kJIdRmL%{_*d^cUT_~Baq zqB3svx=<9rYGQh(^=jD$5OwNlMkq{Po#98Js?;2cmZW1gG;bkdL{QM9LbPxBwY`%X z2L#g9L|-b@V5XGCzyGnhAmi!uMO1RMxIw5O{NDQ6B$TgZB%}rNo+*&uz!r%7!emgg zR60oP4Rl`1TZFw@;bzB0b6rwHd29|tU8y%jLs+6I)HCr_LoFTze%w1|C@F2w<5ei_8)6wHQ)f zbH{kirK&$PUHFA#s85p^wTTFa~t2c3>nXB;yA3x|2yFlDp32xx&e7P#R zu?f?)E0bP?Y0{CIiJ7(_XGcQANJUcY!c>iI{hV}gN!!GVn^mg#>zhP>hg zAr>Nnk}OCY*(K#5P+7zkIGK4eEKGQA6hK3ZvkCN(*Xl_RPBo6nyz6aV^^Rc=#u(^OC!cql>+ibPB*W6 zV>n|k9}XVnOtNl*7&(+=Of*fLKB^Eqd%8^0`VEh(N8yLyiGJjBoY1mJMj;h<0o%I3 z{R6JBZSJCng%)b3eISx%8^EZE%!*>iS!Qc`~Ka)JlE6NclLfwPs#J zvKQOf?2rIvX6!W^&6$EGO976H1Pbwy#;vGd@3Dp?ns_`;-$uP{tRq;<7$stUNbgf- zHMNdKyXy%gs<5k9Rb1wp0Dy&WO}}BF)mGUb$RWJ^xu(w3-6o0?njOz~{ZufP2{;ET(#iu$Vb)%c&7Sg26(P7QrI+x%EyEzw* zZ(yyF?<*xTkJR^FgA}KV#iQq@Y2A2p5$~Ns=n)=$7J0^A?G$M3sMDOr%g|9HW#fAM zrRQbHf^quX8&qcc>KE<75i%q%U&Y+rv|}J~PxOR=FWw&R1MJSR3MGfkJTpDYkvh3rt8R^&SN-TUZGXe!W-q%pppbVb+Evb|6pv> z=azS5nyXb3NCn!FAQQoKFe%X#jr(_R#A6ux>Sc^aP^Cgt(WZ%&4#FEI6N%4^#yo1D zhCac#s- zrxb#kW>tm2#2Zq*a4*$jr^sXgR;WHo==1vee4T%;(263_km4%I#6yW4VBHG%HW#eh zEuNMZvjVv*QPx~Oz2RvoS2WuyMvP>x#GbHq5x};yfjlqZ&Uk$1E789n3pbfKMFnDf zkS=tiF#ulHIChvSwu!RCw|=v4m{m1m(hhcrA+c=Km$OKfCktLNvr{JSk#3`!dD-Lm zB7>89njXdvqq4t`t{rkxi2e+i`tgk(cS52{bJ9%Q3GDEwjUt8q8PNffss6k9-D%bp zUS?Q{<|5?cuW+X>XB5T5PG4k|F31nw?=$Y9{))8wWxgnBaqY?Ejq}m5xtq#V@rUWG zgKslAf*RhkheBl5oM)f^aVzQy@fsHu%5fSK9GfX2P+c2klEWbXM$mLx>Oa9Y-Tj}i zo34zE>e`|&+bab`g9ZbO1PN!*0}G`vp{Kffpf#!489I%V;zg`QkXFoDKoh1?MwdsD zs~Sr+HHuO-lQF;CJ%gQbU1;QXiO~x1kkR~_=ohSnMA?BT?rzdYTV)%Hl~|hh=xBZX z4$8hmhoT9~EtknPzYi--jFH}@+v@3v=W`jRZ#tR`X-{WRhmVz9QpG&=n?riV)#(F8* zgn{Ek1XV8&*~7nj;H<`kMbhz}dY_PljM25W*Pv0Pajetgnw1?GV>8yHCH9)AMHiHx zYjki0n?&q*EqOdS+%G>i+ZU~AGA{AmD$+^lMu9o%E6}DHb6`#v15vN(rLN!Hn8at3 zoOD*(Ha*<$H6km`C0|y#=9;jZi%xrr7jz6;&{;UpP z%B6GQ<=-+j#pp?y0qK=@kDg9qh5D_r&nLhK#vnd^FtFr68Qpx-Z*hJ+b~R6k^4vJq z=<(PPEd0_}&>cvkRvQMf(jW9V!`U|HJwM=-wbio+%$ag*Jtr~nwQ(!~a#Hh6kAIEZ zMcz7pN46lx$sLK-!FXI+GFvj^8S)#5d+XcuBezN&wsMv`0vgolDEIRVYs>m9qQg$*k&)!PA`ImLS}4tCrkyK-M1%6A1EO0WW2_TW2B(<>gBXZzmSff%w2VP4F4I& z!4ONu!4a+BdL+G`Nl|>X@z(hYo)&4@N=+kI<=&Gz)eCywhHEgpRc|(&w&gX+p2Hgk z`@koKr`uKR1*SWnsvYZzj;7!4*`;qi51(J?j)CTv9=2+%J|m?cpF>kxTaHOqY zwPrAAqFab6yBCtyXHIKn$qf1LHB`EdcP0lQJXuGUgREJyj6F^=St%>ba2C-kX6=L4 zX%Y9^3AIOxe}7LmT!}Cn9pBZ}byVv~JgQnCJSOro0QSg!RhUqD3^UY{J+{B>c#$4wj#5LtX{ zwtp>zfny@h()$TIKYgpZcrOnZh~5*fe)$|dT4j1D6pGqCccGy^cN~$Bs^MC87%FN$ zr>^6*0tVfA7;+A1HXj>OZ43a7p(FzxlZ<17;D>JA!kBBzNo)i&Gn&4Ip5YN)ijJLQ zEMVtq)Z?2Un@kXnG}4CNBK3a4F9)E$LHbCP5xbQsWe|s&!Z#Sh_nZyOg?9Fkm7$4f z2V}@jQ8=LMJpxYJXkmFle8gAk;+{C*X|>jez&x54=EXR{U^V~L4MV@E<7|G0Si6!v zYxbuc>GtKg^O`OEPG*)5dd`|KhYb$jERMyU&XZtg2tmzgQ z%k0(TsNABwX`Ay3>PZL59AEbg$2%5{I1J?n4=1G80 zgCz1Lz*#{N`4SMNoFH@n5$HVsE6IP^#*koO)PH5_|2{1N4#NKu#R@nfT1Eba<01&4 z`~o@%Jn_FNIq0eYgXF)>RWLA`|Ly-9TLSPT{~<#CW4D5C3m75lmH%y&Ec{CH->Mf3 zjN!i;S=9a|<`!~70JQ&w&xNcIlZO97rSRkKbA_x|z zf76~~4u~AUU&sogDxrf24*Iu>7lZ%;mLUBzYhp?WAoi0%+i3)#krK>*D(inM4(Wd@ zq#%OQe>rre1Q4(p|8i2&(HKfA;bBOB-~F$-{C~cm|9_fLGmt@} srAQ?IHPhh1z)1eT12+Xs|A%ax0fVg~4-NCr9{%5z2?qv7ocB-mKiT(lO#lD@ delta 9974 zcmZX4WmMh0vo7xL?gfe!r+6vu6nA&nxD^UN9Euh;Qrz90;_kMw;_gmyzkT0(?^)-6 zPS(nrJTsG-Bp+syS($>sR7}BC_o1Q-#gythb;p=VVM0NffFY=qfRXD8Z#xHu2|pM&%^H)P!cOhbT2GRpJfUI+^p$zDUOlbsx$kVqU$kK<|>j#0#tbHzvN(J zu!di8O^#BmD*dj8AgHJd78g`p3zL+m$U#&7BsalAb0~0sE~!%m*(ln4;#OU_s_RwK z6kOV9FNXxK+HAY1^telLzi`T(KnO161j8D#z7r5=+oKKv63Ya4*5$KIdYM@o2qEI4 z1^D=ZhHn9hb;b?J8-plmG^zF4e!)S!o{dHg`~>?}I!SRGI~{WaVyt{=Qk7xrfObVF zW%Qau!P4|aG{mR)G4FjJK_W9Lq{KE1;qmghwVVVp=fyc#uW_hcN%_`MQF-4#tc~M- z7{j{ccd`XMqd|2sIfoU@SiHMk*m>P;0V^8l{@V35{nI#~=q^)%v)3_a-8L9iVA!^b2d12bd(T; zwayMd`9ejh5+)XeHJ`6~q>jl^$G;67wxx_Rt%X0-@wC`|X9$X>g3T32Ewso{7zvHBUfgdD+UM&3 z&S%ccmfmMxYK%)X?l%o#+JGg$!ASG?Vp4Yv92MUAKdCNcFqj;z?X^_Hk#|$#tw?&8Y-2nrThWDJ&AEGH;nM;nMUmi-L)}NDy!<7$TE5S` zK~JW#!5L0umxArVRiLC;y5z}A3NGGEqYqabEA2oW6J*$pg3^xNasXA&Ei%f`oZlR8 zU>j0iH7w&dgQz{(N-;20ev%m*dYP`)pFy?UM*W z|FTnWpv?M#u@!HdN4Qvqz2j@Z(9AWe93@1}wHf2Flh5Q+_ijBNtOk24p1e!f7WPz9{O}oE4g}U@nJfaf2d-bP9E!s2$wtdpF;YnmoE0Rl`NBiT^*$5za4b_Jhp(FEJ6JwZf#m>QQ)&zaIxURvW4{2^ZmpGsL7VUe*C=lTRp(oeO}mo(i6u=Jg*Z&kmC_LwPi)F=zg zvI?LaEj9Nme8RLEvS$Y+q242>i&ivr$YU}@TyTk1C7G7hyVusPPcIoxWf~vN7fG|b zpp=E5ItLdV0sJ z@~@C!qJPI*P!b5dJ$%Nr&VYw-i`ZkZEXu0p-7v7Y|qy=qwWa{oy4k6D0Q)nSP}T0I|6NY_lF&De z3PK1`*uO)Ozgiy}kgkh%U|gML#ZUOTx>2f|$8X|}z(Fl8phUNpSFE#%oSwrzH&ORF zv3#3-HMv;;Jbl3{q3*}|Xe`W}DYG-`g~#mz3g)N33JR$Ks1zH(cjoS^Epfc;-3Qq= z-f9Z)*eMpBab&kGNojdmr>Hb4-)v8cx)Tez19@hiwY(<9wt8Az*>!+SH)!BNO;T{Krqp}g($qk6}lDT=g zRjIA;a7SB#GvwlHEnYSIVjvXbc^?%ehi zTLw<)gu{hK{E_xw&>76%*QDUZ#I`DP@e(`59Z+Wg9uK{DfOl|M_&qHP#a6q}E%6;E zJQDJa`RVnjvf9SihwFu+POrU1D>&|E?MG+m#}q8Cn#|m;545RN9QSBDk7Tk^9DEvP zj<8^;DcGbU6BHIVe<*6X&>uI8bKgoBMZBANXJy#8Hn5d|jwS6$D4%^w&fCmD&S&A&#Ibo1>qu_Hj`Db=jDu zlfA`}ShkWk=J?AM?J}T*;fIO7jTnh#Hc=PW#rUGuY|gfop$OkIZQnw-3?Wun@+pTf zOFDk`dm1HH!j4T-&p^PXKk{h%eJtWC505&KTjwjB^;(_}*NpzXX`}6w`zuf2eKvUs zw=4Yx8FWQfNGWN3^}K_0s@E6Nudz%Rq^N6^a~UPow}V_7IUnoP+`~x-(^;j1?WONI z6ABk2gltOEAuHQN2ZI~nn7AY0yVQ@26DLZ?ci-(TF#ZOWxA9AVX>guQ=VCuMLEkUn za^7sTc#A?1G~u8WY18}7rYQ907n8P-ud%PvM=oyM<;JpV9l;+V1;X15+z@I(yS6$g$x{$@+>`G_RJ~4VdmjTr_ zOU8#W#JLYneu0q3>IK4Qc)c^j>P;MP`KBeD68J=h$d=_MUJQGF8I3%bGEr8B)jnhS zNzb5^FG`qVPDhQ0sZ&OxDx1=bz`<+=4H3Z#)=epYK%e(?{tRewL->&wt!ejLs0Bn3 zrG*P)Z?9EvooZ)b*2%3qQj%xBucdjU-!9~s4(+;3N{^+tNpc-eu6b0f;*~b6)YZFA z$5-pw>a(VU_sC46S<`AR`g<~-QK8g5OS{WzV9X2CCuTUat1s``6JN6f5IeqVkj7#1 zT2-HZm(t!H4O6kqBjF0iQyx+Hs` z6kCKiN)B8ob!Ajk#_>z8%!h9D(yzxzsFX3!iRAtkl34v^Lwj3_G?)j6s5-b$9K%aY zYktoo{?;ec9)8l@CXBpLZQ!>K1|J6%SqGZKW?}oKuZ`m+tE^2r00{EU84c-n_p;yM z=yr1Y{sA5H+r~PV;ydW0zpQVL+q@;$&zBwX_1J?j*1x%WW*4Ox_LG7z0+k?w-qVmt z!rzZq{Gjt8bgg7Bj{CSQ2F~<9>`PltHg+8nl;5DU&bNE3U!*^(&@hm+EOse2oRmCZ z!rH}tKfIRX<+_9eFrt?_0>cjcz2HF~vwcT44Y|p!_=;PEjgqcWC*R4N<6%!kg}C?b z&<}B&Y+I%)HFUA|Z z3-Nu_+OCaQz6>+XkKRC!WS`_tb3~z|+3bIBLDQ0~Vy(>~)}I^|{{ zfPOdcv*ANr_bz9dT0A(*TS+qa9H!wOmG_yT7p8xM4;$+dI4Au9Ckycg4d25EWz=V;5ztCh!X#krIQhkO+XVBAu@|td$x}!1 z5jIjXvTk6v+dkTJKPH@9USCPb_@$gYpS;!Sl2XuNysv7|zcc)<0S^rYRSFC4o{ z=Ar_LQ(_LiR9GRK!Z@BpHF5-(&`cUfrTN=xiFYy6FpCOPW7}VP=)QhjfFei z@xWrMPT0z6Joa={SNvtsL$nd7nP4sEsIq2=J?{7g&r|3W=^MP%;0jeH0JlGHUXIdc8$E)o0U1u6F$h^1H!&|AMm%7? zb31_4j%@vO{5-Me<1V6}b^4E<(w>cHNkT8crYm8GsltP z;5976uQ0taw6XR!ZKom)*~sbC%c*ARcr^0Kzz;Ncg5DwOHXB_Q@6H=?-VJ0oV)2ZS zCnh^huQ740fi7h}k1N!ab^xK{K=hv(UQ#?6(Bwia(n(y^yi;faqD?d}N0RolzIs5& z66_f10K)1^_+Y!b`J}aa&U^1cH6J3?Eoz2%YM(#QoaiQ##P|kVuO&EvJ9`p=G2TUc z6sPW)b7n{tcdEAxG*R2<$!cBl!ObuWF)bY2Umr$Wv{5AtGtG%sd4#H!fzt@|z!^}_ zDIeu#dkuM2?-72+=EJ6xm*O4Ih+gI~uKPxi`fXb>SU*p!T^ww!@FlN4Jv>-G(r*0J z3qzS&T%M>VpFCF3YmsVJmGQOEx$2i>cArYm)aE_2TPXwE#rm}N*jDEp#v6M<{ttWy zcKbeLIzhM+e;>sxtD`U!3B(>P&4 ze1!5q3%qL&ZB~d~z4JiQm$n4Z>(dt^$j7Eh_BR;jemRDt1qHu^5(DZ}{AjkVF;@5d z)ov}GEh$104eKm?6&?w8QVk>oX0){`ArRlfU4xWp^kl3o_6+(o08%}+anx@p=4WX+ zld^F?M9qHew_u$+1;E9oJ|a@WsXbc_@8$_jbT8R*y+3@5j()e4D%_NoxxExZl$q^= zZLvbc_FF@Jjmc8GvU{GN#jxmy6x(E3Y{j#$za#$YowZ8lP1GqK19P-FDN{nO>^IQQ zVC-K6`ju$~p7_eQccs8;&`Wp>ghCvaN3TXlkP;GpRu>p zvqT8JhMhC%Y*U;Z`K(Ox-j=|t$jZb7|IqjIE%I3v>J&L{fb+(+x1Dgi3R?*sC&rYs zaU&6PzgK`<#CV{Fsd)#lQTI3LVkOVG8+4Qsljp@vR`Ot2BiE_`*0An8{;!P$ZMfW3 zCCe|kwhtI4ro6V8QMfttg;GBIDevQy)=_-ffAEh>1h$fng!}NRU#&D0%y{AgkS(6~UE3@qii`L=buMDT1PjMJ?+H4H0B`~K3$Zs7_1Iqvd4(SYqcmThc>u-~)8 zW~$8b%&Y>HiGHOO#Tn73#AIoG{L)ydok^)tiwbjg+8Ry%So8tt^t|nO?aG}G&D70( zwQ*mnU61VSDN;2Ji1le{dlCovxih%#c|P4>yzE!YuCoxs@FwQn@*9in6~>HdatZhq zil%(Zv>L#=lJ~h)IenM$6rMd-{f4L_4+{sb$R`2+0a4f0YK0SFk8w8Y)1oVd3?f58 zkudzfum^xF+)T_(+)UVyA4=y2jbVrPjvl1X5Y>%L4Ame`sP|RVOOHPj%1RM^@9G-T%(C_kpm<{ zi3oZ$`HWjAtfN0IE|sZtLJBS45A0j*TXojfy8*}0lqy9l+0JQyq>%2XmQ-bn8@#PI zca1hzmobf1*7FLD96xk1y*oHoIuw+?dX8GV!V@c#viwl7$3wI$!`Yn@5HZJ-+>h{j zdDwKP!U2+_5BBLXM~EI%iA)+brE2JpUv-_cHva5iQx4D8UC$XkIho5SAiAwlQ6I7% z9S_i{-|IBdEgXnNn3jFj+<4Hb)ZlrJQN#OA$i?CtxusQ+Sh(r{($6jalI9>53d2e- zq5v>}@zynDJP3ga_!R;= zP9iBwoBiYCrqmd-St>EaZWT?HOv{VDA$&BCi@Ty-jnasuJ=YlRbNNCTt@M;k3oPGs z2t$T#SBC{Oh4ll)e1=G}x%Jm82bbFv>6a}nkN6%yEDh+mz0O9@W{>4E-KXy6>UnBW z4tz@6r|#;`X3Z>>KiQ_F$YHM!$F_vlMkxrbwc?82YOI8p(9(gMH*^PfC$8PIT?xK` zw~6SJ_$~xZ+WA}n-H-PU`ii6jU}*$=aam%B8Q<&PbBes$RbS|XaZ#f*HjKWZSYDtY zkr0uuI%dTmOWcXZem~P9BM` z10CX{ekMthzKoB*olE0%6kOhM&X%>?9j1E~{pvq_(o)soc21Ne4PTq_TqrG%cHH6f zWYWvU5CrnhuQFJrlACpl=`nLJ11o@~G;|pyI-h@WLaa$y72|O+ znS*H7uQhUK_0-t>!3-3kjYjs@Pv~USd`en~L&y!Tvk2a9T?=6{YjeXZaBuun1Qt+Bf1+=M*DGP}d~WH) zlM1!ngwH~ImM!xog_AU0;aia#5R7BNs>*+kR>ZQRoVgl zRNaeetq_tDS>Epwxe3J;$0?Z17w@r~W^WE3Zw0h8ST9W0TjQoXB@Dw4cqG_@B#j1b zF-x-!{VjA%b!v*sT5&9rx0No7KJCj$><8k*E0tU8m?-fA9Zj4d;ydqc#v>)C#3cN^ z>}vHN7MA)~U5Y(i<+hT9edn|#cda+zzdj6rEVE9q_lJ!Lq!2bK$T)$HZU=M$*Q`E) z+bZ@M%;-Cvz{t%pagS-bB)tN{0h$5ptMYBP6#o+z{?$iTw|5YUw#r+Zvgr4JTE7j_M=X-_8 zy2Q6J6qm;JM{)y!NajX$&N!7@QrvoahMPen+Q+-R`nPA&3Z3e^(%a{{3jWMOwv9Z@ zuo!-=8l=YBQfF2Y^OY5H%ZJMorAi}=aukc%Iu<}fOFgE#0)mBgyCapo3?LhIoK;+H zO7)Nf_oa> zuLCZ87{Lb89g9(9&yAF`Uh-2Xh_PdA&=`nucm5CD`HU1XveU3VVI~{Y(pZJq7p2ho zf&S(JpFEVQ*s0yj_H3ZKEgAz1hG!tGdoY|KQm@0GN9rA<53JBcM+rzuzI+VN?>&sR zAV8*AB(}C?{GyD7Up7m+V6tBwgB_?87zGrX^LWhQ@zrf2)GzT;aC+VqA%EJ=D}*)X zELR^Kt`*-5l{ZUKaCXPDGIU%yHS=W(AeMVtIOVE$T@Z22;ZMpkw-$yQb%_+H!O&9{ z5+cmCEQ?!>-gs*t{t?PW(Vdo*)^xT7g*HI6WS#Iy5*)MgJ9=V*3Kw zsW!Hs?(Q;2HMe126(_`~@t39yO3(rswp}WY{mHA9kQ(g{^pR$xWNxRq+*+r9yg8NROh z&Du}QuUy_n4`UQezIco^#5d-CsZ}D;;R5{t!HX@Tvo4Y6 z2CryMrL)HUhpd7XL-g5$=xJtC zcWrFGLff^@>HsM4QKOZ#e((SoWyYTvK1q%T+PUdl9FX9$1&r$=iWB9XcqhHR?00gL z>yCXrOd7fma7rz}&dAi2NEo8f5PSCK%gW=JgDlnT*YeqCk>Go7&fjbkM6lTn4se3d zZ+N&i2T)E#Ru>~byON|>j`t7Wi5e&n(e(uwo{| z?MKH(d^h^G&qL`cXF<-yUVS$p8N7e7JJegUNrsCyqbSD$pVt)!dMo0h*Wj_NBz3Gm zhrCoj=0nveSSm0s^5~u8JEfG`j_k8A#_&m6e!dFx*tK%%zp8tKtz&oUd0Yd+E+OaVb-=LGh?GM8J3g0wWiQHslufRqAzO4@2xs=J(#x|enn;*r^{AisxH&1L)?PSr!1xp|{zn@uQc8t?+w&PG& z(@-0ISITd4;%;O{=X3fXME+>17S7+{Up=+Y(gXHKXc&Q1>?T0JNfP=Cb^+^%NsZVG zA1eimI)f(Xr4rKQhh8hp*GKP+XqNW<9FzULvuP%Ral=E4MAE;OEjP#1wD>_OOTr@$iOs@kZ|XwU&!v3b&E zi=Q0x`_ddTkImIhxrVmtumKw60NN187o(ZSn?~UB;c`fK;@g?S)>Lq>_#u6%22>U& zRW&@me^@6R?g-^`;t!%ry$(1Ygw^D^FZkFp@#}}qH_aiMML!~6<>D1ODawYx=c?M} z!wdok)M8)iuUX%YiTk~lG3eO|PZlocGMiV*jc_Cz@^{F|v}1PeKPFzsBNok*&SYk5 zZz2MI^Mz(t4V(q5A%or z4Z5>%WPlneaXEn;W@h-Xl9enR7~{xd}FXq zq6SnNi0N?Qu5QVU4AuIbEcqKGThVrTgjeG~uj~dE2`$+26+gA|c;?dK!M(eryHOsB zdCnFYibcEa#mH@YnVHttNi=wHY<0idTr@Jthvb)ly?@2pFAc-ZPKHq8nbHAEC%ZBk zdc1Vj@ozD=v8k4?rvp^M)l?+{LdA{+&EE(6F;4=Cg);?YE^O{-*ECe+Z}6#oi(t6j z?1J^eLto9bL*Ed0@OBnS$xGVZw5FMF+y-K1sP~D@XN_~tOE8uO>HF}zL6s2zJQ{0^ zEqj1>nd>d}5-M6+Sh-?TgdR;r>7qz~hOe}JD$72@pn~bo=3?3JAn~^X!KF11^}zdW z08E*^{6D@tA?9d6bAt_XWS}{~g*g&1rS#xadSWnqE)z5gm_1hl#-9g#0ipmWhH7szglB}wCKNzhq*k^abU_kHJIzqe;KskwZy;hLKzjTn2$#F zzv`O*WeN(aF~C*&Uu8#CRB%x~6U;A#f8lLD2MnJ2ze98*G_ZC76U?^Zzi`C{58V0} zl9>I4EG#PW&@gyVcz;bILH(~v!hbmOu=r;_(*G|kfUyc$VZt2$wdv@D0=6$?g1K?| z7p}TufQSFWVIXx|F*080LIjRt=AXhpkyII KL3w5UBmF-_03xOU diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/cel_proxy.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/cel_proxy.py index 581c1dcc6..210cebc8a 100644 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/cel_proxy.py +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/cel_proxy.py @@ -127,6 +127,7 @@ class names (e.g. "Spreadsheet"). When omitted, every _namespace_descriptions = { "app": "Application state, logging, and alerts", + "data": "Sidecar fields, tags, content blocks, and project-health", "document": "Open, edit, and manage editor documents", "explorer": "File and folder operations in the project tree", "file": "Read files, search, and query project structure", diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/conftest.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/conftest.py index 419e9671a..b5cf16e21 100644 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/conftest.py +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/conftest.py @@ -48,5 +48,5 @@ def spreadsheet(): @pytest.fixture(scope="session") -def metadata(): - return celbridge.metadata +def data(): + return celbridge.data diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py new file mode 100644 index 000000000..2f3e3741a --- /dev/null +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py @@ -0,0 +1,246 @@ +"""Integration tests for the cel.data.* namespace. + +Each test exercises the full Python proxy -> MCP server -> C# tool -> workspace +service round-trip so the alias mapping, JSON marshalling, and underlying +service behaviour all stay in sync. +""" +import json + +import pytest + +import celbridge +from celbridge.cel_proxy import CelError + +from .helpers import delete_if_exists + + +def assert_project_clean(extra_broken_references=None, extra_orphan_sidecars=None): + """Run data_check_project and assert no unexpected attention items. + + Pass ``extra_*`` for entries the caller knows about (e.g. a deliberate + broken reference left by a destructive test). The default expects every + list to be empty. + """ + report = celbridge.data.check_project() + + extra_broken = set(extra_broken_references or []) + extra_orphan = set(extra_orphan_sidecars or []) + + actual_broken = { + (entry["source"], entry["missingTarget"]) + for entry in report.get("brokenReferences", []) + } + unexpected_broken = actual_broken - extra_broken + assert not unexpected_broken, ( + f"Unexpected broken references: {unexpected_broken}; expected only {extra_broken}" + ) + + actual_orphan = set(report.get("orphanSidecars", [])) + unexpected_orphan = actual_orphan - extra_orphan + assert not unexpected_orphan, ( + f"Unexpected orphan sidecars: {unexpected_orphan}; expected only {extra_orphan}" + ) + + broken_sidecars = report.get("brokenSidecars", []) + assert broken_sidecars == [], ( + f"Unexpected broken sidecars: {broken_sidecars}" + ) + + +@pytest.fixture(autouse=True) +def workspace(explorer, file): + delete_if_exists(explorer, "TestData") + explorer.create_folder("TestData") + file.write("TestData/notes.md", "Notes body.\n") + file.write("TestData/other.md", "Other body.\n") + yield + delete_if_exists(explorer, "TestData") + + +class TestData: + + def test_set_field_creates_sidecar_and_get_info_returns_field(self, data): + # Set a field on a resource that has no sidecar. The sidecar is created + # and the new field appears in the get_info response. + data.set_field("TestData/notes.md", "priority", json.dumps("high")) + info = data.get_info("TestData/notes.md") + assert info["fields"].get("priority") == "high" + + def test_get_field_returns_field_value(self, data): + data.set_field("TestData/notes.md", "priority", json.dumps("high")) + value = data.get_field("TestData/notes.md", "priority") + assert value == "high" + + def test_get_field_missing_returns_error(self, data): + data.set_field("TestData/notes.md", "priority", json.dumps("high")) + with pytest.raises(CelError): + data.get_field("TestData/notes.md", "missing_field") + + def test_set_field_accepts_list_of_scalars(self, data): + data.set_field( + "TestData/notes.md", + "categories", + json.dumps(["alpha", "beta"]), + ) + info = data.get_info("TestData/notes.md") + assert info["fields"].get("categories") == ["alpha", "beta"] + + def test_set_field_rejects_nested_object(self, data): + with pytest.raises(CelError): + data.set_field( + "TestData/notes.md", + "complex", + json.dumps({"nested": "value"}), + ) + + def test_add_tag_creates_sidecar_when_missing(self, data): + data.add_tag("TestData/notes.md", "flagged") + info = data.get_info("TestData/notes.md") + assert "flagged" in info["fields"].get("tags", []) + + def test_add_tag_appends_and_is_idempotent(self, data): + data.add_tag("TestData/notes.md", "alpha") + data.add_tag("TestData/notes.md", "beta") + data.add_tag("TestData/notes.md", "alpha") + info = data.get_info("TestData/notes.md") + tags = info["fields"].get("tags", []) + # Tags appear once each; ordering reflects insertion order. + assert tags.count("alpha") == 1 + assert "beta" in tags + + def test_find_tag_returns_resource(self, data): + data.add_tag("TestData/notes.md", "flagged") + matches = data.find_tag("flagged") + assert "TestData/notes.md" in matches + + def test_remove_tag_drops_entry(self, data): + data.add_tag("TestData/notes.md", "flagged") + data.remove_tag("TestData/notes.md", "flagged") + matches = data.find_tag("flagged") + assert "TestData/notes.md" not in matches + + def test_remove_tag_idempotent_when_missing(self, data): + # No sidecar yet; removing a tag is a no-op success. + data.remove_tag("TestData/notes.md", "nope") + + def test_remove_field_is_no_op_when_absent(self, data): + # Returns success without touching disk. + data.remove_field("TestData/notes.md", "nope") + + def test_get_info_returns_empty_when_no_sidecar(self, data): + result = data.get_info("TestData/notes.md") + assert result == {"fields": {}, "blocks": []} + + def test_set_field_visible_through_file_read(self, data, file): + data.set_field("TestData/notes.md", "priority", json.dumps("high")) + sidecar_text = file.read("TestData/notes.md.cel")["content"] + assert "priority" in sidecar_text + assert "high" in sidecar_text + + def test_invalid_resource_key_fails(self, data): + with pytest.raises(CelError): + data.set_field("\\invalid", "priority", json.dumps("high")) + + def test_sidecar_key_rejected(self, data): + with pytest.raises(CelError): + data.set_field("TestData/notes.md.cel", "priority", json.dumps("high")) + + def test_write_block_creates_and_overwrites(self, data): + data.write_block("TestData/notes.md", "test.content", "first body") + data.write_block("TestData/notes.md", "test.content", "second body") + content = data.read_block("TestData/notes.md", "test.content") + assert "second body" in content + assert "first body" not in content + + def test_get_info_lists_blocks_in_order(self, data): + data.write_block("TestData/notes.md", "test.first", "one") + data.write_block("TestData/notes.md", "test.second", "two") + info = data.get_info("TestData/notes.md") + block_ids = [b["id"] for b in info["blocks"]] + assert block_ids == ["test.first", "test.second"] + + def test_remove_block_drops_named_block(self, data): + data.write_block("TestData/notes.md", "test.disposable", "bye") + data.remove_block("TestData/notes.md", "test.disposable") + info = data.get_info("TestData/notes.md") + block_ids = [b["id"] for b in info["blocks"]] + assert "test.disposable" not in block_ids + + def test_write_block_rejects_invalid_id(self, data): + with pytest.raises(CelError): + data.write_block("TestData/notes.md", "Bad ID", "content") + + +class TestDataCheckProject: + + def test_clean_project_returns_empty_lists(self, data): + report = data.check_project() + # The autouse workspace fixture leaves only TestData behind, which + # doesn't carry references. Any unrelated project state shows up here + # too; we assert only the report shape so the test is robust to other + # content in the demo project. + assert isinstance(report.get("brokenReferences"), list) + assert isinstance(report.get("orphanSidecars"), list) + assert isinstance(report.get("brokenSidecars"), list) + + def test_broken_reference_detected_after_target_deleted_with_break_references(self, data, file, explorer): + # Create a source that references a target, then delete the target + # under break_references so the reference is left dangling. The check + # tool reports the resulting broken reference. + file.write("TestData/source.md", "Refers to \"project:TestData/target.md\".\n") + file.write("TestData/target.md", "Target body.\n") + + explorer.delete("TestData/target.md", reference_policy="break_references") + + report = data.check_project() + broken = [ + entry for entry in report.get("brokenReferences", []) + if entry["missingTarget"] == "TestData/target.md" + ] + assert len(broken) == 1 + assert broken[0]["source"] == "TestData/source.md" + + def test_orphan_sidecar_detected_when_parent_missing(self, data, file): + # Write a sidecar whose parent does not exist on disk. The pairing + # pass classifies it as an orphan. + file.write( + "TestData/orphaned.png.cel", + "tags = [\"orphan\"]\n", + ) + report = data.check_project() + assert "TestData/orphaned.png.cel" in report.get("orphanSidecars", []) + + def test_broken_sidecar_detected_when_frontmatter_unparseable(self, data, file): + # Write a sidecar whose frontmatter is malformed TOML. + file.write("TestData/notes.md.cel", "this is not = valid // toml") + report = data.check_project() + assert "TestData/notes.md.cel" in report.get("brokenSidecars", []) + + def test_move_preserves_invariant(self, data, explorer, file): + # A reference rewrite during a move must leave the project in a + # consistent state — no broken references remain. + file.write("TestData/src.md", "Refers to \"project:TestData/old.md\".\n") + file.write("TestData/old.md", "Old body.\n") + + explorer.move("TestData/old.md", "TestData/new.md") + + report = data.check_project() + broken = [ + entry for entry in report.get("brokenReferences", []) + if entry["source"].startswith("TestData/") + or entry["missingTarget"].startswith("TestData/") + ] + assert broken == [], f"Move broke references: {broken}" + + def test_delete_without_referencers_leaves_clean_state(self, data, explorer, file): + # Delete a resource that nothing references; the broken-references list + # should not gain any entries scoped to our test folder. + file.write("TestData/standalone.md", "No incoming references.\n") + explorer.delete("TestData/standalone.md") + + report = data.check_project() + broken = [ + entry for entry in report.get("brokenReferences", []) + if entry["missingTarget"].startswith("TestData/") + ] + assert broken == [] diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_explorer.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_explorer.py index cf195dfcf..03a824f7c 100644 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_explorer.py +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_explorer.py @@ -64,7 +64,7 @@ def test_move(self, explorer, file): assert "moved.txt" in names assert "original.txt" not in names - def test_move_preserves_referential_integrity(self, explorer, file, metadata): + def test_move_preserves_referential_integrity(self, explorer, file, data): # The reference-rewrite cascade in IResourceFileSystem.MoveAsync must # leave no broken project: references after a move. file.write( @@ -76,7 +76,7 @@ def test_move_preserves_referential_integrity(self, explorer, file, metadata): explorer.move("TestExplorer/target.md", "TestExplorer/renamed.md") # No project: reference in our test folder should be broken after the move. - report = metadata.check_project() + report = data.check_project() broken = [ entry for entry in report.get("brokenReferences", []) if entry["source"].startswith("TestExplorer/") @@ -84,9 +84,9 @@ def test_move_preserves_referential_integrity(self, explorer, file, metadata): ] assert broken == [], f"Move broke references: {broken}" - def test_delete_with_break_references_leaves_dangling_reference(self, explorer, file, metadata): + def test_delete_with_break_references_leaves_dangling_reference(self, explorer, file, data): # Deleting a referenced resource under break_references should leave - # the reference dangling, surfaced by metadata_check_project. + # the reference dangling, surfaced by data_check_project. file.write( "TestExplorer/has_ref.md", "Refers to \"project:TestExplorer/will_delete.md\".\n", @@ -98,7 +98,7 @@ def test_delete_with_break_references_leaves_dangling_reference(self, explorer, reference_policy="break_references", ) - report = metadata.check_project() + report = data.check_project() broken_targets = { entry["missingTarget"] for entry in report.get("brokenReferences", []) diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_metadata.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_metadata.py deleted file mode 100644 index 588c2c605..000000000 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_metadata.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Integration tests for the cel.metadata.* namespace. - -Each test exercises the full Python proxy -> MCP server -> C# tool -> workspace -service round-trip so the alias mapping, JSON marshalling, and underlying -service behaviour all stay in sync. -""" -import json - -import pytest - -import celbridge -from celbridge.cel_proxy import CelError - -from .helpers import delete_if_exists - - -def assert_project_clean(extra_broken_references=None, extra_orphan_sidecars=None): - """Run metadata_check_project and assert no unexpected attention items. - - Pass ``extra_*`` for entries the caller knows about (e.g. a deliberate - broken reference left by a destructive test). The default expects every - list to be empty. - """ - report = celbridge.metadata.check_project() - - extra_broken = set(extra_broken_references or []) - extra_orphan = set(extra_orphan_sidecars or []) - - actual_broken = { - (entry["source"], entry["missingTarget"]) - for entry in report.get("brokenReferences", []) - } - unexpected_broken = actual_broken - extra_broken - assert not unexpected_broken, ( - f"Unexpected broken references: {unexpected_broken}; expected only {extra_broken}" - ) - - actual_orphan = set(report.get("orphanSidecars", [])) - unexpected_orphan = actual_orphan - extra_orphan - assert not unexpected_orphan, ( - f"Unexpected orphan sidecars: {unexpected_orphan}; expected only {extra_orphan}" - ) - - broken_sidecars = report.get("brokenSidecars", []) - assert broken_sidecars == [], ( - f"Unexpected broken sidecars: {broken_sidecars}" - ) - - -@pytest.fixture(autouse=True) -def workspace(explorer, file): - delete_if_exists(explorer, "TestMetaData") - explorer.create_folder("TestMetaData") - file.write("TestMetaData/notes.md", "Notes body.\n") - file.write("TestMetaData/other.md", "Other body.\n") - yield - delete_if_exists(explorer, "TestMetaData") - - -class TestMetaData: - - def test_set_creates_sidecar_and_list_returns_field(self, metadata): - # Set a field on a resource that has no sidecar. The sidecar should be - # created and the new field should appear in the list response. - metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) - listed = metadata.list("TestMetaData/notes.md") - assert listed.get("priority") == "high" - - def test_get_returns_field_value(self, metadata): - metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) - value = metadata.get("TestMetaData/notes.md", "priority") - assert value == "high" - - def test_get_missing_field_returns_error(self, metadata): - metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) - with pytest.raises(CelError): - metadata.get("TestMetaData/notes.md", "missing_field") - - def test_find_returns_resources_matching_scalar(self, metadata): - metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) - metadata.set("TestMetaData/other.md", "priority", json.dumps("low")) - - high = metadata.find("priority", json.dumps("high")) - assert "TestMetaData/notes.md" in high - assert "TestMetaData/other.md" not in high - - def test_set_accepts_list_of_scalars(self, metadata): - metadata.set( - "TestMetaData/notes.md", - "categories", - json.dumps(["alpha", "beta"]), - ) - listed = metadata.list("TestMetaData/notes.md") - assert listed.get("categories") == ["alpha", "beta"] - - def test_set_rejects_nested_object(self, metadata): - with pytest.raises(CelError): - metadata.set( - "TestMetaData/notes.md", - "complex", - json.dumps({"nested": "value"}), - ) - - def test_add_tag_creates_sidecar_when_missing(self, metadata): - metadata.add_tag("TestMetaData/notes.md", "flagged") - tags = metadata.list("TestMetaData/notes.md").get("tags", []) - assert "flagged" in tags - - def test_add_tag_appends_and_is_idempotent(self, metadata): - metadata.add_tag("TestMetaData/notes.md", "alpha") - metadata.add_tag("TestMetaData/notes.md", "beta") - metadata.add_tag("TestMetaData/notes.md", "alpha") - tags = metadata.list("TestMetaData/notes.md").get("tags", []) - # Tags appear once each; ordering reflects insertion order. - assert tags.count("alpha") == 1 - assert "beta" in tags - - def test_find_by_tag_returns_resource(self, metadata): - metadata.add_tag("TestMetaData/notes.md", "flagged") - matches = metadata.find("tags", json.dumps("flagged")) - assert "TestMetaData/notes.md" in matches - - def test_remove_tag_drops_entry(self, metadata): - metadata.add_tag("TestMetaData/notes.md", "flagged") - metadata.remove_tag("TestMetaData/notes.md", "flagged") - matches = metadata.find("tags", json.dumps("flagged")) - assert "TestMetaData/notes.md" not in matches - - def test_remove_tag_idempotent_when_missing(self, metadata): - # No sidecar yet; removing a tag is a no-op success. - metadata.remove_tag("TestMetaData/notes.md", "nope") - - def test_remove_field_is_no_op_when_absent(self, metadata): - # Returns success without touching disk. - metadata.remove("TestMetaData/notes.md", "nope") - - def test_list_returns_empty_object_when_no_sidecar(self, metadata): - result = metadata.list("TestMetaData/notes.md") - assert result == {} - - def test_set_field_visible_through_file_read(self, metadata, file): - metadata.set("TestMetaData/notes.md", "priority", json.dumps("high")) - sidecar_text = file.read("TestMetaData/notes.md.cel")["content"] - assert "priority" in sidecar_text - assert "high" in sidecar_text - - def test_invalid_resource_key_fails(self, metadata): - with pytest.raises(CelError): - metadata.set("\\invalid", "priority", json.dumps("high")) - - def test_find_with_non_scalar_value_fails(self, metadata): - with pytest.raises(CelError): - metadata.find("tags", json.dumps(["a", "b"])) - - -class TestMetaDataCheckProject: - - def test_clean_project_returns_empty_lists(self, metadata): - report = metadata.check_project() - # The autouse workspace fixture leaves only TestMetaData behind, which - # doesn't carry references. Any unrelated project state shows up here - # too; we assert only the report shape so the test is robust to other - # content in the demo project. - assert isinstance(report.get("brokenReferences"), list) - assert isinstance(report.get("orphanSidecars"), list) - assert isinstance(report.get("brokenSidecars"), list) - - def test_broken_reference_detected_after_target_deleted_with_break_references(self, metadata, file, explorer): - # Create a source that references a target, then delete the target - # under break_references so the reference is left dangling. The check - # tool reports the resulting broken reference. - file.write("TestMetaData/source.md", "Refers to \"project:TestMetaData/target.md\".\n") - file.write("TestMetaData/target.md", "Target body.\n") - - explorer.delete("TestMetaData/target.md", reference_policy="break_references") - - report = metadata.check_project() - broken = [ - entry for entry in report.get("brokenReferences", []) - if entry["missingTarget"] == "TestMetaData/target.md" - ] - assert len(broken) == 1 - assert broken[0]["source"] == "TestMetaData/source.md" - - def test_orphan_sidecar_detected_when_parent_missing(self, metadata, file): - # Write a sidecar whose parent does not exist on disk. The pairing - # pass classifies it as an orphan. - file.write( - "TestMetaData/orphaned.png.cel", - "+++\ntags = [\"orphan\"]\n+++\n", - ) - report = metadata.check_project() - assert "TestMetaData/orphaned.png.cel" in report.get("orphanSidecars", []) - - def test_broken_sidecar_detected_when_frontmatter_unparseable(self, metadata, file): - # Write a sidecar whose frontmatter is malformed TOML between fences. - file.write("TestMetaData/notes.md.cel", "+++\nthis is not = valid // toml\n+++\n") - report = metadata.check_project() - assert "TestMetaData/notes.md.cel" in report.get("brokenSidecars", []) - - def test_move_preserves_invariant(self, metadata, explorer, file): - # A reference rewrite during a move must leave the project in a - # consistent state — no broken references remain. - file.write("TestMetaData/src.md", "Refers to \"project:TestMetaData/old.md\".\n") - file.write("TestMetaData/old.md", "Old body.\n") - - explorer.move("TestMetaData/old.md", "TestMetaData/new.md") - - report = metadata.check_project() - broken = [ - entry for entry in report.get("brokenReferences", []) - if entry["source"].startswith("TestMetaData/") - or entry["missingTarget"].startswith("TestMetaData/") - ] - assert broken == [], f"Move broke references: {broken}" - - def test_delete_without_referencers_leaves_clean_state(self, metadata, explorer, file): - # Delete a resource that nothing references; the broken-references list - # should not gain any entries scoped to our test folder. - file.write("TestMetaData/standalone.md", "No incoming references.\n") - explorer.delete("TestMetaData/standalone.md") - - report = metadata.check_project() - broken = [ - entry for entry in report.get("brokenReferences", []) - if entry["missingTarget"].startswith("TestMetaData/") - ] - assert broken == [] diff --git a/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs new file mode 100644 index 000000000..458528acc --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs @@ -0,0 +1,48 @@ +using Celbridge.Commands; +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Appends a tag to the parent resource's .cel sidecar tags list, creating +/// the sidecar if missing. Idempotent. +/// +public sealed class AddTagCommand : CommandBase, IAddTagCommand +{ + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + + public ResourceKey Resource { get; set; } + public string Tag { get; set; } = string.Empty; + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public AddTagCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + var tag = Tag; + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + return await sidecarService.MutateFrontmatterAsync( + Resource, + dict => + { + var existing = dict.TryGetValue(SidecarHelper.TagsFieldName, out var value) + ? SidecarHelper.ExtractStringList(value) + : Array.Empty(); + + if (existing.Contains(tag, StringComparer.Ordinal)) + { + return; + } + + var updated = new List(existing.Count + 1); + updated.AddRange(existing); + updated.Add(tag); + dict[SidecarHelper.TagsFieldName] = updated; + }); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index d1196de26..d40ab6ccb 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -2,6 +2,7 @@ using Celbridge.Commands; using Celbridge.Dialog; using Celbridge.Logging; +using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -55,9 +56,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; - var metaData = workspaceService.ResourceMetaData; - - await metaData.WaitUntilReadyAsync(); + var scanner = workspaceService.ResourceScanner; // Phase A: aggregate referencers external to the batch. References // from one doomed resource to another are filtered out so an internal @@ -96,11 +95,11 @@ bool IsInsideBatch(ResourceKey candidate) var keysToCheck = new List { resource }; if (folderResources.Contains(resource)) { - // The reference graph keys descendants by file, not by folder. - // Walk every indexed target and pull in those that live under + // The reference scanner keys descendants by file, not by folder. + // Walk every referenced target and pull in those that live under // this folder so we surface every incoming reference that the // recursive delete will leave dangling. - foreach (var target in metaData.GetAllReferencedTargets()) + foreach (var target in await scanner.FindAllReferencedTargetsAsync()) { if (target.IsDescendantOf(resource)) { @@ -117,7 +116,7 @@ bool IsInsideBatch(ResourceKey candidate) foreach (var key in keysToCheck) { var perKeyReferencers = new List(); - foreach (var referencer in metaData.GetReferencers(key)) + foreach (var referencer in await scanner.FindReferencersAsync(key)) { if (!IsInsideBatch(referencer)) { @@ -190,7 +189,7 @@ bool IsInsideBatch(ResourceKey candidate) } var resourcePath = resolveResult.Value; - bool sidecarPresent = SidecarExistsForResource(resourceRegistry, resource); + bool sidecarPresent = SidecarExistsForResource(workspaceService, resource); Result deleteResult; if (File.Exists(resourcePath)) @@ -320,15 +319,15 @@ private static bool IsFolderResource(IResourceRegistry registry, ResourceKey res return Directory.Exists(resolveResult.Value); } - private static bool SidecarExistsForResource(IResourceRegistry registry, ResourceKey resource) + private static bool SidecarExistsForResource(IWorkspaceService workspaceService, ResourceKey resource) { - if (resource.IsEmpty) + var sidecarKeyResult = workspaceService.SidecarService.GetSidecarKey(resource); + if (sidecarKeyResult.IsFailure) { return false; } - var sidecarKey = new ResourceKey(resource.Root + ":" + resource.Path + Helpers.SidecarHelper.Extension); - var resolveResult = registry.ResolveResourcePath(sidecarKey); + var resolveResult = workspaceService.ResourceService.Registry.ResolveResourcePath(sidecarKeyResult.Value); if (resolveResult.IsFailure) { return false; diff --git a/Source/Workspace/Celbridge.Resources/Commands/FindTagCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/FindTagCommand.cs new file mode 100644 index 000000000..8d44a92f7 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/FindTagCommand.cs @@ -0,0 +1,36 @@ +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Enumerates every paired-sidecar parent resource whose .cel frontmatter +/// "tags" list contains the given tag value. +/// +public sealed class FindTagCommand : CommandBase, IFindTagCommand +{ + public override CommandFlags CommandFlags => CommandFlags.SuppressCommandLog; + + public string Tag { get; set; } = string.Empty; + + public IReadOnlyList ResultValue { get; private set; } = Array.Empty(); + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public FindTagCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + if (string.IsNullOrEmpty(Tag)) + { + return Result.Fail("Tag must be a non-empty string."); + } + + var scanner = _workspaceWrapper.WorkspaceService.ResourceScanner; + ResultValue = await scanner.FindByTagAsync(Tag); + return Result.Ok(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs new file mode 100644 index 000000000..e2eefeaf3 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs @@ -0,0 +1,58 @@ +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Reads a single frontmatter field through the sidecar data service. +/// SuppressCommandLog because reads should not clutter the command log. +/// +public sealed class GetFieldCommand : CommandBase, IGetFieldCommand +{ + public override CommandFlags CommandFlags => CommandFlags.SuppressCommandLog; + + public ResourceKey Resource { get; set; } + public string Field { get; set; } = string.Empty; + + public object ResultValue { get; private set; } = new object(); + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public GetFieldCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + if (string.IsNullOrEmpty(Field)) + { + return Result.Fail("Field must be a non-empty string."); + } + + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var readResult = await sidecarService.ReadAsync(Resource); + if (readResult.IsFailure) + { + return Result.Fail(readResult); + } + var read = readResult.Value; + + if (read.Outcome == SidecarReadOutcome.NoSidecar) + { + return Result.Fail($"Resource '{Resource}' has no sidecar."); + } + if (read.Outcome == SidecarReadOutcome.Broken) + { + return Result.Fail($"Sidecar for resource '{Resource}' is broken: {read.FailureMessage}"); + } + + if (!read.Content!.Frontmatter.TryGetValue(Field, out var value)) + { + return Result.Fail($"Field '{Field}' is not set on resource '{Resource}'."); + } + + ResultValue = value; + return Result.Ok(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs new file mode 100644 index 000000000..fc0d73d04 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs @@ -0,0 +1,59 @@ +using System.Text; +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Returns the resource's full sidecar frontmatter plus the ordered list of +/// block descriptors in one call. +/// +public sealed class GetInfoCommand : CommandBase, IGetInfoCommand +{ + public override CommandFlags CommandFlags => CommandFlags.SuppressCommandLog; + + public ResourceKey Resource { get; set; } + + public GetInfoResult ResultValue { get; private set; } = new GetInfoResult( + new Dictionary(), + Array.Empty()); + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public GetInfoCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var readResult = await sidecarService.ReadAsync(Resource); + if (readResult.IsFailure) + { + return Result.Fail(readResult); + } + var read = readResult.Value; + + if (read.Outcome == SidecarReadOutcome.NoSidecar) + { + // Empty result already set on ResultValue; signal success so callers + // can iterate uniformly across "no sidecar" and "has sidecar". + return Result.Ok(); + } + + if (read.Outcome == SidecarReadOutcome.Broken) + { + return Result.Fail($"Sidecar for resource '{Resource}' is broken: {read.FailureMessage}. Use file_read for raw inspection or data_check_project for the system-level view."); + } + + var content = read.Content!; + var fields = new Dictionary(content.Frontmatter, StringComparer.Ordinal); + var blocks = content.Blocks + .Select(b => new SidecarBlockDescriptor(b.Name, Encoding.UTF8.GetByteCount(b.Content))) + .ToList(); + + ResultValue = new GetInfoResult(fields, blocks); + return Result.Ok(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs index 91a18b794..59ea5d14e 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs @@ -4,8 +4,10 @@ namespace Celbridge.Resources.Commands; /// -/// Builds a ProjectCheckReport from the metadata service's reference graph and -/// the registry's sidecar pairing snapshot. Pure read; no FS mutation. +/// Builds a ProjectCheckReport via on-demand scanning of the project's text +/// files plus the registry's sidecar pairing snapshot. Pure read; no FS +/// mutation. Performance is bounded by scan time; there is no precomputed +/// reference index waiting in memory. /// public sealed class ProjectCheckCommand : CommandBase, IProjectCheckCommand { @@ -25,22 +27,17 @@ public override async Task ExecuteAsync() { var workspaceService = _workspaceWrapper.WorkspaceService; var registry = workspaceService.ResourceService.Registry; - var metaData = workspaceService.ResourceMetaData; - - // Reference graph and sidecar report are both in-memory after the - // initial rebuild completes. Block the call on readiness so the check - // never returns a partial view of the project. - await metaData.WaitUntilReadyAsync(); + var scanner = workspaceService.ResourceScanner; var brokenReferences = new List(); - foreach (var target in metaData.GetAllReferencedTargets()) + foreach (var target in await scanner.FindAllReferencedTargetsAsync()) { var resourceResult = registry.GetResource(target); if (resourceResult.IsSuccess) { continue; } - foreach (var source in metaData.GetReferencers(target)) + foreach (var source in await scanner.FindReferencersAsync(target)) { brokenReferences.Add(new BrokenReference(source, target)); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/ReadBlockCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ReadBlockCommand.cs new file mode 100644 index 000000000..0048a82b8 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/ReadBlockCommand.cs @@ -0,0 +1,59 @@ +using Celbridge.Commands; +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Reads a named content block through the sidecar data service. +/// +public sealed class ReadBlockCommand : CommandBase, IReadBlockCommand +{ + public override CommandFlags CommandFlags => CommandFlags.SuppressCommandLog; + + public ResourceKey Resource { get; set; } + public string BlockId { get; set; } = string.Empty; + + public string ResultValue { get; private set; } = string.Empty; + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public ReadBlockCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + if (!SidecarHelper.IsValidBlockName(BlockId)) + { + return Result.Fail($"block_id '{BlockId}' does not match the block-naming rules."); + } + + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var readResult = await sidecarService.ReadAsync(Resource); + if (readResult.IsFailure) + { + return Result.Fail(readResult); + } + var read = readResult.Value; + + if (read.Outcome == SidecarReadOutcome.NoSidecar) + { + return Result.Fail($"Resource '{Resource}' has no sidecar."); + } + if (read.Outcome == SidecarReadOutcome.Broken) + { + return Result.Fail($"Sidecar for resource '{Resource}' is broken: {read.FailureMessage}"); + } + + var block = read.Content!.Blocks.FirstOrDefault(b => string.Equals(b.Name, BlockId, StringComparison.Ordinal)); + if (block is null) + { + return Result.Fail($"Block '{BlockId}' is not present on resource '{Resource}'."); + } + + ResultValue = block.Content; + return Result.Ok(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs new file mode 100644 index 000000000..5673d0c0c --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs @@ -0,0 +1,42 @@ +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Removes a named content block from the parent resource's .cel sidecar. +/// No-op when the block or the sidecar is absent. +/// +public sealed class RemoveBlockCommand : CommandBase, IRemoveBlockCommand +{ + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + + public ResourceKey Resource { get; set; } + public string BlockId { get; set; } = string.Empty; + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public RemoveBlockCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + var blockId = BlockId; + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + return await sidecarService.MutateBlocksAsync( + Resource, + blocks => + { + for (int i = blocks.Count - 1; i >= 0; i--) + { + if (string.Equals(blocks[i].Name, blockId, StringComparison.Ordinal)) + { + blocks.RemoveAt(i); + } + } + }, + createIfMissing: false); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs new file mode 100644 index 000000000..e0e4afada --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs @@ -0,0 +1,31 @@ +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Removes a single frontmatter field through the sidecar data service. +/// +public sealed class RemoveFieldCommand : CommandBase, IRemoveFieldCommand +{ + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + + public ResourceKey Resource { get; set; } + public string Field { get; set; } = string.Empty; + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public RemoveFieldCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + return await sidecarService.MutateFrontmatterAsync( + Resource, + dict => dict.Remove(Field), + createIfMissing: false); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs new file mode 100644 index 000000000..335d6e3e8 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs @@ -0,0 +1,55 @@ +using Celbridge.Commands; +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Removes a tag from the parent resource's .cel sidecar tags list. Idempotent. +/// +public sealed class RemoveTagCommand : CommandBase, IRemoveTagCommand +{ + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + + public ResourceKey Resource { get; set; } + public string Tag { get; set; } = string.Empty; + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public RemoveTagCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + var tag = Tag; + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + return await sidecarService.MutateFrontmatterAsync( + Resource, + dict => + { + if (!dict.TryGetValue(SidecarHelper.TagsFieldName, out var value)) + { + return; + } + + var existing = SidecarHelper.ExtractStringList(value); + if (!existing.Contains(tag, StringComparer.Ordinal)) + { + return; + } + + var updated = existing.Where(t => !string.Equals(t, tag, StringComparison.Ordinal)).ToList(); + if (updated.Count == 0) + { + dict.Remove(SidecarHelper.TagsFieldName); + } + else + { + dict[SidecarHelper.TagsFieldName] = updated; + } + }, + createIfMissing: false); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs new file mode 100644 index 000000000..abe550e48 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs @@ -0,0 +1,44 @@ +using Celbridge.Commands; +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Writes a single frontmatter field through the sidecar data service. +/// Sets CommandFlags.UpdateResources so the registry refreshes after the +/// write, making the new sidecar visible to subsequent reads (data_find_tag, +/// data_check_project, the rename cascade). +/// +public sealed class SetFieldCommand : CommandBase, ISetFieldCommand +{ + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + + public ResourceKey Resource { get; set; } + public string Field { get; set; } = string.Empty; + public object? Value { get; set; } + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public SetFieldCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + if (Value is null) + { + return Result.Fail("Value is null."); + } + if (!SidecarHelper.IsIndexableValue(Value)) + { + return Result.Fail($"Field '{Field}' value is not indexable. Only scalar (string/number/bool) and list-of-scalar values are supported."); + } + + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + return await sidecarService.MutateFrontmatterAsync( + Resource, + dict => dict[Field] = Value!); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs new file mode 100644 index 000000000..e07f5b96d --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs @@ -0,0 +1,55 @@ +using Celbridge.Commands; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Commands; + +/// +/// Creates or overwrites a named content block in the parent resource's +/// .cel sidecar. +/// +public sealed class WriteBlockCommand : CommandBase, IWriteBlockCommand +{ + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + + public ResourceKey Resource { get; set; } + public string BlockId { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + + private readonly IWorkspaceWrapper _workspaceWrapper; + + public WriteBlockCommand(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public override async Task ExecuteAsync() + { + var blockId = BlockId; + var content = Content; + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + return await sidecarService.MutateBlocksAsync( + Resource, + blocks => + { + var index = -1; + for (int i = 0; i < blocks.Count; i++) + { + if (string.Equals(blocks[i].Name, blockId, StringComparison.Ordinal)) + { + index = i; + break; + } + } + + var updated = new SidecarBlock(blockId, content); + if (index >= 0) + { + blocks[index] = updated; + } + else + { + blocks.Add(updated); + } + }); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs index 0f81c8435..ff3212712 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.RegularExpressions; using Celbridge.Logging; using Tomlyn; using Tomlyn.Model; @@ -5,130 +7,302 @@ namespace Celbridge.Resources.Helpers; /// -/// The parsed result of a sidecar file: the frontmatter dictionary and the body string. -/// -public record SidecarParseResult( - IReadOnlyDictionary Frontmatter, - string Body); - -/// -/// Parse, compose, and on-disk inspection for the TOML-frontmatter-plus-body -/// sidecar format used by .cel files. The frontmatter is fenced by lines -/// containing only +++; the body that follows is opaque text. +/// Parse, compose, and on-disk inspection for the .cel sidecar format: TOML +/// frontmatter at the top, optionally followed by zero-or-more named content +/// blocks delimited by lines of the form '+++ "block-name"'. Format constants +/// and pure utility helpers used by the rest of the resources subsystem live +/// here; the workspace-scoped ISidecarService exposes the surface that crosses +/// project boundaries. /// public static class SidecarHelper { /// - /// The file extension for sidecar files. + /// The file extension used for sidecar files. /// public const string Extension = ".cel"; /// - /// The fence delimiter for the frontmatter section. + /// The standardised list-of-string frontmatter field that the data tools + /// surface as tags. /// - public const string Delimiter = "+++"; + public const string TagsFieldName = "tags"; + + // Fence line: '+++' then one space, then a double-quoted block name, with + // optional trailing whitespace. The block name is lowercase letters and + // digits with optional dotted segments separated by '.', and hyphens + // permitted inside a segment. + private static readonly Regex FenceLineRegex = new( + @"^\+\+\+\s+""([a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*)""\s*$", + RegexOptions.Compiled); + + // Block name regex: same shape as the fence's capture group, applied to + // candidate names at write time so a malformed block ID is caught before + // it lands on disk. + private static readonly Regex BlockNameRegex = new( + @"^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$", + RegexOptions.Compiled); /// - /// Parses sidecar content into its frontmatter dictionary and body string. - /// Frontmatter is parsed as TOML; the body is returned verbatim. - /// Fails if the leading +++ fence is missing or no closing fence is found. + /// True when the candidate string matches the block-naming rules + /// (lowercase letters, digits, hyphens, dotted segments). /// - public static Result Parse(string text) + public static bool IsValidBlockName(string name) { - if (text is null) + return !string.IsNullOrEmpty(name) + && BlockNameRegex.IsMatch(name); + } + + /// + /// True when the value can be written through the structured frontmatter + /// surface: scalars (string, numeric, bool, datetime) and lists of those. + /// Nested objects and mixed lists are rejected. + /// + public static bool IsIndexableValue(object? value) + { + if (value is null) + { + return false; + } + if (IsScalar(value)) { - return Result.Fail("Sidecar content is null."); + return true; } + if (value is System.Collections.IEnumerable enumerable + && value is not string) + { + foreach (var item in enumerable) + { + if (item is null + || !IsScalar(item)) + { + return false; + } + } + return true; + } + return false; + } - // Strip an optional UTF-8 BOM so the fence detector sees the +++ on the - // first byte. Tomlyn is BOM-tolerant; this normalises the body too. - if (text.Length > 0 - && text[0] == '') + /// + /// Extracts a string list from a frontmatter value (e.g. the tags field). + /// Returns an empty list when the value is missing or not a list-of-string. + /// + public static IReadOnlyList ExtractStringList(object? value) + { + var result = new List(); + if (value is null + || value is string) { - text = text.Substring(1); + return result; } + if (value is System.Collections.IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item is string s) + { + result.Add(s); + } + } + } + return result; + } - // Find the opening fence. The fence must be the first non-empty content - // in the file; leading whitespace before the fence is not permitted. - var openingFenceLineEnd = FindFenceLineEnd(text, startIndex: 0); - if (openingFenceLineEnd < 0) + private static bool IsScalar(object value) + { + return value is string + || value is bool + || value is long + || value is int + || value is double + || value is float + || value is decimal + || value is DateTime + || value is DateTimeOffset + || value is DateOnly + || value is TimeOnly; + } + + /// + /// Parses sidecar content into its frontmatter dictionary and ordered + /// block list. Frontmatter is parsed as TOML; block bodies are opaque text. + /// Fails if the TOML prefix is malformed or any block name appears twice. + /// + public static Result Parse(string text) + { + if (text is null) { - return Result.Fail("Sidecar content does not start with a '+++' fence line."); + return Result.Fail("Sidecar content is null."); } - // The frontmatter starts immediately after the opening fence's line terminator. - var frontmatterStart = openingFenceLineEnd; - var closingFenceStart = FindClosingFence(text, frontmatterStart); - if (closingFenceStart < 0) + // Strip an optional UTF-8 BOM so the first byte is the first content + // character. Tomlyn is BOM-tolerant; this normalises the block split. + if (text.Length > 0 + && text[0] == '') { - return Result.Fail("Sidecar content has no closing '+++' fence."); + text = text.Substring(1); } - var frontmatterToml = text.Substring(frontmatterStart, closingFenceStart - frontmatterStart); + var lines = SplitLines(text); - // Advance past the closing fence line to find the body start. - var closingFenceLineEnd = FindFenceLineEnd(text, closingFenceStart); - if (closingFenceLineEnd < 0) + // Walk the lines once and record the index of every fence line. The + // frontmatter spans lines [0, firstFence); each block spans the lines + // from (fence + 1) to the next fence (or end of input). + var fenceIndexes = new List(); + var fenceNames = new List(); + for (int i = 0; i < lines.Count; i++) { - // The closing fence is the final line of the file with no trailing - // line terminator. The body is empty. - closingFenceLineEnd = text.Length; + var match = FenceLineRegex.Match(lines[i].Text); + if (match.Success) + { + fenceIndexes.Add(i); + fenceNames.Add(match.Groups[1].Value); + } } - var body = text.Substring(closingFenceLineEnd); + var frontmatterEnd = fenceIndexes.Count > 0 ? fenceIndexes[0] : lines.Count; + var frontmatterText = JoinLines(lines, 0, frontmatterEnd); - var parseResult = ParseFrontmatterToml(frontmatterToml); + var parseResult = ParseFrontmatterToml(frontmatterText); if (parseResult.IsFailure) { return Result.Fail(parseResult); } + var frontmatter = parseResult.Value; - return Result.Ok(new SidecarParseResult(parseResult.Value, body)); + var blocks = new List(fenceIndexes.Count); + var seenNames = new HashSet(StringComparer.Ordinal); + for (int b = 0; b < fenceIndexes.Count; b++) + { + var name = fenceNames[b]; + if (!seenNames.Add(name)) + { + return Result.Fail($"Sidecar contains duplicate block name '{name}'."); + } + + var contentStart = fenceIndexes[b] + 1; + var contentEnd = b + 1 < fenceIndexes.Count ? fenceIndexes[b + 1] : lines.Count; + var content = JoinLines(lines, contentStart, contentEnd); + + // Block content is line-oriented: the terminator that follows the + // last content line is a separator (between blocks or to EOF), not + // part of the block's semantic content. Stripping a single trailing + // \n or \r\n makes the SidecarBlock.Content value position- + // independent so its byte count is stable as adjacent blocks are + // added or removed. + content = StripTrailingTerminator(content); + blocks.Add(new SidecarBlock(name, content)); + } + + return Result.Ok(new SidecarContent(frontmatter, blocks)); + } + + /// + /// Composes a sidecar text from the frontmatter dictionary and named blocks. + /// The output is the inverse of Parse for any cleanly-parsed input. + /// + public static string Compose(SidecarContent content) + { + ArgumentNullException.ThrowIfNull(content); + return Compose(content.Frontmatter, content.Blocks); } /// - /// Composes a sidecar text from a frontmatter dictionary and a body string. - /// The frontmatter is emitted as TOML between '+++' fence lines; the body - /// follows the closing fence. + /// Composes a sidecar text from a frontmatter dictionary and an ordered + /// list of named blocks. /// - public static string Compose(IReadOnlyDictionary frontmatter, string body) + public static string Compose( + IReadOnlyDictionary frontmatter, + IReadOnlyList blocks) { ArgumentNullException.ThrowIfNull(frontmatter); - body ??= string.Empty; + blocks ??= Array.Empty(); - var tomlTable = new TomlTable(); - foreach (var (key, value) in frontmatter) + var builder = new StringBuilder(); + + if (frontmatter.Count > 0) { - tomlTable[key] = ConvertToTomlValue(value); + var tomlTable = new TomlTable(); + foreach (var (key, value) in frontmatter) + { + tomlTable[key] = ConvertToTomlValue(value); + } + + var tomlText = Toml.FromModel(tomlTable); + // Toml.FromModel emits a trailing newline. Trim it so the join with + // the first fence (if any) is predictable; we add an explicit + // separator below. + tomlText = tomlText.TrimEnd('\r', '\n'); + builder.Append(tomlText); } - var tomlText = Toml.FromModel(tomlTable); + for (int i = 0; i < blocks.Count; i++) + { + var block = blocks[i]; + if (!IsValidBlockName(block.Name)) + { + throw new ArgumentException($"Block name '{block.Name}' does not match the block-naming rules."); + } - // Toml.FromModel emits a trailing newline; trim trailing whitespace so - // the composed output has predictable fence-line spacing. - tomlText = tomlText.TrimEnd('\r', '\n'); + // Each fence line starts on its own line. If we already wrote + // frontmatter or a prior block, ensure a newline before this fence. + if (builder.Length > 0 + && builder[builder.Length - 1] != '\n') + { + builder.Append('\n'); + } - var separator = "\n"; - var hasBody = body.Length > 0; + builder.Append("+++ \""); + builder.Append(block.Name); + builder.Append("\"\n"); + builder.Append(block.Content); + + // Ensure each non-empty block contributes a trailing newline so + // the next fence (or EOF) starts on its own line and so the + // block's on-disk byte footprint is independent of position. + // Parse strips this terminator back off, restoring round-trip + // equivalence between "X" and "X\n" input. + if (block.Content.Length > 0 + && block.Content[block.Content.Length - 1] != '\n') + { + builder.Append('\n'); + } + } - var composed = Delimiter + separator; - if (tomlText.Length > 0) + // When only frontmatter is present, leave a single trailing newline so + // the file ends on a newline boundary. When blocks are present, each + // block now guarantees its own terminator (see the per-block append + // above), so the file already ends on \n. + if (blocks.Count == 0 + && builder.Length > 0 + && builder[builder.Length - 1] != '\n') { - composed += tomlText + separator; + builder.Append('\n'); } - composed += Delimiter + separator; - if (hasBody) + + return builder.ToString(); + } + + // Removes a single trailing line terminator (\r\n or \n) from a block + // content slice extracted by Parse. The terminator is the separator + // between blocks (or to EOF), not part of the block's content. + private static string StripTrailingTerminator(string content) + { + if (content.EndsWith("\r\n", StringComparison.Ordinal)) { - composed += body; + return content.Substring(0, content.Length - 2); } - - return composed; + if (content.EndsWith("\n", StringComparison.Ordinal)) + { + return content.Substring(0, content.Length - 1); + } + return content; } /// /// Reads a sidecar file at absolutePath and classifies it as Healthy - /// (frontmatter parses cleanly) or Broken (any parse or read failure). - /// The bytes on disk are never modified. + /// (parses cleanly) or Broken (any parse or read failure). The bytes on + /// disk are never modified. /// public static SidecarStatus Inspect(string absolutePath, ILogger logger) { @@ -146,115 +320,75 @@ public static SidecarStatus Inspect(string absolutePath, ILogger logger) var parseResult = Parse(text); if (parseResult.IsFailure) { - logger.LogWarning($"sidecar pairing: '{absolutePath}' has unparseable frontmatter"); + logger.LogWarning($"sidecar pairing: '{absolutePath}' has unparseable content"); return SidecarStatus.Broken; } return SidecarStatus.Healthy; } - // Returns the position immediately after the line terminator of the fence - // line that starts at startIndex, or -1 if no fence line is found there. - // A fence line is "+++" followed by an optional line terminator. - private static int FindFenceLineEnd(string text, int startIndex) - { - if (startIndex < 0 - || startIndex > text.Length) - { - return -1; - } - - if (startIndex + Delimiter.Length > text.Length) - { - return -1; - } - - if (string.CompareOrdinal(text, startIndex, Delimiter, 0, Delimiter.Length) != 0) - { - return -1; - } + // One physical line plus the line terminator that follows it. The + // terminator is preserved so JoinLines reproduces the original bytes. + private readonly record struct PhysicalLine(string Text, string Terminator); - int after = startIndex + Delimiter.Length; - - // Allow trailing whitespace on the fence line up to but not including a - // line terminator. Anything else after the +++ is not a fence line. - while (after < text.Length) + private static List SplitLines(string text) + { + var lines = new List(); + int start = 0; + int i = 0; + while (i < text.Length) { - var current = text[after]; - if (current == '\r' - || current == '\n') - { - break; - } - if (current == ' ' - || current == '\t') + var c = text[i]; + if (c == '\r') { - after++; + var lineText = text.Substring(start, i - start); + string terminator; + if (i + 1 < text.Length + && text[i + 1] == '\n') + { + terminator = "\r\n"; + i += 2; + } + else + { + terminator = "\r"; + i += 1; + } + lines.Add(new PhysicalLine(lineText, terminator)); + start = i; continue; } - return -1; - } - - if (after >= text.Length) - { - return after; - } - - if (text[after] == '\r') - { - after++; - if (after < text.Length - && text[after] == '\n') + if (c == '\n') { - after++; + var lineText = text.Substring(start, i - start); + lines.Add(new PhysicalLine(lineText, "\n")); + i += 1; + start = i; + continue; } - return after; + i++; } - - if (text[after] == '\n') + if (start < text.Length) { - after++; - return after; + lines.Add(new PhysicalLine(text.Substring(start), string.Empty)); } - - return after; + return lines; } - // Returns the start position of the next fence line at column 0, or -1 if - // no closing fence is found. - private static int FindClosingFence(string text, int searchStart) + private static string JoinLines(List lines, int startInclusive, int endExclusive) { - int lineStart = searchStart; - while (lineStart < text.Length) + if (startInclusive >= endExclusive) { - if (FindFenceLineEnd(text, lineStart) >= 0) - { - return lineStart; - } - - // Advance to the next line. - int newlineIndex = text.IndexOfAny(new[] { '\r', '\n' }, lineStart); - if (newlineIndex < 0) - { - return -1; - } - - if (text[newlineIndex] == '\r') - { - if (newlineIndex + 1 < text.Length - && text[newlineIndex + 1] == '\n') - { - lineStart = newlineIndex + 2; - continue; - } - lineStart = newlineIndex + 1; - continue; - } - - lineStart = newlineIndex + 1; + return string.Empty; } - return -1; + var builder = new StringBuilder(); + for (int i = startInclusive; i < endExclusive; i++) + { + builder.Append(lines[i].Text); + builder.Append(lines[i].Terminator); + } + return builder.ToString(); } private static Result> ParseFrontmatterToml(string tomlText) diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index 9c833d696..7bafb665f 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -22,7 +22,8 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // @@ -46,6 +47,18 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs b/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs index 44189d9f6..2336c0d66 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs @@ -11,7 +11,7 @@ public sealed partial record ParsedReference(int StartIndex, int EndIndex, Resou /// /// Shared rules for parsing "project:" reference literals in text. The -/// detection pass in and the rewrite cascade in +/// detection pass in and the rewrite cascade in /// both consume this module so they cannot /// drift on what constitutes a valid reference. A symmetry test in /// Celbridge.Tests asserts that every position the scanner records is a diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 4bbd75e66..73e118f5c 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -268,11 +268,12 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey var descendantRemovedMessage = new ResourceDeletedMessage(key); _messengerService.Send(descendantRemovedMessage); } - var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; - await metaData.WaitForPendingUpdatesAsync(); } - return Result.Ok(new MoveResult(updatedReferencers, skippedReferencers, sidecarOutcome)); + await Task.CompletedTask; + + var moveResult = new MoveResult(updatedReferencers, skippedReferencers, sidecarOutcome); + return moveResult; } public async Task> CopyAsync(ResourceKey source, ResourceKey destination) @@ -340,13 +341,10 @@ public async Task> CopyAsync(ResourceKey source, ResourceKey var sidecarOutcome = TryCascadeSidecarCopy(source, destination); - if (destination.Root == ResourceKey.DefaultRoot) - { - var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; - await metaData.WaitForPendingUpdatesAsync(); - } + await Task.CompletedTask; - return Result.Ok(new CopyResult(sidecarOutcome)); + var copyResult = new CopyResult(sidecarOutcome); + return copyResult; } public async Task> DeleteAsync(ResourceKey source) @@ -424,11 +422,12 @@ public async Task> DeleteAsync(ResourceKey source) var descendantRemovedMessage = new ResourceDeletedMessage(key); _messengerService.Send(descendantRemovedMessage); } - var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; - await metaData.WaitForPendingUpdatesAsync(); } - return Result.Ok(new DeleteResult(sidecarOutcome)); + await Task.CompletedTask; + + var deleteResult = new DeleteResult(sidecarOutcome); + return deleteResult; } // Returns the resource keys of every file inside a folder that exists on @@ -499,18 +498,10 @@ private async Task RewriteReferencesForMoveAsync( List updatedReferencers, List skippedReferencers) { - var metaData = _workspaceWrapper.WorkspaceService.ResourceMetaData; - await metaData.WaitUntilReadyAsync(); - - // Drain any watcher events queued by prior operations (e.g. the delete - // half of an undone copy) before reading the referencer list. Without - // this, GetReferencers can return a stale entry for a file that was - // just deleted; the cascade then tries to read it and surfaces a - // phantom ReadFailed in SkippedReferencers. - await metaData.WaitForPendingUpdatesAsync(); + var scanner = _workspaceWrapper.WorkspaceService.ResourceScanner; var referencerSet = new HashSet(); - foreach (var referencer in metaData.GetReferencers(source)) + foreach (var referencer in await scanner.FindReferencersAsync(source)) { referencerSet.Add(referencer); } @@ -520,11 +511,11 @@ private async Task RewriteReferencesForMoveAsync( // Children of source contribute prefix-form references; gather every // referencer of every descendant target so the prefix rewrite reaches // each file that names a child key. - foreach (var target in metaData.GetAllReferencedTargets()) + foreach (var target in await scanner.FindAllReferencedTargetsAsync()) { if (target.IsDescendantOf(source)) { - foreach (var referencer in metaData.GetReferencers(target)) + foreach (var referencer in await scanner.FindReferencersAsync(target)) { referencerSet.Add(referencer); } @@ -542,19 +533,18 @@ private async Task RewriteReferencesForMoveAsync( // Per-referencer failures (typically file locked by an external editor // for a moment, or marked read-only by the user) are logged and skipped // rather than aborting the whole move. The parent move still completes; - // metadata_check_project (Phase 5) surfaces any references that - // remained stale, and a subsequent rerun of the rename picks up the - // residual rewrites because the FS layer is idempotent under partial - // completion (the source bytes are still in place between the rewrite - // loop and the parent move, and the reference graph re-derives on the - // next watcher event). + // data_check_project surfaces any references that remained stale, and a + // subsequent rerun of the rename picks up the residual rewrites because + // the FS layer is idempotent under partial completion (the source bytes + // are still in place between the rewrite loop and the parent move, and + // the next scanner call re-derives the referencer set). foreach (var referencer in orderedReferencers) { var readResult = await ReadAllTextAsync(referencer); if (readResult.IsFailure) { var message = $"read failed for '{referencer}'"; - _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {message}. The reference is left as-is and will surface via metadata_check_project."); + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {message}. The reference is left as-is and will surface via data_check_project."); skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadFailed, message)); continue; } @@ -575,7 +565,7 @@ private async Task RewriteReferencesForMoveAsync( // the user (or the calling agent) knows exactly why and can // decide whether to fix the permissions and rerun the rename. var classification = ClassifyReferencerWriteFailure(referencer, writeResult); - _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {classification.Message}. The reference is left as-is and will surface via metadata_check_project."); + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {classification.Message}. The reference is left as-is and will surface via data_check_project."); skippedReferencers.Add(new SkippedReferencer(referencer, classification.Reason, classification.Message)); continue; } @@ -837,16 +827,14 @@ private SidecarOutcome TryCascadeSidecarDelete(ResourceKey source) } } - // Returns a new ResourceKey with ".cel" appended to the path portion, or - // null for a root-only key (no path to append to). - private static ResourceKey? AppendSidecarSuffix(ResourceKey key) + // Returns the sidecar resource key for the given parent, or null when no + // valid sidecar key can be derived (root-only key, or the parent itself + // is already a sidecar key — in which case there is nothing to cascade). + private ResourceKey? AppendSidecarSuffix(ResourceKey key) { - if (key.IsEmpty) - { - return null; - } - - return new ResourceKey(key.Root + ":" + key.Path + SidecarHelper.Extension); + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var result = sidecarService.GetSidecarKey(key); + return result.IsSuccess ? result.Value : null; } // Clears the read-only attribute from a file before the FS layer performs diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs deleted file mode 100644 index f783f22a8..000000000 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaData.cs +++ /dev/null @@ -1,1965 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Text.Json; -using Celbridge.Logging; -using Celbridge.Resources.Helpers; -using Celbridge.Workspace; -using Tomlyn.Model; - -namespace Celbridge.Resources.Services; - -/// -/// Workspace-scoped reference graph and frontmatter index for project resources. -/// -public sealed class ResourceMetaData : IResourceMetaData, IDisposable -{ - // Files larger than this byte budget are skipped during the scan. - private const long MaxScanFileSizeBytes = 10 * 1024 * 1024; - - // The standardised list-of-string field exposed via metadata_add_tag / - // metadata_remove_tag / FindByTag. - private const string TagsField = "tags"; - - // Re-queue delays for a transient rescan failure (file locked by external - // writer, antivirus, etc.). The retry attempt counter resets when any - // watcher event arrives for the resource, so normal user activity always - // gets a fresh budget. After MaxScanRetryAttempts consecutive transient - // failures the rescan is dropped (logged) until the next watcher event. - private const int MaxScanRetryAttempts = 3; - private static readonly TimeSpan[] ScanRetryDelays = - { - TimeSpan.FromMilliseconds(500), - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5), - }; - - // Delimiter and boundary rules live in ReferenceLiteralRules so the scanner - // and the rewrite cascade in ResourceFileSystem cannot drift on what - // constitutes a valid reference. - - private readonly ILogger _logger; - private readonly IMessengerService _messengerService; - private readonly IWorkspaceWrapper _workspaceWrapper; - private readonly ITextBinarySniffer _textBinarySniffer; - - private readonly object _indexLock = new(); - private readonly Dictionary> _referencersByTarget = new(); - private readonly Dictionary> _referencesBySource = new(); - - // Per-resource snapshot of the parsed sidecar frontmatter as a top-level - // field map. Keyed on the parent resource (e.g. "foo.png"), not the sidecar - // key. Absent entries mean "no frontmatter indexed for this parent" — either - // the parent has no sidecar, the sidecar is unparseable, or the sidecar's - // top-level fields are all non-indexable shapes. - private readonly Dictionary> _frontmatterByResource = new(); - - // Inverted index from field -> indexed value -> set of resources carrying - // that value in their sidecar frontmatter. Scalar fields contribute their - // value directly; list-of-scalar fields contribute each element. Object / - // nested fields are stored in _frontmatterByResource but not indexed here. - private readonly Dictionary>> _resourcesByMetaDataField = - new(StringComparer.Ordinal); - - // The pending-rescan queue. Watcher events push file keys onto this; the - // background worker drains them. WaitForPendingUpdatesAsync awaits the - // worker when it sees a non-empty queue. - private readonly ConcurrentQueue _pendingRescans = new(); - private readonly SemaphoreSlim _workerSignal = new(0); - private Task? _workerTask; - - // Per-resource counter for consecutive transient rescan failures. - // Cleared on a successful scan, a permanent exclusion, or a new watcher - // event. Used by ScheduleRetryAfterTransientFailure to cap the retry chain. - private readonly ConcurrentDictionary _transientFailureCounts = new(); - - // Per-file mtime + size + isText stamp, captured at scan time and persisted - // to the cache file. The dictionary key is the file's resource key relative - // to the project root; the value is the stamp at the last successful scan. - // Kept in sync with the index dictionaries (entries are added when a file - // is indexed, removed when it is dropped). Guarded by _indexLock. - private readonly Dictionary _cacheStamps = new(); - - private TaskCompletionSource _readyCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - private bool _isReady; - private volatile bool _isShuttingDown; - private bool _isDisposed; - - // Debounced cache-save tracker. _isDirty is set to true on every index - // mutation; the worker checks it after draining the pending-rescan queue - // and, if enough time has passed since the last save, persists a snapshot. - private volatile bool _isDirty; - private DateTime _lastCacheSaveUtc = DateTime.MinValue; - private static readonly TimeSpan MinCacheSaveInterval = TimeSpan.FromSeconds(30); - - private record CacheStamp(long MtimeUtcTicks, long Size, bool IsText); - - public bool IsReady => _isReady; - - public ResourceMetaData( - ILogger logger, - IMessengerService messengerService, - IWorkspaceWrapper workspaceWrapper, - ITextBinarySniffer textBinarySniffer) - { - _logger = logger; - _messengerService = messengerService; - _workspaceWrapper = workspaceWrapper; - _textBinarySniffer = textBinarySniffer; - - _messengerService.Register(this, OnResourceCreated); - _messengerService.Register(this, OnResourceChanged); - _messengerService.Register(this, OnResourceDeleted); - _messengerService.Register(this, OnResourceRenamed); - - _workerTask = Task.Run(WorkerLoopAsync); - } - - public Task WaitUntilReadyAsync() - { - if (_isReady) - { - return Task.CompletedTask; - } - - return _readyCompletionSource.Task; - } - - public async Task WaitForPendingUpdatesAsync() - { - // Spin-wait while the queue still has items or the worker is mid-flight. - // The worker drains items in order, so once the queue is empty and the - // worker is idle, every prior watcher event has been applied. - while (!_pendingRescans.IsEmpty) - { - await Task.Delay(10); - } - } - - public async Task> RebuildAsync() - { - try - { - var stopwatch = Stopwatch.StartNew(); - - // WorkspaceService is available as soon as the wrapper has been populated, - // which happens before the workspace page UI loads. The rebuild can run - // during that window. WorkspaceService throws InvalidOperationException if - // no workspace is present; that's caught by the outer try. - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); - - // Try to hydrate from the persisted cache first. Entries whose - // mtime + size match the on-disk stat populate the indexes - // directly; entries that don't validate fall through to a full - // scan. The cache is host-private and may be missing or stale — - // both cases produce correct behaviour after fallback. - var cacheDocument = LoadCacheDocument(registry); - - var newReferencersByTarget = new Dictionary>(); - var newReferencesBySource = new Dictionary>(); - var newFrontmatterByResource = new Dictionary>(); - var newResourcesByMetaDataField = new Dictionary>>(StringComparer.Ordinal); - var newCacheStamps = new Dictionary(); - var transientFailures = new List(); - - int filesScanned = 0; - int filesSkipped = 0; - int filesHydratedFromCache = 0; - int referencesFound = 0; - int frontmatterEntries = 0; - - foreach (var (resourceKey, absolutePath) in files) - { - // Cache hit path: stat the file and compare against the cached - // mtime+size. A match means the cached data is good and we can - // skip the scan entirely. - if (TryHydrateFromCache( - cacheDocument, - registry, - resourceKey, - absolutePath, - newReferencersByTarget, - newReferencesBySource, - newFrontmatterByResource, - newResourcesByMetaDataField, - newCacheStamps, - ref referencesFound, - ref frontmatterEntries)) - { - filesHydratedFromCache++; - continue; - } - - var scanResult = await ScanTextFileAsync(resourceKey, absolutePath); - switch (scanResult.Outcome) - { - case ScanOutcome.TransientFailure: - // Re-queue once the swap below is complete so the worker - // picks it up. Don't include in the new index yet. - transientFailures.Add(resourceKey); - filesSkipped++; - continue; - - case ScanOutcome.ExcludedPermanently: - filesSkipped++; - // Capture the stamp even for excluded files so the - // cache can record "this is binary, don't re-sniff" - // and the next load skips the sniff cost. - TryRecordExcludedStamp(absolutePath, resourceKey, newCacheStamps); - continue; - - case ScanOutcome.Indexed: - filesScanned++; - if (scanResult.References.Count > 0) - { - referencesFound += scanResult.References.Count; - ApplyReferences(newReferencersByTarget, newReferencesBySource, resourceKey, scanResult.References); - } - - if (IsSidecarPath(absolutePath)) - { - // For sidecars, the frontmatter is indexed against - // the parent resource (the file the sidecar - // describes), not the sidecar key itself. The - // pairing pass in ResourceRegistry derives the - // parent for us — but only ".cel" files (not - // ".cel.cel") are valid sidecars. - var parentResult = registry.GetSidecarParent(resourceKey); - if (parentResult.IsSuccess) - { - var parentKey = registry.GetResourceKey(parentResult.Value); - var parsed = TryParseSidecarFrontmatter(absolutePath, scanResult.SidecarText); - if (parsed is not null - && parsed.Count > 0) - { - newFrontmatterByResource[parentKey] = parsed; - ApplyFrontmatter(newResourcesByMetaDataField, parentKey, parsed); - frontmatterEntries++; - } - } - } - - TryRecordScannedStamp(absolutePath, resourceKey, isText: true, newCacheStamps); - break; - } - } - - lock (_indexLock) - { - _referencersByTarget.Clear(); - foreach (var entry in newReferencersByTarget) - { - _referencersByTarget[entry.Key] = entry.Value; - } - _referencesBySource.Clear(); - foreach (var entry in newReferencesBySource) - { - _referencesBySource[entry.Key] = entry.Value; - } - - _frontmatterByResource.Clear(); - foreach (var entry in newFrontmatterByResource) - { - _frontmatterByResource[entry.Key] = entry.Value; - } - _resourcesByMetaDataField.Clear(); - foreach (var entry in newResourcesByMetaDataField) - { - _resourcesByMetaDataField[entry.Key] = entry.Value; - } - - _cacheStamps.Clear(); - foreach (var entry in newCacheStamps) - { - _cacheStamps[entry.Key] = entry.Value; - } - } - - stopwatch.Stop(); - - // Enqueue the transient failures after the index swap so the - // worker's retry attempts mutate the freshly-installed index, not - // the prior one. - foreach (var failed in transientFailures) - { - QueueRescan(failed); - } - - MarkReady(); - - var report = new MetaDataScanReport( - FilesScanned: filesScanned, - FilesSkipped: filesSkipped, - ReferencesFound: referencesFound, - FrontmatterEntries: frontmatterEntries, - Elapsed: stopwatch.Elapsed); - - _logger.LogInformation($"Metadata rebuild complete: {filesScanned} scanned, {filesHydratedFromCache} hydrated from cache, {filesSkipped} skipped ({transientFailures.Count} transient retries queued), {referencesFound} references, {frontmatterEntries} sidecars in {stopwatch.ElapsedMilliseconds}ms"); - - // First save after rebuild — schedule for the next worker tick so - // we don't block the project-load path on disk I/O. - MarkDirty(); - - return Result.Ok(report); - } - catch (Exception ex) - { - return Result.Fail("An exception occurred during the metadata rebuild.") - .WithException(ex); - } - } - - // Returns the on-disk cache file path for the current project. Null when - // the workspace has no project folder configured. - private string? GetCacheFilePath() - { - try - { - var projectFolder = _workspaceWrapper.WorkspaceService.ResourceService.Registry.ProjectFolderPath; - if (string.IsNullOrEmpty(projectFolder)) - { - return null; - } - return Path.Combine( - projectFolder, - Celbridge.Projects.ProjectConstants.CelbridgeFolder, - Celbridge.Projects.ProjectConstants.CelbridgeCacheFolder, - Celbridge.Projects.ProjectConstants.MetaDataCacheFileName); - } - catch - { - return null; - } - } - - private MetaDataCacheDocument? LoadCacheDocument(IResourceRegistry registry) - { - var path = GetCacheFilePath(); - if (string.IsNullOrEmpty(path)) - { - return null; - } - return ResourceMetaDataCache.TryLoad(path); - } - - // Looks up the file's cache entry, validates mtime + size, and applies the - // cached references / frontmatter into the new index dictionaries. Returns - // true on a clean hydration; false otherwise (the caller falls back to a - // fresh scan). - private bool TryHydrateFromCache( - MetaDataCacheDocument? cacheDocument, - IResourceRegistry registry, - ResourceKey resourceKey, - string absolutePath, - Dictionary> referencersByTarget, - Dictionary> referencesBySource, - Dictionary> frontmatterByResource, - Dictionary>> resourcesByMetaDataField, - Dictionary cacheStamps, - ref int referencesFound, - ref int frontmatterEntries) - { - if (cacheDocument is null) - { - return false; - } - - if (!cacheDocument.Files.TryGetValue(resourceKey.ToString(), out var entry)) - { - return false; - } - - long mtimeTicks; - long size; - try - { - var fileInfo = new FileInfo(absolutePath); - if (!fileInfo.Exists) - { - return false; - } - mtimeTicks = fileInfo.LastWriteTimeUtc.Ticks; - size = fileInfo.Length; - } - catch - { - return false; - } - - if (entry.MtimeUtcTicks != mtimeTicks - || entry.Size != size) - { - return false; - } - - cacheStamps[resourceKey] = new CacheStamp(mtimeTicks, size, entry.IsText); - - if (!entry.IsText) - { - // Binary entry: stamp only, no index population. - return true; - } - - if (entry.References is { Count: > 0 }) - { - var references = new HashSet(); - foreach (var raw in entry.References) - { - if (ResourceKey.TryCreate(raw, out var key)) - { - references.Add(key); - } - } - if (references.Count > 0) - { - referencesFound += references.Count; - ApplyReferences(referencersByTarget, referencesBySource, resourceKey, references); - } - } - - if (entry.Frontmatter is { Count: > 0 }) - { - // Frontmatter index entries are keyed against the parent resource. - var parentResult = registry.GetSidecarParent(resourceKey); - if (parentResult.IsSuccess) - { - var parentKey = registry.GetResourceKey(parentResult.Value); - var normalised = NormaliseJsonFrontmatter(entry.Frontmatter); - if (normalised.Count > 0) - { - frontmatterByResource[parentKey] = normalised; - ApplyFrontmatter(resourcesByMetaDataField, parentKey, normalised); - frontmatterEntries++; - } - } - } - - return true; - } - - // Walks the cached frontmatter dictionary (deserialised from JSON, so - // numbers come out as JsonElement or boxed long/double) and normalises - // each value into the same CLR shape produced by the live TOML parse. - private static IReadOnlyDictionary NormaliseJsonFrontmatter(IReadOnlyDictionary raw) - { - var normalised = new Dictionary(raw.Count, StringComparer.Ordinal); - foreach (var (key, value) in raw) - { - var converted = NormaliseJsonValue(value); - if (converted is not null) - { - normalised[key] = converted; - } - } - return normalised; - } - - private static object? NormaliseJsonValue(object? value) - { - if (value is null) - { - return null; - } - if (value is JsonElement element) - { - return NormaliseJsonElement(element); - } - return value; - } - - private static object? NormaliseJsonElement(JsonElement element) - { - switch (element.ValueKind) - { - case JsonValueKind.String: - return element.GetString(); - case JsonValueKind.Number: - if (element.TryGetInt64(out var l)) - { - return l; - } - if (element.TryGetDouble(out var d)) - { - return d; - } - return null; - case JsonValueKind.True: - return true; - case JsonValueKind.False: - return false; - case JsonValueKind.Array: - var list = new List(); - foreach (var item in element.EnumerateArray()) - { - var converted = NormaliseJsonElement(item); - if (converted is not null) - { - list.Add(converted); - } - } - return list; - case JsonValueKind.Object: - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var property in element.EnumerateObject()) - { - var converted = NormaliseJsonElement(property.Value); - if (converted is not null) - { - dict[property.Name] = converted; - } - } - return dict; - default: - return null; - } - } - - // Records a stamp for a file that was excluded permanently (binary or - // oversize). Stat failures here are non-fatal; the file simply isn't - // stamped and the next load re-sniffs it. - private static void TryRecordExcludedStamp( - string absolutePath, - ResourceKey resourceKey, - Dictionary stamps) - { - try - { - var info = new FileInfo(absolutePath); - if (info.Exists) - { - stamps[resourceKey] = new CacheStamp(info.LastWriteTimeUtc.Ticks, info.Length, IsText: false); - } - } - catch - { - // No stamp recorded. - } - } - - private static void TryRecordScannedStamp( - string absolutePath, - ResourceKey resourceKey, - bool isText, - Dictionary stamps) - { - try - { - var info = new FileInfo(absolutePath); - if (info.Exists) - { - stamps[resourceKey] = new CacheStamp(info.LastWriteTimeUtc.Ticks, info.Length, isText); - } - } - catch - { - // No stamp recorded. - } - } - - // Marks the in-memory state as ahead of the persisted cache. The worker - // checks this flag periodically and persists when the debounce window has - // elapsed. - private void MarkDirty() - { - _isDirty = true; - } - - // Persists the current in-memory state to the cache file. Skipped when a - // transient-failure retry is queued so the cache never reflects partial - // state. Best-effort: any failure logs a warning and leaves the existing - // cache file untouched. - private void PersistCache() - { - var path = GetCacheFilePath(); - if (string.IsNullOrEmpty(path)) - { - return; - } - - if (!_transientFailureCounts.IsEmpty) - { - // Defer the write until the retry queue empties so the cache - // doesn't snapshot a known-stale partial state. - return; - } - - MetaDataCacheDocument document; - try - { - document = SnapshotForCache(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Metadata cache: failed to snapshot in-memory state"); - return; - } - - var saved = ResourceMetaDataCache.TrySave(path, document); - if (saved) - { - _isDirty = false; - _lastCacheSaveUtc = DateTime.UtcNow; - } - else - { - _logger.LogWarning("Metadata cache: failed to write cache file '{path}'", path); - } - } - - // Builds a cache document from the current in-memory state. The frontmatter - // entries are keyed by the parent resource (matching the in-memory shape); - // the on-disk cache stores them under the sidecar's resource key because - // that's the file the mtime + size stamp refers to. The hydration path - // reverses the mapping via GetSidecarParent. - private MetaDataCacheDocument SnapshotForCache() - { - var files = new Dictionary(); - - IReadOnlyDictionary stampsSnapshot; - IReadOnlyDictionary> referencesSnapshot; - IReadOnlyDictionary> frontmatterSnapshot; - - lock (_indexLock) - { - stampsSnapshot = new Dictionary(_cacheStamps); - referencesSnapshot = _referencesBySource.ToDictionary( - kvp => kvp.Key, - kvp => new HashSet(kvp.Value)); - frontmatterSnapshot = new Dictionary>(_frontmatterByResource); - } - - // Reverse map: sidecar resource key -> parent's frontmatter. We need to - // walk the parent -> frontmatter map and emit the entry against the - // sidecar's key under which the mtime + size stamp lives. - var parentToSidecar = new Dictionary(); - foreach (var parent in frontmatterSnapshot.Keys) - { - if (parent.IsEmpty) - { - continue; - } - var sidecarKey = new ResourceKey(parent.Root + ":" + parent.Path + SidecarHelper.Extension); - parentToSidecar[parent] = sidecarKey; - } - - foreach (var (resourceKey, stamp) in stampsSnapshot) - { - List? referencesList = null; - if (referencesSnapshot.TryGetValue(resourceKey, out var refSet) - && refSet.Count > 0) - { - referencesList = refSet.Select(r => r.ToString()).ToList(); - } - - Dictionary? frontmatterDict = null; - // If this is a sidecar key and its parent has frontmatter, embed - // the frontmatter in this entry so reload hydrates both stamp and - // index from the same record. - if (IsSidecarKey(resourceKey)) - { - var parent = StripSidecarSuffix(resourceKey); - if (parent.HasValue - && frontmatterSnapshot.TryGetValue(parent.Value, out var fm) - && fm.Count > 0) - { - frontmatterDict = new Dictionary(fm, StringComparer.Ordinal); - } - } - - files[resourceKey.ToString()] = new MetaDataCacheEntry - { - MtimeUtcTicks = stamp.MtimeUtcTicks, - Size = stamp.Size, - IsText = stamp.IsText, - References = referencesList, - Frontmatter = frontmatterDict, - }; - } - - return new MetaDataCacheDocument - { - Version = ResourceMetaDataCache.CurrentVersion, - Files = files, - }; - } - - public IReadOnlyList GetReferencers(ResourceKey target) - { - lock (_indexLock) - { - if (_referencersByTarget.TryGetValue(target, out var set)) - { - return set.ToList(); - } - return Array.Empty(); - } - } - - public IReadOnlyList GetReferences(ResourceKey source) - { - lock (_indexLock) - { - if (_referencesBySource.TryGetValue(source, out var set)) - { - return set.ToList(); - } - return Array.Empty(); - } - } - - public IReadOnlyList GetAllReferencedTargets() - { - lock (_indexLock) - { - return _referencersByTarget.Keys.ToList(); - } - } - - public Result> GetFrontmatter(ResourceKey resource) - { - lock (_indexLock) - { - if (!_frontmatterByResource.TryGetValue(resource, out var frontmatter)) - { - return Result>.Fail( - $"No frontmatter is indexed for resource '{resource}'. The resource may have no sidecar or its sidecar may be broken."); - } - // Return a snapshot copy so callers cannot mutate our state. - var snapshot = new Dictionary(frontmatter, StringComparer.Ordinal); - return Result>.Ok(snapshot); - } - } - - public async Task SetFrontmatterFieldAsync(ResourceKey resource, string field, object value) - { - if (string.IsNullOrEmpty(field)) - { - return Result.Fail("The 'field' argument must be a non-empty string."); - } - - if (!IsIndexableValue(value)) - { - return Result.Fail($"Field '{field}' value is not indexable. Only scalar (string/number/bool) and list-of-scalar values are supported."); - } - - return await MutateSidecarFrontmatterAsync(resource, mutate: dict => dict[field] = value); - } - - public async Task RemoveFrontmatterFieldAsync(ResourceKey resource, string field) - { - if (string.IsNullOrEmpty(field)) - { - return Result.Fail("The 'field' argument must be a non-empty string."); - } - - return await MutateSidecarFrontmatterAsync( - resource, - mutate: dict => { dict.Remove(field); }, - createSidecarIfMissing: false); - } - - public IReadOnlyList FindByMetaData(string field, object value) - { - if (string.IsNullOrEmpty(field) || value is null) - { - return Array.Empty(); - } - - lock (_indexLock) - { - if (!_resourcesByMetaDataField.TryGetValue(field, out var byValue)) - { - return Array.Empty(); - } - - // Normalise the query value into the same canonical form used when - // populating the index so an int query against a long-typed scalar - // still finds the entry. - var canonical = CanonicaliseScalar(value); - if (canonical is null) - { - return Array.Empty(); - } - - if (!byValue.TryGetValue(canonical, out var resources)) - { - return Array.Empty(); - } - - return resources.ToList(); - } - } - - public IReadOnlyList GetTags(ResourceKey resource) - { - lock (_indexLock) - { - if (!_frontmatterByResource.TryGetValue(resource, out var frontmatter)) - { - return Array.Empty(); - } - - if (!frontmatter.TryGetValue(TagsField, out var tagsValue)) - { - return Array.Empty(); - } - - return ExtractStringList(tagsValue); - } - } - - public async Task AddTagAsync(ResourceKey resource, string tag) - { - if (string.IsNullOrEmpty(tag)) - { - return Result.Fail("Tag value must be a non-empty string."); - } - - return await MutateSidecarFrontmatterAsync(resource, mutate: dict => - { - var existing = dict.TryGetValue(TagsField, out var value) - ? ExtractStringList(value) - : Array.Empty(); - - if (existing.Contains(tag, StringComparer.Ordinal)) - { - // Idempotent: no change needed. - return; - } - - var updated = new List(existing.Count + 1); - updated.AddRange(existing); - updated.Add(tag); - dict[TagsField] = updated; - }); - } - - public async Task RemoveTagAsync(ResourceKey resource, string tag) - { - if (string.IsNullOrEmpty(tag)) - { - return Result.Fail("Tag value must be a non-empty string."); - } - - return await MutateSidecarFrontmatterAsync( - resource, - mutate: dict => - { - if (!dict.TryGetValue(TagsField, out var value)) - { - return; - } - - var existing = ExtractStringList(value); - if (!existing.Contains(tag, StringComparer.Ordinal)) - { - return; - } - - var updated = existing.Where(t => !string.Equals(t, tag, StringComparison.Ordinal)).ToList(); - if (updated.Count == 0) - { - // Drop the field entirely when the list goes empty rather - // than leaving an empty array in the file. - dict.Remove(TagsField); - } - else - { - dict[TagsField] = updated; - } - }, - createSidecarIfMissing: false); - } - - public IReadOnlyList FindByTag(string tag) - { - // FindByTag is a thin alias for FindByMetaData against the standardised - // tags field; the inverted index already records list-of-scalar fields - // element-wise, so a tag query is just a value lookup. - return FindByMetaData(TagsField, tag); - } - - // Walks file text for "project:" candidate references. Returns the unique - // set of valid keys found; invalid candidates are silently dropped. Parsing - // logic is delegated to ReferenceLiteralRules so the scanner and the - // rewrite cascade share one definition of what counts as a reference. - public static HashSet ScanTextForReferences(string text) - { - var references = new HashSet(); - int searchStart = 0; - - while (true) - { - int markerIndex = text.IndexOf(ReferenceLiteralRules.ReferenceMarker, searchStart, StringComparison.Ordinal); - if (markerIndex < 0) - { - break; - } - - var parsed = ReferenceLiteralRules.TryParseReferenceAt(text, markerIndex); - if (parsed is not null) - { - references.Add(parsed.Key); - searchStart = parsed.EndIndex; - } - else - { - searchStart = markerIndex + ReferenceLiteralRules.ReferenceMarker.Length; - } - } - - return references; - } - - private enum ScanOutcome - { - // Scan succeeded; References reflects what the file contains right now. - Indexed, - - // File is deliberately not indexable in its current shape (deleted, - // oversize, binary). Prior index entries should be dropped. - ExcludedPermanently, - - // Scan failed in a way that may resolve itself (file locked by another - // process, transient IO error). Prior index entries should be preserved - // and the rescan should be retried after a short delay. - TransientFailure, - } - - // SidecarText is populated for .cel files so the caller can parse the - // frontmatter without re-reading the bytes; null for non-sidecar paths. - private record FileScanResult(ScanOutcome Outcome, HashSet References, string? SidecarText = null); - - private static readonly HashSet EmptyReferenceSet = new(); - - private async Task ScanTextFileAsync(ResourceKey resourceKey, string absolutePath) - { - FileInfo fileInfo; - try - { - fileInfo = new FileInfo(absolutePath); - if (!fileInfo.Exists) - { - return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); - } - if (fileInfo.Length > MaxScanFileSizeBytes) - { - _logger.LogInformation($"metadata scan: skipping {resourceKey} (size {fileInfo.Length} bytes exceeds limit)"); - return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); - } - } - catch (Exception ex) when (IsTransientIoFailure(ex)) - { - _logger.LogDebug($"metadata scan: transient stat failure for {resourceKey} ({ex.GetType().Name}): {ex.Message}"); - return new FileScanResult(ScanOutcome.TransientFailure, EmptyReferenceSet); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"metadata scan: failed to stat {resourceKey}"); - return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); - } - - var extension = Path.GetExtension(absolutePath); - if (!string.IsNullOrEmpty(extension) - && _textBinarySniffer.IsBinaryExtension(extension)) - { - return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); - } - - var isTextResult = _textBinarySniffer.IsTextFile(absolutePath); - if (isTextResult.IsFailure) - { - // The sniffer's failure surface doesn't distinguish locked-file from - // genuinely-unreadable. Treat as transient: a real permanent failure - // exhausts MaxScanRetryAttempts and gets dropped; a transient one - // succeeds on retry. Worst case is three short retries. - _logger.LogDebug($"metadata scan: sniffer failure for {resourceKey} - treating as transient"); - return new FileScanResult(ScanOutcome.TransientFailure, EmptyReferenceSet); - } - if (!isTextResult.Value) - { - return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); - } - - string text; - try - { - text = await File.ReadAllTextAsync(absolutePath); - } - catch (Exception ex) when (IsTransientIoFailure(ex)) - { - _logger.LogDebug($"metadata scan: transient read failure for {resourceKey} ({ex.GetType().Name}): {ex.Message}"); - return new FileScanResult(ScanOutcome.TransientFailure, EmptyReferenceSet); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"metadata scan: failed to read {resourceKey}"); - return new FileScanResult(ScanOutcome.ExcludedPermanently, EmptyReferenceSet); - } - - var references = ScanTextForReferences(text); - - // Capture the file content for sidecar files so the caller can parse - // the frontmatter without a second disk read. Non-sidecar files leave - // SidecarText null. - var sidecarText = IsSidecarPath(absolutePath) ? text : null; - return new FileScanResult(ScanOutcome.Indexed, references, sidecarText); - } - - // True when the path's filename ends in ".cel" but not ".cel.cel". The - // ".cel.cel" form is reserved as the invalid-sidecar marker per - // file_metadata_sidecars.md and is never paired with a parent. - private static bool IsSidecarPath(string absolutePath) - { - var fileName = Path.GetFileName(absolutePath); - if (!fileName.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - if (fileName.EndsWith(SidecarHelper.Extension + SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - return true; - } - - // IOException covers file-locked, sharing-violation, network-share blip; - // UnauthorizedAccessException can fire transiently on Windows while an - // antivirus or backup product holds the file. Both are worth retrying. - private static bool IsTransientIoFailure(Exception ex) - { - return ex is IOException - || ex is UnauthorizedAccessException; - } - - // Returns a normalised top-level frontmatter dictionary suitable for the - // index, or null when the sidecar bytes cannot be parsed. The TOML model - // values from Tomlyn are normalised via NormaliseTomlValue so the index - // entries stay consistent across reload paths (cache hydration, fresh - // parse) and the equality semantics of the dictionary keys are - // case-sensitive ordinal. - private IReadOnlyDictionary? TryParseSidecarFrontmatter(string absolutePath, string? text) - { - if (text is null) - { - return null; - } - - var parseResult = SidecarHelper.Parse(text); - if (parseResult.IsFailure) - { - _logger.LogWarning($"metadata scan: sidecar at '{absolutePath}' has unparseable frontmatter and will not be indexed."); - return null; - } - - var raw = parseResult.Value.Frontmatter; - if (raw.Count == 0) - { - return null; - } - - var normalised = new Dictionary(raw.Count, StringComparer.Ordinal); - foreach (var (key, value) in raw) - { - var converted = NormaliseTomlValue(value); - if (converted is null) - { - continue; - } - normalised[key] = converted; - } - - return normalised.Count == 0 ? null : normalised; - } - - // Converts a Tomlyn model value into the plain CLR shapes the index - // expects: strings stay strings, TomlArray becomes List, TomlTable - // becomes Dictionary. Scalars come out as their underlying - // CLR type (long, double, bool, DateTime, etc.). Returns null only for - // truly unrepresentable input. - private static object? NormaliseTomlValue(object? value) - { - switch (value) - { - case null: - return null; - case TomlTable table: - var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (k, v) in table) - { - var converted = NormaliseTomlValue(v); - if (converted is null) - { - continue; - } - dict[k] = converted; - } - return dict; - case TomlArray array: - var list = new List(array.Count); - foreach (var item in array) - { - var converted = NormaliseTomlValue(item); - if (converted is null) - { - continue; - } - list.Add(converted); - } - return list; - default: - return value; - } - } - - // Populates the inverted index for one resource's frontmatter. Scalar - // fields contribute their value as a single index entry; list-of-scalar - // fields contribute each element. Non-indexable shapes (nested tables, - // arrays of arrays) are stored in _frontmatterByResource but not indexed. - private static void ApplyFrontmatter( - Dictionary>> resourcesByMetaDataField, - ResourceKey resource, - IReadOnlyDictionary frontmatter) - { - foreach (var (field, value) in frontmatter) - { - foreach (var indexedValue in EnumerateIndexValues(value)) - { - if (!resourcesByMetaDataField.TryGetValue(field, out var byValue)) - { - byValue = new Dictionary>(); - resourcesByMetaDataField[field] = byValue; - } - if (!byValue.TryGetValue(indexedValue, out var set)) - { - set = new HashSet(); - byValue[indexedValue] = set; - } - set.Add(resource); - } - } - } - - // Yields the values to index for a given frontmatter field. Scalars yield - // themselves (canonicalised); list-of-scalar yields each canonicalised - // element. Nested objects and lists-of-non-scalars yield nothing — they - // remain available via GetFrontmatter but not via FindByMetaData. - private static IEnumerable EnumerateIndexValues(object value) - { - if (value is IReadOnlyList objectList - && value is not string) - { - foreach (var item in objectList) - { - if (item is null) - { - continue; - } - var canonical = CanonicaliseScalar(item); - if (canonical is not null) - { - yield return canonical; - } - } - yield break; - } - - if (value is System.Collections.IEnumerable enumerable - && value is not string) - { - foreach (var item in enumerable) - { - if (item is null) - { - continue; - } - var canonical = CanonicaliseScalar(item); - if (canonical is not null) - { - yield return canonical; - } - } - yield break; - } - - var scalar = CanonicaliseScalar(value); - if (scalar is not null) - { - yield return scalar; - } - } - - // True when the value is a shape we can serialise back to TOML and index: - // strings, numeric scalars, booleans, datetimes, and lists of those. The - // service rejects nested-object frontmatter writes at the mutation surface - // so callers get a clear error rather than a silent drop. - private static bool IsIndexableValue(object? value) - { - if (value is null) - { - return false; - } - if (IsScalar(value)) - { - return true; - } - if (value is IReadOnlyList objectList - && value is not string) - { - return objectList.All(item => item is not null && IsScalar(item)); - } - if (value is System.Collections.IEnumerable enumerable - && value is not string) - { - foreach (var item in enumerable) - { - if (item is null - || !IsScalar(item)) - { - return false; - } - } - return true; - } - return false; - } - - private static bool IsScalar(object value) - { - return value is string - || value is bool - || value is long - || value is int - || value is double - || value is float - || value is decimal - || value is DateTime - || value is DateTimeOffset - || value is DateOnly - || value is TimeOnly; - } - - // Normalises a scalar into the form used as a dictionary key in the - // inverted index, so a long-typed cached value and an int-typed query - // value still compare equal. Returns null for unrepresentable input. - private static object? CanonicaliseScalar(object? value) - { - switch (value) - { - case null: - return null; - case string s: - return s; - case bool b: - return b; - case int i: - return (long)i; - case long l: - return l; - case short sh: - return (long)sh; - case byte by: - return (long)by; - case sbyte sb: - return (long)sb; - case uint ui: - return (long)ui; - case ulong ul: - return (long)ul; - case float f: - return (double)f; - case double d: - return d; - case decimal dec: - return (double)dec; - case DateTime dt: - return dt.ToUniversalTime(); - case DateTimeOffset dto: - return dto.UtcDateTime; - default: - return null; - } - } - - // Returns the value as a list of strings when possible (a TOML "tags" - // array contributes a list of strings); empty otherwise. - private static IReadOnlyList ExtractStringList(object value) - { - var result = new List(); - if (value is string) - { - return result; - } - if (value is System.Collections.IEnumerable enumerable) - { - foreach (var item in enumerable) - { - if (item is string s) - { - result.Add(s); - } - } - } - return result; - } - - // Reads the resource's sidecar (creating it if missing), applies the - // mutation to a working copy of the frontmatter dictionary, and writes the - // result back through IResourceFileSystem so atomic-write + watcher event - // semantics apply. Returns success even when the mutation is a no-op. - private async Task MutateSidecarFrontmatterAsync( - ResourceKey resource, - Action> mutate, - bool createSidecarIfMissing = true) - { - if (resource.IsEmpty) - { - return Result.Fail("Cannot set frontmatter on an empty resource key."); - } - - var sidecarKey = new ResourceKey(resource.Root + ":" + resource.Path + SidecarHelper.Extension); - - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var existsResult = await fileSystem.ExistsAsync(sidecarKey); - if (existsResult.IsFailure) - { - return Result.Fail($"Failed to check sidecar existence for resource '{resource}'.") - .WithErrors(existsResult); - } - - Dictionary working; - string body = string.Empty; - - if (existsResult.Value) - { - var readResult = await fileSystem.ReadAllTextAsync(sidecarKey); - if (readResult.IsFailure) - { - return Result.Fail($"Failed to read sidecar '{sidecarKey}'.") - .WithErrors(readResult); - } - - var parseResult = SidecarHelper.Parse(readResult.Value); - if (parseResult.IsFailure) - { - return Result.Fail($"Cannot mutate sidecar '{sidecarKey}': frontmatter does not parse.") - .WithErrors(parseResult); - } - working = new Dictionary(parseResult.Value.Frontmatter, StringComparer.Ordinal); - body = parseResult.Value.Body; - } - else - { - if (!createSidecarIfMissing) - { - // Removing a field from a non-existent sidecar is a no-op success. - return Result.Ok(); - } - working = new Dictionary(StringComparer.Ordinal); - } - - mutate(working); - - var composed = SidecarHelper.Compose(working, body); - var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, composed); - if (writeResult.IsFailure) - { - return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") - .WithErrors(writeResult); - } - - // The file watcher delivers ResourceChangedMessage asynchronously - // through the UI dispatcher, so by the time this method returns the - // background worker has not yet seen the write. Apply the index - // update synchronously here so the caller's next read sees the new - // state. The watcher's eventual rescan re-applies the same parsed - // frontmatter against the in-memory dictionaries; that pass is - // idempotent. - var parsedForIndex = TryParseSidecarFrontmatter("", composed); - UpdateFrontmatterInIndexes(resource, parsedForIndex); - - // Refresh the cache stamp for the sidecar file so a subsequent - // workspace load can hydrate from the cache instead of rescanning. - // Stat failures here are absorbed by UpdateCacheStamp's catch. - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveSidecar = registry.ResolveResourcePath(sidecarKey); - if (resolveSidecar.IsSuccess) - { - UpdateCacheStamp(sidecarKey, resolveSidecar.Value, isText: true); - } - - return Result.Ok(); - } - - private static void ApplyReferences( - Dictionary> referencersByTarget, - Dictionary> referencesBySource, - ResourceKey source, - HashSet references) - { - if (!referencesBySource.TryGetValue(source, out var sourceSet)) - { - sourceSet = new HashSet(); - referencesBySource[source] = sourceSet; - } - - foreach (var target in references) - { - sourceSet.Add(target); - - if (!referencersByTarget.TryGetValue(target, out var targetSet)) - { - targetSet = new HashSet(); - referencersByTarget[target] = targetSet; - } - targetSet.Add(source); - } - } - - private void RemoveSourceFromIndexes(ResourceKey source) - { - lock (_indexLock) - { - if (_referencesBySource.TryGetValue(source, out var oldTargets)) - { - foreach (var target in oldTargets) - { - if (_referencersByTarget.TryGetValue(target, out var referencers)) - { - referencers.Remove(source); - if (referencers.Count == 0) - { - _referencersByTarget.Remove(target); - } - } - } - _referencesBySource.Remove(source); - } - _cacheStamps.Remove(source); - } - MarkDirty(); - } - - // Strips a parent resource's frontmatter from both the per-resource snapshot - // and the inverted index. Called when a sidecar disappears or its content - // becomes unparseable. The argument is the parent key (e.g. "foo.png"), not - // the sidecar key. - private void RemoveFrontmatterFromIndexes(ResourceKey parentResource) - { - bool changed = false; - lock (_indexLock) - { - if (!_frontmatterByResource.TryGetValue(parentResource, out var existing)) - { - return; - } - _frontmatterByResource.Remove(parentResource); - changed = true; - - foreach (var (field, value) in existing) - { - if (!_resourcesByMetaDataField.TryGetValue(field, out var byValue)) - { - continue; - } - foreach (var indexedValue in EnumerateIndexValues(value)) - { - if (!byValue.TryGetValue(indexedValue, out var set)) - { - continue; - } - set.Remove(parentResource); - if (set.Count == 0) - { - byValue.Remove(indexedValue); - } - } - if (byValue.Count == 0) - { - _resourcesByMetaDataField.Remove(field); - } - } - } - if (changed) - { - MarkDirty(); - } - } - - // Replaces the frontmatter snapshot and inverted-index entries for one - // parent resource. Empty/null frontmatter behaves as a removal so the - // caller doesn't have to branch when a sidecar transitions to broken. - private void UpdateFrontmatterInIndexes(ResourceKey parentResource, IReadOnlyDictionary? frontmatter) - { - RemoveFrontmatterFromIndexes(parentResource); - - if (frontmatter is null - || frontmatter.Count == 0) - { - return; - } - - lock (_indexLock) - { - _frontmatterByResource[parentResource] = frontmatter; - ApplyFrontmatter(_resourcesByMetaDataField, parentResource, frontmatter); - } - MarkDirty(); - } - - private void UpdateSourceInIndexes(ResourceKey source, HashSet references) - { - lock (_indexLock) - { - // Strip any prior referrals from this source first so the new set - // fully replaces the old set. - if (_referencesBySource.TryGetValue(source, out var oldTargets)) - { - foreach (var target in oldTargets) - { - if (_referencersByTarget.TryGetValue(target, out var referencers)) - { - referencers.Remove(source); - if (referencers.Count == 0) - { - _referencersByTarget.Remove(target); - } - } - } - } - - if (references.Count == 0) - { - _referencesBySource.Remove(source); - } - else - { - _referencesBySource[source] = new HashSet(references); - foreach (var target in references) - { - if (!_referencersByTarget.TryGetValue(target, out var targetSet)) - { - targetSet = new HashSet(); - _referencersByTarget[target] = targetSet; - } - targetSet.Add(source); - } - } - } - MarkDirty(); - } - - // Records a per-file stamp captured after a successful incremental scan. - private void UpdateCacheStamp(ResourceKey resource, string absolutePath, bool isText) - { - try - { - var info = new FileInfo(absolutePath); - if (!info.Exists) - { - lock (_indexLock) - { - _cacheStamps.Remove(resource); - } - return; - } - var stamp = new CacheStamp(info.LastWriteTimeUtc.Ticks, info.Length, isText); - lock (_indexLock) - { - _cacheStamps[resource] = stamp; - } - } - catch - { - // Best-effort; the next watcher event will re-stamp. - } - } - - private void OnResourceCreated(object recipient, ResourceCreatedMessage message) - { - // A fresh lifecycle event means the file's state is changing; reset - // the retry budget so a file that previously gave up after - // MaxScanRetryAttempts gets re-scanned with full budget on its next - // legitimate change. - _transientFailureCounts.TryRemove(message.Resource, out _); - QueueRescan(message.Resource); - } - - private void OnResourceChanged(object recipient, ResourceChangedMessage message) - { - _transientFailureCounts.TryRemove(message.Resource, out _); - QueueRescan(message.Resource); - } - - private void OnResourceDeleted(object recipient, ResourceDeletedMessage message) - { - if (message.Resource.Root != ResourceKey.DefaultRoot - || message.Resource.IsEmpty) - { - return; - } - - // Atomic temp + rename writes (ResourceFileSystem.WriteAtomicAsync) - // briefly remove the destination during File.Move(overwrite: true), - // which fires a FileSystemWatcher delete event immediately followed - // by a create event. By the time the dispatcher delivers the delete - // here the file is back on disk; clearing the index would clobber - // the synchronous entry MutateSidecarFrontmatterAsync just installed. - // The companion create event still triggers a rescan, which would - // re-establish the entry — but list / get / find calls landing in - // the window between the spurious delete and the rescan would see - // empty results. Skipping the removal when the file is still on - // disk closes that window. A genuine deletion has the file gone by - // the time the event arrives, so File.Exists is false and we fall - // through to the original removal logic. - try - { - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = registry.ResolveResourcePath(message.Resource); - if (resolveResult.IsSuccess - && File.Exists(resolveResult.Value)) - { - return; - } - } - catch - { - // If resolution itself fails, fall through to the conservative - // removal so a true deletion still clears the index. - } - - RemoveSourceFromIndexes(message.Resource); - - // A deleted parent file drops its own frontmatter entry; a deleted - // sidecar drops the entry under its parent key. The sidecar cascade - // path runs both events, so handle both shapes here. - if (IsSidecarKey(message.Resource)) - { - var parentKey = StripSidecarSuffix(message.Resource); - if (parentKey.HasValue) - { - RemoveFrontmatterFromIndexes(parentKey.Value); - } - } - else - { - RemoveFrontmatterFromIndexes(message.Resource); - } - - _transientFailureCounts.TryRemove(message.Resource, out _); - } - - private void OnResourceRenamed(object recipient, ResourceRenamedMessage message) - { - if (message.OldResource.Root == ResourceKey.DefaultRoot) - { - RemoveSourceFromIndexes(message.OldResource); - - if (IsSidecarKey(message.OldResource)) - { - var oldParent = StripSidecarSuffix(message.OldResource); - if (oldParent.HasValue) - { - RemoveFrontmatterFromIndexes(oldParent.Value); - } - } - else - { - RemoveFrontmatterFromIndexes(message.OldResource); - } - - _transientFailureCounts.TryRemove(message.OldResource, out _); - } - _transientFailureCounts.TryRemove(message.NewResource, out _); - QueueRescan(message.NewResource); - } - - // True when the key's path ends in ".cel" but not ".cel.cel". Mirrors the - // file-path test used during the rebuild scan. - private static bool IsSidecarKey(ResourceKey key) - { - if (key.IsEmpty) - { - return false; - } - var path = key.Path; - if (!path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - if (path.EndsWith(SidecarHelper.Extension + SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - return true; - } - - // Strips the trailing ".cel" from a sidecar key to recover its parent key. - // Returns null when the result would be empty (e.g. a hypothetical bare - // ".cel" key with no path component). - private static ResourceKey? StripSidecarSuffix(ResourceKey sidecarKey) - { - var path = sidecarKey.Path; - if (!path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - var parentPath = path.Substring(0, path.Length - SidecarHelper.Extension.Length); - if (string.IsNullOrEmpty(parentPath)) - { - return null; - } - return new ResourceKey(sidecarKey.Root + ":" + parentPath); - } - - // Helper used by the rescan paths to drop frontmatter entries when a - // sidecar key disappears or stops parsing. No-op for non-sidecar keys. - private void MaybeDropFrontmatterForSidecar(ResourceKey resource) - { - if (!IsSidecarKey(resource)) - { - return; - } - var parentKey = StripSidecarSuffix(resource); - if (parentKey.HasValue) - { - RemoveFrontmatterFromIndexes(parentKey.Value); - } - } - - private void QueueRescan(ResourceKey resource) - { - // Only project: resources contribute to the index. Watcher messages from - // temp: and logs: roots are ignored. - if (resource.Root != ResourceKey.DefaultRoot - || resource.IsEmpty) - { - return; - } - - _pendingRescans.Enqueue(resource); - try - { - _workerSignal.Release(); - } - catch (SemaphoreFullException) - { - // The signal count is unbounded in practice; ignore the rare overflow. - } - } - - private async Task WorkerLoopAsync() - { - // The worker waits on the semaphore for new work and checks _isShuttingDown - // after every wake. Dispose sets the flag and releases the semaphore once, - // so the worker exits cleanly without raising an OperationCanceledException. - while (!_isShuttingDown) - { - try - { - await _workerSignal.WaitAsync(); - } - catch (ObjectDisposedException) - { - return; - } - - if (_isShuttingDown) - { - return; - } - - while (_pendingRescans.TryDequeue(out var resource)) - { - if (_isShuttingDown) - { - return; - } - - await ProcessRescanAsync(resource); - } - - // Debounced cache write: persist once the queue is empty and the - // last save was long enough ago that we won't thrash. Skipping when - // _isDirty is false avoids re-saving an already-current snapshot. - if (_isDirty - && (DateTime.UtcNow - _lastCacheSaveUtc) >= MinCacheSaveInterval) - { - PersistCache(); - } - } - } - - private async Task ProcessRescanAsync(ResourceKey resource) - { - try - { - if (_isShuttingDown) - { - return; - } - - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = registry.ResolveResourcePath(resource); - if (resolveResult.IsFailure) - { - RemoveSourceFromIndexes(resource); - MaybeDropFrontmatterForSidecar(resource); - _transientFailureCounts.TryRemove(resource, out _); - return; - } - var absolutePath = resolveResult.Value; - - if (!File.Exists(absolutePath)) - { - RemoveSourceFromIndexes(resource); - MaybeDropFrontmatterForSidecar(resource); - _transientFailureCounts.TryRemove(resource, out _); - return; - } - - var scanResult = await ScanTextFileAsync(resource, absolutePath); - switch (scanResult.Outcome) - { - case ScanOutcome.Indexed: - UpdateSourceInIndexes(resource, scanResult.References); - if (IsSidecarPath(absolutePath)) - { - // Derive the parent key by stripping the .cel suffix - // rather than querying the registry's pairing table. - // The pairing table is refreshed only by - // UpdateResourceRegistry, which lags watcher events - // for newly-created sidecars; relying on it here - // would race with the synchronous index update in - // MutateSidecarFrontmatterAsync. Parent existence is - // checked on disk so a true orphan still drops the - // index entry. - var parentKey = StripSidecarSuffix(resource); - if (parentKey.HasValue) - { - var parentResolve = registry.ResolveResourcePath(parentKey.Value); - var parentExists = parentResolve.IsSuccess - && File.Exists(parentResolve.Value); - if (parentExists) - { - var parsed = TryParseSidecarFrontmatter(absolutePath, scanResult.SidecarText); - UpdateFrontmatterInIndexes(parentKey.Value, parsed); - } - else - { - RemoveFrontmatterFromIndexes(parentKey.Value); - } - } - } - UpdateCacheStamp(resource, absolutePath, isText: true); - _transientFailureCounts.TryRemove(resource, out _); - break; - - case ScanOutcome.ExcludedPermanently: - RemoveSourceFromIndexes(resource); - MaybeDropFrontmatterForSidecar(resource); - UpdateCacheStamp(resource, absolutePath, isText: false); - _transientFailureCounts.TryRemove(resource, out _); - break; - - case ScanOutcome.TransientFailure: - // Preserve existing index entries; the file is briefly - // unreadable but the prior data is still our best guess. - ScheduleRetryAfterTransientFailure(resource); - break; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"metadata scan: failed to rescan {resource}"); - } - } - - private void ScheduleRetryAfterTransientFailure(ResourceKey resource) - { - var attempt = _transientFailureCounts.AddOrUpdate(resource, 1, (_, previous) => previous + 1); - if (attempt > MaxScanRetryAttempts) - { - _logger.LogWarning($"metadata scan: giving up on {resource} after {MaxScanRetryAttempts} transient failures. The next watcher event for this file will reset the retry budget."); - _transientFailureCounts.TryRemove(resource, out _); - return; - } - - var delay = ScanRetryDelays[attempt - 1]; - - // Detached background continuation; nothing awaits this task. If the - // service is disposed mid-delay the worker exits early via _isShuttingDown. - _ = Task.Delay(delay).ContinueWith(_ => - { - if (_isShuttingDown) - { - return; - } - QueueRescan(resource); - }, TaskScheduler.Default); - } - - private void MarkReady() - { - if (_isReady) - { - return; - } - _isReady = true; - _readyCompletionSource.TrySetResult(true); - } - - public void Dispose() - { - if (_isDisposed) - { - return; - } - _isDisposed = true; - - _messengerService.UnregisterAll(this); - - // Persist the cache before signalling shutdown so the in-memory state - // survives the next workspace load. Best-effort: a failure here is - // logged inside PersistCache and the next load falls back to a full - // rebuild. - if (_isDirty) - { - try - { - PersistCache(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Metadata cache: failed to persist on dispose"); - } - } - - // Signal the worker to exit, then nudge the semaphore so it observes the - // flag and returns. The worker checks _isShuttingDown after every wake. - _isShuttingDown = true; - try - { - _workerSignal.Release(); - } - catch (SemaphoreFullException) - { - // Worker is already pending wake-up; nothing more to do. - } - catch (ObjectDisposedException) - { - // Already disposed; nothing more to do. - } - - try - { - _workerTask?.Wait(TimeSpan.FromSeconds(2)); - } - catch (Exception) - { - // Worker shutdown is best-effort; never let dispose throw. - } - - _workerSignal.Dispose(); - } -} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs deleted file mode 100644 index ef0cb20db..000000000 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMetaDataCache.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Celbridge.Resources.Services; - -/// -/// On-disk JSON shape for the resource-metadata cache. Mirrors the in-memory -/// reference graph and frontmatter index entries plus the per-file mtime + size -/// stamp used to validate cache entries on load. Version is bumped whenever the -/// shape changes incompatibly so older caches discard cleanly on first read. -/// -internal record MetaDataCacheDocument -{ - [JsonPropertyName("version")] - public int Version { get; init; } - - [JsonPropertyName("files")] - public Dictionary Files { get; init; } = new(); -} - -/// -/// One entry per indexed file. References / Frontmatter / IsText are optional -/// so a binary entry can be a stat-only record and a sidecar entry can carry -/// frontmatter without forcing the reference list to be present. -/// -internal record MetaDataCacheEntry -{ - [JsonPropertyName("mtimeUtcTicks")] - public long MtimeUtcTicks { get; init; } - - [JsonPropertyName("size")] - public long Size { get; init; } - - [JsonPropertyName("isText")] - public bool IsText { get; init; } - - [JsonPropertyName("references")] - public List? References { get; init; } - - [JsonPropertyName("frontmatter")] - public Dictionary? Frontmatter { get; init; } -} - -/// -/// Read/write logic for the .celbridge/cache/metadata.json file. The file is -/// host-private and does not flow through IResourceFileSystem — the FS layer's -/// structural operations depend on the metadata service, so routing the cache -/// through that layer would introduce a circular dependency. -/// -internal static class ResourceMetaDataCache -{ - /// - /// Cache format version. Bump whenever the on-disk shape changes such that - /// older readers cannot parse newer files (or vice versa). Mismatched - /// versions are discarded silently and trigger a full rebuild. - /// - public const int CurrentVersion = 1; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = false, - PropertyNameCaseInsensitive = false, - }; - - /// - /// Loads the cache document at the given path. Returns null on any failure - /// (missing file, malformed JSON, version mismatch) so the caller can fall - /// back to a full rebuild without distinguishing the failure cause. - /// - public static MetaDataCacheDocument? TryLoad(string cacheFilePath) - { - if (!File.Exists(cacheFilePath)) - { - return null; - } - - try - { - using var stream = File.OpenRead(cacheFilePath); - var document = JsonSerializer.Deserialize(stream, JsonOptions); - if (document is null - || document.Version != CurrentVersion) - { - return null; - } - return document; - } - catch - { - // Any read or parse failure is treated as a missing cache. The - // service is correct without the cache; falling back to a full - // rebuild is always safe. - return null; - } - } - - /// - /// Writes the cache document atomically via temp + move. Best-effort; a - /// crash or IO failure leaves the cache untouched and the next load runs - /// the full rebuild. Returns true on success, false otherwise. - /// - public static bool TrySave(string cacheFilePath, MetaDataCacheDocument document) - { - try - { - var folder = Path.GetDirectoryName(cacheFilePath); - if (!string.IsNullOrEmpty(folder) - && !Directory.Exists(folder)) - { - Directory.CreateDirectory(folder); - } - - var tempPath = cacheFilePath + ".tmp"; - using (var stream = File.Create(tempPath)) - { - JsonSerializer.Serialize(stream, document, JsonOptions); - } - File.Move(tempPath, cacheFilePath, overwrite: true); - return true; - } - catch - { - return false; - } - } -} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 5e4ba1a80..d44b54f33 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -105,7 +105,7 @@ public async Task> CopyFileAsync(string sourcePath, string de // are explicitly selected (file-by-file) or contained in a copied folder // come along as ordinary bytes via the path-based fallback; the registry's // pairing pass picks them up on the next sync. Stale "project:" references - // inside imported sidecar bodies surface via metadata_check_project (ri-2). + // inside imported sidecar bodies surface via data_check_project. if (!IsInProjectFolder(sourcePath)) { return await CopyExternalFileAsync(sourcePath, destPath); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs new file mode 100644 index 000000000..64a98fe30 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -0,0 +1,360 @@ +using System.Collections.Concurrent; +using Celbridge.Logging; +using Celbridge.Resources.Helpers; +using Celbridge.Utilities; +using Celbridge.Workspace; +using Tomlyn.Model; + +namespace Celbridge.Resources.Services; + +/// +/// One-shot, stateless on-demand scanner over the project's text files. The +/// rename cascade, ProjectCheckCommand, and the data_find_tag tool all consume +/// the same instance. Each call walks the registry's known files in parallel +/// via IResourceFileSystem; the OS page cache absorbs repeated reads. No +/// in-memory index, no persistent cache. +/// +public sealed class ResourceScanner : IResourceScanner +{ + private const string TagsField = "tags"; + + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly ITextBinarySniffer _textBinarySniffer; + + public ResourceScanner( + ILogger logger, + IWorkspaceWrapper workspaceWrapper, + ITextBinarySniffer textBinarySniffer) + { + _logger = logger; + _workspaceWrapper = workspaceWrapper; + _textBinarySniffer = textBinarySniffer; + } + + public async Task> FindReferencersAsync(ResourceKey target) + { + var matches = new ConcurrentBag(); + + await EnumerateProjectTextFilesAsync(async (resourceKey, _) => + { + var readResult = await ReadFileTextAsync(resourceKey); + if (readResult is null) + { + return; + } + + if (ContainsReferenceTo(readResult, target)) + { + matches.Add(resourceKey); + } + }); + + return matches + .OrderBy(k => k.ToString(), StringComparer.Ordinal) + .ToList(); + } + + public async Task> FindReferencesInAsync(ResourceKey source) + { + var text = await ReadFileTextAsync(source); + if (text is null) + { + return Array.Empty(); + } + return ScanReferences(text).ToList(); + } + + public async Task> FindAllReferencedTargetsAsync() + { + var targets = new ConcurrentDictionary(); + + await EnumerateProjectTextFilesAsync(async (resourceKey, _) => + { + var text = await ReadFileTextAsync(resourceKey); + if (text is null) + { + return; + } + + foreach (var target in ScanReferences(text)) + { + targets.TryAdd(target, 0); + } + }); + + return targets.Keys + .OrderBy(t => t.ToString(), StringComparer.Ordinal) + .ToList(); + } + + public async Task> FindByTagAsync(string tag) + { + if (string.IsNullOrEmpty(tag)) + { + return Array.Empty(); + } + + var matches = new ConcurrentBag(); + + await EnumerateProjectSidecarFilesAsync(async (sidecarKey, parentKey) => + { + var text = await ReadFileTextAsync(sidecarKey); + if (text is null) + { + return; + } + + // Pre-filter for the literal tag bytes before invoking the TOML + // parser. SIMD-accelerated IndexOf keeps the cost of files-without- + // the-tag close to a single memory scan. + if (text.IndexOf(tag, StringComparison.Ordinal) < 0) + { + return; + } + + var parseResult = SidecarHelper.Parse(text); + if (parseResult.IsFailure) + { + return; + } + + if (!parseResult.Value.Frontmatter.TryGetValue(TagsField, out var tagsValue)) + { + return; + } + + if (ListContainsString(tagsValue, tag)) + { + matches.Add(parentKey); + } + }); + + return matches + .OrderBy(k => k.ToString(), StringComparer.Ordinal) + .ToList(); + } + + // Reads a file through IResourceFileSystem so atomic-read + retry semantics + // apply uniformly. Returns null on any read failure; the caller treats + // unreadable files as empty (they simply don't contribute matches). + private async Task ReadFileTextAsync(ResourceKey resource) + { + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var readResult = await fileSystem.ReadAllTextAsync(resource); + if (readResult.IsFailure) + { + _logger.LogDebug($"scanner: read failed for {resource} ({readResult.FirstErrorMessage})"); + return null; + } + return readResult.Value; + } + + // True when `text` contains a tracked "project:" reference. The + // boundary rules in ReferenceLiteralRules constrain the match to canonical + // quoted forms. + private static bool ContainsReferenceTo(string text, ResourceKey target) + { + var marker = ReferenceLiteralRules.ReferenceMarker; + int searchStart = 0; + while (true) + { + int markerIndex = text.IndexOf(marker, searchStart, StringComparison.Ordinal); + if (markerIndex < 0) + { + return false; + } + + var parsed = ReferenceLiteralRules.TryParseReferenceAt(text, markerIndex); + if (parsed is not null + && parsed.Key.Equals(target)) + { + return true; + } + + searchStart = parsed?.EndIndex ?? markerIndex + marker.Length; + } + } + + // Returns the distinct set of "project:" keys named in `text`. + private static HashSet ScanReferences(string text) + { + var references = new HashSet(); + var marker = ReferenceLiteralRules.ReferenceMarker; + int searchStart = 0; + while (true) + { + int markerIndex = text.IndexOf(marker, searchStart, StringComparison.Ordinal); + if (markerIndex < 0) + { + break; + } + + var parsed = ReferenceLiteralRules.TryParseReferenceAt(text, markerIndex); + if (parsed is not null) + { + references.Add(parsed.Key); + searchStart = parsed.EndIndex; + } + else + { + searchStart = markerIndex + marker.Length; + } + } + return references; + } + + // Walks all project: text files in parallel, invoking the visitor for + // each. Reads the registry's snapshot directly; mutation commands carry + // CommandFlags.UpdateResources so the snapshot reflects the latest disk + // state by the time a tool that consults the scanner runs. Binary files + // are skipped via the extension sniffer; unknown extensions trigger a + // one-time content sniff cached for the scan's duration. + private async Task EnumerateProjectTextFilesAsync(Func visit) + { + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); + + var textSniffCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + await Parallel.ForEachAsync(files, async (file, _) => + { + var (resourceKey, absolutePath) = file; + if (!IsScannableTextFile(absolutePath, textSniffCache)) + { + return; + } + + await visit(resourceKey, absolutePath); + }); + } + + // Walks all .cel files paired with an existing parent file. Reads the + // registry's snapshot directly; mutation commands carry + // CommandFlags.UpdateResources so the snapshot reflects the latest disk + // state. Orphans (no parent file on disk) are skipped — tag queries are + // scoped to paired sidecars; orphans surface via data_check_project. + private async Task EnumerateProjectSidecarFilesAsync(Func visit) + { + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); + + await Parallel.ForEachAsync(files, async (file, _) => + { + var (resourceKey, absolutePath) = file; + if (!IsSidecarPath(absolutePath)) + { + return; + } + + var parentKey = StripSidecarSuffix(resourceKey); + if (parentKey is null) + { + return; + } + + var existsResult = await fileSystem.ExistsAsync(parentKey.Value); + if (existsResult.IsFailure + || !existsResult.Value) + { + return; + } + + await visit(resourceKey, parentKey.Value); + }); + } + + private bool IsScannableTextFile( + string absolutePath, + ConcurrentDictionary textSniffCache) + { + var extension = Path.GetExtension(absolutePath); + if (!string.IsNullOrEmpty(extension) + && _textBinarySniffer.IsBinaryExtension(extension)) + { + return false; + } + + // Unknown-extension files: content-sniff once per scan. The cache is + // local to this scan; we don't keep it across calls because the + // registry rebuilds frequently and the per-scan cost is small. + if (string.IsNullOrEmpty(extension)) + { + return textSniffCache.GetOrAdd(absolutePath, path => + { + var sniff = _textBinarySniffer.IsTextFile(path); + return sniff.IsSuccess && sniff.Value; + }); + } + + return true; + } + + private static bool IsSidecarPath(string absolutePath) + { + var fileName = Path.GetFileName(absolutePath); + if (!fileName.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + // .cel.cel files are invalid sidecars by definition. + if (fileName.EndsWith(SidecarHelper.Extension + SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + return true; + } + + // Strips the trailing ".cel" from a sidecar key to recover its parent + // resource key. Returns null when the result would be empty. + private static ResourceKey? StripSidecarSuffix(ResourceKey sidecarKey) + { + var fullKey = sidecarKey.FullKey; + if (!fullKey.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + var trimmed = fullKey.Substring(0, fullKey.Length - SidecarHelper.Extension.Length); + if (string.IsNullOrEmpty(trimmed) + || trimmed.EndsWith(":", StringComparison.Ordinal)) + { + return null; + } + return new ResourceKey(trimmed); + } + + private static bool ListContainsString(object value, string candidate) + { + if (value is string) + { + return false; + } + + if (value is TomlArray tomlArray) + { + foreach (var item in tomlArray) + { + if (item is string s + && string.Equals(s, candidate, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + if (value is System.Collections.IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item is string s + && string.Equals(s, candidate, StringComparison.Ordinal)) + { + return true; + } + } + } + return false; + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs new file mode 100644 index 000000000..6642ad9f7 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -0,0 +1,191 @@ +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +/// +/// Workspace-scoped implementation of ISidecarService. Reads and writes +/// .cel sidecar files through IResourceFileSystem so the chokepoint's +/// atomic-write + retry behaviour applies uniformly. Pure utility helpers +/// (block-name validation, indexable-shape validation) delegate to +/// SidecarHelper so the format internals stay in one place. +/// +public sealed class SidecarService : ISidecarService +{ + private readonly IWorkspaceWrapper _workspaceWrapper; + + public SidecarService(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + public bool IsSidecarKey(ResourceKey resource) + { + if (resource.IsEmpty) + { + return false; + } + return resource.Path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase); + } + + public Result GetSidecarKey(ResourceKey parent) + { + if (parent.IsEmpty) + { + return Result.Fail("Cannot build a sidecar key for an empty resource."); + } + if (IsSidecarKey(parent)) + { + return Result.Fail($"Cannot build a sidecar key for sidecar resource '{parent}': pass the parent resource key instead."); + } + return Result.Ok(new ResourceKey(parent.FullKey + SidecarHelper.Extension)); + } + + public bool IsValidBlockName(string name) => SidecarHelper.IsValidBlockName(name); + + public bool IsIndexableValue(object? value) => SidecarHelper.IsIndexableValue(value); + + public async Task> ReadAsync(ResourceKey parent) + { + var sidecarKeyResult = GetSidecarKey(parent); + if (sidecarKeyResult.IsFailure) + { + return Result.Fail(sidecarKeyResult); + } + var sidecarKey = sidecarKeyResult.Value; + + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + + var existsResult = await fileSystem.ExistsAsync(sidecarKey); + if (existsResult.IsFailure + || !existsResult.Value) + { + return Result.Ok(new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)); + } + + var readResult = await fileSystem.ReadAllTextAsync(sidecarKey); + if (readResult.IsFailure) + { + return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Broken, null, readResult.FirstErrorMessage)); + } + + var parseResult = SidecarHelper.Parse(readResult.Value); + if (parseResult.IsFailure) + { + return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Broken, null, parseResult.FirstErrorMessage)); + } + + return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Healthy, parseResult.Value, null)); + } + + public async Task MutateFrontmatterAsync( + ResourceKey parent, + Action> mutate, + bool createIfMissing = true) + { + var sidecarKeyResult = GetSidecarKey(parent); + if (sidecarKeyResult.IsFailure) + { + return Result.Fail(sidecarKeyResult); + } + var sidecarKey = sidecarKeyResult.Value; + + var readResult = await ReadAsync(parent); + if (readResult.IsFailure) + { + return Result.Fail(readResult); + } + var read = readResult.Value; + + Dictionary working; + IReadOnlyList blocks = Array.Empty(); + + switch (read.Outcome) + { + case SidecarReadOutcome.Healthy: + working = new Dictionary(read.Content!.Frontmatter, StringComparer.Ordinal); + blocks = read.Content.Blocks; + break; + + case SidecarReadOutcome.NoSidecar: + if (!createIfMissing) + { + return Result.Ok(); + } + working = new Dictionary(StringComparer.Ordinal); + break; + + case SidecarReadOutcome.Broken: + default: + return Result.Fail($"Cannot mutate sidecar '{sidecarKey}': {read.FailureMessage ?? "parse failed"}."); + } + + mutate(working); + + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var composed = SidecarHelper.Compose(working, blocks); + var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, composed); + if (writeResult.IsFailure) + { + return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") + .WithErrors(writeResult); + } + return Result.Ok(); + } + + public async Task MutateBlocksAsync( + ResourceKey parent, + Action> mutate, + bool createIfMissing = true) + { + var sidecarKeyResult = GetSidecarKey(parent); + if (sidecarKeyResult.IsFailure) + { + return Result.Fail(sidecarKeyResult); + } + var sidecarKey = sidecarKeyResult.Value; + + var readResult = await ReadAsync(parent); + if (readResult.IsFailure) + { + return Result.Fail(readResult); + } + var read = readResult.Value; + + Dictionary frontmatter; + List working; + + switch (read.Outcome) + { + case SidecarReadOutcome.Healthy: + frontmatter = new Dictionary(read.Content!.Frontmatter, StringComparer.Ordinal); + working = new List(read.Content.Blocks); + break; + + case SidecarReadOutcome.NoSidecar: + if (!createIfMissing) + { + return Result.Ok(); + } + frontmatter = new Dictionary(StringComparer.Ordinal); + working = new List(); + break; + + case SidecarReadOutcome.Broken: + default: + return Result.Fail($"Cannot mutate sidecar '{sidecarKey}': {read.FailureMessage ?? "parse failed"}."); + } + + mutate(working); + + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var composed = SidecarHelper.Compose(frontmatter, working); + var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, composed); + if (writeResult.IsFailure) + { + return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") + .WithErrors(writeResult); + } + return Result.Ok(); + } +} diff --git a/Source/Workspace/Celbridge.WorkspaceUI/ServiceConfiguration.cs b/Source/Workspace/Celbridge.WorkspaceUI/ServiceConfiguration.cs index 3f2ca72c6..1c70b8516 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/ServiceConfiguration.cs @@ -21,6 +21,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // // Register panels diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs new file mode 100644 index 000000000..ea43e1a56 --- /dev/null +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs @@ -0,0 +1,109 @@ +using System.Globalization; +using System.Text; +using Celbridge.Console; +using Celbridge.Logging; +using Celbridge.Messaging; +using Celbridge.Resources; + +namespace Celbridge.WorkspaceUI.Services; + +/// +/// Formats and dispatches the output of a workspace-load project-consistency +/// check. Writes one multi-line warning per non-empty finding category to the +/// host log (capped per category so a project with many findings doesn't flood +/// the log) and publishes a single summary banner via IMessengerService so the +/// user notices without having to invoke data_check_project by hand. +/// +public sealed class ProjectCheckReporter +{ + // Cap the per-category enumeration so a project with many findings does + // not flood the host log. The MCP tool data_check_project always returns + // the full set. + private const int MaxLoggedFindingsPerCategory = 20; + + private readonly ILogger _logger; + private readonly IMessengerService _messengerService; + + public ProjectCheckReporter( + ILogger logger, + IMessengerService messengerService) + { + _logger = logger; + _messengerService = messengerService; + } + + /// + /// Logs one warning per non-empty finding category and, when the total + /// finding count is non-zero, sends a ConsoleErrorMessage carrying the + /// total so the console panel can surface a dismissable warning banner. + /// + public void Report(ProjectCheckReport report) + { + if (report.BrokenReferences.Count > 0) + { + var entries = report.BrokenReferences + .Select(r => $"'{r.Source.FullKey}' references missing '{r.MissingTarget.FullKey}'") + .ToList(); + LogFindingsCategory( + $"Project consistency check: {entries.Count} broken project: reference(s).", + entries); + } + if (report.OrphanSidecars.Count > 0) + { + var entries = report.OrphanSidecars + .Select(o => $"'{o.Sidecar.FullKey}'") + .ToList(); + LogFindingsCategory( + $"Project consistency check: {entries.Count} orphan sidecar(s).", + entries); + } + if (report.BrokenSidecars.Count > 0) + { + var entries = report.BrokenSidecars + .Select(b => $"'{b.Sidecar.FullKey}'") + .ToList(); + LogFindingsCategory( + $"Project consistency check: {entries.Count} broken sidecar(s).", + entries); + } + + var totalFindings = report.BrokenReferences.Count + + report.OrphanSidecars.Count + + report.BrokenSidecars.Count; + if (totalFindings > 0) + { + var message = new ConsoleErrorMessage( + ConsoleErrorType.ProjectCheckError, + totalFindings.ToString(CultureInfo.InvariantCulture)); + _messengerService.Send(message); + } + } + + // Emits a single multi-line warning per category: header line followed by + // each entry indented two spaces, with a trailing "... and N more" when + // the list was truncated. Keeps developer-facing diagnostics in one place + // (the host log) rather than splitting them across a count-only warning + // and a separate MCP tool invocation. + private void LogFindingsCategory(string headerSummary, IReadOnlyList entries) + { + var builder = new StringBuilder(); + builder.Append(headerSummary); + + var limit = Math.Min(entries.Count, MaxLoggedFindingsPerCategory); + for (int i = 0; i < limit; i++) + { + builder.AppendLine(); + builder.Append(" "); + builder.Append(entries[i]); + } + + if (entries.Count > MaxLoggedFindingsPerCategory) + { + var omitted = entries.Count - MaxLoggedFindingsPerCategory; + builder.AppendLine(); + builder.Append($" ... and {omitted} more (use data_check_project for the full list)."); + } + + _logger.LogWarning(builder.ToString()); + } +} diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs index d88fa09f3..e17316c38 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs @@ -17,6 +17,7 @@ public class WorkspaceLoader private readonly IFeatureFlags _featureFlags; private readonly IProjectService _projectService; private readonly IServerService _serverService; + private readonly ProjectCheckReporter _projectCheckReporter; public WorkspaceLoader( ILogger logger, @@ -24,7 +25,8 @@ public WorkspaceLoader( IUserInterfaceService userInterfaceService, IFeatureFlags featureFlags, IProjectService projectService, - IServerService serverService) + IServerService serverService, + ProjectCheckReporter projectCheckReporter) { _logger = logger; _workspaceWrapper = workspaceWrapper; @@ -32,6 +34,7 @@ public WorkspaceLoader( _featureFlags = featureFlags; _projectService = projectService; _serverService = serverService; + _projectCheckReporter = projectCheckReporter; } public async Task LoadWorkspaceAsync() @@ -130,21 +133,10 @@ public async Task LoadWorkspaceAsync() .WithErrors(updateResult); } - // Rebuild the metadata index synchronously before downstream steps - // (package discovery, activity service, Python init) get a chance to - // touch files on disk. Running concurrently risks scanning a file - // mid-write and recording stale references or mtime stamps. - var metaData = workspaceService.ResourceMetaData; - var rebuildResult = await metaData.RebuildAsync(); - if (rebuildResult.IsFailure) - { - _logger.LogWarning(rebuildResult, "Failed to rebuild resource metadata index"); - } - // Fire-and-forget the project-health check so banner-worthy findings // surface in the host log without blocking workspace load. The - // command awaits the metadata index internally and then walks the - // reference graph; on a clean project the result is empty. + // command scans the project's text files on demand; on a clean + // project the result is empty. _ = Task.Run(() => RunProjectCheckAsync()); } catch (Exception ex) @@ -238,10 +230,10 @@ public async Task LoadWorkspaceAsync() return Result.Ok(); } - // Runs metadata_check_project in the background and writes a one-line - // summary per non-empty category to the host log. The check is read-only - // and does not repair anything; surfacing the issues here lets the user - // notice them without having to invoke the MCP tool by hand. + // Runs data_check_project in the background and delegates formatting and + // dispatch of the report to ProjectCheckReporter. Failure to execute the + // command or any unexpected exception is logged but never propagated, so + // a broken check cannot tear down the workspace load path. private async Task RunProjectCheckAsync() { try @@ -253,27 +245,11 @@ private async Task RunProjectCheckAsync() _logger.LogWarning(reportResult, "Project consistency check failed."); return; } - var report = reportResult.Value; - if (report.BrokenReferences.Count > 0) - { - _logger.LogWarning( - $"Project consistency check: {report.BrokenReferences.Count} broken project: reference(s). Run metadata_check_project for the full list."); - } - if (report.OrphanSidecars.Count > 0) - { - _logger.LogWarning( - $"Project consistency check: {report.OrphanSidecars.Count} orphan sidecar(s). Run metadata_check_project for the full list."); - } - if (report.BrokenSidecars.Count > 0) - { - _logger.LogWarning( - $"Project consistency check: {report.BrokenSidecars.Count} broken sidecar(s). Run metadata_check_project for the full list."); - } + _projectCheckReporter.Report(reportResult.Value); } catch (Exception ex) { - // Never let the background check tear down the workspace load path. _logger.LogWarning(ex, "Project consistency check threw an unexpected exception."); } } diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index ab1bac85b..582261d4a 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -24,7 +24,8 @@ public class WorkspaceService : IWorkspaceService, IDisposable public IPackageService PackageService { get; } public IResourceService ResourceService { get; } public IResourceFileSystem ResourceFileSystem { get; } - public IResourceMetaData ResourceMetaData { get; } + public IResourceScanner ResourceScanner { get; } + public ISidecarService SidecarService { get; } public IExplorerService ExplorerService { get; } public IDocumentsService DocumentsService { get; } public IInspectorService InspectorService { get; } @@ -60,7 +61,8 @@ public WorkspaceService( PackageService = serviceProvider.GetRequiredService(); ResourceService = serviceProvider.GetRequiredService(); ResourceFileSystem = serviceProvider.GetRequiredService(); - ResourceMetaData = serviceProvider.GetRequiredService(); + ResourceScanner = serviceProvider.GetRequiredService(); + SidecarService = serviceProvider.GetRequiredService(); ExplorerService = serviceProvider.GetRequiredService(); DocumentsService = serviceProvider.GetRequiredService(); InspectorService = serviceProvider.GetRequiredService(); @@ -188,7 +190,6 @@ protected virtual void Dispose(bool disposing) // Dispose resource service first to stop file system monitoring (ResourceService as IDisposable)?.Dispose(); - (ResourceMetaData as IDisposable)?.Dispose(); (WorkspaceSettingsService as IDisposable)!.Dispose(); (PythonService as IDisposable)!.Dispose(); (ConsoleService as IDisposable)!.Dispose(); From 444b9ed0a55a16c3c9b9c77714099f247b88b7ee Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 22 May 2026 18:21:01 +0100 Subject: [PATCH 15/48] Clarify data_get_info behavior when parent missing Clarifies that data_get_info returns an empty { "fields": {}, "blocks": [] } both when the sidecar is missing and when the parent resource doesn't exist, because the tool only inspects the sidecar file. Advises using file_get_info first to verify the parent exists; retains the note about clear errors for broken sidecars and alternatives (file_read, data_check_project). --- Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md index 05ed1f6f0..8b342117b 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md @@ -18,6 +18,6 @@ Response shape: } ``` -Returns `{ "fields": {}, "blocks": [] }` when the resource has no sidecar. Errors with a clear message when the sidecar exists but is broken; use `file_read` for raw inspection in that case, or `data_check_project` for the system-level view. +Returns `{ "fields": {}, "blocks": [] }` when the resource has no sidecar — and that same empty success is returned when the *parent* resource doesn't exist either (the tool only inspects the sidecar file, it does not check whether the parent file is on disk). Use `file_get_info` first if you need to verify the parent exists before reading its data. Errors with a clear message when the sidecar exists but is broken; use `file_read` for raw inspection in that case, or `data_check_project` for the system-level view. `size` is the UTF-8 byte count of the block's semantic content (matching what `data_read_block` returns). Block content is line-oriented: the terminator that separates one block from the next on disk is not part of the content, so a block's `size` is stable as adjacent blocks are added or removed. From 7b928432d65552fe77a4baf6b118b77cd60791ea Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 22 May 2026 20:45:52 +0100 Subject: [PATCH 16/48] Canonicalize ResourceKey.ToString to include root Make ResourceKey.ToString() always return the canonical "root:path" form (including the explicit "project:" prefix for the default root) and update call sites, docs and tests accordingly. Adjust tools to expose empty selection/document as empty string (signal none) while keeping canonical serialisation for non-empty keys. Update WebView screenshot/resolver and URL code to use the Path portion for filesystem/URL operations. Normalize sidecar TOML frontmatter to LF line endings to avoid mixed CRLF/LF. Minor cleanup in ResourceFileSystem GetSidecarKey return style. Tests and guides updated to reflect the new canonical form and behavior. --- .../Celbridge.Foundation/Core/ResourceKey.cs | 20 +++++---- .../Guides/Namespaces/explorer.md | 4 +- .../Guides/Tools/explorer_delete.md | 2 + .../Guides/Tools/explorer_move.md | 4 ++ .../Tools/Document/DocumentStateProvider.cs | 7 +++- .../Tools/Explorer/ExplorerTools.GetState.cs | 8 +++- .../WebView/WebViewScreenshotResolver.cs | 10 ++--- .../ViewModels/WebViewDocumentViewModel.cs | 5 ++- .../Tests/Resources/DataCheckProjectTests.cs | 10 ++--- Source/Tests/Resources/ResourceKeyTests.cs | 40 ++++++++++-------- .../Tests/Resources/ResourceRegistryTests.cs | 3 +- Source/Tests/Resources/SidecarHelperTests.cs | 24 +++++++++++ Source/Tests/Tools/DocumentToolTests.cs | 8 ++-- Source/Tests/Tools/ExplorerToolTests.cs | 9 ++-- Source/Tests/Tools/FileToolTests.cs | 10 ++--- Source/Tests/Tools/FileToolsReadImageTests.cs | 2 +- .../Tools/WebViewScreenshotResolverTests.cs | 41 ++++++++++--------- .../Helpers/SidecarHelper.cs | 10 ++++- .../Services/ResourceFileSystem.cs | 6 ++- 19 files changed, 148 insertions(+), 75 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs index d8a60671b..c402e9fc0 100644 --- a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs +++ b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs @@ -79,18 +79,24 @@ public static bool TryCreate(string key, out ResourceKey result) /// /// The canonical "root:path" form of this key. Always carries the explicit root prefix, - /// even for the default "project" root. Use for serialisation and unambiguous diagnostics. + /// even for the default "project" root. Equivalent to ToString(); retained as an explicit + /// accessor for sites where intent benefits from being explicit. /// public string FullKey => (_root ?? DefaultRoot) + ":" + (_path ?? string.Empty); + /// + /// Canonical serialised form: always "root:path", including the explicit "project:" prefix + /// for the default root. Matches the literal form the reference scanner detects in file + /// content, so any resource key surfaced through ToString (tool responses, log messages, + /// error text, debugger views) can be round-tripped or copy-pasted directly into a quoted + /// reference without forgetting the prefix. + /// + /// For UI surfaces or other display contexts that explicitly want the bare path without + /// the root prefix, use the accessor instead. + /// public override string ToString() { - // Display form: bare path for the default "project" root, "root:path" otherwise. - if (_root is null) - { - return _path ?? string.Empty; - } - return _root + ":" + (_path ?? string.Empty); + return (_root ?? DefaultRoot) + ":" + (_path ?? string.Empty); } /// diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/explorer.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/explorer.md index 3e87bbf82..867f432dd 100644 --- a/Source/Core/Celbridge.Tools/Guides/Namespaces/explorer.md +++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/explorer.md @@ -6,7 +6,7 @@ The `explorer` namespace operates on the resource tree: it creates, renames, mov - **Resource keys are forward-slash paths relative to the project content root.** No backslashes, no absolute paths. `Scripts/hello.py` is a file; `Data` is a folder; the empty string is the project root. See `resource_keys`. - **Most explorer mutations participate in the undo stack.** `explorer_undo` and `explorer_redo` reverse the last user-driven or tool-driven action. The undo unit is the operation, not the keystroke. -- **`explorer_rename` and `explorer_duplicate` are interactive.** They surface a dialog the user must confirm. For non-interactive renames, use `explorer_move`. See `silent_vs_interactive`. +- **`explorer_rename` is always interactive** — it opens a dialog the user must confirm. For non-interactive renames, use `explorer_move`. **`explorer_duplicate` is silent by default** (`showDialog: false`, the recommended agent path: auto-generates a unique name and returns without prompting); pass `showDialog: true` only when the user has asked to pick the destination name. See `silent_vs_interactive`. - **Resolve "the folder I'm looking at" against the explorer selection.** Call `explorer_get_state` to read selection and expanded folders before resorting to project-wide search. See `workspace_panels`. ## Tools @@ -17,7 +17,7 @@ The `explorer` namespace operates on the resource tree: it creates, renames, mov - `explorer_rename` — interactive rename via dialog. - `explorer_move` — non-interactive rename or move. - `explorer_copy` — copy a resource to a new key. -- `explorer_duplicate` — interactive duplicate via dialog. +- `explorer_duplicate` — silent duplicate with auto-generated name (interactive dialog available via `showDialog: true`). - `explorer_delete` — delete a resource. Sends to the system trash where supported. **Selection and tree state.** diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md index 6d6f6bb14..239bf18e9 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md @@ -48,6 +48,8 @@ The JSON shape is: } ``` +Resource keys appear in their canonical `root:path` form (with the explicit `project:` prefix for project-rooted resources), matching the literal form the reference scanner detects in tracked content. + - `batchOutcome` summarises the whole batch. `DeletedAll` and `DeletedSome` mean execution ran (the policy gate passed); `CancelledByUser` and `BlockedByReferences` mean the gate refused before any filesystem changes. `DeletedSome` also covers the rare edge where every resource in the batch failed mechanically — inspect `resourceResults` for the per-resource detail in any non-`DeletedAll` case. - `resourceResults` carries one entry per input resource — for a folder delete that means one entry for the folder itself, not one entry per descendant file. The per-descendant breakdown of external references lives in `referencers` (see below). `outcome` is typed so the agent can branch on the cause without parsing strings: - `NotFound` — the resource was already gone on disk. Treat as success — the user's intent is already satisfied. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md index 5cc65bd32..6f4de3a90 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_move.md @@ -15,6 +15,8 @@ Resolved against the source: The compact `"ok"` is reserved for the no-side-effect case: the move touched no references, no referencers were skipped, and no resources failed mechanically. Whenever the move actually rewrote references, left a cascade incomplete, or had a per-resource failure, the response is the JSON payload below — so an agent that needs to report what changed gets the rewritten-referencer list without a follow-up grep. +Both the compact `"ok"` string and the JSON `{"status":"ok", ...}` object indicate overall success — the difference is that the compact form means zero observable side effects, while the JSON form means at least one reference was rewritten or one cascade step ran. An agent that only branches on `response.status == "ok"` misses the compact-vs-JSON distinction; branch on the response shape (string vs object) first. + ```json { "status": "ok" | "ok_with_skipped_referencers" | "partial_failure", @@ -27,6 +29,8 @@ The compact `"ok"` is reserved for the no-side-effect case: the move touched no } ``` +Resource keys appear in their canonical `root:path` form (with the explicit `project:` prefix for project-rooted resources), matching the literal form the reference scanner detects in tracked content. + - `status`: - `"ok"` — every cascade step succeeded; `updatedReferencers` may be non-empty. - `"ok_with_skipped_referencers"` — the move itself completed but the cascade left some references stale (see `skippedReferencers`). diff --git a/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs b/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs index 2bc0f0fd3..364b6eee9 100644 --- a/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs +++ b/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs @@ -63,8 +63,13 @@ public async Task> GetStateAsync() document.EditorId.ToString())); } + // An empty active document key (no document open) serialises as the + // empty string rather than the canonical "project:" form, so the + // response field is a clean signal that nothing is active. + var activeDocumentString = activeDocument.IsEmpty ? string.Empty : activeDocument.ToString(); + return new DocumentStateResult( - activeDocument.ToString(), + activeDocumentString, snapshot.SectionCount, documents); } diff --git a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.GetState.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.GetState.cs index 3ab18855c..89612f47e 100644 --- a/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.GetState.cs +++ b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.GetState.cs @@ -23,8 +23,14 @@ public partial CallToolResult GetState() var workspaceWrapper = GetRequiredService(); var explorerService = workspaceWrapper.WorkspaceService.ExplorerService; + // Empty resource keys (no selection) serialise as the empty string + // rather than the canonical "project:" form, so the response field is + // a clean signal that nothing is selected. + var selectedResource = explorerService.SelectedResource; + var selectedResourceString = selectedResource.IsEmpty ? string.Empty : selectedResource.ToString(); + var result = new ExplorerStateResult( - explorerService.SelectedResource.ToString(), + selectedResourceString, explorerService.SelectedResources.Select(r => r.ToString()).ToList(), explorerService.FolderStateService.ExpandedFolders); diff --git a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs index 9b76f1ba6..cca231900 100644 --- a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs +++ b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs @@ -53,12 +53,12 @@ public static Result Resolve(string saveTo, string format, string p // A trailing slash means "auto-name in this folder". A path without // an extension is also treated as a folder, since screenshot files // always carry an extension. - if (endsWithSlash || !HasExtension(key.ToString())) + if (endsWithSlash || !HasExtension(key.Path)) { - var folderResourceKey = key.ToString(); - var folderAbsolutePath = ResolveAbsoluteUnderProject(projectFolderPath, folderResourceKey); + var folderPath = key.Path; + var folderAbsolutePath = ResolveAbsoluteUnderProject(projectFolderPath, folderPath); var fileName = GenerateAutoName(extension, folderAbsolutePath); - var combined = string.IsNullOrEmpty(folderResourceKey) ? fileName : folderResourceKey + "/" + fileName; + var combined = string.IsNullOrEmpty(folderPath) ? fileName : folderPath + "/" + fileName; if (!ResourceKey.TryCreate(combined, out var fileKey)) { return Result.Fail($"Failed to construct resource key for auto-named screenshot in folder '{saveTo}'"); @@ -70,7 +70,7 @@ public static Result Resolve(string saveTo, string format, string p // Treat as exact resource key path. Validate the extension matches // the requested format so the saved bytes are consistent with the // filename. - var actualExtension = Path.GetExtension(key.ToString()).TrimStart('.').ToLowerInvariant(); + var actualExtension = Path.GetExtension(key.Path).TrimStart('.').ToLowerInvariant(); if (!ExtensionMatchesFormat(actualExtension, format)) { return Result.Fail( diff --git a/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs b/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs index 30018eade..0e0169630 100644 --- a/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs +++ b/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs @@ -41,7 +41,10 @@ public string NavigateUrl return string.Empty; } - return $"https://{ProjectVirtualHost}/{FileResource}"; + // URL path is the bare resource path; the "project:" prefix that + // ResourceKey.ToString() now emits is for serialised diagnostics, + // not URL construction. + return $"https://{ProjectVirtualHost}/{FileResource.Path}"; } return SourceUrl; diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index f645a189f..0cc96c65c 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -180,10 +180,10 @@ public async Task MultipleBrokenReferences_OrderedDeterministically() .Select(r => (r.MissingTarget.ToString(), r.Source.ToString())) .ToList(); - keys[0].Item1.Should().Be("aaa.md"); - keys[1].Item1.Should().Be("zzz.md"); - keys[2].Item1.Should().Be("zzz.md"); - keys[1].Item2.Should().Be("a.md"); - keys[2].Item2.Should().Be("b.md"); + keys[0].Item1.Should().Be("project:aaa.md"); + keys[1].Item1.Should().Be("project:zzz.md"); + keys[2].Item1.Should().Be("project:zzz.md"); + keys[1].Item2.Should().Be("project:a.md"); + keys[2].Item2.Should().Be("project:b.md"); } } diff --git a/Source/Tests/Resources/ResourceKeyTests.cs b/Source/Tests/Resources/ResourceKeyTests.cs index 0ec361da0..16e3ed4db 100644 --- a/Source/Tests/Resources/ResourceKeyTests.cs +++ b/Source/Tests/Resources/ResourceKeyTests.cs @@ -23,7 +23,7 @@ public void ConstructorThrowsOnInvalidKey() { // Valid keys should not throw var validKey = new ResourceKey("Some/Path/File.txt"); - validKey.ToString().Should().Be("Some/Path/File.txt"); + validKey.ToString().Should().Be("project:Some/Path/File.txt"); // Empty key is valid var emptyKey = new ResourceKey(""); @@ -61,7 +61,7 @@ public void ImplicitConversionThrowsOnInvalidKey() { // Valid string converts successfully ResourceKey key = "Some/Path/File.txt"; - key.ToString().Should().Be("Some/Path/File.txt"); + key.ToString().Should().Be("project:Some/Path/File.txt"); // Invalid string throws var act = () => { ResourceKey invalid = "../escape"; }; @@ -73,7 +73,7 @@ public void CreateThrowsOnInvalidKey() { // Valid keys should not throw var validKey = ResourceKey.Create("Some/Path/File.txt"); - validKey.ToString().Should().Be("Some/Path/File.txt"); + validKey.ToString().Should().Be("project:Some/Path/File.txt"); // Empty key is valid var emptyKey = ResourceKey.Create(""); @@ -95,7 +95,7 @@ public void TryCreateReturnsFalseOnInvalidKey() { // Valid keys should succeed ResourceKey.TryCreate("Some/Path/File.txt", out var validKey).Should().BeTrue(); - validKey.ToString().Should().Be("Some/Path/File.txt"); + validKey.ToString().Should().Be("project:Some/Path/File.txt"); // Empty key is valid ResourceKey.TryCreate("", out var emptyKey).Should().BeTrue(); @@ -133,22 +133,22 @@ public void IsDescendantOfWorksCorrectly() public void GetParentReturnsParentFolder() { // Nested path returns parent folder - new ResourceKey("a/b/file.txt").GetParent().ToString().Should().Be("a/b"); + new ResourceKey("a/b/file.txt").GetParent().ToString().Should().Be("project:a/b"); // Deeply nested path - new ResourceKey("a/b/c/d/file.txt").GetParent().ToString().Should().Be("a/b/c/d"); + new ResourceKey("a/b/c/d/file.txt").GetParent().ToString().Should().Be("project:a/b/c/d"); // Root-level file returns empty - new ResourceKey("file.txt").GetParent().ToString().Should().Be(""); + new ResourceKey("file.txt").GetParent().ToString().Should().Be("project:"); // Empty key returns empty - ResourceKey.Empty.GetParent().ToString().Should().Be(""); + ResourceKey.Empty.GetParent().ToString().Should().Be("project:"); // Path with spaces in segments - new ResourceKey("My Docs/My File.txt").GetParent().ToString().Should().Be("My Docs"); + new ResourceKey("My Docs/My File.txt").GetParent().ToString().Should().Be("project:My Docs"); // Single subfolder - new ResourceKey("docs/readme.md").GetParent().ToString().Should().Be("docs"); + new ResourceKey("docs/readme.md").GetParent().ToString().Should().Be("project:docs"); } [Test] @@ -158,12 +158,12 @@ public void CombineValidatesSegments() // Valid segment var combined = baseKey.Combine("file.txt"); - combined.ToString().Should().Be("folder/file.txt"); + combined.ToString().Should().Be("project:folder/file.txt"); // Empty base key var emptyBase = ResourceKey.Empty; var fromEmpty = emptyBase.Combine("file.txt"); - fromEmpty.ToString().Should().Be("file.txt"); + fromEmpty.ToString().Should().Be("project:file.txt"); // Invalid segment with path separator throws var act1 = () => baseKey.Combine("sub/file.txt"); @@ -183,7 +183,9 @@ public void EmptyKeyIsValid() { var emptyKey = ResourceKey.Empty; emptyKey.IsEmpty.Should().BeTrue(); - emptyKey.ToString().Should().Be(""); + // Empty key still carries its (default) root prefix in canonical form; + // use IsEmpty to detect the "no path" case. + emptyKey.ToString().Should().Be("project:"); } [Test] @@ -196,7 +198,7 @@ public void ImplicitProjectRootRoundTripsCleanly() rk.Root.Should().Be("project"); rk.Path.Should().Be("foo"); rk.FullKey.Should().Be("project:foo"); - rk.ToString().Should().Be("foo"); + rk.ToString().Should().Be("project:foo"); } [Test] @@ -230,11 +232,13 @@ public void FullKeyAlwaysCarriesRootPrefix() } [Test] - public void ToStringEmitsDisplayForm() + public void ToStringEmitsCanonicalForm() { - // The "project:" prefix is suppressed in display form; other roots are shown explicitly. - new ResourceKey("foo/bar").ToString().Should().Be("foo/bar"); - new ResourceKey("project:foo/bar").ToString().Should().Be("foo/bar"); + // ToString always carries the root prefix, including "project:" for the default + // root, so any value surfaced through ToString matches the literal form the + // reference scanner detects and can be copy-pasted into a tracked reference. + new ResourceKey("foo/bar").ToString().Should().Be("project:foo/bar"); + new ResourceKey("project:foo/bar").ToString().Should().Be("project:foo/bar"); new ResourceKey("temp:staging/foo").ToString().Should().Be("temp:staging/foo"); new ResourceKey("temp:").ToString().Should().Be("temp:"); } diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index cc4cb9d6e..c8df954c9 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -126,7 +126,8 @@ public void ICanExpandAFolderResource() var expandedFoldersOut = folderStateService.ExpandedFolders; expandedFoldersOut.Count.Should().Be(1); - expandedFoldersOut[0].Should().Be(FolderNameA); + // ExpandedFolders stores resource keys in their canonical (prefixed) string form. + expandedFoldersOut[0].Should().Be("project:" + FolderNameA); var folderResource = (resourceRegistry.ProjectFolder.Children[0] as FolderResource)!; var folderPath = resourceRegistry.GetResourceKey(folderResource); diff --git a/Source/Tests/Resources/SidecarHelperTests.cs b/Source/Tests/Resources/SidecarHelperTests.cs index f48390cc8..cff246d9c 100644 --- a/Source/Tests/Resources/SidecarHelperTests.cs +++ b/Source/Tests/Resources/SidecarHelperTests.cs @@ -234,6 +234,30 @@ public void Parse_BlockContentSizeIsStableAcrossAdjacentAppends() parsedTwo.Blocks[0].Content.Length.Should().Be(parsedSingle.Blocks[0].Content.Length); } + [Test] + public void Compose_NormalisesTomlFrontmatterToLfLineEndings() + { + // Tomlyn emits Environment.NewLine (CRLF on Windows) for the + // frontmatter section. Compose normalises to LF so the whole sidecar + // file uses a single line ending convention, matching the LF literals + // used for fence lines and content terminators. + var frontmatter = new Dictionary + { + ["editor"] = "celbridge.test", + ["tags"] = new List { "alpha", "beta" }, + }; + var blocks = new List + { + new("test.block", "body"), + }; + + var composed = SidecarHelper.Compose(frontmatter, blocks); + + composed.Should().NotContain("\r\n"); + composed.Should().NotContain("\r"); + composed.Should().Contain("\n"); + } + [Test] public void Parse_CRLFBlockContentTerminatorIsStripped() { diff --git a/Source/Tests/Tools/DocumentToolTests.cs b/Source/Tests/Tools/DocumentToolTests.cs index f5d414a00..594829895 100644 --- a/Source/Tests/Tools/DocumentToolTests.cs +++ b/Source/Tests/Tools/DocumentToolTests.cs @@ -61,14 +61,14 @@ public async Task GetState_ReturnsActiveDocument() var tools = new DocumentTools(_services); var root = ParseResult(await tools.GetState()); - root.GetProperty("activeDocument").GetString().Should().Be("notes/readme.md"); + root.GetProperty("activeDocument").GetString().Should().Be("project:notes/readme.md"); root.GetProperty("sectionCount").GetInt32().Should().Be(1); var openDocuments = root.GetProperty("openDocuments"); openDocuments.GetArrayLength().Should().Be(1); var firstDocument = openDocuments[0]; - firstDocument.GetProperty("resource").GetString().Should().Be("notes/readme.md"); + firstDocument.GetProperty("resource").GetString().Should().Be("project:notes/readme.md"); firstDocument.GetProperty("isActive").GetBoolean().Should().BeTrue(); } @@ -95,11 +95,11 @@ public async Task GetState_MultipleDocumentsAcrossSections() var documents = root.GetProperty("openDocuments"); var activeDoc = documents.EnumerateArray().First(d => d.GetProperty("isActive").GetBoolean()); - activeDoc.GetProperty("resource").GetString().Should().Be("src/main.py"); + activeDoc.GetProperty("resource").GetString().Should().Be("project:src/main.py"); activeDoc.GetProperty("sectionIndex").GetInt32().Should().Be(0); var inactiveDoc = documents.EnumerateArray().First(d => !d.GetProperty("isActive").GetBoolean()); - inactiveDoc.GetProperty("resource").GetString().Should().Be("tests/test_main.py"); + inactiveDoc.GetProperty("resource").GetString().Should().Be("project:tests/test_main.py"); inactiveDoc.GetProperty("sectionIndex").GetInt32().Should().Be(1); } diff --git a/Source/Tests/Tools/ExplorerToolTests.cs b/Source/Tests/Tools/ExplorerToolTests.cs index 70af3fdb7..8a6234f64 100644 --- a/Source/Tests/Tools/ExplorerToolTests.cs +++ b/Source/Tests/Tools/ExplorerToolTests.cs @@ -45,12 +45,15 @@ public void GetState_ReturnsSelectionAndExpandedFolders() var tools = new ExplorerTools(_services); var root = ParseResult(tools.GetState()); - root.GetProperty("selectedResource").GetString().Should().Be("src/main.py"); + root.GetProperty("selectedResource").GetString().Should().Be("project:src/main.py"); var selectedResources = root.GetProperty("selectedResources"); selectedResources.GetArrayLength().Should().Be(1); - selectedResources[0].GetString().Should().Be("src/main.py"); + selectedResources[0].GetString().Should().Be("project:src/main.py"); + // The folder state service is mocked with bare strings so the response + // pass-through is bare. In production the persisted list also stores + // the canonical prefixed form (see FolderStateService). var expandedFolders = root.GetProperty("expandedFolders"); expandedFolders.GetArrayLength().Should().Be(2); expandedFolders[0].GetString().Should().Be("src"); @@ -96,7 +99,7 @@ public void GetState_MultiSelect() var tools = new ExplorerTools(_services); var root = ParseResult(tools.GetState()); - root.GetProperty("selectedResource").GetString().Should().Be("src/a.py"); + root.GetProperty("selectedResource").GetString().Should().Be("project:src/a.py"); root.GetProperty("selectedResources").GetArrayLength().Should().Be(2); } diff --git a/Source/Tests/Tools/FileToolTests.cs b/Source/Tests/Tools/FileToolTests.cs index b274c5a82..6bfaed8be 100644 --- a/Source/Tests/Tools/FileToolTests.cs +++ b/Source/Tests/Tools/FileToolTests.cs @@ -616,10 +616,11 @@ public async Task Read_MissingFileUnderNonProjectRoot_EmitsCanonicalRootPath() } [Test] - public async Task Read_MissingFileUnderProjectRoot_EmitsBarePath() + public async Task Read_MissingFileUnderProjectRoot_EmitsCanonicalRootPath() { - // Counterpart to the temp: test: project-root keys must be reported as bare paths, - // never with the explicit "project:" prefix. + // Counterpart to the temp: test: project-root keys are reported in their canonical + // "project:" form to match the cascade scanner's tracked-reference literal and to + // stay symmetric with non-default roots. var resourceKey = ResourceKey.Create("Scripts/missing.py"); var resourcePath = Path.Combine(_tempFolder, "Scripts", "missing.py"); _resourceRegistry.ResolveResourcePath(resourceKey).Returns(Result.Ok(resourcePath)); @@ -629,8 +630,7 @@ public async Task Read_MissingFileUnderProjectRoot_EmitsBarePath() result.IsError.Should().BeTrue(); var text = result.Content.OfType().Single().Text; - text.Should().Contain("Scripts/missing.py"); - text.Should().NotContain("project:Scripts/missing.py"); + text.Should().Contain("project:Scripts/missing.py"); } private static JsonElement ParseResult(CallToolResult result) diff --git a/Source/Tests/Tools/FileToolsReadImageTests.cs b/Source/Tests/Tools/FileToolsReadImageTests.cs index 1d6066abe..0e1c8301e 100644 --- a/Source/Tests/Tools/FileToolsReadImageTests.cs +++ b/Source/Tests/Tools/FileToolsReadImageTests.cs @@ -136,7 +136,7 @@ public async Task ReadImage_HappyPath_ReturnsImageAndMetadata() var metadataJson = result.Content.OfType().Single().Text; var metadata = JsonDocument.Parse(metadataJson).RootElement; - metadata.GetProperty("resource").GetString().Should().Be("captures/sample.jpg"); + metadata.GetProperty("resource").GetString().Should().Be("project:captures/sample.jpg"); metadata.GetProperty("mimeType").GetString().Should().Be("image/jpeg"); metadata.GetProperty("sizeBytes").GetInt32().Should().Be(MinimalJpegBytes.Length); } diff --git a/Source/Tests/Tools/WebViewScreenshotResolverTests.cs b/Source/Tests/Tools/WebViewScreenshotResolverTests.cs index 41f871416..ada8f18be 100644 --- a/Source/Tests/Tools/WebViewScreenshotResolverTests.cs +++ b/Source/Tests/Tools/WebViewScreenshotResolverTests.cs @@ -29,11 +29,13 @@ public void Resolve_EmptySaveTo_UsesDefaultFolderWithCleanName() var result = WebViewScreenshotResolver.Resolve(saveTo: "", format: "jpeg", _projectFolder); result.IsSuccess.Should().BeTrue(); - var key = result.Value.ToString(); - key.Should().StartWith("screenshots/screenshot-"); - key.Should().EndWith(".jpg"); + // Assert against the bare path portion (without the project: prefix) + // because these checks are about the path shape the resolver picked. + var path = result.Value.Path; + path.Should().StartWith("screenshots/screenshot-"); + path.Should().EndWith(".jpg"); // No collision in a fresh folder, so the unsuffixed form should be used. - key.Should().NotContain(".jpg-").And.MatchRegex(@"screenshots/screenshot-\d{8}-\d{6}\.jpg$"); + path.Should().NotContain(".jpg-").And.MatchRegex(@"screenshots/screenshot-\d{8}-\d{6}\.jpg$"); } [Test] @@ -42,7 +44,7 @@ public void Resolve_EmptySaveToWithPng_UsesPngExtension() var result = WebViewScreenshotResolver.Resolve(saveTo: "", format: "png", _projectFolder); result.IsSuccess.Should().BeTrue(); - result.Value.ToString().Should().EndWith(".png"); + result.Value.Path.Should().EndWith(".png"); } [Test] @@ -51,7 +53,7 @@ public void Resolve_ExactResourceKeyWithMatchingExtension_PreservesKey() var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.png", format: "png", _projectFolder); result.IsSuccess.Should().BeTrue(); - result.Value.ToString().Should().Be("docs/output.png"); + result.Value.ToString().Should().Be("project:docs/output.png"); } [Test] @@ -60,7 +62,7 @@ public void Resolve_JpgExtensionMatchesJpegFormat() var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.jpg", format: "jpeg", _projectFolder); result.IsSuccess.Should().BeTrue(); - result.Value.ToString().Should().Be("docs/output.jpg"); + result.Value.ToString().Should().Be("project:docs/output.jpg"); } [Test] @@ -96,9 +98,9 @@ public void Resolve_TrailingSlashSaveTo_GeneratesAutoNameInThatFolder() var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/", format: "jpeg", _projectFolder); result.IsSuccess.Should().BeTrue(); - var key = result.Value.ToString(); - key.Should().StartWith("docs/screenshot-"); - key.Should().EndWith(".jpg"); + var path = result.Value.Path; + path.Should().StartWith("docs/screenshot-"); + path.Should().EndWith(".jpg"); } [Test] @@ -109,9 +111,9 @@ public void Resolve_NoExtensionSaveTo_TreatedAsFolder() var result = WebViewScreenshotResolver.Resolve(saveTo: "captures", format: "png", _projectFolder); result.IsSuccess.Should().BeTrue(); - var key = result.Value.ToString(); - key.Should().StartWith("captures/screenshot-"); - key.Should().EndWith(".png"); + var path = result.Value.Path; + path.Should().StartWith("captures/screenshot-"); + path.Should().EndWith(".png"); } [Test] @@ -124,9 +126,10 @@ public void Resolve_CollisionWithExistingFile_AddsSequenceSuffix() var first = WebViewScreenshotResolver.Resolve(saveTo: "screenshots/", format: "jpeg", _projectFolder); first.IsSuccess.Should().BeTrue(); - // Materialise the first name so the next probe collides. - var firstResourceKey = first.Value.ToString(); - var firstAbsolute = Path.Combine(_projectFolder, firstResourceKey.Replace('/', Path.DirectorySeparatorChar)); + // Materialise the first name so the next probe collides. Use the bare + // path (no root prefix) because we're constructing a filesystem path. + var firstPath = first.Value.Path; + var firstAbsolute = Path.Combine(_projectFolder, firstPath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(firstAbsolute)!); File.WriteAllBytes(firstAbsolute, new byte[] { 0 }); @@ -137,9 +140,9 @@ public void Resolve_CollisionWithExistingFile_AddsSequenceSuffix() // should carry a -1 suffix. If they straddled a second boundary, the // names will differ in the timestamp and neither carries a suffix — // both outcomes are correct, so the assertion accepts either form. - var secondKey = second.Value.ToString(); - secondKey.Should().NotBe(firstResourceKey); - secondKey.Should().MatchRegex(@"screenshots/screenshot-\d{8}-\d{6}(-\d+)?\.jpg$"); + var secondPath = second.Value.Path; + secondPath.Should().NotBe(firstPath); + secondPath.Should().MatchRegex(@"screenshots/screenshot-\d{8}-\d{6}(-\d+)?\.jpg$"); } [Test] diff --git a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs index ff3212712..9c11945c0 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs @@ -229,10 +229,18 @@ public static string Compose( } var tomlText = Toml.FromModel(tomlTable); + // Tomlyn emits Environment.NewLine internally (CRLF on Windows). + // The rest of Compose uses LF literals (fence lines, block-content + // terminators), so normalise to LF here for a single-line-ending + // file on every platform. Without this normalisation a sidecar + // with TOML frontmatter plus blocks ends up CRLF in the frontmatter + // section and LF in the block section, which surprises tools that + // round-trip the bytes. + tomlText = tomlText.Replace("\r\n", "\n"); // Toml.FromModel emits a trailing newline. Trim it so the join with // the first fence (if any) is predictable; we add an explicit // separator below. - tomlText = tomlText.TrimEnd('\r', '\n'); + tomlText = tomlText.TrimEnd('\n'); builder.Append(tomlText); } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 73e118f5c..4de28bafe 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -834,7 +834,11 @@ private SidecarOutcome TryCascadeSidecarDelete(ResourceKey source) { var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; var result = sidecarService.GetSidecarKey(key); - return result.IsSuccess ? result.Value : null; + if (result.IsSuccess) + { + return result.Value; + } + return null; } // Clears the read-only attribute from a file before the FS layer performs From fa75841b4a58df7e1afc71c6c6c12baa72300274 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 22 May 2026 21:01:26 +0100 Subject: [PATCH 17/48] Prefer resource Path for UI and file paths Use the resource key's Path (bare path) where callers expect or construct filesystem paths, and keep the canonical ToString() only when the root should be visible. - ResourcePickerItem: DisplayText now uses resourceKey.Path for default project-rooted resources and falls back to resourceKey.ToString() for non-default roots so the root remains visible when needed. - ContributionDialogHandler: Pass result.Value.Path to GetRelativePathFromResourceKey (that method expects the bare path) instead of ToString(), avoiding the canonical "project:" prefix. - CopyPathMenuOption: Use resourceKey.Path when combining with ProjectFolderPath to form a filesystem path (ToString() emits a root prefix that doesn't belong in a filesystem path). This clarifies UI display and prevents accidental inclusion of the root prefix in paths used for file operations or relative-path computation. --- .../ViewModels/Dialogs/ResourcePickerItem.cs | 7 ++++++- .../Views/ContributionDialogHandler.cs | 11 +++++++---- .../Menu/Options/CopyPathMenuOption.cs | 5 ++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerItem.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerItem.cs index 0127271e0..82d945d61 100644 --- a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerItem.cs +++ b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerItem.cs @@ -24,7 +24,12 @@ public ResourcePickerItem(IResource resource, ResourceKey resourceKey, FileIconD Resource = resource; ResourceKey = resourceKey; IconDefinition = iconDefinition; - DisplayText = resourceKey.ToString(); + // Display text uses the bare path for project-rooted resources (cleaner + // for the picker UI) and falls back to the full "root:path" form for + // non-default roots so the root is visible when it matters. + DisplayText = resourceKey.Root == ResourceKey.DefaultRoot + ? resourceKey.Path + : resourceKey.ToString(); DisplayTextLower = DisplayText.ToLowerInvariant(); } } diff --git a/Source/Workspace/Celbridge.Documents/Views/ContributionDialogHandler.cs b/Source/Workspace/Celbridge.Documents/Views/ContributionDialogHandler.cs index 4279ea1c3..7d18695b0 100644 --- a/Source/Workspace/Celbridge.Documents/Views/ContributionDialogHandler.cs +++ b/Source/Workspace/Celbridge.Documents/Views/ContributionDialogHandler.cs @@ -47,8 +47,11 @@ public async Task PickImageAsync(IReadOnlyList? extensi if (result.IsSuccess) { - var resourceKey = result.Value.ToString(); - var relativePath = _viewModel.GetRelativePathFromResourceKey(resourceKey); + // GetRelativePathFromResourceKey treats its input as the bare path + // portion of a project-rooted resource key, so we pass .Path to skip + // the canonical "project:" prefix that ToString() now emits. + var resourcePath = result.Value.Path; + var relativePath = _viewModel.GetRelativePathFromResourceKey(resourcePath); return new PickImageResult(relativePath); } @@ -63,8 +66,8 @@ public async Task PickFileAsync(IReadOnlyList? extension if (result.IsSuccess) { - var resourceKey = result.Value.ToString(); - var relativePath = _viewModel.GetRelativePathFromResourceKey(resourceKey); + var resourcePath = result.Value.Path; + var relativePath = _viewModel.GetRelativePathFromResourceKey(resourcePath); return new PickFileResult(relativePath); } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs index 9af6cdeb4..e6c3c8d92 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs @@ -46,7 +46,10 @@ public void Execute(ExplorerMenuContext context) var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var resourceKey = resourceRegistry.GetResourceKey(target); - var filePath = Path.Combine(resourceRegistry.ProjectFolderPath, resourceKey.ToString()); + // Use .Path (the path portion only) for filesystem-path construction; + // ToString() now emits the canonical "project:" prefix that does not + // belong in a filesystem path. + var filePath = Path.Combine(resourceRegistry.ProjectFolderPath, resourceKey.Path); _commandService.Execute(command => { From 14a7d0ed1cb6d15b1fb9dfd474946894faac49d2 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 22 May 2026 21:57:35 +0100 Subject: [PATCH 18/48] Exclude .md from resource scanning Introduce a central ResourceScanRules with an ExcludedExtensions set (currently ".md") and IsExcludedExtension helper. ResourceScanner now skips files with excluded extensions, meaning quoted "project:" literals inside .md files are treated as documentation (not machine-tracked) so cascade rewrites and broken-reference checks ignore them. Update docs (resource_keys.md) to explain the exclusion and consequences. Update tests to use .txt fixtures where scanning is expected, and add tests that verify Markdown references are excluded and that sidecar files (e.g. .md.cel) are still scanned because the rule matches the final extension. --- .../Guides/Concepts/resource_keys.md | 22 +++++- .../Tests/Resources/DataCheckProjectTests.cs | 77 +++++++++++++++---- .../Services/ResourceScanRules.cs | 62 +++++++++++++++ .../Services/ResourceScanner.cs | 5 ++ 4 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index 00b14d486..d4d9122ff 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -141,14 +141,28 @@ Strict matching means surrounding whitespace inside the wrapping quotes is part - **Unicode "smart quotes" (curly forms of `"` and `'`) are not recognised** — only the ASCII forms (`"` U+0022 and `'` U+0027) count. Pasted content from Word, chat apps, or auto-formatting editors may carry visually-identical curly quotes that the scanner ignores; check the raw bytes if a reference silently fails to track. - **JSON `\/` escape**: a reference written as `"project:foo\/bar.md"` (representing `project:foo/bar.md`) is not tracked — the scanner sees the literal `\` and treats it as a key boundary. JSON serialisers almost never emit `\/`; write `/` directly. -- **Markdown link URLs with whitespace** are not tracked through this scanner. Markdown links use their own paths-relative-to-the-document scheme and are the subject of a future format-aware scanner. Until then, write the reference outside the link target (in prose) or use a hyphen / underscore / camelCase name for the file. +- **References inside `.md` files**: not tracked at all (see the "Excluded extensions" section below). Markdown is documentation; references inside it are mentions for human readers, not active links. Rename them manually when a referenced resource moves. ## Where the scanner looks -The reference scanner reads the full text of every text file in the project (skipping binary files via extension and content sniffing). Quoted `project:` references are tracked wherever they appear: +The reference scanner reads the full text of every text file in the project (skipping binary files via extension and content sniffing) — *except* for the deliberately excluded extensions below. Quoted `project:` references are tracked wherever they appear in scanned files: -- **Plain text and source files** — markdown bodies, code, TOML/JSON/YAML configs, etc. +- **Plain text and source files** — code, TOML/JSON/YAML configs, plain `.txt` files, etc. - **Sidecar (`.cel`) frontmatter** — quoted `project:` references in the TOML frontmatter of a sidecar are tracked the same as anywhere else. - **Sidecar (`.cel`) body** — and so is the opaque body section. Either location works equally for editor data that needs to track resources. -What's not scanned: binary files (PNG, XLSX, PDF, etc.). A reference baked into a binary asset won't participate in the cascade — those workflows must use sidecar frontmatter or a paired text file instead. +### Excluded extensions + +**Markdown (`.md`) files are deliberately excluded from reference scanning.** A quoted `"project:..."` literal inside a `.md` file is treated as descriptive prose, not as an active reference. Documentation, READMEs, runbooks, and test prompts can mention resource keys in their canonical form for the reader's benefit without participating in cascades or `data_check_project`'s broken-reference detection. + +Consequences of the exclusion: + +- **Cascade does not rewrite references inside `.md` files on rename.** If you move `foo.md` and a doc file references it as `"project:foo.md"`, that mention stays as written. You (or an agent) need to update the doc manually — same as you would under any GUID-style addressing scheme where doc mentions are never machine-rewritten. +- **`data_check_project` does not report references inside `.md` files as broken.** A `"project:gone.md"` mention in a README won't surface as a finding even if `gone.md` is missing — the system can't reliably tell "agent meant a tracked reference but used the wrong extension" from "this paragraph describes what `gone.md` used to be." Doc accuracy is the author's responsibility. +- **Markdown files can still BE referenced.** Other (scanned) files referring to a `.md` via `"project:notes.md"` ARE tracked normally. The exclusion is about what happens to references *inside* `.md` content, not what can be referenced. + +Other file types not currently scanned: + +- **Binary files** (PNG, XLSX, PDF, etc.). A reference baked into a binary asset won't participate in the cascade — those workflows must use sidecar frontmatter or a paired text file instead. + +The exclusion list is intentionally narrow: only `.md` today. Other documentation formats (`.rst`, `.adoc`, `.org`) may be added if concrete need emerges. Plain `.txt` stays scannable — it's the natural extension for fixtures and config-like data files where embedded references should track. diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index 0cc96c65c..65b92932c 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -86,9 +86,12 @@ public void TearDown() [Test] public async Task CleanProject_AllReportListsAreEmpty() { - File.WriteAllText(Path.Combine(_projectFolderPath, "a.md"), "Body A."); - File.WriteAllText(Path.Combine(_projectFolderPath, "b.md"), - "Refers to \"project:a.md\"."); + // Fixture uses .txt because reference scanning excludes documentation + // file types (.md). See ResourceScanner.ExcludedExtensions for the + // rationale. + File.WriteAllText(Path.Combine(_projectFolderPath, "a.txt"), "Body A."); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.txt"), + "Refers to \"project:a.txt\"."); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -102,8 +105,8 @@ public async Task CleanProject_AllReportListsAreEmpty() [Test] public async Task BrokenReference_IsReportedWithSourceAndTarget() { - File.WriteAllText(Path.Combine(_projectFolderPath, "source.md"), - "Refers to \"project:missing.md\" which is not present."); + File.WriteAllText(Path.Combine(_projectFolderPath, "source.txt"), + "Refers to \"project:missing.txt\" which is not present."); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -111,8 +114,48 @@ public async Task BrokenReference_IsReportedWithSourceAndTarget() _command.ResultValue.BrokenReferences.Should().HaveCount(1); var entry = _command.ResultValue.BrokenReferences[0]; - entry.Source.Should().Be(new ResourceKey("source.md")); - entry.MissingTarget.Should().Be(new ResourceKey("missing.md")); + entry.Source.Should().Be(new ResourceKey("source.txt")); + entry.MissingTarget.Should().Be(new ResourceKey("missing.txt")); + } + + [Test] + public async Task MarkdownReferences_AreExcludedFromScan() + { + // Markdown is documentation, not data. A "project:..." literal inside + // a .md file is a descriptive mention, not an active reference, so the + // scanner deliberately skips .md files for both cascade rewrites and + // broken-reference detection. This test guards that exclusion. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), + "This documentation mentions \"project:missing.md\" but it should NOT be tracked."); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenReferences.Should().BeEmpty(); + } + + [Test] + public async Task SidecarOfExcludedParent_IsStillScanned() + { + // A .cel sidecar attached to an excluded parent (e.g. notes.md.cel + // next to notes.md) carries the .cel extension under + // Path.GetExtension, NOT the parent's .md extension. The exclusion + // policy excludes by file extension, not by parent — sidecars are + // data regardless of what they're paired with, so they continue to + // participate in reference scanning even when their parent file is + // a documentation type. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), + "Body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md.cel"), + "editor = \"celbridge.notes\"\nlink = \"project:missing.txt\"\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenReferences.Should().ContainSingle() + .Which.Source.Should().Be(new ResourceKey("notes.md.cel")); } [Test] @@ -163,16 +206,16 @@ public async Task InvalidSidecarSuffix_AppearsInBrokenList() [Test] public async Task MultipleBrokenReferences_OrderedDeterministically() { - File.WriteAllText(Path.Combine(_projectFolderPath, "a.md"), - "Refers \"project:zzz.md\" and \"project:aaa.md\"."); - File.WriteAllText(Path.Combine(_projectFolderPath, "b.md"), - "Also refers \"project:zzz.md\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "a.txt"), + "Refers \"project:zzz.txt\" and \"project:aaa.txt\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.txt"), + "Also refers \"project:zzz.txt\"."); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); - // Three entries: aaa.md from a.md; zzz.md from a.md and b.md. + // Three entries: aaa.txt from a.txt; zzz.txt from a.txt and b.txt. // The ordering is by missingTarget then by source. _command.ResultValue.BrokenReferences.Should().HaveCount(3); @@ -180,10 +223,10 @@ public async Task MultipleBrokenReferences_OrderedDeterministically() .Select(r => (r.MissingTarget.ToString(), r.Source.ToString())) .ToList(); - keys[0].Item1.Should().Be("project:aaa.md"); - keys[1].Item1.Should().Be("project:zzz.md"); - keys[2].Item1.Should().Be("project:zzz.md"); - keys[1].Item2.Should().Be("project:a.md"); - keys[2].Item2.Should().Be("project:b.md"); + keys[0].Item1.Should().Be("project:aaa.txt"); + keys[1].Item1.Should().Be("project:zzz.txt"); + keys[2].Item1.Should().Be("project:zzz.txt"); + keys[1].Item2.Should().Be("project:a.txt"); + keys[2].Item2.Should().Be("project:b.txt"); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs new file mode 100644 index 000000000..4bbe0d91f --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs @@ -0,0 +1,62 @@ +namespace Celbridge.Resources.Services; + +/// +/// Centralised policy for which files participate in the on-demand resource +/// scanner. Both the cascade rewrite path in +/// and the broken-reference detection in consume +/// this module so they cannot drift on what counts as a scannable file. +/// +/// The exclusion list captured here is system-baseline, not user-configurable: +/// these extensions are deliberately invisible to the cascade because their +/// content is documentation rather than data. A future per-project configurable +/// include/exclude filter (see follow_up.md §10) layers on top — projects that +/// want to exclude additional extensions can do so; projects cannot opt back +/// in to scanning the baseline-excluded extensions. +/// +public static class ResourceScanRules +{ + /// + /// File extensions excluded from reference scanning. Match is case-insensitive + /// against the result of , + /// which returns only the FINAL extension. A sidecar file paired to an + /// excluded parent (e.g. notes.md.cel) carries the .cel + /// extension under that rule, so the sidecar continues to be scanned even + /// when its parent file would not be. + /// + /// Documentation file types only. Plain .txt stays scannable because + /// it is the natural extension for fixtures and config-like data files + /// whose embedded references should track. + /// + /// Adding a new exclusion: append the extension (with the leading dot, + /// e.g. ".rst") to this set. The change reaches both the rename + /// cascade and data_check_project automatically, and the + /// resource_keys guide's "Excluded extensions" section should be + /// updated to match. + /// + public static readonly IReadOnlySet ExcludedExtensions + = new HashSet(StringComparer.OrdinalIgnoreCase) + { + // Markdown is documentation. A quoted "project:..." literal in a + // .md file is a descriptive mention, not an active link the + // cascade should rewrite. Cascade tests, runbooks, READMEs, and + // tool-feedback notes can quote tracked-reference forms freely. + ".md", + }; + + /// + /// True when files with the given extension are excluded from reference + /// scanning. The extension argument must include the leading dot + /// (e.g. ".md"); pass the result of + /// directly. An empty + /// or null extension returns false (extensionless files are sniffed by + /// the scanner via the text/binary heuristic). + /// + public static bool IsExcludedExtension(string? extension) + { + if (string.IsNullOrEmpty(extension)) + { + return false; + } + return ExcludedExtensions.Contains(extension); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs index 64a98fe30..26f1612f8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -270,6 +270,11 @@ private bool IsScannableTextFile( ConcurrentDictionary textSniffCache) { var extension = Path.GetExtension(absolutePath); + if (ResourceScanRules.IsExcludedExtension(extension)) + { + return false; + } + if (!string.IsNullOrEmpty(extension) && _textBinarySniffer.IsBinaryExtension(extension)) { From 41014ac684f497b6640bcd1cb6b18d891639c7c9 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Sat, 23 May 2026 07:33:19 +0100 Subject: [PATCH 19/48] Allowlist-based scanner; resolve paths via registry Replace the previous exclusion-based scan rules with an explicit allowlist of scannable extensions embedded in ResourceScanner, remove the standalone ResourceScanRules, and drop the text/binary sniffer dependency. Update docs to describe the allowlist approach and clarify scanning semantics (sidecars, what is/aren't scanned). Adjust tests to use allowlisted data files (e.g. .json) and update expectations accordingly. Also simplify CopyResourceCommand by removing IProjectService usage and changing CopySingleResourceAsync to resolve source/destination paths via IResourceRegistry.ResolveResourcePath (with failure handling), ensuring prefixes like project:/temp: are handled correctly instead of combining with a project folder path. --- .../Guides/Concepts/resource_keys.md | 35 +++++---- .../Tests/Resources/DataCheckProjectTests.cs | 74 +++++++++--------- .../Resources/ResourceFileSystemTests.cs | 18 ++--- .../Commands/CopyResourceCommand.cs | 45 ++++++----- .../Services/ResourceScanRules.cs | 62 --------------- .../Services/ResourceScanner.cs | 75 +++++++++---------- 6 files changed, 129 insertions(+), 180 deletions(-) delete mode 100644 Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index d4d9122ff..3997d5f36 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -140,29 +140,34 @@ Strict matching means surrounding whitespace inside the wrapping quotes is part ### Known limitations - **Unicode "smart quotes" (curly forms of `"` and `'`) are not recognised** — only the ASCII forms (`"` U+0022 and `'` U+0027) count. Pasted content from Word, chat apps, or auto-formatting editors may carry visually-identical curly quotes that the scanner ignores; check the raw bytes if a reference silently fails to track. -- **JSON `\/` escape**: a reference written as `"project:foo\/bar.md"` (representing `project:foo/bar.md`) is not tracked — the scanner sees the literal `\` and treats it as a key boundary. JSON serialisers almost never emit `\/`; write `/` directly. -- **References inside `.md` files**: not tracked at all (see the "Excluded extensions" section below). Markdown is documentation; references inside it are mentions for human readers, not active links. Rename them manually when a referenced resource moves. +- **JSON `\/` escape**: a reference written as `"project:foo\/bar.json"` (representing `project:foo/bar.json`) is not tracked — the scanner sees the literal `\` and treats it as a key boundary. JSON serialisers almost never emit `\/`; write `/` directly. +- **References inside non-allowlisted file types**: not tracked at all (see the "Where the scanner looks" section below). The scanner only walks a fixed set of data-bearing extensions — references inside other file types are mentions for human readers, not active links. Rename them manually when a referenced resource moves. ## Where the scanner looks -The reference scanner reads the full text of every text file in the project (skipping binary files via extension and content sniffing) — *except* for the deliberately excluded extensions below. Quoted `project:` references are tracked wherever they appear in scanned files: +The reference scanner walks an explicit **allowlist** of data-bearing file extensions. A file's extension determines whether it participates; nothing else (parent file, location, content sniffing) overrides that gate. Quoted `project:` references inside an allowlisted file are tracked; quoted `project:` references inside any other file type are ignored. -- **Plain text and source files** — code, TOML/JSON/YAML configs, plain `.txt` files, etc. -- **Sidecar (`.cel`) frontmatter** — quoted `project:` references in the TOML frontmatter of a sidecar are tracked the same as anywhere else. -- **Sidecar (`.cel`) body** — and so is the opaque body section. Either location works equally for editor data that needs to track resources. +The current allowlist: -### Excluded extensions +| Category | Extensions | +|---|---| +| Sidecars | `.cel` | +| Scripts | `.js`, `.py`, `.ipy`, `.ipynb` | +| Tabular data | `.csv`, `.tsv` | +| Structured data and configuration | `.json`, `.jsonl`, `.ndjson`, `.yaml`, `.yml`, `.toml`, `.xml` | -**Markdown (`.md`) files are deliberately excluded from reference scanning.** A quoted `"project:..."` literal inside a `.md` file is treated as descriptive prose, not as an active reference. Documentation, READMEs, runbooks, and test prompts can mention resource keys in their canonical form for the reader's benefit without participating in cascades or `data_check_project`'s broken-reference detection. +A `.cel` sidecar attached to a parent whose extension is NOT on the list (e.g. `notes.md.cel` next to `notes.md`) is still scanned — the sidecar carries the `.cel` extension under `Path.GetExtension`, not the parent's `.md`. Sidecars are data regardless of what they're paired with. -Consequences of the exclusion: +### Files that are NOT scanned -- **Cascade does not rewrite references inside `.md` files on rename.** If you move `foo.md` and a doc file references it as `"project:foo.md"`, that mention stays as written. You (or an agent) need to update the doc manually — same as you would under any GUID-style addressing scheme where doc mentions are never machine-rewritten. -- **`data_check_project` does not report references inside `.md` files as broken.** A `"project:gone.md"` mention in a README won't surface as a finding even if `gone.md` is missing — the system can't reliably tell "agent meant a tracked reference but used the wrong extension" from "this paragraph describes what `gone.md` used to be." Doc accuracy is the author's responsibility. -- **Markdown files can still BE referenced.** Other (scanned) files referring to a `.md` via `"project:notes.md"` ARE tracked normally. The exclusion is about what happens to references *inside* `.md` content, not what can be referenced. +Every extension not in the allowlist is skipped. The most common implications: -Other file types not currently scanned: +- **Markdown (`.md`)** — documentation, READMEs, runbooks, agent-prompt files. Quoted `"project:..."` literals inside `.md` are descriptive prose; they don't cascade and they don't show up as broken references. +- **Plain text (`.txt`)** — fixtures and notes. If you need cascade tracking for a fixture, use `.json` (or attach a `.cel` sidecar with the reference in frontmatter). +- **Source code outside the listed languages** — e.g. `.cs`, `.ts`, `.cpp`. Add the extension to the allowlist if you need cascade support there. +- **HTML and CSS** — HTML uses `href`-shaped references that don't follow the `"project:..."` form; CSS doesn't address resources by key at all. +- **Binary files** (PNG, XLSX, PDF, etc.) — never scanned. A reference baked into a binary asset won't participate in the cascade — those workflows must use sidecar frontmatter or a paired text file instead. -- **Binary files** (PNG, XLSX, PDF, etc.). A reference baked into a binary asset won't participate in the cascade — those workflows must use sidecar frontmatter or a paired text file instead. +Files that are not scanned can still BE referenced. The allowlist gates what gets *read for references*, not what can appear *as a target*. A `.json` referencer pointing at a `.md` document is fully tracked; renaming the `.md` cascades through the `.json`. -The exclusion list is intentionally narrow: only `.md` today. Other documentation formats (`.rst`, `.adoc`, `.org`) may be added if concrete need emerges. Plain `.txt` stays scannable — it's the natural extension for fixtures and config-like data files where embedded references should track. +If you find yourself reaching for a file type that isn't on the list, add the extension to `ScannableExtensions` (or open a follow-up if the use-case is shared across projects). diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index 65b92932c..272ed03e3 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -60,8 +60,7 @@ public void Setup() var scanner = new ResourceScanner( Substitute.For>(), - _workspaceWrapper, - new TextBinarySniffer()); + _workspaceWrapper); workspaceService.ResourceScanner.Returns(scanner); _command = new ProjectCheckCommand(_workspaceWrapper); @@ -86,12 +85,11 @@ public void TearDown() [Test] public async Task CleanProject_AllReportListsAreEmpty() { - // Fixture uses .txt because reference scanning excludes documentation - // file types (.md). See ResourceScanner.ExcludedExtensions for the - // rationale. - File.WriteAllText(Path.Combine(_projectFolderPath, "a.txt"), "Body A."); - File.WriteAllText(Path.Combine(_projectFolderPath, "b.txt"), - "Refers to \"project:a.txt\"."); + // Fixture uses .json because the scanner only walks allowlisted + // data-bearing extensions. See ResourceScanRules.ScannableExtensions. + File.WriteAllText(Path.Combine(_projectFolderPath, "a.json"), "{}"); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.json"), + "{ \"target\": \"project:a.json\" }"); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -105,8 +103,8 @@ public async Task CleanProject_AllReportListsAreEmpty() [Test] public async Task BrokenReference_IsReportedWithSourceAndTarget() { - File.WriteAllText(Path.Combine(_projectFolderPath, "source.txt"), - "Refers to \"project:missing.txt\" which is not present."); + File.WriteAllText(Path.Combine(_projectFolderPath, "source.json"), + "{ \"target\": \"project:missing.json\" }"); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -114,19 +112,21 @@ public async Task BrokenReference_IsReportedWithSourceAndTarget() _command.ResultValue.BrokenReferences.Should().HaveCount(1); var entry = _command.ResultValue.BrokenReferences[0]; - entry.Source.Should().Be(new ResourceKey("source.txt")); - entry.MissingTarget.Should().Be(new ResourceKey("missing.txt")); + entry.Source.Should().Be(new ResourceKey("source.json")); + entry.MissingTarget.Should().Be(new ResourceKey("missing.json")); } [Test] - public async Task MarkdownReferences_AreExcludedFromScan() + public async Task NonAllowlistedExtensions_AreExcludedFromScan() { - // Markdown is documentation, not data. A "project:..." literal inside - // a .md file is a descriptive mention, not an active reference, so the - // scanner deliberately skips .md files for both cascade rewrites and - // broken-reference detection. This test guards that exclusion. + // .md is not on the allowlist (along with .txt, .rst, .yaml, and every + // other extension not enumerated in ResourceScanRules.ScannableExtensions). + // A "project:..." literal inside an off-allowlist file is treated as + // descriptive prose, not as an active reference — no cascade rewrite, + // no broken-reference detection. This test guards the allowlist gate + // using markdown as a representative example. File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), - "This documentation mentions \"project:missing.md\" but it should NOT be tracked."); + "This documentation mentions \"project:missing.json\" but it should NOT be tracked."); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -136,19 +136,19 @@ public async Task MarkdownReferences_AreExcludedFromScan() } [Test] - public async Task SidecarOfExcludedParent_IsStillScanned() + public async Task SidecarOfNonAllowlistedParent_IsStillScanned() { - // A .cel sidecar attached to an excluded parent (e.g. notes.md.cel - // next to notes.md) carries the .cel extension under - // Path.GetExtension, NOT the parent's .md extension. The exclusion - // policy excludes by file extension, not by parent — sidecars are - // data regardless of what they're paired with, so they continue to - // participate in reference scanning even when their parent file is - // a documentation type. + // A .cel sidecar attached to a parent whose extension is NOT on the + // allowlist (e.g. notes.md.cel next to notes.md) carries the .cel + // extension under Path.GetExtension, NOT the parent's .md extension. + // The allowlist is keyed on file extension, not on parent — sidecars + // are data regardless of what they're paired with, so they continue + // to participate in reference scanning even when their parent file + // would be skipped on its own. File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "Body."); File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md.cel"), - "editor = \"celbridge.notes\"\nlink = \"project:missing.txt\"\n"); + "editor = \"celbridge.notes\"\nlink = \"project:missing.json\"\n"); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -206,16 +206,16 @@ public async Task InvalidSidecarSuffix_AppearsInBrokenList() [Test] public async Task MultipleBrokenReferences_OrderedDeterministically() { - File.WriteAllText(Path.Combine(_projectFolderPath, "a.txt"), - "Refers \"project:zzz.txt\" and \"project:aaa.txt\"."); - File.WriteAllText(Path.Combine(_projectFolderPath, "b.txt"), - "Also refers \"project:zzz.txt\"."); + File.WriteAllText(Path.Combine(_projectFolderPath, "a.json"), + "{ \"a\": \"project:zzz.json\", \"b\": \"project:aaa.json\" }"); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.json"), + "{ \"target\": \"project:zzz.json\" }"); _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); - // Three entries: aaa.txt from a.txt; zzz.txt from a.txt and b.txt. + // Three entries: aaa.json from a.json; zzz.json from a.json and b.json. // The ordering is by missingTarget then by source. _command.ResultValue.BrokenReferences.Should().HaveCount(3); @@ -223,10 +223,10 @@ public async Task MultipleBrokenReferences_OrderedDeterministically() .Select(r => (r.MissingTarget.ToString(), r.Source.ToString())) .ToList(); - keys[0].Item1.Should().Be("project:aaa.txt"); - keys[1].Item1.Should().Be("project:zzz.txt"); - keys[2].Item1.Should().Be("project:zzz.txt"); - keys[1].Item2.Should().Be("project:a.txt"); - keys[2].Item2.Should().Be("project:b.txt"); + keys[0].Item1.Should().Be("project:aaa.json"); + keys[1].Item1.Should().Be("project:zzz.json"); + keys[2].Item1.Should().Be("project:zzz.json"); + keys[1].Item2.Should().Be("project:a.json"); + keys[2].Item2.Should().Be("project:b.json"); } } diff --git a/Source/Tests/Resources/ResourceFileSystemTests.cs b/Source/Tests/Resources/ResourceFileSystemTests.cs index cc7e504b5..eefa57ea5 100644 --- a/Source/Tests/Resources/ResourceFileSystemTests.cs +++ b/Source/Tests/Resources/ResourceFileSystemTests.cs @@ -388,11 +388,11 @@ public async Task MoveAsync_RewritesReferencers() { var sourceKey = new ResourceKey("source.txt"); var destKey = new ResourceKey("dest.txt"); - var referencerKey = new ResourceKey("doc.md"); + var referencerKey = new ResourceKey("doc.json"); var sourcePath = Path.Combine(_tempFolder, "source.txt"); var destPath = Path.Combine(_tempFolder, "dest.txt"); - var referencerPath = Path.Combine(_tempFolder, "doc.md"); + var referencerPath = Path.Combine(_tempFolder, "doc.json"); await File.WriteAllTextAsync(sourcePath, "data"); await File.WriteAllTextAsync(referencerPath, "See \"project:source.txt\" for details."); @@ -422,11 +422,11 @@ public async Task MoveAsync_DoesNotRewriteUnquotedOccurrencesAtFileBoundaries() // rewrote unquoted byte sequences. var sourceKey = new ResourceKey("source.txt"); var destKey = new ResourceKey("dest.txt"); - var referencerKey = new ResourceKey("doc.md"); + var referencerKey = new ResourceKey("doc.json"); var sourcePath = Path.Combine(_tempFolder, "source.txt"); var destPath = Path.Combine(_tempFolder, "dest.txt"); - var referencerPath = Path.Combine(_tempFolder, "doc.md"); + var referencerPath = Path.Combine(_tempFolder, "doc.json"); await File.WriteAllTextAsync(sourcePath, "data"); // The file contains the literal "project:source.txt" at three positions: @@ -464,11 +464,11 @@ public async Task MoveAsync_RewritesQuotedReferencerWithSpaceInKey() // detection. var sourceKey = new ResourceKey("My Document.md"); var destKey = new ResourceKey("My Renamed Document.md"); - var referencerKey = new ResourceKey("doc.md"); + var referencerKey = new ResourceKey("doc.json"); var sourcePath = Path.Combine(_tempFolder, "My Document.md"); var destPath = Path.Combine(_tempFolder, "My Renamed Document.md"); - var referencerPath = Path.Combine(_tempFolder, "doc.md"); + var referencerPath = Path.Combine(_tempFolder, "doc.json"); await File.WriteAllTextAsync(sourcePath, "data"); await File.WriteAllTextAsync(referencerPath, "See \"project:My Document.md\" and also 'project:My Document.md' as well."); @@ -653,11 +653,11 @@ public async Task MoveAsync_SkipsReadOnlyReferencer_AndReportsItInResult() { var sourceKey = new ResourceKey("target.txt"); var destKey = new ResourceKey("target2.txt"); - var referencerKey = new ResourceKey("doc.md"); + var referencerKey = new ResourceKey("doc.json"); var sourcePath = Path.Combine(_tempFolder, "target.txt"); var destPath = Path.Combine(_tempFolder, "target2.txt"); - var referencerPath = Path.Combine(_tempFolder, "doc.md"); + var referencerPath = Path.Combine(_tempFolder, "doc.json"); await File.WriteAllTextAsync(sourcePath, "data"); await File.WriteAllTextAsync(referencerPath, "See \"project:target.txt\" for details."); new FileInfo(referencerPath).IsReadOnly = true; @@ -669,7 +669,7 @@ public async Task MoveAsync_SkipsReadOnlyReferencer_AndReportsItInResult() _resourceRegistry.ResolveResourcePath(referencerKey).Returns(Result.Ok(referencerPath)); _resourceRegistry.ResolveResourcePath(new ResourceKey("target.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); _resourceRegistry.ResolveResourcePath(new ResourceKey("target2.txt.cel")).Returns(Result.Ok(destPath + ".cel")); - _resourceRegistry.ResolveResourcePath(new ResourceKey("doc.md")).Returns(Result.Ok(referencerPath)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("doc.json")).Returns(Result.Ok(referencerPath)); _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 969198463..81806e7db 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -2,7 +2,6 @@ using Celbridge.DataTransfer; using Celbridge.Explorer; using Celbridge.Logging; -using Celbridge.Projects; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -34,20 +33,17 @@ public class CopyResourceCommand : CommandBase, ICopyResourceCommand private readonly ILogger _logger; private readonly IMessengerService _messengerService; - private readonly IProjectService _projectService; private readonly IWorkspaceWrapper _workspaceWrapper; private readonly ICommandService _commandService; public CopyResourceCommand( ILogger logger, IMessengerService messengerService, - IProjectService projectService, IWorkspaceWrapper workspaceWrapper, ICommandService commandService) { _logger = logger; _messengerService = messengerService; - _projectService = projectService; _workspaceWrapper = workspaceWrapper; _commandService = commandService; } @@ -64,15 +60,6 @@ public override async Task ExecuteAsync() return Result.Ok(); } - var project = _projectService.CurrentProject; - Guard.IsNotNull(project); - - var projectFolderPath = project.ProjectFolderPath; - if (string.IsNullOrEmpty(projectFolderPath)) - { - return Result.Fail("Project folder path is empty."); - } - // Hoist the workspace-scoped service lookups out of the per-resource // loop. Acquiring them inside ExecuteAsync (rather than via constructor // injection) honours the workspace-scoped DI rule — the workspace can @@ -100,7 +87,7 @@ public override async Task ExecuteAsync() { foreach (var sourceResource in filteredResources) { - var outcome = await CopySingleResourceAsync(sourceResource, projectFolderPath, resourceRegistry, resourceOpService); + var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, resourceOpService); if (outcome.Result.IsFailure) { @@ -195,16 +182,38 @@ public override async Task ExecuteAsync() private async Task CopySingleResourceAsync( ResourceKey sourceResource, - string projectFolderPath, IResourceRegistry resourceRegistry, IResourceOperationService resourceOpService) { // Resolve destination to handle folder drops var resolvedDestResource = resourceRegistry.ResolveDestinationResource(sourceResource, DestResource); - // Convert resource keys to paths - var sourcePath = Path.GetFullPath(Path.Combine(projectFolderPath, sourceResource)); - var destPath = Path.GetFullPath(Path.Combine(projectFolderPath, resolvedDestResource)); + // Convert resource keys to absolute paths via the registry so root prefixes + // (project:, temp:, logs:) are stripped correctly. Path.Combine with the bare + // ResourceKey would incorporate the prefix as a literal directory component. + var resolveSourceResult = resourceRegistry.ResolveResourcePath(sourceResource); + if (resolveSourceResult.IsFailure) + { + return new CopyResourceOutcome( + Result.Fail($"Failed to resolve path for source resource: '{sourceResource}'") + .WithErrors(resolveSourceResult), + ParentFolder: null, + CopiedFolder: null, + MoveDetail: null); + } + var sourcePath = resolveSourceResult.Value; + + var resolveDestResult = resourceRegistry.ResolveResourcePath(resolvedDestResource); + if (resolveDestResult.IsFailure) + { + return new CopyResourceOutcome( + Result.Fail($"Failed to resolve path for destination resource: '{resolvedDestResource}'") + .WithErrors(resolveDestResult), + ParentFolder: null, + CopiedFolder: null, + MoveDetail: null); + } + var destPath = resolveDestResult.Value; // Determine resource type bool isFile = File.Exists(sourcePath); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs deleted file mode 100644 index 4bbe0d91f..000000000 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanRules.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace Celbridge.Resources.Services; - -/// -/// Centralised policy for which files participate in the on-demand resource -/// scanner. Both the cascade rewrite path in -/// and the broken-reference detection in consume -/// this module so they cannot drift on what counts as a scannable file. -/// -/// The exclusion list captured here is system-baseline, not user-configurable: -/// these extensions are deliberately invisible to the cascade because their -/// content is documentation rather than data. A future per-project configurable -/// include/exclude filter (see follow_up.md §10) layers on top — projects that -/// want to exclude additional extensions can do so; projects cannot opt back -/// in to scanning the baseline-excluded extensions. -/// -public static class ResourceScanRules -{ - /// - /// File extensions excluded from reference scanning. Match is case-insensitive - /// against the result of , - /// which returns only the FINAL extension. A sidecar file paired to an - /// excluded parent (e.g. notes.md.cel) carries the .cel - /// extension under that rule, so the sidecar continues to be scanned even - /// when its parent file would not be. - /// - /// Documentation file types only. Plain .txt stays scannable because - /// it is the natural extension for fixtures and config-like data files - /// whose embedded references should track. - /// - /// Adding a new exclusion: append the extension (with the leading dot, - /// e.g. ".rst") to this set. The change reaches both the rename - /// cascade and data_check_project automatically, and the - /// resource_keys guide's "Excluded extensions" section should be - /// updated to match. - /// - public static readonly IReadOnlySet ExcludedExtensions - = new HashSet(StringComparer.OrdinalIgnoreCase) - { - // Markdown is documentation. A quoted "project:..." literal in a - // .md file is a descriptive mention, not an active link the - // cascade should rewrite. Cascade tests, runbooks, READMEs, and - // tool-feedback notes can quote tracked-reference forms freely. - ".md", - }; - - /// - /// True when files with the given extension are excluded from reference - /// scanning. The extension argument must include the leading dot - /// (e.g. ".md"); pass the result of - /// directly. An empty - /// or null extension returns false (extensionless files are sniffed by - /// the scanner via the text/binary heuristic). - /// - public static bool IsExcludedExtension(string? extension) - { - if (string.IsNullOrEmpty(extension)) - { - return false; - } - return ExcludedExtensions.Contains(extension); - } -} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs index 26f1612f8..71292bc9c 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using Celbridge.Logging; using Celbridge.Resources.Helpers; -using Celbridge.Utilities; using Celbridge.Workspace; using Tomlyn.Model; @@ -18,18 +17,44 @@ public sealed class ResourceScanner : IResourceScanner { private const string TagsField = "tags"; + // File extensions that participate in reference scanning. Add an entry here + // when a workflow needs cascade support for a new file type, and update the + // resource_keys guide's "Where the scanner looks" section to match. + private static readonly HashSet ScannableExtensions + = new(StringComparer.OrdinalIgnoreCase) + { + // Sidecars. + ".cel", + + // Scripts. + ".js", + ".py", + ".ipy", + ".ipynb", + + // Tabular. + ".csv", + ".tsv", + + // Structured data and configuration. + ".json", + ".jsonl", + ".ndjson", + ".yaml", + ".yml", + ".toml", + ".xml", + }; + private readonly ILogger _logger; private readonly IWorkspaceWrapper _workspaceWrapper; - private readonly ITextBinarySniffer _textBinarySniffer; public ResourceScanner( ILogger logger, - IWorkspaceWrapper workspaceWrapper, - ITextBinarySniffer textBinarySniffer) + IWorkspaceWrapper workspaceWrapper) { _logger = logger; _workspaceWrapper = workspaceWrapper; - _textBinarySniffer = textBinarySniffer; } public async Task> FindReferencersAsync(ResourceKey target) @@ -207,20 +232,18 @@ private static HashSet ScanReferences(string text) // Walks all project: text files in parallel, invoking the visitor for // each. Reads the registry's snapshot directly; mutation commands carry // CommandFlags.UpdateResources so the snapshot reflects the latest disk - // state by the time a tool that consults the scanner runs. Binary files - // are skipped via the extension sniffer; unknown extensions trigger a - // one-time content sniff cached for the scan's duration. + // state by the time a tool that consults the scanner runs. Files are + // filtered through ResourceScanRules.ScannableExtensions — only the + // allowlisted data-bearing extensions participate. private async Task EnumerateProjectTextFilesAsync(Func visit) { var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); - var textSniffCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - await Parallel.ForEachAsync(files, async (file, _) => { var (resourceKey, absolutePath) = file; - if (!IsScannableTextFile(absolutePath, textSniffCache)) + if (!IsScannableFile(absolutePath)) { return; } @@ -265,35 +288,9 @@ await Parallel.ForEachAsync(files, async (file, _) => }); } - private bool IsScannableTextFile( - string absolutePath, - ConcurrentDictionary textSniffCache) + private static bool IsScannableFile(string absolutePath) { - var extension = Path.GetExtension(absolutePath); - if (ResourceScanRules.IsExcludedExtension(extension)) - { - return false; - } - - if (!string.IsNullOrEmpty(extension) - && _textBinarySniffer.IsBinaryExtension(extension)) - { - return false; - } - - // Unknown-extension files: content-sniff once per scan. The cache is - // local to this scan; we don't keep it across calls because the - // registry rebuilds frequently and the per-scan cost is small. - if (string.IsNullOrEmpty(extension)) - { - return textSniffCache.GetOrAdd(absolutePath, path => - { - var sniff = _textBinarySniffer.IsTextFile(path); - return sniff.IsSuccess && sniff.Value; - }); - } - - return true; + return ScannableExtensions.Contains(Path.GetExtension(absolutePath)); } private static bool IsSidecarPath(string absolutePath) From 69f6e03d93e3f0dbedeb1aa5797859e4d0ca6b42 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Sat, 23 May 2026 09:04:54 +0100 Subject: [PATCH 20/48] Enforce resource key case and improve error handling Enforce disk-canonical case for project resource keys and improve error propagation across tools. - ResourceRegistry: add strict case enforcement for the project root (reject wrong-case keys that resolve to existing disk paths), with a diagnostic message naming the canonical key; allow put/create flows when the resource doesn't exist. Add helper EnsureProjectKeyCaseMatchesDisk and related logic. - ResourceFileSystem: skip referencer rewrites when the referencer file is DOS read-only (log a warning and record a skipped referencer) and add IsReferencerReadOnly helper to check file attributes. - Tools: propagate ResolveResourcePath failure messages (resolveResult.FirstErrorMessage) rather than returning generic messages in multiple File/Package/Spreadsheet tools. - Tests: add unit tests for case-rejection and non-existent resource behavior; update integration tests (Python) to expect canonical "project:..." resource keys from tool responses and adapt test fixtures to use scanner-allowlisted extensions (.json) where needed. - Docs: update guides to state that resource keys are case-sensitive on all platforms and that malformed sidecar files block data_* mutations (with guidance to repair via file_write). These changes prevent inconsistent behavior on case-insensitive filesystems (Windows), make errors clearer to callers, and ensure referencer rewrites respect read-only files. --- .../Guides/Concepts/resource_keys.md | 2 +- .../Celbridge.Tools/Guides/Namespaces/data.md | 1 + .../Tools/File/FileTools.Read.cs | 2 +- .../Tools/File/FileTools.ReadBinary.cs | 2 +- .../Tools/File/FileTools.ReadImage.cs | 2 +- .../Tools/File/FileTools.ReadMany.cs | 2 +- .../Tools/Package/PackageTools.Publish.cs | 2 +- .../Spreadsheet/SpreadsheetTools.ExportCsv.cs | 2 +- .../Tools/Spreadsheet/SpreadsheetTools.cs | 2 +- .../Tests/Resources/ResourceRegistryTests.cs | 49 +++++++++++ Source/Tests/Tools/SpreadsheetToolTests.cs | 3 +- .../Python/celbridge-0.1.0-py3-none-any.whl | Bin 43216 -> 43797 bytes .../celbridge/integration_tests/helpers.py | 10 ++- .../celbridge/integration_tests/test_data.py | 30 ++++--- .../integration_tests/test_document.py | 9 +- .../integration_tests/test_explorer.py | 18 ++-- .../celbridge/integration_tests/test_file.py | 8 +- .../integration_tests/test_spreadsheet.py | 3 +- .../integration_tests/test_webview.py | 3 +- .../Services/ResourceFileSystem.cs | 36 ++++++++ .../Services/ResourceRegistry.cs | 77 +++++++++++++++++- 21 files changed, 222 insertions(+), 41 deletions(-) diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index 3997d5f36..9c1f20666 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -37,7 +37,7 @@ So `file_read` against a missing `temp:foo/bar` reports `temp:foo/bar` in the er - No absolute paths or drive letters. The key is always relative to its root's backing folder. - Root prefixes are lowercase and match `[a-z][a-z0-9_]+`. Single-character roots and uppercase roots are rejected. - An undeclared root (e.g. `unknown:foo`) is an error, not a missing-file failure. -- Case sensitivity follows the underlying filesystem; on Windows the system is case-preserving but case-insensitive. +- Resource keys are case-sensitive on every platform — including Windows, where the filesystem itself is case-insensitive. A key whose case doesn't match the on-disk canonical case is rejected at the resolve boundary, with the canonical form named in the error message. Take resource keys from tool responses (file listings, search results, tool outputs) rather than typing them by memory to keep the case correct. When in doubt about which keys exist, call `file_get_tree("")` to list the top level of the project tree, or pass a folder key to list its contents. diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md index b7268fb2a..db4b3f017 100644 --- a/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md +++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md @@ -9,6 +9,7 @@ The `data` namespace reads and writes per-resource data stored in `.cel` sidecar - **Field values are JSON-encoded.** `data_set_field` accepts the value as a JSON string so types pass through cleanly: `"high"`, `42`, `true`, `["a", "b"]`. Nested objects are rejected at write time. - **Tags are the only structured cross-resource query.** Use `data_add_tag` / `data_remove_tag` for atomic mutation and `data_find_tag` to enumerate resources carrying a tag. The `tag:value` convention (`priority:high`, `status:draft`) covers most "search by field" needs. - **Content blocks are opaque text.** Block IDs follow `[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*` (lowercase, dotted, hyphens). By convention each editor namespaces its blocks under its own ID (`celbridge.notes.note-document.content`). +- **A broken sidecar blocks all `data_*` mutations.** When the sidecar fails to parse (invalid TOML, unterminated string, garbled fence line), `data_set_field`, `data_add_tag`, `data_write_block`, and their siblings refuse with a `Cannot mutate sidecar '...': TOML parse error(s): ...` message rather than silently overwriting the bad content. Repair by hand with `file_write` against one of the three on-disk shapes below, then retry the mutation. `data_check_project` surfaces broken sidecars project-wide for batch triage. ## Tools diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs index aeb6a5169..4488af993 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs @@ -27,7 +27,7 @@ public async partial Task Read(string resource, int offset = 0, var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); + return ToolResponse.Error(resolveResult.FirstErrorMessage); } var resourcePath = resolveResult.Value; diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs index ac11c8c00..cbd9d28cd 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs @@ -28,7 +28,7 @@ public async partial Task ReadBinary(string resource) var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); + return ToolResponse.Error(resolveResult.FirstErrorMessage); } var resourcePath = resolveResult.Value; diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs index b38bb01fd..c94f17bc8 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs @@ -41,7 +41,7 @@ public async partial Task ReadImage(string resource) var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); + return ToolResponse.Error(resolveResult.FirstErrorMessage); } var resourcePath = resolveResult.Value; diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs index 32169af99..47286d370 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs @@ -56,7 +56,7 @@ public async partial Task ReadMany(string resources, int offset var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - entries.Add(new ReadManyFileEntry(canonicalResource, Error: $"Failed to resolve path for resource: '{canonicalResource}'")); + entries.Add(new ReadManyFileEntry(canonicalResource, Error: resolveResult.FirstErrorMessage)); continue; } var resourcePath = resolveResult.Value; diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs index b03fbfb12..112485b3f 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs @@ -69,7 +69,7 @@ public async partial Task Publish(string resource, string packag var resolveSourceResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveSourceResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resourceKey}'"); + return ToolResponse.Error(resolveSourceResult.FirstErrorMessage); } var sourcePath = resolveSourceResult.Value; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs index 12acdc69b..fcde102e9 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs @@ -64,7 +64,7 @@ public async partial Task ExportCsv(string resource, string shee } var byteCount = Encoding.UTF8.GetByteCount(csv.Csv); - var metadata = new ExportCsvFileResult(csv.RowCount, csv.ColumnCount, byteCount, destination); + var metadata = new ExportCsvFileResult(csv.RowCount, csv.ColumnCount, byteCount, destinationResourceKey.ToString()); var json = SerializeJson(metadata); return ToolResponse.Success(json); } diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs index 8a50b3f62..47995f221 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs @@ -35,7 +35,7 @@ private Result ResolveWorkbookPath(string resource) var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resourceKey}'"); + return Result.Fail(resolveResult.FirstErrorMessage); } var workbookPath = resolveResult.Value; diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index c8df954c9..32b98bb67 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -184,6 +184,55 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() resolveResult.Value.Should().Be(expectedPath); } + [Test] + public void ResolveResourcePathRejectsWrongCaseKey_WhenFileExistsOnDisk() + { + // Windows is case-insensitive at the filesystem layer (would happily + // resolve "filea.txt" to the on-disk "FileA.txt"), but the registry + // tree and the cascade scanner are Ordinal-case-sensitive. To keep + // the abstraction internally consistent, ResolveResourcePath rejects + // wrong-case keys whose resolved path resolves to an existing file + // and names the canonical key in the error message. + Guard.IsNotNull(_resourceFolderPath); + + var messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); + resourceRegistry.ProjectFolderPath = _resourceFolderPath; + resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + // FileA.txt exists on disk (created in Setup); request it as "filea.txt". + var wrongCaseKey = ResourceKey.Create(FileNameA.ToLowerInvariant()); + var resolveResult = resourceRegistry.ResolveResourcePath(wrongCaseKey); + + resolveResult.IsFailure.Should().BeTrue(); + resolveResult.FirstErrorMessage.Should().Contain("does not match the on-disk case"); + resolveResult.FirstErrorMessage.Should().Contain($"project:{FileNameA}"); + } + + [Test] + public void ResolveResourcePathAcceptsKeyForNonExistentResource() + { + // The strict case check only fires when the resolved path exists on + // disk. Keys for resources that don't yet exist (create flows) pass + // through unchanged so the file gets created at the case the caller + // supplied. + Guard.IsNotNull(_resourceFolderPath); + + var messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); + resourceRegistry.ProjectFolderPath = _resourceFolderPath; + resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var newKey = ResourceKey.Create("NewResource.json"); + var resolveResult = resourceRegistry.ResolveResourcePath(newKey); + + resolveResult.IsSuccess.Should().BeTrue(); + var expectedPath = Path.GetFullPath(Path.Combine(_resourceFolderPath, "NewResource.json")); + resolveResult.Value.Should().Be(expectedPath); + } + [Test] public void ResolveResourcePathAcceptsNonExistentPath() { diff --git a/Source/Tests/Tools/SpreadsheetToolTests.cs b/Source/Tests/Tools/SpreadsheetToolTests.cs index 3bce706be..f4dfe06e4 100644 --- a/Source/Tests/Tools/SpreadsheetToolTests.cs +++ b/Source/Tests/Tools/SpreadsheetToolTests.cs @@ -229,7 +229,8 @@ public async Task ExportCsv_WithDestination_DispatchesWriteCommandAndReturnsJson root.GetProperty("rowCount").GetInt32().Should().Be(3); root.GetProperty("columnCount").GetInt32().Should().Be(2); root.GetProperty("byteCount").GetInt32().Should().Be(System.Text.Encoding.UTF8.GetByteCount(csv)); - root.GetProperty("destination").GetString().Should().Be("exports/sales_q1.csv"); + // Tool responses surface resource keys in canonical "root:path" form. + root.GetProperty("destination").GetString().Should().Be("project:exports/sales_q1.csv"); } [Test] diff --git a/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl b/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl index b43192723846de995dc81672f47069e1343b04b9..6e77b237250015ec6d40d986d6f43d9f05f6fa97 100644 GIT binary patch delta 18269 zcmYhCQ;;T1(52h9ZQHi(Y1^2#`L=D_wr$(fwrz92{dXgF>n0;AD(dQqtUM>P<`+Ox zRzT_pgb@1q+jQmq(VfyjfPn14ljelz0m=OKn@mWd?w~)2jO}U=c=EzZV0X(dH}Por zi~afINh6^Y;5*c6>5#Yb^yT9e{~=Qka%0%9f0CS|BvP!0K{yQ&9X9hwZeJjQ`% z8i6igqP^0yH&CCJkc#0mj4Xrm(fX77TtDV!le8KIrlcL0Vku15OPHkomIQ@GtKrwlUtL*W(ks5QI_UT0|p== z>Sv|yg1Oxlhc2WfSY9O=W@cSj%KC&x65)asaba^Q91CrMGTrCmLLQ53-80K_Odong z%%MM~bs3D}ScDzRNZv3hG3INkfOtgOw?w@HN2knZo=|L+LLxu0(q!V}^fD}8h-YRd zX3N{7=@Hr(I7~yA@B!@W0+nc|U7K^Xjr12AhhUD5z=&0`^T{uq9H@0iZJ=v@^6|B# z5QJ8&Pq7-_MEYW5?ulGYI&CP()22bboe$m9C#Qv$j&L{n<(|pLIf@54faRY&M+&Fe&Cy@oOqX)3&GaiXsyIL1AqW%DwulOyHSPheXw|U1wc4lWjZ!vH_-1Z4 ztL;ub8x4dR;4yenS2`Hm0BlNfAXe`f^J$WYxg@>I!hKZ5>>Zm4lfH)Kivd_UIQ!(a z5v4=kBym=Zt(joaym0ECb6xWed-HfPCg{+goWfNk#QkEh2sa)r#p!VR&hbF|xL*cU z2K+2{{BqQ1eca0Bx%{0rlN$ijLgI*dugAs7ja%cN4Ar??>-d)w+$QZBYuO~>CP#&t zWLfvt7+9Vdji5Z%>eQ&dCLB>15ag!+&20V79uh1NkPs~p5LMExEm@M5I4pp+&DFmD z*MUi24aSG^dL=ZflvAtN(vATIQ*V4!*ZM?iD4hh7Y^>1`>rCs8*f&%(QM3$ zl(8ClV(8INA(3p?CeDVon+=DL=ho1S?hS=lG6iEW>WOyVIH(qdyR?k5jB+=k`>4ks z(bD;R{yyMO4Vur+`=VuewI^V0AWU!wkx(#5)H^OUS0FclQ(|GYrokQV@hN!4EY?uI zCJJ$95+>UyUPOA|khD_f;+gyF`jk*`KtfXkrsNH?0JhvUK>fN{-#tH7Gt%428dikb zLH|m~+JQHxtc+4RF*|o$-mE&zAbUYf?NReOynY9fyRsx@1V>R(eFgBh^OQ|81)L?T zhEUnpqNc$m^R<+`5~d~!7e?)jIjIVcu=p{SBR3bV9b_L*TXD~CV9TOIq`=~BuVy@T zWvgJ8u6Dj~c0tdV1!s=98b>EZ#6kfh9Ts=x1hb412uM2dOU;G|+uSqaT`zb}7gi&X zL&?Ucoat55|BitDG7#|8?nq|`@~p)LY0w+Y+xPJhk^-p{tpk-=|?byYFHm#?`AoZk=y&-GW;;KolSaV+f$5vpfAZ5yl8IA-!)7 z8)Q!S)Goj29J^!nH6PyECUGon<)&ymoxa{CS(iF3);ECdlE@PSJ3|#8%p^EdSUt0l z_WxCl@^acS`a%0~rI|Nk9ljaz=Y;=J4*5ds=brW|C;vTj`AY|I2aZcRR7{I$ZU8F! zhd}VTD#*Tw?+kDv3J`!D9Gr{60s+NAc&fFFLmUua96EF^4#N+Vwg-}M#X=##Kt$fD zj)k^=YW!1Q#h*x+$lnie`hvk|V~nf~`-SQU>RU?c19Q);JqoI$pB1~7zrMTJSm8p< zt%C<;^(jwou$(g@|8qG!D$vwM-yw0@hptangB@DJ)&`*BGp8+rX>OVj2+Xsqd0MOS zt%;FLvx_{!OH_QqPc2<5;rv)v9Zj>$QWac$y|2g-`_mnZ_A!O-x%GA_j>hR2&6Uah zv>qvB5ulDK9uKW&YV(w_Oq-_P~Jii&>tv_Q( zmI?vUfSePN2p~7i_ALYEWB7X($`F`%ec?tkk3m9pM@Gq=N+hUshY^t_!#u=Tt z(Kqj=x50q~S@0{E4xgbL$y~z&LlGDmYt{6z2=rwvsaN>AfSLra)4roE)g}((^IDTs zaHWi}3*AE0k=xGZXV$tHB718jG{zj$0!fJh!_pZS2m?3n=G@?F}1>?sLWYRxpUOyCgrB+i2)TM$OAQYn^Q@zR+zqc8( zBSL!GqX^oq+14=v%rfzPIq2w+$dCkXMnp_tv>!<*L4CPO$IUWFRD9g zb8asaAvhj7RE7-A(-V3W!?HwQ^`^QVB@vJ*CiaX|SAx2a{KdxE^3o&)gMFS?s$#z5 z;DceqiF_n;K{Ro8U=n#(E7OTAz?k4Ma(Y(4jXcoI0S^AS=Uvms2=3XoM$y!Cu7M+rVsjvLXmXJkl2(`^ZfHC?w)R z*R)-hg*{b)!Y%6dn7!&?J$y(2l>j+ENyVtmGK;~l9v7zyrpFTY=g#T!#7VkHKXI1o zj~#yH==*<%ppF4H+@pptsW{#fJ_$z>Ngpa8tS#{;>UK0QLW*rH6ic?G`qI9P$;H7` z?R8fO?&~sA$2i;mXn~{ z`tfv3U3X4r(vB5PUMSj&BOoVLMV<{BN#sGeDYG1<_4fEjKXuGN9C#NXyX$qrca82>JTZDo5t21Om{j66%Qj86 zrj2-lID=>Q`$T5Pdw62FbaL(0HP7ZRT+m*&4$a<5I>=U))X5cQ3!r{%M66;udm`jJ z8r;ycH;ft;@?A1Q~924g41-O#0`i4Kw^ zu$O^cp}S-2giAa}|g*`8J(?7$rn2R+(Iptf-B!o3&Tl2WSU+~Z8 z6;R2#f1{&Qax-O|BsvPcq)_ZWknWz;ecVQR17{O^l^^{IHojEn-#;JwNC>R^2dxvhN zCw|{+(&FogcMa-2CZk|!8Z(TmKqXIwtXLY+F6V$2SUK6Y0{HgWssgm27zOB*9cY0F z`K&2p+|uIhU_fFJJ1Sx64As-Sck*BOb_6XH$3nMbuz=d1-B-%=y_g4~cP#3%Q`gM+xXZ8^_hU*C1buYQNzZBJkZ z5n7{)tvvOQrHvc7A8Bi0jvch`%|)(Ypg?><8(PFkONx;awk&0GGHO3Ub@BVSqI}&2 zSm?zdLs*!zo7ERl{x=7RsOH5>CHH7Ij_vLd`9mLjK;kCu_AWc1r4|3GTP_}(cB^;b zh4{9|z?#;kd3QdFkfX|#yjpZJYKN?sfX+z_x1f9>6kPZBZD`lPiRaLGE^QGRZ83-z zM`8k`_XnqYz5EDYi?7|SkH4>9e-=O+^_8hA1z&>}G^&hw@xuI=rBBp2?h{aG{>tE= zKNOk>KvKx%QX3AIv_>jQ9+n={k{%EFd8`90ow+?sg14*&FQ2n9Jr9Inv0sLf&~=8h z#u>3;GGKyp)XA6Qhq18q4=A-4&<)Fz^J^C{2siXF%Y@hafsr1*VXamg?9jV@7BQlN?f>mJs2p-f`Xv|0Rce)E&f+UOLNVW znEa>wP+)+7=>DgQzEgw;$VdIB|9T)F2up89fDc7Bd%!?KYzx8&K>5jp%O5<7YU!}8 zx*dOf69iFsTbpp#^vazb$a***pV$a=%sU#u-V^2*d^qw-R!oVMz8hFP`!(~8JJ^qr zO=G$xpNF(f6xTfBXe2MS{gpx0bLRo~6N!f*RWB^OLJ-nf`*h*K zMrFsBNRV3&DOb;75muAIT|xt%Aqd?X}|%qDNv)X zgY87`!_cB(H24H&;?^ezGViRLSI7zjb@2?r{6x%>b#zin&{eG0*+oJ7vjyZ(G&3F# zXvHs2#IvV%%62#>O7UzTJ7^{Y0)rb^;23&#&7zR$(hs5lRn36Aa7hivb`EWrTB7>V zd6v^=L?U*MsJRC1)FAY~iSqNYK{=8li)6dHRJ+%qh0ZoVh55(9O#+M5n|okYB7h{P zN>W9EE59s|%P-r*&JvKF1tHrraEiuiB{JubYV;chpBi3=)olep_o5aF6jYPX@^Mz2 zSuoSes6;H_p?j}x=4nDdqW6;RebPUQv9j`9_Io%*P7~H7&ZGOG znSks%t1e;9>3lxq8dDOJLj`HmHp#kp>NS~-K2bWy@SQ?sMb-76Y)>aHjP0)Bt%Ai*REv&<5hU2> zToKfdyO^ghpsp^sFqj-NZb)2b$ z)sq2*AI3Eek{cc||DlHiQX4&_W?nI&tSFo>0@HU-gwH+a?)xlR!Zdpn0276(9#4%o zO9=QI(6*`p!n%gr<;Z==H6U6WEzS#Wn4(BirIhhxBYn)UR3y&fM~_ z{3=<;;GYv=AOR5ai?%p$;KVLN%=)P6&;e^dWP^=nJeDZpZuz`kBCzh15?Q# zK=u5tCYBEmWv&ef;oW>wOg!u$^#&X0I$)=p!Fimw`#lX2t0wS~x%-}uNPfki+Q;~F z@DW~j!nj^LQ&x5h1=|I(^yZezNdQxtluQRR@|e>*G%(VYm=SosYGi9@JU~->TOtyd zb2ju7){k}Pa*x`Z{W(i_v^}mR9QwTgC0?s1dM?mrJ4aRpaDZy%)#L_i zizCswdw3hjOQ{k_>dtV^f2Ac-d7`pxoX$=>(ExMOOFO;SxrUUq z3#9aNd&!sq@~}>Vm|!P}on(U8VK?#f5TYiI@@Rye#i=a$GUlSM$wFD-U7gpW-Ch-T zM8h1e>RNoQlB>1D<>;ZSH9*a3kDmag0Nt9|hhkc^b<`)uCuxPJbEz4FHB5YWwR|GA zfToJK!Y0CxO0h8VdXpKyh?-&Y%$XevV?mvV z^0_i{T4yBZC3*CPn~tC?Uc8s6DlA8&E2u2;M$%tAl%#(RFErYQ#juin5{a;{M&?`sM;nf&szb<3I%YQ=XlQd9 ztv%x&JM8w;1I&(WiS??!`lv5(FPtFmA?)p6Y>nZ$Om?)5GJYP8nV7`xY{{W^xajtJ zrKL%4KM#Qzv@7X|Cx8_L!zp1|(F=M3Y*J_3XK^qLy_~gj`tK_+%5ZQ|tn*ZCS$cF2 z)TE;O8V~u75hCeQ`w?EN_A2eVfc4=8DAsnV-QJ}jqQOn)?9*+4)j~tUQu?!K z%sh*NQy7z@O%o(>1`Dl5Q4jRApEuLdX*MDROx^k|BX|aV6`&Q9)_FH)Hm|5y+Lh08lIg_TDD}yTbRj&s^jcdz>&@7lpa6>1 z_Xwxzc<^$SmOn4V{LwM8M#x1Ea>#8PSw%{AwQQm4tjUx&=?+N zbM_8O6*7rG1IGch^NZ4gGiTZ>4XR+tjAO6E#Ex_clY&l`2xOUvG|X!$0ptg zLM-j>5dZV)porPDcelVSn}3c!^FR0rGCs$5=B~dFHgDwDjsQgAuv|>*vyN&|YW{O_Nq&pqi%@@kF3D z;Z3w0c0dG!U#A{EAw%QR#@$jCzZwe48-yyN_FfA z)^z|@MbM`-K)lsg9^_A~qflLBUmc+bwf7pD2%|7Z>6>BhMgbLyF6b(J&xrFO0C#@q zJqva(A>4>*%-;UC_V-JQ;_wst{n*P%e%7F;_YENr-lQHSy)wL^6n#9~k%m)8w5M4= zi#0aLY1DY0)g`9UOEYbtDs2zZkS$D&4`KUGk(+KD@HG>H*he{?=dZg!r)cy>ZLpW0 zS&P+X6hAr^lJbsOqDHMI(8Cg!9XVVZPbY**4-5gbXv*m|W2Rp)A5bgCjGzS9p&wcY z>u^#i6R_+yjCLuH;1N2AWP~~1P8?oDE^7jfM>=|457C}XEe}Iji4iO(S}GIX1&ZT6 zrkiaJFgz;z8#!aoCmInSj*XoiRS}*1Z$2i4PKEgwqaTK_;O1rtx zqbr4RDpUBT_?DQQykSklSP1puU6ZDxp6Y@zAcW;vU<3;?Bi~lkL%joX;iwEl7iF1- zy`x3O6tZ(19g??fiFrg^2#41T2nkhIj4T2K^#yxr7lK+yLF0`^NL|n4M)=%%Wq~PX z7uvwgIsQV&XvH;!&5-cq{U0+@6pRB^ZLIcbz(J%Myn<-4n3el3+_%4k4zfn~L5{Ho z0Ik_clR1D`&O1_PssmWn@zmU;-unu&V*BqcLUcZwpoj zjew%~(|e!PolB66nG{G&YW_SSZV#Hznw4YKs6nC4lwT-?NU^yOLpw+ak;zK8F*&JF zo>9^CBoSY_60cyH9u}E;Gsb~D0!45Md8Nu^W>e)5N02>agx z%Wx3kx)z-fF#{V7g;0u@u7*Xeo8ryU@XH@lIqFf&VX6!8?vil`9};yw0T3okfLKjo ziMPP?dm%2@;qQF%NHHAI12#twUA|H`>W+cI=#VILExfD5J95UnjN-TF#%iTUVsgYe z%T|nWh=m2kiotaw{hqen1$R&UGin{pVHUDmxq%$4{E|CwadG@kQw#`Aky)7$4TskF z!Redt~n6=-EvkRXLIAc z2U;L2(hq9Ev#t96Q+*M1_8#Q2v_$~NYC*LY{&7FwtsF*H+o(v{1RzxsvHgc?3$ft>UR@WF;xB2IIzk|$3`5MJBn*-d(Nl|2u}j=qHtFBeffv9=A1VG@g)iSS^n5W?)Yw|Vsv?J9|4w91;f}`S}wkq3SY3Q9(f~I01T)She z$c0^Kutr^bsFcK)3^y1j0Lx`2w~0-71S{zAABw9iAWt`!9BAyxfh)%~eej3G5)rRF zvgh?Lt{|>SbP_unHZBZx7BbK@a;V-NY*zxWWh-sl#Sd=9Jk75RS3Uc@wLrTL@;l7)h(JKH20%cJ|0A%sosj_7_M2Rbzxw&Vpap&nx6J~Z(r$GEZw|3t zBmA$8Bdxx<--eJN^a~tqY}D~C*}4h8-vjYpX;f2=i%+`w(!<2!vtQ<1tazC>#>DI& zdeA=~|1LF_f3SCL3fcHHWgA3^jcBEd;IV^)!5h%rU#SYRQD2&pMa-Ln=#7{YXvhIj z8A@dNmqNqApHw8#mXDkrMKZK!uOI*H2?c&0KZe2ph13rMs3n3UiF;DE&nLu{vl|X>F*b7gRs)X0M3TmdejHW4o-mx-GVO27PfN~c1 zCj^c*geC^nG5~m&C`AySLku#xPJ9FA3e3#{9(aA^LjVY)F8FOxHMr*c0Es$i9~6JS zd5uSEtilOIK$Dt;-8lHw-*i8NVY2JI=o~G^N#qevUOB^>xw|69Yy*Q}j2CdQ-A}bc z>A~`#o7|wJraD0XJT6W#V#M&GtJ7%2icQygdW{|-VBAZrPpBN?hn(Fmp(lG!I4Bqy zWR1^Bo(GT7Upp zPX-R8fh{Vll^w5q2LgGeofoTG3sJ;%a|(JdiN5s9W$d*EeF z9TTbb#BV@{APz#>7r)lK$=ir`J9YG$U|5I11_~Z>#bQk%Zxw90wkiNx4h@p6=B^96 z1p9fAfvrLKw|~o~i2X`RhzE~jD0!D+E7ksh88#cE` zXg-xSc$>pTxKJNZpPxOGsaw$0r9rv)uZMzq-yq8E4gP};475|zK^%>Q*Sa&J2M+o} z2$2gRaIyiS&)1JQ>!->c4TTCQ3s((=WqW`EB-uFy7I) zkMmvrM}e3&ZsUNls~i-*HR|cI^Op7ekQoPNHb%gw+uqs$3`E7~O<@C1&i2R-{#p+c zZX=?14YTj<#N^|G2Uqv`uBn#OPyme#K&ZLrhl5SW1Pqj*v+xY z2XFdT#h~z^wMB)zR;^?C;d@KIE+_epbmn4qj~Qg~O9BpS(D@{92kZ&Ww>YNhQY&_~ zut)bp)y2%ys?8mzsUl*53kH_D!GLuH(Z7*nkUS)j3=7hwa3{GWn;J8-Ohq58(+(kt|DWBGV^`4Y= z5v&fgxO1&Sg1c4`aannbys&P&Ztj=$?C2}Fjm>ci7D5}+1=QN*bl@!*DA<(s*$0;W z9lo3gAl+=d$x(Ins$|UHj{}1vb9_?Pq2SY7(157!d*zN~k=N3k?N3t>tF~$rX}gNG zDudWl$R&nsyvXGfa7Iw1&l?afYcMzJ7$h3m3USvwCC8-kEy?{@3eVgTX?wZ5Rtqd?6l08=3}{j7Zn^Tn?8tJVJE47@#hflmaMTtTT^y}x z3Bbs>F`LhwazO){TXa}0-4?6dW#OB-{l_`rcRMSFqREjkSM_jaSEh@d-o}2-YJH*^_pl5TGX1TO`}N++ip(xVhU65X3_WlUFTAc#Qw` zbQZfHSH}di_c%*!wqkg{zY(HOC81DJRXH^?H;;&I&> zj1%3*=M5DxFqV-|Y1vzkr*IYfL^gMAL{QvwV!1FpA-+LYl~SIWJ1U07u{QQAlXRSj z&L;OTl?ln>R0-Latl77k1>`xP0d`30Q4_ThHaC=o^nz8<L(-(2tQsb z_EY0_OiCjqNAatdu*VKF{Oe}&GU%GHyS8?2bQDIk=#xE`V@wJbikFjO0MZ%!)TUrl zlEXLKl``5QJ>BC2*XiV_HDsc5IjZ{SEPjLMF?p6=Lq1+Z3`@AV<2@`+XvImQ^7hgW zvJ(Xmxia2BEdffcsisvx!jViU1cDM*i3NY9m^hspS*lQ2lb9bo9G00i(qCL2x5`_{ zbA>}IA_`i2>qf_lujb1002_-f>hYgL$)YrCeA{D;nj=AJJ}pQ4iITDk3N;%IHr^;f z+_}13yL!AzVQnjj*Dy)wkgh7v>Xqs(?hN^r7M9N54(Xtjm zl0R#Gnh&zSlfUtmV}BJ$4{h~wx$HO}Pg$M0AvoI-ZAQrhND zNNgjWJqo4idhYajQ&H3X{(X2BOl|mF(8)V4=^hFY)D}{U z&$Cgjl$^xqxcKPnU*46Auc#*c1{WOl+_UP%I#^%+3J)<=8NN(oh!qE36Z`vyYk`QfWZFg|2EKI{17v~kFJ5eVVo?_?W(CH5-?%oW zC2VbH$iJ~Iz{)wjFm7Q}q$1ckTJj$FpQBey(cGPE>ezC&6|jn$wI(QPd2CT$DkW?_ zVg{Kl+hK`_3VNuD$7Xa!cpuv!MSsrviEPk9mvHI}w4{v-E}L{%Ye1{lFYc(X@o}pO zM}#7{LzF$tFMQ!QgL+R0=jVUT(XEzsOTG>QJrB-r0LM(;_frkpCPPt>_9l8Waw(@C z4#ws`oM}JnRfo@9_3fY1Ol?fp48rd*n%hU}L(-5GH#^h}XIPD7dn{?RtcUn((s4s= z1Y_#^?|0sI!-I-8d#py!+gtZB7b70G@{?x!jZ%B-=HV0HBkO9F|5 zeuH^RK&Xe?1Th@PuR%7>?fSFtWpS$SCFi7dN+5F*m&3g75t|9-jG2N-v@omgVp155 z@#1NsT`Q%S3cgHg<;J>h-*PgiHA8b?^~ZRE)g*)OvR8E6Q~Iixu8X`JKc`on;z(@` z29<$`p{RD>-5qQ4PIU}U;%^HR7rxQ7(6o$g5isj#t{6Hj3?C` zE0ax>Wp>SxtG<-4GH*_~{X*nP-w8dvo>&ovQfH-Oa|?2Og!{FFa*3pD_A7^pV%}b) zv0OFu;0b#jy_3?#_fKAY+)5&fe%>PTF-QPn8G*93`DiZ$v6IyHK3*I8w`RSF&f4`&*tBV`k6FZYoM%6Z}XINR3V;&uj3AO8e42&~dEPfTHDvog`QmF<`@{ z8OkeGLiE%O36@N@k42Md(@*X7h*(k@(l>GTA2@0BKlTaIu=F>s6xDi$_6KbfPyONzoIB3A3z);Opi4cN-%j;`1Y&J-H-zLxXqK^(a0{3ReCY{R zFt$M!gi|XAtW*DONbaHb2^4(!s!c&JTy^?9bn@At_RWAj9r!1Z8`bWTy&sA>ccX@J zTh zT&2CUE->aD0|NVu{v=?F^#R}ZUBgy`Us7YXD^~%CjrG7s&~>Yx>``7E^@tqqr<>hi zOQn63$7RPVeeU$JwnGX@YjqP4`cl+^XYjod!{8wu8PwQ>XncTfru4|~8EI$eL(3N( zS_XO`#)YiWb-1 zJfiE}RHuSR5V;j9^nmD~*o&ra&OTH0^J>hY;|Rv-3>($S1uRUdJ+(wmAO*5(Q3k@` z5s#K3Fx4|PyJeps6osZuw!jc`zgJn>^qusA;%xFKBt3y>iyWEPLf`@;_v>Lz*V&aT(2)46^!JY+=6j2bpKk^S!1 zR!JC+PW{U`L)4=1Y2Hl?U+&X{*B(>N@pf*uM7VvJ- z9ETu?JBa6v031TXoLwen8D`0h!sd7;oB_7LjuX#V>iWUTXsui(;{Rr>MWng#3Bijc zsCT0Av`M>-KrqJt79e8;(g)xwMUA|@QAHLWlI9-E=+yIx77~4B)J14|)z8xefsN=s zxPIpmP_&Cq0E?0qSC1dMIu3JK5=i`8$wU;3H7r3 z`oec}`hL&T-0NnIAQr%JK7mjBoVs(64o7LTBF^5<(%J;SeSTiP>T-f$r7sP&74q{? z8G#ZZ8=9EX2Yv80^|P+9_(Nai_py(UuCbj!Dnvk4wC(^8z~liF>_^(Z!^5%Dld|FQ ze&-ZU)a_#FnPQJyxxnVx;{w3R>dq+D@a1GOIOIrRJNfypX_ki%MuTM24U6e6K0asw zuHXGt!a2d=Sl`%DY%yiF?>IGbw;WO=)Y;g$(kJM^iDNnu><-Ftm5=Fp7AbW(+~%h8 z`ltm!$15OJC%}T-gh46|uc{RF_vFpe`fB4Fgh}HZM_@oinpe9%I_jAki(+Ox|0F6c zp1J4f0j>96dQY&R<>Xtc6?7hvvdFnASB4$iiy!_yH|9#-M$| zxkPoj?2fMwv*ZHwD38m<~m@JOTnF^qwF{e|Z#s6CO*hJru1VsCXn45xX zv>5DL*y_^|#?Fy1=ZGM5VAzQ~psXnz$ch|}85PKohp`&|)`eKonU>Trvnj#Q8LjC| zk9dH^89%3o_Q>TR%wAprjMW?i`7$%ffH@-{>hYU!lc;?f_A)h%ZYbfC6=X3{%k+L$ zA|3#nx+fEX_DuI7_{TCo0a;E4i7nd7lpwOvQsTJi>@N>BfH4dgE&Yug{+BZLYgZ?{ zE|~@DsrLx2U+p+Hj#CbeD@xAgMZiQvj`GKIwVjTnFDhc>=?cEId;BJgxUgwej?vL( z%jIC-qe3B2)7>n)fcb9(F7~MfEt<$c%_4w={@)DUfezz2V#?g)bAZ#YHw{TChBGl*8BXlnh!WqK81aZ;X5kBXBV~Y8oy#|ltJ}!TTK@Nr_RML>h+kc=k3AHBRTRA9 z>)xJgIVV;UcppsH5u8`%g#JVd%CsrS?F4{oCQWw`B;s1)&)))i*}|SbnVJ^<(rt6f zmY~p6)Ns&rj;fzMET+T?v4hhKfx8_E`r{^)TYf?COz#kHDl{?FKCIODSqcD}kvxUK z+dPFqhO-0wk9R`Co#-JN-z2Jsz*&@{fqUgh*!*^CiHQ@CC4s5O0jqxP`jPz#IsjFw1eutg>*}(ydSJLX&l>vlDtdFR!Hjwq@U5?0b$-;v}be(3(U@be+tK` zBf0^BonsNJWl>FsfFYR;@&mwTfQGFIzdy-`mB+hVwxub?VaO)thpnhp&pmxe@3#aV zXYe+VnY&Cy4iIm{w!@b~lOvdicR6L^;-#9N!(@b4sg(hDQE;W-J zirczY1<6w0Cb=9|+8o_S6S{26am$Ek)pA^A>W&aY7q<(Y4MsRFVU}{%<_>O5g6QPi z;t(@nnO!mgf>JMT<&%6Vblz}nZvaPJaJKtm|Am_W*#+Dg%cj`2+(!XM0RriBtbvmh zwdzs;zdrQ1S!JZBA_E{2|7sn0)rKte+kIJEIzyzgx^VIg-R=BnB}7f50?e?0RR&@5 z*56(e8-j96*Il!`mon*eprQ?lt$=1%zA4n6mUpV;+V9|XfJufH2&PlAHe0vIWE>-b z&NJXK(9X0y-F57SKI>;PbEdn&IOi@pJ`O!s_yc$ZHj>Pj9R@HTtDl!6Oa|CQ5|K<& zsH{gUVFE-h#RM$e&~;|UN7KXE7w=(~<|K1v35Am70&H}5qdq+Ngp=y=ZU?kUQE_{d zvOb+XdkwE*Py-!_!W+B>(*#8BPpBToN- z&LFWS-TT5zQH?YzAnbR~;a8c82f*Pr?;$H;XUNCWv$n!6&F*r>IN>bvS{Xx~HRI z>~;|yc{im>hwH(Qi$F2y<~O$_lfHS3Xsy=(PlDRzbN>pjoXq2N`CE%GsI-h^h%j2F zeU5qC`vA=0>d^74)T{SFbU8wlSq8+#;V?pEEh%Uz(xh1K%DV0~iU(YTiYi)2X}gSa z4Ho=&y8obTlAq@=?<6F#=}yF6-zuuyiFO;PV_hocXOk;0oj+-3teyZ<*OzR&hnXEm z$@^<+F5fJGf{dU{`W6_ARSbu-Ed;ek>qOPi9(%Ox34hMBhr`9fvOq44%skYQ&LZ^T(gPn+Vf`RNhr*(sRkk2$^6 ztO9IMj0T5;Dj>!O2erhI4CIIlunwi@x9Qrq;}WP=-aAK7U^R`GYyl8aORAjtqKWlf_y&{O7VDWaz8ti2X7(6A zo1-fYjtDSxODXjE0XWw%N`k5-JZuwhqtAjBN z+ve-pjIe*w>ww1a0rd-wF{d`Ge^c!^(3?_IxA~E&%{EGcf0(>Dz~su7TCam;lK>{h z?&C0XRW-pE+iH}a{?cGbyv!HU*gDtD;WAaAyS$Q}dMEV-n+mJ)m6Kz(0jjB(7)!5c z^XZwq_Sl^VB>yyD-(Q`Yst^s}-t$kH#yKOea}eG|eJj zGz`=9%H6R{Yv2}e#zNg4sbDhGVE{{xT6-Rb{rLXZU{KLf>uqzn*B3}hmwrbw`wIU^ z!$%3x(|o(5)AcjYtoK+x)Y8_kGQZr~0U!|2sDjr2$u6VMG6AoFJuF`z0JqjM*JM6mzp=dV}!cuQMXYC;BxLdkxn=(*alHURX>6=l7~ z6*Jb#(tIvHcMG%SA#9b8=gU34I5kRdC+B9;2&J$AHH* z5^6J9gKFD9G#kLKWVqNdu0>VU|7}(-#law6;RNv9Fm=qLr(u-aG{)Lp|AS+3lSC;F z%xj>V+Evoki>L-K^2)5uH3V$hI6QC3^o?O)JeP+`j6Shn?Btg^29>oq*NLE~IGUSh zJ-=plBXxao+tj<~o$uOsEO4-PMqgIMSnJbalurO}-{Gr8Sys?nI@+ciMtSyqdiECdSM@)Yrs?gPLLKhqe6gNXxvfU_lt)Gv z;W7PmW$y%P?+lx%_Sv3#HFh7>sZB2Hi_WsqMTxWy8yx$W1RZc>S0~Aq7O9uKv;s1y zG-TSWF?p9`TtL#L?B@$^pGO#sPlQXReKrqQ$w~pY88C~#)kKXaj8Bz#$nVlKwhXx> z!rOO6iyI1$@{iL*zZc+$wBbeylA(Rgb}K)O`T5`I4UfuI?eWzm-PcUa8$3-b$joIN&hO+&K%NZd?PAA zzCF^DcD!AAaWsbeorIGnR~C3fS1x}1-f!@Q1CS8qaH!nfKlFvZ6SCn4CE!@@40>|PybkR1QAb5<*ix33yOrc4 zA1bCbddeu!qkat@76j|Ws=5ZByPK&!gdwT(?|8m^d2lzSwid;lgu1lR{WJmE-lmM; z0jwVK6y}&P^%8Ele=d15xFbT?ArqR0U1({R?7(%~$8v5Es&(!x3FxjSHhM|K@Yb>= zbxBp}Na8&#^M7ixR{#xnO!mwy>P>8Wt@!C9k!F24^t<8;BBZZ5fTKr3o^a9@a7=78 zxUUk3zXH$5pp%sJT9gqfu(pyhJGb#|0;+w7#29#k>p7udNw9CK*R*)yQ}q}=j#_;| zPUWJW!v|5?gctY&qHBcr5_NK!qn7uQ9bthrFdJ2qWN*43YRP>=K9&u)c7h|N*$UZc zTQ;?tJpXXOU74yGlaGA#rVjvwVYmAxzn^KG5~=T^Z{FuZWNHmS1_DrM@cpR<0YQ1I zZY3AJHWsUEdmd{klT(%jE~9_LjRjBIuVUS)!+!#nr_t|o*%kbbXPS3)wYlDm(#}a> z&SgOYQ7MWp0h$HH_J{jU@2yCM-JyL==H%Xx-f@(*Dm=r(?q+vREsCL`+e9s|>h2d` z5zE^!TeIg&O`SyBTNj}REACrr0Aw+S%jqr3sv*^J;7cvgpc-aFyq*!gr>!W9p@`KxjhK>FuSvLWqu zBo+F#Ub3OI8pr7(xI$!h{gPyNG#MugV^)shc(qR*Lo5GLjy(@hv4MIVt6FO6!ZCr)mDSV-p)zE9sKwD zzsk7wc&6JZKEp7ol}lI`T}0TY&|IRI&&!feNK&pFqFh?$mRq#VZK^fdFNCm++;W+@ zz6?pqBwbu`PjAdEuh8sOc-Q*q{mA*}dCocC^PK1N{B!;|&-aixM!PQBnwcbeOsK@5 zoXD^ni;~2JyW_mZ)*|D#5WPaX_DSXQEL70qNfrgpAQui^drIS?8O|2KhuXbg*gnnK zD<0-nM?F3_3y&~&8syplg%Hwzi)E}M?G|K%Z*j=F|#L~gLAy> zQ&%M?e39`t%C=!NYoBTwerz(yF2vzNg#x)w2T?9N#hUCG z#!kN=XMR^!T_lY&uH(WCgAiG!SS~ZKUxD+5;Hp}tBi7Xmqx(S_`Oy-|E{b}>{uJxbfyiq0$ZjG_*CjZq z#TzdH>tI&i&OEA6^oi`wK4gE^c}Xi6z2$kXS?C!0JS#al1CYn{+NShiV_wC(yMJ+= zRCWed_MkC>r9o2-?%ed)4;Aa-Ry|1`K&nL5D>VH zd>hoVz}RmJiD={G&%PIcic9U`S%eQ6wdw|n-miO7%ikXJs56??jEV)}X+N`~=Jx7+fCEH^3%^a^6 zS461wc~LC|m`ob72Xegq!nvLAtdCoHwK#S@!`R_cqwD&1$cTz8!zIv$$gC8tuR2TZ za>G-1ML`;gv$tlHUs5HhH3mn+u)U+=z=!AxjWy=C+1@Jwo z$7*{SFzDLeDyuVeuD?C{(3rh`K7Q3=uA*sG{~*Gr0$MY}3}}W}FZo~$%P%w`^~y=Y zl6oAOPY)YXyH^R^94%>h@}tI`ptClQJu9!HprwfCkm|HQ8_K?f&Q6FGOv%E15$rmB z+T+XjMkXjUFUk!X3mDU(gb#0;@(nqtarZGqx&T-E}_EL7rChZqeK*O!dS+!S5oh=VwL(M`xSd zqCOVjDn>~DvX`fQBVrYXD?=oR$IP77`y&$)6luC+6X z^k5^)XMJoZ&u;^>4}vrpXyaH&9JDJ!46#fVM`UVucIx=KGxG4lxgl8N*Mz(V8Y?pZC@xRr#ff$-$r?l7O1E*=zgepmjXVyPn3&(L*cnc+*uRJ zv3qvQoKKOMFk>3>6oVbiqPrTB9SP+qRy5^aBB$(8J-nS zOf>=Kt{wnV=sT1)4Y1%7^46t_NJR;NGjwUCO&q;-6o0KEPFM?ob7v$0Oa9;4-+5FT z8^B5D1Yn$vq=xlrA+QYSCkt>BGhjoIvV$Oqo1+P4xC*vgT%-W|Ts1J^ia;IomI3Pc zYBi3pY5>xY)WFgK0uRm)3Sb{eDQ)@%xgi1ZngBc$xD{yQgV#GGf(D5N2*e-m@k^?R znXv#Z8o{SE{D1u)rAspp4pzGl2qhofn}z yN4-Pbj3-I)bMgt1ApXRQUs6SU_A|&OUqO7mFam^pIj~%Y;IN8(IfzI05Bz^ZulPs+ delta 17671 zcmV)YK&-!&)&kJc013o!%iiVRLZOH{Jy{4AzBRt~%%Wp5Yq8qs+2yjT`Arygv&I z8l@J0MLV6bGft)g%BNOQJaYUyM6Q3?P6NGSjSD3h>LB!6UKbYU)VdCeNjZsfS} zZovOgkPqozcr?k?0|(j6>|uk<>|n-04sAoj7E3Lz*`z>Hwi||ld_=x5UlJ54>e;f} z9%r-dgImq2V!c>ZtWuIBKP!hD!<^6x99n0g&<0$IkfTgFb1eM<6x*V$SdQuO(ecqw zEH6DQ;2oM=Sbwx0p{S*V&pTIY1(nfvJ9zVF`1t-aSTr|i=;{3lIISgpz18O0(>y++ zb&(?&T`T^^8Bu}No+OikS=h3w5^8e_fBX8&C)hG$OD3iNg((iT;%MYfs7*&|jU~Gg zx;DV11nbxigaW%$d3t=5B+2oS*jCy&_|NB$zzI_XO{_yRacM??2h{pCglM)SFD}8H$ zt#$2Sf$1g%MpBD48s;$23IahZ5J)T>tal#NI#cIP+@N=`WeOK10>^^YlH85hw178g zc7d1_)`8QoM!;?5c4;em_+rp&{=8>5@6!W)EPs|!&c4dw94xvyD}Xkzx?1+pf-MGU zBlEOK0gdIZX0qGvKKx9d1u1v8O02F;j>*-j*Y<9eY=yN#Z7!JEpi3eH^ob_%o*v{* zH|Q2gp#P<1Jvr?-e+d6N7wEVda`$GSn}+}BLT*an6c#tN@5EI$-`!2bmO99CCPny$zHh`@lq?13d0Xn)-Y=2+4m!?2X*2ARXG(2}EBOyG!qdTWHk zZtbno4sE*S$tfhC=sT=6-=&x8Q7r9BG!51-^nHxA6DaxfM4`W2QpAnFo<(z$#z>=? zMVA#sIt#MeXo8F>o^8-&LKQkQ=xU>E09{y=#i`E+QM>dxfv@O*frkODfPujfcYnZ@ z%t6U4rHN*P5R#h*5EcRhyibtw3uOg`w+&fVRY(pdlwXJp(oU6DmI{lA?&EVS-;9MS zBeW52mz??xp7MHI*+o){O_{`#O&`C>d7v3L-AA&A(i{zfTqT|dn_PwS)b6^ZlM;5D zm}b+OW}yA-hRGUdZ1Acw`;GcSAAdx2s)2^i0fIJ3sTCNh4EilZy%nx(YPQrcszevD z3hVFL?)#KBq97h7oW+@A$MPI2XEQ25GF@agXObCvn9xJc_OUX8$eF{9rfO@v)KkwY zVX`V2$+1SqlB;D8_J4LJdaU_VG2!A7B^spi%Vr9#&wy)RjqO+Ab4@*`?SI2G9<;8v zmBhP!Ydy8!CxQLs3=)52l83(H8|9C5z&Otw+f1VOZHX!#r^kG$!Jl#CLbA;U2Nry} z(OYU1y!Uy4W1F_Nm=Tl0zv9*>Ct)+eaa(D+YP?*=;AP8a;dqB!e&04PP$Ve`&Y#p> z1V4cbLI^1nG+mL9<>fojhJT|GYD2ZN^fw`)$V7Bs^K0qEctk(Vb*Hg;mjLa1QL7~*4z@TyL_WiBs~bmj5mri*>Bk?$#O5~86rk;C9t-=LR-8(rDV z-h1=;?4aCInd!9X~EyOJO@*<)O<^pt0dQo+?R@s@yTd+^^LzC=_#n^ zWC}LYl~6ZK3f@M(>>77j!GyH0Ua=&Xm(6a6=x=P(MhIdr6j;b#pTqqoc|N!PLY;Xh zZHpnQ67&rkzxHTJ-NT-7J}+Bz6NGq+?P4I}YZ83VR>t?>7>J^so#{QQBw^^WtjpZA`o51+&E zVcOgC)1P)0G^`~2X`QVfnpwyG2}cAw@eRDD72EY+p=r{BM0P{-4Qf6|hH0KA6hDRA zQsiZy7T0Li9~|V&m|d@_pTdu72emRNsXGX9^kf}#2!FPuIZ<6fi3a=RoYwE)7NaM4 zCa7W9$Yx7cVH^(n5u~tS^tujHAm5GclZ%nKX*+V<>d49Tt|Y8b)-jdi;?eQR^3;!= zmWL7;4IiG1K5F83=p(lokEk+^E=(3!;m~BxkBqX=h2|MKf3`Nr=)gqTCdD4j8AFqL zSXaOIsec+6^c%&!=@{JzXuDI=yJ}kB!D{~ZJ}p1Lg)uA9%H5n$*f1*OOl@fT8#>Y6 z&qppK8-EG2$xs_pu|FO?^?YD&cRvW%oNey`mfY+zX?*Ccg$z=qTYj1Jn*XzeTW}&A zW_kU=L5JOrG=c_aNOU%@v=sU7JQ-_qNcF%-(SMEtSHoZ!ri?F|H?}#Ym>FpLe)Aq7 zJM?M3kf;`6)0yT*SX}+_svD>d_vs8hIab3FZg;5sovvbrZqL(;^Yp580{IoV5dsMxM}3;}vmqM8_?xMB@rns*`}Ap5lumq75e3cQg#%`RQ6Grd-S60QZRdc4 zJb$g!CY13b$fkj{x@=CbkAHtEu8i+Yt`wH2kp0hT?jkt6eG5)+W%3HD9c;J% zv_7F$Up`g_{hK_YvND1V`Yv6j*r&uFdw+43mD zxc+^{?-)>1>zHwt%8H&qePzW@+;Db@Qg?4N;}_m>{xr}y*+UErsus>=n#2z1{cc_MmEG?6 z=)X`)0|XQR000O8$4#hQ7wH4=H3b3y0Hz6&Q8a(SirX*{y$A9SvpzXsTxh$Og+O7q z&_j=b9+DtjX}l4uNJbiOoWC!!t;CM)Yy;heItRznd-Fzbq_O3`FqWV=5;~gC=CjA8cOkfTNV_#g6ag4Dll(A?N$fd#rgyH2LH7y?IGXNM! zbO_N39X+-9Xwl%qbjmGege*5opp8edUNJpbNf`|f;&EJ2XpK(}wGMU8U7MjKbtC!ee-eRZ!ag{j- z-I)hJ+=R-(*SSuDWm>R|lYGqxV}~jqma-&mOldhfSi)MYqwpMUAvb>( zL`zs-sBO1Hp$Qj!gpqqwV8D2;_wyFg^g^0&8C$a>Aby6+P1}{ z7FfMYDyyk|)RSJ`4-*YM1Zq ziwvc`f4IE?fPwH9Y4nW7KGv_%eMKLZW2%u@Oj0En>d7c=$x*QCXUC*T0MXMi+n^9C zwg(#|yp2t1zUGpM_j6`yhpf%^9(TAuDu^-AUU_hzD^V%92qnQ&T;Rl@NT)|9PQiT) zp??^4uVZ?}N$%kmGXrxkFBaTsRnnrgW;`_Rkt9RMv*3%!{f=HBRT?;OS;>28Xo)eX zEkuoSN984)w71HPcUwG?P%~4ks_&m+bEEvU%?1Th+cWkf0(<1EHJoATL0Y3tj3pL7 z7gb=P_5kySu|an~;&Cq(*>^Om&=kGnsee5TDe;gK$&fwy0GWoc=ZuOjasN~aL2`2< zK}Wq)bC32q&+0LR)_W=Y6A{Mo_sc2ZB|=;QY)9P~n!NmFU91HnFHbUz?iwE0r*_2# z!Ra>P_M4CJ-q?=UVfBj6@VvSaUekSz@|NfNL>>4bl|Ap}B-dzA@6aR~Q`9IeCx0U0 z(&AckJD$T+V8oJkEbicWgHTJL_?yPP<`#hxLkUBN;m^>ep18n&N@FpL|57{r!<3Rj zR=AbQ z-Kj8O6)dBmx5H|QUZagOwv6JXY=86LkHUz?xqWu|7g#A)I#I_)@>-U5g|t^Rx@a@E z)RA@v-_(d3U1(IHeMUgl48d$vAsDPMc)#+r-C*C>8vP_b_X!cx4bp8=>u7ISwU{|9 zx~@iPb?Fno`o(1}(d~PasABtmjCeGK#Y;=9FTS+mT}1UB_!1jSv^9HfH-9~+wF@vj zLH8ocxG`L=tBAZ+%&<&@t#eqsOd*Y2j#nRBx%&V=D!QN1pe*az+9^&}8p$?PCPkC@ zx-bbNaxJ)AH%o6W)UQrFmunym9tOHDPAH_yfWF?45?*~R zL(<8O_gCwN_y%=Rx$e0TwSQMHt=X;!S94#La#eP+W-H>eHuZ&eX^&n_awRw|=TSc> zJtHox%A{%~ZZrBZQk{dk=+azVyFZ6p*E#1n@m;)wx+x@6iAJXW-yrYB_$z}`LxZE3 zj4gQO!&$ISg%6l#tO_nUg{{{0Mz(8BE?oelQ^_5C`}_Ivq3uk!4u3?lmUTrgI3*T= zXt#4&)P!2TLztM2+5_W6sI{rl-g#z9q|>)T9}HwpGm+r#fimLW4b?n%E$Q@qM8f5c z2+oqwCX+4pN27KqUGJ`Mx?)YZK{E-rS#7e@QC}`sgl>%>X0u#MWtU4LgxX_kPanTW zxF*v2zwc!n}?=V*8|tch;Y4e4o&C0eGO2(1O7Q|(szb{P35S44w#R*l_Rs&jEm=C)-JyHcn`l z5~OTN#^cX3O-Pz32zvUmpl2vU+uv0R}- z@)e{*DTS{gLa#5c zF3}ul3=%y;!*^FFXrUlR(-lfs3VjJQCM=^fKn9=cA0LsoSJzi>FWwNNhrIkW8s4A~ zO%49u`1lARga*sY%K#0QfBhCzzlQ^JAK3e;y`Np!`!jn#U;iEqf>O=kG+2M%YYH}= zY`)z>a2^~VO-`H%{&Ys}Cv2$~5_)u@#X>wnDC0R1au8vk<3-!Km7N%4R8=`Am1cbh`!ImpHo@61y>6h(q-;JTr=cy9b}1G+zb zb_P5WG4!VR<<<4YhficQycvK-wpqq!;DOyo%!&4!u%e{70rM06#Ad@84jxH zig7CjX}n)pnSW7Al|tpy=jM@9k=PZI8EB#l>;f4#1aUNHX)J+b#JC;S0GBeQN#R4)f{G?A`1m65UUB z&P2=-264NHn~13qEH(DHFl8KGi3Qg+6)jdMQ}2C@v6BhfHiUm?m0J)^ermxbK`l~~ zTdPF2ql;W|uyZ;cV_XEJv7-k7F|kBMk|;1?pE3PokOJg%g--%x)rF@Xl&)s&4b%9Y z@I^KSseTk@T~|p1wqc$=-v?q*0d~dJQR_TVT#>55X^504?e3eP7ih2+am6d%D0W3b zD~A;m7{aurxV3+r8!&}tzeQ~EB@*;Paz!%GG^U!iSm*&7%>g;S8%vQZTglBil#7BVU~yl-&p*orXbB5pXS6_554N`bkoioy`b4)Gwxmrsfd8raNFJi*EXT;p9xy`c`Z?y^h~(Wu9fUe zv7Q`tP)t?UxxLM)p10v}9O!47r%AK6tW*%Lw*H|8?LeqJUDr;I+iXfVOxD%o>3W^-Av0X#a6W8lsykUkdAM<4DC;wm{ZRaz5OPR%aZgpZt8M>T34X{u zW=R1y+{!T&qrVdfWVv1uDkVK++9}A%q-*AkEg>e(43t-y@X!Rvv!m-w$WRVukFK^V zke4hK(%U4-EyB^H5OGsg!7-&3_OQ*0B+}Z3vN2cfJ=jZMCs?$fCJaIeSuwQe?H<03f z-+>1EdPz!4$iTEp zOOUg#VB8cD0Dh-jE!HsFnGXN}@?HP{FO#rSB$L2(5PxlN+c*;bUZDShP`}u>+MMkq z+iea$oa1h=w{OL^z1t6VAjlGJb4!x~Nyo_+`R|7z^)6DD^sg_?Ac|q2J3Ff4qDD^X0pDKcg}7K4PgpBm#%ATw|>M zuKz!|rSjG@#Q!q0J6V!jwH3G*ZWZ53JpL%Qxc~A+jF)J~#V47B5$@sJgmQs}k76Ee zH<+pZ#mg56p~g%~r1d#?ebXhQIEe%f6%Ll1#(!A!I+GKMd)ZP`nmdANY<{h9s+UdI zN3|XY&Kv$kG?Wt68ZV#)27HBN@ibUr6%e-MSu3y;*qt{7c$28R09d@>{8LY2y7bFK zWDJH6bU@>B>q$0FofGg0rNlxZidhdxW}faMz3V<2kI}Fo)X&ivRjFo!>&95RZRW9X zgMYni7{&VlPlle0GU1Xa!kM8Sy2u3IP5G87LpHFJ!D4&fR^s@5$X3|vBO=jnoMG3f zOTqub>^{G5?K5_~7e zBOgU(I^$vg!3a25uyu}&4&d0(t^)8|pnrdDaI|PLYS7L^f#sH}+UkoTEWB*$f*E7x zDXv2Leh=s}Vi1U-nO(Q7VMo+Dh(i^22ZJ%|K_qW_1p$2W#Z({9J{u`|7bU=Wk-Yhc zZ}r0sXKL-skYYJ!KZVR64hC~}g%|z^e(~NLmA@ka3H9Jy}4hD9Z;@eCT4G|Yj0b!Id{9v>eBOQQNy+p6W ztR8^BFi9|rflGEO{J@Q#d31XNw&!t;P%q4;p(T$gJ2D z9jn&+p}I>lF9tg8QL&qllDHi(WvBo?Q_ufIghXQ5%e?zCSshg+fmVs&vwu&--h7_U zb3RW(wJxCtK7G68+Xkb0j}h1>W%CnTbuw#a}4vby*3}Tv}er*`sHuloH#a`lHF*qyKGYqXLq@G z2X@c&zUjl3s4dhLUU)tl4SxpLow#k@hGXZ}UZdCh@q4~=MFJTe4NGJrI}Z!Dslmdc zDcEeOBB^k@?}%}Vhq6_ldB1D>{rkw1_CzjB>Dyo5jZgr2cZND6Mu0yM>DdT>YPCO zYM0{Wl5Of;A@m2RogFc6LlpwkKWsOfP~263lesCHrtnmX5Uoj?4P&Qvt|y4M2_+FE zOb81Wp^DbQG9>%V`2%+^2P=NVJ8gD=9r^FT5BIbqPAQRuGvn3jh zN4Fb(gPrkh(Rj24`hVe8iEzL9sl2mf_f2=V4jWTpydgvT;H(2=ha?)KnandqOD>QE z=vFfk7Mz=RBgVl?Gef!f3Ohv_dOsa3T>LCz89OXs=1y1?jSotBl)Bq?cJG6Zcipm1 z1im4BD}URc#>=`eyem`d7d#I@%)3kAXb_mCSRquEQj6+7qJQG10kn_2>3;RKgAL;D zVT(LnN!EuYj2qd)O2T)V$4Re1YMU($k-$+%qb=28NQ0LtRZvi28E_WJ@CJA1?m@vs zBWspL7L#E)vKS8TeN<|!gdL-SD_Qmg(>g7`*&BcO&#%47@M1FR!*e393=6#r1j)9? zOyZ>x&g8VkUVqeso6~_SxB*dzD$s*A?RKR#Q215_A&Y}W7=2m^zGZPc8gNXMHXBto zer|fc%_OOXMo%Ndgr(ig*1NF1Koh_Mu5u!8!D*Zc$ai!pmOAB>3wZmhtE<6aFtAOb zKFodb=k*?`S6E#Vyyl@>(z?0g$GZcyMDPR)MR2KuPJiFDN`1ivB#r;SB%My$O`)k2 zPc#sux}*AaAKv})@3%Ve%7qQ09t^DETIp>{@lAE8x04S){!kn1d;M{aRYJ>G2B=nm zn8abQBe7a*^8hE(OQN0$F?#@$<~jd>+3G#RM;=W0im2>x%x)5%p7rT8a^~;h`<8uT ze8+(QTz>?qhx`;Cb#*x{Sxx(KH%n6}N?wGYEiY!z8e zTn_gu<XdrAF-g5A zMHGkP0kM0x;*XY4uc$?EJ7(!bYst`K=TsD=j2>}%qeq(FpqSpE%k%~Za<7UU3F+iW zk$)Q;+z`BLfTz|TKnWjIm!;V%K#;@~NBfTvyJRRW31%xNYqP2_GaaLd2ZtSVg;`HG z&$U_`;t6D!Yz#GJcnvyV7L&P99Wbm4B&K2Pt5f;)Q;#|aV*o|cnMuJNT)ty3To#Zp(V9vagKRIE&C9vW%x%c#% ziC!@XsWW1YDLcJ#reLYLrbq=(@Q!6eP_>rzUt&*2(v=I(|;*I z!FS)KKVPRmzfXU@Nq-Kr4#QE|9lf^OH|;MpK(3z}&ncidyaBAMH5MkF7=)BF{G8^^ zU-;3S4gHaSI%jA8x&Pgv;5B~Iqd0}5i|MGVm8(^PRP5U;R4@us-B4Z6wi_%+gqBc0 zF45u+WE7e@G>#G)>VpY|S6Gw|9DiOR9YK5xK^6+(^g_v#r+_j%PE)U`-GoXFFLIF% zvqbIC**?5>7Efm+qdg=eGodpdo%`s!YX`bbeY?T9ej)?R6cGoXt~>?2HMmSt zd(+c;v*JlW@lvG()wDxJwlGc0F`LcTZVDBeJG#_{wCqrubvX#l`=@Zklpfukx=0mI ze!)vsqAj`V23GG2Tc_3-t+2*p!kNSf(kUp#3#!TQADTS7;8Gq@s!(Ah*L+K3Bo(0) zZJT)`%D42ZHJ#;8+FYO3RmVJ@lt(dzvx zjQhySZN{{TPX=(=ZVdmO#M@0GdoyqH`r=Z%Ym?#V;&OQEUH98GfAacEDYjWg2emdU zXd}!nq*xZ))f!A*w`;h0PpfW!QB7@%y2_9KLzVaGl<|SW$~w1+%zwFsOTtzZr;1Hj zM7J?yFbxMoA;P=kcW_SmV{hLuqEqfR8Zf*Q`#~k1GkS zytGk2jQNf|qfZR+?3_Uutp7hfG~P4o`_f4*^VTnv)v5$zI&t8jIW1^LE}FA~MjHb= zT(XmgI%2Ztf~?jDM1N8pN|x%{X|G?h<&ErxR*+R6Ixno26-&g2z2mw9gJG7u7`(zyREV&BDCnK~DCDJIvF;W~&hbJ!lrz&6s z>m=HjW*F!+LpsjLaX7FfEN-695xDAt)mAVW=&+$k3a4Avv47j#JIKX3_qj*YBB##$ zh$-dh4=!ljDMNAijLZt%6S_QaPRK_~K`?7hX^(P&z{*I~gS8|%v`pun0Z5p7KN55D98GQXSheTft%wyPNFkYhCKWn9tA@B64%JEy<4V^I&D<+k*hb;Oy% zOhK(Hz5BY&^M8`_J+`)58w(v4p*YIgYCv>bAWX0JOph^kEZr>&eObMeVXouPeW0Dg zN|#C-^tB&%0&trFI=FJC(9rmTi@S7(e@2IJx()>;c+Ms?S%-6WDZ(3ms8`3^)XC3A&7JS#;mV5;O)3}`{QhM*eQLL3&+9LJf8BTa-89Y z7t8NdmVfSY>Fyn`3{^tqhkVCSaLkpi$_Rkf(oMEvDc476jUHfcNS}o?DLD3=t)7iK zs6uv$wZr|yPFFxp;9qfIA6SLm4%imZwg6W*<&*4I&#cphTgBn~+!SusNYvWFm{71U zibHWwY!aK#xX%G>^LzAQTm50W&9$B^y9@M+wSOLTCX89Qpr*{*DV~l+&x3R9ZZngGspY<4NyJ8V-s93DGgOH|W z>L6b$FzlpobzcT6{Vfb4Q0tHdVoUK?wB?_-UG*HEJMVfr0bpmu@qX&avLyXYMHk3R z8Grn7t-rXXbd7c3ywfn9PN!XSksz|JxBoQldwn^+{H?p=1~ev1TJq|hRXc4>@G&Ko-0KWHz}H&$g7QXE8EsrUwJ7qMJ( z`n*{OheG{7>QYmn&TmCXrL3j$0ff>>+~%|t@?!hH2SL+rNb|0yi*`*`>w%JA<$tpbQtL!NrsE}8L~sg0aT|3$=6U;yI?fVE^C9iZ*zG=R7kl?V zP)h>@6aWAK2mqUqr(BN3s-74U005Cp001kKuu~+H-GvT+8#j{QRi*#X2Yn!!)05Ma_eMChz`>$ij8d%}7YZ}@ z`@0`rLBW@QI0RjwvLM_LxXJ+U(4@km1-wDC_Mpb6Cl@zdjxsDzWvCpaoJMeiV6R0{ zU)zx17lF4EV%&J;a>-6l zet!MqPZzH*{@OXXRJ0Ce)dc!E3j!c{5IakY8O+y_<&DXn(PMbKEj+8eyaPkdAk%0; zX$L`nKA;tFwT8mz-!OF}y9yE_q+j@nLP?mn+tR#r;{ zi8m-|-6guDdYEzrbF4Kc8=sye?=Qal<@<~GiD#duaR!XB>3?K8b?X9wm~ei2GNn(D zTrFn|74uJ!XY`q)7>GFI;qGb%sh&J z)78^fYF1Cf>*9Pz>?FWiRY;C0$}uTHe^A*z*itwgPbWoyVKH?=D-hX?5g?f`)N}JV4P7?WlUh z&Zm>=_iDIBiD_8UihtrL{Jn$tN^6Ti?<-CLOm*S;c*A9hGiKTO+nh@|orLFI<8nrR z7gLAX`45n#P=_J4jQk_Jx?#nnwYxxp4GA_%+jwN+CUDkU3Og5>#OWjxHxbR?Y{Dwhg^Z9W zW(^-Inf>GKkFUoCIjDZ`13BJ*8%}2bo7JkG@CT&ar7P&;U^B5=5NUtv_flRiS46C_ z2>#jg-Vksc7q3BukI4jHN|9c}imObbsa-*}@2CE)=U<;&i^f^VxZ@-L0y9`hQP{ya zfXj4R=BV6wiS9xo{rcVd`>fxKzMwZ4^DMi#LFHZv>rf$KOptm*Sd=P%C|$onom&Yz zw&=cf&WL&Gu-|Jg-y1^DnR3I^12_a7>J7$Z!7t-3IfI_zs#kHP0yCMwTk;>;h^0`l zKw`VqHCt%QQs{2AA=X*o%I|X&+Ca(+3PU1G{M3H03R#Ggqd0T45O;oL5c1dyx4*>( zn0y6dskFfizM53{O((T~;!h{h_fIEr7xeKC{rpb|Z^m(zzD+fGL2rp{aC(yA0w``u zQ1Q9Mtj~*L@H}EI`O?A+cu^1^6XG5A2lRWN#W?G4G=>e~!ykh%$)GEZ3UkBA_CSpd z;cr}89JJ#}QD13s_Rf?T(Ijr!f~VNWyFzM%W(W%*aac>cLVP!W@M=tG22eyl4X@sg zQ-j=Lvhe#tzilY3H@>CNx4#v~4Pie(*`HErC%|58H{872jcI&Da~`($D&&h2kMd@5 zc3iPmej2-rCr_QY;jZQ^O*n&hD})gC_G}eAjtuf_4NLA2Qhh^lZmz8Zz3?%D;x}T+ zy^`K?;UWrWFfSc{oeF3dgQejqLV-r;3?w?+8YP-iky%9_R>7%u9Df)n3|g|(D*T6q zFxKq^jZ)HMr1OdQ8Y0KBm54?E$@p0$buCb4(MdO>^?t>)A#5>h@L-LiA8%gGaQitu z4Rl3w7#pN*sCrHcP6m*K$4M|Fo9OF49k7Pgv`WQ;so7G0lK0vM*>59&{sq@vPn!YL z*KG6p4Bq(pG@ud{sen*utyhCFyywEz$3lg74qn%4@KWamaVcwpj3){|)Nddn$S-iH zK45q~2@4VmQG%^fZ0A>H7>PlvOOs-<;6mDN=G_Xnif6yE;dCzTSLeGSL}@qi`VvlPoyZs5trBNA*%+6JK{{Sx_UXtA6#|Hg zDR7PJdr=>cCumb%;5}FykN<(#= zGq|LX$zV3DOe^?3E+v6Cx?HZd0ER)9=LikLLQn`kXjy}HD~kG}C`n1i|29L}=-0+I z`b18jJWewV-3cJ}rR(DuZiHLq=tR0Lw&7No)5J5#)JH8aq%ceRVwIN4Ib%LYV>EPg}5d zj+DwDZit+EuT)wc&6P8_xWlw`_|^v@-`cfGW9(`{l~RHg5|wMD93jLj-pXz*P#$mt z&HE3sHjmcW2UIJHmg+6emS#b_+&|t1kTgl4+p zYKbI+aJn0RK^ja4G@9Btvho`q_{9eF=CxMXF3I4G@s6CN6AA30sAa8Bj}c@l4H&XQ;R)JKlRZKwl^)qL zU5(sIsAZcq|6Zb5?@{QVCi8V-bYl~L<5DIOrw2&F!`elW;Lcb`jknlKC4gLK$gceM zOEXyKK&Ks-KSX|AwTCN#C{Ix#OPf$yoz4ahjrR(BTl6QAHS~#lUs54E}OR%c%NA?qMNRW|P&^$I-3`^%GU9Lz5g|Mj@@9vJTpm zPDq%KlncN+o>C~Q6?Qth7H)LuHqYl`S?bb$0XXjYgN>GLJYTzJ^K7%J*tK%}?vY5T z(k>PBhtabkU^<<)Z6`w60#mntM^=1ZZzWXkraH+3Od^8Vi&4+2*y?~m)LI?+-Z|3m zGRF62F)DF%GelvCVREH*;%E z3LMy))rs|=965E8OnIgjtE|xe0_?I2^$vq(->fH$@8>E_f;3=cbBI5kwhYGGADamf_K0A5D-@sVuXa97_Ep27fQn9#p=AByFo$ z2Z2L~ONW(xb#61UU8s+LOM1*t*(9WwLhAe9qMX|d+M&v$(eU(suF8PgMh!G_ zZm!Yv1`UlHJg%Jw6{@LBay#3sf6(Nm+~?2v7JCnw{=J6BcA3#-T#s#3^K!93Lk{Rh z+@OtBxp0n7O59Daz3FQy3ft@r$FxGlC3KJrcZ^nYJI3Y=yUiGk?X}Ly{kXik?8Ho;28WilO7Af7Nd`CZ=Y!Np<~IeL=Fa1Z88g0 zCtkGy>8fLo>in`K0~mTwLCwWn%3({JitkYIwq&Ut3Kw#J9cf3Nt^&NzU3dF*yNun2 zZOR%bXl*s@4?g^{YpAV0<}Z)nr9KOp4W)m4I$J$e)VU+#s?tbuQJ20!^pcf^;#=>9 ze8*^;C%S4s$#sg=n|NuY2TPyfPdhm5i-M674sJ+Nmz08eeeCJ+Vsvf?nCk=?7Vl7j&yh|GjZ;Ub)H*b$ zDnLdsExENoL1?w8@qfHNL#5To1HV7D)=okv%AYS8*zAw|NUS^3feRt`8#m}-LVdM~ zzmAmnYdMEH3xD+^euY|Z8{M+F@YjtBqYE@{4ZgkH`j$X{d#`xZ5^b10Uo$R-FF=D5F$N)gLXo`1V`LTNekxNWsVqX9X#6AWe)#_ z_WmTq>Ib~tyOw()!rIbbF%Kc1R{rANi|TGweex4q{gVcl++>pOEa*k;`5;{U8h=7S z|Ewi_T}SJODvcpj+-l9*ALRQ9zD?6IFC{fTx0EFx_Z)HWWweI`=y*pP2i*kpqWbxN z%`3lAMM=dd94w+b6pFWMlP4@$aY_;VUJB#y8lcP`TADaowDDBG$H)5;;E~p520U)4 zO$Qm|G}{M%`J^EzV!5Z1nBHe1Bg3RCVf2}!TWSA<#6JYO7;D@+k>d~hjw_112Ikjq z+RNXOXJ{;4C?l+|aUG5Uba$U5>Rpe2?}+w}!G?b5(Cts}h1x$D_(ejH9jj+?m1Z;W zp*g{5zth?JK>Ki0ORDM_HSYM*OyMJ~ZXNN+Q}^u0s!0tXDRdERmhMXK*Ru}uf*85Y z4-ee5@#BbzTdtr+@yPsUE!1tY_UzraN6~#iyV9l1sPEY;h}VoANzD8$&o*m+-!M_A z4j~*2!lFoqdntPiwc*P2aV*Yiw{l?n>n)TWFJW7o*tU3S4%_yA(u! zM{4Gacu%%Ye$~yiUwv9Q`eC2xA`0nC0-_TpqwBw4p`vf&5o-Cml(M>pzqe*Bdk_17 zC$3XE#KbpM@N#uXhL+zTwfEL{>cWZC-wNE#>C>~OApP9>hT_h5+|&_&M4_6@JS3~* zQ^*nP*%k&nV}e#Do)^KF?FE;#x}-)=z( zJ-U=mp4NZo#TMj!#A(cBh&}?+G@Eq{^IvZgRk~YWl%_3)@*t_s=R*1V0wGUfGRA6w3wjkdG?;I+Bp(z*EqxP8e>6i-g&!P?%E`MC@6aWAK2mlXtwzD^wh64i+b+(hho0bC)b+(gP zoOTfpb+%kBiHL^`0000A0000dlQEqgll`0{2oH6(TuH!2f0HPkFn^;)6a~<|zXeq% zGc3Gdl#oDZKn&VsFq6z;MqlsfMcY)^uC7f+uFj3fcX@q zUMcI+560mR_xV=%Afe}p1 z%|oY`=1_ZQeZ5XvHuAmE&W?IbX@>zAiG!`##cc`uco^G_hl(mW>wD@Tg~}?oxvmf- zm^9rf8DpsCU=}U05R$-QQjDUh?Nqupgkg!|df0gQZuQyswQZ!_RV~;$^Gzy5T8jX0 zTudpLU^EobrGF3cD@O7Bb}mEHlyF$vxZikp^;C+hWJUVhHq^B?Dz?$XP+uKb+e#H2 zfN*+e)*fUj#!~EdCa50bwDSkl)NqLVq5rQ=_HLIe31!%*KDjc8B1Tn@VZp-^30~(t zZgeJRXKG&=0Ap#ue(C?5*3a92+k0i+SGZRKIxMJ);xBqP~J4Qm=;s+QNhP(lxl$d)32moZ!FY zf1c29x9uQZeQ9r|e>EpM(aRT);|<{L{S9R!7@xBrIwyDio_akn~? zPo91p6_p>ff6|LJ*JNMHRmIjH(BQkU-{0F^q&7QqLRV-I z1XQRu`OPvO1@IDk*a~|E;E@U_CKiWJc8t`T?6h2q>0BI;sXlq?awDd>nPBFJ 0 - assert result["destination"] == dest + # Tool responses emit resource keys in canonical "root:path" form. + assert result["destination"] == f"project:{dest}" info = file.get_info(dest) assert info["type"] == "file" diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_webview.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_webview.py index 578c52637..394ffde18 100644 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_webview.py +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_webview.py @@ -464,7 +464,8 @@ def test_screenshot_save_to_resource_writes_file(self, webview, file): format="png", ) assert result["format"] == "png" - assert result["resource"] == save_resource + # Tool responses emit resource keys in canonical "root:path" form. + assert result["resource"] == f"project:{save_resource}" assert not result["imageReturned"] info = file.get_info(save_resource) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 4de28bafe..92fe097d1 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -556,6 +556,22 @@ private async Task RewriteReferencesForMoveAsync( continue; } + // Honor the DOS read-only attribute as a "do not modify" hint + // BEFORE attempting the write. The atomic temp+rename path would + // surface this as a write failure on Windows (MoveFileEx checks + // the target's read-only bit) but silently succeed on Linux + // (rename only checks write permission on the parent directory, + // not on the target). Pre-checking closes that cross-platform + // gap so the user's "don't touch this file" intent is honored + // identically on every platform. + if (IsReferencerReadOnly(referencer)) + { + const string readOnlyMessage = "file is read-only"; + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {readOnlyMessage}. The reference is left as-is and will surface via data_check_project."); + skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadOnly, readOnlyMessage)); + continue; + } + var writeResult = await WriteAllTextAsync(referencer, rewritten); if (writeResult.IsFailure) { @@ -576,6 +592,26 @@ private async Task RewriteReferencesForMoveAsync( return Result.Ok(); } + private bool IsReferencerReadOnly(ResourceKey referencer) + { + var resolveResult = ResolvePath(referencer); + if (resolveResult.IsFailure) + { + return false; + } + + try + { + var info = new FileInfo(resolveResult.Value); + return info.Exists + && info.IsReadOnly; + } + catch + { + return false; + } + } + private (ReferencerSkipReason Reason, string Message) ClassifyReferencerWriteFailure(ResourceKey referencer, Result writeResult) { var resolveResult = ResolvePath(referencer); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index 0f65d6cb1..4259dd05e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -171,7 +171,82 @@ public Result ResolveResourcePath(ResourceKey resource) return Result.Fail( $"Resource root '{resource.Root}' is not registered."); } - return handler.Resolve(resource); + + var resolveResult = handler.Resolve(resource); + if (resolveResult.IsFailure) + { + return resolveResult; + } + var absolutePath = resolveResult.Value; + + // Strict case enforcement for the project root: if the resolved path + // exists on disk, the supplied key must match disk-canonical case. + // Without this guard a Windows user can resolve a wrong-case key + // (Windows IO is case-insensitive) but the in-memory tree and cascade + // scanner (both Ordinal-case-sensitive) would treat it as a separate + // resource, leaving the project in an inconsistent state. + if (resource.Root == ResourceKey.DefaultRoot) + { + var caseCheck = EnsureProjectKeyCaseMatchesDisk(resource, absolutePath); + if (caseCheck.IsFailure) + { + return Result.Fail(caseCheck.FirstErrorMessage); + } + } + + return absolutePath; + } + + // Cheap path: if the registry tree already has a node for this exact key, + // the case is canonical by construction (the tree was built from disk- + // preserved names). Only when the tree lookup misses do we go to disk to + // disambiguate "case is wrong" from "resource is new and being created". + private Result EnsureProjectKeyCaseMatchesDisk(ResourceKey resource, string absolutePath) + { + if (resource.IsEmpty) + { + return Result.Ok(); + } + + var treeLookup = GetResource(resource); + if (treeLookup.IsSuccess) + { + return Result.Ok(); + } + + // Tree miss. Either the resource is new (not on disk yet — pass + // through for create flows) or the case is wrong. Check disk to find + // out which. + if (!File.Exists(absolutePath) + && !Directory.Exists(absolutePath)) + { + return Result.Ok(); + } + + var realPathResult = GetRealPath(absolutePath); + if (realPathResult.IsFailure) + { + // Couldn't determine disk-canonical case for some reason; don't + // block the operation on the diagnostic. + return Result.Ok(); + } + + if (string.Equals(realPathResult.Value, absolutePath, StringComparison.Ordinal)) + { + // Disk case already matches the supplied case — the tree miss is + // a registry-rebuild lag, not a case mismatch. + return Result.Ok(); + } + + var canonicalKeyResult = GetResourceKey(realPathResult.Value); + if (canonicalKeyResult.IsFailure) + { + return Result.Fail( + $"Resource key '{resource}' does not match the on-disk case."); + } + + return Result.Fail( + $"Resource key '{resource}' does not match the on-disk case. Canonical form is '{canonicalKeyResult.Value}'."); } public Result GetResource(ResourceKey resource) From 798485321eabe8288b12f43b665377db1af4ea0e Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Sat, 23 May 2026 16:34:49 +0100 Subject: [PATCH 21/48] Support exact filenames and multi-part extensions Add support for exact filename matches and multi-part extension suffixes for document editors. Introduce SupportedFilenames on IDocumentEditorFactory and DocumentEditorFactoryBase, update CanHandleResource to prefer filename matches and to check multi-part extensions. Extend DocumentEditorRegistry to index filename-to-factories (case-insensitive), handle null collection properties, try filename matches before extension matches, and walk longest-to-shortest extension suffixes. Add CelFileClassification and CelFileClassifier to classify .cel sidecars (Standalone, Sidecar, Orphan) using the resource and editor registries. Persist per-file editor choice via sidecar when using "Open With" and have DocumentsService consult a sidecar's editor preference before falling back to normal resolution. Add ProjectFileFactory and ModManifestFactory and register them in the module (plus project references). Add resource strings and update docs. Include extensive unit tests for multi-part resolution and .cel classification. --- .../Resources/Strings/en-US/Resources.resw | 6 + .../Documents/DocumentEditorFactoryBase.cs | 24 ++- .../Documents/IDocumentEditorFactory.cs | 12 +- .../Resources/CelFileClassification.cs | 123 ++++++++++++ .../Guides/Concepts/resource_keys.md | 8 +- .../Celbridge.Core/Celbridge.Core.csproj | 2 + Source/Modules/Celbridge.Core/Module.cs | 10 +- .../MultiPartExtensionResolutionTests.cs | 182 ++++++++++++++++++ .../Tests/Resources/CelFileClassifierTests.cs | 132 +++++++++++++ .../Services/DocumentEditorRegistry.cs | 94 ++++++++- .../Services/DocumentsService.cs | 78 ++++++++ .../Menu/Options/OpenWithMenuOption.cs | 13 ++ .../Celbridge.Packages/ModManifestFactory.cs | 41 ++++ .../Celbridge.Resources/ProjectFileFactory.cs | 36 ++++ 14 files changed, 744 insertions(+), 17 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs create mode 100644 Source/Tests/Documents/MultiPartExtensionResolutionTests.cs create mode 100644 Source/Tests/Resources/CelFileClassifierTests.cs create mode 100644 Source/Workspace/Celbridge.Packages/ModManifestFactory.cs create mode 100644 Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs diff --git a/Source/Celbridge/Resources/Strings/en-US/Resources.resw b/Source/Celbridge/Resources/Strings/en-US/Resources.resw index 849f4628a..f064be081 100644 --- a/Source/Celbridge/Resources/Strings/en-US/Resources.resw +++ b/Source/Celbridge/Resources/Strings/en-US/Resources.resw @@ -968,6 +968,12 @@ Do you wish to continue? HTML Viewer + + Project File + + + Mod Manifest + Open external link diff --git a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs index 118649aa0..8874226b3 100644 --- a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs +++ b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs @@ -13,12 +13,32 @@ public abstract class DocumentEditorFactoryBase : IDocumentEditorFactory public abstract IReadOnlyList SupportedExtensions { get; } + public virtual IReadOnlyList SupportedFilenames { get; } = Array.Empty(); + public virtual EditorPriority Priority => EditorPriority.Specialized; public virtual bool CanHandleResource(ResourceKey fileResource, string filePath) { - var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); - return SupportedExtensions.Contains(extension); + var fileName = Path.GetFileName(fileResource.ToString()); + + foreach (var supportedFilename in SupportedFilenames) + { + if (string.Equals(fileName, supportedFilename, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + var lowerFileName = fileName.ToLowerInvariant(); + foreach (var supportedExtension in SupportedExtensions) + { + if (lowerFileName.EndsWith(supportedExtension, StringComparison.Ordinal)) + { + return true; + } + } + + return false; } public abstract Result CreateDocumentView(ResourceKey fileResource); diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs index 449b8970c..a1736481f 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs @@ -34,10 +34,20 @@ public interface IDocumentEditorFactory /// /// The file extensions this factory handles (e.g., ".md", ".txt", ".cs"). - /// Extensions should be lowercase with leading dot. + /// Extensions should be lowercase with leading dot. Multi-part forms such as + /// ".project.cel" are also accepted; the registry resolves longest match + /// first when a file's name matches more than one registered suffix. /// IReadOnlyList SupportedExtensions { get; } + /// + /// Exact file names this factory handles (e.g., "package.toml"). Filename + /// matches are tried before extension matches. Names are compared + /// case-insensitively. Defaults to an empty list when the factory matches + /// purely by extension. + /// + IReadOnlyList SupportedFilenames { get; } + /// /// Priority for conflict resolution when multiple factories support the same extension. /// Specialized editors take precedence over general-purpose editors. diff --git a/Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs b/Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs new file mode 100644 index 000000000..ff5f55ccc --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs @@ -0,0 +1,123 @@ +using Celbridge.Documents; + +namespace Celbridge.Resources; + +/// +/// How a .cel file is classified relative to other resources and the +/// registered document editor factories. +/// +public enum CelFileClassification +{ + /// + /// The .cel file is a standalone form recognised by a registered factory + /// (e.g. .project.cel, .mod.cel). It owns its own content and is not + /// paired with a parent file. + /// + Standalone, + + /// + /// The .cel file pairs with a sibling parent file in the same folder. + /// Parent existence wins: a paired sidecar is classified as Sidecar even + /// when its multi-part extension also matches a registered factory. + /// + Sidecar, + + /// + /// The .cel file has no sibling parent and no factory claims its + /// multi-part extension. Surfaced by project-health checks for the user + /// to repair. + /// + Orphan, +} + +/// +/// Classifies a .cel file as Standalone, Sidecar, or Orphan by consulting the +/// resource registry (for parent existence) and the document editor registry +/// (for registered multi-part extensions). The single helper keeps the two +/// dimensions of "is this a sidecar?" and "is this a standalone form?" in one +/// place so callers do not reinvent the precedence rule. +/// +public static class CelFileClassifier +{ + private const string CelExtension = ".cel"; + + /// + /// Classify a .cel resource. Parent existence wins: if a sibling parent + /// file exists in the same folder, the result is Sidecar regardless of + /// whether the multi-part extension is also registered. Otherwise the + /// multi-part extension lookup decides between Standalone and Orphan. + /// + public static CelFileClassification Classify( + ResourceKey key, + IResourceRegistry resources, + IDocumentEditorRegistry editors) + { + Guard.IsNotNull(resources); + Guard.IsNotNull(editors); + + var path = key.Path; + if (string.IsNullOrEmpty(path) + || !path.EndsWith(CelExtension, StringComparison.OrdinalIgnoreCase)) + { + return CelFileClassification.Orphan; + } + + // Parent existence wins: if removing the .cel suffix names a sibling + // file that exists in the registry, the .cel file is the sidecar for + // that parent. This holds even when the resulting multi-part extension + // also matches a registered factory. + var parentPath = path.Substring(0, path.Length - CelExtension.Length); + if (!string.IsNullOrEmpty(parentPath)) + { + var parentKey = new ResourceKey(key.Root + ":" + parentPath); + var parentResult = resources.GetResource(parentKey); + if (parentResult.IsSuccess + && parentResult.Value is IFileResource) + { + return CelFileClassification.Sidecar; + } + } + + // Compute the multi-part extension - the suffix from the last interior + // dot in the file name. For meeting.note.cel this is ".note.cel"; for + // foo.cel it is just ".cel". + var multiPartExtension = GetMultiPartExtension(key); + if (!string.IsNullOrEmpty(multiPartExtension)) + { + var factories = editors.GetFactoriesForFileExtension(multiPartExtension); + if (factories.Count > 0) + { + return CelFileClassification.Standalone; + } + } + + return CelFileClassification.Orphan; + } + + private static string GetMultiPartExtension(ResourceKey key) + { + var fileName = key.ResourceName; + if (string.IsNullOrEmpty(fileName)) + { + return string.Empty; + } + + // Skip a leading '.' so dotfiles like ".cel" still expose ".cel" rather + // than the whole name. + int searchFrom = fileName.Length > 0 && fileName[0] == '.' ? 1 : 0; + + var trimmed = fileName.Substring(searchFrom); + var firstDot = trimmed.IndexOf('.'); + if (firstDot < 0) + { + return string.Empty; + } + + // The interior segment may itself contain multiple dots (e.g. + // foo.bar.baz.cel produces ".bar.baz.cel"). Resolution picks the + // longest interior suffix that names a real .cel form; the registry's + // longest-match walk handles the rest. + var multiPart = trimmed.Substring(firstDot).ToLowerInvariant(); + return multiPart; + } +} diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index 9c1f20666..a401d6516 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -17,18 +17,18 @@ A resource key has the optional `root:path` form. When no root prefix is given, ## Roots -- `project:` — the visible project tree. The default root; the prefix is optional in input and omitted in output. Use for all user content. +- `project:` — the visible project tree. The default root; the prefix is optional in input but always present in output. Use for all user content. - `temp:` — host scratch space (`.celbridge/temp/`). Hidden from the resource tree. Used by host tools, scripts, and agents for transient artifacts and staging output. Contents are not version-controlled. Conventional sub-folders include `temp:staging/...`, `temp:scratch/...`, and `temp:cache/...`. - `logs:` — host diagnostic logs (`.celbridge/logs/`). Hidden from the resource tree. Used by the host engine, Python scripts, agents, and Console panel session loggers. ## Output canonical form -When a tool reports a resource key in its result or in an error message, it uses the canonical form: +When a tool reports a resource key in its result or in an error message, it always carries the explicit root prefix: -- `project:` keys are reported as bare paths (e.g. `Scripts/hello.py`), never with the explicit `project:` prefix. +- `project:` keys are reported as `project:Scripts/hello.py`, never bare `Scripts/hello.py`. - Non-`project:` keys are reported with their full root prefix (e.g. `temp:staging/pkg/file.txt`). -So `file_read` against a missing `temp:foo/bar` reports `temp:foo/bar` in the error, never bare `foo/bar`. +This form matches the literal that the reference scanner detects in file content, so a key copied from a tool response can be pasted straight into a quoted reference without forgetting the prefix. ## Rules diff --git a/Source/Modules/Celbridge.Core/Celbridge.Core.csproj b/Source/Modules/Celbridge.Core/Celbridge.Core.csproj index f228c7dc4..9eafd96cb 100644 --- a/Source/Modules/Celbridge.Core/Celbridge.Core.csproj +++ b/Source/Modules/Celbridge.Core/Celbridge.Core.csproj @@ -16,6 +16,8 @@ + + diff --git a/Source/Modules/Celbridge.Core/Module.cs b/Source/Modules/Celbridge.Core/Module.cs index 9b5991e13..2fe6565c1 100644 --- a/Source/Modules/Celbridge.Core/Module.cs +++ b/Source/Modules/Celbridge.Core/Module.cs @@ -3,6 +3,9 @@ using Celbridge.Documents; using Celbridge.Modules; using Celbridge.Packages; +using Celbridge.Resources; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; namespace Celbridge.Core; @@ -26,7 +29,12 @@ public Result Initialize() public IReadOnlyList CreateDocumentEditorFactories(IServiceProvider serviceProvider) { - return []; + var stringLocalizer = serviceProvider.GetRequiredService(); + return + [ + new ProjectFileFactory(stringLocalizer), + new ModManifestFactory(stringLocalizer), + ]; } public Result CreateActivity(string activityName) diff --git a/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs new file mode 100644 index 000000000..bf2fde444 --- /dev/null +++ b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs @@ -0,0 +1,182 @@ +namespace Celbridge.Tests.Documents; + +[TestFixture] +public class MultiPartExtensionResolutionTests +{ + [Test] + public void GetFactory_PrefersMultiPartExtensionOverSingleCelFallback() + { + var registry = new DocumentEditorRegistry(); + + var projectCelFactory = CreateMockFactory("test.project-cel", ".project.cel", EditorPriority.Specialized); + var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); + + registry.RegisterFactory(projectCelFactory); + registry.RegisterFactory(celFactory); + + var fileResource = new ResourceKey("foo.project.cel"); + var result = registry.GetFactory(fileResource, "/path/foo.project.cel"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(projectCelFactory); + } + + [Test] + public void GetFactory_FallsBackToSingleCelWhenNoMultiPartFactoryRegistered() + { + var registry = new DocumentEditorRegistry(); + + var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); + registry.RegisterFactory(celFactory); + + var fileResource = new ResourceKey("foo.cel"); + var result = registry.GetFactory(fileResource, "/path/foo.cel"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(celFactory); + } + + [Test] + public void GetFactory_MultiPartWinsEvenWhenSingleCelIsAlsoRegistered() + { + var registry = new DocumentEditorRegistry(); + + // Both extensions present and both can handle the resource. Longest match + // wins extension selection independently of the priority bands. + var projectCelFactory = CreateMockFactory("test.project-cel", ".project.cel", EditorPriority.Specialized); + var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); + + registry.RegisterFactory(projectCelFactory); + registry.RegisterFactory(celFactory); + + var fileResource = new ResourceKey("foo.project.cel"); + var result = registry.GetFactory(fileResource, "/path/foo.project.cel"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(projectCelFactory); + } + + [Test] + public void GetFactory_FactoryRegisteringMultipleMultiPartExtensionsMatchesBoth() + { + var registry = new DocumentEditorRegistry(); + + var multiFactory = CreateMockFactoryWithExtensions("test.multi-cel", new[] { ".project.cel", ".mod.cel" }); + registry.RegisterFactory(multiFactory); + + var projectResult = registry.GetFactory(new ResourceKey("foo.project.cel"), "/path/foo.project.cel"); + var modResult = registry.GetFactory(new ResourceKey("bar.mod.cel"), "/path/bar.mod.cel"); + + projectResult.IsSuccess.Should().BeTrue(); + projectResult.Value.Should().Be(multiFactory); + modResult.IsSuccess.Should().BeTrue(); + modResult.Value.Should().Be(multiFactory); + } + + [Test] + public void GetFactory_SpecializedStillBeatsGeneralOnSameMultiPartExtension() + { + var registry = new DocumentEditorRegistry(); + + var specialized = CreateMockFactory("test.special-cel", ".project.cel", EditorPriority.Specialized); + var general = CreateMockFactory("test.general-cel", ".project.cel", EditorPriority.General); + + registry.RegisterFactory(general); + registry.RegisterFactory(specialized); + + var result = registry.GetFactory(new ResourceKey("foo.project.cel"), "/path/foo.project.cel"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(specialized); + } + + [Test] + public void GetFactory_MatchesByExactFilenameBeforeExtension() + { + var registry = new DocumentEditorRegistry(); + + var packageTomlFactory = CreateMockFactoryWithFilenames("test.package-toml", new[] { "package.toml" }); + var tomlFactory = CreateMockFactory("test.toml-fallback", ".toml", EditorPriority.General); + + registry.RegisterFactory(packageTomlFactory); + registry.RegisterFactory(tomlFactory); + + var packageResult = registry.GetFactory(new ResourceKey("package.toml"), "/path/package.toml"); + var otherTomlResult = registry.GetFactory(new ResourceKey("other.toml"), "/path/other.toml"); + + packageResult.IsSuccess.Should().BeTrue(); + packageResult.Value.Should().Be(packageTomlFactory); + + otherTomlResult.IsSuccess.Should().BeTrue(); + otherTomlResult.Value.Should().Be(tomlFactory); + } + + [Test] + public void RegisterFactory_AllowsFilenameOnlyFactory() + { + var registry = new DocumentEditorRegistry(); + + var factory = CreateMockFactoryWithFilenames("test.filename-only", new[] { "package.toml" }); + + var result = registry.RegisterFactory(factory); + + result.IsSuccess.Should().BeTrue(); + } + + [Test] + public void RegisterFactory_RejectsFactoryWithNeitherExtensionNorFilename() + { + var registry = new DocumentEditorRegistry(); + + var factory = Substitute.For(); + factory.EditorId.Returns(new DocumentEditorId("test.empty-both")); + factory.DisplayName.Returns("Empty"); + factory.SupportedExtensions.Returns(new List()); + factory.SupportedFilenames.Returns(new List()); + + var result = registry.RegisterFactory(factory); + + result.IsFailure.Should().BeTrue(); + } + + private static IDocumentEditorFactory CreateMockFactory( + string documentEditorId, + string extension, + EditorPriority priority = EditorPriority.Specialized, + bool canHandle = true) + { + return CreateMockFactoryWithExtensions(documentEditorId, new[] { extension }, priority, canHandle); + } + + private static IDocumentEditorFactory CreateMockFactoryWithExtensions( + string documentEditorId, + IReadOnlyList extensions, + EditorPriority priority = EditorPriority.Specialized, + bool canHandle = true) + { + var factory = Substitute.For(); + factory.EditorId.Returns(new DocumentEditorId(documentEditorId)); + factory.DisplayName.Returns(documentEditorId); + factory.SupportedExtensions.Returns(extensions); + factory.SupportedFilenames.Returns(Array.Empty()); + factory.Priority.Returns(priority); + factory.CanHandleResource(Arg.Any(), Arg.Any()).Returns(canHandle); + return factory; + } + + private static IDocumentEditorFactory CreateMockFactoryWithFilenames( + string documentEditorId, + IReadOnlyList filenames, + EditorPriority priority = EditorPriority.Specialized, + bool canHandle = true) + { + var factory = Substitute.For(); + factory.EditorId.Returns(new DocumentEditorId(documentEditorId)); + factory.DisplayName.Returns(documentEditorId); + factory.SupportedExtensions.Returns(Array.Empty()); + factory.SupportedFilenames.Returns(filenames); + factory.Priority.Returns(priority); + factory.CanHandleResource(Arg.Any(), Arg.Any()).Returns(canHandle); + return factory; + } +} diff --git a/Source/Tests/Resources/CelFileClassifierTests.cs b/Source/Tests/Resources/CelFileClassifierTests.cs new file mode 100644 index 000000000..34ed3cd78 --- /dev/null +++ b/Source/Tests/Resources/CelFileClassifierTests.cs @@ -0,0 +1,132 @@ +using Celbridge.Documents; +using Celbridge.Resources; + +namespace Celbridge.Tests.Resources; + +[TestFixture] +public class CelFileClassifierTests +{ + private IResourceRegistry _resources = null!; + private IDocumentEditorRegistry _editors = null!; + + [SetUp] + public void Setup() + { + _resources = Substitute.For(); + _editors = Substitute.For(); + + // Default: no parent files exist, no editor factories registered. Tests + // override the relevant calls to set up their scenarios. + _resources + .GetResource(Arg.Any()) + .Returns(Result.Fail("not found")); + _editors + .GetFactoriesForFileExtension(Arg.Any()) + .Returns(Array.Empty()); + } + + [Test] + public void Classify_StandaloneWhenMultiPartExtensionRegisteredAndNoParent() + { + RegisterExtension(".project.cel"); + + var result = CelFileClassifier.Classify( + new ResourceKey("foo.project.cel"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Standalone); + } + + [Test] + public void Classify_SidecarWhenParentExistsEvenIfMultiPartExtensionRegistered() + { + RegisterExtension(".project.cel"); + ExistingParentFile("foo.project"); + + var result = CelFileClassifier.Classify( + new ResourceKey("foo.project.cel"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Sidecar); + } + + [Test] + public void Classify_SidecarWhenParentExistsAndNoExtensionRegistered() + { + ExistingParentFile("foo.png"); + + var result = CelFileClassifier.Classify( + new ResourceKey("foo.png.cel"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Sidecar); + } + + [Test] + public void Classify_OrphanWhenNoParentAndNoExtensionRegistered() + { + var result = CelFileClassifier.Classify( + new ResourceKey("foo.png.cel"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Orphan); + } + + [Test] + public void Classify_StandaloneForNestedResourceWithRegisteredExtension() + { + RegisterExtension(".note.cel"); + + var result = CelFileClassifier.Classify( + new ResourceKey("notes/meeting.note.cel"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Standalone); + } + + [Test] + public void Classify_OrphanForBareCelWhenCelNotRegistered() + { + var result = CelFileClassifier.Classify( + new ResourceKey("foo.cel"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Orphan); + } + + [Test] + public void Classify_OrphanForKeyNotEndingInCel() + { + // Defensive: the classifier is only meaningful for .cel-shaped keys. + // A non-.cel key is reported as Orphan rather than raising. + var result = CelFileClassifier.Classify( + new ResourceKey("foo.png"), + _resources, + _editors); + + result.Should().Be(CelFileClassification.Orphan); + } + + private void RegisterExtension(string extension) + { + var factory = Substitute.For(); + _editors + .GetFactoriesForFileExtension(extension) + .Returns(new[] { factory }); + } + + private void ExistingParentFile(string path) + { + var parentKey = new ResourceKey($"project:{path}"); + var fileResource = Substitute.For(); + _resources + .GetResource(parentKey) + .Returns(Result.Ok(fileResource)); + } +} diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs index f58153a17..ee49e9a58 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs @@ -9,6 +9,7 @@ public class DocumentEditorRegistry : IDocumentEditorRegistry, IDisposable private bool _disposed; private readonly List _factories = new(); private readonly Dictionary> _extensionToFactories = new(); + private readonly Dictionary> _filenameToFactories = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _registeredEditorIds = new(); private readonly Dictionary _idToFactory = new(); @@ -19,9 +20,17 @@ public Result RegisterFactory(IDocumentEditorFactory factory) { Guard.IsNotNull(factory); - if (factory.SupportedExtensions.Count == 0) + // NSubstitute stubs return null for collection properties that are + // never explicitly configured, so treat null as empty here rather than + // pushing the burden to every test setup. + var supportedExtensions = factory.SupportedExtensions ?? Array.Empty(); + var supportedFilenames = factory.SupportedFilenames ?? Array.Empty(); + + var supportsAnything = supportedExtensions.Count > 0 + || supportedFilenames.Count > 0; + if (!supportsAnything) { - return Result.Fail("Factory must support at least one extension"); + return Result.Fail("Factory must support at least one extension or filename"); } if (!_registeredEditorIds.Add(factory.EditorId)) @@ -35,8 +44,10 @@ public Result RegisterFactory(IDocumentEditorFactory factory) _idToFactory[factory.EditorId] = factory; _factories.Add(factory); - // Index the factory by each supported extension - foreach (var extension in factory.SupportedExtensions) + // Index the factory by each supported extension. + // Multi-part extensions such as ".project.cel" are indexed as-is; the + // longest-suffix walk in GetFactory tries the most specific form first. + foreach (var extension in supportedExtensions) { var normalizedExtension = extension.ToLowerInvariant(); @@ -52,6 +63,20 @@ public Result RegisterFactory(IDocumentEditorFactory factory) factoryList.Sort((a, b) => a.Priority.CompareTo(b.Priority)); } + // Index the factory by each supported exact filename. Filename matches + // are tried before any extension match in GetFactory. + foreach (var filename in supportedFilenames) + { + if (!_filenameToFactories.TryGetValue(filename, out var factoryList)) + { + factoryList = new List(); + _filenameToFactories[filename] = factoryList; + } + + factoryList.Add(factory); + factoryList.Sort((a, b) => a.Priority.CompareTo(b.Priority)); + } + return Result.Ok(); } @@ -61,13 +86,17 @@ public Result RegisterFactory(IDocumentEditorFactory factory) /// public Result GetFactory(ResourceKey fileResource, string filePath) { - var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); + // Use ResourceKey.ResourceName directly rather than Path.GetFileName on + // the key's string form. Path.GetFileName treats the "project:" prefix + // inconsistently across platforms (volume separator on Windows), so it + // would split "project:package.toml" differently from a real path. + var fileName = fileResource.ResourceName; - // Check factories registered for this specific extension - if (_extensionToFactories.TryGetValue(extension, out var factoryList)) + // 1. Try exact-filename match first. A factory that claims "package.toml" + // by filename wins over a generic ".toml" extension factory. + if (_filenameToFactories.TryGetValue(fileName, out var byFilename)) { - // Find the first factory (sorted by priority) that can handle the resource - foreach (var factory in factoryList) + foreach (var factory in byFilename) { if (factory.CanHandleResource(fileResource, filePath)) { @@ -76,6 +105,23 @@ public Result GetFactory(ResourceKey fileResource, strin } } + // 2. Try multi-part extension suffixes from longest to shortest, so a + // ".project.cel" factory beats a generic ".cel" factory on the same file. + var lowerFileName = fileName.ToLowerInvariant(); + foreach (var suffix in GetExtensionSuffixes(lowerFileName)) + { + if (_extensionToFactories.TryGetValue(suffix, out var factoryList)) + { + foreach (var factory in factoryList) + { + if (factory.CanHandleResource(fileResource, filePath)) + { + return Result.Ok(factory); + } + } + } + } + return Result.Fail($"No registered factory can handle resource: '{fileResource}'"); } @@ -158,6 +204,35 @@ public IReadOnlyList GetAllSupportedExtensions() return _extensionToFactories.Keys.ToList().AsReadOnly(); } + // Yields the extension suffixes of a filename from longest to shortest. + // "foo.project.cel" produces ".project.cel" then ".cel"; "foo.md" produces + // ".md"; "Makefile" produces nothing. A leading dot (".gitignore") is + // skipped so the file's full name is not treated as an extension. + private static IEnumerable GetExtensionSuffixes(string fileName) + { + int searchFrom = 0; + + // Skip a leading '.' on dotfiles so the first yielded suffix is anchored + // on an interior dot rather than the leading one. + if (fileName.Length > 0 + && fileName[0] == '.') + { + searchFrom = 1; + } + + while (searchFrom < fileName.Length) + { + int dotIndex = fileName.IndexOf('.', searchFrom); + if (dotIndex < 0) + { + yield break; + } + + yield return fileName.Substring(dotIndex); + searchFrom = dotIndex + 1; + } + } + public void Dispose() { if (_disposed) @@ -178,6 +253,7 @@ public void Dispose() _factories.Clear(); _extensionToFactories.Clear(); + _filenameToFactories.Clear(); _registeredEditorIds.Clear(); _idToFactory.Clear(); } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index 400992911..f8f6c5ba9 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -4,6 +4,7 @@ using Celbridge.Messaging; using Celbridge.Modules; using Celbridge.Packages; +using Celbridge.Resources; using Celbridge.Settings; using Celbridge.Workspace; @@ -726,6 +727,38 @@ private async Task> CreateDocumentViewInternalAsync(Resour } var filePath = resolveResult.Value; + // Step 0: consult the file's sidecar for a per-file editor preference. + // The sidecar's `editor` field records the user's last explicit + // "Open With X" choice for this file and wins over the per-extension + // workspace preference and the priority-based fallback. A stale or + // unregistered id falls through to the existing chain rather than + // failing the open. + if (documentEditorId.IsEmpty) + { + var sidecarEditorResult = await TryReadEditorFromSidecarAsync(fileResource); + if (sidecarEditorResult.IsSuccess + && !sidecarEditorResult.Value.IsEmpty) + { + var sidecarEditorId = sidecarEditorResult.Value; + var sidecarFactoryResult = _documentEditorRegistry.GetFactoryById(sidecarEditorId); + if (sidecarFactoryResult.IsSuccess) + { + var sidecarFactory = sidecarFactoryResult.Value; + if (sidecarFactory.CanHandleResource(fileResource, filePath)) + { + var sidecarCreateResult = sidecarFactory.CreateDocumentView(fileResource); + if (sidecarCreateResult.IsSuccess) + { + return sidecarCreateResult; + } + + _logger.LogWarning(sidecarCreateResult, + $"Sidecar editor '{sidecarEditorId}' failed to create view for '{fileResource}'; falling through"); + } + } + } + } + // If a specific editor was requested, use it directly. Do not fall through to priority-based // resolution on failure: silently opening a different editor than the one the caller asked for // is confusing and hides real problems (e.g., "Open With..." handing a file to a factory that @@ -827,6 +860,51 @@ private async Task> CreateDocumentViewInternalAsync(Resour return Result.Fail($"Failed to create document view for file: '{fileResource}'"); } + // Read the file's sidecar (if any) and return the parsed `editor` field as + // a DocumentEditorId. Returns success with Empty when no sidecar exists, + // the sidecar has no `editor` field, or the field value does not parse as + // a DocumentEditorId. Returns failure only on unexpected sidecar service + // errors (so the caller can fall through gracefully on the success path). + private async Task> TryReadEditorFromSidecarAsync(ResourceKey fileResource) + { + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + if (sidecarService.IsSidecarKey(fileResource)) + { + // The sidecar file itself does not have a sidecar pairing of its + // own; nothing to consult. + return DocumentEditorId.Empty; + } + + var readResult = await sidecarService.ReadAsync(fileResource); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read sidecar for '{fileResource}'") + .WithErrors(readResult); + } + + var sidecar = readResult.Value; + if (sidecar.Outcome != SidecarReadOutcome.Healthy + || sidecar.Content is null) + { + return DocumentEditorId.Empty; + } + + if (!sidecar.Content.Frontmatter.TryGetValue("editor", out var editorValue) + || editorValue is not string editorIdString + || string.IsNullOrWhiteSpace(editorIdString)) + { + return DocumentEditorId.Empty; + } + + if (!DocumentEditorId.TryParse(editorIdString, out var editorId)) + { + _logger.LogDebug($"Sidecar for '{fileResource}' has malformed editor value '{editorIdString}'"); + return DocumentEditorId.Empty; + } + + return editorId; + } + private void OnDocumentResourceChangedMessage(object recipient, DocumentResourceChangedMessage message) { var oldResource = message.OldResource.ToString(); diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs index 4b1e19008..e8aada09c 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs @@ -3,6 +3,7 @@ using Celbridge.Dialog; using Celbridge.Documents; using Celbridge.Logging; +using Celbridge.Resources; using Celbridge.Workspace; using Microsoft.Extensions.Localization; @@ -147,6 +148,18 @@ private async Task ExecuteAsync(ExplorerMenuContext context) }); } + // Persist the user's explicit per-file choice in the sidecar's `editor` + // field, creating the sidecar if needed. The KISS rule: every "Open + // With X" invocation writes the chosen editor, even when it matches + // the per-extension default - a redundant entry is less surprising + // than an auto-removal the user did not request. + _commandService.Execute(command => + { + command.Resource = resourceKey; + command.Field = "editor"; + command.Value = selectedFactory.EditorId.ToString(); + }); + _commandService.Execute(command => { command.FileResource = resourceKey; diff --git a/Source/Workspace/Celbridge.Packages/ModManifestFactory.cs b/Source/Workspace/Celbridge.Packages/ModManifestFactory.cs new file mode 100644 index 000000000..b1efa0864 --- /dev/null +++ b/Source/Workspace/Celbridge.Packages/ModManifestFactory.cs @@ -0,0 +1,41 @@ +using Celbridge.Documents; +using Microsoft.Extensions.Localization; + +namespace Celbridge.Packages; + +/// +/// Factory that claims ownership of mod manifest files. Currently matches the +/// legacy package.toml filename; the next migration phase switches to the +/// .mod.cel multi-part extension. Registering through the standard factory +/// surface consolidates mod-manifest identity in the same registry that other +/// document editors use. +/// +public class ModManifestFactory : DocumentEditorFactoryBase +{ + private const string PackageTomlFilename = "package.toml"; + + private readonly IStringLocalizer _stringLocalizer; + + public override DocumentEditorId EditorId { get; } = new("celbridge.mod-manifest"); + + public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_ModManifest"); + + public override IReadOnlyList SupportedExtensions { get; } = Array.Empty(); + + public override IReadOnlyList SupportedFilenames { get; } = [PackageTomlFilename]; + + public ModManifestFactory(IStringLocalizer stringLocalizer) + { + _stringLocalizer = stringLocalizer; + } + + public override Result CreateDocumentView(ResourceKey fileResource) + { + // The mod manifest is loaded by PackageManifestLoader.LoadPackage, not + // as an in-workspace document view. Registering here reserves + // ownership of the filename; opening one as a document is not a + // supported flow. + return Result.Fail( + $"Mod manifest '{fileResource}' is not opened as a document; it is loaded by the package service."); + } +} diff --git a/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs b/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs new file mode 100644 index 000000000..c36e281eb --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs @@ -0,0 +1,36 @@ +using Celbridge.Documents; +using Microsoft.Extensions.Localization; + +namespace Celbridge.Resources; + +/// +/// Factory that claims ownership of Celbridge project files. Currently matches +/// the legacy .celbridge extension; the next migration phase switches to the +/// .project.cel multi-part extension. Registering through the standard factory +/// surface consolidates project-file identity in the same registry that other +/// document editors use. +/// +public class ProjectFileFactory : DocumentEditorFactoryBase +{ + private readonly IStringLocalizer _stringLocalizer; + + public override DocumentEditorId EditorId { get; } = new("celbridge.project-file"); + + public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_ProjectFile"); + + public override IReadOnlyList SupportedExtensions { get; } = [".celbridge"]; + + public ProjectFileFactory(IStringLocalizer stringLocalizer) + { + _stringLocalizer = stringLocalizer; + } + + public override Result CreateDocumentView(ResourceKey fileResource) + { + // The project file is loaded by ProjectService.LoadProjectAsync, not as + // an in-workspace document view. Registering here reserves ownership of + // the extension; opening one as a document is not a supported flow. + return Result.Fail( + $"Project file '{fileResource}' is not opened as a document; it is loaded by the project service."); + } +} From c4d3ef0a048964d4d421c2c8ecf4356deb3aeecc Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Tue, 26 May 2026 22:35:25 +0100 Subject: [PATCH 22/48] Refactor resource/sidecar and document editor APIs Major API and implementation reshuffle across Foundation and Workspace layers to standardize resource, .cel sidecar, and document-editor handling. Key changes: - Introduces cross-root and project tree abstractions: IRootHandlerRegistry, IProjectTreeBuilder and FolderItem + EnumerateFolderAsync on IResourceFileSystem. - Reworks sidecar model and services: ISidecarService signature changed (ReadAsync now accepts resource), GetInfoResult gains HasSidecar, ISidecarPairingService added, and many typed sidecar operations added (SetField/RemoveField/AddTag/RemoveTag/WriteBlock/RemoveBlock). - Resource registry and transfer helpers moved/adjusted: IResourceRegistry ProjectFolderPath is now read-only and InitializeProjectRoot added; destination-resolution and context-folder helpers moved into IResourceTransferService. - Document editor API updates: IDocumentEditorFactory adds IsPlaceholder and simplifies CanHandleResource; IDocumentEditorRegistry and IDocumentsService gained new methods for richer factory lookup and preferred-editor resolution; DocumentConstants adds CodeEditorId and SidecarEditorFieldName; DocumentEditorId implicit-from-string removed. - File/extension semantics adjusted: WebView extension canonicalized to ".webview.cel"; various .cel classification logic consolidated (CelFileClassification removed in favour of pairing service). - Manifest and resources: Package.appxmanifest version bumped to 0.3.0.0 and resource string keys for package/document contribution renamed/added. - Adds numerous concrete implementations, helpers, tests, and workspace wiring to exercise the new APIs. This is a breaking/internal API refactor intended to centralize and clarify resource resolution, sidecar ownership, and editor-selection logic; callers should adapt to the new method signatures and types. --- CLAUDE.md | 1 + Source/Celbridge/Package.appxmanifest | 2 +- .../Resources/Strings/en-US/Resources.resw | 7 +- .../Console/ConsoleMessages.cs | 2 +- .../Celbridge.Foundation/Core/ResourceKey.cs | 8 +- .../Documents/DocumentConstants.cs | 12 + .../Documents/DocumentEditorFactoryBase.cs | 4 +- .../Documents/DocumentEditorId.cs | 2 - .../Documents/IDocumentEditorFactory.cs | 10 +- .../Documents/IDocumentEditorRegistry.cs | 24 +- .../Documents/IDocumentsService.cs | 7 + .../Explorer/ExplorerConstants.cs | 2 +- .../Resources/CelFileClassification.cs | 123 --- .../Resources/IGetInfoCommand.cs | 13 +- .../Resources/IProjectCheckCommand.cs | 28 +- .../Resources/IProjectTreeBuilder.cs | 16 + .../Resources/IResourceFileSystem.cs | 16 + .../Resources/IResourceRegistry.cs | 33 +- .../Resources/IResourceService.cs | 7 + .../Resources/IResourceTransferService.cs | 27 +- .../Resources/IRootHandlerRegistry.cs | 48 ++ .../Resources/ISidecarPairingService.cs | 22 + .../Resources/ISidecarService.cs | 67 +- .../MigrationSteps/MigrationStep_0_3_0.cs | 441 +++++++++++ .../Celbridge.Tools/Guides/Namespaces/data.md | 2 +- .../Guides/Tools/data_check_project.md | 10 +- .../Guides/Tools/data_get_info.md | 7 +- .../Guides/Tools/explorer_duplicate.md | 2 +- .../Celbridge.Tools/Guides/Tools/file_edit.md | 2 +- .../Guides/Tools/package_install.md | 2 +- .../Guides/Tools/spreadsheet_find.md | 4 +- .../Guides/Tools/spreadsheet_format_ranges.md | 2 +- .../Guides/Tools/spreadsheet_get_info.md | 1 + .../Tools/spreadsheet_set_active_view.md | 2 +- .../Celbridge.Tools/Tools/AgentToolBase.cs | 8 +- .../Tools/Data/DataTools.CheckProject.cs | 10 +- .../Tools/Data/DataTools.GetInfo.cs | 1 + .../Tools/File/FileTools.ReadMany.cs | 3 +- .../Tools/File/FileTools.Search.cs | 79 +- .../Tools/Package/PackageTools.Install.cs | 24 +- .../Web/celbridge-client/api/document-api.js | 21 + .../tests/document-api.test.js | 25 + Source/Modules/Celbridge.Core/Module.cs | 3 +- .../{code.document.toml => code.document.cel} | 0 .../Editors/CodeEditor/js/preview-pipeline.js | 10 +- ...wn.document.toml => markdown.document.cel} | 5 +- .../CodeEditor/{package.toml => package.cel} | 2 +- ....document.toml => fileviewer.document.cel} | 0 .../Editors/FileViewer/js/file-viewer.js | 8 +- .../FileViewer/{package.toml => package.cel} | 2 +- .../Editors/Notes/js/note.js | 6 +- .../{note.document.toml => note.document.cel} | 0 .../Notes/{package.toml => package.cel} | 2 +- .../SceneViewer/{package.toml => package.cel} | 2 +- ...scene.document.toml => scene.document.cel} | 0 .../Package/{package.toml => package.cel} | 2 +- ...document.toml => spreadsheet.document.cel} | 0 .../Services/WebViewEditorFactory.cs | 6 +- .../ViewModels/WebViewDocumentViewModel.cs | 58 +- .../02_webapps/30_days_of_python.webview | 3 - .../02_webapps/30_days_of_python.webview.cel | 1 + .../Examples/02_webapps/github_issues.webview | 3 - .../02_webapps/github_issues.webview.cel | 1 + .../Examples/02_webapps/kleki_paint.webview | 3 - .../02_webapps/kleki_paint.webview.cel | 1 + .../Examples/02_webapps/mit_scratch.webview | 3 - .../02_webapps/mit_scratch.webview.cel | 1 + .../Templates/Examples/02_webapps/readme.md | 10 +- .../Examples/02_webapps/wikipedia.webview | 3 - .../Examples/02_webapps/wikipedia.webview.cel | 1 + .../04_data_import/dublinbikes.webview | 1 - .../04_data_import/dublinbikes.webview.cel | 1 + .../Examples/04_data_import/readme.md | 2 +- .../DocumentEditorPreferenceStoreTests.cs | 220 ++++++ .../Documents/DocumentEditorRegistryTests.cs | 136 +++- .../Documents/DocumentLayoutStoreTests.cs | 374 +++++++++ .../Documents/DocumentViewFactoryTests.cs | 402 ++++++++++ .../MultiPartExtensionResolutionTests.cs | 62 +- .../Tests/Explorer/OpenWithMenuOptionTests.cs | 168 +++- Source/Tests/GlobalUsings.cs | 1 + .../Steps/MigrationStep_0_3_0_Tests.cs | 299 +++++++ .../Tests/Packages/FileTypeProviderTests.cs | 8 +- Source/Tests/Packages/PackageRegistryTests.cs | 12 +- .../Tests/Resources/CelFileClassifierTests.cs | 132 ---- .../Tests/Resources/DataCheckProjectTests.cs | 102 ++- .../Resources/ProjectTreeBuilderTests.cs | 157 ++++ .../Tests/Resources/ResourceCommandTests.cs | 99 ++- .../Tests/Resources/ResourceRegistryTests.cs | 67 +- .../Resources/ResourceTreeNavigatorTests.cs | 126 +++ .../Resources/RootHandlerRegistryTests.cs | 152 ++++ .../Resources/SidecarClassificationTests.cs | 6 +- .../Resources/SidecarPairingServiceTests.cs | 195 +++++ .../Resources/SidecarPairingTestHelper.cs | 71 ++ Source/Tests/Resources/SidecarServiceTests.cs | 338 ++++++++ .../Tests/Resources/SidecarTrackingTests.cs | 11 +- .../Resources/WriteBinaryFileCommandTests.cs | 1 - .../Tests/Resources/WriteFileCommandTests.cs | 7 +- Source/Tests/Search/FileFilterTests.cs | 9 +- .../WebView/HtmlViewerEditorFactoryTests.cs | 8 +- .../WebView/WebViewDocumentViewModelTests.cs | 116 ++- .../WebView/WebViewEditorFactoryTests.cs | 10 +- .../Celbridge.Console/Commands/RunCommand.cs | 3 +- .../Helpers/FileAccessHelper.cs | 76 ++ .../Helpers/FileTypeClassifier.cs | 77 ++ .../{Services => Helpers}/FileTypeHelper.cs | 30 +- .../ServiceConfiguration.cs | 4 - .../Services/CustomDocumentViewFactory.cs | 4 +- .../Services/DocumentEditorPreferenceStore.cs | 134 ++++ .../Services/DocumentEditorRegistry.cs | 70 +- .../Services/DocumentLayoutStore.cs | 347 ++++++++ .../Services/DocumentViewFactory.cs | 283 +++++++ .../Services/DocumentsService.cs | 739 ++---------------- .../ViewModels/DocumentTabViewModel.cs | 2 +- .../ViewModels/DocumentViewModel.cs | 6 +- .../ViewModels/DocumentsPanelViewModel.cs | 9 +- .../Views/DocumentsPanel.xaml.cs | 3 +- .../Services/EntityRegistry.cs | 2 +- .../Services/EntityService.cs | 2 +- .../Menu/Options/OpenWithMenuOption.cs | 35 +- .../Views/ResourceTree.DragDrop.cs | 2 +- .../Views/ResourceTree.Keyboard.cs | 2 +- .../Services/InspectorFactory.cs | 6 +- .../ViewModels/WebInspectorViewModel.cs | 94 +-- .../DocumentContributionFactory.cs | 38 + .../Celbridge.Packages/ModManifestFactory.cs | 41 - .../PackageManifestFactory.cs | 44 ++ .../Services/PackageRegistry.cs | 2 +- .../Python/celbridge-0.1.0-py3-none-any.whl | Bin 43797 -> 43815 bytes .../celbridge/integration_tests/test_data.py | 28 +- .../integration_tests/test_document.py | 1 + .../Commands/AddResourceCommand.cs | 8 +- .../Commands/AddTagCommand.cs | 21 +- .../Commands/CopyResourceCommand.cs | 6 +- .../Commands/GetFileTreeCommand.cs | 60 +- .../Commands/GetInfoCommand.cs | 5 +- .../Commands/ListFolderContentsCommand.cs | 53 +- .../Commands/ProjectCheckCommand.cs | 106 ++- .../Commands/RemoveBlockCommand.cs | 15 +- .../Commands/RemoveFieldCommand.cs | 5 +- .../Commands/RemoveTagCommand.cs | 29 +- .../Commands/SetFieldCommand.cs | 9 +- .../Commands/TransferResourcesCommand.cs | 9 +- .../Commands/WriteBinaryFileCommand.cs | 17 +- .../Commands/WriteBlockCommand.cs | 27 +- .../Commands/WriteFileCommand.cs | 24 +- .../Helpers/PathValidator.cs | 2 +- .../Helpers/ResourceTreeNavigator.cs | 101 +++ .../Helpers/ResourceUtils.cs | 61 -- .../Celbridge.Resources/ProjectFileFactory.cs | 10 +- .../ServiceConfiguration.cs | 3 +- .../Services/ProjectTreeBuilder.cs | 124 +++ .../Services/ResourceFileSystem.cs | 80 +- .../Services/ResourceOperationService.cs | 6 +- .../Services/ResourceRegistry.cs | 559 ++----------- .../Services/ResourceService.cs | 34 +- .../Services/ResourceTransferService.cs | 85 +- .../Services/RootHandlerRegistry.cs | 95 +++ .../Services/Roots/ResourceRootHandlerBase.cs | 2 +- .../Services/SidecarPairingService.cs | 178 +++++ .../Services/SidecarService.cs | 285 +++++-- .../Celbridge.Search/Services/FileFilter.cs | 2 +- .../Services/ProjectCheckReporter.cs | 20 +- .../Services/WorkspaceLoader.cs | 42 +- 163 files changed, 6331 insertions(+), 2392 deletions(-) delete mode 100644 Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IProjectTreeBuilder.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs create mode 100644 Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs create mode 100644 Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs create mode 100644 Source/Core/Celbridge.WebHost/Web/celbridge-client/tests/document-api.test.js rename Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/{code.document.toml => code.document.cel} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/{markdown.document.toml => markdown.document.cel} (66%) rename Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/{package.toml => package.cel} (60%) rename Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/{fileviewer.document.toml => fileviewer.document.cel} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/{package.toml => package.cel} (68%) rename Source/Modules/Celbridge.DocumentEditors/Editors/Notes/{note.document.toml => note.document.cel} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/Notes/{package.toml => package.cel} (74%) rename Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/{package.toml => package.cel} (71%) rename Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/{scene.document.toml => scene.document.cel} (100%) rename Source/Modules/Celbridge.Spreadsheet/Package/{package.toml => package.cel} (68%) rename Source/Modules/Celbridge.Spreadsheet/Package/{spreadsheet.document.toml => spreadsheet.document.cel} (100%) delete mode 100644 Source/Templates/Examples/02_webapps/30_days_of_python.webview create mode 100644 Source/Templates/Examples/02_webapps/30_days_of_python.webview.cel delete mode 100644 Source/Templates/Examples/02_webapps/github_issues.webview create mode 100644 Source/Templates/Examples/02_webapps/github_issues.webview.cel delete mode 100644 Source/Templates/Examples/02_webapps/kleki_paint.webview create mode 100644 Source/Templates/Examples/02_webapps/kleki_paint.webview.cel delete mode 100644 Source/Templates/Examples/02_webapps/mit_scratch.webview create mode 100644 Source/Templates/Examples/02_webapps/mit_scratch.webview.cel delete mode 100644 Source/Templates/Examples/02_webapps/wikipedia.webview create mode 100644 Source/Templates/Examples/02_webapps/wikipedia.webview.cel delete mode 100644 Source/Templates/Examples/04_data_import/dublinbikes.webview create mode 100644 Source/Templates/Examples/04_data_import/dublinbikes.webview.cel create mode 100644 Source/Tests/Documents/DocumentEditorPreferenceStoreTests.cs create mode 100644 Source/Tests/Documents/DocumentLayoutStoreTests.cs create mode 100644 Source/Tests/Documents/DocumentViewFactoryTests.cs create mode 100644 Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs delete mode 100644 Source/Tests/Resources/CelFileClassifierTests.cs create mode 100644 Source/Tests/Resources/ProjectTreeBuilderTests.cs create mode 100644 Source/Tests/Resources/ResourceTreeNavigatorTests.cs create mode 100644 Source/Tests/Resources/RootHandlerRegistryTests.cs create mode 100644 Source/Tests/Resources/SidecarPairingServiceTests.cs create mode 100644 Source/Tests/Resources/SidecarPairingTestHelper.cs create mode 100644 Source/Tests/Resources/SidecarServiceTests.cs create mode 100644 Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs create mode 100644 Source/Workspace/Celbridge.Documents/Helpers/FileTypeClassifier.cs rename Source/Workspace/Celbridge.Documents/{Services => Helpers}/FileTypeHelper.cs (83%) create mode 100644 Source/Workspace/Celbridge.Documents/Services/DocumentEditorPreferenceStore.cs create mode 100644 Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs create mode 100644 Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs create mode 100644 Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs delete mode 100644 Source/Workspace/Celbridge.Packages/ModManifestFactory.cs create mode 100644 Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs create mode 100644 Source/Workspace/Celbridge.Resources/Helpers/ResourceTreeNavigator.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/RootHandlerRegistry.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs diff --git a/CLAUDE.md b/CLAUDE.md index 84960a45a..91adffd9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ python run_tests.py - When logging an exception, pass the exception object to the logger overload (e.g. `_logger.LogError(ex, "...")`); do not interpolate `ex.Message` or `ex.ToString()` into the message string - Keep XML doc comments concise but informative: one or two `` sentences describing *what* the member does, written so a reader who hasn't seen the class can understand it. If one line would just rephrase the member name (e.g. `"Typed counterpart of X"`), use two — conciseness is the constraint, not the goal. Do not embed implementation rationale, caller behavior, or detail already carried by types (enums, records, nullable returns). Avoid inline formatting tags (``, ``, ``) and multi-paragraph `` blocks; plain type names read fine without `` prose in summaries - Interface members and public types in `Celbridge.Foundation` must always carry a concise `` — the Foundation abstractions are how a reader understands the system, so every interface method, public record, and public enum there needs enough comment to stand alone. Conversely, skip xmldoc on concrete-class members by default: the interface they implement already documents them, and duplicated comments drift out of sync with the implementation. Exception: when the implementation has behavior that isn't obvious from the signature (unusual threading constraints, hidden side effects, non-obvious failure modes, subtle invariants), add a brief note. Treat the exception as rare — if the summary would just restate the name or repeat the interface comment, skip it +- Keep inline body comments terse — write only what a first-time reader needs to know that they can't read off the code. Don't narrate what the current change is about, don't recap rationale visible in the surrounding code, don't enumerate edge cases the reader can infer. If a comment approaches paragraph length, the code probably needs restructuring instead - Model user or programmatic cancellation as a typed success outcome (e.g., `Result` with a `Cancelled` value), not as `Result.Fail`; `Result.Fail` stays reserved for genuine errors (precedent: `OpenDocumentOutcome`, `CloseDocumentOutcome`) - Minimize `Result` boilerplate at return sites: use implicit conversions (`return value;` for concrete types; `return Result.Fail("message");` for failures). For interface return types, use the `OkResult()` extension from `ResultExtensions`. Always unpack `result.Value` into a named temporary variable before using it diff --git a/Source/Celbridge/Package.appxmanifest b/Source/Celbridge/Package.appxmanifest index a9d67e49d..eb397d130 100644 --- a/Source/Celbridge/Package.appxmanifest +++ b/Source/Celbridge/Package.appxmanifest @@ -5,7 +5,7 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap rescap"> - + Celbridge celbridge.org diff --git a/Source/Celbridge/Resources/Strings/en-US/Resources.resw b/Source/Celbridge/Resources/Strings/en-US/Resources.resw index f064be081..90f652bdc 100644 --- a/Source/Celbridge/Resources/Strings/en-US/Resources.resw +++ b/Source/Celbridge/Resources/Strings/en-US/Resources.resw @@ -971,8 +971,11 @@ Do you wish to continue? Project File - - Mod Manifest + + Package Manifest + + + Document Contribution Open external link diff --git a/Source/Core/Celbridge.Foundation/Console/ConsoleMessages.cs b/Source/Core/Celbridge.Foundation/Console/ConsoleMessages.cs index 5a5ffd79e..bd12189e6 100644 --- a/Source/Core/Celbridge.Foundation/Console/ConsoleMessages.cs +++ b/Source/Core/Celbridge.Foundation/Console/ConsoleMessages.cs @@ -48,7 +48,7 @@ public enum ConsoleErrorType /// /// The workspace-load project consistency check returned non-empty - /// findings (broken references, orphan sidecars, or broken sidecars). + /// findings (broken references, orphan .cel files, or broken .cel files). /// The ConsoleErrorMessage.ConfigFileName field carries the summary text. /// ProjectCheckError, diff --git a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs index c402e9fc0..d05f1653e 100644 --- a/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs +++ b/Source/Core/Celbridge.Foundation/Core/ResourceKey.cs @@ -73,7 +73,9 @@ public static bool TryCreate(string key, out ResourceKey result) public string Root => _root ?? DefaultRoot; /// - /// The path portion of this key, with the root prefix stripped. May be empty for a root-only key. + /// The path portion only, no root prefix; empty for root-only keys. Use this in any + /// external context (filesystem paths, URLs, etc.) — the canonical "root:path" form + /// returned by ToString() is only meaningful to Celbridge's resource APIs. /// public string Path => _path ?? string.Empty; @@ -91,8 +93,8 @@ public static bool TryCreate(string key, out ResourceKey result) /// error text, debugger views) can be round-tripped or copy-pasted directly into a quoted /// reference without forgetting the prefix. /// - /// For UI surfaces or other display contexts that explicitly want the bare path without - /// the root prefix, use the accessor instead. + /// This form is only meaningful to Celbridge's resource APIs. For any external context + /// (filesystem paths, URLs, etc.) use the accessor instead. /// public override string ToString() { diff --git a/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs b/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs index 4a9725f98..2d206f213 100644 --- a/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs +++ b/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs @@ -5,6 +5,18 @@ namespace Celbridge.Documents; /// public static class DocumentConstants { + /// + /// Id of the bundled code editor. + /// + public static readonly DocumentEditorId CodeEditorId = new("celbridge.code-editor.code-document"); + + /// + /// Sidecar frontmatter field that records the user's per-file editor choice + /// (last "Open with..." selection). Shared so the read path (preference + /// store) and write path (OpenWith menu) stay in lockstep. + /// + public const string SidecarEditorFieldName = "editor"; + /// /// Returns the workspace settings key for the user's preferred document editor for a file extension. /// diff --git a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs index 8874226b3..1587fdcdc 100644 --- a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs +++ b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs @@ -17,7 +17,9 @@ public abstract class DocumentEditorFactoryBase : IDocumentEditorFactory public virtual EditorPriority Priority => EditorPriority.Specialized; - public virtual bool CanHandleResource(ResourceKey fileResource, string filePath) + public virtual bool IsPlaceholder => false; + + public virtual bool CanHandleResource(ResourceKey fileResource) { var fileName = Path.GetFileName(fileResource.ToString()); diff --git a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorId.cs b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorId.cs index 35164dd0c..057f4c8d4 100644 --- a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorId.cs +++ b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorId.cs @@ -95,7 +95,5 @@ public override int GetHashCode() return !left.Equals(right); } - public static implicit operator DocumentEditorId(string id) => new(id); - public static implicit operator string(DocumentEditorId id) => id.ToString(); } diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs index a1736481f..1d6da4ce7 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs @@ -54,10 +54,18 @@ public interface IDocumentEditorFactory /// EditorPriority Priority { get; } + /// + /// True for factories that exist solely to register an extension so the + /// resources subsystem recognizes the form (e.g. package.cel, *.celbridge, + /// *.document.cel). Placeholders do not produce real document views and + /// are hidden from user-facing pickers such as the "Open with..." menu. + /// + bool IsPlaceholder { get; } + /// /// Determines if this factory can handle the given file resource. /// - bool CanHandleResource(ResourceKey fileResource, string filePath); + bool CanHandleResource(ResourceKey fileResource); /// /// Creates a document view for the specified file resource. diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs index 187d2f53e..7b9ad884b 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs @@ -14,7 +14,7 @@ public interface IDocumentEditorRegistry /// Gets the factory for the specified file resource. /// Returns the highest priority factory that can handle the resource. /// - Result GetFactory(ResourceKey fileResource, string filePath); + Result GetFactory(ResourceKey fileResource); /// /// Checks if any registered factory can handle the specified extension. @@ -27,10 +27,26 @@ public interface IDocumentEditorRegistry IReadOnlyList GetAllFactories(); /// - /// Gets all factories that can handle the specified extension, sorted by priority. - /// Returns an empty list if no factories are registered for the extension. + /// Gets all factories indexed under the specified extension, sorted by + /// priority. Direct bucket lookup; does not walk the multi-part suffix + /// chain or apply CanHandleResource. Returns an empty list when no + /// factory is registered for the extension. /// - IReadOnlyList GetFactoriesForFileExtension(string fileExtension); + IReadOnlyList GetFactoriesForExtension(string fileExtension); + + /// + /// Gets every factory that can handle the given file, sorted by priority + /// (most specialized first), deduplicated by editor id and filtered by + /// CanHandleResource. Uses the same matching rules as GetFactory. + /// + IReadOnlyList GetFactoriesForResource(ResourceKey fileResource); + + /// + /// Returns the factories a user could reasonably pick from an "Open with..." + /// dialog: non-placeholder factories that claim the file, plus the code + /// editor appended as a "view as text" option for text-shaped files. + /// + IReadOnlyList GetUserPickableFactoriesForResource(ResourceKey fileResource); /// /// Gets a factory by its editor ID. diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs index 282a52266..b305dc0fa 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs @@ -64,6 +64,13 @@ public interface IDocumentsService /// Task GetEditorPreferenceAsync(string extension); + /// + /// Returns the editor that would open this file: the sidecar's 'editor' + /// field when set, otherwise the per-extension preference, otherwise + /// DocumentEditorId.Empty. + /// + Task GetPreferredEditorAsync(ResourceKey fileResource); + /// /// Stores the user's preferred editor for a file extension. Pass DocumentEditorId.Empty /// to clear the preference. diff --git a/Source/Core/Celbridge.Foundation/Explorer/ExplorerConstants.cs b/Source/Core/Celbridge.Foundation/Explorer/ExplorerConstants.cs index c18495969..17884e0fa 100644 --- a/Source/Core/Celbridge.Foundation/Explorer/ExplorerConstants.cs +++ b/Source/Core/Celbridge.Foundation/Explorer/ExplorerConstants.cs @@ -8,7 +8,7 @@ public static class ExplorerConstants /// /// File extension for web view file resources. /// - public const string WebViewExtension = ".webview"; + public const string WebViewExtension = ".webview.cel"; /// /// File extension for markdown file resources. diff --git a/Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs b/Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs deleted file mode 100644 index ff5f55ccc..000000000 --- a/Source/Core/Celbridge.Foundation/Resources/CelFileClassification.cs +++ /dev/null @@ -1,123 +0,0 @@ -using Celbridge.Documents; - -namespace Celbridge.Resources; - -/// -/// How a .cel file is classified relative to other resources and the -/// registered document editor factories. -/// -public enum CelFileClassification -{ - /// - /// The .cel file is a standalone form recognised by a registered factory - /// (e.g. .project.cel, .mod.cel). It owns its own content and is not - /// paired with a parent file. - /// - Standalone, - - /// - /// The .cel file pairs with a sibling parent file in the same folder. - /// Parent existence wins: a paired sidecar is classified as Sidecar even - /// when its multi-part extension also matches a registered factory. - /// - Sidecar, - - /// - /// The .cel file has no sibling parent and no factory claims its - /// multi-part extension. Surfaced by project-health checks for the user - /// to repair. - /// - Orphan, -} - -/// -/// Classifies a .cel file as Standalone, Sidecar, or Orphan by consulting the -/// resource registry (for parent existence) and the document editor registry -/// (for registered multi-part extensions). The single helper keeps the two -/// dimensions of "is this a sidecar?" and "is this a standalone form?" in one -/// place so callers do not reinvent the precedence rule. -/// -public static class CelFileClassifier -{ - private const string CelExtension = ".cel"; - - /// - /// Classify a .cel resource. Parent existence wins: if a sibling parent - /// file exists in the same folder, the result is Sidecar regardless of - /// whether the multi-part extension is also registered. Otherwise the - /// multi-part extension lookup decides between Standalone and Orphan. - /// - public static CelFileClassification Classify( - ResourceKey key, - IResourceRegistry resources, - IDocumentEditorRegistry editors) - { - Guard.IsNotNull(resources); - Guard.IsNotNull(editors); - - var path = key.Path; - if (string.IsNullOrEmpty(path) - || !path.EndsWith(CelExtension, StringComparison.OrdinalIgnoreCase)) - { - return CelFileClassification.Orphan; - } - - // Parent existence wins: if removing the .cel suffix names a sibling - // file that exists in the registry, the .cel file is the sidecar for - // that parent. This holds even when the resulting multi-part extension - // also matches a registered factory. - var parentPath = path.Substring(0, path.Length - CelExtension.Length); - if (!string.IsNullOrEmpty(parentPath)) - { - var parentKey = new ResourceKey(key.Root + ":" + parentPath); - var parentResult = resources.GetResource(parentKey); - if (parentResult.IsSuccess - && parentResult.Value is IFileResource) - { - return CelFileClassification.Sidecar; - } - } - - // Compute the multi-part extension - the suffix from the last interior - // dot in the file name. For meeting.note.cel this is ".note.cel"; for - // foo.cel it is just ".cel". - var multiPartExtension = GetMultiPartExtension(key); - if (!string.IsNullOrEmpty(multiPartExtension)) - { - var factories = editors.GetFactoriesForFileExtension(multiPartExtension); - if (factories.Count > 0) - { - return CelFileClassification.Standalone; - } - } - - return CelFileClassification.Orphan; - } - - private static string GetMultiPartExtension(ResourceKey key) - { - var fileName = key.ResourceName; - if (string.IsNullOrEmpty(fileName)) - { - return string.Empty; - } - - // Skip a leading '.' so dotfiles like ".cel" still expose ".cel" rather - // than the whole name. - int searchFrom = fileName.Length > 0 && fileName[0] == '.' ? 1 : 0; - - var trimmed = fileName.Substring(searchFrom); - var firstDot = trimmed.IndexOf('.'); - if (firstDot < 0) - { - return string.Empty; - } - - // The interior segment may itself contain multiple dots (e.g. - // foo.bar.baz.cel produces ".bar.baz.cel"). Resolution picks the - // longest interior suffix that names a real .cel form; the registry's - // longest-match walk handles the rest. - var multiPart = trimmed.Substring(firstDot).ToLowerInvariant(); - return multiPart; - } -} diff --git a/Source/Core/Celbridge.Foundation/Resources/IGetInfoCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IGetInfoCommand.cs index f908fc330..a25e215ef 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IGetInfoCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IGetInfoCommand.cs @@ -10,14 +10,17 @@ namespace Celbridge.Resources; public partial record class SidecarBlockDescriptor(string Id, int Size); /// -/// Result of IGetInfoCommand: the resource's full sidecar frontmatter inline -/// plus the ordered list of block descriptors. Both lists are empty when the -/// resource has no sidecar; the command fails when the sidecar exists but is -/// broken. +/// Result of IGetInfoCommand: the resource's full sidecar frontmatter inline, +/// the ordered list of block descriptors, and a flag indicating whether a +/// sidecar was found. HasSidecar distinguishes a parent that has no sidecar +/// (empty Fields/Blocks, HasSidecar=false) from a parent whose sidecar exists +/// but is genuinely empty (empty Fields/Blocks, HasSidecar=true). The command +/// fails when the sidecar exists but is broken. /// public record class GetInfoResult( IReadOnlyDictionary Fields, - IReadOnlyList Blocks); + IReadOnlyList Blocks, + bool HasSidecar); /// /// Returns the parent resource's full sidecar frontmatter and the ordered diff --git a/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs index cbbb29a1a..dc2b071dd 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IProjectCheckCommand.cs @@ -10,33 +10,17 @@ namespace Celbridge.Resources; public record BrokenReference(ResourceKey Source, ResourceKey MissingTarget); /// -/// A .cel file that the registry tracks as not paired with a parent file. The -/// user / agent resolves orphans by deleting them, renaming the parent to claim -/// the sidecar, or creating a new file at the parent path. -/// -public record OrphanSidecar(ResourceKey Sidecar); - -/// -/// A .cel file whose frontmatter does not parse cleanly. Covers merge-conflict -/// markers, malformed TOML, missing fences, and any other parse failure — the -/// host does not differentiate between these post-Phase-1 of the redesign. -/// Files ending in .cel.cel are also classified Broken via this category. -/// -public record BrokenSidecar(ResourceKey Sidecar); - -/// -/// Structured project health report produced by IProjectCheckCommand. Empty -/// lists mean the corresponding invariant holds. The command does not repair -/// any of the surfaced issues; it is a pure read. +/// Structured project health report. Empty lists mean the corresponding +/// invariant holds. /// public record ProjectCheckReport( IReadOnlyList BrokenReferences, - IReadOnlyList OrphanSidecars, - IReadOnlyList BrokenSidecars); + IReadOnlyList OrphanCelFiles, + IReadOnlyList BrokenCelFiles); /// -/// Read-only check that surfaces dangling project: references and any sidecar -/// in an attention state (orphan, broken). Invoked at workspace load and +/// Read-only check that surfaces dangling project: references and any .cel +/// file in an attention state (orphan, broken). Invoked at workspace load and /// exposed as the data_check_project MCP tool. /// public interface IProjectCheckCommand : IExecutableCommand diff --git a/Source/Core/Celbridge.Foundation/Resources/IProjectTreeBuilder.cs b/Source/Core/Celbridge.Foundation/Resources/IProjectTreeBuilder.cs new file mode 100644 index 000000000..b8e2309ca --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/IProjectTreeBuilder.cs @@ -0,0 +1,16 @@ +namespace Celbridge.Resources; + +/// +/// Walks the project folder on disk and produces a fresh resource tree, +/// skipping hidden and tool-internal entries that should not surface in the +/// user-visible registry. +/// +public interface IProjectTreeBuilder +{ + /// + /// Builds a fresh tree rooted at the supplied project folder. The returned + /// root has parent = null and child resources sorted folders-first then + /// alphabetical. Fresh instances are returned on every call. + /// + IFolderResource BuildTree(string projectFolderPath); +} diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs index 0befa1332..fd263711a 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs @@ -69,6 +69,15 @@ public record CopyResult( public record DeleteResult( SidecarOutcome Sidecar); +/// +/// One immediate child of a folder, returned by EnumerateFolderAsync. +/// +public record FolderItem( + ResourceKey Resource, + bool IsFolder, + long Size, + DateTime ModifiedUtc); + /// /// The chokepoint for disk reads, writes, and structural operations on project /// resources. Callers pass a ResourceKey; the layer resolves it through @@ -138,4 +147,11 @@ public interface IResourceFileSystem /// Returns true if a file or folder exists at the resolved path of the resource key. /// Task> ExistsAsync(ResourceKey resource); + + /// + /// Returns the immediate children of a folder resource as FolderItem records. + /// Works for any registered root. Single-level only; recursive callers walk per-level. + /// Fails when the resource does not resolve to an existing folder. + /// + Task>> EnumerateFolderAsync(ResourceKey folder); } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs index 2e79d4c73..ca14813c2 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs @@ -6,7 +6,7 @@ namespace Celbridge.Resources; /// data_check_project to surface attention states. /// /// Parse state (Healthy / Broken) and orphan-ness are orthogonal dimensions: -/// an orphan sidecar with malformed content appears in both Broken and Orphan. +/// an orphan .cel file with malformed content appears in both Broken and Orphan. /// Files whose names end in .cel.cel are classified as Broken and never as a /// regular sidecar. /// @@ -21,9 +21,14 @@ public record SidecarReport( public interface IResourceRegistry { /// - /// The path of the project folder. + /// The path of the project folder. Empty until InitializeProjectRoot is called. /// - string ProjectFolderPath { get; set; } + string ProjectFolderPath { get; } + + /// + /// Sets the project folder path and registers the project root handler. + /// + void InitializeProjectRoot(string projectFolderPath); /// /// The project folder resource that contains all the resources in the project. @@ -74,28 +79,6 @@ public interface IResourceRegistry /// Result GetResource(ResourceKey resource); - /// - /// Returns a resolved destination resource key for a resource transfer. - /// If destResource specifies an existing folder in the project, then the name of the source resource is - /// appended to the destination folder resource. In all other situations, destResource is returned unchanged. - /// - ResourceKey ResolveDestinationResource(ResourceKey sourceResource, ResourceKey destResource); - - /// - /// Returns a resolved destination resource key for a resource transfer from a source path to a destination resource. - /// If destResource specifies an existing folder in the project, then the name of the source resource is - /// appended to the destination folder resource. In all other situations, destResource is returned unchanged. - /// - ResourceKey ResolveSourcePathDestinationResource(string sourcePath, ResourceKey destResource); - - /// - /// Returns the folder resource associated with the context menu item for a resource. - /// If the resource is a folder, then the folder is returned. - /// If the resource is a file, then the file's parent folder is returned. - /// If the resource is null, then the project folder is returned. - /// - ResourceKey GetContextMenuItemFolder(IResource? resource); - /// /// Updates the registry to mirror the current state of the files and folders in the project folder. /// diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs index b8e8015a3..72554cd74 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceService.cs @@ -11,6 +11,13 @@ public interface IResourceService /// IResourceRegistry Registry { get; } + /// + /// Returns the registry of resource root handlers for the current project. + /// Use this rather than IResourceRegistry when only cross-root path/key + /// dispatch is required. + /// + IRootHandlerRegistry RootHandlerRegistry { get; } + /// /// Returns the Resource Monitor associated with the current project. /// diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs index a345e0f5f..905f710ef 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs @@ -3,7 +3,9 @@ namespace Celbridge.Resources; /// -/// Service for creating and executing resource transfer operations. +/// Service for creating and executing resource transfer operations, plus the +/// destination-resolution helpers shared by drag/drop, paste, and the +/// transfer commands. /// public interface IResourceTransferService { @@ -16,4 +18,27 @@ public interface IResourceTransferService /// Transfer resources to a destination folder resource. /// Result TransferResources(ResourceKey destFolderResource, IResourceTransfer transfer); + + /// + /// Returns a resolved destination resource key for a resource transfer. + /// If destResource specifies an existing folder in the project then the + /// source resource name is appended; otherwise destResource is returned + /// unchanged. + /// + ResourceKey ResolveDestinationResource(ResourceKey sourceResource, ResourceKey destResource); + + /// + /// Returns a resolved destination resource key for a resource transfer + /// from an external source path. When destResource is an existing folder + /// the source filename is appended; otherwise destResource is returned + /// unchanged. + /// + ResourceKey ResolveSourcePathDestinationResource(string sourcePath, ResourceKey destResource); + + /// + /// Returns the folder resource associated with the context menu item for + /// a resource. Folder → itself; file → its parent folder; null → the + /// project folder. + /// + ResourceKey GetContextMenuItemFolder(IResource? resource); } diff --git a/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs new file mode 100644 index 000000000..aeeb7e64e --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs @@ -0,0 +1,48 @@ +namespace Celbridge.Resources; + +/// +/// Cross-root dispatch over a registered set of resource root handlers. Owns +/// the project, temp, and logs roots once they have been registered; resolves +/// resource keys to absolute paths and vice versa via longest-prefix-wins +/// matching across the backing locations. +/// +public interface IRootHandlerRegistry +{ + /// + /// Registers a handler for the named root. Replaces any handler previously + /// registered against the same root name. + /// + void RegisterRootHandler(IResourceRootHandler handler); + + /// + /// The currently registered root handlers, keyed by root name. + /// + IReadOnlyDictionary RootHandlers { get; } + + /// + /// Returns true if the key's root is registered. Use this for early + /// validation at trust boundaries without performing a full resolve. + /// + bool IsResolvable(ResourceKey key); + + /// + /// Maps an absolute path to its resource key by dispatching to the root + /// handler whose backing location is the longest prefix of the path. + /// + Result GetResourceKey(string absolutePath); + + /// + /// Resolves a resource key to its absolute filesystem path via the + /// registered handler. Does not enforce case-canonical matching against + /// the project tree; callers that want that should go through + /// IResourceRegistry.ResolveResourcePath instead. + /// + Result ResolveResourcePath(ResourceKey resource); + + /// + /// Clears the path-validator cache shared by registered handlers. Call + /// after the project folder layout changes so stale verified-folder + /// entries do not mask new reparse-point risks. + /// + void InvalidatePathCache(); +} diff --git a/Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs b/Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs new file mode 100644 index 000000000..2b5ac0c72 --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs @@ -0,0 +1,22 @@ +namespace Celbridge.Resources; + +/// +/// Result of a single pairing pass: the classification report and the +/// sidecar-to-parent lookup. +/// +public sealed record SidecarPairingResult( + SidecarReport Report, + IReadOnlyDictionary SidecarToParent); + +/// +/// Classifies every .cel-shaped file in the project tree as a healthy sidecar, +/// a broken sidecar, or a parentless orphan. +/// +public interface ISidecarPairingService +{ + /// + /// Walks the project root, sets each parent file's Sidecar property in place, + /// and returns the classification report and sidecar-to-parent lookup. + /// + SidecarPairingResult ComputePairings(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry); +} diff --git a/Source/Core/Celbridge.Foundation/Resources/ISidecarService.cs b/Source/Core/Celbridge.Foundation/Resources/ISidecarService.cs index 511ae755f..a50daa457 100644 --- a/Source/Core/Celbridge.Foundation/Resources/ISidecarService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/ISidecarService.cs @@ -47,11 +47,9 @@ public sealed record SidecarReadResult( string? FailureMessage); /// -/// Workspace-scoped service for reading, mutating, and writing .cel sidecar -/// files plus the validation helpers that surround them. IO goes through -/// IResourceFileSystem so the chokepoint's atomic-write + retry behaviour -/// applies uniformly. The TOML + named-blocks parser lives in the workspace -/// implementation so Foundation does not carry the Tomlyn dependency. +/// Workspace-scoped service for reading and editing .cel sidecar files. +/// Exposes typed operations (set field, add tag, write block, etc.) over the +/// frontmatter and named-blocks model. /// public interface ISidecarService { @@ -81,31 +79,50 @@ public interface ISidecarService bool IsIndexableValue(object? value); /// - /// Reads and parses the sidecar for the parent resource. Returns the - /// NoSidecar outcome when the file does not exist, Broken when it exists - /// but does not parse, and Healthy with parsed content otherwise. Fails - /// when the parent key itself is invalid (empty or sidecar-shaped). + /// Reads and parses the sidecar storage for the given resource. For a regular + /// file the storage is the sibling .cel sidecar; for a standalone .cel file + /// the resource itself is the storage. Returns NoSidecar when the storage + /// file does not exist, Broken when it exists but does not parse, and Healthy + /// with parsed content otherwise. /// - Task> ReadAsync(ResourceKey parent); + Task> ReadAsync(ResourceKey resource); /// - /// Applies the mutator to the parent resource's sidecar frontmatter. If the - /// sidecar is missing and createIfMissing is true, the helper creates an - /// empty sidecar; if createIfMissing is false, missing sidecars short- - /// circuit as a successful no-op. The blocks list is preserved verbatim. + /// Sets a single frontmatter field, creating the sidecar if it does not + /// already exist. The value must pass IsIndexableValue (scalar or list of + /// scalars); other shapes are rejected at the service boundary. /// - Task MutateFrontmatterAsync( - ResourceKey parent, - Action> mutate, - bool createIfMissing = true); + Task SetFieldAsync(ResourceKey resource, string field, object value); /// - /// Applies the mutator to the parent resource's named blocks list. If the - /// sidecar is missing and createIfMissing is true, an empty sidecar is - /// created before the mutation runs. + /// Removes a single frontmatter field. No-op when the field or the sidecar + /// is absent; the sidecar file is not created just to record an absence. /// - Task MutateBlocksAsync( - ResourceKey parent, - Action> mutate, - bool createIfMissing = true); + Task RemoveFieldAsync(ResourceKey resource, string field); + + /// + /// Appends a tag to the sidecar's tags list, creating the sidecar if it + /// does not already exist. Idempotent: adding a tag that is already present + /// neither changes the list nor rewrites the file. + /// + Task AddTagAsync(ResourceKey resource, string tag); + + /// + /// Removes a tag from the sidecar's tags list. Idempotent. Dropping the + /// final tag removes the tags field entirely. No-op when the sidecar is + /// absent. + /// + Task RemoveTagAsync(ResourceKey resource, string tag); + + /// + /// Creates or overwrites a named content block, creating the sidecar if it + /// does not already exist. The block id must pass IsValidBlockName. + /// + Task WriteBlockAsync(ResourceKey resource, string blockId, string content); + + /// + /// Removes a named content block. No-op when the block or the sidecar is + /// absent. + /// + Task RemoveBlockAsync(ResourceKey resource, string blockId); } diff --git a/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs new file mode 100644 index 000000000..858d65847 --- /dev/null +++ b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs @@ -0,0 +1,441 @@ +using System.Text; +using System.Text.Json; +using Celbridge.Projects.Services; + +namespace Celbridge.Projects.MigrationSteps; + +/// +/// Migrates projects to v0.3.0. Celbridge's package and document file formats +/// consolidate onto the .cel extension as the final phase of the resources +/// redesign. This step renames each package.toml to package.cel, each *.document.toml +/// to *.document.cel, and each *.webview file to *.webview.cel (converting the +/// JSON body to TOML at the same time). The .celbridge project file extension is +/// deliberately retained and is not renamed by this step. Internal references in +/// the project config and the renamed package.cel manifests are rewritten so the +/// post-migration project loads cleanly. +/// +public class MigrationStep_0_3_0 : IMigrationStep +{ + private const string PackageManifestOldName = "package.toml"; + private const string PackageManifestNewName = "package.cel"; + + private const string DocumentManifestOldExtension = ".document.toml"; + private const string DocumentManifestNewExtension = ".document.cel"; + + private const string WebViewOldExtension = ".webview"; + private const string WebViewNewExtension = ".webview.cel"; + + private const string WebViewJsonSourceUrlProperty = "sourceUrl"; + private const string WebViewTomlSourceUrlKey = "source_url"; + + public Version TargetVersion => new Version("0.3.0"); + + public async Task ApplyAsync(MigrationContext context) + { + var projectDataFolderPath = Path.GetFullPath(context.ProjectDataFolderPath); + + var packageRenameResult = RenamePackageManifests(context, projectDataFolderPath); + if (packageRenameResult.IsFailure) + { + return packageRenameResult; + } + + var documentRenameResult = RenameDocumentManifests(context, projectDataFolderPath); + if (documentRenameResult.IsFailure) + { + return documentRenameResult; + } + + var webViewConvertResult = await ConvertWebViewFilesAsync(context, projectDataFolderPath); + if (webViewConvertResult.IsFailure) + { + return webViewConvertResult; + } + + var configRewriteResult = await RewriteProjectConfigAsync(context); + if (configRewriteResult.IsFailure) + { + return configRewriteResult; + } + + return Result.Ok(); + } + + private Result RenamePackageManifests(MigrationContext context, string projectDataFolderPath) + { + try + { + var matches = Directory.EnumerateFiles( + context.ProjectFolderPath, + PackageManifestOldName, + SearchOption.AllDirectories); + + int renamedCount = 0; + foreach (var oldPath in matches) + { + var fullOldPath = Path.GetFullPath(oldPath); + if (IsInsideMetaDataFolder(fullOldPath, projectDataFolderPath)) + { + continue; + } + + var newPath = Path.Combine( + Path.GetDirectoryName(fullOldPath)!, + PackageManifestNewName); + + if (File.Exists(newPath)) + { + return Result.Fail( + $"Cannot rename '{fullOldPath}' to '{newPath}'. Target file already exists."); + } + + File.Move(fullOldPath, newPath); + renamedCount++; + } + + if (renamedCount > 0) + { + context.Logger.LogInformation( + $"Renamed {renamedCount} '{PackageManifestOldName}' file(s) to '{PackageManifestNewName}'"); + } + + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to rename '{PackageManifestOldName}' files in project folder") + .WithException(ex); + } + } + + private Result RenameDocumentManifests(MigrationContext context, string projectDataFolderPath) + { + try + { + // Two passes: rename the files first, then rewrite each surviving + // package.cel so the document_editors array points at the renamed + // *.document.cel files. The package.cel rewrite is best-effort: we only + // touch quoted entries that end with the old extension, leaving any + // bespoke prose paths alone. + + var renamedFiles = new List<(string OldPath, string NewPath)>(); + var matches = Directory.EnumerateFiles( + context.ProjectFolderPath, + $"*{DocumentManifestOldExtension}", + SearchOption.AllDirectories); + + foreach (var oldPath in matches) + { + var fullOldPath = Path.GetFullPath(oldPath); + if (IsInsideMetaDataFolder(fullOldPath, projectDataFolderPath)) + { + continue; + } + + var fileName = Path.GetFileName(fullOldPath); + var stem = fileName.Substring(0, fileName.Length - DocumentManifestOldExtension.Length); + var newPath = Path.Combine( + Path.GetDirectoryName(fullOldPath)!, + stem + DocumentManifestNewExtension); + + if (File.Exists(newPath)) + { + return Result.Fail( + $"Cannot rename '{fullOldPath}' to '{newPath}'. Target file already exists."); + } + + File.Move(fullOldPath, newPath); + renamedFiles.Add((fullOldPath, newPath)); + } + + if (renamedFiles.Count > 0) + { + context.Logger.LogInformation( + $"Renamed {renamedFiles.Count} '*{DocumentManifestOldExtension}' file(s) to '*{DocumentManifestNewExtension}'"); + } + + var packageManifestRewriteResult = RewritePackageManifestReferences(context, projectDataFolderPath); + if (packageManifestRewriteResult.IsFailure) + { + return packageManifestRewriteResult; + } + + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to rename '*{DocumentManifestOldExtension}' files in project folder") + .WithException(ex); + } + } + + private Result RewritePackageManifestReferences(MigrationContext context, string projectDataFolderPath) + { + var packageManifests = Directory.EnumerateFiles( + context.ProjectFolderPath, + PackageManifestNewName, + SearchOption.AllDirectories); + + foreach (var packageManifestPath in packageManifests) + { + var fullPath = Path.GetFullPath(packageManifestPath); + if (IsInsideMetaDataFolder(fullPath, projectDataFolderPath)) + { + continue; + } + + try + { + var originalText = File.ReadAllText(fullPath); + var rewrittenText = RewriteQuotedExtensions( + originalText, + DocumentManifestOldExtension, + DocumentManifestNewExtension); + + if (rewrittenText != originalText) + { + File.WriteAllText(fullPath, rewrittenText); + context.Logger.LogInformation( + $"Rewrote '*{DocumentManifestOldExtension}' references in package manifest: '{fullPath}'"); + } + } + catch (Exception ex) + { + return Result.Fail($"Failed to rewrite references in package manifest: '{fullPath}'") + .WithException(ex); + } + } + + return Result.Ok(); + } + + private async Task ConvertWebViewFilesAsync(MigrationContext context, string projectDataFolderPath) + { + try + { + var matches = Directory.EnumerateFiles( + context.ProjectFolderPath, + $"*{WebViewOldExtension}", + SearchOption.AllDirectories); + + int convertedCount = 0; + foreach (var oldPath in matches) + { + var fullOldPath = Path.GetFullPath(oldPath); + if (IsInsideMetaDataFolder(fullOldPath, projectDataFolderPath)) + { + continue; + } + + // EnumerateFiles uses a Windows-style trailing-wildcard match which also + // accepts longer extensions. Skip anything that already carries the new + // suffix so reruns do not double-convert. + if (fullOldPath.EndsWith(WebViewNewExtension, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var newPath = fullOldPath + ".cel"; + if (File.Exists(newPath)) + { + return Result.Fail( + $"Cannot convert '{fullOldPath}' to '{newPath}'. Target file already exists."); + } + + var convertResult = await ConvertWebViewFileAsync(fullOldPath, newPath); + if (convertResult.IsFailure) + { + return Result.Fail($"Failed to convert WebView file: '{fullOldPath}'") + .WithErrors(convertResult); + } + + File.Delete(fullOldPath); + convertedCount++; + } + + if (convertedCount > 0) + { + context.Logger.LogInformation( + $"Converted {convertedCount} '*{WebViewOldExtension}' file(s) to '*{WebViewNewExtension}'"); + } + + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to convert '*{WebViewOldExtension}' files in project folder") + .WithException(ex); + } + } + + private async Task ConvertWebViewFileAsync(string oldPath, string newPath) + { + try + { + var originalText = await File.ReadAllTextAsync(oldPath); + var sourceUrl = ExtractSourceUrlFromJson(originalText); + + var tomlBuilder = new StringBuilder(); + tomlBuilder.Append(WebViewTomlSourceUrlKey); + tomlBuilder.Append(" = "); + tomlBuilder.Append(QuoteTomlBasicString(sourceUrl)); + tomlBuilder.Append('\n'); + + await File.WriteAllTextAsync(newPath, tomlBuilder.ToString()); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to read or write WebView file during conversion: '{oldPath}'") + .WithException(ex); + } + } + + private static string ExtractSourceUrlFromJson(string jsonText) + { + // A pre-0.3.0 .webview file always parsed as a JSON object with a + // single "sourceUrl" string. Missing or malformed content is treated as + // an empty URL: the migrated file still loads, just navigates nowhere + // until the user supplies a URL. + if (string.IsNullOrWhiteSpace(jsonText)) + { + return string.Empty; + } + + try + { + using var document = JsonDocument.Parse(jsonText); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return string.Empty; + } + + if (!document.RootElement.TryGetProperty(WebViewJsonSourceUrlProperty, out var urlElement)) + { + return string.Empty; + } + + if (urlElement.ValueKind != JsonValueKind.String) + { + return string.Empty; + } + + var url = urlElement.GetString(); + return url ?? string.Empty; + } + catch (JsonException) + { + return string.Empty; + } + } + + private static string QuoteTomlBasicString(string value) + { + var builder = new StringBuilder(value.Length + 2); + builder.Append('"'); + foreach (var character in value) + { + switch (character) + { + case '\\': + builder.Append("\\\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\b': + builder.Append("\\b"); + break; + case '\t': + builder.Append("\\t"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\r': + builder.Append("\\r"); + break; + default: + if (character < 0x20) + { + builder.Append($"\\u{(int)character:X4}"); + } + else + { + builder.Append(character); + } + break; + } + } + builder.Append('"'); + return builder.ToString(); + } + + private async Task RewriteProjectConfigAsync(MigrationContext context) + { + try + { + var originalText = await File.ReadAllTextAsync(context.ProjectFilePath); + + // Rewrites are scoped to quoted occurrences so bare prose mentions of + // these extensions in comments stay untouched. Order matters: rewrite + // the longer/more-specific extension first so .webview does not eat + // .document.toml or vice versa. + var updatedText = originalText; + updatedText = RewriteQuotedExtensions(updatedText, DocumentManifestOldExtension, DocumentManifestNewExtension); + updatedText = RewriteQuotedExtensions(updatedText, WebViewOldExtension, WebViewNewExtension); + updatedText = RewriteQuotedFilenames(updatedText, PackageManifestOldName, PackageManifestNewName); + + if (updatedText == originalText) + { + return Result.Ok(); + } + + var writeResult = await context.WriteProjectFileAsync(updatedText); + if (writeResult.IsFailure) + { + return Result.Fail($"Failed to write rewritten project config: '{context.ProjectFilePath}'") + .WithErrors(writeResult); + } + + context.Logger.LogInformation( + $"Rewrote renamed-resource references in project config: '{context.ProjectFilePath}'"); + + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail("Failed to rewrite project config") + .WithException(ex); + } + } + + private static string RewriteQuotedExtensions(string text, string oldExtension, string newExtension) + { + return text + .Replace($"{oldExtension}\"", $"{newExtension}\"") + .Replace($"{oldExtension}'", $"{newExtension}'"); + } + + private static string RewriteQuotedFilenames(string text, string oldFilename, string newFilename) + { + return text + .Replace($"/{oldFilename}\"", $"/{newFilename}\"") + .Replace($"/{oldFilename}'", $"/{newFilename}'") + .Replace($"\\{oldFilename}\"", $"\\{newFilename}\"") + .Replace($"\\{oldFilename}'", $"\\{newFilename}'"); + } + + private static bool IsInsideMetaDataFolder(string fullPath, string projectDataFolderPath) + { + if (string.IsNullOrEmpty(projectDataFolderPath)) + { + return false; + } + + return fullPath.StartsWith(projectDataFolderPath, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md index db4b3f017..9a4d1ebad 100644 --- a/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md +++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md @@ -34,7 +34,7 @@ The `data` namespace reads and writes per-resource data stored in `.cel` sidecar **Project-wide health.** -- `data_check_project` — report broken `project:` references, orphan sidecars, and any sidecar that fails to parse cleanly. +- `data_check_project` — report broken `project:` references, orphan `.cel` files, and any `.cel` file that fails to parse cleanly. ## When to use which surface diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md index d21996e54..3cb9ef6aa 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md @@ -3,19 +3,19 @@ Reports project-wide consistency findings without modifying anything: - **Broken references** — `project:` references in text files that name a missing target. -- **Orphan sidecars** — `.cel` files with no parent file present on disk. -- **Broken sidecars** — `.cel` files (including invalid `.cel.cel` filenames) that fail to parse. +- **Orphan .cel files** — `.cel` files with no parent file present on disk and no registered factory claiming the standalone form (e.g. `package.cel`, `*.note.cel`, `*.document.cel`). +- **Broken .cel files** — `.cel` files (including invalid `.cel.cel` filenames) that fail to parse. Applies to both parent-paired sidecars and standalone `.cel` forms. Returns a JSON object with three arrays: ```json { "brokenReferences": [{"source": "...", "missingTarget": "..."}], - "orphanSidecars": ["..."], - "brokenSidecars": ["..."] + "orphanCelFiles": ["..."], + "brokenCelFiles": ["..."] } ``` Runs an on-demand parallel scan over the project's text files; no precomputed report waits in memory. The same check runs fire-and-forget on workspace load and publishes a summary message when findings are non-empty. -Pure read; the tool does not repair anything. Resolution is the caller's responsibility: rename or restore the missing target, delete or re-parent the orphan, repair or delete the broken sidecar. +Pure read; the tool does not repair anything. Resolution is the caller's responsibility: rename or restore the missing target, delete or re-parent the orphan, repair or delete the broken `.cel` file. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md index 8b342117b..0fc50d952 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md @@ -6,6 +6,7 @@ Response shape: ```json { + "hasSidecar": true, "fields": { "editor": "celbridge.notes.note-document", "tags": ["meeting"], @@ -18,6 +19,10 @@ Response shape: } ``` -Returns `{ "fields": {}, "blocks": [] }` when the resource has no sidecar — and that same empty success is returned when the *parent* resource doesn't exist either (the tool only inspects the sidecar file, it does not check whether the parent file is on disk). Use `file_get_info` first if you need to verify the parent exists before reading its data. Errors with a clear message when the sidecar exists but is broken; use `file_read` for raw inspection in that case, or `data_check_project` for the system-level view. +`hasSidecar` distinguishes the two empty-result cases: +- `hasSidecar: false`, empty fields and blocks → the resource has no sidecar on disk. This is also what you get when the *parent* resource itself doesn't exist (the tool only inspects the sidecar file, not the parent). Use `file_get_info` first if you need to confirm the parent exists. +- `hasSidecar: true`, empty fields and blocks → the sidecar file exists but is genuinely empty (zero-byte canonical empty form). + +Errors with a clear message when the sidecar exists but is broken; use `file_read` for raw inspection in that case, or `data_check_project` for the system-level view. `size` is the UTF-8 byte count of the block's semantic content (matching what `data_read_block` returns). Block content is line-oriented: the terminator that separates one block from the next on disk is not part of the content, so a block's `size` is stable as adjacent blocks are added or removed. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md index 29cdd4aaf..6ab64e7c9 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_duplicate.md @@ -2,7 +2,7 @@ Creates a copy of a resource alongside the original. Silent by default — picks a unique name like `"foo - Copy.md"` (or `"foo - Copy (2).md"`, etc. on collision) in the same folder, performs the copy, and returns the new resource key. Pass `showDialog: true` for the interactive form where the rename dialog opens preseeded and the user confirms or types a different name. -The copy runs the same cascade as `explorer_copy` — a paired `.cel` sidecar is copied alongside the parent. References inside the duplicated content are *not* rewritten; they keep pointing at the original targets. +A paired `.cel` sidecar on the source is duplicated alongside the parent under the matching new name (`foo.md` + `foo.md.cel` → `foo - Copy.md` + `foo - Copy.md.cel`), matching the sidecar-pairing behaviour of `explorer_move` and `explorer_delete`. References inside the duplicated content are *not* rewritten; they keep pointing at the original targets — same contract as `explorer_copy`. ## showDialog diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/file_edit.md b/Source/Core/Celbridge.Tools/Guides/Tools/file_edit.md index e1ef67d10..1e384bed6 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/file_edit.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/file_edit.md @@ -16,7 +16,7 @@ Line endings are normalised at match time: pass `\n` or `\r\n` indifferently and A JSON object with: - `matchCount` — the total number of occurrences replaced. -- `affectedLines` — array of `{ from, to, matchCount, contextLines }`. `contextLines` is the post-edit content of the affected range plus one surrounding line on each side, so you can verify the edit without a follow-up `file_read`. Ranges are 1-based inclusive line numbers in the post-edit file, sorted ascending by `from`. **Ranges are per-line, not per-match:** multiple matches on the same line (only possible under `replaceAll`) collapse into one entry whose `matchCount` reports the per-line hit total. The sum of `matchCount` across all entries equals the top-level `matchCount`. **`contextLines` is included on every returned entry, including the sample entries in a truncated response** — when the response is capped, the first/last sample is the only verification signal you have, so keeping its context attached is the point. +- `affectedLines` — array of `{ from, to, matchCount, contextLines }`. `contextLines` is the post-edit content of the affected range plus one surrounding line on each side, so you can verify the edit without a follow-up `file_read`. **Use this for self-auditing:** a clean line deletion shows the surrounding lines adjacent to each other; a partial delete that left an empty line behind shows an empty string `""` between them. At the very start or end of the file the surrounding-line slot on the missing side is simply absent, so `contextLines` has fewer than 3 entries — not a bug, just no neighbour to show. Ranges are 1-based inclusive line numbers in the post-edit file, sorted ascending by `from`. **Ranges are per-line, not per-match:** multiple matches on the same line (only possible under `replaceAll`) collapse into one entry whose `matchCount` reports the per-line hit total. The sum of `matchCount` across all entries equals the top-level `matchCount`. **`contextLines` is included on every returned entry, including the sample entries in a truncated response** — when the response is capped, the first/last sample is the only verification signal you have, so keeping its context attached is the point. - `truncated` — `true` when the response was capped because `matchCount` exceeded the verbose threshold (5). The first 3 ranges and the last 1 range are returned; `matchCount` still reflects the real total. `false` when the full list is returned. ## Failure modes diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md b/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md index 55980fa17..31de72955 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/package_install.md @@ -24,5 +24,5 @@ A JSON object: ## Gotchas -- The downloaded zip is staged briefly under `.celbridge/.cache/` and removed after extraction. A failure mid-extract still cleans up the temp file. +- The downloaded zip is staged briefly under `temp:` and removed after extraction. A failure mid-extract still cleans up the temp file. - An existing `packages/{packageName}` folder causes the call to fail — decide whether to remove it explicitly rather than relying on a flag. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_find.md b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_find.md index 5804dddc9..a7e4e276f 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_find.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_find.md @@ -19,4 +19,6 @@ Numeric, boolean, and date cells are skipped. ## Response shape -For formula cells, `text` is the formula expression without the leading `=` (e.g. `"SUM(C2:F2)"` for the cell `=SUM(C2:F2)`). +Each match carries `sheet`, `cell`, `text`, and `isFormula`. For formula cells, `text` is the formula expression without the leading `=` (e.g. `"SUM(C2:F2)"` for the cell `=SUM(C2:F2)`). + +`isFormula` lets the caller distinguish matches against formula text from matches against displayed values. Searching `"North"` would return both `A2` containing the literal string `"North"` (`isFormula: false`) and `B2` containing `=SUMIF(RawSales!B:B, "North", ...)` (`isFormula: true`) — the latter only matches because the formula expression contains `"North"` as a string literal argument, not because the cell's computed value matches. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_format_ranges.md b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_format_ranges.md index 4345ed643..12a67e406 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_format_ranges.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_format_ranges.md @@ -26,7 +26,7 @@ Each `format` field's value shape is given below. Omit a field to leave that asp ## Common number formats -`numberFormat` takes a raw Excel format string. Reach for these first: +`numberFormat` is **only** a raw Excel format string — pass `"$#,##0.00"`, not a typed wrapper like `{"type": "CURRENCY", "pattern": "$#,##0.00"}`. Reach for these first: | Goal | Pattern | |---|---| diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_get_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_get_info.md index d802dd656..bffaaee72 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_get_info.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_get_info.md @@ -27,6 +27,7 @@ Returns a workbook overview: every sheet with its name, tab position, used range - `usedRange` is `null` for sheets with no used range. - `frozenRows` and `frozenColumns` are 0 on axes with no frozen panes. - Named-range `scope` is `"workbook"` for workbook-scoped names, or the owning sheet name for sheet-scoped names. +- `namedRanges` may include Excel-internal names prefixed with `_xlnm.` (e.g. `_xlnm._FilterDatabase` generated by an auto-filter). These are not user-defined names; filter them out when iterating. ## Detecting an inflated used range diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_set_active_view.md b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_set_active_view.md index 0b1a3abb9..c1e997745 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_set_active_view.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/spreadsheet_set_active_view.md @@ -29,7 +29,7 @@ The cell anchored at the upper-left of the visible viewport on the target sheet. Scroll position is best-effort: frozen panes may clamp `topLeftCell`, and Excel or other host applications may reset it on open. A subsequent `spreadsheet_get_active_view` may report a different `topLeftCell` than the one written. -The response echoes what was submitted, so an empty `topLeftCell` in the write response is the "unchanged" sentinel — not the resolved viewport. Call `spreadsheet_get_active_view` to read the resolved value. +**Response semantics:** the write response echoes the *submitted* values, not the resolved ones. If you pass `topLeftCell: ""` (the "unchanged" sentinel), the response also returns `topLeftCell: ""` — even though the sheet has a real top-left cell. To read the resolved viewport, call `spreadsheet_get_active_view` afterwards. ## Round-tripping a multi-range selection diff --git a/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs b/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs index 5eac574ca..0bf561c72 100644 --- a/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs +++ b/Source/Core/Celbridge.Tools/Tools/AgentToolBase.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Celbridge.Commands; namespace Celbridge.Tools; @@ -11,10 +12,15 @@ namespace Celbridge.Tools; /// public abstract class AgentToolBase { + // UnmappedMemberHandling.Disallow makes typed deserialisation reject unknown + // fields. Agents that typo a property name (e.g. minColor vs lowColor on a + // conditional formatting rule) get a clear error instead of silently running + // with defaults for the field they meant to set. protected static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; private readonly IApplicationServiceProvider _services; diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs index 54ba24bac..93afb20d0 100644 --- a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs @@ -5,7 +5,7 @@ namespace Celbridge.Tools; public partial class DataTools { - /// Report broken project: references, orphan sidecars, and any sidecar that fails to parse cleanly. + /// Report broken project: references, orphan .cel files, and any .cel file that fails to parse cleanly. [McpServerTool(Name = "data_check_project", ReadOnly = true)] [ToolAlias("data.check_project")] [RelatedGuides("resource_keys")] @@ -27,11 +27,11 @@ public async partial Task CheckProject() missingTarget = b.MissingTarget.ToString(), }) .ToArray(), - orphanSidecars = report.OrphanSidecars - .Select(o => o.Sidecar.ToString()) + orphanCelFiles = report.OrphanCelFiles + .Select(o => o.ToString()) .ToArray(), - brokenSidecars = report.BrokenSidecars - .Select(b => b.Sidecar.ToString()) + brokenCelFiles = report.BrokenCelFiles + .Select(b => b.ToString()) .ToArray(), }; diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs index fd61590e3..7d0d3e468 100644 --- a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs @@ -33,6 +33,7 @@ public async partial Task GetInfo(string resource) var report = commandResult.Value; var payload = new { + hasSidecar = report.HasSidecar, fields = report.Fields, blocks = report.Blocks .Select(b => new diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs index 47286d370..4b23f1280 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs @@ -29,7 +29,8 @@ public async partial Task ReadMany(string resources, int offset } catch (JsonException ex) { - return ToolResponse.Error($"Invalid JSON array: {ex.Message}"); + return ToolResponse.Error( + $"resources must be a JSON array of resource keys, e.g. [\"project:notes/a.md\", \"project:notes/b.md\"]. Parse error: {ex.Message}"); } if (resourceKeys is null || resourceKeys.Count == 0) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs index 8a4167ee1..a4b8261ca 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs @@ -15,16 +15,29 @@ public partial class FileTools [McpServerTool(Name = "file_search", ReadOnly = true)] [ToolAlias("file.search")] [RelatedGuides("resource_keys")] - public partial CallToolResult Search(string pattern, bool includeMetadata = false, string type = "") + public async partial Task Search(string pattern, bool includeMetadata = false, string type = "") { var workspaceWrapper = GetRequiredService(); var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; var regexPattern = GlobHelper.PathGlobToRegex(pattern); var regex = new Regex(regexPattern, RegexOptions.IgnoreCase); var isFolderSearch = string.Equals(type, "folder", StringComparison.OrdinalIgnoreCase); + // When the pattern carries a non-default root prefix (logs:, temp:), walk + // that root's filesystem tree via the chokepoint. Patterns with no prefix + // or the project: prefix fall through to the existing in-memory tree path. + var patternRoot = ExtractRootPrefix(pattern); + if (patternRoot is not null + && patternRoot != ResourceKey.DefaultRoot + && resourceRegistry.RootHandlers.ContainsKey(patternRoot)) + { + return await SearchNonDefaultRootAsync( + fileSystem, patternRoot, regex, isFolderSearch, includeMetadata); + } + if (isFolderSearch) { var folderKeys = new List(); @@ -80,4 +93,68 @@ public partial CallToolResult Search(string pattern, bool includeMetadata = fals var resourceStrings = matches.Select(r => r.Resource.ToString()).ToList(); return ToolResponse.Success(SerializeJson(resourceStrings)); } + + // Pulls the "logs" out of "logs:**/*.log". Returns null when the pattern has + // no root prefix or the part before ':' is not a valid root identifier shape. + private static string? ExtractRootPrefix(string pattern) + { + var colonIndex = pattern.IndexOf(':'); + if (colonIndex <= 0) + { + return null; + } + return pattern.Substring(0, colonIndex); + } + + private async Task SearchNonDefaultRootAsync( + IResourceFileSystem fileSystem, + string rootName, + Regex regex, + bool isFolderSearch, + bool includeMetadata) + { + var rootKey = new ResourceKey(rootName + ":"); + var allEntries = new List(); + await CollectRecursiveAsync(fileSystem, rootKey, allEntries); + + var matches = allEntries + .Where(entry => entry.IsFolder == isFolderSearch) + .Where(entry => regex.IsMatch(entry.Resource.ToString())) + .ToList(); + + if (includeMetadata) + { + var results = matches + .Select(entry => new SearchResultWithMetadata( + entry.Resource.ToString(), + entry.IsFolder ? 0 : entry.Size, + entry.ModifiedUtc.ToString("o"))) + .ToList(); + return ToolResponse.Success(SerializeJson(results)); + } + + var resourceStrings = matches.Select(entry => entry.Resource.ToString()).ToList(); + return ToolResponse.Success(SerializeJson(resourceStrings)); + } + + private static async Task CollectRecursiveAsync( + IResourceFileSystem fileSystem, + ResourceKey folder, + List entries) + { + var enumerateResult = await fileSystem.EnumerateFolderAsync(folder); + if (enumerateResult.IsFailure) + { + return; + } + + foreach (var entry in enumerateResult.Value) + { + entries.Add(entry); + if (entry.IsFolder) + { + await CollectRecursiveAsync(fileSystem, entry.Resource, entries); + } + } + } } diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs index 0c4ea7876..802722268 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs @@ -1,7 +1,6 @@ using System.Text.Json; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using File = System.IO.File; namespace Celbridge.Tools; @@ -72,21 +71,11 @@ public async partial Task Install(string packageName, bool confi var workspaceWrapper = GetRequiredService(); var workspaceService = workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; var fileSystem = workspaceService.ResourceFileSystem; - // Write the downloaded zip to a temporary cache file in the project. - // The FS layer ensures the parent folder exists and writes atomically. - var tempArchiveResource = ResourceKey.Create($".celbridge/.cache/{packageName}.zip"); - var resolveTempResult = resourceRegistry.ResolveResourcePath(tempArchiveResource); - if (resolveTempResult.IsFailure) - { - var failure = Result.Fail("Failed to resolve temporary archive path") - .WithErrors(resolveTempResult); - return ToolResponse.Error(failure); - } - var tempArchivePath = resolveTempResult.Value; - + // Stage the downloaded zip under temp: so it lives in .celbridge/temp/ + // (created at workspace load) and is reachable through the chokepoint. + var tempArchiveResource = new ResourceKey($"temp:{packageName}.zip"); var writeArchiveResult = await fileSystem.WriteAllBytesAsync(tempArchiveResource, downloadResult.Value); if (writeArchiveResult.IsFailure) { @@ -116,10 +105,9 @@ public async partial Task Install(string packageName, bool confi } finally { - if (File.Exists(tempArchivePath)) - { - File.Delete(tempArchivePath); - } + // Best-effort cleanup of the staged archive; a failure here does + // not change the install outcome the caller sees. + await fileSystem.DeleteAsync(tempArchiveResource); } } } diff --git a/Source/Core/Celbridge.WebHost/Web/celbridge-client/api/document-api.js b/Source/Core/Celbridge.WebHost/Web/celbridge-client/api/document-api.js index a7186beec..2fa7b97d9 100644 --- a/Source/Core/Celbridge.WebHost/Web/celbridge-client/api/document-api.js +++ b/Source/Core/Celbridge.WebHost/Web/celbridge-client/api/document-api.js @@ -16,6 +16,27 @@ export const ContentLoadedReason = Object.freeze({ ExternalReload: 'external-reload', }); +/** + * Base URL of the project virtual host. Project files are addressable at + * `${PROJECT_HOST_URL}` where is the resource key with the + * "project:" prefix stripped. + */ +export const PROJECT_HOST_URL = 'https://project.celbridge/'; + +/** + * Converts a project resource key to a full URL under the project virtual host. + * Strips the "project:" prefix so the URL path lines up with WebView2's virtual + * host mapping (which serves paths relative to the project folder). Returns the + * bare PROJECT_HOST_URL when the resource key is empty. + */ +export function projectUrl(resourceKey) { + const key = resourceKey || ''; + const path = key.startsWith('project:') + ? key.substring('project:'.length) + : key; + return `${PROJECT_HOST_URL}${path}`; +} + /** * Document operations API. */ diff --git a/Source/Core/Celbridge.WebHost/Web/celbridge-client/tests/document-api.test.js b/Source/Core/Celbridge.WebHost/Web/celbridge-client/tests/document-api.test.js new file mode 100644 index 000000000..04d1dfb9e --- /dev/null +++ b/Source/Core/Celbridge.WebHost/Web/celbridge-client/tests/document-api.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { PROJECT_HOST_URL, projectUrl } from '../api/document-api.js'; + +describe('projectUrl', () => { + it('strips the project: prefix when present', () => { + // Regression: a naive concatenation produced URLs like + // https://project.celbridge/project:packages/foo.png which 404'd + // because WebView2's virtual host mapping treats the URL path as + // relative to the project folder. + const url = projectUrl('project:packages/king-fury/sprites/piece_bishop.png'); + expect(url).toBe('https://project.celbridge/packages/king-fury/sprites/piece_bishop.png'); + }); + + it('returns the bare host URL for an empty resource key', () => { + expect(projectUrl('')).toBe(PROJECT_HOST_URL); + expect(projectUrl(null)).toBe(PROJECT_HOST_URL); + expect(projectUrl(undefined)).toBe(PROJECT_HOST_URL); + }); + + it('passes through a key with no project: prefix', () => { + // The helper is intentionally lenient on its input. A caller that + // already trimmed the prefix should still get a sane URL. + expect(projectUrl('packages/foo.png')).toBe('https://project.celbridge/packages/foo.png'); + }); +}); diff --git a/Source/Modules/Celbridge.Core/Module.cs b/Source/Modules/Celbridge.Core/Module.cs index 2fe6565c1..85f04d10a 100644 --- a/Source/Modules/Celbridge.Core/Module.cs +++ b/Source/Modules/Celbridge.Core/Module.cs @@ -33,7 +33,8 @@ public IReadOnlyList CreateDocumentEditorFactories(IServ return [ new ProjectFileFactory(stringLocalizer), - new ModManifestFactory(stringLocalizer), + new PackageManifestFactory(stringLocalizer), + new DocumentContributionFactory(stringLocalizer), ]; } diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.cel similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.cel diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/preview-pipeline.js b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/preview-pipeline.js index 2d18c5481..b21dd088c 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/preview-pipeline.js +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/preview-pipeline.js @@ -117,10 +117,16 @@ export class PreviewPipeline { } } +// Returns the parent path of a resource key, stripped of the "project:" prefix +// so callers can append it under the https://project.celbridge/ virtual host +// without producing a bogus "project:..." segment in the URL. function extractParentPath(resourceKey) { if (!resourceKey) { return ''; } - const slashIndex = resourceKey.lastIndexOf('/'); - return slashIndex >= 0 ? resourceKey.substring(0, slashIndex + 1) : ''; + const stripped = resourceKey.startsWith('project:') + ? resourceKey.substring('project:'.length) + : resourceKey; + const slashIndex = stripped.lastIndexOf('/'); + return slashIndex >= 0 ? stripped.substring(0, slashIndex + 1) : ''; } diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.cel similarity index 66% rename from Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.cel index a3856c3de..4d2bba168 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.cel @@ -5,10 +5,7 @@ entry_point = "index.html" priority = "specialized" display_name = "CodeEditor_Editor_Markdown" -# Package-defined options threaded into window.__celbridgeContext.options -# and exposed to the editor JS via celbridge.options. -# The code editor checks preview_renderer_url, initial_view_mode, and -# enable_snippet_toolbar to configure the Monaco shell for markdown. +# Surfaced on the JS side as celbridge.options. [options] preview_renderer_url = "https://pkg-celbridge-code-editor.celbridge/markdown-preview/preview-module.js" initial_view_mode = "preview" diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.cel similarity index 60% rename from Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.cel index c4f2b24b3..f5064a8b8 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.toml +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.cel @@ -4,4 +4,4 @@ name = "CodeEditor_Package_Name" version = "1.0.0" [contributes] -document_editors = ["code.document.toml", "markdown.document.toml"] +document_editors = ["code.document.cel", "markdown.document.cel"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.cel similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.cel diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/js/file-viewer.js b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/js/file-viewer.js index ee6adbf18..f074fb8e4 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/js/file-viewer.js +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/js/file-viewer.js @@ -1,9 +1,9 @@ // File viewer initialization for Celbridge WebView integration. // Renders an image, audio, video, or PDF file by loading it from the -// project virtual host (https://project.celbridge/{resourceKey}). +// project virtual host. import celbridge from 'https://shared.celbridge/celbridge-client/celbridge.js'; -import { ContentLoadedReason } from 'https://shared.celbridge/celbridge-client/api/document-api.js'; +import { ContentLoadedReason, projectUrl } from 'https://shared.celbridge/celbridge-client/api/document-api.js'; if (!window.isWebView) { console.log('Not running in WebView, skipping client initialization'); @@ -11,8 +11,6 @@ if (!window.isWebView) { const client = celbridge; -const PROJECT_HOST = 'https://project.celbridge/'; - const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico']); const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.m4a']); const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.avi', '.mov', '.mkv']); @@ -38,7 +36,7 @@ function getExtension(fileName) { function buildResourceUrl(resourceKey) { // Cache-bust on every load so external changes immediately replace the rendered media. const cacheBuster = Date.now(); - return `${PROJECT_HOST}${resourceKey}?t=${cacheBuster}`; + return `${projectUrl(resourceKey)}?t=${cacheBuster}`; } function renderFile(metadata) { diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.cel similarity index 68% rename from Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.cel index 5f7db6c1c..f6935aaec 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.toml +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.cel @@ -4,4 +4,4 @@ name = "FileViewer_Package_Name" version = "1.0.0" [contributes] -document_editors = ["fileviewer.document.toml"] +document_editors = ["fileviewer.document.cel"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/js/note.js b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/js/note.js index 646c1b055..bae491b16 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/js/note.js +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/js/note.js @@ -4,7 +4,7 @@ import { Editor, StarterKit, Link, Placeholder, TaskList, TaskItem, CellSelection, TableMap } from '../lib/tiptap.js'; import { t } from 'https://shared.celbridge/celbridge-client/localization.js'; import celbridge from 'https://shared.celbridge/celbridge-client/celbridge.js'; -import { ContentLoadedReason } from 'https://shared.celbridge/celbridge-client/api/document-api.js'; +import { ContentLoadedReason, PROJECT_HOST_URL, projectUrl } from 'https://shared.celbridge/celbridge-client/api/document-api.js'; import { createImageExtension, init as initImagePopover, toggleImage } from './note-image-popover.js'; import { init as initLinkPopover, toggleLink } from './note-link-popover.js'; @@ -451,11 +451,11 @@ async function initializeEditor() { await client.initializeDocument({ onContent: async (content, metadata) => { // Set base URLs for resolving relative paths - projectBaseUrl = 'https://project.celbridge/'; + projectBaseUrl = PROJECT_HOST_URL; const resourceKey = metadata?.resourceKey || ''; const lastSlash = resourceKey.lastIndexOf('/'); documentBaseUrl = lastSlash >= 0 - ? `${projectBaseUrl}${resourceKey.substring(0, lastSlash + 1)}` + ? projectUrl(resourceKey.substring(0, lastSlash + 1)) : projectBaseUrl; // Localization is auto-loaded by celbridge.js during initialize() diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.cel similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.cel diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.cel similarity index 74% rename from Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.cel index c8c073426..03797b185 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.toml +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.cel @@ -5,4 +5,4 @@ version = "1.0.0" feature_flag = "note-editor" [contributes] -document_editors = ["note.document.toml"] +document_editors = ["note.document.cel"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.cel similarity index 71% rename from Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.cel index 9935895f4..eab034e19 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.toml +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.cel @@ -4,4 +4,4 @@ name = "SceneViewer_Package_Name" version = "1.0.0" [contributes] -document_editors = ["scene.document.toml"] +document_editors = ["scene.document.cel"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.toml b/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.cel similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.toml rename to Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.cel diff --git a/Source/Modules/Celbridge.Spreadsheet/Package/package.toml b/Source/Modules/Celbridge.Spreadsheet/Package/package.cel similarity index 68% rename from Source/Modules/Celbridge.Spreadsheet/Package/package.toml rename to Source/Modules/Celbridge.Spreadsheet/Package/package.cel index 298c31305..b3956be5c 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Package/package.toml +++ b/Source/Modules/Celbridge.Spreadsheet/Package/package.cel @@ -4,4 +4,4 @@ name = "Spreadsheet_Package_Name" version = "1.0.0" [contributes] -document_editors = ["spreadsheet.document.toml"] +document_editors = ["spreadsheet.document.cel"] diff --git a/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.toml b/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.cel similarity index 100% rename from Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.toml rename to Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.cel diff --git a/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs b/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs index cea2090f4..8a187a969 100644 --- a/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs +++ b/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs @@ -5,8 +5,8 @@ namespace Celbridge.WebView.Services; /// -/// Factory for the .webview editor. Produces a WebViewDocumentView configured for -/// the external-URL role; the URL is read from the .webview document's JSON body. +/// Factory for the .webview.cel editor. Produces a WebViewDocumentView configured for +/// the external-URL role; the URL is read from the .webview.cel document's TOML frontmatter. /// public class WebViewEditorFactory : DocumentEditorFactoryBase { @@ -17,7 +17,7 @@ public class WebViewEditorFactory : DocumentEditorFactoryBase public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_WebViewEditor"); - public override IReadOnlyList SupportedExtensions { get; } = [".webview"]; + public override IReadOnlyList SupportedExtensions { get; } = [".webview.cel"]; public WebViewEditorFactory(IServiceProvider serviceProvider, IStringLocalizer stringLocalizer) { diff --git a/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs b/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs index 0e0169630..8ab9b009a 100644 --- a/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs +++ b/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs @@ -1,9 +1,10 @@ -using System.Text.Json; using Celbridge.Commands; using Celbridge.Documents.ViewModels; using Celbridge.Explorer; +using Celbridge.Resources; using Celbridge.WebHost; using Celbridge.WebView.Services; +using Celbridge.Workspace; using CommunityToolkit.Mvvm.ComponentModel; namespace Celbridge.WebView.ViewModels; @@ -11,9 +12,11 @@ namespace Celbridge.WebView.ViewModels; public partial class WebViewDocumentViewModel : DocumentViewModel { private const string ProjectVirtualHost = "project.celbridge"; + private const string SourceUrlFieldName = "source_url"; private readonly ICommandService _commandService; private readonly IWebViewService _webViewService; + private readonly IWorkspaceWrapper _workspaceWrapper; [ObservableProperty] private string _sourceUrl = string.Empty; @@ -21,12 +24,12 @@ public partial class WebViewDocumentViewModel : DocumentViewModel /// /// Selects how LoadContent and NavigateUrl interpret the backing resource. Set /// by the view before the first LoadContent call. Defaults to ExternalUrl, which - /// matches the .webview document behaviour assumed by the parameterless code-gen flow. + /// matches the .webview.cel document behaviour assumed by the parameterless code-gen flow. /// public WebViewDocumentRole Role { get; set; } /// - /// The URL the view should navigate to. For .webview documents this is the configured + /// The URL the view should navigate to. For .webview.cel documents this is the configured /// source URL verbatim; for the HTML viewer it is the project virtual-host URL derived /// from FileResource. /// @@ -59,10 +62,12 @@ public WebViewDocumentViewModel() public WebViewDocumentViewModel( ICommandService commandService, - IWebViewService webViewService) + IWebViewService webViewService, + IWorkspaceWrapper workspaceWrapper) { _commandService = commandService; _webViewService = webViewService; + _workspaceWrapper = workspaceWrapper; } public async Task LoadContent() @@ -75,31 +80,36 @@ public async Task LoadContent() return Result.Ok(); } - string sourceUrl; - try + // The .webview.cel file is a standalone .cel form: SidecarService.ReadAsync + // treats the resource itself as the storage, parses the TOML frontmatter + // through SidecarHelper, and routes IO via the chokepoint so this read + // coordinates with concurrent writes from the inspector panel. + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var readResult = await sidecarService.ReadAsync(FileResource); + if (readResult.IsFailure) { - var text = await File.ReadAllTextAsync(FilePath); - - if (string.IsNullOrEmpty(text)) - { - SourceUrl = string.Empty; - return Result.Ok(); - } - - using var document = JsonDocument.Parse(text); - if (!document.RootElement.TryGetProperty("sourceUrl", out var urlElement)) - { - return Result.Fail($"Failed to load content from .webview file: {FileResource}"); - } + return Result.Fail($"Failed to read '.webview.cel' file '{FileResource}'") + .WithErrors(readResult); + } + var read = readResult.Value; - sourceUrl = urlElement.GetString()?.Trim() ?? string.Empty; + if (read.Outcome == SidecarReadOutcome.Broken) + { + return Result.Fail($"Failed to parse '.webview.cel' file '{FileResource}': {read.FailureMessage ?? "parse failed"}"); } - catch (Exception ex) + + if (read.Outcome == SidecarReadOutcome.NoSidecar + || read.Content is null + || !read.Content.Frontmatter.TryGetValue(SourceUrlFieldName, out var urlObject) + || urlObject is not string urlValue) { - return Result.Fail($"An exception occurred when loading document from file: {FilePath}") - .WithException(ex); + // No file, no frontmatter, or no source_url. Treat as a blank URL so + // the view shows nothing rather than failing the open. + SourceUrl = string.Empty; + return Result.Ok(); } + var sourceUrl = urlValue.Trim(); if (string.IsNullOrEmpty(sourceUrl)) { SourceUrl = string.Empty; @@ -108,7 +118,7 @@ public async Task LoadContent() if (!_webViewService.IsExternalUrl(sourceUrl)) { - return Result.Fail($".webview documents only support external http/https URLs. Configured URL: '{sourceUrl}'"); + return Result.Fail($".webview.cel documents only support external http/https URLs. Configured URL: '{sourceUrl}'"); } SourceUrl = sourceUrl; diff --git a/Source/Templates/Examples/02_webapps/30_days_of_python.webview b/Source/Templates/Examples/02_webapps/30_days_of_python.webview deleted file mode 100644 index 68081f5dd..000000000 --- a/Source/Templates/Examples/02_webapps/30_days_of_python.webview +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sourceUrl": "https://github.com/Asabeneh/30-Days-Of-Python" -} \ No newline at end of file diff --git a/Source/Templates/Examples/02_webapps/30_days_of_python.webview.cel b/Source/Templates/Examples/02_webapps/30_days_of_python.webview.cel new file mode 100644 index 000000000..d58b49b79 --- /dev/null +++ b/Source/Templates/Examples/02_webapps/30_days_of_python.webview.cel @@ -0,0 +1 @@ +source_url = "https://github.com/Asabeneh/30-Days-Of-Python" diff --git a/Source/Templates/Examples/02_webapps/github_issues.webview b/Source/Templates/Examples/02_webapps/github_issues.webview deleted file mode 100644 index 30db44f6d..000000000 --- a/Source/Templates/Examples/02_webapps/github_issues.webview +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sourceUrl": "https://github.com/celbridge-org/celbridge/issues" -} \ No newline at end of file diff --git a/Source/Templates/Examples/02_webapps/github_issues.webview.cel b/Source/Templates/Examples/02_webapps/github_issues.webview.cel new file mode 100644 index 000000000..a9b4bca62 --- /dev/null +++ b/Source/Templates/Examples/02_webapps/github_issues.webview.cel @@ -0,0 +1 @@ +source_url = "https://github.com/celbridge-org/celbridge/issues" diff --git a/Source/Templates/Examples/02_webapps/kleki_paint.webview b/Source/Templates/Examples/02_webapps/kleki_paint.webview deleted file mode 100644 index 0fc59f1f4..000000000 --- a/Source/Templates/Examples/02_webapps/kleki_paint.webview +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sourceUrl": "https://kleki.com/" -} \ No newline at end of file diff --git a/Source/Templates/Examples/02_webapps/kleki_paint.webview.cel b/Source/Templates/Examples/02_webapps/kleki_paint.webview.cel new file mode 100644 index 000000000..b823b4a7f --- /dev/null +++ b/Source/Templates/Examples/02_webapps/kleki_paint.webview.cel @@ -0,0 +1 @@ +source_url = "https://kleki.com/" diff --git a/Source/Templates/Examples/02_webapps/mit_scratch.webview b/Source/Templates/Examples/02_webapps/mit_scratch.webview deleted file mode 100644 index 4fcafdd70..000000000 --- a/Source/Templates/Examples/02_webapps/mit_scratch.webview +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sourceUrl": "https://scratch.mit.edu/" -} \ No newline at end of file diff --git a/Source/Templates/Examples/02_webapps/mit_scratch.webview.cel b/Source/Templates/Examples/02_webapps/mit_scratch.webview.cel new file mode 100644 index 000000000..f6fa8390f --- /dev/null +++ b/Source/Templates/Examples/02_webapps/mit_scratch.webview.cel @@ -0,0 +1 @@ +source_url = "https://scratch.mit.edu/" diff --git a/Source/Templates/Examples/02_webapps/readme.md b/Source/Templates/Examples/02_webapps/readme.md index 70eac1581..ef0578ce5 100644 --- a/Source/Templates/Examples/02_webapps/readme.md +++ b/Source/Templates/Examples/02_webapps/readme.md @@ -1,18 +1,18 @@ # Web App Examples -**.webview** files are a quick and easy way to embed web applications in your Celbridge project. You can use the Explorer window to navigate to your web applications exactly as you would with a text file, Python script or a spreadsheet file. +**.webview.cel** files are a quick and easy way to embed web applications in your Celbridge project. You can use the Explorer window to navigate to your web applications exactly as you would with a text file, Python script or a spreadsheet file. This powerful feature allows you to embed all the web-based productivity applications, dashboards, online documentation, ticketing systems, etc. that you already work with right alongside your project data. -**.webview** files help to reduce the cognitive load associated with using web applications in a traditional browser in a couple ways. +**.webview.cel** files help to reduce the cognitive load associated with using web applications in a traditional browser in a couple ways. -Firstly, the file-based structure is much faster to navigate than a long row of browser tabs. Secondly, unlike browser bookmarks, all opened **.webview** documents retain their previous state. When you open an already active **.webview** file, the page is still in the same state that you left it, so you can pick up right where you left off. +Firstly, the file-based structure is much faster to navigate than a long row of browser tabs. Secondly, unlike browser bookmarks, all opened **.webview.cel** documents retain their previous state. When you open an already active **.webview.cel** file, the page is still in the same state that you left it, so you can pick up right where you left off. These small conveniences soon add up if you use web applications heavily! -Note: While **.webview** files can be used to display any webpage, they are not intended as a general replacement for a dedicated web browser. They work best for web applications & documentation sites that you visit frequently. +Note: While **.webview.cel** files can be used to display any webpage, they are not intended as a general replacement for a dedicated web browser. They work best for web applications & documentation sites that you visit frequently. -This folder contains several example **.webview** files for some great free & open source web applications. +This folder contains several example **.webview.cel** files for some great free & open source web applications. # 30 Days of Python diff --git a/Source/Templates/Examples/02_webapps/wikipedia.webview b/Source/Templates/Examples/02_webapps/wikipedia.webview deleted file mode 100644 index 9a9ea1719..000000000 --- a/Source/Templates/Examples/02_webapps/wikipedia.webview +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sourceUrl": "https://en.wikipedia.org/wiki/Python_(programming_language)" -} \ No newline at end of file diff --git a/Source/Templates/Examples/02_webapps/wikipedia.webview.cel b/Source/Templates/Examples/02_webapps/wikipedia.webview.cel new file mode 100644 index 000000000..e579a6efe --- /dev/null +++ b/Source/Templates/Examples/02_webapps/wikipedia.webview.cel @@ -0,0 +1 @@ +source_url = "https://en.wikipedia.org/wiki/Python_(programming_language)" diff --git a/Source/Templates/Examples/04_data_import/dublinbikes.webview b/Source/Templates/Examples/04_data_import/dublinbikes.webview deleted file mode 100644 index 2ddb9c2a3..000000000 --- a/Source/Templates/Examples/04_data_import/dublinbikes.webview +++ /dev/null @@ -1 +0,0 @@ -{"sourceUrl":"https://www.dublinbikes.ie/en/mapping"} \ No newline at end of file diff --git a/Source/Templates/Examples/04_data_import/dublinbikes.webview.cel b/Source/Templates/Examples/04_data_import/dublinbikes.webview.cel new file mode 100644 index 000000000..9a932c570 --- /dev/null +++ b/Source/Templates/Examples/04_data_import/dublinbikes.webview.cel @@ -0,0 +1 @@ +source_url = "https://www.dublinbikes.ie/en/mapping" diff --git a/Source/Templates/Examples/04_data_import/readme.md b/Source/Templates/Examples/04_data_import/readme.md index b5d102aee..dfaa30ab0 100644 --- a/Source/Templates/Examples/04_data_import/readme.md +++ b/Source/Templates/Examples/04_data_import/readme.md @@ -2,7 +2,7 @@ This example demonstrates downloading data from a public REST API and importing it to a spreadsheet file. -1. Open **dublinbikes.webview** to view the public bikes available in Dublin city. +1. Open **dublinbikes.webview.cel** to view the public bikes available in Dublin city. 2. Click on a station to see how many bikes are available to hire. 3. Right click on **data_import.py** and select **Run** to run the script. Alternatively, ENTER `run "04_data_import/data_import.py"` in the console. 4. The script downloads real-time data from the **Dublin Bikes GBFS API** and saves it to a **dublinbikes.xlsx** Excel file in the same folder. diff --git a/Source/Tests/Documents/DocumentEditorPreferenceStoreTests.cs b/Source/Tests/Documents/DocumentEditorPreferenceStoreTests.cs new file mode 100644 index 000000000..d0ad408a6 --- /dev/null +++ b/Source/Tests/Documents/DocumentEditorPreferenceStoreTests.cs @@ -0,0 +1,220 @@ +using Celbridge.Resources; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Documents; + +/// +/// Covers DocumentEditorPreferenceStore: per-extension reads/writes against +/// workspace settings, sidecar 'editor' lookups, and the effective resolution +/// that prefers the sidecar over the per-extension preference. +/// +[TestFixture] +public class DocumentEditorPreferenceStoreTests +{ + private ISidecarService _sidecarService = null!; + private IWorkspaceSettings _workspaceSettings = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + private DocumentEditorPreferenceStore _store = null!; + + [SetUp] + public void Setup() + { + _sidecarService = Substitute.For(); + _sidecarService.IsSidecarKey(Arg.Any()).Returns(false); + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)))); + + _workspaceSettings = Substitute.For(); + _workspaceSettings.GetPropertyAsync(Arg.Any()).Returns(Task.FromResult(null)); + + var workspaceService = Substitute.For(); + workspaceService.SidecarService.Returns(_sidecarService); + workspaceService.WorkspaceSettings.Returns(_workspaceSettings); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _store = new DocumentEditorPreferenceStore( + _workspaceWrapper, + Substitute.For>()); + } + + [Test] + public async Task GetExtensionPreferenceAsync_ReturnsParsedEditorId() + { + StubExtensionPreference(".md", "test.markdown-editor"); + + var editorId = await _store.GetExtensionPreferenceAsync(".md"); + + editorId.Should().Be(new DocumentEditorId("test.markdown-editor")); + } + + [Test] + public async Task GetExtensionPreferenceAsync_ReturnsEmptyWhenNoPreference() + { + var editorId = await _store.GetExtensionPreferenceAsync(".md"); + + editorId.IsEmpty.Should().BeTrue(); + } + + [Test] + public async Task GetExtensionPreferenceAsync_ReturnsEmptyWhenStoredValueIsMalformed() + { + // DocumentEditorId.TryParse rejects strings that are not a valid id; + // a malformed value should fall through to Empty rather than throw. + StubExtensionPreference(".md", "not a valid id with spaces"); + + var editorId = await _store.GetExtensionPreferenceAsync(".md"); + + editorId.IsEmpty.Should().BeTrue(); + } + + [Test] + public async Task SetExtensionPreferenceAsync_WritesTheEditorIdString() + { + await _store.SetExtensionPreferenceAsync(".md", new DocumentEditorId("test.markdown-editor")); + + var expectedKey = DocumentConstants.GetEditorPreferenceKey(".md"); + await _workspaceSettings.Received(1).SetPropertyAsync(expectedKey, "test.markdown-editor"); + } + + [Test] + public async Task SetExtensionPreferenceAsync_WithEmptyDeletesTheProperty() + { + // Passing Empty signals "clear my preference"; the store should remove + // the underlying key rather than persist an empty string that would + // round-trip as a malformed id. + await _store.SetExtensionPreferenceAsync(".md", DocumentEditorId.Empty); + + var expectedKey = DocumentConstants.GetEditorPreferenceKey(".md"); + await _workspaceSettings.Received(1).DeletePropertyAsync(expectedKey); + await _workspaceSettings.DidNotReceive().SetPropertyAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetSidecarPreferenceAsync_ReturnsParsedEditorIdFromFrontmatter() + { + StubSidecarEditor("test.specific-editor"); + + var result = await _store.GetSidecarPreferenceAsync(new ResourceKey("doc.md")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(new DocumentEditorId("test.specific-editor")); + } + + [Test] + public async Task GetSidecarPreferenceAsync_ReturnsEmptyWhenNoSidecar() + { + var result = await _store.GetSidecarPreferenceAsync(new ResourceKey("doc.md")); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsEmpty.Should().BeTrue(); + } + + [Test] + public async Task GetSidecarPreferenceAsync_ReturnsEmptyWhenSidecarHasNoEditorField() + { + // Healthy sidecar with frontmatter but no 'editor' key means the user + // never set a per-file preference. Treat as "no opinion", not failure. + var content = new SidecarContent( + new Dictionary { ["title"] = "Notes" }, + Array.Empty()); + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.Healthy, content, null)))); + + var result = await _store.GetSidecarPreferenceAsync(new ResourceKey("doc.md")); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsEmpty.Should().BeTrue(); + } + + [Test] + public async Task GetSidecarPreferenceAsync_ReturnsEmptyWhenEditorValueIsMalformed() + { + StubSidecarEditor("not a valid editor id with spaces"); + + var result = await _store.GetSidecarPreferenceAsync(new ResourceKey("doc.md")); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsEmpty.Should().BeTrue(); + } + + [Test] + public async Task GetSidecarPreferenceAsync_ShortCircuitsForSidecarKey() + { + // The sidecar file itself does not have its own sidecar pairing; the + // store must not call ReadAsync on a sidecar resource (which would + // recurse pointlessly through the chokepoint). + _sidecarService.IsSidecarKey(Arg.Any()).Returns(true); + + var result = await _store.GetSidecarPreferenceAsync(new ResourceKey("doc.cel")); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsEmpty.Should().BeTrue(); + await _sidecarService.DidNotReceive().ReadAsync(Arg.Any()); + } + + [Test] + public async Task GetSidecarPreferenceAsync_SurfacesSidecarReadFailure() + { + // A read failure (not NoSidecar/Broken — those are typed outcomes, but + // a Result.Fail from the service) is an unexpected error and should + // surface so the caller can log it rather than be silently treated as + // "no preference". + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Fail("read failed"))); + + var result = await _store.GetSidecarPreferenceAsync(new ResourceKey("doc.md")); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task GetPreferredEditorAsync_PrefersSidecarOverExtensionPreference() + { + StubSidecarEditor("test.sidecar-editor"); + StubExtensionPreference(".md", "test.extension-editor"); + + var editorId = await _store.GetPreferredEditorAsync(new ResourceKey("doc.md")); + + editorId.Should().Be(new DocumentEditorId("test.sidecar-editor")); + } + + [Test] + public async Task GetPreferredEditorAsync_FallsBackToExtensionPreferenceWhenSidecarSilent() + { + StubExtensionPreference(".md", "test.extension-editor"); + + var editorId = await _store.GetPreferredEditorAsync(new ResourceKey("doc.md")); + + editorId.Should().Be(new DocumentEditorId("test.extension-editor")); + } + + [Test] + public async Task GetPreferredEditorAsync_ReturnsEmptyWhenNeitherSourceHasPreference() + { + var editorId = await _store.GetPreferredEditorAsync(new ResourceKey("doc.md")); + + editorId.IsEmpty.Should().BeTrue(); + } + + private void StubExtensionPreference(string extension, string editorId) + { + var preferenceKey = DocumentConstants.GetEditorPreferenceKey(extension); + _workspaceSettings.GetPropertyAsync(preferenceKey).Returns(Task.FromResult(editorId)); + } + + private void StubSidecarEditor(string editorId) + { + var frontmatter = new Dictionary + { + [DocumentConstants.SidecarEditorFieldName] = editorId, + }; + var content = new SidecarContent(frontmatter, Array.Empty()); + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.Healthy, content, null)))); + } +} diff --git a/Source/Tests/Documents/DocumentEditorRegistryTests.cs b/Source/Tests/Documents/DocumentEditorRegistryTests.cs index 12eed2173..1f649026f 100644 --- a/Source/Tests/Documents/DocumentEditorRegistryTests.cs +++ b/Source/Tests/Documents/DocumentEditorRegistryTests.cs @@ -6,7 +6,7 @@ public class DocumentEditorRegistryTests [Test] public void RegisterFactory_AddsFactoryToRegistry() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = CreateMockFactory("test.md-editor", ".md"); @@ -19,7 +19,7 @@ public void RegisterFactory_AddsFactoryToRegistry() [Test] public void RegisterFactory_FailsWithEmptySupportedExtensions() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = Substitute.For(); factory.EditorId.Returns(new DocumentEditorId("test.empty")); @@ -34,7 +34,7 @@ public void RegisterFactory_FailsWithEmptySupportedExtensions() [Test] public void RegisterFactory_AllowsMultipleFactoriesForSameExtension() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory1 = CreateMockFactory("test.md-editor-1", ".md", EditorPriority.Specialized); var factory2 = CreateMockFactory("test.md-editor-2", ".md", EditorPriority.Specialized); @@ -49,7 +49,7 @@ public void RegisterFactory_AllowsMultipleFactoriesForSameExtension() [Test] public void RegisterFactory_SkipsDuplicateDocumentEditorId() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory1 = CreateMockFactory("test.duplicate", ".md"); var factory2 = CreateMockFactory("test.duplicate", ".txt"); @@ -65,9 +65,8 @@ public void RegisterFactory_SkipsDuplicateDocumentEditorId() [Test] public void GetFactory_ReturnsSpecializedPriorityFactoryOverGeneral() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var fileResource = new ResourceKey("test.md"); - var filePath = "/path/test.md"; var generalPriority = CreateMockFactory("test.general", ".md", EditorPriority.General, canHandle: true); var specializedPriority = CreateMockFactory("test.specialized", ".md", EditorPriority.Specialized, canHandle: true); @@ -75,7 +74,7 @@ public void GetFactory_ReturnsSpecializedPriorityFactoryOverGeneral() registry.RegisterFactory(generalPriority); registry.RegisterFactory(specializedPriority); - var result = registry.GetFactory(fileResource, filePath); + var result = registry.GetFactory(fileResource); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(specializedPriority); @@ -84,9 +83,8 @@ public void GetFactory_ReturnsSpecializedPriorityFactoryOverGeneral() [Test] public void GetFactory_FallsBackToGeneralWhenSpecializedCannotHandle() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var fileResource = new ResourceKey("test.md"); - var filePath = "/path/test.md"; var specializedButCantHandle = CreateMockFactory("test.specialized", ".md", EditorPriority.Specialized, canHandle: false); var generalCanHandleResource = CreateMockFactory("test.general", ".md", EditorPriority.General, canHandle: true); @@ -94,7 +92,7 @@ public void GetFactory_FallsBackToGeneralWhenSpecializedCannotHandle() registry.RegisterFactory(specializedButCantHandle); registry.RegisterFactory(generalCanHandleResource); - var result = registry.GetFactory(fileResource, filePath); + var result = registry.GetFactory(fileResource); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(generalCanHandleResource); @@ -103,15 +101,14 @@ public void GetFactory_FallsBackToGeneralWhenSpecializedCannotHandle() [Test] public void GetFactory_FailsWhenNoFactoryCanHandleResource() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var fileResource = new ResourceKey("test.md"); - var filePath = "/path/test.md"; var factory = CreateMockFactory("test.md-editor", ".md", canHandle: false); registry.RegisterFactory(factory); - var result = registry.GetFactory(fileResource, filePath); + var result = registry.GetFactory(fileResource); result.IsFailure.Should().BeTrue(); } @@ -119,11 +116,10 @@ public void GetFactory_FailsWhenNoFactoryCanHandleResource() [Test] public void GetFactory_FailsForUnregisteredExtension() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var fileResource = new ResourceKey("test.xyz"); - var filePath = "/path/test.xyz"; - var result = registry.GetFactory(fileResource, filePath); + var result = registry.GetFactory(fileResource); result.IsFailure.Should().BeTrue(); } @@ -131,7 +127,7 @@ public void GetFactory_FailsForUnregisteredExtension() [Test] public void IsExtensionSupported_ReturnsFalseForUnregisteredExtension() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); registry.IsExtensionSupported(".xyz").Should().BeFalse(); } @@ -139,7 +135,7 @@ public void IsExtensionSupported_ReturnsFalseForUnregisteredExtension() [Test] public void IsExtensionSupported_IsCaseInsensitive() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = CreateMockFactory("test.upper", ".MD"); @@ -153,13 +149,13 @@ public void IsExtensionSupported_IsCaseInsensitive() [Test] public void GetFactory_HandlesMultipleExtensionsPerFactory() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = Substitute.For(); factory.EditorId.Returns(new DocumentEditorId("test.multi-ext")); factory.DisplayName.Returns("Multi Extension Editor"); factory.SupportedExtensions.Returns(new List { ".md", ".markdown", ".mdown" }); - factory.CanHandleResource(Arg.Any(), Arg.Any()).Returns(true); + factory.CanHandleResource(Arg.Any()).Returns(true); registry.RegisterFactory(factory); @@ -167,8 +163,8 @@ public void GetFactory_HandlesMultipleExtensionsPerFactory() registry.IsExtensionSupported(".markdown").Should().BeTrue(); registry.IsExtensionSupported(".mdown").Should().BeTrue(); - var result1 = registry.GetFactory(new ResourceKey("test.md"), "/path/test.md"); - var result2 = registry.GetFactory(new ResourceKey("test.markdown"), "/path/test.markdown"); + var result1 = registry.GetFactory(new ResourceKey("test.md")); + var result2 = registry.GetFactory(new ResourceKey("test.markdown")); result1.IsSuccess.Should().BeTrue(); result2.IsSuccess.Should().BeTrue(); @@ -179,7 +175,7 @@ public void GetFactory_HandlesMultipleExtensionsPerFactory() [Test] public void GetAllFactories_ReturnsAllRegisteredFactories() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory1 = CreateMockFactory("test.md-editor", ".md"); var factory2 = CreateMockFactory("test.txt-editor", ".txt"); @@ -195,9 +191,9 @@ public void GetAllFactories_ReturnsAllRegisteredFactories() } [Test] - public void GetFactoriesForFileExtension_ReturnsAllFactoriesSortedByPriority() + public void GetFactoriesForExtension_ReturnsAllFactoriesSortedByPriority() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var generalFactory = CreateMockFactory("test.general", ".md", EditorPriority.General); var specializedFactory = CreateMockFactory("test.specialized", ".md", EditorPriority.Specialized); @@ -205,7 +201,7 @@ public void GetFactoriesForFileExtension_ReturnsAllFactoriesSortedByPriority() registry.RegisterFactory(generalFactory); registry.RegisterFactory(specializedFactory); - var factories = registry.GetFactoriesForFileExtension(".md"); + var factories = registry.GetFactoriesForExtension(".md"); factories.Should().HaveCount(2); factories[0].Should().Be(specializedFactory); @@ -213,11 +209,11 @@ public void GetFactoriesForFileExtension_ReturnsAllFactoriesSortedByPriority() } [Test] - public void GetFactoriesForFileExtension_ReturnsEmptyForUnknownExtension() + public void GetFactoriesForExtension_ReturnsEmptyForUnknownExtension() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); - var factories = registry.GetFactoriesForFileExtension(".xyz"); + var factories = registry.GetFactoriesForExtension(".xyz"); factories.Should().BeEmpty(); } @@ -225,12 +221,12 @@ public void GetFactoriesForFileExtension_ReturnsEmptyForUnknownExtension() [Test] public void GetFactoryById_ReturnsCorrectFactory() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = CreateMockFactory("test.my-editor", ".md"); registry.RegisterFactory(factory); - var result = registry.GetFactoryById("test.my-editor"); + var result = registry.GetFactoryById(new DocumentEditorId("test.my-editor")); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(factory); @@ -239,13 +235,85 @@ public void GetFactoryById_ReturnsCorrectFactory() [Test] public void GetFactoryById_FailsForUnknownId() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); - var result = registry.GetFactoryById("nonexistent.editor"); + var result = registry.GetFactoryById(new DocumentEditorId("nonexistent.editor")); result.IsFailure.Should().BeTrue(); } + [Test] + public void GetUserPickableFactoriesForResource_FiltersPlaceholders() + { + var sniffer = Substitute.For(); + sniffer.IsBinaryExtension(Arg.Any()).Returns(true); + var registry = new DocumentEditorRegistry(sniffer); + + var placeholder = CreateMockFactory("acme.placeholder", ".widget"); + placeholder.IsPlaceholder.Returns(true); + var real = CreateMockFactory("acme.real", ".widget"); + + registry.RegisterFactory(placeholder); + registry.RegisterFactory(real); + + var candidates = registry.GetUserPickableFactoriesForResource(new ResourceKey("design.widget")); + + candidates.Should().ContainSingle().Which.Should().Be(real); + } + + [Test] + public void GetUserPickableFactoriesForResource_AppendsCodeEditorForTextShapedFiles() + { + var sniffer = Substitute.For(); + sniffer.IsBinaryExtension(".md").Returns(false); + var registry = new DocumentEditorRegistry(sniffer); + + var specialized = CreateMockFactory("acme.markdown", ".md"); + var codeEditor = CreateMockFactory(DocumentConstants.CodeEditorId.ToString(), ".cs"); + + registry.RegisterFactory(specialized); + registry.RegisterFactory(codeEditor); + + var candidates = registry.GetUserPickableFactoriesForResource(new ResourceKey("readme.md")); + + candidates.Should().HaveCount(2); + candidates.Should().Contain(specialized); + candidates.Should().Contain(codeEditor); + } + + [Test] + public void GetUserPickableFactoriesForResource_OmitsCodeEditorForBinaryFiles() + { + var sniffer = Substitute.For(); + sniffer.IsBinaryExtension(".png").Returns(true); + var registry = new DocumentEditorRegistry(sniffer); + + var imageEditor = CreateMockFactory("acme.image", ".png"); + var codeEditor = CreateMockFactory(DocumentConstants.CodeEditorId.ToString(), ".cs"); + + registry.RegisterFactory(imageEditor); + registry.RegisterFactory(codeEditor); + + var candidates = registry.GetUserPickableFactoriesForResource(new ResourceKey("photo.png")); + + candidates.Should().ContainSingle().Which.Should().Be(imageEditor); + } + + [Test] + public void GetUserPickableFactoriesForResource_DoesNotDuplicateCodeEditorWhenAlreadyClaimingExtension() + { + var sniffer = Substitute.For(); + sniffer.IsBinaryExtension(".cs").Returns(false); + var registry = new DocumentEditorRegistry(sniffer); + + var codeEditor = CreateMockFactory(DocumentConstants.CodeEditorId.ToString(), ".cs"); + registry.RegisterFactory(codeEditor); + + var candidates = registry.GetUserPickableFactoriesForResource(new ResourceKey("program.cs")); + + candidates.Should().ContainSingle().Which.Should().Be(codeEditor); + } + private static IDocumentEditorFactory CreateMockFactory( string documentEditorId, string extension, @@ -257,7 +325,7 @@ private static IDocumentEditorFactory CreateMockFactory( factory.DisplayName.Returns(documentEditorId); factory.SupportedExtensions.Returns(new List { extension }); factory.Priority.Returns(priority); - factory.CanHandleResource(Arg.Any(), Arg.Any()).Returns(canHandle); + factory.CanHandleResource(Arg.Any()).Returns(canHandle); return factory; } } diff --git a/Source/Tests/Documents/DocumentLayoutStoreTests.cs b/Source/Tests/Documents/DocumentLayoutStoreTests.cs new file mode 100644 index 000000000..46a797eee --- /dev/null +++ b/Source/Tests/Documents/DocumentLayoutStoreTests.cs @@ -0,0 +1,374 @@ +using Celbridge.Commands; +using Celbridge.Documents.Helpers; +using Celbridge.Resources; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Documents; + +/// +/// Covers DocumentLayoutStore: restore-parsing edge cases (corrupted layout, +/// invalid resource keys, malformed editor ids, section clamps), the +/// default-readme fallback when no layout is stored, and the basic +/// settings-writing shape of the Store* methods. +/// +[TestFixture] +public class DocumentLayoutStoreTests +{ + private IWorkspaceSettings _workspaceSettings = null!; + private IResourceRegistry _resourceRegistry = null!; + private IDocumentsPanel _documentsPanel = null!; + private ICommandService _commandService = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + private FileAccessHelper _fileAccessHelper = null!; + private DocumentLayoutStore _store = null!; + private string _tempFolder = null!; + private string _accessibleFilePath = null!; + + [SetUp] + public void Setup() + { + _tempFolder = Path.Combine(Path.GetTempPath(), "Celbridge", nameof(DocumentLayoutStoreTests)); + Directory.CreateDirectory(_tempFolder); + _accessibleFilePath = Path.Combine(_tempFolder, "accessible.md"); + File.WriteAllText(_accessibleFilePath, string.Empty); + + _workspaceSettings = Substitute.For(); + _resourceRegistry = Substitute.For(); + _documentsPanel = Substitute.For(); + _commandService = Substitute.For(); + + // Default registry behaviour: every key resolves to the accessible temp + // file and exists in the registry. Individual tests override these + // when they want to exercise the negative branches. + _resourceRegistry.ResolveResourcePath(Arg.Any()) + .Returns(Result.Ok(_accessibleFilePath)); + _resourceRegistry.GetResource(Arg.Any()) + .Returns(Result.Ok(Substitute.For())); + + _documentsPanel.OpenDocument(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(OpenDocumentOutcome.Opened))); + _documentsPanel.SectionCount.Returns(1); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.WorkspaceSettings.Returns(_workspaceSettings); + workspaceService.ResourceService.Returns(resourceService); + workspaceService.DocumentsPanel.Returns(_documentsPanel); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _fileAccessHelper = new FileAccessHelper(_workspaceWrapper); + + _store = new DocumentLayoutStore( + _workspaceWrapper, + _commandService, + _fileAccessHelper, + Substitute.For>()); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempFolder)) + { + Directory.Delete(_tempFolder, true); + } + } + + [Test] + public async Task RestorePanelStateAsync_NoStoredLayout_OpensDefaultReadme() + { + // Empty workspace: settings has no layout key, so we fall back to + // opening readme.md if it resolves and is readable. + _resourceRegistry.NormalizeResourceKey(Arg.Any()) + .Returns(ci => Result.Ok(ci.Arg())); + + await _store.RestorePanelStateAsync(); + + // ICommandService.Execute has [CallerFilePath]/[CallerLineNumber] + // parameters that the compiler fills in at each call site, so the + // verification must accept any value for those. + _commandService.Received(1).Execute( + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task RestorePanelStateAsync_NoStoredLayout_SkipsReadmeWhenItDoesNotResolve() + { + // No readme.md in the workspace: NormalizeResourceKey fails; the + // fallback is a no-op rather than an error. + _resourceRegistry.NormalizeResourceKey(Arg.Any()) + .Returns(Result.Fail("not found")); + + await _store.RestorePanelStateAsync(); + + _commandService.DidNotReceive().Execute( + Arg.Any?>(), + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task RestorePanelStateAsync_MalformedLayoutJson_DoesNotThrow() + { + // Old format / corrupted settings: GetPropertyAsync throws inside the + // store, which catches and treats the layout as empty. + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns?>>(_ => throw new InvalidOperationException("bad json")); + _resourceRegistry.NormalizeResourceKey(Arg.Any()) + .Returns(Result.Fail("not found")); + + Func act = async () => await _store.RestorePanelStateAsync(); + + await act.Should().NotThrowAsync(); + } + + [Test] + public async Task RestorePanelStateAsync_RestoresStoredAddressesViaPanelOpen() + { + // One stored doc: the store should call panel.OpenDocument with the + // parsed editor id and target address. + var stored = new List + { + new("notes/readme.md", WindowIndex: 0, SectionIndex: 0, TabOrder: 2, DocumentEditorId: "test.editor"), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.Received(1).OpenDocument( + new ResourceKey("notes/readme.md"), + Arg.Is(options => + options.EditorId == new DocumentEditorId("test.editor") + && options.Activate == false + && options.Address!.SectionIndex == 0 + && options.Address.TabOrder == 2)); + } + + [Test] + public async Task RestorePanelStateAsync_InvalidResourceKey_IsSkipped() + { + // A stored address whose Resource string isn't a valid ResourceKey + // must not abort the rest of the restore. + var stored = new List + { + new("///invalid///", 0, 0, 0), + new("notes/readme.md", 0, 0, 1), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.Received(1).OpenDocument( + new ResourceKey("notes/readme.md"), + Arg.Any()); + await _documentsPanel.Received(1).OpenDocument(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task RestorePanelStateAsync_MissingResource_IsSkipped() + { + // The resource key is well-formed but no longer exists in the registry + // (e.g., the file was deleted between sessions). Skip without failing. + _resourceRegistry.GetResource(new ResourceKey("notes/readme.md")) + .Returns(Result.Fail("missing")); + var stored = new List + { + new("notes/readme.md", 0, 0, 0), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.DidNotReceive().OpenDocument(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task RestorePanelStateAsync_InaccessibleFile_IsSkipped() + { + // ResolveResourcePath returns a path that does not exist on disk. + // FileAccessHelper.CanAccessFile rejects it; the restore skips. + var missingPath = Path.Combine(_tempFolder, "does_not_exist.md"); + _resourceRegistry.ResolveResourcePath(Arg.Any()) + .Returns(Result.Ok(missingPath)); + var stored = new List + { + new("notes/readme.md", 0, 0, 0), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.DidNotReceive().OpenDocument(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task RestorePanelStateAsync_MalformedEditorId_FallsBackToEmpty() + { + // A persisted editor id that no longer parses (renamed format, etc.) + // should be treated as "no preference" rather than aborting the open. + var stored = new List + { + new("notes/readme.md", 0, 0, 0, "totally not a valid id"), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.Received(1).OpenDocument( + Arg.Any(), + Arg.Is(options => options.EditorId.IsEmpty)); + } + + [Test] + public async Task RestorePanelStateAsync_SectionIndexLargerThanCount_ClampsToLastSection() + { + // A previously-saved 3-section layout opened today with a 1-section + // window should merge the over-flowing tabs into the only available + // section rather than dropping them. + _documentsPanel.SectionCount.Returns(1); + var stored = new List + { + new("notes/readme.md", WindowIndex: 0, SectionIndex: 2, TabOrder: 0), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.Received(1).OpenDocument( + Arg.Any(), + Arg.Is(options => options.Address!.SectionIndex == 0)); + } + + [Test] + public async Task RestorePanelStateAsync_AttachesEditorStateJsonByResourceKey() + { + // Saved editor state is indexed by resource key (the canonical + // "project:..." form ResourceKey.ToString emits); the restore must + // forward only the entry that matches each opened tab. + var stored = new List + { + new("notes/readme.md", 0, 0, 0), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + _workspaceSettings.GetPropertyAsync>("DocumentEditorStates") + .Returns(Task.FromResult?>(new Dictionary + { + [new ResourceKey("notes/readme.md").ToString()] = "{\"scroll\":0.5}", + [new ResourceKey("other/file.md").ToString()] = "{\"scroll\":1.0}", + })); + + await _store.RestorePanelStateAsync(); + + await _documentsPanel.Received(1).OpenDocument( + Arg.Any(), + Arg.Is(options => options.EditorStateJson == "{\"scroll\":0.5}")); + } + + [Test] + public async Task RestorePanelStateAsync_RestoresActiveDocumentAfterOpens() + { + var stored = new List + { + new("notes/readme.md", 0, 0, 0), + }; + _workspaceSettings.GetPropertyAsync>("DocumentLayout") + .Returns(Task.FromResult?>(stored)); + _workspaceSettings.GetPropertyAsync("ActiveDocument") + .Returns(Task.FromResult("notes/readme.md")); + + await _store.RestorePanelStateAsync(); + + _documentsPanel.Received().ActiveDocument = new ResourceKey("notes/readme.md"); + } + + [Test] + public async Task RestorePanelStateAsync_AppliesSectionRatiosWhenValid() + { + var ratios = new List { 0.3, 0.7 }; + _workspaceSettings.GetPropertyAsync>("SectionRatios") + .Returns(Task.FromResult?>(ratios)); + _resourceRegistry.NormalizeResourceKey(Arg.Any()) + .Returns(Result.Fail("not found")); + + await _store.RestorePanelStateAsync(); + + _documentsPanel.Received().SectionCount = 2; + _documentsPanel.Received(1).SetSectionRatios(ratios); + } + + [Test] + public async Task StoreActiveDocumentAsync_WritesResourceKeyString() + { + var resource = new ResourceKey("notes/readme.md"); + + await _store.StoreActiveDocumentAsync(resource); + + // ResourceKey.ToString prefixes the default root, so the persisted + // value is "project:notes/readme.md" rather than the bare path. + await _workspaceSettings.Received(1).SetPropertyAsync("ActiveDocument", resource.ToString()); + } + + [Test] + public async Task StoreSectionRatiosAsync_WritesRatiosList() + { + var ratios = new List { 0.5, 0.5 }; + + await _store.StoreSectionRatiosAsync(ratios); + + await _workspaceSettings.Received(1).SetPropertyAsync("SectionRatios", ratios); + } + + [Test] + public async Task StoreDocumentEditorStateAsync_WithStateUpdatesDictionary() + { + var targetResource = new ResourceKey("notes/readme.md"); + var otherResource = new ResourceKey("other/file.md"); + _workspaceSettings.GetPropertyAsync>("DocumentEditorStates") + .Returns(Task.FromResult?>(new Dictionary + { + [otherResource.ToString()] = "{\"scroll\":1.0}", + })); + + await _store.StoreDocumentEditorStateAsync(targetResource, "{\"scroll\":0.5}"); + + await _workspaceSettings.Received(1).SetPropertyAsync( + "DocumentEditorStates", + Arg.Is>(d => + d[targetResource.ToString()] == "{\"scroll\":0.5}" + && d[otherResource.ToString()] == "{\"scroll\":1.0}")); + } + + [Test] + public async Task StoreDocumentEditorStateAsync_WithNullRemovesEntry() + { + var targetResource = new ResourceKey("notes/readme.md"); + var otherResource = new ResourceKey("other/file.md"); + _workspaceSettings.GetPropertyAsync>("DocumentEditorStates") + .Returns(Task.FromResult?>(new Dictionary + { + [targetResource.ToString()] = "{\"scroll\":0.5}", + [otherResource.ToString()] = "{\"scroll\":1.0}", + })); + + await _store.StoreDocumentEditorStateAsync(targetResource, null); + + await _workspaceSettings.Received(1).SetPropertyAsync( + "DocumentEditorStates", + Arg.Is>(d => + !d.ContainsKey(targetResource.ToString()) + && d[otherResource.ToString()] == "{\"scroll\":1.0}")); + } +} diff --git a/Source/Tests/Documents/DocumentViewFactoryTests.cs b/Source/Tests/Documents/DocumentViewFactoryTests.cs new file mode 100644 index 000000000..de34c12d7 --- /dev/null +++ b/Source/Tests/Documents/DocumentViewFactoryTests.cs @@ -0,0 +1,402 @@ +using Celbridge.Documents.Helpers; +using Celbridge.Resources; +using Celbridge.Utilities; +using Celbridge.Workspace; +using Microsoft.Extensions.DependencyInjection; + +namespace Celbridge.Tests.Documents; + +/// +/// Covers DocumentViewFactory.CreateAsync across each step of the +/// resolution chain: sidecar wins, requested editor used directly, workspace +/// preference, priority-based factory, and the text-file fallback that prefers +/// the code editor and skips placeholder factories. +/// +[TestFixture] +public class DocumentViewFactoryTests +{ + private DocumentEditorRegistry _registry = null!; + private ISidecarService _sidecarService = null!; + private IWorkspaceSettings _workspaceSettings = null!; + private IResourceRegistry _resourceRegistry = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + private ITextBinarySniffer _textBinarySniffer = null!; + private FileTypeHelper _fileTypeHelper = null!; + private DocumentEditorPreferenceStore _preferenceStore = null!; + private FileTypeClassifier _classifier = null!; + private IServiceProvider _serviceProvider = null!; + + [SetUp] + public void Setup() + { + _registry = new DocumentEditorRegistry(Substitute.For()); + + _sidecarService = Substitute.For(); + _sidecarService.IsSidecarKey(Arg.Any()).Returns(false); + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)))); + + _workspaceSettings = Substitute.For(); + _workspaceSettings.GetPropertyAsync(Arg.Any()).Returns(Task.FromResult(null)); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ResolveResourcePath(Arg.Any()) + .Returns(Result.Ok("c:/test/fake/path")); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.SidecarService.Returns(_sidecarService); + workspaceService.WorkspaceSettings.Returns(_workspaceSettings); + workspaceService.ResourceService.Returns(resourceService); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _textBinarySniffer = Substitute.For(); + + _fileTypeHelper = new FileTypeHelper(); + _fileTypeHelper.SetDocumentEditorRegistry(_registry); + _fileTypeHelper.Initialize(); + + _preferenceStore = new DocumentEditorPreferenceStore( + _workspaceWrapper, + Substitute.For>()); + + _classifier = new FileTypeClassifier( + _fileTypeHelper, + _textBinarySniffer, + _workspaceWrapper, + _registry); + + _serviceProvider = Substitute.For(); + } + + [Test] + public async Task CreateAsync_FailsWhenResourcePathCannotBeResolved() + { + _resourceRegistry.ResolveResourcePath(Arg.Any()) + .Returns(Result.Fail("missing")); + + var result = await CreateFactory().CreateAsync(new ResourceKey("missing.md"), DocumentEditorId.Empty); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task CreateAsync_SidecarEditor_WinsOverEverythingElse() + { + // A registered factory exists for the extension, but the sidecar names a + // different editor and that's the one that wins. Both factories can + // handle the resource; without the sidecar, priority would pick the + // specialized one. + var sidecarEditorId = new DocumentEditorId("test.sidecar-editor"); + var sidecarView = Substitute.For(); + var sidecarFactory = CreateFakeFactory(sidecarEditorId, ".md", sidecarView, EditorPriority.General); + var defaultFactory = CreateFakeFactory(new DocumentEditorId("test.default-editor"), ".md", + Substitute.For(), EditorPriority.Specialized); + _registry.RegisterFactory(sidecarFactory); + _registry.RegisterFactory(defaultFactory); + + StubSidecarEditor("test.sidecar-editor"); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.md"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(sidecarView); + } + + [Test] + public async Task CreateAsync_SidecarEditor_FallsThroughWhenIdIsUnregistered() + { + // A persisted sidecar id whose package was uninstalled must not block + // the open; the priority-based resolution kicks in and finds the + // currently-registered editor for the extension. + var defaultView = Substitute.For(); + var defaultFactory = CreateFakeFactory(new DocumentEditorId("test.default-editor"), ".md", defaultView); + _registry.RegisterFactory(defaultFactory); + + StubSidecarEditor("test.uninstalled-editor"); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.md"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(defaultView); + } + + [Test] + public async Task CreateAsync_SidecarEditor_FallsThroughWhenFactoryCannotHandleResource() + { + // The sidecar names an editor that's registered but its CanHandleResource + // rejects this file. Resolution continues without losing the open. + var rejectingFactory = CreateFakeFactory( + new DocumentEditorId("test.rejecting"), ".md", + Substitute.For(), canHandle: false); + var defaultView = Substitute.For(); + var defaultFactory = CreateFakeFactory(new DocumentEditorId("test.default"), ".md", defaultView); + _registry.RegisterFactory(rejectingFactory); + _registry.RegisterFactory(defaultFactory); + + StubSidecarEditor("test.rejecting"); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.md"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(defaultView); + } + + [Test] + public async Task CreateAsync_SidecarEditor_CodeEditorIsAcceptedRegardlessOfExtensionClaim() + { + // The code editor is the universal "view as text" choice. Its + // CanHandleResource is keyed to its extension list, so the resolver + // bypasses that check when the sidecar names the code editor id. + var codeView = Substitute.For(); + var codeFactory = CreateFakeFactory( + DocumentConstants.CodeEditorId, ".cs", codeView, canHandle: false); + _registry.RegisterFactory(codeFactory); + + StubSidecarEditor(DocumentConstants.CodeEditorId.ToString()); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.txt"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(codeView); + } + + [Test] + public async Task CreateAsync_RequestedEditor_IsUsedDirectly() + { + var requestedView = Substitute.For(); + var requestedFactory = CreateFakeFactory(new DocumentEditorId("test.requested"), ".md", requestedView); + var otherFactory = CreateFakeFactory(new DocumentEditorId("test.other"), ".md", + Substitute.For(), EditorPriority.Specialized); + _registry.RegisterFactory(requestedFactory); + _registry.RegisterFactory(otherFactory); + + var result = await CreateFactory().CreateAsync( + new ResourceKey("doc.md"), + new DocumentEditorId("test.requested")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(requestedView); + } + + [Test] + public async Task CreateAsync_RequestedEditor_FailsLoudlyWhenFactoryCannotHandle() + { + // Failing to honour an explicit caller request would hide bugs like + // an MCP document_open call passing the wrong extension to a tool. + var requestedFactory = CreateFakeFactory( + new DocumentEditorId("test.requested"), ".md", + Substitute.For(), canHandle: false); + _registry.RegisterFactory(requestedFactory); + + var result = await CreateFactory().CreateAsync( + new ResourceKey("doc.md"), + new DocumentEditorId("test.requested")); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task CreateAsync_RequestedEditor_CodeEditorBypassesExtensionCheck() + { + var codeView = Substitute.For(); + var codeFactory = CreateFakeFactory( + DocumentConstants.CodeEditorId, ".cs", codeView, canHandle: false); + _registry.RegisterFactory(codeFactory); + + var result = await CreateFactory().CreateAsync( + new ResourceKey("doc.txt"), + DocumentConstants.CodeEditorId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(codeView); + } + + [Test] + public async Task CreateAsync_RequestedEditor_FailsWhenIdIsUnregistered() + { + var result = await CreateFactory().CreateAsync( + new ResourceKey("doc.md"), + new DocumentEditorId("test.never-registered")); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task CreateAsync_WorkspacePreference_PicksConfiguredEditor() + { + // No sidecar, no explicit request, but the workspace preference for + // this extension points at a non-specialized editor that should win + // over the priority-default. + var preferredView = Substitute.For(); + var preferredFactory = CreateFakeFactory( + new DocumentEditorId("test.preferred"), ".md", preferredView, EditorPriority.General); + var specializedFactory = CreateFakeFactory( + new DocumentEditorId("test.specialized"), ".md", + Substitute.For(), EditorPriority.Specialized); + _registry.RegisterFactory(preferredFactory); + _registry.RegisterFactory(specializedFactory); + + StubExtensionPreference(".md", "test.preferred"); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.md"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(preferredView); + } + + [Test] + public async Task CreateAsync_PriorityFactory_ChosenWhenNoPreferenceSet() + { + var specializedView = Substitute.For(); + var specializedFactory = CreateFakeFactory( + new DocumentEditorId("test.specialized"), ".md", specializedView, EditorPriority.Specialized); + _registry.RegisterFactory(specializedFactory); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.md"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(specializedView); + } + + [Test] + public async Task CreateAsync_PriorityFactory_PlaceholderIsNeverInvoked() + { + // Placeholder factories reserve an extension but never produce a view. + // The resolver must skip them in the priority step (which it would + // otherwise pick) and not call their CreateDocumentView at any point. + _textBinarySniffer.IsTextFile(Arg.Any()).Returns(Result.Ok(true)); + + var placeholderFactory = CreatePlaceholderFactory( + new DocumentEditorId("test.placeholder"), ".xyz"); + _registry.RegisterFactory(placeholderFactory); + + var codeView = Substitute.For(); + var codeFactory = CreateFakeFactory(DocumentConstants.CodeEditorId, ".cs", codeView); + _registry.RegisterFactory(codeFactory); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.xyz"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(codeView); + placeholderFactory.DidNotReceive().CreateDocumentView(Arg.Any()); + } + + [Test] + public async Task CreateAsync_TextFallback_PrefersCodeEditorForUnknownTextExtensions() + { + // Unknown extension, sniffer reports text, no factory claims it. + // Resolver should route to the code editor's id even though its + // CanHandleResource may reject the extension. + _textBinarySniffer.IsTextFile(Arg.Any()).Returns(Result.Ok(true)); + + var codeView = Substitute.For(); + var codeFactory = CreateFakeFactory( + DocumentConstants.CodeEditorId, ".cs", codeView, canHandle: false); + _registry.RegisterFactory(codeFactory); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.xyz"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(codeView); + } + + [Test] + public async Task CreateAsync_TextFallback_FactoryScanSkipsPlaceholders() + { + // The text-file scan walks every registered factory; placeholder + // factories must not be invoked even if their CanHandleResource would + // accept the file. + _textBinarySniffer.IsTextFile(Arg.Any()).Returns(Result.Ok(true)); + + var placeholderFactory = CreatePlaceholderFactory( + new DocumentEditorId("test.placeholder"), ".xyz"); + _registry.RegisterFactory(placeholderFactory); + + var codeView = Substitute.For(); + var codeFactory = CreateFakeFactory(DocumentConstants.CodeEditorId, ".cs", codeView); + _registry.RegisterFactory(codeFactory); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.xyz"), DocumentEditorId.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(codeView); + placeholderFactory.DidNotReceive().CreateDocumentView(Arg.Any()); + } + + [Test] + public async Task CreateAsync_FailsWithUnsupportedFormatWhenSnifferReportsBinary() + { + _textBinarySniffer.IsTextFile(Arg.Any()).Returns(Result.Ok(false)); + + var result = await CreateFactory().CreateAsync(new ResourceKey("doc.xyz"), DocumentEditorId.Empty); + + result.IsFailure.Should().BeTrue(); + } + + private DocumentViewFactory CreateFactory() + { + return new DocumentViewFactory( + _registry, + _workspaceWrapper, + _preferenceStore, + _classifier, + _serviceProvider, + Substitute.For>()); + } + + private void StubSidecarEditor(string editorId) + { + var frontmatter = new Dictionary + { + [DocumentConstants.SidecarEditorFieldName] = editorId, + }; + var content = new SidecarContent(frontmatter, Array.Empty()); + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.Healthy, content, null)))); + } + + private void StubExtensionPreference(string extension, string editorId) + { + var preferenceKey = DocumentConstants.GetEditorPreferenceKey(extension); + _workspaceSettings.GetPropertyAsync(preferenceKey).Returns(Task.FromResult(editorId)); + } + + private static IDocumentEditorFactory CreateFakeFactory( + DocumentEditorId editorId, + string extension, + IDocumentView view, + EditorPriority priority = EditorPriority.Specialized, + bool canHandle = true) + { + var factory = Substitute.For(); + factory.EditorId.Returns(editorId); + factory.DisplayName.Returns(editorId.ToString()); + factory.SupportedExtensions.Returns(new List { extension }); + factory.Priority.Returns(priority); + factory.IsPlaceholder.Returns(false); + factory.CanHandleResource(Arg.Any()).Returns(canHandle); + factory.CreateDocumentView(Arg.Any()).Returns(Result.Ok(view)); + return factory; + } + + private static IDocumentEditorFactory CreatePlaceholderFactory( + DocumentEditorId editorId, + string extension) + { + var factory = Substitute.For(); + factory.EditorId.Returns(editorId); + factory.DisplayName.Returns(editorId.ToString()); + factory.SupportedExtensions.Returns(new List { extension }); + factory.Priority.Returns(EditorPriority.General); + factory.IsPlaceholder.Returns(true); + factory.CanHandleResource(Arg.Any()).Returns(true); + return factory; + } +} diff --git a/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs index bf2fde444..2a8dad33b 100644 --- a/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs +++ b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs @@ -6,31 +6,31 @@ public class MultiPartExtensionResolutionTests [Test] public void GetFactory_PrefersMultiPartExtensionOverSingleCelFallback() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); - var projectCelFactory = CreateMockFactory("test.project-cel", ".project.cel", EditorPriority.Specialized); + var noteCelFactory = CreateMockFactory("test.note-cel", ".note.cel", EditorPriority.Specialized); var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); - registry.RegisterFactory(projectCelFactory); + registry.RegisterFactory(noteCelFactory); registry.RegisterFactory(celFactory); - var fileResource = new ResourceKey("foo.project.cel"); - var result = registry.GetFactory(fileResource, "/path/foo.project.cel"); + var fileResource = new ResourceKey("foo.note.cel"); + var result = registry.GetFactory(fileResource); result.IsSuccess.Should().BeTrue(); - result.Value.Should().Be(projectCelFactory); + result.Value.Should().Be(noteCelFactory); } [Test] public void GetFactory_FallsBackToSingleCelWhenNoMultiPartFactoryRegistered() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); registry.RegisterFactory(celFactory); var fileResource = new ResourceKey("foo.cel"); - var result = registry.GetFactory(fileResource, "/path/foo.cel"); + var result = registry.GetFactory(fileResource); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(celFactory); @@ -39,36 +39,36 @@ public void GetFactory_FallsBackToSingleCelWhenNoMultiPartFactoryRegistered() [Test] public void GetFactory_MultiPartWinsEvenWhenSingleCelIsAlsoRegistered() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); // Both extensions present and both can handle the resource. Longest match // wins extension selection independently of the priority bands. - var projectCelFactory = CreateMockFactory("test.project-cel", ".project.cel", EditorPriority.Specialized); + var noteCelFactory = CreateMockFactory("test.note-cel", ".note.cel", EditorPriority.Specialized); var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); - registry.RegisterFactory(projectCelFactory); + registry.RegisterFactory(noteCelFactory); registry.RegisterFactory(celFactory); - var fileResource = new ResourceKey("foo.project.cel"); - var result = registry.GetFactory(fileResource, "/path/foo.project.cel"); + var fileResource = new ResourceKey("foo.note.cel"); + var result = registry.GetFactory(fileResource); result.IsSuccess.Should().BeTrue(); - result.Value.Should().Be(projectCelFactory); + result.Value.Should().Be(noteCelFactory); } [Test] public void GetFactory_FactoryRegisteringMultipleMultiPartExtensionsMatchesBoth() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); - var multiFactory = CreateMockFactoryWithExtensions("test.multi-cel", new[] { ".project.cel", ".mod.cel" }); + var multiFactory = CreateMockFactoryWithExtensions("test.multi-cel", new[] { ".note.cel", ".package.cel" }); registry.RegisterFactory(multiFactory); - var projectResult = registry.GetFactory(new ResourceKey("foo.project.cel"), "/path/foo.project.cel"); - var modResult = registry.GetFactory(new ResourceKey("bar.mod.cel"), "/path/bar.mod.cel"); + var noteResult = registry.GetFactory(new ResourceKey("foo.note.cel")); + var modResult = registry.GetFactory(new ResourceKey("bar.package.cel")); - projectResult.IsSuccess.Should().BeTrue(); - projectResult.Value.Should().Be(multiFactory); + noteResult.IsSuccess.Should().BeTrue(); + noteResult.Value.Should().Be(multiFactory); modResult.IsSuccess.Should().BeTrue(); modResult.Value.Should().Be(multiFactory); } @@ -76,15 +76,15 @@ public void GetFactory_FactoryRegisteringMultipleMultiPartExtensionsMatchesBoth( [Test] public void GetFactory_SpecializedStillBeatsGeneralOnSameMultiPartExtension() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); - var specialized = CreateMockFactory("test.special-cel", ".project.cel", EditorPriority.Specialized); - var general = CreateMockFactory("test.general-cel", ".project.cel", EditorPriority.General); + var specialized = CreateMockFactory("test.special-cel", ".note.cel", EditorPriority.Specialized); + var general = CreateMockFactory("test.general-cel", ".note.cel", EditorPriority.General); registry.RegisterFactory(general); registry.RegisterFactory(specialized); - var result = registry.GetFactory(new ResourceKey("foo.project.cel"), "/path/foo.project.cel"); + var result = registry.GetFactory(new ResourceKey("foo.note.cel")); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(specialized); @@ -93,7 +93,7 @@ public void GetFactory_SpecializedStillBeatsGeneralOnSameMultiPartExtension() [Test] public void GetFactory_MatchesByExactFilenameBeforeExtension() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var packageTomlFactory = CreateMockFactoryWithFilenames("test.package-toml", new[] { "package.toml" }); var tomlFactory = CreateMockFactory("test.toml-fallback", ".toml", EditorPriority.General); @@ -101,8 +101,8 @@ public void GetFactory_MatchesByExactFilenameBeforeExtension() registry.RegisterFactory(packageTomlFactory); registry.RegisterFactory(tomlFactory); - var packageResult = registry.GetFactory(new ResourceKey("package.toml"), "/path/package.toml"); - var otherTomlResult = registry.GetFactory(new ResourceKey("other.toml"), "/path/other.toml"); + var packageResult = registry.GetFactory(new ResourceKey("package.toml")); + var otherTomlResult = registry.GetFactory(new ResourceKey("other.toml")); packageResult.IsSuccess.Should().BeTrue(); packageResult.Value.Should().Be(packageTomlFactory); @@ -114,7 +114,7 @@ public void GetFactory_MatchesByExactFilenameBeforeExtension() [Test] public void RegisterFactory_AllowsFilenameOnlyFactory() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = CreateMockFactoryWithFilenames("test.filename-only", new[] { "package.toml" }); @@ -126,7 +126,7 @@ public void RegisterFactory_AllowsFilenameOnlyFactory() [Test] public void RegisterFactory_RejectsFactoryWithNeitherExtensionNorFilename() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var factory = Substitute.For(); factory.EditorId.Returns(new DocumentEditorId("test.empty-both")); @@ -160,7 +160,7 @@ private static IDocumentEditorFactory CreateMockFactoryWithExtensions( factory.SupportedExtensions.Returns(extensions); factory.SupportedFilenames.Returns(Array.Empty()); factory.Priority.Returns(priority); - factory.CanHandleResource(Arg.Any(), Arg.Any()).Returns(canHandle); + factory.CanHandleResource(Arg.Any()).Returns(canHandle); return factory; } @@ -176,7 +176,7 @@ private static IDocumentEditorFactory CreateMockFactoryWithFilenames( factory.SupportedExtensions.Returns(Array.Empty()); factory.SupportedFilenames.Returns(filenames); factory.Priority.Returns(priority); - factory.CanHandleResource(Arg.Any(), Arg.Any()).Returns(canHandle); + factory.CanHandleResource(Arg.Any()).Returns(canHandle); return factory; } } diff --git a/Source/Tests/Explorer/OpenWithMenuOptionTests.cs b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs index 292b38efc..430bd0d2f 100644 --- a/Source/Tests/Explorer/OpenWithMenuOptionTests.cs +++ b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs @@ -4,6 +4,7 @@ using Celbridge.Explorer.Menu; using Celbridge.Explorer.Menu.Options; using Celbridge.Resources; +using Celbridge.Utilities; using Celbridge.Workspace; using Microsoft.Extensions.Localization; @@ -22,6 +23,8 @@ public class OpenWithMenuOptionTests private IDialogService _dialogService = null!; private IWorkspaceWrapper _workspaceWrapper = null!; private IDocumentEditorRegistry _editorRegistry = null!; + private IDocumentsService _documentsService = null!; + private IResourceRegistry _resourceRegistry = null!; private Logging.ILogger _logger = null!; [SetUp] @@ -33,12 +36,23 @@ public void Setup() _logger = Substitute.For>(); _editorRegistry = Substitute.For(); + // Default to an empty candidate list; tests opt-in by stubbing this. + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(Array.Empty()); - var documentsService = Substitute.For(); - documentsService.DocumentEditorRegistry.Returns(_editorRegistry); + _documentsService = Substitute.For(); + _documentsService.DocumentEditorRegistry.Returns(_editorRegistry); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.GetResourceKey(Arg.Any()) + .Returns(callInfo => new ResourceKey(((IResource)callInfo[0]).Name)); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); var workspaceService = Substitute.For(); - workspaceService.DocumentsService.Returns(documentsService); + workspaceService.DocumentsService.Returns(_documentsService); + workspaceService.ResourceService.Returns(resourceService); _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); @@ -74,6 +88,13 @@ private static IFileResource CreateFileResource(string name) return file; } + private static IDocumentEditorFactory CreateFactory(string editorId) + { + var factory = Substitute.For(); + factory.EditorId.Returns(new DocumentEditorId(editorId)); + return factory; + } + [Test] public void GetState_HiddenWhenNoFileClicked() { @@ -90,8 +111,9 @@ public void GetState_HiddenWhenNoFileClicked() public void GetState_HiddenWhenFewerThanTwoEditorsRegistered() { var clickedFile = CreateFileResource("readme.md"); - var singleFactory = Substitute.For(); - _editorRegistry.GetFactoriesForFileExtension(".md").Returns(new[] { singleFactory }); + var singleFactory = CreateFactory("acme.md-only"); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { singleFactory }); var option = CreateOption(); var state = option.GetState(ContextFor(clickedFile)); @@ -104,9 +126,28 @@ public void GetState_HiddenWhenFewerThanTwoEditorsRegistered() public void GetState_VisibleWhenMultipleEditorsRegistered() { var clickedFile = CreateFileResource("readme.md"); - var firstFactory = Substitute.For(); - var secondFactory = Substitute.For(); - _editorRegistry.GetFactoriesForFileExtension(".md").Returns(new[] { firstFactory, secondFactory }); + var firstFactory = CreateFactory("acme.markdown"); + var secondFactory = CreateFactory("acme.code"); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { firstFactory, secondFactory }); + + var option = CreateOption(); + var state = option.GetState(ContextFor(clickedFile)); + + state.IsVisible.Should().BeTrue(); + state.IsEnabled.Should().BeTrue(); + } + + [Test] + public void GetState_VisibleForMultiPartExtensionWithSingleSpecializedEditorPlusFallback() + { + // Registry returns the specialized editor plus the code editor fallback; + // two candidates make the menu visible. + var clickedFile = CreateFileResource("design.widget.cel"); + var specializedEditor = CreateFactory("acme.widget-editor.widget-document"); + var fallback = CreateFactory("celbridge.code-editor.code-document"); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { specializedEditor, fallback }); var option = CreateOption(); var state = option.GetState(ContextFor(clickedFile)); @@ -116,19 +157,116 @@ public void GetState_VisibleWhenMultipleEditorsRegistered() } [Test] - public void GetState_NormalisesExtensionToLowercase() + public void GetState_HiddenForBinaryFileWithSingleSpecializedEditor() + { + // For binary files (.png, .pdf, .zip, etc.) the text fallback must not be + // offered: Monaco would just show garbled bytes. With only one specialized + // editor registered and the fallback suppressed, no second candidate + // remains, so the menu stays hidden. + var clickedFile = CreateFileResource("photo.png"); + var specializedEditor = CreateFactory("acme.binary-editor"); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { specializedEditor }); + + var fallback = CreateFactory("celbridge.code-editor.code-document"); + _editorRegistry.GetFactoryById(DocumentConstants.CodeEditorId) + .Returns(Result.Ok(fallback)); + + var option = CreateOption(); + var state = option.GetState(ContextFor(clickedFile)); + + state.IsVisible.Should().BeFalse(); + } + + [Test] + public void GetState_VisibleForBinaryFileWithMultipleSpecializedEditors() { - var clickedFile = CreateFileResource("README.MD"); - var firstFactory = Substitute.For(); - var secondFactory = Substitute.For(); + // When two or more specialized editors claim a binary file, the menu is + // visible because two real candidates exist. The fallback skip for binary + // files does not prevent the menu showing in this case. + var clickedFile = CreateFileResource("photo.png"); + var firstEditor = CreateFactory("acme.binary-editor-one"); + var secondEditor = CreateFactory("acme.binary-editor-two"); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { firstEditor, secondEditor }); - // Only the lowercase ".md" lookup is wired up. If the option doesn't lowercase the extension - // before querying the registry, this test fails because the default Substitute returns null. - _editorRegistry.GetFactoriesForFileExtension(".md").Returns(new[] { firstFactory, secondFactory }); + var fallback = CreateFactory("celbridge.code-editor.code-document"); + _editorRegistry.GetFactoryById(DocumentConstants.CodeEditorId) + .Returns(Result.Ok(fallback)); var option = CreateOption(); var state = option.GetState(ContextFor(clickedFile)); state.IsVisible.Should().BeTrue(); } + + [Test] + public void GetState_HiddenWhenOnlyCandidateIsTextFallback() + { + // No specialized editor registers for the extension. The fallback alone is + // a single candidate, which is not enough to show the menu (the user would + // have nothing to choose between). + var clickedFile = CreateFileResource("scratch.xyz"); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(Array.Empty()); + + var fallback = CreateFactory("celbridge.code-editor.code-document"); + _editorRegistry.GetFactoryById(DocumentConstants.CodeEditorId) + .Returns(Result.Ok(fallback)); + + var option = CreateOption(); + var state = option.GetState(ContextFor(clickedFile)); + + state.IsVisible.Should().BeFalse(); + } + + [Test] + public void GetState_HiddenForPlaceholderFactoryPlusTextFallback() + { + // Placeholder factories (PackageManifestFactory, ProjectFileFactory, + // DocumentContributionFactory) exist only to register an extension for + // resource classification; they cannot create document views and must + // not appear in the "Open with..." picker. With one placeholder plus + // the text fallback, only the fallback survives the filter, so the + // menu stays hidden (one candidate, nothing to pick between). This + // closes the footgun where picking a placeholder would write a + // non-functional editor id into the manifest's own frontmatter. + var clickedFile = CreateFileResource("package.cel"); + var placeholder = CreateFactory("celbridge.package-manifest"); + placeholder.IsPlaceholder.Returns(true); + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { placeholder }); + + var fallback = CreateFactory("celbridge.code-editor.code-document"); + _editorRegistry.GetFactoryById(DocumentConstants.CodeEditorId) + .Returns(Result.Ok(fallback)); + + var option = CreateOption(); + var state = option.GetState(ContextFor(clickedFile)); + + state.IsVisible.Should().BeFalse(); + } + + [Test] + public void GetState_DoesNotDuplicateTextFallbackWhenAlreadyRegistered() + { + // The code editor registers itself explicitly for .md (alongside the + // markdown preview editor). The augmentation must dedupe by editor id, + // otherwise the dialog would show two "Source Code Editor" entries. + var clickedFile = CreateFileResource("readme.md"); + var fallback = CreateFactory("celbridge.code-editor.code-document"); + + // The registry returns only the fallback, simulating an extension where the + // code editor is the sole registered factory. Without dedup, the augmented + // list would have two copies and falsely report >= 2 candidates. + _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) + .Returns(new[] { fallback }); + _editorRegistry.GetFactoryById(DocumentConstants.CodeEditorId) + .Returns(Result.Ok(fallback)); + + var option = CreateOption(); + var state = option.GetState(ContextFor(clickedFile)); + + state.IsVisible.Should().BeFalse(); + } } diff --git a/Source/Tests/GlobalUsings.cs b/Source/Tests/GlobalUsings.cs index 10ae8c3c4..00bde27bf 100644 --- a/Source/Tests/GlobalUsings.cs +++ b/Source/Tests/GlobalUsings.cs @@ -1,6 +1,7 @@ global using Celbridge.Core; global using Celbridge.Documents; global using Celbridge.Logging; +global using Celbridge.Utilities; global using Celbridge.Documents.Services; global using FluentAssertions; global using NSubstitute; diff --git a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs new file mode 100644 index 000000000..6c5ff2446 --- /dev/null +++ b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs @@ -0,0 +1,299 @@ +using Celbridge.Projects; +using Celbridge.Projects.MigrationSteps; +using Celbridge.Projects.Services; +using Celbridge.Tests.Migration.TestHelpers; +using Tomlyn; +using Tomlyn.Model; + +namespace Celbridge.Tests.Migration.Steps; + +/// +/// Unit tests for MigrationStep_0_3_0 which renames package.toml to package.cel, +/// *.document.toml to *.document.cel, and *.webview to *.webview.cel (converting +/// the JSON body to TOML at the same time). +/// +[TestFixture] +public class MigrationStep_0_3_0_Tests +{ + private ILogger _mockLogger = null!; + private MigrationStep_0_3_0 _step = null!; + private string _projectFolderPath = null!; + private string _projectFilePath = null!; + private string _projectDataFolderPath = null!; + + [SetUp] + public void SetUp() + { + _mockLogger = MigrationTestHelper.CreateMockLogger(); + _step = new MigrationStep_0_3_0(); + + _projectFolderPath = Path.Combine(Path.GetTempPath(), $"MigrationStep_0_3_0_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_projectFolderPath); + + _projectFilePath = Path.Combine(_projectFolderPath, "test.celbridge"); + _projectDataFolderPath = Path.Combine(_projectFolderPath, ProjectConstants.MetaDataFolder); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, recursive: true); + } + catch + { + // Ignore cleanup errors so they do not mask test failures. + } + } + } + + [Test] + public void TargetVersion_IsZeroDotThreeDotZero() + { + _step.TargetVersion.Should().Be(new Version("0.3.0")); + } + + [Test] + public async Task ApplyAsync_RenamesPackageTomlToPackageCel() + { + // Arrange + WriteMinimalProjectFile(); + var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); + Directory.CreateDirectory(packageDir); + var oldManifestPath = Path.Combine(packageDir, "package.toml"); + await File.WriteAllTextAsync(oldManifestPath, "[package]\nid = \"my-package\"\nname = \"My Package\"\nversion = \"1.0.0\"\n"); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + File.Exists(oldManifestPath).Should().BeFalse(); + File.Exists(Path.Combine(packageDir, "package.cel")).Should().BeTrue(); + } + + [Test] + public async Task ApplyAsync_RenamesDocumentTomlToDocumentCel() + { + // Arrange + WriteMinimalProjectFile(); + var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); + Directory.CreateDirectory(packageDir); + var oldDocPath = Path.Combine(packageDir, "myeditor.document.toml"); + await File.WriteAllTextAsync(oldDocPath, "[document]\nid = \"my-doc\"\ntype = \"custom\"\ndisplay_name = \"My Doc\"\n\n[[document_file_types]]\nextension = \".my\"\ndisplay_name = \"My File\"\n"); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + File.Exists(oldDocPath).Should().BeFalse(); + File.Exists(Path.Combine(packageDir, "myeditor.document.cel")).Should().BeTrue(); + } + + [Test] + public async Task ApplyAsync_RewritesDocumentEditorsReferencesInPackageCel() + { + // Arrange + WriteMinimalProjectFile(); + var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); + Directory.CreateDirectory(packageDir); + var oldManifestPath = Path.Combine(packageDir, "package.toml"); + await File.WriteAllTextAsync(oldManifestPath, """ + [package] + id = "my-package" + name = "My Package" + version = "1.0.0" + + [contributes] + document_editors = ["editor-a.document.toml", "editor-b.document.toml"] + """); + await File.WriteAllTextAsync(Path.Combine(packageDir, "editor-a.document.toml"), "[document]\nid = \"a\"\ntype = \"custom\"\ndisplay_name = \"A\"\n[[document_file_types]]\nextension = \".a\"\ndisplay_name = \"A\"\n"); + await File.WriteAllTextAsync(Path.Combine(packageDir, "editor-b.document.toml"), "[document]\nid = \"b\"\ntype = \"custom\"\ndisplay_name = \"B\"\n[[document_file_types]]\nextension = \".b\"\ndisplay_name = \"B\"\n"); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + + var newManifestText = await File.ReadAllTextAsync(Path.Combine(packageDir, "package.cel")); + newManifestText.Should().Contain("\"editor-a.document.cel\""); + newManifestText.Should().Contain("\"editor-b.document.cel\""); + newManifestText.Should().NotContain(".document.toml"); + } + + [Test] + public async Task ApplyAsync_ConvertsWebViewFromJsonToToml() + { + // Arrange + WriteMinimalProjectFile(); + var oldWebViewPath = Path.Combine(_projectFolderPath, "page.webview"); + await File.WriteAllTextAsync(oldWebViewPath, "{\"sourceUrl\": \"https://example.com/path\"}"); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + File.Exists(oldWebViewPath).Should().BeFalse(); + + var newPath = Path.Combine(_projectFolderPath, "page.webview.cel"); + File.Exists(newPath).Should().BeTrue(); + + var newText = await File.ReadAllTextAsync(newPath); + var parsed = Toml.Parse(newText); + parsed.HasErrors.Should().BeFalse(); + + var root = (TomlTable)parsed.ToModel(); + root.TryGetValue("source_url", out var urlValue).Should().BeTrue(); + urlValue.Should().Be("https://example.com/path"); + } + + [Test] + public async Task ApplyAsync_ConvertsWebViewWithMissingSourceUrlToEmptyValue() + { + // Arrange + WriteMinimalProjectFile(); + var oldWebViewPath = Path.Combine(_projectFolderPath, "empty.webview"); + await File.WriteAllTextAsync(oldWebViewPath, "{}"); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + var newPath = Path.Combine(_projectFolderPath, "empty.webview.cel"); + File.Exists(newPath).Should().BeTrue(); + + var newText = await File.ReadAllTextAsync(newPath); + newText.Should().Contain("source_url"); + } + + [Test] + public async Task ApplyAsync_RewritesWebViewReferencesInProjectConfig() + { + // Arrange + var content = """ + [celbridge] + celbridge-version = "0.2.7" + + [project] + name = "TestProject" + entry = "Sites/index.webview" + """; + await File.WriteAllTextAsync(_projectFilePath, content); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + var updated = await File.ReadAllTextAsync(_projectFilePath); + updated.Should().Contain("entry = \"Sites/index.webview.cel\""); + } + + [Test] + public async Task ApplyAsync_SkipsFilesInsideMetaDataFolder() + { + // Arrange + WriteMinimalProjectFile(); + Directory.CreateDirectory(_projectDataFolderPath); + + var metadataWebView = Path.Combine(_projectDataFolderPath, "stale.webview"); + var metadataPackage = Path.Combine(_projectDataFolderPath, "package.toml"); + await File.WriteAllTextAsync(metadataWebView, "{}"); + await File.WriteAllTextAsync(metadataPackage, "[package]\nid = \"x\"\n"); + + var context = CreateContext(); + + // Act + var result = await _step.ApplyAsync(context); + + // Assert + result.IsSuccess.Should().BeTrue(); + File.Exists(metadataWebView).Should().BeTrue(); + File.Exists(metadataPackage).Should().BeTrue(); + File.Exists(Path.Combine(_projectDataFolderPath, "stale.webview.cel")).Should().BeFalse(); + File.Exists(Path.Combine(_projectDataFolderPath, "package.cel")).Should().BeFalse(); + } + + [Test] + public async Task ApplyAsync_IsIdempotent() + { + // Arrange + WriteMinimalProjectFile(); + var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); + Directory.CreateDirectory(packageDir); + await File.WriteAllTextAsync(Path.Combine(packageDir, "package.toml"), "[package]\nid = \"my-package\"\nname = \"My Package\"\nversion = \"1.0.0\"\n"); + await File.WriteAllTextAsync(Path.Combine(_projectFolderPath, "page.webview"), "{\"sourceUrl\": \"https://example.com\"}"); + + var context = CreateContext(); + + // Act - run twice + var firstResult = await _step.ApplyAsync(context); + var secondResult = await _step.ApplyAsync(context); + + // Assert + firstResult.IsSuccess.Should().BeTrue(); + secondResult.IsSuccess.Should().BeTrue(); + File.Exists(Path.Combine(packageDir, "package.toml")).Should().BeFalse(); + File.Exists(Path.Combine(packageDir, "package.cel")).Should().BeTrue(); + File.Exists(Path.Combine(_projectFolderPath, "page.webview")).Should().BeFalse(); + File.Exists(Path.Combine(_projectFolderPath, "page.webview.cel")).Should().BeTrue(); + } + + private void WriteMinimalProjectFile() + { + var content = """ + [celbridge] + celbridge-version = "0.2.7" + + [project] + name = "TestProject" + """; + File.WriteAllText(_projectFilePath, content); + } + + private MigrationContext CreateContext() + { + Func> writeProjectFileAsync = async (text) => + { + try + { + await File.WriteAllTextAsync(_projectFilePath, text); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail("Failed to write project file").WithException(ex); + } + }; + + return new MigrationContext + { + ProjectFilePath = _projectFilePath, + ProjectFolderPath = _projectFolderPath, + ProjectDataFolderPath = _projectDataFolderPath, + Configuration = new TomlTable(), + Logger = _mockLogger, + OriginalVersion = "0.2.7", + WriteProjectFileAsync = writeProjectFileAsync, + }; + } +} diff --git a/Source/Tests/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index 93cf76fdc..b57f6d3ce 100644 --- a/Source/Tests/Packages/FileTypeProviderTests.cs +++ b/Source/Tests/Packages/FileTypeProviderTests.cs @@ -275,15 +275,15 @@ private void CreateBundledPackage( var packageId = $"test.{dirName}"; var featureFlagLine = featureFlag is not null ? $"\nfeature_flag = \"{featureFlag}\"" : ""; - // Write package.toml - File.WriteAllText(Path.Combine(packageDir, "package.toml"), $""" + // Write package.cel + File.WriteAllText(Path.Combine(packageDir, "package.cel"), $""" [package] id = "{packageId}" name = "{packageName}" version = "1.0.0"{featureFlagLine} [contributes] - document_editors = ["editor.document.toml"] + document_editors = ["editor.document.cel"] """); var fileTypesToml = string.Join("\n", fileTypes.Select(ft => $""" @@ -304,7 +304,7 @@ private void CreateBundledPackage( """)); } - File.WriteAllText(Path.Combine(packageDir, "editor.document.toml"), $""" + File.WriteAllText(Path.Combine(packageDir, "editor.document.cel"), $""" [document] id = "{packageId}-doc" type = "custom" diff --git a/Source/Tests/Packages/PackageRegistryTests.cs b/Source/Tests/Packages/PackageRegistryTests.cs index 20255b2ae..5f5fcb640 100644 --- a/Source/Tests/Packages/PackageRegistryTests.cs +++ b/Source/Tests/Packages/PackageRegistryTests.cs @@ -94,7 +94,7 @@ public void RegisterPackages_InvalidManifest_SkipsAndContinues() // Create an invalid package var badDir = Path.Combine(_tempProjectFolder, "packages", "bad"); Directory.CreateDirectory(badDir); - File.WriteAllText(Path.Combine(badDir, "package.toml"), "{ invalid toml }"); + File.WriteAllText(Path.Combine(badDir, "package.cel"), "{ invalid toml }"); _service.RegisterPackages(_tempProjectFolder); @@ -287,7 +287,7 @@ public void RegisterPackages_InvalidBundledManifestSkipped() { var bundledDir = Path.Combine(_tempProjectFolder, "bad-bundled"); Directory.CreateDirectory(bundledDir); - File.WriteAllText(Path.Combine(bundledDir, "package.toml"), "{ invalid toml }"); + File.WriteAllText(Path.Combine(bundledDir, "package.cel"), "{ invalid toml }"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); @@ -345,7 +345,7 @@ public void GetContributingPackage_KnownEditorId_ReturnsThePackage() _service.RegisterPackages(_tempProjectFolder); // CustomDocumentViewFactory builds editor IDs as "{packageId}.{contributionId}". - // The contributionId comes from the [document] table key in package.toml, + // The contributionId comes from the [document] table key in package.cel, // which CreateBundledPackage sets to the docType argument. var editorId = new DocumentEditorId("celbridge.notes.custom"); @@ -407,17 +407,17 @@ private string CreateBundledPackage(string dirName, string packageId, string pac private static void WritePackageFiles(string packageDir, string packageId, string packageName, string docType, string fileExt) { - File.WriteAllText(Path.Combine(packageDir, "package.toml"), $""" + File.WriteAllText(Path.Combine(packageDir, "package.cel"), $""" [package] id = "{packageId}" name = "{packageName}" version = "1.0.0" [contributes] - document_editors = ["editor.document.toml"] + document_editors = ["editor.document.cel"] """); - File.WriteAllText(Path.Combine(packageDir, "editor.document.toml"), $""" + File.WriteAllText(Path.Combine(packageDir, "editor.document.cel"), $""" [document] id = "{packageId}-doc" type = "{docType}" diff --git a/Source/Tests/Resources/CelFileClassifierTests.cs b/Source/Tests/Resources/CelFileClassifierTests.cs deleted file mode 100644 index 34ed3cd78..000000000 --- a/Source/Tests/Resources/CelFileClassifierTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Celbridge.Documents; -using Celbridge.Resources; - -namespace Celbridge.Tests.Resources; - -[TestFixture] -public class CelFileClassifierTests -{ - private IResourceRegistry _resources = null!; - private IDocumentEditorRegistry _editors = null!; - - [SetUp] - public void Setup() - { - _resources = Substitute.For(); - _editors = Substitute.For(); - - // Default: no parent files exist, no editor factories registered. Tests - // override the relevant calls to set up their scenarios. - _resources - .GetResource(Arg.Any()) - .Returns(Result.Fail("not found")); - _editors - .GetFactoriesForFileExtension(Arg.Any()) - .Returns(Array.Empty()); - } - - [Test] - public void Classify_StandaloneWhenMultiPartExtensionRegisteredAndNoParent() - { - RegisterExtension(".project.cel"); - - var result = CelFileClassifier.Classify( - new ResourceKey("foo.project.cel"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Standalone); - } - - [Test] - public void Classify_SidecarWhenParentExistsEvenIfMultiPartExtensionRegistered() - { - RegisterExtension(".project.cel"); - ExistingParentFile("foo.project"); - - var result = CelFileClassifier.Classify( - new ResourceKey("foo.project.cel"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Sidecar); - } - - [Test] - public void Classify_SidecarWhenParentExistsAndNoExtensionRegistered() - { - ExistingParentFile("foo.png"); - - var result = CelFileClassifier.Classify( - new ResourceKey("foo.png.cel"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Sidecar); - } - - [Test] - public void Classify_OrphanWhenNoParentAndNoExtensionRegistered() - { - var result = CelFileClassifier.Classify( - new ResourceKey("foo.png.cel"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Orphan); - } - - [Test] - public void Classify_StandaloneForNestedResourceWithRegisteredExtension() - { - RegisterExtension(".note.cel"); - - var result = CelFileClassifier.Classify( - new ResourceKey("notes/meeting.note.cel"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Standalone); - } - - [Test] - public void Classify_OrphanForBareCelWhenCelNotRegistered() - { - var result = CelFileClassifier.Classify( - new ResourceKey("foo.cel"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Orphan); - } - - [Test] - public void Classify_OrphanForKeyNotEndingInCel() - { - // Defensive: the classifier is only meaningful for .cel-shaped keys. - // A non-.cel key is reported as Orphan rather than raising. - var result = CelFileClassifier.Classify( - new ResourceKey("foo.png"), - _resources, - _editors); - - result.Should().Be(CelFileClassification.Orphan); - } - - private void RegisterExtension(string extension) - { - var factory = Substitute.For(); - _editors - .GetFactoriesForFileExtension(extension) - .Returns(new[] { factory }); - } - - private void ExistingParentFile(string path) - { - var parentKey = new ResourceKey($"project:{path}"); - var fileResource = Substitute.For(); - _resources - .GetResource(parentKey) - .Returns(Result.Ok(fileResource)); - } -} diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index 272ed03e3..212dc7bc6 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -3,7 +3,9 @@ using Celbridge.Messaging.Services; using Celbridge.Resources; using Celbridge.Resources.Commands; +using Celbridge.Resources.Helpers; using Celbridge.Resources.Services; +using Celbridge.Resources.Services.Roots; using Celbridge.UserInterface.Services; using Celbridge.Utilities; using Celbridge.Workspace; @@ -19,6 +21,7 @@ namespace Celbridge.Tests.Resources; public class DataCheckProjectTests { private string _projectFolderPath = null!; + private string _logsBackingFolder = null!; private ResourceRegistry _resourceRegistry = null!; private IMessengerService _messengerService = null!; private IWorkspaceWrapper _workspaceWrapper = null!; @@ -34,13 +37,27 @@ public void Setup() Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_projectFolderPath); + _logsBackingFolder = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(DataCheckProjectTests) + "_logs", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_logsBackingFolder); + _messengerService = new MessengerService(); var fileIconService = new FileIconService(); _resourceRegistry = new ResourceRegistry( Substitute.For>(), _messengerService, - fileIconService); - _resourceRegistry.ProjectFolderPath = _projectFolderPath; + new ProjectTreeBuilder(fileIconService), + SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(), + new RootHandlerRegistry()); + _resourceRegistry.InitializeProjectRoot(_projectFolderPath); + + // ProjectCheckCommand writes its latest report to logs:project-check.log, + // so the chokepoint needs a logs: root or the write step fails. + _resourceRegistry.RegisterRootHandler( + new LogsRootHandler(_logsBackingFolder, new PathValidator())); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); @@ -63,7 +80,9 @@ public void Setup() _workspaceWrapper); workspaceService.ResourceScanner.Returns(scanner); - _command = new ProjectCheckCommand(_workspaceWrapper); + _command = new ProjectCheckCommand( + Substitute.For>(), + _workspaceWrapper); } [TearDown] @@ -80,6 +99,17 @@ public void TearDown() // Best effort } } + if (Directory.Exists(_logsBackingFolder)) + { + try + { + Directory.Delete(_logsBackingFolder, true); + } + catch + { + // Best effort + } + } } [Test] @@ -96,8 +126,8 @@ public async Task CleanProject_AllReportListsAreEmpty() (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); _command.ResultValue.BrokenReferences.Should().BeEmpty(); - _command.ResultValue.OrphanSidecars.Should().BeEmpty(); - _command.ResultValue.BrokenSidecars.Should().BeEmpty(); + _command.ResultValue.OrphanCelFiles.Should().BeEmpty(); + _command.ResultValue.BrokenCelFiles.Should().BeEmpty(); } [Test] @@ -159,7 +189,7 @@ public async Task SidecarOfNonAllowlistedParent_IsStillScanned() } [Test] - public async Task OrphanSidecar_AppearsInReport() + public async Task OrphanCelFile_AppearsInReport() { // foo.png is the would-be parent; only the sidecar exists. File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), @@ -169,12 +199,12 @@ public async Task OrphanSidecar_AppearsInReport() (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); - _command.ResultValue.OrphanSidecars - .Should().Contain(o => o.Sidecar == new ResourceKey("foo.png.cel")); + _command.ResultValue.OrphanCelFiles + .Should().Contain(new ResourceKey("foo.png.cel")); } [Test] - public async Task BrokenSidecar_AppearsInReport() + public async Task BrokenCelFile_AppearsInReport() { File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md"), "Body."); File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md.cel"), @@ -184,12 +214,12 @@ public async Task BrokenSidecar_AppearsInReport() (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); - _command.ResultValue.BrokenSidecars - .Should().Contain(b => b.Sidecar == new ResourceKey("doc.md.cel")); + _command.ResultValue.BrokenCelFiles + .Should().Contain(new ResourceKey("doc.md.cel")); } [Test] - public async Task InvalidSidecarSuffix_AppearsInBrokenList() + public async Task InvalidCelSuffix_AppearsInBrokenList() { // .cel.cel files are classified Broken per the sidecar pairing rules. File.WriteAllText(Path.Combine(_projectFolderPath, "weird.cel.cel"), @@ -199,8 +229,8 @@ public async Task InvalidSidecarSuffix_AppearsInBrokenList() (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); - _command.ResultValue.BrokenSidecars - .Should().Contain(b => b.Sidecar == new ResourceKey("weird.cel.cel")); + _command.ResultValue.BrokenCelFiles + .Should().Contain(new ResourceKey("weird.cel.cel")); } [Test] @@ -229,4 +259,48 @@ public async Task MultipleBrokenReferences_OrderedDeterministically() keys[1].Item2.Should().Be("project:a.json"); keys[2].Item2.Should().Be("project:b.json"); } + + [Test] + public async Task ReportFile_IsWrittenToLogsRoot_WithFindings() + { + // The report file is the durable artifact the UI will eventually link + // to from the project-check warning banner; the command rewrites it on + // every run. + File.WriteAllText(Path.Combine(_projectFolderPath, "source.json"), + "{ \"target\": \"project:missing.json\" }"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "tags = [\"orphaned\"]\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + var reportPath = Path.Combine(_logsBackingFolder, "project-check.log"); + File.Exists(reportPath).Should().BeTrue(); + + var reportText = File.ReadAllText(reportPath); + reportText.Should().Contain("Project consistency check"); + reportText.Should().Contain("Broken references (1):"); + reportText.Should().Contain("project:source.json"); + reportText.Should().Contain("project:missing.json"); + reportText.Should().Contain("Orphan .cel files (1):"); + reportText.Should().Contain("project:foo.png.cel"); + } + + [Test] + public async Task ReportFile_IsWrittenToLogsRoot_NoFindings() + { + // Clean projects still produce a file so the eventual "open report" + // button is never broken. + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + var reportPath = Path.Combine(_logsBackingFolder, "project-check.log"); + File.Exists(reportPath).Should().BeTrue(); + + var reportText = File.ReadAllText(reportPath); + reportText.Should().Contain("Project consistency check"); + reportText.Should().Contain("No findings."); + } } diff --git a/Source/Tests/Resources/ProjectTreeBuilderTests.cs b/Source/Tests/Resources/ProjectTreeBuilderTests.cs new file mode 100644 index 000000000..7106d25e7 --- /dev/null +++ b/Source/Tests/Resources/ProjectTreeBuilderTests.cs @@ -0,0 +1,157 @@ +using Celbridge.Resources; +using Celbridge.Resources.Models; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; + +namespace Celbridge.Tests.Resources; + +/// +/// Direct tests for the project tree builder: disk-to-tree walk, hidden-name +/// filtering, folders-before-files ordering, fresh instances on every call. +/// Targets the builder rather than going through ResourceRegistry so the +/// project-scope filter rules can be exercised cleanly. +/// +[TestFixture] +public class ProjectTreeBuilderTests +{ + private string _projectFolderPath = null!; + private ProjectTreeBuilder _builder = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(ProjectTreeBuilderTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _builder = new ProjectTreeBuilder(new FileIconService()); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public void BuildTree_ProducesFolderResource_WithProjectAsRoot() + { + var tree = _builder.BuildTree(_projectFolderPath); + + tree.Should().NotBeNull(); + tree.Name.Should().BeEmpty(); + tree.ParentFolder.Should().BeNull(); + tree.Children.Should().BeEmpty(); + } + + [Test] + public void BuildTree_AddsFilesAndFolders() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "root.txt"), "x"); + Directory.CreateDirectory(Path.Combine(_projectFolderPath, "sub")); + File.WriteAllText(Path.Combine(_projectFolderPath, "sub", "child.md"), "y"); + + var tree = _builder.BuildTree(_projectFolderPath); + + tree.Children.Should().HaveCount(2); + + // Folders sort before files; "sub" comes first. + tree.Children[0].Should().BeOfType(); + tree.Children[0].Name.Should().Be("sub"); + + tree.Children[1].Should().BeOfType(); + tree.Children[1].Name.Should().Be("root.txt"); + + var sub = (FolderResource)tree.Children[0]; + sub.Children.Should().HaveCount(1); + sub.Children[0].Name.Should().Be("child.md"); + } + + [Test] + public void BuildTree_ExcludesDotPrefixedFiles_AndDotPrefixedFolders() + { + // Leading-dot names are project-hidden (covers .celbridge plus any + // editor scratch files like .gitignore, .vscode/, etc.). + File.WriteAllText(Path.Combine(_projectFolderPath, ".gitignore"), "x"); + File.WriteAllText(Path.Combine(_projectFolderPath, "visible.txt"), "y"); + Directory.CreateDirectory(Path.Combine(_projectFolderPath, ".vscode")); + Directory.CreateDirectory(Path.Combine(_projectFolderPath, "src")); + + var tree = _builder.BuildTree(_projectFolderPath); + + tree.Children.Select(c => c.Name).Should().BeEquivalentTo(new[] { "src", "visible.txt" }); + } + + [Test] + public void BuildTree_ExcludesPyCacheFolders() + { + Directory.CreateDirectory(Path.Combine(_projectFolderPath, "scripts", "__pycache__")); + File.WriteAllText(Path.Combine(_projectFolderPath, "scripts", "__pycache__", "x.pyc"), ""); + File.WriteAllText(Path.Combine(_projectFolderPath, "scripts", "main.py"), ""); + + var tree = _builder.BuildTree(_projectFolderPath); + + var scripts = (FolderResource)tree.Children.Single(c => c.Name == "scripts"); + scripts.Children.Select(c => c.Name).Should().BeEquivalentTo(new[] { "main.py" }); + } + + [Test] + public void BuildTree_ExcludesPythonLibFolder_OnlyWhenParentIsPython() + { + // Python/Lib is excluded (virtualenv pip packages). A "Lib" folder + // anywhere else stays — the exclusion is keyed on the parent name. + Directory.CreateDirectory(Path.Combine(_projectFolderPath, "Python", "Lib")); + File.WriteAllText(Path.Combine(_projectFolderPath, "Python", "Lib", "pkg.py"), ""); + File.WriteAllText(Path.Combine(_projectFolderPath, "Python", "main.py"), ""); + + Directory.CreateDirectory(Path.Combine(_projectFolderPath, "OtherProject", "Lib")); + File.WriteAllText(Path.Combine(_projectFolderPath, "OtherProject", "Lib", "thing.txt"), ""); + + var tree = _builder.BuildTree(_projectFolderPath); + + var python = (FolderResource)tree.Children.Single(c => c.Name == "Python"); + python.Children.Select(c => c.Name).Should().BeEquivalentTo(new[] { "main.py" }); + + var other = (FolderResource)tree.Children.Single(c => c.Name == "OtherProject"); + var otherLib = (FolderResource)other.Children.Single(c => c.Name == "Lib"); + otherLib.Children.Single().Name.Should().Be("thing.txt"); + } + + [Test] + public void BuildTree_ReturnsFreshInstances_EveryCall() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "stable.txt"), "x"); + + var first = _builder.BuildTree(_projectFolderPath); + var second = _builder.BuildTree(_projectFolderPath); + + // Each call rebuilds the tree from scratch so stale UI-bound references + // do not survive an undo/redo or rapid rebuild. + first.Should().NotBeSameAs(second); + first.Children[0].Should().NotBeSameAs(second.Children[0]); + } + + [Test] + public void BuildTree_FilesGetIcons() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.txt"), "x"); + + var tree = _builder.BuildTree(_projectFolderPath); + + var file = (FileResource)tree.Children.Single(); + file.Icon.Should().NotBeNull(); + } +} diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index 30a169a64..ebddc1d1f 100644 --- a/Source/Tests/Resources/ResourceCommandTests.cs +++ b/Source/Tests/Resources/ResourceCommandTests.cs @@ -1,7 +1,10 @@ +using Celbridge.Messaging; using Celbridge.Messaging.Services; using Celbridge.Resources; using Celbridge.Resources.Commands; +using Celbridge.Resources.Helpers; using Celbridge.Resources.Services; +using Celbridge.Resources.Services.Roots; using Celbridge.UserInterface.Services; using Celbridge.Utilities; using Celbridge.Workspace; @@ -46,8 +49,8 @@ public void Setup() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - _resourceRegistry.ProjectFolderPath = _projectFolderPath; + _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + _resourceRegistry.InitializeProjectRoot(_projectFolderPath); _resourceRegistry.UpdateResourceRegistry(); var resourceService = Substitute.For(); @@ -58,6 +61,15 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + // ListFolderContentsCommand and GetFileTreeCommand route through the + // ResourceFileSystem chokepoint, so the workspace needs a real instance + // (a Substitute would return null for EnumerateFolderAsync). + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] @@ -257,4 +269,87 @@ public async Task GetFileTree_WithFileOnlyFilter_OmitsFolders() Guard.IsNotNull(root); root.Children.Should().OnlyContain(childNode => !childNode.IsFolder); } + + // ---- Non-project root coverage -------------------------------------------------------- + + // Registers a logs: root backed by a fresh temp folder pre-populated with the + // supplied entries (string == file with that name; ending in "/" == folder). + // Returns the backing path so the caller can clean up. + private string SetupLogsRoot(params string[] entries) + { + var logsBacking = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(ResourceCommandTests)}_logs/{Guid.NewGuid():N}"); + Directory.CreateDirectory(logsBacking); + foreach (var entry in entries) + { + var fullPath = Path.Combine(logsBacking, entry); + if (entry.EndsWith('/')) + { + Directory.CreateDirectory(fullPath); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, "log content"); + } + } + _resourceRegistry.RegisterRootHandler(new LogsRootHandler(logsBacking, new PathValidator())); + return logsBacking; + } + + [Test] + public async Task ListFolderContents_ForLogsRoot_ReturnsBackingFolderChildren() + { + // Regression for the logs: enumeration bug. Before the fix, this returned + // "Resource not found" because the in-memory tree is project-only. + var logsBacking = SetupLogsRoot("session.log", "errors/", "errors/today.log"); + try + { + var command = new ListFolderContentsCommand(_workspaceWrapper) + { + Resource = new ResourceKey("logs:") + }; + + var result = await command.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + var entries = command.ResultValue.Entries; + entries.Select(entry => entry.Name).Should().BeEquivalentTo(new[] { "session.log", "errors" }); + + var errorsEntry = entries.Single(entry => entry.Name == "errors"); + errorsEntry.IsFolder.Should().BeTrue(); + } + finally + { + Directory.Delete(logsBacking, recursive: true); + } + } + + [Test] + public async Task GetFileTree_ForLogsRoot_WalksBackingFolderRecursively() + { + var logsBacking = SetupLogsRoot("session.log", "errors/", "errors/today.log", "errors/yesterday.log"); + try + { + var command = new GetFileTreeCommand(_workspaceWrapper) + { + Resource = new ResourceKey("logs:"), + Depth = 3 + }; + + var result = await command.ExecuteAsync(); + + result.IsSuccess.Should().BeTrue(); + var root = command.ResultValue.Root; + Guard.IsNotNull(root); + + var errorsNode = root.Children.Single(childNode => childNode.Name == "errors"); + errorsNode.IsFolder.Should().BeTrue(); + errorsNode.Children.Select(childNode => childNode.Name) + .Should().BeEquivalentTo(new[] { "today.log", "yesterday.log" }); + } + finally + { + Directory.Delete(logsBacking, recursive: true); + } + } } diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 32b98bb67..aec3d124a 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -1,5 +1,6 @@ using Celbridge.Explorer.Services; using Celbridge.Messaging.Services; +using Celbridge.Resources; using Celbridge.Resources.Helpers; using Celbridge.Resources.Models; using Celbridge.Resources.Services; @@ -71,8 +72,8 @@ public void ICanUpdateTheResourceTree() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var updateResult = resourceRegistry.UpdateResourceRegistry(); updateResult.IsSuccess.Should().BeTrue(); @@ -110,8 +111,8 @@ public void ICanExpandAFolderResource() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var workspaceWrapper = Substitute.For(); var folderStateService = new FolderStateService(workspaceWrapper); @@ -141,8 +142,8 @@ public void ResolveResourcePathReturnsCorrectAbsolutePath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Create(FileNameA)); resolveResult.IsSuccess.Should().BeTrue(); @@ -157,8 +158,8 @@ public void ResolveResourcePathWithEmptyKeyReturnsProjectFolder() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Empty); resolveResult.IsSuccess.Should().BeTrue(); @@ -173,8 +174,8 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( ResourceKey.Create($"{FolderNameA}/{FileNameB}")); @@ -197,8 +198,8 @@ public void ResolveResourcePathRejectsWrongCaseKey_WhenFileExistsOnDisk() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); // FileA.txt exists on disk (created in Setup); request it as "filea.txt". @@ -221,8 +222,8 @@ public void ResolveResourcePathAcceptsKeyForNonExistentResource() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); var newKey = ResourceKey.Create("NewResource.json"); @@ -240,8 +241,8 @@ public void ResolveResourcePathAcceptsNonExistentPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); // Non-existent files should still resolve without error var resolveResult = resourceRegistry.ResolveResourcePath( @@ -257,8 +258,8 @@ public void ResolveResourcePathRoundTripsWithGetResourceKey() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var filePath = Path.Combine(_resourceFolderPath, FileNameA); var getKeyResult = resourceRegistry.GetResourceKey(filePath); @@ -298,8 +299,8 @@ public void ResolveResourcePathRejectsSymlinksWithinProject() { var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( ResourceKey.Create("escape_link/secret.txt")); @@ -326,12 +327,12 @@ public void ProjectRootHandlerIsRegisteredOnProjectFolderPathSet() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); // Before ProjectFolderPath is set, no handler is registered. resourceRegistry.RootHandlers.Should().BeEmpty(); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.RootHandlers.Should().ContainKey(ResourceKey.DefaultRoot); var handler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; @@ -348,8 +349,8 @@ public void IsResolvableReturnsTrueForProjectRootAndFalseForUnknownRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.IsResolvable(ResourceKey.Create("foo/bar")).Should().BeTrue(); resourceRegistry.IsResolvable(ResourceKey.Create("project:foo/bar")).Should().BeTrue(); @@ -365,8 +366,8 @@ public void ResolveResourcePathFailsClearlyForUnregisteredRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( ResourceKey.Create("temp:foo/bar")); @@ -382,8 +383,8 @@ public void GetAllFileResourcesScopesToProjectRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.UpdateResourceRegistry(); // Default form enumerates the project tree. @@ -405,8 +406,8 @@ public void RegisterRootHandlerReplacesExistingHandler() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var originalHandler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; @@ -417,7 +418,7 @@ public void RegisterRootHandlerReplacesExistingHandler() try { - resourceRegistry.ProjectFolderPath = alternatePath; + resourceRegistry.InitializeProjectRoot(alternatePath); var newHandler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; newHandler.Should().NotBeSameAs(originalHandler); newHandler.BackingLocation.Should().Be(alternatePath); @@ -438,8 +439,8 @@ public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); // Register a temp root whose backing folder is nested inside the project folder. // A path under the nested folder should match the temp root (longer prefix), diff --git a/Source/Tests/Resources/ResourceTreeNavigatorTests.cs b/Source/Tests/Resources/ResourceTreeNavigatorTests.cs new file mode 100644 index 000000000..b1a2f976c --- /dev/null +++ b/Source/Tests/Resources/ResourceTreeNavigatorTests.cs @@ -0,0 +1,126 @@ +using Celbridge.Resources; +using Celbridge.Resources.Helpers; +using Celbridge.Resources.Models; +using Celbridge.UserInterface; + +namespace Celbridge.Tests.Resources; + +/// +/// Direct tests for the static tree-walking helpers. Builds a synthetic +/// IFolderResource tree (no filesystem, no registry) and exercises BuildKey +/// and FindResource on it. +/// +[TestFixture] +public class ResourceTreeNavigatorTests +{ + [Test] + public void BuildKey_RootResource_ReturnsEmptyKey() + { + var root = new FolderResource(string.Empty, null); + + ResourceTreeNavigator.BuildKey(root).IsEmpty.Should().BeTrue(); + } + + [Test] + public void BuildKey_TopLevelFile_ReturnsSingleSegment() + { + var root = new FolderResource(string.Empty, null); + var file = new FileResource("a.txt", root, FakeIcon); + root.AddChild(file); + + ResourceTreeNavigator.BuildKey(file).Path.Should().Be("a.txt"); + } + + [Test] + public void BuildKey_NestedFile_JoinsSegmentsWithSlash() + { + var root = new FolderResource(string.Empty, null); + var sub = new FolderResource("sub", root); + root.AddChild(sub); + var nested = new FileResource("note.md", sub, FakeIcon); + sub.AddChild(nested); + + ResourceTreeNavigator.BuildKey(nested).Path.Should().Be("sub/note.md"); + } + + [Test] + public void FindResource_EmptyKey_ReturnsRoot() + { + var root = new FolderResource(string.Empty, null); + + var result = ResourceTreeNavigator.FindResource(root, ResourceKey.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeSameAs(root); + } + + [Test] + public void FindResource_FileSegment_ReturnsFileResource() + { + var root = new FolderResource(string.Empty, null); + var file = new FileResource("a.txt", root, FakeIcon); + root.AddChild(file); + + var result = ResourceTreeNavigator.FindResource(root, ResourceKey.Create("a.txt")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeSameAs(file); + } + + [Test] + public void FindResource_NestedFile_TraversesAllSegments() + { + var root = new FolderResource(string.Empty, null); + var sub = new FolderResource("sub", root); + root.AddChild(sub); + var nested = new FileResource("note.md", sub, FakeIcon); + sub.AddChild(nested); + + var result = ResourceTreeNavigator.FindResource(root, ResourceKey.Create("sub/note.md")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeSameAs(nested); + } + + [Test] + public void FindResource_FolderSegment_ReturnsFolderResource() + { + var root = new FolderResource(string.Empty, null); + var sub = new FolderResource("sub", root); + root.AddChild(sub); + + var result = ResourceTreeNavigator.FindResource(root, ResourceKey.Create("sub")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeSameAs(sub); + } + + [Test] + public void FindResource_UnknownSegment_FailsWithKeyInMessage() + { + var root = new FolderResource(string.Empty, null); + + var result = ResourceTreeNavigator.FindResource(root, ResourceKey.Create("missing")); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("missing"); + } + + [Test] + public void BuildKey_FindResource_RoundTrip() + { + var root = new FolderResource(string.Empty, null); + var sub = new FolderResource("docs", root); + root.AddChild(sub); + var leaf = new FileResource("readme.md", sub, FakeIcon); + sub.AddChild(leaf); + + var key = ResourceTreeNavigator.BuildKey(leaf); + var found = ResourceTreeNavigator.FindResource(root, key); + + found.IsSuccess.Should().BeTrue(); + found.Value.Should().BeSameAs(leaf); + } + + private static readonly FileIconDefinition FakeIcon = new("x", "#000000", "fa-solid", "12"); +} diff --git a/Source/Tests/Resources/RootHandlerRegistryTests.cs b/Source/Tests/Resources/RootHandlerRegistryTests.cs new file mode 100644 index 000000000..e402d5c3e --- /dev/null +++ b/Source/Tests/Resources/RootHandlerRegistryTests.cs @@ -0,0 +1,152 @@ +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Resources.Services.Roots; + +namespace Celbridge.Tests.Resources; + +/// +/// Direct tests for the cross-root dispatch logic: longest-prefix-wins match, +/// IsResolvable across roots, raw resolve via the matched handler, and +/// InvalidatePathCache propagation. Pulled out of ResourceRegistryTests so +/// the root-registration concern can be exercised on its own surface. +/// +[TestFixture] +public class RootHandlerRegistryTests +{ + private string _projectFolderPath = null!; + private RootHandlerRegistry _rootRegistry = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(RootHandlerRegistryTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + + _rootRegistry = new RootHandlerRegistry(); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public void RegisterRootHandler_AddsHandlerKeyedByRootName() + { + var projectHandler = new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator); + + _rootRegistry.RegisterRootHandler(projectHandler); + + _rootRegistry.RootHandlers.Should().ContainKey(ResourceKey.DefaultRoot); + _rootRegistry.RootHandlers[ResourceKey.DefaultRoot].Should().BeSameAs(projectHandler); + } + + [Test] + public void RegisterRootHandler_ReplacesExistingHandlerForSameRoot() + { + var firstHandler = new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator); + var alternatePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(alternatePath); + + try + { + var secondHandler = new ProjectRootHandler(alternatePath, _rootRegistry.PathValidator); + + _rootRegistry.RegisterRootHandler(firstHandler); + _rootRegistry.RegisterRootHandler(secondHandler); + + _rootRegistry.RootHandlers[ResourceKey.DefaultRoot].Should().BeSameAs(secondHandler); + } + finally + { + Directory.Delete(alternatePath, true); + } + } + + [Test] + public void IsResolvable_ReturnsTrueForRegisteredRoot_FalseOtherwise() + { + _rootRegistry.RegisterRootHandler( + new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + + _rootRegistry.IsResolvable(ResourceKey.Create("foo/bar")).Should().BeTrue(); + _rootRegistry.IsResolvable(ResourceKey.Empty).Should().BeTrue(); + _rootRegistry.IsResolvable(ResourceKey.Create("temp:foo")).Should().BeFalse(); + } + + [Test] + public void GetResourceKey_DispatchesToLongestPrefixRoot() + { + var tempBacking = Path.Combine(_projectFolderPath, ".celbridge", "temp"); + Directory.CreateDirectory(tempBacking); + + _rootRegistry.RegisterRootHandler( + new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + _rootRegistry.RegisterRootHandler( + new TempRootHandler(tempBacking, _rootRegistry.PathValidator)); + + // Path under both roots — temp wins because its backing prefix is longer. + var tempPath = Path.Combine(tempBacking, "staging", "x.txt"); + var tempKey = _rootRegistry.GetResourceKey(tempPath); + tempKey.IsSuccess.Should().BeTrue(); + tempKey.Value.Root.Should().Be("temp"); + tempKey.Value.Path.Should().Be("staging/x.txt"); + + // Path under project only. + File.WriteAllText(Path.Combine(_projectFolderPath, "root.txt"), "x"); + var projectKey = _rootRegistry.GetResourceKey(Path.Combine(_projectFolderPath, "root.txt")); + projectKey.IsSuccess.Should().BeTrue(); + projectKey.Value.Root.Should().Be(ResourceKey.DefaultRoot); + projectKey.Value.Path.Should().Be("root.txt"); + } + + [Test] + public void GetResourceKey_FailsForPathOutsideEveryRoot() + { + _rootRegistry.RegisterRootHandler( + new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + + var outsidePath = Path.Combine(Path.GetTempPath(), "somewhere_else", "file.txt"); + var result = _rootRegistry.GetResourceKey(outsidePath); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("not under any registered resource root"); + } + + [Test] + public void ResolveResourcePath_DelegatesToRegisteredHandler() + { + _rootRegistry.RegisterRootHandler( + new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + + var resolved = _rootRegistry.ResolveResourcePath(ResourceKey.Create("a/b.txt")); + + resolved.IsSuccess.Should().BeTrue(); + resolved.Value.Should().Be(Path.GetFullPath(Path.Combine(_projectFolderPath, "a", "b.txt"))); + } + + [Test] + public void ResolveResourcePath_FailsForUnregisteredRoot() + { + var result = _rootRegistry.ResolveResourcePath(ResourceKey.Create("temp:x")); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("'temp'"); + result.FirstErrorMessage.Should().Contain("not registered"); + } +} diff --git a/Source/Tests/Resources/SidecarClassificationTests.cs b/Source/Tests/Resources/SidecarClassificationTests.cs index 78107dea7..715b0813d 100644 --- a/Source/Tests/Resources/SidecarClassificationTests.cs +++ b/Source/Tests/Resources/SidecarClassificationTests.cs @@ -31,8 +31,10 @@ public void Setup() _registry = new ResourceRegistry( Substitute.For>(), new MessengerService(), - new FileIconService()); - _registry.ProjectFolderPath = _projectFolderPath; + new ProjectTreeBuilder(new FileIconService()), + SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(), + new RootHandlerRegistry()); + _registry.InitializeProjectRoot(_projectFolderPath); } [TearDown] diff --git a/Source/Tests/Resources/SidecarPairingServiceTests.cs b/Source/Tests/Resources/SidecarPairingServiceTests.cs new file mode 100644 index 000000000..dbe21e613 --- /dev/null +++ b/Source/Tests/Resources/SidecarPairingServiceTests.cs @@ -0,0 +1,195 @@ +using Celbridge.Documents; +using Celbridge.Resources; +using Celbridge.Resources.Services; + +namespace Celbridge.Tests.Resources; + +/// +/// Direct unit tests for the sidecar pairing pass: parent pairing, parentless +/// classification (standalone-form vs orphan), and the broken / healthy split. +/// The previous behaviour was tested only end-to-end through ResourceRegistry +/// with a nullable workspace wrapper, which silently disabled the standalone- +/// form recognition in tests; this fixture covers the cross-domain decision +/// directly. +/// +/// The pairing service reads sidecar bytes from disk to drive SidecarHelper.Inspect, +/// so tests still set up real files; the value is that they target the service +/// surface rather than the registry. +/// +[TestFixture] +public class SidecarPairingServiceTests +{ + private string _projectFolderPath = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(SidecarPairingServiceTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_projectFolderPath); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public void StandaloneCelWithMultiPartExtensionRegistration_IsNotReportedAsOrphan() + { + // A .cel file whose multi-part extension is claimed by a registered + // editor factory is a standalone .cel form (e.g. foo.webview.cel, + // foo.note.cel). It has no parent and must not appear in Orphan. + File.WriteAllText(Path.Combine(_projectFolderPath, "feature.note.cel"), + "[note]\ntitle = \"Hello\"\n"); + + var editorRegistry = Substitute.For(); + editorRegistry.GetFactory( + Arg.Is(k => k.ToString().EndsWith("feature.note.cel"))) + .Returns(Result.Ok(Substitute.For())); + editorRegistry.GetFactory( + Arg.Is(k => !k.ToString().EndsWith("feature.note.cel"))) + .Returns(Result.Fail("no factory")); + + var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Orphan.Should().NotContain(new ResourceKey("feature.note.cel")); + report.Healthy.Should().Contain(new ResourceKey("feature.note.cel")); + } + + [Test] + public void StandaloneCelWithFilenameOnlyRegistration_IsNotReportedAsOrphan() + { + // Regression for "package.cel": a filename-only factory registration must + // also drive standalone classification. Earlier code computed a multi- + // part suffix and missed the bare-filename case, so every package.cel showed + // up in the orphan list. + File.WriteAllText(Path.Combine(_projectFolderPath, "package.cel"), + "[package]\nid = \"acme\"\nname = \"Acme\"\nversion = \"1.0.0\"\n"); + + var editorRegistry = Substitute.For(); + editorRegistry.GetFactory( + Arg.Is(k => k.ToString().EndsWith("package.cel"))) + .Returns(Result.Ok(Substitute.For())); + editorRegistry.GetFactory( + Arg.Is(k => !k.ToString().EndsWith("package.cel"))) + .Returns(Result.Fail("no factory")); + + var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Orphan.Should().NotContain(new ResourceKey("package.cel")); + report.Healthy.Should().Contain(new ResourceKey("package.cel")); + } + + [Test] + public void OrphanCelWithNoFactoryClaim_IsStillReportedAsOrphan() + { + // When the editor registry is wired up but no factory claims the + // file, the .cel is a genuine orphan that the user needs to repair. + // The registry hookup must not paper over real orphans. + File.WriteAllText(Path.Combine(_projectFolderPath, "scratch.unknown.cel"), + "key = \"value\"\n"); + + var pairingService = SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Orphan.Should().Contain(new ResourceKey("scratch.unknown.cel")); + } + + [Test] + public void ParentedSidecar_IsNeverConsultedAgainstEditorRegistry() + { + // A .cel that pairs with a sibling parent is never a candidate for + // standalone-form classification, so the editor registry must not + // be queried for it. Guards against an edge case where a hypothetical + // factory match would otherwise mis-classify a real sidecar. + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "tags = [\"x\"]\n"); + + var editorRegistry = Substitute.For(); + editorRegistry.GetFactory(Arg.Any()) + .Returns(Result.Fail("no factory")); + + var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + editorRegistry.DidNotReceive().GetFactory(new ResourceKey("foo.png.cel")); + + var report = registry.GetSidecarReport(); + report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); + report.Orphan.Should().NotContain(new ResourceKey("foo.png.cel")); + } + + [Test] + public void NestedFolders_PairCorrectly_AndReportUsesRelativeKeys() + { + // Make sure the pairing pass walks nested folders and produces project- + // relative keys (not just leaf names). Catches a regression where the + // service mistakenly built keys from leaf-only names. + var sub = Path.Combine(_projectFolderPath, "subfolder"); + Directory.CreateDirectory(sub); + File.WriteAllText(Path.Combine(sub, "note.md"), "body"); + File.WriteAllText(Path.Combine(sub, "note.md.cel"), "tags = [\"meeting\"]\n"); + + var pairingService = SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var noteResource = registry.GetResource(new ResourceKey("subfolder/note.md")).Value as IFileResource; + noteResource!.Sidecar.Should().NotBeNull(); + noteResource.Sidecar!.Key.Should().Be(new ResourceKey("subfolder/note.md.cel")); + noteResource.Sidecar.Status.Should().Be(SidecarStatus.Healthy); + + registry.GetSidecarReport() + .Healthy.Should().Contain(new ResourceKey("subfolder/note.md.cel")); + } + + [Test] + public void EmptyTree_ProducesEmptyReport() + { + var pairingService = SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Healthy.Should().BeEmpty(); + report.Broken.Should().BeEmpty(); + report.Orphan.Should().BeEmpty(); + } + + private ResourceRegistry BuildRegistry(SidecarPairingService pairingService) + { + var registry = new ResourceRegistry( + Substitute.For>(), + new Celbridge.Messaging.Services.MessengerService(), + new ProjectTreeBuilder(new Celbridge.UserInterface.Services.FileIconService()), + pairingService, + new RootHandlerRegistry()); + registry.InitializeProjectRoot(_projectFolderPath); + return registry; + } +} diff --git a/Source/Tests/Resources/SidecarPairingTestHelper.cs b/Source/Tests/Resources/SidecarPairingTestHelper.cs new file mode 100644 index 000000000..6cdbe8870 --- /dev/null +++ b/Source/Tests/Resources/SidecarPairingTestHelper.cs @@ -0,0 +1,71 @@ +using Celbridge.Documents; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Helpers for tests that need a real SidecarPairingService — typically tests +/// that exercise code paths reading the sidecar report or per-file Sidecar +/// pairing through the resource registry. +/// +internal static class SidecarPairingTestHelper +{ + /// + /// Builds a stub that returns an empty pairing result on every call. Use + /// for tests that exercise the registry but do not care about sidecar + /// classification (most ResourceRegistry tests). Avoids needing a real + /// workspace wrapper. + /// + public static ISidecarPairingService BuildEmptyStub() + { + var stub = Substitute.For(); + var emptyReport = new SidecarReport( + Healthy: Array.Empty(), + Broken: Array.Empty(), + Orphan: Array.Empty()); + var emptyResult = new SidecarPairingResult( + emptyReport, + new Dictionary()); + stub.ComputePairings(Arg.Any(), Arg.Any()) + .Returns(emptyResult); + return stub; + } + + /// + /// Builds a real SidecarPairingService wrapped around an editor registry + /// that claims no factories. Every parentless .cel file is classified as + /// an orphan, which matches the default expectation for tests that are + /// not exercising standalone-form recognition. + /// + public static SidecarPairingService BuildPairingServiceWithNoFactories() + { + var editorRegistry = Substitute.For(); + editorRegistry.GetFactory(Arg.Any()) + .Returns(Result.Fail("no factory")); + return BuildPairingService(editorRegistry); + } + + /// + /// Builds a real SidecarPairingService wrapped around the supplied editor + /// registry. Use when the test wants to stub specific standalone-form + /// recognition rules (e.g. package.cel, foo.webview.cel). + /// + public static SidecarPairingService BuildPairingService(IDocumentEditorRegistry editorRegistry) + { + var documentsService = Substitute.For(); + documentsService.DocumentEditorRegistry.Returns(editorRegistry); + + var workspaceService = Substitute.For(); + workspaceService.DocumentsService.Returns(documentsService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + workspaceWrapper.IsWorkspacePageLoaded.Returns(true); + + return new SidecarPairingService( + Substitute.For>(), + workspaceWrapper); + } +} diff --git a/Source/Tests/Resources/SidecarServiceTests.cs b/Source/Tests/Resources/SidecarServiceTests.cs new file mode 100644 index 000000000..43dae1148 --- /dev/null +++ b/Source/Tests/Resources/SidecarServiceTests.cs @@ -0,0 +1,338 @@ +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for SidecarService's dispatch between sibling-sidecar storage (regular +/// files) and self-storage (standalone .cel files), plus the idempotent-write +/// and validation behavior of the typed mutation surface. The TOML format +/// itself is covered by SidecarHelperTests; these tests assert which file gets +/// read or written and which inputs are rejected at the service boundary. +/// +[TestFixture] +public class SidecarServiceTests +{ + private IResourceFileSystem _fileSystem = null!; + private SidecarService _sidecarService = null!; + + [SetUp] + public void Setup() + { + _fileSystem = Substitute.For(); + // Default: nothing exists on disk. Tests opt-in per resource. + _fileSystem.ExistsAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok(false))); + + var workspaceService = Substitute.For(); + workspaceService.ResourceFileSystem.Returns(_fileSystem); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _sidecarService = new SidecarService(workspaceWrapper); + } + + [Test] + public void GetSidecarKey_FailsForCelKey() + { + // GetSidecarKey stays sibling-only. DeleteResourceCommand and the rename + // cascade rely on this failure to skip the "also delete/rename the + // sidecar" code path when the resource is itself a .cel file. + var result = _sidecarService.GetSidecarKey(new ResourceKey("design.widget.cel")); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("pass the parent resource key instead"); + } + + [Test] + public async Task ReadAsync_ReadsSiblingSidecar_ForRegularFile() + { + var regularFile = new ResourceKey("photo.png"); + var siblingSidecar = new ResourceKey("photo.png.cel"); + + _fileSystem.ExistsAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.ReadAllTextAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok("editor = \"acme.binary-editor\"\n"))); + + var readResult = await _sidecarService.ReadAsync(regularFile); + + readResult.IsSuccess.Should().BeTrue(); + readResult.Value.Outcome.Should().Be(SidecarReadOutcome.Healthy); + readResult.Value.Content!.Frontmatter["editor"].Should().Be("acme.binary-editor"); + } + + [Test] + public async Task ReadAsync_ReadsFileItself_ForStandaloneCelFile() + { + // When the resource IS a .cel file, the file holds its own frontmatter + // and there is no sibling sidecar. ReadAsync must operate on the file + // directly rather than appending ".cel" again (which would produce a + // bogus .cel.cel key). + var standaloneCel = new ResourceKey("design.widget.cel"); + + _fileSystem.ExistsAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.ReadAllTextAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok("editor = \"celbridge.code-editor.code-document\"\n"))); + + var readResult = await _sidecarService.ReadAsync(standaloneCel); + + readResult.IsSuccess.Should().BeTrue(); + readResult.Value.Outcome.Should().Be(SidecarReadOutcome.Healthy); + readResult.Value.Content!.Frontmatter["editor"].Should().Be("celbridge.code-editor.code-document"); + + // Belt-and-braces: the bogus .cel.cel key must never be touched. + await _fileSystem.DidNotReceive().ExistsAsync(new ResourceKey("design.widget.cel.cel")); + await _fileSystem.DidNotReceive().ReadAllTextAsync(new ResourceKey("design.widget.cel.cel")); + } + + [Test] + public async Task SetFieldAsync_WritesToSiblingSidecar_ForRegularFile() + { + var regularFile = new ResourceKey("photo.png"); + var siblingSidecar = new ResourceKey("photo.png.cel"); + + _fileSystem.WriteAllTextAsync(siblingSidecar, Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + var setResult = await _sidecarService.SetFieldAsync(regularFile, "editor", "acme.binary-editor"); + + setResult.IsSuccess.Should().BeTrue(); + await _fileSystem.Received(1).WriteAllTextAsync( + siblingSidecar, + Arg.Is(text => text.Contains("editor") && text.Contains("acme.binary-editor"))); + } + + [Test] + public async Task SetFieldAsync_WritesToFileItself_ForStandaloneCelFile() + { + // Regression for the Open With... -> Code Editor flow on Design.fury.cel. + // The user picks Code Editor as the per-file editor, OpenWithMenuOption + // executes SetFieldCommand, which calls SetFieldAsync. The mutation + // must write the "editor" field directly into the .cel file's own TOML, + // not attempt to derive a .cel.cel sibling sidecar. + var standaloneCel = new ResourceKey("design.widget.cel"); + + _fileSystem.WriteAllTextAsync(standaloneCel, Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + var setResult = await _sidecarService.SetFieldAsync( + standaloneCel, + "editor", + "celbridge.code-editor.code-document"); + + setResult.IsSuccess.Should().BeTrue(); + await _fileSystem.Received(1).WriteAllTextAsync( + standaloneCel, + Arg.Is(text => text.Contains("editor") + && text.Contains("celbridge.code-editor.code-document"))); + + // The bogus .cel.cel key must never be touched. + await _fileSystem.DidNotReceive().WriteAllTextAsync( + new ResourceKey("design.widget.cel.cel"), + Arg.Any()); + } + + [Test] + public async Task SetFieldAsync_PreservesExistingContent_ForStandaloneCelFile() + { + // A standalone .cel file may already carry meaningful frontmatter (e.g. a + // fury-editor design document's [fury] section). Mutating one field must + // preserve the rest of the frontmatter so the editor's own data survives. + var standaloneCel = new ResourceKey("design.widget.cel"); + var existingContent = "title = \"My Design\"\nversion = 1\n"; + + _fileSystem.ExistsAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.ReadAllTextAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(existingContent))); + + string? capturedWrite = null; + _fileSystem.WriteAllTextAsync(standaloneCel, Arg.Do(text => capturedWrite = text)) + .Returns(Task.FromResult(Result.Ok())); + + var setResult = await _sidecarService.SetFieldAsync( + standaloneCel, + "editor", + "celbridge.code-editor.code-document"); + + setResult.IsSuccess.Should().BeTrue(); + capturedWrite.Should().NotBeNull(); + capturedWrite.Should().Contain("title"); + capturedWrite.Should().Contain("My Design"); + capturedWrite.Should().Contain("editor"); + capturedWrite.Should().Contain("celbridge.code-editor.code-document"); + } + + [Test] + public async Task SetFieldAsync_SkipsWrite_WhenValueMatchesExisting() + { + // Idempotency: setting a field to its current value must not rewrite the + // file. The watcher event a write would trigger fans out to a resource + // refresh, so a redundant write is not free. + var regularFile = new ResourceKey("photo.png"); + var siblingSidecar = new ResourceKey("photo.png.cel"); + + _fileSystem.ExistsAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.ReadAllTextAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok("editor = \"acme.binary-editor\"\n"))); + + var setResult = await _sidecarService.SetFieldAsync(regularFile, "editor", "acme.binary-editor"); + + setResult.IsSuccess.Should().BeTrue(); + await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task AddTagAsync_SkipsWrite_WhenTagAlreadyPresent() + { + // Idempotency: AddTag with a tag already in the list is a no-op. The + // closure inside SidecarService leaves the working dictionary unchanged, + // and the canonical-compare must catch that so no rewrite happens. + var regularFile = new ResourceKey("photo.png"); + var siblingSidecar = new ResourceKey("photo.png.cel"); + + _fileSystem.ExistsAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.ReadAllTextAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok("tags = [\"hero\", \"sprite\"]\n"))); + + var addResult = await _sidecarService.AddTagAsync(regularFile, "hero"); + + addResult.IsSuccess.Should().BeTrue(); + await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SetFieldAsync_RejectsNonIndexableValue() + { + // The frontmatter surface only accepts scalars and lists of scalars. + // A nested dictionary (or any other unsupported shape) must fail at the + // service boundary before any read or write happens, so the failure + // surfaces with a clear "not indexable" message rather than from inside + // the Tomlyn writer. + var nested = new Dictionary { ["nested"] = "value" }; + + var setResult = await _sidecarService.SetFieldAsync( + new ResourceKey("photo.png"), + "metadata", + nested); + + setResult.IsFailure.Should().BeTrue(); + setResult.FirstErrorMessage.Should().Contain("not indexable"); + await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task WriteBlockAsync_RejectsInvalidBlockId() + { + // Block ids must match the dotted-lowercase rule. A bad id is caught at + // the service boundary so the failure points at the caller's id rather + // than at Compose's throw-on-invalid-name guard. + var writeResult = await _sidecarService.WriteBlockAsync( + new ResourceKey("photo.png"), + "Invalid Block Name!", + "body"); + + writeResult.IsFailure.Should().BeTrue(); + writeResult.FirstErrorMessage.Should().Contain("block-naming rules"); + await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } + + [Test] + public void GetSidecarKey_FailsForNonProjectRoot() + { + // Sidecars are a project-scoped metadata system; the tracking pass only + // scans the project tree, so cross-root sidecars would be silently + // invisible to validation. The API refuses non-project roots up front. + var result = _sidecarService.GetSidecarKey(new ResourceKey("logs:foo.txt")); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("project root"); + } + + [Test] + public async Task ReadAsync_FailsForNonProjectRoot() + { + var readResult = await _sidecarService.ReadAsync(new ResourceKey("logs:foo.txt")); + + readResult.IsFailure.Should().BeTrue(); + readResult.FirstErrorMessage.Should().Contain("project root"); + } + + [Test] + public async Task SetFieldAsync_FailsForNonProjectRoot() + { + var setResult = await _sidecarService.SetFieldAsync( + new ResourceKey("logs:foo.txt"), + "editor", + "something"); + + setResult.IsFailure.Should().BeTrue(); + setResult.FirstErrorMessage.Should().Contain("project root"); + } + + [Test] + public async Task WriteBlockAsync_FailsForNonProjectRoot() + { + var writeResult = await _sidecarService.WriteBlockAsync( + new ResourceKey("logs:foo.txt"), + "scratch", + string.Empty); + + writeResult.IsFailure.Should().BeTrue(); + writeResult.FirstErrorMessage.Should().Contain("project root"); + } + + [Test] + public async Task SetFieldAsync_FailsForStandaloneCelOnNonProjectRoot() + { + // A .cel file under logs: would, without gating, be treated as a + // standalone-cel storage key (the same as Design.fury.cel under project:). + // The root check must refuse it before the .cel branch. + var setResult = await _sidecarService.SetFieldAsync( + new ResourceKey("logs:scratch.cel"), + "editor", + "something"); + + setResult.IsFailure.Should().BeTrue(); + setResult.FirstErrorMessage.Should().Contain("project root"); + } + + [Test] + public async Task SetFieldAsync_CreatesFile_WhenStandaloneCelMissing() + { + // A standalone .cel file that does not exist yet should be created on + // SetField. The created file holds the new frontmatter and nothing else. + var standaloneCel = new ResourceKey("new.widget.cel"); + + _fileSystem.WriteAllTextAsync(standaloneCel, Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + var setResult = await _sidecarService.SetFieldAsync( + standaloneCel, + "editor", + "celbridge.code-editor.code-document"); + + setResult.IsSuccess.Should().BeTrue(); + await _fileSystem.Received(1).WriteAllTextAsync( + standaloneCel, + Arg.Is(text => text.Contains("editor"))); + } + + [Test] + public async Task RemoveFieldAsync_SkipsWrite_WhenSidecarMissing() + { + // Removing a field from a non-existent sidecar must not create the + // sidecar (createIfMissing=false inside RemoveFieldAsync) and must not + // write anything. + var setResult = await _sidecarService.RemoveFieldAsync(new ResourceKey("photo.png"), "editor"); + + setResult.IsSuccess.Should().BeTrue(); + await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/Source/Tests/Resources/SidecarTrackingTests.cs b/Source/Tests/Resources/SidecarTrackingTests.cs index 89401dd23..417a8c554 100644 --- a/Source/Tests/Resources/SidecarTrackingTests.cs +++ b/Source/Tests/Resources/SidecarTrackingTests.cs @@ -3,7 +3,6 @@ using Celbridge.Resources; using Celbridge.Resources.Services; using Celbridge.UserInterface.Services; -using Celbridge.Utilities; namespace Celbridge.Tests.Resources; @@ -26,8 +25,10 @@ public void Setup() _registry = new ResourceRegistry( Substitute.For>(), new MessengerService(), - new FileIconService()); - _registry.ProjectFolderPath = _projectFolderPath; + new ProjectTreeBuilder(new FileIconService()), + SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(), + new RootHandlerRegistry()); + _registry.InitializeProjectRoot(_projectFolderPath); } [TearDown] @@ -171,4 +172,8 @@ public void BrokenOrphan_AppearsInBothBrokenAndOrphan() report.Broken.Should().Contain(new ResourceKey("lonely.cel")); report.Orphan.Should().Contain(new ResourceKey("lonely.cel")); } + + // Standalone .cel form recognition (package.cel, foo.webview.cel) and the + // editor-registry hookup live in SidecarPairingServiceTests, which targets + // the pairing service directly. } diff --git a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs index 944cff064..2fd6696d3 100644 --- a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs +++ b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs @@ -70,7 +70,6 @@ public async Task ExecuteAsync_WritesDecodedBytesToDisk() result.IsSuccess.Should().BeTrue(); File.Exists(path).Should().BeTrue(); (await File.ReadAllBytesAsync(path)).Should().Equal(bytes); - _resourceRegistry.Received(1).UpdateResourceRegistry(); } [Test] diff --git a/Source/Tests/Resources/WriteFileCommandTests.cs b/Source/Tests/Resources/WriteFileCommandTests.cs index e0f06bae0..201733144 100644 --- a/Source/Tests/Resources/WriteFileCommandTests.cs +++ b/Source/Tests/Resources/WriteFileCommandTests.cs @@ -69,11 +69,13 @@ public async Task ExecuteAsync_CreatesNewFile_WhenFileDoesNotExist() result.IsSuccess.Should().BeTrue(); File.Exists(path).Should().BeTrue(); (await File.ReadAllTextAsync(path)).Should().Be("fresh content"); - _resourceRegistry.Received(1).UpdateResourceRegistry(); + // Registry refresh is driven by CommandFlags.UpdateResources, processed + // by the command service framework after the command body returns; + // ExecuteAsync itself does not call the registry directly. } [Test] - public async Task ExecuteAsync_OverwritesExistingFile_WithoutRefreshingRegistry() + public async Task ExecuteAsync_OverwritesExistingFile() { var resource = new ResourceKey("notes/existing.md"); var path = Path.Combine(_tempFolder, "existing.md"); @@ -88,7 +90,6 @@ public async Task ExecuteAsync_OverwritesExistingFile_WithoutRefreshingRegistry( result.IsSuccess.Should().BeTrue(); (await File.ReadAllTextAsync(path)).Should().Be("new content"); - _resourceRegistry.DidNotReceive().UpdateResourceRegistry(); } [Test] diff --git a/Source/Tests/Search/FileFilterTests.cs b/Source/Tests/Search/FileFilterTests.cs index 2134ec542..11bb133a7 100644 --- a/Source/Tests/Search/FileFilterTests.cs +++ b/Source/Tests/Search/FileFilterTests.cs @@ -53,10 +53,13 @@ public void ShouldSearchFile_MetadataExtension_ReturnsFalse() } [Test] - public void ShouldSearchFile_WebviewExtension_ReturnsFalse() + public void ShouldSearchFile_CelExtension_ReturnsFalse() { - var filePath = Path.Combine(_testDir, "test.webview"); - File.WriteAllText(filePath, "webview data"); + // .cel files (sidecars and standalone manifests) are excluded from + // plain-text search because their content is editor-owned and a + // plain-text replace would corrupt the file structure. + var filePath = Path.Combine(_testDir, "test.webview.cel"); + File.WriteAllText(filePath, "source_url = \"https://example.com\"\n"); _filter.ShouldSearchFile(filePath).Should().BeFalse(); } diff --git a/Source/Tests/WebView/HtmlViewerEditorFactoryTests.cs b/Source/Tests/WebView/HtmlViewerEditorFactoryTests.cs index b2dd7997d..a61c30c5e 100644 --- a/Source/Tests/WebView/HtmlViewerEditorFactoryTests.cs +++ b/Source/Tests/WebView/HtmlViewerEditorFactoryTests.cs @@ -38,25 +38,25 @@ public void Factory_PriorityIsSpecialized() [Test] public void Registry_HtmlExtensionResolvesToHtmlViewerByDefault_AndCodeEditorIsListedAsAlternate() { - var registry = new DocumentEditorRegistry(); + var registry = new DocumentEditorRegistry(Substitute.For()); var codeEditor = Substitute.For(); codeEditor.EditorId.Returns(new DocumentEditorId("celbridge.code-editor")); codeEditor.DisplayName.Returns("Code Editor"); codeEditor.SupportedExtensions.Returns(new List { ".html", ".htm" }); codeEditor.Priority.Returns(EditorPriority.General); - codeEditor.CanHandleResource(Arg.Any(), Arg.Any()).Returns(true); + codeEditor.CanHandleResource(Arg.Any()).Returns(true); registry.RegisterFactory(codeEditor); registry.RegisterFactory(_factory); var fileResource = new ResourceKey("page.html"); - var resolveResult = registry.GetFactory(fileResource, "/path/page.html"); + var resolveResult = registry.GetFactory(fileResource); resolveResult.IsSuccess.Should().BeTrue(); resolveResult.Value.Should().Be(_factory); - var alternates = registry.GetFactoriesForFileExtension(".html"); + var alternates = registry.GetFactoriesForExtension(".html"); alternates.Should().HaveCount(2); alternates[0].Should().Be(_factory); alternates[1].Should().Be(codeEditor); diff --git a/Source/Tests/WebView/WebViewDocumentViewModelTests.cs b/Source/Tests/WebView/WebViewDocumentViewModelTests.cs index 40e364b45..7d4dde252 100644 --- a/Source/Tests/WebView/WebViewDocumentViewModelTests.cs +++ b/Source/Tests/WebView/WebViewDocumentViewModelTests.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Resources; using Celbridge.Settings; using Celbridge.WebHost; using Celbridge.WebHost.Services; @@ -11,38 +12,34 @@ namespace Celbridge.Tests.WebView; [TestFixture] public class WebViewDocumentViewModelTests { - private string _tempFolder = null!; - private string _tempFilePath = null!; private ICommandService _commandService = null!; private IWebViewService _webViewService = null!; + private ISidecarService _sidecarService = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; [SetUp] public void SetUp() { - _tempFolder = Path.Combine(Path.GetTempPath(), "Celbridge", nameof(WebViewDocumentViewModelTests)); - Directory.CreateDirectory(_tempFolder); - - _tempFilePath = Path.Combine(_tempFolder, "test.webview"); - _commandService = Substitute.For(); var featureFlags = Substitute.For(); - var workspaceWrapper = Substitute.For(); - _webViewService = new WebViewService(featureFlags, workspaceWrapper); - } - [TearDown] - public void TearDown() - { - if (Directory.Exists(_tempFolder)) - { - Directory.Delete(_tempFolder, true); - } + _sidecarService = Substitute.For(); + var workspaceService = Substitute.For(); + workspaceService.SidecarService.Returns(_sidecarService); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _webViewService = new WebViewService(featureFlags, _workspaceWrapper); } [Test] public async Task LoadContent_AcceptsExternalHttpUrl() { - await File.WriteAllTextAsync(_tempFilePath, """{"sourceUrl": "http://example.com"}"""); + StubSidecarFrontmatter(new Dictionary + { + ["source_url"] = "http://example.com", + }); var viewModel = CreateViewModel(); var result = await viewModel.LoadContent(); @@ -54,7 +51,10 @@ public async Task LoadContent_AcceptsExternalHttpUrl() [Test] public async Task LoadContent_AcceptsExternalHttpsUrl() { - await File.WriteAllTextAsync(_tempFilePath, """{"sourceUrl": "https://example.com/path?q=1"}"""); + StubSidecarFrontmatter(new Dictionary + { + ["source_url"] = "https://example.com/path?q=1", + }); var viewModel = CreateViewModel(); var result = await viewModel.LoadContent(); @@ -66,7 +66,10 @@ public async Task LoadContent_AcceptsExternalHttpsUrl() [Test] public async Task LoadContent_FailsOnLocalAbsoluteUrl() { - await File.WriteAllTextAsync(_tempFilePath, """{"sourceUrl": "local://Sites/index.html"}"""); + StubSidecarFrontmatter(new Dictionary + { + ["source_url"] = "local://Sites/index.html", + }); var viewModel = CreateViewModel(); var result = await viewModel.LoadContent(); @@ -77,7 +80,10 @@ public async Task LoadContent_FailsOnLocalAbsoluteUrl() [Test] public async Task LoadContent_FailsOnLocalPathUrl() { - await File.WriteAllTextAsync(_tempFilePath, """{"sourceUrl": "../index.html"}"""); + StubSidecarFrontmatter(new Dictionary + { + ["source_url"] = "../index.html", + }); var viewModel = CreateViewModel(); var result = await viewModel.LoadContent(); @@ -86,31 +92,63 @@ public async Task LoadContent_FailsOnLocalPathUrl() } [Test] - public async Task LoadContent_HtmlViewer_IgnoresFileContents_AndSucceeds() + public async Task LoadContent_FailsOnBrokenSidecar() + { + // A malformed .webview.cel should surface as a parse failure, not silently + // open with an empty source_url. SidecarReadOutcome.Broken is the channel + // SidecarService uses to report this. + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.Broken, null, "bad TOML")))); + + var viewModel = CreateViewModel(); + var result = await viewModel.LoadContent(); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("parse"); + } + + [Test] + public async Task LoadContent_TreatsMissingSidecarAsBlankUrl() { - var htmlPath = Path.Combine(_tempFolder, "page.html"); - await File.WriteAllTextAsync(htmlPath, "not JSON"); + // No file on disk: open with no URL configured rather than failing. The + // inspector lets the user type a URL in afterward. + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)))); - var viewModel = new WebViewDocumentViewModel(_commandService, _webViewService) + var viewModel = CreateViewModel(); + var result = await viewModel.LoadContent(); + + result.IsSuccess.Should().BeTrue(); + viewModel.SourceUrl.Should().BeEmpty(); + } + + [Test] + public async Task LoadContent_HtmlViewer_IgnoresFileContents_AndSucceeds() + { + // The HtmlViewer role serves the HTML file directly via the project virtual + // host without consulting any .webview.cel; SidecarService is never called. + var viewModel = new WebViewDocumentViewModel(_commandService, _webViewService, _workspaceWrapper) { - FilePath = htmlPath, + FilePath = "ignored.html", FileResource = new ResourceKey("page.html"), - Role = WebViewDocumentRole.HtmlViewer + Role = WebViewDocumentRole.HtmlViewer, }; var result = await viewModel.LoadContent(); result.IsSuccess.Should().BeTrue(); + await _sidecarService.DidNotReceive().ReadAsync(Arg.Any()); } [Test] public void NavigateUrl_HtmlViewer_BuildsProjectVirtualHostUrlFromResourceKey() { - var viewModel = new WebViewDocumentViewModel(_commandService, _webViewService) + var viewModel = new WebViewDocumentViewModel(_commandService, _webViewService, _workspaceWrapper) { - FilePath = _tempFilePath, FileResource = new ResourceKey("Pages/welcome.html"), - Role = WebViewDocumentRole.HtmlViewer + Role = WebViewDocumentRole.HtmlViewer, }; viewModel.NavigateUrl.Should().Be("https://project.celbridge/Pages/welcome.html"); @@ -119,7 +157,10 @@ public void NavigateUrl_HtmlViewer_BuildsProjectVirtualHostUrlFromResourceKey() [Test] public async Task NavigateUrl_ExternalUrl_ReturnsSourceUrl() { - await File.WriteAllTextAsync(_tempFilePath, """{"sourceUrl": "https://example.com/x"}"""); + StubSidecarFrontmatter(new Dictionary + { + ["source_url"] = "https://example.com/x", + }); var viewModel = CreateViewModel(); await viewModel.LoadContent(); @@ -127,12 +168,19 @@ public async Task NavigateUrl_ExternalUrl_ReturnsSourceUrl() viewModel.NavigateUrl.Should().Be("https://example.com/x"); } + private void StubSidecarFrontmatter(IReadOnlyDictionary frontmatter) + { + var content = new SidecarContent(frontmatter, Array.Empty()); + _sidecarService.ReadAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok( + new SidecarReadResult(SidecarReadOutcome.Healthy, content, null)))); + } + private WebViewDocumentViewModel CreateViewModel() { - return new WebViewDocumentViewModel(_commandService, _webViewService) + return new WebViewDocumentViewModel(_commandService, _webViewService, _workspaceWrapper) { - FilePath = _tempFilePath, - FileResource = new ResourceKey("test.webview") + FileResource = new ResourceKey("test.webview.cel"), }; } } diff --git a/Source/Tests/WebView/WebViewEditorFactoryTests.cs b/Source/Tests/WebView/WebViewEditorFactoryTests.cs index 4c889d104..2ea197e77 100644 --- a/Source/Tests/WebView/WebViewEditorFactoryTests.cs +++ b/Source/Tests/WebView/WebViewEditorFactoryTests.cs @@ -17,9 +17,15 @@ public void SetUp() } [Test] - public void SupportedExtensions_IncludesDotWebview() + public void SupportedExtensions_IncludesDotWebviewCel() { - _factory.SupportedExtensions.Should().Contain(".webview"); + _factory.SupportedExtensions.Should().Contain(".webview.cel"); + } + + [Test] + public void SupportedExtensions_DoesNotIncludeLegacyDotWebview() + { + _factory.SupportedExtensions.Should().NotContain(".webview"); } [Test] diff --git a/Source/Workspace/Celbridge.Console/Commands/RunCommand.cs b/Source/Workspace/Celbridge.Console/Commands/RunCommand.cs index 80480356b..99876c9b0 100644 --- a/Source/Workspace/Celbridge.Console/Commands/RunCommand.cs +++ b/Source/Workspace/Celbridge.Console/Commands/RunCommand.cs @@ -31,7 +31,8 @@ public override async Task ExecuteAsync() var consoleService = _workspaceWrapper.WorkspaceService.ConsoleService; - var command = $"%run \"{ScriptResource}\""; + // .Path here, not ToString — the REPL's working folder is the project root. + var command = $"%run \"{ScriptResource.Path}\""; if (!string.IsNullOrEmpty(Arguments)) { command += " " + Arguments; diff --git a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs new file mode 100644 index 000000000..6266f6b20 --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs @@ -0,0 +1,76 @@ +using Celbridge.Workspace; + +namespace Celbridge.Documents.Helpers; + +/// +/// Resolves resource keys to backing file paths and verifies that the file +/// exists and is readable. Used by the documents subsystem to gate opens and +/// restores on access checks without scattering File.IO calls. +/// +public class FileAccessHelper +{ + private readonly IWorkspaceWrapper _workspaceWrapper; + + public FileAccessHelper(IWorkspaceWrapper workspaceWrapper) + { + _workspaceWrapper = workspaceWrapper; + } + + /// + /// True when the path points to an existing file that can be opened for + /// shared read access. Returns false for empty paths, missing files, or + /// access-denied conditions. + /// + public bool CanAccessFile(string resourcePath) + { + if (string.IsNullOrEmpty(resourcePath) + || !File.Exists(resourcePath)) + { + return false; + } + + try + { + var fileInfo = new FileInfo(resourcePath); + using var stream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Resolves a resource key to its backing path and verifies the file + /// exists and is readable. Returns the resolved path on success. + /// + public Result ResolveAndValidateFilePath(ResourceKey fileResource) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") + .WithErrors(resolveResult); + } + var filePath = resolveResult.Value; + + if (!File.Exists(filePath)) + { + return Result.Fail($"File path does not exist: '{filePath}'"); + } + + if (!CanAccessFile(filePath)) + { + return Result.Fail($"File exists but cannot be opened: '{filePath}'"); + } + + return filePath; + } +} diff --git a/Source/Workspace/Celbridge.Documents/Helpers/FileTypeClassifier.cs b/Source/Workspace/Celbridge.Documents/Helpers/FileTypeClassifier.cs new file mode 100644 index 000000000..fa2fba614 --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Helpers/FileTypeClassifier.cs @@ -0,0 +1,77 @@ +using Celbridge.Workspace; + +namespace Celbridge.Documents.Helpers; + +/// +/// Classifies a file resource by document view type and determines whether the +/// editor stack can open it. +/// +public class FileTypeClassifier +{ + private readonly FileTypeHelper _fileTypeHelper; + private readonly ITextBinarySniffer _textBinarySniffer; + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly IDocumentEditorRegistry _documentEditorRegistry; + + public FileTypeClassifier( + FileTypeHelper fileTypeHelper, + ITextBinarySniffer textBinarySniffer, + IWorkspaceWrapper workspaceWrapper, + IDocumentEditorRegistry documentEditorRegistry) + { + _fileTypeHelper = fileTypeHelper; + _textBinarySniffer = textBinarySniffer; + _workspaceWrapper = workspaceWrapper; + _documentEditorRegistry = documentEditorRegistry; + } + + /// + /// Returns the document view type for the file resource. Recognised + /// extensions resolve through FileTypeHelper; unrecognised extensions + /// fall back to a content sniff so plain-text files with novel + /// extensions still classify as TextDocument. + /// + public DocumentViewType GetDocumentViewType(ResourceKey fileResource) + { + var fileName = fileResource.ToString(); + + if (!_fileTypeHelper.IsRecognizedExtension(fileName)) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); + if (resolveResult.IsFailure) + { + return DocumentViewType.UnsupportedFormat; + } + + var sniffResult = _textBinarySniffer.IsTextFile(resolveResult.Value); + if (sniffResult.IsFailure) + { + return DocumentViewType.UnsupportedFormat; + } + + if (!sniffResult.Value) + { + return DocumentViewType.UnsupportedFormat; + } + } + + return _fileTypeHelper.GetDocumentViewType(fileName); + } + + /// + /// True when the file resource can be opened in the editor stack: a + /// registered factory advertises its extension, or the resource resolves + /// to a non-Unsupported view type. + /// + public bool IsDocumentSupported(ResourceKey fileResource) + { + var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); + if (_documentEditorRegistry.IsExtensionSupported(extension)) + { + return true; + } + + return GetDocumentViewType(fileResource) != DocumentViewType.UnsupportedFormat; + } +} diff --git a/Source/Workspace/Celbridge.Documents/Services/FileTypeHelper.cs b/Source/Workspace/Celbridge.Documents/Helpers/FileTypeHelper.cs similarity index 83% rename from Source/Workspace/Celbridge.Documents/Services/FileTypeHelper.cs rename to Source/Workspace/Celbridge.Documents/Helpers/FileTypeHelper.cs index 4a1fa1eca..c0d5ea3fd 100644 --- a/Source/Workspace/Celbridge.Documents/Services/FileTypeHelper.cs +++ b/Source/Workspace/Celbridge.Documents/Helpers/FileTypeHelper.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Celbridge.Explorer; -namespace Celbridge.Documents.Services; +namespace Celbridge.Documents.Helpers; public class FileTypeHelper { @@ -42,15 +42,18 @@ public Result Initialize() } /// - /// Gets the document view type based on the file extension. + /// Returns the document view type for a file. Detects multi-part extensions + /// like .webview.cel before falling back to the single-part suffix. /// - public DocumentViewType GetDocumentViewType(string fileExtension) + public DocumentViewType GetDocumentViewType(string fileName) { - if (fileExtension == ExplorerConstants.WebViewExtension) + if (fileName.EndsWith(ExplorerConstants.WebViewExtension, StringComparison.OrdinalIgnoreCase)) { return DocumentViewType.WebViewDocument; } + var fileExtension = Path.GetExtension(fileName).ToLowerInvariant(); + if (fileExtension == ExplorerConstants.MarkdownExtension) { return DocumentViewType.Markdown; @@ -97,34 +100,37 @@ public bool IsSupportedBinaryExtension(string fileExtension) } /// - /// Determines if a file extension is recognized (either as a text editor type or a supported binary type). + /// Determines if a file is recognized (either as a text editor type or a supported binary type). + /// Detects multi-part extensions like .webview.cel before falling back to the single-part suffix. /// - public bool IsRecognizedExtension(string fileExtension) + public bool IsRecognizedExtension(string fileName) { - if (string.IsNullOrEmpty(fileExtension)) + if (string.IsNullOrEmpty(fileName)) { return false; } - // Check for web view extension - if (fileExtension == ExplorerConstants.WebViewExtension) + if (fileName.EndsWith(ExplorerConstants.WebViewExtension, StringComparison.OrdinalIgnoreCase)) { return true; } - // Check for markdown extension (handled by the WYSIWYG editor) + var fileExtension = Path.GetExtension(fileName).ToLowerInvariant(); + if (string.IsNullOrEmpty(fileExtension)) + { + return false; + } + if (fileExtension == ExplorerConstants.MarkdownExtension) { return true; } - // Check if it's a known text editor type (via registered factories) if (_documentEditorRegistry?.IsExtensionSupported(fileExtension) == true) { return true; } - // Check if it's a supported binary type if (_binaryFileExtensions.Contains(fileExtension)) { return true; diff --git a/Source/Workspace/Celbridge.Documents/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Documents/ServiceConfiguration.cs index b43e06a1f..85dc361dc 100644 --- a/Source/Workspace/Celbridge.Documents/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Documents/ServiceConfiguration.cs @@ -15,10 +15,6 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); - // FileTypeHelper must be singleton because it's initialized by DocumentsService - // and shared across all document editor factories - services.AddSingleton(); - // // Register views // diff --git a/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs b/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs index ce10502f7..4627ac37d 100644 --- a/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs +++ b/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs @@ -54,7 +54,7 @@ private string ResolveDisplayName(IPackageLocalizationService localizationServic return displayKey; } - public override bool CanHandleResource(ResourceKey fileResource, string filePath) + public override bool CanHandleResource(ResourceKey fileResource) { if (!string.IsNullOrEmpty(_contribution.Package.FeatureFlag) && !_featureFlags.IsEnabled(_contribution.Package.FeatureFlag)) @@ -62,7 +62,7 @@ public override bool CanHandleResource(ResourceKey fileResource, string filePath return false; } - return base.CanHandleResource(fileResource, filePath); + return base.CanHandleResource(fileResource); } public override Result CreateDocumentView(ResourceKey fileResource) diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorPreferenceStore.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorPreferenceStore.cs new file mode 100644 index 000000000..bc7ffc76c --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorPreferenceStore.cs @@ -0,0 +1,134 @@ +using Celbridge.Logging; +using Celbridge.Resources; +using Celbridge.Workspace; + +namespace Celbridge.Documents.Services; + +/// +/// Reads and writes the user's preferred editor for a file. Knows two +/// preference sources: the per-file sidecar 'editor' field (set by +/// "Open with...") and the per-extension workspace setting. The sidecar +/// preference takes precedence; resolution stops at the first non-empty value. +/// +public class DocumentEditorPreferenceStore +{ + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly ILogger _logger; + + public DocumentEditorPreferenceStore( + IWorkspaceWrapper workspaceWrapper, + ILogger logger) + { + _workspaceWrapper = workspaceWrapper; + _logger = logger; + } + + /// + /// Returns the per-extension workspace preference, or Empty when no + /// preference is stored or the stored value does not parse. + /// + public async Task GetExtensionPreferenceAsync(string extension) + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + var preferenceKey = DocumentConstants.GetEditorPreferenceKey(extension); + var preferredId = await workspaceSettings.GetPropertyAsync(preferenceKey); + + // TryParse handles empty/null/malformed strings; callers are responsible + // for checking whether the returned id still maps to a registered editor. + if (string.IsNullOrEmpty(preferredId) + || !DocumentEditorId.TryParse(preferredId, out var editorId)) + { + return DocumentEditorId.Empty; + } + + return editorId; + } + + /// + /// Stores the user's preferred editor for an extension. Pass Empty to clear + /// the preference. + /// + public async Task SetExtensionPreferenceAsync(string extension, DocumentEditorId editorId) + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + var preferenceKey = DocumentConstants.GetEditorPreferenceKey(extension); + + if (editorId.IsEmpty) + { + await workspaceSettings.DeletePropertyAsync(preferenceKey); + return; + } + + await workspaceSettings.SetPropertyAsync(preferenceKey, editorId.ToString()); + } + + /// + /// Reads the resource's sidecar (if any) and returns its 'editor' field as + /// a DocumentEditorId. Returns success with Empty when no sidecar exists, + /// the sidecar has no 'editor' field, or the field value does not parse. + /// Returns failure only on unexpected sidecar service errors so callers can + /// fall through gracefully on success. + /// + public async Task> GetSidecarPreferenceAsync(ResourceKey fileResource) + { + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + if (sidecarService.IsSidecarKey(fileResource)) + { + // The sidecar file itself does not have a sidecar pairing of its + // own; nothing to consult. + return DocumentEditorId.Empty; + } + + var readResult = await sidecarService.ReadAsync(fileResource); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read sidecar for '{fileResource}'") + .WithErrors(readResult); + } + + var sidecar = readResult.Value; + if (sidecar.Outcome != SidecarReadOutcome.Healthy + || sidecar.Content is null) + { + return DocumentEditorId.Empty; + } + + if (!sidecar.Content.Frontmatter.TryGetValue(DocumentConstants.SidecarEditorFieldName, out var editorValue) + || editorValue is not string editorIdString + || string.IsNullOrWhiteSpace(editorIdString)) + { + return DocumentEditorId.Empty; + } + + if (!DocumentEditorId.TryParse(editorIdString, out var editorId)) + { + _logger.LogDebug($"Sidecar for '{fileResource}' has malformed editor value '{editorIdString}'"); + return DocumentEditorId.Empty; + } + + return editorId; + } + + /// + /// Returns the editor that should open the given file: the sidecar 'editor' + /// field when set, otherwise the per-extension workspace preference, or + /// Empty when neither source has a preference. + /// + public async Task GetPreferredEditorAsync(ResourceKey fileResource) + { + var sidecarPreferenceResult = await GetSidecarPreferenceAsync(fileResource); + if (sidecarPreferenceResult.IsSuccess + && !sidecarPreferenceResult.Value.IsEmpty) + { + return sidecarPreferenceResult.Value; + } + + var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); + var extensionPreference = await GetExtensionPreferenceAsync(extension); + return extensionPreference; + } +} diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs index ee49e9a58..193c1c6e1 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs @@ -7,12 +7,18 @@ namespace Celbridge.Documents.Services; public class DocumentEditorRegistry : IDocumentEditorRegistry, IDisposable { private bool _disposed; + private readonly ITextBinarySniffer _textBinarySniffer; private readonly List _factories = new(); private readonly Dictionary> _extensionToFactories = new(); private readonly Dictionary> _filenameToFactories = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _registeredEditorIds = new(); private readonly Dictionary _idToFactory = new(); + public DocumentEditorRegistry(ITextBinarySniffer textBinarySniffer) + { + _textBinarySniffer = textBinarySniffer; + } + /// /// Registers a document editor factory. /// @@ -84,29 +90,38 @@ public Result RegisterFactory(IDocumentEditorFactory factory) /// Gets the factory for the specified file resource. /// Returns the highest priority factory that can handle the resource. /// - public Result GetFactory(ResourceKey fileResource, string filePath) + public Result GetFactory(ResourceKey fileResource) { - // Use ResourceKey.ResourceName directly rather than Path.GetFileName on - // the key's string form. Path.GetFileName treats the "project:" prefix - // inconsistently across platforms (volume separator on Windows), so it - // would split "project:package.toml" differently from a real path. + var candidates = GetFactoriesForResource(fileResource); + if (candidates.Count == 0) + { + return Result.Fail($"No registered factory can handle resource: '{fileResource}'"); + } + return Result.Ok(candidates[0]); + } + + public IReadOnlyList GetFactoriesForResource(ResourceKey fileResource) + { + // Match order: exact filename first, then multi-part extension suffixes + // longest first. Dedupe by editor id so a factory registered against + // both a filename and an extension does not appear twice in the + // "Open with..." dialog. var fileName = fileResource.ResourceName; + var seenEditorIds = new HashSet(); + var candidates = new List(); - // 1. Try exact-filename match first. A factory that claims "package.toml" - // by filename wins over a generic ".toml" extension factory. if (_filenameToFactories.TryGetValue(fileName, out var byFilename)) { foreach (var factory in byFilename) { - if (factory.CanHandleResource(fileResource, filePath)) + if (factory.CanHandleResource(fileResource) + && seenEditorIds.Add(factory.EditorId)) { - return Result.Ok(factory); + candidates.Add(factory); } } } - // 2. Try multi-part extension suffixes from longest to shortest, so a - // ".project.cel" factory beats a generic ".cel" factory on the same file. var lowerFileName = fileName.ToLowerInvariant(); foreach (var suffix in GetExtensionSuffixes(lowerFileName)) { @@ -114,15 +129,40 @@ public Result GetFactory(ResourceKey fileResource, strin { foreach (var factory in factoryList) { - if (factory.CanHandleResource(fileResource, filePath)) + if (factory.CanHandleResource(fileResource) + && seenEditorIds.Add(factory.EditorId)) { - return Result.Ok(factory); + candidates.Add(factory); } } } } - return Result.Fail($"No registered factory can handle resource: '{fileResource}'"); + return candidates; + } + + public IReadOnlyList GetUserPickableFactoriesForResource(ResourceKey fileResource) + { + var candidates = GetFactoriesForResource(fileResource) + .Where(factory => !factory.IsPlaceholder) + .ToList(); + + var extension = Path.GetExtension(fileResource.ResourceName).ToLowerInvariant(); + if (_textBinarySniffer.IsBinaryExtension(extension)) + { + return candidates; + } + + // Text-shaped files always get the code editor as a "view as text" option, + // even if no factory claims the extension. Skip if already in the list. + var codeEditorResult = GetFactoryById(DocumentConstants.CodeEditorId); + if (codeEditorResult.IsSuccess + && !candidates.Any(factory => factory.EditorId == codeEditorResult.Value.EditorId)) + { + candidates.Add(codeEditorResult.Value); + } + + return candidates; } /// @@ -145,7 +185,7 @@ public IReadOnlyList GetAllFactories() /// /// Gets all factories that can handle the specified extension, sorted by priority. /// - public IReadOnlyList GetFactoriesForFileExtension(string fileExtension) + public IReadOnlyList GetFactoriesForExtension(string fileExtension) { var normalizedExtension = fileExtension.ToLowerInvariant(); diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs new file mode 100644 index 000000000..c7ff42d5d --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs @@ -0,0 +1,347 @@ +using Celbridge.Commands; +using Celbridge.Documents.Helpers; +using Celbridge.Logging; +using Celbridge.Resources; +using Celbridge.Workspace; + +namespace Celbridge.Documents.Services; + +/// +/// Owns the workspace-settings round trip for the documents panel: which tabs +/// are open, in which sections, with which editor and saved view state. +/// Reads and writes the settings keys directly; DocumentsService delegates its +/// IDocumentsService persistence methods here. +/// +public class DocumentLayoutStore +{ + private const string DocumentLayoutKey = "DocumentLayout"; + private const string ActiveDocumentKey = "ActiveDocument"; + private const string SectionRatiosKey = "SectionRatios"; + private const string DocumentEditorStatesKey = "DocumentEditorStates"; + + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly ICommandService _commandService; + private readonly FileAccessHelper _fileAccessHelper; + private readonly ILogger _logger; + + private IDocumentsPanel DocumentsPanel => _workspaceWrapper.WorkspaceService.DocumentsPanel; + + public DocumentLayoutStore( + IWorkspaceWrapper workspaceWrapper, + ICommandService commandService, + FileAccessHelper fileAccessHelper, + ILogger logger) + { + _workspaceWrapper = workspaceWrapper; + _commandService = commandService; + _fileAccessHelper = fileAccessHelper; + _logger = logger; + } + + /// + /// Serialization DTO for a single open document tab. Public so the + /// workspace-settings deserializer can reach it through the store. + /// + public record StoredDocumentAddress(string Resource, int WindowIndex, int SectionIndex, int TabOrder, string DocumentEditorId = ""); + + public async Task StoreDocumentLayoutAsync() + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + var storedAddresses = DocumentsPanel.GetOpenDocuments() + .Select(document => new StoredDocumentAddress( + document.FileResource.ToString(), + document.Address.WindowIndex, + document.Address.SectionIndex, + document.Address.TabOrder, + document.EditorId.ToString())) + .OrderBy(address => address.WindowIndex) + .ThenBy(address => address.SectionIndex) + .ThenBy(address => address.TabOrder) + .ToList(); + + await workspaceSettings.SetPropertyAsync(DocumentLayoutKey, storedAddresses); + } + + public async Task StoreActiveDocumentAsync(ResourceKey activeDocument) + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + var fileResource = activeDocument.ToString(); + await workspaceSettings.SetPropertyAsync(ActiveDocumentKey, fileResource); + } + + public async Task StoreSectionRatiosAsync(List ratios) + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + await workspaceSettings.SetPropertyAsync(SectionRatiosKey, ratios); + } + + public async Task StoreDocumentEditorStatesAsync() + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + // Start with existing saved states so that editors that aren't ready yet + // (e.g., WebView still loading) preserve their previously saved state. + var editorStates = await workspaceSettings.GetPropertyAsync>(DocumentEditorStatesKey) + ?? new Dictionary(); + + var openDocumentKeys = new HashSet(); + + foreach (var document in DocumentsPanel.GetOpenDocuments()) + { + var resourceKey = document.FileResource.ToString(); + openDocumentKeys.Add(resourceKey); + + var documentView = DocumentsPanel.GetDocumentView(document.FileResource); + if (documentView is null) + { + continue; + } + + try + { + // A null / empty return from TrySaveEditorStateAsync means the editor is either + // still initialising or has no state to contribute. In both cases we keep the + // previously saved state rather than overwriting it. Losing state on a transient + // "not ready" would surprise the user who carefully set scroll/zoom and then + // happens to reload a workspace while a tab is mid-init. + var state = await documentView.TrySaveEditorStateAsync(); + if (!string.IsNullOrEmpty(state)) + { + editorStates[resourceKey] = state; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, $"Could not save editor state for '{resourceKey}'"); + } + } + + // Remove entries for documents that are no longer open + var staleKeys = editorStates.Keys.Where(key => !openDocumentKeys.Contains(key)).ToList(); + foreach (var staleKey in staleKeys) + { + editorStates.Remove(staleKey); + } + + await workspaceSettings.SetPropertyAsync(DocumentEditorStatesKey, editorStates); + } + + public async Task StoreDocumentEditorStateAsync(ResourceKey fileResource, string? state) + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + try + { + var editorStates = await workspaceSettings.GetPropertyAsync>(DocumentEditorStatesKey) + ?? new Dictionary(); + + var resourceKey = fileResource.ToString(); + if (!string.IsNullOrEmpty(state)) + { + editorStates[resourceKey] = state; + } + else + { + editorStates.Remove(resourceKey); + } + + await workspaceSettings.SetPropertyAsync(DocumentEditorStatesKey, editorStates); + } + catch (Exception ex) + { + // Best-effort persistence: losing editor state is a user convenience, not data loss. + // Log at debug level so unexpected failures are visible without being alarming. + _logger.LogDebug(ex, $"Failed to store editor state for '{fileResource}'"); + } + } + + public async Task RestorePanelStateAsync() + { + var storedLayout = await LoadStoredLayoutAsync(); + + if (storedLayout.SectionRatios is not null + && storedLayout.SectionRatios.Count >= 1 + && storedLayout.SectionRatios.Count <= 3) + { + DocumentsPanel.SectionCount = storedLayout.SectionRatios.Count; + DocumentsPanel.SetSectionRatios(storedLayout.SectionRatios); + } + + if (storedLayout.Addresses is null + || storedLayout.Addresses.Count == 0) + { + OpenDefaultReadme(); + return; + } + + await RestoreDocumentsAsync(storedLayout.Addresses, storedLayout.EditorStates); + await RestoreActiveDocumentAsync(); + } + + private record StoredLayout( + List? SectionRatios, + List? Addresses, + Dictionary? EditorStates); + + private async Task LoadStoredLayoutAsync() + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + var sectionRatios = await workspaceSettings.GetPropertyAsync>(SectionRatiosKey); + + // Try to load document addresses - if format is incompatible, just start fresh + List? storedAddresses = null; + try + { + storedAddresses = await workspaceSettings.GetPropertyAsync>(DocumentLayoutKey); + } + catch + { + // Old format or corrupted data - ignore and start fresh + _logger.LogDebug("Could not load document addresses - starting fresh"); + } + + // Load saved editor states for restoration after documents are opened + Dictionary? editorStates = null; + try + { + editorStates = await workspaceSettings.GetPropertyAsync>(DocumentEditorStatesKey); + } + catch + { + _logger.LogDebug("Could not load editor states - starting fresh"); + } + + return new StoredLayout(sectionRatios, storedAddresses, editorStates); + } + + private async Task RestoreDocumentsAsync( + IReadOnlyList storedAddresses, + IReadOnlyDictionary? editorStates) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + int currentSectionCount = DocumentsPanel.SectionCount; + + foreach (var stored in storedAddresses) + { + if (!ResourceKey.TryCreate(stored.Resource, out var fileResource)) + { + _logger.LogWarning($"Invalid resource key '{stored.Resource}' found in previously open documents"); + continue; + } + + var getResourceResult = resourceRegistry.GetResource(fileResource); + if (getResourceResult.IsFailure) + { + _logger.LogWarning(getResourceResult, $"Failed to open document because '{fileResource}' resource does not exist."); + continue; + } + + var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); + if (resolveResult.IsFailure) + { + _logger.LogWarning(resolveResult, $"Failed to resolve path for resource: '{fileResource}'"); + continue; + } + var filePath = resolveResult.Value; + + if (!_fileAccessHelper.CanAccessFile(filePath)) + { + _logger.LogWarning($"Cannot access file for resource: '{fileResource}'"); + continue; + } + + // Handle mismatch: if saved section doesn't exist, merge into last section + int targetSection = Math.Min(stored.SectionIndex, currentSectionCount - 1); + var address = new DocumentAddress(stored.WindowIndex, targetSection, stored.TabOrder); + + // Use TryParse rather than the throwing constructor: a persisted editor id may reference + // a package or contribution that has since been renamed or uninstalled, and an invalid + // value should fall back to the default editor instead of aborting the restore. + DocumentEditorId editorId; + if (string.IsNullOrEmpty(stored.DocumentEditorId)) + { + editorId = DocumentEditorId.Empty; + } + else if (!DocumentEditorId.TryParse(stored.DocumentEditorId, out editorId)) + { + _logger.LogWarning($"Stored document editor id '{stored.DocumentEditorId}' is invalid and will be ignored for resource '{fileResource}'"); + editorId = DocumentEditorId.Empty; + } + + string? editorStateJson = null; + editorStates?.TryGetValue(fileResource.ToString(), out editorStateJson); + + var restoreOptions = new OpenDocumentOptions( + Address: address, + Activate: false, + EditorId: editorId, + EditorStateJson: editorStateJson); + + var openResult = await DocumentsPanel.OpenDocument(fileResource, restoreOptions); + if (openResult.IsFailure) + { + _logger.LogWarning(openResult, $"Failed to open previously open document '{fileResource}'"); + await StoreDocumentEditorStateAsync(fileResource, null); + } + } + } + + private async Task RestoreActiveDocumentAsync() + { + var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; + Guard.IsNotNull(workspaceSettings); + + var selectedDocument = await workspaceSettings.GetPropertyAsync(ActiveDocumentKey); + if (string.IsNullOrEmpty(selectedDocument)) + { + return; + } + + if (!ResourceKey.TryCreate(selectedDocument, out var selectedDocumentKey)) + { + _logger.LogWarning($"Invalid resource key '{selectedDocument}' found for previously selected document"); + return; + } + + // Set the active document. SectionContainer.SetActiveDocument also enforces the invariant + // that every populated section has a selected tab, so non-active sections that had tabs + // inserted during restore get a sensible default selection automatically. + DocumentsPanel.ActiveDocument = selectedDocumentKey; + } + + private void OpenDefaultReadme() + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var readmeResource = new ResourceKey("readme.md"); + + var normalizeResult = resourceRegistry.NormalizeResourceKey(readmeResource); + if (normalizeResult.IsFailure) + { + return; + } + var normalizedResource = normalizeResult.Value; + + var resolveResult = resourceRegistry.ResolveResourcePath(normalizedResource); + if (resolveResult.IsFailure + || !_fileAccessHelper.CanAccessFile(resolveResult.Value)) + { + return; + } + + _commandService.Execute(command => + { + command.FileResource = normalizedResource; + command.ForceReload = false; + }); + } +} diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs new file mode 100644 index 000000000..78b16eb70 --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs @@ -0,0 +1,283 @@ +using Celbridge.Documents.Helpers; +using Celbridge.Documents.Views; +using Celbridge.Logging; +using Celbridge.Workspace; + +namespace Celbridge.Documents.Services; + +/// +/// Picks the appropriate editor for a file resource and creates its document view. +/// +public class DocumentViewFactory +{ + private readonly IDocumentEditorRegistry _documentEditorRegistry; + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly DocumentEditorPreferenceStore _preferenceStore; + private readonly FileTypeClassifier _fileTypeClassifier; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public DocumentViewFactory( + IDocumentEditorRegistry documentEditorRegistry, + IWorkspaceWrapper workspaceWrapper, + DocumentEditorPreferenceStore preferenceStore, + FileTypeClassifier fileTypeClassifier, + IServiceProvider serviceProvider, + ILogger logger) + { + _documentEditorRegistry = documentEditorRegistry; + _workspaceWrapper = workspaceWrapper; + _preferenceStore = preferenceStore; + _fileTypeClassifier = fileTypeClassifier; + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// Selects an editor for the given resource and constructs its document view. + /// The view is returned without content being loaded; the caller drives + /// SetFileResource and LoadContent. + /// + public async Task> CreateAsync( + ResourceKey fileResource, + DocumentEditorId requestedEditorId) + { + var pathFailure = CheckResourcePathResolves(fileResource); + if (pathFailure is not null) + { + return pathFailure; + } + + if (!requestedEditorId.IsEmpty) + { + // Explicit editor request short-circuits the resolution chain. Failing + // here rather than falling through surfaces wrong-editor requests + // (e.g. an MCP call handing a .png to a code editor by mistake). + return CreateForRequestedEditor(fileResource, requestedEditorId); + } + + var sidecarChoice = await TryCreateFromSidecarPreferenceAsync(fileResource); + if (sidecarChoice is not null) + { + return sidecarChoice; + } + + var extensionChoice = await TryCreateFromExtensionPreferenceAsync(fileResource); + if (extensionChoice is not null) + { + return extensionChoice; + } + + var factoryChoice = TryCreateFromPriorityFactory(fileResource); + if (factoryChoice is not null) + { + return factoryChoice; + } + + return CreateTextFallback(fileResource); + } + + // Confirms the resource maps to a real backing path before any preference or + // factory lookup runs. Returns null on success; the resolved path itself is + // not used downstream. + private Result? CheckResourcePathResolves(ResourceKey fileResource) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") + .WithErrors(resolveResult); + } + + return null; + } + + // The sidecar's 'editor' field records the user's last explicit "Open With X" + // choice and wins over the per-extension preference and the priority fallback. + // A stale or unregistered id returns null so the caller falls through. + private async Task?> TryCreateFromSidecarPreferenceAsync(ResourceKey fileResource) + { + var sidecarEditorResult = await _preferenceStore.GetSidecarPreferenceAsync(fileResource); + if (sidecarEditorResult.IsFailure + || sidecarEditorResult.Value.IsEmpty) + { + return null; + } + + var sidecarEditorId = sidecarEditorResult.Value; + var sidecarFactoryResult = _documentEditorRegistry.GetFactoryById(sidecarEditorId); + if (sidecarFactoryResult.IsFailure) + { + return null; + } + + var sidecarFactory = sidecarFactoryResult.Value; + if (!IsCodeEditor(sidecarEditorId) + && !sidecarFactory.CanHandleResource(fileResource)) + { + return null; + } + + var createResult = sidecarFactory.CreateDocumentView(fileResource); + if (createResult.IsSuccess) + { + return createResult; + } + + _logger.LogWarning(createResult, + $"Sidecar editor '{sidecarEditorId}' failed to create view for '{fileResource}'; falling through"); + return null; + } + + private async Task?> TryCreateFromExtensionPreferenceAsync(ResourceKey fileResource) + { + var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); + var preferredEditorId = await _preferenceStore.GetExtensionPreferenceAsync(extension); + if (preferredEditorId.IsEmpty) + { + return null; + } + + var preferredFactoryResult = _documentEditorRegistry.GetFactoryById(preferredEditorId); + if (preferredFactoryResult.IsFailure) + { + return null; + } + + var preferredFactory = preferredFactoryResult.Value; + if (!preferredFactory.CanHandleResource(fileResource)) + { + return null; + } + + var createResult = preferredFactory.CreateDocumentView(fileResource); + if (createResult.IsSuccess) + { + return createResult; + } + + return null; + } + + // Priority-based factory resolution. Placeholder factories (package.cel, + // *.celbridge, *.document.cel) exist only for extension reservation and + // never produce a view, so they are skipped here; the text fallback below + // catches the open and routes it to the code editor without logging a + // spurious "factory failed" warning. + private Result? TryCreateFromPriorityFactory(ResourceKey fileResource) + { + var factoryResult = _documentEditorRegistry.GetFactory(fileResource); + if (factoryResult.IsFailure + || factoryResult.Value.IsPlaceholder) + { + return null; + } + + var factory = factoryResult.Value; + var createResult = factory.CreateDocumentView(fileResource); + if (createResult.IsSuccess) + { + return createResult; + } + + _logger.LogWarning(createResult, $"Factory failed to create document view for: '{fileResource}'"); + return null; + } + + private Result CreateTextFallback(ResourceKey fileResource) + { + var viewType = _fileTypeClassifier.GetDocumentViewType(fileResource); + if (viewType == DocumentViewType.UnsupportedFormat) + { + return Result.Fail($"File resource is not a supported document format: '{fileResource}'"); + } + + if (viewType != DocumentViewType.TextDocument) + { + return Result.Fail($"Failed to create document view for file: '{fileResource}'"); + } + + return CreateTextDocumentView(fileResource); + } + + private Result CreateForRequestedEditor(ResourceKey fileResource, DocumentEditorId requestedEditorId) + { + var getFactoryResult = _documentEditorRegistry.GetFactoryById(requestedEditorId); + if (getFactoryResult.IsFailure) + { + return Result.Fail($"No document editor is registered with id '{requestedEditorId}'") + .WithErrors(getFactoryResult); + } + var requestedFactory = getFactoryResult.Value; + + // The code editor is the universal "view as text" option offered through + // "Open with...". The user can pick it for any text file, including ones + // whose extension the code editor does not claim, so the extension match + // is bypassed for this one editor id. Every other editor still goes + // through CanHandleResource so wrong-editor requests fail loudly. + if (!IsCodeEditor(requestedEditorId) + && !requestedFactory.CanHandleResource(fileResource)) + { + return Result.Fail($"Document editor '{requestedEditorId}' cannot handle file resource: '{fileResource}'"); + } + + var createResult = requestedFactory.CreateDocumentView(fileResource); + if (createResult.IsFailure) + { + return Result.Fail($"Document editor '{requestedEditorId}' failed to create view for: '{fileResource}'") + .WithErrors(createResult); + } + + return createResult; + } + + private Result CreateTextDocumentView(ResourceKey fileResource) + { + // Try every non-placeholder factory in priority order. Placeholders cannot + // produce a view, so calling them here would burn cycles and fall through. + foreach (var factory in _documentEditorRegistry.GetAllFactories().OrderBy(candidate => candidate.Priority)) + { + if (factory.IsPlaceholder) + { + continue; + } + + if (factory.CanHandleResource(fileResource)) + { + var createResult = factory.CreateDocumentView(fileResource); + if (createResult.IsSuccess) + { + return createResult; + } + } + } + + // Default to the bundled Monaco-based code editor. Constructed by id, not + // by extension match, so the code editor opens any text file even when + // its extension is not in the code editor's extension list. + var codeEditorFactoryResult = _documentEditorRegistry.GetFactoryById(DocumentConstants.CodeEditorId); + if (codeEditorFactoryResult.IsSuccess) + { + var codeEditorResult = codeEditorFactoryResult.Value.CreateDocumentView(fileResource); + if (codeEditorResult.IsSuccess) + { + return codeEditorResult; + } + + _logger.LogWarning(codeEditorResult, + $"Code editor '{DocumentConstants.CodeEditorId}' failed to create view for '{fileResource}'; using TextBoxDocumentView"); + } + + // Last-resort fallback: the cross-platform plain TextBox. Kept for + // non-Windows hosts (Monaco runs in WebView2, which is Windows-only) + // and for the case where the code editor factory failed to construct. + var textBoxView = _serviceProvider.GetRequiredService(); + return textBoxView.OkResult(); + } + + private static bool IsCodeEditor(DocumentEditorId editorId) + { + return editorId == DocumentConstants.CodeEditorId; + } +} diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index f8f6c5ba9..3abc50a9c 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Documents.Helpers; using Celbridge.Documents.Views; using Celbridge.Logging; using Celbridge.Messaging; @@ -12,11 +13,6 @@ namespace Celbridge.Documents.Services; public class DocumentsService : IDocumentsService, IDisposable { - private const string DocumentLayoutKey = "DocumentLayout"; - private const string ActiveDocumentKey = "ActiveDocument"; - private const string SectionRatiosKey = "SectionRatios"; - private const string DocumentEditorStatesKey = "DocumentEditorStates"; - private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly IMessengerService _messengerService; @@ -24,13 +20,25 @@ public class DocumentsService : IDocumentsService, IDisposable private readonly IWorkspaceWrapper _workspaceWrapper; private readonly ITextBinarySniffer _textBinarySniffer; private readonly IFeatureFlags _featureFlags; + private readonly FileTypeHelper _fileTypeHelper; + private readonly DocumentEditorRegistry _documentEditorRegistry; + private readonly FileTypeClassifier _fileTypeClassifier; + private readonly FileAccessHelper _fileAccessHelper; + private readonly DocumentEditorPreferenceStore _preferenceStore; + private readonly DocumentViewFactory _viewFactory; + private readonly DocumentLayoutStore _layoutStore; + private bool _disposed; - /// - /// Gets the documents panel from the workspace service. - /// private IDocumentsPanel DocumentsPanel => _workspaceWrapper.WorkspaceService.DocumentsPanel; - public ResourceKey ActiveDocument { get; private set; } + /// + /// The currently active document, sourced from the documents panel. Returns + /// Empty before the workspace page is loaded (the panel does not exist yet). + /// + public ResourceKey ActiveDocument => + _workspaceWrapper.IsWorkspacePageLoaded + ? DocumentsPanel.ActiveDocument + : ResourceKey.Empty; /// /// Returns the currently open documents from the documents panel. @@ -45,12 +53,6 @@ public class DocumentsService : IDocumentsService, IDisposable /// public int SectionCount => DocumentsPanel.SectionCount; - private bool _isWorkspaceLoaded; - - private FileTypeHelper _fileTypeHelper; - - private readonly DocumentEditorRegistry _documentEditorRegistry = new(); - public IDocumentEditorRegistry DocumentEditorRegistry => _documentEditorRegistry; public DocumentsService( @@ -73,19 +75,22 @@ public DocumentsService( _workspaceWrapper = workspaceWrapper; _textBinarySniffer = textBinarySniffer; _featureFlags = featureFlags; + _documentEditorRegistry = new DocumentEditorRegistry(_textBinarySniffer); _messengerService.Register(this, OnPackagesInitializedMessage); _messengerService.Register(this, OnWorkspaceLoadedMessage); - _messengerService.Register(this, OnDocumentLayoutChangedMessage); - _messengerService.Register(this, OnActiveDocumentChangedMessage); - _messengerService.Register(this, OnSectionRatiosChangedMessage); _messengerService.Register(this, OnDocumentResourceChangedMessage); + // The layout / active / section subscriptions are deferred to + // OnWorkspaceLoadedMessage so the messages fired by RestorePanelState + // (which runs before workspace-loaded is published) do not trigger + // settings writes for what we just read out of settings. + // Register document editor factories from all loaded modules. // This must happen before FileTypeHelper initialization so factories can provide language mappings. RegisterModuleDocumentEditorFactories(moduleService); - _fileTypeHelper = serviceProvider.GetRequiredService(); + _fileTypeHelper = new FileTypeHelper(); _fileTypeHelper.SetDocumentEditorRegistry(_documentEditorRegistry); var loadResult = _fileTypeHelper.Initialize(); @@ -93,6 +98,34 @@ public DocumentsService( { throw new InvalidProgramException("Failed to initialize file type helper"); } + + _fileTypeClassifier = new FileTypeClassifier( + _fileTypeHelper, + _textBinarySniffer, + _workspaceWrapper, + _documentEditorRegistry); + + _fileAccessHelper = new FileAccessHelper(_workspaceWrapper); + + _preferenceStore = new DocumentEditorPreferenceStore( + _workspaceWrapper, + serviceProvider.GetRequiredService>()); + + // Built after the registry is fully populated so the factory sees + // every editor it might choose. + _viewFactory = new DocumentViewFactory( + _documentEditorRegistry, + _workspaceWrapper, + _preferenceStore, + _fileTypeClassifier, + _serviceProvider, + serviceProvider.GetRequiredService>()); + + _layoutStore = new DocumentLayoutStore( + _workspaceWrapper, + _commandService, + _fileAccessHelper, + serviceProvider.GetRequiredService>()); } private void RegisterModuleDocumentEditorFactories(IModuleService moduleService) @@ -159,36 +192,24 @@ private void OnPackagesInitializedMessage(object recipient, PackagesInitializedM private void OnWorkspaceLoadedMessage(object recipient, WorkspaceLoadedMessage message) { - // Once set, this will remain true for the lifetime of the service - _isWorkspaceLoaded = true; + _messengerService.Register(this, OnDocumentLayoutChangedMessage); + _messengerService.Register(this, OnActiveDocumentChangedMessage); + _messengerService.Register(this, OnSectionRatiosChangedMessage); } private void OnActiveDocumentChangedMessage(object recipient, ActiveDocumentChangedMessage message) { - ActiveDocument = message.DocumentResource; - - if (_isWorkspaceLoaded) - { - // Ignore change events that happen while loading the workspace - _ = StoreActiveDocument(); - } + _ = StoreActiveDocument(); } private void OnDocumentLayoutChangedMessage(object recipient, DocumentLayoutChangedMessage message) { - if (_isWorkspaceLoaded) - { - _ = StoreDocumentLayout(); - } + _ = StoreDocumentLayout(); } private void OnSectionRatiosChangedMessage(object recipient, SectionRatiosChangedMessage message) { - if (_isWorkspaceLoaded) - { - // Ignore change events that happen while loading the workspace - _ = StoreSectionRatios(message.SectionRatios); - } + _ = _layoutStore.StoreSectionRatiosAsync(message.SectionRatios); } public async Task> CreateDocumentView(ResourceKey fileResource, DocumentEditorId documentEditorId = default) @@ -228,54 +249,11 @@ public async Task> CreateDocumentView(ResourceKey fileReso return documentView.OkResult(); } - /// - /// Returns the document view type for the specified file resource. - /// - public DocumentViewType GetDocumentViewType(ResourceKey fileResource) - { - var extension = Path.GetExtension(fileResource).ToLowerInvariant(); - - // For unrecognized extensions (including empty), check if the file is text - if (!_fileTypeHelper.IsRecognizedExtension(extension)) - { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - return DocumentViewType.UnsupportedFormat; - } - - var result = _textBinarySniffer.IsTextFile(resolveResult.Value); - if (result.IsFailure) - { - // Failed to determine if the file is text - return DocumentViewType.UnsupportedFormat; - } - var isTextFile = result.Value; - - if (!isTextFile) - { - // We determined the file type, but it's not a text file. - return DocumentViewType.UnsupportedFormat; - } - } - - return _fileTypeHelper.GetDocumentViewType(extension); - } - - public bool IsDocumentSupported(ResourceKey fileResource) - { - // First check if any registered factory supports this extension - var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); - if (_documentEditorRegistry.IsExtensionSupported(extension)) - { - return true; - } + public DocumentViewType GetDocumentViewType(ResourceKey fileResource) => + _fileTypeClassifier.GetDocumentViewType(fileResource); - // Fall back to built-in types - var documentType = GetDocumentViewType(fileResource); - return documentType != DocumentViewType.UnsupportedFormat; - } + public bool IsDocumentSupported(ResourceKey fileResource) => + _fileTypeClassifier.IsDocumentSupported(fileResource); public string GetDocumentLanguage(ResourceKey fileResource) { @@ -283,93 +261,18 @@ public string GetDocumentLanguage(ResourceKey fileResource) return _fileTypeHelper.GetTextEditorLanguage(extension); } - public async Task GetEditorPreferenceAsync(string extension) - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - var preferenceKey = DocumentConstants.GetEditorPreferenceKey(extension); - var preferredId = await workspaceSettings.GetPropertyAsync(preferenceKey); - - // TryParse handles empty/null/malformed strings; callers are responsible - // for checking whether the returned id still maps to a registered editor. - if (string.IsNullOrEmpty(preferredId) || !DocumentEditorId.TryParse(preferredId, out var editorId)) - { - return DocumentEditorId.Empty; - } - - return editorId; - } - - public async Task SetEditorPreferenceAsync(string extension, DocumentEditorId editorId) - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - var preferenceKey = DocumentConstants.GetEditorPreferenceKey(extension); + public Task GetEditorPreferenceAsync(string extension) => + _preferenceStore.GetExtensionPreferenceAsync(extension); - if (editorId.IsEmpty) - { - // Empty editorId signals that the user wants to clear the preference for this extension - await workspaceSettings.DeletePropertyAsync(preferenceKey); - return; - } + public Task GetPreferredEditorAsync(ResourceKey fileResource) => + _preferenceStore.GetPreferredEditorAsync(fileResource); - await workspaceSettings.SetPropertyAsync(preferenceKey, editorId.ToString()); - } - - public bool CanAccessFile(string resourcePath) - { - if (string.IsNullOrEmpty(resourcePath) || - !File.Exists(resourcePath)) - { - return false; - } - - try - { - var fileInfo = new FileInfo(resourcePath); - using var stream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.Read); - return true; - } - catch (IOException) - { - return false; - } - catch (UnauthorizedAccessException) - { - return false; - } - } - - private Result ResolveAndValidateFilePath(ResourceKey fileResource) - { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult); - } - var filePath = resolveResult.Value; - - if (!File.Exists(filePath)) - { - return Result.Fail($"File path does not exist: '{filePath}'"); - } - - if (!CanAccessFile(filePath)) - { - return Result.Fail($"File exists but cannot be opened: '{filePath}'"); - } - - return filePath; - } + public Task SetEditorPreferenceAsync(string extension, DocumentEditorId editorId) => + _preferenceStore.SetExtensionPreferenceAsync(extension, editorId); public async Task> OpenDocument(ResourceKey fileResource, OpenDocumentOptions? options = null) { - var resolveResult = ResolveAndValidateFilePath(fileResource); + var resolveResult = _fileAccessHelper.ResolveAndValidateFilePath(fileResource); if (resolveResult.IsFailure) { return Result.Fail($"Failed to open document for file resource '{fileResource}'") @@ -436,473 +339,20 @@ public async Task SaveModifiedDocuments(double deltaTime) return Result.Ok(); } - /// - /// DTO for serializing document addresses to workspace settings. - /// - private record StoredDocumentAddress(string Resource, int WindowIndex, int SectionIndex, int TabOrder, string DocumentEditorId = ""); - - public async Task StoreDocumentLayout() - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - var storedAddresses = GetOpenDocuments() - .Select(document => new StoredDocumentAddress( - document.FileResource.ToString(), - document.Address.WindowIndex, - document.Address.SectionIndex, - document.Address.TabOrder, - document.EditorId.ToString())) - .OrderBy(address => address.WindowIndex) - .ThenBy(address => address.SectionIndex) - .ThenBy(address => address.TabOrder) - .ToList(); - - await workspaceSettings.SetPropertyAsync(DocumentLayoutKey, storedAddresses); - } + public Task StoreDocumentLayout() => _layoutStore.StoreDocumentLayoutAsync(); + public Task StoreActiveDocument() => _layoutStore.StoreActiveDocumentAsync(ActiveDocument); - public async Task StoreActiveDocument() - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); + public Task StoreDocumentEditorStates() => _layoutStore.StoreDocumentEditorStatesAsync(); - var fileResource = ActiveDocument.ToString(); + public Task StoreDocumentEditorState(ResourceKey fileResource, string? state) => + _layoutStore.StoreDocumentEditorStateAsync(fileResource, state); - await workspaceSettings.SetPropertyAsync(ActiveDocumentKey, fileResource); - } + public Task RestorePanelState() => _layoutStore.RestorePanelStateAsync(); - public async Task StoreSectionRatios(List ratios) + private Task> CreateDocumentViewInternalAsync(ResourceKey fileResource, DocumentEditorId documentEditorId = default) { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - await workspaceSettings.SetPropertyAsync(SectionRatiosKey, ratios); - } - - public async Task StoreDocumentEditorStates() - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - // Start with existing saved states so that editors that aren't ready yet - // (e.g., WebView still loading) preserve their previously saved state. - var editorStates = await workspaceSettings.GetPropertyAsync>(DocumentEditorStatesKey) - ?? new Dictionary(); - - // Track which documents are currently open so we can remove stale entries - var openDocumentKeys = new HashSet(); - - foreach (var document in GetOpenDocuments()) - { - var resourceKey = document.FileResource.ToString(); - openDocumentKeys.Add(resourceKey); - - var documentView = DocumentsPanel.GetDocumentView(document.FileResource); - if (documentView is null) - { - continue; - } - - try - { - // A null / empty return from TrySaveEditorStateAsync means the editor is either - // still initialising or has no state to contribute. In both cases we keep the - // previously saved state rather than overwriting it. Losing state on a transient - // "not ready" would surprise the user who carefully set scroll/zoom and then - // happens to reload a workspace while a tab is mid-init. - var state = await documentView.TrySaveEditorStateAsync(); - if (!string.IsNullOrEmpty(state)) - { - editorStates[resourceKey] = state; - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, $"Could not save editor state for '{resourceKey}'"); - } - } - - // Remove entries for documents that are no longer open - var staleKeys = editorStates.Keys.Where(key => !openDocumentKeys.Contains(key)).ToList(); - foreach (var staleKey in staleKeys) - { - editorStates.Remove(staleKey); - } - - await workspaceSettings.SetPropertyAsync(DocumentEditorStatesKey, editorStates); - } - - public async Task StoreDocumentEditorState(ResourceKey fileResource, string? state) - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - try - { - var editorStates = await workspaceSettings.GetPropertyAsync>(DocumentEditorStatesKey) - ?? new Dictionary(); - - var resourceKey = fileResource.ToString(); - if (!string.IsNullOrEmpty(state)) - { - editorStates[resourceKey] = state; - } - else - { - editorStates.Remove(resourceKey); - } - - await workspaceSettings.SetPropertyAsync(DocumentEditorStatesKey, editorStates); - } - catch (Exception ex) - { - // Best-effort persistence: losing editor state is a user convenience, not data loss. - // Log at debug level so unexpected failures are visible without being alarming. - _logger.LogDebug(ex, $"Failed to store editor state for '{fileResource}'"); - } - } - - - - public async Task RestorePanelState() - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - // Restore section layout (count is inferred from ratios list length) - var sectionRatios = await workspaceSettings.GetPropertyAsync>(SectionRatiosKey); - if (sectionRatios != null && sectionRatios.Count >= 1 && sectionRatios.Count <= 3) - { - DocumentsPanel.SectionCount = sectionRatios.Count; - DocumentsPanel.SetSectionRatios(sectionRatios); - } - - // Try to load document addresses - if format is incompatible, just start fresh - List? storedAddresses = null; - try - { - storedAddresses = await workspaceSettings.GetPropertyAsync>(DocumentLayoutKey); - } - catch - { - // Old format or corrupted data - ignore and start fresh - _logger.LogDebug("Could not load document addresses - starting fresh"); - } - - if (storedAddresses is null || storedAddresses.Count == 0) - { - // No documents to restore - open default readme - await OpenDefaultReadme(resourceRegistry); - return; - } - - // Load saved editor states for restoration after documents are opened - Dictionary? editorStates = null; - try - { - editorStates = await workspaceSettings.GetPropertyAsync>(DocumentEditorStatesKey); - } - catch - { - _logger.LogDebug("Could not load editor states - starting fresh"); - } - - int currentSectionCount = DocumentsPanel.SectionCount; - - foreach (var stored in storedAddresses) - { - if (!ResourceKey.TryCreate(stored.Resource, out var fileResource)) - { - _logger.LogWarning($"Invalid resource key '{stored.Resource}' found in previously open documents"); - continue; - } - - var getResourceResult = resourceRegistry.GetResource(fileResource); - if (getResourceResult.IsFailure) - { - _logger.LogWarning(getResourceResult, $"Failed to open document because '{fileResource}' resource does not exist."); - continue; - } - - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - _logger.LogWarning(resolveResult, $"Failed to resolve path for resource: '{fileResource}'"); - continue; - } - var filePath = resolveResult.Value; - - if (!CanAccessFile(filePath)) - { - _logger.LogWarning($"Cannot access file for resource: '{fileResource}'"); - continue; - } - - // Handle mismatch: if saved section doesn't exist, merge into last section - int targetSection = Math.Min(stored.SectionIndex, currentSectionCount - 1); - var address = new DocumentAddress(stored.WindowIndex, targetSection, stored.TabOrder); - - // Use TryParse rather than the throwing constructor: a persisted editor id may reference - // a package or contribution that has since been renamed or uninstalled, and an invalid - // value should fall back to the default editor instead of aborting the restore. - DocumentEditorId editorId; - if (string.IsNullOrEmpty(stored.DocumentEditorId)) - { - editorId = DocumentEditorId.Empty; - } - else if (!DocumentEditorId.TryParse(stored.DocumentEditorId, out editorId)) - { - _logger.LogWarning($"Stored document editor id '{stored.DocumentEditorId}' is invalid and will be ignored for resource '{fileResource}'"); - editorId = DocumentEditorId.Empty; - } - - string? editorStateJson = null; - editorStates?.TryGetValue(fileResource.ToString(), out editorStateJson); - - var restoreOptions = new OpenDocumentOptions( - Address: address, - Activate: false, - EditorId: editorId, - EditorStateJson: editorStateJson); - - var openResult = await DocumentsPanel.OpenDocument(fileResource, restoreOptions); - if (openResult.IsFailure) - { - _logger.LogWarning(openResult, $"Failed to open previously open document '{fileResource}'"); - await StoreDocumentEditorState(fileResource, null); - } - } - - // Restore selected document - var selectedDocument = await workspaceSettings.GetPropertyAsync(ActiveDocumentKey); - if (string.IsNullOrEmpty(selectedDocument)) - { - return; - } - - if (!ResourceKey.TryCreate(selectedDocument, out var selectedDocumentKey)) - { - _logger.LogWarning($"Invalid resource key '{selectedDocument}' found for previously selected document"); - return; - } - - // Set the active document. SectionContainer.SetActiveDocument also enforces the invariant - // that every populated section has a selected tab, so non-active sections that had tabs - // inserted during restore get a sensible default selection automatically. - DocumentsPanel.ActiveDocument = selectedDocumentKey; - } - - private async Task OpenDefaultReadme(IResourceRegistry resourceRegistry) - { - var readmeResource = new ResourceKey("readme.md"); - - var normalizeResult = resourceRegistry.NormalizeResourceKey(readmeResource); - if (normalizeResult.IsSuccess) - { - var normalizedResource = normalizeResult.Value; - var resolveResult = resourceRegistry.ResolveResourcePath(normalizedResource); - if (resolveResult.IsSuccess && CanAccessFile(resolveResult.Value)) - { - _commandService.Execute(command => - { - command.FileResource = normalizedResource; - command.ForceReload = false; - }); - } - } - } - - private async Task> CreateDocumentViewInternalAsync(ResourceKey fileResource, DocumentEditorId documentEditorId = default) - { - // First, try to get a document view from the registry - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult); - } - var filePath = resolveResult.Value; - - // Step 0: consult the file's sidecar for a per-file editor preference. - // The sidecar's `editor` field records the user's last explicit - // "Open With X" choice for this file and wins over the per-extension - // workspace preference and the priority-based fallback. A stale or - // unregistered id falls through to the existing chain rather than - // failing the open. - if (documentEditorId.IsEmpty) - { - var sidecarEditorResult = await TryReadEditorFromSidecarAsync(fileResource); - if (sidecarEditorResult.IsSuccess - && !sidecarEditorResult.Value.IsEmpty) - { - var sidecarEditorId = sidecarEditorResult.Value; - var sidecarFactoryResult = _documentEditorRegistry.GetFactoryById(sidecarEditorId); - if (sidecarFactoryResult.IsSuccess) - { - var sidecarFactory = sidecarFactoryResult.Value; - if (sidecarFactory.CanHandleResource(fileResource, filePath)) - { - var sidecarCreateResult = sidecarFactory.CreateDocumentView(fileResource); - if (sidecarCreateResult.IsSuccess) - { - return sidecarCreateResult; - } - - _logger.LogWarning(sidecarCreateResult, - $"Sidecar editor '{sidecarEditorId}' failed to create view for '{fileResource}'; falling through"); - } - } - } - } - - // If a specific editor was requested, use it directly. Do not fall through to priority-based - // resolution on failure: silently opening a different editor than the one the caller asked for - // is confusing and hides real problems (e.g., "Open With..." handing a file to a factory that - // cannot handle it). - if (!documentEditorId.IsEmpty) - { - var getFactoryResult = _documentEditorRegistry.GetFactoryById(documentEditorId); - if (getFactoryResult.IsFailure) - { - return Result.Fail($"No document editor is registered with id '{documentEditorId}'") - .WithErrors(getFactoryResult); - } - var requestedFactory = getFactoryResult.Value; - - if (!requestedFactory.CanHandleResource(fileResource, filePath)) - { - return Result.Fail($"Document editor '{documentEditorId}' cannot handle file resource: '{fileResource}'"); - } - - var createResult = requestedFactory.CreateDocumentView(fileResource); - if (createResult.IsFailure) - { - return Result.Fail($"Document editor '{documentEditorId}' failed to create view for: '{fileResource}'") - .WithErrors(createResult); - } - - return createResult; - } - - // Check workspace preference for this extension - if (documentEditorId.IsEmpty) - { - var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); - var preferredEditorId = await GetEditorPreferenceAsync(extension); - - if (!preferredEditorId.IsEmpty) - { - var getPreferredFactoryResult = _documentEditorRegistry.GetFactoryById(preferredEditorId); - if (getPreferredFactoryResult.IsSuccess) - { - var preferredFactory = getPreferredFactoryResult.Value; - if (preferredFactory.CanHandleResource(fileResource, filePath)) - { - var createResult = preferredFactory.CreateDocumentView(fileResource); - if (createResult.IsSuccess) - { - return createResult; - } - } - } - } - } - - // Fall back to priority-based factory resolution - var factoryResult = _documentEditorRegistry.GetFactory(fileResource, filePath); - if (factoryResult.IsSuccess) - { - var factory = factoryResult.Value; - var createResult = factory.CreateDocumentView(fileResource); - if (createResult.IsSuccess) - { - return createResult; - } - - // Log the failure and fall through to fallback - _logger.LogWarning(createResult, $"Factory failed to create document view for: '{fileResource}'"); - } - - // Fall back for text files when no factory is registered - var viewType = GetDocumentViewType(fileResource); - - if (viewType == DocumentViewType.UnsupportedFormat) - { - return Result.Fail($"File resource is not a supported document format: '{fileResource}'"); - } - - // For text documents with unrecognized extensions, try to find a factory that can handle them. - // Useful when a factory declares support via CanHandleResource rather than a fixed extension list. - if (viewType == DocumentViewType.TextDocument) - { - // Check all factories to see if any can handle this text file - foreach (var factory in _documentEditorRegistry.GetAllFactories().OrderBy(candidate => candidate.Priority)) - { - if (factory.CanHandleResource(fileResource, filePath)) - { - var createResult = factory.CreateDocumentView(fileResource); - if (createResult.IsSuccess) - { - return createResult; - } - } - } - - // Ultimate fallback to TextBoxDocumentView. - var textBoxView = _serviceProvider.GetRequiredService(); - return textBoxView.OkResult(); - } - - return Result.Fail($"Failed to create document view for file: '{fileResource}'"); - } - - // Read the file's sidecar (if any) and return the parsed `editor` field as - // a DocumentEditorId. Returns success with Empty when no sidecar exists, - // the sidecar has no `editor` field, or the field value does not parse as - // a DocumentEditorId. Returns failure only on unexpected sidecar service - // errors (so the caller can fall through gracefully on the success path). - private async Task> TryReadEditorFromSidecarAsync(ResourceKey fileResource) - { - var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - if (sidecarService.IsSidecarKey(fileResource)) - { - // The sidecar file itself does not have a sidecar pairing of its - // own; nothing to consult. - return DocumentEditorId.Empty; - } - - var readResult = await sidecarService.ReadAsync(fileResource); - if (readResult.IsFailure) - { - return Result.Fail($"Failed to read sidecar for '{fileResource}'") - .WithErrors(readResult); - } - - var sidecar = readResult.Value; - if (sidecar.Outcome != SidecarReadOutcome.Healthy - || sidecar.Content is null) - { - return DocumentEditorId.Empty; - } - - if (!sidecar.Content.Frontmatter.TryGetValue("editor", out var editorValue) - || editorValue is not string editorIdString - || string.IsNullOrWhiteSpace(editorIdString)) - { - return DocumentEditorId.Empty; - } - - if (!DocumentEditorId.TryParse(editorIdString, out var editorId)) - { - _logger.LogDebug($"Sidecar for '{fileResource}' has malformed editor value '{editorIdString}'"); - return DocumentEditorId.Empty; - } - - return editorId; + return _viewFactory.CreateAsync(fileResource, documentEditorId); } private void OnDocumentResourceChangedMessage(object recipient, DocumentResourceChangedMessage message) @@ -921,11 +371,8 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource Guard.IsTrue(File.Exists(newResourcePath)); - var oldExtension = Path.GetExtension(oldResource); - var oldDocumentType = _fileTypeHelper.GetDocumentViewType(oldExtension); - - var newExtension = Path.GetExtension(newResource); - var newDocumentType = _fileTypeHelper.GetDocumentViewType(newExtension); + var oldDocumentType = _fileTypeHelper.GetDocumentViewType(oldResource); + var newDocumentType = _fileTypeHelper.GetDocumentViewType(newResource); var changeDocumentResource = async Task () => { @@ -941,33 +388,15 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource _ = changeDocumentResource(); } - private bool _disposed; - public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) + if (_disposed) { - if (disposing) - { - // Dispose managed objects here - _messengerService.UnregisterAll(this); - - // Dispose the document editor registry to clean up factories - _documentEditorRegistry.Dispose(); - } - - _disposed = true; + return; } - } + _disposed = true; - ~DocumentsService() - { - Dispose(false); + _messengerService.UnregisterAll(this); + _documentEditorRegistry.Dispose(); } } diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs index 28e02df80..bbf54f2e3 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs @@ -115,7 +115,7 @@ public bool HasMultipleCompatibleEditors() var extension = Path.GetExtension(FileResource.ToString()).ToLowerInvariant(); var factories = _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry - .GetFactoriesForFileExtension(extension); + .GetFactoriesForExtension(extension); return factories.Count >= 2; } diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index cca528b23..26f7446e1 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -59,11 +59,11 @@ public Result UpdateSaveTimer(double deltaTime) if (SaveTimer <= 0) { SaveTimer = 0; - return Result.Ok(true); + return true; } } - return Result.Ok(false); + return false; } /// @@ -137,7 +137,7 @@ protected async Task> LoadTextFromFileAsync() { var text = await File.ReadAllTextAsync(FilePath); UpdateFileTrackingInfo(); - return Result.Ok(text); + return text; } catch (Exception ex) { diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs index 67e17fa99..e69872d09 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs @@ -146,7 +146,7 @@ public record class EditorDisplayInfo(DocumentEditorId EditorId, string EditorDi { var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); var editorRegistry = _documentsService.DocumentEditorRegistry; - var factories = editorRegistry.GetFactoriesForFileExtension(extension); + var factories = editorRegistry.GetFactoriesForExtension(extension); if (!documentEditorId.IsEmpty) { @@ -160,12 +160,9 @@ public record class EditorDisplayInfo(DocumentEditorId EditorId, string EditorDi if (factories.Count >= 2) { - var resolveResult = ResolveResourcePath(fileResource); - var filePath = resolveResult.IsSuccess ? resolveResult.Value : string.Empty; - foreach (var factory in factories) { - if (factory.CanHandleResource(fileResource, filePath)) + if (factory.CanHandleResource(fileResource)) { return new EditorDisplayInfo(factory.EditorId, factory.DisplayName); } @@ -187,7 +184,7 @@ public record class EditorChoiceInfo( public EditorChoiceInfo? GetChoicesForFileExtension(string extension, DocumentEditorId currentEditorId) { var editorRegistry = _documentsService.DocumentEditorRegistry; - var factories = editorRegistry.GetFactoriesForFileExtension(extension); + var factories = editorRegistry.GetFactoriesForExtension(extension); if (factories.Count < 2) { diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs index 569434dcc..98955fb44 100644 --- a/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs +++ b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs @@ -306,9 +306,8 @@ public async Task> OpenDocument(ResourceKey fileReso var existingSection = existingLocation.Section; var existingTab = existingLocation.Tab; - // If a different editor was requested, close and reopen with the new editor + // Honor an explicit editor request even when the existing tab's EditorId is Empty. bool isDifferentEditor = !effectiveOptions.EditorId.IsEmpty && - !existingTab.ViewModel.EditorId.IsEmpty && effectiveOptions.EditorId != existingTab.ViewModel.EditorId; if (isDifferentEditor) diff --git a/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs b/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs index 26f576edc..ba2abf4e5 100644 --- a/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs +++ b/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs @@ -53,7 +53,7 @@ public Result Initialize(ComponentConfigRegistry configRegistry) public string GetEntityDataPath(ResourceKey resource) { - var entityDataPath = Path.Combine(GetEntitiesFolderPath(), resource) + ".json"; + var entityDataPath = Path.Combine(GetEntitiesFolderPath(), resource.Path) + ".json"; entityDataPath = Path.GetFullPath(entityDataPath); return entityDataPath; } diff --git a/Source/Workspace/Celbridge.Entities/Services/EntityService.cs b/Source/Workspace/Celbridge.Entities/Services/EntityService.cs index 680a24492..c7c926865 100644 --- a/Source/Workspace/Celbridge.Entities/Services/EntityService.cs +++ b/Source/Workspace/Celbridge.Entities/Services/EntityService.cs @@ -92,7 +92,7 @@ public string GetEntityDataPath(ResourceKey resource) public string GetEntityDataRelativePath(ResourceKey resource) { - var relativePath = $"{ProjectConstants.MetaDataFolder}/{ProjectConstants.EntitiesFolder}/{resource}.json"; + var relativePath = $"{ProjectConstants.MetaDataFolder}/{ProjectConstants.EntitiesFolder}/{resource.Path}.json"; return relativePath; } diff --git a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs index e8aada09c..4dd6ee8f0 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs @@ -10,8 +10,7 @@ namespace Celbridge.Explorer.Menu.Options; /// -/// Menu option to open a document with a specific editor chosen by the user. -/// Only visible when multiple editors are registered for the file's extension. +/// Menu option that lets the user pick which editor opens the clicked file. /// public class OpenWithMenuOption : IMenuOption { @@ -51,14 +50,21 @@ public MenuItemState GetState(ExplorerMenuContext context) return new MenuItemState(IsVisible: false, IsEnabled: false); } - var extension = Path.GetExtension(clickedFile.Name).ToLowerInvariant(); - var registry = _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry; - var factories = registry.GetFactoriesForFileExtension(extension); + var candidates = GetCandidateFactories(clickedFile); - bool hasMultipleEditors = factories.Count >= 2; + bool hasMultipleEditors = candidates.Count >= 2; return new MenuItemState(IsVisible: hasMultipleEditors, IsEnabled: hasMultipleEditors); } + private IReadOnlyList GetCandidateFactories(IFileResource clickedFile) + { + var registry = _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry; + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resourceKey = resourceRegistry.GetResourceKey(clickedFile); + + return registry.GetUserPickableFactoriesForResource(resourceKey); + } + public async void Execute(ExplorerMenuContext context) { try @@ -82,8 +88,7 @@ private async Task ExecuteAsync(ExplorerMenuContext context) var resourceKey = resourceRegistry.GetResourceKey(clickedFile); var extension = Path.GetExtension(clickedFile.Name).ToLowerInvariant(); - var editorRegistry = _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry; - var factories = editorRegistry.GetFactoriesForFileExtension(extension); + var factories = GetCandidateFactories(clickedFile); if (factories.Count < 2) { @@ -106,9 +111,7 @@ private async Task ExecuteAsync(ExplorerMenuContext context) if (currentEditorId.IsEmpty) { - // GetEditorPreferenceAsync validates the stored value and returns Empty if a - // persisted preference references an editor that has since been renamed or uninstalled. - currentEditorId = await documentsService.GetEditorPreferenceAsync(extension); + currentEditorId = await documentsService.GetPreferredEditorAsync(resourceKey); } int defaultIndex = 0; @@ -148,16 +151,18 @@ private async Task ExecuteAsync(ExplorerMenuContext context) }); } - // Persist the user's explicit per-file choice in the sidecar's `editor` + // Persist the user's explicit per-file choice in the sidecar's editor // field, creating the sidecar if needed. The KISS rule: every "Open // With X" invocation writes the chosen editor, even when it matches // the per-extension default - a redundant entry is less surprising - // than an auto-removal the user did not request. + // than an auto-removal the user did not request. For standalone .cel + // files the SidecarService writes the field directly into the file's + // own frontmatter (the .cel file is its own metadata). _commandService.Execute(command => { command.Resource = resourceKey; - command.Field = "editor"; - command.Value = selectedFactory.EditorId.ToString(); + command.Field = DocumentConstants.SidecarEditorFieldName; + command.Value = selectedFactory.EditorId; }); _commandService.Execute(command => diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs index 69a1fee59..4f0881230 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs @@ -150,7 +150,7 @@ private void MoveResourcesToFolder(List resources, IFolderResource de foreach (var resource in resources) { var sourceResource = _resourceRegistry.GetResourceKey(resource); - var resolvedDestResource = _resourceRegistry.ResolveDestinationResource(sourceResource, destResource); + var resolvedDestResource = _resourceTransferService.ResolveDestinationResource(sourceResource, destResource); if (sourceResource == resolvedDestResource) { diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs index 15c7d2277..fd8b63437 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs @@ -169,7 +169,7 @@ private bool HandleCut(List selectedResources) private bool HandlePaste(ResourceViewItem? selectedItem) { - var destFolderResource = _resourceRegistry.GetContextMenuItemFolder(selectedItem?.Resource); + var destFolderResource = _resourceTransferService.GetContextMenuItemFolder(selectedItem?.Resource); _commandService.Execute(command => { command.DestFolderResource = destFolderResource; diff --git a/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs b/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs index 261ab949e..a4f50ded9 100644 --- a/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs +++ b/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs @@ -85,10 +85,12 @@ private Result CreateFolderInspector(ResourceKey resource) private Result CreateFileInspector(ResourceKey resource) { - var fileExtension = Path.GetExtension(resource); + // WebViewExtension is multi-part (.webview.cel) so Path.GetExtension + // would return only the final suffix. Match on the resource string instead. + var resourceString = resource.ToString(); IInspector? inspector = null; - if (fileExtension == ExplorerConstants.WebViewExtension) + if (resourceString.EndsWith(ExplorerConstants.WebViewExtension, StringComparison.OrdinalIgnoreCase)) { // WebInspector uses XAML with a parameterless constructor inspector = new WebInspector diff --git a/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs b/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs index ea5da2dfb..33ea5dfdc 100644 --- a/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs +++ b/Source/Workspace/Celbridge.Inspector/ViewModels/WebInspectorViewModel.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using Celbridge.Documents; using Celbridge.Logging; using Celbridge.Messaging; @@ -13,11 +12,12 @@ namespace Celbridge.Inspector.ViewModels; public partial class WebInspectorViewModel : InspectorViewModel { + private const string SourceUrlFieldName = "source_url"; + private readonly ILogger _logger; private readonly IStringLocalizer _stringLocalizer; private readonly IMessengerService _messengerService; - private readonly IResourceRegistry _resourceRegistry; - private readonly IResourceFileSystem _resourceFileSystem; + private readonly IWorkspaceWrapper _workspaceWrapper; private readonly IWebViewService _webViewService; [ObservableProperty] @@ -73,8 +73,7 @@ public WebInspectorViewModel( _logger = logger; _stringLocalizer = stringLocalizer; _messengerService = messengerService; - _resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - _resourceFileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + _workspaceWrapper = workspaceWrapper; _webViewService = webViewService; _messengerService.Register(this, OnWebViewNavigationStateChanged); @@ -156,22 +155,7 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro { if (e.PropertyName == nameof(Resource)) { - var resolveLoadResult = _resourceRegistry.ResolveResourcePath(Resource); - if (resolveLoadResult.IsFailure) - { - _logger.LogError(resolveLoadResult, $"Failed to resolve path for resource: '{Resource}'"); - return; - } - var loadResult = LoadWebView(resolveLoadResult.Value); - if (loadResult.IsFailure) - { - _logger.LogError(loadResult, $"Failed to load .webview file: {resolveLoadResult.Value}"); - return; - } - - _suppressSaving = true; - SourceUrl = loadResult.Value; - _suppressSaving = false; + _ = LoadWebViewAsync(Resource); } else if (e.PropertyName == nameof(SourceUrl) && !_suppressSaving) { @@ -179,57 +163,55 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro } } - private Result LoadWebView(string webFilePath) + private async Task LoadWebViewAsync(ResourceKey resource) { - if (!File.Exists(webFilePath)) + // The .webview.cel file is a standalone .cel form, so SidecarService + // treats the resource itself as the storage. Parse and chokepoint IO + // live in the sidecar service; this method just plucks 'source_url' + // from the frontmatter and posts it back to the inspector field. + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var readResult = await sidecarService.ReadAsync(resource); + if (readResult.IsFailure) { - return Result.Fail($"File not found at path: {webFilePath}"); + _logger.LogError(readResult, $"Failed to read .webview.cel file: '{resource}'"); + return; } + var read = readResult.Value; - try + if (read.Outcome == SidecarReadOutcome.Broken) { - var json = File.ReadAllText(webFilePath); + _logger.LogError($"Failed to parse .webview.cel file '{resource}': {read.FailureMessage ?? "parse failed"}"); + return; + } - if (string.IsNullOrEmpty(json)) + var sourceUrl = string.Empty; + if (read.Outcome == SidecarReadOutcome.Healthy + && read.Content is not null + && read.Content.Frontmatter.TryGetValue(SourceUrlFieldName, out var urlObject)) + { + if (urlObject is string urlValue) { - return Result.Ok(string.Empty); + sourceUrl = urlValue; } - - var jsonObject = JsonNode.Parse(json) as JsonObject; - if (jsonObject is null) + else { - return Result.Fail($"Failed to parse JSON file: {webFilePath}"); + var actualType = urlObject?.GetType().Name ?? "null"; + _logger.LogWarning($"Field '{SourceUrlFieldName}' in '{resource}' is not a string (got {actualType})"); } - - var sourceUrl = jsonObject["sourceUrl"]?.ToString() ?? string.Empty; - - return Result.Ok(sourceUrl); - } - catch (Exception ex) - { - return Result.Fail($"An exception occurred when loading .webview file: {webFilePath}") - .WithException(ex); } + + _suppressSaving = true; + SourceUrl = sourceUrl; + _suppressSaving = false; } private async Task SaveWebViewAsync(ResourceKey resource, string sourceUrl) { - try - { - var jsonObject = new JsonObject - { - ["sourceUrl"] = sourceUrl - }; - - var writeResult = await _resourceFileSystem.WriteAllTextAsync(resource, jsonObject.ToJsonString()); - if (writeResult.IsFailure) - { - _logger.LogError(writeResult, $"Failed to save .webview file: '{resource}'"); - } - } - catch (Exception ex) + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var setResult = await sidecarService.SetFieldAsync(resource, SourceUrlFieldName, sourceUrl); + if (setResult.IsFailure) { - _logger.LogError(ex, $"An exception occurred when saving .webview file: '{resource}'"); + _logger.LogError(setResult, $"Failed to save .webview.cel file: '{resource}'"); } } } diff --git a/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs b/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs new file mode 100644 index 000000000..5096c51c8 --- /dev/null +++ b/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs @@ -0,0 +1,38 @@ +using Celbridge.Documents; +using Microsoft.Extensions.Localization; + +namespace Celbridge.Packages; + +/// +/// Factory that claims ownership of per-contribution document manifests +/// (*.document.cel). These files are sub-components of a package, loaded by +/// PackageManifestLoader as part of package.cel resolution; they are never opened +/// as in-workspace documents. The factory reserves the extension so the +/// resources subsystem classifies a .document.cel file as a standalone .cel +/// form rather than an orphan .cel file. +/// +public class DocumentContributionFactory : DocumentEditorFactoryBase +{ + private readonly IStringLocalizer _stringLocalizer; + + public override DocumentEditorId EditorId { get; } = new("celbridge.document-contribution"); + + public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_DocumentContribution"); + + public override IReadOnlyList SupportedExtensions { get; } = [".document.cel"]; + + public override bool IsPlaceholder => true; + + public DocumentContributionFactory(IStringLocalizer stringLocalizer) + { + _stringLocalizer = stringLocalizer; + } + + public override Result CreateDocumentView(ResourceKey fileResource) + { + // Document contributions are loaded by PackageManifestLoader as part of + // a parent package.cel; opening one as a document is not a supported flow. + return Result.Fail( + $"Document contribution '{fileResource}' is not opened as a document; it is loaded by the package service."); + } +} diff --git a/Source/Workspace/Celbridge.Packages/ModManifestFactory.cs b/Source/Workspace/Celbridge.Packages/ModManifestFactory.cs deleted file mode 100644 index b1efa0864..000000000 --- a/Source/Workspace/Celbridge.Packages/ModManifestFactory.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Celbridge.Documents; -using Microsoft.Extensions.Localization; - -namespace Celbridge.Packages; - -/// -/// Factory that claims ownership of mod manifest files. Currently matches the -/// legacy package.toml filename; the next migration phase switches to the -/// .mod.cel multi-part extension. Registering through the standard factory -/// surface consolidates mod-manifest identity in the same registry that other -/// document editors use. -/// -public class ModManifestFactory : DocumentEditorFactoryBase -{ - private const string PackageTomlFilename = "package.toml"; - - private readonly IStringLocalizer _stringLocalizer; - - public override DocumentEditorId EditorId { get; } = new("celbridge.mod-manifest"); - - public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_ModManifest"); - - public override IReadOnlyList SupportedExtensions { get; } = Array.Empty(); - - public override IReadOnlyList SupportedFilenames { get; } = [PackageTomlFilename]; - - public ModManifestFactory(IStringLocalizer stringLocalizer) - { - _stringLocalizer = stringLocalizer; - } - - public override Result CreateDocumentView(ResourceKey fileResource) - { - // The mod manifest is loaded by PackageManifestLoader.LoadPackage, not - // as an in-workspace document view. Registering here reserves - // ownership of the filename; opening one as a document is not a - // supported flow. - return Result.Fail( - $"Mod manifest '{fileResource}' is not opened as a document; it is loaded by the package service."); - } -} diff --git a/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs b/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs new file mode 100644 index 000000000..56f917e63 --- /dev/null +++ b/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs @@ -0,0 +1,44 @@ +using Celbridge.Documents; +using Microsoft.Extensions.Localization; + +namespace Celbridge.Packages; + +/// +/// Factory that claims ownership of package manifest files by exact filename +/// (package.cel). The manifest sits at the top of each package folder and has no +/// stem segment, so it is matched by filename rather than by a multi-part +/// extension form. Registering through the standard factory surface +/// consolidates package-manifest identity in the same registry that other +/// document editors use. +/// +public class PackageManifestFactory : DocumentEditorFactoryBase +{ + private const string PackageManifestFilename = "package.cel"; + + private readonly IStringLocalizer _stringLocalizer; + + public override DocumentEditorId EditorId { get; } = new("celbridge.package-manifest"); + + public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_PackageManifest"); + + public override IReadOnlyList SupportedExtensions { get; } = Array.Empty(); + + public override IReadOnlyList SupportedFilenames { get; } = [PackageManifestFilename]; + + public override bool IsPlaceholder => true; + + public PackageManifestFactory(IStringLocalizer stringLocalizer) + { + _stringLocalizer = stringLocalizer; + } + + public override Result CreateDocumentView(ResourceKey fileResource) + { + // The package manifest is loaded by PackageManifestLoader.LoadPackage, not + // as an in-workspace document view. Registering here reserves + // ownership of the extension; opening one as a document is not a + // supported flow. + return Result.Fail( + $"Package manifest '{fileResource}' is not opened as a document; it is loaded by the package service."); + } +} diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs index 7605d5bf4..24c4855dc 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs @@ -11,7 +11,7 @@ namespace Celbridge.Packages; public class PackageRegistry { private const string PackagesFolderName = "packages"; - private const string ManifestFileName = "package.toml"; + private const string ManifestFileName = "package.cel"; private const string ReservedIdPrefix = "celbridge."; // Editors like the code editor can handle 150+ extensions; listing them all diff --git a/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl b/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl index 6e77b237250015ec6d40d986d6f43d9f05f6fa97..5f7f624d42ff7f2bd83a559a2f184b8ec79f8bb6 100644 GIT binary patch delta 4670 zcmY*dXEYpK*Byh2-bIT}v=AlHjVRHg6D^DuM2Ox?1~Ix2qxW7zFzO%-LX>Euw-9Bt zAO=z6^W==h&DCzV+C>hiq#JhB2UTG z-rJ#s#}c;aPM^hiN*1-MH|?D?je|dOCq-i3+9h$xXM_mwgw=z97c16|%;7+G)f^2< z)0scIq~aISc^fE2U`P(wfAl^PUmC)fPf^ff$eCsv}ydaZ!eUTr!0pADW{?5 z?5Z7Z8a0Q~g~si$d%-P&6a1{p*a?jw2Mh4^18Y zjAFTn7;j%ECpVD$6tbS;KBsxA#!M_4i>3yI zaH+qoAW5V^w0B)KwT8^yJ;1Xq5QX|#+OX(`5#W#tyd!Ekec%c_QTHRylkb1ld484A zh^OUNdH1bQgUX%eJ{Q?2ST#^n*rJ3j%DM3g4^YMA1IMf$9%CBL-51L{@2ZaS_Y)sG zXLdgiWpn4_*Sb)770@N)wLqXLz{+M~!nT4WS@2Q~xawg?{Hf=ZG}>X2zmJASj0lac zQprXr*#LH}-irNXiS6EEW*T(x<j$)Ao9)11Q z)CmDnYb|4LX<&y#0T7g;E3k`^VnrCqN&q9!CD2(d{LF^jyddT!r^K1VS96aje23B^ zHA?0tB8rZnW5nAoonX{qQ1Q&ZK7Rihmx@29UK{pCbWJyR@U_K-x_CoTFTU5@PUy|mwjc$%tM$AW-_FwO5ZxKPszpiLGZpI>&S@FGw zLm5XX!>8*URfYAz$DM56MT&Ynt`*eDN!poS9|JM_%b1pNitfNdK^9?|4;T|ro*qZi zoZY5p0Swzuxzz8>%KwhJm7t|Q*_$*?dM|@VF%8BheV1O@L+t0?^=t8Qh4^Zl@GsPw zUTTi%qa*=j?}(uzgQtkYl0tCr&oxPwR+pKQzRO@I<|cVA%Io!eRN@)ybwg*F=eR5c zd1gc?+LEzM z0S-GgWy9uQX3|tFC>_ri8N!Zg?3~wVlfhx8_oFr&s$u5*)ZB9|XZN4_8E{*~CemdO z9G!1Y41DfzaC<5UyB~TO;zTl)0k|2Z5oRVe8Ow3IVKclhyzIf>e!L!W(9oMbFaey?z3CG8avsp7qY zavUn>6uNMv;h$4LrVcmz9SUadlf|>BbjAhcMBG`Z^zo#+|8u~r5lFX^KEaJsJFiqE zE9_J)fX-rGN>(-rJ|*5+;)>1c=droTIeJ|nLJUJpRjyo!c=96cG&*U5uzPicNSuS{ z$L1>{Alq8Qlly4@q!&ukTBhG-op)ZeZp`AyEf1#0HIw?44Cjo+)E94|>t8I|KPzZ} zVrig!t)FXLWg?(T8Pm!X0U||8=cn}hbOS?m8$@7R?j<|R7RC&y z9tV|b_GJt)9cH^1xK7tz29d_8NkW%q9}Yt=3S;=xQNEwkc{J`zELK@GM0@=daP8YK z_RM@;35Hpi_n$mxw&4g*|1ic?rWgGX(_r|y0rKFHhkge$qAj-jL*k#jVHS2YFP3Z% z?o!NW%M@9?Ja{elFrkx(P(D}(>OJhg_#{ZjHbKW5B2fs#Xt(hh%6C{f$)y`kDy2fN zi&E?t7<@@~mt7YQVMqZI#xKnAYULYCY?^RcyLA$A*kG*C1IO_!dAzSM&xH|TcG$u2 zUH&y)0~xB!j5Kl%wzDvh;d4TK>Af)GX-F4{Nz00>T*(x@Nvvlhmb$)LcC@lr7_-LX za^r(~oZGVRsbRhVg1dJVw2OiX;Lx@8fx2&Aeb}1qIsiy1A&rGqlWcwsw~=9_Pc)i+ zM66dde+ZaRSwDN4%D@M0a^wAK#W=4rz@jB-Yuuja+=++x7B7b?|5*l`i>&H6gXBXcY;w4D< zXc>b{;|Zwbz6|xiHdWe8Tc-1&hr5=GazN25Zl+1UbEqSGEtw-fePZkpXKj=f z#%%n#wBDS?ieJR_WU7cor?I}e=>2le>N@X_{d0+|XFU%^)wX%#nrEhV>rq>o-z3Dg ziJkFm<1_d#5zW#}b1GBpots+t2dTjMg=**CYZRzq+iEq4Lhga4ph>v`<$y3P-XCq2 zOXS|0YYfSbjz)Ed^m`uzgRr~vmk=I_nbJ0=3mb2l*#ri5vB{QTg9D}g%aGaBk<8js zUv{A?O}=c^!8izaC)op!r%nYGHxX1d;6GQtF^eO$i^Q-aJQ&StlD(luck_3}TBn?L z_JqKh{pKx)vmpjI=yqbB!k6_izVTh2MwAPySm$myN8~VFBCU)c4j%J#a)jGOFRD=8 zKN&|vO{4uNJFVAwOaA6ULd#2)T(o@mezJOhK?SjpN`<-veSx#>NCRPU>tSo6iG1bQ z3o`qcp&h2~TBdQ?X82x;mLV%GQwr9$0)pGE38LPZsH0E1(Bq_5X)`d>fcaZ5dW0DZ zL(dQGw**hp!3-U00f7lk7m-=W-t*vyiu;gaFaez`w=Z1eH&W9H{O!6(*H^Y&Zo@;% zGpM^>^X+}?3hU)T{JeuQu2VIu!=&7zxPcou_C(!+yAFr>P51ZyM_y-z-LqY!KkhaX z8{I@LwY^tJGI2k7)NUoihH0jHiLq}BOu%@)C|s{E_NX(7erT237|9k#Gtao#0mZTN zXxm?#^`AQO;@=EBeA~=_l`?s7yxyt%3>S|E000mGtg4XaHM>mAwm<-Y263aC1jO^V zUXn+o800DA?pGlZ66q1R{{VPbR_fnun(ae*yEhk%j*29?ok4UPJaCa5X83hBC*eOG z^9eZ>GB{t(LuipC1!yHup4c#!naood)DGw)w*U1M#qq@cG0^tvm^yfikl(%(DMXa> zSw1;~i?H$sOPSOKA-l^VU{zJZpg77z;mWB+?U7Sxhr9ct_TxCG;Xc>NjAEmGcT+zI zjZtXXqQgR|@~Ucm?GAxN-h(O9%k~Wa-)f+R_QUHcN@%gKtm&ZS%f`gKN10AiZyPX) zKTIHIuk_w$;uww3a^dgL*&hN+jZZa@u~b?)M9=PIdda%PPY+Fy22D4&AxCnrM-Y74 zcY9$O`9wa!mzi|g!Y0iYtuwReD$U=`uS=SnFY!11jK~!PwL@FQW^B$}1Tt()ATQ}4 zQ=3vQ9?^HT{da^wywQ)Vs5JA$qC74D(H4J&Wx1he8pi z71(*L>ShNfPum%NC+_ISm$ko+rI*7}&V?39c|A0Kl-TLGiRHH^fELBwp1fqcc?{>; zItb`nU~w6Kg6qi7*azmLHCcT=8##l}ef1^u`=oe27mk|cMJMuPx9RXwV{Ol8DpD0? z3pH;$InL_%&H3)`sc??rr$dzRvnNvf&ooxM(t<)O$xeoL{Ki`>GH4}5V>km+-h9FA zhSq#YY|C$yu9y@xqjozTREk+m{&a516NlXJatl4#8PWFg$W^p|P7bp+%lFF?6H*ag zKE=Nr3=7_kCoG_c=>OR(8>a|nl$Q$bafW_d8T9jsY!y{jShjiaR5Ug=Z0z&a!6;}k z%Q(iGz6;7(pw)MQ)#V7HxV8x#Ns1^cg3N9#7bWl4az^@;L4M@+e_3~)ShX|w$ObLa z+?sm+=EgBlGANb5p9ORW{a)Q?s>S~yHAY#(CNT0N`Xa}SBG?I!n!A2N0+O^!BnSnL zWL<%&i0PS%x)RzysNtVp`3&Kr&KgC7y zK}P#WlDYT&9t1jUqEfDc_EyW4vWGoR`d28<#72 zGG~XDsFRQ6=74*X%Ec)Y;a{4*6b=Y#qgVB!rl# zHk|BM8TW3O6`<~5alrbTAVvw(OZ4<0UH zT1o68<((N28%i<0QTV?hIAG1N%X3^-e0nzR@?sr-n4I_aRn@#!W{mZmCq8ce5%W z@1)Ulns=xFJK|+w3@+)vJ~rT&CF*SdQsP@teQ>L*|5;6;SGw>z15S8k>0@Ap!7UVm z@0IccrA*-pru=ZlGJfC}t6RtePbm`xCOiFwhh@^hA=kghbjrnmJRW~xS@|PiiuXS- zsX`K15BpoRsJP4ak8)d-C(o|q}81vi4 zi`!uR7qLI n|L;%#^%HymAchbCVEdmL4Waxwe`jdtfZ#Wk6d<3%f4ct%{d?%G delta 4634 zcmY*dXHXN|(hi|W5|tvo2vS4|y+=9{iZm%AAfYG%f`%f!7&=G^n9zIg)qsHXB2q+( zv`_*_NdQ5SDhhnOesk~K_ss0 zSVMCGl%1`It%$4dFxwCk+UV)hdG0D^WdaTm*q(}rDV8-abVQ2KGb8%@ES=W1dQy2V z@qK)2!$Q*Bhy3RM0OR;<{PyX`XS|}7bOSwmukW+{J6Kj@Y`Ut>`|{p?*wjOrQD@q_7Gfj z%$x@)^$a%MF0}H_O*V`6f93>HyX|WAO~uJoKC7feKpT^p-LLnwGSWJ8OxX#sXG(>zPpvbT z2mk=um_H`Y+^qJ#uYV!JMl2x@LuBqdqXJ6Bj?997T$kPo*>7?aLIMxW%_&Fy zU>Squ_v*J?`JLdK8jg25{p(u*y4u7pb~B!(;-4+`{E7i}{h1w`196^yo$+`D)HvP& zW*K}Jr3JPT5<(JBpQtj0KH=T1kL(tI^V_0y=l#bYl7aTU^9wv~#qVG@8;PH`7kKbm z#6l};NQ*{}ItA$(ygU6mNq?H;pdEB>9OLIsusvfq^AsGlmF(T=36i~XW*Gj5p%eDW zuay6skojX_w(%n50#{wj0ySiVEjHl^> zkN2#+ER5ug)OG=Ac(Tx6XQH7aRH8vH`)?oARw!b)Fp8Zh_dj5!M$zc%$aCsWK*tws z2ZeWf^=fF1@MraHz3m^Hvr}?3*)>l{aH`5$cc_9@{Sg_|`{#12L)zADy; zBB{n(3ii+fa6N87DI^D3wLeo8SQV$4f{b3}z!>avCKt~X$)4bi`%)Y;jFrcacFVHV zAGD$wPKFqL=a0AG46<&qa_O*re6)%~uqiV<0few~-cOr=V$Js(CPkiS!AvS%qA$H^ z*@uPZjD-RSZ*PS)dUUlr_(P1zyG%a5m0 zZCmoihUy?g6&o5q4;Xd7xHeY#XzX%R&I07Y3K?rFbU45;2Sq9f(ua+9!a_!G-fE!W zEmA#)7&VTk=zUJMP)#EV+1sAV&-q(_)!+*|qIjfWRf={yI)0!G+3(e~n zg;z4skau5pdvR-yP4r*}rxjLyPt`+cEN0bNQL#f0)yjM9bm#{P!PEg||wi%GOnl(0mAd?t4-RgF8#fV@jk%D)N`sSWpwva(h zvYPh)s%crj63a)`H5tSJ;&vNGPOY@)}ctE(Td)f8P@ zru!o$+i-}}0<&)96dTzSTmzV#W$9M$ZE_EgzCMZmQGM?{os#GqpAW>9B3z*VMaMfJ z@nF{=_KlWLYgfH9U#zOJ3%|_cuIoFFxpyp)XYow(spu~47wP2)Hw1=cbE0DQo{UhEG&>o z<>;tg<-Vi6*}P0AHhEJcfqcfoV?x!}7!c)vs#y~EtAY3g@Sw_dUuhb|%#}EF|7r7Z zFB97qMyTp@Q0c82xYfRow$2Lj%#YG6-{fRYUf>!wJ- zLluKYiJM$2-G?_M)KVM_fo;2xLHq?VsBnQlyoB5}t8b(5f^m=t=&*c4n3Q(Hc&+h|*c~TUXd- zd{sEmN4y!HG1`qS^7<_GiEqY&W}Rl38=@+^AWtI8?l#2&tmpiq~C}-aVUHWY97Sso2YmF}A>BTfMyO@FALg@9wW( z;~N)rea`~}@dkf7U9%T_$wbtkwq^4;l>isa z9Q(qeG^*hp=eJhf1Xk#Ld)MKO?`_sjQ0Mx^(HJh73QzvZyMr+VzV_=v?&|D$rDGA4 zmgmPki`MRURvwR}jMFiUheFvj27v8nY_0gxRZeq-Ca(^~j!vu3C}_-|bYrb6RScnh zhRmCX;(HlZ+;96)0m1q9Mpy^h!A+B_uk#?l9~@dtxe6g>%jsiDLS>3CJNyb{CJuW#6@D7om5HUX`_u}%S((aQU{ejsI%3QQa$l5~u z?#>$03#WuL^EX^6y5}>w2KWD=#;nE~w;dzG?TM7R%i&*BqmUETOxC(s1wPl<^!EE{ zeP4&1f&l;kPy@yKmuzuXpSD`XaC0G7% zXoLSj8{A`GqYceo7h68>*^_&SsI%0?E;V!Q4mx$`pW12bi`i{=-*He#KS|=W5*9$U z7T{@1#9uUhO4crZGBaQa49PRMEedCK(lm=SW+ytDrJX@YNa?88Onq;qh!H@$UDBL*V8F*%g0Hq1#E|Rk1R|%WZAr{?hMwSmSa}h2!ac3t=*Moh1kU( zxEkjZGz|gKcH>t1+D6htZ*$Xn)>u@FjPP5=&9;qU!t<^E*z7<2xAr5~>-=4O6+DD< z)Sn;XN@iJ!yFoU$YGp1>Ow$&)Z9cRzl-?Ma!9^k}h1{*#z{5c$_CC@s=wXgw4DW+E zsh`KOHZZS+!O@Rm)sP7^G@d8rzRz4>BQ$U_xJ}mDE2;Mxh;|s)5R)y&T7g=PeKL5{ z%+T^k!V7WbW09uM8sb4)a&i`y#bw$gW$Jm@=cp%7c5Su<^*jKNPP63w6CA=i!CU&# zUg~&SG<>?7Zkg_UTK_TXR#Kw$LjGhRD<7z){9_ZnE#i6TjV45s6gAP_p~jlKzodzc0rc+G@u*V;3=m6q#%i5cSB)tuJDCoU02iFLHq-so8Yp>n)*&$sj+hk-%3JU=Ruxh zJW;cSj<^G=-y;gi#8(yyIk=zGdwCn|!LY?|v%ZoM!wdoqoGhsGpg7?!B;bfx|6CAb zAkb@8xOPU}IPu_(IZJNEmwjzz-&=~`y}kt8|M_kCFaZOGR$m-@v>H%9v{WI#A8 zp}`vzETw4)oehkSXUmmcBO&~ZytaQ4EbFZJ$-1I)^iR+2mR_mYhP(`$+Lo(&ZW1q` z?S@5^O|qxeX(3T9O!7~wHzXNrmt7B8hUVT<3`=~KU6(E(ndkbp0daB2Z~Ste_^fu( zmH}UI{5+Kv^!ADPP&qF!;2Nf{rqOD3+98~RYvR(e7FYoT&+t;#`+y&OueNtCKUgZ5;Wi+UyJr~s$n$Oa)KuZaKLsowE@aLn! z>WrSDy{gzW%k0@0__O6Q0cc%;D?G}&>_Y4*4-sSN<0k=e=M;q|UATvAgJ!MTb$UrD z&z2@aUP=QptU6QKz+0mffpFJ~k=_nqUv;FkU%p?}%c<)-6cKvlQ~B}(WlVU|;FV_! z7BXlnKc)fk5j|Sii)+^=9`3c@lHcOF)*IhJhV#mgCcpBpwFUM{u{QU=qsp1!8*o6uHt4J5CS`Pa=P6Er>$v#{}oY;5etX%&cER+C1Gzb9T{-3J)U?%KN Mxg?cc{y)k8154$-O#lD@ diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py index be8b62105..74b95022b 100644 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_data.py @@ -14,7 +14,7 @@ from .helpers import delete_if_exists -def assert_project_clean(extra_broken_references=None, extra_orphan_sidecars=None): +def assert_project_clean(extra_broken_references=None, extra_orphan_cel_files=None): """Run data_check_project and assert no unexpected attention items. Pass ``extra_*`` for entries the caller knows about (e.g. a deliberate @@ -24,7 +24,7 @@ def assert_project_clean(extra_broken_references=None, extra_orphan_sidecars=Non report = celbridge.data.check_project() extra_broken = set(extra_broken_references or []) - extra_orphan = set(extra_orphan_sidecars or []) + extra_orphan = set(extra_orphan_cel_files or []) actual_broken = { (entry["source"], entry["missingTarget"]) @@ -35,15 +35,15 @@ def assert_project_clean(extra_broken_references=None, extra_orphan_sidecars=Non f"Unexpected broken references: {unexpected_broken}; expected only {extra_broken}" ) - actual_orphan = set(report.get("orphanSidecars", [])) + actual_orphan = set(report.get("orphanCelFiles", [])) unexpected_orphan = actual_orphan - extra_orphan assert not unexpected_orphan, ( - f"Unexpected orphan sidecars: {unexpected_orphan}; expected only {extra_orphan}" + f"Unexpected orphan .cel files: {unexpected_orphan}; expected only {extra_orphan}" ) - broken_sidecars = report.get("brokenSidecars", []) - assert broken_sidecars == [], ( - f"Unexpected broken sidecars: {broken_sidecars}" + broken_cel_files = report.get("brokenCelFiles", []) + assert broken_cel_files == [], ( + f"Unexpected broken .cel files: {broken_cel_files}" ) @@ -130,7 +130,7 @@ def test_remove_field_is_no_op_when_absent(self, data): def test_get_info_returns_empty_when_no_sidecar(self, data): result = data.get_info("TestData/notes.md") - assert result == {"fields": {}, "blocks": []} + assert result == {"hasSidecar": False, "fields": {}, "blocks": []} def test_set_field_visible_through_file_read(self, data, file): data.set_field("TestData/notes.md", "priority", json.dumps("high")) @@ -181,8 +181,8 @@ def test_clean_project_returns_empty_lists(self, data): # too; we assert only the report shape so the test is robust to other # content in the demo project. assert isinstance(report.get("brokenReferences"), list) - assert isinstance(report.get("orphanSidecars"), list) - assert isinstance(report.get("brokenSidecars"), list) + assert isinstance(report.get("orphanCelFiles"), list) + assert isinstance(report.get("brokenCelFiles"), list) def test_broken_reference_detected_after_target_deleted_with_break_references(self, data, file, explorer): # Create a source that references a target, then delete the target @@ -203,7 +203,7 @@ def test_broken_reference_detected_after_target_deleted_with_break_references(se assert len(broken) == 1 assert broken[0]["source"] == "project:TestData/source.json" - def test_orphan_sidecar_detected_when_parent_missing(self, data, file): + def test_orphan_cel_file_detected_when_parent_missing(self, data, file): # Write a sidecar whose parent does not exist on disk. The pairing # pass classifies it as an orphan. file.write( @@ -211,13 +211,13 @@ def test_orphan_sidecar_detected_when_parent_missing(self, data, file): "tags = [\"orphan\"]\n", ) report = data.check_project() - assert "project:TestData/orphaned.png.cel" in report.get("orphanSidecars", []) + assert "project:TestData/orphaned.png.cel" in report.get("orphanCelFiles", []) - def test_broken_sidecar_detected_when_frontmatter_unparseable(self, data, file): + def test_broken_cel_file_detected_when_frontmatter_unparseable(self, data, file): # Write a sidecar whose frontmatter is malformed TOML. file.write("TestData/notes.md.cel", "this is not = valid // toml") report = data.check_project() - assert "project:TestData/notes.md.cel" in report.get("brokenSidecars", []) + assert "project:TestData/notes.md.cel" in report.get("brokenCelFiles", []) def test_move_preserves_invariant(self, data, explorer, file): # A reference rewrite during a move must leave the project in a diff --git a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_document.py b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_document.py index c0b791647..37c7ea8d3 100644 --- a/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_document.py +++ b/Source/Workspace/Celbridge.Python/packages/celbridge/src/celbridge/integration_tests/test_document.py @@ -66,3 +66,4 @@ def test_open_invalid_section_index(self, document): def test_activate_invalid_resource_key(self, document): with pytest.raises(CelError): document.activate("\\invalid") + diff --git a/Source/Workspace/Celbridge.Resources/Commands/AddResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/AddResourceCommand.cs index 50e40fd64..2fc0fe967 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/AddResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/AddResourceCommand.cs @@ -80,8 +80,8 @@ public static async void AddFile(string sourcePath, ResourceKey destResource) // If the destination resource is a existing folder, resolve the destination resource to a file in // that folder with the same name as the source file. - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolvedDestResource = resourceRegistry.ResolveSourcePathDestinationResource(sourcePath, destResource); + var transferService = workspaceWrapper.WorkspaceService.ResourceService.TransferService; + var resolvedDestResource = transferService.ResolveSourcePathDestinationResource(sourcePath, destResource); var commandService = ServiceLocator.AcquireService(); @@ -109,8 +109,8 @@ public static void AddFolder(string sourcePath, ResourceKey destResource) // If the destination resource is a existing folder, resolve the destination resource to a folder in // that folder with the same name as the source folder. - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolvedDestResource = resourceRegistry.ResolveSourcePathDestinationResource(sourcePath, destResource); + var transferService = workspaceWrapper.WorkspaceService.ResourceService.TransferService; + var resolvedDestResource = transferService.ResolveSourcePathDestinationResource(sourcePath, destResource); var commandService = ServiceLocator.AcquireService(); diff --git a/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs index 458528acc..d30896341 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/AddTagCommand.cs @@ -1,5 +1,4 @@ using Celbridge.Commands; -using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -24,25 +23,7 @@ public AddTagCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - var tag = Tag; var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - return await sidecarService.MutateFrontmatterAsync( - Resource, - dict => - { - var existing = dict.TryGetValue(SidecarHelper.TagsFieldName, out var value) - ? SidecarHelper.ExtractStringList(value) - : Array.Empty(); - - if (existing.Contains(tag, StringComparer.Ordinal)) - { - return; - } - - var updated = new List(existing.Count + 1); - updated.AddRange(existing); - updated.Add(tag); - dict[SidecarHelper.TagsFieldName] = updated; - }); + return await sidecarService.AddTagAsync(Resource, Tag); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 81806e7db..04c4eafb7 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -68,6 +68,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var transferService = workspaceService.ResourceService.TransferService; // Filter out resources whose parent folders are also selected. // This prevents duplicate operations when both a folder and its contents are selected. @@ -87,7 +88,7 @@ public override async Task ExecuteAsync() { foreach (var sourceResource in filteredResources) { - var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, resourceOpService); + var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, transferService, resourceOpService); if (outcome.Result.IsFailure) { @@ -183,10 +184,11 @@ public override async Task ExecuteAsync() private async Task CopySingleResourceAsync( ResourceKey sourceResource, IResourceRegistry resourceRegistry, + IResourceTransferService transferService, IResourceOperationService resourceOpService) { // Resolve destination to handle folder drops - var resolvedDestResource = resourceRegistry.ResolveDestinationResource(sourceResource, DestResource); + var resolvedDestResource = transferService.ResolveDestinationResource(sourceResource, DestResource); // Convert resource keys to absolute paths via the registry so root prefixes // (project:, temp:, logs:) are stripped correctly. Path.Combine with the bare diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs index 6277637e5..b38b8ce63 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs @@ -26,20 +26,18 @@ public GetFileTreeCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var getResult = resourceRegistry.GetResource(Resource); - if (getResult.IsFailure) + // EnumerateFolderAsync at the root surfaces a missing-or-not-a-folder + // error to the caller; deeper recursion silently skips unreadable + // subfolders to match the existing tree-walk behavior. + var rootEntriesResult = await fileSystem.EnumerateFolderAsync(Resource); + if (rootEntriesResult.IsFailure) { - return Result.Fail($"Resource not found: '{Resource}'"); - } - - if (getResult.Value is not IFolderResource folderResource) - { - return Result.Fail($"Resource is not a folder: '{Resource}'"); + return Result.Fail($"Resource not found: '{Resource}'") + .WithErrors(rootEntriesResult); } + var rootEntries = rootEntriesResult.Value; Regex? globRegex = null; if (!string.IsNullOrEmpty(Glob)) @@ -47,14 +45,25 @@ public override async Task ExecuteAsync() globRegex = new Regex(GlobHelper.GlobToRegex(Glob), RegexOptions.IgnoreCase); } - var rootNode = BuildSnapshot(folderResource, Depth, globRegex, TypeFilter); + var folderName = Resource.IsEmpty + ? Resource.Root + : Resource.ResourceName; + + var rootNode = await BuildSnapshotAsync( + fileSystem, folderName, rootEntries, Depth, globRegex, TypeFilter); ResultValue = new FileTreeSnapshot(rootNode); return Result.Ok(); } - private static FileTreeSnapshotNode? BuildSnapshot( - IFolderResource folder, + // Returns the snapshot node for the folder with the supplied entries, or + // null when a non-empty glob filters every child out and the folder itself + // is therefore irrelevant to the result. Subfolder enumeration failures are + // swallowed so a single unreadable directory doesn't break the whole tree. + private static async Task BuildSnapshotAsync( + IResourceFileSystem fileSystem, + string folderName, + IReadOnlyList entries, int remainingDepth, Regex? globRegex, string typeFilter) @@ -67,11 +76,19 @@ public override async Task ExecuteAsync() var showFiles = !string.Equals(typeFilter, "folder", StringComparison.OrdinalIgnoreCase); var showFolders = !string.Equals(typeFilter, "file", StringComparison.OrdinalIgnoreCase); - foreach (var child in folder.Children) + foreach (var entry in entries) { - if (child is IFolderResource childFolder) + if (entry.IsFolder) { - var childNode = BuildSnapshot(childFolder, remainingDepth - 1, globRegex, typeFilter); + var childEntriesResult = await fileSystem.EnumerateFolderAsync(entry.Resource); + if (childEntriesResult.IsFailure) + { + continue; + } + + var childNode = await BuildSnapshotAsync( + fileSystem, entry.Resource.ResourceName, childEntriesResult.Value, + remainingDepth - 1, globRegex, typeFilter); if (childNode is not null && showFolders) { children.Add(childNode); @@ -79,19 +96,20 @@ public override async Task ExecuteAsync() } else if (showFiles) { - if (globRegex is not null && !globRegex.IsMatch(child.Name)) + var fileName = entry.Resource.ResourceName; + if (globRegex is not null && !globRegex.IsMatch(fileName)) { continue; } children.Add(new FileTreeSnapshotNode( - child.Name, + fileName, IsFolder: false, Children: Array.Empty(), Truncated: false)); } } } - else if (folder.Children.Any()) + else if (entries.Count > 0) { isTruncated = true; } @@ -102,7 +120,7 @@ public override async Task ExecuteAsync() } return new FileTreeSnapshotNode( - folder.Name, + folderName, IsFolder: true, Children: children, Truncated: isTruncated); diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs index fc0d73d04..aea3bde8f 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetInfoCommand.cs @@ -16,7 +16,8 @@ public sealed class GetInfoCommand : CommandBase, IGetInfoCommand public GetInfoResult ResultValue { get; private set; } = new GetInfoResult( new Dictionary(), - Array.Empty()); + Array.Empty(), + HasSidecar: false); private readonly IWorkspaceWrapper _workspaceWrapper; @@ -53,7 +54,7 @@ public override async Task ExecuteAsync() .Select(b => new SidecarBlockDescriptor(b.Name, Encoding.UTF8.GetByteCount(b.Content))) .ToList(); - ResultValue = new GetInfoResult(fields, blocks); + ResultValue = new GetInfoResult(fields, blocks, HasSidecar: true); return Result.Ok(); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs index 239e692b6..8d949fff6 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs @@ -21,51 +21,22 @@ public ListFolderContentsCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var getResult = resourceRegistry.GetResource(Resource); - if (getResult.IsFailure) - { - return Result.Fail($"Resource not found: '{Resource}'"); - } - - if (getResult.Value is not IFolderResource folderResource) + var enumerateResult = await fileSystem.EnumerateFolderAsync(Resource); + if (enumerateResult.IsFailure) { - return Result.Fail($"Resource is not a folder: '{Resource}'"); + return Result.Fail($"Resource not found: '{Resource}'") + .WithErrors(enumerateResult); } - var entries = new List(); - foreach (var child in folderResource.Children) - { - var childKey = resourceRegistry.GetResourceKey(child); - var resolveResult = resourceRegistry.ResolveResourcePath(childKey); - if (resolveResult.IsFailure) - { - continue; - } - var childPath = resolveResult.Value; - - if (child is IFolderResource) - { - var directoryInfo = new DirectoryInfo(childPath); - entries.Add(new FolderContentsEntry( - child.Name, - IsFolder: true, - Size: 0, - ModifiedUtc: directoryInfo.LastWriteTimeUtc)); - } - else - { - var fileInfo = new FileInfo(childPath); - entries.Add(new FolderContentsEntry( - child.Name, - IsFolder: false, - Size: fileInfo.Length, - ModifiedUtc: fileInfo.LastWriteTimeUtc)); - } - } + var entries = enumerateResult.Value + .Select(entry => new FolderContentsEntry( + entry.Resource.ResourceName, + IsFolder: entry.IsFolder, + Size: entry.Size, + ModifiedUtc: entry.ModifiedUtc)) + .ToList(); ResultValue = new FolderContentsSnapshot(entries); diff --git a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs index 59ea5d14e..931ed5163 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs @@ -1,27 +1,39 @@ +using System.Globalization; +using System.Text; using Celbridge.Commands; +using Celbridge.Logging; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; /// /// Builds a ProjectCheckReport via on-demand scanning of the project's text -/// files plus the registry's sidecar pairing snapshot. Pure read; no FS -/// mutation. Performance is bounded by scan time; there is no precomputed -/// reference index waiting in memory. +/// files plus the registry's sidecar pairing snapshot. Pure read against the +/// project tree; writes the latest report to logs:project-check.log as a +/// side-effect so the host UI can offer a one-click "open report" affordance +/// without re-running the scan. /// public sealed class ProjectCheckCommand : CommandBase, IProjectCheckCommand { + // Stable filename overwritten on every run; the report is "latest result", + // not a per-run history. + private static readonly ResourceKey ReportFileResource = new("logs:project-check.log"); + + private readonly ILogger _logger; private readonly IWorkspaceWrapper _workspaceWrapper; - public ProjectCheckCommand(IWorkspaceWrapper workspaceWrapper) + public ProjectCheckCommand( + ILogger logger, + IWorkspaceWrapper workspaceWrapper) { + _logger = logger; _workspaceWrapper = workspaceWrapper; } public ProjectCheckReport ResultValue { get; private set; } = new ProjectCheckReport( BrokenReferences: Array.Empty(), - OrphanSidecars: Array.Empty(), - BrokenSidecars: Array.Empty()); + OrphanCelFiles: Array.Empty(), + BrokenCelFiles: Array.Empty()); public override async Task ExecuteAsync() { @@ -56,20 +68,90 @@ public override async Task ExecuteAsync() }); var sidecarReport = registry.GetSidecarReport(); - var orphanSidecars = sidecarReport.Orphan + var orphanCelFiles = sidecarReport.Orphan .OrderBy(k => k.ToString(), StringComparer.Ordinal) - .Select(k => new OrphanSidecar(k)) .ToList(); - var brokenSidecars = sidecarReport.Broken + var brokenCelFiles = sidecarReport.Broken .OrderBy(k => k.ToString(), StringComparer.Ordinal) - .Select(k => new BrokenSidecar(k)) .ToList(); ResultValue = new ProjectCheckReport( BrokenReferences: brokenReferences, - OrphanSidecars: orphanSidecars, - BrokenSidecars: brokenSidecars); + OrphanCelFiles: orphanCelFiles, + BrokenCelFiles: brokenCelFiles); + + await WriteReportFileAsync(ResultValue); return Result.Ok(); } + + // Write a human-readable snapshot of the report to logs:project-check.log. + // Best-effort: a write failure leaves the in-memory ResultValue intact and + // the command still succeeds — the file is a convenience artifact, not part + // of the command's contract. + private async Task WriteReportFileAsync(ProjectCheckReport report) + { + try + { + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var content = FormatReport(report); + var writeResult = await fileSystem.WriteAllTextAsync(ReportFileResource, content); + if (writeResult.IsFailure) + { + _logger.LogWarning(writeResult, "Failed to write project check report to '{Resource}'.", ReportFileResource); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to write project check report to '{Resource}'.", ReportFileResource); + } + } + + private static string FormatReport(ProjectCheckReport report) + { + var builder = new StringBuilder(); + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); + builder.Append("Project consistency check - "); + builder.AppendLine(timestamp); + + var totalFindings = report.BrokenReferences.Count + + report.OrphanCelFiles.Count + + report.BrokenCelFiles.Count; + if (totalFindings == 0) + { + builder.AppendLine(); + builder.AppendLine("No findings."); + return builder.ToString(); + } + + if (report.BrokenReferences.Count > 0) + { + builder.AppendLine(); + builder.AppendLine($"Broken references ({report.BrokenReferences.Count}):"); + foreach (var entry in report.BrokenReferences) + { + builder.AppendLine($" '{entry.Source.FullKey}' references missing '{entry.MissingTarget.FullKey}'"); + } + } + if (report.OrphanCelFiles.Count > 0) + { + builder.AppendLine(); + builder.AppendLine($"Orphan .cel files ({report.OrphanCelFiles.Count}):"); + foreach (var entry in report.OrphanCelFiles) + { + builder.AppendLine($" '{entry.FullKey}'"); + } + } + if (report.BrokenCelFiles.Count > 0) + { + builder.AppendLine(); + builder.AppendLine($"Broken .cel files ({report.BrokenCelFiles.Count}):"); + foreach (var entry in report.BrokenCelFiles) + { + builder.AppendLine($" '{entry.FullKey}'"); + } + } + + return builder.ToString(); + } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs index 5673d0c0c..98ff3f32f 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/RemoveBlockCommand.cs @@ -23,20 +23,7 @@ public RemoveBlockCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - var blockId = BlockId; var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - return await sidecarService.MutateBlocksAsync( - Resource, - blocks => - { - for (int i = blocks.Count - 1; i >= 0; i--) - { - if (string.Equals(blocks[i].Name, blockId, StringComparison.Ordinal)) - { - blocks.RemoveAt(i); - } - } - }, - createIfMissing: false); + return await sidecarService.RemoveBlockAsync(Resource, BlockId); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs index e0e4afada..ef7a810c9 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/RemoveFieldCommand.cs @@ -23,9 +23,6 @@ public RemoveFieldCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - return await sidecarService.MutateFrontmatterAsync( - Resource, - dict => dict.Remove(Field), - createIfMissing: false); + return await sidecarService.RemoveFieldAsync(Resource, Field); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs index 335d6e3e8..be551c6ce 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/RemoveTagCommand.cs @@ -1,5 +1,4 @@ using Celbridge.Commands; -using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -23,33 +22,7 @@ public RemoveTagCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - var tag = Tag; var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - return await sidecarService.MutateFrontmatterAsync( - Resource, - dict => - { - if (!dict.TryGetValue(SidecarHelper.TagsFieldName, out var value)) - { - return; - } - - var existing = SidecarHelper.ExtractStringList(value); - if (!existing.Contains(tag, StringComparer.Ordinal)) - { - return; - } - - var updated = existing.Where(t => !string.Equals(t, tag, StringComparison.Ordinal)).ToList(); - if (updated.Count == 0) - { - dict.Remove(SidecarHelper.TagsFieldName); - } - else - { - dict[SidecarHelper.TagsFieldName] = updated; - } - }, - createIfMissing: false); + return await sidecarService.RemoveTagAsync(Resource, Tag); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs index abe550e48..a1a25ea46 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/SetFieldCommand.cs @@ -1,5 +1,4 @@ using Celbridge.Commands; -using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -31,14 +30,8 @@ public override async Task ExecuteAsync() { return Result.Fail("Value is null."); } - if (!SidecarHelper.IsIndexableValue(Value)) - { - return Result.Fail($"Field '{Field}' value is not indexable. Only scalar (string/number/bool) and list-of-scalar values are supported."); - } var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - return await sidecarService.MutateFrontmatterAsync( - Resource, - dict => dict[Field] = Value!); + return await sidecarService.SetFieldAsync(Resource, Field, Value); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs index 29b5f24a5..6e77b4ca5 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs @@ -42,6 +42,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var transferService = workspaceService.ResourceService.TransferService; // Filter out any items where the destination resource already exists TransferItems.RemoveAll(item => resourceRegistry.GetResource(item.DestResource).IsSuccess); @@ -60,7 +61,7 @@ public override async Task ExecuteAsync() { foreach (var item in TransferItems) { - var result = await TransferSingleItemAsync(item, resourceRegistry, resourceOpService); + var result = await TransferSingleItemAsync(item, resourceRegistry, transferService, resourceOpService); if (result.IsFailure) { failedItems.Add(item.DestResource.ResourceName); @@ -96,6 +97,7 @@ public override async Task ExecuteAsync() private async Task TransferSingleItemAsync( ResourceTransferItem item, IResourceRegistry resourceRegistry, + IResourceTransferService transferService, IResourceOperationService resourceOpService) { if (item.SourceResource.IsEmpty) @@ -106,7 +108,7 @@ private async Task TransferSingleItemAsync( else { // Resource is inside the project folder - copy/move it - return await CopyInternalResourceAsync(item, resourceRegistry, resourceOpService); + return await CopyInternalResourceAsync(item, resourceRegistry, transferService, resourceOpService); } } @@ -148,9 +150,10 @@ private async Task AddExternalResourceAsync( private async Task CopyInternalResourceAsync( ResourceTransferItem item, IResourceRegistry resourceRegistry, + IResourceTransferService transferService, IResourceOperationService resourceOpService) { - var resolvedDestResource = resourceRegistry.ResolveDestinationResource(item.SourceResource, item.DestResource); + var resolvedDestResource = transferService.ResolveDestinationResource(item.SourceResource, item.DestResource); var resolveSourceResult = resourceRegistry.ResolveResourcePath(item.SourceResource); if (resolveSourceResult.IsFailure) diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs index b705015fb..7b47bc1f8 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs @@ -6,6 +6,9 @@ namespace Celbridge.Resources.Commands; public class WriteBinaryFileCommand : CommandBase, IWriteBinaryFileCommand { + // See WriteFileCommand for why this command always refreshes the registry. + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + private readonly ILogger _logger; private readonly IWorkspaceWrapper _workspaceWrapper; @@ -33,28 +36,14 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") - .WithErrors(resolveResult); - } - var isNewFile = !File.Exists(resolveResult.Value); - var writeResult = await fileSystem.WriteAllBytesAsync(FileResource, bytes); if (writeResult.IsFailure) { return writeResult; } - if (isNewFile) - { - resourceRegistry.UpdateResourceRegistry(); - } - return Result.Ok(); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs index e07f5b96d..4d924550a 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteBlockCommand.cs @@ -24,32 +24,7 @@ public WriteBlockCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - var blockId = BlockId; - var content = Content; var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - return await sidecarService.MutateBlocksAsync( - Resource, - blocks => - { - var index = -1; - for (int i = 0; i < blocks.Count; i++) - { - if (string.Equals(blocks[i].Name, blockId, StringComparison.Ordinal)) - { - index = i; - break; - } - } - - var updated = new SidecarBlock(blockId, content); - if (index >= 0) - { - blocks[index] = updated; - } - else - { - blocks.Add(updated); - } - }); + return await sidecarService.WriteBlockAsync(Resource, BlockId, Content); } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs index 43bc7b448..adcf6bb91 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs @@ -6,6 +6,12 @@ namespace Celbridge.Resources.Commands; public class WriteFileCommand : CommandBase, IWriteFileCommand { + // Force a registry update so sidecar classification refreshes on every + // write. Without this, overwriting an existing .cel file with broken TOML + // would leave data_check_project returning the stale "Healthy" status + // while data_get_field correctly rejects the file at read time. + public override CommandFlags CommandFlags => CommandFlags.UpdateResources; + private readonly ILogger _logger; private readonly IWorkspaceWrapper _workspaceWrapper; @@ -34,14 +40,14 @@ public override async Task ExecuteAsync() } var resourcePath = resolveResult.Value; - var isNewFile = !File.Exists(resourcePath); - - // Preserve existing line endings when overwriting. Use LF for new - // files regardless of host platform (see LineEndingHelper). + // Preserve existing line endings when overwriting. For a new file, + // honour whatever endings the caller's content already uses (so a CSV + // exporter emitting CRLF lands as CRLF on disk); fall back to the + // platform default when the content has no line endings to detect. string targetSeparator; - if (isNewFile) + if (!File.Exists(resourcePath)) { - targetSeparator = LineEndingHelper.PlatformDefault; + targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(Content); } else { @@ -57,12 +63,6 @@ public override async Task ExecuteAsync() return writeResult; } - if (isNewFile) - { - // Update the resource registry so the new file is immediately visible - resourceRegistry.UpdateResourceRegistry(); - } - return Result.Ok(); } diff --git a/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs b/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs index 51f11af9b..7a177be6d 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs @@ -65,7 +65,7 @@ public Result ValidateAndResolve(string rootName, string backingLocation return Result.Fail(reparseResult.FirstErrorMessage); } - return Result.Ok(resolvedPath); + return resolvedPath; } /// diff --git a/Source/Workspace/Celbridge.Resources/Helpers/ResourceTreeNavigator.cs b/Source/Workspace/Celbridge.Resources/Helpers/ResourceTreeNavigator.cs new file mode 100644 index 000000000..ac8150ea5 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Helpers/ResourceTreeNavigator.cs @@ -0,0 +1,101 @@ +using System.Text; + +namespace Celbridge.Resources.Helpers; + +/// +/// Pure tree-walking helpers over an IResource graph. Builds resource keys +/// from an in-tree node (walk parents) and finds a node from a resource key +/// (walk segments). Stateless and independent of the resource registry so +/// the same logic is shared between the registry, the sidecar pairing service, +/// and anyone else who needs to traverse the tree. +/// +public static class ResourceTreeNavigator +{ + /// + /// Walks the parent chain to build the project-relative resource key. + /// The root folder has a null ParentFolder and contributes no segment. + /// + public static ResourceKey BuildKey(IResource resource) + { + try + { + var builder = new StringBuilder(); + Append(resource); + + return ResourceKey.Create(builder.ToString()); + + void Append(IResource current) + { + if (current.ParentFolder is null) + { + return; + } + + Append(current.ParentFolder); + + if (builder.Length > 0) + { + builder.Append('/'); + } + builder.Append(current.Name); + } + } + catch (Exception ex) + { + throw new ArgumentException($"Failed to get resource key for '{resource}'", ex); + } + } + + /// + /// Walks the segments of the supplied key and returns the matching node + /// under the root, or a failure result if no match exists. An empty key + /// resolves to the root itself. + /// + public static Result FindResource(IFolderResource root, ResourceKey resource) + { + if (resource.IsEmpty) + { + return Result.Ok(root); + } + + var segments = resource.Path.Split('/'); + var searchFolder = root; + + var segmentIndex = 0; + while (segmentIndex < segments.Length) + { + IFolderResource? matchingFolder = null; + string segment = segments[segmentIndex]; + foreach (var childResource in searchFolder.Children) + { + if (childResource is IFolderResource childFolder + && childFolder.Name == segment) + { + if (segmentIndex == segments.Length - 1) + { + return Result.Ok(childFolder); + } + + matchingFolder = childFolder; + break; + } + else if (childResource is IFileResource childFile + && childFile.Name == segment + && segmentIndex == segments.Length - 1) + { + return Result.Ok(childFile); + } + } + + if (matchingFolder is null) + { + break; + } + + searchFolder = matchingFolder; + segmentIndex++; + } + + return Result.Fail($"Failed to find a resource matching the resource key '{resource}'."); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Helpers/ResourceUtils.cs b/Source/Workspace/Celbridge.Resources/Helpers/ResourceUtils.cs index d4954bd40..cd050f536 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/ResourceUtils.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/ResourceUtils.cs @@ -1,6 +1,4 @@ -using System.Text.Json; using Windows.System; -using Celbridge.Explorer; namespace Celbridge.Resources.Helpers; @@ -156,63 +154,4 @@ public static async Task OpenBrowser(string url) return Result.Ok(); } - - public static Result ExtractUrlFromWebViewFile(string webViewPath) - { - try - { - if (string.IsNullOrEmpty(webViewPath)) - { - return Result.Fail($"Failed to get path for file resource: {webViewPath}"); - } - - if (!File.Exists(webViewPath)) - { - return Result.Fail($"File does not exist: {webViewPath}"); - } - - var fileExtension = Path.GetExtension(webViewPath); - - if (fileExtension == ExplorerConstants.WebViewExtension) - { - return Result.Fail($"File does not have the .webview extension: {webViewPath}"); - } - - var json = File.ReadAllText(webViewPath); - using var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - if (!root.TryGetProperty("sourceUrl", out var urlElement)) - { - return Result.Fail($"Failed to find 'sourceUrl' property in .webview JSON data: {webViewPath}"); - } - - var urlValue = urlElement.GetString(); - if (urlValue is null) - { - return Result.Fail($"Failed to find 'sourceUrl' property in .webview JSON data: {webViewPath}"); - } - - // Todo: This logic is repeated in multiple places, move it to the utility service - string targetUrl = urlValue.Trim(); - if (!string.IsNullOrWhiteSpace(targetUrl) && - !targetUrl.StartsWith("http") && - !targetUrl.StartsWith("file")) - { - targetUrl = $"https://{targetUrl}"; - } - - if (!Uri.IsWellFormedUriString(targetUrl, UriKind.Absolute)) - { - return Result.Fail($"Url is not valid: {targetUrl}"); - } - - return Result.Ok(targetUrl); - } - catch (Exception ex) - { - return Result.Fail($"An exception occurred when extracting the url from a .webview file") - .WithException(ex); ; - } - } } diff --git a/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs b/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs index c36e281eb..a6f5c2ae8 100644 --- a/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs +++ b/Source/Workspace/Celbridge.Resources/ProjectFileFactory.cs @@ -4,11 +4,9 @@ namespace Celbridge.Resources; /// -/// Factory that claims ownership of Celbridge project files. Currently matches -/// the legacy .celbridge extension; the next migration phase switches to the -/// .project.cel multi-part extension. Registering through the standard factory -/// surface consolidates project-file identity in the same registry that other -/// document editors use. +/// Factory that claims ownership of Celbridge project files via the .celbridge +/// extension. Registering through the standard factory surface consolidates +/// project-file identity in the same registry that other document editors use. /// public class ProjectFileFactory : DocumentEditorFactoryBase { @@ -20,6 +18,8 @@ public class ProjectFileFactory : DocumentEditorFactoryBase public override IReadOnlyList SupportedExtensions { get; } = [".celbridge"]; + public override bool IsPlaceholder => true; + public ProjectFileFactory(IStringLocalizer stringLocalizer) { _stringLocalizer = stringLocalizer; diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index 7bafb665f..c96f09ed4 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -16,7 +16,6 @@ public static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -24,6 +23,8 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // diff --git a/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs b/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs new file mode 100644 index 000000000..9d54ac732 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs @@ -0,0 +1,124 @@ +using Celbridge.Projects; +using Celbridge.UserInterface; + +namespace Celbridge.Resources.Services; + +public sealed class ProjectTreeBuilder : IProjectTreeBuilder +{ + private readonly IFileIconService _fileIconService; + + public ProjectTreeBuilder(IFileIconService fileIconService) + { + _fileIconService = fileIconService; + } + + public IFolderResource BuildTree(string projectFolderPath) + { + var root = new FolderResource(string.Empty, null); + SynchronizeFolder(root, projectFolderPath); + return root; + } + + private void SynchronizeFolder(FolderResource folderResource, string folderPath) + { + bool isProjectFolder = folderResource.ParentFolder is null; + var subFolderPaths = Directory.GetDirectories(folderPath).OrderBy(d => d).ToList(); + RemoveHiddenFolders(subFolderPaths, isProjectFolder); + + var filePaths = Directory.GetFiles(folderPath).OrderBy(f => f).ToList(); + RemoveHiddenFiles(filePaths); + + // Children rebuild from scratch on every call so stale TreeViewNode.Content + // references do not survive a rapid undo/redo cycle. + folderResource.Children.Clear(); + + foreach (var subFolderPath in subFolderPaths) + { + var folderName = Path.GetFileName(subFolderPath); + var childFolder = new FolderResource(folderName, folderResource); + SynchronizeFolder(childFolder, subFolderPath); + folderResource.AddChild(childFolder); + } + + foreach (var filePath in filePaths) + { + var fileName = Path.GetFileName(filePath); + var fileExtension = Path.GetExtension(filePath).TrimStart('.'); + + var getIconResult = _fileIconService.GetFileIconForExtension(fileExtension); + var iconDefinition = getIconResult.IsSuccess + ? getIconResult.Value + : _fileIconService.DefaultFileIcon; + + folderResource.AddChild(new FileResource(fileName, folderResource, iconDefinition)); + } + + folderResource.Children = folderResource.Children + .OrderBy(child => child is IFolderResource ? 0 : 1) + .ThenBy(child => child.Name) + .ToList(); + } + + private static void RemoveHiddenFolders(List folderPaths, bool isProjectFolder) + { + folderPaths.RemoveAll(path => + { + if (Path.GetFileName(path).StartsWith('.')) + { + // Hidden by leading dot. Includes the .celbridge metadata folder. + return true; + } + +#if WINDOWS + var attributes = File.GetAttributes(path); + if ((attributes & System.IO.FileAttributes.Hidden) != 0) + { + return true; + } +#endif + var dirInfo = new DirectoryInfo(path); + + if (isProjectFolder + && dirInfo.Name == ProjectConstants.MetaDataFolder) + { + return true; + } + + if (dirInfo.Name == "__pycache__") + { + return true; + } + + // Python/Lib carries pip packages and is excluded so the user sees + // their project, not their virtualenv internals. + if (dirInfo.Name == "Lib" + && dirInfo.Parent?.Name == "Python") + { + return true; + } + + return false; + }); + } + + private static void RemoveHiddenFiles(List filePaths) + { + filePaths.RemoveAll(path => + { + if (Path.GetFileName(path).StartsWith('.')) + { + return true; + } + +#if WINDOWS + var attributes = File.GetAttributes(path); + if ((attributes & System.IO.FileAttributes.Hidden) != 0) + { + return true; + } +#endif + + return false; + }); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 92fe097d1..a0775651e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -92,7 +92,7 @@ public async Task> OpenReadAsync(ResourceKey resource) FileShare.Read, StreamBufferSize, useAsync: true); - return Result.Ok(stream); + return stream; } catch (Exception ex) { @@ -145,7 +145,7 @@ public async Task> OpenWriteAsync(ResourceKey resource) FileShare.None, StreamBufferSize, useAsync: true); - return Result.Ok(stream); + return stream; } catch (Exception ex) { @@ -188,7 +188,8 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey return Result.Fail($"Source resource does not exist: '{source}'"); } - if (!IsRootWritable(registry, destination)) + var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; + if (!IsRootWritable(rootHandlerRegistry, destination)) { return Result.Fail($"Root '{destination.Root}' is read-only."); } @@ -217,7 +218,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey // entries. After Directory.Move the source path is gone and the // enumeration is no longer possible. var sourceDescendantKeys = sourceIsFolder - ? EnumerateDescendantKeys(registry, sourcePath) + ? EnumerateDescendantKeys(rootHandlerRegistry, sourcePath) : Array.Empty(); try @@ -304,7 +305,8 @@ public async Task> CopyAsync(ResourceKey source, ResourceKey return Result.Fail($"Source resource does not exist: '{source}'"); } - if (!IsRootWritable(registry, destination)) + var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; + if (!IsRootWritable(rootHandlerRegistry, destination)) { return Result.Fail($"Root '{destination.Root}' is read-only."); } @@ -367,7 +369,8 @@ public async Task> DeleteAsync(ResourceKey source) return Result.Fail($"Resource does not exist: '{source}'"); } - if (!IsRootWritable(registry, source)) + var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; + if (!IsRootWritable(rootHandlerRegistry, source)) { return Result.Fail($"Root '{source.Root}' is read-only."); } @@ -377,7 +380,7 @@ public async Task> DeleteAsync(ResourceKey source) // Capture descendant keys (folders only) before the disk delete so the // post-delete eager-notify can drop their stale index entries too. var descendantKeys = sourceIsFolder - ? EnumerateDescendantKeys(registry, sourcePath) + ? EnumerateDescendantKeys(rootHandlerRegistry, sourcePath) : Array.Empty(); try @@ -433,14 +436,14 @@ public async Task> DeleteAsync(ResourceKey source) // Returns the resource keys of every file inside a folder that exists on // disk. Used to capture descendant keys before a recursive delete or move // so eager-notify can drop their stale entries from the reference index. - private static IReadOnlyList EnumerateDescendantKeys(IResourceRegistry registry, string folderPath) + private static IReadOnlyList EnumerateDescendantKeys(IRootHandlerRegistry rootHandlerRegistry, string folderPath) { var keys = new List(); try { foreach (var file in Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories)) { - var keyResult = registry.GetResourceKey(file); + var keyResult = rootHandlerRegistry.GetResourceKey(file); if (keyResult.IsSuccess) { keys.Add(keyResult.Value); @@ -456,19 +459,66 @@ private static IReadOnlyList EnumerateDescendantKeys(IResourceRegis return keys; } - public Task> ExistsAsync(ResourceKey resource) + public async Task> ExistsAsync(ResourceKey resource) { + await Task.CompletedTask; + var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); - return Task.FromResult(failure); } var resourcePath = resolveResult.Value; var exists = File.Exists(resourcePath) || Directory.Exists(resourcePath); - return Task.FromResult(Result.Ok(exists)); + return exists; + } + + public async Task>> EnumerateFolderAsync(ResourceKey folder) + { + await Task.CompletedTask; + + var resolveResult = ResolvePath(folder); + if (resolveResult.IsFailure) + { + return Result>.Fail($"Failed to resolve path for resource: '{folder}'") + .WithErrors(resolveResult); + } + var folderPath = resolveResult.Value; + + if (!Directory.Exists(folderPath)) + { + return Result>.Fail($"Resource is not a folder: '{folder}'"); + } + + try + { + // EnumerateFileSystemInfos populates each entry's metadata in the + // single OS directory listing, avoiding a separate stat per child. + var directoryInfo = new DirectoryInfo(folderPath); + var entries = new List(); + foreach (var info in directoryInfo.EnumerateFileSystemInfos()) + { + var childKey = folder.Combine(info.Name); + + bool isFolder = info is DirectoryInfo; + long size = info is FileInfo file ? file.Length : 0; + + entries.Add(new FolderItem( + Resource: childKey, + IsFolder: isFolder, + Size: size, + ModifiedUtc: info.LastWriteTimeUtc)); + } + + return Result>.Ok(entries); + } + catch (Exception ex) + { + return Result>.Fail($"Failed to enumerate folder: '{folder}'") + .WithException(ex); + } } private Result ResolvePath(ResourceKey resource) @@ -479,9 +529,9 @@ private Result ResolvePath(ResourceKey resource) // Roots that don't have a registered handler are assumed writable — the // default project root falls into this category and is always writable. - private static bool IsRootWritable(IResourceRegistry registry, ResourceKey key) + private static bool IsRootWritable(IRootHandlerRegistry rootHandlerRegistry, ResourceKey key) { - return !registry.RootHandlers.TryGetValue(key.Root, out var handler) + return !rootHandlerRegistry.RootHandlers.TryGetValue(key.Root, out var handler) || handler.Capabilities.IsWritable; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index d44b54f33..8426f5944 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -138,7 +138,7 @@ public async Task> CopyFileAsync(string sourcePath, string de } AddOperation(operation); - return Result.Ok(operation.LastCopyResult ?? EmptyCopyResult); + return operation.LastCopyResult ?? EmptyCopyResult; } private async Task> CopyExternalFileAsync(string sourcePath, string destPath) @@ -150,7 +150,7 @@ private async Task> CopyExternalFileAsync(string sourcePath, return Result.Fail(execResult); } AddOperation(operation); - return Result.Ok(EmptyCopyResult); + return EmptyCopyResult; } private async Task> CopyExternalFolderAsync(string sourcePath, string destPath) @@ -162,7 +162,7 @@ private async Task> CopyExternalFolderAsync(string sourcePath return Result.Fail(execResult); } AddOperation(operation); - return Result.Ok(EmptyCopyResult); + return EmptyCopyResult; } private bool IsInProjectFolder(string absolutePath) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index 4259dd05e..09b9a7e9c 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -1,9 +1,6 @@ -using System.Text; using Celbridge.Logging; -using Celbridge.Projects; using Celbridge.Resources.Helpers; using Celbridge.Resources.Services.Roots; -using Celbridge.UserInterface; namespace Celbridge.Resources.Services; @@ -11,9 +8,9 @@ public class ResourceRegistry : IResourceRegistry { private readonly ILogger _logger; private readonly IMessengerService _messengerService; - private readonly IFileIconService _fileIconService; - private readonly PathValidator _pathValidator = new(); - private readonly Dictionary _rootHandlers = new(StringComparer.Ordinal); + private readonly IProjectTreeBuilder _projectTreeBuilder; + private readonly ISidecarPairingService _sidecarPairingService; + private readonly RootHandlerRegistry _rootHandlerRegistry; // Sidecar tracking state, refreshed on each UpdateResourceRegistry pass. // The report is rebuilt atomically per pass so readers always see a coherent @@ -27,80 +24,50 @@ public class ResourceRegistry : IResourceRegistry private string _projectFolderPath = string.Empty; - public string ProjectFolderPath + public string ProjectFolderPath => _projectFolderPath; + + public void InitializeProjectRoot(string projectFolderPath) { - get => _projectFolderPath; - set - { - _projectFolderPath = value; - // Construct (or replace) the project root handler whenever the project folder - // path is set. ResolveResourcePath delegates to this handler for any project-root key. - if (!string.IsNullOrEmpty(value)) - { - RegisterRootHandler(new ProjectRootHandler(value, _pathValidator)); - } - } + Guard.IsNotNullOrEmpty(projectFolderPath); + + _projectFolderPath = projectFolderPath; + _rootHandlerRegistry.RegisterRootHandler( + new ProjectRootHandler(projectFolderPath, _rootHandlerRegistry.PathValidator)); } private FolderResource _projectFolder = new FolderResource(string.Empty, null); public IFolderResource ProjectFolder => _projectFolder; - public IReadOnlyDictionary RootHandlers => _rootHandlers; + public IReadOnlyDictionary RootHandlers => _rootHandlerRegistry.RootHandlers; public ResourceRegistry( ILogger logger, IMessengerService messengerService, - IFileIconService fileIconService) + IProjectTreeBuilder projectTreeBuilder, + ISidecarPairingService sidecarPairingService, + RootHandlerRegistry rootHandlerRegistry) { _logger = logger; _messengerService = messengerService; - _fileIconService = fileIconService; + _projectTreeBuilder = projectTreeBuilder; + _sidecarPairingService = sidecarPairingService; + _rootHandlerRegistry = rootHandlerRegistry; } public void RegisterRootHandler(IResourceRootHandler handler) { - _rootHandlers[handler.RootName] = handler; + _rootHandlerRegistry.RegisterRootHandler(handler); } public bool IsResolvable(ResourceKey key) { - return _rootHandlers.ContainsKey(key.Root); + return _rootHandlerRegistry.IsResolvable(key); } public ResourceKey GetResourceKey(IResource resource) { - try - { - var sb = new StringBuilder(); - void AddResourceKeySegment(IResource resource) - { - if (resource.ParentFolder is null) - { - return; - } - - // Build path by recursively visiting each parent folders - AddResourceKeySegment(resource.ParentFolder); - - // The trick is to append the path segment after we've visited the parent. - // This ensures the path segments are appended in the right order. - if (sb.Length > 0) - { - sb.Append("/"); - } - sb.Append(resource.Name); - } - AddResourceKeySegment(resource); - - var resourceKey = ResourceKey.Create(sb.ToString()); - - return resourceKey; - } - catch (Exception ex) - { - throw new ArgumentException($"Failed to get resource key for '{resource}'", ex); - } + return ResourceTreeNavigator.BuildKey(resource); } public List GetResourceKeys(IEnumerable resources) @@ -110,52 +77,7 @@ public List GetResourceKeys(IEnumerable resources) public Result GetResourceKey(string resourcePath) { - try - { - // Cross-root dispatch: find the registered handler whose backing location is - // the longest prefix (left substring) of the absolute path. Longest-prefix-wins - // so that a path under .celbridge/temp/ matches the temp handler rather - // than the project handler (which has the shorter / prefix). e.g. - // C:\proj\ (project root, length 8) - // C:\proj\.celbridge\temp\ (temp root, length 23) - var normalizedPath = Path.GetFullPath(resourcePath); - - var comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - IResourceRootHandler? bestHandler = null; - int bestPrefixLength = -1; - - foreach (var handler in _rootHandlers.Values) - { - var backing = Path.GetFullPath(handler.BackingLocation) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - bool isBackingRoot = normalizedPath.Equals(backing, comparison); - bool isUnderBacking = normalizedPath.StartsWith( - backing + Path.DirectorySeparatorChar, comparison); - - if ((isBackingRoot || isUnderBacking) && backing.Length > bestPrefixLength) - { - bestHandler = handler; - bestPrefixLength = backing.Length; - } - } - - if (bestHandler is null) - { - return Result.Fail( - $"The path '{resourcePath}' is not under any registered resource root."); - } - - return bestHandler.GetResourceKey(normalizedPath); - } - catch (Exception ex) - { - return Result.Fail($"An exception occurred when getting the resource key.") - .WithException(ex); - } + return _rootHandlerRegistry.GetResourceKey(resourcePath); } public Result ResolveResourcePath(IResource resource) @@ -166,13 +88,7 @@ public Result ResolveResourcePath(IResource resource) public Result ResolveResourcePath(ResourceKey resource) { - if (!_rootHandlers.TryGetValue(resource.Root, out var handler)) - { - return Result.Fail( - $"Resource root '{resource.Root}' is not registered."); - } - - var resolveResult = handler.Resolve(resource); + var resolveResult = _rootHandlerRegistry.ResolveResourcePath(resource); if (resolveResult.IsFailure) { return resolveResult; @@ -184,7 +100,9 @@ public Result ResolveResourcePath(ResourceKey resource) // Without this guard a Windows user can resolve a wrong-case key // (Windows IO is case-insensitive) but the in-memory tree and cascade // scanner (both Ordinal-case-sensitive) would treat it as a separate - // resource, leaving the project in an inconsistent state. + // resource, leaving the project in an inconsistent state. The case + // check requires the project tree, so it stays on the registry rather + // than moving down into the root handler registry. if (resource.Root == ResourceKey.DefaultRoot) { var caseCheck = EnsureProjectKeyCaseMatchesDisk(resource, absolutePath); @@ -258,141 +176,7 @@ public Result GetResource(ResourceKey resource) $"GetResource is scoped to the project tree; root '{resource.Root}' has no tracked resources."); } - if (resource.IsEmpty) - { - // An empty resource key refers to the project folder - return Result.Ok(_projectFolder); - } - - var segments = resource.Path.Split('/'); - var searchFolder = _projectFolder; - - // Attempt to match each segment with the corresponding resource in the tree - var segmentIndex = 0; - while (segmentIndex < segments.Length) - { - FolderResource? matchingFolder = null; - string segment = segments[segmentIndex]; - foreach (var childResource in searchFolder.Children) - { - if (childResource is FolderResource childFolder && - childFolder.Name == segment) - { - if (segmentIndex == segments.Length - 1) - { - // The folder name matches the last segment in the key, so this is the - // folder resource we're looking for. - return Result.Ok(childFolder); - } - - // This folder resource matches this segment in the key, so we can move onto - // searching for the next segment. - matchingFolder = childFolder; - break; - } - else if (childResource is FileResource childFile && - childFile.Name == segment && - segmentIndex == segments.Length - 1) - { - // The file name matches the last segment in the key, so this is the - // file resource we're looking for. - return Result.Ok(childFile); - } - } - - if (matchingFolder is null) - { - break; - } - - searchFolder = matchingFolder; - segmentIndex++; - } - - return Result.Fail($"Failed to find a resource matching the resource key '{resource}'."); - } - - public ResourceKey ResolveDestinationResource(ResourceKey sourceResource, ResourceKey destResource) - { - string output = destResource; - - var getResult = GetResource(destResource); - if (getResult.IsSuccess) - { - var resource = getResult.Value; - if (resource is IFolderResource) - { - if (destResource.IsEmpty) - { - // Destination is the project folder - output = sourceResource.ResourceName; - } - else - { - if (sourceResource == destResource) - { - // Source and destination are the same folder. This case is allowed because - // the user may duplicate a folder by copying and pasting it to the same destination. - output = destResource; - } - else - { - // Destination is a folder, so append the source resource name to this folder. - output = destResource.Combine(sourceResource.ResourceName); - } - } - } - } - - return output; - } - - public ResourceKey ResolveSourcePathDestinationResource(string sourcePath, ResourceKey destResource) - { - string output = destResource; - - var getResult = GetResource(destResource); - if (getResult.IsSuccess) - { - var resource = getResult.Value; - if (resource is IFolderResource) - { - var filename = Path.GetFileName(sourcePath); - if (destResource.IsEmpty) - { - // Destination is the project folder - output = filename; - } - else - { - // Destination is a folder, so append the source filename to this folder. - output = destResource.Combine(filename); - } - } - } - - return output; - } - - - public ResourceKey GetContextMenuItemFolder(IResource? resource) - { - IFolderResource? destFolder = null; - switch (resource) - { - case IFolderResource folder: - destFolder = folder; - break; - case IFileResource file: - destFolder = file.ParentFolder; - break; - } - if (destFolder is null) - { - destFolder = _projectFolder; - } - - return GetResourceKey(destFolder); + return ResourceTreeNavigator.FindResource(_projectFolder, resource); } public Result UpdateResourceRegistry() @@ -405,12 +189,30 @@ public Result UpdateResourceRegistry() // iterators on Children remain valid even if a swap happens during a read. // Volatile.Write adds a release fence so the tree's construction writes are // visible before the new reference (a no-op on x64, required on ARM64). - var newRoot = new FolderResource(string.Empty, null); - SynchronizeFolder(newRoot, ProjectFolderPath); - UpdateSidecarPairings(newRoot); + var newRoot = (FolderResource)_projectTreeBuilder.BuildTree(ProjectFolderPath); + + // Sidecar pairing runs on the new tree before publication. The + // pairing service sets each parent FileResource.Sidecar in place + // and returns the report and sidecar-to-parent lookup, which are + // swapped under the lock alongside the tree reference. The root + // handler registry is handed in so per-sidecar path resolution + // goes through the same reparse-point chokepoint as every other + // resource operation. + var pairings = _sidecarPairingService.ComputePairings(newRoot, _rootHandlerRegistry); + Volatile.Write(ref _projectFolder, newRoot); - _pathValidator.InvalidateCache(); + lock (_sidecarLock) + { + _sidecarToParent.Clear(); + foreach (var entry in pairings.SidecarToParent) + { + _sidecarToParent[entry.Key] = entry.Value; + } + _sidecarReport = pairings.Report; + } + + _rootHandlerRegistry.InvalidatePathCache(); try { @@ -433,54 +235,6 @@ public Result UpdateResourceRegistry() } } - /// - /// Recursively synchronizes a folder resource with the file system. - /// Always creates fresh resource instances to prevent stale TreeViewNode.Content references - /// during rapid registry updates (e.g., undo/redo operations). - /// - private void SynchronizeFolder(FolderResource folderResource, string folderPath) - { - // Get filtered lists of subfolders and files - bool isProjectFolder = folderResource.ParentFolder is null; - var subFolderPaths = Directory.GetDirectories(folderPath).OrderBy(d => d).ToList(); - RemoveHiddenFolders(subFolderPaths, isProjectFolder); - - var filePaths = Directory.GetFiles(folderPath).OrderBy(f => f).ToList(); - RemoveHiddenFiles(filePaths); - - // Clear and rebuild with fresh instances - folderResource.Children.Clear(); - - // Add subfolder resources (recursive) - foreach (var subFolderPath in subFolderPaths) - { - var folderName = Path.GetFileName(subFolderPath); - var childFolder = new FolderResource(folderName, folderResource); - SynchronizeFolder(childFolder, subFolderPath); - folderResource.AddChild(childFolder); - } - - // Add file resources - foreach (var filePath in filePaths) - { - var fileName = Path.GetFileName(filePath); - var fileExtension = Path.GetExtension(filePath).TrimStart('.'); - - var getIconResult = _fileIconService.GetFileIconForExtension(fileExtension); - var iconDefinition = getIconResult.IsSuccess - ? getIconResult.Value - : _fileIconService.DefaultFileIcon; - - folderResource.AddChild(new FileResource(fileName, folderResource, iconDefinition)); - } - - // Sort children: folders first, then files, both alphabetically - folderResource.Children = folderResource.Children - .OrderBy(child => child is IFolderResource ? 0 : 1) - .ThenBy(child => child.Name) - .ToList(); - } - public List<(ResourceKey Resource, string Path)> GetAllFileResources() { return GetAllFileResources(ResourceKey.DefaultRoot); @@ -571,148 +325,6 @@ public SidecarReport GetSidecarReport() } } - // Walks the newly-built tree pairing parent files with their .cel sidecars. - // Runs after SynchronizeFolder so the tree shape is final; sets each - // FileResource.Sidecar in place and rebuilds the report snapshot. - private void UpdateSidecarPairings(FolderResource projectRoot) - { - var healthy = new List(); - var broken = new List(); - var orphan = new List(); - var newSidecarToParent = new Dictionary(); - - ProcessFolder(projectRoot); - - var newReport = new SidecarReport( - Healthy: healthy, - Broken: broken, - Orphan: orphan); - - lock (_sidecarLock) - { - _sidecarToParent.Clear(); - foreach (var entry in newSidecarToParent) - { - _sidecarToParent[entry.Key] = entry.Value; - } - _sidecarReport = newReport; - } - - void ProcessFolder(FolderResource folder) - { - // Build a name lookup for siblings so the pairing checks are O(1) per file. - var siblingByName = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var child in folder.Children) - { - siblingByName[child.Name] = child; - } - - foreach (var child in folder.Children) - { - if (child is FolderResource subFolder) - { - ProcessFolder(subFolder); - continue; - } - - if (child is not FileResource fileResource) - { - continue; - } - - ClassifyFile(fileResource, siblingByName); - } - } - - void ClassifyFile( - FileResource fileResource, - Dictionary siblingByName) - { - var name = fileResource.Name; - - // Files ending in .cel.cel are never paired with anything. They are - // surfaced as Broken so the user can resolve them. - if (name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) - { - fileResource.Sidecar = null; - broken.Add(GetResourceKey(fileResource)); - return; - } - - if (name.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - ClassifySidecarFile(fileResource, siblingByName); - return; - } - - // Non-sidecar file: pair with the sibling .cel if it exists. - var sidecarName = name + SidecarHelper.Extension; - if (siblingByName.TryGetValue(sidecarName, out var sibling) - && sibling is FileResource siblingFile - && !siblingFile.Name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) - { - var sidecarKey = GetResourceKey(siblingFile); - - // The sidecar's classification may not have run yet; populate a - // placeholder Healthy entry now and let ClassifySidecarFile - // overwrite it with the inspected status when it runs. - var existingStatus = fileResource.Sidecar?.Status ?? SidecarStatus.Healthy; - fileResource.Sidecar = new SidecarInfo(sidecarKey, existingStatus); - return; - } - - fileResource.Sidecar = null; - } - - void ClassifySidecarFile( - FileResource sidecarFile, - Dictionary siblingByName) - { - var sidecarName = sidecarFile.Name; - var parentName = sidecarName.Substring(0, sidecarName.Length - SidecarHelper.Extension.Length); - - var sidecarKey = GetResourceKey(sidecarFile); - - // Inspect the .cel file's content to determine its status. Broken - // bytes are never modified on disk; the user repairs them by hand. - var resolveResult = ResolveResourcePath(sidecarKey); - SidecarStatus status; - if (resolveResult.IsFailure) - { - _logger.LogWarning($"sidecar pairing: failed to resolve path for '{sidecarKey}'"); - status = SidecarStatus.Broken; - } - else - { - status = SidecarHelper.Inspect(resolveResult.Value, _logger); - } - - // A .cel file has no sidecar of its own (sidecars don't have sidecars). - sidecarFile.Sidecar = null; - - // Pair with the parent if present. - if (siblingByName.TryGetValue(parentName, out var parentSibling) - && parentSibling is FileResource parentFile) - { - newSidecarToParent[sidecarKey] = GetResourceKey(parentFile); - parentFile.Sidecar = new SidecarInfo(sidecarKey, status); - } - else - { - orphan.Add(sidecarKey); - } - - if (status == SidecarStatus.Healthy) - { - healthy.Add(sidecarKey); - } - else - { - broken.Add(sidecarKey); - } - } - } - public Result NormalizeResourceKey(ResourceKey resourceKey) { try @@ -781,7 +393,7 @@ private static Result GetRealPath(string path) // If the path is just the root, return it as-is if (fullPath.Equals(root, StringComparison.OrdinalIgnoreCase)) { - return Result.Ok(fullPath); + return fullPath; } // Get the relative path after the root @@ -806,7 +418,7 @@ private static Result GetRealPath(string path) currentPath = entries[0]; } - return Result.Ok(currentPath); + return currentPath; } catch (Exception ex) { @@ -815,73 +427,4 @@ private static Result GetRealPath(string path) } } - // Remove hidden folders from a list of folder paths - private static void RemoveHiddenFolders(List folderPaths, bool isProjectFolder) - { - folderPaths.RemoveAll(path => - { - if (Path.GetFileName(path).StartsWith('.')) - { - // Ignore files or folders that start with a dot. - // This includes the .celbridge folder which is used to store workspace settings. - return true; - } - -#if WINDOWS - var attributes = File.GetAttributes(path); - if ((attributes & System.IO.FileAttributes.Hidden) != 0) - { - // Windows only: Ignore folders with the 'hidden' attribute - return true; - } -#endif - var dirInfo = new DirectoryInfo(path); - - // Ignore the CelData folder - if (isProjectFolder && dirInfo.Name == ProjectConstants.MetaDataFolder) - { - return true; - } - - // Ignore python cache folders - if (dirInfo.Name == "__pycache__") - { - return true; - } - - // Ignore the Python/Lib folder containing pip packages - if (dirInfo.Name == "Lib" && - dirInfo.Parent?.Name == "Python") - { - return true; - } - - return false; - }); - } - - // Remove hidden files from a list of folder paths - private static void RemoveHiddenFiles(List filePaths) - { - // Ignore hidden files - filePaths.RemoveAll(path => - { - if (Path.GetFileName(path).StartsWith('.')) - { - // Ignore files that start with a dot. - return true; - } - -#if WINDOWS - var attributes = File.GetAttributes(path); - if ((attributes & System.IO.FileAttributes.Hidden) != 0) - { - // Windows only: Ignore files with the 'hidden' attribute - return true; - } -#endif - - return false; - }); - } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index 14b0158bc..c8aa4bf32 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -1,7 +1,6 @@ using Celbridge.Commands; using Celbridge.Logging; using Celbridge.Projects; -using Celbridge.Resources.Helpers; using Celbridge.Resources.Services.Roots; using Celbridge.UserInterface; using Celbridge.Workspace; @@ -20,17 +19,20 @@ public class ResourceService : IResourceService, IDisposable private readonly IProjectService _projectService; public IResourceRegistry Registry { get; } + public IRootHandlerRegistry RootHandlerRegistry { get; } public IResourceMonitor Monitor { get; } public IResourceTransferService TransferService { get; } public IResourceOperationService OperationService { get; } public ResourceService( ILogger logger, + ILogger registryLogger, ICommandService commandService, IMessengerService messengerService, IProjectService projectService, IWorkspaceWrapper workspaceWrapper, - IResourceRegistry resourceRegistry, + IProjectTreeBuilder projectTreeBuilder, + ISidecarPairingService sidecarPairingService, IResourceMonitor resourceMonitor, IResourceTransferService resourceTransferService, IResourceOperationService resourceOperationService) @@ -43,15 +45,24 @@ public ResourceService( _messengerService = messengerService; _projectService = projectService; - Registry = resourceRegistry; + // RootHandlerRegistry and ResourceRegistry are constructed together so + // they share the same root-handler instance + var rootHandlerRegistry = new RootHandlerRegistry(); + RootHandlerRegistry = rootHandlerRegistry; + + Registry = new ResourceRegistry( + registryLogger, + messengerService, + projectTreeBuilder, + sidecarPairingService, + rootHandlerRegistry); + Monitor = resourceMonitor; TransferService = resourceTransferService; OperationService = resourceOperationService; - // Set the project folder path on the registry. This also auto-registers the - // ProjectRootHandler for the project: root via the setter. var projectFolderPath = _projectService.CurrentProject!.ProjectFolderPath; - Registry.ProjectFolderPath = projectFolderPath; + Registry.InitializeProjectRoot(projectFolderPath); // Build the new .celbridge/ hidden folder layout: temp/, logs/, trash/, // staging-fs/. These need to exist before downstream services start reading @@ -108,11 +119,12 @@ public ResourceService( } } - // Register the temp: and logs: root handlers. These share a single PathValidator - // instance so their reparse-point caches stay coherent across the two roots. - var handlerPathValidator = new PathValidator(); - Registry.RegisterRootHandler(new TempRootHandler(celbridgeTempFolder, handlerPathValidator)); - Registry.RegisterRootHandler(new LogsRootHandler(celbridgeLogsFolder, handlerPathValidator)); + // Register the temp: and logs: root handlers against the shared + // PathValidator owned by the root handler registry so a single + // InvalidatePathCache call covers project + temp + logs together. + var sharedPathValidator = rootHandlerRegistry.PathValidator; + rootHandlerRegistry.RegisterRootHandler(new TempRootHandler(celbridgeTempFolder, sharedPathValidator)); + rootHandlerRegistry.RegisterRootHandler(new LogsRootHandler(celbridgeLogsFolder, sharedPathValidator)); // Monitor.Initialize() is called from WorkspaceLoader after construction completes; // the monitor looks up its registry through IWorkspaceWrapper, which is only populated diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs index 3c7e47210..669b130f1 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs @@ -101,7 +101,7 @@ private Result> CreateResourceTransferItems(List _rootHandlers = new(StringComparer.Ordinal); + private readonly PathValidator _pathValidator = new(); + + /// + /// The shared path validator that this registry's handlers consult for + /// reparse-point checks and verified-folder caching. Surfaced so callers + /// constructing concrete handlers (project, temp, logs) can wire them up + /// against the same cache. + /// + public PathValidator PathValidator => _pathValidator; + + public void RegisterRootHandler(IResourceRootHandler handler) + { + _rootHandlers[handler.RootName] = handler; + } + + public IReadOnlyDictionary RootHandlers => _rootHandlers; + + public bool IsResolvable(ResourceKey key) + { + return _rootHandlers.ContainsKey(key.Root); + } + + public Result GetResourceKey(string absolutePath) + { + try + { + // Longest-prefix-wins so a path under .celbridge/temp/ matches the + // temp handler rather than the project handler (which has the + // shorter / prefix). Example: + // C:\proj\ (project root, length 8) + // C:\proj\.celbridge\temp\ (temp root, length 23) + var normalizedPath = Path.GetFullPath(absolutePath); + + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + IResourceRootHandler? bestHandler = null; + int bestPrefixLength = -1; + + foreach (var handler in _rootHandlers.Values) + { + var backing = Path.GetFullPath(handler.BackingLocation) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + bool isBackingRoot = normalizedPath.Equals(backing, comparison); + bool isUnderBacking = normalizedPath.StartsWith( + backing + Path.DirectorySeparatorChar, comparison); + + if ((isBackingRoot || isUnderBacking) + && backing.Length > bestPrefixLength) + { + bestHandler = handler; + bestPrefixLength = backing.Length; + } + } + + if (bestHandler is null) + { + return Result.Fail( + $"The path '{absolutePath}' is not under any registered resource root."); + } + + return bestHandler.GetResourceKey(normalizedPath); + } + catch (Exception ex) + { + return Result.Fail($"An exception occurred when getting the resource key.") + .WithException(ex); + } + } + + public Result ResolveResourcePath(ResourceKey resource) + { + if (!_rootHandlers.TryGetValue(resource.Root, out var handler)) + { + return Result.Fail( + $"Resource root '{resource.Root}' is not registered."); + } + + return handler.Resolve(resource); + } + + public void InvalidatePathCache() + { + _pathValidator.InvalidateCache(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs index 9c7bce400..85977c98e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs @@ -68,7 +68,7 @@ public Result GetResourceKey(string absolutePath) $"Path '{absolutePath}' produces an invalid resource key: '{keyString}'."); } - return Result.Ok(resourceKey); + return resourceKey; } catch (Exception ex) { diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs new file mode 100644 index 000000000..4ffe8181d --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs @@ -0,0 +1,178 @@ +using Celbridge.Documents; +using Celbridge.Logging; +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +public sealed class SidecarPairingService : ISidecarPairingService +{ + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; + + public SidecarPairingService( + ILogger logger, + IWorkspaceWrapper workspaceWrapper) + { + _logger = logger; + _workspaceWrapper = workspaceWrapper; + } + + public SidecarPairingResult ComputePairings(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry) + { + var healthy = new List(); + var broken = new List(); + var orphan = new List(); + var sidecarToParent = new Dictionary(); + + var editorRegistry = ResolveEditorRegistry(); + + ProcessFolder(projectRoot); + + var report = new SidecarReport( + Healthy: healthy, + Broken: broken, + Orphan: orphan); + + return new SidecarPairingResult(report, sidecarToParent); + + void ProcessFolder(IFolderResource folder) + { + // Sibling name lookup keeps the per-file pairing checks O(1). + var siblingByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var child in folder.Children) + { + siblingByName[child.Name] = child; + } + + foreach (var child in folder.Children) + { + if (child is IFolderResource subFolder) + { + ProcessFolder(subFolder); + continue; + } + + if (child is not FileResource fileResource) + { + continue; + } + + ClassifyFile(fileResource, siblingByName); + } + } + + void ClassifyFile(FileResource fileResource, Dictionary siblingByName) + { + var name = fileResource.Name; + + // Files ending in .cel.cel are never paired with anything. They are + // surfaced as Broken so the user can resolve them. + if (name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) + { + fileResource.Sidecar = null; + broken.Add(ResourceTreeNavigator.BuildKey(fileResource)); + return; + } + + if (name.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) + { + ClassifySidecarFile(fileResource, siblingByName); + return; + } + + var sidecarName = name + SidecarHelper.Extension; + if (siblingByName.TryGetValue(sidecarName, out var sibling) + && sibling is FileResource siblingFile + && !siblingFile.Name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) + { + var sidecarKey = ResourceTreeNavigator.BuildKey(siblingFile); + + // The sidecar's classification may not have run yet; populate a + // placeholder Healthy entry now and let ClassifySidecarFile + // overwrite it with the inspected status when it runs. + var existingStatus = fileResource.Sidecar?.Status ?? SidecarStatus.Healthy; + fileResource.Sidecar = new SidecarInfo(sidecarKey, existingStatus); + return; + } + + fileResource.Sidecar = null; + } + + void ClassifySidecarFile(FileResource sidecarFile, Dictionary siblingByName) + { + var sidecarName = sidecarFile.Name; + var parentName = sidecarName.Substring(0, sidecarName.Length - SidecarHelper.Extension.Length); + var sidecarKey = ResourceTreeNavigator.BuildKey(sidecarFile); + + // Inspect the .cel file's content to determine its parse state. + // Path resolution goes through the root handler registry so a + // sidecar that resolves through a symlink or junction is rejected + // by the same check that protects every other resource operation. + // A failed resolve is treated as Broken — the bytes might still be + // readable, but the rest of the system refuses to operate on them + // and the user needs to see the file flagged for repair. + SidecarStatus status; + var resolveResult = rootHandlerRegistry.ResolveResourcePath(sidecarKey); + if (resolveResult.IsFailure) + { + _logger.LogWarning($"sidecar pairing: failed to resolve path for '{sidecarKey}': {resolveResult.FirstErrorMessage}"); + status = SidecarStatus.Broken; + } + else + { + status = SidecarHelper.Inspect(resolveResult.Value, _logger); + } + + // A .cel file has no sidecar of its own. + sidecarFile.Sidecar = null; + + if (siblingByName.TryGetValue(parentName, out var parentSibling) + && parentSibling is FileResource parentFile) + { + sidecarToParent[sidecarKey] = ResourceTreeNavigator.BuildKey(parentFile); + parentFile.Sidecar = new SidecarInfo(sidecarKey, status); + } + else + { + // No parent: either a registered standalone .cel form (package.cel, + // foo.webview.cel, foo.document.cel) or a true orphan. Standalone + // forms are matched via the editor registry and must not appear + // in the orphan list. + if (!IsRegisteredStandaloneCelForm(sidecarKey, editorRegistry)) + { + orphan.Add(sidecarKey); + } + } + + if (status == SidecarStatus.Healthy) + { + healthy.Add(sidecarKey); + } + else + { + broken.Add(sidecarKey); + } + } + } + + private IDocumentEditorRegistry ResolveEditorRegistry() + { + // WorkspaceService is populated before the page UI loads, so this is safe + // during workspace load. The property throws if no service is present. + return _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry; + } + + // Checks whether a parentless .cel file is claimed by a registered factory. + // Delegates to GetFactory so filename-only registrations (e.g. package.cel) + // and multi-part extensions (e.g. foo.webview.cel) are matched the same way + // the editor open path matches them. + private static bool IsRegisteredStandaloneCelForm( + ResourceKey sidecarKey, + IDocumentEditorRegistry editorRegistry) + { + var factoryResult = editorRegistry.GetFactory(sidecarKey); + return factoryResult.IsSuccess; + } + +} diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs index 6642ad9f7..9c0940a1e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -4,11 +4,8 @@ namespace Celbridge.Resources.Services; /// -/// Workspace-scoped implementation of ISidecarService. Reads and writes -/// .cel sidecar files through IResourceFileSystem so the chokepoint's -/// atomic-write + retry behaviour applies uniformly. Pure utility helpers -/// (block-name validation, indexable-shape validation) delegate to -/// SidecarHelper so the format internals stay in one place. +/// Mutations re-read the sidecar, apply the change, and skip the write when +/// the composed output matches what is on disk. /// public sealed class SidecarService : ISidecarService { @@ -34,6 +31,10 @@ public Result GetSidecarKey(ResourceKey parent) { return Result.Fail("Cannot build a sidecar key for an empty resource."); } + if (parent.Root != ResourceKey.DefaultRoot) + { + return Result.Fail($"Sidecars are only supported on the project root; resource '{parent}' is on root '{parent.Root}'."); + } if (IsSidecarKey(parent)) { return Result.Fail($"Cannot build a sidecar key for sidecar resource '{parent}': pass the parent resource key instead."); @@ -45,14 +46,14 @@ public Result GetSidecarKey(ResourceKey parent) public bool IsIndexableValue(object? value) => SidecarHelper.IsIndexableValue(value); - public async Task> ReadAsync(ResourceKey parent) + public async Task> ReadAsync(ResourceKey resource) { - var sidecarKeyResult = GetSidecarKey(parent); - if (sidecarKeyResult.IsFailure) + var resolveResult = ResolveSidecarKey(resource); + if (resolveResult.IsFailure) { - return Result.Fail(sidecarKeyResult); + return Result.Fail(resolveResult); } - var sidecarKey = sidecarKeyResult.Value; + var sidecarKey = resolveResult.Value; var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; @@ -78,74 +79,205 @@ public async Task> ReadAsync(ResourceKey parent) return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Healthy, parseResult.Value, null)); } - public async Task MutateFrontmatterAsync( - ResourceKey parent, - Action> mutate, - bool createIfMissing = true) + public async Task SetFieldAsync(ResourceKey resource, string field, object value) { - var sidecarKeyResult = GetSidecarKey(parent); - if (sidecarKeyResult.IsFailure) + if (string.IsNullOrEmpty(field)) + { + return Result.Fail("Field name is empty."); + } + if (value is null) { - return Result.Fail(sidecarKeyResult); + return Result.Fail("Value is null. Use RemoveFieldAsync to clear a field."); + } + if (!SidecarHelper.IsIndexableValue(value)) + { + return Result.Fail($"Field '{field}' value is not indexable. Only scalar (string/number/bool/datetime) and list-of-scalar values are supported."); } - var sidecarKey = sidecarKeyResult.Value; - var readResult = await ReadAsync(parent); - if (readResult.IsFailure) + return await MutateFrontmatterAsync( + resource, + dictionary => dictionary[field] = value); + } + + public async Task RemoveFieldAsync(ResourceKey resource, string field) + { + if (string.IsNullOrEmpty(field)) { - return Result.Fail(readResult); + return Result.Fail("Field name is empty."); } - var read = readResult.Value; - Dictionary working; - IReadOnlyList blocks = Array.Empty(); + return await MutateFrontmatterAsync( + resource, + dictionary => dictionary.Remove(field), + createIfMissing: false); + } - switch (read.Outcome) + public async Task AddTagAsync(ResourceKey resource, string tag) + { + if (string.IsNullOrEmpty(tag)) { - case SidecarReadOutcome.Healthy: - working = new Dictionary(read.Content!.Frontmatter, StringComparer.Ordinal); - blocks = read.Content.Blocks; - break; + return Result.Fail("Tag is empty."); + } - case SidecarReadOutcome.NoSidecar: - if (!createIfMissing) + return await MutateFrontmatterAsync( + resource, + dictionary => + { + var existing = dictionary.TryGetValue(SidecarHelper.TagsFieldName, out var value) + ? SidecarHelper.ExtractStringList(value) + : Array.Empty(); + + if (existing.Contains(tag, StringComparer.Ordinal)) { - return Result.Ok(); + return; } - working = new Dictionary(StringComparer.Ordinal); - break; - case SidecarReadOutcome.Broken: - default: - return Result.Fail($"Cannot mutate sidecar '{sidecarKey}': {read.FailureMessage ?? "parse failed"}."); + var updated = new List(existing.Count + 1); + updated.AddRange(existing); + updated.Add(tag); + dictionary[SidecarHelper.TagsFieldName] = updated; + }); + } + + public async Task RemoveTagAsync(ResourceKey resource, string tag) + { + if (string.IsNullOrEmpty(tag)) + { + return Result.Fail("Tag is empty."); } - mutate(working); + return await MutateFrontmatterAsync( + resource, + dictionary => + { + if (!dictionary.TryGetValue(SidecarHelper.TagsFieldName, out var value)) + { + return; + } + + var existing = SidecarHelper.ExtractStringList(value); + if (!existing.Contains(tag, StringComparer.Ordinal)) + { + return; + } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var composed = SidecarHelper.Compose(working, blocks); - var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, composed); - if (writeResult.IsFailure) + var updated = existing.Where(other => !string.Equals(other, tag, StringComparison.Ordinal)).ToList(); + if (updated.Count == 0) + { + dictionary.Remove(SidecarHelper.TagsFieldName); + } + else + { + dictionary[SidecarHelper.TagsFieldName] = updated; + } + }, + createIfMissing: false); + } + + public async Task WriteBlockAsync(ResourceKey resource, string blockId, string content) + { + if (!SidecarHelper.IsValidBlockName(blockId)) { - return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") - .WithErrors(writeResult); + return Result.Fail($"Block id '{blockId}' does not match the block-naming rules (lowercase letters, digits, hyphens, dotted segments)."); } - return Result.Ok(); + if (content is null) + { + return Result.Fail("Block content is null."); + } + + return await MutateBlocksAsync( + resource, + blocks => + { + var index = -1; + for (int i = 0; i < blocks.Count; i++) + { + if (string.Equals(blocks[i].Name, blockId, StringComparison.Ordinal)) + { + index = i; + break; + } + } + + var updated = new SidecarBlock(blockId, content); + if (index >= 0) + { + blocks[index] = updated; + } + else + { + blocks.Add(updated); + } + }); + } + + public async Task RemoveBlockAsync(ResourceKey resource, string blockId) + { + if (string.IsNullOrEmpty(blockId)) + { + return Result.Fail("Block id is empty."); + } + + return await MutateBlocksAsync( + resource, + blocks => + { + for (int i = blocks.Count - 1; i >= 0; i--) + { + if (string.Equals(blocks[i].Name, blockId, StringComparison.Ordinal)) + { + blocks.RemoveAt(i); + } + } + }, + createIfMissing: false); } - public async Task MutateBlocksAsync( - ResourceKey parent, + private Task MutateFrontmatterAsync( + ResourceKey resource, + Action> mutate, + bool createIfMissing = true) + { + return ApplyMutationAsync( + resource, + context => mutate(context.Frontmatter), + createIfMissing); + } + + private Task MutateBlocksAsync( + ResourceKey resource, Action> mutate, bool createIfMissing = true) { - var sidecarKeyResult = GetSidecarKey(parent); - if (sidecarKeyResult.IsFailure) + return ApplyMutationAsync( + resource, + context => mutate(context.Blocks), + createIfMissing); + } + + // The shared read-modify-write engine behind every typed mutator. Loads the + // current sidecar state into mutable working copies, runs the supplied + // mutation, then writes the composed result back through the chokepoint. + // The pre-mutation compose is captured up front so the post-mutation compose + // can be compared against it; when they match the write is skipped, so a + // no-op mutate (AddTagAsync with an already-present tag, SetFieldAsync to + // the current value) does not trigger a watcher event or downstream + // resource refresh. A missing storage file is either created + // (createIfMissing=true) or quietly skipped (createIfMissing=false); a + // Broken sidecar fails rather than being overwritten with fresh content. + private async Task ApplyMutationAsync( + ResourceKey resource, + Action applyMutation, + bool createIfMissing) + { + var resolveResult = ResolveSidecarKey(resource); + if (resolveResult.IsFailure) { - return Result.Fail(sidecarKeyResult); + return Result.Fail(resolveResult); } - var sidecarKey = sidecarKeyResult.Value; + var sidecarKey = resolveResult.Value; - var readResult = await ReadAsync(parent); + var readResult = await ReadAsync(resource); if (readResult.IsFailure) { return Result.Fail(readResult); @@ -153,13 +285,13 @@ public async Task MutateBlocksAsync( var read = readResult.Value; Dictionary frontmatter; - List working; + List blocks; switch (read.Outcome) { case SidecarReadOutcome.Healthy: frontmatter = new Dictionary(read.Content!.Frontmatter, StringComparer.Ordinal); - working = new List(read.Content.Blocks); + blocks = new List(read.Content.Blocks); break; case SidecarReadOutcome.NoSidecar: @@ -168,7 +300,7 @@ public async Task MutateBlocksAsync( return Result.Ok(); } frontmatter = new Dictionary(StringComparer.Ordinal); - working = new List(); + blocks = new List(); break; case SidecarReadOutcome.Broken: @@ -176,11 +308,18 @@ public async Task MutateBlocksAsync( return Result.Fail($"Cannot mutate sidecar '{sidecarKey}': {read.FailureMessage ?? "parse failed"}."); } - mutate(working); + var canonicalBefore = SidecarHelper.Compose(frontmatter, blocks); + var context = new MutationContext(frontmatter, blocks); + applyMutation(context); + var canonicalAfter = SidecarHelper.Compose(frontmatter, blocks); + + if (string.Equals(canonicalAfter, canonicalBefore, StringComparison.Ordinal)) + { + return Result.Ok(); + } var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var composed = SidecarHelper.Compose(frontmatter, working); - var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, composed); + var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, canonicalAfter); if (writeResult.IsFailure) { return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") @@ -188,4 +327,34 @@ public async Task MutateBlocksAsync( } return Result.Ok(); } + + // The mutable working state passed to ApplyMutationAsync's callback. Direct + // mutation of either collection is the way edits land. + private sealed record MutationContext( + Dictionary Frontmatter, + List Blocks); + + // Resolves the resource key whose file holds the frontmatter+blocks for the + // given resource. For a regular file this is the sibling .cel key produced + // by GetSidecarKey. For a standalone .cel file the resource itself carries + // the data, so the same key is returned unchanged. Non-project roots are + // refused outright: sidecar metadata is a project-scoped system and the + // tracking pass only scans the project tree, so cross-root sidecars would + // be silently invisible to validation. + private Result ResolveSidecarKey(ResourceKey resource) + { + if (resource.IsEmpty) + { + return Result.Fail("Cannot resolve sidecar key for an empty resource."); + } + if (resource.Root != ResourceKey.DefaultRoot) + { + return Result.Fail($"Sidecars are only supported on the project root; resource '{resource}' is on root '{resource.Root}'."); + } + if (IsSidecarKey(resource)) + { + return resource; + } + return GetSidecarKey(resource); + } } diff --git a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs index 3d40bf3da..9dee0aebc 100644 --- a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs +++ b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs @@ -13,7 +13,7 @@ public class FileFilter private readonly HashSet _metadataExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".webview", + ".cel", ".celbridge" }; diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs index ea43e1a56..5d7980f48 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs @@ -48,28 +48,28 @@ public void Report(ProjectCheckReport report) $"Project consistency check: {entries.Count} broken project: reference(s).", entries); } - if (report.OrphanSidecars.Count > 0) + if (report.OrphanCelFiles.Count > 0) { - var entries = report.OrphanSidecars - .Select(o => $"'{o.Sidecar.FullKey}'") + var entries = report.OrphanCelFiles + .Select(o => $"'{o.FullKey}'") .ToList(); LogFindingsCategory( - $"Project consistency check: {entries.Count} orphan sidecar(s).", + $"Project consistency check: {entries.Count} orphan .cel file(s).", entries); } - if (report.BrokenSidecars.Count > 0) + if (report.BrokenCelFiles.Count > 0) { - var entries = report.BrokenSidecars - .Select(b => $"'{b.Sidecar.FullKey}'") + var entries = report.BrokenCelFiles + .Select(b => $"'{b.FullKey}'") .ToList(); LogFindingsCategory( - $"Project consistency check: {entries.Count} broken sidecar(s).", + $"Project consistency check: {entries.Count} broken .cel file(s).", entries); } var totalFindings = report.BrokenReferences.Count - + report.OrphanSidecars.Count - + report.BrokenSidecars.Count; + + report.OrphanCelFiles.Count + + report.BrokenCelFiles.Count; if (totalFindings > 0) { var message = new ConsoleErrorMessage( diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs index e17316c38..4198e5e5c 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs @@ -125,6 +125,18 @@ public async Task LoadWorkspaceAsync() _logger.LogWarning(initMonitorResult, "Failed to initialize resource monitor"); } + // Register packages before the first resource scan so the sidecar + // pairing pass sees package-contributed document-editor factories. + try + { + var packageService = workspaceService.PackageService; + packageService.RegisterPackages(projectFolderPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "An exception occurred while registering packages. The workspace will continue to load with reduced functionality."); + } + // Update resource registry immediately to ensure we are up to date var updateResult = resourceService.UpdateResources(); if (updateResult.IsFailure) @@ -132,12 +144,6 @@ public async Task LoadWorkspaceAsync() return Result.Fail("Failed to update resources") .WithErrors(updateResult); } - - // Fire-and-forget the project-health check so banner-worthy findings - // surface in the host log without blocking workspace load. The - // command scans the project's text files on demand; on a clean - // project the result is empty. - _ = Task.Run(() => RunProjectCheckAsync()); } catch (Exception ex) { @@ -165,19 +171,7 @@ public async Task LoadWorkspaceAsync() // Select the previous selected resources in the Explorer Panel. await explorerService.RestorePanelState(); - // Register all packages before restoring documents so that restored documents can use editors - // defined in packages. - try - { - var packageService = workspaceService.PackageService; - packageService.RegisterPackages(projectFolderPath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "An exception occurred while registering packages. The workspace will continue to load with reduced functionality."); - } - - // Open previous opened documents in the Documents Panel + // Open previous opened documents in the Documents Panel. var documentsService = workspaceService.DocumentsService; await documentsService.RestorePanelState(); @@ -200,6 +194,10 @@ public async Task LoadWorkspaceAsync() var workspaceLoadedMessage = new WorkspaceLoadedMessage(); messengerService.Send(workspaceLoadedMessage); + // Run the project-health check after WorkspaceLoadedMessage so it sees + // the fully-initialised workspace. Fire-and-forget; never blocks load. + _ = Task.Run(() => RunProjectCheckAsync()); + // // Initialize terminal window and Python scripting // These run after the workspace is considered "loaded" because they don't block @@ -230,10 +228,8 @@ public async Task LoadWorkspaceAsync() return Result.Ok(); } - // Runs data_check_project in the background and delegates formatting and - // dispatch of the report to ProjectCheckReporter. Failure to execute the - // command or any unexpected exception is logged but never propagated, so - // a broken check cannot tear down the workspace load path. + // Runs the project consistency check and hands the report to ProjectCheckReporter. + // Errors are logged, never thrown — a broken check must not fail workspace load. private async Task RunProjectCheckAsync() { try From b74a133ccef0e73039a4c4fcd9139e4a1ee969d7 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 27 May 2026 07:08:49 +0100 Subject: [PATCH 23/48] Add test NLog.config and improve tests Add a test-only NLog.config that routes all logging to a Null target to prevent incidental service logs from surfacing on stdout/stderr (avoids GitHub Actions auto-annotating expected error output). Update the test project file to copy the NLog.config to output, taking precedence over the shared logging config. Improve ResourceRegistryTests by capturing UpdateResourceRegistry results into a variable and passing updateResult.FirstErrorMessage into assertions for clearer failure diagnostics; add a Platform("Win") attribute to the case-sensitivity test with an explanatory reason. --- Source/Tests/Celbridge.Tests.csproj | 14 +++++++++- Source/Tests/NLog.config | 27 +++++++++++++++++++ .../Tests/Resources/ResourceRegistryTests.cs | 11 +++++--- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 Source/Tests/NLog.config diff --git a/Source/Tests/Celbridge.Tests.csproj b/Source/Tests/Celbridge.Tests.csproj index 8a26b57af..71e9a88c5 100644 --- a/Source/Tests/Celbridge.Tests.csproj +++ b/Source/Tests/Celbridge.Tests.csproj @@ -38,6 +38,18 @@ - + + + + + PreserveNewest + + + diff --git a/Source/Tests/NLog.config b/Source/Tests/NLog.config new file mode 100644 index 000000000..4aa2e523a --- /dev/null +++ b/Source/Tests/NLog.config @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index aec3d124a..0825b6f6b 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -76,7 +76,7 @@ public void ICanUpdateTheResourceTree() resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var updateResult = resourceRegistry.UpdateResourceRegistry(); - updateResult.IsSuccess.Should().BeTrue(); + updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); // // Check the scanned resources match the files and folders we created earlier. @@ -119,7 +119,7 @@ public void ICanExpandAFolderResource() folderStateService.SetExpanded(FolderNameA, true); var updateResult = resourceRegistry.UpdateResourceRegistry(); - updateResult.IsSuccess.Should().BeTrue(); + updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); // // Check that the folder resource expanded state is tracked correctly. @@ -186,6 +186,7 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() } [Test] + [Platform("Win", Reason = "Asserts the registry rejects wrong-case keys that the OS would otherwise case-fold to an on-disk file. On case-sensitive filesystems (Linux CI) the wrong-case path simply does not exist, so there is nothing for the registry to reject.")] public void ResolveResourcePathRejectsWrongCaseKey_WhenFileExistsOnDisk() { // Windows is case-insensitive at the filesystem layer (would happily @@ -200,7 +201,8 @@ public void ResolveResourcePathRejectsWrongCaseKey_WhenFileExistsOnDisk() var fileIconService = new FileIconService(); var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); - resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + var updateResult = resourceRegistry.UpdateResourceRegistry(); + updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); // FileA.txt exists on disk (created in Setup); request it as "filea.txt". var wrongCaseKey = ResourceKey.Create(FileNameA.ToLowerInvariant()); @@ -224,7 +226,8 @@ public void ResolveResourcePathAcceptsKeyForNonExistentResource() var fileIconService = new FileIconService(); var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); - resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + var updateResult = resourceRegistry.UpdateResourceRegistry(); + updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); var newKey = ResourceKey.Create("NewResource.json"); var resolveResult = resourceRegistry.ResolveResourcePath(newKey); From 455012b00dbd048ec43dfb4ce028ba163e816793 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 27 May 2026 09:21:29 +0100 Subject: [PATCH 24/48] Track and enforce EditorId on document views Add an immutable EditorId to IDocumentView and DocumentView and require factories to stamp views with their EditorId. Update HtmlViewer, WebView and CustomDocumentViewFactory (and tests) to set EditorId; DocumentsService now fails if a created view has an empty EditorId. Introduce DocumentConstants.TextBoxFallbackEditorId and stamp it on the last-resort TextBoxDocumentView. Refactor DocumentViewFactory to resolve resource paths earlier, simplify sidecar/extension/priority creation helpers to return constructed views (null for fall-through), and improve error/logging and return semantics. Update DocumentsPanel and DocumentsPanelViewModel to use the view's EditorId for tab labels and adjust tests accordingly. --- .../Documents/DocumentConstants.cs | 6 ++ .../Documents/DocumentEditorFactoryBase.cs | 1 + .../Documents/IDocumentView.cs | 5 ++ .../Documents/IDocumentsService.cs | 7 +- .../Services/HtmlViewerEditorFactory.cs | 1 + .../Services/WebViewEditorFactory.cs | 1 + .../Documents/DocumentViewFactoryTests.cs | 4 + .../Services/CustomDocumentViewFactory.cs | 1 + .../Services/DocumentViewFactory.cs | 88 ++++++++----------- .../Services/DocumentsService.cs | 8 ++ .../ViewModels/DocumentsPanelViewModel.cs | 40 +++------ .../Celbridge.Documents/Views/DocumentView.cs | 18 ++++ .../Views/DocumentsPanel.xaml.cs | 13 ++- 13 files changed, 104 insertions(+), 89 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs b/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs index 2d206f213..a0c1cd392 100644 --- a/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs +++ b/Source/Core/Celbridge.Foundation/Documents/DocumentConstants.cs @@ -10,6 +10,12 @@ public static class DocumentConstants /// public static readonly DocumentEditorId CodeEditorId = new("celbridge.code-editor.code-document"); + /// + /// Id stamped on TextBoxDocumentView when DocumentViewFactory uses it as + /// the last-resort text fallback. Has no registered factory. + /// + public static readonly DocumentEditorId TextBoxFallbackEditorId = new("celbridge.text-box-fallback"); + /// /// Sidecar frontmatter field that records the user's per-file editor choice /// (last "Open with..." selection). Shared so the read path (preference diff --git a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs index 1587fdcdc..e763f8afb 100644 --- a/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs +++ b/Source/Core/Celbridge.Foundation/Documents/DocumentEditorFactoryBase.cs @@ -43,6 +43,7 @@ public virtual bool CanHandleResource(ResourceKey fileResource) return false; } + // Implementations must set view.EditorId = EditorId on the returned view. public abstract Result CreateDocumentView(ResourceKey fileResource); public virtual string? GetLanguageForExtension(string extension) => null; diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentView.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentView.cs index 8451ce453..5ce2b394f 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentView.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentView.cs @@ -11,6 +11,11 @@ public interface IDocumentView /// ResourceKey FileResource { get; } + /// + /// Id of the factory that produced this view. Immutable for the view's lifetime. + /// + DocumentEditorId EditorId { get; } + /// /// Sets the file resource for the document view. /// Fails if the resource does not exist in the resource registry or in the file system. diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs index b305dc0fa..7d82e648e 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs @@ -34,10 +34,9 @@ public interface IDocumentsService IReadOnlyList GetOpenDocuments(); /// - /// Create a document view for the specified file resource. - /// The type of document view created is based on the file extension. - /// When documentEditorId is specified, uses that specific editor instead of the default. - /// Fails if the file resource does not exist. + /// Creates a document view for the given file resource. When editorId is + /// non-empty, uses that specific editor instead of the default resolution + /// chain. Fails if the resource does not exist. /// Task> CreateDocumentView(ResourceKey fileResource, DocumentEditorId editorId = default); diff --git a/Source/Modules/Celbridge.WebView/Services/HtmlViewerEditorFactory.cs b/Source/Modules/Celbridge.WebView/Services/HtmlViewerEditorFactory.cs index 37fb0875a..f7963528c 100644 --- a/Source/Modules/Celbridge.WebView/Services/HtmlViewerEditorFactory.cs +++ b/Source/Modules/Celbridge.WebView/Services/HtmlViewerEditorFactory.cs @@ -34,6 +34,7 @@ public override Result CreateDocumentView(ResourceKey fileResourc view.Options = new WebViewDocumentOptions( WebViewDocumentRole.HtmlViewer, InterceptTopFrameNavigation: true); + view.EditorId = EditorId; return Result.Ok(view); } diff --git a/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs b/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs index 8a187a969..9acdb6bc9 100644 --- a/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs +++ b/Source/Modules/Celbridge.WebView/Services/WebViewEditorFactory.cs @@ -31,6 +31,7 @@ public override Result CreateDocumentView(ResourceKey fileResourc view.Options = new WebViewDocumentOptions( WebViewDocumentRole.ExternalUrl, InterceptTopFrameNavigation: false); + view.EditorId = EditorId; return Result.Ok(view); } diff --git a/Source/Tests/Documents/DocumentViewFactoryTests.cs b/Source/Tests/Documents/DocumentViewFactoryTests.cs index de34c12d7..1fc6b6831 100644 --- a/Source/Tests/Documents/DocumentViewFactoryTests.cs +++ b/Source/Tests/Documents/DocumentViewFactoryTests.cs @@ -106,6 +106,7 @@ public async Task CreateAsync_SidecarEditor_WinsOverEverythingElse() result.IsSuccess.Should().BeTrue(); result.Value.Should().Be(sidecarView); + result.Value.EditorId.Should().Be(sidecarEditorId); } [Test] @@ -375,6 +376,9 @@ private static IDocumentEditorFactory CreateFakeFactory( EditorPriority priority = EditorPriority.Specialized, bool canHandle = true) { + // Production factories stamp view.EditorId themselves; mocks don't, so stub it. + view.EditorId.Returns(editorId); + var factory = Substitute.For(); factory.EditorId.Returns(editorId); factory.DisplayName.Returns(editorId.ToString()); diff --git a/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs b/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs index 4627ac37d..2a308839e 100644 --- a/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs +++ b/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs @@ -70,6 +70,7 @@ public override Result CreateDocumentView(ResourceKey fileResourc #if WINDOWS var view = _serviceProvider.GetRequiredService(); view.Contribution = _contribution; + view.EditorId = EditorId; return Result.Ok(view); #else diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs index 78b16eb70..a0b05dd76 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs @@ -35,17 +35,19 @@ public DocumentViewFactory( /// /// Selects an editor for the given resource and constructs its document view. - /// The view is returned without content being loaded; the caller drives + /// The view is returned without content loaded; the caller drives /// SetFileResource and LoadContent. /// public async Task> CreateAsync( ResourceKey fileResource, DocumentEditorId requestedEditorId) { - var pathFailure = CheckResourcePathResolves(fileResource); - if (pathFailure is not null) + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); + if (resolveResult.IsFailure) { - return pathFailure; + return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") + .WithErrors(resolveResult); } if (!requestedEditorId.IsEmpty) @@ -56,47 +58,33 @@ public async Task> CreateAsync( return CreateForRequestedEditor(fileResource, requestedEditorId); } - var sidecarChoice = await TryCreateFromSidecarPreferenceAsync(fileResource); - if (sidecarChoice is not null) + var sidecarView = await CreateFromSidecarPreferenceAsync(fileResource); + if (sidecarView is not null) { - return sidecarChoice; + return sidecarView.OkResult(); } - var extensionChoice = await TryCreateFromExtensionPreferenceAsync(fileResource); - if (extensionChoice is not null) + var extensionView = await CreateFromExtensionPreferenceAsync(fileResource); + if (extensionView is not null) { - return extensionChoice; + return extensionView.OkResult(); } - var factoryChoice = TryCreateFromPriorityFactory(fileResource); - if (factoryChoice is not null) + var factoryView = CreateFromPriorityFactory(fileResource); + if (factoryView is not null) { - return factoryChoice; + return factoryView.OkResult(); } return CreateTextFallback(fileResource); } - // Confirms the resource maps to a real backing path before any preference or - // factory lookup runs. Returns null on success; the resolved path itself is - // not used downstream. - private Result? CheckResourcePathResolves(ResourceKey fileResource) - { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult); - } - - return null; - } - - // The sidecar's 'editor' field records the user's last explicit "Open With X" - // choice and wins over the per-extension preference and the priority fallback. - // A stale or unregistered id returns null so the caller falls through. - private async Task?> TryCreateFromSidecarPreferenceAsync(ResourceKey fileResource) + // Sidecar 'editor' field — the user's per-file "Open With X" choice. Wins + // over per-extension preference and priority fallback. Returns the view + // on success; null when no preference is set, the editor is unregistered, + // it cannot handle the resource, or construction fails (logged before + // fall-through). + private async Task CreateFromSidecarPreferenceAsync(ResourceKey fileResource) { var sidecarEditorResult = await _preferenceStore.GetSidecarPreferenceAsync(fileResource); if (sidecarEditorResult.IsFailure @@ -122,7 +110,7 @@ public async Task> CreateAsync( var createResult = sidecarFactory.CreateDocumentView(fileResource); if (createResult.IsSuccess) { - return createResult; + return createResult.Value; } _logger.LogWarning(createResult, @@ -130,7 +118,8 @@ public async Task> CreateAsync( return null; } - private async Task?> TryCreateFromExtensionPreferenceAsync(ResourceKey fileResource) + // Per-extension preference: same fall-through contract as sidecar. + private async Task CreateFromExtensionPreferenceAsync(ResourceKey fileResource) { var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); var preferredEditorId = await _preferenceStore.GetExtensionPreferenceAsync(extension); @@ -154,18 +143,16 @@ public async Task> CreateAsync( var createResult = preferredFactory.CreateDocumentView(fileResource); if (createResult.IsSuccess) { - return createResult; + return createResult.Value; } return null; } - // Priority-based factory resolution. Placeholder factories (package.cel, - // *.celbridge, *.document.cel) exist only for extension reservation and - // never produce a view, so they are skipped here; the text fallback below - // catches the open and routes it to the code editor without logging a - // spurious "factory failed" warning. - private Result? TryCreateFromPriorityFactory(ResourceKey fileResource) + // Highest-priority factory for the resource. Placeholder factories + // (package.cel, *.celbridge, *.document.cel) reserve extensions but + // never produce a view, so they are skipped here. + private IDocumentView? CreateFromPriorityFactory(ResourceKey fileResource) { var factoryResult = _documentEditorRegistry.GetFactory(fileResource); if (factoryResult.IsFailure @@ -178,7 +165,7 @@ public async Task> CreateAsync( var createResult = factory.CreateDocumentView(fileResource); if (createResult.IsSuccess) { - return createResult; + return createResult.Value; } _logger.LogWarning(createResult, $"Factory failed to create document view for: '{fileResource}'"); @@ -211,11 +198,9 @@ private Result CreateForRequestedEditor(ResourceKey fileResource, } var requestedFactory = getFactoryResult.Value; - // The code editor is the universal "view as text" option offered through - // "Open with...". The user can pick it for any text file, including ones - // whose extension the code editor does not claim, so the extension match - // is bypassed for this one editor id. Every other editor still goes - // through CanHandleResource so wrong-editor requests fail loudly. + // The code editor is the "view as text" option in Open With and may be + // requested for any file; skip the extension check for that one id. + // Other editors still go through CanHandleResource. if (!IsCodeEditor(requestedEditorId) && !requestedFactory.CanHandleResource(fileResource)) { @@ -269,10 +254,11 @@ private Result CreateTextDocumentView(ResourceKey fileResource) $"Code editor '{DocumentConstants.CodeEditorId}' failed to create view for '{fileResource}'; using TextBoxDocumentView"); } - // Last-resort fallback: the cross-platform plain TextBox. Kept for - // non-Windows hosts (Monaco runs in WebView2, which is Windows-only) - // and for the case where the code editor factory failed to construct. + // Last-resort fallback. Used on non-Windows hosts (Monaco runs in + // Windows-only WebView2) and when the code editor factory fails. + // Stamped here because the TextBox is not produced by a factory. var textBoxView = _serviceProvider.GetRequiredService(); + textBoxView.EditorId = DocumentConstants.TextBoxFallbackEditorId; return textBoxView.OkResult(); } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index 3abc50a9c..eaa1ad16e 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -226,6 +226,14 @@ public async Task> CreateDocumentView(ResourceKey fileReso } var documentView = createResult.Value; + // Factories must set view.EditorId before returning; catch a missed stamp here. + if (documentView.EditorId.IsEmpty) + { + return Result.Fail( + $"Document view for '{fileResource}' was returned with an empty EditorId. " + + "The factory that produced it must set view.EditorId before returning."); + } + // // Load the content from the document file // diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs index e69872d09..c437dadc7 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs @@ -38,9 +38,8 @@ public async Task> CreateDocumentView(ResourceKey fileReso return Result.Fail($"Failed to create document view for file resource: '{fileResource}'") .WithErrors(createResult); } - var documentView = createResult.Value; - return Result.Ok(documentView); + return createResult.Value.OkResult(); } public void OnCloseDocumentRequested(ResourceKey fileResource) @@ -142,38 +141,27 @@ public void OpenApplicationForTab(ResourceKey fileResource) public record class EditorDisplayInfo(DocumentEditorId EditorId, string EditorDisplayName); + // Looks up the display name for the supplied editor id. Returns an empty + // label when only one factory claims the extension (no disambiguation + // needed); null when the editor id is empty or unregistered. public EditorDisplayInfo? ResolveEditorDisplayInfo(ResourceKey fileResource, DocumentEditorId documentEditorId) { - var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); - var editorRegistry = _documentsService.DocumentEditorRegistry; - var factories = editorRegistry.GetFactoriesForExtension(extension); - - if (!documentEditorId.IsEmpty) + if (documentEditorId.IsEmpty) { - var factoryResult = editorRegistry.GetFactoryById(documentEditorId); - if (factoryResult.IsSuccess) - { - var displayName = factories.Count >= 2 ? factoryResult.Value.DisplayName : string.Empty; - return new EditorDisplayInfo(factoryResult.Value.EditorId, displayName); - } + return null; } - if (factories.Count >= 2) - { - foreach (var factory in factories) - { - if (factory.CanHandleResource(fileResource)) - { - return new EditorDisplayInfo(factory.EditorId, factory.DisplayName); - } - } - } - else if (factories.Count == 1) + var editorRegistry = _documentsService.DocumentEditorRegistry; + var factoryResult = editorRegistry.GetFactoryById(documentEditorId); + if (factoryResult.IsFailure) { - return new EditorDisplayInfo(factories[0].EditorId, string.Empty); + return null; } - return null; + var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); + var factoriesForExtension = editorRegistry.GetFactoriesForExtension(extension); + var displayName = factoriesForExtension.Count >= 2 ? factoryResult.Value.DisplayName : string.Empty; + return new EditorDisplayInfo(factoryResult.Value.EditorId, displayName); } public record class EditorChoiceInfo( diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs index edbe47a70..b6c8a0094 100644 --- a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs +++ b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs @@ -33,6 +33,24 @@ protected IResourceRegistry ResourceRegistry public virtual ResourceKey FileResource => DocumentViewModel.FileResource; + private DocumentEditorId _editorId = DocumentEditorId.Empty; + + // Set once by the constructing factory; throws on any subsequent set. + public DocumentEditorId EditorId + { + get => _editorId; + set + { + if (!_editorId.IsEmpty) + { + throw new InvalidOperationException( + $"DocumentView.EditorId is set once and immutable thereafter. " + + $"Current value: '{_editorId}'; attempted to set: '{value}'."); + } + _editorId = value; + } + } + /// /// Sets the file resource for the document view. /// Validates the resource exists in the registry and on disk, then sets the ViewModel properties. diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs index 98955fb44..b1478709d 100644 --- a/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs +++ b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs @@ -409,7 +409,7 @@ public async Task> OpenDocument(ResourceKey fileReso documentTab.ViewModel.DocumentView = documentView; documentTab.Content = documentView; - UpdateEditorDisplayName(documentTab, effectiveOptions.EditorId); + UpdateEditorDisplayName(documentTab, documentView.EditorId); targetSectionForNew.RefreshSelectedTab(); UpdateAllTabDisplayNames(); @@ -655,9 +655,10 @@ public async Task ChangeDocumentResource(ResourceKey oldResource, Docume // Clean up the old DocumentView state await oldDocumentView.PrepareToClose(); - // Populate the tab content + // Resource (and possibly extension) changed; refresh content and label. documentTab.ViewModel.DocumentView = newDocumentView; documentTab.Content = newDocumentView; + UpdateEditorDisplayName(documentTab, newDocumentView.EditorId); // At this point there should be no remaining references to oldDocumentView, so it should go // out of scope and eventually be cleaned up by GC. @@ -679,12 +680,8 @@ public async Task ChangeDocumentResource(ResourceKey oldResource, Docume return Result.Ok(); } - /// - /// Updates all tab display names to ensure tabs with the same filename are disambiguated. - /// Tabs with unique filenames show just the filename; tabs with ambiguous filenames - /// show additional path segments to differentiate them. - /// - private void UpdateEditorDisplayName(DocumentTab documentTab, DocumentEditorId documentEditorId = default) + // Sets the tab's recorded editor id and display label. + private void UpdateEditorDisplayName(DocumentTab documentTab, DocumentEditorId documentEditorId) { var displayInfo = ViewModel.ResolveEditorDisplayInfo(documentTab.ViewModel.FileResource, documentEditorId); if (displayInfo is not null) From 1c297640782a0e23835cb94b4d5fa73c4f8a2634 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 27 May 2026 09:46:19 +0100 Subject: [PATCH 25/48] Notify messenger when WebView gains focus Attach a GotFocus handler to the acquired WebView and detach it when the view is removed to avoid duplicate subscriptions and leaks. Add WebView_GotFocus implementation that sends a DocumentViewFocusedMessage(FileResource) via the messenger service so the app is notified when this document's WebView receives focus. --- .../Views/WebViewDocumentView.xaml.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs index 542d42dde..6c306ad3a 100644 --- a/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs +++ b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs @@ -218,6 +218,9 @@ private async void WebViewDocumentView_Loaded(object sender, RoutedEventArgs e) _webView = await _webViewFactory.AcquireAsync(); AppWebViewContainer.Children.Add(_webView); + _webView.GotFocus -= WebView_GotFocus; + _webView.GotFocus += WebView_GotFocus; + _webView.CoreWebView2.Settings.AreDevToolsEnabled = _webViewService.IsDevToolsFeatureEnabled(); if (Options.Role == WebViewDocumentRole.HtmlViewer) @@ -392,6 +395,7 @@ private void TeardownWebViewState() if (_webView is not null) { + _webView.GotFocus -= WebView_GotFocus; AppWebViewContainer.Children.Remove(_webView); _webView.Close(); _webView = null; @@ -606,6 +610,12 @@ private void WebView_NewWindowRequested(CoreWebView2 sender, CoreWebView2NewWind } } + private void WebView_GotFocus(object sender, RoutedEventArgs e) + { + var message = new DocumentViewFocusedMessage(FileResource); + _messengerService.Send(message); + } + public override async Task PrepareToClose() { _messengerService.UnregisterAll(this); From 9a35679f00e9e87a8d2554810747972c83b85828 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Wed, 27 May 2026 10:34:16 +0100 Subject: [PATCH 26/48] Add .cel editor type and Celbridge icons Define two new file icon entries (_cog_medium-green and _gears_medium-green) with FontAwesome glyphs and medium-green color, update file-icon mappings to use _gears_medium-green for "cel" and _cog_medium-green for "celbridge" (replacing the previous database icon), and add a code-editor mapping for ".cel" to use the Ruby language mode for syntax highlighting. --- .../Fonts/FileIcons/file-icons-icon-theme.json | 15 ++++++++++++++- .../Editors/CodeEditor/js/code-editor-types.json | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Source/Core/Celbridge.UserInterface/Assets/Fonts/FileIcons/file-icons-icon-theme.json b/Source/Core/Celbridge.UserInterface/Assets/Fonts/FileIcons/file-icons-icon-theme.json index 90ee49fa0..a4115c6a7 100644 --- a/Source/Core/Celbridge.UserInterface/Assets/Fonts/FileIcons/file-icons-icon-theme.json +++ b/Source/Core/Celbridge.UserInterface/Assets/Fonts/FileIcons/file-icons-icon-theme.json @@ -2415,6 +2415,12 @@ "fontId": "fi", "fontSize": "107%" }, + "_cog_medium-green": { + "fontCharacter": "\\f013", + "fontColor": "#90a959", + "fontId": "fa", + "fontSize": "107%" + }, "_coffee_dark-maroon": { "fontCharacter": "\\f0f4", "fontColor": "#7c4426", @@ -4336,6 +4342,12 @@ "fontId": "fa", "fontSize": "107%" }, + "_gears_medium-green": { + "fontCharacter": "\\f085", + "fontColor": "#90a959", + "fontId": "fa", + "fontSize": "107%" + }, "_genshi_medium-red": { "fontCharacter": "\\e976", "fontColor": "#ac4142", @@ -12161,7 +12173,8 @@ "cdr": "_coreldraw_medium-green", "cdrx": "_coreldraw_medium-green", "cdt": "_coreldraw_medium-green", - "celbridge": "_database_dark-cyan", + "cel": "_gears_medium-green", + "celbridge": "_cog_medium-green", "ceylon": "_ceylon_medium-orange", "cf": "_bnf_dark-yellow", "cfc": "_cf_light-cyan", diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/code-editor-types.json b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/code-editor-types.json index 71751aa0b..544024fe3 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/code-editor-types.json +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/js/code-editor-types.json @@ -14,6 +14,7 @@ ".c": "c", ".cake": "csharp", ".cc": "cpp", + ".cel": "ruby", ".celbridge": "ruby", ".cjs": "javascript", ".clj": "clojure", From 08c673d60c138fc3217e7fcb54ae36fc269a17e4 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 08:15:06 +0100 Subject: [PATCH 27/48] Use IResourceFileSystem and async streaming IO Replace direct filesystem/path resolution with the resource file system chokepoint and async/streamed I/O across the codebase. Added ResourceInfo record and GetInfoAsync(ResourceKey) (replacing ExistsAsync) so callers can probe NotFound/File/Folder plus size/modified-time in one roundtrip. ISpreadsheetReader now operates on Stream inputs (stateless reader) and spreadsheet tools open workbook streams via ResolveWorkbookResourceAsync/OpenWorkbookStreamAsync. File tools now use WorkspaceService.ResourceFileSystem for GetInfoAsync, ReadAllTextAsync, ReadAllBytesAsync, OpenReadAsync and avoid System.IO.File/DirectoryInfo calls; a ReadFileLinesStreamedAsync helper was added to stream lines safely. Several interfaces were made async or renamed: IPackageService.RegisterPackagesAsync, IResourceTransferService.CreateResourceTransferAsync, IInspectorFactory.CreateResourceInspectorAsync, and IDialogFactory.CreateResourcePickerDialog signature updated. Tests and many command/tool classes updated accordingly to route I/O through the containment chokepoint and support streaming/async patterns. --- .../Dialog/IDialogFactory.cs | 4 +- .../Documents/IDocumentEditorRegistry.cs | 7 + .../Inspector/IInspectorFactory.cs | 2 +- .../Packages/IPackageService.cs | 2 +- .../Resources/IResourceFileSystem.cs | 29 +++- .../Resources/IResourceTransferService.cs | 2 +- .../Spreadsheet/ISpreadsheetReader.cs | 21 ++- .../Tools/File/FileTools.Edit.cs | 4 +- .../Tools/File/FileTools.Grep.cs | 68 +++++--- .../Tools/File/FileTools.MultiEdit.cs | 4 +- .../Tools/File/FileTools.Read.cs | 27 +-- .../Tools/File/FileTools.ReadBinary.cs | 21 ++- .../Tools/File/FileTools.ReadImage.cs | 27 +-- .../Tools/File/FileTools.ReadMany.cs | 20 ++- .../Tools/File/FileTools.Replace.cs | 4 +- .../Tools/File/FileTools.Search.cs | 19 +- .../Celbridge.Tools/Tools/File/FileTools.cs | 13 +- .../Spreadsheet/SpreadsheetTools.AddSheets.cs | 6 +- .../SpreadsheetTools.AppendRows.cs | 6 +- .../Spreadsheet/SpreadsheetTools.Clear.cs | 6 +- .../Spreadsheet/SpreadsheetTools.Delete.cs | 6 +- .../SpreadsheetTools.DuplicateSheet.cs | 6 +- .../Spreadsheet/SpreadsheetTools.ExportCsv.cs | 24 ++- .../Spreadsheet/SpreadsheetTools.Find.cs | 15 +- .../SpreadsheetTools.FormatRanges.cs | 6 +- .../SpreadsheetTools.FreezePanes.cs | 6 +- .../SpreadsheetTools.GetActiveView.cs | 15 +- .../Spreadsheet/SpreadsheetTools.GetInfo.cs | 15 +- .../Spreadsheet/SpreadsheetTools.ImportCsv.cs | 6 +- .../Spreadsheet/SpreadsheetTools.Insert.cs | 6 +- .../Spreadsheet/SpreadsheetTools.MoveSheet.cs | 6 +- .../SpreadsheetTools.ReadFormat.cs | 15 +- .../Spreadsheet/SpreadsheetTools.ReadSheet.cs | 15 +- .../SpreadsheetTools.RemoveSheet.cs | 6 +- .../SpreadsheetTools.RenameSheet.cs | 6 +- .../SpreadsheetTools.SetActiveView.cs | 6 +- .../SpreadsheetTools.SetAutoFilter.cs | 6 +- ...readsheetTools.SetConditionalFormatting.cs | 6 +- .../Spreadsheet/SpreadsheetTools.Sort.cs | 6 +- .../SpreadsheetTools.WriteCells.cs | 6 +- .../Tools/Spreadsheet/SpreadsheetTools.cs | 50 ++++-- .../WebView/WebViewScreenshotResolver.cs | 33 ++-- .../Tools/WebView/WebViewTools.Screenshot.cs | 3 +- .../Services/Dialogs/DialogFactory.cs | 4 +- .../Services/Dialogs/DialogService.cs | 3 +- .../Dialogs/ResourcePickerDialogViewModel.cs | 65 ++++--- .../Commands/AddSheetsCommand.cs | 22 ++- .../Commands/AppendRowsCommand.cs | 22 ++- .../Commands/ClearRangesCommand.cs | 22 ++- .../Commands/DeleteRangesCommand.cs | 22 ++- .../Commands/DuplicateSheetCommand.cs | 22 ++- .../Commands/FormatRangesCommand.cs | 22 ++- .../Commands/FreezePanesCommand.cs | 22 ++- .../Commands/ImportCsvCommand.cs | 22 ++- .../Commands/InsertRangesCommand.cs | 22 ++- .../Commands/MoveSheetCommand.cs | 22 ++- .../Commands/RemoveSheetCommand.cs | 22 ++- .../Commands/RenameSheetCommand.cs | 22 ++- .../Commands/SetActiveViewCommand.cs | 26 ++- .../Commands/SetAutoFilterCommand.cs | 22 ++- .../SetConditionalFormattingCommand.cs | 22 ++- .../Commands/SortRangeCommand.cs | 22 ++- .../Commands/WriteCellsCommand.cs | 23 ++- .../Helpers/SpreadsheetHelper.cs | 97 +++++++++-- .../Services/SpreadsheetReader.cs | 45 +++-- .../Documents/DocumentLayoutStoreTests.cs | 12 ++ .../Tests/Documents/DocumentViewModelTests.cs | 22 ++- .../Tests/Packages/FileTypeProviderTests.cs | 73 +++++--- Source/Tests/Packages/PackageRegistryTests.cs | 133 ++++++++------ .../Resources/ResourceFileSystemTests.cs | 69 ++++++-- .../Resources/SidecarPairingServiceTests.cs | 42 +++-- .../Resources/SidecarPairingTestHelper.cs | 5 +- Source/Tests/Resources/SidecarServiceTests.cs | 26 +-- Source/Tests/Search/FileFilterTests.cs | 87 +++++++--- .../Spreadsheet/SpreadsheetCommandTests.cs | 20 ++- .../Spreadsheet/SpreadsheetReaderTests.cs | 62 +++---- Source/Tests/Tools/FileToolTests.cs | 11 ++ Source/Tests/Tools/FileToolsReadImageTests.cs | 9 + Source/Tests/Tools/SpreadsheetToolTests.cs | 116 +++++++------ .../Tools/WebViewScreenshotResolverTests.cs | 99 ++++++----- .../ViewModels/ConsolePanelViewModel.cs | 100 ++++++----- .../Helpers/FileAccessHelper.cs | 37 ++-- .../Services/DocumentEditorRegistry.cs | 5 + .../Services/DocumentLayoutStore.cs | 10 +- .../Services/DocumentsService.cs | 8 +- .../ContributionDocumentViewModel.cs | 49 ++++-- .../ViewModels/DefaultDocumentViewModel.cs | 18 +- .../ViewModels/DocumentTabViewModel.cs | 12 +- .../ViewModels/DocumentViewModel.cs | 139 +++++++-------- .../Celbridge.Documents/Views/DocumentView.cs | 34 +++- .../Commands/AddResourceDialogCommand.cs | 45 ++--- .../Views/ResourceTree.DragDrop.cs | 2 +- .../Services/InspectorFactory.cs | 20 +-- .../Views/InspectorPanel.xaml.cs | 8 +- .../Services/PackageRegistry.cs | 55 ++++-- .../Services/PackageService.cs | 10 +- .../Commands/ApplyRangeEditsCommand.cs | 20 +-- .../Commands/ArchiveResourceCommand.cs | 97 ++++++++--- .../Commands/CopyResourceCommand.cs | 20 ++- .../Commands/DeleteResourceCommand.cs | 40 +++-- .../Commands/EditFileCommand.cs | 19 +- .../Commands/GetFieldCommand.cs | 4 +- .../Commands/GetFileInfoCommand.cs | 57 +++--- .../Commands/MultiEditFileCommand.cs | 19 +- .../Commands/ReplaceFileCommand.cs | 25 ++- .../Commands/UnarchiveResourceCommand.cs | 67 +++++-- .../Commands/WriteFileCommand.cs | 22 ++- .../Helpers/ArchiveHelper.cs | 20 ++- .../Services/ResourceFileSystem.cs | 42 ++++- .../Services/ResourceScanner.cs | 6 +- .../Services/ResourceTransferService.cs | 12 +- .../Services/SidecarPairingService.cs | 60 ++++++- .../Services/SidecarService.cs | 6 +- .../Celbridge.Search/Services/FileFilter.cs | 15 +- .../Services/SearchService.cs | 163 ++++++++++-------- .../Services/DataTransferService.cs | 8 +- .../Services/WorkspaceLoader.cs | 2 +- 117 files changed, 2100 insertions(+), 1116 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs b/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs index 6cfa4856c..efb08a4ff 100644 --- a/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs +++ b/Source/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs @@ -39,8 +39,10 @@ public interface IDialogFactory /// /// Create a Resource Picker Dialog filtered to the specified file extensions. + /// Requires a loaded workspace; the dialog's view model resolves the + /// resource registry and file system from the workspace wrapper. /// - IResourcePickerDialog CreateResourcePickerDialog(IResourceRegistry registry, IReadOnlyList extensions, string? title = null, bool showPreview = false); + IResourcePickerDialog CreateResourcePickerDialog(IReadOnlyList extensions, string? title = null, bool showPreview = false); /// /// Create a Choice Dialog that lets the user pick from a list of named options. diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs index 7b9ad884b..d567c5d1b 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs @@ -21,6 +21,13 @@ public interface IDocumentEditorRegistry /// bool IsExtensionSupported(string fileExtension); + /// + /// Checks if any registered factory is bound to the specified exact filename. + /// Filename-only registrations (e.g. "package.cel") drive matching distinct + /// from extension lookups. + /// + bool IsFilenameSupported(string fileName); + /// /// Gets all registered factories. /// diff --git a/Source/Core/Celbridge.Foundation/Inspector/IInspectorFactory.cs b/Source/Core/Celbridge.Foundation/Inspector/IInspectorFactory.cs index 72f148bdf..fd78ab2a4 100644 --- a/Source/Core/Celbridge.Foundation/Inspector/IInspectorFactory.cs +++ b/Source/Core/Celbridge.Foundation/Inspector/IInspectorFactory.cs @@ -13,7 +13,7 @@ public interface IInspectorFactory /// /// Creates an inspector based on the resource type. /// - Result CreateResourceInspector(ResourceKey resource); + Task> CreateResourceInspectorAsync(ResourceKey resource); /// /// Creates an entity component list view for a resource. diff --git a/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs b/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs index ab08d5e71..4d411c875 100644 --- a/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs +++ b/Source/Core/Celbridge.Foundation/Packages/IPackageService.cs @@ -18,7 +18,7 @@ public interface IPackageService /// Discovers all packages (bundled module packages and project packages) /// and registers all package behaviors (e.g. custom document editor factories). /// - void RegisterPackages(string projectFolderPath); + Task RegisterPackagesAsync(string projectFolderPath); /// /// Gets document type entries from discovered packages that declare templates. diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs index fd263711a..ef3617ff2 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs @@ -78,6 +78,27 @@ public record FolderItem( long Size, DateTime ModifiedUtc); +/// +/// Discriminates the outcome of a GetInfoAsync probe. +/// +public enum ResourceInfoKind +{ + NotFound, + File, + Folder, +} + +/// +/// Metadata for a single resource, returned by GetInfoAsync. Size is the +/// file size in bytes for File; 0 for Folder and NotFound. ModifiedUtc is +/// the last-modified timestamp for File and Folder; default(DateTime) for +/// NotFound. +/// +public record ResourceInfo( + ResourceInfoKind Kind, + long Size, + DateTime ModifiedUtc); + /// /// The chokepoint for disk reads, writes, and structural operations on project /// resources. Callers pass a ResourceKey; the layer resolves it through @@ -144,9 +165,13 @@ public interface IResourceFileSystem Task> DeleteAsync(ResourceKey source); /// - /// Returns true if a file or folder exists at the resolved path of the resource key. + /// Probes a resource and returns its kind (NotFound, File, or Folder) along + /// with its size and modified-time in a single roundtrip. Callers that only + /// need existence check Kind != NotFound; callers that need to discriminate + /// file vs folder switch on Kind. Size and ModifiedUtc are populated for + /// the File case; Folder yields Size = 0 with ModifiedUtc set. /// - Task> ExistsAsync(ResourceKey resource); + Task> GetInfoAsync(ResourceKey resource); /// /// Returns the immediate children of a folder resource as FolderItem records. diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs index 905f710ef..b78cbf353 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceTransferService.cs @@ -12,7 +12,7 @@ public interface IResourceTransferService /// /// Create a Resource Transfer object describing the transfer of resources from a list of source paths to a destination folder. /// - Result CreateResourceTransfer(List sourcePaths, ResourceKey destFolderResource, DataTransferMode transferMode); + Task> CreateResourceTransferAsync(List sourcePaths, ResourceKey destFolderResource, DataTransferMode transferMode); /// /// Transfer resources to a destination folder resource. diff --git a/Source/Core/Celbridge.Foundation/Spreadsheet/ISpreadsheetReader.cs b/Source/Core/Celbridge.Foundation/Spreadsheet/ISpreadsheetReader.cs index 3f610ce7a..8d997c61b 100644 --- a/Source/Core/Celbridge.Foundation/Spreadsheet/ISpreadsheetReader.cs +++ b/Source/Core/Celbridge.Foundation/Spreadsheet/ISpreadsheetReader.cs @@ -154,11 +154,10 @@ public record ActiveView( string TopLeftCell); /// -/// Reads .xlsx workbooks from disk for the spreadsheet_* MCP tools. The reader -/// is stateless and opens the workbook fresh on every call. Callers pass the -/// absolute filesystem path resolved from a resource key. All methods report -/// expected failures (missing sheet, malformed range) as Result.Fail rather -/// than throwing. +/// Reads .xlsx workbooks for the spreadsheet_* MCP tools. The reader is +/// stateless; callers pass a freshly opened stream produced by the resource +/// file system. All methods report expected failures (missing sheet, +/// malformed range) as Result.Fail rather than throwing. /// public interface ISpreadsheetReader { @@ -166,13 +165,13 @@ public interface ISpreadsheetReader /// Returns a workbook overview: every sheet with its used range and /// dimensions, plus any workbook-scoped or sheet-scoped named ranges. /// - Result GetInfo(string workbookPath); + Result GetInfo(Stream workbookStream); /// /// Reads cell values from a sheet. When options.Range is null the sheet's /// used range is read. An empty sheet returns Rows = [] and TotalRowCount = 0. /// - Result ReadSheet(string workbookPath, string sheetName, ReadOptions options); + Result ReadSheet(Stream workbookStream, string sheetName, ReadOptions options); /// /// Returns the contents of a sheet as RFC 4180 CSV text along with the row @@ -180,14 +179,14 @@ public interface ISpreadsheetReader /// Null exports the sheet's used range. An empty range returns an empty Csv /// string and zero dimensions. /// - Result ExportCsv(string workbookPath, string sheetName, string? range); + Result ExportCsv(Stream workbookStream, string sheetName, string? range); /// /// Reads cell formatting from a sheet as FormatSpec objects in /// the same shape accepted by spreadsheet_format_ranges. When range is null /// the sheet's used range is read. An empty sheet returns Rows = []. /// - Result ReadFormat(string workbookPath, string sheetName, string? range); + Result ReadFormat(Stream workbookStream, string sheetName, string? range); /// /// Returns the workbook's persisted view state: active sheet, selection on @@ -195,7 +194,7 @@ public interface ISpreadsheetReader /// the parameters accepted by ISetActiveViewCommand so callers /// can round-trip the view state. /// - Result GetActiveView(string workbookPath); + Result GetActiveView(Stream workbookStream); /// /// Searches the workbook for cells whose literal text or formula expression @@ -203,5 +202,5 @@ public interface ISpreadsheetReader /// Empty Sheet searches every worksheet; empty Range searches each chosen /// sheet's entire used range. /// - Result Find(string workbookPath, FindOptions options); + Result Find(Stream workbookStream, FindOptions options); } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs index fe5c1b67a..e5a47247e 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs @@ -55,7 +55,7 @@ public async partial Task Edit( var editValue = editResult.Value; var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; var affectedLines = new List(editValue.AffectedRanges.Count); @@ -67,7 +67,7 @@ public async partial Task Edit( string[]? fileLines = null; if (editValue.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(resourceRegistry, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileSystem, fileResourceKey); } foreach (var range in editValue.AffectedRanges) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs index c32918afd..42ae76e9a 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs @@ -63,11 +63,11 @@ public async partial Task Grep(string searchTerm, bool useRegex } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; if (!string.IsNullOrEmpty(files)) { - return await GrepTargetedFiles(files, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, resourceRegistry); + return await GrepTargetedFiles(files, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, fileSystem); } var searchService = workspaceWrapper.WorkspaceService.SearchService; @@ -85,7 +85,7 @@ public async partial Task Grep(string searchTerm, bool useRegex var truncated = results.ReachedMaxResults || results.WasCancelled; - var fileLineCache = new Dictionary(); + var fileLineCache = new Dictionary(); var fileResults = new List(); foreach (var fileResult in results.FileResults) @@ -99,18 +99,10 @@ public async partial Task Grep(string searchTerm, bool useRegex { if (contextLines > 0) { - var resolveContextResult = resourceRegistry.ResolveResourcePath(fileResult.Resource); - if (resolveContextResult.IsFailure) + if (!fileLineCache.TryGetValue(fileResult.Resource, out var fileLines)) { - matchList.Add(new GrepMatch(match.LineNumber, match.LineText, match.MatchStart, match.MatchLength)); - continue; - } - var resourcePath = resolveContextResult.Value; - - if (!fileLineCache.TryGetValue(resourcePath, out var fileLines)) - { - fileLines = File.Exists(resourcePath) ? await File.ReadAllLinesAsync(resourcePath) : Array.Empty(); - fileLineCache[resourcePath] = fileLines; + fileLines = await ReadFileLinesStreamedAsync(fileSystem, fileResult.Resource); + fileLineCache[fileResult.Resource] = fileLines; } var matchLineIndex = match.LineNumber - 1; @@ -152,10 +144,10 @@ public async partial Task Grep(string searchTerm, bool useRegex if (includeContent && !summaryOnly) { - var resolveContentResult = resourceRegistry.ResolveResourcePath(fileResult.Resource); - if (resolveContentResult.IsSuccess && File.Exists(resolveContentResult.Value)) + var contentResult = await fileSystem.ReadAllTextAsync(fileResult.Resource); + if (contentResult.IsSuccess) { - fileContent = await File.ReadAllTextAsync(resolveContentResult.Value); + fileContent = contentResult.Value; } } @@ -199,7 +191,32 @@ private static CallToolResult BuildGrepResponse(GrepResult grepResult) }; } - private async Task GrepTargetedFiles(string filesJson, string searchTerm, bool useRegex, bool matchCase, bool wholeWord, int maxResults, int contextLines, bool includeContent, bool summaryOnly, IResourceRegistry resourceRegistry) + /// + /// Streams a file via the chokepoint's OpenReadAsync and returns it as a + /// line array. Avoids loading the full content into memory and routes the + /// read through containment validation. Returns an empty array on failure + /// so callers can treat missing or unreadable files as zero matches. + /// + private static async Task ReadFileLinesStreamedAsync(IResourceFileSystem fileSystem, ResourceKey resource) + { + var openResult = await fileSystem.OpenReadAsync(resource); + if (openResult.IsFailure) + { + return Array.Empty(); + } + + var lines = new List(); + await using var stream = openResult.Value; + using var reader = new StreamReader(stream); + string? line; + while ((line = await reader.ReadLineAsync()) is not null) + { + lines.Add(line); + } + return lines.ToArray(); + } + + private async Task GrepTargetedFiles(string filesJson, string searchTerm, bool useRegex, bool matchCase, bool wholeWord, int maxResults, int contextLines, bool includeContent, bool summaryOnly, IResourceFileSystem fileSystem) { // Detect the most common mis-use: a glob or single path passed where a // JSON array is required. The raw JsonException for this case ("'w' is @@ -255,19 +272,14 @@ private async Task GrepTargetedFiles(string filesJson, string se continue; } - var resolveResult = resourceRegistry.ResolveResourcePath(fileResourceKey); - if (resolveResult.IsFailure) - { - continue; - } - var filePath = resolveResult.Value; - - if (!File.Exists(filePath)) + var infoResult = await fileSystem.GetInfoAsync(fileResourceKey); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { continue; } - var fileLines = await File.ReadAllLinesAsync(filePath); + var fileLines = await ReadFileLinesStreamedAsync(fileSystem, fileResourceKey); var matchList = new List(); int fileMatchCount = 0; @@ -334,7 +346,7 @@ private async Task GrepTargetedFiles(string filesJson, string se fileResults.Add(new GrepFileResult( fileKeyString, - Path.GetFileName(filePath), + fileResourceKey.ResourceName, fileMatchCount, matchList, fileContent)); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs index 88e268caf..4b5b0a466 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs @@ -90,12 +90,12 @@ public async partial Task MultiEdit(string fileResource, string // only verification signal a caller has for a truncated edit, so // stripping their context would leave bare positions with no evidence. var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; string[]? fileLines = null; if (resultValue.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(resourceRegistry, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileSystem, fileResourceKey); } var affectedLines = new List(resultValue.AffectedRanges.Count); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs index 4488af993..0c90499da 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs @@ -22,21 +22,28 @@ public async partial Task Read(string resource, int offset = 0, } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return ToolResponse.Error(resolveResult.FirstErrorMessage); + // Surface the chokepoint's failure verbatim so case-mismatch + // errors (which carry the canonical key) reach the caller. The + // generic "resource not found" message only fires when the + // resolve succeeded but the resource genuinely is not a file. + return ToolResponse.Error(infoResult); } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != ResourceInfoKind.File) { return ToolResponse.Error($"Resource not found: '{resourceKey}'. file_read addresses resources by resource key, not arbitrary disk paths — only files under a registered root (e.g. 'project:', 'temp:', 'logs:') can be read."); } - var fileText = await File.ReadAllTextAsync(resourcePath); + var readResult = await fileSystem.ReadAllTextAsync(resourceKey); + if (readResult.IsFailure) + { + return ToolResponse.Error(readResult.FirstErrorMessage); + } + var fileText = readResult.Value; var totalLineCount = LineEndingHelper.CountLines(fileText); var fileSeparator = LineEndingHelper.DetectSeparatorOrDefault(fileText); @@ -82,7 +89,7 @@ public async partial Task Read(string resource, int offset = 0, rangeContent = string.Join(fileSeparator, selectedLines); } - var readResult = new FileReadResult(rangeContent, totalLineCount); - return ToolResponse.Success(SerializeJson(readResult)); + var rangeReadResult = new FileReadResult(rangeContent, totalLineCount); + return ToolResponse.Success(SerializeJson(rangeReadResult)); } } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs index cbd9d28cd..9a8aa484b 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs @@ -23,23 +23,26 @@ public async partial Task ReadBinary(string resource) } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return ToolResponse.Error(resolveResult.FirstErrorMessage); + return ToolResponse.Error(infoResult); } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != ResourceInfoKind.File) { return ToolResponse.Error($"File not found: '{resourceKey}'"); } - var bytes = await File.ReadAllBytesAsync(resourcePath); + var bytesResult = await fileSystem.ReadAllBytesAsync(resourceKey); + if (bytesResult.IsFailure) + { + return ToolResponse.Error(bytesResult.FirstErrorMessage); + } + var bytes = bytesResult.Value; var base64 = Convert.ToBase64String(bytes); - var extension = Path.GetExtension(resourcePath).ToLowerInvariant(); + var extension = Path.GetExtension(resourceKey.Path).ToLowerInvariant(); var mimeType = GetMimeType(extension); var result = new FileReadBinaryResult(base64, mimeType, bytes.Length); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs index c94f17bc8..150f2d3b4 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs @@ -36,21 +36,20 @@ public async partial Task ReadImage(string resource) } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return ToolResponse.Error(resolveResult.FirstErrorMessage); + return ToolResponse.Error(infoResult); } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != ResourceInfoKind.File) { return ToolResponse.Error($"File not found: '{resourceKey}'"); } + var info = infoResult.Value; - var extension = Path.GetExtension(resourcePath).ToLowerInvariant(); + var extension = Path.GetExtension(resourceKey.Path).ToLowerInvariant(); if (!SupportedImageMimeTypes.TryGetValue(extension, out var mimeType)) { return ToolResponse.Error( @@ -59,16 +58,20 @@ public async partial Task ReadImage(string resource) $"For other binary content, use file_read_binary."); } - var fileInfo = new FileInfo(resourcePath); - if (fileInfo.Length > MaxInlineImageBytes) + if (info.Size > MaxInlineImageBytes) { return ToolResponse.Error( - $"Image '{resourceKey}' is {fileInfo.Length} bytes, which exceeds the {MaxInlineImageBytes}-byte inline cap. " + + $"Image '{resourceKey}' is {info.Size} bytes, which exceeds the {MaxInlineImageBytes}-byte inline cap. " + $"Resize or recompress the image (or capture a smaller screenshot via webview_screenshot with maxEdge) " + $"before calling file_read_image."); } - var bytes = await File.ReadAllBytesAsync(resourcePath); + var bytesResult = await fileSystem.ReadAllBytesAsync(resourceKey); + if (bytesResult.IsFailure) + { + return ToolResponse.Error(bytesResult.FirstErrorMessage); + } + var bytes = bytesResult.Value; var metadata = new FileReadImageResult(resourceKey.ToString(), mimeType, bytes.Length); var metadataJson = JsonSerializer.Serialize(metadata, JsonOptions); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs index 4b23f1280..602a6457c 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs @@ -39,7 +39,7 @@ public async partial Task ReadMany(string resources, int offset } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; var entries = new List(); foreach (var resourceString in resourceKeys) @@ -54,21 +54,25 @@ public async partial Task ReadMany(string resources, int offset // entries for different roots are unambiguous regardless of how the agent typed them. var canonicalResource = resourceKey.ToString(); - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - entries.Add(new ReadManyFileEntry(canonicalResource, Error: resolveResult.FirstErrorMessage)); + entries.Add(new ReadManyFileEntry(canonicalResource, Error: infoResult.FirstErrorMessage)); continue; } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != ResourceInfoKind.File) { entries.Add(new ReadManyFileEntry(canonicalResource, Error: $"File not found: '{canonicalResource}'")); continue; } - var fileText = await File.ReadAllTextAsync(resourcePath); + var readResult = await fileSystem.ReadAllTextAsync(resourceKey); + if (readResult.IsFailure) + { + entries.Add(new ReadManyFileEntry(canonicalResource, Error: readResult.FirstErrorMessage)); + continue; + } + var fileText = readResult.Value; var totalLineCount = LineEndingHelper.CountLines(fileText); if (offset == 0 && limit == 0) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs index 17b5c6ac2..7a618a3db 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs @@ -56,7 +56,7 @@ public async partial Task Replace( var commandResult = findReplaceResult.Value; var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; var affectedLines = new List(commandResult.AffectedRanges.Count); @@ -68,7 +68,7 @@ public async partial Task Replace( string[]? fileLines = null; if (commandResult.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(resourceRegistry, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileSystem, fileResourceKey); } foreach (var range in commandResult.AffectedRanges) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs index a4b8261ca..580591e4c 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs @@ -52,16 +52,16 @@ public async partial Task Search(string pattern, bool includeMet var results = new List(); foreach (var folderKey in matchingFolders) { - var resolvePathResult = resourceRegistry.ResolveResourcePath(folderKey); - if (resolvePathResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(folderKey); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.Folder) { continue; } - var directoryInfo = new DirectoryInfo(resolvePathResult.Value); results.Add(new SearchResultWithMetadata( folderKey.ToString(), 0, - directoryInfo.LastWriteTimeUtc.ToString("o"))); + infoResult.Value.ModifiedUtc.ToString("o"))); } return ToolResponse.Success(SerializeJson(results)); } @@ -81,11 +81,16 @@ public async partial Task Search(string pattern, bool includeMet var results = new List(); foreach (var match in matches) { - var fileInfo = new FileInfo(match.Path); + var infoResult = await fileSystem.GetInfoAsync(match.Resource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) + { + continue; + } results.Add(new SearchResultWithMetadata( match.Resource.ToString(), - fileInfo.Length, - fileInfo.LastWriteTimeUtc.ToString("o"))); + infoResult.Value.Size, + infoResult.Value.ModifiedUtc.ToString("o"))); } return ToolResponse.Success(SerializeJson(results)); } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs index 728489da2..4c938297f 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs @@ -78,21 +78,22 @@ private static string SerializeJson(object value) /// when the resource cannot be resolved or the file no longer exists, so /// the caller can fall back to ranges without context. /// - private static async Task ReadFileLinesForContextAsync(IResourceRegistry resourceRegistry, ResourceKey fileResourceKey) + private static async Task ReadFileLinesForContextAsync(IResourceFileSystem fileSystem, ResourceKey fileResourceKey) { - var resolveResult = resourceRegistry.ResolveResourcePath(fileResourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(fileResourceKey); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { return null; } - var resourcePath = resolveResult.Value; - if (!File.Exists(resourcePath)) + var readResult = await fileSystem.ReadAllTextAsync(fileResourceKey); + if (readResult.IsFailure) { return null; } - return await File.ReadAllLinesAsync(resourcePath); + return LineEndingHelper.SplitToContentLines(readResult.Value).ToArray(); } /// diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AddSheets.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AddSheets.cs index 37ab67504..7ad0c129a 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AddSheets.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AddSheets.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_editor_division")] public async partial Task AddSheets(string resource, string sheetsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; var parseResult = ParseSheetNames(sheetsJson); if (parseResult.IsFailure) @@ -26,10 +27,9 @@ public async partial Task AddSheets(string resource, string shee } var sheetNames = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheets = sheetNames; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AppendRows.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AppendRows.cs index 9181febb9..1691f40d8 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AppendRows.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.AppendRows.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_cell_typing", "spreadsheet_headers_mode", "spreadsheet_editor_division", "spreadsheet_workflows")] public async partial Task AppendRows(string resource, string sheet, string rowsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -31,10 +32,9 @@ public async partial Task AppendRows(string resource, string she } var parsedRows = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Rows = parsedRows; }); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Clear.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Clear.cs index 95267dbf8..b7b664c6b 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Clear.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Clear.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_editor_division")] public async partial Task Clear(string resource, string operationsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; var parseResult = ParseClearOperations(operationsJson); if (parseResult.IsFailure) @@ -26,10 +27,9 @@ public async partial Task Clear(string resource, string operatio } var operations = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Operations = operations; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Delete.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Delete.cs index 5d49d781a..c1c519855 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Delete.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Delete.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_editor_division")] public async partial Task Delete(string resource, string operationsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; var parseResult = ParseDeleteOperations(operationsJson); if (parseResult.IsFailure) @@ -26,10 +27,9 @@ public async partial Task Delete(string resource, string operati } var operations = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Operations = operations; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.DuplicateSheet.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.DuplicateSheet.cs index ab0468f03..566db3d42 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.DuplicateSheet.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.DuplicateSheet.cs @@ -16,11 +16,12 @@ public async partial Task DuplicateSheet( string newSheet, int position = 0) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sourceSheet)) { @@ -32,10 +33,9 @@ public async partial Task DuplicateSheet( return ToolResponse.Error("New sheet name is required."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.SourceSheet = sourceSheet; command.NewSheet = newSheet; command.Position = position; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs index fcde102e9..8d8cff13d 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ExportCsv.cs @@ -21,12 +21,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_workflows")] public async partial Task ExportCsv(string resource, string sheet, string range = "", string destination = "") { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -35,13 +35,23 @@ public async partial Task ExportCsv(string resource, string shee var rangeArgument = string.IsNullOrEmpty(range) ? null : range; - var reader = GetRequiredService(); - var csvResult = reader.ExportCsv(workbookPath, sheet, rangeArgument); - if (csvResult.IsFailure) + var openResult = await OpenWorkbookStreamAsync(workbookResource); + if (openResult.IsFailure) { - return ToolResponse.Error(csvResult); + return ToolResponse.Error(openResult); + } + + ExportCsvResult csv; + using (var stream = openResult.Value) + { + var reader = GetRequiredService(); + var csvResult = reader.ExportCsv(stream, sheet, rangeArgument); + if (csvResult.IsFailure) + { + return ToolResponse.Error(csvResult); + } + csv = csvResult.Value; } - var csv = csvResult.Value; if (string.IsNullOrEmpty(destination)) { diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Find.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Find.cs index 9a7c50094..ce7261f1a 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Find.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Find.cs @@ -10,7 +10,7 @@ public partial class SpreadsheetTools [McpServerTool(Name = "spreadsheet_find", ReadOnly = true)] [ToolAlias("spreadsheet.find")] [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_cell_typing")] - public partial CallToolResult Find( + public async partial Task Find( string resource, string find, string sheet = "", @@ -18,21 +18,28 @@ public partial CallToolResult Find( bool matchCase = false, bool matchEntireCellContents = false) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(find)) { return ToolResponse.Error("Find text is required and must be non-empty."); } + var openResult = await OpenWorkbookStreamAsync(workbookResource); + if (openResult.IsFailure) + { + return ToolResponse.Error(openResult); + } + + using var stream = openResult.Value; var reader = GetRequiredService(); var options = new FindOptions(find, sheet, range, matchCase, matchEntireCellContents); - var findResult = reader.Find(workbookPath, options); + var findResult = reader.Find(stream, options); if (findResult.IsFailure) { return ToolResponse.Error(findResult); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FormatRanges.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FormatRanges.cs index 435f56d8b..087e5d184 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FormatRanges.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FormatRanges.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_editor_division")] public async partial Task FormatRanges(string resource, string editsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; var parseResult = ParseFormatEdits(editsJson); if (parseResult.IsFailure) @@ -26,10 +27,9 @@ public async partial Task FormatRanges(string resource, string e } var edits = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Edits = edits; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FreezePanes.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FreezePanes.cs index 5a57517d8..c60a779a6 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FreezePanes.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.FreezePanes.cs @@ -12,11 +12,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_editor_division")] public async partial Task FreezePanes(string resource, string sheet, int rows = 0, int columns = 0) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -28,10 +29,9 @@ public async partial Task FreezePanes(string resource, string sh return ToolResponse.Error("rows and columns must be non-negative."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Rows = rows; command.Columns = columns; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetActiveView.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetActiveView.cs index 678173a11..cd73b5d1d 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetActiveView.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetActiveView.cs @@ -10,17 +10,24 @@ public partial class SpreadsheetTools [McpServerTool(Name = "spreadsheet_get_active_view", ReadOnly = true)] [ToolAlias("spreadsheet.get_active_view")] [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_editor_division")] - public partial CallToolResult GetActiveView(string resource) + public async partial Task GetActiveView(string resource) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; + var openResult = await OpenWorkbookStreamAsync(workbookResource); + if (openResult.IsFailure) + { + return ToolResponse.Error(openResult); + } + + using var stream = openResult.Value; var reader = GetRequiredService(); - var viewResult = reader.GetActiveView(workbookPath); + var viewResult = reader.GetActiveView(stream); if (viewResult.IsFailure) { return ToolResponse.Error(viewResult); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetInfo.cs index 0527d01cf..aff0145ec 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetInfo.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.GetInfo.cs @@ -10,17 +10,24 @@ public partial class SpreadsheetTools [McpServerTool(Name = "spreadsheet_get_info", ReadOnly = true)] [ToolAlias("spreadsheet.get_info")] [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_workflows")] - public partial CallToolResult GetInfo(string resource) + public async partial Task GetInfo(string resource) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; + var openResult = await OpenWorkbookStreamAsync(workbookResource); + if (openResult.IsFailure) + { + return ToolResponse.Error(openResult); + } + + using var stream = openResult.Value; var reader = GetRequiredService(); - var infoResult = reader.GetInfo(workbookPath); + var infoResult = reader.GetInfo(stream); if (infoResult.IsFailure) { return ToolResponse.Error(infoResult); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ImportCsv.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ImportCsv.cs index 86759c3c8..5d762502b 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ImportCsv.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ImportCsv.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_cell_typing", "spreadsheet_editor_division", "spreadsheet_workflows")] public async partial Task ImportCsv(string resource, string importsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; var parseResult = ParseCsvImports(importsJson); if (parseResult.IsFailure) @@ -26,10 +27,9 @@ public async partial Task ImportCsv(string resource, string impo } var imports = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Imports = imports; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Insert.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Insert.cs index 5f8fe2e8c..9baad6e1b 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Insert.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Insert.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_editor_division")] public async partial Task Insert(string resource, string operationsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; var parseResult = ParseInsertOperations(operationsJson); if (parseResult.IsFailure) @@ -26,10 +27,9 @@ public async partial Task Insert(string resource, string operati } var operations = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Operations = operations; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.MoveSheet.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.MoveSheet.cs index c505e34da..ac1a4c12e 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.MoveSheet.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.MoveSheet.cs @@ -12,11 +12,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_editor_division")] public async partial Task MoveSheet(string resource, string sheet, int position) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -28,10 +29,9 @@ public async partial Task MoveSheet(string resource, string shee return ToolResponse.Error($"Position must be 1 or greater, was {position}."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Position = position; }); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadFormat.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadFormat.cs index 55c59716f..4585ddb5f 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadFormat.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadFormat.cs @@ -10,17 +10,17 @@ public partial class SpreadsheetTools [McpServerTool(Name = "spreadsheet_read_format", ReadOnly = true)] [ToolAlias("spreadsheet.read_format")] [RelatedGuides("resource_keys", "spreadsheet_a1_notation")] - public partial CallToolResult ReadFormat( + public async partial Task ReadFormat( string resource, string sheet, string range = "") { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -29,8 +29,15 @@ public partial CallToolResult ReadFormat( var rangeArgument = string.IsNullOrEmpty(range) ? null : range; + var openResult = await OpenWorkbookStreamAsync(workbookResource); + if (openResult.IsFailure) + { + return ToolResponse.Error(openResult); + } + + using var stream = openResult.Value; var reader = GetRequiredService(); - var readResult = reader.ReadFormat(workbookPath, sheet, rangeArgument); + var readResult = reader.ReadFormat(stream, sheet, rangeArgument); if (readResult.IsFailure) { return ToolResponse.Error(readResult); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadSheet.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadSheet.cs index 18be127d8..3ac2ee65d 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadSheet.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.ReadSheet.cs @@ -10,7 +10,7 @@ public partial class SpreadsheetTools [McpServerTool(Name = "spreadsheet_read_sheet", ReadOnly = true)] [ToolAlias("spreadsheet.read_sheet")] [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_cell_typing", "spreadsheet_headers_mode", "spreadsheet_paging")] - public partial CallToolResult ReadSheet( + public async partial Task ReadSheet( string resource, string sheet, string range = "", @@ -20,12 +20,12 @@ public partial CallToolResult ReadSheet( int limit = 0, int columnLimit = 0) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -47,8 +47,15 @@ public partial CallToolResult ReadSheet( Limit: limit, ColumnLimit: columnLimit); + var openResult = await OpenWorkbookStreamAsync(workbookResource); + if (openResult.IsFailure) + { + return ToolResponse.Error(openResult); + } + + using var stream = openResult.Value; var reader = GetRequiredService(); - var readResult = reader.ReadSheet(workbookPath, sheet, options); + var readResult = reader.ReadSheet(stream, sheet, options); if (readResult.IsFailure) { return ToolResponse.Error(readResult); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RemoveSheet.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RemoveSheet.cs index 0a581b388..c3677fd63 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RemoveSheet.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RemoveSheet.cs @@ -12,21 +12,21 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_editor_division")] public async partial Task RemoveSheet(string resource, string sheet) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { return ToolResponse.Error("Sheet name is required."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; }); if (commandResult.IsFailure) diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RenameSheet.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RenameSheet.cs index 5467a0fb2..2e66a0cf4 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RenameSheet.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.RenameSheet.cs @@ -12,11 +12,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_editor_division")] public async partial Task RenameSheet(string resource, string sheet, string newName) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -28,10 +29,9 @@ public async partial Task RenameSheet(string resource, string sh return ToolResponse.Error("New sheet name is required."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.NewName = newName; }); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetActiveView.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetActiveView.cs index a911c5431..2f986d9a4 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetActiveView.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetActiveView.cs @@ -19,11 +19,12 @@ public async partial Task SetActiveView( string activeCell = "", string topLeftCell = "") { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -37,10 +38,9 @@ public async partial Task SetActiveView( } var ranges = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Range = range; command.Ranges = ranges; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetAutoFilter.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetAutoFilter.cs index b75d56e14..2f77e8ece 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetAutoFilter.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetAutoFilter.cs @@ -16,21 +16,21 @@ public async partial Task SetAutoFilter( string range = "", bool enabled = true) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { return ToolResponse.Error("Sheet name is required."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Range = range; command.Enabled = enabled; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetConditionalFormatting.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetConditionalFormatting.cs index 3af7f75d9..283f30a96 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetConditionalFormatting.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.SetConditionalFormatting.cs @@ -18,11 +18,12 @@ public async partial Task SetConditionalFormatting( string rulesJson, bool clearExisting = false) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -46,10 +47,9 @@ public async partial Task SetConditionalFormatting( return ToolResponse.Error("Rules array must contain at least one rule when clearExisting is false."); } - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Range = range; command.Rules = rules; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Sort.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Sort.cs index 19a2a80d2..4e76b24f7 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Sort.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.Sort.cs @@ -19,11 +19,12 @@ public async partial Task Sort( bool hasHeaderRow = false, bool matchCase = false) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -37,10 +38,9 @@ public async partial Task Sort( } var sortKeys = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Range = range; command.SortKeys = sortKeys; diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.WriteCells.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.WriteCells.cs index c31a8092c..ea4cb1b67 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.WriteCells.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.WriteCells.cs @@ -13,11 +13,12 @@ public partial class SpreadsheetTools [RelatedGuides("resource_keys", "spreadsheet_a1_notation", "spreadsheet_cell_typing", "spreadsheet_editor_division", "spreadsheet_workflows")] public async partial Task WriteCells(string resource, string sheet, string editsJson) { - var resolveResult = ResolveWorkbookPath(resource); + var resolveResult = await ResolveWorkbookResourceAsync(resource); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); } + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(sheet)) { @@ -31,10 +32,9 @@ public async partial Task WriteCells(string resource, string she } var cellEdits = parseResult.Value; - var fileResourceKey = ResourceKey.Create(resource); var commandResult = await ExecuteCommandAsync(command => { - command.FileResource = fileResourceKey; + command.FileResource = workbookResource; command.Sheet = sheet; command.Edits = cellEdits; }); diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs index 47995f221..ff29b9d3f 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Celbridge.Resources; using ModelContextProtocol.Server; using Path = System.IO.Path; @@ -6,8 +7,9 @@ namespace Celbridge.Tools; /// /// MCP tools for reading, querying, and modifying .xlsx workbooks. Reads use -/// ISpreadsheetReader directly. Writes route through ISpreadsheet*Command -/// implementations so they appear in the command audit trail. +/// ISpreadsheetReader directly against a stream opened through the resource +/// file system. Writes route through ISpreadsheet*Command implementations so +/// they appear in the command audit trail. /// [McpServerToolType] public partial class SpreadsheetTools : AgentToolBase @@ -16,7 +18,12 @@ public partial class SpreadsheetTools : AgentToolBase public SpreadsheetTools(IApplicationServiceProvider services) : base(services) { } - private Result ResolveWorkbookPath(string resource) + // Validates the resource is a present .xlsx file and returns its key. + // Mirrors SpreadsheetHelper.ResolveWorkbookResourceAsync in the + // Spreadsheet module — that helper is internal to its assembly so the + // tool layer reimplements the same check rather than taking a module + // dependency. + private async Task> ResolveWorkbookResourceAsync(string resource) { if (!ResourceKey.TryCreate(resource, out var resourceKey)) { @@ -30,21 +37,42 @@ private Result ResolveWorkbookPath(string resource) } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return Result.Fail(resolveResult.FirstErrorMessage); + return Result.Fail($"Failed to inspect workbook: '{resourceKey}'") + .WithErrors(infoResult); } - var workbookPath = resolveResult.Value; - if (!File.Exists(workbookPath)) + var info = infoResult.Value; + if (info.Kind == ResourceInfoKind.NotFound) { return Result.Fail($"File not found: '{resourceKey}'"); } + if (info.Kind != ResourceInfoKind.File) + { + return Result.Fail($"Resource is not a file: '{resourceKey}'"); + } + + return resourceKey; + } + + // Opens the workbook bytes via the resource file system and returns them + // as a seekable MemoryStream positioned at zero. Caller disposes. + private async Task> OpenWorkbookStreamAsync(ResourceKey resource) + { + var workspaceWrapper = GetRequiredService(); + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + + var bytesResult = await fileSystem.ReadAllBytesAsync(resource); + if (bytesResult.IsFailure) + { + return Result.Fail($"Failed to read workbook: '{resource}'") + .WithErrors(bytesResult); + } - return workbookPath; + return (Stream)new MemoryStream(bytesResult.Value, writable: false); } private static string SerializeJson(object value) diff --git a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs index cca231900..219e69637 100644 --- a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs +++ b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Celbridge.Resources; using Path = System.IO.Path; namespace Celbridge.Tools; @@ -22,11 +23,11 @@ public static class WebViewScreenshotResolver /// A trailing slash, or a path with no extension, is treated as a folder /// reference and an auto-named file is generated inside it. Otherwise /// the saveTo value is used verbatim and its extension is checked - /// against the format. The projectFolderPath is used to probe for - /// filename collisions when generating an auto-name, so the common case - /// (no collision) yields a clean unsuffixed filename. + /// against the format. Collision probing for auto-named files routes + /// through the chokepoint so the lookup honours the same containment + /// validation as the screenshot save that follows. /// - public static Result Resolve(string saveTo, string format, string projectFolderPath) + public static async Task> ResolveAsync(string saveTo, string format, IResourceFileSystem fileSystem) { var extension = ExtensionForFormat(format); if (extension is null) @@ -55,9 +56,9 @@ public static Result Resolve(string saveTo, string format, string p // always carry an extension. if (endsWithSlash || !HasExtension(key.Path)) { + var folderResource = key; var folderPath = key.Path; - var folderAbsolutePath = ResolveAbsoluteUnderProject(projectFolderPath, folderPath); - var fileName = GenerateAutoName(extension, folderAbsolutePath); + var fileName = await GenerateAutoNameAsync(extension, fileSystem, folderResource); var combined = string.IsNullOrEmpty(folderPath) ? fileName : folderPath + "/" + fileName; if (!ResourceKey.TryCreate(combined, out var fileKey)) { @@ -81,7 +82,7 @@ public static Result Resolve(string saveTo, string format, string p return key; } - private static string GenerateAutoName(string extension, string absoluteFolderPath) + private static async Task GenerateAutoNameAsync(string extension, IResourceFileSystem fileSystem, ResourceKey folderResource) { // Prefer the clean unsuffixed name. In the common case (no collision) // the agent gets `screenshot-20260430-090238.jpg` rather than a noisy @@ -91,8 +92,7 @@ private static string GenerateAutoName(string extension, string absoluteFolderPa var timestamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); var primary = $"screenshot-{timestamp}.{extension}"; - var primaryPath = Path.Combine(absoluteFolderPath, primary); - if (!File.Exists(primaryPath)) + if (!await ExistsAsync(fileSystem, folderResource, primary)) { return primary; } @@ -100,8 +100,7 @@ private static string GenerateAutoName(string extension, string absoluteFolderPa for (int seq = 1; seq <= 999; seq++) { var candidate = $"screenshot-{timestamp}-{seq}.{extension}"; - var candidatePath = Path.Combine(absoluteFolderPath, candidate); - if (!File.Exists(candidatePath)) + if (!await ExistsAsync(fileSystem, folderResource, candidate)) { return candidate; } @@ -137,13 +136,11 @@ private static bool HasExtension(string resourceKeyString) return !string.IsNullOrEmpty(extension); } - private static string ResolveAbsoluteUnderProject(string projectFolderPath, string resourceKeyString) + private static async Task ExistsAsync(IResourceFileSystem fileSystem, ResourceKey folderResource, string fileName) { - if (string.IsNullOrEmpty(resourceKeyString)) - { - return projectFolderPath; - } - - return Path.Combine(projectFolderPath, resourceKeyString.Replace('/', Path.DirectorySeparatorChar)); + var candidateKey = folderResource.IsEmpty ? new ResourceKey(fileName) : folderResource.Combine(fileName); + var infoResult = await fileSystem.GetInfoAsync(candidateKey); + return infoResult.IsSuccess + && infoResult.Value.Kind != ResourceInfoKind.NotFound; } } diff --git a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs index e8d999105..94fd93d89 100644 --- a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs +++ b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs @@ -60,7 +60,8 @@ public async partial Task Screenshot( return ToolResponse.Error("No project is currently loaded. webview_screenshot requires an open project to resolve its save destination."); } - var resolveResult = WebViewScreenshotResolver.Resolve(saveTo, format, projectFolderPath); + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var resolveResult = await WebViewScreenshotResolver.ResolveAsync(saveTo, format, fileSystem); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); diff --git a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs index 09d57ed37..34fef21ab 100644 --- a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs +++ b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs @@ -80,10 +80,10 @@ public IAddFileDialog CreateAddFileDialog(string defaultFileName, Range selectio return dialog; } - public IResourcePickerDialog CreateResourcePickerDialog(IResourceRegistry registry, IReadOnlyList extensions, string? title = null, bool showPreview = false) + public IResourcePickerDialog CreateResourcePickerDialog(IReadOnlyList extensions, string? title = null, bool showPreview = false) { var dialog = new ResourcePickerDialog(); - dialog.ViewModel.Initialize(registry, extensions, showPreview); + dialog.ViewModel.Initialize(extensions, showPreview); if (title is not null) { dialog.SetTitle(title); diff --git a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs index 959c8fed2..7f8e10f24 100644 --- a/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs +++ b/Source/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs @@ -143,8 +143,7 @@ public async Task> ShowResourcePickerDialogAsync(IReadOnlyLi return Result.Fail("Cannot show resource picker: no project is currently loaded."); } - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var dialog = _dialogFactory.CreateResourcePickerDialog(registry, extensions, title, showPreview); + var dialog = _dialogFactory.CreateResourcePickerDialog(extensions, title, showPreview); return await ShowDialogAsync(dialog.ShowDialogAsync); } diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs index 5f6a3a999..21c6e6281 100644 --- a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs +++ b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs @@ -1,13 +1,15 @@ using System.ComponentModel; +using Celbridge.Workspace; using Microsoft.UI.Xaml.Media.Imaging; namespace Celbridge.UserInterface.ViewModels; public partial class ResourcePickerDialogViewModel : ObservableObject { - private readonly IFileIconService _fileIconService; + private readonly IWorkspaceWrapper _workspaceWrapper; private IResourceRegistry? _registry; + private IResourceFileSystem? _fileSystem; private List _extensions = []; private List _allItems = []; private bool _showPreview; @@ -33,15 +35,24 @@ public partial class ResourcePickerDialogViewModel : ObservableObject [ObservableProperty] private Visibility _previewImageVisibility = Visibility.Collapsed; - public ResourcePickerDialogViewModel(IFileIconService fileIconService) + public ResourcePickerDialogViewModel( + IWorkspaceWrapper workspaceWrapper) { - _fileIconService = fileIconService; + _workspaceWrapper = workspaceWrapper; PropertyChanged += OnPropertyChanged; } - public void Initialize(IResourceRegistry registry, IReadOnlyList extensions, bool showPreview) + public void Initialize(IReadOnlyList extensions, bool showPreview) { - _registry = registry; + // The resource picker only makes sense for a loaded project. Callers + // (DialogService.ShowResourcePickerDialogAsync) already short-circuit + // with a user-facing error in that case; the guard here is a + // belt-and-braces safety net against a future caller that forgets. + Guard.IsTrue(_workspaceWrapper.IsWorkspacePageLoaded); + + var workspaceService = _workspaceWrapper.WorkspaceService; + _registry = workspaceService.ResourceService.Registry; + _fileSystem = workspaceService.ResourceFileSystem; _showPreview = showPreview; _extensions = extensions .Select(e => e.TrimStart('.').ToLowerInvariant()) @@ -50,7 +61,7 @@ public void Initialize(IResourceRegistry registry, IReadOnlyList extensi // Show the preview panel container if preview is enabled (reserves space) PreviewPanelVisibility = showPreview ? Visibility.Visible : Visibility.Collapsed; - _allItems = BuildFlatList(registry); + _allItems = BuildFlatList(_registry); UpdateFilteredItems(); } @@ -67,16 +78,17 @@ private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) } } - private void UpdatePreview() + private async void UpdatePreview() { - if (!_showPreview || SelectedItem is null || _registry is null) + if (!_showPreview || SelectedItem is null || _registry is null || _fileSystem is null) { PreviewImageVisibility = Visibility.Collapsed; PreviewImage = null; return; } - var resolveResult = _registry.ResolveResourcePath(SelectedItem.ResourceKey); + var selectedItem = SelectedItem; + var resolveResult = _registry.ResolveResourcePath(selectedItem.ResourceKey); if (resolveResult.IsFailure) { PreviewImageVisibility = Visibility.Collapsed; @@ -85,22 +97,29 @@ private void UpdatePreview() } var resourcePath = resolveResult.Value; - if (File.Exists(resourcePath)) + var infoResult = await _fileSystem.GetInfoAsync(selectedItem.ResourceKey); + // The selection can change while the probe is in flight; the late + // result must not overwrite a newer selection's preview. + if (!ReferenceEquals(selectedItem, SelectedItem)) { - try - { - var bitmap = new BitmapImage(); - bitmap.UriSource = new Uri(resourcePath); - PreviewImage = bitmap; - PreviewImageVisibility = Visibility.Visible; - } - catch - { - PreviewImageVisibility = Visibility.Collapsed; - PreviewImage = null; - } + return; + } + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) + { + PreviewImageVisibility = Visibility.Collapsed; + PreviewImage = null; + return; + } + + try + { + var bitmap = new BitmapImage(); + bitmap.UriSource = new Uri(resourcePath); + PreviewImage = bitmap; + PreviewImageVisibility = Visibility.Visible; } - else + catch { PreviewImageVisibility = Visibility.Collapsed; PreviewImage = null; diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs index 9d5d9f41c..cdcdc9956 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -21,14 +22,12 @@ public AddSheetsCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (Sheets.Count == 0) { @@ -49,9 +48,16 @@ public override async Task ExecuteAsync() } } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; foreach (var sheetName in Sheets) { @@ -66,7 +72,11 @@ public override async Task ExecuteAsync() workbook.Worksheets.Add(sheetName); } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new AddSheetsResult(Sheets.ToList()); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs index aa7a977c3..c8e926ca0 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Spreadsheet.Services; using Celbridge.Workspace; using ClosedXML.Excel; @@ -22,14 +23,12 @@ public AppendRowsCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -55,9 +54,16 @@ public override async Task ExecuteAsync() } } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -96,7 +102,11 @@ public override async Task ExecuteAsync() } } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } var lastRow = firstRow + Rows.Count - 1; ResultValue = new AppendRowsResult(Rows.Count, firstRow, lastRow); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs index ad5066a74..eb4f881e3 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -21,14 +22,12 @@ public ClearRangesCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (Operations.Count == 0) { @@ -49,9 +48,16 @@ public override async Task ExecuteAsync() } } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; int totalCellCount = 0; @@ -74,7 +80,11 @@ public override async Task ExecuteAsync() totalCellCount += cellCount; } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new ClearRangesResult(Operations.Count, totalCellCount); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs index eccea25e8..949827010 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -21,14 +22,12 @@ public DeleteRangesCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (Operations.Count == 0) { @@ -59,9 +58,16 @@ public override async Task ExecuteAsync() var rowsBySheet = new Dictionary>(StringComparer.Ordinal); var columnsBySheet = new Dictionary>(StringComparer.Ordinal); + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; for (int operationIndex = 0; operationIndex < Operations.Count; operationIndex++) { @@ -115,7 +121,11 @@ public override async Task ExecuteAsync() } } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new DeleteRangesResult(Operations.Count, totalRowsDeleted, totalColumnsDeleted); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs index da6c91b20..0fb08f7a7 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -23,14 +24,12 @@ public DuplicateSheetCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(SourceSheet)) { @@ -42,9 +41,16 @@ public override async Task ExecuteAsync() return Result.Fail("New sheet name is required."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(SourceSheet)) { @@ -87,7 +93,11 @@ public override async Task ExecuteAsync() ColorScaleCopyHelper.Reapply(duplicate, colorScaleSnapshots); } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new DuplicateSheetResult(duplicate.Name, duplicate.Position); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs index f3d1688f1..45e2a1163 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Spreadsheet.Services; using Celbridge.Workspace; using ClosedXML.Excel; @@ -24,14 +25,12 @@ public FormatRangesCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (Edits.Count == 0) { @@ -54,9 +53,16 @@ public override async Task ExecuteAsync() int totalPropertiesApplied = 0; bool anyAutoFitApplied = false; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; for (int editIndex = 0; editIndex < Edits.Count; editIndex++) { @@ -81,7 +87,11 @@ public override async Task ExecuteAsync() } } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new FormatRangesResult(Edits.Count, totalPropertiesApplied, anyAutoFitApplied); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs index fa810485b..e33dd1a0a 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -23,14 +24,12 @@ public FreezePanesCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -42,9 +41,16 @@ public override async Task ExecuteAsync() return Result.Fail("Rows and Columns must be non-negative."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -76,7 +82,11 @@ public override async Task ExecuteAsync() worksheet.SheetView.FreezeRows(Rows); worksheet.SheetView.FreezeColumns(Columns); - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new FreezePanesResult(Sheet, Rows, Columns); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs index 515675910..5aff1a9ed 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs @@ -1,5 +1,6 @@ using System.Globalization; using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Spreadsheet.Services; using Celbridge.Workspace; using ClosedXML.Excel; @@ -23,14 +24,12 @@ public ImportCsvCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (Imports.Count == 0) { @@ -80,9 +79,16 @@ public override async Task ExecuteAsync() int totalRowCount = 0; int sheetsCreated = 0; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; for (int importIndex = 0; importIndex < parsedImports.Count; importIndex++) { @@ -127,7 +133,11 @@ public override async Task ExecuteAsync() totalRowCount += parsedRows.Count; } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new ImportCsvResult(parsedImports.Count, totalRowCount, sheetsCreated); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs index dbb3e1c1d..fe1edfaa8 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -21,14 +22,12 @@ public InsertRangesCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (Operations.Count == 0) { @@ -61,9 +60,16 @@ public override async Task ExecuteAsync() var rowsBySheet = new Dictionary>(StringComparer.Ordinal); var columnsBySheet = new Dictionary>(StringComparer.Ordinal); + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; for (int operationIndex = 0; operationIndex < Operations.Count; operationIndex++) { @@ -113,7 +119,11 @@ public override async Task ExecuteAsync() } } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new InsertRangesResult(Operations.Count, totalRowsInserted, totalColumnsInserted); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs index f9a3de078..76fb728f0 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -22,14 +23,12 @@ public MoveSheetCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -41,9 +40,16 @@ public override async Task ExecuteAsync() return Result.Fail($"Position must be 1 or greater, was {Position}."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -60,7 +66,11 @@ public override async Task ExecuteAsync() if (worksheet.Position != Position) { worksheet.Position = Position; - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } } ResultValue = new MoveSheetResult(Sheet, Position); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs index 6391e23ac..e64d2fb43 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -21,23 +22,28 @@ public RemoveSheetCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { return Result.Fail("Sheet name is required."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -50,7 +56,11 @@ public override async Task ExecuteAsync() } workbook.Worksheets.Delete(Sheet); - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new RemoveSheetResult(Sheet); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs index 80ee4197a..86669f172 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -22,14 +23,12 @@ public RenameSheetCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -47,9 +46,16 @@ public override async Task ExecuteAsync() return Result.Ok(); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -63,7 +69,11 @@ public override async Task ExecuteAsync() var worksheet = workbook.Worksheet(Sheet); worksheet.Name = NewName; - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new RenameSheetResult(Sheet, NewName); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs index 10f94e955..38216ce3d 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -25,14 +26,12 @@ public SetActiveViewCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -85,7 +84,7 @@ public override async Task ExecuteAsync() // Write to disk and let the file-watcher reload path apply the view // state to any open editor. The editor's restoreViewState yields to // disk when the active sheet or selection has changed. - var applyResult = ApplyViewStateToWorkbook(workbookPath); + var applyResult = await ApplyViewStateToWorkbookAsync(workbookResource); if (applyResult.IsFailure) { return Result.Fail(applyResult.FirstErrorMessage); @@ -103,11 +102,18 @@ public override async Task ExecuteAsync() // first cell of the first selection range. private record AppliedViewState(IReadOnlyList Ranges, string ActiveCell); - private Result ApplyViewStateToWorkbook(string workbookPath) + private async Task> ApplyViewStateToWorkbookAsync(ResourceKey workbookResource) { + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -207,7 +213,11 @@ private Result ApplyViewStateToWorkbook(string workbookPath) worksheet.SheetView.TopLeftCellAddress = scrollAnchor.Address; } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } return new AppliedViewState(appliedRanges, appliedActiveCell); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs index 9a4e57f3f..3ebe56e02 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -23,14 +24,12 @@ public SetAutoFilterCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -44,9 +43,16 @@ public override async Task ExecuteAsync() return Result.Fail($"Auto-filter range must be an A1 cell range like 'A1:F100', was '{Range}'."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -90,7 +96,11 @@ public override async Task ExecuteAsync() ResultValue = new SetAutoFilterResult(true, filterRange.RangeAddress.ToStringRelative()); } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } } catch (Exception ex) { diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs index b65dc93f5..c999512c3 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs @@ -1,5 +1,6 @@ using System.Globalization; using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Spreadsheet.Services; using Celbridge.Workspace; using ClosedXML.Excel; @@ -26,14 +27,12 @@ public SetConditionalFormattingCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -55,9 +54,16 @@ public override async Task ExecuteAsync() return Result.Fail("At least one rule is required when clearExisting is false."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -100,7 +106,11 @@ public override async Task ExecuteAsync() } } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new SetConditionalFormattingResult(Rules.Count, rulesRemoved); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs index f674abf85..69bede5ee 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -25,14 +26,12 @@ public SortRangeCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -48,9 +47,16 @@ public override async Task ExecuteAsync() return Result.Fail($"Range '{Range}' must not include a sheet qualifier."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -80,7 +86,11 @@ public override async Task ExecuteAsync() sortRange.Sort(sortString, XLSortOrder.Ascending, MatchCase, ignoreBlanks: true); - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new SortRangeResult(sortRange.RowCount()); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs index f04b311aa..18ba68e37 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs @@ -1,5 +1,5 @@ using Celbridge.Commands; -using Celbridge.Spreadsheet.Services; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -23,14 +23,12 @@ public WriteCellsCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resolveResult = SpreadsheetHelper.ResolveWorkbookPath(_workspaceWrapper, FileResource); + var resolveResult = await SpreadsheetHelper.ResolveWorkbookResourceAsync(_workspaceWrapper, FileResource); if (resolveResult.IsFailure) { return Result.Fail(resolveResult.FirstErrorMessage); } - var workbookPath = resolveResult.Value; + var workbookResource = resolveResult.Value; if (string.IsNullOrEmpty(Sheet)) { @@ -42,9 +40,16 @@ public override async Task ExecuteAsync() return Result.Fail("At least one edit is required."); } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + if (loadResult.IsFailure) + { + return Result.Fail(loadResult); + } + try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = loadResult.Value; if (!workbook.Worksheets.Contains(Sheet)) { @@ -94,7 +99,11 @@ public override async Task ExecuteAsync() } } - SpreadsheetHelper.RecalculateAndSave(workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + if (saveResult.IsFailure) + { + return Result.Fail(saveResult); + } ResultValue = new WriteCellsResult(Edits.Count); } diff --git a/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs b/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs index 1cd1fd914..117afea16 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs @@ -1,3 +1,4 @@ +using Celbridge.Resources; using Celbridge.Workspace; using ClosedXML.Excel; @@ -11,13 +12,13 @@ internal static class SpreadsheetHelper // values (headless readers, SpreadJS on reload) see fresh results without a // separate recalc step. Per-cell evaluation failures skip the cached value // for that cell but the file still saves. - public static void RecalculateAndSave(XLWorkbook workbook) + public static void RecalculateInto(XLWorkbook workbook, Stream destination) { var saveOptions = new SaveOptions { EvaluateFormulasBeforeSaving = true }; - workbook.Save(saveOptions); + workbook.SaveAs(destination, saveOptions); } // ClosedXML serialises doubles with 15-digit precision, which rounds @@ -53,7 +54,14 @@ public static bool IsRowRange(string range) return range.Split(':').All(part => !string.IsNullOrEmpty(part) && part.All(char.IsDigit)); } - public static Result ResolveWorkbookPath(IWorkspaceWrapper workspaceWrapper, ResourceKey fileResource) + /// + /// Validates that the resource key is a non-empty .xlsx file that exists + /// inside a registered root. Returns the key on success so callers can + /// pass it to subsequent chokepoint operations. + /// + public static async Task> ResolveWorkbookResourceAsync( + IWorkspaceWrapper workspaceWrapper, + ResourceKey fileResource) { if (fileResource.IsEmpty) { @@ -66,20 +74,87 @@ public static Result ResolveWorkbookPath(IWorkspaceWrapper workspaceWrap return Result.Fail($"Resource is not an .xlsx workbook: '{fileResource}'"); } - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) + var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(fileResource); + if (infoResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult); + return Result.Fail($"Failed to inspect workbook: '{fileResource}'") + .WithErrors(infoResult); } - var workbookPath = resolveResult.Value; - if (!File.Exists(workbookPath)) + var info = infoResult.Value; + if (info.Kind == ResourceInfoKind.NotFound) { return Result.Fail($"Workbook file not found: '{fileResource}'"); } + if (info.Kind != ResourceInfoKind.File) + { + return Result.Fail($"Resource is not a file: '{fileResource}'"); + } + + return fileResource; + } + + /// + /// Loads the workbook bytes via the chokepoint and constructs an XLWorkbook + /// from an in-memory copy. The caller owns the returned workbook and must + /// dispose it; the underlying stream is owned by the workbook. + /// + public static async Task> LoadWorkbookAsync( + IResourceFileSystem fileSystem, + ResourceKey fileResource) + { + var bytesResult = await fileSystem.ReadAllBytesAsync(fileResource); + if (bytesResult.IsFailure) + { + return Result.Fail($"Failed to read workbook: '{fileResource}'") + .WithErrors(bytesResult); + } + + try + { + // The workbook holds onto the stream; do not dispose the + // MemoryStream here. ClosedXML closes it when the workbook is + // disposed. + var stream = new MemoryStream(bytesResult.Value, writable: false); + return new XLWorkbook(stream); + } + catch (Exception ex) + { + return Result.Fail($"Failed to open workbook: '{fileResource}'") + .WithException(ex); + } + } + + /// + /// Serialises the workbook to memory and writes it via the chokepoint. + /// Evaluates formulas before saving so cached values stay fresh. + /// + public static async Task SaveWorkbookAsync( + IResourceFileSystem fileSystem, + ResourceKey fileResource, + XLWorkbook workbook) + { + byte[] bytes; + try + { + using var buffer = new MemoryStream(); + RecalculateInto(workbook, buffer); + bytes = buffer.ToArray(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to serialise workbook: '{fileResource}'") + .WithException(ex); + } + + var writeResult = await fileSystem.WriteAllBytesAsync(fileResource, bytes); + if (writeResult.IsFailure) + { + return Result.Fail($"Failed to save workbook: '{fileResource}'") + .WithErrors(writeResult); + } - return workbookPath; + return Result.Ok(); } } diff --git a/Source/Modules/Celbridge.Spreadsheet/Services/SpreadsheetReader.cs b/Source/Modules/Celbridge.Spreadsheet/Services/SpreadsheetReader.cs index c7b42a5a2..15ee225c6 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Services/SpreadsheetReader.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Services/SpreadsheetReader.cs @@ -5,20 +5,20 @@ namespace Celbridge.Spreadsheet.Services; /// -/// ClosedXML-backed implementation of ISpreadsheetReader. Each call opens the -/// workbook fresh from disk so the reader is stateless and safe to register as -/// a singleton. +/// ClosedXML-backed implementation of ISpreadsheetReader. Each call constructs +/// a fresh XLWorkbook from the supplied stream so the reader is stateless and +/// safe to register as a singleton. /// public class SpreadsheetReader : ISpreadsheetReader { private const int DefaultRowLimit = 1000; private const int DefaultColumnLimit = 256; - public Result GetInfo(string workbookPath) + public Result GetInfo(Stream workbookStream) { try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = new XLWorkbook(workbookStream); var sheets = new List(); foreach (var worksheet in workbook.Worksheets) @@ -71,16 +71,16 @@ public Result GetInfo(string workbookPath) } catch (Exception ex) { - return Result.Fail($"Failed to read workbook info from '{workbookPath}'") + return Result.Fail("Failed to read workbook info") .WithException(ex); } } - public Result ReadSheet(string workbookPath, string sheetName, ReadOptions options) + public Result ReadSheet(Stream workbookStream, string sheetName, ReadOptions options) { try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = new XLWorkbook(workbookStream); var worksheetResult = GetWorksheet(workbook, sheetName); if (worksheetResult.IsFailure) @@ -109,16 +109,16 @@ public Result ReadSheet(string workbookPath, string sheetName, ReadO } catch (Exception ex) { - return Result.Fail($"Failed to read sheet '{sheetName}' from '{workbookPath}'") + return Result.Fail($"Failed to read sheet '{sheetName}'") .WithException(ex); } } - public Result ExportCsv(string workbookPath, string sheetName, string? range) + public Result ExportCsv(Stream workbookStream, string sheetName, string? range) { try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = new XLWorkbook(workbookStream); var worksheetResult = GetWorksheet(workbook, sheetName); if (worksheetResult.IsFailure) @@ -163,16 +163,16 @@ public Result ExportCsv(string workbookPath, string sheetName, } catch (Exception ex) { - return Result.Fail($"Failed to export sheet '{sheetName}' as CSV from '{workbookPath}'") + return Result.Fail($"Failed to export sheet '{sheetName}' as CSV") .WithException(ex); } } - public Result GetActiveView(string workbookPath) + public Result GetActiveView(Stream workbookStream) { try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = new XLWorkbook(workbookStream); IXLWorksheet? activeWorksheet = null; foreach (var worksheet in workbook.Worksheets) @@ -189,7 +189,7 @@ public Result GetActiveView(string workbookPath) } if (activeWorksheet is null) { - return Result.Fail($"Workbook '{workbookPath}' has no worksheets."); + return Result.Fail("Workbook has no worksheets."); } string activeCellString; @@ -258,12 +258,12 @@ public Result GetActiveView(string workbookPath) } catch (Exception ex) { - return Result.Fail($"Failed to read active view from '{workbookPath}'") + return Result.Fail("Failed to read active view") .WithException(ex); } } - public Result Find(string workbookPath, FindOptions options) + public Result Find(Stream workbookStream, FindOptions options) { if (string.IsNullOrEmpty(options.Find)) { @@ -282,7 +282,7 @@ public Result Find(string workbookPath, FindOptions options) try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = new XLWorkbook(workbookStream); IEnumerable targetSheets; if (string.IsNullOrEmpty(options.Sheet)) @@ -348,7 +348,7 @@ public Result Find(string workbookPath, FindOptions options) } catch (Exception ex) { - return Result.Fail($"Failed to search workbook '{workbookPath}'") + return Result.Fail("Failed to search workbook") .WithException(ex); } } @@ -390,11 +390,11 @@ private static bool IsMatch(string source, FindOptions options, StringComparison return source.IndexOf(options.Find, comparison) >= 0; } - public Result ReadFormat(string workbookPath, string sheetName, string? range) + public Result ReadFormat(Stream workbookStream, string sheetName, string? range) { try { - using var workbook = new XLWorkbook(workbookPath); + using var workbook = new XLWorkbook(workbookStream); var worksheetResult = GetWorksheet(workbook, sheetName); if (worksheetResult.IsFailure) @@ -437,8 +437,7 @@ public Result ReadFormat(string workbookPath, string sheetName } catch (Exception ex) { - return Result.Fail( - $"Failed to read format from sheet '{sheetName}' in '{workbookPath}'") + return Result.Fail($"Failed to read format from sheet '{sheetName}'") .WithException(ex); } } diff --git a/Source/Tests/Documents/DocumentLayoutStoreTests.cs b/Source/Tests/Documents/DocumentLayoutStoreTests.cs index 46a797eee..20f5f1e11 100644 --- a/Source/Tests/Documents/DocumentLayoutStoreTests.cs +++ b/Source/Tests/Documents/DocumentLayoutStoreTests.cs @@ -1,6 +1,8 @@ using Celbridge.Commands; using Celbridge.Documents.Helpers; +using Celbridge.Messaging; using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Workspace; namespace Celbridge.Tests.Documents; @@ -34,6 +36,7 @@ public void Setup() _workspaceSettings = Substitute.For(); _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); _documentsPanel = Substitute.For(); _commandService = Substitute.For(); @@ -60,6 +63,15 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); + // Wire a real ResourceFileSystem so FileAccessHelper's GetInfoAsync / + // OpenReadAsync calls probe the actual disk paths the registry + // resolves to. + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + _fileAccessHelper = new FileAccessHelper(_workspaceWrapper); _store = new DocumentLayoutStore( diff --git a/Source/Tests/Documents/DocumentViewModelTests.cs b/Source/Tests/Documents/DocumentViewModelTests.cs index 9e9a72c2e..2ed7e7295 100644 --- a/Source/Tests/Documents/DocumentViewModelTests.cs +++ b/Source/Tests/Documents/DocumentViewModelTests.cs @@ -17,6 +17,7 @@ public class DocumentViewModelTests { private IMessengerService _messengerService = null!; private IResourceFileSystem _fileSystem = null!; + private IResourceRegistry _resourceRegistry = null!; private TestDocumentViewModel _vm = null!; private string _tempFolder = null!; private string _tempFilePath = null!; @@ -36,12 +37,12 @@ public void Setup() // whose registry maps the test's resource key to the temp file path. The // layer's atomic write + retry semantics are exercised directly against // the temp folder. - var resourceRegistry = Substitute.For(); - resourceRegistry.ProjectFolderPath.Returns(_tempFolder); - resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(Result.Ok(_tempFilePath)); + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(Result.Ok(_tempFilePath)); var resourceService = Substitute.For(); - resourceService.Registry.Returns(resourceRegistry); + resourceService.Registry.Returns(_resourceRegistry); var workspaceService = Substitute.For(); workspaceService.ResourceService.Returns(resourceService); @@ -89,7 +90,14 @@ public async Task LoadDocument_ReturnsContent_WhenFileExists() [Test] public async Task LoadDocument_ReturnsFailure_WhenFileIsMissing() { - _vm.FilePath = Path.Combine(_tempFolder, "nonexistent.md"); + // Point the registry at a path that doesn't exist on disk so the + // chokepoint-routed read fails. Setting FilePath alone is not enough + // because the read goes through ResolveResourcePath(FileResource). + var missingPath = Path.Combine(_tempFolder, "nonexistent.md"); + _resourceRegistry.ResolveResourcePath(Arg.Any()) + .Returns(Result.Ok(missingPath)); + + _vm.FilePath = missingPath; var result = await _vm.LoadDocument(); @@ -316,14 +324,14 @@ public Task SaveDocumentContent(string text) protected override IResourceFileSystem GetFileSystem() => _fileSystem; - protected override void UpdateFileTrackingInfo() + protected override async Task UpdateFileTrackingInfoAsync() { if (!_hasInjected) { _hasInjected = true; File.WriteAllText(_injectedFilePath, _externalContent); } - base.UpdateFileTrackingInfo(); + await base.UpdateFileTrackingInfoAsync(); } } } diff --git a/Source/Tests/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index b57f6d3ce..4855dee3f 100644 --- a/Source/Tests/Packages/FileTypeProviderTests.cs +++ b/Source/Tests/Packages/FileTypeProviderTests.cs @@ -1,7 +1,10 @@ using Celbridge.Packages; using Celbridge.Messaging; using Celbridge.Modules; +using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Settings; +using Celbridge.Workspace; namespace Celbridge.Tests.Packages; @@ -33,7 +36,31 @@ public void Setup() var messengerService = Substitute.For(); var localizationLogger = Substitute.For>(); var localizationService = new PackageLocalizationService(localizationLogger); - _service = new PackageService(logger, _moduleService, messengerService, _featureFlags, localizationService); + + var resourceRegistry = Substitute.For(); + resourceRegistry.ProjectFolderPath.Returns(_tempProjectFolder); + resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(callInfo => + { + var key = callInfo.Arg(); + return Result.Ok(Path.Combine(_tempProjectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); + }); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + + _service = new PackageService(logger, _moduleService, messengerService, _featureFlags, localizationService, workspaceWrapper); } [TearDown] @@ -54,9 +81,9 @@ public void GetDocumentTypes_NoPackages_ReturnsEmpty() } [Test] - public void GetDocumentTypes_PackageWithTemplates_ReturnsDocumentType() + public async Task GetDocumentTypes_PackageWithTemplates_ReturnsDocumentType() { - CreateBundledPackage( + await CreateBundledPackage( "test-editor", "TestEditor", [(".test", "TestEditor")], @@ -73,9 +100,9 @@ public void GetDocumentTypes_PackageWithTemplates_ReturnsDocumentType() } [Test] - public void GetDocumentTypes_PackageWithoutTemplates_Excluded() + public async Task GetDocumentTypes_PackageWithoutTemplates_Excluded() { - CreateBundledPackage("no-templates", "NoTemplates", [(".notemplate", "NoTemplates")], templates: null); + await CreateBundledPackage("no-templates", "NoTemplates", [(".notemplate", "NoTemplates")], templates: null); var documentTypes = _service.GetDocumentTypes(); @@ -83,9 +110,9 @@ public void GetDocumentTypes_PackageWithoutTemplates_Excluded() } [Test] - public void GetDocumentTypes_WithLocalization_ResolvesDisplayName() + public async Task GetDocumentTypes_WithLocalization_ResolvesDisplayName() { - CreateBundledPackage( + await CreateBundledPackage( "note", "Note", [(".note", "Note_FileType_Note")], @@ -105,9 +132,9 @@ public void GetDocumentTypes_WithLocalization_ResolvesDisplayName() } [Test] - public void GetDocumentTypes_DisabledFeatureFlag_Excluded() + public async Task GetDocumentTypes_DisabledFeatureFlag_Excluded() { - CreateBundledPackage( + await CreateBundledPackage( "flagged-editor", "FlaggedEditor", [(".flagged", "FlaggedEditor")], @@ -125,9 +152,9 @@ public void GetDocumentTypes_DisabledFeatureFlag_Excluded() } [Test] - public void GetDocumentTypes_EnabledFeatureFlag_Included() + public async Task GetDocumentTypes_EnabledFeatureFlag_Included() { - CreateBundledPackage( + await CreateBundledPackage( "flagged-editor", "FlaggedEditor", [(".flagged", "FlaggedEditor")], @@ -145,9 +172,9 @@ public void GetDocumentTypes_EnabledFeatureFlag_Included() } [Test] - public void GetDocumentTypes_MultipleFileExtensions_AllIncluded() + public async Task GetDocumentTypes_MultipleFileExtensions_AllIncluded() { - CreateBundledPackage( + await CreateBundledPackage( "multi-ext", "MultiExt", [(".md", "MultiExt"), (".markdown", "MultiExt")], @@ -165,10 +192,10 @@ public void GetDocumentTypes_MultipleFileExtensions_AllIncluded() } [Test] - public void GetDefaultTemplateContent_PackageWithTemplate_ReturnsContent() + public async Task GetDefaultTemplateContent_PackageWithTemplate_ReturnsContent() { var templateContent = "{\"type\":\"doc\"}"; - CreateBundledPackage( + await CreateBundledPackage( "note", "Note", [(".note", "Note")], @@ -197,9 +224,9 @@ public void GetDefaultTemplateContent_NoMatchingExtension_ReturnsNull() } [Test] - public void GetDefaultTemplateContent_PackageWithoutDefaultTemplate_ReturnsNull() + public async Task GetDefaultTemplateContent_PackageWithoutDefaultTemplate_ReturnsNull() { - CreateBundledPackage( + await CreateBundledPackage( "non-default", "NonDefault", [(".nd", "NonDefault")], @@ -214,10 +241,10 @@ public void GetDefaultTemplateContent_PackageWithoutDefaultTemplate_ReturnsNull( } [Test] - public void GetDefaultTemplateContent_CaseInsensitiveExtension() + public async Task GetDefaultTemplateContent_CaseInsensitiveExtension() { var templateContent = "template content"; - CreateBundledPackage( + await CreateBundledPackage( "case-test", "CaseTest", [(".TEST", "CaseTest")], @@ -236,9 +263,9 @@ public void GetDefaultTemplateContent_CaseInsensitiveExtension() } [Test] - public void GetDefaultTemplateContent_NoProject_StillFindsBundled() + public async Task GetDefaultTemplateContent_NoProject_StillFindsBundled() { - CreateBundledPackage( + await CreateBundledPackage( "orphan", "Orphan", [(".orphan", "Orphan")], @@ -260,7 +287,7 @@ public void GetDefaultTemplateContent_NoProject_StillFindsBundled() /// Helper to create a bundled package folder with TOML manifests and optional files. /// Registers the path with the module service mock and re-discovers packages. /// - private void CreateBundledPackage( + private async Task CreateBundledPackage( string dirName, string packageName, (string Extension, string DisplayName)[] fileTypes, @@ -340,6 +367,6 @@ private void CreateBundledPackage( } _bundledPackagePaths.Add(packageDir); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); } } diff --git a/Source/Tests/Packages/PackageRegistryTests.cs b/Source/Tests/Packages/PackageRegistryTests.cs index 5f5fcb640..7d6c0b01d 100644 --- a/Source/Tests/Packages/PackageRegistryTests.cs +++ b/Source/Tests/Packages/PackageRegistryTests.cs @@ -2,7 +2,10 @@ using Celbridge.Messaging; using Celbridge.Modules; using Celbridge.Packages; +using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Settings; +using Celbridge.Workspace; namespace Celbridge.Tests.Packages; @@ -13,6 +16,7 @@ public class PackageServiceTests private PackageService _service = null!; private IModuleService _moduleService = null!; private IMessengerService _messengerService = null!; + private IResourceRegistry _resourceRegistry = null!; [SetUp] public void Setup() @@ -28,7 +32,31 @@ public void Setup() _moduleService.GetBundledPackages().Returns(new List()); var featureFlags = Substitute.For(); featureFlags.IsEnabled(Arg.Any()).Returns(true); - _service = new PackageService(logger, _moduleService, _messengerService, featureFlags, localizationService); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempProjectFolder); + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(callInfo => + { + var key = callInfo.Arg(); + return Result.Ok(Path.Combine(_tempProjectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); + }); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + + _service = new PackageService(logger, _moduleService, _messengerService, featureFlags, localizationService, workspaceWrapper); } [TearDown] @@ -41,29 +69,29 @@ public void TearDown() } [Test] - public void RegisterPackages_NoPackagesFolder_ReturnsEmpty() + public async Task RegisterPackages_NoPackagesFolder_ReturnsEmpty() { - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } [Test] - public void RegisterPackages_EmptyPackagesFolder_ReturnsEmpty() + public async Task RegisterPackages_EmptyPackagesFolder_ReturnsEmpty() { Directory.CreateDirectory(Path.Combine(_tempProjectFolder, "packages")); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } [Test] - public void RegisterPackages_ValidManifest_ReturnsManifest() + public async Task RegisterPackages_ValidManifest_ReturnsManifest() { CreateProjectPackage("my-editor", "my-editor", "My Editor", "custom", ".myext"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -72,12 +100,12 @@ public void RegisterPackages_ValidManifest_ReturnsManifest() } [Test] - public void RegisterPackages_MultiplePackages_ReturnsAll() + public async Task RegisterPackages_MultiplePackages_ReturnsAll() { CreateProjectPackage("editor-a", "editor-a", "Editor A", "custom", ".a"); CreateProjectPackage("editor-b", "editor-b", "Editor B", "code", ".b"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(2); @@ -87,7 +115,7 @@ public void RegisterPackages_MultiplePackages_ReturnsAll() } [Test] - public void RegisterPackages_InvalidManifest_SkipsAndContinues() + public async Task RegisterPackages_InvalidManifest_SkipsAndContinues() { CreateProjectPackage("good", "good", "Good", "custom", ".good"); @@ -96,7 +124,7 @@ public void RegisterPackages_InvalidManifest_SkipsAndContinues() Directory.CreateDirectory(badDir); File.WriteAllText(Path.Combine(badDir, "package.cel"), "{ invalid toml }"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -104,7 +132,7 @@ public void RegisterPackages_InvalidManifest_SkipsAndContinues() } [Test] - public void RegisterPackages_FolderWithoutManifest_IsSkipped() + public async Task RegisterPackages_FolderWithoutManifest_IsSkipped() { CreateProjectPackage("with-manifest", "with-manifest", "Found", "code", ".found"); @@ -112,7 +140,7 @@ public void RegisterPackages_FolderWithoutManifest_IsSkipped() var folderWithoutManifest = Path.Combine(_tempProjectFolder, "packages", "no-manifest"); Directory.CreateDirectory(folderWithoutManifest); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -120,13 +148,13 @@ public void RegisterPackages_FolderWithoutManifest_IsSkipped() } [Test] - public void RegisterPackages_IncludesModulePackages() + public async Task RegisterPackages_IncludesModulePackages() { var bundledDir = CreateBundledPackage("bundled-editor", "celbridge.bundled", "Bundled", "custom", ".bnd"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -134,14 +162,14 @@ public void RegisterPackages_IncludesModulePackages() } [Test] - public void RegisterPackages_CombinesProjectAndBundled() + public async Task RegisterPackages_CombinesProjectAndBundled() { CreateProjectPackage("proj-editor", "proj", "Project", "custom", ".proj"); var bundledDir = CreateBundledPackage("bundled-editor", "celbridge.bundled", "Bundled", "custom", ".bnd"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(2); @@ -151,13 +179,13 @@ public void RegisterPackages_CombinesProjectAndBundled() } [Test] - public void RegisterPackages_ProjectPackageWithReservedIdPrefix_Skipped() + public async Task RegisterPackages_ProjectPackageWithReservedIdPrefix_Skipped() { // Project packages may not claim an id under the reserved "celbridge." namespace. CreateProjectPackage("impostor", "celbridge.notes", "Impostor Notes", "custom", ".imp"); CreateProjectPackage("legit", "legit", "Legit", "custom", ".legit"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -165,20 +193,20 @@ public void RegisterPackages_ProjectPackageWithReservedIdPrefix_Skipped() } [Test] - public void RegisterPackages_ProjectPackageWithMixedCaseId_RejectedByFormatValidation() + public async Task RegisterPackages_ProjectPackageWithMixedCaseId_RejectedByFormatValidation() { // Package ids are lowercase-only. A mixed-case id fails manifest validation // before the reserved-prefix check runs, so "Celbridge.Something" cannot be // used as a workaround for the prefix block. CreateProjectPackage("mixed-case", "Celbridge.Something", "Mixed Case", "custom", ".mc"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } [Test] - public void RegisterPackages_ProjectPackageWithDottedId_Skipped() + public async Task RegisterPackages_ProjectPackageWithDottedId_Skipped() { // Until a namespace registry exists, project packages cannot claim a // dotted id because there is no way to validate namespace ownership. @@ -186,7 +214,7 @@ public void RegisterPackages_ProjectPackageWithDottedId_Skipped() CreateProjectPackage("dotted", "acme.tool", "Dotted", "custom", ".dot"); CreateProjectPackage("flat", "legit-tool", "Flat", "custom", ".flat"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -194,14 +222,14 @@ public void RegisterPackages_ProjectPackageWithDottedId_Skipped() } [Test] - public void RegisterPackages_BundledPackageWithReservedIdPrefix_Allowed() + public async Task RegisterPackages_BundledPackageWithReservedIdPrefix_Allowed() { // Bundled packages are the intended owners of the "celbridge." namespace. var bundledDir = CreateBundledPackage("bundled-official", "celbridge.notes", "Official Notes", "custom", ".note"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -209,7 +237,7 @@ public void RegisterPackages_BundledPackageWithReservedIdPrefix_Allowed() } [Test] - public void RegisterPackages_TwoBundledPackagesSameId_BothSkipped() + public async Task RegisterPackages_TwoBundledPackagesSameId_BothSkipped() { // Two bundled packages with the same id is a first-party build bug. // Both are skipped rather than silently picking a winner. @@ -222,13 +250,13 @@ public void RegisterPackages_TwoBundledPackagesSameId_BothSkipped() new() { Folder = dirB } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } [Test] - public void RegisterPackages_ProjectPackageConflictsWithBundled_ProjectSkipped() + public async Task RegisterPackages_ProjectPackageConflictsWithBundled_ProjectSkipped() { // Bundled wins over project when ids collide. Both use flat ids here // so the collision check is what rejects the project package, not the @@ -238,7 +266,7 @@ public void RegisterPackages_ProjectPackageConflictsWithBundled_ProjectSkipped() _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -246,7 +274,7 @@ public void RegisterPackages_ProjectPackageConflictsWithBundled_ProjectSkipped() } [Test] - public void RegisterPackages_TwoProjectPackagesSameId_BothSkipped() + public async Task RegisterPackages_TwoProjectPackagesSameId_BothSkipped() { // Two project packages with the same id cannot be distinguished so // both are skipped. A non-colliding sibling continues to load. @@ -254,7 +282,7 @@ public void RegisterPackages_TwoProjectPackagesSameId_BothSkipped() CreateProjectPackage("dup-b", "dup-tool", "Dup B", "custom", ".b"); CreateProjectPackage("legit", "other-tool", "Legit", "custom", ".legit"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -262,28 +290,28 @@ public void RegisterPackages_TwoProjectPackagesSameId_BothSkipped() } [Test] - public void RegisterPackages_LoadFailures_SendPackageLoadErrorMessage() + public async Task RegisterPackages_LoadFailures_SendPackageLoadErrorMessage() { CreateProjectPackage("dup-a", "dup-tool", "Dup A", "custom", ".a"); CreateProjectPackage("dup-b", "dup-tool", "Dup B", "custom", ".b"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _messengerService.Received(1).Send(Arg.Is(m => m.ErrorType == ConsoleErrorType.PackageLoadError)); } [Test] - public void RegisterPackages_NoFailures_DoesNotSendPackageLoadErrorMessage() + public async Task RegisterPackages_NoFailures_DoesNotSendPackageLoadErrorMessage() { CreateProjectPackage("legit", "legit", "Legit", "custom", ".legit"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _messengerService.DidNotReceive().Send(Arg.Is(m => m.ErrorType == ConsoleErrorType.PackageLoadError)); } [Test] - public void RegisterPackages_InvalidBundledManifestSkipped() + public async Task RegisterPackages_InvalidBundledManifestSkipped() { var bundledDir = Path.Combine(_tempProjectFolder, "bad-bundled"); Directory.CreateDirectory(bundledDir); @@ -291,30 +319,30 @@ public void RegisterPackages_InvalidBundledManifestSkipped() _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } [Test] - public void RegisterPackages_MissingBundledManifestSkipped() + public async Task RegisterPackages_MissingBundledManifestSkipped() { var bundledDir = Path.Combine(_tempProjectFolder, "no-manifest-bundled"); Directory.CreateDirectory(bundledDir); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } [Test] - public void RegisterPackages_ClearsPreviousContributions() + public async Task RegisterPackages_ClearsPreviousContributions() { CreateProjectPackage("editor-a", "editor-a", "Editor A", "custom", ".a"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); _service.GetAllDocumentEditors().Should().HaveCount(1); // Create a second temp folder with different packages @@ -323,8 +351,17 @@ public void RegisterPackages_ClearsPreviousContributions() { Directory.CreateDirectory(secondFolder); + // Repoint the workspace-bound chokepoint at the second folder so the + // second discovery probes secondFolder/packages instead of the original. + _resourceRegistry.ProjectFolderPath.Returns(secondFolder); + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(callInfo => + { + var key = callInfo.Arg(); + return Result.Ok(Path.Combine(secondFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); + }); + // Discover from empty folder - should clear previous contributions - _service.RegisterPackages(secondFolder); + await _service.RegisterPackagesAsync(secondFolder); _service.GetAllDocumentEditors().Should().BeEmpty(); } finally @@ -337,12 +374,12 @@ public void RegisterPackages_ClearsPreviousContributions() } [Test] - public void GetContributingPackage_KnownEditorId_ReturnsThePackage() + public async Task GetContributingPackage_KnownEditorId_ReturnsThePackage() { var bundledDir = CreateBundledPackage("notes-pkg", "celbridge.notes", "Notes", "custom", ".note"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); // CustomDocumentViewFactory builds editor IDs as "{packageId}.{contributionId}". // The contributionId comes from the [document] table key in package.cel, @@ -356,10 +393,10 @@ public void GetContributingPackage_KnownEditorId_ReturnsThePackage() } [Test] - public void GetContributingPackage_UnknownEditorId_ReturnsNull() + public async Task GetContributingPackage_UnknownEditorId_ReturnsNull() { CreateProjectPackage("known", "known", "Known", "custom", ".known"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var package = _service.GetContributingPackage(new DocumentEditorId("thirdparty.binary-editor")); @@ -367,7 +404,7 @@ public void GetContributingPackage_UnknownEditorId_ReturnsNull() } [Test] - public void GetContributingPackage_DistinguishesPackagesWithDottedIdPrefixes() + public async Task GetContributingPackage_DistinguishesPackagesWithDottedIdPrefixes() { // A naive split-on-first-dot would mismatch "celbridge.notes.custom" against // a package whose id is just "celbridge". The lookup must match the longest @@ -375,7 +412,7 @@ public void GetContributingPackage_DistinguishesPackagesWithDottedIdPrefixes() var notesDir = CreateBundledPackage("notes-pkg", "celbridge.notes", "Notes", "custom", ".note"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = notesDir } }); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var package = _service.GetContributingPackage(new DocumentEditorId("celbridge.notes.custom")); diff --git a/Source/Tests/Resources/ResourceFileSystemTests.cs b/Source/Tests/Resources/ResourceFileSystemTests.cs index eefa57ea5..6116f456f 100644 --- a/Source/Tests/Resources/ResourceFileSystemTests.cs +++ b/Source/Tests/Resources/ResourceFileSystemTests.cs @@ -256,40 +256,87 @@ public async Task OpenWriteAsync_CreatesParentFolder() } [Test] - public async Task ExistsAsync_ReturnsTrue_WhenFilePresent() + public async Task GetInfoAsync_ReturnsFile_WithSizeAndModifiedUtc_WhenFilePresent() { - var resource = new ResourceKey("present.txt"); - var path = Path.Combine(_tempFolder, "present.txt"); - await File.WriteAllTextAsync(path, "content"); + var resource = new ResourceKey("present.bin"); + var path = Path.Combine(_tempFolder, "present.bin"); + var bytes = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; + await File.WriteAllBytesAsync(path, bytes); + var expectedModifiedUtc = File.GetLastWriteTimeUtc(path); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.ExistsAsync(resource); + var result = await _fileSystem.GetInfoAsync(resource); result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeTrue(); + var info = result.Value; + info.Kind.Should().Be(ResourceInfoKind.File); + info.Size.Should().Be(bytes.Length); + info.ModifiedUtc.Should().Be(expectedModifiedUtc); } [Test] - public async Task ExistsAsync_ReturnsFalse_WhenFileMissing() + public async Task GetInfoAsync_ReturnsFolder_WhenFolderPresent() + { + var resource = new ResourceKey("nested"); + var folderPath = Path.Combine(_tempFolder, "nested"); + Directory.CreateDirectory(folderPath); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(folderPath)); + + var result = await _fileSystem.GetInfoAsync(resource); + + result.IsSuccess.Should().BeTrue(); + var info = result.Value; + info.Kind.Should().Be(ResourceInfoKind.Folder); + info.Size.Should().Be(0); + info.ModifiedUtc.Should().NotBe(default); + } + + [Test] + public async Task GetInfoAsync_ReturnsNotFound_WhenResourceMissing() { var resource = new ResourceKey("missing.txt"); var path = Path.Combine(_tempFolder, "missing.txt"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.ExistsAsync(resource); + var result = await _fileSystem.GetInfoAsync(resource); + + result.IsSuccess.Should().BeTrue(); + var info = result.Value; + info.Kind.Should().Be(ResourceInfoKind.NotFound); + info.Size.Should().Be(0); + info.ModifiedUtc.Should().Be(default); + } + + [Test] + public async Task GetInfoAsync_ResolvesViaRegistry_ForNonDefaultRoot() + { + // Non-default-root callers route through IResourceRegistry the same way + // default-root callers do: the chokepoint hands the key off, the + // registry resolves it to an absolute path, and the on-disk probe is + // identical. This test pins the contract end-to-end against a temp: + // key so a future regression in the resolution wiring surfaces here. + var resource = new ResourceKey("temp:scratch.txt"); + var stagingFolder = Path.Combine(_tempFolder, ".celbridge", "scratch"); + Directory.CreateDirectory(stagingFolder); + var path = Path.Combine(stagingFolder, "scratch.txt"); + await File.WriteAllTextAsync(path, "scratch"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _fileSystem.GetInfoAsync(resource); result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeFalse(); + result.Value.Kind.Should().Be(ResourceInfoKind.File); + result.Value.Size.Should().Be("scratch".Length); } [Test] - public async Task ExistsAsync_ReturnsFailure_WhenResolveFails() + public async Task GetInfoAsync_ReturnsFailure_WhenResolveFails() { var resource = new ResourceKey("bad.txt"); _resourceRegistry.ResolveResourcePath(resource) .Returns(Result.Fail("simulated resolve failure")); - var result = await _fileSystem.ExistsAsync(resource); + var result = await _fileSystem.GetInfoAsync(resource); result.IsFailure.Should().BeTrue(); } diff --git a/Source/Tests/Resources/SidecarPairingServiceTests.cs b/Source/Tests/Resources/SidecarPairingServiceTests.cs index dbe21e613..78468db51 100644 --- a/Source/Tests/Resources/SidecarPairingServiceTests.cs +++ b/Source/Tests/Resources/SidecarPairingServiceTests.cs @@ -58,12 +58,7 @@ public void StandaloneCelWithMultiPartExtensionRegistration_IsNotReportedAsOrpha "[note]\ntitle = \"Hello\"\n"); var editorRegistry = Substitute.For(); - editorRegistry.GetFactory( - Arg.Is(k => k.ToString().EndsWith("feature.note.cel"))) - .Returns(Result.Ok(Substitute.For())); - editorRegistry.GetFactory( - Arg.Is(k => !k.ToString().EndsWith("feature.note.cel"))) - .Returns(Result.Fail("no factory")); + editorRegistry.IsExtensionSupported(".note.cel").Returns(true); var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); var registry = BuildRegistry(pairingService); @@ -85,12 +80,7 @@ public void StandaloneCelWithFilenameOnlyRegistration_IsNotReportedAsOrphan() "[package]\nid = \"acme\"\nname = \"Acme\"\nversion = \"1.0.0\"\n"); var editorRegistry = Substitute.For(); - editorRegistry.GetFactory( - Arg.Is(k => k.ToString().EndsWith("package.cel"))) - .Returns(Result.Ok(Substitute.For())); - editorRegistry.GetFactory( - Arg.Is(k => !k.ToString().EndsWith("package.cel"))) - .Returns(Result.Fail("no factory")); + editorRegistry.IsFilenameSupported("package.cel").Returns(true); var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); var registry = BuildRegistry(pairingService); @@ -101,6 +91,28 @@ public void StandaloneCelWithFilenameOnlyRegistration_IsNotReportedAsOrphan() report.Healthy.Should().Contain(new ResourceKey("package.cel")); } + [Test] + public void BareCelExtensionRegistration_DoesNotPreventOrphanReport() + { + // The ".cel" extension is also registered as a generic code-editor + // language (for syntax highlighting), and that registration must not + // be treated as evidence of a standalone .cel form. A parentless + // ".cel" whose only matching registration is the bare extension is a + // true orphan and must appear in the report. + File.WriteAllText(Path.Combine(_projectFolderPath, "orphaned.png.cel"), + "tags = [\"orphan\"]\n"); + + var editorRegistry = Substitute.For(); + editorRegistry.IsExtensionSupported(".cel").Returns(true); + + var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); + var registry = BuildRegistry(pairingService); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Orphan.Should().Contain(new ResourceKey("orphaned.png.cel")); + } + [Test] public void OrphanCelWithNoFactoryClaim_IsStillReportedAsOrphan() { @@ -130,14 +142,14 @@ public void ParentedSidecar_IsNeverConsultedAgainstEditorRegistry() "tags = [\"x\"]\n"); var editorRegistry = Substitute.For(); - editorRegistry.GetFactory(Arg.Any()) - .Returns(Result.Fail("no factory")); + editorRegistry.IsFilenameSupported(Arg.Any()).Returns(false); + editorRegistry.IsExtensionSupported(Arg.Any()).Returns(false); var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); var registry = BuildRegistry(pairingService); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - editorRegistry.DidNotReceive().GetFactory(new ResourceKey("foo.png.cel")); + editorRegistry.DidNotReceive().IsFilenameSupported("foo.png.cel"); var report = registry.GetSidecarReport(); report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); diff --git a/Source/Tests/Resources/SidecarPairingTestHelper.cs b/Source/Tests/Resources/SidecarPairingTestHelper.cs index 6cdbe8870..97f23939c 100644 --- a/Source/Tests/Resources/SidecarPairingTestHelper.cs +++ b/Source/Tests/Resources/SidecarPairingTestHelper.cs @@ -41,9 +41,10 @@ public static ISidecarPairingService BuildEmptyStub() /// public static SidecarPairingService BuildPairingServiceWithNoFactories() { + // NSubstitute returns false for unconfigured bool methods, so the + // standalone-form check naturally returns "no match" without any + // explicit stubbing. var editorRegistry = Substitute.For(); - editorRegistry.GetFactory(Arg.Any()) - .Returns(Result.Fail("no factory")); return BuildPairingService(editorRegistry); } diff --git a/Source/Tests/Resources/SidecarServiceTests.cs b/Source/Tests/Resources/SidecarServiceTests.cs index 43dae1148..48d4ed072 100644 --- a/Source/Tests/Resources/SidecarServiceTests.cs +++ b/Source/Tests/Resources/SidecarServiceTests.cs @@ -22,8 +22,8 @@ public void Setup() { _fileSystem = Substitute.For(); // Default: nothing exists on disk. Tests opt-in per resource. - _fileSystem.ExistsAsync(Arg.Any()) - .Returns(Task.FromResult(Result.Ok(false))); + _fileSystem.GetInfoAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.NotFound, 0, default)))); var workspaceService = Substitute.For(); workspaceService.ResourceFileSystem.Returns(_fileSystem); @@ -52,8 +52,8 @@ public async Task ReadAsync_ReadsSiblingSidecar_ForRegularFile() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.ExistsAsync(siblingSidecar) - .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.GetInfoAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); _fileSystem.ReadAllTextAsync(siblingSidecar) .Returns(Task.FromResult(Result.Ok("editor = \"acme.binary-editor\"\n"))); @@ -73,8 +73,8 @@ public async Task ReadAsync_ReadsFileItself_ForStandaloneCelFile() // bogus .cel.cel key). var standaloneCel = new ResourceKey("design.widget.cel"); - _fileSystem.ExistsAsync(standaloneCel) - .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.GetInfoAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); _fileSystem.ReadAllTextAsync(standaloneCel) .Returns(Task.FromResult(Result.Ok("editor = \"celbridge.code-editor.code-document\"\n"))); @@ -85,7 +85,7 @@ public async Task ReadAsync_ReadsFileItself_ForStandaloneCelFile() readResult.Value.Content!.Frontmatter["editor"].Should().Be("celbridge.code-editor.code-document"); // Belt-and-braces: the bogus .cel.cel key must never be touched. - await _fileSystem.DidNotReceive().ExistsAsync(new ResourceKey("design.widget.cel.cel")); + await _fileSystem.DidNotReceive().GetInfoAsync(new ResourceKey("design.widget.cel.cel")); await _fileSystem.DidNotReceive().ReadAllTextAsync(new ResourceKey("design.widget.cel.cel")); } @@ -145,8 +145,8 @@ public async Task SetFieldAsync_PreservesExistingContent_ForStandaloneCelFile() var standaloneCel = new ResourceKey("design.widget.cel"); var existingContent = "title = \"My Design\"\nversion = 1\n"; - _fileSystem.ExistsAsync(standaloneCel) - .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.GetInfoAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); _fileSystem.ReadAllTextAsync(standaloneCel) .Returns(Task.FromResult(Result.Ok(existingContent))); @@ -176,8 +176,8 @@ public async Task SetFieldAsync_SkipsWrite_WhenValueMatchesExisting() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.ExistsAsync(siblingSidecar) - .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.GetInfoAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); _fileSystem.ReadAllTextAsync(siblingSidecar) .Returns(Task.FromResult(Result.Ok("editor = \"acme.binary-editor\"\n"))); @@ -196,8 +196,8 @@ public async Task AddTagAsync_SkipsWrite_WhenTagAlreadyPresent() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.ExistsAsync(siblingSidecar) - .Returns(Task.FromResult(Result.Ok(true))); + _fileSystem.GetInfoAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); _fileSystem.ReadAllTextAsync(siblingSidecar) .Returns(Task.FromResult(Result.Ok("tags = [\"hero\", \"sprite\"]\n"))); diff --git a/Source/Tests/Search/FileFilterTests.cs b/Source/Tests/Search/FileFilterTests.cs index 11bb133a7..9458d82d8 100644 --- a/Source/Tests/Search/FileFilterTests.cs +++ b/Source/Tests/Search/FileFilterTests.cs @@ -1,5 +1,8 @@ +using Celbridge.Messaging; +using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Search; -using Celbridge.Utilities; +using Celbridge.Workspace; namespace Celbridge.Tests.Search; @@ -7,6 +10,8 @@ namespace Celbridge.Tests.Search; public class FileFilterTests { private FileFilter _filter = null!; + private IResourceFileSystem _fileSystem = null!; + private IResourceRegistry _resourceRegistry = null!; private string _testDir = null!; [SetUp] @@ -15,6 +20,24 @@ public void SetUp() _filter = new FileFilter(new TextBinarySniffer()); _testDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDir); + + // Wire a real ResourceFileSystem so size + existence probes hit disk. + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_testDir); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); } [TearDown] @@ -26,91 +49,99 @@ public void TearDown() } } + private (ResourceKey Resource, string Path) MakeResource(string name) + { + var resource = new ResourceKey(name); + var path = Path.Combine(_testDir, name); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + return (resource, path); + } + [Test] - public void ShouldSearchFile_RegularTextFile_ReturnsTrue() + public async Task ShouldSearchFile_RegularTextFile_ReturnsTrue() { - var filePath = Path.Combine(_testDir, "test.txt"); + var (resource, filePath) = MakeResource("test.txt"); File.WriteAllText(filePath, "test content"); - _filter.ShouldSearchFile(filePath).Should().BeTrue(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeTrue(); } [Test] - public void ShouldSearchFile_NonExistentFile_ReturnsFalse() + public async Task ShouldSearchFile_NonExistentFile_ReturnsFalse() { - var filePath = Path.Combine(_testDir, "nonexistent.txt"); + var (resource, filePath) = MakeResource("nonexistent.txt"); - _filter.ShouldSearchFile(filePath).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); } [Test] - public void ShouldSearchFile_MetadataExtension_ReturnsFalse() + public async Task ShouldSearchFile_MetadataExtension_ReturnsFalse() { - var filePath = Path.Combine(_testDir, "test.celbridge"); + var (resource, filePath) = MakeResource("test.celbridge"); File.WriteAllText(filePath, "metadata"); - _filter.ShouldSearchFile(filePath).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); } [Test] - public void ShouldSearchFile_CelExtension_ReturnsFalse() + public async Task ShouldSearchFile_CelExtension_ReturnsFalse() { // .cel files (sidecars and standalone manifests) are excluded from // plain-text search because their content is editor-owned and a // plain-text replace would corrupt the file structure. - var filePath = Path.Combine(_testDir, "test.webview.cel"); + var (resource, filePath) = MakeResource("test.webview.cel"); File.WriteAllText(filePath, "source_url = \"https://example.com\"\n"); - _filter.ShouldSearchFile(filePath).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); } [Test] - public void ShouldSearchFile_BinaryExtension_ReturnsFalse() + public async Task ShouldSearchFile_BinaryExtension_ReturnsFalse() { - var filePath = Path.Combine(_testDir, "test.exe"); + var (resource, filePath) = MakeResource("test.exe"); File.WriteAllBytes(filePath, new byte[] { 0x00, 0x01, 0x02 }); - _filter.ShouldSearchFile(filePath).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); } [Test] - public void ShouldSearchFile_ImageExtension_ReturnsFalse() + public async Task ShouldSearchFile_ImageExtension_ReturnsFalse() { - var filePath = Path.Combine(_testDir, "test.png"); + var (resource, filePath) = MakeResource("test.png"); File.WriteAllBytes(filePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); - _filter.ShouldSearchFile(filePath).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); } [Test] - public void ShouldSearchFile_CSharpFile_ReturnsTrue() + public async Task ShouldSearchFile_CSharpFile_ReturnsTrue() { - var filePath = Path.Combine(_testDir, "Test.cs"); + var (resource, filePath) = MakeResource("Test.cs"); File.WriteAllText(filePath, "public class Test { }"); - _filter.ShouldSearchFile(filePath).Should().BeTrue(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeTrue(); } [Test] - public void ShouldSearchFile_MarkdownFile_ReturnsTrue() + public async Task ShouldSearchFile_MarkdownFile_ReturnsTrue() { - var filePath = Path.Combine(_testDir, "README.md"); + var (resource, filePath) = MakeResource("README.md"); File.WriteAllText(filePath, "# Readme"); - _filter.ShouldSearchFile(filePath).Should().BeTrue(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeTrue(); } [Test] - public void ShouldSearchFile_LargeFile_ReturnsFalse() + public async Task ShouldSearchFile_LargeFile_ReturnsFalse() { - var filePath = Path.Combine(_testDir, "large.txt"); + var (resource, filePath) = MakeResource("large.txt"); // Create a file larger than 1MB using (var fs = File.Create(filePath)) { fs.SetLength(1024 * 1024 + 1); } - _filter.ShouldSearchFile(filePath).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); } [Test] diff --git a/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs b/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs index 4d80c02c2..b80320874 100644 --- a/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs +++ b/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs @@ -1,4 +1,6 @@ +using Celbridge.Messaging; using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Spreadsheet; using Celbridge.Spreadsheet.Commands; using Celbridge.Spreadsheet.Services; @@ -34,6 +36,12 @@ public void SetUp() _workbookResource = new ResourceKey(WorkbookResourceName); _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(callInfo => + { + var key = callInfo.Arg(); + return Result.Ok(Path.Combine(_tempFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); + }); _resourceRegistry.ResolveResourcePath(_workbookResource).Returns(Result.Ok(_workbookPath)); var resourceService = Substitute.For(); @@ -44,6 +52,12 @@ public void SetUp() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); } [TearDown] @@ -1692,7 +1706,7 @@ public async Task SetActiveView_TopLeftCellA1_RoundTripsThroughGet() setResult.IsSuccess.Should().BeTrue(); var reader = new Celbridge.Spreadsheet.Services.SpreadsheetReader(); - var viewResult = reader.GetActiveView(_workbookPath); + var viewResult = reader.GetActiveView(new MemoryStream(File.ReadAllBytes(_workbookPath))); viewResult.IsSuccess.Should().BeTrue(); var view = viewResult.Value; view.Sheet.Should().Be("Summary"); @@ -1926,7 +1940,7 @@ public async Task SetActiveView_MultiRange_RoundTripsThroughGet() setResult.IsSuccess.Should().BeTrue(); var reader = new SpreadsheetReader(); - var viewResult = reader.GetActiveView(_workbookPath); + var viewResult = reader.GetActiveView(new MemoryStream(File.ReadAllBytes(_workbookPath))); viewResult.IsSuccess.Should().BeTrue(); var view = viewResult.Value; view.Range.Should().Be("A7:B8"); @@ -1955,7 +1969,7 @@ public async Task SetActiveView_RangesPreferredOverSingleRange() result.IsSuccess.Should().BeTrue(); var reader = new SpreadsheetReader(); - var viewResult = reader.GetActiveView(_workbookPath); + var viewResult = reader.GetActiveView(new MemoryStream(File.ReadAllBytes(_workbookPath))); viewResult.Value.Ranges.Should().Equal("A1:B2", "D5:E6"); } diff --git a/Source/Tests/Spreadsheet/SpreadsheetReaderTests.cs b/Source/Tests/Spreadsheet/SpreadsheetReaderTests.cs index c6e5737e7..ad3132f07 100644 --- a/Source/Tests/Spreadsheet/SpreadsheetReaderTests.cs +++ b/Source/Tests/Spreadsheet/SpreadsheetReaderTests.cs @@ -48,7 +48,7 @@ public void GetInfo_ReturnsSheetsAndUsedRange() workbook.Worksheets.Add("Empty"); }); - var result = _reader.GetInfo(workbookPath); + var result = _reader.GetInfo(OpenWorkbook(workbookPath)); result.IsSuccess.Should().BeTrue(); var info = result.Value; @@ -81,7 +81,7 @@ public void ReadSheet_ReturnsRowArrays() sheet.Cell("B2").Value = 100; }); - var result = _reader.ReadSheet(workbookPath, "Q1", new ReadOptions()); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Q1", new ReadOptions()); result.IsSuccess.Should().BeTrue(); var read = result.Value; @@ -112,7 +112,7 @@ public void ReadSheet_HeadersMode_ReturnsRowDictionaries() }); var options = new ReadOptions(Headers: true); - var result = _reader.ReadSheet(workbookPath, "Q1", options); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Q1", options); result.IsSuccess.Should().BeTrue(); var read = result.Value; @@ -140,7 +140,7 @@ public void ReadSheet_HeadersMode_DisambiguatesDuplicatesAndEmptyHeaders() }); var options = new ReadOptions(Headers: true); - var result = _reader.ReadSheet(workbookPath, "Sheet1", options); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Sheet1", options); result.IsSuccess.Should().BeTrue(); var read = result.Value; @@ -164,7 +164,7 @@ public void ReadSheet_FormulasMode_ReturnsFormulaText() }); var options = new ReadOptions(Mode: SpreadsheetReadMode.Formulas); - var result = _reader.ReadSheet(workbookPath, "Q1", options); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Q1", options); result.IsSuccess.Should().BeTrue(); var read = result.Value; @@ -180,7 +180,7 @@ public void ReadSheet_EmptySheet_ReturnsEmptyRowsAndZeroTotal() workbook.Worksheets.Add("Empty"); }); - var result = _reader.ReadSheet(workbookPath, "Empty", new ReadOptions()); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Empty", new ReadOptions()); result.IsSuccess.Should().BeTrue(); result.Value.Rows.Should().BeEmpty(); @@ -195,7 +195,7 @@ public void ReadSheet_MissingSheet_ReturnsFailure() workbook.Worksheets.Add("Sheet1"); }); - var result = _reader.ReadSheet(workbookPath, "Missing", new ReadOptions()); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Missing", new ReadOptions()); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Missing"); @@ -211,7 +211,7 @@ public void ReadSheet_RangeWithSheetQualifier_ReturnsFailure() }); var options = new ReadOptions(Range: "Q1!A1:B2"); - var result = _reader.ReadSheet(workbookPath, "Q1", options); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Q1", options); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("sheet qualifier"); @@ -230,7 +230,7 @@ public void ReadSheet_OffsetAndLimitPageThroughRows() }); var options = new ReadOptions(Offset: 3, Limit: 4); - var result = _reader.ReadSheet(workbookPath, "Q1", options); + var result = _reader.ReadSheet(OpenWorkbook(workbookPath), "Q1", options); result.IsSuccess.Should().BeTrue(); var read = result.Value; @@ -254,7 +254,7 @@ public void ExportCsv_RoundTripsValuesAndQuotesSpecialFields() sheet.Cell("B3").Value = "line1\nline2"; }); - var result = _reader.ExportCsv(workbookPath, "Q1", null); + var result = _reader.ExportCsv(OpenWorkbook(workbookPath), "Q1", null); result.IsSuccess.Should().BeTrue(); var csvResult = result.Value; @@ -275,7 +275,7 @@ public void ExportCsv_EmptySheet_ReturnsEmptyResult() workbook.Worksheets.Add("Empty"); }); - var result = _reader.ExportCsv(workbookPath, "Empty", null); + var result = _reader.ExportCsv(OpenWorkbook(workbookPath), "Empty", null); result.IsSuccess.Should().BeTrue(); var csvResult = result.Value; @@ -296,7 +296,7 @@ public void ReadFormat_ReturnsFormatForFormattedCell() sheet.Cell("A1").Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; }); - var result = _reader.ReadFormat(workbookPath, "Data", "A1"); + var result = _reader.ReadFormat(OpenWorkbook(workbookPath), "Data", "A1"); result.IsSuccess.Should().BeTrue(); result.Value.Range.Should().Be("Data!A1:A1"); @@ -317,7 +317,7 @@ public void ReadFormat_UnformattedCell_EmitsClearSentinelsForRoundTrip() sheet.Cell("A1").Value = "plain"; }); - var result = _reader.ReadFormat(workbookPath, "Data", "A1"); + var result = _reader.ReadFormat(OpenWorkbook(workbookPath), "Data", "A1"); result.IsSuccess.Should().BeTrue(); var spec = result.Value.Rows[0][0]; @@ -342,7 +342,7 @@ public void ReadFormat_MultiCellRange_ReturnsMappedGrid() sheet.Cell("A2").Style.Fill.BackgroundColor = XLColor.FromHtml("#FFFF00"); }); - var result = _reader.ReadFormat(workbookPath, "Data", "A1:B2"); + var result = _reader.ReadFormat(OpenWorkbook(workbookPath), "Data", "A1:B2"); result.IsSuccess.Should().BeTrue(); result.Value.Rows.Should().HaveCount(2); @@ -364,7 +364,7 @@ public void ReadFormat_Borders_RoundTripsStyleAndColor() sheet.Cell("A1").Style.Border.BottomBorder = XLBorderStyleValues.Dashed; }); - var result = _reader.ReadFormat(workbookPath, "Data", "A1"); + var result = _reader.ReadFormat(OpenWorkbook(workbookPath), "Data", "A1"); result.IsSuccess.Should().BeTrue(); var spec = result.Value.Rows[0][0]; @@ -388,7 +388,7 @@ public void ReadFormat_EmptyRange_ReadsUsedRange() sheet.Cell("B2").Style.Fill.BackgroundColor = XLColor.FromHtml("#FFFF00"); }); - var result = _reader.ReadFormat(workbookPath, "Data", null); + var result = _reader.ReadFormat(OpenWorkbook(workbookPath), "Data", null); result.IsSuccess.Should().BeTrue(); result.Value.Rows.Should().HaveCount(2); @@ -403,7 +403,7 @@ public void ReadFormat_MissingSheet_ReturnsFailure() workbook.Worksheets.Add("Data"); }); - var result = _reader.ReadFormat(workbookPath, "Missing", null); + var result = _reader.ReadFormat(OpenWorkbook(workbookPath), "Missing", null); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Missing"); @@ -420,7 +420,7 @@ public void GetInfo_ReturnsFrozenPaneCounts() workbook.Worksheets.Add("Plain"); }); - var result = _reader.GetInfo(workbookPath); + var result = _reader.GetInfo(OpenWorkbook(workbookPath)); result.IsSuccess.Should().BeTrue(); var sheets = result.Value.Sheets; @@ -445,7 +445,7 @@ public void GetActiveView_ReturnsActiveSheetSelectionAndScroll() data.SheetView.TopLeftCellAddress = data.Cell("A10").Address; }); - var result = _reader.GetActiveView(workbookPath); + var result = _reader.GetActiveView(OpenWorkbook(workbookPath)); result.IsSuccess.Should().BeTrue(); var view = result.Value; @@ -468,7 +468,7 @@ public void GetActiveView_SingleCellSelection_CollapsesRange() sheet.SelectedRanges.Add(sheet.Range("D5")); }); - var result = _reader.GetActiveView(workbookPath); + var result = _reader.GetActiveView(OpenWorkbook(workbookPath)); result.IsSuccess.Should().BeTrue(); result.Value.Range.Should().Be("D5"); @@ -490,7 +490,7 @@ public void GetActiveView_MultiRangeSelection_ReturnsAllRanges() sheet.SelectedRanges.Add(sheet.Range("A12:B13")); }); - var result = _reader.GetActiveView(workbookPath); + var result = _reader.GetActiveView(OpenWorkbook(workbookPath)); result.IsSuccess.Should().BeTrue(); var view = result.Value; @@ -512,7 +512,7 @@ public void GetActiveView_MultiCellSelection_FiltersDegenerateActiveCellRange() sheet.SelectedRanges.Add(sheet.Range("B2:D5")); }); - var result = _reader.GetActiveView(workbookPath); + var result = _reader.GetActiveView(OpenWorkbook(workbookPath)); result.IsSuccess.Should().BeTrue(); var view = result.Value; @@ -534,7 +534,7 @@ public void Find_FindsTextSubstringsAcrossSheets() }); var options = new FindOptions(Find: "Hello", Sheet: "", Range: "", MatchCase: false, MatchEntireCellContents: false); - var result = _reader.Find(workbookPath, options); + var result = _reader.Find(OpenWorkbook(workbookPath), options); result.IsSuccess.Should().BeTrue(); result.Value.MatchCount.Should().Be(2); @@ -554,7 +554,7 @@ public void Find_MatchesFormulaExpressionText() }); var options = new FindOptions(Find: "SUM", Sheet: "Q1", Range: "", MatchCase: false, MatchEntireCellContents: false); - var result = _reader.Find(workbookPath, options); + var result = _reader.Find(OpenWorkbook(workbookPath), options); result.IsSuccess.Should().BeTrue(); result.Value.MatchCount.Should().Be(1); @@ -576,7 +576,7 @@ public void Find_MatchEntireCellContents_OnlyExactMatches() }); var options = new FindOptions(Find: "foo", Sheet: "Q1", Range: "", MatchCase: false, MatchEntireCellContents: true); - var result = _reader.Find(workbookPath, options); + var result = _reader.Find(OpenWorkbook(workbookPath), options); result.IsSuccess.Should().BeTrue(); result.Value.MatchCount.Should().Be(1); @@ -595,7 +595,7 @@ public void Find_RangeLimitsScope() }); var options = new FindOptions(Find: "needle", Sheet: "Q1", Range: "A1:C3", MatchCase: false, MatchEntireCellContents: false); - var result = _reader.Find(workbookPath, options); + var result = _reader.Find(OpenWorkbook(workbookPath), options); result.IsSuccess.Should().BeTrue(); result.Value.MatchCount.Should().Be(2); @@ -611,7 +611,7 @@ public void Find_RangeWithoutSheet_Fails() }); var options = new FindOptions(Find: "x", Sheet: "", Range: "A1:C3", MatchCase: false, MatchEntireCellContents: false); - var result = _reader.Find(workbookPath, options); + var result = _reader.Find(OpenWorkbook(workbookPath), options); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Range can only be used together with a specific sheet"); @@ -629,14 +629,14 @@ public void Find_MatchCase_DistinguishesCase() }); var caseSensitive = new FindOptions(Find: "Hello", Sheet: "Q1", Range: "", MatchCase: true, MatchEntireCellContents: false); - var caseSensitiveResult = _reader.Find(workbookPath, caseSensitive); + var caseSensitiveResult = _reader.Find(OpenWorkbook(workbookPath), caseSensitive); caseSensitiveResult.IsSuccess.Should().BeTrue(); caseSensitiveResult.Value.MatchCount.Should().Be(1); caseSensitiveResult.Value.Matches[0].Cell.Should().Be("A1"); var caseInsensitive = caseSensitive with { MatchCase = false }; - var caseInsensitiveResult = _reader.Find(workbookPath, caseInsensitive); + var caseInsensitiveResult = _reader.Find(OpenWorkbook(workbookPath), caseInsensitive); caseInsensitiveResult.IsSuccess.Should().BeTrue(); caseInsensitiveResult.Value.MatchCount.Should().Be(3); @@ -652,7 +652,7 @@ public void Find_NoMatches_ReturnsEmpty() }); var options = new FindOptions(Find: "missing", Sheet: "", Range: "", MatchCase: false, MatchEntireCellContents: false); - var result = _reader.Find(workbookPath, options); + var result = _reader.Find(OpenWorkbook(workbookPath), options); result.IsSuccess.Should().BeTrue(); result.Value.MatchCount.Should().Be(0); @@ -667,4 +667,6 @@ private string CreateWorkbook(Action populate) workbook.SaveAs(workbookPath); return workbookPath; } + + private static Stream OpenWorkbook(string path) => new MemoryStream(File.ReadAllBytes(path)); } diff --git a/Source/Tests/Tools/FileToolTests.cs b/Source/Tests/Tools/FileToolTests.cs index 6bfaed8be..5e2bfb7cd 100644 --- a/Source/Tests/Tools/FileToolTests.cs +++ b/Source/Tests/Tools/FileToolTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Celbridge.Commands; +using Celbridge.Messaging; using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Server; using Celbridge.Tools; using Celbridge.Workspace; @@ -31,6 +33,7 @@ public void SetUp() Directory.CreateDirectory(_tempFolder); _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); @@ -41,6 +44,14 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); + // Wire a real ResourceFileSystem against the temp folder so the + // chokepoint reads tests rely on probe and read the actual files. + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + _services.GetRequiredService().Returns(workspaceWrapper); } diff --git a/Source/Tests/Tools/FileToolsReadImageTests.cs b/Source/Tests/Tools/FileToolsReadImageTests.cs index 0e1c8301e..c4c55b9b0 100644 --- a/Source/Tests/Tools/FileToolsReadImageTests.cs +++ b/Source/Tests/Tools/FileToolsReadImageTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using Celbridge.Messaging; using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Server; using Celbridge.Tools; using Celbridge.Workspace; @@ -35,6 +37,7 @@ public void SetUp() Directory.CreateDirectory(_tempFolder); _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); @@ -45,6 +48,12 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + _services.GetRequiredService().Returns(workspaceWrapper); } diff --git a/Source/Tests/Tools/SpreadsheetToolTests.cs b/Source/Tests/Tools/SpreadsheetToolTests.cs index f4dfe06e4..a9e406e58 100644 --- a/Source/Tests/Tools/SpreadsheetToolTests.cs +++ b/Source/Tests/Tools/SpreadsheetToolTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Celbridge.Commands; +using Celbridge.Messaging; using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Server; using Celbridge.Spreadsheet; using Celbridge.Tools; @@ -31,6 +33,16 @@ public void SetUp() _resourceRegistry = Substitute.For(); _commandService = Substitute.For(); + _tempFolder = Path.Combine(Path.GetTempPath(), "Celbridge", nameof(SpreadsheetToolTests), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempFolder); + + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(callInfo => + { + var key = callInfo.Arg(); + return Result.Ok(Path.Combine(_tempFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); + }); + var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); @@ -40,12 +52,15 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); + var fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.ResourceFileSystem.Returns(fileSystem); + _services.GetRequiredService().Returns(workspaceWrapper); _services.GetRequiredService().Returns(_reader); _services.GetRequiredService().Returns(_commandService); - - _tempFolder = Path.Combine(Path.GetTempPath(), "Celbridge", nameof(SpreadsheetToolTests), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempFolder); } [TearDown] @@ -58,16 +73,16 @@ public void TearDown() } [Test] - public void GetInfo_DispatchesToReaderAndReturnsJson() + public async Task GetInfo_DispatchesToReaderAndReturnsJson() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); var info = new WorkbookInfo( new[] { new SheetInfo("Q1", 1, "A1:B2", 2, 2, 0, 0) }, Array.Empty()); - _reader.GetInfo(workbookPath).Returns(Result.Ok(info)); + _reader.GetInfo(Arg.Any()).Returns(Result.Ok(info)); var tools = new SpreadsheetTools(_services); - var root = ParseResult(tools.GetInfo("data/sales.xlsx")); + var root = ParseResult(await tools.GetInfo("data/sales.xlsx")); var sheets = root.GetProperty("sheets"); sheets.GetArrayLength().Should().Be(1); @@ -77,35 +92,31 @@ public void GetInfo_DispatchesToReaderAndReturnsJson() } [Test] - public void GetInfo_NonXlsxResource_ReturnsError() + public async Task GetInfo_NonXlsxResource_ReturnsError() { var tools = new SpreadsheetTools(_services); - var result = tools.GetInfo("notes/readme.md"); + var result = await tools.GetInfo("notes/readme.md"); result.IsError.Should().BeTrue(); GetResultText(result).Should().Contain(".xlsx"); } [Test] - public void GetInfo_MissingFile_ReturnsError() + public async Task GetInfo_MissingFile_ReturnsError() { - var resource = new ResourceKey("data/missing.xlsx"); - var missingPath = Path.Combine(_tempFolder, "missing.xlsx"); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(missingPath)); - var tools = new SpreadsheetTools(_services); - var result = tools.GetInfo("data/missing.xlsx"); + var result = await tools.GetInfo("data/missing.xlsx"); result.IsError.Should().BeTrue(); GetResultText(result).Should().Contain("File not found"); } [Test] - public void ReadSheet_DispatchesToReaderWithOptions() + public async Task ReadSheet_DispatchesToReaderWithOptions() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); ReadOptions? capturedOptions = null; - _reader.ReadSheet(workbookPath, "Q1", Arg.Do(o => capturedOptions = o)) + _reader.ReadSheet(Arg.Any(), "Q1", Arg.Do(o => capturedOptions = o)) .Returns(Result.Ok( new ReadResult( new object?[] { new object?[] { "Jan", 100.0 } }, @@ -114,7 +125,7 @@ public void ReadSheet_DispatchesToReaderWithOptions() Array.Empty()))); var tools = new SpreadsheetTools(_services); - var root = ParseResult(tools.ReadSheet("data/sales.xlsx", "Q1", "A1:B2", "values", false, 0, 0)); + var root = ParseResult(await tools.ReadSheet("data/sales.xlsx", "Q1", "A1:B2", "values", false, 0, 0)); capturedOptions.Should().NotBeNull(); capturedOptions!.Range.Should().Be("A1:B2"); @@ -125,39 +136,39 @@ public void ReadSheet_DispatchesToReaderWithOptions() } [Test] - public void ReadSheet_FormulasMode_PassesFormulasModeThrough() + public async Task ReadSheet_FormulasMode_PassesFormulasModeThrough() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); ReadOptions? capturedOptions = null; - _reader.ReadSheet(workbookPath, "Q1", Arg.Do(o => capturedOptions = o)) + _reader.ReadSheet(Arg.Any(), "Q1", Arg.Do(o => capturedOptions = o)) .Returns(Result.Ok( new ReadResult(Array.Empty(), 0, 0, Array.Empty()))); var tools = new SpreadsheetTools(_services); - tools.ReadSheet("data/sales.xlsx", "Q1", "", "formulas"); + await tools.ReadSheet("data/sales.xlsx", "Q1", "", "formulas"); capturedOptions!.Mode.Should().Be(SpreadsheetReadMode.Formulas); } [Test] - public void ReadSheet_InvalidMode_ReturnsError() + public async Task ReadSheet_InvalidMode_ReturnsError() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); var tools = new SpreadsheetTools(_services); - var result = tools.ReadSheet("data/sales.xlsx", "Q1", mode: "raw"); + var result = await tools.ReadSheet("data/sales.xlsx", "Q1", mode: "raw"); result.IsError.Should().BeTrue(); GetResultText(result).Should().Contain("mode"); } [Test] - public void ReadSheet_EmptySheetName_ReturnsError() + public async Task ReadSheet_EmptySheetName_ReturnsError() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); var tools = new SpreadsheetTools(_services); - var result = tools.ReadSheet("data/sales.xlsx", string.Empty); + var result = await tools.ReadSheet("data/sales.xlsx", string.Empty); result.IsError.Should().BeTrue(); GetResultText(result).Should().Contain("Sheet"); @@ -168,7 +179,7 @@ public async Task ExportCsv_NoDestination_ReturnsCsvTextInline() { var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); var csv = "month,total\r\nJan,100\r\n"; - _reader.ExportCsv(workbookPath, "Q1", null).Returns( + _reader.ExportCsv(Arg.Any(), "Q1", null).Returns( Result.Ok(new ExportCsvResult(csv, 2, 2))); var tools = new SpreadsheetTools(_services); @@ -182,7 +193,7 @@ public async Task ExportCsv_NoDestination_ReturnsCsvTextInline() public async Task ExportCsv_EmptySheet_ReturnsEmptyBody() { var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); - _reader.ExportCsv(workbookPath, "Empty", null).Returns( + _reader.ExportCsv(Arg.Any(), "Empty", null).Returns( Result.Ok(new ExportCsvResult(string.Empty, 0, 0))); var tools = new SpreadsheetTools(_services); @@ -197,7 +208,7 @@ public async Task ExportCsv_WithDestination_DispatchesWriteCommandAndReturnsJson { var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); var csv = "month,total\r\nJan,100\r\nFeb,200\r\n"; - _reader.ExportCsv(workbookPath, "Q1", null).Returns( + _reader.ExportCsv(Arg.Any(), "Q1", null).Returns( Result.Ok(new ExportCsvResult(csv, 3, 2))); IWriteFileCommand? capturedCommand = null; @@ -237,7 +248,7 @@ public async Task ExportCsv_WithDestination_DispatchesWriteCommandAndReturnsJson public async Task ExportCsv_WithInvalidDestinationKey_ReturnsError() { var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); - _reader.ExportCsv(workbookPath, "Q1", null).Returns( + _reader.ExportCsv(Arg.Any(), "Q1", null).Returns( Result.Ok(new ExportCsvResult("a\r\n", 1, 1))); var tools = new SpreadsheetTools(_services); @@ -711,7 +722,7 @@ public async Task FreezePanes_NegativeRows_ReturnsError() } [Test] - public void ReadFormat_DispatchesToReaderAndReturnsGrid() + public async Task ReadFormat_DispatchesToReaderAndReturnsGrid() { CreatePlaceholderFile("data/styles.xlsx"); @@ -726,11 +737,11 @@ public void ReadFormat_DispatchesToReaderAndReturnsGrid() } }); - _reader.ReadFormat(Arg.Any(), "Data", "A1:B1") + _reader.ReadFormat(Arg.Any(), "Data", "A1:B1") .Returns(Result.Ok(formatResult)); var tools = new SpreadsheetTools(_services); - var root = ParseResult(tools.ReadFormat("data/styles.xlsx", "Data", "A1:B1")); + var root = ParseResult(await tools.ReadFormat("data/styles.xlsx", "Data", "A1:B1")); root.GetProperty("range").GetString().Should().Be("Data!A1:B1"); var rows = root.GetProperty("rows"); @@ -742,12 +753,12 @@ public void ReadFormat_DispatchesToReaderAndReturnsGrid() } [Test] - public void ReadFormat_EmptySheetName_ReturnsError() + public async Task ReadFormat_EmptySheetName_ReturnsError() { CreatePlaceholderFile("data/styles.xlsx"); var tools = new SpreadsheetTools(_services); - var result = tools.ReadFormat("data/styles.xlsx", sheet: "", range: ""); + var result = await tools.ReadFormat("data/styles.xlsx", sheet: "", range: ""); result.IsError.Should().BeTrue(); GetResultText(result).Should().Contain("Sheet"); @@ -901,11 +912,11 @@ public async Task Clear_InvalidJson_ReturnsError() } [Test] - public void Find_DispatchesToReaderAndReturnsMatches() + public async Task Find_DispatchesToReaderAndReturnsMatches() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); FindOptions? capturedOptions = null; - _reader.Find(workbookPath, Arg.Do(o => capturedOptions = o)) + _reader.Find(Arg.Any(), Arg.Do(o => capturedOptions = o)) .Returns(Result.Ok(new FindResult( new[] { @@ -915,7 +926,7 @@ public void Find_DispatchesToReaderAndReturnsMatches() 2))); var tools = new SpreadsheetTools(_services); - var root = ParseResult(tools.Find("data/sales.xlsx", "Total", sheet: "Q1", matchCase: true)); + var root = ParseResult(await tools.Find("data/sales.xlsx", "Total", sheet: "Q1", matchCase: true)); capturedOptions.Should().NotBeNull(); capturedOptions!.Find.Should().Be("Total"); @@ -929,12 +940,12 @@ public void Find_DispatchesToReaderAndReturnsMatches() } [Test] - public void Find_EmptyFindString_ReturnsError() + public async Task Find_EmptyFindString_ReturnsError() { CreatePlaceholderFile("data/sales.xlsx"); var tools = new SpreadsheetTools(_services); - var result = tools.Find("data/sales.xlsx", string.Empty); + var result = await tools.Find("data/sales.xlsx", string.Empty); result.IsError.Should().BeTrue(); GetResultText(result).Should().Contain("Find text"); @@ -1144,19 +1155,19 @@ public async Task SetConditionalFormatting_EmptyRulesWithoutClearExisting_Return } [Test] - public void GetActiveView_DispatchesToReaderAndReturnsViewState() + public async Task GetActiveView_DispatchesToReaderAndReturnsViewState() { - var workbookPath = CreatePlaceholderFile("data/sales.xlsx"); + CreatePlaceholderFile("data/sales.xlsx"); var view = new ActiveView( "Summary", "B2:D4", new[] { "B2:D4", "F1:F10" }, "C3", "A1"); - _reader.GetActiveView(workbookPath).Returns(Result.Ok(view)); + _reader.GetActiveView(Arg.Any()).Returns(Result.Ok(view)); var tools = new SpreadsheetTools(_services); - var root = ParseResult(tools.GetActiveView("data/sales.xlsx")); + var root = ParseResult(await tools.GetActiveView("data/sales.xlsx")); root.GetProperty("sheet").GetString().Should().Be("Summary"); root.GetProperty("range").GetString().Should().Be("B2:D4"); @@ -1169,10 +1180,13 @@ public void GetActiveView_DispatchesToReaderAndReturnsViewState() private string CreatePlaceholderFile(string resourceKey) { - var resource = new ResourceKey(resourceKey); - var path = Path.Combine(_tempFolder, Path.GetFileName(resourceKey)); + var path = Path.Combine(_tempFolder, resourceKey.Replace('/', Path.DirectorySeparatorChar)); + var folder = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(folder)) + { + Directory.CreateDirectory(folder); + } File.WriteAllText(path, string.Empty); - _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); return path; } diff --git a/Source/Tests/Tools/WebViewScreenshotResolverTests.cs b/Source/Tests/Tools/WebViewScreenshotResolverTests.cs index ada8f18be..b4ed062b4 100644 --- a/Source/Tests/Tools/WebViewScreenshotResolverTests.cs +++ b/Source/Tests/Tools/WebViewScreenshotResolverTests.cs @@ -1,4 +1,8 @@ +using Celbridge.Messaging; +using Celbridge.Resources; +using Celbridge.Resources.Services; using Celbridge.Tools; +using Celbridge.Workspace; namespace Celbridge.Tests.Tools; @@ -6,12 +10,36 @@ namespace Celbridge.Tests.Tools; public class WebViewScreenshotResolverTests { private string _projectFolder = null!; + private IResourceFileSystem _fileSystem = null!; + private IResourceRegistry _resourceRegistry = null!; [SetUp] public void SetUp() { _projectFolder = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(WebViewScreenshotResolverTests)}/{Guid.NewGuid():N}"); Directory.CreateDirectory(_projectFolder); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_projectFolder); + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(callInfo => + { + var key = callInfo.Arg(); + return Result.Ok(Path.Combine(_projectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); + }); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + _fileSystem = new ResourceFileSystem( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); } [TearDown] @@ -24,78 +52,75 @@ public void TearDown() } [Test] - public void Resolve_EmptySaveTo_UsesDefaultFolderWithCleanName() + public async Task Resolve_EmptySaveTo_UsesDefaultFolderWithCleanName() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "", format: "jpeg", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "jpeg", _fileSystem); result.IsSuccess.Should().BeTrue(); - // Assert against the bare path portion (without the project: prefix) - // because these checks are about the path shape the resolver picked. var path = result.Value.Path; path.Should().StartWith("screenshots/screenshot-"); path.Should().EndWith(".jpg"); - // No collision in a fresh folder, so the unsuffixed form should be used. path.Should().NotContain(".jpg-").And.MatchRegex(@"screenshots/screenshot-\d{8}-\d{6}\.jpg$"); } [Test] - public void Resolve_EmptySaveToWithPng_UsesPngExtension() + public async Task Resolve_EmptySaveToWithPng_UsesPngExtension() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "", format: "png", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "png", _fileSystem); result.IsSuccess.Should().BeTrue(); result.Value.Path.Should().EndWith(".png"); } [Test] - public void Resolve_ExactResourceKeyWithMatchingExtension_PreservesKey() + public async Task Resolve_ExactResourceKeyWithMatchingExtension_PreservesKey() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.png", format: "png", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.png", format: "png", _fileSystem); result.IsSuccess.Should().BeTrue(); result.Value.ToString().Should().Be("project:docs/output.png"); } [Test] - public void Resolve_JpgExtensionMatchesJpegFormat() + public async Task Resolve_JpgExtensionMatchesJpegFormat() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.jpg", format: "jpeg", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.jpg", format: "jpeg", _fileSystem); result.IsSuccess.Should().BeTrue(); result.Value.ToString().Should().Be("project:docs/output.jpg"); } [Test] - public void Resolve_JpegExtensionMatchesJpegFormat() + public async Task Resolve_JpegExtensionMatchesJpegFormat() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.jpeg", format: "jpeg", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.jpeg", format: "jpeg", _fileSystem); result.IsSuccess.Should().BeTrue(); } [Test] - public void Resolve_ExtensionFormatMismatch_Fails() + public async Task Resolve_ExtensionFormatMismatch_Fails() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.png", format: "jpeg", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.png", format: "jpeg", _fileSystem); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("does not match format"); } [Test] - public void Resolve_TxtExtension_FailsForBothFormats() + public async Task Resolve_TxtExtension_FailsForBothFormats() { - var resultJpeg = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.txt", format: "jpeg", _projectFolder); - var resultPng = WebViewScreenshotResolver.Resolve(saveTo: "docs/output.txt", format: "png", _projectFolder); + var resultJpeg = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "jpeg", _fileSystem); + var resultPng = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "png", _fileSystem); resultJpeg.IsFailure.Should().BeTrue(); resultPng.IsFailure.Should().BeTrue(); } [Test] - public void Resolve_TrailingSlashSaveTo_GeneratesAutoNameInThatFolder() + public async Task Resolve_TrailingSlashSaveTo_GeneratesAutoNameInThatFolder() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "docs/", format: "jpeg", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/", format: "jpeg", _fileSystem); result.IsSuccess.Should().BeTrue(); var path = result.Value.Path; @@ -104,11 +129,11 @@ public void Resolve_TrailingSlashSaveTo_GeneratesAutoNameInThatFolder() } [Test] - public void Resolve_NoExtensionSaveTo_TreatedAsFolder() + public async Task Resolve_NoExtensionSaveTo_TreatedAsFolder() { // A path without a file extension is interpreted as a folder reference, // matching the agent's likely intent ("put a screenshot in this folder"). - var result = WebViewScreenshotResolver.Resolve(saveTo: "captures", format: "png", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "captures", format: "png", _fileSystem); result.IsSuccess.Should().BeTrue(); var path = result.Value.Path; @@ -117,65 +142,57 @@ public void Resolve_NoExtensionSaveTo_TreatedAsFolder() } [Test] - public void Resolve_CollisionWithExistingFile_AddsSequenceSuffix() + public async Task Resolve_CollisionWithExistingFile_AddsSequenceSuffix() { // Pre-create a file matching the timestamp pattern the saver will pick. // To do this deterministically without racing the wall clock, we let // the saver generate its first name, then re-run Resolve and confirm // the second call produces a -1 suffix. - var first = WebViewScreenshotResolver.Resolve(saveTo: "screenshots/", format: "jpeg", _projectFolder); + var first = await WebViewScreenshotResolver.ResolveAsync(saveTo: "screenshots/", format: "jpeg", _fileSystem); first.IsSuccess.Should().BeTrue(); - // Materialise the first name so the next probe collides. Use the bare - // path (no root prefix) because we're constructing a filesystem path. var firstPath = first.Value.Path; var firstAbsolute = Path.Combine(_projectFolder, firstPath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(firstAbsolute)!); File.WriteAllBytes(firstAbsolute, new byte[] { 0 }); - var second = WebViewScreenshotResolver.Resolve(saveTo: "screenshots/", format: "jpeg", _projectFolder); + var second = await WebViewScreenshotResolver.ResolveAsync(saveTo: "screenshots/", format: "jpeg", _fileSystem); second.IsSuccess.Should().BeTrue(); - // If both calls landed in the same wall-clock second, the second name - // should carry a -1 suffix. If they straddled a second boundary, the - // names will differ in the timestamp and neither carries a suffix — - // both outcomes are correct, so the assertion accepts either form. var secondPath = second.Value.Path; secondPath.Should().NotBe(firstPath); secondPath.Should().MatchRegex(@"screenshots/screenshot-\d{8}-\d{6}(-\d+)?\.jpg$"); } [Test] - public void Resolve_TraversalAttempt_RejectedByResourceKey() + public async Task Resolve_TraversalAttempt_RejectedByResourceKey() { - // Defense-in-depth check: ResourceKey.IsValidKey rejects '..', so the - // saveTo path cannot escape the project root via traversal. - var result = WebViewScreenshotResolver.Resolve(saveTo: "../escape.png", format: "png", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "../escape.png", format: "png", _fileSystem); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Invalid saveTo"); } [Test] - public void Resolve_BackslashInSaveTo_Rejected() + public async Task Resolve_BackslashInSaveTo_Rejected() { - var result = WebViewScreenshotResolver.Resolve(saveTo: @"docs\output.png", format: "png", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: @"docs\output.png", format: "png", _fileSystem); result.IsFailure.Should().BeTrue(); } [Test] - public void Resolve_AbsolutePathSaveTo_Rejected() + public async Task Resolve_AbsolutePathSaveTo_Rejected() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "/etc/output.png", format: "png", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "/etc/output.png", format: "png", _fileSystem); result.IsFailure.Should().BeTrue(); } [Test] - public void Resolve_UnsupportedFormat_Fails() + public async Task Resolve_UnsupportedFormat_Fails() { - var result = WebViewScreenshotResolver.Resolve(saveTo: "", format: "webp", _projectFolder); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "webp", _fileSystem); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Unsupported screenshot format"); diff --git a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs index 6faf57c0c..416a08f19 100644 --- a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs +++ b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs @@ -2,6 +2,7 @@ using Celbridge.Commands; using Celbridge.Messaging; using Celbridge.Projects; +using Celbridge.Resources; using Celbridge.Workspace; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.Localization; @@ -14,6 +15,7 @@ public partial class ConsolePanelViewModel : ObservableObject private readonly IDispatcher _dispatcher; private readonly IStringLocalizer _stringLocalizer; private readonly IProjectService _projectService; + private readonly IWorkspaceWrapper _workspaceWrapper; private readonly ICommandService _commandService; private readonly ILayoutService _layoutService; @@ -95,6 +97,7 @@ public ConsolePanelViewModel( _dispatcher = dispatcher; _stringLocalizer = stringLocalizer; _projectService = projectService; + _workspaceWrapper = workspaceWrapper; _commandService = commandService; _layoutService = layoutService; @@ -110,8 +113,11 @@ public ConsolePanelViewModel( // Register for console maximized state changes _messengerService.Register(this, OnConsoleMaximizedChanged); - // Store the original project file contents - StoreProjectFileHash(); + // Snapshot the project file contents so subsequent changes can be + // detected. The hash read goes through the resource file system, + // which is async; fire-and-forget here since the constructor is sync + // and the snapshot is only consulted on later change events. + _ = StoreProjectFileHashAsync(); // Check if the project was migrated and show banner if needed CheckMigrationStatus(); @@ -259,72 +265,86 @@ private void OnResourceChanged(object recipient, ResourceChangedMessage message) { // This handler may be called from a background thread so ensure that the message // is handled on the main UI thread. - _dispatcher.TryEnqueue(() => + _dispatcher.TryEnqueue(async () => { - CheckProjectFileChanged(); + await CheckProjectFileChangedAsync(); }); } } - private void StoreProjectFileHash() + // Resolves the project config file as a ResourceKey at the project root. + // The .celbridge config sits next to the project folder root, so its key + // is just the file name on the default root. + private bool TryGetProjectFileResourceKey(out ResourceKey resourceKey) { + resourceKey = default; var projectFilePath = _projectService?.CurrentProject?.ProjectFilePath; - if (string.IsNullOrEmpty(projectFilePath) || !File.Exists(projectFilePath)) + if (string.IsNullOrEmpty(projectFilePath)) { - _originalProjectFileHash = null; - return; + return false; } - try + var projectFileName = Path.GetFileName(projectFilePath); + return ResourceKey.TryCreate(projectFileName, out resourceKey); + } + + private async Task StoreProjectFileHashAsync() + { + if (!TryGetProjectFileResourceKey(out var projectFileResource)) { - var fileContents = File.ReadAllText(projectFilePath); - _originalProjectFileHash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(fileContents)); + _originalProjectFileHash = null; + return; } - catch + + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var readResult = await fileSystem.ReadAllBytesAsync(projectFileResource); + if (readResult.IsFailure) { _originalProjectFileHash = null; + return; } + + _originalProjectFileHash = SHA256.HashData(readResult.Value); } - private void CheckProjectFileChanged() + private async Task CheckProjectFileChangedAsync() { - var projectFilePath = _projectService?.CurrentProject?.ProjectFilePath; - if (string.IsNullOrEmpty(projectFilePath) || !File.Exists(projectFilePath)) + if (!TryGetProjectFileResourceKey(out var projectFileResource)) { return; } - try + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var readResult = await fileSystem.ReadAllBytesAsync(projectFileResource); + if (readResult.IsFailure) { - var currentContents = File.ReadAllText(projectFilePath); - var currentHash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(currentContents)); + // If we can't read the file, hide the banner + IsProjectChangeBannerVisible = false; + return; + } - // If error banner is visible, don't show the project change banner - if (IsErrorBannerVisible) - { - IsProjectChangeBannerVisible = false; - return; - } + var currentHash = SHA256.HashData(readResult.Value); - // Check if the hash has changed from the original - if (_originalProjectFileHash == null || - !currentHash.SequenceEqual(_originalProjectFileHash)) - { - // Populate the project change banner strings - ProjectChangeBannerTitle = _stringLocalizer.GetString("ConsolePanel_ProjectChangeBannerTitle"); - ProjectChangeBannerMessage = _stringLocalizer.GetString("ConsolePanel_ProjectChangeBannerMessage"); + // If error banner is visible, don't show the project change banner + if (IsErrorBannerVisible) + { + IsProjectChangeBannerVisible = false; + return; + } - IsProjectChangeBannerVisible = true; - ShowConsolePanel(); - } - else - { - IsProjectChangeBannerVisible = false; - } + // Check if the hash has changed from the original + if (_originalProjectFileHash == null + || !currentHash.SequenceEqual(_originalProjectFileHash)) + { + // Populate the project change banner strings + ProjectChangeBannerTitle = _stringLocalizer.GetString("ConsolePanel_ProjectChangeBannerTitle"); + ProjectChangeBannerMessage = _stringLocalizer.GetString("ConsolePanel_ProjectChangeBannerMessage"); + + IsProjectChangeBannerVisible = true; + ShowConsolePanel(); } - catch + else { - // If we can't read the file, hide the banner IsProjectChangeBannerVisible = false; } } diff --git a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs index 6266f6b20..beba4e499 100644 --- a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs +++ b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs @@ -17,39 +17,35 @@ public FileAccessHelper(IWorkspaceWrapper workspaceWrapper) } /// - /// True when the path points to an existing file that can be opened for - /// shared read access. Returns false for empty paths, missing files, or + /// True when the resource key resolves to an existing file that can be + /// opened for shared read access. Returns false for missing files or /// access-denied conditions. /// - public bool CanAccessFile(string resourcePath) + public async Task CanAccessFileAsync(ResourceKey fileResource) { - if (string.IsNullOrEmpty(resourcePath) - || !File.Exists(resourcePath)) - { - return false; - } + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - try - { - var fileInfo = new FileInfo(resourcePath); - using var stream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.Read); - return true; - } - catch (IOException) + var infoResult = await fileSystem.GetInfoAsync(fileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { return false; } - catch (UnauthorizedAccessException) + + var openResult = await fileSystem.OpenReadAsync(fileResource); + if (openResult.IsFailure) { return false; } + openResult.Value.Dispose(); + return true; } /// /// Resolves a resource key to its backing path and verifies the file /// exists and is readable. Returns the resolved path on success. /// - public Result ResolveAndValidateFilePath(ResourceKey fileResource) + public async Task> ResolveAndValidateFilePathAsync(ResourceKey fileResource) { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -61,12 +57,15 @@ public Result ResolveAndValidateFilePath(ResourceKey fileResource) } var filePath = resolveResult.Value; - if (!File.Exists(filePath)) + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(fileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { return Result.Fail($"File path does not exist: '{filePath}'"); } - if (!CanAccessFile(filePath)) + if (!await CanAccessFileAsync(fileResource)) { return Result.Fail($"File exists but cannot be opened: '{filePath}'"); } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs index 193c1c6e1..cee067a9e 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs @@ -174,6 +174,11 @@ public bool IsExtensionSupported(string fileExtension) return _extensionToFactories.ContainsKey(normalizedExtension); } + public bool IsFilenameSupported(string fileName) + { + return _filenameToFactories.ContainsKey(fileName); + } + /// /// Gets all registered factories. /// diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs index c7ff42d5d..89d22c631 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs @@ -178,7 +178,7 @@ public async Task RestorePanelStateAsync() if (storedLayout.Addresses is null || storedLayout.Addresses.Count == 0) { - OpenDefaultReadme(); + await OpenDefaultReadmeAsync(); return; } @@ -254,7 +254,7 @@ private async Task RestoreDocumentsAsync( } var filePath = resolveResult.Value; - if (!_fileAccessHelper.CanAccessFile(filePath)) + if (!await _fileAccessHelper.CanAccessFileAsync(fileResource)) { _logger.LogWarning($"Cannot access file for resource: '{fileResource}'"); continue; @@ -319,7 +319,7 @@ private async Task RestoreActiveDocumentAsync() DocumentsPanel.ActiveDocument = selectedDocumentKey; } - private void OpenDefaultReadme() + private async Task OpenDefaultReadmeAsync() { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var readmeResource = new ResourceKey("readme.md"); @@ -331,9 +331,7 @@ private void OpenDefaultReadme() } var normalizedResource = normalizeResult.Value; - var resolveResult = resourceRegistry.ResolveResourcePath(normalizedResource); - if (resolveResult.IsFailure - || !_fileAccessHelper.CanAccessFile(resolveResult.Value)) + if (!await _fileAccessHelper.CanAccessFileAsync(normalizedResource)) { return; } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index eaa1ad16e..282be56b1 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -280,7 +280,7 @@ public Task SetEditorPreferenceAsync(string extension, DocumentEditorId editorId public async Task> OpenDocument(ResourceKey fileResource, OpenDocumentOptions? options = null) { - var resolveResult = _fileAccessHelper.ResolveAndValidateFilePath(fileResource); + var resolveResult = await _fileAccessHelper.ResolveAndValidateFilePathAsync(fileResource); if (resolveResult.IsFailure) { return Result.Fail($"Failed to open document for file resource '{fileResource}'") @@ -377,13 +377,15 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource } var newResourcePath = resolveResult.Value; - Guard.IsTrue(File.Exists(newResourcePath)); - var oldDocumentType = _fileTypeHelper.GetDocumentViewType(oldResource); var newDocumentType = _fileTypeHelper.GetDocumentViewType(newResource); var changeDocumentResource = async Task () => { + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(message.NewResource); + Guard.IsTrue(infoResult.IsSuccess && infoResult.Value.Kind == ResourceInfoKind.File); + var changeResult = await DocumentsPanel.ChangeDocumentResource(oldResource, oldDocumentType, newResource, newResourcePath, newDocumentType); if (changeResult.IsFailure) { diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs index e05c704b8..a828212fb 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs @@ -11,6 +11,7 @@ namespace Celbridge.Documents.ViewModels; /// public partial class ContributionDocumentViewModel : DocumentViewModel { + private readonly IWorkspaceWrapper _workspaceWrapper; private readonly IResourceRegistry _resourceRegistry; private readonly IReadOnlyList _contentProviders; @@ -24,6 +25,7 @@ public ContributionDocumentViewModel( IWorkspaceWrapper workspaceWrapper, IEnumerable contentProviders) { + _workspaceWrapper = workspaceWrapper; _resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; _contentProviders = contentProviders.ToList().AsReadOnly(); @@ -61,7 +63,7 @@ public async Task LoadTextContentAsync() var generateResult = await provider.LoadContentAsync(FileResource); if (generateResult.IsSuccess) { - UpdateFileTrackingInfo(); + await UpdateFileTrackingInfoAsync(); return generateResult.Value; } } @@ -69,31 +71,44 @@ public async Task LoadTextContentAsync() return string.Empty; } - if (!File.Exists(FilePath)) + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { - return GetDefaultTemplateContent(); + return await GetDefaultTemplateContentAsync(); } if (IsBinary) { - var bytes = await File.ReadAllBytesAsync(FilePath); + var bytesResult = await fileSystem.ReadAllBytesAsync(FileResource); + if (bytesResult.IsFailure) + { + return await GetDefaultTemplateContentAsync(); + } + var bytes = bytesResult.Value; if (bytes.Length == 0) { - return GetDefaultTemplateContent(); + return await GetDefaultTemplateContentAsync(); } - UpdateFileTrackingInfo(); + await UpdateFileTrackingInfoAsync(); return Convert.ToBase64String(bytes); } - var content = await File.ReadAllTextAsync(FilePath); + var textResult = await fileSystem.ReadAllTextAsync(FileResource); + if (textResult.IsFailure) + { + return await GetDefaultTemplateContentAsync(); + } + var content = textResult.Value; if (string.IsNullOrEmpty(content)) { - return GetDefaultTemplateContent(); + return await GetDefaultTemplateContentAsync(); } - UpdateFileTrackingInfo(); + await UpdateFileTrackingInfoAsync(); return content; } @@ -268,8 +283,10 @@ public Result ResolveLinkTarget(string href) /// /// Reads the default template content from the manifest's template file. /// Returns empty string if no default template is declared or the file cannot be read. + /// Routes through IResourceFileSystem when the template path is registry-addressable; + /// falls back to direct read for packages installed outside the project tree. /// - private string GetDefaultTemplateContent() + private async Task GetDefaultTemplateContentAsync() { if (Contribution is null) { @@ -285,6 +302,17 @@ private string GetDefaultTemplateContent() } var templatePath = Path.Combine(Contribution.Package.PackageFolder, defaultTemplate.TemplateFile); + + var keyResult = _resourceRegistry.GetResourceKey(templatePath); + if (keyResult.IsSuccess) + { + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var textResult = await fileSystem.ReadAllTextAsync(keyResult.Value); + return textResult.IsSuccess ? textResult.Value : string.Empty; + } + + // Template lives outside the project tree (bundled with the app's + // packages). Treat as embedded resource. if (!File.Exists(templatePath)) { return string.Empty; @@ -292,6 +320,7 @@ private string GetDefaultTemplateContent() try { + await Task.CompletedTask; return File.ReadAllText(templatePath, Encoding.UTF8); } catch diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DefaultDocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DefaultDocumentViewModel.cs index bafe4db29..5894e9556 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DefaultDocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DefaultDocumentViewModel.cs @@ -9,21 +9,17 @@ public partial class DefaultDocumentViewModel : DocumentViewModel public async Task LoadDocument() { - try - { - PropertyChanged -= TextDocumentViewModel_PropertyChanged; - - // Read the file contents to initialize the text editor - var text = await File.ReadAllTextAsync(FilePath); - Text = text; + PropertyChanged -= TextDocumentViewModel_PropertyChanged; - PropertyChanged += TextDocumentViewModel_PropertyChanged; - } - catch (Exception ex) + var loadResult = await LoadTextFromFileAsync(); + if (loadResult.IsFailure) { return Result.Fail($"Failed to load document file: '{FilePath}'") - .WithException(ex); + .WithErrors(loadResult); } + Text = loadResult.Value; + + PropertyChanged += TextDocumentViewModel_PropertyChanged; return Result.Ok(); } diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs index bbf54f2e3..8a0f29e7c 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs @@ -120,7 +120,7 @@ public bool HasMultipleCompatibleEditors() return factories.Count >= 2; } - private void OnResourceRegistryUpdatedMessage(object recipient, ResourceRegistryUpdatedMessage message) + private async void OnResourceRegistryUpdatedMessage(object recipient, ResourceRegistryUpdatedMessage message) { if (_pendingResourceKeyChangedMessage is not null) { @@ -144,7 +144,10 @@ private void OnResourceRegistryUpdatedMessage(object recipient, ResourceRegistry // rename temp" save pattern used by some editors and coding agents. Check if the file // still exists on disk before closing. The resource registry may not have caught up // with the rename yet. - if (File.Exists(FilePath)) + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsSuccess + && infoResult.Value.Kind == ResourceInfoKind.File) { return; } @@ -181,7 +184,10 @@ public async Task> CloseDocument(bool forceClose) { Guard.IsNotNull(DocumentView); - if (!File.Exists(FilePath)) + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var closeInfoResult = await fileSystem.GetInfoAsync(FileResource); + if (closeInfoResult.IsFailure + || closeInfoResult.Value.Kind != ResourceInfoKind.File) { // The file no longer exists, so we assume that it was deleted intentionally. // Any pending save changes are discarded. diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index 26f7446e1..3e6bace91 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -98,7 +98,7 @@ protected void EnableFileChangeMonitoring() _messengerService.Register(this, OnDocumentSaveCompleted); } - private void OnResourceChanged(object recipient, ResourceChangedMessage message) + private async void OnResourceChanged(object recipient, ResourceChangedMessage message) { if (message.Resource != FileResource) { @@ -107,7 +107,7 @@ private void OnResourceChanged(object recipient, ResourceChangedMessage message) // Self-events from our own writes hash-match _lastSavedFileHash and are // ignored. Genuine external changes have a different hash and proceed. - if (IsFileChangedExternally()) + if (await IsFileChangedExternallyAsync()) { // External edits supersede any pending or in-flight buffer save. // Discard the queued save so the buffer reload wins. @@ -119,11 +119,11 @@ private void OnResourceChanged(object recipient, ResourceChangedMessage message) } } - private void OnDocumentSaveCompleted(object recipient, DocumentSaveCompletedMessage message) + private async void OnDocumentSaveCompleted(object recipient, DocumentSaveCompletedMessage message) { if (message.DocumentResource == FileResource) { - UpdateFileTrackingInfo(); + await UpdateFileTrackingInfoAsync(); } } @@ -133,17 +133,16 @@ private void OnDocumentSaveCompleted(object recipient, DocumentSaveCompletedMess /// protected async Task> LoadTextFromFileAsync() { - try - { - var text = await File.ReadAllTextAsync(FilePath); - UpdateFileTrackingInfo(); - return text; - } - catch (Exception ex) + var fileSystem = GetFileSystem(); + var readResult = await fileSystem.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) { return Result.Fail($"Failed to load file: '{FilePath}'") - .WithException(ex); + .WithErrors(readResult); } + + await UpdateFileTrackingInfoAsync(); + return readResult.Value; } /// @@ -187,7 +186,7 @@ private async Task SaveBytesToFileAsync(byte[] bytes) { var intendedHash = ComputeBytesHash(bytes); - if (TryDetectPreWriteExternalChange()) + if (await TryDetectPreWriteExternalChangeAsync()) { return Result.Ok(); } @@ -199,7 +198,7 @@ private async Task SaveBytesToFileAsync(byte[] bytes) return writeResult; } - UpdateFileTrackingInfo(); + await UpdateFileTrackingInfoAsync(); if (_lastSavedFileHash is not null && _lastSavedFileHash != intendedHash) @@ -220,36 +219,35 @@ private async Task SaveBytesToFileAsync(byte[] bytes) /// against, or if the disk read fails (the caller falls through to the /// write attempt, whose retry loop handles transient IO errors). /// - private bool TryDetectPreWriteExternalChange() + private async Task TryDetectPreWriteExternalChangeAsync() { - if (_lastSavedFileHash is null - || !File.Exists(FilePath)) + if (_lastSavedFileHash is null) { return false; } - try + var fileSystem = GetFileSystem(); + var readResult = await fileSystem.ReadAllBytesAsync(FileResource); + if (readResult.IsFailure) { - var preWriteHash = ComputeFileHash(FilePath); - if (preWriteHash == _lastSavedFileHash) - { - return false; - } - - SaveTimer = 0; - HasUnsavedChanges = false; - UpdateFileTrackingInfo(); - - _logger?.LogDebug($"External write detected before save for '{FileResource}', aborting save and requesting reload"); - RaiseReloadRequested(); - - return true; + _logger?.LogDebug($"Pre-write hash check failed for '{FilePath}', proceeding to write attempt"); + return false; } - catch (IOException ex) + + var preWriteHash = ComputeBytesHash(readResult.Value); + if (preWriteHash == _lastSavedFileHash) { - _logger?.LogDebug(ex, $"Pre-write hash check failed for '{FilePath}', proceeding to write attempt"); return false; } + + SaveTimer = 0; + HasUnsavedChanges = false; + await UpdateFileTrackingInfoAsync(); + + _logger?.LogDebug($"External write detected before save for '{FileResource}', aborting save and requesting reload"); + RaiseReloadRequested(); + + return true; } /// @@ -272,7 +270,7 @@ public virtual void Cleanup() _messengerService?.UnregisterAll(this); } - protected bool IsFileChangedExternally() + protected async Task IsFileChangedExternallyAsync() { // If we haven't saved yet, any change is considered external if (_lastSavedFileHash == null) @@ -280,61 +278,54 @@ protected bool IsFileChangedExternally() return true; } - try + var fileSystem = GetFileSystem(); + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { - if (!File.Exists(FilePath)) - { - // File was deleted, consider this an external change - return true; - } - - var fileInfo = new FileInfo(FilePath); - var currentSize = fileInfo.Length; - - // Quick check: if file size is different, it's definitely changed - if (currentSize != _lastSavedFileSize) - { - return true; - } - - // File size is the same - compute hash to check if content actually changed - // This handles cases where the file was rewritten with identical content - var currentHash = ComputeFileHash(FilePath); + return true; + } - return currentHash != _lastSavedFileHash; + // Quick check: if file size is different, it's definitely changed. + if (infoResult.Value.Size != _lastSavedFileSize) + { + return true; } - catch (Exception) + + // File size is the same; compute hash to check if content actually changed. + // This handles cases where the file was rewritten with identical content. + var readResult = await fileSystem.ReadAllBytesAsync(FileResource); + if (readResult.IsFailure) { - // If we can't read the file, assume it changed (safer to reload) return true; } + + var currentHash = ComputeBytesHash(readResult.Value); + return currentHash != _lastSavedFileHash; } - protected virtual void UpdateFileTrackingInfo() + protected virtual async Task UpdateFileTrackingInfoAsync() { - try + var fileSystem = GetFileSystem(); + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { - if (File.Exists(FilePath)) - { - var fileInfo = new FileInfo(FilePath); - _lastSavedFileSize = fileInfo.Length; - _lastSavedFileHash = ComputeFileHash(FilePath); - } + _lastSavedFileHash = null; + _lastSavedFileSize = 0; + return; } - catch (Exception) + + var readResult = await fileSystem.ReadAllBytesAsync(FileResource); + if (readResult.IsFailure) { - // If we can't read the file, clear our tracking info _lastSavedFileHash = null; _lastSavedFileSize = 0; + return; } - } - protected static string ComputeFileHash(string filePath) - { - using var stream = File.OpenRead(filePath); - using var sha256 = SHA256.Create(); - var hashBytes = sha256.ComputeHash(stream); - return Convert.ToBase64String(hashBytes); + _lastSavedFileSize = infoResult.Value.Size; + _lastSavedFileHash = ComputeBytesHash(readResult.Value); } private static string ComputeBytesHash(byte[] bytes) diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs index b6c8a0094..d8e894b5f 100644 --- a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs +++ b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs @@ -7,6 +7,7 @@ namespace Celbridge.Documents.Views; public abstract partial class DocumentView : UserControl, IDocumentView { private IResourceRegistry? _resourceRegistry; + private IResourceFileSystem? _resourceFileSystem; /// /// Provides access to the resource registry for file resource validation. @@ -25,6 +26,23 @@ protected IResourceRegistry ResourceRegistry } } + /// + /// Provides access to the resource file system chokepoint. + /// Lazily initialized from the workspace wrapper. + /// + protected IResourceFileSystem ResourceFileSystem + { + get + { + if (_resourceFileSystem is null) + { + var workspaceWrapper = ServiceLocator.AcquireService(); + _resourceFileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + } + return _resourceFileSystem; + } + } + /// /// Returns the ViewModel for this document view. /// Used by the base class to provide default SetFileResource and FileResource implementations. @@ -56,30 +74,32 @@ public DocumentEditorId EditorId /// Validates the resource exists in the registry and on disk, then sets the ViewModel properties. /// Subclasses can override to add additional logic (call base first). /// - public virtual Task SetFileResource(ResourceKey fileResource) + public virtual async Task SetFileResource(ResourceKey fileResource) { if (ResourceRegistry.GetResource(fileResource).IsFailure) { - return Task.FromResult(Result.Fail($"File resource does not exist in resource registry: {fileResource}")); + return Result.Fail($"File resource does not exist in resource registry: {fileResource}"); } var resolveResult = ResourceRegistry.ResolveResourcePath(fileResource); if (resolveResult.IsFailure) { - return Task.FromResult(Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult)); + return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") + .WithErrors(resolveResult); } var filePath = resolveResult.Value; - if (!File.Exists(filePath)) + var infoResult = await ResourceFileSystem.GetInfoAsync(fileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { - return Task.FromResult(Result.Fail($"File resource does not exist on disk: {fileResource}")); + return Result.Fail($"File resource does not exist on disk: {fileResource}"); } DocumentViewModel.FileResource = fileResource; DocumentViewModel.FilePath = filePath; - return Task.FromResult(Result.Ok()); + return Result.Ok(); } public abstract Task LoadContent(); diff --git a/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs b/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs index f4e3f7fa2..329116204 100644 --- a/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs +++ b/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs @@ -72,7 +72,7 @@ private async Task ShowAddFileDialogAsync() return Result.Fail($"Parent folder resource key '{DestFolderResource}' does not reference a folder resource."); } - var getDefaultResult = FindDefaultFileName(parentFolder); + var getDefaultResult = await FindDefaultFileNameAsync(parentFolder); if (getDefaultResult.IsFailure) { return Result.Fail() @@ -130,7 +130,7 @@ private async Task ShowAddFolderDialogAsync() return Result.Fail($"Parent folder resource key '{DestFolderResource}' does not reference a folder resource."); } - var getDefaultResult = FindDefaultFolderName(parentFolder); + var getDefaultResult = await FindDefaultFolderNameAsync(parentFolder); if (getDefaultResult.IsFailure) { return Result.Fail() @@ -174,9 +174,9 @@ private async Task ShowAddFolderDialogAsync() } /// - /// Find a default folder name that doesn't clash with an existing folder on disk. + /// Find a default folder name that doesn't clash with an existing folder on disk. /// - private Result FindDefaultFolderName(IFolderResource? parentFolder) + private async Task> FindDefaultFolderNameAsync(IFolderResource? parentFolder) { if (parentFolder is null) { @@ -184,14 +184,8 @@ private Result FindDefaultFolderName(IFolderResource? parentFolder) } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveParentFolderResult = resourceRegistry.ResolveResourcePath(parentFolder); - if (resolveParentFolderResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for parent folder") - .WithErrors(resolveParentFolderResult); - } - var parentFolderPath = resolveParentFolderResult.Value; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var parentFolderKey = resourceRegistry.GetResourceKey(parentFolder); string defaultFolderName = string.Empty; int folderNumber = 1; @@ -199,9 +193,10 @@ private Result FindDefaultFolderName(IFolderResource? parentFolder) { var candidateName = _stringLocalizer.GetString(DefaultFolderNameKey, folderNumber).ToString(); - var candidatePath = Path.Combine(parentFolderPath, candidateName); - if (!Directory.Exists(candidatePath) && - !File.Exists(candidatePath)) + var candidateKey = parentFolderKey.Combine(candidateName); + var infoResult = await fileSystem.GetInfoAsync(candidateKey); + if (infoResult.IsSuccess + && infoResult.Value.Kind == ResourceInfoKind.NotFound) { defaultFolderName = candidateName; break; @@ -213,10 +208,10 @@ private Result FindDefaultFolderName(IFolderResource? parentFolder) } /// - /// Find a default file name that doesn't clash with an existing file on disk. + /// Find a default file name that doesn't clash with an existing file on disk. /// Uses the previously saved file extension from settings. /// - private Result FindDefaultFileName(IFolderResource? parentFolder) + private async Task> FindDefaultFileNameAsync(IFolderResource? parentFolder) { if (parentFolder is null) { @@ -224,18 +219,13 @@ private Result FindDefaultFileName(IFolderResource? parentFolder) } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; var editorSettings = _serviceProvider.GetRequiredService(); // Get the previously saved extension var extension = editorSettings.PreviousNewFileExtension; - var resolveParentFolderResult = resourceRegistry.ResolveResourcePath(parentFolder); - if (resolveParentFolderResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for parent folder") - .WithErrors(resolveParentFolderResult); - } - var parentFolderPath = resolveParentFolderResult.Value; + var parentFolderKey = resourceRegistry.GetResourceKey(parentFolder); string defaultFileName = string.Empty; int fileNumber = 1; @@ -246,9 +236,10 @@ private Result FindDefaultFileName(IFolderResource? parentFolder) // Replace the default extension with the preferred extension candidateName = Path.ChangeExtension(candidateName, extension); - var candidatePath = Path.Combine(parentFolderPath, candidateName); - if (!Directory.Exists(candidatePath) && - !File.Exists(candidatePath)) + var candidateKey = parentFolderKey.Combine(candidateName); + var infoResult = await fileSystem.GetInfoAsync(candidateKey); + if (infoResult.IsSuccess + && infoResult.Value.Kind == ResourceInfoKind.NotFound) { defaultFileName = candidateName; break; diff --git a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs index 4f0881230..d799fd8b0 100644 --- a/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs +++ b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.DragDrop.cs @@ -193,7 +193,7 @@ private async Task ProcessExternalDrop(DataPackageView dataView, IFolderResource } var destFolderResource = _resourceRegistry.GetResourceKey(destFolder); - var createResult = _resourceTransferService.CreateResourceTransfer( + var createResult = await _resourceTransferService.CreateResourceTransferAsync( sourcePaths, destFolderResource, DataTransferMode.Copy); diff --git a/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs b/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs index a4f50ded9..9935f46a3 100644 --- a/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs +++ b/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs @@ -46,30 +46,30 @@ public Result CreateComponentListView(ResourceKey resource) } } - public Result CreateResourceInspector(ResourceKey resource) + public async Task> CreateResourceInspectorAsync(ResourceKey resource) { try { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveResult = resourceRegistry.ResolveResourcePath(resource); - if (resolveResult.IsFailure) + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var infoResult = await fileSystem.GetInfoAsync(resource); + if (infoResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") - .WithErrors(resolveResult); + return Result.Fail($"Failed to probe resource: '{resource}'") + .WithErrors(infoResult); } - var path = resolveResult.Value; + var info = infoResult.Value; - if (Directory.Exists(path)) + if (info.Kind == ResourceInfoKind.Folder) { return CreateFolderInspector(resource); } - if (File.Exists(path)) + if (info.Kind == ResourceInfoKind.File) { return CreateFileInspector(resource); } - return Result.Fail($"Resource not found at path: {path}"); + return Result.Fail($"Resource not found: '{resource}'"); } catch (Exception ex) { diff --git a/Source/Workspace/Celbridge.Inspector/Views/InspectorPanel.xaml.cs b/Source/Workspace/Celbridge.Inspector/Views/InspectorPanel.xaml.cs index bc378c802..386784312 100644 --- a/Source/Workspace/Celbridge.Inspector/Views/InspectorPanel.xaml.cs +++ b/Source/Workspace/Celbridge.Inspector/Views/InspectorPanel.xaml.cs @@ -53,15 +53,15 @@ private void UserControl_PointerPressed(object sender, PointerRoutedEventArgs e) _panelFocusService.SetFocusedPanel(WorkspacePanel.Inspector); } - private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + private async void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(ViewModel.SelectedResource)) { - UpdateSelectedResource(ViewModel.SelectedResource); + await UpdateSelectedResourceAsync(ViewModel.SelectedResource); } } - private void UpdateSelectedResource(ResourceKey resource) + private async Task UpdateSelectedResourceAsync(ResourceKey resource) { EntityEditor.ClearComponentListPanel(); @@ -86,7 +86,7 @@ private void UpdateSelectedResource(ResourceKey resource) inspectorElements.Add(nameInspector); // Optional resource inspector - var resourceInspectorResult = factory.CreateResourceInspector(resource); + var resourceInspectorResult = await factory.CreateResourceInspectorAsync(resource); if (resourceInspectorResult.IsSuccess) { var resourceInspector = resourceInspectorResult.Value as UserControl; diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs index 24c4855dc..e33c4f4ab 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs @@ -1,7 +1,9 @@ using Celbridge.Documents; using Celbridge.Logging; using Celbridge.Modules; +using Celbridge.Resources; using Celbridge.Settings; +using Celbridge.Workspace; namespace Celbridge.Packages; @@ -22,6 +24,7 @@ public class PackageRegistry private readonly IModuleService _moduleService; private readonly IFeatureFlags _featureFlags; private readonly IPackageLocalizationService _localizationService; + private readonly IWorkspaceWrapper _workspaceWrapper; private List _bundledPackages = []; private List _projectPackages = []; @@ -30,21 +33,23 @@ public PackageRegistry( ILogger logger, IModuleService moduleService, IFeatureFlags featureFlags, - IPackageLocalizationService localizationService) + IPackageLocalizationService localizationService, + IWorkspaceWrapper workspaceWrapper) { _logger = logger; _moduleService = moduleService; _featureFlags = featureFlags; _localizationService = localizationService; + _workspaceWrapper = workspaceWrapper; } - public PackageDiscoveryReport DiscoverPackages(string projectFolderPath) + public async Task DiscoverPackagesAsync(string projectFolderPath) { _bundledPackages.Clear(); _projectPackages.Clear(); var bundledFailures = DiscoverBundledPackages(); - var projectFailures = DiscoverProjectPackages(projectFolderPath); + var projectFailures = await DiscoverProjectPackagesAsync(projectFolderPath); var failures = new List(bundledFailures.Count + projectFailures.Count); failures.AddRange(bundledFailures); @@ -146,6 +151,11 @@ public IReadOnlyList GetDocumentTypes() return documentTypes.AsReadOnly(); } + // Template files are read from contribution.Package.PackageFolder, which is + // an absolute path that may live inside the project tree (project packages) + // or outside it (bundled packages shipped with module DLLs). The contribution + // does not carry a bundled/project tag, so the read stays on direct I/O until + // package origin is tracked. public byte[]? GetDefaultTemplateContent(string fileExtension) { var normalizedExtension = fileExtension.ToLowerInvariant(); @@ -263,7 +273,7 @@ private List DiscoverBundledPackages() return failures; } - private List DiscoverProjectPackages(string projectFolderPath) + private async Task> DiscoverProjectPackagesAsync(string projectFolderPath) { var failures = new List(); @@ -272,25 +282,50 @@ private List DiscoverProjectPackages(string projectFolderPat return failures; } - var packagesFolder = Path.Combine(projectFolderPath, PackagesFolderName); - if (!Directory.Exists(packagesFolder)) + var packagesResource = new ResourceKey(PackagesFolderName); + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + + var packagesInfoResult = await fileSystem.GetInfoAsync(packagesResource); + if (packagesInfoResult.IsFailure + || packagesInfoResult.Value.Kind != ResourceInfoKind.Folder) { return failures; } - var packageFolders = Directory.GetDirectories(packagesFolder); + var enumerateResult = await fileSystem.EnumerateFolderAsync(packagesResource); + if (enumerateResult.IsFailure) + { + return failures; + } + + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var candidates = new List(); - foreach (var packageFolder in packageFolders) + foreach (var item in enumerateResult.Value) { - var manifestPath = Path.Combine(packageFolder, ManifestFileName); - if (!File.Exists(manifestPath)) + if (!item.IsFolder) + { + continue; + } + + var manifestResource = item.Resource.Combine(ManifestFileName); + var manifestInfoResult = await fileSystem.GetInfoAsync(manifestResource); + if (manifestInfoResult.IsFailure + || manifestInfoResult.Value.Kind != ResourceInfoKind.File) { // A folder under packages/ with no manifest is not a package. // Silently skip rather than report as a failure. continue; } + var resolveResult = resourceRegistry.ResolveResourcePath(manifestResource); + if (resolveResult.IsFailure) + { + continue; + } + var manifestPath = resolveResult.Value; + var packageFolder = Path.GetDirectoryName(manifestPath)!; + var loadResult = PackageManifestLoader.LoadPackage(manifestPath, hostNameOverride: null, secrets: null); if (loadResult.IsFailure) { diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageService.cs b/Source/Workspace/Celbridge.Packages/Services/PackageService.cs index d7e44913e..bb8123f83 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageService.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageService.cs @@ -4,6 +4,7 @@ using Celbridge.Messaging; using Celbridge.Modules; using Celbridge.Settings; +using Celbridge.Workspace; namespace Celbridge.Packages; @@ -20,15 +21,16 @@ public PackageService( IModuleService moduleService, IMessengerService messengerService, IFeatureFlags featureFlags, - IPackageLocalizationService localizationService) + IPackageLocalizationService localizationService, + IWorkspaceWrapper workspaceWrapper) { _messengerService = messengerService; - _registry = new PackageRegistry(logger, moduleService, featureFlags, localizationService); + _registry = new PackageRegistry(logger, moduleService, featureFlags, localizationService, workspaceWrapper); } - public void RegisterPackages(string projectFolderPath) + public async Task RegisterPackagesAsync(string projectFolderPath) { - var report = _registry.DiscoverPackages(projectFolderPath); + var report = await _registry.DiscoverPackagesAsync(projectFolderPath); if (report.Failures.Count > 0) { diff --git a/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs index 52ce490d8..7485508f8 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ApplyRangeEditsCommand.cs @@ -91,15 +91,9 @@ private static async Task ApplyEditsToDisk( ResourceKey resource, List edits) { - var resolveResult = resourceRegistry.ResolveResourcePath(resource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") - .WithErrors(resolveResult); - } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + var infoResult = await fileSystem.GetInfoAsync(resource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { return Result.Fail($"File not found: '{resource}'"); } @@ -107,7 +101,13 @@ private static async Task ApplyEditsToDisk( // Read the file's existing content to capture its line-ending style and // trailing-newline state. Both must be preserved across the edit so the // file's on-disk format does not silently drift. - var originalContent = await File.ReadAllTextAsync(resourcePath); + var readResult = await fileSystem.ReadAllTextAsync(resource); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read file: '{resource}'") + .WithErrors(readResult); + } + var originalContent = readResult.Value; var originalSeparator = LineEndingHelper.DetectSeparatorOrDefault(originalContent); var originalEndsWithNewline = LineEndingHelper.EndsWithNewline(originalContent); diff --git a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs index 489503469..3764afecb 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs @@ -2,7 +2,6 @@ using Celbridge.Commands; using Celbridge.Logging; using Celbridge.Resources.Helpers; -using Celbridge.Resources.Services; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -49,6 +48,41 @@ public override async Task ExecuteAsync() return result; } + // Recursive walk via the chokepoint to collect every descendant file + // together with the relative archive entry name. Mirrors the prior + // Directory.GetFiles(..., AllDirectories) traversal but routes through + // EnumerateFolderAsync so the read side honours the same containment + // validation as the write side. + private static async Task CollectArchiveEntriesAsync( + IResourceFileSystem fileSystem, + ResourceKey folder, + string relativePrefix, + List<(ResourceKey Resource, string RelativePath)> entries) + { + var enumerateResult = await fileSystem.EnumerateFolderAsync(folder); + if (enumerateResult.IsFailure) + { + return; + } + + foreach (var item in enumerateResult.Value) + { + var name = item.Resource.ResourceName; + var childRelative = string.IsNullOrEmpty(relativePrefix) + ? name + : $"{relativePrefix}/{name}"; + + if (item.IsFolder) + { + await CollectArchiveEntriesAsync(fileSystem, item.Resource, childRelative, entries); + } + else + { + entries.Add((item.Resource, childRelative)); + } + } + } + private async Task ExecuteArchiveAsync() { if (!_workspaceWrapper.IsWorkspacePageLoaded) @@ -59,6 +93,7 @@ private async Task ExecuteArchiveAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var fileSystem = workspaceService.ResourceFileSystem; if (!ResourceKey.IsValidKey(SourceResource)) { @@ -86,15 +121,25 @@ private async Task ExecuteArchiveAsync() } var archivePath = resolveArchiveResult.Value; - bool isFile = File.Exists(sourcePath); - bool isFolder = Directory.Exists(sourcePath); + var sourceInfoResult = await fileSystem.GetInfoAsync(SourceResource); + if (sourceInfoResult.IsFailure) + { + return Result.Fail($"Failed to probe source resource: '{SourceResource}'") + .WithErrors(sourceInfoResult); + } + bool isFile = sourceInfoResult.Value.Kind == ResourceInfoKind.File; + bool isFolder = sourceInfoResult.Value.Kind == ResourceInfoKind.Folder; if (!isFile && !isFolder) { return Result.Fail($"Resource not found: '{SourceResource}'"); } - if (!Overwrite && File.Exists(archivePath)) + var archiveInfoResult = await fileSystem.GetInfoAsync(ArchiveResource); + bool archiveExists = archiveInfoResult.IsSuccess + && archiveInfoResult.Value.Kind == ResourceInfoKind.File; + + if (!Overwrite && archiveExists) { return Result.Fail($"Archive already exists: '{ArchiveResource}'. Set overwrite to true to replace it."); } @@ -103,7 +148,7 @@ private async Task ExecuteArchiveAsync() var excludeRegexes = ArchiveHelper.ParseGlobPatterns(Exclude); // If overwriting, delete the existing file first so it can be restored on undo - if (Overwrite && File.Exists(archivePath)) + if (Overwrite && archiveExists) { var deleteResult = await resourceOpService.DeleteFileAsync(archivePath); if (deleteResult.IsFailure) @@ -123,57 +168,55 @@ private async Task ExecuteArchiveAsync() { if (isFile) { - var fileName = Path.GetFileName(sourcePath); + var fileName = SourceResource.ResourceName; if (ArchiveHelper.ShouldIncludeFile(fileName, includeRegexes, excludeRegexes)) { - await ArchiveHelper.AddFileToArchiveAsync(zipArchive, sourcePath, fileName); + var addResult = await ArchiveHelper.AddFileToArchiveAsync(zipArchive, fileSystem, SourceResource, fileName); + if (addResult.IsFailure) + { + return addResult; + } entryCount++; } } else { - var filePaths = Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories); + var fileEntries = new List<(ResourceKey Resource, string RelativePath)>(); + await CollectArchiveEntriesAsync(fileSystem, SourceResource, string.Empty, fileEntries); - foreach (var filePath in filePaths) + foreach (var (fileResource, relativePath) in fileEntries) { - var fileAttributes = File.GetAttributes(filePath); - if (fileAttributes.HasFlag(System.IO.FileAttributes.ReparsePoint)) + if (!ArchiveHelper.ShouldIncludeFile(relativePath, includeRegexes, excludeRegexes)) { continue; } - var relativePath = Path.GetRelativePath(sourcePath, filePath); - var entryName = relativePath.Replace('\\', '/'); - - if (!ArchiveHelper.ShouldIncludeFile(entryName, includeRegexes, excludeRegexes)) + var addResult = await ArchiveHelper.AddFileToArchiveAsync(zipArchive, fileSystem, fileResource, relativePath); + if (addResult.IsFailure) { - continue; + return addResult; } - - await ArchiveHelper.AddFileToArchiveAsync(zipArchive, filePath, entryName); entryCount++; } } } - // Read the temp file and register it as an undoable create operation + // Read the temp file and register it as an undoable create operation. + // The temp file lives under the OS temp folder, outside the project + // tree, so the chokepoint contract does not apply. var archiveBytes = await File.ReadAllBytesAsync(tempPath); - // Ensure parent folder exists - var archiveFolder = Path.GetDirectoryName(archivePath); - if (!string.IsNullOrEmpty(archiveFolder) && !Directory.Exists(archiveFolder)) - { - Directory.CreateDirectory(archiveFolder); - } - var createResult = await resourceOpService.CreateFileAsync(archivePath, archiveBytes); if (createResult.IsFailure) { return createResult; } - var archiveSize = new FileInfo(archivePath).Length; + var archiveProbeResult = await fileSystem.GetInfoAsync(ArchiveResource); + long archiveSize = archiveProbeResult.IsSuccess + ? archiveProbeResult.Value.Size + : archiveBytes.Length; ResultValue = new ArchiveResult { diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 04c4eafb7..258d4f14e 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -68,6 +68,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var fileSystem = workspaceService.ResourceFileSystem; var transferService = workspaceService.ResourceService.TransferService; // Filter out resources whose parent folders are also selected. @@ -88,7 +89,7 @@ public override async Task ExecuteAsync() { foreach (var sourceResource in filteredResources) { - var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, transferService, resourceOpService); + var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, fileSystem, transferService, resourceOpService); if (outcome.Result.IsFailure) { @@ -184,6 +185,7 @@ public override async Task ExecuteAsync() private async Task CopySingleResourceAsync( ResourceKey sourceResource, IResourceRegistry resourceRegistry, + IResourceFileSystem fileSystem, IResourceTransferService transferService, IResourceOperationService resourceOpService) { @@ -217,9 +219,19 @@ private async Task CopySingleResourceAsync( } var destPath = resolveDestResult.Value; - // Determine resource type - bool isFile = File.Exists(sourcePath); - bool isFolder = Directory.Exists(sourcePath); + var infoResult = await fileSystem.GetInfoAsync(sourceResource); + if (infoResult.IsFailure) + { + return new CopyResourceOutcome( + Result.Fail($"Failed to probe source resource: '{sourceResource}'") + .WithErrors(infoResult), + ParentFolder: null, + CopiedFolder: null, + MoveDetail: null); + } + var info = infoResult.Value; + bool isFile = info.Kind == ResourceInfoKind.File; + bool isFolder = info.Kind == ResourceInfoKind.Folder; if (!isFile && !isFolder) { diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index d40ab6ccb..286afcb47 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -56,6 +56,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var fileSystem = workspaceService.ResourceFileSystem; var scanner = workspaceService.ResourceScanner; // Phase A: aggregate referencers external to the batch. References @@ -68,7 +69,7 @@ public override async Task ExecuteAsync() var folderResources = new List(); foreach (var resource in Resources) { - if (IsFolderResource(resourceRegistry, resource)) + if (await IsFolderResourceAsync(fileSystem, resource)) { folderResources.Add(resource); } @@ -189,14 +190,28 @@ bool IsInsideBatch(ResourceKey candidate) } var resourcePath = resolveResult.Value; - bool sidecarPresent = SidecarExistsForResource(workspaceService, resource); + bool sidecarPresent = await SidecarExistsForResourceAsync(workspaceService, fileSystem, resource); + + var infoResult = await fileSystem.GetInfoAsync(resource); + if (infoResult.IsFailure) + { + _logger.LogWarning($"Cannot delete resource because info probe failed: '{resource}'"); + resourceResults.Add(new DeleteResourceResult( + resource, + DeleteResourceOutcome.IOFailure, + SidecarOutcome.NotPresent, + FailureMessage: infoResult.FirstErrorMessage)); + failedItems.Add(resource.ResourceName); + continue; + } + var info = infoResult.Value; Result deleteResult; - if (File.Exists(resourcePath)) + if (info.Kind == ResourceInfoKind.File) { deleteResult = await resourceOpService.DeleteFileAsync(resourcePath); } - else if (Directory.Exists(resourcePath)) + else if (info.Kind == ResourceInfoKind.Folder) { deleteResult = await resourceOpService.DeleteFolderAsync(resourcePath); } @@ -309,17 +324,17 @@ private static (DeleteResourceOutcome Outcome, string Message) ClassifyDeleteFai return (DeleteResourceOutcome.IOFailure, deleteResult.FirstErrorMessage); } - private static bool IsFolderResource(IResourceRegistry registry, ResourceKey resource) + private static async Task IsFolderResourceAsync(IResourceFileSystem fileSystem, ResourceKey resource) { - var resolveResult = registry.ResolveResourcePath(resource); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(resource); + if (infoResult.IsFailure) { return false; } - return Directory.Exists(resolveResult.Value); + return infoResult.Value.Kind == ResourceInfoKind.Folder; } - private static bool SidecarExistsForResource(IWorkspaceService workspaceService, ResourceKey resource) + private static async Task SidecarExistsForResourceAsync(IWorkspaceService workspaceService, IResourceFileSystem fileSystem, ResourceKey resource) { var sidecarKeyResult = workspaceService.SidecarService.GetSidecarKey(resource); if (sidecarKeyResult.IsFailure) @@ -327,13 +342,12 @@ private static bool SidecarExistsForResource(IWorkspaceService workspaceService, return false; } - var resolveResult = workspaceService.ResourceService.Registry.ResolveResourcePath(sidecarKeyResult.Value); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(sidecarKeyResult.Value); + if (infoResult.IsFailure) { return false; } - - return File.Exists(resolveResult.Value); + return infoResult.Value.Kind == ResourceInfoKind.File; } private static string BuildConfirmationMessage( diff --git a/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs index cd2e389d7..ebed3e8eb 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs @@ -33,23 +33,22 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { - return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") - .WithErrors(resolveResult); + return Result.Fail($"File not found: '{FileResource}'"); } - var resourcePath = resolveResult.Value; - if (!File.Exists(resourcePath)) + var readResult = await fileSystem.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) { - return Result.Fail($"File not found: '{FileResource}'"); + return Result.Fail($"Failed to read file: '{FileResource}'") + .WithErrors(readResult); } - - var content = await File.ReadAllTextAsync(resourcePath); + var content = readResult.Value; var separator = LineEndingHelper.DetectSeparatorOrDefault(content); var oldString = LineEndingHelper.ConvertLineEndings(OldString, separator); var newString = LineEndingHelper.ConvertLineEndings(NewString, separator); diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs index e2eefeaf3..748c6e9e8 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFieldCommand.cs @@ -49,7 +49,9 @@ public override async Task ExecuteAsync() if (!read.Content!.Frontmatter.TryGetValue(Field, out var value)) { - return Result.Fail($"Field '{Field}' is not set on resource '{Resource}'."); + return Result.Fail( + $"Field '{Field}' is not set on resource '{Resource}'. " + + "Use data_get_info to see the fields currently set on this resource."); } ResultValue = value; diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs index b525ca43d..f1a55b06b 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs @@ -35,9 +35,9 @@ public GetFileInfoCommand( public override async Task ExecuteAsync() { - await Task.CompletedTask; - - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var workspaceService = _workspaceWrapper.WorkspaceService; + var resourceRegistry = workspaceService.ResourceService.Registry; + var fileSystem = workspaceService.ResourceFileSystem; var resolveResult = resourceRegistry.ResolveResourcePath(Resource); if (resolveResult.IsFailure) @@ -46,15 +46,25 @@ public override async Task ExecuteAsync() } var resourcePath = resolveResult.Value; - if (File.Exists(resourcePath)) + var infoResult = await fileSystem.GetInfoAsync(Resource); + if (infoResult.IsFailure) + { + return Result.Fail($"Failed to probe resource: '{Resource}'") + .WithErrors(infoResult); + } + var info = infoResult.Value; + + if (info.Kind == ResourceInfoKind.File) { - var fileInfo = new FileInfo(resourcePath); - var isText = IsTextFile(_textBinarySniffer, resourcePath); + var extension = Path.GetExtension(resourcePath); + var isText = !_textBinarySniffer.IsBinaryExtension(extension) + && _textBinarySniffer.IsTextFile(resourcePath).IsSuccess + && _textBinarySniffer.IsTextFile(resourcePath).Value; int? lineCount = null; if (isText) { - lineCount = File.ReadAllLines(resourcePath).Length; + lineCount = await CountLinesAsync(fileSystem, Resource); } // Surface the paired sidecar's key and current parse state when @@ -74,9 +84,9 @@ public override async Task ExecuteAsync() ResultValue = new FileInfoSnapshot( Exists: true, IsFile: true, - Size: fileInfo.Length, - ModifiedUtc: fileInfo.LastWriteTimeUtc, - Extension: fileInfo.Extension, + Size: info.Size, + ModifiedUtc: info.ModifiedUtc, + Extension: extension, IsText: isText, LineCount: lineCount, SidecarKey: sidecarKey, @@ -85,15 +95,13 @@ public override async Task ExecuteAsync() return Result.Ok(); } - if (Directory.Exists(resourcePath)) + if (info.Kind == ResourceInfoKind.Folder) { - var directoryInfo = new DirectoryInfo(resourcePath); - ResultValue = new FileInfoSnapshot( Exists: true, IsFile: false, Size: 0, - ModifiedUtc: directoryInfo.LastWriteTimeUtc, + ModifiedUtc: info.ModifiedUtc, Extension: string.Empty, IsText: false, LineCount: null, @@ -106,15 +114,24 @@ public override async Task ExecuteAsync() return Result.Fail($"Resource not found: '{Resource}'"); } - private static bool IsTextFile(ITextBinarySniffer textBinarySniffer, string filePath) + // Streams the file via the chokepoint and counts lines without loading + // the entire content into memory. Used for the LineCount field on the + // FileInfoSnapshot when the resource is text. + private static async Task CountLinesAsync(IResourceFileSystem fileSystem, ResourceKey resource) { - var extension = Path.GetExtension(filePath); - if (textBinarySniffer.IsBinaryExtension(extension)) + var openResult = await fileSystem.OpenReadAsync(resource); + if (openResult.IsFailure) { - return false; + return 0; } - var result = textBinarySniffer.IsTextFile(filePath); - return result.IsSuccess && result.Value; + int count = 0; + await using var stream = openResult.Value; + using var reader = new StreamReader(stream); + while (await reader.ReadLineAsync() is not null) + { + count++; + } + return count; } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs index 8315fb86e..492b721d5 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs @@ -38,23 +38,22 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); - if (resolveResult.IsFailure) + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { - return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") - .WithErrors(resolveResult); + return Result.Fail($"File not found: '{FileResource}'"); } - var resourcePath = resolveResult.Value; - if (!File.Exists(resourcePath)) + var readResult = await fileSystem.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) { - return Result.Fail($"File not found: '{FileResource}'"); + return Result.Fail($"Failed to read file: '{FileResource}'") + .WithErrors(readResult); } - - var originalContent = await File.ReadAllTextAsync(resourcePath); + var originalContent = readResult.Value; var separator = LineEndingHelper.DetectSeparatorOrDefault(originalContent); // Sequential application: each edit anchors against the buffer state diff --git a/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs index f24d088b4..228e76ab5 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs @@ -38,28 +38,27 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") - .WithErrors(resolveResult); - } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { return Result.Fail($"File not found: '{FileResource}'"); } - return await ReplaceOnDisk(fileSystem, resourcePath); + return await ReplaceOnDisk(fileSystem); } - private async Task ReplaceOnDisk(IResourceFileSystem fileSystem, string resourcePath) + private async Task ReplaceOnDisk(IResourceFileSystem fileSystem) { - var content = await File.ReadAllTextAsync(resourcePath); + var readResult = await fileSystem.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read file: '{FileResource}'") + .WithErrors(readResult); + } + var content = readResult.Value; // Match positions in the post-edit buffer plus the actual substituted // text for each match. Regex back-references can make every match's diff --git a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs index 7e8eaf4f2..bd6d268a3 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs @@ -56,6 +56,7 @@ private async Task ExecuteExtractAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; + var fileSystem = workspaceService.ResourceFileSystem; if (!ResourceKey.IsValidKey(ArchiveResource)) { @@ -83,7 +84,9 @@ private async Task ExecuteExtractAsync() } var destinationPath = resolveDestinationResult.Value; - if (!File.Exists(archivePath)) + var archiveInfoResult = await fileSystem.GetInfoAsync(ArchiveResource); + if (archiveInfoResult.IsFailure + || archiveInfoResult.Value.Kind != ResourceInfoKind.File) { return Result.Fail($"Archive not found: '{ArchiveResource}'"); } @@ -93,7 +96,13 @@ private async Task ExecuteExtractAsync() try { - using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var openArchiveResult = await fileSystem.OpenReadAsync(ArchiveResource); + if (openArchiveResult.IsFailure) + { + return Result.Fail($"Failed to open archive: '{ArchiveResource}'") + .WithErrors(openArchiveResult); + } + await using var fileStream = openArchiveResult.Value; using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read); // First pass: validate all entries and collect folder paths @@ -143,11 +152,17 @@ private async Task ExecuteExtractAsync() "Aborting extraction."); } - if (!Overwrite && File.Exists(outputPath)) + if (!Overwrite) { - return Result.Fail( - $"File already exists: '{DestinationResource}/{entryName}'. " + - "Set overwrite to true to replace existing files."); + var entryResource = DestinationResource.Combine(entryName); + var existingInfoResult = await fileSystem.GetInfoAsync(entryResource); + if (existingInfoResult.IsSuccess + && existingInfoResult.Value.Kind == ResourceInfoKind.File) + { + return Result.Fail( + $"File already exists: '{DestinationResource}/{entryName}'. " + + "Set overwrite to true to replace existing files."); + } } validEntries.Add(entry); @@ -165,13 +180,31 @@ private async Task ExecuteExtractAsync() try { - // Create the destination folder if it doesn't exist + // Create the destination folder if it doesn't exist. Any + // missing ancestor folders above the destination are created + // through the operation service too so the entire chain lands + // in the unarchive's undo batch; the earlier direct + // Directory.CreateDirectory bypassed undo and watcher + // coordination. if (!Directory.Exists(destinationPath)) { - var parentFolder = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentFolder) && !Directory.Exists(parentFolder)) + var missingAncestors = new List(); + var ancestor = Path.GetDirectoryName(destinationPath); + while (!string.IsNullOrEmpty(ancestor) + && !Directory.Exists(ancestor)) { - Directory.CreateDirectory(parentFolder); + missingAncestors.Add(ancestor); + ancestor = Path.GetDirectoryName(ancestor); + } + missingAncestors.Reverse(); + + foreach (var ancestorPath in missingAncestors) + { + var createAncestorResult = await resourceOpService.CreateFolderAsync(ancestorPath); + if (createAncestorResult.IsFailure) + { + return createAncestorResult; + } } var createDestResult = await resourceOpService.CreateFolderAsync(destinationPath); @@ -202,12 +235,18 @@ private async Task ExecuteExtractAsync() var outputPath = Path.Combine(destinationPath, entry.FullName.Replace('/', Path.DirectorySeparatorChar)); // If overwriting, delete existing file first so it's preserved in trash for undo - if (Overwrite && File.Exists(outputPath)) + if (Overwrite) { - var deleteResult = await resourceOpService.DeleteFileAsync(outputPath); - if (deleteResult.IsFailure) + var entryResource = DestinationResource.Combine(entry.FullName); + var existingInfoResult = await fileSystem.GetInfoAsync(entryResource); + if (existingInfoResult.IsSuccess + && existingInfoResult.Value.Kind == ResourceInfoKind.File) { - return deleteResult; + var deleteResult = await resourceOpService.DeleteFileAsync(outputPath); + if (deleteResult.IsFailure) + { + return deleteResult; + } } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs index adcf6bb91..9d062b796 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs @@ -29,30 +29,28 @@ public WriteFileCommand( public override async Task ExecuteAsync() { var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; var fileSystem = workspaceService.ResourceFileSystem; - var resolveResult = resourceRegistry.ResolveResourcePath(FileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{FileResource}'") - .WithErrors(resolveResult); - } - var resourcePath = resolveResult.Value; - // Preserve existing line endings when overwriting. For a new file, // honour whatever endings the caller's content already uses (so a CSV // exporter emitting CRLF lands as CRLF on disk); fall back to the // platform default when the content has no line endings to detect. string targetSeparator; - if (!File.Exists(resourcePath)) + var infoResult = await fileSystem.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(Content); } else { - var existingContent = await File.ReadAllTextAsync(resourcePath); - targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(existingContent); + var readResult = await fileSystem.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read existing file: '{FileResource}'") + .WithErrors(readResult); + } + targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(readResult.Value); } var contentToWrite = LineEndingHelper.ConvertLineEndings(Content, targetSeparator); diff --git a/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs index 103eed00e..e1bda34be 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs @@ -26,15 +26,29 @@ public static bool IsUnixSymlink(ZipArchiveEntry entry) } /// - /// Adds a file to a zip archive under the specified entry name. + /// Adds a project-tree file to a zip archive under the specified entry name, + /// reading the source through the chokepoint so containment validation + /// applies uniformly to archive sources. /// - public static async Task AddFileToArchiveAsync(ZipArchive zipArchive, string filePath, string entryName) + public static async Task AddFileToArchiveAsync( + ZipArchive zipArchive, + IResourceFileSystem fileSystem, + ResourceKey sourceResource, + string entryName) { + var openResult = await fileSystem.OpenReadAsync(sourceResource); + if (openResult.IsFailure) + { + return Result.Fail($"Failed to read source file for archive: '{sourceResource}'") + .WithErrors(openResult); + } + var entry = zipArchive.CreateEntry(entryName, CompressionLevel.Optimal); using var entryStream = entry.Open(); - using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using var fileStream = openResult.Value; await fileStream.CopyToAsync(entryStream); + return Result.Ok(); } /// diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index a0775651e..6107073e3 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -459,20 +459,54 @@ private static IReadOnlyList EnumerateDescendantKeys(IRootHandlerRe return keys; } - public async Task> ExistsAsync(ResourceKey resource) + public async Task> GetInfoAsync(ResourceKey resource) { await Task.CompletedTask; var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); } var resourcePath = resolveResult.Value; - var exists = File.Exists(resourcePath) || Directory.Exists(resourcePath); - return exists; + try + { + // File.Exists, FileInfo.Length, and FileInfo.LastWriteTimeUtc share + // the same underlying stat() call; populating the rich record costs + // no more than a plain existence probe. + var fileInfo = new FileInfo(resourcePath); + if (fileInfo.Exists) + { + var fileResult = new ResourceInfo( + Kind: ResourceInfoKind.File, + Size: fileInfo.Length, + ModifiedUtc: fileInfo.LastWriteTimeUtc); + return fileResult; + } + + var directoryInfo = new DirectoryInfo(resourcePath); + if (directoryInfo.Exists) + { + var folderResult = new ResourceInfo( + Kind: ResourceInfoKind.Folder, + Size: 0, + ModifiedUtc: directoryInfo.LastWriteTimeUtc); + return folderResult; + } + + var notFoundResult = new ResourceInfo( + Kind: ResourceInfoKind.NotFound, + Size: 0, + ModifiedUtc: default); + return notFoundResult; + } + catch (Exception ex) + { + return Result.Fail($"Failed to get info for resource: '{resource}'") + .WithException(ex); + } } public async Task>> EnumerateFolderAsync(ResourceKey folder) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs index 71292bc9c..6efa3ce71 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -277,9 +277,9 @@ await Parallel.ForEachAsync(files, async (file, _) => return; } - var existsResult = await fileSystem.ExistsAsync(parentKey.Value); - if (existsResult.IsFailure - || !existsResult.Value) + var infoResult = await fileSystem.GetInfoAsync(parentKey.Value); + if (infoResult.IsFailure + || infoResult.Value.Kind == ResourceInfoKind.NotFound) { return; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs index 669b130f1..dc8d74a74 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs @@ -24,9 +24,9 @@ public ResourceTransferService( _workspaceWrapper = workspaceWrapper; } - public Result CreateResourceTransfer(List sourcePaths, ResourceKey destFolderResource, DataTransferMode transferMode) + public async Task> CreateResourceTransferAsync(List sourcePaths, ResourceKey destFolderResource, DataTransferMode transferMode) { - var createItemsResult = CreateResourceTransferItems(sourcePaths, destFolderResource); + var createItemsResult = await CreateResourceTransferItemsAsync(sourcePaths, destFolderResource); if (createItemsResult.IsFailure) { return Result.Fail($"Failed to create resource transfer items.") @@ -43,7 +43,7 @@ public Result CreateResourceTransfer(List sourcePaths return Result.Ok(resourceTransfer); } - private Result> CreateResourceTransferItems(List sourcePaths, ResourceKey destFolderResource) + private async Task>> CreateResourceTransferItemsAsync(List sourcePaths, ResourceKey destFolderResource) { try { @@ -56,7 +56,11 @@ private Result> CreateResourceTransferItems(List>.Fail($"The path '{destFolderPath}' does not exist."); } diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs index 4ffe8181d..eb5dbca87 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs @@ -163,16 +163,64 @@ private IDocumentEditorRegistry ResolveEditorRegistry() return _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry; } - // Checks whether a parentless .cel file is claimed by a registered factory. - // Delegates to GetFactory so filename-only registrations (e.g. package.cel) - // and multi-part extensions (e.g. foo.webview.cel) are matched the same way - // the editor open path matches them. + // Checks whether a parentless .cel file is claimed by a registered factory + // in a way that denotes a standalone form. Two registration shapes count: + // an exact-filename match (e.g. "package.cel"), or a multi-part extension + // suffix that includes a segment in front of ".cel" (e.g. ".webview.cel", + // ".note.cel"). The bare ".cel" extension is excluded: it also serves the + // generic code-editor syntax-highlighting registration, which says nothing + // about pairing semantics. Without that exclusion every parentless ".cel" + // would silently disappear from the orphan report. private static bool IsRegisteredStandaloneCelForm( ResourceKey sidecarKey, IDocumentEditorRegistry editorRegistry) { - var factoryResult = editorRegistry.GetFactory(sidecarKey); - return factoryResult.IsSuccess; + var fileName = sidecarKey.ResourceName; + + if (editorRegistry.IsFilenameSupported(fileName)) + { + return true; + } + + foreach (var suffix in EnumerateExtensionSuffixes(fileName)) + { + if (string.Equals(suffix, ".cel", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (editorRegistry.IsExtensionSupported(suffix)) + { + return true; + } + } + + return false; } + // Yields each extension suffix of a filename from longest to shortest. + // Mirrors the walk in DocumentEditorRegistry so the pairing check sees the + // same suffix set as the editor open path. A leading dot is skipped so a + // dotfile is not treated as one giant extension. + private static IEnumerable EnumerateExtensionSuffixes(string fileName) + { + int searchFrom = 0; + if (fileName.Length > 0 + && fileName[0] == '.') + { + searchFrom = 1; + } + + while (searchFrom < fileName.Length) + { + int dotIndex = fileName.IndexOf('.', searchFrom); + if (dotIndex < 0) + { + yield break; + } + + yield return fileName.Substring(dotIndex); + searchFrom = dotIndex + 1; + } + } } diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs index 9c0940a1e..7b8d5241b 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -57,9 +57,9 @@ public async Task> ReadAsync(ResourceKey resource) var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var existsResult = await fileSystem.ExistsAsync(sidecarKey); - if (existsResult.IsFailure - || !existsResult.Value) + var infoResult = await fileSystem.GetInfoAsync(sidecarKey); + if (infoResult.IsFailure + || infoResult.Value.Kind == ResourceInfoKind.NotFound) { return Result.Ok(new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)); } diff --git a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs index 9dee0aebc..77f52d805 100644 --- a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs +++ b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs @@ -1,3 +1,4 @@ +using Celbridge.Resources; using Path = System.IO.Path; namespace Celbridge.Search; @@ -23,19 +24,21 @@ public FileFilter(ITextBinarySniffer textBinarySniffer) } /// - /// Checks if a file should be included in search based on its path. + /// Checks if a file should be included in search. Routes the file probe + /// through the chokepoint so the size check honours the same containment + /// validation as the read that follows. /// - public bool ShouldSearchFile(string filePath) + public async Task ShouldSearchFileAsync(IResourceFileSystem fileSystem, ResourceKey resource, string filePath) { - if (!File.Exists(filePath)) + var infoResult = await fileSystem.GetInfoAsync(resource); + if (infoResult.IsFailure + || infoResult.Value.Kind != ResourceInfoKind.File) { return false; } - var fileInfo = new FileInfo(filePath); - // Skip large files - if (fileInfo.Length > MaxFileSizeBytes) + if (infoResult.Value.Size > MaxFileSizeBytes) { return false; } diff --git a/Source/Workspace/Celbridge.Search/Services/SearchService.cs b/Source/Workspace/Celbridge.Search/Services/SearchService.cs index f4186ced6..891baf8dd 100644 --- a/Source/Workspace/Celbridge.Search/Services/SearchService.cs +++ b/Source/Workspace/Celbridge.Search/Services/SearchService.cs @@ -65,6 +65,7 @@ public async Task SearchAsync( } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; var projectFolder = resourceRegistry.ProjectFolderPath; if (string.IsNullOrEmpty(projectFolder)) @@ -123,45 +124,60 @@ public async Task SearchAsync( if (!string.IsNullOrEmpty(scope)) { + // Canonicalize the scope so callers can pass a bare path + // ("Data") or the fully-prefixed form ("project:Data") + // interchangeably, matching the convention used by every other + // resource-addressing tool. The comparison runs against the + // canonical ":" form of each candidate, so a bare + // scope without a root prefix never matched otherwise. + string canonicalScope; + if (ResourceKey.TryCreate(scope, out var scopeKey)) + { + canonicalScope = scopeKey.ToString(); + } + else + { + canonicalScope = scope; + } + fileResources = fileResources - .Where(entry => entry.Resource.ToString().StartsWith(scope, StringComparison.OrdinalIgnoreCase)) + .Where(entry => entry.Resource.ToString().StartsWith(canonicalScope, StringComparison.OrdinalIgnoreCase)) .ToList(); } - await Task.Run(() => + cancellationToken.ThrowIfCancellationRequested(); + foreach (var (resource, filePath) in fileResources) { - foreach (var (resource, filePath) in fileResources) + cancellationToken.ThrowIfCancellationRequested(); + + if (maxResults.HasValue && searchState.TotalMatches >= maxResults.Value) { - cancellationToken.ThrowIfCancellationRequested(); + searchState.ReachedMaxResults = true; + break; + } - if (maxResults.HasValue && searchState.TotalMatches >= maxResults.Value) - { - searchState.ReachedMaxResults = true; - break; - } + var remainingMatches = maxResults.HasValue + ? maxResults.Value - searchState.TotalMatches + : int.MaxValue; - var remainingMatches = maxResults.HasValue - ? maxResults.Value - searchState.TotalMatches - : int.MaxValue; - - var fileResult = SearchFile( - filePath, - projectFolder, - resource, - searchTerm, - matchCase, - wholeWord, - remainingMatches, - cancellationToken, - searchRegex); - - if (fileResult != null && fileResult.Matches.Count > 0) - { - fileResults.Add(fileResult); - searchState.TotalMatches += fileResult.Matches.Count; - } + var fileResult = await SearchFileAsync( + fileSystem, + filePath, + projectFolder, + resource, + searchTerm, + matchCase, + wholeWord, + remainingMatches, + cancellationToken, + searchRegex); + + if (fileResult != null && fileResult.Matches.Count > 0) + { + fileResults.Add(fileResult); + searchState.TotalMatches += fileResult.Matches.Count; } - }, cancellationToken); + } } catch (OperationCanceledException) { @@ -175,7 +191,8 @@ await Task.Run(() => return new SearchResults(searchTerm, fileResults, searchState.TotalMatches, fileResults.Count, false, searchState.ReachedMaxResults); } - private SearchFileResult? SearchFile( + private async Task SearchFileAsync( + IResourceFileSystem fileSystem, string filePath, string rootDirectory, ResourceKey resourceKey, @@ -189,7 +206,7 @@ await Task.Run(() => try { // Check if file should be searched (size, extension filters) - if (!_fileFilter.ShouldSearchFile(filePath)) + if (!await _fileFilter.ShouldSearchFileAsync(fileSystem, resourceKey, filePath)) { return null; } @@ -200,42 +217,44 @@ await Task.Run(() => return null; } - // Try to read the file content - string content; - try - { - content = File.ReadAllText(filePath); - } - catch + // Stream the file via the chokepoint so reads pick up the same + // containment validation as writes and large files do not load + // fully into memory. + var openResult = await fileSystem.OpenReadAsync(resourceKey); + if (openResult.IsFailure) { return null; } var matches = new List(); - var lines = content.Split('\n'); - - for (int i = 0; i < lines.Length && matches.Count < maxMatches; i++) + await using (var stream = openResult.Value) + using (var reader = new StreamReader(stream)) { - cancellationToken.ThrowIfCancellationRequested(); - - var line = lines[i].TrimEnd('\r'); + int lineNumber = 0; + string? rawLine; + while ((rawLine = await reader.ReadLineAsync(cancellationToken)) is not null + && matches.Count < maxMatches) + { + lineNumber++; + var line = rawLine.TrimEnd('\r'); - var lineMatches = searchRegex != null - ? _textMatcher.FindRegexMatches(line, searchRegex) - : _textMatcher.FindMatches(line, searchTerm, matchCase, wholeWord); + var lineMatches = searchRegex != null + ? _textMatcher.FindRegexMatches(line, searchRegex) + : _textMatcher.FindMatches(line, searchTerm, matchCase, wholeWord); - foreach (var match in lineMatches) - { - if (matches.Count >= maxMatches) - break; - - var (contextLine, displayMatchStart) = _formatter.FormatContextLine(line, match.Start, match.Length); - matches.Add(new SearchMatchLine( - i + 1, // Line numbers are 1-based - contextLine, - displayMatchStart, - match.Length, - match.Start)); // Store original position for navigation + foreach (var match in lineMatches) + { + if (matches.Count >= maxMatches) + break; + + var (contextLine, displayMatchStart) = _formatter.FormatContextLine(line, match.Start, match.Length); + matches.Add(new SearchMatchLine( + lineNumber, + contextLine, + displayMatchStart, + match.Length, + match.Start)); + } } } @@ -249,6 +268,12 @@ await Task.Run(() => return new SearchFileResult(resourceKey, fileName, relativePath, matches); } + catch (OperationCanceledException) + { + // Let cancellation propagate so the outer loop returns a Cancelled + // result rather than treating the file as unsearchable. + throw; + } catch (Exception) { return null; @@ -333,15 +358,12 @@ private async Task ReplaceInFileAsync( { cancellationToken.ThrowIfCancellationRequested(); - string content; - try - { - content = File.ReadAllText(filePath); - } - catch (IOException) + var readResult = await fileSystem.ReadAllTextAsync(resource); + if (readResult.IsFailure) { return new ReplaceResult(false, 0); } + var content = readResult.Value; var (newContent, totalReplacements) = _textReplacer.ReplaceAll( content, @@ -438,15 +460,12 @@ private async Task ReplaceMatchAsync( { cancellationToken.ThrowIfCancellationRequested(); - string content; - try - { - content = File.ReadAllText(filePath); - } - catch (IOException) + var readResult = await fileSystem.ReadAllTextAsync(resource); + if (readResult.IsFailure) { return new ReplaceMatchResult(false); } + var content = readResult.Value; var (newContent, success) = _textReplacer.ReplaceMatch( content, diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs index 6e0112fa6..84a81d6b0 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs @@ -115,7 +115,11 @@ public async Task> GetClipboardResourceTransfer(Resour .WithErrors(resolveResult); } var destFolderPath = resolveResult.Value; - if (!Directory.Exists(destFolderPath)) + + var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var destInfoResult = await fileSystem.GetInfoAsync(destFolderResource); + if (destInfoResult.IsFailure + || destInfoResult.Value.Kind != ResourceInfoKind.Folder) { return Result.Fail($"The path '{destFolderPath}' does not exist."); } @@ -147,7 +151,7 @@ public async Task> GetClipboardResourceTransfer(Resour ? DataTransferMode.Move : DataTransferMode.Copy; - var createTransferResult = resourceTransferService.CreateResourceTransfer(paths, destFolderResource, transferMode); + var createTransferResult = await resourceTransferService.CreateResourceTransferAsync(paths, destFolderResource, transferMode); if (createTransferResult.IsFailure) { return Result.Fail($"Failed to create resource transfer.") diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs index 4198e5e5c..6678b202a 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceLoader.cs @@ -130,7 +130,7 @@ public async Task LoadWorkspaceAsync() try { var packageService = workspaceService.PackageService; - packageService.RegisterPackages(projectFolderPath); + await packageService.RegisterPackagesAsync(projectFolderPath); } catch (Exception ex) { From eaa6ccfd6729d6726a6e4003e46aff8b88aef437 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 13:06:19 +0100 Subject: [PATCH 28/48] Introduce ResourceClassifier and FileKind Add IResourceClassifier and ResourceClassificationResult and replace the older sidecar-pairing API. Rename SidecarStatus->CelFileStatus and SidecarInfo->SidecarLink, introduce FileKind on IFileResource, and change SidecarReport->CelFileReport/GetCelFileReport. Delete ISidecarPairingService. Update migration logic to only convert pre-0.3.0 .webview files to .webview.cel (leave .toml manifests alone). Update module manifests and document editor filenames to use package.toml / *.document.toml, and adjust tests, helpers, and usages to match the new names and behavior. --- .../Documents/IDocumentEditorFactory.cs | 11 +- .../Documents/IDocumentEditorRegistry.cs | 2 +- .../Resources/IFileResource.cs | 62 +++++- .../Resources/IGetFileInfoCommand.cs | 2 +- .../Resources/IResourceClassifier.cs | 25 +++ .../Resources/IResourceRegistry.cs | 14 +- .../Resources/ISidecarPairingService.cs | 22 -- .../MigrationSteps/MigrationStep_0_3_0.cs | 198 +----------------- .../Guides/Tools/data_check_project.md | 2 +- .../Tools/File/FileTools.GetInfo.cs | 4 +- .../{code.document.cel => code.document.toml} | 0 ...wn.document.cel => markdown.document.toml} | 0 .../CodeEditor/{package.cel => package.toml} | 2 +- ....document.cel => fileviewer.document.toml} | 0 .../FileViewer/{package.cel => package.toml} | 2 +- .../{note.document.cel => note.document.toml} | 0 .../Notes/{package.cel => package.toml} | 2 +- .../SceneViewer/{package.cel => package.toml} | 2 +- ...scene.document.cel => scene.document.toml} | 0 .../Package/{package.cel => package.toml} | 2 +- ...document.cel => spreadsheet.document.toml} | 0 .../MultiPartExtensionResolutionTests.cs | 4 +- .../Tests/Explorer/OpenWithMenuOptionTests.cs | 2 +- .../Steps/MigrationStep_0_3_0_Tests.cs | 131 +++--------- .../Tests/Packages/FileTypeProviderTests.cs | 8 +- Source/Tests/Packages/PackageRegistryTests.cs | 12 +- .../Tests/Resources/DataCheckProjectTests.cs | 2 +- ...per.cs => ResourceClassifierTestHelper.cs} | 37 ++-- ...iceTests.cs => ResourceClassifierTests.cs} | 158 ++++++++++---- .../Tests/Resources/ResourceCommandTests.cs | 2 +- .../Tests/Resources/ResourceRegistryTests.cs | 32 +-- .../Resources/SidecarClassificationTests.cs | 20 +- .../Tests/Resources/SidecarTrackingTests.cs | 24 +-- Source/Tests/Search/FileFilterTests.cs | 6 +- .../Services/DocumentEditorRegistry.cs | 9 +- .../Services/DocumentViewFactory.cs | 2 +- .../DocumentContributionFactory.cs | 14 +- .../PackageManifestFactory.cs | 6 +- .../Services/PackageRegistry.cs | 2 +- .../Commands/GetFileInfoCommand.cs | 2 +- .../Commands/ProjectCheckCommand.cs | 6 +- .../Helpers/FileSystemHelper.cs | 44 +++- .../Helpers/SidecarHelper.cs | 8 +- .../Models/FileResource.cs | 7 +- .../ServiceConfiguration.cs | 2 +- ...airingService.cs => ResourceClassifier.cs} | 62 +++--- .../Services/ResourceFileSystem.cs | 12 +- .../Services/ResourceOperations.cs | 28 +-- .../Services/ResourceRegistry.cs | 16 +- .../Services/ResourceService.cs | 4 +- 50 files changed, 466 insertions(+), 548 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs delete mode 100644 Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs rename Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/{code.document.cel => code.document.toml} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/{markdown.document.cel => markdown.document.toml} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/{package.cel => package.toml} (60%) rename Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/{fileviewer.document.cel => fileviewer.document.toml} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/{package.cel => package.toml} (68%) rename Source/Modules/Celbridge.DocumentEditors/Editors/Notes/{note.document.cel => note.document.toml} (100%) rename Source/Modules/Celbridge.DocumentEditors/Editors/Notes/{package.cel => package.toml} (74%) rename Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/{package.cel => package.toml} (71%) rename Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/{scene.document.cel => scene.document.toml} (100%) rename Source/Modules/Celbridge.Spreadsheet/Package/{package.cel => package.toml} (68%) rename Source/Modules/Celbridge.Spreadsheet/Package/{spreadsheet.document.cel => spreadsheet.document.toml} (100%) rename Source/Tests/Resources/{SidecarPairingTestHelper.cs => ResourceClassifierTestHelper.cs} (58%) rename Source/Tests/Resources/{SidecarPairingServiceTests.cs => ResourceClassifierTests.cs} (51%) rename Source/Workspace/Celbridge.Resources/Services/{SidecarPairingService.cs => ResourceClassifier.cs} (76%) diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs index 1d6da4ce7..7c9daea13 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorFactory.cs @@ -35,8 +35,9 @@ public interface IDocumentEditorFactory /// /// The file extensions this factory handles (e.g., ".md", ".txt", ".cs"). /// Extensions should be lowercase with leading dot. Multi-part forms such as - /// ".project.cel" are also accepted; the registry resolves longest match - /// first when a file's name matches more than one registered suffix. + /// ".webview.cel" or ".document.toml" are also accepted; the registry + /// resolves longest match first when a file's name matches more than one + /// registered suffix. /// IReadOnlyList SupportedExtensions { get; } @@ -55,9 +56,9 @@ public interface IDocumentEditorFactory EditorPriority Priority { get; } /// - /// True for factories that exist solely to register an extension so the - /// resources subsystem recognizes the form (e.g. package.cel, *.celbridge, - /// *.document.cel). Placeholders do not produce real document views and + /// True for factories that exist solely to reserve a filename or extension + /// for a known non-document role (e.g. package.toml, *.celbridge, + /// *.document.toml). Placeholders do not produce real document views and /// are hidden from user-facing pickers such as the "Open with..." menu. /// bool IsPlaceholder { get; } diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs index d567c5d1b..6f3e8aa80 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs @@ -23,7 +23,7 @@ public interface IDocumentEditorRegistry /// /// Checks if any registered factory is bound to the specified exact filename. - /// Filename-only registrations (e.g. "package.cel") drive matching distinct + /// Filename-only registrations (e.g. "package.toml") drive matching distinct /// from extension lookups. /// bool IsFilenameSupported(string fileName); diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs b/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs index 5495e3343..09bdb0399 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs @@ -3,21 +3,62 @@ namespace Celbridge.Resources; /// -/// The state of a paired .cel sidecar's content. Healthy means the frontmatter -/// parses cleanly; Broken means it does not (malformed TOML, merge-conflict -/// markers, missing fences, or any other parse failure). Absence of a sidecar -/// is expressed by a null SidecarInfo on the parent resource. +/// Parse health of a .cel file's content. Healthy means the frontmatter parses +/// cleanly; Broken means it does not (malformed TOML, merge-conflict markers, +/// missing fences, or any other parse failure). Applies to any .cel file — +/// paired sidecar, standalone, or orphan. /// -public enum SidecarStatus +public enum CelFileStatus { Healthy, Broken, } /// -/// Identifies a paired sidecar and its current parse state. +/// Link from a parent file to its paired .cel sidecar, carrying the sidecar's +/// resource key and current parse state. /// -public partial record SidecarInfo(ResourceKey Key, SidecarStatus Status); +public partial record SidecarLink(ResourceKey Key, CelFileStatus Status); + +/// +/// The role a file resource plays in the project resource taxonomy. Populated +/// by the resource classifier during project load and refreshed on every +/// resource registry update. Orthogonal to parse health: a Sidecar, Standalone, +/// or Orphan can independently be Healthy or Broken. +/// +public enum FileKind +{ + /// A non-.cel file (e.g. notes.md, image.png). + PlainData, + + /// + /// A .cel file paired with a parent content file in the same folder + /// (e.g. notes.md.cel paired with notes.md). Holds frontmatter and + /// content-block metadata for the parent. + /// + Sidecar, + + /// + /// A parentless .cel file recognized as a registered standalone form + /// (e.g. page.webview.cel, sprite.note.cel). Holds both metadata and + /// content for a custom document type. + /// + Standalone, + + /// + /// A parentless .cel file with no registered standalone form claiming it. + /// Usually a sidecar whose parent was renamed or deleted, or a custom + /// document type that is no longer installed. + /// + Orphan, + + /// + /// A .cel file that fails the structural rules for a sidecar (e.g. a + /// .cel.cel file). Distinct from CelFileStatus.Broken, which describes a + /// well-shaped sidecar whose content failed to parse. + /// + InvalidSidecar, +} /// /// A file resource in the project folder. @@ -29,9 +70,14 @@ public interface IFileResource : IResource /// public FileIconDefinition Icon { get; } + /// + /// The role this file plays in the project resource taxonomy. + /// + public FileKind FileKind { get; } + /// /// The paired sidecar for this file, or null if no sidecar exists. /// Null also applies to .cel files (which do not have sidecars of their own). /// - public SidecarInfo? Sidecar { get; } + public SidecarLink? Sidecar { get; } } diff --git a/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs index a1ff6ca27..a4f27e38a 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs @@ -18,7 +18,7 @@ public record class FileInfoSnapshot( bool IsText, int? LineCount, string? SidecarKey, - SidecarStatus? SidecarStatus); + CelFileStatus? SidecarStatus); /// /// Read-only query that captures metadata for a single file or folder resource in a snapshot. diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs new file mode 100644 index 000000000..661a00690 --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs @@ -0,0 +1,25 @@ +namespace Celbridge.Resources; + +/// +/// Result of a single classification pass: the .cel parse-state report and +/// the sidecar-to-parent lookup. +/// +public sealed record ResourceClassificationResult( + CelFileReport Report, + IReadOnlyDictionary SidecarToParent); + +/// +/// Classifies every file in the project tree. Stamps each FileResource with +/// its FileKind (PlainData, Sidecar, Standalone, Orphan, or InvalidSidecar), +/// sets each parent file's Sidecar link in place, and produces a report +/// partitioning .cel files by parse state and orphan-ness. +/// +public interface IResourceClassifier +{ + /// + /// Walks the project root, stamps FileKind and Sidecar on every visited + /// FileResource, and returns the .cel parse-state report and + /// sidecar-to-parent lookup. + /// + ResourceClassificationResult ClassifyResources(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry); +} diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs index ca14813c2..9a1abc3e2 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs @@ -1,8 +1,8 @@ namespace Celbridge.Resources; /// -/// Snapshot of every .cel-shaped file the registry knows about, partitioned by -/// parse state and orphan-ness. Used for project-load diagnostics and by +/// Snapshot of every .cel file the registry knows about, partitioned by parse +/// state and orphan-ness. Used for project-load diagnostics and by /// data_check_project to surface attention states. /// /// Parse state (Healthy / Broken) and orphan-ness are orthogonal dimensions: @@ -10,7 +10,7 @@ namespace Celbridge.Resources; /// Files whose names end in .cel.cel are classified as Broken and never as a /// regular sidecar. /// -public record SidecarReport( +public record CelFileReport( IReadOnlyList Healthy, IReadOnlyList Broken, IReadOnlyList Orphan); @@ -123,9 +123,9 @@ public interface IResourceRegistry Result GetSidecarParent(ResourceKey sidecar); /// - /// Returns a snapshot of every sidecar the registry knows about, partitioned - /// by parse state, orphan-ness, and the .cel.cel invalid category. Used for - /// project-load diagnostics and by data_check_project. + /// Returns a snapshot of every .cel file the registry knows about, + /// partitioned by parse state, orphan-ness, and the .cel.cel invalid + /// category. Used for project-load diagnostics and by data_check_project. /// - SidecarReport GetSidecarReport(); + CelFileReport GetCelFileReport(); } diff --git a/Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs b/Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs deleted file mode 100644 index 2b5ac0c72..000000000 --- a/Source/Core/Celbridge.Foundation/Resources/ISidecarPairingService.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Celbridge.Resources; - -/// -/// Result of a single pairing pass: the classification report and the -/// sidecar-to-parent lookup. -/// -public sealed record SidecarPairingResult( - SidecarReport Report, - IReadOnlyDictionary SidecarToParent); - -/// -/// Classifies every .cel-shaped file in the project tree as a healthy sidecar, -/// a broken sidecar, or a parentless orphan. -/// -public interface ISidecarPairingService -{ - /// - /// Walks the project root, sets each parent file's Sidecar property in place, - /// and returns the classification report and sidecar-to-parent lookup. - /// - SidecarPairingResult ComputePairings(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry); -} diff --git a/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs index 858d65847..7b23a2840 100644 --- a/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs +++ b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs @@ -5,23 +5,15 @@ namespace Celbridge.Projects.MigrationSteps; /// -/// Migrates projects to v0.3.0. Celbridge's package and document file formats -/// consolidate onto the .cel extension as the final phase of the resources -/// redesign. This step renames each package.toml to package.cel, each *.document.toml -/// to *.document.cel, and each *.webview file to *.webview.cel (converting the -/// JSON body to TOML at the same time). The .celbridge project file extension is -/// deliberately retained and is not renamed by this step. Internal references in -/// the project config and the renamed package.cel manifests are rewritten so the -/// post-migration project loads cleanly. +/// Migrates projects to v0.3.0. The user-visible change in this version is that +/// the WebView resource adopts the .cel sidecar/standalone form: each pre-v0.3.0 +/// "blah.webview" JSON file is converted to "blah.webview.cel" TOML. The .celbridge +/// project file extension and the .toml package and document manifest filenames are +/// deliberately retained. Quoted references to the old WebView extension inside the +/// project config are rewritten so the post-migration project loads cleanly. /// public class MigrationStep_0_3_0 : IMigrationStep { - private const string PackageManifestOldName = "package.toml"; - private const string PackageManifestNewName = "package.cel"; - - private const string DocumentManifestOldExtension = ".document.toml"; - private const string DocumentManifestNewExtension = ".document.cel"; - private const string WebViewOldExtension = ".webview"; private const string WebViewNewExtension = ".webview.cel"; @@ -34,18 +26,6 @@ public async Task ApplyAsync(MigrationContext context) { var projectDataFolderPath = Path.GetFullPath(context.ProjectDataFolderPath); - var packageRenameResult = RenamePackageManifests(context, projectDataFolderPath); - if (packageRenameResult.IsFailure) - { - return packageRenameResult; - } - - var documentRenameResult = RenameDocumentManifests(context, projectDataFolderPath); - if (documentRenameResult.IsFailure) - { - return documentRenameResult; - } - var webViewConvertResult = await ConvertWebViewFilesAsync(context, projectDataFolderPath); if (webViewConvertResult.IsFailure) { @@ -61,154 +41,6 @@ public async Task ApplyAsync(MigrationContext context) return Result.Ok(); } - private Result RenamePackageManifests(MigrationContext context, string projectDataFolderPath) - { - try - { - var matches = Directory.EnumerateFiles( - context.ProjectFolderPath, - PackageManifestOldName, - SearchOption.AllDirectories); - - int renamedCount = 0; - foreach (var oldPath in matches) - { - var fullOldPath = Path.GetFullPath(oldPath); - if (IsInsideMetaDataFolder(fullOldPath, projectDataFolderPath)) - { - continue; - } - - var newPath = Path.Combine( - Path.GetDirectoryName(fullOldPath)!, - PackageManifestNewName); - - if (File.Exists(newPath)) - { - return Result.Fail( - $"Cannot rename '{fullOldPath}' to '{newPath}'. Target file already exists."); - } - - File.Move(fullOldPath, newPath); - renamedCount++; - } - - if (renamedCount > 0) - { - context.Logger.LogInformation( - $"Renamed {renamedCount} '{PackageManifestOldName}' file(s) to '{PackageManifestNewName}'"); - } - - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to rename '{PackageManifestOldName}' files in project folder") - .WithException(ex); - } - } - - private Result RenameDocumentManifests(MigrationContext context, string projectDataFolderPath) - { - try - { - // Two passes: rename the files first, then rewrite each surviving - // package.cel so the document_editors array points at the renamed - // *.document.cel files. The package.cel rewrite is best-effort: we only - // touch quoted entries that end with the old extension, leaving any - // bespoke prose paths alone. - - var renamedFiles = new List<(string OldPath, string NewPath)>(); - var matches = Directory.EnumerateFiles( - context.ProjectFolderPath, - $"*{DocumentManifestOldExtension}", - SearchOption.AllDirectories); - - foreach (var oldPath in matches) - { - var fullOldPath = Path.GetFullPath(oldPath); - if (IsInsideMetaDataFolder(fullOldPath, projectDataFolderPath)) - { - continue; - } - - var fileName = Path.GetFileName(fullOldPath); - var stem = fileName.Substring(0, fileName.Length - DocumentManifestOldExtension.Length); - var newPath = Path.Combine( - Path.GetDirectoryName(fullOldPath)!, - stem + DocumentManifestNewExtension); - - if (File.Exists(newPath)) - { - return Result.Fail( - $"Cannot rename '{fullOldPath}' to '{newPath}'. Target file already exists."); - } - - File.Move(fullOldPath, newPath); - renamedFiles.Add((fullOldPath, newPath)); - } - - if (renamedFiles.Count > 0) - { - context.Logger.LogInformation( - $"Renamed {renamedFiles.Count} '*{DocumentManifestOldExtension}' file(s) to '*{DocumentManifestNewExtension}'"); - } - - var packageManifestRewriteResult = RewritePackageManifestReferences(context, projectDataFolderPath); - if (packageManifestRewriteResult.IsFailure) - { - return packageManifestRewriteResult; - } - - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to rename '*{DocumentManifestOldExtension}' files in project folder") - .WithException(ex); - } - } - - private Result RewritePackageManifestReferences(MigrationContext context, string projectDataFolderPath) - { - var packageManifests = Directory.EnumerateFiles( - context.ProjectFolderPath, - PackageManifestNewName, - SearchOption.AllDirectories); - - foreach (var packageManifestPath in packageManifests) - { - var fullPath = Path.GetFullPath(packageManifestPath); - if (IsInsideMetaDataFolder(fullPath, projectDataFolderPath)) - { - continue; - } - - try - { - var originalText = File.ReadAllText(fullPath); - var rewrittenText = RewriteQuotedExtensions( - originalText, - DocumentManifestOldExtension, - DocumentManifestNewExtension); - - if (rewrittenText != originalText) - { - File.WriteAllText(fullPath, rewrittenText); - context.Logger.LogInformation( - $"Rewrote '*{DocumentManifestOldExtension}' references in package manifest: '{fullPath}'"); - } - } - catch (Exception ex) - { - return Result.Fail($"Failed to rewrite references in package manifest: '{fullPath}'") - .WithException(ex); - } - } - - return Result.Ok(); - } - private async Task ConvertWebViewFilesAsync(MigrationContext context, string projectDataFolderPath) { try @@ -381,13 +213,8 @@ private async Task RewriteProjectConfigAsync(MigrationContext context) var originalText = await File.ReadAllTextAsync(context.ProjectFilePath); // Rewrites are scoped to quoted occurrences so bare prose mentions of - // these extensions in comments stay untouched. Order matters: rewrite - // the longer/more-specific extension first so .webview does not eat - // .document.toml or vice versa. - var updatedText = originalText; - updatedText = RewriteQuotedExtensions(updatedText, DocumentManifestOldExtension, DocumentManifestNewExtension); - updatedText = RewriteQuotedExtensions(updatedText, WebViewOldExtension, WebViewNewExtension); - updatedText = RewriteQuotedFilenames(updatedText, PackageManifestOldName, PackageManifestNewName); + // the old extension in comments stay untouched. + var updatedText = RewriteQuotedExtensions(originalText, WebViewOldExtension, WebViewNewExtension); if (updatedText == originalText) { @@ -420,15 +247,6 @@ private static string RewriteQuotedExtensions(string text, string oldExtension, .Replace($"{oldExtension}'", $"{newExtension}'"); } - private static string RewriteQuotedFilenames(string text, string oldFilename, string newFilename) - { - return text - .Replace($"/{oldFilename}\"", $"/{newFilename}\"") - .Replace($"/{oldFilename}'", $"/{newFilename}'") - .Replace($"\\{oldFilename}\"", $"\\{newFilename}\"") - .Replace($"\\{oldFilename}'", $"\\{newFilename}'"); - } - private static bool IsInsideMetaDataFolder(string fullPath, string projectDataFolderPath) { if (string.IsNullOrEmpty(projectDataFolderPath)) diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md index 3cb9ef6aa..19dfbe0d5 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md @@ -3,7 +3,7 @@ Reports project-wide consistency findings without modifying anything: - **Broken references** — `project:` references in text files that name a missing target. -- **Orphan .cel files** — `.cel` files with no parent file present on disk and no registered factory claiming the standalone form (e.g. `package.cel`, `*.note.cel`, `*.document.cel`). +- **Orphan .cel files** — `.cel` files with no parent file present on disk and no registered factory claiming the standalone form (e.g. `*.webview.cel`, `*.note.cel`). - **Broken .cel files** — `.cel` files (including invalid `.cel.cel` filenames) that fail to parse. Applies to both parent-paired sidecars and standalone `.cel` forms. Returns a JSON object with three arrays: diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs index d73789145..54567f588 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs @@ -58,8 +58,8 @@ public async partial Task GetInfo(string resource) { var sidecarStatusText = snapshot.SidecarStatus switch { - Celbridge.Resources.SidecarStatus.Healthy => "healthy", - Celbridge.Resources.SidecarStatus.Broken => "broken", + Celbridge.Resources.CelFileStatus.Healthy => "healthy", + Celbridge.Resources.CelFileStatus.Broken => "broken", _ => "none", }; diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.toml similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/code.document.toml diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.toml similarity index 60% rename from Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.toml index f5064a8b8..c4f2b24b3 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.cel +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/package.toml @@ -4,4 +4,4 @@ name = "CodeEditor_Package_Name" version = "1.0.0" [contributes] -document_editors = ["code.document.cel", "markdown.document.cel"] +document_editors = ["code.document.toml", "markdown.document.toml"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.toml similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/fileviewer.document.toml diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.toml similarity index 68% rename from Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.toml index f6935aaec..5f7db6c1c 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.cel +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/FileViewer/package.toml @@ -4,4 +4,4 @@ name = "FileViewer_Package_Name" version = "1.0.0" [contributes] -document_editors = ["fileviewer.document.cel"] +document_editors = ["fileviewer.document.toml"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.toml similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/Notes/note.document.toml diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.toml similarity index 74% rename from Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.toml index 03797b185..c8c073426 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.cel +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/Notes/package.toml @@ -5,4 +5,4 @@ version = "1.0.0" feature_flag = "note-editor" [contributes] -document_editors = ["note.document.cel"] +document_editors = ["note.document.toml"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.toml similarity index 71% rename from Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.toml index eab034e19..9935895f4 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.cel +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/package.toml @@ -4,4 +4,4 @@ name = "SceneViewer_Package_Name" version = "1.0.0" [contributes] -document_editors = ["scene.document.cel"] +document_editors = ["scene.document.toml"] diff --git a/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.cel b/Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.toml similarity index 100% rename from Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.cel rename to Source/Modules/Celbridge.DocumentEditors/Editors/SceneViewer/scene.document.toml diff --git a/Source/Modules/Celbridge.Spreadsheet/Package/package.cel b/Source/Modules/Celbridge.Spreadsheet/Package/package.toml similarity index 68% rename from Source/Modules/Celbridge.Spreadsheet/Package/package.cel rename to Source/Modules/Celbridge.Spreadsheet/Package/package.toml index b3956be5c..298c31305 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Package/package.cel +++ b/Source/Modules/Celbridge.Spreadsheet/Package/package.toml @@ -4,4 +4,4 @@ name = "Spreadsheet_Package_Name" version = "1.0.0" [contributes] -document_editors = ["spreadsheet.document.cel"] +document_editors = ["spreadsheet.document.toml"] diff --git a/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.cel b/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.toml similarity index 100% rename from Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.cel rename to Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.document.toml diff --git a/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs index 2a8dad33b..8e32a3f88 100644 --- a/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs +++ b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs @@ -61,11 +61,11 @@ public void GetFactory_FactoryRegisteringMultipleMultiPartExtensionsMatchesBoth( { var registry = new DocumentEditorRegistry(Substitute.For()); - var multiFactory = CreateMockFactoryWithExtensions("test.multi-cel", new[] { ".note.cel", ".package.cel" }); + var multiFactory = CreateMockFactoryWithExtensions("test.multi-cel", new[] { ".note.cel", ".theme.cel" }); registry.RegisterFactory(multiFactory); var noteResult = registry.GetFactory(new ResourceKey("foo.note.cel")); - var modResult = registry.GetFactory(new ResourceKey("bar.package.cel")); + var modResult = registry.GetFactory(new ResourceKey("bar.theme.cel")); noteResult.IsSuccess.Should().BeTrue(); noteResult.Value.Should().Be(multiFactory); diff --git a/Source/Tests/Explorer/OpenWithMenuOptionTests.cs b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs index 430bd0d2f..19b3bb445 100644 --- a/Source/Tests/Explorer/OpenWithMenuOptionTests.cs +++ b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs @@ -231,7 +231,7 @@ public void GetState_HiddenForPlaceholderFactoryPlusTextFallback() // menu stays hidden (one candidate, nothing to pick between). This // closes the footgun where picking a placeholder would write a // non-functional editor id into the manifest's own frontmatter. - var clickedFile = CreateFileResource("package.cel"); + var clickedFile = CreateFileResource("package.toml"); var placeholder = CreateFactory("celbridge.package-manifest"); placeholder.IsPlaceholder.Returns(true); _editorRegistry.GetUserPickableFactoriesForResource(Arg.Any()) diff --git a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs index 6c5ff2446..e08f6ddfe 100644 --- a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs +++ b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs @@ -8,9 +8,9 @@ namespace Celbridge.Tests.Migration.Steps; /// -/// Unit tests for MigrationStep_0_3_0 which renames package.toml to package.cel, -/// *.document.toml to *.document.cel, and *.webview to *.webview.cel (converting -/// the JSON body to TOML at the same time). +/// Unit tests for MigrationStep_0_3_0 which converts each pre-v0.3.0 +/// "blah.webview" JSON file to "blah.webview.cel" TOML and rewrites quoted +/// references to the old extension in the project config. /// [TestFixture] public class MigrationStep_0_3_0_Tests @@ -56,96 +56,17 @@ public void TargetVersion_IsZeroDotThreeDotZero() _step.TargetVersion.Should().Be(new Version("0.3.0")); } - [Test] - public async Task ApplyAsync_RenamesPackageTomlToPackageCel() - { - // Arrange - WriteMinimalProjectFile(); - var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); - Directory.CreateDirectory(packageDir); - var oldManifestPath = Path.Combine(packageDir, "package.toml"); - await File.WriteAllTextAsync(oldManifestPath, "[package]\nid = \"my-package\"\nname = \"My Package\"\nversion = \"1.0.0\"\n"); - - var context = CreateContext(); - - // Act - var result = await _step.ApplyAsync(context); - - // Assert - result.IsSuccess.Should().BeTrue(); - File.Exists(oldManifestPath).Should().BeFalse(); - File.Exists(Path.Combine(packageDir, "package.cel")).Should().BeTrue(); - } - - [Test] - public async Task ApplyAsync_RenamesDocumentTomlToDocumentCel() - { - // Arrange - WriteMinimalProjectFile(); - var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); - Directory.CreateDirectory(packageDir); - var oldDocPath = Path.Combine(packageDir, "myeditor.document.toml"); - await File.WriteAllTextAsync(oldDocPath, "[document]\nid = \"my-doc\"\ntype = \"custom\"\ndisplay_name = \"My Doc\"\n\n[[document_file_types]]\nextension = \".my\"\ndisplay_name = \"My File\"\n"); - - var context = CreateContext(); - - // Act - var result = await _step.ApplyAsync(context); - - // Assert - result.IsSuccess.Should().BeTrue(); - File.Exists(oldDocPath).Should().BeFalse(); - File.Exists(Path.Combine(packageDir, "myeditor.document.cel")).Should().BeTrue(); - } - - [Test] - public async Task ApplyAsync_RewritesDocumentEditorsReferencesInPackageCel() - { - // Arrange - WriteMinimalProjectFile(); - var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); - Directory.CreateDirectory(packageDir); - var oldManifestPath = Path.Combine(packageDir, "package.toml"); - await File.WriteAllTextAsync(oldManifestPath, """ - [package] - id = "my-package" - name = "My Package" - version = "1.0.0" - - [contributes] - document_editors = ["editor-a.document.toml", "editor-b.document.toml"] - """); - await File.WriteAllTextAsync(Path.Combine(packageDir, "editor-a.document.toml"), "[document]\nid = \"a\"\ntype = \"custom\"\ndisplay_name = \"A\"\n[[document_file_types]]\nextension = \".a\"\ndisplay_name = \"A\"\n"); - await File.WriteAllTextAsync(Path.Combine(packageDir, "editor-b.document.toml"), "[document]\nid = \"b\"\ntype = \"custom\"\ndisplay_name = \"B\"\n[[document_file_types]]\nextension = \".b\"\ndisplay_name = \"B\"\n"); - - var context = CreateContext(); - - // Act - var result = await _step.ApplyAsync(context); - - // Assert - result.IsSuccess.Should().BeTrue(); - - var newManifestText = await File.ReadAllTextAsync(Path.Combine(packageDir, "package.cel")); - newManifestText.Should().Contain("\"editor-a.document.cel\""); - newManifestText.Should().Contain("\"editor-b.document.cel\""); - newManifestText.Should().NotContain(".document.toml"); - } - [Test] public async Task ApplyAsync_ConvertsWebViewFromJsonToToml() { - // Arrange WriteMinimalProjectFile(); var oldWebViewPath = Path.Combine(_projectFolderPath, "page.webview"); await File.WriteAllTextAsync(oldWebViewPath, "{\"sourceUrl\": \"https://example.com/path\"}"); var context = CreateContext(); - // Act var result = await _step.ApplyAsync(context); - // Assert result.IsSuccess.Should().BeTrue(); File.Exists(oldWebViewPath).Should().BeFalse(); @@ -164,17 +85,14 @@ public async Task ApplyAsync_ConvertsWebViewFromJsonToToml() [Test] public async Task ApplyAsync_ConvertsWebViewWithMissingSourceUrlToEmptyValue() { - // Arrange WriteMinimalProjectFile(); var oldWebViewPath = Path.Combine(_projectFolderPath, "empty.webview"); await File.WriteAllTextAsync(oldWebViewPath, "{}"); var context = CreateContext(); - // Act var result = await _step.ApplyAsync(context); - // Assert result.IsSuccess.Should().BeTrue(); var newPath = Path.Combine(_projectFolderPath, "empty.webview.cel"); File.Exists(newPath).Should().BeTrue(); @@ -186,7 +104,6 @@ public async Task ApplyAsync_ConvertsWebViewWithMissingSourceUrlToEmptyValue() [Test] public async Task ApplyAsync_RewritesWebViewReferencesInProjectConfig() { - // Arrange var content = """ [celbridge] celbridge-version = "0.2.7" @@ -199,61 +116,69 @@ public async Task ApplyAsync_RewritesWebViewReferencesInProjectConfig() var context = CreateContext(); - // Act var result = await _step.ApplyAsync(context); - // Assert result.IsSuccess.Should().BeTrue(); var updated = await File.ReadAllTextAsync(_projectFilePath); updated.Should().Contain("entry = \"Sites/index.webview.cel\""); } + [Test] + public async Task ApplyAsync_LeavesTomlManifestsAlone() + { + // v0.3.0 keeps package.toml and *.document.toml on their original + // extension. Only the .webview content file is touched. + WriteMinimalProjectFile(); + var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); + Directory.CreateDirectory(packageDir); + + var packagePath = Path.Combine(packageDir, "package.toml"); + var documentPath = Path.Combine(packageDir, "myeditor.document.toml"); + await File.WriteAllTextAsync(packagePath, "[package]\nid = \"my-package\"\n"); + await File.WriteAllTextAsync(documentPath, "[document]\nid = \"my-doc\"\n"); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + result.IsSuccess.Should().BeTrue(); + File.Exists(packagePath).Should().BeTrue(); + File.Exists(documentPath).Should().BeTrue(); + File.Exists(Path.Combine(packageDir, "package.cel")).Should().BeFalse(); + File.Exists(Path.Combine(packageDir, "myeditor.document.cel")).Should().BeFalse(); + } + [Test] public async Task ApplyAsync_SkipsFilesInsideMetaDataFolder() { - // Arrange WriteMinimalProjectFile(); Directory.CreateDirectory(_projectDataFolderPath); var metadataWebView = Path.Combine(_projectDataFolderPath, "stale.webview"); - var metadataPackage = Path.Combine(_projectDataFolderPath, "package.toml"); await File.WriteAllTextAsync(metadataWebView, "{}"); - await File.WriteAllTextAsync(metadataPackage, "[package]\nid = \"x\"\n"); var context = CreateContext(); - // Act var result = await _step.ApplyAsync(context); - // Assert result.IsSuccess.Should().BeTrue(); File.Exists(metadataWebView).Should().BeTrue(); - File.Exists(metadataPackage).Should().BeTrue(); File.Exists(Path.Combine(_projectDataFolderPath, "stale.webview.cel")).Should().BeFalse(); - File.Exists(Path.Combine(_projectDataFolderPath, "package.cel")).Should().BeFalse(); } [Test] public async Task ApplyAsync_IsIdempotent() { - // Arrange WriteMinimalProjectFile(); - var packageDir = Path.Combine(_projectFolderPath, "packages", "my-package"); - Directory.CreateDirectory(packageDir); - await File.WriteAllTextAsync(Path.Combine(packageDir, "package.toml"), "[package]\nid = \"my-package\"\nname = \"My Package\"\nversion = \"1.0.0\"\n"); await File.WriteAllTextAsync(Path.Combine(_projectFolderPath, "page.webview"), "{\"sourceUrl\": \"https://example.com\"}"); var context = CreateContext(); - // Act - run twice var firstResult = await _step.ApplyAsync(context); var secondResult = await _step.ApplyAsync(context); - // Assert firstResult.IsSuccess.Should().BeTrue(); secondResult.IsSuccess.Should().BeTrue(); - File.Exists(Path.Combine(packageDir, "package.toml")).Should().BeFalse(); - File.Exists(Path.Combine(packageDir, "package.cel")).Should().BeTrue(); File.Exists(Path.Combine(_projectFolderPath, "page.webview")).Should().BeFalse(); File.Exists(Path.Combine(_projectFolderPath, "page.webview.cel")).Should().BeTrue(); } diff --git a/Source/Tests/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index 4855dee3f..3cbac6f6b 100644 --- a/Source/Tests/Packages/FileTypeProviderTests.cs +++ b/Source/Tests/Packages/FileTypeProviderTests.cs @@ -302,15 +302,15 @@ private async Task CreateBundledPackage( var packageId = $"test.{dirName}"; var featureFlagLine = featureFlag is not null ? $"\nfeature_flag = \"{featureFlag}\"" : ""; - // Write package.cel - File.WriteAllText(Path.Combine(packageDir, "package.cel"), $""" + // Write package.toml + File.WriteAllText(Path.Combine(packageDir, "package.toml"), $""" [package] id = "{packageId}" name = "{packageName}" version = "1.0.0"{featureFlagLine} [contributes] - document_editors = ["editor.document.cel"] + document_editors = ["editor.document.toml"] """); var fileTypesToml = string.Join("\n", fileTypes.Select(ft => $""" @@ -331,7 +331,7 @@ private async Task CreateBundledPackage( """)); } - File.WriteAllText(Path.Combine(packageDir, "editor.document.cel"), $""" + File.WriteAllText(Path.Combine(packageDir, "editor.document.toml"), $""" [document] id = "{packageId}-doc" type = "custom" diff --git a/Source/Tests/Packages/PackageRegistryTests.cs b/Source/Tests/Packages/PackageRegistryTests.cs index 7d6c0b01d..68fcae971 100644 --- a/Source/Tests/Packages/PackageRegistryTests.cs +++ b/Source/Tests/Packages/PackageRegistryTests.cs @@ -122,7 +122,7 @@ public async Task RegisterPackages_InvalidManifest_SkipsAndContinues() // Create an invalid package var badDir = Path.Combine(_tempProjectFolder, "packages", "bad"); Directory.CreateDirectory(badDir); - File.WriteAllText(Path.Combine(badDir, "package.cel"), "{ invalid toml }"); + File.WriteAllText(Path.Combine(badDir, "package.toml"), "{ invalid toml }"); await _service.RegisterPackagesAsync(_tempProjectFolder); @@ -315,7 +315,7 @@ public async Task RegisterPackages_InvalidBundledManifestSkipped() { var bundledDir = Path.Combine(_tempProjectFolder, "bad-bundled"); Directory.CreateDirectory(bundledDir); - File.WriteAllText(Path.Combine(bundledDir, "package.cel"), "{ invalid toml }"); + File.WriteAllText(Path.Combine(bundledDir, "package.toml"), "{ invalid toml }"); _moduleService.GetBundledPackages().Returns(new List { new() { Folder = bundledDir } }); @@ -382,7 +382,7 @@ public async Task GetContributingPackage_KnownEditorId_ReturnsThePackage() await _service.RegisterPackagesAsync(_tempProjectFolder); // CustomDocumentViewFactory builds editor IDs as "{packageId}.{contributionId}". - // The contributionId comes from the [document] table key in package.cel, + // The contributionId comes from the [document] table key in package.toml, // which CreateBundledPackage sets to the docType argument. var editorId = new DocumentEditorId("celbridge.notes.custom"); @@ -444,17 +444,17 @@ private string CreateBundledPackage(string dirName, string packageId, string pac private static void WritePackageFiles(string packageDir, string packageId, string packageName, string docType, string fileExt) { - File.WriteAllText(Path.Combine(packageDir, "package.cel"), $""" + File.WriteAllText(Path.Combine(packageDir, "package.toml"), $""" [package] id = "{packageId}" name = "{packageName}" version = "1.0.0" [contributes] - document_editors = ["editor.document.cel"] + document_editors = ["editor.document.toml"] """); - File.WriteAllText(Path.Combine(packageDir, "editor.document.cel"), $""" + File.WriteAllText(Path.Combine(packageDir, "editor.document.toml"), $""" [document] id = "{packageId}-doc" type = "{docType}" diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index 212dc7bc6..c62334af1 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -50,7 +50,7 @@ public void Setup() Substitute.For>(), _messengerService, new ProjectTreeBuilder(fileIconService), - SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(), + ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), new RootHandlerRegistry()); _resourceRegistry.InitializeProjectRoot(_projectFolderPath); diff --git a/Source/Tests/Resources/SidecarPairingTestHelper.cs b/Source/Tests/Resources/ResourceClassifierTestHelper.cs similarity index 58% rename from Source/Tests/Resources/SidecarPairingTestHelper.cs rename to Source/Tests/Resources/ResourceClassifierTestHelper.cs index 97f23939c..597ec9ad0 100644 --- a/Source/Tests/Resources/SidecarPairingTestHelper.cs +++ b/Source/Tests/Resources/ResourceClassifierTestHelper.cs @@ -1,4 +1,3 @@ -using Celbridge.Documents; using Celbridge.Resources; using Celbridge.Resources.Services; using Celbridge.Workspace; @@ -6,54 +5,54 @@ namespace Celbridge.Tests.Resources; /// -/// Helpers for tests that need a real SidecarPairingService — typically tests -/// that exercise code paths reading the sidecar report or per-file Sidecar +/// Helpers for tests that need a real ResourceClassifier — typically tests +/// that exercise code paths reading the .cel report or per-file Sidecar /// pairing through the resource registry. /// -internal static class SidecarPairingTestHelper +internal static class ResourceClassifierTestHelper { /// - /// Builds a stub that returns an empty pairing result on every call. Use - /// for tests that exercise the registry but do not care about sidecar + /// Builds a stub that returns an empty classification result on every call. + /// Use for tests that exercise the registry but do not care about file /// classification (most ResourceRegistry tests). Avoids needing a real /// workspace wrapper. /// - public static ISidecarPairingService BuildEmptyStub() + public static IResourceClassifier BuildEmptyStub() { - var stub = Substitute.For(); - var emptyReport = new SidecarReport( + var stub = Substitute.For(); + var emptyReport = new CelFileReport( Healthy: Array.Empty(), Broken: Array.Empty(), Orphan: Array.Empty()); - var emptyResult = new SidecarPairingResult( + var emptyResult = new ResourceClassificationResult( emptyReport, new Dictionary()); - stub.ComputePairings(Arg.Any(), Arg.Any()) + stub.ClassifyResources(Arg.Any(), Arg.Any()) .Returns(emptyResult); return stub; } /// - /// Builds a real SidecarPairingService wrapped around an editor registry + /// Builds a real ResourceClassifier wrapped around an editor registry /// that claims no factories. Every parentless .cel file is classified as /// an orphan, which matches the default expectation for tests that are /// not exercising standalone-form recognition. /// - public static SidecarPairingService BuildPairingServiceWithNoFactories() + public static ResourceClassifier BuildClassifierWithNoFactories() { // NSubstitute returns false for unconfigured bool methods, so the // standalone-form check naturally returns "no match" without any // explicit stubbing. var editorRegistry = Substitute.For(); - return BuildPairingService(editorRegistry); + return BuildClassifier(editorRegistry); } /// - /// Builds a real SidecarPairingService wrapped around the supplied editor + /// Builds a real ResourceClassifier wrapped around the supplied editor /// registry. Use when the test wants to stub specific standalone-form - /// recognition rules (e.g. package.cel, foo.webview.cel). + /// recognition rules (e.g. foo.webview.cel, foo.note.cel). /// - public static SidecarPairingService BuildPairingService(IDocumentEditorRegistry editorRegistry) + public static ResourceClassifier BuildClassifier(IDocumentEditorRegistry editorRegistry) { var documentsService = Substitute.For(); documentsService.DocumentEditorRegistry.Returns(editorRegistry); @@ -65,8 +64,8 @@ public static SidecarPairingService BuildPairingService(IDocumentEditorRegistry workspaceWrapper.WorkspaceService.Returns(workspaceService); workspaceWrapper.IsWorkspacePageLoaded.Returns(true); - return new SidecarPairingService( - Substitute.For>(), + return new ResourceClassifier( + Substitute.For>(), workspaceWrapper); } } diff --git a/Source/Tests/Resources/SidecarPairingServiceTests.cs b/Source/Tests/Resources/ResourceClassifierTests.cs similarity index 51% rename from Source/Tests/Resources/SidecarPairingServiceTests.cs rename to Source/Tests/Resources/ResourceClassifierTests.cs index 78468db51..a6d77f783 100644 --- a/Source/Tests/Resources/SidecarPairingServiceTests.cs +++ b/Source/Tests/Resources/ResourceClassifierTests.cs @@ -1,23 +1,22 @@ -using Celbridge.Documents; using Celbridge.Resources; using Celbridge.Resources.Services; namespace Celbridge.Tests.Resources; /// -/// Direct unit tests for the sidecar pairing pass: parent pairing, parentless -/// classification (standalone-form vs orphan), and the broken / healthy split. -/// The previous behaviour was tested only end-to-end through ResourceRegistry -/// with a nullable workspace wrapper, which silently disabled the standalone- -/// form recognition in tests; this fixture covers the cross-domain decision -/// directly. +/// Direct unit tests for the resource classification pass: parent pairing, +/// parentless classification (standalone-form vs orphan), per-file FileKind +/// stamping, and the broken / healthy split. The previous behaviour was tested +/// only end-to-end through ResourceRegistry with a nullable workspace wrapper, +/// which silently disabled the standalone-form recognition in tests; this +/// fixture covers the cross-domain decision directly. /// -/// The pairing service reads sidecar bytes from disk to drive SidecarHelper.Inspect, +/// The classifier reads sidecar bytes from disk to drive SidecarHelper.Inspect, /// so tests still set up real files; the value is that they target the service /// surface rather than the registry. /// [TestFixture] -public class SidecarPairingServiceTests +public class ResourceClassifierTests { private string _projectFolderPath = null!; @@ -27,7 +26,7 @@ public void Setup() _projectFolderPath = Path.Combine( Path.GetTempPath(), "Celbridge", - nameof(SidecarPairingServiceTests), + nameof(ResourceClassifierTests), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_projectFolderPath); } @@ -60,11 +59,11 @@ public void StandaloneCelWithMultiPartExtensionRegistration_IsNotReportedAsOrpha var editorRegistry = Substitute.For(); editorRegistry.IsExtensionSupported(".note.cel").Returns(true); - var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetSidecarReport(); + var report = registry.GetCelFileReport(); report.Orphan.Should().NotContain(new ResourceKey("feature.note.cel")); report.Healthy.Should().Contain(new ResourceKey("feature.note.cel")); } @@ -72,23 +71,23 @@ public void StandaloneCelWithMultiPartExtensionRegistration_IsNotReportedAsOrpha [Test] public void StandaloneCelWithFilenameOnlyRegistration_IsNotReportedAsOrphan() { - // Regression for "package.cel": a filename-only factory registration must - // also drive standalone classification. Earlier code computed a multi- - // part suffix and missed the bare-filename case, so every package.cel showed - // up in the orphan list. - File.WriteAllText(Path.Combine(_projectFolderPath, "package.cel"), - "[package]\nid = \"acme\"\nname = \"Acme\"\nversion = \"1.0.0\"\n"); + // Filename-only factory registrations must drive standalone classification. + // Earlier code computed a multi-part suffix and missed the bare-filename + // case, so any .cel file owned by a filename-only factory showed up in + // the orphan list. + File.WriteAllText(Path.Combine(_projectFolderPath, "config.cel"), + "value = 1\n"); var editorRegistry = Substitute.For(); - editorRegistry.IsFilenameSupported("package.cel").Returns(true); + editorRegistry.IsFilenameSupported("config.cel").Returns(true); - var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetSidecarReport(); - report.Orphan.Should().NotContain(new ResourceKey("package.cel")); - report.Healthy.Should().Contain(new ResourceKey("package.cel")); + var report = registry.GetCelFileReport(); + report.Orphan.Should().NotContain(new ResourceKey("config.cel")); + report.Healthy.Should().Contain(new ResourceKey("config.cel")); } [Test] @@ -105,11 +104,11 @@ public void BareCelExtensionRegistration_DoesNotPreventOrphanReport() var editorRegistry = Substitute.For(); editorRegistry.IsExtensionSupported(".cel").Returns(true); - var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetSidecarReport(); + var report = registry.GetCelFileReport(); report.Orphan.Should().Contain(new ResourceKey("orphaned.png.cel")); } @@ -122,11 +121,11 @@ public void OrphanCelWithNoFactoryClaim_IsStillReportedAsOrphan() File.WriteAllText(Path.Combine(_projectFolderPath, "scratch.unknown.cel"), "key = \"value\"\n"); - var pairingService = SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetSidecarReport(); + var report = registry.GetCelFileReport(); report.Orphan.Should().Contain(new ResourceKey("scratch.unknown.cel")); } @@ -145,13 +144,13 @@ public void ParentedSidecar_IsNeverConsultedAgainstEditorRegistry() editorRegistry.IsFilenameSupported(Arg.Any()).Returns(false); editorRegistry.IsExtensionSupported(Arg.Any()).Returns(false); - var pairingService = SidecarPairingTestHelper.BuildPairingService(editorRegistry); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); editorRegistry.DidNotReceive().IsFilenameSupported("foo.png.cel"); - var report = registry.GetSidecarReport(); + var report = registry.GetCelFileReport(); report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); report.Orphan.Should().NotContain(new ResourceKey("foo.png.cel")); } @@ -167,39 +166,112 @@ public void NestedFolders_PairCorrectly_AndReportUsesRelativeKeys() File.WriteAllText(Path.Combine(sub, "note.md"), "body"); File.WriteAllText(Path.Combine(sub, "note.md.cel"), "tags = [\"meeting\"]\n"); - var pairingService = SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); var noteResource = registry.GetResource(new ResourceKey("subfolder/note.md")).Value as IFileResource; noteResource!.Sidecar.Should().NotBeNull(); noteResource.Sidecar!.Key.Should().Be(new ResourceKey("subfolder/note.md.cel")); - noteResource.Sidecar.Status.Should().Be(SidecarStatus.Healthy); + noteResource.Sidecar.Status.Should().Be(CelFileStatus.Healthy); - registry.GetSidecarReport() + registry.GetCelFileReport() .Healthy.Should().Contain(new ResourceKey("subfolder/note.md.cel")); } [Test] public void EmptyTree_ProducesEmptyReport() { - var pairingService = SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(); - var registry = BuildRegistry(pairingService); + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetSidecarReport(); + var report = registry.GetCelFileReport(); report.Healthy.Should().BeEmpty(); report.Broken.Should().BeEmpty(); report.Orphan.Should().BeEmpty(); } - private ResourceRegistry BuildRegistry(SidecarPairingService pairingService) + [Test] + public void Classify_PlainDataFileWithoutSidecar_IsPlainData() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "# Notes\n"); + + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var file = registry.GetResource(new ResourceKey("notes.md")).Value as IFileResource; + file!.FileKind.Should().Be(FileKind.PlainData); + } + + [Test] + public void Classify_PairedSidecarAndParent_AssignsExpectedKinds() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), "# Notes\n"); + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md.cel"), "tags = []\n"); + + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var parent = registry.GetResource(new ResourceKey("notes.md")).Value as IFileResource; + var sidecar = registry.GetResource(new ResourceKey("notes.md.cel")).Value as IFileResource; + + parent!.FileKind.Should().Be(FileKind.PlainData); + sidecar!.FileKind.Should().Be(FileKind.Sidecar); + } + + [Test] + public void Classify_RegisteredStandaloneCel_IsStandalone() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "page.webview.cel"), + "source_url = \"https://example.com\"\n"); + + var editorRegistry = Substitute.For(); + editorRegistry.IsExtensionSupported(".webview.cel").Returns(true); + + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var file = registry.GetResource(new ResourceKey("page.webview.cel")).Value as IFileResource; + file!.FileKind.Should().Be(FileKind.Standalone); + } + + [Test] + public void Classify_ParentlessUnregisteredCel_IsOrphan() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "lonely.cel"), "tags = []\n"); + + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var file = registry.GetResource(new ResourceKey("lonely.cel")).Value as IFileResource; + file!.FileKind.Should().Be(FileKind.Orphan); + } + + [Test] + public void Classify_DoubleCelExtension_IsInvalidSidecar() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "stray.cel.cel"), "broken\n"); + + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var file = registry.GetResource(new ResourceKey("stray.cel.cel")).Value as IFileResource; + file!.FileKind.Should().Be(FileKind.InvalidSidecar); + } + + private ResourceRegistry BuildRegistry(ResourceClassifier classifier) { var registry = new ResourceRegistry( Substitute.For>(), new Celbridge.Messaging.Services.MessengerService(), new ProjectTreeBuilder(new Celbridge.UserInterface.Services.FileIconService()), - pairingService, + classifier, new RootHandlerRegistry()); registry.InitializeProjectRoot(_projectFolderPath); return registry; diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index ebddc1d1f..17490466d 100644 --- a/Source/Tests/Resources/ResourceCommandTests.cs +++ b/Source/Tests/Resources/ResourceCommandTests.cs @@ -49,7 +49,7 @@ public void Setup() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); _resourceRegistry.InitializeProjectRoot(_projectFolderPath); _resourceRegistry.UpdateResourceRegistry(); diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 0825b6f6b..6c6bbd33f 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -72,7 +72,7 @@ public void ICanUpdateTheResourceTree() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var updateResult = resourceRegistry.UpdateResourceRegistry(); @@ -111,7 +111,7 @@ public void ICanExpandAFolderResource() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var workspaceWrapper = Substitute.For(); @@ -142,7 +142,7 @@ public void ResolveResourcePathReturnsCorrectAbsolutePath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Create(FileNameA)); @@ -158,7 +158,7 @@ public void ResolveResourcePathWithEmptyKeyReturnsProjectFolder() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Empty); @@ -174,7 +174,7 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( @@ -199,7 +199,7 @@ public void ResolveResourcePathRejectsWrongCaseKey_WhenFileExistsOnDisk() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var updateResult = resourceRegistry.UpdateResourceRegistry(); updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); @@ -224,7 +224,7 @@ public void ResolveResourcePathAcceptsKeyForNonExistentResource() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var updateResult = resourceRegistry.UpdateResourceRegistry(); updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); @@ -244,7 +244,7 @@ public void ResolveResourcePathAcceptsNonExistentPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); // Non-existent files should still resolve without error @@ -261,7 +261,7 @@ public void ResolveResourcePathRoundTripsWithGetResourceKey() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var filePath = Path.Combine(_resourceFolderPath, FileNameA); @@ -302,7 +302,7 @@ public void ResolveResourcePathRejectsSymlinksWithinProject() { var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( @@ -330,7 +330,7 @@ public void ProjectRootHandlerIsRegisteredOnProjectFolderPathSet() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); // Before ProjectFolderPath is set, no handler is registered. resourceRegistry.RootHandlers.Should().BeEmpty(); @@ -352,7 +352,7 @@ public void IsResolvableReturnsTrueForProjectRootAndFalseForUnknownRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.IsResolvable(ResourceKey.Create("foo/bar")).Should().BeTrue(); @@ -369,7 +369,7 @@ public void ResolveResourcePathFailsClearlyForUnregisteredRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( @@ -386,7 +386,7 @@ public void GetAllFileResourcesScopesToProjectRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); resourceRegistry.UpdateResourceRegistry(); @@ -409,7 +409,7 @@ public void RegisterRootHandlerReplacesExistingHandler() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var originalHandler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; @@ -442,7 +442,7 @@ public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), SidecarPairingTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); // Register a temp root whose backing folder is nested inside the project folder. diff --git a/Source/Tests/Resources/SidecarClassificationTests.cs b/Source/Tests/Resources/SidecarClassificationTests.cs index 715b0813d..ebebf1bef 100644 --- a/Source/Tests/Resources/SidecarClassificationTests.cs +++ b/Source/Tests/Resources/SidecarClassificationTests.cs @@ -32,7 +32,7 @@ public void Setup() Substitute.For>(), new MessengerService(), new ProjectTreeBuilder(new FileIconService()), - SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(), + ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), new RootHandlerRegistry()); _registry.InitializeProjectRoot(_projectFolderPath); } @@ -53,7 +53,7 @@ public void TearDown() } } - private SidecarInfo? GetParentSidecar(string parentName) + private SidecarLink? GetParentSidecar(string parentName) { var resource = _registry.GetResource(new ResourceKey(parentName)).Value as IFileResource; return resource!.Sidecar; @@ -69,7 +69,7 @@ public void MalformedTomlPrefix_ClassifiedAsBroken_BytesUntouched() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); File.ReadAllText(sidecarPath).Should().Be(originalContent); } @@ -83,7 +83,7 @@ public void UnterminatedTomlString_ClassifiedAsBroken_BytesUntouched() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); File.ReadAllText(sidecarPath).Should().Be(originalContent); } @@ -102,9 +102,9 @@ public void MergeConflictMarkers_ClassifiedAsBroken_BytesUntouched() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); File.ReadAllText(sidecarPath).Should().Be(originalContent); - _registry.GetSidecarReport().Broken.Should().Contain(new ResourceKey("foo.png.cel")); + _registry.GetCelFileReport().Broken.Should().Contain(new ResourceKey("foo.png.cel")); } [Test] @@ -120,7 +120,7 @@ public void DuplicateBlockNames_ClassifiedAsBroken_BytesUntouched() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Broken); + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); File.ReadAllText(sidecarPath).Should().Be(originalContent); } @@ -134,7 +134,7 @@ public void BomAndCrlf_ClassifiedAsHealthy() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - GetParentSidecar("foo.png")!.Status.Should().Be(SidecarStatus.Healthy); + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Healthy); } [Test] @@ -151,7 +151,7 @@ public void ProjectLoads_EvenWhenSidecarStateIsBroken() var result = _registry.UpdateResourceRegistry(); result.IsSuccess.Should().BeTrue(); - GetParentSidecar("good.png")!.Status.Should().Be(SidecarStatus.Healthy); - GetParentSidecar("bad.png")!.Status.Should().Be(SidecarStatus.Broken); + GetParentSidecar("good.png")!.Status.Should().Be(CelFileStatus.Healthy); + GetParentSidecar("bad.png")!.Status.Should().Be(CelFileStatus.Broken); } } diff --git a/Source/Tests/Resources/SidecarTrackingTests.cs b/Source/Tests/Resources/SidecarTrackingTests.cs index 417a8c554..7833d7042 100644 --- a/Source/Tests/Resources/SidecarTrackingTests.cs +++ b/Source/Tests/Resources/SidecarTrackingTests.cs @@ -26,7 +26,7 @@ public void Setup() Substitute.For>(), new MessengerService(), new ProjectTreeBuilder(new FileIconService()), - SidecarPairingTestHelper.BuildPairingServiceWithNoFactories(), + ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), new RootHandlerRegistry()); _registry.InitializeProjectRoot(_projectFolderPath); } @@ -74,7 +74,7 @@ public void HealthySidecar_IsPairedWithStatusHealthy() var fileResource = resourceResult.Value as IFileResource; fileResource!.Sidecar.Should().NotBeNull(); fileResource.Sidecar!.Key.Should().Be(new ResourceKey("foo.png.cel")); - fileResource.Sidecar.Status.Should().Be(SidecarStatus.Healthy); + fileResource.Sidecar.Status.Should().Be(CelFileStatus.Healthy); var parentResult = _registry.GetSidecarParent(new ResourceKey("foo.png.cel")); parentResult.IsSuccess.Should().BeTrue(); @@ -101,7 +101,7 @@ public void OrphanSidecar_AppearsInReportOrphan() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetSidecarReport(); + var report = _registry.GetCelFileReport(); report.Orphan.Should().Contain(new ResourceKey("foo.png.cel")); } @@ -116,14 +116,14 @@ public void CelCelFile_AppearsInReportBroken() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetSidecarReport(); + var report = _registry.GetCelFileReport(); report.Broken.Should().Contain(new ResourceKey("foo.png.cel.cel")); // foo.png.cel is still healthy and paired with foo.png; the .cel.cel // file is not considered its sidecar. report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); var fooPng = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; - fooPng!.Sidecar!.Status.Should().Be(SidecarStatus.Healthy); + fooPng!.Sidecar!.Status.Should().Be(CelFileStatus.Healthy); } [Test] @@ -135,11 +135,11 @@ public void UnparseableSidecar_AppearsInReportBroken() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetSidecarReport(); + var report = _registry.GetCelFileReport(); report.Broken.Should().Contain(new ResourceKey("foo.png.cel")); var parent = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; - parent!.Sidecar!.Status.Should().Be(SidecarStatus.Broken); + parent!.Sidecar!.Status.Should().Be(CelFileStatus.Broken); } [Test] @@ -152,7 +152,7 @@ public void DeletingSidecar_FlipsParentToNullSidecar() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); var parent1 = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; - parent1!.Sidecar!.Status.Should().Be(SidecarStatus.Healthy); + parent1!.Sidecar!.Status.Should().Be(CelFileStatus.Healthy); File.Delete(sidecarPath); _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); @@ -168,12 +168,12 @@ public void BrokenOrphan_AppearsInBothBrokenAndOrphan() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetSidecarReport(); + var report = _registry.GetCelFileReport(); report.Broken.Should().Contain(new ResourceKey("lonely.cel")); report.Orphan.Should().Contain(new ResourceKey("lonely.cel")); } - // Standalone .cel form recognition (package.cel, foo.webview.cel) and the - // editor-registry hookup live in SidecarPairingServiceTests, which targets - // the pairing service directly. + // Standalone .cel form recognition (foo.webview.cel, foo.note.cel) and the + // editor-registry hookup live in ResourceClassifierTests, which targets + // the classifier directly. } diff --git a/Source/Tests/Search/FileFilterTests.cs b/Source/Tests/Search/FileFilterTests.cs index 9458d82d8..2724bf8ae 100644 --- a/Source/Tests/Search/FileFilterTests.cs +++ b/Source/Tests/Search/FileFilterTests.cs @@ -86,9 +86,9 @@ public async Task ShouldSearchFile_MetadataExtension_ReturnsFalse() [Test] public async Task ShouldSearchFile_CelExtension_ReturnsFalse() { - // .cel files (sidecars and standalone manifests) are excluded from - // plain-text search because their content is editor-owned and a - // plain-text replace would corrupt the file structure. + // .cel files (sidecars and standalone forms such as .webview.cel) are + // excluded from plain-text search because their content is editor-owned + // and a plain-text replace would corrupt the file structure. var (resource, filePath) = MakeResource("test.webview.cel"); File.WriteAllText(filePath, "source_url = \"https://example.com\"\n"); diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs index cee067a9e..b95a2a211 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs @@ -51,7 +51,7 @@ public Result RegisterFactory(IDocumentEditorFactory factory) _factories.Add(factory); // Index the factory by each supported extension. - // Multi-part extensions such as ".project.cel" are indexed as-is; the + // Multi-part extensions such as ".document.toml" are indexed as-is; the // longest-suffix walk in GetFactory tries the most specific form first. foreach (var extension in supportedExtensions) { @@ -250,9 +250,10 @@ public IReadOnlyList GetAllSupportedExtensions() } // Yields the extension suffixes of a filename from longest to shortest. - // "foo.project.cel" produces ".project.cel" then ".cel"; "foo.md" produces - // ".md"; "Makefile" produces nothing. A leading dot (".gitignore") is - // skipped so the file's full name is not treated as an extension. + // "foo.document.toml" produces ".document.toml" then ".toml"; "foo.md" + // produces ".md"; "Makefile" produces nothing. A leading dot + // (".gitignore") is skipped so the file's full name is not treated as + // an extension. private static IEnumerable GetExtensionSuffixes(string fileName) { int searchFrom = 0; diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs index a0b05dd76..33a469d86 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs @@ -150,7 +150,7 @@ public async Task> CreateAsync( } // Highest-priority factory for the resource. Placeholder factories - // (package.cel, *.celbridge, *.document.cel) reserve extensions but + // (package.toml, *.celbridge, *.document.toml) reserve extensions but // never produce a view, so they are skipped here. private IDocumentView? CreateFromPriorityFactory(ResourceKey fileResource) { diff --git a/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs b/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs index 5096c51c8..bdf6af83a 100644 --- a/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs +++ b/Source/Workspace/Celbridge.Packages/DocumentContributionFactory.cs @@ -5,11 +5,11 @@ namespace Celbridge.Packages; /// /// Factory that claims ownership of per-contribution document manifests -/// (*.document.cel). These files are sub-components of a package, loaded by -/// PackageManifestLoader as part of package.cel resolution; they are never opened -/// as in-workspace documents. The factory reserves the extension so the -/// resources subsystem classifies a .document.cel file as a standalone .cel -/// form rather than an orphan .cel file. +/// (*.document.toml). These files are sub-components of a package, loaded by +/// PackageManifestLoader as part of package.toml resolution; they are never +/// opened as in-workspace documents. The factory reserves the extension so the +/// "Open with..." picker treats a .document.toml file as a known manifest form +/// rather than a generic TOML file the user might want to edit by hand. /// public class DocumentContributionFactory : DocumentEditorFactoryBase { @@ -19,7 +19,7 @@ public class DocumentContributionFactory : DocumentEditorFactoryBase public override string DisplayName => _stringLocalizer.GetString("DocumentEditor_DocumentContribution"); - public override IReadOnlyList SupportedExtensions { get; } = [".document.cel"]; + public override IReadOnlyList SupportedExtensions { get; } = [".document.toml"]; public override bool IsPlaceholder => true; @@ -31,7 +31,7 @@ public DocumentContributionFactory(IStringLocalizer stringLocalizer) public override Result CreateDocumentView(ResourceKey fileResource) { // Document contributions are loaded by PackageManifestLoader as part of - // a parent package.cel; opening one as a document is not a supported flow. + // a parent package.toml; opening one as a document is not a supported flow. return Result.Fail( $"Document contribution '{fileResource}' is not opened as a document; it is loaded by the package service."); } diff --git a/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs b/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs index 56f917e63..0e9ad96cf 100644 --- a/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs +++ b/Source/Workspace/Celbridge.Packages/PackageManifestFactory.cs @@ -5,15 +5,15 @@ namespace Celbridge.Packages; /// /// Factory that claims ownership of package manifest files by exact filename -/// (package.cel). The manifest sits at the top of each package folder and has no -/// stem segment, so it is matched by filename rather than by a multi-part +/// (package.toml). The manifest sits at the top of each package folder and has +/// no stem segment, so it is matched by filename rather than by a multi-part /// extension form. Registering through the standard factory surface /// consolidates package-manifest identity in the same registry that other /// document editors use. /// public class PackageManifestFactory : DocumentEditorFactoryBase { - private const string PackageManifestFilename = "package.cel"; + private const string PackageManifestFilename = "package.toml"; private readonly IStringLocalizer _stringLocalizer; diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs index e33c4f4ab..2c5a6d634 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs @@ -13,7 +13,7 @@ namespace Celbridge.Packages; public class PackageRegistry { private const string PackagesFolderName = "packages"; - private const string ManifestFileName = "package.cel"; + private const string ManifestFileName = "package.toml"; private const string ReservedIdPrefix = "celbridge."; // Editors like the code editor can handle 150+ extensions; listing them all diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs index f1a55b06b..1ca1802d1 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs @@ -71,7 +71,7 @@ public override async Task ExecuteAsync() // the registry has recorded one for this file. Sidecars belong to // file resources only; folders don't have their own sidecars in v1. string? sidecarKey = null; - SidecarStatus? sidecarStatus = null; + CelFileStatus? sidecarStatus = null; var resourceResult = resourceRegistry.GetResource(Resource); if (resourceResult.IsSuccess && resourceResult.Value is IFileResource fileResource diff --git a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs index 931ed5163..8bf7b9c44 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs @@ -67,11 +67,11 @@ public override async Task ExecuteAsync() return string.Compare(a.Source.ToString(), b.Source.ToString(), StringComparison.Ordinal); }); - var sidecarReport = registry.GetSidecarReport(); - var orphanCelFiles = sidecarReport.Orphan + var celFileReport = registry.GetCelFileReport(); + var orphanCelFiles = celFileReport.Orphan .OrderBy(k => k.ToString(), StringComparer.Ordinal) .ToList(); - var brokenCelFiles = sidecarReport.Broken + var brokenCelFiles = celFileReport.Broken .OrderBy(k => k.ToString(), StringComparer.Ordinal) .ToList(); diff --git a/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs index 5057815c2..a267a3fb1 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs @@ -5,14 +5,26 @@ namespace Celbridge.Resources.Helpers; /// internal static class FileSystemHelper { + // Retry budget for cross-process sharing-violation races on file/folder + // moves. After a file is created, the OS, antivirus, search indexer, or + // shell can briefly hold a read handle on the file, which surfaces as an + // IOException ("being used by another process") on an immediate File.Move + // or Directory.Move. Mirrors the read/write retry budgets in + // ResourceFileSystem; worst-case wait across all attempts is + // MoveRetryBaseDelayMs * (1 + 2) = 150ms with the values below. + private const int MaxMoveAttempts = 3; + private const int MoveRetryBaseDelayMs = 50; + /// /// Moves a file to a destination, creating the destination directory if needed. + /// Retries briefly on transient IOException to absorb cross-process sharing + /// races; non-IO exceptions fall through unchanged. /// - public static void MoveFileWithDirectoryCreation(string sourcePath, string destPath) + public static async Task MoveFileWithDirectoryCreationAsync(string sourcePath, string destPath) { var destDir = Path.GetDirectoryName(destPath)!; Directory.CreateDirectory(destDir); - File.Move(sourcePath, destPath); + await MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); } /// @@ -27,12 +39,36 @@ public static void CopyFileWithDirectoryCreation(string sourcePath, string destP /// /// Moves a directory to a destination, creating the parent directory if needed. + /// Retries briefly on transient IOException to absorb cross-process sharing + /// races; non-IO exceptions fall through unchanged. /// - public static void MoveDirectoryWithParentCreation(string sourcePath, string destPath) + public static async Task MoveDirectoryWithParentCreationAsync(string sourcePath, string destPath) { var destParentDir = Path.GetDirectoryName(destPath)!; Directory.CreateDirectory(destParentDir); - Directory.Move(sourcePath, destPath); + await MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); + } + + /// + /// Invokes a synchronous file/folder move operation with brief retries on + /// transient IOException. Non-IO exceptions and the last attempt's + /// IOException propagate unchanged so persistent failures surface + /// immediately. + /// + public static async Task MoveWithRetryAsync(Action moveOperation) + { + for (var attempt = 1; attempt <= MaxMoveAttempts; attempt++) + { + try + { + moveOperation(); + return; + } + catch (IOException) when (attempt < MaxMoveAttempts) + { + await Task.Delay(MoveRetryBaseDelayMs * attempt); + } + } } /// diff --git a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs index 9c11945c0..b415ffdb7 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs @@ -312,7 +312,7 @@ private static string StripTrailingTerminator(string content) /// (parses cleanly) or Broken (any parse or read failure). The bytes on /// disk are never modified. /// - public static SidecarStatus Inspect(string absolutePath, ILogger logger) + public static CelFileStatus Inspect(string absolutePath, ILogger logger) { string text; try @@ -322,17 +322,17 @@ public static SidecarStatus Inspect(string absolutePath, ILogger logger) catch (Exception ex) { logger.LogWarning(ex, $"sidecar pairing: failed to read '{absolutePath}'"); - return SidecarStatus.Broken; + return CelFileStatus.Broken; } var parseResult = Parse(text); if (parseResult.IsFailure) { logger.LogWarning($"sidecar pairing: '{absolutePath}' has unparseable content"); - return SidecarStatus.Broken; + return CelFileStatus.Broken; } - return SidecarStatus.Healthy; + return CelFileStatus.Healthy; } // One physical line plus the line terminator that follows it. The diff --git a/Source/Workspace/Celbridge.Resources/Models/FileResource.cs b/Source/Workspace/Celbridge.Resources/Models/FileResource.cs index ceb47fb4e..75758a3c8 100644 --- a/Source/Workspace/Celbridge.Resources/Models/FileResource.cs +++ b/Source/Workspace/Celbridge.Resources/Models/FileResource.cs @@ -6,7 +6,12 @@ public class FileResource : Resource, IFileResource { public FileIconDefinition Icon { get; } - public SidecarInfo? Sidecar { get; set; } + public SidecarLink? Sidecar { get; set; } + + // PlainData is the safe default — correct for any non-.cel file. The + // classifier overwrites it during the project-load walk for the .cel cases + // before any consumer reads this value. + public FileKind FileKind { get; set; } = FileKind.PlainData; public FileResource(string name, IFolderResource parentFolder, FileIconDefinition icon) : base(name, parentFolder) diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index c96f09ed4..0f850b633 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -23,7 +23,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs similarity index 76% rename from Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs rename to Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs index eb5dbca87..b1903aba3 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarPairingService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs @@ -5,20 +5,20 @@ namespace Celbridge.Resources.Services; -public sealed class SidecarPairingService : ISidecarPairingService +public sealed class ResourceClassifier : IResourceClassifier { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IWorkspaceWrapper _workspaceWrapper; - public SidecarPairingService( - ILogger logger, + public ResourceClassifier( + ILogger logger, IWorkspaceWrapper workspaceWrapper) { _logger = logger; _workspaceWrapper = workspaceWrapper; } - public SidecarPairingResult ComputePairings(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry) + public ResourceClassificationResult ClassifyResources(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry) { var healthy = new List(); var broken = new List(); @@ -29,12 +29,12 @@ public SidecarPairingResult ComputePairings(IFolderResource projectRoot, IRootHa ProcessFolder(projectRoot); - var report = new SidecarReport( + var report = new CelFileReport( Healthy: healthy, Broken: broken, Orphan: orphan); - return new SidecarPairingResult(report, sidecarToParent); + return new ResourceClassificationResult(report, sidecarToParent); void ProcessFolder(IFolderResource folder) { @@ -71,6 +71,7 @@ void ClassifyFile(FileResource fileResource, Dictionary sibli if (name.EndsWith(".cel.cel", StringComparison.OrdinalIgnoreCase)) { fileResource.Sidecar = null; + fileResource.FileKind = FileKind.InvalidSidecar; broken.Add(ResourceTreeNavigator.BuildKey(fileResource)); return; } @@ -81,6 +82,10 @@ void ClassifyFile(FileResource fileResource, Dictionary sibli return; } + // A non-.cel file is plain data regardless of whether a sibling + // .cel sidecar exists for it; only its Sidecar pointer changes. + fileResource.FileKind = FileKind.PlainData; + var sidecarName = name + SidecarHelper.Extension; if (siblingByName.TryGetValue(sidecarName, out var sibling) && sibling is FileResource siblingFile @@ -91,8 +96,8 @@ void ClassifyFile(FileResource fileResource, Dictionary sibli // The sidecar's classification may not have run yet; populate a // placeholder Healthy entry now and let ClassifySidecarFile // overwrite it with the inspected status when it runs. - var existingStatus = fileResource.Sidecar?.Status ?? SidecarStatus.Healthy; - fileResource.Sidecar = new SidecarInfo(sidecarKey, existingStatus); + var existingStatus = fileResource.Sidecar?.Status ?? CelFileStatus.Healthy; + fileResource.Sidecar = new SidecarLink(sidecarKey, existingStatus); return; } @@ -112,12 +117,12 @@ void ClassifySidecarFile(FileResource sidecarFile, Dictionary // A failed resolve is treated as Broken — the bytes might still be // readable, but the rest of the system refuses to operate on them // and the user needs to see the file flagged for repair. - SidecarStatus status; + CelFileStatus status; var resolveResult = rootHandlerRegistry.ResolveResourcePath(sidecarKey); if (resolveResult.IsFailure) { _logger.LogWarning($"sidecar pairing: failed to resolve path for '{sidecarKey}': {resolveResult.FirstErrorMessage}"); - status = SidecarStatus.Broken; + status = CelFileStatus.Broken; } else { @@ -131,21 +136,27 @@ void ClassifySidecarFile(FileResource sidecarFile, Dictionary && parentSibling is FileResource parentFile) { sidecarToParent[sidecarKey] = ResourceTreeNavigator.BuildKey(parentFile); - parentFile.Sidecar = new SidecarInfo(sidecarKey, status); + parentFile.Sidecar = new SidecarLink(sidecarKey, status); + sidecarFile.FileKind = FileKind.Sidecar; } else { - // No parent: either a registered standalone .cel form (package.cel, - // foo.webview.cel, foo.document.cel) or a true orphan. Standalone - // forms are matched via the editor registry and must not appear - // in the orphan list. - if (!IsRegisteredStandaloneCelForm(sidecarKey, editorRegistry)) + // No parent: either a registered standalone .cel form + // (e.g. foo.webview.cel, foo.note.cel) or a true orphan. + // Standalone forms are matched via the editor registry and + // must not appear in the orphan list. + if (IsRegisteredStandaloneCelForm(sidecarKey, editorRegistry)) + { + sidecarFile.FileKind = FileKind.Standalone; + } + else { + sidecarFile.FileKind = FileKind.Orphan; orphan.Add(sidecarKey); } } - if (status == SidecarStatus.Healthy) + if (status == CelFileStatus.Healthy) { healthy.Add(sidecarKey); } @@ -164,13 +175,14 @@ private IDocumentEditorRegistry ResolveEditorRegistry() } // Checks whether a parentless .cel file is claimed by a registered factory - // in a way that denotes a standalone form. Two registration shapes count: - // an exact-filename match (e.g. "package.cel"), or a multi-part extension - // suffix that includes a segment in front of ".cel" (e.g. ".webview.cel", - // ".note.cel"). The bare ".cel" extension is excluded: it also serves the - // generic code-editor syntax-highlighting registration, which says nothing - // about pairing semantics. Without that exclusion every parentless ".cel" - // would silently disappear from the orphan report. + // in a way that denotes a standalone form. The match shape that counts + // here is a multi-part extension suffix that includes a segment in front + // of ".cel" (e.g. ".webview.cel", ".note.cel"). Exact-filename matches are + // also accepted for completeness, though no current factory registers a + // bare .cel filename. The bare ".cel" extension is excluded: it also + // serves the generic code-editor syntax-highlighting registration, which + // says nothing about pairing semantics. Without that exclusion every + // parentless ".cel" would silently disappear from the orphan report. private static bool IsRegisteredStandaloneCelForm( ResourceKey sidecarKey, IDocumentEditorRegistry editorRegistry) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs index 6107073e3..18d4ff0fc 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs @@ -236,11 +236,11 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey // attribute the user has explicitly chosen to override by // invoking a move on the file. ClearReadOnlyIfSet(sourcePath); - File.Move(sourcePath, destPath); + await FileSystemHelper.MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); } else { - Directory.Move(sourcePath, destPath); + await FileSystemHelper.MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); } } catch (UnauthorizedAccessException ex) @@ -254,7 +254,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey .WithException(ex); } - var sidecarOutcome = TryCascadeSidecarMove(source, destination); + var sidecarOutcome = await TryCascadeSidecarMoveAsync(source, destination); if (source.Root == ResourceKey.DefaultRoot) { @@ -802,7 +802,7 @@ private static string RewriteReferenceLiterals(string text, string sourceLiteral return builder.ToString(); } - private SidecarOutcome TryCascadeSidecarMove(ResourceKey source, ResourceKey destination) + private async Task TryCascadeSidecarMoveAsync(ResourceKey source, ResourceKey destination) { var sourceSidecar = AppendSidecarSuffix(source); var destSidecar = AppendSidecarSuffix(destination); @@ -848,7 +848,7 @@ private SidecarOutcome TryCascadeSidecarMove(ResourceKey source, ResourceKey des Directory.CreateDirectory(destFolder); } - File.Move(sourceSidecarPath, destSidecarPath); + await FileSystemHelper.MoveWithRetryAsync(() => File.Move(sourceSidecarPath, destSidecarPath)); return SidecarOutcome.Cascaded; } catch (Exception ex) @@ -1189,7 +1189,7 @@ private static async Task WriteAtomicAsync(string resourcePath, string stagingFo try { await File.WriteAllBytesAsync(tempPath, bytes); - File.Move(tempPath, resourcePath, overwrite: true); + await FileSystemHelper.MoveWithRetryAsync(() => File.Move(tempPath, resourcePath, overwrite: true)); } catch { diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index f9a1da979..60b52c5c4 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -230,7 +230,7 @@ public override async Task ExecuteAsync() // re-apply it via the OS file properties dialog. ClearReadOnlyIfSet(_originalPath); - FileSystemHelper.MoveFileWithDirectoryCreation(_originalPath, _trashPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_originalPath, _trashPath); // Also move entity data file to trash if it exists if (!string.IsNullOrEmpty(_entityDataOriginalPath) && @@ -238,7 +238,7 @@ public override async Task ExecuteAsync() File.Exists(_entityDataOriginalPath)) { ClearReadOnlyIfSet(_entityDataOriginalPath); - FileSystemHelper.MoveFileWithDirectoryCreation(_entityDataOriginalPath, _entityDataTrashPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_entityDataOriginalPath, _entityDataTrashPath); } // Also move the paired sidecar to trash if it exists @@ -247,7 +247,7 @@ public override async Task ExecuteAsync() File.Exists(_sidecarOriginalPath)) { ClearReadOnlyIfSet(_sidecarOriginalPath); - FileSystemHelper.MoveFileWithDirectoryCreation(_sidecarOriginalPath, _sidecarTrashPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_sidecarOriginalPath, _sidecarTrashPath); } return Result.Ok(); @@ -287,14 +287,14 @@ public override async Task UndoAsync() return Result.Fail($"Trash file does not exist: {_trashPath}"); } - FileSystemHelper.MoveFileWithDirectoryCreation(_trashPath, _originalPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_trashPath, _originalPath); // Also restore entity data file if it was trashed if (!string.IsNullOrEmpty(_entityDataOriginalPath) && !string.IsNullOrEmpty(_entityDataTrashPath) && File.Exists(_entityDataTrashPath)) { - FileSystemHelper.MoveFileWithDirectoryCreation(_entityDataTrashPath, _entityDataOriginalPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_entityDataTrashPath, _entityDataOriginalPath); } // Also restore the paired sidecar if it was trashed @@ -302,7 +302,7 @@ public override async Task UndoAsync() !string.IsNullOrEmpty(_sidecarTrashPath) && File.Exists(_sidecarTrashPath)) { - FileSystemHelper.MoveFileWithDirectoryCreation(_sidecarTrashPath, _sidecarOriginalPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_sidecarTrashPath, _sidecarOriginalPath); } FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); @@ -514,10 +514,10 @@ public override async Task ExecuteAsync() else { // Move entity data files to trash first - MoveEntityDataFilesToTrash(); + await MoveEntityDataFilesToTrashAsync(); // Non-empty folder - move to trash - FileSystemHelper.MoveDirectoryWithParentCreation(_originalPath, _trashPath); + await FileSystemHelper.MoveDirectoryWithParentCreationAsync(_originalPath, _trashPath); } return Result.Ok(); @@ -573,10 +573,10 @@ public override async Task UndoAsync() return Result.Fail($"Trash folder does not exist: {_trashPath}"); } - FileSystemHelper.MoveDirectoryWithParentCreation(_trashPath, _originalPath); + await FileSystemHelper.MoveDirectoryWithParentCreationAsync(_trashPath, _originalPath); // Restore entity data files from trash - RestoreEntityDataFiles(); + await RestoreEntityDataFilesAsync(); FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); } @@ -616,25 +616,25 @@ public void CleanupTrashFolder() } } - private void MoveEntityDataFilesToTrash() + private async Task MoveEntityDataFilesToTrashAsync() { foreach (var (originalPath, trashPath) in _entityDataFiles) { if (File.Exists(originalPath)) { - FileSystemHelper.MoveFileWithDirectoryCreation(originalPath, trashPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(originalPath, trashPath); FileSystemHelper.CleanupEmptyParentDirectories(originalPath); } } } - private void RestoreEntityDataFiles() + private async Task RestoreEntityDataFilesAsync() { foreach (var (originalPath, trashPath) in _entityDataFiles) { if (File.Exists(trashPath)) { - FileSystemHelper.MoveFileWithDirectoryCreation(trashPath, originalPath); + await FileSystemHelper.MoveFileWithDirectoryCreationAsync(trashPath, originalPath); FileSystemHelper.CleanupEmptyParentDirectories(trashPath); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index 09b9a7e9c..889e1e7df 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -9,14 +9,14 @@ public class ResourceRegistry : IResourceRegistry private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IProjectTreeBuilder _projectTreeBuilder; - private readonly ISidecarPairingService _sidecarPairingService; + private readonly IResourceClassifier _resourceClassifier; private readonly RootHandlerRegistry _rootHandlerRegistry; // Sidecar tracking state, refreshed on each UpdateResourceRegistry pass. // The report is rebuilt atomically per pass so readers always see a coherent // snapshot. private readonly object _sidecarLock = new(); - private SidecarReport _sidecarReport = new( + private CelFileReport _celFileReport = new( Healthy: Array.Empty(), Broken: Array.Empty(), Orphan: Array.Empty()); @@ -45,13 +45,13 @@ public ResourceRegistry( ILogger logger, IMessengerService messengerService, IProjectTreeBuilder projectTreeBuilder, - ISidecarPairingService sidecarPairingService, + IResourceClassifier resourceClassifier, RootHandlerRegistry rootHandlerRegistry) { _logger = logger; _messengerService = messengerService; _projectTreeBuilder = projectTreeBuilder; - _sidecarPairingService = sidecarPairingService; + _resourceClassifier = resourceClassifier; _rootHandlerRegistry = rootHandlerRegistry; } @@ -198,7 +198,7 @@ public Result UpdateResourceRegistry() // handler registry is handed in so per-sidecar path resolution // goes through the same reparse-point chokepoint as every other // resource operation. - var pairings = _sidecarPairingService.ComputePairings(newRoot, _rootHandlerRegistry); + var pairings = _resourceClassifier.ClassifyResources(newRoot, _rootHandlerRegistry); Volatile.Write(ref _projectFolder, newRoot); @@ -209,7 +209,7 @@ public Result UpdateResourceRegistry() { _sidecarToParent[entry.Key] = entry.Value; } - _sidecarReport = pairings.Report; + _celFileReport = pairings.Report; } _rootHandlerRegistry.InvalidatePathCache(); @@ -317,11 +317,11 @@ public Result GetSidecarParent(ResourceKey sidecar) } } - public SidecarReport GetSidecarReport() + public CelFileReport GetCelFileReport() { lock (_sidecarLock) { - return _sidecarReport; + return _celFileReport; } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index c8aa4bf32..ffd711579 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -32,7 +32,7 @@ public ResourceService( IProjectService projectService, IWorkspaceWrapper workspaceWrapper, IProjectTreeBuilder projectTreeBuilder, - ISidecarPairingService sidecarPairingService, + IResourceClassifier resourceClassifier, IResourceMonitor resourceMonitor, IResourceTransferService resourceTransferService, IResourceOperationService resourceOperationService) @@ -54,7 +54,7 @@ public ResourceService( registryLogger, messengerService, projectTreeBuilder, - sidecarPairingService, + resourceClassifier, rootHandlerRegistry); Monitor = resourceMonitor; From 9d700fa1dc3982ad8aa8b090acf9aaf223ed558b Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 14:13:05 +0100 Subject: [PATCH 29/48] Rename ResourceFileSystem API to FileStorage Rename the chokepoint API from IResourceFileSystem to IFileStorage and update related types and members across the codebase. ResourceInfoKind -> StorageItemKind and ResourceInfo -> StorageItemInfo were renamed; IWorkspaceService.ResourceFileSystem was changed to FileStorage. All callers (tools, commands, services, UI viewmodels, tests and docs) were updated to use the new interface and type names, and the foundation interface file was renamed accordingly. CLAUDE.md updated to reflect the new API name. This aligns naming with broader "storage" semantics and centralizes file access behind the FileStorage chokepoint. --- CLAUDE.md | 2 +- ...IResourceFileSystem.cs => IFileStorage.cs} | 26 +++--- .../Resources/IResourceScanner.cs | 2 +- .../Workspace/IWorkspaceService.cs | 2 +- .../Tools/File/FileTools.Edit.cs | 4 +- .../Tools/File/FileTools.Grep.cs | 20 ++-- .../Tools/File/FileTools.MultiEdit.cs | 4 +- .../Tools/File/FileTools.Read.cs | 8 +- .../Tools/File/FileTools.ReadBinary.cs | 8 +- .../Tools/File/FileTools.ReadImage.cs | 8 +- .../Tools/File/FileTools.ReadMany.cs | 8 +- .../Tools/File/FileTools.Replace.cs | 4 +- .../Tools/File/FileTools.Search.cs | 22 ++--- .../Celbridge.Tools/Tools/File/FileTools.cs | 8 +- .../Tools/Package/PackageTools.Create.cs | 4 +- .../Tools/Package/PackageTools.Install.cs | 6 +- .../Tools/Spreadsheet/SpreadsheetTools.cs | 14 +-- .../WebView/WebViewScreenshotResolver.cs | 16 ++-- .../Tools/WebView/WebViewTools.Screenshot.cs | 4 +- .../Dialogs/ResourcePickerDialogViewModel.cs | 10 +- .../Commands/AddSheetsCommand.cs | 6 +- .../Commands/AppendRowsCommand.cs | 6 +- .../Commands/ClearRangesCommand.cs | 6 +- .../Commands/DeleteRangesCommand.cs | 6 +- .../Commands/DuplicateSheetCommand.cs | 6 +- .../Commands/FormatRangesCommand.cs | 6 +- .../Commands/FreezePanesCommand.cs | 6 +- .../Commands/ImportCsvCommand.cs | 6 +- .../Commands/InsertRangesCommand.cs | 6 +- .../Commands/MoveSheetCommand.cs | 6 +- .../Commands/RemoveSheetCommand.cs | 6 +- .../Commands/RenameSheetCommand.cs | 6 +- .../Commands/SetActiveViewCommand.cs | 6 +- .../Commands/SetAutoFilterCommand.cs | 6 +- .../SetConditionalFormattingCommand.cs | 6 +- .../Commands/SortRangeCommand.cs | 6 +- .../Commands/WriteCellsCommand.cs | 6 +- .../Helpers/SpreadsheetHelper.cs | 16 ++-- .../Documents/DocumentLayoutStoreTests.cs | 8 +- .../Tests/Documents/DocumentViewModelTests.cs | 30 +++--- .../Tests/Packages/FileTypeProviderTests.cs | 6 +- Source/Tests/Packages/PackageRegistryTests.cs | 6 +- .../Resources/ApplyRangeEditsCommandTests.cs | 4 +- .../Tests/Resources/DataCheckProjectTests.cs | 6 +- .../Tests/Resources/EditFileCommandTests.cs | 4 +- ...FileSystemTests.cs => FileStorageTests.cs} | 86 +++++++++--------- .../Resources/MultiEditFileCommandTests.cs | 4 +- .../Resources/ReplaceFileCommandTests.cs | 4 +- .../Tests/Resources/ResourceCommandTests.cs | 8 +- Source/Tests/Resources/SidecarServiceTests.cs | 70 +++++++------- .../Resources/WriteBinaryFileCommandTests.cs | 4 +- .../Tests/Resources/WriteFileCommandTests.cs | 4 +- Source/Tests/Search/FileFilterTests.cs | 26 +++--- .../Spreadsheet/SpreadsheetCommandTests.cs | 6 +- Source/Tests/Tools/FileToolTests.cs | 8 +- Source/Tests/Tools/FileToolsReadImageTests.cs | 6 +- Source/Tests/Tools/SpreadsheetToolTests.cs | 6 +- .../Tools/WebViewScreenshotResolverTests.cs | 38 ++++---- .../ViewModels/ConsolePanelViewModel.cs | 10 +- .../Helpers/FileAccessHelper.cs | 14 +-- .../Services/DocumentsService.cs | 6 +- .../ContributionDocumentViewModel.cs | 16 ++-- .../ViewModels/DocumentTabViewModel.cs | 12 +-- .../ViewModels/DocumentViewModel.cs | 34 +++---- .../Celbridge.Documents/Views/DocumentView.cs | 16 ++-- .../Commands/AddResourceDialogCommand.cs | 12 +-- .../Services/InspectorFactory.cs | 8 +- .../Services/PackageRegistry.cs | 12 +-- .../Python/celbridge-0.1.0-py3-none-any.whl | Bin 43815 -> 43813 bytes .../integration_tests/test_explorer.py | 2 +- .../Commands/ApplyRangeEditsCommand.cs | 14 +-- .../Commands/ArchiveResourceCommand.cs | 26 +++--- .../Commands/CopyResourceCommand.cs | 12 +-- .../Commands/DeleteResourceCommand.cs | 24 ++--- .../Commands/EditFileCommand.cs | 10 +- .../Commands/GetFileInfoCommand.cs | 14 +-- .../Commands/GetFileTreeCommand.cs | 12 +-- .../Commands/ListFolderContentsCommand.cs | 4 +- .../Commands/MultiEditFileCommand.cs | 10 +- .../Commands/ProjectCheckCommand.cs | 4 +- .../Commands/ReplaceFileCommand.cs | 14 +-- .../Commands/UnarchiveResourceCommand.cs | 16 ++-- .../Commands/WriteBinaryFileCommand.cs | 4 +- .../Commands/WriteFileCommand.cs | 10 +- .../Helpers/ArchiveHelper.cs | 4 +- .../Helpers/FileSystemHelper.cs | 2 +- .../ServiceConfiguration.cs | 2 +- .../{ResourceFileSystem.cs => FileStorage.cs} | 26 +++--- .../Services/ReferenceLiteralRules.cs | 2 +- .../Services/ResourceOperationService.cs | 36 ++++---- .../Services/ResourceOperations.cs | 48 +++++----- .../Services/ResourceScanner.cs | 14 +-- .../Services/ResourceTransferService.cs | 6 +- .../Services/SidecarService.cs | 12 +-- .../Celbridge.Search/Services/FileFilter.cs | 6 +- .../Services/SearchService.cs | 30 +++--- .../Services/DataTransferService.cs | 6 +- .../Services/WorkspaceService.cs | 4 +- 98 files changed, 575 insertions(+), 571 deletions(-) rename Source/Core/Celbridge.Foundation/Resources/{IResourceFileSystem.cs => IFileStorage.cs} (87%) rename Source/Tests/Resources/{ResourceFileSystemTests.cs => FileStorageTests.cs} (91%) rename Source/Workspace/Celbridge.Resources/Services/{ResourceFileSystem.cs => FileStorage.cs} (98%) diff --git a/CLAUDE.md b/CLAUDE.md index 91adffd9f..78a05e396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ python run_tests.py ## Architecture - Workspace-scoped services are transient and must NOT be injected via constructor DI. Access them through `_workspaceWrapper.WorkspaceService`: - - IWorkspaceSettingsService, IWorkspaceSettings, IResourceRegistry, IResourceFileSystem, IResourceTransferService, IResourceOperationService, IPythonService, IConsoleService, IDocumentsService, IExplorerService, IInspectorService, IDataTransferService, IEntityService, IGenerativeAIService, IActivityService + - IWorkspaceSettingsService, IWorkspaceSettings, IResourceRegistry, IFileStorage, IResourceTransferService, IResourceOperationService, IPythonService, IConsoleService, IDocumentsService, IExplorerService, IInspectorService, IDataTransferService, IEntityService, IGenerativeAIService, IActivityService - Project configuration: use `IProjectService.CurrentProject` (singleton) to access the current project, and `project.Config` for its config. To parse `.celbridge` files outside of project loading, use `ProjectConfigParser.ParseFromFile()` - The Foundation project (`Core\Celbridge.Foundation`) should only contain abstractions (interfaces, abstract classes), never concrete implementations - Never bypass `ICommandService` to call methods directly. Every important operation goes through the command service for automation and auditing support. If a command-based flow has a bug, fix it within the command service pattern (e.g., add new command options or fix the command handling logic) diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs similarity index 87% rename from Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs rename to Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs index ef3617ff2..c3f3dd3aa 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceFileSystem.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs @@ -81,7 +81,7 @@ public record FolderItem( /// /// Discriminates the outcome of a GetInfoAsync probe. /// -public enum ResourceInfoKind +public enum StorageItemKind { NotFound, File, @@ -94,20 +94,24 @@ public enum ResourceInfoKind /// the last-modified timestamp for File and Folder; default(DateTime) for /// NotFound. /// -public record ResourceInfo( - ResourceInfoKind Kind, +public record StorageItemInfo( + StorageItemKind Kind, long Size, DateTime ModifiedUtc); /// -/// The chokepoint for disk reads, writes, and structural operations on project -/// resources. Callers pass a ResourceKey; the layer resolves it through -/// IResourceRegistry so containment and symlink validation run automatically. -/// Bytes and text writes are atomic via temp-file rename with bounded retry on -/// transient IO failures. Structural operations include reference rewrites and -/// the paired-sidecar cascade as part of their definition. +/// The chokepoint for disk reads, writes, and structural operations against any +/// resource addressable by a ResourceKey — files under the project tree as well +/// as files under registered non-project roots (e.g. temp:, logs:). Callers pass +/// a ResourceKey; the layer dispatches via the registered root handlers so +/// containment and symlink validation run automatically. Bytes and text writes +/// are atomic via temp-file rename with bounded retry on transient IO failures. +/// Structural operations on project: resources additionally cascade the paired +/// sidecar, and rewrite references that live inside scannable file types (see +/// ResourceScanner for the current allowlist); operations on non-project roots +/// are pure byte moves. /// -public interface IResourceFileSystem +public interface IFileStorage { /// /// Reads the full byte content of the resource. @@ -171,7 +175,7 @@ public interface IResourceFileSystem /// file vs folder switch on Kind. Size and ModifiedUtc are populated for /// the File case; Folder yields Size = 0 with ModifiedUtc set. /// - Task> GetInfoAsync(ResourceKey resource); + Task> GetInfoAsync(ResourceKey resource); /// /// Returns the immediate children of a folder resource as FolderItem records. diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceScanner.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceScanner.cs index 73ff286f9..f7614bb67 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceScanner.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceScanner.cs @@ -3,7 +3,7 @@ namespace Celbridge.Resources; /// /// On-demand scanner over the project's text and sidecar files. Each call /// walks the registry's known files in parallel; no in-memory index is -/// maintained between calls. Reads go through IResourceFileSystem so the +/// maintained between calls. Reads go through IFileStorage so the /// chokepoint's atomic-read + retry semantics apply uniformly. /// public interface IResourceScanner diff --git a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs index 12e2944e7..217470b75 100644 --- a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs +++ b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs @@ -49,7 +49,7 @@ void SetPanels( /// /// Returns the chokepoint file-system layer for project resources. /// - IResourceFileSystem ResourceFileSystem { get; } + IFileStorage FileStorage { get; } /// /// Returns the on-demand scanner over project text and sidecar files, diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs index e5a47247e..d2bcf45aa 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs @@ -55,7 +55,7 @@ public async partial Task Edit( var editValue = editResult.Value; var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; var affectedLines = new List(editValue.AffectedRanges.Count); @@ -67,7 +67,7 @@ public async partial Task Edit( string[]? fileLines = null; if (editValue.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(fileSystem, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileStorage, fileResourceKey); } foreach (var range in editValue.AffectedRanges) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs index 42ae76e9a..114d7855d 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs @@ -63,11 +63,11 @@ public async partial Task Grep(string searchTerm, bool useRegex } var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; if (!string.IsNullOrEmpty(files)) { - return await GrepTargetedFiles(files, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, fileSystem); + return await GrepTargetedFiles(files, searchTerm, useRegex, matchCase, wholeWord, maxResults, contextLines, includeContent, summaryOnly, fileStorage); } var searchService = workspaceWrapper.WorkspaceService.SearchService; @@ -101,7 +101,7 @@ public async partial Task Grep(string searchTerm, bool useRegex { if (!fileLineCache.TryGetValue(fileResult.Resource, out var fileLines)) { - fileLines = await ReadFileLinesStreamedAsync(fileSystem, fileResult.Resource); + fileLines = await ReadFileLinesStreamedAsync(fileStorage, fileResult.Resource); fileLineCache[fileResult.Resource] = fileLines; } @@ -144,7 +144,7 @@ public async partial Task Grep(string searchTerm, bool useRegex if (includeContent && !summaryOnly) { - var contentResult = await fileSystem.ReadAllTextAsync(fileResult.Resource); + var contentResult = await fileStorage.ReadAllTextAsync(fileResult.Resource); if (contentResult.IsSuccess) { fileContent = contentResult.Value; @@ -197,9 +197,9 @@ private static CallToolResult BuildGrepResponse(GrepResult grepResult) /// read through containment validation. Returns an empty array on failure /// so callers can treat missing or unreadable files as zero matches. /// - private static async Task ReadFileLinesStreamedAsync(IResourceFileSystem fileSystem, ResourceKey resource) + private static async Task ReadFileLinesStreamedAsync(IFileStorage fileStorage, ResourceKey resource) { - var openResult = await fileSystem.OpenReadAsync(resource); + var openResult = await fileStorage.OpenReadAsync(resource); if (openResult.IsFailure) { return Array.Empty(); @@ -216,7 +216,7 @@ private static async Task ReadFileLinesStreamedAsync(IResourceFileSyst return lines.ToArray(); } - private async Task GrepTargetedFiles(string filesJson, string searchTerm, bool useRegex, bool matchCase, bool wholeWord, int maxResults, int contextLines, bool includeContent, bool summaryOnly, IResourceFileSystem fileSystem) + private async Task GrepTargetedFiles(string filesJson, string searchTerm, bool useRegex, bool matchCase, bool wholeWord, int maxResults, int contextLines, bool includeContent, bool summaryOnly, IFileStorage fileStorage) { // Detect the most common mis-use: a glob or single path passed where a // JSON array is required. The raw JsonException for this case ("'w' is @@ -272,14 +272,14 @@ private async Task GrepTargetedFiles(string filesJson, string se continue; } - var infoResult = await fileSystem.GetInfoAsync(fileResourceKey); + var infoResult = await fileStorage.GetInfoAsync(fileResourceKey); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { continue; } - var fileLines = await ReadFileLinesStreamedAsync(fileSystem, fileResourceKey); + var fileLines = await ReadFileLinesStreamedAsync(fileStorage, fileResourceKey); var matchList = new List(); int fileMatchCount = 0; diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs index 4b5b0a466..22283a674 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.MultiEdit.cs @@ -90,12 +90,12 @@ public async partial Task MultiEdit(string fileResource, string // only verification signal a caller has for a truncated edit, so // stripping their context would leave bare positions with no evidence. var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; string[]? fileLines = null; if (resultValue.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(fileSystem, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileStorage, fileResourceKey); } var affectedLines = new List(resultValue.AffectedRanges.Count); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs index 0c90499da..e0504b8d1 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Read.cs @@ -22,9 +22,9 @@ public async partial Task Read(string resource, int offset = 0, } var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(resourceKey); + var infoResult = await fileStorage.GetInfoAsync(resourceKey); if (infoResult.IsFailure) { // Surface the chokepoint's failure verbatim so case-mismatch @@ -33,12 +33,12 @@ public async partial Task Read(string resource, int offset = 0, // resolve succeeded but the resource genuinely is not a file. return ToolResponse.Error(infoResult); } - if (infoResult.Value.Kind != ResourceInfoKind.File) + if (infoResult.Value.Kind != StorageItemKind.File) { return ToolResponse.Error($"Resource not found: '{resourceKey}'. file_read addresses resources by resource key, not arbitrary disk paths — only files under a registered root (e.g. 'project:', 'temp:', 'logs:') can be read."); } - var readResult = await fileSystem.ReadAllTextAsync(resourceKey); + var readResult = await fileStorage.ReadAllTextAsync(resourceKey); if (readResult.IsFailure) { return ToolResponse.Error(readResult.FirstErrorMessage); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs index 9a8aa484b..ba5329419 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadBinary.cs @@ -23,19 +23,19 @@ public async partial Task ReadBinary(string resource) } var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(resourceKey); + var infoResult = await fileStorage.GetInfoAsync(resourceKey); if (infoResult.IsFailure) { return ToolResponse.Error(infoResult); } - if (infoResult.Value.Kind != ResourceInfoKind.File) + if (infoResult.Value.Kind != StorageItemKind.File) { return ToolResponse.Error($"File not found: '{resourceKey}'"); } - var bytesResult = await fileSystem.ReadAllBytesAsync(resourceKey); + var bytesResult = await fileStorage.ReadAllBytesAsync(resourceKey); if (bytesResult.IsFailure) { return ToolResponse.Error(bytesResult.FirstErrorMessage); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs index 150f2d3b4..07119aaa6 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadImage.cs @@ -36,14 +36,14 @@ public async partial Task ReadImage(string resource) } var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(resourceKey); + var infoResult = await fileStorage.GetInfoAsync(resourceKey); if (infoResult.IsFailure) { return ToolResponse.Error(infoResult); } - if (infoResult.Value.Kind != ResourceInfoKind.File) + if (infoResult.Value.Kind != StorageItemKind.File) { return ToolResponse.Error($"File not found: '{resourceKey}'"); } @@ -66,7 +66,7 @@ public async partial Task ReadImage(string resource) $"before calling file_read_image."); } - var bytesResult = await fileSystem.ReadAllBytesAsync(resourceKey); + var bytesResult = await fileStorage.ReadAllBytesAsync(resourceKey); if (bytesResult.IsFailure) { return ToolResponse.Error(bytesResult.FirstErrorMessage); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs index 602a6457c..6294f0777 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.ReadMany.cs @@ -39,7 +39,7 @@ public async partial Task ReadMany(string resources, int offset } var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; var entries = new List(); foreach (var resourceString in resourceKeys) @@ -54,19 +54,19 @@ public async partial Task ReadMany(string resources, int offset // entries for different roots are unambiguous regardless of how the agent typed them. var canonicalResource = resourceKey.ToString(); - var infoResult = await fileSystem.GetInfoAsync(resourceKey); + var infoResult = await fileStorage.GetInfoAsync(resourceKey); if (infoResult.IsFailure) { entries.Add(new ReadManyFileEntry(canonicalResource, Error: infoResult.FirstErrorMessage)); continue; } - if (infoResult.Value.Kind != ResourceInfoKind.File) + if (infoResult.Value.Kind != StorageItemKind.File) { entries.Add(new ReadManyFileEntry(canonicalResource, Error: $"File not found: '{canonicalResource}'")); continue; } - var readResult = await fileSystem.ReadAllTextAsync(resourceKey); + var readResult = await fileStorage.ReadAllTextAsync(resourceKey); if (readResult.IsFailure) { entries.Add(new ReadManyFileEntry(canonicalResource, Error: readResult.FirstErrorMessage)); diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs index 7a618a3db..f6a8b9b1e 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs @@ -56,7 +56,7 @@ public async partial Task Replace( var commandResult = findReplaceResult.Value; var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; var affectedLines = new List(commandResult.AffectedRanges.Count); @@ -68,7 +68,7 @@ public async partial Task Replace( string[]? fileLines = null; if (commandResult.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(fileSystem, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileStorage, fileResourceKey); } foreach (var range in commandResult.AffectedRanges) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs index 580591e4c..7dbb2b90b 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs @@ -19,7 +19,7 @@ public async partial Task Search(string pattern, bool includeMet { var workspaceWrapper = GetRequiredService(); var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; var regexPattern = GlobHelper.PathGlobToRegex(pattern); var regex = new Regex(regexPattern, RegexOptions.IgnoreCase); @@ -35,7 +35,7 @@ public async partial Task Search(string pattern, bool includeMet && resourceRegistry.RootHandlers.ContainsKey(patternRoot)) { return await SearchNonDefaultRootAsync( - fileSystem, patternRoot, regex, isFolderSearch, includeMetadata); + fileStorage, patternRoot, regex, isFolderSearch, includeMetadata); } if (isFolderSearch) @@ -52,9 +52,9 @@ public async partial Task Search(string pattern, bool includeMet var results = new List(); foreach (var folderKey in matchingFolders) { - var infoResult = await fileSystem.GetInfoAsync(folderKey); + var infoResult = await fileStorage.GetInfoAsync(folderKey); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.Folder) + || infoResult.Value.Kind != StorageItemKind.Folder) { continue; } @@ -81,9 +81,9 @@ public async partial Task Search(string pattern, bool includeMet var results = new List(); foreach (var match in matches) { - var infoResult = await fileSystem.GetInfoAsync(match.Resource); + var infoResult = await fileStorage.GetInfoAsync(match.Resource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { continue; } @@ -112,7 +112,7 @@ public async partial Task Search(string pattern, bool includeMet } private async Task SearchNonDefaultRootAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, string rootName, Regex regex, bool isFolderSearch, @@ -120,7 +120,7 @@ private async Task SearchNonDefaultRootAsync( { var rootKey = new ResourceKey(rootName + ":"); var allEntries = new List(); - await CollectRecursiveAsync(fileSystem, rootKey, allEntries); + await CollectRecursiveAsync(fileStorage, rootKey, allEntries); var matches = allEntries .Where(entry => entry.IsFolder == isFolderSearch) @@ -143,11 +143,11 @@ private async Task SearchNonDefaultRootAsync( } private static async Task CollectRecursiveAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, ResourceKey folder, List entries) { - var enumerateResult = await fileSystem.EnumerateFolderAsync(folder); + var enumerateResult = await fileStorage.EnumerateFolderAsync(folder); if (enumerateResult.IsFailure) { return; @@ -158,7 +158,7 @@ private static async Task CollectRecursiveAsync( entries.Add(entry); if (entry.IsFolder) { - await CollectRecursiveAsync(fileSystem, entry.Resource, entries); + await CollectRecursiveAsync(fileStorage, entry.Resource, entries); } } } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs index 4c938297f..75bfea563 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs @@ -78,16 +78,16 @@ private static string SerializeJson(object value) /// when the resource cannot be resolved or the file no longer exists, so /// the caller can fall back to ranges without context. /// - private static async Task ReadFileLinesForContextAsync(IResourceFileSystem fileSystem, ResourceKey fileResourceKey) + private static async Task ReadFileLinesForContextAsync(IFileStorage fileStorage, ResourceKey fileResourceKey) { - var infoResult = await fileSystem.GetInfoAsync(fileResourceKey); + var infoResult = await fileStorage.GetInfoAsync(fileResourceKey); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return null; } - var readResult = await fileSystem.ReadAllTextAsync(fileResourceKey); + var readResult = await fileStorage.ReadAllTextAsync(fileResourceKey); if (readResult.IsFailure) { return null; diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs index dc6406c92..c69676cb5 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs @@ -29,7 +29,7 @@ public async partial Task Create(string packageName) var workspaceWrapper = GetRequiredService(); var workspaceService = workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var packageResource = ResourceKey.Create($"packages/{packageName}"); var resolveResult = resourceRegistry.ResolveResourcePath(packageResource); @@ -55,7 +55,7 @@ public async partial Task Create(string packageName) manifestContent.AppendLine("[contributes]"); var manifestResource = ResourceKey.Create($"packages/{packageName}/{ManifestFileName}"); - var writeManifestResult = await fileSystem.WriteAllTextAsync(manifestResource, manifestContent.ToString()); + var writeManifestResult = await fileStorage.WriteAllTextAsync(manifestResource, manifestContent.ToString()); if (writeManifestResult.IsFailure) { return ToolResponse.Error($"Failed to create package: {writeManifestResult.FirstErrorMessage}"); diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs index 802722268..9af63baf3 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs @@ -71,12 +71,12 @@ public async partial Task Install(string packageName, bool confi var workspaceWrapper = GetRequiredService(); var workspaceService = workspaceWrapper.WorkspaceService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; // Stage the downloaded zip under temp: so it lives in .celbridge/temp/ // (created at workspace load) and is reachable through the chokepoint. var tempArchiveResource = new ResourceKey($"temp:{packageName}.zip"); - var writeArchiveResult = await fileSystem.WriteAllBytesAsync(tempArchiveResource, downloadResult.Value); + var writeArchiveResult = await fileStorage.WriteAllBytesAsync(tempArchiveResource, downloadResult.Value); if (writeArchiveResult.IsFailure) { return ToolResponse.Error($"Failed to write downloaded package: {writeArchiveResult.FirstErrorMessage}"); @@ -107,7 +107,7 @@ public async partial Task Install(string packageName, bool confi { // Best-effort cleanup of the staged archive; a failure here does // not change the install outcome the caller sees. - await fileSystem.DeleteAsync(tempArchiveResource); + await fileStorage.DeleteAsync(tempArchiveResource); } } } diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs index ff29b9d3f..4d66e9905 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs @@ -37,8 +37,8 @@ private async Task> ResolveWorkbookResourceAsync(string reso } var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(resourceKey); + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(resourceKey); if (infoResult.IsFailure) { return Result.Fail($"Failed to inspect workbook: '{resourceKey}'") @@ -46,11 +46,11 @@ private async Task> ResolveWorkbookResourceAsync(string reso } var info = infoResult.Value; - if (info.Kind == ResourceInfoKind.NotFound) + if (info.Kind == StorageItemKind.NotFound) { return Result.Fail($"File not found: '{resourceKey}'"); } - if (info.Kind != ResourceInfoKind.File) + if (info.Kind != StorageItemKind.File) { return Result.Fail($"Resource is not a file: '{resourceKey}'"); } @@ -58,14 +58,14 @@ private async Task> ResolveWorkbookResourceAsync(string reso return resourceKey; } - // Opens the workbook bytes via the resource file system and returns them + // Opens the workbook bytes via the file storage chokepoint and returns them // as a seekable MemoryStream positioned at zero. Caller disposes. private async Task> OpenWorkbookStreamAsync(ResourceKey resource) { var workspaceWrapper = GetRequiredService(); - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var bytesResult = await fileSystem.ReadAllBytesAsync(resource); + var bytesResult = await fileStorage.ReadAllBytesAsync(resource); if (bytesResult.IsFailure) { return Result.Fail($"Failed to read workbook: '{resource}'") diff --git a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs index 219e69637..7a2a53efe 100644 --- a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs +++ b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewScreenshotResolver.cs @@ -27,7 +27,7 @@ public static class WebViewScreenshotResolver /// through the chokepoint so the lookup honours the same containment /// validation as the screenshot save that follows. /// - public static async Task> ResolveAsync(string saveTo, string format, IResourceFileSystem fileSystem) + public static async Task> ResolveAsync(string saveTo, string format, IFileStorage fileStorage) { var extension = ExtensionForFormat(format); if (extension is null) @@ -58,7 +58,7 @@ public static async Task> ResolveAsync(string saveTo, string { var folderResource = key; var folderPath = key.Path; - var fileName = await GenerateAutoNameAsync(extension, fileSystem, folderResource); + var fileName = await GenerateAutoNameAsync(extension, fileStorage, folderResource); var combined = string.IsNullOrEmpty(folderPath) ? fileName : folderPath + "/" + fileName; if (!ResourceKey.TryCreate(combined, out var fileKey)) { @@ -82,7 +82,7 @@ public static async Task> ResolveAsync(string saveTo, string return key; } - private static async Task GenerateAutoNameAsync(string extension, IResourceFileSystem fileSystem, ResourceKey folderResource) + private static async Task GenerateAutoNameAsync(string extension, IFileStorage fileStorage, ResourceKey folderResource) { // Prefer the clean unsuffixed name. In the common case (no collision) // the agent gets `screenshot-20260430-090238.jpg` rather than a noisy @@ -92,7 +92,7 @@ private static async Task GenerateAutoNameAsync(string extension, IResou var timestamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); var primary = $"screenshot-{timestamp}.{extension}"; - if (!await ExistsAsync(fileSystem, folderResource, primary)) + if (!await ExistsAsync(fileStorage, folderResource, primary)) { return primary; } @@ -100,7 +100,7 @@ private static async Task GenerateAutoNameAsync(string extension, IResou for (int seq = 1; seq <= 999; seq++) { var candidate = $"screenshot-{timestamp}-{seq}.{extension}"; - if (!await ExistsAsync(fileSystem, folderResource, candidate)) + if (!await ExistsAsync(fileStorage, folderResource, candidate)) { return candidate; } @@ -136,11 +136,11 @@ private static bool HasExtension(string resourceKeyString) return !string.IsNullOrEmpty(extension); } - private static async Task ExistsAsync(IResourceFileSystem fileSystem, ResourceKey folderResource, string fileName) + private static async Task ExistsAsync(IFileStorage fileStorage, ResourceKey folderResource, string fileName) { var candidateKey = folderResource.IsEmpty ? new ResourceKey(fileName) : folderResource.Combine(fileName); - var infoResult = await fileSystem.GetInfoAsync(candidateKey); + var infoResult = await fileStorage.GetInfoAsync(candidateKey); return infoResult.IsSuccess - && infoResult.Value.Kind != ResourceInfoKind.NotFound; + && infoResult.Value.Kind != StorageItemKind.NotFound; } } diff --git a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs index 94fd93d89..b84ceb381 100644 --- a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs +++ b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs @@ -60,8 +60,8 @@ public async partial Task Screenshot( return ToolResponse.Error("No project is currently loaded. webview_screenshot requires an open project to resolve its save destination."); } - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; - var resolveResult = await WebViewScreenshotResolver.ResolveAsync(saveTo, format, fileSystem); + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + var resolveResult = await WebViewScreenshotResolver.ResolveAsync(saveTo, format, fileStorage); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); diff --git a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs index 21c6e6281..78b7c71be 100644 --- a/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs +++ b/Source/Core/Celbridge.UserInterface/ViewModels/Dialogs/ResourcePickerDialogViewModel.cs @@ -9,7 +9,7 @@ public partial class ResourcePickerDialogViewModel : ObservableObject private readonly IWorkspaceWrapper _workspaceWrapper; private IResourceRegistry? _registry; - private IResourceFileSystem? _fileSystem; + private IFileStorage? _fileStorage; private List _extensions = []; private List _allItems = []; private bool _showPreview; @@ -52,7 +52,7 @@ public void Initialize(IReadOnlyList extensions, bool showPreview) var workspaceService = _workspaceWrapper.WorkspaceService; _registry = workspaceService.ResourceService.Registry; - _fileSystem = workspaceService.ResourceFileSystem; + _fileStorage = workspaceService.FileStorage; _showPreview = showPreview; _extensions = extensions .Select(e => e.TrimStart('.').ToLowerInvariant()) @@ -80,7 +80,7 @@ private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) private async void UpdatePreview() { - if (!_showPreview || SelectedItem is null || _registry is null || _fileSystem is null) + if (!_showPreview || SelectedItem is null || _registry is null || _fileStorage is null) { PreviewImageVisibility = Visibility.Collapsed; PreviewImage = null; @@ -97,7 +97,7 @@ private async void UpdatePreview() } var resourcePath = resolveResult.Value; - var infoResult = await _fileSystem.GetInfoAsync(selectedItem.ResourceKey); + var infoResult = await _fileStorage.GetInfoAsync(selectedItem.ResourceKey); // The selection can change while the probe is in flight; the late // result must not overwrite a newer selection's preview. if (!ReferenceEquals(selectedItem, SelectedItem)) @@ -105,7 +105,7 @@ private async void UpdatePreview() return; } if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { PreviewImageVisibility = Visibility.Collapsed; PreviewImage = null; diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs index cdcdc9956..9e134f23e 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs @@ -48,8 +48,8 @@ public override async Task ExecuteAsync() } } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -72,7 +72,7 @@ public override async Task ExecuteAsync() workbook.Worksheets.Add(sheetName); } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs index c8e926ca0..e0ec78118 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/AppendRowsCommand.cs @@ -54,8 +54,8 @@ public override async Task ExecuteAsync() } } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -102,7 +102,7 @@ public override async Task ExecuteAsync() } } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs index eb4f881e3..f04b59e16 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/ClearRangesCommand.cs @@ -48,8 +48,8 @@ public override async Task ExecuteAsync() } } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -80,7 +80,7 @@ public override async Task ExecuteAsync() totalCellCount += cellCount; } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs index 949827010..b284947ab 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/DeleteRangesCommand.cs @@ -58,8 +58,8 @@ public override async Task ExecuteAsync() var rowsBySheet = new Dictionary>(StringComparer.Ordinal); var columnsBySheet = new Dictionary>(StringComparer.Ordinal); - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -121,7 +121,7 @@ public override async Task ExecuteAsync() } } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs index 0fb08f7a7..df75dd281 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/DuplicateSheetCommand.cs @@ -41,8 +41,8 @@ public override async Task ExecuteAsync() return Result.Fail("New sheet name is required."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -93,7 +93,7 @@ public override async Task ExecuteAsync() ColorScaleCopyHelper.Reapply(duplicate, colorScaleSnapshots); } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs index 45e2a1163..41cb4a023 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/FormatRangesCommand.cs @@ -53,8 +53,8 @@ public override async Task ExecuteAsync() int totalPropertiesApplied = 0; bool anyAutoFitApplied = false; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -87,7 +87,7 @@ public override async Task ExecuteAsync() } } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs index e33dd1a0a..f6a07fb25 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/FreezePanesCommand.cs @@ -41,8 +41,8 @@ public override async Task ExecuteAsync() return Result.Fail("Rows and Columns must be non-negative."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -82,7 +82,7 @@ public override async Task ExecuteAsync() worksheet.SheetView.FreezeRows(Rows); worksheet.SheetView.FreezeColumns(Columns); - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs index 5aff1a9ed..e186beddc 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/ImportCsvCommand.cs @@ -79,8 +79,8 @@ public override async Task ExecuteAsync() int totalRowCount = 0; int sheetsCreated = 0; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -133,7 +133,7 @@ public override async Task ExecuteAsync() totalRowCount += parsedRows.Count; } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs index fe1edfaa8..6c3f1fa84 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/InsertRangesCommand.cs @@ -60,8 +60,8 @@ public override async Task ExecuteAsync() var rowsBySheet = new Dictionary>(StringComparer.Ordinal); var columnsBySheet = new Dictionary>(StringComparer.Ordinal); - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -119,7 +119,7 @@ public override async Task ExecuteAsync() } } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs index 76fb728f0..9051fe118 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/MoveSheetCommand.cs @@ -40,8 +40,8 @@ public override async Task ExecuteAsync() return Result.Fail($"Position must be 1 or greater, was {Position}."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -66,7 +66,7 @@ public override async Task ExecuteAsync() if (worksheet.Position != Position) { worksheet.Position = Position; - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs index e64d2fb43..2bda5291d 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/RemoveSheetCommand.cs @@ -34,8 +34,8 @@ public override async Task ExecuteAsync() return Result.Fail("Sheet name is required."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -56,7 +56,7 @@ public override async Task ExecuteAsync() } workbook.Worksheets.Delete(Sheet); - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs index 86669f172..eb3169f4b 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/RenameSheetCommand.cs @@ -46,8 +46,8 @@ public override async Task ExecuteAsync() return Result.Ok(); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -69,7 +69,7 @@ public override async Task ExecuteAsync() var worksheet = workbook.Worksheet(Sheet); worksheet.Name = NewName; - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs index 38216ce3d..2c78929d3 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs @@ -104,8 +104,8 @@ private record AppliedViewState(IReadOnlyList Ranges, string ActiveCell) private async Task> ApplyViewStateToWorkbookAsync(ResourceKey workbookResource) { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -213,7 +213,7 @@ private async Task> ApplyViewStateToWorkbookAsync(Resou worksheet.SheetView.TopLeftCellAddress = scrollAnchor.Address; } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs index 3ebe56e02..5db5b623e 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetAutoFilterCommand.cs @@ -43,8 +43,8 @@ public override async Task ExecuteAsync() return Result.Fail($"Auto-filter range must be an A1 cell range like 'A1:F100', was '{Range}'."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -96,7 +96,7 @@ public override async Task ExecuteAsync() ResultValue = new SetAutoFilterResult(true, filterRange.RangeAddress.ToStringRelative()); } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs index c999512c3..5b9004378 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetConditionalFormattingCommand.cs @@ -54,8 +54,8 @@ public override async Task ExecuteAsync() return Result.Fail("At least one rule is required when clearExisting is false."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -106,7 +106,7 @@ public override async Task ExecuteAsync() } } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs index 69bede5ee..9fb2f356f 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SortRangeCommand.cs @@ -47,8 +47,8 @@ public override async Task ExecuteAsync() return Result.Fail($"Range '{Range}' must not include a sheet qualifier."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -86,7 +86,7 @@ public override async Task ExecuteAsync() sortRange.Sort(sortString, XLSortOrder.Ascending, MatchCase, ignoreBlanks: true); - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs index 18ba68e37..da3eebd06 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/WriteCellsCommand.cs @@ -40,8 +40,8 @@ public override async Task ExecuteAsync() return Result.Fail("At least one edit is required."); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileSystem, workbookResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, workbookResource); if (loadResult.IsFailure) { return Result.Fail(loadResult); @@ -99,7 +99,7 @@ public override async Task ExecuteAsync() } } - var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileSystem, workbookResource, workbook); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { return Result.Fail(saveResult); diff --git a/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs b/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs index 117afea16..42fcca072 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Helpers/SpreadsheetHelper.cs @@ -74,8 +74,8 @@ public static async Task> ResolveWorkbookResourceAsync( return Result.Fail($"Resource is not an .xlsx workbook: '{fileResource}'"); } - var fileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(fileResource); + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(fileResource); if (infoResult.IsFailure) { return Result.Fail($"Failed to inspect workbook: '{fileResource}'") @@ -83,11 +83,11 @@ public static async Task> ResolveWorkbookResourceAsync( } var info = infoResult.Value; - if (info.Kind == ResourceInfoKind.NotFound) + if (info.Kind == StorageItemKind.NotFound) { return Result.Fail($"Workbook file not found: '{fileResource}'"); } - if (info.Kind != ResourceInfoKind.File) + if (info.Kind != StorageItemKind.File) { return Result.Fail($"Resource is not a file: '{fileResource}'"); } @@ -101,10 +101,10 @@ public static async Task> ResolveWorkbookResourceAsync( /// dispose it; the underlying stream is owned by the workbook. /// public static async Task> LoadWorkbookAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, ResourceKey fileResource) { - var bytesResult = await fileSystem.ReadAllBytesAsync(fileResource); + var bytesResult = await fileStorage.ReadAllBytesAsync(fileResource); if (bytesResult.IsFailure) { return Result.Fail($"Failed to read workbook: '{fileResource}'") @@ -131,7 +131,7 @@ public static async Task> LoadWorkbookAsync( /// Evaluates formulas before saving so cached values stay fresh. /// public static async Task SaveWorkbookAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, ResourceKey fileResource, XLWorkbook workbook) { @@ -148,7 +148,7 @@ public static async Task SaveWorkbookAsync( .WithException(ex); } - var writeResult = await fileSystem.WriteAllBytesAsync(fileResource, bytes); + var writeResult = await fileStorage.WriteAllBytesAsync(fileResource, bytes); if (writeResult.IsFailure) { return Result.Fail($"Failed to save workbook: '{fileResource}'") diff --git a/Source/Tests/Documents/DocumentLayoutStoreTests.cs b/Source/Tests/Documents/DocumentLayoutStoreTests.cs index 20f5f1e11..3b62f934b 100644 --- a/Source/Tests/Documents/DocumentLayoutStoreTests.cs +++ b/Source/Tests/Documents/DocumentLayoutStoreTests.cs @@ -63,14 +63,14 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - // Wire a real ResourceFileSystem so FileAccessHelper's GetInfoAsync / + // Wire a real FileStorage so FileAccessHelper's GetInfoAsync / // OpenReadAsync calls probe the actual disk paths the registry // resolves to. - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); _fileAccessHelper = new FileAccessHelper(_workspaceWrapper); diff --git a/Source/Tests/Documents/DocumentViewModelTests.cs b/Source/Tests/Documents/DocumentViewModelTests.cs index 2ed7e7295..c29e9c8c3 100644 --- a/Source/Tests/Documents/DocumentViewModelTests.cs +++ b/Source/Tests/Documents/DocumentViewModelTests.cs @@ -16,7 +16,7 @@ namespace Celbridge.Tests.Documents; public class DocumentViewModelTests { private IMessengerService _messengerService = null!; - private IResourceFileSystem _fileSystem = null!; + private IFileStorage _fileStorage = null!; private IResourceRegistry _resourceRegistry = null!; private TestDocumentViewModel _vm = null!; private string _tempFolder = null!; @@ -33,7 +33,7 @@ public void Setup() _tempFilePath = Path.Combine(_tempFolder, "test.md"); File.WriteAllText(_tempFilePath, string.Empty); - // Wire a real ResourceFileSystem over a substituted workspace hierarchy + // Wire a real FileStorage over a substituted workspace hierarchy // whose registry maps the test's resource key to the temp file path. The // layer's atomic write + retry semantics are exercised directly against // the temp folder. @@ -50,15 +50,15 @@ public void Setup() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - _fileSystem = new ResourceFileSystem(Substitute.For>(), _messengerService, workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(_fileSystem); + _fileStorage = new FileStorage(Substitute.For>(), _messengerService, workspaceWrapper); + workspaceService.FileStorage.Returns(_fileStorage); var services = new ServiceCollection(); services.AddSingleton(_messengerService); services.AddSingleton(workspaceWrapper); ServiceLocator.Initialize(services.BuildServiceProvider()); - _vm = new TestDocumentViewModel(_fileSystem); + _vm = new TestDocumentViewModel(_fileStorage); _vm.FileResource = new ResourceKey("test.md"); _vm.FilePath = _tempFilePath; } @@ -144,7 +144,7 @@ public async Task SaveDocumentContent_ReturnsFailure_WhenWriterFails() var failingWrapper = Substitute.For(); failingWrapper.WorkspaceService.Returns(failingWorkspaceService); - var failingFileSystem = new ResourceFileSystem(Substitute.For>(), _messengerService, failingWrapper); + var failingFileSystem = new FileStorage(Substitute.For>(), _messengerService, failingWrapper); var failingVm = new TestDocumentViewModel(failingFileSystem) { @@ -243,7 +243,7 @@ public async Task Save_RaisesReloadRequested_WhenPostWriteDiskHashDiffersFromInt // immediately after we call WriteAllBytesAsync but before // UpdateFileTrackingInfo runs. var externalContent = "external content that overrode our save"; - var savingVm = new ExternalWriteDocumentViewModel(_fileSystem, _tempFilePath, externalContent); + var savingVm = new ExternalWriteDocumentViewModel(_fileStorage, _tempFilePath, externalContent); savingVm.FileResource = new ResourceKey("interleave.md"); savingVm.FilePath = _tempFilePath; @@ -264,11 +264,11 @@ public async Task Save_RaisesReloadRequested_WhenPostWriteDiskHashDiffersFromInt /// private sealed class TestDocumentViewModel : DocumentViewModel { - private readonly IResourceFileSystem _fileSystem; + private readonly IFileStorage _fileStorage; - public TestDocumentViewModel(IResourceFileSystem fileSystem) + public TestDocumentViewModel(IFileStorage fileStorage) { - _fileSystem = fileSystem; + _fileStorage = fileStorage; EnableFileChangeMonitoring(); } @@ -290,7 +290,7 @@ public void OnTextChanged() SaveTimer = SaveDelay; } - protected override IResourceFileSystem GetFileSystem() => _fileSystem; + protected override IFileStorage GetFileSystem() => _fileStorage; } /// @@ -302,14 +302,14 @@ public void OnTextChanged() /// private sealed class ExternalWriteDocumentViewModel : DocumentViewModel { - private readonly IResourceFileSystem _fileSystem; + private readonly IFileStorage _fileStorage; private readonly string _injectedFilePath; private readonly string _externalContent; private bool _hasInjected; - public ExternalWriteDocumentViewModel(IResourceFileSystem fileSystem, string filePath, string externalContent) + public ExternalWriteDocumentViewModel(IFileStorage fileStorage, string filePath, string externalContent) { - _fileSystem = fileSystem; + _fileStorage = fileStorage; _injectedFilePath = filePath; _externalContent = externalContent; EnableFileChangeMonitoring(); @@ -322,7 +322,7 @@ public Task SaveDocumentContent(string text) return SaveTextToFileAsync(text); } - protected override IResourceFileSystem GetFileSystem() => _fileSystem; + protected override IFileStorage GetFileSystem() => _fileStorage; protected override async Task UpdateFileTrackingInfoAsync() { diff --git a/Source/Tests/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index 3cbac6f6b..fc355dbbd 100644 --- a/Source/Tests/Packages/FileTypeProviderTests.cs +++ b/Source/Tests/Packages/FileTypeProviderTests.cs @@ -54,11 +54,11 @@ public void Setup() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); _service = new PackageService(logger, _moduleService, messengerService, _featureFlags, localizationService, workspaceWrapper); } diff --git a/Source/Tests/Packages/PackageRegistryTests.cs b/Source/Tests/Packages/PackageRegistryTests.cs index 68fcae971..ed7537f8a 100644 --- a/Source/Tests/Packages/PackageRegistryTests.cs +++ b/Source/Tests/Packages/PackageRegistryTests.cs @@ -50,11 +50,11 @@ public void Setup() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); _service = new PackageService(logger, _moduleService, _messengerService, featureFlags, localizationService, workspaceWrapper); } diff --git a/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs index 7bfb09178..429915a48 100644 --- a/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs +++ b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs @@ -37,8 +37,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + var fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index c62334af1..9f4f6d69e 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -69,11 +69,11 @@ public void Setup() _workspaceWrapper.IsWorkspacePageLoaded.Returns(true); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), _messengerService, _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); var scanner = new ResourceScanner( Substitute.For>(), diff --git a/Source/Tests/Resources/EditFileCommandTests.cs b/Source/Tests/Resources/EditFileCommandTests.cs index a56dba322..767034ec0 100644 --- a/Source/Tests/Resources/EditFileCommandTests.cs +++ b/Source/Tests/Resources/EditFileCommandTests.cs @@ -36,8 +36,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + var fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/ResourceFileSystemTests.cs b/Source/Tests/Resources/FileStorageTests.cs similarity index 91% rename from Source/Tests/Resources/ResourceFileSystemTests.cs rename to Source/Tests/Resources/FileStorageTests.cs index 6116f456f..bbbaaa3ec 100644 --- a/Source/Tests/Resources/ResourceFileSystemTests.cs +++ b/Source/Tests/Resources/FileStorageTests.cs @@ -7,17 +7,17 @@ namespace Celbridge.Tests.Resources; /// -/// Tests for ResourceFileSystem — atomic writes, retry on transient IO, +/// Tests for FileStorage — atomic writes, retry on transient IO, /// parent-folder creation, ResolveResourcePath integration, reads, and /// stream-open happy paths. /// [TestFixture] -public class ResourceFileSystemTests +public class FileStorageTests { private string _tempFolder = null!; private IResourceRegistry _resourceRegistry = null!; private IResourceScanner _resourceScanner = null!; - private ResourceFileSystem _fileSystem = null!; + private FileStorage _fileStorage = null!; [SetUp] public void Setup() @@ -25,7 +25,7 @@ public void Setup() _tempFolder = Path.Combine( Path.GetTempPath(), "Celbridge", - nameof(ResourceFileSystemTests), + nameof(FileStorageTests), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempFolder); @@ -50,8 +50,8 @@ public void Setup() var sidecarService = new SidecarService(workspaceWrapper); workspaceService.SidecarService.Returns(sidecarService); - _fileSystem = new ResourceFileSystem( - Substitute.For>(), + _fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); } @@ -74,7 +74,7 @@ public async Task WriteAllBytesAsync_WritesContent_WhenFileDoesNotExist() var bytes = new byte[] { 0x01, 0x02, 0x03 }; - var result = await _fileSystem.WriteAllBytesAsync(resource, bytes); + var result = await _fileStorage.WriteAllBytesAsync(resource, bytes); result.IsSuccess.Should().BeTrue(); File.Exists(path).Should().BeTrue(); @@ -88,7 +88,7 @@ public async Task WriteAllTextAsync_WritesContent_WhenFileDoesNotExist() var path = Path.Combine(_tempFolder, "new.txt"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.WriteAllTextAsync(resource, "hello world"); + var result = await _fileStorage.WriteAllTextAsync(resource, "hello world"); result.IsSuccess.Should().BeTrue(); (await File.ReadAllTextAsync(path)).Should().Be("hello world"); @@ -102,7 +102,7 @@ public async Task WriteAllTextAsync_OverwritesExistingFile() await File.WriteAllTextAsync(path, "old"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.WriteAllTextAsync(resource, "new"); + var result = await _fileStorage.WriteAllTextAsync(resource, "new"); result.IsSuccess.Should().BeTrue(); (await File.ReadAllTextAsync(path)).Should().Be("new"); @@ -115,7 +115,7 @@ public async Task WriteAllTextAsync_CreatesIntermediateFolders() var path = Path.Combine(_tempFolder, "nested", "deeper", "file.txt"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.WriteAllTextAsync(resource, "deep content"); + var result = await _fileStorage.WriteAllTextAsync(resource, "deep content"); result.IsSuccess.Should().BeTrue(); File.Exists(path).Should().BeTrue(); @@ -129,7 +129,7 @@ public async Task WriteAllTextAsync_ReturnsFailure_WhenResolveFails() _resourceRegistry.ResolveResourcePath(resource) .Returns(Result.Fail("simulated resolve failure")); - var result = await _fileSystem.WriteAllTextAsync(resource, "anything"); + var result = await _fileStorage.WriteAllTextAsync(resource, "anything"); result.IsFailure.Should().BeTrue(); } @@ -144,7 +144,7 @@ public async Task WriteAllBytesAsync_StagesTempInCelbridgeStagingFolder_AndLeave var path = Path.Combine(_tempFolder, "clean.bin"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - await _fileSystem.WriteAllBytesAsync(resource, new byte[] { 0x42 }); + await _fileStorage.WriteAllBytesAsync(resource, new byte[] { 0x42 }); // No sibling temp file next to the destination. File.Exists(path + ".tmp").Should().BeFalse(); @@ -167,7 +167,7 @@ public async Task ReadAllBytesAsync_ReturnsContent_WhenFileExists() await File.WriteAllBytesAsync(path, expected); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.ReadAllBytesAsync(resource); + var result = await _fileStorage.ReadAllBytesAsync(resource); result.IsSuccess.Should().BeTrue(); result.Value.Should().Equal(expected); @@ -181,7 +181,7 @@ public async Task ReadAllTextAsync_ReturnsContent_WhenFileExists() await File.WriteAllTextAsync(path, "the content"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.ReadAllTextAsync(resource); + var result = await _fileStorage.ReadAllTextAsync(resource); result.IsSuccess.Should().BeTrue(); result.Value.Should().Be("the content"); @@ -194,7 +194,7 @@ public async Task ReadAllBytesAsync_ReturnsFailure_WhenFileMissing() var path = Path.Combine(_tempFolder, "missing.bin"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.ReadAllBytesAsync(resource); + var result = await _fileStorage.ReadAllBytesAsync(resource); result.IsFailure.Should().BeTrue(); } @@ -208,7 +208,7 @@ public async Task OpenReadAsync_ReturnsStreamWithFileContent() await File.WriteAllBytesAsync(path, expected); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var openResult = await _fileSystem.OpenReadAsync(resource); + var openResult = await _fileStorage.OpenReadAsync(resource); openResult.IsSuccess.Should().BeTrue(); await using var stream = openResult.Value; @@ -224,7 +224,7 @@ public async Task OpenWriteAsync_WritesContentThroughStream() var path = Path.Combine(_tempFolder, "openwrite.bin"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var openResult = await _fileSystem.OpenWriteAsync(resource); + var openResult = await _fileStorage.OpenWriteAsync(resource); openResult.IsSuccess.Should().BeTrue(); var content = new byte[] { 0x01, 0x02, 0x03, 0x04 }; @@ -244,7 +244,7 @@ public async Task OpenWriteAsync_CreatesParentFolder() var path = Path.Combine(_tempFolder, "nested", "folder", "openwrite.bin"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var openResult = await _fileSystem.OpenWriteAsync(resource); + var openResult = await _fileStorage.OpenWriteAsync(resource); openResult.IsSuccess.Should().BeTrue(); await using (var stream = openResult.Value) @@ -265,11 +265,11 @@ public async Task GetInfoAsync_ReturnsFile_WithSizeAndModifiedUtc_WhenFilePresen var expectedModifiedUtc = File.GetLastWriteTimeUtc(path); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.GetInfoAsync(resource); + var result = await _fileStorage.GetInfoAsync(resource); result.IsSuccess.Should().BeTrue(); var info = result.Value; - info.Kind.Should().Be(ResourceInfoKind.File); + info.Kind.Should().Be(StorageItemKind.File); info.Size.Should().Be(bytes.Length); info.ModifiedUtc.Should().Be(expectedModifiedUtc); } @@ -282,11 +282,11 @@ public async Task GetInfoAsync_ReturnsFolder_WhenFolderPresent() Directory.CreateDirectory(folderPath); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(folderPath)); - var result = await _fileSystem.GetInfoAsync(resource); + var result = await _fileStorage.GetInfoAsync(resource); result.IsSuccess.Should().BeTrue(); var info = result.Value; - info.Kind.Should().Be(ResourceInfoKind.Folder); + info.Kind.Should().Be(StorageItemKind.Folder); info.Size.Should().Be(0); info.ModifiedUtc.Should().NotBe(default); } @@ -298,11 +298,11 @@ public async Task GetInfoAsync_ReturnsNotFound_WhenResourceMissing() var path = Path.Combine(_tempFolder, "missing.txt"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.GetInfoAsync(resource); + var result = await _fileStorage.GetInfoAsync(resource); result.IsSuccess.Should().BeTrue(); var info = result.Value; - info.Kind.Should().Be(ResourceInfoKind.NotFound); + info.Kind.Should().Be(StorageItemKind.NotFound); info.Size.Should().Be(0); info.ModifiedUtc.Should().Be(default); } @@ -322,10 +322,10 @@ public async Task GetInfoAsync_ResolvesViaRegistry_ForNonDefaultRoot() await File.WriteAllTextAsync(path, "scratch"); _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); - var result = await _fileSystem.GetInfoAsync(resource); + var result = await _fileStorage.GetInfoAsync(resource); result.IsSuccess.Should().BeTrue(); - result.Value.Kind.Should().Be(ResourceInfoKind.File); + result.Value.Kind.Should().Be(StorageItemKind.File); result.Value.Size.Should().Be("scratch".Length); } @@ -336,7 +336,7 @@ public async Task GetInfoAsync_ReturnsFailure_WhenResolveFails() _resourceRegistry.ResolveResourcePath(resource) .Returns(Result.Fail("simulated resolve failure")); - var result = await _fileSystem.GetInfoAsync(resource); + var result = await _fileStorage.GetInfoAsync(resource); result.IsFailure.Should().BeTrue(); } @@ -357,7 +357,7 @@ public async Task MoveAsync_MovesFile_WhenNoReferencersAndNoSidecar() _resourceRegistry.ResolveResourcePath(sidecarSource).Returns(Result.Ok(sourcePath + ".cel")); _resourceRegistry.ResolveResourcePath(sidecarDest).Returns(Result.Ok(destPath + ".cel")); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); File.Exists(sourcePath).Should().BeFalse(); @@ -373,7 +373,7 @@ public async Task MoveAsync_RejectsCrossRootMove() var sourceKey = new ResourceKey("project:a.txt"); var destKey = new ResourceKey("temp:a.txt"); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Cross-root"); @@ -399,7 +399,7 @@ public async Task MoveAsync_CascadesSidecarWithFile() _resourceRegistry.ResolveResourcePath(sidecarSource).Returns(Result.Ok(sourceSidecarPath)); _resourceRegistry.ResolveResourcePath(sidecarDest).Returns(Result.Ok(destSidecarPath)); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); result.Value.Sidecar.Should().Be(SidecarOutcome.Cascaded); @@ -422,7 +422,7 @@ public async Task MoveAsync_FailsWhenDestinationExists() _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); _resourceRegistry.ResolveResourcePath(destKey).Returns(Result.Ok(destPath)); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsFailure.Should().BeTrue(); // Source still in place; destination unchanged. @@ -451,7 +451,7 @@ public async Task MoveAsync_RewritesReferencers() _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); result.Value.UpdatedReferencers.Should().Contain(referencerKey); @@ -491,7 +491,7 @@ public async Task MoveAsync_DoesNotRewriteUnquotedOccurrencesAtFileBoundaries() _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); result.Value.UpdatedReferencers.Should().Contain(referencerKey); @@ -528,7 +528,7 @@ await File.WriteAllTextAsync(referencerPath, _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); result.Value.UpdatedReferencers.Should().Contain(referencerKey); @@ -562,7 +562,7 @@ await File.WriteAllTextAsync(referencerPath, _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); result.Value.UpdatedReferencers.Should().Contain(referencerKey); @@ -588,7 +588,7 @@ public async Task CopyAsync_CopiesFile_AndCascadesSidecar() _resourceRegistry.ResolveResourcePath(new ResourceKey("a.txt.cel")).Returns(Result.Ok(sourceSidecarPath)); _resourceRegistry.ResolveResourcePath(new ResourceKey("b.txt.cel")).Returns(Result.Ok(destSidecarPath)); - var result = await _fileSystem.CopyAsync(sourceKey, destKey); + var result = await _fileStorage.CopyAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); result.Value.Sidecar.Should().Be(SidecarOutcome.Cascaded); @@ -610,7 +610,7 @@ public async Task DeleteAsync_DeletesFile_AndCascadesSidecar() _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); _resourceRegistry.ResolveResourcePath(new ResourceKey("a.txt.cel")).Returns(Result.Ok(sourceSidecarPath)); - var result = await _fileSystem.DeleteAsync(sourceKey); + var result = await _fileStorage.DeleteAsync(sourceKey); result.IsSuccess.Should().BeTrue(); result.Value.Sidecar.Should().Be(SidecarOutcome.Cascaded); @@ -626,7 +626,7 @@ public async Task DeleteAsync_FailsWhenSourceMissing() _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); - var result = await _fileSystem.DeleteAsync(sourceKey); + var result = await _fileStorage.DeleteAsync(sourceKey); result.IsFailure.Should().BeTrue(); } @@ -649,7 +649,7 @@ public async Task ReadAllTextAsync_RetriesAndSucceeds_WhenLockReleasesQuickly() lockStream.Dispose(); }); - var result = await _fileSystem.ReadAllTextAsync(resource); + var result = await _fileStorage.ReadAllTextAsync(resource); await releaseTask; result.IsSuccess.Should().BeTrue(); @@ -667,7 +667,7 @@ public async Task DeleteAsync_DeletesReadOnlyFile() _resourceRegistry.ResolveResourcePath(sourceKey).Returns(Result.Ok(sourcePath)); _resourceRegistry.ResolveResourcePath(new ResourceKey("readonly.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); - var result = await _fileSystem.DeleteAsync(sourceKey); + var result = await _fileStorage.DeleteAsync(sourceKey); result.IsSuccess.Should().BeTrue(); File.Exists(sourcePath).Should().BeFalse(); @@ -688,7 +688,7 @@ public async Task MoveAsync_MovesReadOnlyFile() _resourceRegistry.ResolveResourcePath(new ResourceKey("readonly.txt.cel")).Returns(Result.Ok(sourcePath + ".cel")); _resourceRegistry.ResolveResourcePath(new ResourceKey("renamed.txt.cel")).Returns(Result.Ok(destPath + ".cel")); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); File.Exists(sourcePath).Should().BeFalse(); @@ -720,7 +720,7 @@ public async Task MoveAsync_SkipsReadOnlyReferencer_AndReportsItInResult() _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); - var result = await _fileSystem.MoveAsync(sourceKey, destKey); + var result = await _fileStorage.MoveAsync(sourceKey, destKey); result.IsSuccess.Should().BeTrue(); // Parent move completed even though the referencer was read-only. @@ -752,7 +752,7 @@ public async Task ReadAllBytesAsync_FailsImmediately_WhenFileMissing_WithoutRetr _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - var result = await _fileSystem.ReadAllBytesAsync(resource); + var result = await _fileStorage.ReadAllBytesAsync(resource); stopwatch.Stop(); result.IsFailure.Should().BeTrue(); diff --git a/Source/Tests/Resources/MultiEditFileCommandTests.cs b/Source/Tests/Resources/MultiEditFileCommandTests.cs index ead150048..2b19a4689 100644 --- a/Source/Tests/Resources/MultiEditFileCommandTests.cs +++ b/Source/Tests/Resources/MultiEditFileCommandTests.cs @@ -36,8 +36,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + var fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/ReplaceFileCommandTests.cs b/Source/Tests/Resources/ReplaceFileCommandTests.cs index 39593a666..3e220d21b 100644 --- a/Source/Tests/Resources/ReplaceFileCommandTests.cs +++ b/Source/Tests/Resources/ReplaceFileCommandTests.cs @@ -36,8 +36,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + var fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index 17490466d..dd560175c 100644 --- a/Source/Tests/Resources/ResourceCommandTests.cs +++ b/Source/Tests/Resources/ResourceCommandTests.cs @@ -63,13 +63,13 @@ public void Setup() _workspaceWrapper.WorkspaceService.Returns(workspaceService); // ListFolderContentsCommand and GetFileTreeCommand route through the - // ResourceFileSystem chokepoint, so the workspace needs a real instance + // FileStorage chokepoint, so the workspace needs a real instance // (a Substitute would return null for EnumerateFolderAsync). - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/SidecarServiceTests.cs b/Source/Tests/Resources/SidecarServiceTests.cs index 48d4ed072..bd0fbffec 100644 --- a/Source/Tests/Resources/SidecarServiceTests.cs +++ b/Source/Tests/Resources/SidecarServiceTests.cs @@ -14,19 +14,19 @@ namespace Celbridge.Tests.Resources; [TestFixture] public class SidecarServiceTests { - private IResourceFileSystem _fileSystem = null!; + private IFileStorage _fileStorage = null!; private SidecarService _sidecarService = null!; [SetUp] public void Setup() { - _fileSystem = Substitute.For(); + _fileStorage = Substitute.For(); // Default: nothing exists on disk. Tests opt-in per resource. - _fileSystem.GetInfoAsync(Arg.Any()) - .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.NotFound, 0, default)))); + _fileStorage.GetInfoAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.NotFound, 0, default)))); var workspaceService = Substitute.For(); - workspaceService.ResourceFileSystem.Returns(_fileSystem); + workspaceService.FileStorage.Returns(_fileStorage); var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); @@ -52,9 +52,9 @@ public async Task ReadAsync_ReadsSiblingSidecar_ForRegularFile() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.GetInfoAsync(siblingSidecar) - .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); - _fileSystem.ReadAllTextAsync(siblingSidecar) + _fileStorage.GetInfoAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.File, 0, default)))); + _fileStorage.ReadAllTextAsync(siblingSidecar) .Returns(Task.FromResult(Result.Ok("editor = \"acme.binary-editor\"\n"))); var readResult = await _sidecarService.ReadAsync(regularFile); @@ -73,9 +73,9 @@ public async Task ReadAsync_ReadsFileItself_ForStandaloneCelFile() // bogus .cel.cel key). var standaloneCel = new ResourceKey("design.widget.cel"); - _fileSystem.GetInfoAsync(standaloneCel) - .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); - _fileSystem.ReadAllTextAsync(standaloneCel) + _fileStorage.GetInfoAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.File, 0, default)))); + _fileStorage.ReadAllTextAsync(standaloneCel) .Returns(Task.FromResult(Result.Ok("editor = \"celbridge.code-editor.code-document\"\n"))); var readResult = await _sidecarService.ReadAsync(standaloneCel); @@ -85,8 +85,8 @@ public async Task ReadAsync_ReadsFileItself_ForStandaloneCelFile() readResult.Value.Content!.Frontmatter["editor"].Should().Be("celbridge.code-editor.code-document"); // Belt-and-braces: the bogus .cel.cel key must never be touched. - await _fileSystem.DidNotReceive().GetInfoAsync(new ResourceKey("design.widget.cel.cel")); - await _fileSystem.DidNotReceive().ReadAllTextAsync(new ResourceKey("design.widget.cel.cel")); + await _fileStorage.DidNotReceive().GetInfoAsync(new ResourceKey("design.widget.cel.cel")); + await _fileStorage.DidNotReceive().ReadAllTextAsync(new ResourceKey("design.widget.cel.cel")); } [Test] @@ -95,13 +95,13 @@ public async Task SetFieldAsync_WritesToSiblingSidecar_ForRegularFile() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.WriteAllTextAsync(siblingSidecar, Arg.Any()) + _fileStorage.WriteAllTextAsync(siblingSidecar, Arg.Any()) .Returns(Task.FromResult(Result.Ok())); var setResult = await _sidecarService.SetFieldAsync(regularFile, "editor", "acme.binary-editor"); setResult.IsSuccess.Should().BeTrue(); - await _fileSystem.Received(1).WriteAllTextAsync( + await _fileStorage.Received(1).WriteAllTextAsync( siblingSidecar, Arg.Is(text => text.Contains("editor") && text.Contains("acme.binary-editor"))); } @@ -116,7 +116,7 @@ public async Task SetFieldAsync_WritesToFileItself_ForStandaloneCelFile() // not attempt to derive a .cel.cel sibling sidecar. var standaloneCel = new ResourceKey("design.widget.cel"); - _fileSystem.WriteAllTextAsync(standaloneCel, Arg.Any()) + _fileStorage.WriteAllTextAsync(standaloneCel, Arg.Any()) .Returns(Task.FromResult(Result.Ok())); var setResult = await _sidecarService.SetFieldAsync( @@ -125,13 +125,13 @@ public async Task SetFieldAsync_WritesToFileItself_ForStandaloneCelFile() "celbridge.code-editor.code-document"); setResult.IsSuccess.Should().BeTrue(); - await _fileSystem.Received(1).WriteAllTextAsync( + await _fileStorage.Received(1).WriteAllTextAsync( standaloneCel, Arg.Is(text => text.Contains("editor") && text.Contains("celbridge.code-editor.code-document"))); // The bogus .cel.cel key must never be touched. - await _fileSystem.DidNotReceive().WriteAllTextAsync( + await _fileStorage.DidNotReceive().WriteAllTextAsync( new ResourceKey("design.widget.cel.cel"), Arg.Any()); } @@ -145,13 +145,13 @@ public async Task SetFieldAsync_PreservesExistingContent_ForStandaloneCelFile() var standaloneCel = new ResourceKey("design.widget.cel"); var existingContent = "title = \"My Design\"\nversion = 1\n"; - _fileSystem.GetInfoAsync(standaloneCel) - .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); - _fileSystem.ReadAllTextAsync(standaloneCel) + _fileStorage.GetInfoAsync(standaloneCel) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.File, 0, default)))); + _fileStorage.ReadAllTextAsync(standaloneCel) .Returns(Task.FromResult(Result.Ok(existingContent))); string? capturedWrite = null; - _fileSystem.WriteAllTextAsync(standaloneCel, Arg.Do(text => capturedWrite = text)) + _fileStorage.WriteAllTextAsync(standaloneCel, Arg.Do(text => capturedWrite = text)) .Returns(Task.FromResult(Result.Ok())); var setResult = await _sidecarService.SetFieldAsync( @@ -176,15 +176,15 @@ public async Task SetFieldAsync_SkipsWrite_WhenValueMatchesExisting() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.GetInfoAsync(siblingSidecar) - .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); - _fileSystem.ReadAllTextAsync(siblingSidecar) + _fileStorage.GetInfoAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.File, 0, default)))); + _fileStorage.ReadAllTextAsync(siblingSidecar) .Returns(Task.FromResult(Result.Ok("editor = \"acme.binary-editor\"\n"))); var setResult = await _sidecarService.SetFieldAsync(regularFile, "editor", "acme.binary-editor"); setResult.IsSuccess.Should().BeTrue(); - await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); } [Test] @@ -196,15 +196,15 @@ public async Task AddTagAsync_SkipsWrite_WhenTagAlreadyPresent() var regularFile = new ResourceKey("photo.png"); var siblingSidecar = new ResourceKey("photo.png.cel"); - _fileSystem.GetInfoAsync(siblingSidecar) - .Returns(Task.FromResult(Result.Ok(new ResourceInfo(ResourceInfoKind.File, 0, default)))); - _fileSystem.ReadAllTextAsync(siblingSidecar) + _fileStorage.GetInfoAsync(siblingSidecar) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.File, 0, default)))); + _fileStorage.ReadAllTextAsync(siblingSidecar) .Returns(Task.FromResult(Result.Ok("tags = [\"hero\", \"sprite\"]\n"))); var addResult = await _sidecarService.AddTagAsync(regularFile, "hero"); addResult.IsSuccess.Should().BeTrue(); - await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); } [Test] @@ -224,7 +224,7 @@ public async Task SetFieldAsync_RejectsNonIndexableValue() setResult.IsFailure.Should().BeTrue(); setResult.FirstErrorMessage.Should().Contain("not indexable"); - await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); } [Test] @@ -240,7 +240,7 @@ public async Task WriteBlockAsync_RejectsInvalidBlockId() writeResult.IsFailure.Should().BeTrue(); writeResult.FirstErrorMessage.Should().Contain("block-naming rules"); - await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); } [Test] @@ -310,7 +310,7 @@ public async Task SetFieldAsync_CreatesFile_WhenStandaloneCelMissing() // SetField. The created file holds the new frontmatter and nothing else. var standaloneCel = new ResourceKey("new.widget.cel"); - _fileSystem.WriteAllTextAsync(standaloneCel, Arg.Any()) + _fileStorage.WriteAllTextAsync(standaloneCel, Arg.Any()) .Returns(Task.FromResult(Result.Ok())); var setResult = await _sidecarService.SetFieldAsync( @@ -319,7 +319,7 @@ public async Task SetFieldAsync_CreatesFile_WhenStandaloneCelMissing() "celbridge.code-editor.code-document"); setResult.IsSuccess.Should().BeTrue(); - await _fileSystem.Received(1).WriteAllTextAsync( + await _fileStorage.Received(1).WriteAllTextAsync( standaloneCel, Arg.Is(text => text.Contains("editor"))); } @@ -333,6 +333,6 @@ public async Task RemoveFieldAsync_SkipsWrite_WhenSidecarMissing() var setResult = await _sidecarService.RemoveFieldAsync(new ResourceKey("photo.png"), "editor"); setResult.IsSuccess.Should().BeTrue(); - await _fileSystem.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); } } diff --git a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs index 2fd6696d3..884537637 100644 --- a/Source/Tests/Resources/WriteBinaryFileCommandTests.cs +++ b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs @@ -35,8 +35,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + var fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/WriteFileCommandTests.cs b/Source/Tests/Resources/WriteFileCommandTests.cs index 201733144..9301aa37a 100644 --- a/Source/Tests/Resources/WriteFileCommandTests.cs +++ b/Source/Tests/Resources/WriteFileCommandTests.cs @@ -35,8 +35,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem(Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + var fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Search/FileFilterTests.cs b/Source/Tests/Search/FileFilterTests.cs index 2724bf8ae..27119093e 100644 --- a/Source/Tests/Search/FileFilterTests.cs +++ b/Source/Tests/Search/FileFilterTests.cs @@ -10,7 +10,7 @@ namespace Celbridge.Tests.Search; public class FileFilterTests { private FileFilter _filter = null!; - private IResourceFileSystem _fileSystem = null!; + private IFileStorage _fileStorage = null!; private IResourceRegistry _resourceRegistry = null!; private string _testDir = null!; @@ -21,7 +21,7 @@ public void SetUp() _testDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDir); - // Wire a real ResourceFileSystem so size + existence probes hit disk. + // Wire a real FileStorage so size + existence probes hit disk. _resourceRegistry = Substitute.For(); _resourceRegistry.ProjectFolderPath.Returns(_testDir); @@ -34,8 +34,8 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - _fileSystem = new ResourceFileSystem( - Substitute.For>(), + _fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); } @@ -63,7 +63,7 @@ public async Task ShouldSearchFile_RegularTextFile_ReturnsTrue() var (resource, filePath) = MakeResource("test.txt"); File.WriteAllText(filePath, "test content"); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeTrue(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeTrue(); } [Test] @@ -71,7 +71,7 @@ public async Task ShouldSearchFile_NonExistentFile_ReturnsFalse() { var (resource, filePath) = MakeResource("nonexistent.txt"); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); } [Test] @@ -80,7 +80,7 @@ public async Task ShouldSearchFile_MetadataExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.celbridge"); File.WriteAllText(filePath, "metadata"); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); } [Test] @@ -92,7 +92,7 @@ public async Task ShouldSearchFile_CelExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.webview.cel"); File.WriteAllText(filePath, "source_url = \"https://example.com\"\n"); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); } [Test] @@ -101,7 +101,7 @@ public async Task ShouldSearchFile_BinaryExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.exe"); File.WriteAllBytes(filePath, new byte[] { 0x00, 0x01, 0x02 }); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); } [Test] @@ -110,7 +110,7 @@ public async Task ShouldSearchFile_ImageExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.png"); File.WriteAllBytes(filePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); } [Test] @@ -119,7 +119,7 @@ public async Task ShouldSearchFile_CSharpFile_ReturnsTrue() var (resource, filePath) = MakeResource("Test.cs"); File.WriteAllText(filePath, "public class Test { }"); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeTrue(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeTrue(); } [Test] @@ -128,7 +128,7 @@ public async Task ShouldSearchFile_MarkdownFile_ReturnsTrue() var (resource, filePath) = MakeResource("README.md"); File.WriteAllText(filePath, "# Readme"); - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeTrue(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeTrue(); } [Test] @@ -141,7 +141,7 @@ public async Task ShouldSearchFile_LargeFile_ReturnsFalse() fs.SetLength(1024 * 1024 + 1); } - (await _filter.ShouldSearchFileAsync(_fileSystem, resource, filePath)).Should().BeFalse(); + (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); } [Test] diff --git a/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs b/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs index b80320874..853f2dea3 100644 --- a/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs +++ b/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs @@ -53,11 +53,11 @@ public void SetUp() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), _workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Tools/FileToolTests.cs b/Source/Tests/Tools/FileToolTests.cs index 5e2bfb7cd..3e8756e7c 100644 --- a/Source/Tests/Tools/FileToolTests.cs +++ b/Source/Tests/Tools/FileToolTests.cs @@ -44,13 +44,13 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - // Wire a real ResourceFileSystem against the temp folder so the + // Wire a real FileStorage against the temp folder so the // chokepoint reads tests rely on probe and read the actual files. - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); _services.GetRequiredService().Returns(workspaceWrapper); } diff --git a/Source/Tests/Tools/FileToolsReadImageTests.cs b/Source/Tests/Tools/FileToolsReadImageTests.cs index c4c55b9b0..1fa462193 100644 --- a/Source/Tests/Tools/FileToolsReadImageTests.cs +++ b/Source/Tests/Tools/FileToolsReadImageTests.cs @@ -48,11 +48,11 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); _services.GetRequiredService().Returns(workspaceWrapper); } diff --git a/Source/Tests/Tools/SpreadsheetToolTests.cs b/Source/Tests/Tools/SpreadsheetToolTests.cs index a9e406e58..39c3efe0c 100644 --- a/Source/Tests/Tools/SpreadsheetToolTests.cs +++ b/Source/Tests/Tools/SpreadsheetToolTests.cs @@ -52,11 +52,11 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileSystem = new ResourceFileSystem( - Substitute.For>(), + var fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); - workspaceService.ResourceFileSystem.Returns(fileSystem); + workspaceService.FileStorage.Returns(fileStorage); _services.GetRequiredService().Returns(workspaceWrapper); _services.GetRequiredService().Returns(_reader); diff --git a/Source/Tests/Tools/WebViewScreenshotResolverTests.cs b/Source/Tests/Tools/WebViewScreenshotResolverTests.cs index b4ed062b4..0a854ba4a 100644 --- a/Source/Tests/Tools/WebViewScreenshotResolverTests.cs +++ b/Source/Tests/Tools/WebViewScreenshotResolverTests.cs @@ -10,7 +10,7 @@ namespace Celbridge.Tests.Tools; public class WebViewScreenshotResolverTests { private string _projectFolder = null!; - private IResourceFileSystem _fileSystem = null!; + private IFileStorage _fileStorage = null!; private IResourceRegistry _resourceRegistry = null!; [SetUp] @@ -36,8 +36,8 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - _fileSystem = new ResourceFileSystem( - Substitute.For>(), + _fileStorage = new FileStorage( + Substitute.For>(), Substitute.For(), workspaceWrapper); } @@ -54,7 +54,7 @@ public void TearDown() [Test] public async Task Resolve_EmptySaveTo_UsesDefaultFolderWithCleanName() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "jpeg", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "jpeg", _fileStorage); result.IsSuccess.Should().BeTrue(); var path = result.Value.Path; @@ -66,7 +66,7 @@ public async Task Resolve_EmptySaveTo_UsesDefaultFolderWithCleanName() [Test] public async Task Resolve_EmptySaveToWithPng_UsesPngExtension() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "png", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "png", _fileStorage); result.IsSuccess.Should().BeTrue(); result.Value.Path.Should().EndWith(".png"); @@ -75,7 +75,7 @@ public async Task Resolve_EmptySaveToWithPng_UsesPngExtension() [Test] public async Task Resolve_ExactResourceKeyWithMatchingExtension_PreservesKey() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.png", format: "png", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.png", format: "png", _fileStorage); result.IsSuccess.Should().BeTrue(); result.Value.ToString().Should().Be("project:docs/output.png"); @@ -84,7 +84,7 @@ public async Task Resolve_ExactResourceKeyWithMatchingExtension_PreservesKey() [Test] public async Task Resolve_JpgExtensionMatchesJpegFormat() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.jpg", format: "jpeg", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.jpg", format: "jpeg", _fileStorage); result.IsSuccess.Should().BeTrue(); result.Value.ToString().Should().Be("project:docs/output.jpg"); @@ -93,7 +93,7 @@ public async Task Resolve_JpgExtensionMatchesJpegFormat() [Test] public async Task Resolve_JpegExtensionMatchesJpegFormat() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.jpeg", format: "jpeg", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.jpeg", format: "jpeg", _fileStorage); result.IsSuccess.Should().BeTrue(); } @@ -101,7 +101,7 @@ public async Task Resolve_JpegExtensionMatchesJpegFormat() [Test] public async Task Resolve_ExtensionFormatMismatch_Fails() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.png", format: "jpeg", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.png", format: "jpeg", _fileStorage); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("does not match format"); @@ -110,8 +110,8 @@ public async Task Resolve_ExtensionFormatMismatch_Fails() [Test] public async Task Resolve_TxtExtension_FailsForBothFormats() { - var resultJpeg = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "jpeg", _fileSystem); - var resultPng = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "png", _fileSystem); + var resultJpeg = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "jpeg", _fileStorage); + var resultPng = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "png", _fileStorage); resultJpeg.IsFailure.Should().BeTrue(); resultPng.IsFailure.Should().BeTrue(); @@ -120,7 +120,7 @@ public async Task Resolve_TxtExtension_FailsForBothFormats() [Test] public async Task Resolve_TrailingSlashSaveTo_GeneratesAutoNameInThatFolder() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/", format: "jpeg", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/", format: "jpeg", _fileStorage); result.IsSuccess.Should().BeTrue(); var path = result.Value.Path; @@ -133,7 +133,7 @@ public async Task Resolve_NoExtensionSaveTo_TreatedAsFolder() { // A path without a file extension is interpreted as a folder reference, // matching the agent's likely intent ("put a screenshot in this folder"). - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "captures", format: "png", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "captures", format: "png", _fileStorage); result.IsSuccess.Should().BeTrue(); var path = result.Value.Path; @@ -148,7 +148,7 @@ public async Task Resolve_CollisionWithExistingFile_AddsSequenceSuffix() // To do this deterministically without racing the wall clock, we let // the saver generate its first name, then re-run Resolve and confirm // the second call produces a -1 suffix. - var first = await WebViewScreenshotResolver.ResolveAsync(saveTo: "screenshots/", format: "jpeg", _fileSystem); + var first = await WebViewScreenshotResolver.ResolveAsync(saveTo: "screenshots/", format: "jpeg", _fileStorage); first.IsSuccess.Should().BeTrue(); var firstPath = first.Value.Path; @@ -156,7 +156,7 @@ public async Task Resolve_CollisionWithExistingFile_AddsSequenceSuffix() Directory.CreateDirectory(Path.GetDirectoryName(firstAbsolute)!); File.WriteAllBytes(firstAbsolute, new byte[] { 0 }); - var second = await WebViewScreenshotResolver.ResolveAsync(saveTo: "screenshots/", format: "jpeg", _fileSystem); + var second = await WebViewScreenshotResolver.ResolveAsync(saveTo: "screenshots/", format: "jpeg", _fileStorage); second.IsSuccess.Should().BeTrue(); var secondPath = second.Value.Path; @@ -167,7 +167,7 @@ public async Task Resolve_CollisionWithExistingFile_AddsSequenceSuffix() [Test] public async Task Resolve_TraversalAttempt_RejectedByResourceKey() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "../escape.png", format: "png", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "../escape.png", format: "png", _fileStorage); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Invalid saveTo"); @@ -176,7 +176,7 @@ public async Task Resolve_TraversalAttempt_RejectedByResourceKey() [Test] public async Task Resolve_BackslashInSaveTo_Rejected() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: @"docs\output.png", format: "png", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: @"docs\output.png", format: "png", _fileStorage); result.IsFailure.Should().BeTrue(); } @@ -184,7 +184,7 @@ public async Task Resolve_BackslashInSaveTo_Rejected() [Test] public async Task Resolve_AbsolutePathSaveTo_Rejected() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "/etc/output.png", format: "png", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "/etc/output.png", format: "png", _fileStorage); result.IsFailure.Should().BeTrue(); } @@ -192,7 +192,7 @@ public async Task Resolve_AbsolutePathSaveTo_Rejected() [Test] public async Task Resolve_UnsupportedFormat_Fails() { - var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "webp", _fileSystem); + var result = await WebViewScreenshotResolver.ResolveAsync(saveTo: "", format: "webp", _fileStorage); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Unsupported screenshot format"); diff --git a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs index 416a08f19..f4cc13e48 100644 --- a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs +++ b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs @@ -114,7 +114,7 @@ public ConsolePanelViewModel( _messengerService.Register(this, OnConsoleMaximizedChanged); // Snapshot the project file contents so subsequent changes can be - // detected. The hash read goes through the resource file system, + // detected. The hash read goes through the file storage chokepoint, // which is async; fire-and-forget here since the constructor is sync // and the snapshot is only consulted on later change events. _ = StoreProjectFileHashAsync(); @@ -296,8 +296,8 @@ private async Task StoreProjectFileHashAsync() return; } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var readResult = await fileSystem.ReadAllBytesAsync(projectFileResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var readResult = await fileStorage.ReadAllBytesAsync(projectFileResource); if (readResult.IsFailure) { _originalProjectFileHash = null; @@ -314,8 +314,8 @@ private async Task CheckProjectFileChangedAsync() return; } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var readResult = await fileSystem.ReadAllBytesAsync(projectFileResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var readResult = await fileStorage.ReadAllBytesAsync(projectFileResource); if (readResult.IsFailure) { // If we can't read the file, hide the banner diff --git a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs index beba4e499..3645c7065 100644 --- a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs +++ b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs @@ -23,16 +23,16 @@ public FileAccessHelper(IWorkspaceWrapper workspaceWrapper) /// public async Task CanAccessFileAsync(ResourceKey fileResource) { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(fileResource); + var infoResult = await fileStorage.GetInfoAsync(fileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return false; } - var openResult = await fileSystem.OpenReadAsync(fileResource); + var openResult = await fileStorage.OpenReadAsync(fileResource); if (openResult.IsFailure) { return false; @@ -57,10 +57,10 @@ public async Task> ResolveAndValidateFilePathAsync(ResourceKey fi } var filePath = resolveResult.Value; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(fileResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(fileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"File path does not exist: '{filePath}'"); } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index 282be56b1..150d5388c 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -382,9 +382,9 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource var changeDocumentResource = async Task () => { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(message.NewResource); - Guard.IsTrue(infoResult.IsSuccess && infoResult.Value.Kind == ResourceInfoKind.File); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(message.NewResource); + Guard.IsTrue(infoResult.IsSuccess && infoResult.Value.Kind == StorageItemKind.File); var changeResult = await DocumentsPanel.ChangeDocumentResource(oldResource, oldDocumentType, newResource, newResourcePath, newDocumentType); if (changeResult.IsFailure) diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs index a828212fb..47cf98c23 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs @@ -71,17 +71,17 @@ public async Task LoadTextContentAsync() return string.Empty; } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return await GetDefaultTemplateContentAsync(); } if (IsBinary) { - var bytesResult = await fileSystem.ReadAllBytesAsync(FileResource); + var bytesResult = await fileStorage.ReadAllBytesAsync(FileResource); if (bytesResult.IsFailure) { return await GetDefaultTemplateContentAsync(); @@ -96,7 +96,7 @@ public async Task LoadTextContentAsync() return Convert.ToBase64String(bytes); } - var textResult = await fileSystem.ReadAllTextAsync(FileResource); + var textResult = await fileStorage.ReadAllTextAsync(FileResource); if (textResult.IsFailure) { return await GetDefaultTemplateContentAsync(); @@ -283,7 +283,7 @@ public Result ResolveLinkTarget(string href) /// /// Reads the default template content from the manifest's template file. /// Returns empty string if no default template is declared or the file cannot be read. - /// Routes through IResourceFileSystem when the template path is registry-addressable; + /// Routes through IFileStorage when the template path is registry-addressable; /// falls back to direct read for packages installed outside the project tree. /// private async Task GetDefaultTemplateContentAsync() @@ -306,8 +306,8 @@ private async Task GetDefaultTemplateContentAsync() var keyResult = _resourceRegistry.GetResourceKey(templatePath); if (keyResult.IsSuccess) { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var textResult = await fileSystem.ReadAllTextAsync(keyResult.Value); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var textResult = await fileStorage.ReadAllTextAsync(keyResult.Value); return textResult.IsSuccess ? textResult.Value : string.Empty; } diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs index 8a0f29e7c..0a9ab5522 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs @@ -144,10 +144,10 @@ private async void OnResourceRegistryUpdatedMessage(object recipient, ResourceRe // rename temp" save pattern used by some editors and coding agents. Check if the file // still exists on disk before closing. The resource registry may not have caught up // with the rename yet. - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsSuccess - && infoResult.Value.Kind == ResourceInfoKind.File) + && infoResult.Value.Kind == StorageItemKind.File) { return; } @@ -184,10 +184,10 @@ public async Task> CloseDocument(bool forceClose) { Guard.IsNotNull(DocumentView); - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var closeInfoResult = await fileSystem.GetInfoAsync(FileResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var closeInfoResult = await fileStorage.GetInfoAsync(FileResource); if (closeInfoResult.IsFailure - || closeInfoResult.Value.Kind != ResourceInfoKind.File) + || closeInfoResult.Value.Kind != StorageItemKind.File) { // The file no longer exists, so we assume that it was deleted intentionally. // Any pending save changes are discarded. diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index 3e6bace91..43462a9dd 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -133,8 +133,8 @@ private async void OnDocumentSaveCompleted(object recipient, DocumentSaveComplet /// protected async Task> LoadTextFromFileAsync() { - var fileSystem = GetFileSystem(); - var readResult = await fileSystem.ReadAllTextAsync(FileResource); + var fileStorage = GetFileSystem(); + var readResult = await fileStorage.ReadAllTextAsync(FileResource); if (readResult.IsFailure) { return Result.Fail($"Failed to load file: '{FilePath}'") @@ -176,7 +176,7 @@ protected async Task SaveBinaryToFileAsync(string base64Content) } /// - /// Routes the save through IResourceFileSystem (atomic write + bounded retry + /// Routes the save through IFileStorage (atomic write + bounded retry /// on transient IO) and raises ReloadRequested when external interleaving is /// detected either before the write (pre-write hash check) or between the /// write completing and our tracking-hash read (post-write check). Updates @@ -191,8 +191,8 @@ private async Task SaveBytesToFileAsync(byte[] bytes) return Result.Ok(); } - var fileSystem = GetFileSystem(); - var writeResult = await fileSystem.WriteAllBytesAsync(FileResource, bytes); + var fileStorage = GetFileSystem(); + var writeResult = await fileStorage.WriteAllBytesAsync(FileResource, bytes); if (writeResult.IsFailure) { return writeResult; @@ -226,8 +226,8 @@ private async Task TryDetectPreWriteExternalChangeAsync() return false; } - var fileSystem = GetFileSystem(); - var readResult = await fileSystem.ReadAllBytesAsync(FileResource); + var fileStorage = GetFileSystem(); + var readResult = await fileStorage.ReadAllBytesAsync(FileResource); if (readResult.IsFailure) { _logger?.LogDebug($"Pre-write hash check failed for '{FilePath}', proceeding to write attempt"); @@ -255,10 +255,10 @@ private async Task TryDetectPreWriteExternalChangeAsync() /// substitute a layer wired to a temp folder without going through the /// workspace service hierarchy. /// - protected virtual IResourceFileSystem GetFileSystem() + protected virtual IFileStorage GetFileSystem() { var workspaceWrapper = ServiceLocator.AcquireService(); - return workspaceWrapper.WorkspaceService.ResourceFileSystem; + return workspaceWrapper.WorkspaceService.FileStorage; } /// @@ -278,10 +278,10 @@ protected async Task IsFileChangedExternallyAsync() return true; } - var fileSystem = GetFileSystem(); - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var fileStorage = GetFileSystem(); + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return true; } @@ -294,7 +294,7 @@ protected async Task IsFileChangedExternallyAsync() // File size is the same; compute hash to check if content actually changed. // This handles cases where the file was rewritten with identical content. - var readResult = await fileSystem.ReadAllBytesAsync(FileResource); + var readResult = await fileStorage.ReadAllBytesAsync(FileResource); if (readResult.IsFailure) { return true; @@ -306,17 +306,17 @@ protected async Task IsFileChangedExternallyAsync() protected virtual async Task UpdateFileTrackingInfoAsync() { - var fileSystem = GetFileSystem(); - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var fileStorage = GetFileSystem(); + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { _lastSavedFileHash = null; _lastSavedFileSize = 0; return; } - var readResult = await fileSystem.ReadAllBytesAsync(FileResource); + var readResult = await fileStorage.ReadAllBytesAsync(FileResource); if (readResult.IsFailure) { _lastSavedFileHash = null; diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs index d8e894b5f..3247ed337 100644 --- a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs +++ b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs @@ -7,7 +7,7 @@ namespace Celbridge.Documents.Views; public abstract partial class DocumentView : UserControl, IDocumentView { private IResourceRegistry? _resourceRegistry; - private IResourceFileSystem? _resourceFileSystem; + private IFileStorage? _fileStorage; /// /// Provides access to the resource registry for file resource validation. @@ -27,19 +27,19 @@ protected IResourceRegistry ResourceRegistry } /// - /// Provides access to the resource file system chokepoint. + /// Provides access to the file storage chokepoint. /// Lazily initialized from the workspace wrapper. /// - protected IResourceFileSystem ResourceFileSystem + protected IFileStorage FileStorage { get { - if (_resourceFileSystem is null) + if (_fileStorage is null) { var workspaceWrapper = ServiceLocator.AcquireService(); - _resourceFileSystem = workspaceWrapper.WorkspaceService.ResourceFileSystem; + _fileStorage = workspaceWrapper.WorkspaceService.FileStorage; } - return _resourceFileSystem; + return _fileStorage; } } @@ -89,9 +89,9 @@ public virtual async Task SetFileResource(ResourceKey fileResource) } var filePath = resolveResult.Value; - var infoResult = await ResourceFileSystem.GetInfoAsync(fileResource); + var infoResult = await FileStorage.GetInfoAsync(fileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"File resource does not exist on disk: {fileResource}"); } diff --git a/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs b/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs index 329116204..e367e01bb 100644 --- a/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs +++ b/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs @@ -184,7 +184,7 @@ private async Task> FindDefaultFolderNameAsync(IFolderResource? p } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; var parentFolderKey = resourceRegistry.GetResourceKey(parentFolder); string defaultFolderName = string.Empty; @@ -194,9 +194,9 @@ private async Task> FindDefaultFolderNameAsync(IFolderResource? p var candidateName = _stringLocalizer.GetString(DefaultFolderNameKey, folderNumber).ToString(); var candidateKey = parentFolderKey.Combine(candidateName); - var infoResult = await fileSystem.GetInfoAsync(candidateKey); + var infoResult = await fileStorage.GetInfoAsync(candidateKey); if (infoResult.IsSuccess - && infoResult.Value.Kind == ResourceInfoKind.NotFound) + && infoResult.Value.Kind == StorageItemKind.NotFound) { defaultFolderName = candidateName; break; @@ -219,7 +219,7 @@ private async Task> FindDefaultFileNameAsync(IFolderResource? par } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; var editorSettings = _serviceProvider.GetRequiredService(); // Get the previously saved extension @@ -237,9 +237,9 @@ private async Task> FindDefaultFileNameAsync(IFolderResource? par candidateName = Path.ChangeExtension(candidateName, extension); var candidateKey = parentFolderKey.Combine(candidateName); - var infoResult = await fileSystem.GetInfoAsync(candidateKey); + var infoResult = await fileStorage.GetInfoAsync(candidateKey); if (infoResult.IsSuccess - && infoResult.Value.Kind == ResourceInfoKind.NotFound) + && infoResult.Value.Kind == StorageItemKind.NotFound) { defaultFileName = candidateName; break; diff --git a/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs b/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs index 9935f46a3..1b3472a2d 100644 --- a/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs +++ b/Source/Workspace/Celbridge.Inspector/Services/InspectorFactory.cs @@ -50,8 +50,8 @@ public async Task> CreateResourceInspectorAsync(ResourceKey r { try { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var infoResult = await fileSystem.GetInfoAsync(resource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(resource); if (infoResult.IsFailure) { return Result.Fail($"Failed to probe resource: '{resource}'") @@ -59,12 +59,12 @@ public async Task> CreateResourceInspectorAsync(ResourceKey r } var info = infoResult.Value; - if (info.Kind == ResourceInfoKind.Folder) + if (info.Kind == StorageItemKind.Folder) { return CreateFolderInspector(resource); } - if (info.Kind == ResourceInfoKind.File) + if (info.Kind == StorageItemKind.File) { return CreateFileInspector(resource); } diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs index 2c5a6d634..222b55b33 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs @@ -283,16 +283,16 @@ private async Task> DiscoverProjectPackagesAsync(string } var packagesResource = new ResourceKey(PackagesFolderName); - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var packagesInfoResult = await fileSystem.GetInfoAsync(packagesResource); + var packagesInfoResult = await fileStorage.GetInfoAsync(packagesResource); if (packagesInfoResult.IsFailure - || packagesInfoResult.Value.Kind != ResourceInfoKind.Folder) + || packagesInfoResult.Value.Kind != StorageItemKind.Folder) { return failures; } - var enumerateResult = await fileSystem.EnumerateFolderAsync(packagesResource); + var enumerateResult = await fileStorage.EnumerateFolderAsync(packagesResource); if (enumerateResult.IsFailure) { return failures; @@ -309,9 +309,9 @@ private async Task> DiscoverProjectPackagesAsync(string } var manifestResource = item.Resource.Combine(ManifestFileName); - var manifestInfoResult = await fileSystem.GetInfoAsync(manifestResource); + var manifestInfoResult = await fileStorage.GetInfoAsync(manifestResource); if (manifestInfoResult.IsFailure - || manifestInfoResult.Value.Kind != ResourceInfoKind.File) + || manifestInfoResult.Value.Kind != StorageItemKind.File) { // A folder under packages/ with no manifest is not a package. // Silently skip rather than report as a failure. diff --git a/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl b/Source/Workspace/Celbridge.Python/Assets/Python/celbridge-0.1.0-py3-none-any.whl index 5f7f624d42ff7f2bd83a559a2f184b8ec79f8bb6..dc474c11a25503a7ca224c50d1e3127e62b7aff3 100644 GIT binary patch delta 3014 zcmZ8jcQ_P|8$W07l98_+Wt_d~$}HEva4B%4V zItJRG&dfWouc?xBk@3D6j8A@_T=|+H<29^20kdI92$i0}2^f|8IMV;Pk&D?V+bH{# zru59*2h<64Opxfue%;hcNrLA!){l%)y5n_|F@8!Emc+*rHRQ$OEL*p<`xI$AlNxp9Ib zk+wvPfKiBAy39KW3xK?aH)X)W=Y^=cl_GCIlYC3gR&I-@G+m-sU(JjSt{kNv3Cs0p zepjs_y99=}WVqrXC9Hr6GnL3|GiTM(BS7S~i9C}r0`To#lS9O;Z~D&e@(sL-;&=7; zS|si)F4{T0#tO6PACIz&DFr^{?%Epr#IbDB*tp8+#HEScD%4)90P;euG1QY6T}2TDYG_yr zvpuoI-f(lGx())z^6DS+&DVxu#goEt_3px%86YZ&C+5Qo(JD^2ce;y%Rx73tS!}?X z3B()iD908LM*NGz8;D2;Dk6ua&)G=(bWEnMPlgxID2>@`IuLlyl z{=1D-0MC78BZ0ORE8*%dv#du|*y72I_RRbgb=JN)LpOzwpKq|!uTKnzBq-wUcl;9p zdP>C49^JIpzmEH{545cwx-C;Ni#0Fx-hA?8l4G`Sr%aBpW1Qk;5Hj+fFeJU%Rt57& zT0DV~6O9I6F)}JiHLnyc?`pR3C+ojkp7;J81P43XU2K%iYidX|EFNml=Zrdn+wp5b zR2NihmjYu+LvcB$fkD{2^L(7L^0h|BkIQ_U-)>?ou+m{|O6xR)U%P?uQU5|XDYabAWIqp`$mV$_SPP(D0{5~vqR>Ee5HTR4~1|EOTRUnT+5|kIig0mfU zl~YX)cfvb*s2ISP!qIl)HHlwrhjFZQ*32gwHELF@8kCNX_!=X0G$o~zBOXOgPx&LD z+%x?D#^nrSx^w+exDIp=11a;qGEQe-+5*QrS+k#H_YpI4Ny@^p&ZOyyZ=&&d=HGx^uzT! zm~@MGcPv>POVgtmFwPGGnQrLKpO9;Nr$#d?cMEIomLJ(LIBmGVLEjZ1G4eOwMyi>a zro0R096vm)xA?`_zRX!? z%^(5h2KROfWcaD2=(O zZg0bL?jX6>YO@s1nt<<9wb*b~aofJ7-oM@9Yuyw@8*N4P!^`fjJS}07(cG`t&bSLf z-0UrGMIznXu#yTl?O1&ghm#c8udfTzND_%Pmi(qnqVWE+<7l(`r?e~S63(7W4C1j?$MBLmb0?XtZ>aQQ=j@`MAz1*uK zqdBiuzC2ukzhSa;x%g~)@c@a|oLQxQv53%@sEnDAqQd*o#dg2B);y8NjJ=aYWwjqn zA!PX7+oBgV5;=vhFsS*w$|WaY5*0esXx8rXL}B_1#euhWXR^C^G>6k+Kbk>BO!rP~ zddOG%Gb@pGGNNW>*Z=$ToBq)v-d!6d-qRUD^E+Ce@aF?FY(BTN=y%}8eR>%$)Psf^lLxpaq(y)ud)%VU&T8;d-#KkXIdvw9fYeK-VXaSP~ zTLLl}-u<)G{gsc5Kk*}#?0O-+-^o(bxEWalw*Brao8!J!t(8v74 z2;p0eVjKUa#=#@Ml$C9i>(tSaB4>TsRPq8EF5}`^23z)2(WW4(*_Ya52%|^0#tGn= z0UU_~jfme?=DyyT^e{Rp=`=YuEjPQPyt7~PX7uQei6+sW!gkE`>JPun4D?CRw;;aV zEtW_>QMUGDPV{FIg~+F z6iEQzx}8HY)R-G9img}z*yDLlg;9@-6@XX0|3X}`A`s<=s`8UV36@9zE2I9Zo+X@u z=gaw#O>xq=0VM#yOa}m50sP16!og_Nb}28at%P0h&(;=&EXO`N&p$kOf&bJ2>cTtI z{}!I5Q-e&6D1ht$_VY9y;Lr3o**$2vbEbCg693a=i_$7p`+F8bOL-_p&lw-8u9REg e-v@%_{3DO02LJ^BtLsC!7;39jfyOuQkN7VhQn`x& delta 3047 zcmZWrc{CJ!8yzDfG$AyyC8M%u$!h$g_&-_Rra=R zQDJpTP&nPNM1Ei~l0AXPDp^u!;H*a^aSVk7Dvb*^`KT|bk@cz)4K!Kq1lJhcAO?*g z0p4QGdIGQl>?Jy7wZ6#9v3pt!YN~`iHrXTbNYWy1z5c;)F_Xfef*tneg;HkaQ;yv( zuk4NbN2b(?F0n;@jMG7TI#ro|gW-NFXOTsUTinKiW=7FGu~IoA7&VsP)oh(*p(q%Y z1O6>Td|D%uHKZoR5kC}_Jste;XUW8A80D&*80swSs*B9^WKzoa@nOZ4!BhH`zGi&l zxplkQ`yD#mn*x7%+4ZudLOs7*TuGSXkkmQ$od^dH;xz zzVV_a;qBn$_|JkG2@GMyZ$89_>q+r}?UFDB9QSxjSMc#npR4Ck;ip0qS(k?v=zCIW*(mS(=aW{p zc#lCxlRCj#uejaVgiwSAVhWdB!*#SW2{c|r*(Z6YlF>KVgsfwXz#d+t{r~;ANyRvG`wVpmoa27s)M|m@WP?^wd@SZ_(_grFnxX%& z-a01}r>KMNy`0h{ZH%U$;LbRpu^k?Ev6fe z%%;3lAqrR{yrcUwA-vW1lf8aT{~A>I!FSJ)tAGz_ICZ2H8Q$ zR_2|=!8BC*lTw;<4ci+|BsNr=Q+q?71ZyYpeld#Mb(ND_L7l~68GMBtXwr*=;wW}m zQ#Xsu-`|-|bwpvG`}IA?WhS;9?==e=*Em0OhZ9O|SQM(a9h08Wc3W6y=KoacIk%z1 z=|m$9>~2Q74%Jk>Pg_jn%B1Th4JyKYW95C^39RR`6s3{a7Pb#hLgg1%aydcluO~Jy zv1G~O>mky9s9DTFKDPMgW@d`mBMlc4dER&oT;jDrXLIn#@?X8sFKM6gC2RzJuV=4I za(UFK2z=gXoLH?y>!#>vpK|@xsfu_XHG;Q^&;$)GE^#BH*IT%wH;LlqX}PaViVy>L z`$Cxqs7C$Q1%C0s7369>?n!?M&#G6+&$beF57WP&n= ztDv!9^|_gq%P;%Wm9(GGyj3EB6jRaV48|8kU4}mm`V`*&Hzz^WhEs=`h9?n>GGmzk z5cF_P`VqS41|H#JF8uhRZf`#fyN1@l$f7>>q+r#!Ky#rw9JdmLPx{G&ngLxLF53oV zu`4dhz`H+pdBRuOq+Ci#a_pE^ozyIGwu;XpIQ4zDbDmp(c|%o1@iS}PpY9FUFS|#! z`1nl2l@r}Rba{=MCRfuOC;hZfJXnXHD!#aY%NE?dA-B*U1LaOwtz-GnvU zU&}LTv~L(3HcD(F4dneAAV|VbcSL6uum^@8WJ7Z0t%(keW8+l3>35=A36XffvK(y1 zsVfVQY*ZR^-t&~sa<+E72XXwkj8gaX&8)oQuPx$|%E&>b8%|YT1uYkSDjM0*WZMK}7#_W(sBrSp*wFXq8vl zR=pXA_{Br^?j!f*B8{Rglw`DG@}O(s>yvbty#4qOYKTL{J2{s4aQ{)%HczQumWa{2 zTnR^u^zbv2^-tTU&%N1_i?J!+nY>X+d?4s|ckHJ7`8ef+>g`>PnWt$#+bJE^SFZ>v_xZ*fE0n!_LUr5tRaQ zrt41oZ6x|e{nml;!k560u_#0GUHc~bZcfX0=wGK9@|at%x5)A)SJY`H#`AA8!Xhh9 z()-qfhZ-ER_|z2Rp^wuZb*^kgR=rGaDyY*aA5paB@n-bu#m}d{{^4{nk+kIR9Z6pw zfcyLAU3IbKjJ{)A5S*hVcTIkV!E(_1G<+k8t&rdG=HFj%LtNnkI_jwKc8~tK-r&I4 zMn!$y8D|*-#e{^XgRQHZ6wq`IGX4(#TNJeLM%Vt1xmXz2FXzyKlo%}5aeQe8n@X#O z#s=aXKj(FKE_w{l-$%R>MPW@>KUzNe?HZ~UmM+yT0%D~;HwygN5ORrUMc>#tH1-vB zA7jfE?tYBt{F`A_#}w2&yDV@Z=co254t_!G+ZW9*ud^_I2KF(NVNnM6deK9VhGIr9 z8ZX;0LYcTqf^G10>Cw(l&999J)HO(r2u1mz08CiKrnT=OA=7|LzfWcu|zfdtA0@K_%N#JMS_y z5jQL``yP(sef&|SqXnYr=X_Or{e||*o^+mb2>i#jmJ7T(3fbvUdCXv%PHe17TMaP; zMlxHo$c>mSBQ;WXd|yOZ8`jb{@W_rO+d?wy4wLpxQ;>kF%>i}vQ(C<*vhC|=e~hO3 zoif5(#R!X6h3$EW@^c6pYvYOgu{s`S2uphLEMlk~makp`pZ33Alju-mHRbcHC1yJ< ze(mGT%@u^boRHX~CD&k0O734bl5{FBJF|Z#P}URlO`$mk1ylqj{EUtku&8qLb*8P< zKu*X6YXUalVo_@~gv$1+;;2rq&rVEwQ)G6SMcoL?1Lm<)-4D)fR#&d46I|+ovI48X z`5ll;J96G;>WQC9;S$UE$NBU+zfpRA^E#qVWlF${zK2v6O)irHrUe|ST;*!OH_=CQGn!q-+=L0p3*ZF+ z0LKA;K4pip)JY>|004X(01!J=|9SP1+L`G293gaDIj`s+<3-cuc>BYy-C>>m$90JH zk=;3m#&iFdoSfg2sc>jeKP;*L8n~T%lx$xibhIBrD+GZP|CTs0;Q!0{vo=m1Uh45| b0D$O! ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var failedResources = new List(); var failureDetails = new List(); @@ -45,7 +45,7 @@ public override async Task ExecuteAsync() { var resource = fileEdit.Resource; - var applyResult = await ApplyEditsToDisk(resourceRegistry, fileSystem, resource, fileEdit.Edits); + var applyResult = await ApplyEditsToDisk(resourceRegistry, fileStorage, resource, fileEdit.Edits); if (applyResult.IsFailure) { _logger.LogWarning($"Failed to apply edits to file on disk: {resource}"); @@ -87,13 +87,13 @@ public override async Task ExecuteAsync() private static async Task ApplyEditsToDisk( IResourceRegistry resourceRegistry, - IResourceFileSystem fileSystem, + IFileStorage fileStorage, ResourceKey resource, List edits) { - var infoResult = await fileSystem.GetInfoAsync(resource); + var infoResult = await fileStorage.GetInfoAsync(resource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"File not found: '{resource}'"); } @@ -101,7 +101,7 @@ private static async Task ApplyEditsToDisk( // Read the file's existing content to capture its line-ending style and // trailing-newline state. Both must be preserved across the edit so the // file's on-disk format does not silently drift. - var readResult = await fileSystem.ReadAllTextAsync(resource); + var readResult = await fileStorage.ReadAllTextAsync(resource); if (readResult.IsFailure) { return Result.Fail($"Failed to read file: '{resource}'") @@ -173,7 +173,7 @@ private static async Task ApplyEditsToDisk( output += originalSeparator; } - var writeResult = await fileSystem.WriteAllTextAsync(resource, output); + var writeResult = await fileStorage.WriteAllTextAsync(resource, output); if (writeResult.IsFailure) { return Result.Fail($"Failed to write edits to file: '{resource}'") diff --git a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs index 3764afecb..82f6aaf27 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs @@ -54,12 +54,12 @@ public override async Task ExecuteAsync() // EnumerateFolderAsync so the read side honours the same containment // validation as the write side. private static async Task CollectArchiveEntriesAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, ResourceKey folder, string relativePrefix, List<(ResourceKey Resource, string RelativePath)> entries) { - var enumerateResult = await fileSystem.EnumerateFolderAsync(folder); + var enumerateResult = await fileStorage.EnumerateFolderAsync(folder); if (enumerateResult.IsFailure) { return; @@ -74,7 +74,7 @@ private static async Task CollectArchiveEntriesAsync( if (item.IsFolder) { - await CollectArchiveEntriesAsync(fileSystem, item.Resource, childRelative, entries); + await CollectArchiveEntriesAsync(fileStorage, item.Resource, childRelative, entries); } else { @@ -93,7 +93,7 @@ private async Task ExecuteArchiveAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; if (!ResourceKey.IsValidKey(SourceResource)) { @@ -121,23 +121,23 @@ private async Task ExecuteArchiveAsync() } var archivePath = resolveArchiveResult.Value; - var sourceInfoResult = await fileSystem.GetInfoAsync(SourceResource); + var sourceInfoResult = await fileStorage.GetInfoAsync(SourceResource); if (sourceInfoResult.IsFailure) { return Result.Fail($"Failed to probe source resource: '{SourceResource}'") .WithErrors(sourceInfoResult); } - bool isFile = sourceInfoResult.Value.Kind == ResourceInfoKind.File; - bool isFolder = sourceInfoResult.Value.Kind == ResourceInfoKind.Folder; + bool isFile = sourceInfoResult.Value.Kind == StorageItemKind.File; + bool isFolder = sourceInfoResult.Value.Kind == StorageItemKind.Folder; if (!isFile && !isFolder) { return Result.Fail($"Resource not found: '{SourceResource}'"); } - var archiveInfoResult = await fileSystem.GetInfoAsync(ArchiveResource); + var archiveInfoResult = await fileStorage.GetInfoAsync(ArchiveResource); bool archiveExists = archiveInfoResult.IsSuccess - && archiveInfoResult.Value.Kind == ResourceInfoKind.File; + && archiveInfoResult.Value.Kind == StorageItemKind.File; if (!Overwrite && archiveExists) { @@ -172,7 +172,7 @@ private async Task ExecuteArchiveAsync() if (ArchiveHelper.ShouldIncludeFile(fileName, includeRegexes, excludeRegexes)) { - var addResult = await ArchiveHelper.AddFileToArchiveAsync(zipArchive, fileSystem, SourceResource, fileName); + var addResult = await ArchiveHelper.AddFileToArchiveAsync(zipArchive, fileStorage, SourceResource, fileName); if (addResult.IsFailure) { return addResult; @@ -183,7 +183,7 @@ private async Task ExecuteArchiveAsync() else { var fileEntries = new List<(ResourceKey Resource, string RelativePath)>(); - await CollectArchiveEntriesAsync(fileSystem, SourceResource, string.Empty, fileEntries); + await CollectArchiveEntriesAsync(fileStorage, SourceResource, string.Empty, fileEntries); foreach (var (fileResource, relativePath) in fileEntries) { @@ -192,7 +192,7 @@ private async Task ExecuteArchiveAsync() continue; } - var addResult = await ArchiveHelper.AddFileToArchiveAsync(zipArchive, fileSystem, fileResource, relativePath); + var addResult = await ArchiveHelper.AddFileToArchiveAsync(zipArchive, fileStorage, fileResource, relativePath); if (addResult.IsFailure) { return addResult; @@ -213,7 +213,7 @@ private async Task ExecuteArchiveAsync() return createResult; } - var archiveProbeResult = await fileSystem.GetInfoAsync(ArchiveResource); + var archiveProbeResult = await fileStorage.GetInfoAsync(ArchiveResource); long archiveSize = archiveProbeResult.IsSuccess ? archiveProbeResult.Value.Size : archiveBytes.Length; diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 258d4f14e..a21bd9def 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -68,7 +68,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var transferService = workspaceService.ResourceService.TransferService; // Filter out resources whose parent folders are also selected. @@ -89,7 +89,7 @@ public override async Task ExecuteAsync() { foreach (var sourceResource in filteredResources) { - var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, fileSystem, transferService, resourceOpService); + var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, fileStorage, transferService, resourceOpService); if (outcome.Result.IsFailure) { @@ -185,7 +185,7 @@ public override async Task ExecuteAsync() private async Task CopySingleResourceAsync( ResourceKey sourceResource, IResourceRegistry resourceRegistry, - IResourceFileSystem fileSystem, + IFileStorage fileStorage, IResourceTransferService transferService, IResourceOperationService resourceOpService) { @@ -219,7 +219,7 @@ private async Task CopySingleResourceAsync( } var destPath = resolveDestResult.Value; - var infoResult = await fileSystem.GetInfoAsync(sourceResource); + var infoResult = await fileStorage.GetInfoAsync(sourceResource); if (infoResult.IsFailure) { return new CopyResourceOutcome( @@ -230,8 +230,8 @@ private async Task CopySingleResourceAsync( MoveDetail: null); } var info = infoResult.Value; - bool isFile = info.Kind == ResourceInfoKind.File; - bool isFolder = info.Kind == ResourceInfoKind.Folder; + bool isFile = info.Kind == StorageItemKind.File; + bool isFolder = info.Kind == StorageItemKind.Folder; if (!isFile && !isFolder) { diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index 286afcb47..9a7032ab1 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -56,7 +56,7 @@ public override async Task ExecuteAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var scanner = workspaceService.ResourceScanner; // Phase A: aggregate referencers external to the batch. References @@ -69,7 +69,7 @@ public override async Task ExecuteAsync() var folderResources = new List(); foreach (var resource in Resources) { - if (await IsFolderResourceAsync(fileSystem, resource)) + if (await IsFolderResourceAsync(fileStorage, resource)) { folderResources.Add(resource); } @@ -190,9 +190,9 @@ bool IsInsideBatch(ResourceKey candidate) } var resourcePath = resolveResult.Value; - bool sidecarPresent = await SidecarExistsForResourceAsync(workspaceService, fileSystem, resource); + bool sidecarPresent = await SidecarExistsForResourceAsync(workspaceService, fileStorage, resource); - var infoResult = await fileSystem.GetInfoAsync(resource); + var infoResult = await fileStorage.GetInfoAsync(resource); if (infoResult.IsFailure) { _logger.LogWarning($"Cannot delete resource because info probe failed: '{resource}'"); @@ -207,11 +207,11 @@ bool IsInsideBatch(ResourceKey candidate) var info = infoResult.Value; Result deleteResult; - if (info.Kind == ResourceInfoKind.File) + if (info.Kind == StorageItemKind.File) { deleteResult = await resourceOpService.DeleteFileAsync(resourcePath); } - else if (info.Kind == ResourceInfoKind.Folder) + else if (info.Kind == StorageItemKind.Folder) { deleteResult = await resourceOpService.DeleteFolderAsync(resourcePath); } @@ -324,17 +324,17 @@ private static (DeleteResourceOutcome Outcome, string Message) ClassifyDeleteFai return (DeleteResourceOutcome.IOFailure, deleteResult.FirstErrorMessage); } - private static async Task IsFolderResourceAsync(IResourceFileSystem fileSystem, ResourceKey resource) + private static async Task IsFolderResourceAsync(IFileStorage fileStorage, ResourceKey resource) { - var infoResult = await fileSystem.GetInfoAsync(resource); + var infoResult = await fileStorage.GetInfoAsync(resource); if (infoResult.IsFailure) { return false; } - return infoResult.Value.Kind == ResourceInfoKind.Folder; + return infoResult.Value.Kind == StorageItemKind.Folder; } - private static async Task SidecarExistsForResourceAsync(IWorkspaceService workspaceService, IResourceFileSystem fileSystem, ResourceKey resource) + private static async Task SidecarExistsForResourceAsync(IWorkspaceService workspaceService, IFileStorage fileStorage, ResourceKey resource) { var sidecarKeyResult = workspaceService.SidecarService.GetSidecarKey(resource); if (sidecarKeyResult.IsFailure) @@ -342,12 +342,12 @@ private static async Task SidecarExistsForResourceAsync(IWorkspaceService return false; } - var infoResult = await fileSystem.GetInfoAsync(sidecarKeyResult.Value); + var infoResult = await fileStorage.GetInfoAsync(sidecarKeyResult.Value); if (infoResult.IsFailure) { return false; } - return infoResult.Value.Kind == ResourceInfoKind.File; + return infoResult.Value.Kind == StorageItemKind.File; } private static string BuildConfirmationMessage( diff --git a/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs index ebed3e8eb..1b60276ff 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/EditFileCommand.cs @@ -33,16 +33,16 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"File not found: '{FileResource}'"); } - var readResult = await fileSystem.ReadAllTextAsync(FileResource); + var readResult = await fileStorage.ReadAllTextAsync(FileResource); if (readResult.IsFailure) { return Result.Fail($"Failed to read file: '{FileResource}'") @@ -70,7 +70,7 @@ public override async Task ExecuteAsync() var newContent = buildResult.NewContent; var replacementStarts = buildResult.ReplacementStarts; - var writeResult = await fileSystem.WriteAllTextAsync(FileResource, newContent); + var writeResult = await fileStorage.WriteAllTextAsync(FileResource, newContent); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs index 1ca1802d1..71f2fb6f7 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs @@ -37,7 +37,7 @@ public override async Task ExecuteAsync() { var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var resolveResult = resourceRegistry.ResolveResourcePath(Resource); if (resolveResult.IsFailure) @@ -46,7 +46,7 @@ public override async Task ExecuteAsync() } var resourcePath = resolveResult.Value; - var infoResult = await fileSystem.GetInfoAsync(Resource); + var infoResult = await fileStorage.GetInfoAsync(Resource); if (infoResult.IsFailure) { return Result.Fail($"Failed to probe resource: '{Resource}'") @@ -54,7 +54,7 @@ public override async Task ExecuteAsync() } var info = infoResult.Value; - if (info.Kind == ResourceInfoKind.File) + if (info.Kind == StorageItemKind.File) { var extension = Path.GetExtension(resourcePath); var isText = !_textBinarySniffer.IsBinaryExtension(extension) @@ -64,7 +64,7 @@ public override async Task ExecuteAsync() if (isText) { - lineCount = await CountLinesAsync(fileSystem, Resource); + lineCount = await CountLinesAsync(fileStorage, Resource); } // Surface the paired sidecar's key and current parse state when @@ -95,7 +95,7 @@ public override async Task ExecuteAsync() return Result.Ok(); } - if (info.Kind == ResourceInfoKind.Folder) + if (info.Kind == StorageItemKind.Folder) { ResultValue = new FileInfoSnapshot( Exists: true, @@ -117,9 +117,9 @@ public override async Task ExecuteAsync() // Streams the file via the chokepoint and counts lines without loading // the entire content into memory. Used for the LineCount field on the // FileInfoSnapshot when the resource is text. - private static async Task CountLinesAsync(IResourceFileSystem fileSystem, ResourceKey resource) + private static async Task CountLinesAsync(IFileStorage fileStorage, ResourceKey resource) { - var openResult = await fileSystem.OpenReadAsync(resource); + var openResult = await fileStorage.OpenReadAsync(resource); if (openResult.IsFailure) { return 0; diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs index b38b8ce63..e8f864d51 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileTreeCommand.cs @@ -26,12 +26,12 @@ public GetFileTreeCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; // EnumerateFolderAsync at the root surfaces a missing-or-not-a-folder // error to the caller; deeper recursion silently skips unreadable // subfolders to match the existing tree-walk behavior. - var rootEntriesResult = await fileSystem.EnumerateFolderAsync(Resource); + var rootEntriesResult = await fileStorage.EnumerateFolderAsync(Resource); if (rootEntriesResult.IsFailure) { return Result.Fail($"Resource not found: '{Resource}'") @@ -50,7 +50,7 @@ public override async Task ExecuteAsync() : Resource.ResourceName; var rootNode = await BuildSnapshotAsync( - fileSystem, folderName, rootEntries, Depth, globRegex, TypeFilter); + fileStorage, folderName, rootEntries, Depth, globRegex, TypeFilter); ResultValue = new FileTreeSnapshot(rootNode); return Result.Ok(); @@ -61,7 +61,7 @@ public override async Task ExecuteAsync() // is therefore irrelevant to the result. Subfolder enumeration failures are // swallowed so a single unreadable directory doesn't break the whole tree. private static async Task BuildSnapshotAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, string folderName, IReadOnlyList entries, int remainingDepth, @@ -80,14 +80,14 @@ public override async Task ExecuteAsync() { if (entry.IsFolder) { - var childEntriesResult = await fileSystem.EnumerateFolderAsync(entry.Resource); + var childEntriesResult = await fileStorage.EnumerateFolderAsync(entry.Resource); if (childEntriesResult.IsFailure) { continue; } var childNode = await BuildSnapshotAsync( - fileSystem, entry.Resource.ResourceName, childEntriesResult.Value, + fileStorage, entry.Resource.ResourceName, childEntriesResult.Value, remainingDepth - 1, globRegex, typeFilter); if (childNode is not null && showFolders) { diff --git a/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs index 8d949fff6..e409f87a3 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ListFolderContentsCommand.cs @@ -21,9 +21,9 @@ public ListFolderContentsCommand(IWorkspaceWrapper workspaceWrapper) public override async Task ExecuteAsync() { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var enumerateResult = await fileSystem.EnumerateFolderAsync(Resource); + var enumerateResult = await fileStorage.EnumerateFolderAsync(Resource); if (enumerateResult.IsFailure) { return Result.Fail($"Resource not found: '{Resource}'") diff --git a/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs index 492b721d5..af73418d8 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/MultiEditFileCommand.cs @@ -38,16 +38,16 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"File not found: '{FileResource}'"); } - var readResult = await fileSystem.ReadAllTextAsync(FileResource); + var readResult = await fileStorage.ReadAllTextAsync(FileResource); if (readResult.IsFailure) { return Result.Fail($"Failed to read file: '{FileResource}'") @@ -107,7 +107,7 @@ public override async Task ExecuteAsync() buffer = applyResult.NewContent; } - var writeResult = await fileSystem.WriteAllTextAsync(FileResource, buffer); + var writeResult = await fileStorage.WriteAllTextAsync(FileResource, buffer); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs index 8bf7b9c44..53d115cd5 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs @@ -93,9 +93,9 @@ private async Task WriteReportFileAsync(ProjectCheckReport report) { try { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; var content = FormatReport(report); - var writeResult = await fileSystem.WriteAllTextAsync(ReportFileResource, content); + var writeResult = await fileStorage.WriteAllTextAsync(ReportFileResource, content); if (writeResult.IsFailure) { _logger.LogWarning(writeResult, "Failed to write project check report to '{Resource}'.", ReportFileResource); diff --git a/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs index 228e76ab5..b0024bde9 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ReplaceFileCommand.cs @@ -38,21 +38,21 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"File not found: '{FileResource}'"); } - return await ReplaceOnDisk(fileSystem); + return await ReplaceOnDisk(fileStorage); } - private async Task ReplaceOnDisk(IResourceFileSystem fileSystem) + private async Task ReplaceOnDisk(IFileStorage fileStorage) { - var readResult = await fileSystem.ReadAllTextAsync(FileResource); + var readResult = await fileStorage.ReadAllTextAsync(FileResource); if (readResult.IsFailure) { return Result.Fail($"Failed to read file: '{FileResource}'") @@ -81,7 +81,7 @@ private async Task ReplaceOnDisk(IResourceFileSystem fileSystem) if (replacementCount > 0) { - var writeResult = await fileSystem.WriteAllTextAsync(FileResource, newContent); + var writeResult = await fileStorage.WriteAllTextAsync(FileResource, newContent); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs index bd6d268a3..77aebb76d 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs @@ -56,7 +56,7 @@ private async Task ExecuteExtractAsync() var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; var resourceOpService = workspaceService.ResourceService.OperationService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; if (!ResourceKey.IsValidKey(ArchiveResource)) { @@ -84,9 +84,9 @@ private async Task ExecuteExtractAsync() } var destinationPath = resolveDestinationResult.Value; - var archiveInfoResult = await fileSystem.GetInfoAsync(ArchiveResource); + var archiveInfoResult = await fileStorage.GetInfoAsync(ArchiveResource); if (archiveInfoResult.IsFailure - || archiveInfoResult.Value.Kind != ResourceInfoKind.File) + || archiveInfoResult.Value.Kind != StorageItemKind.File) { return Result.Fail($"Archive not found: '{ArchiveResource}'"); } @@ -96,7 +96,7 @@ private async Task ExecuteExtractAsync() try { - var openArchiveResult = await fileSystem.OpenReadAsync(ArchiveResource); + var openArchiveResult = await fileStorage.OpenReadAsync(ArchiveResource); if (openArchiveResult.IsFailure) { return Result.Fail($"Failed to open archive: '{ArchiveResource}'") @@ -155,9 +155,9 @@ private async Task ExecuteExtractAsync() if (!Overwrite) { var entryResource = DestinationResource.Combine(entryName); - var existingInfoResult = await fileSystem.GetInfoAsync(entryResource); + var existingInfoResult = await fileStorage.GetInfoAsync(entryResource); if (existingInfoResult.IsSuccess - && existingInfoResult.Value.Kind == ResourceInfoKind.File) + && existingInfoResult.Value.Kind == StorageItemKind.File) { return Result.Fail( $"File already exists: '{DestinationResource}/{entryName}'. " + @@ -238,9 +238,9 @@ private async Task ExecuteExtractAsync() if (Overwrite) { var entryResource = DestinationResource.Combine(entry.FullName); - var existingInfoResult = await fileSystem.GetInfoAsync(entryResource); + var existingInfoResult = await fileStorage.GetInfoAsync(entryResource); if (existingInfoResult.IsSuccess - && existingInfoResult.Value.Kind == ResourceInfoKind.File) + && existingInfoResult.Value.Kind == StorageItemKind.File) { var deleteResult = await resourceOpService.DeleteFileAsync(outputPath); if (deleteResult.IsFailure) diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs index 7b47bc1f8..a804116bb 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteBinaryFileCommand.cs @@ -36,9 +36,9 @@ public override async Task ExecuteAsync() } var workspaceService = _workspaceWrapper.WorkspaceService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; - var writeResult = await fileSystem.WriteAllBytesAsync(FileResource, bytes); + var writeResult = await fileStorage.WriteAllBytesAsync(FileResource, bytes); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs index 9d062b796..763278d69 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs @@ -29,22 +29,22 @@ public WriteFileCommand( public override async Task ExecuteAsync() { var workspaceService = _workspaceWrapper.WorkspaceService; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; // Preserve existing line endings when overwriting. For a new file, // honour whatever endings the caller's content already uses (so a CSV // exporter emitting CRLF lands as CRLF on disk); fall back to the // platform default when the content has no line endings to detect. string targetSeparator; - var infoResult = await fileSystem.GetInfoAsync(FileResource); + var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(Content); } else { - var readResult = await fileSystem.ReadAllTextAsync(FileResource); + var readResult = await fileStorage.ReadAllTextAsync(FileResource); if (readResult.IsFailure) { return Result.Fail($"Failed to read existing file: '{FileResource}'") @@ -55,7 +55,7 @@ public override async Task ExecuteAsync() var contentToWrite = LineEndingHelper.ConvertLineEndings(Content, targetSeparator); - var writeResult = await fileSystem.WriteAllTextAsync(FileResource, contentToWrite); + var writeResult = await fileStorage.WriteAllTextAsync(FileResource, contentToWrite); if (writeResult.IsFailure) { return writeResult; diff --git a/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs index e1bda34be..cdfb0520d 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/ArchiveHelper.cs @@ -32,11 +32,11 @@ public static bool IsUnixSymlink(ZipArchiveEntry entry) /// public static async Task AddFileToArchiveAsync( ZipArchive zipArchive, - IResourceFileSystem fileSystem, + IFileStorage fileStorage, ResourceKey sourceResource, string entryName) { - var openResult = await fileSystem.OpenReadAsync(sourceResource); + var openResult = await fileStorage.OpenReadAsync(sourceResource); if (openResult.IsFailure) { return Result.Fail($"Failed to read source file for archive: '{sourceResource}'") diff --git a/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs index a267a3fb1..078f90582 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs @@ -10,7 +10,7 @@ internal static class FileSystemHelper // shell can briefly hold a read handle on the file, which surfaces as an // IOException ("being used by another process") on an immediate File.Move // or Directory.Move. Mirrors the read/write retry budgets in - // ResourceFileSystem; worst-case wait across all attempts is + // FileStorage; worst-case wait across all attempts is // MoveRetryBaseDelayMs * (1 + 2) = 150ms with the values below. private const int MaxMoveAttempts = 3; private const int MoveRetryBaseDelayMs = 50; diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index 0f850b633..02b8da684 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -20,7 +20,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs similarity index 98% rename from Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs rename to Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 18d4ff0fc..2c1dfb1e8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceFileSystem.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -6,7 +6,7 @@ namespace Celbridge.Resources.Services; -public sealed class ResourceFileSystem : IResourceFileSystem +public sealed class FileStorage : IFileStorage { // Bounded retry for transient IO failures (file briefly locked by AV, // backup software, sync clients, concurrent writers, etc.). Total @@ -19,7 +19,7 @@ public sealed class ResourceFileSystem : IResourceFileSystem // FileStream buffer size when none is supplied. private const int StreamBufferSize = 4096; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; @@ -28,8 +28,8 @@ public sealed class ResourceFileSystem : IResourceFileSystem // and only the ResourceService instance has ProjectFolderPath set. The // file-system layer resolves the live registry through the workspace wrapper // at call time. - public ResourceFileSystem( - ILogger logger, + public FileStorage( + ILogger logger, IMessengerService messengerService, IWorkspaceWrapper workspaceWrapper) { @@ -459,14 +459,14 @@ private static IReadOnlyList EnumerateDescendantKeys(IRootHandlerRe return keys; } - public async Task> GetInfoAsync(ResourceKey resource) + public async Task> GetInfoAsync(ResourceKey resource) { await Task.CompletedTask; var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); } var resourcePath = resolveResult.Value; @@ -479,8 +479,8 @@ public async Task> GetInfoAsync(ResourceKey resource) var fileInfo = new FileInfo(resourcePath); if (fileInfo.Exists) { - var fileResult = new ResourceInfo( - Kind: ResourceInfoKind.File, + var fileResult = new StorageItemInfo( + Kind: StorageItemKind.File, Size: fileInfo.Length, ModifiedUtc: fileInfo.LastWriteTimeUtc); return fileResult; @@ -489,22 +489,22 @@ public async Task> GetInfoAsync(ResourceKey resource) var directoryInfo = new DirectoryInfo(resourcePath); if (directoryInfo.Exists) { - var folderResult = new ResourceInfo( - Kind: ResourceInfoKind.Folder, + var folderResult = new StorageItemInfo( + Kind: StorageItemKind.Folder, Size: 0, ModifiedUtc: directoryInfo.LastWriteTimeUtc); return folderResult; } - var notFoundResult = new ResourceInfo( - Kind: ResourceInfoKind.NotFound, + var notFoundResult = new StorageItemInfo( + Kind: StorageItemKind.NotFound, Size: 0, ModifiedUtc: default); return notFoundResult; } catch (Exception ex) { - return Result.Fail($"Failed to get info for resource: '{resource}'") + return Result.Fail($"Failed to get info for resource: '{resource}'") .WithException(ex); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs b/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs index 2336c0d66..bb52e0ccc 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs @@ -12,7 +12,7 @@ public sealed partial record ParsedReference(int StartIndex, int EndIndex, Resou /// /// Shared rules for parsing "project:" reference literals in text. The /// detection pass in and the rewrite cascade in -/// both consume this module so they cannot +/// both consume this module so they cannot /// drift on what constitutes a valid reference. A symmetry test in /// Celbridge.Tests asserts that every position the scanner records is a /// position the rewrite primitive accepts. diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 8426f5944..c4f002e28 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -40,8 +40,8 @@ public ResourceOperationService( private IResourceRegistry? ResourceRegistry => _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.ResourceService.Registry : null; - private IResourceFileSystem? FileSystem => - _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.ResourceFileSystem : null; + private IFileStorage? FileStorage => + _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.FileStorage : null; private string ProjectFolderPath => _workspaceWrapper.IsWorkspacePageLoaded ? ResourceRegistry!.ProjectFolderPath : string.Empty; @@ -116,10 +116,10 @@ public async Task> CopyFileAsync(string sourcePath, string de { return Result.Fail(keyResult); } - var fileSystem = FileSystem; - if (fileSystem is null) + var fileStorage = FileStorage; + if (fileStorage is null) { - return Result.Fail("Workspace is not loaded; resource file system is unavailable."); + return Result.Fail("Workspace is not loaded; file storage is unavailable."); } var operation = new CopyFileOperation( @@ -129,7 +129,7 @@ public async Task> CopyFileAsync(string sourcePath, string de keyResult.Value.Destination, EntityService, ResourceRegistry, - fileSystem); + fileStorage); var execResult = await operation.ExecuteAsync(); if (execResult.IsFailure) @@ -189,10 +189,10 @@ public async Task> MoveFileAsync(string sourcePath, string de { return Result.Fail(keyResult); } - var fileSystem = FileSystem; - if (fileSystem is null) + var fileStorage = FileStorage; + if (fileStorage is null) { - return Result.Fail("Workspace is not loaded; resource file system is unavailable."); + return Result.Fail("Workspace is not loaded; file storage is unavailable."); } var operation = new MoveFileOperation( @@ -202,7 +202,7 @@ public async Task> MoveFileAsync(string sourcePath, string de keyResult.Value.Destination, EntityService, ResourceRegistry, - fileSystem); + fileStorage); var execResult = await operation.ExecuteAsync(); if (execResult.IsFailure) @@ -306,10 +306,10 @@ public async Task> CopyFolderAsync(string sourcePath, string { return Result.Fail(keyResult); } - var fileSystem = FileSystem; - if (fileSystem is null) + var fileStorage = FileStorage; + if (fileStorage is null) { - return Result.Fail("Workspace is not loaded; resource file system is unavailable."); + return Result.Fail("Workspace is not loaded; file storage is unavailable."); } var operation = new CopyFolderOperation( @@ -319,7 +319,7 @@ public async Task> CopyFolderAsync(string sourcePath, string keyResult.Value.Destination, EntityService, ResourceRegistry, - fileSystem); + fileStorage); var execResult = await operation.ExecuteAsync(); if (execResult.IsFailure) @@ -341,10 +341,10 @@ public async Task> MoveFolderAsync(string sourcePath, string { return Result.Fail(keyResult); } - var fileSystem = FileSystem; - if (fileSystem is null) + var fileStorage = FileStorage; + if (fileStorage is null) { - return Result.Fail("Workspace is not loaded; resource file system is unavailable."); + return Result.Fail("Workspace is not loaded; file storage is unavailable."); } var operation = new MoveFolderOperation( @@ -354,7 +354,7 @@ public async Task> MoveFolderAsync(string sourcePath, string keyResult.Value.Destination, EntityService, ResourceRegistry, - fileSystem); + fileStorage); var execResult = await operation.ExecuteAsync(); if (execResult.IsFailure) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index 60b52c5c4..dc257d27c 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -61,7 +61,7 @@ public override async Task UndoAsync() /// /// Undoable copy file operation. The bytes-and-sidecar cascade runs through -/// IResourceFileSystem.CopyAsync; entity-data cascade rides alongside via +/// IFileStorage.CopyAsync; entity-data cascade rides alongside via /// EntityFileHelper. /// internal class CopyFileOperation : FileOperation @@ -71,7 +71,7 @@ internal class CopyFileOperation : FileOperation private readonly ResourceKey _sourceKey; private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - private readonly IResourceFileSystem _fileSystem; + private readonly IFileStorage _fileStorage; public CopyResult? LastCopyResult { get; private set; } @@ -82,21 +82,21 @@ public CopyFileOperation( ResourceKey destKey, IEntityService? entityService, IResourceRegistry? resourceRegistry, - IResourceFileSystem fileSystem) + IFileStorage fileStorage) { _sourcePath = sourcePath; _destPath = destPath; _sourceKey = sourceKey; _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); - _fileSystem = fileSystem; + _fileStorage = fileStorage; } public override async Task ExecuteAsync() { _entityHelper.CopyEntityDataFile(_sourcePath, _destPath); - var copyResult = await _fileSystem.CopyAsync(_sourceKey, _destKey); + var copyResult = await _fileStorage.CopyAsync(_sourceKey, _destKey); if (copyResult.IsFailure) { return Result.Fail(copyResult); @@ -110,7 +110,7 @@ public override async Task UndoAsync() { _entityHelper.DeleteEntityDataFile(_destPath); - var deleteResult = await _fileSystem.DeleteAsync(_destKey); + var deleteResult = await _fileStorage.DeleteAsync(_destKey); if (deleteResult.IsFailure) { return Result.Fail(deleteResult); @@ -122,7 +122,7 @@ public override async Task UndoAsync() /// /// Undoable move file operation. Bytes, reference rewrites, and sidecar cascade -/// run through IResourceFileSystem.MoveAsync; the inverse re-walks the reference +/// run through IFileStorage.MoveAsync; the inverse re-walks the reference /// graph in the opposite direction so undo restores references too. /// internal class MoveFileOperation : FileOperation @@ -132,7 +132,7 @@ internal class MoveFileOperation : FileOperation private readonly ResourceKey _sourceKey; private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - private readonly IResourceFileSystem _fileSystem; + private readonly IFileStorage _fileStorage; public MoveResult? LastMoveResult { get; private set; } @@ -143,14 +143,14 @@ public MoveFileOperation( ResourceKey destKey, IEntityService? entityService, IResourceRegistry? resourceRegistry, - IResourceFileSystem fileSystem) + IFileStorage fileStorage) { _sourcePath = sourcePath; _destPath = destPath; _sourceKey = sourceKey; _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); - _fileSystem = fileSystem; + _fileStorage = fileStorage; } public override async Task ExecuteAsync() @@ -159,7 +159,7 @@ public override async Task ExecuteAsync() // still resolves while EntityFileHelper computes the destination key. _entityHelper.MoveEntityDataFile(_sourcePath, _destPath); - var moveResult = await _fileSystem.MoveAsync(_sourceKey, _destKey); + var moveResult = await _fileStorage.MoveAsync(_sourceKey, _destKey); if (moveResult.IsFailure) { return Result.Fail(moveResult); @@ -173,7 +173,7 @@ public override async Task UndoAsync() { _entityHelper.MoveEntityDataFile(_destPath, _sourcePath); - var moveResult = await _fileSystem.MoveAsync(_destKey, _sourceKey); + var moveResult = await _fileStorage.MoveAsync(_destKey, _sourceKey); if (moveResult.IsFailure) { return Result.Fail(moveResult); @@ -346,7 +346,7 @@ public void CleanupTrashFile() /// /// Undoable copy folder operation. Bytes-and-sidecar cascade runs through -/// IResourceFileSystem.CopyAsync; entity-data cascade rides alongside via +/// IFileStorage.CopyAsync; entity-data cascade rides alongside via /// EntityFileHelper. /// internal class CopyFolderOperation : FileOperation @@ -356,7 +356,7 @@ internal class CopyFolderOperation : FileOperation private readonly ResourceKey _sourceKey; private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - private readonly IResourceFileSystem _fileSystem; + private readonly IFileStorage _fileStorage; public CopyResult? LastCopyResult { get; private set; } @@ -367,19 +367,19 @@ public CopyFolderOperation( ResourceKey destKey, IEntityService? entityService, IResourceRegistry? resourceRegistry, - IResourceFileSystem fileSystem) + IFileStorage fileStorage) { _sourcePath = sourcePath; _destPath = destPath; _sourceKey = sourceKey; _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); - _fileSystem = fileSystem; + _fileStorage = fileStorage; } public override async Task ExecuteAsync() { - var copyResult = await _fileSystem.CopyAsync(_sourceKey, _destKey); + var copyResult = await _fileStorage.CopyAsync(_sourceKey, _destKey); if (copyResult.IsFailure) { return Result.Fail(copyResult); @@ -395,7 +395,7 @@ public override async Task UndoAsync() { _entityHelper.DeleteFolderEntityDataFiles(_destPath); - var deleteResult = await _fileSystem.DeleteAsync(_destKey); + var deleteResult = await _fileStorage.DeleteAsync(_destKey); if (deleteResult.IsFailure) { return Result.Fail(deleteResult); @@ -407,7 +407,7 @@ public override async Task UndoAsync() /// /// Undoable move folder operation. Bytes, reference rewrites, and sidecar -/// cascade run through IResourceFileSystem.MoveAsync; the inverse re-walks the +/// cascade run through IFileStorage.MoveAsync; the inverse re-walks the /// reference graph in the opposite direction. /// internal class MoveFolderOperation : FileOperation @@ -417,7 +417,7 @@ internal class MoveFolderOperation : FileOperation private readonly ResourceKey _sourceKey; private readonly ResourceKey _destKey; private readonly EntityFileHelper _entityHelper; - private readonly IResourceFileSystem _fileSystem; + private readonly IFileStorage _fileStorage; public MoveResult? LastMoveResult { get; private set; } @@ -428,14 +428,14 @@ public MoveFolderOperation( ResourceKey destKey, IEntityService? entityService, IResourceRegistry? resourceRegistry, - IResourceFileSystem fileSystem) + IFileStorage fileStorage) { _sourcePath = sourcePath; _destPath = destPath; _sourceKey = sourceKey; _destKey = destKey; _entityHelper = new EntityFileHelper(entityService, resourceRegistry); - _fileSystem = fileSystem; + _fileStorage = fileStorage; } public override async Task ExecuteAsync() @@ -443,7 +443,7 @@ public override async Task ExecuteAsync() // Move entity data files first (while source folder still exists for enumeration). _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destPath); - var moveResult = await _fileSystem.MoveAsync(_sourceKey, _destKey); + var moveResult = await _fileStorage.MoveAsync(_sourceKey, _destKey); if (moveResult.IsFailure) { return Result.Fail(moveResult); @@ -458,7 +458,7 @@ public override async Task UndoAsync() // Move entity data files back first (while dest folder still exists for enumeration). _entityHelper.MoveFolderEntityDataFiles(_destPath, _sourcePath); - var moveResult = await _fileSystem.MoveAsync(_destKey, _sourceKey); + var moveResult = await _fileStorage.MoveAsync(_destKey, _sourceKey); if (moveResult.IsFailure) { return Result.Fail(moveResult); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs index 6efa3ce71..493cd713b 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -10,7 +10,7 @@ namespace Celbridge.Resources.Services; /// One-shot, stateless on-demand scanner over the project's text files. The /// rename cascade, ProjectCheckCommand, and the data_find_tag tool all consume /// the same instance. Each call walks the registry's known files in parallel -/// via IResourceFileSystem; the OS page cache absorbs repeated reads. No +/// via IFileStorage; the OS page cache absorbs repeated reads. No /// in-memory index, no persistent cache. /// public sealed class ResourceScanner : IResourceScanner @@ -160,13 +160,13 @@ await EnumerateProjectSidecarFilesAsync(async (sidecarKey, parentKey) => .ToList(); } - // Reads a file through IResourceFileSystem so atomic-read + retry semantics + // Reads a file through IFileStorage so atomic-read + retry semantics // apply uniformly. Returns null on any read failure; the caller treats // unreadable files as empty (they simply don't contribute matches). private async Task ReadFileTextAsync(ResourceKey resource) { - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var readResult = await fileSystem.ReadAllTextAsync(resource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var readResult = await fileStorage.ReadAllTextAsync(resource); if (readResult.IsFailure) { _logger.LogDebug($"scanner: read failed for {resource} ({readResult.FirstErrorMessage})"); @@ -260,7 +260,7 @@ await Parallel.ForEachAsync(files, async (file, _) => private async Task EnumerateProjectSidecarFilesAsync(Func visit) { var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; var files = registry.GetAllFileResources(ResourceKey.DefaultRoot); await Parallel.ForEachAsync(files, async (file, _) => @@ -277,9 +277,9 @@ await Parallel.ForEachAsync(files, async (file, _) => return; } - var infoResult = await fileSystem.GetInfoAsync(parentKey.Value); + var infoResult = await fileStorage.GetInfoAsync(parentKey.Value); if (infoResult.IsFailure - || infoResult.Value.Kind == ResourceInfoKind.NotFound) + || infoResult.Value.Kind == StorageItemKind.NotFound) { return; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs index dc8d74a74..fdd813fb8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceTransferService.cs @@ -57,10 +57,10 @@ private async Task>> CreateResourceTransferIte } var destFolderPath = resolveResult.Value; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var destInfoResult = await fileSystem.GetInfoAsync(destFolderResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var destInfoResult = await fileStorage.GetInfoAsync(destFolderResource); if (destInfoResult.IsFailure - || destInfoResult.Value.Kind != ResourceInfoKind.Folder) + || destInfoResult.Value.Kind != StorageItemKind.Folder) { return Result>.Fail($"The path '{destFolderPath}' does not exist."); } diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs index 7b8d5241b..b65e41bf3 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -55,16 +55,16 @@ public async Task> ReadAsync(ResourceKey resource) } var sidecarKey = resolveResult.Value; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var infoResult = await fileSystem.GetInfoAsync(sidecarKey); + var infoResult = await fileStorage.GetInfoAsync(sidecarKey); if (infoResult.IsFailure - || infoResult.Value.Kind == ResourceInfoKind.NotFound) + || infoResult.Value.Kind == StorageItemKind.NotFound) { return Result.Ok(new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)); } - var readResult = await fileSystem.ReadAllTextAsync(sidecarKey); + var readResult = await fileStorage.ReadAllTextAsync(sidecarKey); if (readResult.IsFailure) { return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Broken, null, readResult.FirstErrorMessage)); @@ -318,8 +318,8 @@ private async Task ApplyMutationAsync( return Result.Ok(); } - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var writeResult = await fileSystem.WriteAllTextAsync(sidecarKey, canonicalAfter); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var writeResult = await fileStorage.WriteAllTextAsync(sidecarKey, canonicalAfter); if (writeResult.IsFailure) { return Result.Fail($"Failed to write sidecar '{sidecarKey}'.") diff --git a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs index 77f52d805..01cb13d17 100644 --- a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs +++ b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs @@ -28,11 +28,11 @@ public FileFilter(ITextBinarySniffer textBinarySniffer) /// through the chokepoint so the size check honours the same containment /// validation as the read that follows. /// - public async Task ShouldSearchFileAsync(IResourceFileSystem fileSystem, ResourceKey resource, string filePath) + public async Task ShouldSearchFileAsync(IFileStorage fileStorage, ResourceKey resource, string filePath) { - var infoResult = await fileSystem.GetInfoAsync(resource); + var infoResult = await fileStorage.GetInfoAsync(resource); if (infoResult.IsFailure - || infoResult.Value.Kind != ResourceInfoKind.File) + || infoResult.Value.Kind != StorageItemKind.File) { return false; } diff --git a/Source/Workspace/Celbridge.Search/Services/SearchService.cs b/Source/Workspace/Celbridge.Search/Services/SearchService.cs index 891baf8dd..9a996b0a9 100644 --- a/Source/Workspace/Celbridge.Search/Services/SearchService.cs +++ b/Source/Workspace/Celbridge.Search/Services/SearchService.cs @@ -65,7 +65,7 @@ public async Task SearchAsync( } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; var projectFolder = resourceRegistry.ProjectFolderPath; if (string.IsNullOrEmpty(projectFolder)) @@ -161,7 +161,7 @@ public async Task SearchAsync( : int.MaxValue; var fileResult = await SearchFileAsync( - fileSystem, + fileStorage, filePath, projectFolder, resource, @@ -192,7 +192,7 @@ public async Task SearchAsync( } private async Task SearchFileAsync( - IResourceFileSystem fileSystem, + IFileStorage fileStorage, string filePath, string rootDirectory, ResourceKey resourceKey, @@ -206,7 +206,7 @@ public async Task SearchAsync( try { // Check if file should be searched (size, extension filters) - if (!await _fileFilter.ShouldSearchFileAsync(fileSystem, resourceKey, filePath)) + if (!await _fileFilter.ShouldSearchFileAsync(fileStorage, resourceKey, filePath)) { return null; } @@ -220,7 +220,7 @@ public async Task SearchAsync( // Stream the file via the chokepoint so reads pick up the same // containment validation as writes and large files do not load // fully into memory. - var openResult = await fileSystem.OpenReadAsync(resourceKey); + var openResult = await fileStorage.OpenReadAsync(resourceKey); if (openResult.IsFailure) { return null; @@ -310,7 +310,7 @@ public async Task ReplaceInFileAsync( var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var resolveReplaceResult = resourceRegistry.ResolveResourcePath(resource); if (resolveReplaceResult.IsFailure) { @@ -323,7 +323,7 @@ public async Task ReplaceInFileAsync( return await ReplaceInFileAsync( resource, filePath, - fileSystem, + fileStorage, searchText, replaceText, matchCase, @@ -349,7 +349,7 @@ public async Task ReplaceInFileAsync( private async Task ReplaceInFileAsync( ResourceKey resource, string filePath, - IResourceFileSystem fileSystem, + IFileStorage fileStorage, string searchText, string replaceText, bool matchCase, @@ -358,7 +358,7 @@ private async Task ReplaceInFileAsync( { cancellationToken.ThrowIfCancellationRequested(); - var readResult = await fileSystem.ReadAllTextAsync(resource); + var readResult = await fileStorage.ReadAllTextAsync(resource); if (readResult.IsFailure) { return new ReplaceResult(false, 0); @@ -377,7 +377,7 @@ private async Task ReplaceInFileAsync( return new ReplaceResult(true, 0); } - var writeResult = await fileSystem.WriteAllTextAsync(resource, newContent); + var writeResult = await fileStorage.WriteAllTextAsync(resource, newContent); if (writeResult.IsFailure) { return new ReplaceResult(false, 0); @@ -408,7 +408,7 @@ public async Task ReplaceMatchAsync( var workspaceService = _workspaceWrapper.WorkspaceService; var resourceRegistry = workspaceService.ResourceService.Registry; - var fileSystem = workspaceService.ResourceFileSystem; + var fileStorage = workspaceService.FileStorage; var resolveMatchResult = resourceRegistry.ResolveResourcePath(resource); if (resolveMatchResult.IsFailure) { @@ -421,7 +421,7 @@ public async Task ReplaceMatchAsync( return await ReplaceMatchAsync( resource, filePath, - fileSystem, + fileStorage, searchText, replaceText, lineNumber, @@ -449,7 +449,7 @@ public async Task ReplaceMatchAsync( private async Task ReplaceMatchAsync( ResourceKey resource, string filePath, - IResourceFileSystem fileSystem, + IFileStorage fileStorage, string searchText, string replaceText, int lineNumber, @@ -460,7 +460,7 @@ private async Task ReplaceMatchAsync( { cancellationToken.ThrowIfCancellationRequested(); - var readResult = await fileSystem.ReadAllTextAsync(resource); + var readResult = await fileStorage.ReadAllTextAsync(resource); if (readResult.IsFailure) { return new ReplaceMatchResult(false); @@ -481,7 +481,7 @@ private async Task ReplaceMatchAsync( return new ReplaceMatchResult(false); } - var writeResult = await fileSystem.WriteAllTextAsync(resource, newContent); + var writeResult = await fileStorage.WriteAllTextAsync(resource, newContent); if (writeResult.IsFailure) { return new ReplaceMatchResult(false); diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs index 84a81d6b0..b5954e82c 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs @@ -116,10 +116,10 @@ public async Task> GetClipboardResourceTransfer(Resour } var destFolderPath = resolveResult.Value; - var fileSystem = _workspaceWrapper.WorkspaceService.ResourceFileSystem; - var destInfoResult = await fileSystem.GetInfoAsync(destFolderResource); + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var destInfoResult = await fileStorage.GetInfoAsync(destFolderResource); if (destInfoResult.IsFailure - || destInfoResult.Value.Kind != ResourceInfoKind.Folder) + || destInfoResult.Value.Kind != StorageItemKind.Folder) { return Result.Fail($"The path '{destFolderPath}' does not exist."); } diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index 582261d4a..3ccbd3262 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -23,7 +23,7 @@ public class WorkspaceService : IWorkspaceService, IDisposable public IWorkspaceSettings WorkspaceSettings => WorkspaceSettingsService.WorkspaceSettings!; public IPackageService PackageService { get; } public IResourceService ResourceService { get; } - public IResourceFileSystem ResourceFileSystem { get; } + public IFileStorage FileStorage { get; } public IResourceScanner ResourceScanner { get; } public ISidecarService SidecarService { get; } public IExplorerService ExplorerService { get; } @@ -60,7 +60,7 @@ public WorkspaceService( WorkspaceSettingsService = serviceProvider.GetRequiredService(); PackageService = serviceProvider.GetRequiredService(); ResourceService = serviceProvider.GetRequiredService(); - ResourceFileSystem = serviceProvider.GetRequiredService(); + FileStorage = serviceProvider.GetRequiredService(); ResourceScanner = serviceProvider.GetRequiredService(); SidecarService = serviceProvider.GetRequiredService(); ExplorerService = serviceProvider.GetRequiredService(); From c973f3a7a458fd199d07639b7e21e722e6954c1a Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 14:34:52 +0100 Subject: [PATCH 30/48] Rename ReferenceLiteralRules to ResourceReferenceParser Rename the ReferenceLiteralRules module to ResourceReferenceParser and update all call sites. FileStorage and ResourceScanner now reference ResourceReferenceParser.ReferenceMarker, IsNonKeyBoundary, and TryParseReferenceAt. Comments and XML docs were rewritten for clarity; parsing behavior and algorithms are unchanged (only naming and wording refinements). --- .../Services/FileStorage.cs | 6 +- ...ralRules.cs => ResourceReferenceParser.cs} | 69 +++++++------------ .../Services/ResourceScanner.cs | 10 +-- 3 files changed, 33 insertions(+), 52 deletions(-) rename Source/Workspace/Celbridge.Resources/Services/{ReferenceLiteralRules.cs => ResourceReferenceParser.cs} (53%) diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 2c1dfb1e8..3a4c85ef9 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -740,7 +740,7 @@ private bool IsReferencerReadOnly(ResourceKey referencer) } // Replaces every quoted occurrence of sourceLiteral with destLiteral. The - // boundary check (ReferenceLiteralRules.IsNonKeyBoundary on the bytes + // boundary check (ResourceReferenceParser.IsNonKeyBoundary on the bytes // immediately before and after the match) keeps incidental substring // matches untouched — only the canonical quoted form gets rewritten. // @@ -777,9 +777,9 @@ private static string RewriteReferenceLiterals(string text, string sourceLiteral int afterMatch = matchIndex + sourceLiteral.Length; bool leadingOk = matchIndex > 0 - && ReferenceLiteralRules.IsNonKeyBoundary(text[matchIndex - 1]); + && ResourceReferenceParser.IsNonKeyBoundary(text[matchIndex - 1]); bool trailingExact = afterMatch < text.Length - && ReferenceLiteralRules.IsNonKeyBoundary(text[afterMatch]); + && ResourceReferenceParser.IsNonKeyBoundary(text[afterMatch]); bool trailingFolderPrefix = sourceIsFolder && afterMatch < text.Length && text[afterMatch] == '/'; diff --git a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs similarity index 53% rename from Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs rename to Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs index bb52e0ccc..4e74942dd 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ReferenceLiteralRules.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs @@ -3,40 +3,31 @@ namespace Celbridge.Resources.Services; /// -/// One reference parse result: the half-open byte range [StartIndex, EndIndex) -/// in the original text that holds the reference literal, plus the validated -/// resource key it encodes. +/// Result of parsing one reference literal: its position in the source text +/// (half-open range) and the resource key it encodes. /// public sealed partial record ParsedReference(int StartIndex, int EndIndex, ResourceKey Key); /// -/// Shared rules for parsing "project:" reference literals in text. The -/// detection pass in and the rewrite cascade in -/// both consume this module so they cannot -/// drift on what constitutes a valid reference. A symmetry test in -/// Celbridge.Tests asserts that every position the scanner records is a -/// position the rewrite primitive accepts. +/// Parser for "project:" reference literals. Shared by +/// (detection) and +/// (rewrite cascade) so the two paths stay in sync on what counts as a +/// valid reference. /// -public static class ReferenceLiteralRules +public static class ResourceReferenceParser { /// - /// The literal that marks the start of a reference. Always the bytes of the - /// default-root prefix; non-project: roots are not tracked. + /// The literal that marks the start of a reference. /// public const string ReferenceMarker = "project:"; - // Single-character openers that enter delimited-scan mode. Per the agreed - // design (Option C in the resources redesign), references must always be - // wrapped in ASCII double or single quotes — there is no bare-prose form - // of a reference. A "project:" marker not preceded by an opener is not a - // tracked reference, even if it parses as a valid ResourceKey. + // References must always be quoted; a bare "project:" marker is not a + // tracked reference even if the key syntax parses cleanly. private static readonly char[] SingleCharOpeners = { '"', '\'' }; - // Two-character openers — the escaped-quote forms used by JSON, TOML basic - // strings, and every C-family string literal. The closer is the same - // two-char sequence. These take precedence over the single-char openers - // (checked first), so a "project:" preceded by \" is treated as the - // escaped-quote case, not the plain-quote case. + // Escaped-quote openers (\" and \'). Take precedence over single-char + // openers, so `\"project:..\"` parses as the escaped-quote case rather + // than as a plain quote preceded by a backslash. private static readonly (char First, char Second)[] EscapedQuoteOpeners = { ('\\', '"'), @@ -44,14 +35,9 @@ private static readonly (char First, char Second)[] EscapedQuoteOpeners = }; /// - /// Returns true if the character can legitimately sit immediately before - /// or after a tracked reference literal. Only the characters that wrap a - /// quoted or escaped-quoted reference qualify: - /// '"' / '\'' — the single-char openers and closers. - /// '\\' — the first char of a \" or \' escape closer. - /// Other characters (whitespace, brackets, parens, etc.) are NOT boundaries, - /// because references must always be quoted — anything not adjacent to a - /// quote is not a reference by definition. + /// Returns true if the character can sit immediately adjacent to a + /// tracked reference — one of the quote forms, or the leading backslash + /// of an escaped quote. /// public static bool IsNonKeyBoundary(char c) { @@ -67,10 +53,10 @@ public static bool IsNonKeyBoundary(char c) } /// - /// Attempts to parse a single reference at the given marker position. The - /// marker index must point at the 'p' of a "project:" literal in the text. - /// Returns null if no valid ResourceKey can be extracted (invalid key - /// syntax, unterminated delimited region, empty key, etc.). + /// Attempts to parse a single reference at the given marker position. + /// must point at the 'p' of a "project:" + /// literal in the text; returns null if the surrounding quoted region is + /// malformed or the key does not parse. /// public static ParsedReference? TryParseReferenceAt(string text, int markerIndex) { @@ -101,8 +87,7 @@ public static bool IsNonKeyBoundary(char c) keyEnd = ScanForSingleCharCloser(text, keyStart, closer); } - // No bare fallback: a marker without a preceding opener is not a - // tracked reference. References must always be quoted. + // No bare fallback: an unquoted marker is not a tracked reference. if (keyEnd < 0 || keyEnd <= keyStart) { @@ -118,9 +103,8 @@ public static bool IsNonKeyBoundary(char c) return new ParsedReference(markerIndex, keyEnd, key); } - // Walks until the matching closing delimiter and returns its index (the - // end-exclusive boundary of the key). Returns -1 if the region is - // unterminated — newline, control char, or end-of-text reached first. + // Returns the closer's index (end-exclusive) or -1 on newline, control + // char, or end-of-text. private static int ScanForSingleCharCloser(string text, int start, char closer) { int cursor = start; @@ -142,10 +126,8 @@ private static int ScanForSingleCharCloser(string text, int start, char closer) return -1; } - // Walks until the two-char closing sequence and returns the index of its - // first character (the end-exclusive boundary of the key). Returns -1 if - // the region is unterminated. Used for the escaped-quote case where a - // literal \" or \' both opens and closes the delimited region. + // Returns the index of the closer's first character (end-exclusive) or + // -1 on newline, control char, or end-of-text. private static int ScanForTwoCharCloser(string text, int start, char first, char second) { int cursor = start; @@ -168,5 +150,4 @@ private static int ScanForTwoCharCloser(string text, int start, char first, char } return -1; } - } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs index 493cd713b..79fefb92e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -176,11 +176,11 @@ await EnumerateProjectSidecarFilesAsync(async (sidecarKey, parentKey) => } // True when `text` contains a tracked "project:" reference. The - // boundary rules in ReferenceLiteralRules constrain the match to canonical + // boundary rules in ResourceReferenceParser constrain the match to canonical // quoted forms. private static bool ContainsReferenceTo(string text, ResourceKey target) { - var marker = ReferenceLiteralRules.ReferenceMarker; + var marker = ResourceReferenceParser.ReferenceMarker; int searchStart = 0; while (true) { @@ -190,7 +190,7 @@ private static bool ContainsReferenceTo(string text, ResourceKey target) return false; } - var parsed = ReferenceLiteralRules.TryParseReferenceAt(text, markerIndex); + var parsed = ResourceReferenceParser.TryParseReferenceAt(text, markerIndex); if (parsed is not null && parsed.Key.Equals(target)) { @@ -205,7 +205,7 @@ private static bool ContainsReferenceTo(string text, ResourceKey target) private static HashSet ScanReferences(string text) { var references = new HashSet(); - var marker = ReferenceLiteralRules.ReferenceMarker; + var marker = ResourceReferenceParser.ReferenceMarker; int searchStart = 0; while (true) { @@ -215,7 +215,7 @@ private static HashSet ScanReferences(string text) break; } - var parsed = ReferenceLiteralRules.TryParseReferenceAt(text, markerIndex); + var parsed = ResourceReferenceParser.TryParseReferenceAt(text, markerIndex); if (parsed is not null) { references.Add(parsed.Key); From cb5b7441a7378ee21af8c924e50a543bf0a1fd3a Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 15:20:34 +0100 Subject: [PATCH 31/48] Add ComputeHash and remove FileAccessHelper Introduce IFileStorage.ComputeHashAsync and implement it in FileStorage to return a SHA256 hex/base64 digest via FileHashHelper. Remove the FileAccessHelper class and replace its usage with direct FileStorage.GetInfoAsync/Open/ComputeHashAsync calls. Update DocumentLayoutStore, DocumentsService, DocumentViewModel, ConsolePanelViewModel and tests to use the new ComputeHashAsync or GetInfoAsync checks, switch to string-based hash tracking and comparisons, and remove direct SHA256 byte-hashing and related using directives. Also clarify FileHashHelper documentation to distinguish hashing of external file paths vs. resource-backed reads. --- .../Resources/IFileStorage.cs | 9 +++ .../Helpers/FileHashHelper.cs | 7 +- .../Documents/DocumentLayoutStoreTests.cs | 12 +-- .../ViewModels/ConsolePanelViewModel.cs | 19 +++-- .../Helpers/FileAccessHelper.cs | 75 ------------------- .../Services/DocumentLayoutStore.cs | 15 ++-- .../Services/DocumentsService.cs | 14 ++-- .../ViewModels/DocumentViewModel.cs | 30 +++----- .../Services/FileStorage.cs | 13 ++++ 9 files changed, 64 insertions(+), 130 deletions(-) delete mode 100644 Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs index c3f3dd3aa..9ab166524 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs @@ -183,4 +183,13 @@ public interface IFileStorage /// Fails when the resource does not resolve to an existing folder. /// Task>> EnumerateFolderAsync(ResourceKey folder); + + /// + /// Reads the resource and returns a SHA256 hex digest of its bytes. Use this + /// when the caller only needs the hash (e.g. external-change detection) and + /// would otherwise read bytes purely to feed them through SHA256. Callers + /// that already have the bytes in hand should hash directly via + /// FileHashHelper.HashBytes rather than re-reading through this method. + /// + Task> ComputeHashAsync(ResourceKey resource); } diff --git a/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs index 4c09591a5..fe071ea2e 100644 --- a/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs +++ b/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs @@ -11,8 +11,11 @@ namespace Celbridge.Utilities; public static class FileHashHelper { /// - /// Computes a SHA256 hash of a file's contents. Returns empty string - /// if the file doesn't exist or can't be read. + /// Computes a SHA256 hash of a file's contents by reading the path directly. + /// Intended for files that live outside the resource system (e.g. the Python + /// install folder); resource-tracked files should hash via + /// IFileStorage.ComputeHashAsync so the read goes through the chokepoint. + /// Returns empty string if the file doesn't exist or can't be read. /// public static string HashFileContents(string filePath) { diff --git a/Source/Tests/Documents/DocumentLayoutStoreTests.cs b/Source/Tests/Documents/DocumentLayoutStoreTests.cs index 3b62f934b..cdf203171 100644 --- a/Source/Tests/Documents/DocumentLayoutStoreTests.cs +++ b/Source/Tests/Documents/DocumentLayoutStoreTests.cs @@ -1,5 +1,4 @@ using Celbridge.Commands; -using Celbridge.Documents.Helpers; using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Services; @@ -21,7 +20,6 @@ public class DocumentLayoutStoreTests private IDocumentsPanel _documentsPanel = null!; private ICommandService _commandService = null!; private IWorkspaceWrapper _workspaceWrapper = null!; - private FileAccessHelper _fileAccessHelper = null!; private DocumentLayoutStore _store = null!; private string _tempFolder = null!; private string _accessibleFilePath = null!; @@ -63,21 +61,17 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - // Wire a real FileStorage so FileAccessHelper's GetInfoAsync / - // OpenReadAsync calls probe the actual disk paths the registry - // resolves to. + // Wire a real FileStorage so GetInfoAsync probes the actual disk + // paths the registry resolves to. var fileStorage = new FileStorage( Substitute.For>(), Substitute.For(), _workspaceWrapper); workspaceService.FileStorage.Returns(fileStorage); - _fileAccessHelper = new FileAccessHelper(_workspaceWrapper); - _store = new DocumentLayoutStore( _workspaceWrapper, _commandService, - _fileAccessHelper, Substitute.For>()); } @@ -207,7 +201,7 @@ public async Task RestorePanelStateAsync_MissingResource_IsSkipped() public async Task RestorePanelStateAsync_InaccessibleFile_IsSkipped() { // ResolveResourcePath returns a path that does not exist on disk. - // FileAccessHelper.CanAccessFile rejects it; the restore skips. + // FileStorage.GetInfoAsync reports NotFound; the restore skips. var missingPath = Path.Combine(_tempFolder, "does_not_exist.md"); _resourceRegistry.ResolveResourcePath(Arg.Any()) .Returns(Result.Ok(missingPath)); diff --git a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs index f4cc13e48..77c6c7999 100644 --- a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs +++ b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using Celbridge.Commands; using Celbridge.Messaging; using Celbridge.Projects; @@ -81,7 +80,7 @@ private record LogEntryException(string Type, string Message, string StackTrace) /// public bool IsMaximizeButtonHighlighted => IsConsoleMaximized; - private byte[]? _originalProjectFileHash = null; + private string? _originalProjectFileHash = null; public ConsolePanelViewModel( IServiceProvider serviceProvider, @@ -297,14 +296,14 @@ private async Task StoreProjectFileHashAsync() } var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var readResult = await fileStorage.ReadAllBytesAsync(projectFileResource); - if (readResult.IsFailure) + var hashResult = await fileStorage.ComputeHashAsync(projectFileResource); + if (hashResult.IsFailure) { _originalProjectFileHash = null; return; } - _originalProjectFileHash = SHA256.HashData(readResult.Value); + _originalProjectFileHash = hashResult.Value; } private async Task CheckProjectFileChangedAsync() @@ -315,15 +314,15 @@ private async Task CheckProjectFileChangedAsync() } var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var readResult = await fileStorage.ReadAllBytesAsync(projectFileResource); - if (readResult.IsFailure) + var hashResult = await fileStorage.ComputeHashAsync(projectFileResource); + if (hashResult.IsFailure) { // If we can't read the file, hide the banner IsProjectChangeBannerVisible = false; return; } - var currentHash = SHA256.HashData(readResult.Value); + var currentHash = hashResult.Value; // If error banner is visible, don't show the project change banner if (IsErrorBannerVisible) @@ -333,8 +332,8 @@ private async Task CheckProjectFileChangedAsync() } // Check if the hash has changed from the original - if (_originalProjectFileHash == null - || !currentHash.SequenceEqual(_originalProjectFileHash)) + if (_originalProjectFileHash is null + || !string.Equals(currentHash, _originalProjectFileHash, StringComparison.Ordinal)) { // Populate the project change banner strings ProjectChangeBannerTitle = _stringLocalizer.GetString("ConsolePanel_ProjectChangeBannerTitle"); diff --git a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs b/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs deleted file mode 100644 index 3645c7065..000000000 --- a/Source/Workspace/Celbridge.Documents/Helpers/FileAccessHelper.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Celbridge.Workspace; - -namespace Celbridge.Documents.Helpers; - -/// -/// Resolves resource keys to backing file paths and verifies that the file -/// exists and is readable. Used by the documents subsystem to gate opens and -/// restores on access checks without scattering File.IO calls. -/// -public class FileAccessHelper -{ - private readonly IWorkspaceWrapper _workspaceWrapper; - - public FileAccessHelper(IWorkspaceWrapper workspaceWrapper) - { - _workspaceWrapper = workspaceWrapper; - } - - /// - /// True when the resource key resolves to an existing file that can be - /// opened for shared read access. Returns false for missing files or - /// access-denied conditions. - /// - public async Task CanAccessFileAsync(ResourceKey fileResource) - { - var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - - var infoResult = await fileStorage.GetInfoAsync(fileResource); - if (infoResult.IsFailure - || infoResult.Value.Kind != StorageItemKind.File) - { - return false; - } - - var openResult = await fileStorage.OpenReadAsync(fileResource); - if (openResult.IsFailure) - { - return false; - } - openResult.Value.Dispose(); - return true; - } - - /// - /// Resolves a resource key to its backing path and verifies the file - /// exists and is readable. Returns the resolved path on success. - /// - public async Task> ResolveAndValidateFilePathAsync(ResourceKey fileResource) - { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult); - } - var filePath = resolveResult.Value; - - var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var infoResult = await fileStorage.GetInfoAsync(fileResource); - if (infoResult.IsFailure - || infoResult.Value.Kind != StorageItemKind.File) - { - return Result.Fail($"File path does not exist: '{filePath}'"); - } - - if (!await CanAccessFileAsync(fileResource)) - { - return Result.Fail($"File exists but cannot be opened: '{filePath}'"); - } - - return filePath; - } -} diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs index 89d22c631..ac96a20d6 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs @@ -1,5 +1,4 @@ using Celbridge.Commands; -using Celbridge.Documents.Helpers; using Celbridge.Logging; using Celbridge.Resources; using Celbridge.Workspace; @@ -21,7 +20,6 @@ public class DocumentLayoutStore private readonly IWorkspaceWrapper _workspaceWrapper; private readonly ICommandService _commandService; - private readonly FileAccessHelper _fileAccessHelper; private readonly ILogger _logger; private IDocumentsPanel DocumentsPanel => _workspaceWrapper.WorkspaceService.DocumentsPanel; @@ -29,12 +27,10 @@ public class DocumentLayoutStore public DocumentLayoutStore( IWorkspaceWrapper workspaceWrapper, ICommandService commandService, - FileAccessHelper fileAccessHelper, ILogger logger) { _workspaceWrapper = workspaceWrapper; _commandService = commandService; - _fileAccessHelper = fileAccessHelper; _logger = logger; } @@ -252,9 +248,11 @@ private async Task RestoreDocumentsAsync( _logger.LogWarning(resolveResult, $"Failed to resolve path for resource: '{fileResource}'"); continue; } - var filePath = resolveResult.Value; - if (!await _fileAccessHelper.CanAccessFileAsync(fileResource)) + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(fileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { _logger.LogWarning($"Cannot access file for resource: '{fileResource}'"); continue; @@ -331,7 +329,10 @@ private async Task OpenDefaultReadmeAsync() } var normalizedResource = normalizeResult.Value; - if (!await _fileAccessHelper.CanAccessFileAsync(normalizedResource)) + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(normalizedResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { return; } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index 150d5388c..5f9a6ecbb 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -23,7 +23,6 @@ public class DocumentsService : IDocumentsService, IDisposable private readonly FileTypeHelper _fileTypeHelper; private readonly DocumentEditorRegistry _documentEditorRegistry; private readonly FileTypeClassifier _fileTypeClassifier; - private readonly FileAccessHelper _fileAccessHelper; private readonly DocumentEditorPreferenceStore _preferenceStore; private readonly DocumentViewFactory _viewFactory; private readonly DocumentLayoutStore _layoutStore; @@ -105,8 +104,6 @@ public DocumentsService( _workspaceWrapper, _documentEditorRegistry); - _fileAccessHelper = new FileAccessHelper(_workspaceWrapper); - _preferenceStore = new DocumentEditorPreferenceStore( _workspaceWrapper, serviceProvider.GetRequiredService>()); @@ -124,7 +121,6 @@ public DocumentsService( _layoutStore = new DocumentLayoutStore( _workspaceWrapper, _commandService, - _fileAccessHelper, serviceProvider.GetRequiredService>()); } @@ -280,11 +276,13 @@ public Task SetEditorPreferenceAsync(string extension, DocumentEditorId editorId public async Task> OpenDocument(ResourceKey fileResource, OpenDocumentOptions? options = null) { - var resolveResult = await _fileAccessHelper.ResolveAndValidateFilePathAsync(fileResource); - if (resolveResult.IsFailure) + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(fileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { - return Result.Fail($"Failed to open document for file resource '{fileResource}'") - .WithErrors(resolveResult); + return Result.Fail($"Failed to open document for file resource '{fileResource}': file does not exist") + .WithErrors(infoResult); } var openResult = await DocumentsPanel.OpenDocument(fileResource, options); diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index 43462a9dd..6a3f9d344 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -1,7 +1,7 @@ -using System.Security.Cryptography; using System.Text; using Celbridge.Logging; using Celbridge.Messaging; +using Celbridge.Utilities; using Celbridge.Workspace; using CommunityToolkit.Mvvm.ComponentModel; @@ -184,7 +184,7 @@ protected async Task SaveBinaryToFileAsync(string base64Content) /// private async Task SaveBytesToFileAsync(byte[] bytes) { - var intendedHash = ComputeBytesHash(bytes); + var intendedHash = FileHashHelper.HashBytes(bytes); if (await TryDetectPreWriteExternalChangeAsync()) { @@ -227,14 +227,14 @@ private async Task TryDetectPreWriteExternalChangeAsync() } var fileStorage = GetFileSystem(); - var readResult = await fileStorage.ReadAllBytesAsync(FileResource); - if (readResult.IsFailure) + var hashResult = await fileStorage.ComputeHashAsync(FileResource); + if (hashResult.IsFailure) { _logger?.LogDebug($"Pre-write hash check failed for '{FilePath}', proceeding to write attempt"); return false; } - var preWriteHash = ComputeBytesHash(readResult.Value); + var preWriteHash = hashResult.Value; if (preWriteHash == _lastSavedFileHash) { return false; @@ -294,14 +294,13 @@ protected async Task IsFileChangedExternallyAsync() // File size is the same; compute hash to check if content actually changed. // This handles cases where the file was rewritten with identical content. - var readResult = await fileStorage.ReadAllBytesAsync(FileResource); - if (readResult.IsFailure) + var hashResult = await fileStorage.ComputeHashAsync(FileResource); + if (hashResult.IsFailure) { return true; } - var currentHash = ComputeBytesHash(readResult.Value); - return currentHash != _lastSavedFileHash; + return hashResult.Value != _lastSavedFileHash; } protected virtual async Task UpdateFileTrackingInfoAsync() @@ -316,8 +315,8 @@ protected virtual async Task UpdateFileTrackingInfoAsync() return; } - var readResult = await fileStorage.ReadAllBytesAsync(FileResource); - if (readResult.IsFailure) + var hashResult = await fileStorage.ComputeHashAsync(FileResource); + if (hashResult.IsFailure) { _lastSavedFileHash = null; _lastSavedFileSize = 0; @@ -325,13 +324,6 @@ protected virtual async Task UpdateFileTrackingInfoAsync() } _lastSavedFileSize = infoResult.Value.Size; - _lastSavedFileHash = ComputeBytesHash(readResult.Value); - } - - private static string ComputeBytesHash(byte[] bytes) - { - using var sha256 = SHA256.Create(); - var hashBytes = sha256.ComputeHash(bytes); - return Convert.ToBase64String(hashBytes); + _lastSavedFileHash = hashResult.Value; } } diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 3a4c85ef9..71524ab3c 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -2,6 +2,7 @@ using Celbridge.Logging; using Celbridge.Projects; using Celbridge.Resources.Helpers; +using Celbridge.Utilities; using Celbridge.Workspace; namespace Celbridge.Resources.Services; @@ -555,6 +556,18 @@ public async Task>> EnumerateFolderAsync(Resour } } + public async Task> ComputeHashAsync(ResourceKey resource) + { + var readResult = await ReadAllBytesAsync(resource); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to compute hash for resource: '{resource}'") + .WithErrors(readResult); + } + + return FileHashHelper.HashBytes(readResult.Value); + } + private Result ResolvePath(ResourceKey resource) { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; From 9e885743dad8af27bbfaa9dbb7d535ef596c9e2e Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 16:08:04 +0100 Subject: [PATCH 32/48] Unify file IO retry logic and use streaming reads Refactor FileStorage to centralize the bounded-retry policy into a generic RunWithRetryAsync and replace ad-hoc read/write retry loops. ReadAllBytesAsync, ReadAllTextAsync and OpenReadAsync now use the shared runner; OpenReadAsync returns a FileStream wrapped by the retry chokepoint. Added IsTransientReadIOException to avoid retrying FileNotFoundException/DirectoryNotFoundException and tightened logging/error messages via an operationLabel. Updated SpreadsheetTools to open a read-only stream from the storage chokepoint instead of loading workbook bytes into a MemoryStream. Minor API doc/comment wording tweaks. --- .../Resources/IFileStorage.cs | 12 +- .../Tools/Spreadsheet/SpreadsheetTools.cs | 14 +- .../Services/FileStorage.cs | 142 +++++++----------- 3 files changed, 70 insertions(+), 98 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs index 9ab166524..fd18a135b 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs @@ -104,12 +104,12 @@ public record StorageItemInfo( /// resource addressable by a ResourceKey — files under the project tree as well /// as files under registered non-project roots (e.g. temp:, logs:). Callers pass /// a ResourceKey; the layer dispatches via the registered root handlers so -/// containment and symlink validation run automatically. Bytes and text writes -/// are atomic via temp-file rename with bounded retry on transient IO failures. -/// Structural operations on project: resources additionally cascade the paired -/// sidecar, and rewrite references that live inside scannable file types (see -/// ResourceScanner for the current allowlist); operations on non-project roots -/// are pure byte moves. +/// containment and symlink validation run automatically. Reads and writes have +/// bounded retry on transient IO failures; writes are additionally atomic via +/// temp-file rename. Structural operations on project: resources additionally +/// cascade the paired sidecar, and rewrite references that live inside +/// scannable file types (see ResourceScanner for the current allowlist); +/// operations on non-project roots are pure byte moves. /// public interface IFileStorage { diff --git a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs index 4d66e9905..1500f1659 100644 --- a/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs +++ b/Source/Core/Celbridge.Tools/Tools/Spreadsheet/SpreadsheetTools.cs @@ -58,21 +58,21 @@ private async Task> ResolveWorkbookResourceAsync(string reso return resourceKey; } - // Opens the workbook bytes via the file storage chokepoint and returns them - // as a seekable MemoryStream positioned at zero. Caller disposes. + // Opens a read-only stream on the workbook via the file storage chokepoint. + // Caller disposes. private async Task> OpenWorkbookStreamAsync(ResourceKey resource) { var workspaceWrapper = GetRequiredService(); var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var bytesResult = await fileStorage.ReadAllBytesAsync(resource); - if (bytesResult.IsFailure) + var openResult = await fileStorage.OpenReadAsync(resource); + if (openResult.IsFailure) { - return Result.Fail($"Failed to read workbook: '{resource}'") - .WithErrors(bytesResult); + return Result.Fail($"Failed to open workbook: '{resource}'") + .WithErrors(openResult); } - return (Stream)new MemoryStream(bytesResult.Value, writable: false); + return openResult.Value; } private static string SerializeJson(object value) diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 71524ab3c..b421a24ac 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -49,10 +49,12 @@ public async Task> ReadAllBytesAsync(ResourceKey resource) } var resourcePath = resolveResult.Value; - return await ReadWithRetryAsync( - resource, - resourcePath, - path => File.ReadAllBytesAsync(path)); + return await RunWithRetryAsync( + operationLabel: "Read", + resource: resource, + resourcePath: resourcePath, + operation: () => File.ReadAllBytesAsync(resourcePath), + shouldRetry: IsTransientReadIOException); } public async Task> ReadAllTextAsync(ResourceKey resource) @@ -65,42 +67,45 @@ public async Task> ReadAllTextAsync(ResourceKey resource) } var resourcePath = resolveResult.Value; - return await ReadWithRetryAsync( - resource, - resourcePath, - path => File.ReadAllTextAsync(path)); + return await RunWithRetryAsync( + operationLabel: "Read", + resource: resource, + resourcePath: resourcePath, + operation: () => File.ReadAllTextAsync(resourcePath), + shouldRetry: IsTransientReadIOException); } public async Task> OpenReadAsync(ResourceKey resource) { - await Task.CompletedTask; - var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); - return failure; } var resourcePath = resolveResult.Value; - try - { - var stream = new FileStream( + return await RunWithRetryAsync( + operationLabel: "Read", + resource: resource, + resourcePath: resourcePath, + operation: () => Task.FromResult(new FileStream( resourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, - useAsync: true); - return stream; - } - catch (Exception ex) - { - var failure = Result.Fail($"Failed to open read stream for resource: '{resource}'") - .WithException(ex); - return failure; - } + useAsync: true)), + shouldRetry: IsTransientReadIOException); + } + + // Reads short-circuit FileNotFoundException and DirectoryNotFoundException + // so a genuinely missing file fails immediately rather than burning the + // retry budget on a hopeless case. + private static bool IsTransientReadIOException(IOException ex) + { + return ex is not FileNotFoundException + and not DirectoryNotFoundException; } public Task WriteAllBytesAsync(ResourceKey resource, byte[] bytes) @@ -1034,20 +1039,22 @@ private static void CopyFolderRecursive(string sourceFolder, string destFolder) } } - // Bounded retry on transient IO failures. Mirrors WriteWithRetryAsync: a - // file briefly held open by an external editor, antivirus, or backup - // product clears within milliseconds, so 3 attempts at 50/100/150ms backoff - // catches the common cases without imposing meaningful latency on the - // typical-case success. FileNotFoundException and DirectoryNotFoundException - // are explicitly not retried — the file is genuinely missing and retrying - // won't change that. UnauthorizedAccessException is also not retried: for - // reads it almost always means a permission issue (e.g. an ACL the user - // can't get past), not a transient lock, and the metadata scanner has its - // own retry budget for the rarer transient cases. - private async Task> ReadWithRetryAsync( + // Runs an IO operation under the chokepoint's bounded-retry policy. A file + // briefly held open by an external editor, antivirus, or backup product + // clears within milliseconds, so 3 attempts at 50/100/150ms backoff catches + // the common cases without imposing meaningful latency on the typical-case + // success. shouldRetry decides whether a particular IOException is worth + // retrying; read paths exclude FileNotFoundException and + // DirectoryNotFoundException because the file is genuinely missing. + // UnauthorizedAccessException is never retried — for reads and writes it + // almost always means a permission issue (e.g. an ACL the user can't get + // past), not a transient lock. + private async Task> RunWithRetryAsync( + string operationLabel, ResourceKey resource, string resourcePath, - Func> readOperation) + Func> operation, + Func? shouldRetry = null) where T : notnull { IOException? lastException = null; @@ -1056,41 +1063,31 @@ private async Task> ReadWithRetryAsync( { try { - var value = await readOperation(resourcePath); + var value = await operation(); if (attempt > 1) { - _logger.LogWarning($"Read succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); + _logger.LogWarning($"{operationLabel} succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); } return Result.Ok(value); } - catch (FileNotFoundException ex) - { - return Result.Fail($"Failed to read file: '{resource}'") - .WithException(ex); - } - catch (DirectoryNotFoundException ex) - { - return Result.Fail($"Failed to read file: '{resource}'") - .WithException(ex); - } - catch (IOException ex) + catch (IOException ex) when (shouldRetry?.Invoke(ex) ?? true) { lastException = ex; if (attempt < MaxAttempts) { var delay = BaseRetryDelayMs * attempt; - _logger.LogWarning(ex, $"Read attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); + _logger.LogWarning(ex, $"{operationLabel} attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); await Task.Delay(delay); } } catch (Exception ex) { - return Result.Fail($"Failed to read file: '{resource}'") + return Result.Fail($"Failed to {operationLabel.ToLowerInvariant()} file: '{resource}'") .WithException(ex); } } - return Result.Fail($"Failed to read file after {MaxAttempts} attempts: '{resource}'") + return Result.Fail($"Failed to {operationLabel.ToLowerInvariant()} file after {MaxAttempts} attempts: '{resource}'") .WithException(lastException!); } @@ -1132,42 +1129,17 @@ private async Task WriteWithRetryAsync(ResourceKey resource, byte[] byte .WithException(ex); } - IOException? lastException = null; - - for (var attempt = 1; attempt <= MaxAttempts; attempt++) - { - try + var runResult = await RunWithRetryAsync( + operationLabel: "Write", + resource: resource, + resourcePath: resourcePath, + operation: async () => { await WriteAtomicAsync(resourcePath, stagingFolder, bytes); - if (attempt > 1) - { - // A retry should be unusual — the workspace owns the project - // folder and we use an atomic temp+rename. Surface success- - // after-retry as a warning so unusual disk contention (AV - // scans, sync clients, external locks) is visible in logs. - _logger.LogWarning($"Write succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); - } - return Result.Ok(); - } - catch (IOException ex) - { - lastException = ex; - if (attempt < MaxAttempts) - { - var delay = BaseRetryDelayMs * attempt; - _logger.LogWarning(ex, $"Write attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); - await Task.Delay(delay); - } - } - catch (Exception ex) - { - return Result.Fail($"Failed to write file: '{resourcePath}'") - .WithException(ex); - } - } + return true; + }); - return Result.Fail($"Failed to write file after {MaxAttempts} attempts: '{resourcePath}'") - .WithException(lastException!); + return runResult.IsSuccess ? Result.Ok() : Result.Fail(runResult); } private static Result EnsureParentFolderExists(string resourcePath, ResourceKey resource) From 367d25d730d0e0f224e6534a7bdba4d207e54cff Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 16:26:53 +0100 Subject: [PATCH 33/48] Remove filename-based document editor support Remove exact-filename editor registrations from the document editor API and implementation, and update classifier/tests to stop relying on filename-only matches. IDocumentEditorRegistry no longer exposes IsFilenameSupported; DocumentEditorRegistry drops its IsFilenameSupported method. ResourceClassifier no longer checks filename registrations when deciding standalone .cel forms and only considers multi-part extension suffixes. Tests were adjusted to remove the filename-only test and update mock expectations accordingly. --- .../Documents/IDocumentEditorRegistry.cs | 7 ------ .../Resources/ResourceClassifierTests.cs | 25 +------------------ .../Services/DocumentEditorRegistry.cs | 5 ---- .../Services/ResourceClassifier.cs | 16 ++++-------- 4 files changed, 6 insertions(+), 47 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs index 6f3e8aa80..7b9ad884b 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentEditorRegistry.cs @@ -21,13 +21,6 @@ public interface IDocumentEditorRegistry /// bool IsExtensionSupported(string fileExtension); - /// - /// Checks if any registered factory is bound to the specified exact filename. - /// Filename-only registrations (e.g. "package.toml") drive matching distinct - /// from extension lookups. - /// - bool IsFilenameSupported(string fileName); - /// /// Gets all registered factories. /// diff --git a/Source/Tests/Resources/ResourceClassifierTests.cs b/Source/Tests/Resources/ResourceClassifierTests.cs index a6d77f783..5c3ee6807 100644 --- a/Source/Tests/Resources/ResourceClassifierTests.cs +++ b/Source/Tests/Resources/ResourceClassifierTests.cs @@ -68,28 +68,6 @@ public void StandaloneCelWithMultiPartExtensionRegistration_IsNotReportedAsOrpha report.Healthy.Should().Contain(new ResourceKey("feature.note.cel")); } - [Test] - public void StandaloneCelWithFilenameOnlyRegistration_IsNotReportedAsOrphan() - { - // Filename-only factory registrations must drive standalone classification. - // Earlier code computed a multi-part suffix and missed the bare-filename - // case, so any .cel file owned by a filename-only factory showed up in - // the orphan list. - File.WriteAllText(Path.Combine(_projectFolderPath, "config.cel"), - "value = 1\n"); - - var editorRegistry = Substitute.For(); - editorRegistry.IsFilenameSupported("config.cel").Returns(true); - - var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); - var registry = BuildRegistry(classifier); - registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - - var report = registry.GetCelFileReport(); - report.Orphan.Should().NotContain(new ResourceKey("config.cel")); - report.Healthy.Should().Contain(new ResourceKey("config.cel")); - } - [Test] public void BareCelExtensionRegistration_DoesNotPreventOrphanReport() { @@ -141,14 +119,13 @@ public void ParentedSidecar_IsNeverConsultedAgainstEditorRegistry() "tags = [\"x\"]\n"); var editorRegistry = Substitute.For(); - editorRegistry.IsFilenameSupported(Arg.Any()).Returns(false); editorRegistry.IsExtensionSupported(Arg.Any()).Returns(false); var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - editorRegistry.DidNotReceive().IsFilenameSupported("foo.png.cel"); + editorRegistry.DidNotReceive().IsExtensionSupported(Arg.Any()); var report = registry.GetCelFileReport(); report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs index b95a2a211..63bc4605f 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs @@ -174,11 +174,6 @@ public bool IsExtensionSupported(string fileExtension) return _extensionToFactories.ContainsKey(normalizedExtension); } - public bool IsFilenameSupported(string fileName) - { - return _filenameToFactories.ContainsKey(fileName); - } - /// /// Gets all registered factories. /// diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs index b1903aba3..aa4569118 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs @@ -177,23 +177,17 @@ private IDocumentEditorRegistry ResolveEditorRegistry() // Checks whether a parentless .cel file is claimed by a registered factory // in a way that denotes a standalone form. The match shape that counts // here is a multi-part extension suffix that includes a segment in front - // of ".cel" (e.g. ".webview.cel", ".note.cel"). Exact-filename matches are - // also accepted for completeness, though no current factory registers a - // bare .cel filename. The bare ".cel" extension is excluded: it also - // serves the generic code-editor syntax-highlighting registration, which - // says nothing about pairing semantics. Without that exclusion every - // parentless ".cel" would silently disappear from the orphan report. + // of ".cel" (e.g. ".webview.cel", ".note.cel"). The bare ".cel" extension + // is excluded: it also serves the generic code-editor syntax-highlighting + // registration, which says nothing about pairing semantics. Without that + // exclusion every parentless ".cel" would silently disappear from the + // orphan report. private static bool IsRegisteredStandaloneCelForm( ResourceKey sidecarKey, IDocumentEditorRegistry editorRegistry) { var fileName = sidecarKey.ResourceName; - if (editorRegistry.IsFilenameSupported(fileName)) - { - return true; - } - foreach (var suffix in EnumerateExtensionSuffixes(fileName)) { if (string.Equals(suffix, ".cel", StringComparison.OrdinalIgnoreCase)) From 22ef66b54b97773fba047218927fb2263b9ad833 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 16:38:48 +0100 Subject: [PATCH 34/48] Build zip archive in-memory, remove temp file Replace temporary file-based zip creation with an in-memory MemoryStream. The ZipArchive is written into a MemoryStream (leaveOpen:true) and the resulting bytes are used directly for CreateFileAsync, removing temp file creation, reading and cleanup. Error handling and result population remain, and archive size is determined from storage probe or the in-memory byte length as before. --- .../Commands/ArchiveResourceCommand.cs | 58 ++++++++----------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs index 82f6aaf27..0e52891f2 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs @@ -157,14 +157,13 @@ private async Task ExecuteArchiveAsync() } } - // Build the zip to a temporary file first - var tempPath = Path.GetTempFileName(); int entryCount = 0; + byte[] archiveBytes; try { - using (var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) - using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false)) + using var memoryStream = new MemoryStream(); + using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) { if (isFile) { @@ -202,43 +201,34 @@ private async Task ExecuteArchiveAsync() } } - // Read the temp file and register it as an undoable create operation. - // The temp file lives under the OS temp folder, outside the project - // tree, so the chokepoint contract does not apply. - var archiveBytes = await File.ReadAllBytesAsync(tempPath); - - var createResult = await resourceOpService.CreateFileAsync(archivePath, archiveBytes); - if (createResult.IsFailure) - { - return createResult; - } - - var archiveProbeResult = await fileStorage.GetInfoAsync(ArchiveResource); - long archiveSize = archiveProbeResult.IsSuccess - ? archiveProbeResult.Value.Size - : archiveBytes.Length; - - ResultValue = new ArchiveResult - { - Entries = entryCount, - Size = archiveSize, - Archive = ArchiveResource.ToString() - }; - - return Result.Ok(); + // Disposing the ZipArchive flushes the central directory into + // memoryStream; leaveOpen:true keeps the buffer accessible. + archiveBytes = memoryStream.ToArray(); } catch (IOException exception) { _logger.LogError(exception, "Failed to create archive"); return Result.Fail($"Failed to create archive: {exception.Message}"); } - finally + + var createResult = await resourceOpService.CreateFileAsync(archivePath, archiveBytes); + if (createResult.IsFailure) { - // Clean up temp file - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } + return createResult; } + + var archiveProbeResult = await fileStorage.GetInfoAsync(ArchiveResource); + long archiveSize = archiveProbeResult.IsSuccess + ? archiveProbeResult.Value.Size + : archiveBytes.Length; + + ResultValue = new ArchiveResult + { + Entries = entryCount, + Size = archiveSize, + Archive = ArchiveResource.ToString() + }; + + return Result.Ok(); } } From 6c0c8f16d615b24699234b079e1f2b833c576a9e Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 17:23:00 +0100 Subject: [PATCH 35/48] Per-root PathValidator and cache invalidation Make PathValidator instance-scoped to a single root/backing location (constructor now takes rootName and backingLocation) and move verified-folder cache inside it. ResourceRootHandlerBase now creates its own PathValidator and exposes InvalidatePathCache; IResourceRootHandler gained InvalidatePathCache and IRootHandlerRegistry.InvalidatePathCache delegates to each registered handler. Remove the shared PathValidator from RootHandlerRegistry and update ResourceService/ResourceRegistry and tests to stop wiring a shared validator. Adjusted tests and handler constructors to match the new API and removed the cross-root cache-scoping test. --- .../Resources/IResourceRootHandler.cs | 6 ++ .../Resources/IRootHandlerRegistry.cs | 6 +- .../Tests/Resources/DataCheckProjectTests.cs | 2 +- Source/Tests/Resources/PathValidatorTests.cs | 81 ++++------------- .../Tests/Resources/ResourceCommandTests.cs | 2 +- .../Tests/Resources/ResourceRegistryTests.cs | 3 +- .../Resources/RootHandlerRegistryTests.cs | 16 ++-- .../Resources/VirtualRootHandlerTests.cs | 27 ++---- .../Commands/UnarchiveResourceCommand.cs | 91 ++++++++++++++----- .../Helpers/PathValidator.cs | 55 +++++------ .../Services/ResourceRegistry.cs | 2 +- .../Services/ResourceService.cs | 8 +- .../Services/RootHandlerRegistry.cs | 16 +--- .../Services/Roots/LogsRootHandler.cs | 6 +- .../Services/Roots/ProjectRootHandler.cs | 6 +- .../Services/Roots/ResourceRootHandlerBase.cs | 17 ++-- .../Services/Roots/TempRootHandler.cs | 6 +- 17 files changed, 162 insertions(+), 188 deletions(-) diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs index 2f766b3a7..5436819a0 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRootHandler.cs @@ -43,4 +43,10 @@ public interface IResourceRootHandler /// produces an invalid key segment. /// Result GetResourceKey(string absolutePath); + + /// + /// Clears this handler's path-validator cache so subsequent resolves re-verify + /// folders against the current filesystem state. + /// + void InvalidatePathCache(); } diff --git a/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs index aeeb7e64e..15a324b0e 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IRootHandlerRegistry.cs @@ -40,9 +40,9 @@ public interface IRootHandlerRegistry Result ResolveResourcePath(ResourceKey resource); /// - /// Clears the path-validator cache shared by registered handlers. Call - /// after the project folder layout changes so stale verified-folder - /// entries do not mask new reparse-point risks. + /// Clears the path-validator cache on every registered handler. Call after + /// the project folder layout changes so stale verified-folder entries do + /// not mask new reparse-point risks. /// void InvalidatePathCache(); } diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index 9f4f6d69e..9a3dec051 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -57,7 +57,7 @@ public void Setup() // ProjectCheckCommand writes its latest report to logs:project-check.log, // so the chokepoint needs a logs: root or the write step fails. _resourceRegistry.RegisterRootHandler( - new LogsRootHandler(_logsBackingFolder, new PathValidator())); + new LogsRootHandler(_logsBackingFolder)); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); diff --git a/Source/Tests/Resources/PathValidatorTests.cs b/Source/Tests/Resources/PathValidatorTests.cs index 4fd3db7ff..84019d309 100644 --- a/Source/Tests/Resources/PathValidatorTests.cs +++ b/Source/Tests/Resources/PathValidatorTests.cs @@ -32,11 +32,10 @@ public void ValidateAndResolveSucceedsForValidKey() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); var resourceKey = ResourceKey.Create("folder/file.txt"); - var resolveResult = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, resourceKey); + var resolveResult = validator.ValidateAndResolve(resourceKey); var expectedPath = Path.GetFullPath(Path.Combine(_projectFolder, "folder", "file.txt")); resolveResult.IsSuccess.Should().BeTrue(); @@ -48,10 +47,9 @@ public void ValidateAndResolveSucceedsForEmptyKey() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Empty); + var resolveResult = validator.ValidateAndResolve(ResourceKey.Empty); var expectedPath = Path.GetFullPath(_projectFolder); resolveResult.IsSuccess.Should().BeTrue(); @@ -68,15 +66,13 @@ public void ValidateAndResolveCachesVerifiedFolders() Directory.CreateDirectory(subFolder); File.WriteAllText(Path.Combine(subFolder, "a.txt"), "test"); - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); // First call — verifies the folder - validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("cached/a.txt")); + validator.ValidateAndResolve(ResourceKey.Create("cached/a.txt")); // Second call — should hit the cache (no way to assert directly, but it should not throw) - validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("cached/b.txt")); + validator.ValidateAndResolve(ResourceKey.Create("cached/b.txt")); } [Test] @@ -88,18 +84,16 @@ public void InvalidateCacheClearsVerifiedFolders() Directory.CreateDirectory(subFolder); File.WriteAllText(Path.Combine(subFolder, "a.txt"), "test"); - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); // Cache the folder - validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("ephemeral/a.txt")); + validator.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); // Invalidate validator.InvalidateCache(); // Next call should re-verify (still succeeds since folder is clean) - var resolveResult = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("ephemeral/a.txt")); + var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); resolveResult.IsSuccess.Should().BeTrue(); resolveResult.Value.Should().NotBeEmpty(); } @@ -127,10 +121,9 @@ public void ValidateAndResolveRejectsReparsePoint() try { - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("link_folder/file.txt")); + var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("link_folder/file.txt")); resolveResult.IsFailure.Should().BeTrue(); resolveResult.FirstErrorMessage.Should().Contain("symbolic link or junction"); } @@ -152,11 +145,10 @@ public void ValidateAndResolveAcceptsNonExistentPath() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); // Non-existent paths should be accepted (for create operations) - var resolveResult = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("new_folder/new_file.txt")); + var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("new_folder/new_file.txt")); resolveResult.IsSuccess.Should().BeTrue(); resolveResult.Value.Should().NotBeEmpty(); } @@ -188,10 +180,9 @@ public void ValidateAndResolveRejectsIntermediateReparsePoint() try { - var validator = new PathValidator(); + var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, ResourceKey.Create("parent/link/file.txt")); + var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("parent/link/file.txt")); resolveResult.IsFailure.Should().BeTrue(); resolveResult.FirstErrorMessage.Should().Contain("symbolic link or junction"); } @@ -207,44 +198,4 @@ public void ValidateAndResolveRejectsIntermediateReparsePoint() } } } - - [Test] - public void ValidateAndResolveCacheIsScopedPerRoot() - { - Guard.IsNotNull(_projectFolder); - - // Two roots with two distinct backing locations. - var secondaryBacking = Path.Combine( - Path.GetTempPath(), $"Celbridge/{nameof(PathValidatorTests)}_secondary"); - if (Directory.Exists(secondaryBacking)) - { - Directory.Delete(secondaryBacking, true); - } - Directory.CreateDirectory(secondaryBacking); - - try - { - var validator = new PathValidator(); - - // Resolve the same path-portion key under both roots; both should succeed and - // produce results scoped to their respective backing locations. - var keyA = ResourceKey.Create("scratch/file.txt"); - var resolveProject = validator.ValidateAndResolve( - ResourceKey.DefaultRoot, _projectFolder, keyA); - var resolveSecondary = validator.ValidateAndResolve( - "temp", secondaryBacking, keyA); - - resolveProject.IsSuccess.Should().BeTrue(); - resolveSecondary.IsSuccess.Should().BeTrue(); - resolveProject.Value.Should().StartWith(Path.GetFullPath(_projectFolder)); - resolveSecondary.Value.Should().StartWith(Path.GetFullPath(secondaryBacking)); - } - finally - { - if (Directory.Exists(secondaryBacking)) - { - Directory.Delete(secondaryBacking, true); - } - } - } } diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index dd560175c..bf46cdff3 100644 --- a/Source/Tests/Resources/ResourceCommandTests.cs +++ b/Source/Tests/Resources/ResourceCommandTests.cs @@ -292,7 +292,7 @@ private string SetupLogsRoot(params string[] entries) File.WriteAllText(fullPath, "log content"); } } - _resourceRegistry.RegisterRootHandler(new LogsRootHandler(logsBacking, new PathValidator())); + _resourceRegistry.RegisterRootHandler(new LogsRootHandler(logsBacking)); return logsBacking; } diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 6c6bbd33f..537dbd6db 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -451,8 +451,7 @@ public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() var tempBacking = Path.Combine(_resourceFolderPath, ".celbridge", "temp"); Directory.CreateDirectory(tempBacking); - var pathValidator = new PathValidator(); - resourceRegistry.RegisterRootHandler(new TempRootHandler(tempBacking, pathValidator)); + resourceRegistry.RegisterRootHandler(new TempRootHandler(tempBacking)); // A path under the project tree but outside .celbridge/temp/ goes to project. var projectFilePath = Path.Combine(_resourceFolderPath, FileNameA); diff --git a/Source/Tests/Resources/RootHandlerRegistryTests.cs b/Source/Tests/Resources/RootHandlerRegistryTests.cs index e402d5c3e..eb1361b49 100644 --- a/Source/Tests/Resources/RootHandlerRegistryTests.cs +++ b/Source/Tests/Resources/RootHandlerRegistryTests.cs @@ -48,7 +48,7 @@ public void TearDown() [Test] public void RegisterRootHandler_AddsHandlerKeyedByRootName() { - var projectHandler = new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator); + var projectHandler = new ProjectRootHandler(_projectFolderPath); _rootRegistry.RegisterRootHandler(projectHandler); @@ -59,13 +59,13 @@ public void RegisterRootHandler_AddsHandlerKeyedByRootName() [Test] public void RegisterRootHandler_ReplacesExistingHandlerForSameRoot() { - var firstHandler = new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator); + var firstHandler = new ProjectRootHandler(_projectFolderPath); var alternatePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(alternatePath); try { - var secondHandler = new ProjectRootHandler(alternatePath, _rootRegistry.PathValidator); + var secondHandler = new ProjectRootHandler(alternatePath); _rootRegistry.RegisterRootHandler(firstHandler); _rootRegistry.RegisterRootHandler(secondHandler); @@ -82,7 +82,7 @@ public void RegisterRootHandler_ReplacesExistingHandlerForSameRoot() public void IsResolvable_ReturnsTrueForRegisteredRoot_FalseOtherwise() { _rootRegistry.RegisterRootHandler( - new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + new ProjectRootHandler(_projectFolderPath)); _rootRegistry.IsResolvable(ResourceKey.Create("foo/bar")).Should().BeTrue(); _rootRegistry.IsResolvable(ResourceKey.Empty).Should().BeTrue(); @@ -96,9 +96,9 @@ public void GetResourceKey_DispatchesToLongestPrefixRoot() Directory.CreateDirectory(tempBacking); _rootRegistry.RegisterRootHandler( - new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + new ProjectRootHandler(_projectFolderPath)); _rootRegistry.RegisterRootHandler( - new TempRootHandler(tempBacking, _rootRegistry.PathValidator)); + new TempRootHandler(tempBacking)); // Path under both roots — temp wins because its backing prefix is longer. var tempPath = Path.Combine(tempBacking, "staging", "x.txt"); @@ -119,7 +119,7 @@ public void GetResourceKey_DispatchesToLongestPrefixRoot() public void GetResourceKey_FailsForPathOutsideEveryRoot() { _rootRegistry.RegisterRootHandler( - new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + new ProjectRootHandler(_projectFolderPath)); var outsidePath = Path.Combine(Path.GetTempPath(), "somewhere_else", "file.txt"); var result = _rootRegistry.GetResourceKey(outsidePath); @@ -132,7 +132,7 @@ public void GetResourceKey_FailsForPathOutsideEveryRoot() public void ResolveResourcePath_DelegatesToRegisteredHandler() { _rootRegistry.RegisterRootHandler( - new ProjectRootHandler(_projectFolderPath, _rootRegistry.PathValidator)); + new ProjectRootHandler(_projectFolderPath)); var resolved = _rootRegistry.ResolveResourcePath(ResourceKey.Create("a/b.txt")); diff --git a/Source/Tests/Resources/VirtualRootHandlerTests.cs b/Source/Tests/Resources/VirtualRootHandlerTests.cs index 50fb4cd46..5e83836db 100644 --- a/Source/Tests/Resources/VirtualRootHandlerTests.cs +++ b/Source/Tests/Resources/VirtualRootHandlerTests.cs @@ -1,4 +1,3 @@ -using Celbridge.Resources.Helpers; using Celbridge.Resources.Services.Roots; namespace Celbridge.Tests.Resources; @@ -41,8 +40,7 @@ public void TearDown() public void TempRootHandlerResolvesUnderBackingLocation() { Guard.IsNotNull(_tempBacking); - var validator = new PathValidator(); - var handler = new TempRootHandler(_tempBacking, validator); + var handler = new TempRootHandler(_tempBacking); handler.RootName.Should().Be("temp"); handler.BackingLocation.Should().Be(_tempBacking); @@ -59,8 +57,7 @@ public void TempRootHandlerResolvesUnderBackingLocation() public void LogsRootHandlerResolvesUnderBackingLocation() { Guard.IsNotNull(_logsBacking); - var validator = new PathValidator(); - var handler = new LogsRootHandler(_logsBacking, validator); + var handler = new LogsRootHandler(_logsBacking); handler.RootName.Should().Be("logs"); handler.BackingLocation.Should().Be(_logsBacking); @@ -77,8 +74,7 @@ public void LogsRootHandlerResolvesUnderBackingLocation() public void TempRootHandlerResolvesRootOnlyKeyToBackingFolder() { Guard.IsNotNull(_tempBacking); - var validator = new PathValidator(); - var handler = new TempRootHandler(_tempBacking, validator); + var handler = new TempRootHandler(_tempBacking); var resolveResult = handler.Resolve(ResourceKey.Create("temp:")); resolveResult.IsSuccess.Should().BeTrue(); @@ -87,15 +83,13 @@ public void TempRootHandlerResolvesRootOnlyKeyToBackingFolder() } [Test] - public void HandlersShareValidatorWithoutCrossContamination() + public void HandlersResolveSameKeyToDifferentBackings() { Guard.IsNotNull(_tempBacking); Guard.IsNotNull(_logsBacking); - // Both handlers share a single PathValidator instance, just like ResourceService wires them in production. - var validator = new PathValidator(); - var tempHandler = new TempRootHandler(_tempBacking, validator); - var logsHandler = new LogsRootHandler(_logsBacking, validator); + var tempHandler = new TempRootHandler(_tempBacking); + var logsHandler = new LogsRootHandler(_logsBacking); // Same path-portion key resolves under each handler to that handler's backing location. var key = ResourceKey.Create("session.log"); @@ -112,8 +106,7 @@ public void HandlersShareValidatorWithoutCrossContamination() public void GetResourceKeyOnHandlerReturnsRootPrefixedKey() { Guard.IsNotNull(_tempBacking); - var validator = new PathValidator(); - var handler = new TempRootHandler(_tempBacking, validator); + var handler = new TempRootHandler(_tempBacking); var absolutePath = Path.Combine(_tempBacking, "staging", "foo", "bar.txt"); var keyResult = handler.GetResourceKey(absolutePath); @@ -128,8 +121,7 @@ public void GetResourceKeyOnHandlerReturnsRootPrefixedKey() public void GetResourceKeyReturnsRootOnlyKeyWhenPathIsBackingLocation() { Guard.IsNotNull(_logsBacking); - var validator = new PathValidator(); - var handler = new LogsRootHandler(_logsBacking, validator); + var handler = new LogsRootHandler(_logsBacking); var keyResult = handler.GetResourceKey(_logsBacking); @@ -143,8 +135,7 @@ public void GetResourceKeyReturnsRootOnlyKeyWhenPathIsBackingLocation() public void GetResourceKeyFailsForPathOutsideBackingLocation() { Guard.IsNotNull(_tempBacking); - var validator = new PathValidator(); - var handler = new TempRootHandler(_tempBacking, validator); + var handler = new TempRootHandler(_tempBacking); // A path under the logs backing folder is not under temp's backing. Guard.IsNotNull(_logsBacking); diff --git a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs index 77aebb76d..0c918a4f3 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs @@ -180,27 +180,46 @@ private async Task ExecuteExtractAsync() try { - // Create the destination folder if it doesn't exist. Any - // missing ancestor folders above the destination are created - // through the operation service too so the entire chain lands - // in the unarchive's undo batch; the earlier direct - // Directory.CreateDirectory bypassed undo and watcher - // coordination. - if (!Directory.Exists(destinationPath)) + // Create the destination folder and any missing ancestors + // through the operation service so the whole chain lands in + // the unarchive's undo batch. + var destInfoResult = await fileStorage.GetInfoAsync(DestinationResource); + if (destInfoResult.IsFailure) { - var missingAncestors = new List(); - var ancestor = Path.GetDirectoryName(destinationPath); - while (!string.IsNullOrEmpty(ancestor) - && !Directory.Exists(ancestor)) + return Result.Fail($"Failed to probe destination resource: '{DestinationResource}'") + .WithErrors(destInfoResult); + } + + if (destInfoResult.Value.Kind == StorageItemKind.NotFound) + { + var missingAncestorKeys = new List(); + var ancestorKey = DestinationResource.GetParent(); + while (!ancestorKey.IsEmpty) { - missingAncestors.Add(ancestor); - ancestor = Path.GetDirectoryName(ancestor); + var ancestorInfoResult = await fileStorage.GetInfoAsync(ancestorKey); + if (ancestorInfoResult.IsFailure) + { + return Result.Fail($"Failed to probe ancestor resource: '{ancestorKey}'") + .WithErrors(ancestorInfoResult); + } + if (ancestorInfoResult.Value.Kind != StorageItemKind.NotFound) + { + break; + } + missingAncestorKeys.Add(ancestorKey); + ancestorKey = ancestorKey.GetParent(); } - missingAncestors.Reverse(); + missingAncestorKeys.Reverse(); - foreach (var ancestorPath in missingAncestors) + foreach (var key in missingAncestorKeys) { - var createAncestorResult = await resourceOpService.CreateFolderAsync(ancestorPath); + var ancestorPathResult = resourceRegistry.ResolveResourcePath(key); + if (ancestorPathResult.IsFailure) + { + return Result.Fail($"Failed to resolve ancestor path: '{key}'") + .WithErrors(ancestorPathResult); + } + var createAncestorResult = await resourceOpService.CreateFolderAsync(ancestorPathResult.Value); if (createAncestorResult.IsFailure) { return createAncestorResult; @@ -214,11 +233,22 @@ private async Task ExecuteExtractAsync() } } - // Create folders shallowest first (sorted by path length) - var sortedFolders = foldersToCreate - .Where(folderPath => !Directory.Exists(folderPath)) - .OrderBy(folderPath => folderPath.Length) - .ToList(); + // Filter shallowest-first so parents are created before children. + var sortedFolders = new List(); + foreach (var folderPath in foldersToCreate.OrderBy(path => path.Length)) + { + var folderKey = BuildDescendantKey(DestinationResource, destinationPath, folderPath); + var folderInfoResult = await fileStorage.GetInfoAsync(folderKey); + if (folderInfoResult.IsFailure) + { + return Result.Fail($"Failed to probe folder resource: '{folderKey}'") + .WithErrors(folderInfoResult); + } + if (folderInfoResult.Value.Kind == StorageItemKind.NotFound) + { + sortedFolders.Add(folderPath); + } + } foreach (var folderPath in sortedFolders) { @@ -288,4 +318,23 @@ private async Task ExecuteExtractAsync() return Result.Ok(); } + + private static ResourceKey BuildDescendantKey( + ResourceKey parentKey, + string parentPath, + string descendantPath) + { + var relative = Path.GetRelativePath(parentPath, descendantPath); + var segments = relative.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + + var key = parentKey; + foreach (var segment in segments) + { + key = key.Combine(segment); + } + + return key; + } } diff --git a/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs b/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs index 7a177be6d..c9d251335 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs @@ -3,33 +3,35 @@ namespace Celbridge.Resources.Helpers; /// -/// Validates that resolved resource paths stay within the backing folder of a given +/// Validates that resolved resource paths stay within the backing folder of a single /// root and do not traverse through symlinks, junctions, or other reparse points. -/// Maintains a per-root cache of verified directory paths to avoid repeated filesystem -/// stat calls. +/// Maintains a cache of verified directory paths to avoid repeated filesystem stat +/// calls. One instance serves exactly one root; its owning root handler constructs +/// it with that root's name and backing location. /// public class PathValidator { + private readonly string _rootName; + private readonly string _backingLocation; private readonly StringComparer _pathComparer; + private readonly HashSet _verifiedFolders; - // Per-root cache of verified folder paths. Cache is scoped by root so two roots - // cannot share verification state, even when their backing locations are nested. - private readonly Dictionary> _verifiedFoldersByRoot; - - public PathValidator() + public PathValidator(string rootName, string backingLocation) { + _rootName = rootName; + _backingLocation = backingLocation; _pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - _verifiedFoldersByRoot = new Dictionary>(StringComparer.Ordinal); + _verifiedFolders = new HashSet(_pathComparer); } /// /// Validates a resource key and resolves it to an absolute filesystem path under the - /// specified backing location. Returns a failure result if the key fails any validation check. + /// validator's backing location. Returns a failure result if the key fails any validation check. /// - public Result ValidateAndResolve(string rootName, string backingLocation, ResourceKey resource) + public Result ValidateAndResolve(ResourceKey resource) { // Belt-and-suspenders format check. Should never fail since construction already // validated, but catches any bypass. @@ -44,10 +46,10 @@ public Result ValidateAndResolve(string rootName, string backingLocation // already been used to select the handler and its backing location. var pathPortion = resource.Path; - var combinedPath = Path.Combine(backingLocation, pathPortion); + var combinedPath = Path.Combine(_backingLocation, pathPortion); var resolvedPath = Path.GetFullPath(combinedPath); - var normalizedBackingLocation = NormalizeBackingLocation(backingLocation); + var normalizedBackingLocation = NormalizeBackingLocation(_backingLocation); var isBackingRoot = resolvedPath.Equals( normalizedBackingLocation.TrimEnd(Path.DirectorySeparatorChar), @@ -56,10 +58,10 @@ public Result ValidateAndResolve(string rootName, string backingLocation if (!isBackingRoot && !resolvedPath.StartsWith(normalizedBackingLocation, GetPathComparison())) { return Result.Fail( - $"Resource key '{resource}' resolves to a path outside the '{rootName}' root."); + $"Resource key '{resource}' resolves to a path outside the '{_rootName}' root."); } - var reparseResult = CheckForReparsePoints(rootName, resolvedPath, normalizedBackingLocation); + var reparseResult = CheckForReparsePoints(resolvedPath, normalizedBackingLocation); if (reparseResult.IsFailure) { return Result.Fail(reparseResult.FirstErrorMessage); @@ -69,20 +71,19 @@ public Result ValidateAndResolve(string rootName, string backingLocation } /// - /// Clears the cache of verified directory paths across all roots. Call this when the directory + /// Clears the cache of verified directory paths. Call this when the directory /// structure may have changed (e.g. after ResourceMonitor triggers a registry sync). /// public void InvalidateCache() { - _verifiedFoldersByRoot.Clear(); + _verifiedFolders.Clear(); } - private Result CheckForReparsePoints(string rootName, string resolvedPath, string normalizedBackingLocation) + private Result CheckForReparsePoints(string resolvedPath, string normalizedBackingLocation) { var folderPath = GetFolderPath(resolvedPath); - var verifiedFolders = GetOrCreateVerifiedFolderSet(rootName); - if (verifiedFolders.Contains(folderPath)) + if (_verifiedFolders.Contains(folderPath)) { return Result.Ok(); } @@ -91,7 +92,7 @@ private Result CheckForReparsePoints(string rootName, string resolvedPath, strin var backingTrimmed = normalizedBackingLocation.TrimEnd(Path.DirectorySeparatorChar); if (resolvedPath.Equals(backingTrimmed, GetPathComparison())) { - verifiedFolders.Add(folderPath); + _verifiedFolders.Add(folderPath); return Result.Ok(); } @@ -119,20 +120,10 @@ private Result CheckForReparsePoints(string rootName, string resolvedPath, strin } } - verifiedFolders.Add(folderPath); + _verifiedFolders.Add(folderPath); return Result.Ok(); } - private HashSet GetOrCreateVerifiedFolderSet(string rootName) - { - if (!_verifiedFoldersByRoot.TryGetValue(rootName, out var verifiedFolders)) - { - verifiedFolders = new HashSet(_pathComparer); - _verifiedFoldersByRoot[rootName] = verifiedFolders; - } - return verifiedFolders; - } - private static string GetFolderPath(string resolvedPath) { if (Directory.Exists(resolvedPath)) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index 889e1e7df..c80279684 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -32,7 +32,7 @@ public void InitializeProjectRoot(string projectFolderPath) _projectFolderPath = projectFolderPath; _rootHandlerRegistry.RegisterRootHandler( - new ProjectRootHandler(projectFolderPath, _rootHandlerRegistry.PathValidator)); + new ProjectRootHandler(projectFolderPath)); } private FolderResource _projectFolder = new FolderResource(string.Empty, null); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index ffd711579..c51f36221 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -119,12 +119,8 @@ public ResourceService( } } - // Register the temp: and logs: root handlers against the shared - // PathValidator owned by the root handler registry so a single - // InvalidatePathCache call covers project + temp + logs together. - var sharedPathValidator = rootHandlerRegistry.PathValidator; - rootHandlerRegistry.RegisterRootHandler(new TempRootHandler(celbridgeTempFolder, sharedPathValidator)); - rootHandlerRegistry.RegisterRootHandler(new LogsRootHandler(celbridgeLogsFolder, sharedPathValidator)); + rootHandlerRegistry.RegisterRootHandler(new TempRootHandler(celbridgeTempFolder)); + rootHandlerRegistry.RegisterRootHandler(new LogsRootHandler(celbridgeLogsFolder)); // Monitor.Initialize() is called from WorkspaceLoader after construction completes; // the monitor looks up its registry through IWorkspaceWrapper, which is only populated diff --git a/Source/Workspace/Celbridge.Resources/Services/RootHandlerRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/RootHandlerRegistry.cs index 1eae0d709..3e52bda4a 100644 --- a/Source/Workspace/Celbridge.Resources/Services/RootHandlerRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/RootHandlerRegistry.cs @@ -1,19 +1,8 @@ -using Celbridge.Resources.Helpers; - namespace Celbridge.Resources.Services; public sealed class RootHandlerRegistry : IRootHandlerRegistry { private readonly Dictionary _rootHandlers = new(StringComparer.Ordinal); - private readonly PathValidator _pathValidator = new(); - - /// - /// The shared path validator that this registry's handlers consult for - /// reparse-point checks and verified-folder caching. Surfaced so callers - /// constructing concrete handlers (project, temp, logs) can wire them up - /// against the same cache. - /// - public PathValidator PathValidator => _pathValidator; public void RegisterRootHandler(IResourceRootHandler handler) { @@ -90,6 +79,9 @@ public Result ResolveResourcePath(ResourceKey resource) public void InvalidatePathCache() { - _pathValidator.InvalidateCache(); + foreach (var handler in _rootHandlers.Values) + { + handler.InvalidatePathCache(); + } } } diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs index 2821f65cc..116a530f8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/LogsRootHandler.cs @@ -1,5 +1,3 @@ -using Celbridge.Resources.Helpers; - namespace Celbridge.Resources.Services.Roots; /// @@ -21,8 +19,8 @@ public class LogsRootHandler : ResourceRootHandlerBase public override string RootName => Name; public override ResourceRootCapabilities Capabilities => LogsCapabilities; - public LogsRootHandler(string backingLocation, PathValidator pathValidator) - : base(backingLocation, pathValidator) + public LogsRootHandler(string backingLocation) + : base(backingLocation) { } } diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs index c6dacc9f0..f5d20157e 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/ProjectRootHandler.cs @@ -1,5 +1,3 @@ -using Celbridge.Resources.Helpers; - namespace Celbridge.Resources.Services.Roots; /// @@ -15,8 +13,8 @@ public class ProjectRootHandler : ResourceRootHandlerBase public override string RootName => ResourceKey.DefaultRoot; public override ResourceRootCapabilities Capabilities => ProjectCapabilities; - public ProjectRootHandler(string projectFolderPath, PathValidator pathValidator) - : base(projectFolderPath, pathValidator) + public ProjectRootHandler(string projectFolderPath) + : base(projectFolderPath) { } } diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs index 85977c98e..05b2c6a86 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs @@ -3,19 +3,19 @@ namespace Celbridge.Resources.Services.Roots; /// -/// Shared implementation for resource root handlers. Holds the backing location and -/// path validator, and provides the common Resolve and GetResourceKey logic. -/// Concrete subclasses supply the root name and capability flags. +/// Shared implementation for resource root handlers. Owns the backing location and +/// a path validator scoped to this root, and provides the common Resolve and +/// GetResourceKey logic. Concrete subclasses supply the root name and capability flags. /// public abstract class ResourceRootHandlerBase : IResourceRootHandler { private readonly PathValidator _pathValidator; private readonly string _backingLocation; - protected ResourceRootHandlerBase(string backingLocation, PathValidator pathValidator) + protected ResourceRootHandlerBase(string backingLocation) { _backingLocation = backingLocation; - _pathValidator = pathValidator; + _pathValidator = new PathValidator(RootName, backingLocation); } public abstract string RootName { get; } @@ -26,7 +26,12 @@ protected ResourceRootHandlerBase(string backingLocation, PathValidator pathVali public Result Resolve(ResourceKey key) { - return _pathValidator.ValidateAndResolve(RootName, BackingLocation, key); + return _pathValidator.ValidateAndResolve(key); + } + + public void InvalidatePathCache() + { + _pathValidator.InvalidateCache(); } public Result GetResourceKey(string absolutePath) diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs index 6421870ae..84d0a8d15 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/TempRootHandler.cs @@ -1,5 +1,3 @@ -using Celbridge.Resources.Helpers; - namespace Celbridge.Resources.Services.Roots; /// @@ -21,8 +19,8 @@ public class TempRootHandler : ResourceRootHandlerBase public override string RootName => Name; public override ResourceRootCapabilities Capabilities => TempCapabilities; - public TempRootHandler(string backingLocation, PathValidator pathValidator) - : base(backingLocation, pathValidator) + public TempRootHandler(string backingLocation) + : base(backingLocation) { } } From 1a15898eae7efa69e5239c2bfb9cba3e793465ed Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 18:19:13 +0100 Subject: [PATCH 36/48] Refactor SearchService to use workspace properties Introduce FileStorage, ResourceRegistry and WorkspaceSettings properties on SearchService and replace direct WorkspaceService lookups with these properties. Inline and remove the ReplaceInFileAsync and ReplaceMatchAsync helpers by performing read/replace/write directly (with cancellation checks and error handling) to reduce duplication and simplify flow. Use ResourceRegistry for path resolution and FileStorage for file IO throughout. Also remove a redundant XML summary comment from SidecarService.cs. --- .../Services/SidecarService.cs | 4 - .../Services/SearchService.cs | 187 ++++++------------ 2 files changed, 65 insertions(+), 126 deletions(-) diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs index b65e41bf3..b99ac5a8a 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -3,10 +3,6 @@ namespace Celbridge.Resources.Services; -/// -/// Mutations re-read the sidecar, apply the change, and skip the write when -/// the composed output matches what is on disk. -/// public sealed class SidecarService : ISidecarService { private readonly IWorkspaceWrapper _workspaceWrapper; diff --git a/Source/Workspace/Celbridge.Search/Services/SearchService.cs b/Source/Workspace/Celbridge.Search/Services/SearchService.cs index 9a996b0a9..19c1dd1be 100644 --- a/Source/Workspace/Celbridge.Search/Services/SearchService.cs +++ b/Source/Workspace/Celbridge.Search/Services/SearchService.cs @@ -19,6 +19,10 @@ public class SearchService : ISearchService, IDisposable private readonly TextReplacer _textReplacer; private bool _disposed; + private IFileStorage FileStorage => _workspaceWrapper.WorkspaceService.FileStorage; + private IResourceRegistry ResourceRegistry => _workspaceWrapper.WorkspaceService.ResourceService.Registry; + private IWorkspaceSettings WorkspaceSettings => _workspaceWrapper.WorkspaceService.WorkspaceSettings; + public SearchService( ILogger logger, IWorkspaceWrapper workspaceWrapper, @@ -64,9 +68,7 @@ public async Task SearchAsync( return new SearchResults(searchTerm, fileResults, 0, 0, false, false); } - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - var projectFolder = resourceRegistry.ProjectFolderPath; + var projectFolder = ResourceRegistry.ProjectFolderPath; if (string.IsNullOrEmpty(projectFolder)) { @@ -102,7 +104,7 @@ public async Task SearchAsync( try { // Get all file resources from the registry (already sorted by path) - var fileResources = resourceRegistry.GetAllFileResources(); + var fileResources = ResourceRegistry.GetAllFileResources(); if (includeRegex != null) { @@ -161,7 +163,6 @@ public async Task SearchAsync( : int.MaxValue; var fileResult = await SearchFileAsync( - fileStorage, filePath, projectFolder, resource, @@ -192,7 +193,6 @@ public async Task SearchAsync( } private async Task SearchFileAsync( - IFileStorage fileStorage, string filePath, string rootDirectory, ResourceKey resourceKey, @@ -206,7 +206,7 @@ public async Task SearchAsync( try { // Check if file should be searched (size, extension filters) - if (!await _fileFilter.ShouldSearchFileAsync(fileStorage, resourceKey, filePath)) + if (!await _fileFilter.ShouldSearchFileAsync(FileStorage, resourceKey, filePath)) { return null; } @@ -220,7 +220,7 @@ public async Task SearchAsync( // Stream the file via the chokepoint so reads pick up the same // containment validation as writes and large files do not load // fully into memory. - var openResult = await fileStorage.OpenReadAsync(resourceKey); + var openResult = await FileStorage.OpenReadAsync(resourceKey); if (openResult.IsFailure) { return null; @@ -308,10 +308,7 @@ public async Task ReplaceInFileAsync( return new ReplaceResult(false, 0); } - var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; - var fileStorage = workspaceService.FileStorage; - var resolveReplaceResult = resourceRegistry.ResolveResourcePath(resource); + var resolveReplaceResult = ResourceRegistry.ResolveResourcePath(resource); if (resolveReplaceResult.IsFailure) { return new ReplaceResult(false, 0); @@ -320,15 +317,34 @@ public async Task ReplaceInFileAsync( try { - return await ReplaceInFileAsync( - resource, - filePath, - fileStorage, + cancellationToken.ThrowIfCancellationRequested(); + + var readResult = await FileStorage.ReadAllTextAsync(resource); + if (readResult.IsFailure) + { + return new ReplaceResult(false, 0); + } + var content = readResult.Value; + + var (newContent, totalReplacements) = _textReplacer.ReplaceAll( + content, searchText, replaceText, matchCase, - wholeWord, - cancellationToken); + wholeWord); + + if (totalReplacements == 0) + { + return new ReplaceResult(true, 0); + } + + var writeResult = await FileStorage.WriteAllTextAsync(resource, newContent); + if (writeResult.IsFailure) + { + return new ReplaceResult(false, 0); + } + + return new ReplaceResult(true, totalReplacements); } catch (OperationCanceledException) { @@ -346,46 +362,6 @@ public async Task ReplaceInFileAsync( } } - private async Task ReplaceInFileAsync( - ResourceKey resource, - string filePath, - IFileStorage fileStorage, - string searchText, - string replaceText, - bool matchCase, - bool wholeWord, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var readResult = await fileStorage.ReadAllTextAsync(resource); - if (readResult.IsFailure) - { - return new ReplaceResult(false, 0); - } - var content = readResult.Value; - - var (newContent, totalReplacements) = _textReplacer.ReplaceAll( - content, - searchText, - replaceText, - matchCase, - wholeWord); - - if (totalReplacements == 0) - { - return new ReplaceResult(true, 0); - } - - var writeResult = await fileStorage.WriteAllTextAsync(resource, newContent); - if (writeResult.IsFailure) - { - return new ReplaceResult(false, 0); - } - - return new ReplaceResult(true, totalReplacements); - } - public async Task ReplaceMatchAsync( ResourceKey resource, string searchText, @@ -406,10 +382,7 @@ public async Task ReplaceMatchAsync( return new ReplaceMatchResult(false); } - var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; - var fileStorage = workspaceService.FileStorage; - var resolveMatchResult = resourceRegistry.ResolveResourcePath(resource); + var resolveMatchResult = ResourceRegistry.ResolveResourcePath(resource); if (resolveMatchResult.IsFailure) { return new ReplaceMatchResult(false); @@ -418,17 +391,36 @@ public async Task ReplaceMatchAsync( try { - return await ReplaceMatchAsync( - resource, - filePath, - fileStorage, + cancellationToken.ThrowIfCancellationRequested(); + + var readResult = await FileStorage.ReadAllTextAsync(resource); + if (readResult.IsFailure) + { + return new ReplaceMatchResult(false); + } + var content = readResult.Value; + + var (newContent, success) = _textReplacer.ReplaceMatch( + content, searchText, replaceText, lineNumber, originalMatchStart, matchCase, - wholeWord, - cancellationToken); + wholeWord); + + if (!success) + { + return new ReplaceMatchResult(false); + } + + var writeResult = await FileStorage.WriteAllTextAsync(resource, newContent); + if (writeResult.IsFailure) + { + return new ReplaceMatchResult(false); + } + + return new ReplaceMatchResult(true); } catch (OperationCanceledException) { @@ -446,50 +438,6 @@ public async Task ReplaceMatchAsync( } } - private async Task ReplaceMatchAsync( - ResourceKey resource, - string filePath, - IFileStorage fileStorage, - string searchText, - string replaceText, - int lineNumber, - int originalMatchStart, - bool matchCase, - bool wholeWord, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var readResult = await fileStorage.ReadAllTextAsync(resource); - if (readResult.IsFailure) - { - return new ReplaceMatchResult(false); - } - var content = readResult.Value; - - var (newContent, success) = _textReplacer.ReplaceMatch( - content, - searchText, - replaceText, - lineNumber, - originalMatchStart, - matchCase, - wholeWord); - - if (!success) - { - return new ReplaceMatchResult(false); - } - - var writeResult = await fileStorage.WriteAllTextAsync(resource, newContent); - if (writeResult.IsFailure) - { - return new ReplaceMatchResult(false); - } - - return new ReplaceMatchResult(true); - } - public async Task ReplaceAllAsync( List fileResults, string searchText, @@ -567,11 +515,9 @@ public async Task GetHistoryAsync() return emptyHistory; } - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - try { - var history = await workspaceSettings.GetPropertyAsync(SearchHistoryKey); + var history = await WorkspaceSettings.GetPropertyAsync(SearchHistoryKey); if (history is null) { @@ -584,7 +530,7 @@ public async Task GetHistoryAsync() { // Handle corrupted or old-format data by clearing it and returning empty history _logger.LogWarning(ex, "Failed to deserialize search history, clearing corrupted data"); - await workspaceSettings.DeletePropertyAsync(SearchHistoryKey); + await WorkspaceSettings.DeletePropertyAsync(SearchHistoryKey); return emptyHistory; } } @@ -617,8 +563,7 @@ public async Task AddSearchTermToHistoryAsync(string term) } var updatedHistory = new SearchHistory(searchTerms, history.ReplaceTerms.ToList()); - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - await workspaceSettings.SetPropertyAsync(SearchHistoryKey, updatedHistory); + await WorkspaceSettings.SetPropertyAsync(SearchHistoryKey, updatedHistory); } public async Task AddReplaceTermToHistoryAsync(string term) @@ -649,8 +594,7 @@ public async Task AddReplaceTermToHistoryAsync(string term) } var updatedHistory = new SearchHistory(history.SearchTerms.ToList(), replaceTerms); - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - await workspaceSettings.SetPropertyAsync(SearchHistoryKey, updatedHistory); + await WorkspaceSettings.SetPropertyAsync(SearchHistoryKey, updatedHistory); } public async Task ClearHistoryAsync() @@ -660,8 +604,7 @@ public async Task ClearHistoryAsync() return; } - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - await workspaceSettings.DeletePropertyAsync(SearchHistoryKey); + await WorkspaceSettings.DeletePropertyAsync(SearchHistoryKey); } public void Dispose() From 00b05f3d3f77dcfcd0e4fc29af911cd129127614 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 18:37:56 +0100 Subject: [PATCH 37/48] Integrate FileFilter into SearchService Remove the standalone FileFilter and move its filtering logic into SearchService as an internal ShouldSearchFileAsync method (size and extension checks, using ITextBinarySniffer). Update SearchService to depend on ITextBinarySniffer, add constants/sets for max file size and excluded metadata extensions, and adjust search flow to call the new method and sniff text content. Rename and update tests (FileFilterTests -> SearchServiceFilterTests) to exercise SearchService.ShouldSearchFileAsync and wire a real FileStorage through the workspace chokepoint. Add InternalsVisibleTo for the test assembly in the Celbridge.Search csproj so tests can access the internal method. --- ...erTests.cs => SearchServiceFilterTests.cs} | 69 ++++++----------- .../Celbridge.Search/Celbridge.Search.csproj | 6 +- .../Celbridge.Search/Services/FileFilter.cs | 75 ------------------- .../Services/SearchService.cs | 44 ++++++++++- 4 files changed, 66 insertions(+), 128 deletions(-) rename Source/Tests/Search/{FileFilterTests.cs => SearchServiceFilterTests.cs} (66%) delete mode 100644 Source/Workspace/Celbridge.Search/Services/FileFilter.cs diff --git a/Source/Tests/Search/FileFilterTests.cs b/Source/Tests/Search/SearchServiceFilterTests.cs similarity index 66% rename from Source/Tests/Search/FileFilterTests.cs rename to Source/Tests/Search/SearchServiceFilterTests.cs index 27119093e..80a9e2ccd 100644 --- a/Source/Tests/Search/FileFilterTests.cs +++ b/Source/Tests/Search/SearchServiceFilterTests.cs @@ -1,27 +1,24 @@ using Celbridge.Messaging; using Celbridge.Resources; using Celbridge.Resources.Services; -using Celbridge.Search; +using Celbridge.Search.Services; using Celbridge.Workspace; namespace Celbridge.Tests.Search; [TestFixture] -public class FileFilterTests +public class SearchServiceFilterTests { - private FileFilter _filter = null!; - private IFileStorage _fileStorage = null!; + private SearchService _service = null!; private IResourceRegistry _resourceRegistry = null!; private string _testDir = null!; [SetUp] public void SetUp() { - _filter = new FileFilter(new TextBinarySniffer()); _testDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(_testDir); - // Wire a real FileStorage so size + existence probes hit disk. _resourceRegistry = Substitute.For(); _resourceRegistry.ProjectFolderPath.Returns(_testDir); @@ -33,11 +30,19 @@ public void SetUp() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); + workspaceWrapper.IsWorkspacePageLoaded.Returns(false); - _fileStorage = new FileStorage( + // Wire a real FileStorage so size + existence probes hit disk through the chokepoint. + var fileStorage = new FileStorage( Substitute.For>(), Substitute.For(), workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + + _service = new SearchService( + Substitute.For>(), + workspaceWrapper, + new TextBinarySniffer()); } [TearDown] @@ -63,7 +68,7 @@ public async Task ShouldSearchFile_RegularTextFile_ReturnsTrue() var (resource, filePath) = MakeResource("test.txt"); File.WriteAllText(filePath, "test content"); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeTrue(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeTrue(); } [Test] @@ -71,7 +76,7 @@ public async Task ShouldSearchFile_NonExistentFile_ReturnsFalse() { var (resource, filePath) = MakeResource("nonexistent.txt"); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); } [Test] @@ -80,7 +85,7 @@ public async Task ShouldSearchFile_MetadataExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.celbridge"); File.WriteAllText(filePath, "metadata"); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); } [Test] @@ -92,7 +97,7 @@ public async Task ShouldSearchFile_CelExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.webview.cel"); File.WriteAllText(filePath, "source_url = \"https://example.com\"\n"); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); } [Test] @@ -101,7 +106,7 @@ public async Task ShouldSearchFile_BinaryExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.exe"); File.WriteAllBytes(filePath, new byte[] { 0x00, 0x01, 0x02 }); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); } [Test] @@ -110,7 +115,7 @@ public async Task ShouldSearchFile_ImageExtension_ReturnsFalse() var (resource, filePath) = MakeResource("test.png"); File.WriteAllBytes(filePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); } [Test] @@ -119,7 +124,7 @@ public async Task ShouldSearchFile_CSharpFile_ReturnsTrue() var (resource, filePath) = MakeResource("Test.cs"); File.WriteAllText(filePath, "public class Test { }"); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeTrue(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeTrue(); } [Test] @@ -128,7 +133,7 @@ public async Task ShouldSearchFile_MarkdownFile_ReturnsTrue() var (resource, filePath) = MakeResource("README.md"); File.WriteAllText(filePath, "# Readme"); - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeTrue(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeTrue(); } [Test] @@ -141,38 +146,6 @@ public async Task ShouldSearchFile_LargeFile_ReturnsFalse() fs.SetLength(1024 * 1024 + 1); } - (await _filter.ShouldSearchFileAsync(_fileStorage, resource, filePath)).Should().BeFalse(); - } - - [Test] - public void IsTextContent_NormalText_ReturnsTrue() - { - var content = "This is normal text content"; - - _filter.IsTextContent(content).Should().BeTrue(); - } - - [Test] - public void IsTextContent_WithNullCharacter_ReturnsFalse() - { - var content = "Text with \0 null character"; - - _filter.IsTextContent(content).Should().BeFalse(); - } - - [Test] - public void IsTextContent_EmptyString_ReturnsTrue() - { - var content = ""; - - _filter.IsTextContent(content).Should().BeTrue(); - } - - [Test] - public void IsTextContent_Unicode_ReturnsTrue() - { - var content = "Text with Unicode: ñ, ü, 中文, 日本語, 한글"; - - _filter.IsTextContent(content).Should().BeTrue(); + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); } } diff --git a/Source/Workspace/Celbridge.Search/Celbridge.Search.csproj b/Source/Workspace/Celbridge.Search/Celbridge.Search.csproj index 14b392016..a604afd30 100644 --- a/Source/Workspace/Celbridge.Search/Celbridge.Search.csproj +++ b/Source/Workspace/Celbridge.Search/Celbridge.Search.csproj @@ -29,5 +29,9 @@ - + + + + + diff --git a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs b/Source/Workspace/Celbridge.Search/Services/FileFilter.cs deleted file mode 100644 index 01cb13d17..000000000 --- a/Source/Workspace/Celbridge.Search/Services/FileFilter.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Celbridge.Resources; -using Path = System.IO.Path; - -namespace Celbridge.Search; - -/// -/// Determines which files should be included in search operations. -/// -public class FileFilter -{ - private const int MaxFileSizeBytes = 1024 * 1024; // 1MB - - private readonly ITextBinarySniffer _textBinarySniffer; - - private readonly HashSet _metadataExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".cel", - ".celbridge" - }; - - public FileFilter(ITextBinarySniffer textBinarySniffer) - { - _textBinarySniffer = textBinarySniffer; - } - - /// - /// Checks if a file should be included in search. Routes the file probe - /// through the chokepoint so the size check honours the same containment - /// validation as the read that follows. - /// - public async Task ShouldSearchFileAsync(IFileStorage fileStorage, ResourceKey resource, string filePath) - { - var infoResult = await fileStorage.GetInfoAsync(resource); - if (infoResult.IsFailure - || infoResult.Value.Kind != StorageItemKind.File) - { - return false; - } - - // Skip large files - if (infoResult.Value.Size > MaxFileSizeBytes) - { - return false; - } - - // Skip excluded file types - var extension = Path.GetExtension(filePath).ToLowerInvariant(); - if (_metadataExtensions.Contains(extension) || _textBinarySniffer.IsBinaryExtension(extension)) - { - return false; - } - - return true; - } - - /// - /// Checks if a file is likely a text file by examining its content. - /// This method reads a sample from the file and uses heuristics to detect binary content. - /// - public bool IsTextFile(string filePath) - { - var result = _textBinarySniffer.IsTextFile(filePath); - return result.IsSuccess && result.Value; - } - - /// - /// Checks if file content appears to be text (not binary). - /// Uses thorough heuristics including BOM detection, UTF-8 validation, - /// and control character ratio analysis. - /// - public bool IsTextContent(string content) - { - return _textBinarySniffer.IsTextContent(content); - } -} diff --git a/Source/Workspace/Celbridge.Search/Services/SearchService.cs b/Source/Workspace/Celbridge.Search/Services/SearchService.cs index 19c1dd1be..0ead41666 100644 --- a/Source/Workspace/Celbridge.Search/Services/SearchService.cs +++ b/Source/Workspace/Celbridge.Search/Services/SearchService.cs @@ -11,9 +11,17 @@ namespace Celbridge.Search.Services; /// public class SearchService : ISearchService, IDisposable { + private const int MaxSearchableFileSizeBytes = 1024 * 1024; // 1MB + + private static readonly HashSet ExcludedMetadataExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cel", + ".celbridge" + }; + private readonly ILogger _logger; private readonly IWorkspaceWrapper _workspaceWrapper; - private readonly FileFilter _fileFilter; + private readonly ITextBinarySniffer _textBinarySniffer; private readonly TextMatcher _textMatcher; private readonly SearchResultFormatter _formatter; private readonly TextReplacer _textReplacer; @@ -33,12 +41,39 @@ public SearchService( _logger = logger; _workspaceWrapper = workspaceWrapper; - _fileFilter = new FileFilter(textBinarySniffer); + _textBinarySniffer = textBinarySniffer; _textMatcher = new TextMatcher(); _formatter = new SearchResultFormatter(); _textReplacer = new TextReplacer(); } + // Decides whether a file should be included in a search. Probes the file + // through the chokepoint so the size check honours the same containment + // validation as the read that follows. Internal for the test suite. + internal async Task ShouldSearchFileAsync(ResourceKey resource, string filePath) + { + var infoResult = await FileStorage.GetInfoAsync(resource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) + { + return false; + } + + if (infoResult.Value.Size > MaxSearchableFileSizeBytes) + { + return false; + } + + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + if (ExcludedMetadataExtensions.Contains(extension) + || _textBinarySniffer.IsBinaryExtension(extension)) + { + return false; + } + + return true; + } + private sealed record SearchState { public int TotalMatches { get; set; } @@ -206,13 +241,14 @@ public async Task SearchAsync( try { // Check if file should be searched (size, extension filters) - if (!await _fileFilter.ShouldSearchFileAsync(FileStorage, resourceKey, filePath)) + if (!await ShouldSearchFileAsync(resourceKey, filePath)) { return null; } // Check if file content is text (not binary) using efficient sampling - if (!_fileFilter.IsTextFile(filePath)) + var sniffResult = _textBinarySniffer.IsTextFile(filePath); + if (!sniffResult.IsSuccess || !sniffResult.Value) { return null; } From e50cf057c69fcca7ac65602940b59b9834f9dba4 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 18:58:05 +0100 Subject: [PATCH 38/48] Refactor resource commands to use workspace services Hoist workspace-scoped service lookups into class-level properties (FileStorage, ResourceRegistry, ResourceOperationService, ResourceTransferService, ResourceScanner, SidecarService) and replace per-method/local lookups. Update CopyResourceCommand and DeleteResourceCommand to use these properties for Resolve/Transfer/Operation/Scanner calls and batch Begin/Commit. Inline previous helper checks (folder probe and sidecar existence) using FileStorage/SidecarService and remove the now-unused private helper methods. This simplifies code, reduces repeated workspace resolution, and aligns with workspace-scoped DI usage. --- .../Commands/CopyResourceCommand.cs | 45 +++++------- .../Commands/DeleteResourceCommand.cs | 70 ++++++++----------- 2 files changed, 45 insertions(+), 70 deletions(-) diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index a21bd9def..9de0aad1c 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -36,6 +36,11 @@ public class CopyResourceCommand : CommandBase, ICopyResourceCommand private readonly IWorkspaceWrapper _workspaceWrapper; private readonly ICommandService _commandService; + private IFileStorage FileStorage => _workspaceWrapper.WorkspaceService.FileStorage; + private IResourceRegistry ResourceRegistry => _workspaceWrapper.WorkspaceService.ResourceService.Registry; + private IResourceOperationService ResourceOperationService => _workspaceWrapper.WorkspaceService.ResourceService.OperationService; + private IResourceTransferService ResourceTransferService => _workspaceWrapper.WorkspaceService.ResourceService.TransferService; + public CopyResourceCommand( ILogger logger, IMessengerService messengerService, @@ -60,23 +65,12 @@ public override async Task ExecuteAsync() return Result.Ok(); } - // Hoist the workspace-scoped service lookups out of the per-resource - // loop. Acquiring them inside ExecuteAsync (rather than via constructor - // injection) honours the workspace-scoped DI rule — the workspace can - // be swapped between executions, but it cannot change while a single - // command runs, so caching for the duration of this call is safe. - var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; - var resourceOpService = workspaceService.ResourceService.OperationService; - var fileStorage = workspaceService.FileStorage; - var transferService = workspaceService.ResourceService.TransferService; - // Filter out resources whose parent folders are also selected. // This prevents duplicate operations when both a folder and its contents are selected. var filteredResources = FilterRedundantResources(SourceResources); // Begin batch for single undo operation - resourceOpService.BeginBatch(); + ResourceOperationService.BeginBatch(); List failedResources = new(); List failedOutcomes = new(); @@ -89,7 +83,7 @@ public override async Task ExecuteAsync() { foreach (var sourceResource in filteredResources) { - var outcome = await CopySingleResourceAsync(sourceResource, resourceRegistry, fileStorage, transferService, resourceOpService); + var outcome = await CopySingleResourceAsync(sourceResource); if (outcome.Result.IsFailure) { @@ -117,7 +111,7 @@ public override async Task ExecuteAsync() finally { // Always commit batch - partial success is acceptable - resourceOpService.CommitBatch(); + ResourceOperationService.CommitBatch(); } ResultValue = new CopyCommandResult( @@ -182,20 +176,15 @@ public override async Task ExecuteAsync() return Result.Ok(); } - private async Task CopySingleResourceAsync( - ResourceKey sourceResource, - IResourceRegistry resourceRegistry, - IFileStorage fileStorage, - IResourceTransferService transferService, - IResourceOperationService resourceOpService) + private async Task CopySingleResourceAsync(ResourceKey sourceResource) { // Resolve destination to handle folder drops - var resolvedDestResource = transferService.ResolveDestinationResource(sourceResource, DestResource); + var resolvedDestResource = ResourceTransferService.ResolveDestinationResource(sourceResource, DestResource); // Convert resource keys to absolute paths via the registry so root prefixes // (project:, temp:, logs:) are stripped correctly. Path.Combine with the bare // ResourceKey would incorporate the prefix as a literal directory component. - var resolveSourceResult = resourceRegistry.ResolveResourcePath(sourceResource); + var resolveSourceResult = ResourceRegistry.ResolveResourcePath(sourceResource); if (resolveSourceResult.IsFailure) { return new CopyResourceOutcome( @@ -207,7 +196,7 @@ private async Task CopySingleResourceAsync( } var sourcePath = resolveSourceResult.Value; - var resolveDestResult = resourceRegistry.ResolveResourcePath(resolvedDestResource); + var resolveDestResult = ResourceRegistry.ResolveResourcePath(resolvedDestResource); if (resolveDestResult.IsFailure) { return new CopyResourceOutcome( @@ -219,7 +208,7 @@ private async Task CopySingleResourceAsync( } var destPath = resolveDestResult.Value; - var infoResult = await fileStorage.GetInfoAsync(sourceResource); + var infoResult = await FileStorage.GetInfoAsync(sourceResource); if (infoResult.IsFailure) { return new CopyResourceOutcome( @@ -249,11 +238,11 @@ private async Task CopySingleResourceAsync( { if (TransferMode == DataTransferMode.Copy) { - result = await resourceOpService.CopyFileAsync(sourcePath, destPath); + result = await ResourceOperationService.CopyFileAsync(sourcePath, destPath); } else { - var moveResult = await resourceOpService.MoveFileAsync(sourcePath, destPath); + var moveResult = await ResourceOperationService.MoveFileAsync(sourcePath, destPath); result = moveResult; if (moveResult.IsSuccess) { @@ -265,11 +254,11 @@ private async Task CopySingleResourceAsync( { if (TransferMode == DataTransferMode.Copy) { - result = await resourceOpService.CopyFolderAsync(sourcePath, destPath); + result = await ResourceOperationService.CopyFolderAsync(sourcePath, destPath); } else { - var moveResult = await resourceOpService.MoveFolderAsync(sourcePath, destPath); + var moveResult = await ResourceOperationService.MoveFolderAsync(sourcePath, destPath); result = moveResult; if (moveResult.IsSuccess) { diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index 9a7032ab1..c01f42ed9 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -25,6 +25,12 @@ public class DeleteResourceCommand : CommandBase, IDeleteResourceCommand private readonly IWorkspaceWrapper _workspaceWrapper; private readonly IDialogService _dialogService; + private IFileStorage FileStorage => _workspaceWrapper.WorkspaceService.FileStorage; + private IResourceRegistry ResourceRegistry => _workspaceWrapper.WorkspaceService.ResourceService.Registry; + private IResourceOperationService ResourceOperationService => _workspaceWrapper.WorkspaceService.ResourceService.OperationService; + private IResourceScanner ResourceScanner => _workspaceWrapper.WorkspaceService.ResourceScanner; + private ISidecarService SidecarService => _workspaceWrapper.WorkspaceService.SidecarService; + public DeleteResourceCommand( ILogger logger, IMessengerService messengerService, @@ -53,12 +59,6 @@ public override async Task ExecuteAsync() return Result.Ok(); } - var workspaceService = _workspaceWrapper.WorkspaceService; - var resourceRegistry = workspaceService.ResourceService.Registry; - var resourceOpService = workspaceService.ResourceService.OperationService; - var fileStorage = workspaceService.FileStorage; - var scanner = workspaceService.ResourceScanner; - // Phase A: aggregate referencers external to the batch. References // from one doomed resource to another are filtered out so an internal // dependency doesn't block the batch. Folder resources expand to every @@ -69,7 +69,9 @@ public override async Task ExecuteAsync() var folderResources = new List(); foreach (var resource in Resources) { - if (await IsFolderResourceAsync(fileStorage, resource)) + var folderInfoResult = await FileStorage.GetInfoAsync(resource); + if (folderInfoResult.IsSuccess + && folderInfoResult.Value.Kind == StorageItemKind.Folder) { folderResources.Add(resource); } @@ -100,7 +102,7 @@ bool IsInsideBatch(ResourceKey candidate) // Walk every referenced target and pull in those that live under // this folder so we surface every incoming reference that the // recursive delete will leave dangling. - foreach (var target in await scanner.FindAllReferencedTargetsAsync()) + foreach (var target in await ResourceScanner.FindAllReferencedTargetsAsync()) { if (target.IsDescendantOf(resource)) { @@ -117,7 +119,7 @@ bool IsInsideBatch(ResourceKey candidate) foreach (var key in keysToCheck) { var perKeyReferencers = new List(); - foreach (var referencer in await scanner.FindReferencersAsync(key)) + foreach (var referencer in await ResourceScanner.FindReferencersAsync(key)) { if (!IsInsideBatch(referencer)) { @@ -171,12 +173,12 @@ bool IsInsideBatch(ResourceKey candidate) var resourceResults = new List(Resources.Count); var failedItems = new List(); - resourceOpService.BeginBatch(); + ResourceOperationService.BeginBatch(); try { foreach (var resource in Resources) { - var resolveResult = resourceRegistry.ResolveResourcePath(resource); + var resolveResult = ResourceRegistry.ResolveResourcePath(resource); if (resolveResult.IsFailure) { _logger.LogWarning($"Cannot delete resource because path could not be resolved: '{resource}'"); @@ -190,9 +192,19 @@ bool IsInsideBatch(ResourceKey candidate) } var resourcePath = resolveResult.Value; - bool sidecarPresent = await SidecarExistsForResourceAsync(workspaceService, fileStorage, resource); + // Probe the sidecar up front so we can report whether the delete + // cascaded one. After the delete runs the sidecar is gone (or + // never existed), so the only honest moment to ask is now. + bool sidecarPresent = false; + var sidecarKeyResult = SidecarService.GetSidecarKey(resource); + if (sidecarKeyResult.IsSuccess) + { + var sidecarInfoResult = await FileStorage.GetInfoAsync(sidecarKeyResult.Value); + sidecarPresent = sidecarInfoResult.IsSuccess + && sidecarInfoResult.Value.Kind == StorageItemKind.File; + } - var infoResult = await fileStorage.GetInfoAsync(resource); + var infoResult = await FileStorage.GetInfoAsync(resource); if (infoResult.IsFailure) { _logger.LogWarning($"Cannot delete resource because info probe failed: '{resource}'"); @@ -209,11 +221,11 @@ bool IsInsideBatch(ResourceKey candidate) Result deleteResult; if (info.Kind == StorageItemKind.File) { - deleteResult = await resourceOpService.DeleteFileAsync(resourcePath); + deleteResult = await ResourceOperationService.DeleteFileAsync(resourcePath); } else if (info.Kind == StorageItemKind.Folder) { - deleteResult = await resourceOpService.DeleteFolderAsync(resourcePath); + deleteResult = await ResourceOperationService.DeleteFolderAsync(resourcePath); } else { @@ -253,7 +265,7 @@ bool IsInsideBatch(ResourceKey candidate) } finally { - resourceOpService.CommitBatch(); + ResourceOperationService.CommitBatch(); } // Distinguish "every resource deleted cleanly" from "policy gate passed @@ -324,32 +336,6 @@ private static (DeleteResourceOutcome Outcome, string Message) ClassifyDeleteFai return (DeleteResourceOutcome.IOFailure, deleteResult.FirstErrorMessage); } - private static async Task IsFolderResourceAsync(IFileStorage fileStorage, ResourceKey resource) - { - var infoResult = await fileStorage.GetInfoAsync(resource); - if (infoResult.IsFailure) - { - return false; - } - return infoResult.Value.Kind == StorageItemKind.Folder; - } - - private static async Task SidecarExistsForResourceAsync(IWorkspaceService workspaceService, IFileStorage fileStorage, ResourceKey resource) - { - var sidecarKeyResult = workspaceService.SidecarService.GetSidecarKey(resource); - if (sidecarKeyResult.IsFailure) - { - return false; - } - - var infoResult = await fileStorage.GetInfoAsync(sidecarKeyResult.Value); - if (infoResult.IsFailure) - { - return false; - } - return infoResult.Value.Kind == StorageItemKind.File; - } - private static string BuildConfirmationMessage( IReadOnlyList resources, IReadOnlyDictionary> externalReferencers) From c92f882c1cd4f3829e0062baa5e892d408b7340c Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Thu, 28 May 2026 22:09:25 +0100 Subject: [PATCH 39/48] Add Trash service and ResourceKey-based ops Introduce a workspace-scoped ITrashService and register TrashService; add CreateFolderAsync(ResourceKey) to IFileStorage and implement it in FileStorage (idempotent, creates missing parents). Convert IResourceOperationService to ResourceKey-based APIs (CreateFile/CreateFolder/Copy/Move/Delete/Transfer) and add ImportExternalFileAsync/ImportExternalFolderAsync for imports. Update commands/helpers to call the new chokepoint signatures and use the file-storage probe for validation; remove the FileSystemHelper and move the move-with-retry logic into FileStorage. Add tests for folder creation and comprehensive TrashService behavior. Note: this is an API-breaking change to resource operation signatures and behavior (path strings -> ResourceKey). --- .../Resources/IFileStorage.cs | 6 + .../Resources/IResourceOperationService.cs | 54 +- .../Resources/ITrashService.cs | 54 ++ .../Workspace/IWorkspaceService.cs | 6 + Source/Tests/Resources/FileStorageTests.cs | 68 ++ Source/Tests/Resources/TrashServiceTests.cs | 283 ++++++ .../Commands/ArchiveResourceCommand.cs | 20 +- .../Commands/CopyResourceCommand.cs | 65 +- .../Commands/DeleteResourceCommand.cs | 28 +- .../Commands/TransferResourcesCommand.cs | 28 +- .../Commands/UnarchiveResourceCommand.cs | 41 +- .../Helpers/AddResourceHelper.cs | 66 +- .../Helpers/FileSystemHelper.cs | 121 --- .../ServiceConfiguration.cs | 1 + .../Services/FileStorage.cs | 68 +- .../Services/ResourceOperationService.cs | 579 +++--------- .../Services/ResourceOperations.cs | 867 +++++------------- .../Services/TrashService.cs | 465 ++++++++++ .../Services/WorkspaceService.cs | 2 + 19 files changed, 1409 insertions(+), 1413 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/ITrashService.cs create mode 100644 Source/Tests/Resources/TrashServiceTests.cs delete mode 100644 Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/TrashService.cs diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs index fd18a135b..bfaa07594 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs @@ -168,6 +168,12 @@ public interface IFileStorage /// Task> DeleteAsync(ResourceKey source); + /// + /// Creates a folder at the resource path, including any missing parents. + /// Idempotent: succeeds without error when the folder already exists. + /// + Task CreateFolderAsync(ResourceKey folder); + /// /// Probes a resource and returns its kind (NotFound, File, or Folder) along /// with its size and modified-time in a single roundtrip. Callers that only diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs index 3b7abf5fd..38dfb83ff 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs @@ -3,54 +3,64 @@ namespace Celbridge.Resources; /// -/// Service for performing resource operations with undo/redo support. +/// The workspace-scoped resource operation service. Layers session-local undo +/// and redo, batched grouping, and soft-delete trash on top of the IFileStorage +/// chokepoint. Every method names its target with a ResourceKey; external +/// imports keep a string source path because the source lies outside the +/// registry by definition. /// public interface IResourceOperationService { /// - /// Create a new file with the specified content. + /// Creates a new file at the resource with the given content. Fails if the + /// resource already exists. /// - Task CreateFileAsync(string path, byte[] content); + Task CreateFileAsync(ResourceKey resource, byte[] content); /// - /// Create a new empty folder. + /// Creates a new empty folder at the resource, including any missing parents. + /// Idempotent: succeeds if the folder already exists. /// - Task CreateFolderAsync(string path); + Task CreateFolderAsync(ResourceKey resource); /// - /// Copy a file from source to destination path. + /// Copies a file or folder from one resource location to another. The + /// returned CopyResult carries the paired-sidecar cascade outcome. /// - Task> CopyFileAsync(string sourcePath, string destPath); + Task> CopyAsync(ResourceKey source, ResourceKey destination); /// - /// Move a file from source to destination path. + /// Moves a file or folder from one resource location to another. The + /// returned MoveResult carries the reference-rewrite and paired-sidecar + /// cascade outcomes. /// - Task> MoveFileAsync(string sourcePath, string destPath); + Task> MoveAsync(ResourceKey source, ResourceKey destination); /// - /// Delete a file at the specified path. + /// Soft-deletes the resource via the trash service, preserving undo. The + /// paired sidecar (if any) cascades into the same trash batch. /// - Task DeleteFileAsync(string path); + Task DeleteAsync(ResourceKey resource); /// - /// Copy a folder from source to destination path. + /// Imports a file from outside the project into a registry-addressable + /// destination. The source path is taken as-is; the destination receives + /// containment validation through the chokepoint. /// - Task> CopyFolderAsync(string sourcePath, string destPath); + Task ImportExternalFileAsync(string sourcePath, ResourceKey destination); /// - /// Move a folder from source to destination path. + /// Imports a folder from outside the project into a registry-addressable + /// destination. The source is enumerated recursively; each file lands at + /// its corresponding destination key through the chokepoint. /// - Task> MoveFolderAsync(string sourcePath, string destPath); + Task ImportExternalFolderAsync(string sourcePath, ResourceKey destination); /// - /// Delete a folder at the specified path. + /// Copies or moves the resource depending on the transfer mode. Dispatches + /// file vs folder internally via the chokepoint's GetInfoAsync probe. /// - Task DeleteFolderAsync(string path); - - /// - /// Transfer a file or folder from source to destination path. - /// - Task TransferAsync(string sourcePath, string destPath, DataTransferMode mode); + Task TransferAsync(ResourceKey source, ResourceKey destination, DataTransferMode mode); /// /// Begin a batch of operations that will be grouped together as a single undo unit. diff --git a/Source/Core/Celbridge.Foundation/Resources/ITrashService.cs b/Source/Core/Celbridge.Foundation/Resources/ITrashService.cs new file mode 100644 index 000000000..a8d693784 --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/ITrashService.cs @@ -0,0 +1,54 @@ +namespace Celbridge.Resources; + +/// +/// A single entity data file that was moved into the trash alongside a parent +/// resource. Carries both ends of the move so restore and purge can act on it. +/// +public record TrashedEntityDataFile(string OriginalPath, string TrashPath); + +/// +/// Bookkeeping for one soft-delete. Captures every path the trash service moved +/// so a later RestoreFromTrashAsync or PurgeAsync can act on the exact same set. +/// DescendantKeys are the resource keys of every file under the deleted folder, +/// captured before the move so consumers can broadcast removal messages. +/// +public record TrashEntry( + ResourceKey OriginalResource, + string TrashId, + bool WasFolder, + bool WasEmptyFolder, + string OriginalPath, + string TrashPath, + string? SidecarOriginalPath, + string? SidecarTrashPath, + IReadOnlyList EntityDataFiles, + IReadOnlyList DescendantKeys); + +/// +/// The workspace-scoped soft-delete service. Moves resources into the workspace +/// trash folder, restores them on undo, and purges them when an undo operation +/// is evicted from the undo stack or the redo stack is cleared. Owns the trash +/// folder layout and the read-only attribute handling for delete-as-override. +/// +public interface ITrashService +{ + /// + /// Moves the resource into the workspace trash folder. Cascades the paired + /// sidecar and any entity data files. Returns a TrashEntry that uniquely + /// identifies the soft-delete for later restore or purge. + /// + Task> MoveToTrashAsync(ResourceKey resource); + + /// + /// Restores a previously-trashed resource to its original location, including + /// any sidecar and entity data files captured at trash time. + /// + Task RestoreFromTrashAsync(TrashEntry entry); + + /// + /// Permanently removes a trashed resource and its associated files. Called + /// when an undo operation is evicted from the undo stack or when the redo + /// stack is cleared by a new operation. + /// + Task PurgeAsync(TrashEntry entry); +} diff --git a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs index 217470b75..abcf25e9c 100644 --- a/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs +++ b/Source/Core/Celbridge.Foundation/Workspace/IWorkspaceService.cs @@ -51,6 +51,12 @@ void SetPanels( /// IFileStorage FileStorage { get; } + /// + /// Returns the soft-delete trash service: move-to-trash, restore, and purge + /// operations used by the resource operation service for undoable deletes. + /// + ITrashService TrashService { get; } + /// /// Returns the on-demand scanner over project text and sidecar files, /// used by the rename cascade, tag queries, and the project-health check. diff --git a/Source/Tests/Resources/FileStorageTests.cs b/Source/Tests/Resources/FileStorageTests.cs index bbbaaa3ec..767fb2754 100644 --- a/Source/Tests/Resources/FileStorageTests.cs +++ b/Source/Tests/Resources/FileStorageTests.cs @@ -741,6 +741,74 @@ public async Task MoveAsync_SkipsReadOnlyReferencer_AndReportsItInResult() } } + [Test] + public async Task CreateFolderAsync_CreatesFolder_WhenAbsent() + { + var folder = new ResourceKey("new-folder"); + var folderPath = Path.Combine(_tempFolder, "new-folder"); + _resourceRegistry.ResolveResourcePath(folder).Returns(Result.Ok(folderPath)); + + var result = await _fileStorage.CreateFolderAsync(folder); + + result.IsSuccess.Should().BeTrue(); + Directory.Exists(folderPath).Should().BeTrue(); + } + + [Test] + public async Task CreateFolderAsync_IsIdempotent_WhenFolderAlreadyExists() + { + var folder = new ResourceKey("existing"); + var folderPath = Path.Combine(_tempFolder, "existing"); + Directory.CreateDirectory(folderPath); + _resourceRegistry.ResolveResourcePath(folder).Returns(Result.Ok(folderPath)); + + var result = await _fileStorage.CreateFolderAsync(folder); + + result.IsSuccess.Should().BeTrue(); + Directory.Exists(folderPath).Should().BeTrue(); + } + + [Test] + public async Task CreateFolderAsync_CreatesMissingIntermediateParents() + { + var folder = new ResourceKey("outer/middle/inner"); + var folderPath = Path.Combine(_tempFolder, "outer", "middle", "inner"); + _resourceRegistry.ResolveResourcePath(folder).Returns(Result.Ok(folderPath)); + + var result = await _fileStorage.CreateFolderAsync(folder); + + result.IsSuccess.Should().BeTrue(); + Directory.Exists(folderPath).Should().BeTrue(); + Directory.Exists(Path.Combine(_tempFolder, "outer", "middle")).Should().BeTrue(); + Directory.Exists(Path.Combine(_tempFolder, "outer")).Should().BeTrue(); + } + + [Test] + public async Task CreateFolderAsync_FailsWhenPathIsAlreadyAFile() + { + var folder = new ResourceKey("collision"); + var folderPath = Path.Combine(_tempFolder, "collision"); + await File.WriteAllTextAsync(folderPath, "I am a file"); + _resourceRegistry.ResolveResourcePath(folder).Returns(Result.Ok(folderPath)); + + var result = await _fileStorage.CreateFolderAsync(folder); + + result.IsFailure.Should().BeTrue(); + File.Exists(folderPath).Should().BeTrue(); + } + + [Test] + public async Task CreateFolderAsync_ReturnsFailure_WhenResolveFails() + { + var folder = new ResourceKey("bad"); + _resourceRegistry.ResolveResourcePath(folder) + .Returns(Result.Fail("simulated resolve failure")); + + var result = await _fileStorage.CreateFolderAsync(folder); + + result.IsFailure.Should().BeTrue(); + } + [Test] public async Task ReadAllBytesAsync_FailsImmediately_WhenFileMissing_WithoutRetry() { diff --git a/Source/Tests/Resources/TrashServiceTests.cs b/Source/Tests/Resources/TrashServiceTests.cs new file mode 100644 index 000000000..8ef726ab7 --- /dev/null +++ b/Source/Tests/Resources/TrashServiceTests.cs @@ -0,0 +1,283 @@ +using Celbridge.Entities; +using Celbridge.Logging; +using Celbridge.Projects; +using Celbridge.Resources; +using Celbridge.Resources.Helpers; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for TrashService — soft-delete moves, sidecar pairing, folder vs +/// file dispatch, restore round-trip, and purge cleanup. +/// +[TestFixture] +public class TrashServiceTests +{ + private string _tempFolder = null!; + private IResourceRegistry _resourceRegistry = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + private TrashService _trashService = null!; + + [SetUp] + public void Setup() + { + _tempFolder = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(TrashServiceTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempFolder); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + // Default to failure for any unstubbed resolve; specific test setups + // (per-Test) override with success results for the keys they exercise. + _resourceRegistry.ResolveResourcePath(Arg.Any()) + .Returns(Result.Fail("not stubbed")); + _resourceRegistry.GetResourceKey(Arg.Any()) + .Returns(Result.Fail("not stubbed")); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + _workspaceWrapper.IsWorkspacePageLoaded.Returns(false); + + var sidecarService = new SidecarService(_workspaceWrapper); + workspaceService.SidecarService.Returns(sidecarService); + + _trashService = new TrashService( + Substitute.For>(), + _workspaceWrapper); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempFolder)) + { + ClearReadOnlyRecursive(_tempFolder); + Directory.Delete(_tempFolder, true); + } + } + + [Test] + public async Task MoveToTrashAsync_File_MovesFileIntoTrash() + { + var resource = new ResourceKey("file.txt"); + var path = Path.Combine(_tempFolder, "file.txt"); + await File.WriteAllTextAsync(path, "contents"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _trashService.MoveToTrashAsync(resource); + + result.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeFalse(); + var entry = result.Value; + entry.WasFolder.Should().BeFalse(); + File.Exists(entry.TrashPath).Should().BeTrue(); + (await File.ReadAllTextAsync(entry.TrashPath)).Should().Be("contents"); + } + + [Test] + public async Task MoveToTrashAsync_File_CascadesPairedSidecar() + { + var resource = new ResourceKey("doc.txt"); + var path = Path.Combine(_tempFolder, "doc.txt"); + var sidecarPath = path + SidecarHelper.Extension; + await File.WriteAllTextAsync(path, "main"); + await File.WriteAllTextAsync(sidecarPath, "+++\ntitle = 'Doc'\n+++\n"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + _resourceRegistry.ResolveResourcePath(new ResourceKey("doc.txt.cel")).Returns(Result.Ok(sidecarPath)); + + var result = await _trashService.MoveToTrashAsync(resource); + + result.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeFalse(); + File.Exists(sidecarPath).Should().BeFalse(); + var entry = result.Value; + entry.SidecarOriginalPath.Should().Be(sidecarPath); + entry.SidecarTrashPath.Should().NotBeNull(); + File.Exists(entry.SidecarTrashPath!).Should().BeTrue(); + } + + [Test] + public async Task MoveToTrashAsync_File_FailsWhenSourceMissing() + { + var resource = new ResourceKey("missing.txt"); + var path = Path.Combine(_tempFolder, "missing.txt"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _trashService.MoveToTrashAsync(resource); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task MoveToTrashAsync_EmptyFolder_RecordsEmptyFlag_AndRemovesFolder() + { + var resource = new ResourceKey("empty"); + var path = Path.Combine(_tempFolder, "empty"); + Directory.CreateDirectory(path); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var result = await _trashService.MoveToTrashAsync(resource); + + result.IsSuccess.Should().BeTrue(); + Directory.Exists(path).Should().BeFalse(); + var entry = result.Value; + entry.WasFolder.Should().BeTrue(); + entry.WasEmptyFolder.Should().BeTrue(); + entry.TrashPath.Should().BeEmpty(); + } + + [Test] + public async Task MoveToTrashAsync_NonEmptyFolder_MovesSubtree_AndCapturesDescendantKeys() + { + var resource = new ResourceKey("folder"); + var folderPath = Path.Combine(_tempFolder, "folder"); + Directory.CreateDirectory(folderPath); + var childPath = Path.Combine(folderPath, "child.txt"); + await File.WriteAllTextAsync(childPath, "child"); + + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(folderPath)); + _resourceRegistry.GetResourceKey(childPath).Returns(Result.Ok(new ResourceKey("folder/child.txt"))); + + var result = await _trashService.MoveToTrashAsync(resource); + + result.IsSuccess.Should().BeTrue(); + Directory.Exists(folderPath).Should().BeFalse(); + var entry = result.Value; + entry.WasFolder.Should().BeTrue(); + entry.WasEmptyFolder.Should().BeFalse(); + Directory.Exists(entry.TrashPath).Should().BeTrue(); + entry.DescendantKeys.Should().ContainSingle().Which.Path.Should().Be("folder/child.txt"); + } + + [Test] + public async Task RestoreFromTrashAsync_File_RestoresOriginalContent() + { + var resource = new ResourceKey("restore.txt"); + var path = Path.Combine(_tempFolder, "restore.txt"); + await File.WriteAllTextAsync(path, "before-trash"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var trashResult = await _trashService.MoveToTrashAsync(resource); + trashResult.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeFalse(); + + var restoreResult = await _trashService.RestoreFromTrashAsync(trashResult.Value); + + restoreResult.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeTrue(); + (await File.ReadAllTextAsync(path)).Should().Be("before-trash"); + } + + [Test] + public async Task RestoreFromTrashAsync_EmptyFolder_RecreatesFolder() + { + var resource = new ResourceKey("empty"); + var path = Path.Combine(_tempFolder, "empty"); + Directory.CreateDirectory(path); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var trashResult = await _trashService.MoveToTrashAsync(resource); + trashResult.IsSuccess.Should().BeTrue(); + + var restoreResult = await _trashService.RestoreFromTrashAsync(trashResult.Value); + + restoreResult.IsSuccess.Should().BeTrue(); + Directory.Exists(path).Should().BeTrue(); + } + + [Test] + public async Task RestoreFromTrashAsync_NonEmptyFolder_RestoresSubtree() + { + var resource = new ResourceKey("folder"); + var folderPath = Path.Combine(_tempFolder, "folder"); + Directory.CreateDirectory(folderPath); + var childPath = Path.Combine(folderPath, "child.txt"); + await File.WriteAllTextAsync(childPath, "kept"); + + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(folderPath)); + _resourceRegistry.GetResourceKey(childPath).Returns(Result.Ok(new ResourceKey("folder/child.txt"))); + + var trashResult = await _trashService.MoveToTrashAsync(resource); + trashResult.IsSuccess.Should().BeTrue(); + + var restoreResult = await _trashService.RestoreFromTrashAsync(trashResult.Value); + + restoreResult.IsSuccess.Should().BeTrue(); + Directory.Exists(folderPath).Should().BeTrue(); + File.Exists(childPath).Should().BeTrue(); + (await File.ReadAllTextAsync(childPath)).Should().Be("kept"); + } + + [Test] + public async Task PurgeAsync_File_RemovesTrashBytes() + { + var resource = new ResourceKey("purgeme.txt"); + var path = Path.Combine(_tempFolder, "purgeme.txt"); + await File.WriteAllTextAsync(path, "doomed"); + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + var trashResult = await _trashService.MoveToTrashAsync(resource); + trashResult.IsSuccess.Should().BeTrue(); + var trashPath = trashResult.Value.TrashPath; + File.Exists(trashPath).Should().BeTrue(); + + var purgeResult = await _trashService.PurgeAsync(trashResult.Value); + + purgeResult.IsSuccess.Should().BeTrue(); + File.Exists(trashPath).Should().BeFalse(); + } + + [Test] + public async Task MoveToTrashAsync_File_ClearsReadOnlyAttribute() + { + var resource = new ResourceKey("readonly.txt"); + var path = Path.Combine(_tempFolder, "readonly.txt"); + await File.WriteAllTextAsync(path, "locked"); + new FileInfo(path).IsReadOnly = true; + _resourceRegistry.ResolveResourcePath(resource).Returns(Result.Ok(path)); + + try + { + var result = await _trashService.MoveToTrashAsync(resource); + + result.IsSuccess.Should().BeTrue(); + File.Exists(path).Should().BeFalse(); + } + finally + { + // Ensure tear-down can delete the temp tree even if the test failed. + ClearReadOnlyRecursive(_tempFolder); + } + } + + private static void ClearReadOnlyRecursive(string folder) + { + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + { + var info = new FileInfo(file); + if (info.Exists + && info.IsReadOnly) + { + info.IsReadOnly = false; + } + } + } + catch + { + // Best effort. + } + } +} diff --git a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs index 0e52891f2..10bbe19c5 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ArchiveResourceCommand.cs @@ -105,22 +105,6 @@ private async Task ExecuteArchiveAsync() return Result.Fail($"Invalid archive resource key: '{ArchiveResource}'"); } - var resolveSourceResult = resourceRegistry.ResolveResourcePath(SourceResource); - if (resolveSourceResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{SourceResource}'") - .WithErrors(resolveSourceResult); - } - var sourcePath = resolveSourceResult.Value; - - var resolveArchiveResult = resourceRegistry.ResolveResourcePath(ArchiveResource); - if (resolveArchiveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{ArchiveResource}'") - .WithErrors(resolveArchiveResult); - } - var archivePath = resolveArchiveResult.Value; - var sourceInfoResult = await fileStorage.GetInfoAsync(SourceResource); if (sourceInfoResult.IsFailure) { @@ -150,7 +134,7 @@ private async Task ExecuteArchiveAsync() // If overwriting, delete the existing file first so it can be restored on undo if (Overwrite && archiveExists) { - var deleteResult = await resourceOpService.DeleteFileAsync(archivePath); + var deleteResult = await resourceOpService.DeleteAsync(ArchiveResource); if (deleteResult.IsFailure) { return deleteResult; @@ -211,7 +195,7 @@ private async Task ExecuteArchiveAsync() return Result.Fail($"Failed to create archive: {exception.Message}"); } - var createResult = await resourceOpService.CreateFileAsync(archivePath, archiveBytes); + var createResult = await resourceOpService.CreateFileAsync(ArchiveResource, archiveBytes); if (createResult.IsFailure) { return createResult; diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 9de0aad1c..89bf85aa6 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -37,7 +37,6 @@ public class CopyResourceCommand : CommandBase, ICopyResourceCommand private readonly ICommandService _commandService; private IFileStorage FileStorage => _workspaceWrapper.WorkspaceService.FileStorage; - private IResourceRegistry ResourceRegistry => _workspaceWrapper.WorkspaceService.ResourceService.Registry; private IResourceOperationService ResourceOperationService => _workspaceWrapper.WorkspaceService.ResourceService.OperationService; private IResourceTransferService ResourceTransferService => _workspaceWrapper.WorkspaceService.ResourceService.TransferService; @@ -181,33 +180,6 @@ private async Task CopySingleResourceAsync(ResourceKey sour // Resolve destination to handle folder drops var resolvedDestResource = ResourceTransferService.ResolveDestinationResource(sourceResource, DestResource); - // Convert resource keys to absolute paths via the registry so root prefixes - // (project:, temp:, logs:) are stripped correctly. Path.Combine with the bare - // ResourceKey would incorporate the prefix as a literal directory component. - var resolveSourceResult = ResourceRegistry.ResolveResourcePath(sourceResource); - if (resolveSourceResult.IsFailure) - { - return new CopyResourceOutcome( - Result.Fail($"Failed to resolve path for source resource: '{sourceResource}'") - .WithErrors(resolveSourceResult), - ParentFolder: null, - CopiedFolder: null, - MoveDetail: null); - } - var sourcePath = resolveSourceResult.Value; - - var resolveDestResult = ResourceRegistry.ResolveResourcePath(resolvedDestResource); - if (resolveDestResult.IsFailure) - { - return new CopyResourceOutcome( - Result.Fail($"Failed to resolve path for destination resource: '{resolvedDestResource}'") - .WithErrors(resolveDestResult), - ParentFolder: null, - CopiedFolder: null, - MoveDetail: null); - } - var destPath = resolveDestResult.Value; - var infoResult = await FileStorage.GetInfoAsync(sourceResource); if (infoResult.IsFailure) { @@ -219,13 +191,12 @@ private async Task CopySingleResourceAsync(ResourceKey sour MoveDetail: null); } var info = infoResult.Value; - bool isFile = info.Kind == StorageItemKind.File; bool isFolder = info.Kind == StorageItemKind.Folder; - if (!isFile && !isFolder) + if (info.Kind == StorageItemKind.NotFound) { return new CopyResourceOutcome( - Result.Fail($"Resource does not exist: {sourcePath}"), + Result.Fail($"Resource does not exist: '{sourceResource}'"), ParentFolder: null, CopiedFolder: null, MoveDetail: null); @@ -234,36 +205,18 @@ private async Task CopySingleResourceAsync(ResourceKey sour Result result; MoveResult? moveDetail = null; - if (isFile) + if (TransferMode == DataTransferMode.Copy) { - if (TransferMode == DataTransferMode.Copy) - { - result = await ResourceOperationService.CopyFileAsync(sourcePath, destPath); - } - else - { - var moveResult = await ResourceOperationService.MoveFileAsync(sourcePath, destPath); - result = moveResult; - if (moveResult.IsSuccess) - { - moveDetail = moveResult.Value; - } - } + var copyResult = await ResourceOperationService.CopyAsync(sourceResource, resolvedDestResource); + result = copyResult; } else { - if (TransferMode == DataTransferMode.Copy) - { - result = await ResourceOperationService.CopyFolderAsync(sourcePath, destPath); - } - else + var moveResult = await ResourceOperationService.MoveAsync(sourceResource, resolvedDestResource); + result = moveResult; + if (moveResult.IsSuccess) { - var moveResult = await ResourceOperationService.MoveFolderAsync(sourcePath, destPath); - result = moveResult; - if (moveResult.IsSuccess) - { - moveDetail = moveResult.Value; - } + moveDetail = moveResult.Value; } } diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index c01f42ed9..0150757a2 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -26,7 +26,6 @@ public class DeleteResourceCommand : CommandBase, IDeleteResourceCommand private readonly IDialogService _dialogService; private IFileStorage FileStorage => _workspaceWrapper.WorkspaceService.FileStorage; - private IResourceRegistry ResourceRegistry => _workspaceWrapper.WorkspaceService.ResourceService.Registry; private IResourceOperationService ResourceOperationService => _workspaceWrapper.WorkspaceService.ResourceService.OperationService; private IResourceScanner ResourceScanner => _workspaceWrapper.WorkspaceService.ResourceScanner; private ISidecarService SidecarService => _workspaceWrapper.WorkspaceService.SidecarService; @@ -178,20 +177,6 @@ bool IsInsideBatch(ResourceKey candidate) { foreach (var resource in Resources) { - var resolveResult = ResourceRegistry.ResolveResourcePath(resource); - if (resolveResult.IsFailure) - { - _logger.LogWarning($"Cannot delete resource because path could not be resolved: '{resource}'"); - resourceResults.Add(new DeleteResourceResult( - resource, - DeleteResourceOutcome.IOFailure, - SidecarOutcome.NotPresent, - FailureMessage: resolveResult.FirstErrorMessage)); - failedItems.Add(resource.ResourceName); - continue; - } - var resourcePath = resolveResult.Value; - // Probe the sidecar up front so we can report whether the delete // cascaded one. After the delete runs the sidecar is gone (or // never existed), so the only honest moment to ask is now. @@ -218,16 +203,7 @@ bool IsInsideBatch(ResourceKey candidate) } var info = infoResult.Value; - Result deleteResult; - if (info.Kind == StorageItemKind.File) - { - deleteResult = await ResourceOperationService.DeleteFileAsync(resourcePath); - } - else if (info.Kind == StorageItemKind.Folder) - { - deleteResult = await ResourceOperationService.DeleteFolderAsync(resourcePath); - } - else + if (info.Kind == StorageItemKind.NotFound) { _logger.LogWarning($"Cannot delete resource because it does not exist: '{resource}'"); resourceResults.Add(new DeleteResourceResult( @@ -239,6 +215,8 @@ bool IsInsideBatch(ResourceKey candidate) continue; } + var deleteResult = await ResourceOperationService.DeleteAsync(resource); + if (deleteResult.IsFailure) { var classification = ClassifyDeleteFailure(deleteResult); diff --git a/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs index 6e77b4ca5..57dfb75c2 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs @@ -117,14 +117,6 @@ private async Task AddExternalResourceAsync( IResourceRegistry resourceRegistry, IResourceOperationService resourceOpService) { - var resolveDestResult = resourceRegistry.ResolveResourcePath(item.DestResource); - if (resolveDestResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{item.DestResource}'") - .WithErrors(resolveDestResult); - } - var destPath = resolveDestResult.Value; - if (item.ResourceType == ResourceType.File) { if (!File.Exists(item.SourcePath)) @@ -132,7 +124,7 @@ private async Task AddExternalResourceAsync( return Result.Fail($"Source file does not exist: {item.SourcePath}"); } - return await resourceOpService.CopyFileAsync(item.SourcePath, destPath); + return await resourceOpService.ImportExternalFileAsync(item.SourcePath, item.DestResource); } else if (item.ResourceType == ResourceType.Folder) { @@ -141,7 +133,7 @@ private async Task AddExternalResourceAsync( return Result.Fail($"Source folder does not exist: {item.SourcePath}"); } - return await resourceOpService.CopyFolderAsync(item.SourcePath, destPath); + return await resourceOpService.ImportExternalFolderAsync(item.SourcePath, item.DestResource); } return Result.Fail($"Invalid resource type: {item.ResourceType}"); @@ -155,21 +147,7 @@ private async Task CopyInternalResourceAsync( { var resolvedDestResource = transferService.ResolveDestinationResource(item.SourceResource, item.DestResource); - var resolveSourceResult = resourceRegistry.ResolveResourcePath(item.SourceResource); - if (resolveSourceResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{item.SourceResource}'") - .WithErrors(resolveSourceResult); - } - - var resolveDestResult = resourceRegistry.ResolveResourcePath(resolvedDestResource); - if (resolveDestResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{resolvedDestResource}'") - .WithErrors(resolveDestResult); - } - - var result = await resourceOpService.TransferAsync(resolveSourceResult.Value, resolveDestResult.Value, TransferMode); + var result = await resourceOpService.TransferAsync(item.SourceResource, resolvedDestResource, TransferMode); // Expand destination parent folder if (result.IsSuccess) diff --git a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs index 0c918a4f3..d92768959 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs @@ -2,7 +2,6 @@ using Celbridge.Commands; using Celbridge.Logging; using Celbridge.Resources.Helpers; -using Celbridge.Resources.Services; using Celbridge.Workspace; namespace Celbridge.Resources.Commands; @@ -68,14 +67,9 @@ private async Task ExecuteExtractAsync() return Result.Fail($"Invalid destination resource key: '{DestinationResource}'"); } - var resolveArchiveResult = resourceRegistry.ResolveResourcePath(ArchiveResource); - if (resolveArchiveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{ArchiveResource}'") - .WithErrors(resolveArchiveResult); - } - var archivePath = resolveArchiveResult.Value; - + // Path resolution is still needed for entry-name validation and the + // zip-slip canonicalization check below; the operation-service writes + // themselves take ResourceKey arguments after cm-9c. var resolveDestinationResult = resourceRegistry.ResolveResourcePath(DestinationResource); if (resolveDestinationResult.IsFailure) { @@ -192,6 +186,10 @@ private async Task ExecuteExtractAsync() if (destInfoResult.Value.Kind == StorageItemKind.NotFound) { + // CreateFolderAsync on the chokepoint is idempotent and + // creates missing intermediate parents in one call. We still + // collect and create ancestors one-at-a-time so each lands + // as its own undoable operation inside the batch. var missingAncestorKeys = new List(); var ancestorKey = DestinationResource.GetParent(); while (!ancestorKey.IsEmpty) @@ -213,20 +211,14 @@ private async Task ExecuteExtractAsync() foreach (var key in missingAncestorKeys) { - var ancestorPathResult = resourceRegistry.ResolveResourcePath(key); - if (ancestorPathResult.IsFailure) - { - return Result.Fail($"Failed to resolve ancestor path: '{key}'") - .WithErrors(ancestorPathResult); - } - var createAncestorResult = await resourceOpService.CreateFolderAsync(ancestorPathResult.Value); + var createAncestorResult = await resourceOpService.CreateFolderAsync(key); if (createAncestorResult.IsFailure) { return createAncestorResult; } } - var createDestResult = await resourceOpService.CreateFolderAsync(destinationPath); + var createDestResult = await resourceOpService.CreateFolderAsync(DestinationResource); if (createDestResult.IsFailure) { return createDestResult; @@ -234,7 +226,7 @@ private async Task ExecuteExtractAsync() } // Filter shallowest-first so parents are created before children. - var sortedFolders = new List(); + var sortedFolderKeys = new List(); foreach (var folderPath in foldersToCreate.OrderBy(path => path.Length)) { var folderKey = BuildDescendantKey(DestinationResource, destinationPath, folderPath); @@ -246,13 +238,13 @@ private async Task ExecuteExtractAsync() } if (folderInfoResult.Value.Kind == StorageItemKind.NotFound) { - sortedFolders.Add(folderPath); + sortedFolderKeys.Add(folderKey); } } - foreach (var folderPath in sortedFolders) + foreach (var folderKey in sortedFolderKeys) { - var createFolderResult = await resourceOpService.CreateFolderAsync(folderPath); + var createFolderResult = await resourceOpService.CreateFolderAsync(folderKey); if (createFolderResult.IsFailure) { return createFolderResult; @@ -262,17 +254,16 @@ private async Task ExecuteExtractAsync() // Extract files foreach (var entry in validEntries) { - var outputPath = Path.Combine(destinationPath, entry.FullName.Replace('/', Path.DirectorySeparatorChar)); + var entryResource = DestinationResource.Combine(entry.FullName); // If overwriting, delete existing file first so it's preserved in trash for undo if (Overwrite) { - var entryResource = DestinationResource.Combine(entry.FullName); var existingInfoResult = await fileStorage.GetInfoAsync(entryResource); if (existingInfoResult.IsSuccess && existingInfoResult.Value.Kind == StorageItemKind.File) { - var deleteResult = await resourceOpService.DeleteFileAsync(outputPath); + var deleteResult = await resourceOpService.DeleteAsync(entryResource); if (deleteResult.IsFailure) { return deleteResult; @@ -286,7 +277,7 @@ private async Task ExecuteExtractAsync() await entryStream.CopyToAsync(memoryStream); var entryBytes = memoryStream.ToArray(); - var createResult = await resourceOpService.CreateFileAsync(outputPath, entryBytes); + var createResult = await resourceOpService.CreateFileAsync(entryResource, entryBytes); if (createResult.IsFailure) { return createResult; diff --git a/Source/Workspace/Celbridge.Resources/Helpers/AddResourceHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/AddResourceHelper.cs index 1a3dbe789..bc8873211 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/AddResourceHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/AddResourceHelper.cs @@ -54,28 +54,28 @@ public async Task AddResourceAsync( // Create the resource on disk // - var resolveResult = resourceRegistry.ResolveResourcePath(destResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{destResource}'") - .WithErrors(resolveResult); - } - var destPath = resolveResult.Value; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; // Fail if the parent folder for a new file does not exist. - // Folders are allowed to create intermediate parents via Directory.CreateDirectory(). + // Folder creation is allowed to materialize missing intermediate + // ancestors via the chokepoint's idempotent CreateFolderAsync. if (resourceType == ResourceType.File) { - var parentFolderPath = Path.GetDirectoryName(destPath); - if (!Directory.Exists(parentFolderPath)) + var parentKey = destResource.GetParent(); + if (!parentKey.IsEmpty) { - return Result.Fail($"Failed to create resource. Parent folder does not exist: '{parentFolderPath}'"); + var parentInfoResult = await fileStorage.GetInfoAsync(parentKey); + if (parentInfoResult.IsFailure + || parentInfoResult.Value.Kind != StorageItemKind.Folder) + { + return Result.Fail($"Failed to create resource. Parent folder does not exist: '{parentKey}'"); + } } } var createResult = resourceType == ResourceType.File - ? await CreateFileAsync(sourcePath, destPath, destResource, resourceOpService) - : await CreateFolderAsync(sourcePath, destPath, destResource, resourceOpService); + ? await CreateFileAsync(sourcePath, destResource, resourceOpService, fileStorage) + : await CreateFolderAsync(sourcePath, destResource, resourceOpService, fileStorage); if (createResult.IsFailure) { @@ -108,20 +108,30 @@ private static Result ValidateDestResource(ResourceKey destResource) private async Task CreateFileAsync( string sourcePath, - string destPath, ResourceKey destResource, - IResourceOperationService opService) + IResourceOperationService opService, + IFileStorage fileStorage) { - if (File.Exists(destPath)) + var infoResult = await fileStorage.GetInfoAsync(destResource); + if (infoResult.IsSuccess + && infoResult.Value.Kind != StorageItemKind.NotFound) { - return Result.Fail($"A file already exists at '{destPath}'."); + return Result.Fail($"A resource already exists at '{destResource}'."); } if (string.IsNullOrEmpty(sourcePath)) { - // Create a new empty file - var content = _fileTemplateService.GetNewFileContent(destPath); - var createResult = await opService.CreateFileAsync(destPath, content); + // Create a new empty file. The template service still consumes a + // path to discriminate by file extension; the chokepoint write + // takes the resource key. + var destPathResult = _workspaceWrapper.WorkspaceService.ResourceService.Registry.ResolveResourcePath(destResource); + if (destPathResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{destResource}'") + .WithErrors(destPathResult); + } + var content = _fileTemplateService.GetNewFileContent(destPathResult.Value); + var createResult = await opService.CreateFileAsync(destResource, content); if (createResult.IsFailure) { return Result.Fail($"Failed to create resource: {destResource}") @@ -136,24 +146,26 @@ private async Task CreateFileAsync( return Result.Fail($"Failed to create resource. Source file '{sourcePath}' does not exist."); } - return await opService.CopyFileAsync(sourcePath, destPath); + return await opService.ImportExternalFileAsync(sourcePath, destResource); } private static async Task CreateFolderAsync( string sourcePath, - string destPath, ResourceKey destResource, - IResourceOperationService opService) + IResourceOperationService opService, + IFileStorage fileStorage) { - if (Directory.Exists(destPath)) + var infoResult = await fileStorage.GetInfoAsync(destResource); + if (infoResult.IsSuccess + && infoResult.Value.Kind != StorageItemKind.NotFound) { - return Result.Fail($"A folder already exists at '{destPath}'."); + return Result.Fail($"A resource already exists at '{destResource}'."); } if (string.IsNullOrEmpty(sourcePath)) { // Create a new empty folder - var createResult = await opService.CreateFolderAsync(destPath); + var createResult = await opService.CreateFolderAsync(destResource); if (createResult.IsFailure) { return Result.Fail($"Failed to create folder: {destResource}") @@ -168,7 +180,7 @@ private static async Task CreateFolderAsync( return Result.Fail($"Failed to create resource. Source folder '{sourcePath}' does not exist."); } - return await opService.CopyFolderAsync(sourcePath, destPath); + return await opService.ImportExternalFolderAsync(sourcePath, destResource); } private void ExpandParentFolder(ResourceKey destResource) diff --git a/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs deleted file mode 100644 index 078f90582..000000000 --- a/Source/Workspace/Celbridge.Resources/Helpers/FileSystemHelper.cs +++ /dev/null @@ -1,121 +0,0 @@ -namespace Celbridge.Resources.Helpers; - -/// -/// Helper class for common file system operations used by file operations. -/// -internal static class FileSystemHelper -{ - // Retry budget for cross-process sharing-violation races on file/folder - // moves. After a file is created, the OS, antivirus, search indexer, or - // shell can briefly hold a read handle on the file, which surfaces as an - // IOException ("being used by another process") on an immediate File.Move - // or Directory.Move. Mirrors the read/write retry budgets in - // FileStorage; worst-case wait across all attempts is - // MoveRetryBaseDelayMs * (1 + 2) = 150ms with the values below. - private const int MaxMoveAttempts = 3; - private const int MoveRetryBaseDelayMs = 50; - - /// - /// Moves a file to a destination, creating the destination directory if needed. - /// Retries briefly on transient IOException to absorb cross-process sharing - /// races; non-IO exceptions fall through unchanged. - /// - public static async Task MoveFileWithDirectoryCreationAsync(string sourcePath, string destPath) - { - var destDir = Path.GetDirectoryName(destPath)!; - Directory.CreateDirectory(destDir); - await MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); - } - - /// - /// Copies a file to a destination, creating the destination directory if needed. - /// - public static void CopyFileWithDirectoryCreation(string sourcePath, string destPath) - { - var destDir = Path.GetDirectoryName(destPath)!; - Directory.CreateDirectory(destDir); - File.Copy(sourcePath, destPath); - } - - /// - /// Moves a directory to a destination, creating the parent directory if needed. - /// Retries briefly on transient IOException to absorb cross-process sharing - /// races; non-IO exceptions fall through unchanged. - /// - public static async Task MoveDirectoryWithParentCreationAsync(string sourcePath, string destPath) - { - var destParentDir = Path.GetDirectoryName(destPath)!; - Directory.CreateDirectory(destParentDir); - await MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); - } - - /// - /// Invokes a synchronous file/folder move operation with brief retries on - /// transient IOException. Non-IO exceptions and the last attempt's - /// IOException propagate unchanged so persistent failures surface - /// immediately. - /// - public static async Task MoveWithRetryAsync(Action moveOperation) - { - for (var attempt = 1; attempt <= MaxMoveAttempts; attempt++) - { - try - { - moveOperation(); - return; - } - catch (IOException) when (attempt < MaxMoveAttempts) - { - await Task.Delay(MoveRetryBaseDelayMs * attempt); - } - } - } - - /// - /// Deletes a file if it exists. - /// - public static void DeleteFileIfExists(string path) - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - - /// - /// Removes empty parent directories starting from the given path. - /// Continues up the directory tree until a non-empty directory is found. - /// This is a best-effort operation that silently ignores errors. - /// - public static void CleanupEmptyParentDirectories(string startPath) - { - try - { - var dir = Path.GetDirectoryName(startPath); - while (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) - { - if (Directory.GetFiles(dir).Length == 0 && Directory.GetDirectories(dir).Length == 0) - { - Directory.Delete(dir); - dir = Path.GetDirectoryName(dir); - } - else - { - break; - } - } - } - catch - { - // Best effort cleanup - ignore errors - } - } - - /// - /// Checks if a directory is empty (contains no files or subdirectories). - /// - public static bool IsDirectoryEmpty(string path) - { - return Directory.GetFiles(path).Length == 0 && Directory.GetDirectories(path).Length == 0; - } -} diff --git a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs index 02b8da684..70e3ff944 100644 --- a/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Resources/ServiceConfiguration.cs @@ -21,6 +21,7 @@ public static void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index b421a24ac..3ab1434d7 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -1,7 +1,6 @@ using System.Text; using Celbridge.Logging; using Celbridge.Projects; -using Celbridge.Resources.Helpers; using Celbridge.Utilities; using Celbridge.Workspace; @@ -242,11 +241,11 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey // attribute the user has explicitly chosen to override by // invoking a move on the file. ClearReadOnlyIfSet(sourcePath); - await FileSystemHelper.MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); + await MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); } else { - await FileSystemHelper.MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); + await MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); } } catch (UnauthorizedAccessException ex) @@ -465,6 +464,44 @@ private static IReadOnlyList EnumerateDescendantKeys(IRootHandlerRe return keys; } + public async Task CreateFolderAsync(ResourceKey folder) + { + await Task.CompletedTask; + + var resolveResult = ResolvePath(folder); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{folder}'") + .WithErrors(resolveResult); + } + var folderPath = resolveResult.Value; + + var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; + if (!IsRootWritable(rootHandlerRegistry, folder)) + { + return Result.Fail($"Root '{folder.Root}' is read-only."); + } + + if (File.Exists(folderPath)) + { + return Result.Fail($"Cannot create folder; a file already exists at: '{folder}'"); + } + + try + { + // Directory.CreateDirectory is idempotent: existing folders return + // the DirectoryInfo without error, and missing intermediate parents + // are created in the same call. + Directory.CreateDirectory(folderPath); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to create folder: '{folder}'") + .WithException(ex); + } + } + public async Task> GetInfoAsync(ResourceKey resource) { await Task.CompletedTask; @@ -866,7 +903,7 @@ private async Task TryCascadeSidecarMoveAsync(ResourceKey source Directory.CreateDirectory(destFolder); } - await FileSystemHelper.MoveWithRetryAsync(() => File.Move(sourceSidecarPath, destSidecarPath)); + await MoveWithRetryAsync(() => File.Move(sourceSidecarPath, destSidecarPath)); return SidecarOutcome.Cascaded; } catch (Exception ex) @@ -1163,6 +1200,27 @@ private static Result EnsureParentFolderExists(string resourcePath, ResourceKey } } + // Runs a synchronous move action under the chokepoint's bounded-retry + // policy. A file briefly held open by AV, indexer, or sync clients after + // creation clears within milliseconds; the same retry budget the read and + // write paths use catches the common races. Non-IO exceptions and the final + // attempt's IOException propagate unchanged so persistent failures surface. + private static async Task MoveWithRetryAsync(Action moveAction) + { + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + moveAction(); + return; + } + catch (IOException) when (attempt < MaxAttempts) + { + await Task.Delay(BaseRetryDelayMs * attempt); + } + } + } + // Writes bytes to a uniquely-named temp file inside the project's central // staging folder, then atomically replaces the destination via File.Move. // A unique filename per write prevents concurrent writers to the same @@ -1174,7 +1232,7 @@ private static async Task WriteAtomicAsync(string resourcePath, string stagingFo try { await File.WriteAllBytesAsync(tempPath, bytes); - await FileSystemHelper.MoveWithRetryAsync(() => File.Move(tempPath, resourcePath, overwrite: true)); + await MoveWithRetryAsync(() => File.Move(tempPath, resourcePath, overwrite: true)); } catch { diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index c4f002e28..03a812999 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -1,15 +1,18 @@ using Celbridge.DataTransfer; using Celbridge.Entities; using Celbridge.Logging; -using Celbridge.Projects; using Celbridge.Resources.Helpers; using Celbridge.Workspace; namespace Celbridge.Resources.Services; /// -/// Service for performing resource operations with undo/redo support. -/// Uses a soft-delete trash folder approach for delete operations. +/// Wraps the IFileStorage chokepoint and the ITrashService soft-delete layer +/// with a session-local undo/redo stack and batched grouping. Public methods +/// accept ResourceKey; external imports keep a string source path because the +/// source is outside the registry by definition. All actual disk I/O routes +/// through the chokepoint or the trash service; this class owns no direct +/// System.IO calls. /// public class ResourceOperationService : IResourceOperationService { @@ -34,29 +37,29 @@ public ResourceOperationService( _workspaceWrapper = workspaceWrapper; } + // Default outcomes returned when the chokepoint's cascade did not run + // (typically because the source is outside the project tree). + private static readonly CopyResult EmptyCopyResult = new(SidecarOutcome.NotPresent); + private static readonly MoveResult EmptyMoveResult = new( + Array.Empty(), + Array.Empty(), + SidecarOutcome.NotPresent); + private IEntityService? EntityService => _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.EntityService : null; - private IResourceRegistry? ResourceRegistry => - _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.ResourceService.Registry : null; - - private IFileStorage? FileStorage => - _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.FileStorage : null; + private IResourceRegistry ResourceRegistry => + _workspaceWrapper.WorkspaceService.ResourceService.Registry; - private string ProjectFolderPath => - _workspaceWrapper.IsWorkspacePageLoaded ? ResourceRegistry!.ProjectFolderPath : string.Empty; + private IFileStorage FileStorage => + _workspaceWrapper.WorkspaceService.FileStorage; - /// - /// Gets the path to the trash folder for soft-deleted files. - /// - private string TrashFolderPath => - Path.Combine(ProjectFolderPath, ProjectConstants.CelbridgeFolder, ProjectConstants.CelbridgeTrashFolder); + private ITrashService TrashService => + _workspaceWrapper.WorkspaceService.TrashService; - public async Task CreateFileAsync(string path, byte[] content) + public async Task CreateFileAsync(ResourceKey resource, byte[] content) { - path = Path.GetFullPath(path); - - var operation = new CreateFileOperation(path, content); + var operation = new CreateOperation(resource, content, FileStorage); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -67,11 +70,9 @@ public async Task CreateFileAsync(string path, byte[] content) return result; } - public async Task CreateFolderAsync(string path) + public async Task CreateFolderAsync(ResourceKey resource) { - path = Path.GetFullPath(path); - - var operation = new CreateFolderOperation(path); + var operation = new CreateOperation(resource, FileStorage); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -82,406 +83,168 @@ public async Task CreateFolderAsync(string path) return result; } - // Default outcome returned when the FS-layer cascade did not run (e.g. - // external import via the path-based fallback). Callers treating the empty - // structure as "no cascade work was applicable" stay symmetric with the - // real-cascade case. - private static readonly CopyResult EmptyCopyResult = new(SidecarOutcome.NotPresent); - private static readonly MoveResult EmptyMoveResult = new( - Array.Empty(), - Array.Empty(), - SidecarOutcome.NotPresent); - - public async Task> CopyFileAsync(string sourcePath, string destPath) + public async Task> CopyAsync(ResourceKey source, ResourceKey destination) { - sourcePath = Path.GetFullPath(sourcePath); - destPath = Path.GetFullPath(destPath); - - // External-import callers (TransferResourcesCommand.AddExternalResourceAsync, - // AddResourceHelper) supply a source path outside the project folder. The - // FS-layer cascade does not apply: the implicit ".cel" sidecar - // lookup is rooted in resource keys and can't address external paths, and - // external bytes have no inbound references in this project. Sidecars that - // are explicitly selected (file-by-file) or contained in a copied folder - // come along as ordinary bytes via the path-based fallback; the registry's - // pairing pass picks them up on the next sync. Stale "project:" references - // inside imported sidecar bodies surface via data_check_project. - if (!IsInProjectFolder(sourcePath)) + var sourcePathResult = ResourceRegistry.ResolveResourcePath(source); + if (sourcePathResult.IsFailure) { - return await CopyExternalFileAsync(sourcePath, destPath); + return Result.Fail($"Failed to resolve path for source resource: '{source}'") + .WithErrors(sourcePathResult); } + var sourcePath = sourcePathResult.Value; - var keyResult = ResolveOperationKeys(sourcePath, destPath); - if (keyResult.IsFailure) + var destinationPathResult = ResourceRegistry.ResolveResourcePath(destination); + if (destinationPathResult.IsFailure) { - return Result.Fail(keyResult); + return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") + .WithErrors(destinationPathResult); } - var fileStorage = FileStorage; - if (fileStorage is null) + var destinationPath = destinationPathResult.Value; + + var infoResult = await FileStorage.GetInfoAsync(source); + if (infoResult.IsFailure + || infoResult.Value.Kind == StorageItemKind.NotFound) { - return Result.Fail("Workspace is not loaded; file storage is unavailable."); + return Result.Fail($"Source resource does not exist: '{source}'"); } + bool isFolder = infoResult.Value.Kind == StorageItemKind.Folder; - var operation = new CopyFileOperation( + var entityHelper = new EntityFileHelper(EntityService, ResourceRegistry); + var operation = new CopyOperation( + source, + destination, + isFolder, sourcePath, - destPath, - keyResult.Value.Source, - keyResult.Value.Destination, - EntityService, - ResourceRegistry, - fileStorage); - var execResult = await operation.ExecuteAsync(); + destinationPath, + entityHelper, + FileStorage); - if (execResult.IsFailure) + var executeResult = await operation.ExecuteAsync(); + if (executeResult.IsFailure) { - return Result.Fail(execResult); + return Result.Fail(executeResult); } AddOperation(operation); return operation.LastCopyResult ?? EmptyCopyResult; } - private async Task> CopyExternalFileAsync(string sourcePath, string destPath) + public async Task> MoveAsync(ResourceKey source, ResourceKey destination) { - var operation = new CopyExternalFileOperation(sourcePath, destPath); - var execResult = await operation.ExecuteAsync(); - if (execResult.IsFailure) + var sourcePathResult = ResourceRegistry.ResolveResourcePath(source); + if (sourcePathResult.IsFailure) { - return Result.Fail(execResult); + return Result.Fail($"Failed to resolve path for source resource: '{source}'") + .WithErrors(sourcePathResult); } - AddOperation(operation); - return EmptyCopyResult; - } + var sourcePath = sourcePathResult.Value; - private async Task> CopyExternalFolderAsync(string sourcePath, string destPath) - { - var operation = new CopyExternalFolderOperation(sourcePath, destPath); - var execResult = await operation.ExecuteAsync(); - if (execResult.IsFailure) + var destinationPathResult = ResourceRegistry.ResolveResourcePath(destination); + if (destinationPathResult.IsFailure) { - return Result.Fail(execResult); + return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") + .WithErrors(destinationPathResult); } - AddOperation(operation); - return EmptyCopyResult; - } + var destinationPath = destinationPathResult.Value; - private bool IsInProjectFolder(string absolutePath) - { - var projectFolderPath = ProjectFolderPath; - if (string.IsNullOrEmpty(projectFolderPath)) + var infoResult = await FileStorage.GetInfoAsync(source); + if (infoResult.IsFailure + || infoResult.Value.Kind == StorageItemKind.NotFound) { - return false; + return Result.Fail($"Source resource does not exist: '{source}'"); } + bool isFolder = infoResult.Value.Kind == StorageItemKind.Folder; - var normalizedProject = Path.GetFullPath(projectFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var normalizedPath = Path.GetFullPath(absolutePath); - return normalizedPath.StartsWith(normalizedProject + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - || string.Equals(normalizedPath, normalizedProject, StringComparison.OrdinalIgnoreCase); - } - - public async Task> MoveFileAsync(string sourcePath, string destPath) - { - sourcePath = Path.GetFullPath(sourcePath); - destPath = Path.GetFullPath(destPath); - - var keyResult = ResolveOperationKeys(sourcePath, destPath); - if (keyResult.IsFailure) - { - return Result.Fail(keyResult); - } - var fileStorage = FileStorage; - if (fileStorage is null) - { - return Result.Fail("Workspace is not loaded; file storage is unavailable."); - } - - var operation = new MoveFileOperation( + var entityHelper = new EntityFileHelper(EntityService, ResourceRegistry); + var operation = new MoveOperation( + source, + destination, + isFolder, sourcePath, - destPath, - keyResult.Value.Source, - keyResult.Value.Destination, - EntityService, - ResourceRegistry, - fileStorage); - var execResult = await operation.ExecuteAsync(); + destinationPath, + entityHelper, + FileStorage); - if (execResult.IsFailure) + var executeResult = await operation.ExecuteAsync(); + if (executeResult.IsFailure) { - return Result.Fail(execResult); + return Result.Fail(executeResult); } AddOperation(operation); - // Notify opened documents that the file has moved - SendResourceKeyChangedMessage(sourcePath, destPath); - - return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); - } - - public async Task DeleteFileAsync(string path) - { - path = Path.GetFullPath(path); - - if (!File.Exists(path)) + if (isFolder) { - return Result.Fail($"File does not exist: {path}"); + SendFolderResourceKeyChangedMessages(source, destination); } - - // Pre-compute trash paths - var trashId = Guid.NewGuid().ToString(); - var relativePath = Path.GetRelativePath(ProjectFolderPath, path); - var trashPath = Path.Combine(TrashFolderPath, trashId, relativePath); - - // Pre-compute entity data paths - string? entityDataPath = null; - string? entityDataTrashPath = null; - if (EntityService != null && ResourceRegistry != null) + else { - var resourceKeyResult = ResourceRegistry.GetResourceKey(path); - if (resourceKeyResult.IsSuccess) - { - var existingEntityDataPath = EntityService.GetEntityDataPath(resourceKeyResult.Value); - if (File.Exists(existingEntityDataPath)) - { - entityDataPath = existingEntityDataPath; - var entityDataRelativePath = EntityService.GetEntityDataRelativePath(resourceKeyResult.Value); - entityDataTrashPath = Path.Combine(TrashFolderPath, trashId, entityDataRelativePath); - } - } + var message = new ResourceKeyChangedMessage(source, destination); + _messengerService.Send(message); } - // Pre-compute sidecar paths so the cascade can land in the same trash - // batch as the parent file. The sibling lookup is a pure filename check - // (matches the FS-layer cascade rule); it does not consult the registry. - string? sidecarPath = null; - string? sidecarTrashPath = null; - var siblingSidecar = path + SidecarHelper.Extension; - if (File.Exists(siblingSidecar)) - { - sidecarPath = siblingSidecar; - sidecarTrashPath = trashPath + SidecarHelper.Extension; - } + return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); + } - var operation = new DeleteFileOperation(path, trashPath, entityDataPath, entityDataTrashPath, sidecarPath, sidecarTrashPath); + public async Task DeleteAsync(ResourceKey resource) + { + var operation = new DeleteOperation(resource, TrashService); var result = await operation.ExecuteAsync(); if (result.IsSuccess) { AddOperation(operation); - - // Announce the removal synchronously so subscribers update before - // control returns. The watcher's own delete event still arrives - // later via UI-thread dispatch; subscribers must treat these - // messages as idempotent. - if (ResourceRegistry is not null) - { - var keyResult = ResourceRegistry.GetResourceKey(path); - if (keyResult.IsSuccess) - { - var removedMessage = new ResourceDeletedMessage(keyResult.Value); - _messengerService.Send(removedMessage); - } - } + BroadcastDeleteMessages(operation.TrashEntry); } return result; } - public async Task> CopyFolderAsync(string sourcePath, string destPath) + public async Task ImportExternalFileAsync(string sourcePath, ResourceKey destination) { sourcePath = Path.GetFullPath(sourcePath); - destPath = Path.GetFullPath(destPath); - // External-import callers supply a source folder outside the project. - // The FS-layer cascade does not apply (see CopyFileAsync for the full - // rationale). Sidecars inside the source folder come along as ordinary - // bytes via the recursive copy; the registry pairing pass picks them up. - if (!IsInProjectFolder(sourcePath)) - { - return await CopyExternalFolderAsync(sourcePath, destPath); - } - - var keyResult = ResolveOperationKeys(sourcePath, destPath); - if (keyResult.IsFailure) - { - return Result.Fail(keyResult); - } - var fileStorage = FileStorage; - if (fileStorage is null) - { - return Result.Fail("Workspace is not loaded; file storage is unavailable."); - } - - var operation = new CopyFolderOperation( - sourcePath, - destPath, - keyResult.Value.Source, - keyResult.Value.Destination, - EntityService, - ResourceRegistry, - fileStorage); - var execResult = await operation.ExecuteAsync(); + var operation = new ImportExternalOperation(sourcePath, destination, isFolder: false, FileStorage); + var result = await operation.ExecuteAsync(); - if (execResult.IsFailure) + if (result.IsSuccess) { - return Result.Fail(execResult); + AddOperation(operation); } - AddOperation(operation); - return Result.Ok(operation.LastCopyResult ?? EmptyCopyResult); + return result; } - public async Task> MoveFolderAsync(string sourcePath, string destPath) + public async Task ImportExternalFolderAsync(string sourcePath, ResourceKey destination) { sourcePath = Path.GetFullPath(sourcePath); - destPath = Path.GetFullPath(destPath); - - var keyResult = ResolveOperationKeys(sourcePath, destPath); - if (keyResult.IsFailure) - { - return Result.Fail(keyResult); - } - var fileStorage = FileStorage; - if (fileStorage is null) - { - return Result.Fail("Workspace is not loaded; file storage is unavailable."); - } - var operation = new MoveFolderOperation( - sourcePath, - destPath, - keyResult.Value.Source, - keyResult.Value.Destination, - EntityService, - ResourceRegistry, - fileStorage); - var execResult = await operation.ExecuteAsync(); - - if (execResult.IsFailure) - { - return Result.Fail(execResult); - } - - AddOperation(operation); - - // Notify opened documents that resources in this folder have moved - SendFolderResourceKeyChangedMessages(sourcePath, destPath); - - return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); - } - - public async Task DeleteFolderAsync(string path) - { - path = Path.GetFullPath(path); - - if (!Directory.Exists(path)) - { - return Result.Fail($"Folder does not exist: {path}"); - } - - var files = Directory.GetFiles(path); - var directories = Directory.GetDirectories(path); - var wasEmpty = files.Length == 0 && directories.Length == 0; - - // Pre-compute trash paths - var trashId = Guid.NewGuid().ToString(); - var relativePath = Path.GetRelativePath(ProjectFolderPath, path); - var trashPath = wasEmpty ? string.Empty : Path.Combine(TrashFolderPath, trashId, relativePath); - - // Pre-compute entity data file paths for trash - var entityDataFiles = new List<(string OriginalPath, string TrashPath)>(); - if (!wasEmpty && EntityService != null && ResourceRegistry != null) - { - var trashBasePath = Path.Combine(TrashFolderPath, trashId); - foreach (var filePath in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - var resourceKeyResult = ResourceRegistry.GetResourceKey(filePath); - if (resourceKeyResult.IsFailure) - { - continue; - } - - var entityDataPath = EntityService.GetEntityDataPath(resourceKeyResult.Value); - if (!File.Exists(entityDataPath)) - { - continue; - } - - var entityDataRelativePath = EntityService.GetEntityDataRelativePath(resourceKeyResult.Value); - var entityDataTrashPath = Path.Combine(trashBasePath, entityDataRelativePath); - entityDataFiles.Add((entityDataPath, entityDataTrashPath)); - } - } - - // Capture descendant keys (folders only) before the disk delete so the - // post-delete eager-notify can drop their stale entries too. - var descendantKeys = new List(); - if (!wasEmpty && ResourceRegistry is not null) - { - foreach (var filePath in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - var keyResult = ResourceRegistry.GetResourceKey(filePath); - if (keyResult.IsSuccess) - { - descendantKeys.Add(keyResult.Value); - } - } - } - - var operation = new DeleteFolderOperation(path, trashPath, wasEmpty, entityDataFiles); + var operation = new ImportExternalOperation(sourcePath, destination, isFolder: true, FileStorage); var result = await operation.ExecuteAsync(); if (result.IsSuccess) { AddOperation(operation); - - // Announce the removal synchronously so subscribers update before - // control returns. The folder key and every captured descendant are - // broadcast; the watcher events still arrive later via UI-thread - // dispatch and are idempotent against the prior notification. - if (ResourceRegistry is not null) - { - var folderKeyResult = ResourceRegistry.GetResourceKey(path); - if (folderKeyResult.IsSuccess) - { - var folderRemovedMessage = new ResourceDeletedMessage(folderKeyResult.Value); - _messengerService.Send(folderRemovedMessage); - } - foreach (var key in descendantKeys) - { - var descendantRemovedMessage = new ResourceDeletedMessage(key); - _messengerService.Send(descendantRemovedMessage); - } - } } return result; } - public async Task TransferAsync(string sourcePath, string destPath, DataTransferMode mode) + public async Task TransferAsync(ResourceKey source, ResourceKey destination, DataTransferMode mode) { - sourcePath = Path.GetFullPath(sourcePath); - - bool isFile = File.Exists(sourcePath); - bool isFolder = Directory.Exists(sourcePath); - - if (!isFile && !isFolder) + var infoResult = await FileStorage.GetInfoAsync(source); + if (infoResult.IsFailure + || infoResult.Value.Kind == StorageItemKind.NotFound) { - return Result.Fail($"Source does not exist: {sourcePath}"); + return Result.Fail($"Source resource does not exist: '{source}'"); } - if (isFile) + if (mode == DataTransferMode.Copy) { - return mode == DataTransferMode.Copy - ? await CopyFileAsync(sourcePath, destPath) - : await MoveFileAsync(sourcePath, destPath); - } - else - { - return mode == DataTransferMode.Copy - ? await CopyFolderAsync(sourcePath, destPath) - : await MoveFolderAsync(sourcePath, destPath); + return await CopyAsync(source, destination); } + + return await MoveAsync(source, destination); } public void BeginBatch() @@ -518,7 +281,7 @@ public async Task UndoAsync() { if (_undoStack.Count == 0) { - return Result.Ok(); // Nothing to undo + return Result.Ok(); } var operation = _undoStack[^1]; @@ -541,7 +304,7 @@ public async Task RedoAsync() { if (_redoStack.Count == 0) { - return Result.Ok(); // Nothing to redo + return Result.Ok(); } var operation = _redoStack[^1]; @@ -560,35 +323,6 @@ public async Task RedoAsync() return result; } - // Maps a pair of project-folder absolute paths to ResourceKey form so the - // FS-layer-backed operations can address sources and destinations via key. - // The registry returns generated keys even for destinations that don't exist - // on disk yet, which is exactly the move/copy case. - private Result<(ResourceKey Source, ResourceKey Destination)> ResolveOperationKeys(string sourcePath, string destPath) - { - var registry = ResourceRegistry; - if (registry is null) - { - return Result<(ResourceKey, ResourceKey)>.Fail("Workspace is not loaded; resource registry is unavailable."); - } - - var sourceKeyResult = registry.GetResourceKey(sourcePath); - if (sourceKeyResult.IsFailure) - { - return Result<(ResourceKey, ResourceKey)>.Fail($"Failed to compute resource key for source path: '{sourcePath}'") - .WithErrors(sourceKeyResult); - } - - var destKeyResult = registry.GetResourceKey(destPath); - if (destKeyResult.IsFailure) - { - return Result<(ResourceKey, ResourceKey)>.Fail($"Failed to compute resource key for destination path: '{destPath}'") - .WithErrors(destKeyResult); - } - - return Result<(ResourceKey, ResourceKey)>.Ok((sourceKeyResult.Value, destKeyResult.Value)); - } - private void AddOperation(FileOperation operation) { if (_currentBatch != null) @@ -599,16 +333,12 @@ private void AddOperation(FileOperation operation) { _undoStack.Add(operation); ClearRedoStack(); - - // Enforce undo stack size limit TrimUndoStack(); } } - /// - /// Remove oldest operations from the undo stack if it exceeds the maximum size. - /// Also cleans up any associated trash files for removed delete operations. - /// + // Drop oldest operations once the stack reaches MaxUndoStackSize and purge + // any trash bytes they were keeping alive for undo. private void TrimUndoStack() { while (_undoStack.Count > MaxUndoStackSize) @@ -616,86 +346,64 @@ private void TrimUndoStack() var oldestOperation = _undoStack[0]; _undoStack.RemoveAt(0); - // Clean up trash files for delete operations that are being discarded - CleanupOperationTrashFiles(oldestOperation); + _ = PurgeOperationTrashAsync(oldestOperation); } } - /// - /// Clear the redo stack and clean up any associated trash files. - /// This is called when a new operation is performed, invalidating the redo history. - /// + // The redo stack is invalidated whenever a new operation lands. Purge any + // trash bytes the cleared redo entries were holding open. private void ClearRedoStack() { foreach (var operation in _redoStack) { - CleanupOperationTrashFiles(operation); + _ = PurgeOperationTrashAsync(operation); } _redoStack.Clear(); } - /// - /// Recursively clean up trash files associated with an operation. - /// - private void CleanupOperationTrashFiles(FileOperation operation) + // Recursively walks operation batches and purges any trash bytes a + // DeleteOperation was keeping alive. Fire-and-forget at the call site + // because trash purge is best-effort cleanup. + private static async Task PurgeOperationTrashAsync(FileOperation operation) { if (operation is FileOperationBatch batch) { - foreach (var op in batch.Operations) + foreach (var inner in batch.Operations) { - CleanupOperationTrashFiles(op); + await PurgeOperationTrashAsync(inner); } } - else if (operation is DeleteFileOperation deleteFile) - { - deleteFile.CleanupTrashFile(); - } - else if (operation is DeleteFolderOperation deleteFolder) + else if (operation is DeleteOperation delete) { - deleteFolder.CleanupTrashFolder(); + await delete.CleanupAsync(); } } - /// - /// Send a ResourceKeyChangedMessage for a single file that has been moved. - /// - private void SendResourceKeyChangedMessage(string sourcePath, string destPath) + private void BroadcastDeleteMessages(TrashEntry? entry) { - if (ResourceRegistry == null) + if (entry is null) { return; } - var sourceKeyResult = ResourceRegistry.GetResourceKey(sourcePath); - var destKeyResult = ResourceRegistry.GetResourceKey(destPath); + var sourceRemovedMessage = new ResourceDeletedMessage(entry.OriginalResource); + _messengerService.Send(sourceRemovedMessage); - if (sourceKeyResult.IsSuccess && destKeyResult.IsSuccess) + foreach (var descendant in entry.DescendantKeys) { - var message = new ResourceKeyChangedMessage(sourceKeyResult.Value, destKeyResult.Value); - _messengerService.Send(message); + var descendantRemovedMessage = new ResourceDeletedMessage(descendant); + _messengerService.Send(descendantRemovedMessage); } } - /// - /// Send ResourceKeyChangedMessage for all resources in a folder that has been moved. - /// - private void SendFolderResourceKeyChangedMessages(string sourceFolderPath, string destFolderPath) + // After a folder move, broadcast a key-changed message for the folder and + // every descendant resource so opened documents can repoint cleanly. Walks + // the registry-cached source tree because the on-disk source is already + // gone by the time we get here. + private void SendFolderResourceKeyChangedMessages(ResourceKey sourceFolder, ResourceKey destinationFolder) { - if (ResourceRegistry == null) - { - return; - } - - var sourceKeyResult = ResourceRegistry.GetResourceKey(sourceFolderPath); - var destKeyResult = ResourceRegistry.GetResourceKey(destFolderPath); - - if (sourceKeyResult.IsFailure || destKeyResult.IsFailure) - { - return; - } - - var sourceFolder = sourceKeyResult.Value; - var destFolder = destKeyResult.Value; + var folderMessage = new ResourceKeyChangedMessage(sourceFolder, destinationFolder); + _messengerService.Send(folderMessage); var getResourceResult = ResourceRegistry.GetResource(sourceFolder); if (getResourceResult.IsFailure) @@ -708,18 +416,17 @@ private void SendFolderResourceKeyChangedMessages(string sourceFolderPath, strin return; } - List sourceResources = new(); + var sourceResources = new List(); PopulateSourceResources(sourceFolderResource); void PopulateSourceResources(FolderResource folderResource) { - var folderKey = ResourceRegistry.GetResourceKey(folderResource); - sourceResources.Add(folderKey); - foreach (var childResource in folderResource.Children) { if (childResource is FolderResource childFolderResource) { + var folderKey = ResourceRegistry.GetResourceKey(childFolderResource); + sourceResources.Add(folderKey); PopulateSourceResources(childFolderResource); } else @@ -730,10 +437,10 @@ void PopulateSourceResources(FolderResource folderResource) } } - foreach (var sourceResource in sourceResources) + foreach (var descendantSource in sourceResources) { - var destResource = sourceResource.ToString().Replace(sourceFolder, destFolder); - var message = new ResourceKeyChangedMessage(sourceResource, destResource); + var descendantDestination = descendantSource.ToString().Replace(sourceFolder, destinationFolder); + var message = new ResourceKeyChangedMessage(descendantSource, descendantDestination); _messengerService.Send(message); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index dc257d27c..76436c396 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -1,4 +1,3 @@ -using Celbridge.Entities; using Celbridge.Resources.Helpers; namespace Celbridge.Resources.Services; @@ -8,24 +7,15 @@ namespace Celbridge.Resources.Services; /// internal abstract class FileOperation { - /// - /// Executes the operation for the first time or re-executes it (redo). - /// public abstract Task ExecuteAsync(); - - /// - /// Reverses the operation. - /// public abstract Task UndoAsync(); - - /// - /// Re-executes the operation after it was undone. - /// public virtual Task RedoAsync() => ExecuteAsync(); } /// -/// Represents a group of file operations that should be undone/redone together. +/// Groups a sequence of operations into one undo unit. Undo runs in reverse so +/// each operation's inverse executes against a filesystem in the same shape it +/// saw on the forward pass. /// internal class FileOperationBatch : FileOperation { @@ -33,9 +23,9 @@ internal class FileOperationBatch : FileOperation public override async Task ExecuteAsync() { - foreach (var op in Operations) + foreach (var operation in Operations) { - var result = await op.ExecuteAsync(); + var result = await operation.ExecuteAsync(); if (result.IsFailure) { return result; @@ -46,7 +36,6 @@ public override async Task ExecuteAsync() public override async Task UndoAsync() { - // Undo in reverse order for (int i = Operations.Count - 1; i >= 0; i--) { var result = await Operations[i].UndoAsync(); @@ -60,390 +49,205 @@ public override async Task UndoAsync() } /// -/// Undoable copy file operation. The bytes-and-sidecar cascade runs through -/// IFileStorage.CopyAsync; entity-data cascade rides alongside via -/// EntityFileHelper. +/// Undoable create-file or create-folder operation. The folder variant runs +/// through the chokepoint's idempotent CreateFolderAsync; undo deletes the +/// folder only when it is still empty so user content added after creation is +/// not silently wiped. The file variant writes bytes through the chokepoint; +/// undo hard-deletes (no trash) since the user is reversing a just-created +/// resource they did not previously want. /// -internal class CopyFileOperation : FileOperation +internal class CreateOperation : FileOperation { - private readonly string _sourcePath; - private readonly string _destPath; - private readonly ResourceKey _sourceKey; - private readonly ResourceKey _destKey; - private readonly EntityFileHelper _entityHelper; + private readonly ResourceKey _resource; + private readonly bool _isFile; + private readonly byte[]? _content; private readonly IFileStorage _fileStorage; - public CopyResult? LastCopyResult { get; private set; } - - public CopyFileOperation( - string sourcePath, - string destPath, - ResourceKey sourceKey, - ResourceKey destKey, - IEntityService? entityService, - IResourceRegistry? resourceRegistry, - IFileStorage fileStorage) + public CreateOperation(ResourceKey resource, byte[] content, IFileStorage fileStorage) { - _sourcePath = sourcePath; - _destPath = destPath; - _sourceKey = sourceKey; - _destKey = destKey; - _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _resource = resource; + _isFile = true; + _content = content; _fileStorage = fileStorage; } - public override async Task ExecuteAsync() - { - _entityHelper.CopyEntityDataFile(_sourcePath, _destPath); - - var copyResult = await _fileStorage.CopyAsync(_sourceKey, _destKey); - if (copyResult.IsFailure) - { - return Result.Fail(copyResult); - } - - LastCopyResult = copyResult.Value; - return Result.Ok(); - } - - public override async Task UndoAsync() - { - _entityHelper.DeleteEntityDataFile(_destPath); - - var deleteResult = await _fileStorage.DeleteAsync(_destKey); - if (deleteResult.IsFailure) - { - return Result.Fail(deleteResult); - } - - return Result.Ok(); - } -} - -/// -/// Undoable move file operation. Bytes, reference rewrites, and sidecar cascade -/// run through IFileStorage.MoveAsync; the inverse re-walks the reference -/// graph in the opposite direction so undo restores references too. -/// -internal class MoveFileOperation : FileOperation -{ - private readonly string _sourcePath; - private readonly string _destPath; - private readonly ResourceKey _sourceKey; - private readonly ResourceKey _destKey; - private readonly EntityFileHelper _entityHelper; - private readonly IFileStorage _fileStorage; - - public MoveResult? LastMoveResult { get; private set; } - - public MoveFileOperation( - string sourcePath, - string destPath, - ResourceKey sourceKey, - ResourceKey destKey, - IEntityService? entityService, - IResourceRegistry? resourceRegistry, - IFileStorage fileStorage) + public CreateOperation(ResourceKey resource, IFileStorage fileStorage) { - _sourcePath = sourcePath; - _destPath = destPath; - _sourceKey = sourceKey; - _destKey = destKey; - _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _resource = resource; + _isFile = false; + _content = null; _fileStorage = fileStorage; } public override async Task ExecuteAsync() { - // Entity-data cascade runs before the bytes move so the source path - // still resolves while EntityFileHelper computes the destination key. - _entityHelper.MoveEntityDataFile(_sourcePath, _destPath); - - var moveResult = await _fileStorage.MoveAsync(_sourceKey, _destKey); - if (moveResult.IsFailure) - { - return Result.Fail(moveResult); - } - - LastMoveResult = moveResult.Value; - return Result.Ok(); - } - - public override async Task UndoAsync() - { - _entityHelper.MoveEntityDataFile(_destPath, _sourcePath); - - var moveResult = await _fileStorage.MoveAsync(_destKey, _sourceKey); - if (moveResult.IsFailure) + if (_isFile) { - return Result.Fail(moveResult); - } - - return Result.Ok(); - } -} - -/// -/// Undoable delete file operation. -/// -internal class DeleteFileOperation : FileOperation -{ - private readonly string _originalPath; - private readonly string _trashPath; - private readonly string? _entityDataOriginalPath; - private readonly string? _entityDataTrashPath; - private readonly string? _sidecarOriginalPath; - private readonly string? _sidecarTrashPath; - - public DeleteFileOperation( - string originalPath, - string trashPath, - string? entityDataOriginalPath, - string? entityDataTrashPath, - string? sidecarOriginalPath, - string? sidecarTrashPath) - { - _originalPath = originalPath; - _trashPath = trashPath; - _entityDataOriginalPath = entityDataOriginalPath; - _entityDataTrashPath = entityDataTrashPath; - _sidecarOriginalPath = sidecarOriginalPath; - _sidecarTrashPath = sidecarTrashPath; - } - - public override async Task ExecuteAsync() - { - await Task.CompletedTask; - - try - { - if (!File.Exists(_originalPath)) + var infoResult = await _fileStorage.GetInfoAsync(_resource); + if (infoResult.IsSuccess + && infoResult.Value.Kind != StorageItemKind.NotFound) { - return Result.Fail($"File does not exist: {_originalPath}"); + return Result.Fail($"Resource already exists: '{_resource}'"); } - // Clear read-only so the soft-delete (a File.Move into the trash - // folder) is not blocked by an attribute the user has explicitly - // chosen to override by invoking delete. The cleared state persists - // through undo — restoring a previously-read-only file produces a - // writable copy. A user who needs the read-only attribute back can - // re-apply it via the OS file properties dialog. - ClearReadOnlyIfSet(_originalPath); - - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_originalPath, _trashPath); - - // Also move entity data file to trash if it exists - if (!string.IsNullOrEmpty(_entityDataOriginalPath) && - !string.IsNullOrEmpty(_entityDataTrashPath) && - File.Exists(_entityDataOriginalPath)) - { - ClearReadOnlyIfSet(_entityDataOriginalPath); - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_entityDataOriginalPath, _entityDataTrashPath); - } - - // Also move the paired sidecar to trash if it exists - if (!string.IsNullOrEmpty(_sidecarOriginalPath) && - !string.IsNullOrEmpty(_sidecarTrashPath) && - File.Exists(_sidecarOriginalPath)) - { - ClearReadOnlyIfSet(_sidecarOriginalPath); - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_sidecarOriginalPath, _sidecarTrashPath); - } - - return Result.Ok(); + return await _fileStorage.WriteAllBytesAsync(_resource, _content!); } - catch (Exception ex) - { - return Result.Fail($"Failed to delete file: {_originalPath}") - .WithException(ex); - } - } - private static void ClearReadOnlyIfSet(string path) - { - try - { - var info = new FileInfo(path); - if (info.Exists - && info.IsReadOnly) - { - info.IsReadOnly = false; - } - } - catch - { - // Best effort; surface from the subsequent move/delete failure. - } + return await _fileStorage.CreateFolderAsync(_resource); } public override async Task UndoAsync() { - await Task.CompletedTask; - - try + var infoResult = await _fileStorage.GetInfoAsync(_resource); + if (infoResult.IsFailure + || infoResult.Value.Kind == StorageItemKind.NotFound) { - if (!File.Exists(_trashPath)) - { - return Result.Fail($"Trash file does not exist: {_trashPath}"); - } - - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_trashPath, _originalPath); - - // Also restore entity data file if it was trashed - if (!string.IsNullOrEmpty(_entityDataOriginalPath) && - !string.IsNullOrEmpty(_entityDataTrashPath) && - File.Exists(_entityDataTrashPath)) - { - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_entityDataTrashPath, _entityDataOriginalPath); - } - - // Also restore the paired sidecar if it was trashed - if (!string.IsNullOrEmpty(_sidecarOriginalPath) && - !string.IsNullOrEmpty(_sidecarTrashPath) && - File.Exists(_sidecarTrashPath)) - { - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(_sidecarTrashPath, _sidecarOriginalPath); - } - - FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); - return Result.Ok(); } - catch (Exception ex) - { - return Result.Fail($"Failed to restore deleted file: {_originalPath}") - .WithException(ex); - } - } - /// - /// Clean up the trash file when this operation is discarded from the undo stack. - /// - public void CleanupTrashFile() - { - try + if (!_isFile) { - FileSystemHelper.DeleteFileIfExists(_trashPath); - - if (!string.IsNullOrEmpty(_entityDataTrashPath)) + // Only remove an empty folder. If the user filled it after the + // original create, leave the contents alone. + var enumerateResult = await _fileStorage.EnumerateFolderAsync(_resource); + if (enumerateResult.IsFailure + || enumerateResult.Value.Count > 0) { - FileSystemHelper.DeleteFileIfExists(_entityDataTrashPath); + return Result.Ok(); } - - if (!string.IsNullOrEmpty(_sidecarTrashPath)) - { - FileSystemHelper.DeleteFileIfExists(_sidecarTrashPath); - } - - FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); - } - catch - { - // Best effort cleanup - ignore errors } + + var deleteResult = await _fileStorage.DeleteAsync(_resource); + return deleteResult.IsSuccess + ? Result.Ok() + : Result.Fail(deleteResult); } } /// -/// Undoable copy folder operation. Bytes-and-sidecar cascade runs through -/// IFileStorage.CopyAsync; entity-data cascade rides alongside via -/// EntityFileHelper. +/// Undoable copy of a file or folder through the chokepoint. The entity-data +/// cascade runs alongside via EntityFileHelper; the bytes-and-sidecar cascade +/// runs inside the chokepoint's CopyAsync. /// -internal class CopyFolderOperation : FileOperation +internal class CopyOperation : FileOperation { - private readonly string _sourcePath; - private readonly string _destPath; - private readonly ResourceKey _sourceKey; - private readonly ResourceKey _destKey; + private readonly ResourceKey _source; + private readonly ResourceKey _destination; + private readonly bool _isFolder; private readonly EntityFileHelper _entityHelper; private readonly IFileStorage _fileStorage; + private readonly string _sourcePath; + private readonly string _destinationPath; public CopyResult? LastCopyResult { get; private set; } - public CopyFolderOperation( + public CopyOperation( + ResourceKey source, + ResourceKey destination, + bool isFolder, string sourcePath, - string destPath, - ResourceKey sourceKey, - ResourceKey destKey, - IEntityService? entityService, - IResourceRegistry? resourceRegistry, + string destinationPath, + EntityFileHelper entityHelper, IFileStorage fileStorage) { + _source = source; + _destination = destination; + _isFolder = isFolder; _sourcePath = sourcePath; - _destPath = destPath; - _sourceKey = sourceKey; - _destKey = destKey; - _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _destinationPath = destinationPath; + _entityHelper = entityHelper; _fileStorage = fileStorage; } public override async Task ExecuteAsync() { - var copyResult = await _fileStorage.CopyAsync(_sourceKey, _destKey); + if (!_isFolder) + { + _entityHelper.CopyEntityDataFile(_sourcePath, _destinationPath); + } + + var copyResult = await _fileStorage.CopyAsync(_source, _destination); if (copyResult.IsFailure) { return Result.Fail(copyResult); } - _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destPath); - LastCopyResult = copyResult.Value; + if (_isFolder) + { + _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destinationPath); + } + LastCopyResult = copyResult.Value; return Result.Ok(); } public override async Task UndoAsync() { - _entityHelper.DeleteFolderEntityDataFiles(_destPath); - - var deleteResult = await _fileStorage.DeleteAsync(_destKey); - if (deleteResult.IsFailure) + if (_isFolder) + { + _entityHelper.DeleteFolderEntityDataFiles(_destinationPath); + } + else { - return Result.Fail(deleteResult); + _entityHelper.DeleteEntityDataFile(_destinationPath); } - return Result.Ok(); + var deleteResult = await _fileStorage.DeleteAsync(_destination); + return deleteResult.IsSuccess + ? Result.Ok() + : Result.Fail(deleteResult); } } /// -/// Undoable move folder operation. Bytes, reference rewrites, and sidecar -/// cascade run through IFileStorage.MoveAsync; the inverse re-walks the -/// reference graph in the opposite direction. +/// Undoable move of a file or folder through the chokepoint. The chokepoint +/// handles references, the paired sidecar, and the source-removal broadcast; +/// the inverse re-walks references in the opposite direction. /// -internal class MoveFolderOperation : FileOperation +internal class MoveOperation : FileOperation { - private readonly string _sourcePath; - private readonly string _destPath; - private readonly ResourceKey _sourceKey; - private readonly ResourceKey _destKey; + private readonly ResourceKey _source; + private readonly ResourceKey _destination; + private readonly bool _isFolder; private readonly EntityFileHelper _entityHelper; private readonly IFileStorage _fileStorage; + private readonly string _sourcePath; + private readonly string _destinationPath; public MoveResult? LastMoveResult { get; private set; } - public MoveFolderOperation( + public MoveOperation( + ResourceKey source, + ResourceKey destination, + bool isFolder, string sourcePath, - string destPath, - ResourceKey sourceKey, - ResourceKey destKey, - IEntityService? entityService, - IResourceRegistry? resourceRegistry, + string destinationPath, + EntityFileHelper entityHelper, IFileStorage fileStorage) { + _source = source; + _destination = destination; + _isFolder = isFolder; _sourcePath = sourcePath; - _destPath = destPath; - _sourceKey = sourceKey; - _destKey = destKey; - _entityHelper = new EntityFileHelper(entityService, resourceRegistry); + _destinationPath = destinationPath; + _entityHelper = entityHelper; _fileStorage = fileStorage; } public override async Task ExecuteAsync() { - // Move entity data files first (while source folder still exists for enumeration). - _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destPath); + // Entity-data cascade runs while the source still resolves so the + // helper can compute keys against the original location. + if (_isFolder) + { + _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destinationPath); + } + else + { + _entityHelper.MoveEntityDataFile(_sourcePath, _destinationPath); + } - var moveResult = await _fileStorage.MoveAsync(_sourceKey, _destKey); + var moveResult = await _fileStorage.MoveAsync(_source, _destination); if (moveResult.IsFailure) { return Result.Fail(moveResult); @@ -455,439 +259,196 @@ public override async Task ExecuteAsync() public override async Task UndoAsync() { - // Move entity data files back first (while dest folder still exists for enumeration). - _entityHelper.MoveFolderEntityDataFiles(_destPath, _sourcePath); - - var moveResult = await _fileStorage.MoveAsync(_destKey, _sourceKey); - if (moveResult.IsFailure) + if (_isFolder) { - return Result.Fail(moveResult); + _entityHelper.MoveFolderEntityDataFiles(_destinationPath, _sourcePath); + } + else + { + _entityHelper.MoveEntityDataFile(_destinationPath, _sourcePath); } - return Result.Ok(); + var moveResult = await _fileStorage.MoveAsync(_destination, _source); + return moveResult.IsSuccess + ? Result.Ok() + : Result.Fail(moveResult); } } /// -/// Undoable delete folder operation. +/// Undoable soft-delete through the trash service. The trash service handles +/// the paired sidecar, entity-data cascade, and read-only attribute clearing +/// as one atomic batch. /// -internal class DeleteFolderOperation : FileOperation +internal class DeleteOperation : FileOperation { - private readonly string _originalPath; - private readonly string _trashPath; - private readonly bool _wasEmpty; - private readonly List<(string OriginalPath, string TrashPath)> _entityDataFiles; - - public DeleteFolderOperation( - string originalPath, - string trashPath, - bool wasEmpty, - List<(string OriginalPath, string TrashPath)> entityDataFiles) + private readonly ResourceKey _resource; + private readonly ITrashService _trashService; + private TrashEntry? _trashEntry; + + public TrashEntry? TrashEntry => _trashEntry; + + public DeleteOperation(ResourceKey resource, ITrashService trashService) { - _originalPath = originalPath; - _trashPath = trashPath; - _wasEmpty = wasEmpty; - _entityDataFiles = entityDataFiles; + _resource = resource; + _trashService = trashService; } public override async Task ExecuteAsync() { - await Task.CompletedTask; - - try + var result = await _trashService.MoveToTrashAsync(_resource); + if (result.IsFailure) { - if (!Directory.Exists(_originalPath)) - { - return Result.Fail($"Folder does not exist: {_originalPath}"); - } - - // Clear read-only on every contained file so the folder move into - // trash (or the empty-folder Directory.Delete) is not blocked by an - // attribute the user has explicitly chosen to override by invoking - // delete on the parent folder. - ClearReadOnlyRecursive(_originalPath); - - if (FileSystemHelper.IsDirectoryEmpty(_originalPath)) - { - Directory.Delete(_originalPath); - } - else - { - // Move entity data files to trash first - await MoveEntityDataFilesToTrashAsync(); - - // Non-empty folder - move to trash - await FileSystemHelper.MoveDirectoryWithParentCreationAsync(_originalPath, _trashPath); - } - - return Result.Ok(); + return Result.Fail(result); } - catch (Exception ex) - { - return Result.Fail($"Failed to delete folder: {_originalPath}") - .WithException(ex); - } - } - private static void ClearReadOnlyRecursive(string folder) - { - try - { - foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) - { - try - { - var info = new FileInfo(file); - if (info.Exists - && info.IsReadOnly) - { - info.IsReadOnly = false; - } - } - catch - { - // Best effort per file; surface aggregate via the delete failure. - } - } - } - catch - { - // Best effort traversal. - } + _trashEntry = result.Value; + return Result.Ok(); } public override async Task UndoAsync() { - await Task.CompletedTask; - - try + if (_trashEntry is null) { - if (_wasEmpty) - { - Directory.CreateDirectory(_originalPath); - } - else - { - if (!Directory.Exists(_trashPath)) - { - return Result.Fail($"Trash folder does not exist: {_trashPath}"); - } - - await FileSystemHelper.MoveDirectoryWithParentCreationAsync(_trashPath, _originalPath); - - // Restore entity data files from trash - await RestoreEntityDataFilesAsync(); - - FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); - } - - return Result.Ok(); + return Result.Fail($"No trash entry to restore for resource: '{_resource}'"); } - catch (Exception ex) - { - return Result.Fail($"Failed to restore deleted folder: {_originalPath}") - .WithException(ex); - } - } - /// - /// Clean up the trash folder when this operation is discarded from the undo stack. - /// - public void CleanupTrashFolder() - { - try - { - if (!_wasEmpty && Directory.Exists(_trashPath)) - { - Directory.Delete(_trashPath, recursive: true); - FileSystemHelper.CleanupEmptyParentDirectories(_trashPath); - } - - // Also clean up entity data files in trash - foreach (var (_, trashPath) in _entityDataFiles) - { - FileSystemHelper.DeleteFileIfExists(trashPath); - FileSystemHelper.CleanupEmptyParentDirectories(trashPath); - } - } - catch - { - // Best effort cleanup - ignore errors - } + return await _trashService.RestoreFromTrashAsync(_trashEntry); } - private async Task MoveEntityDataFilesToTrashAsync() + public async Task CleanupAsync() { - foreach (var (originalPath, trashPath) in _entityDataFiles) + if (_trashEntry is null) { - if (File.Exists(originalPath)) - { - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(originalPath, trashPath); - FileSystemHelper.CleanupEmptyParentDirectories(originalPath); - } + return; } - } - private async Task RestoreEntityDataFilesAsync() - { - foreach (var (originalPath, trashPath) in _entityDataFiles) - { - if (File.Exists(trashPath)) - { - await FileSystemHelper.MoveFileWithDirectoryCreationAsync(trashPath, originalPath); - FileSystemHelper.CleanupEmptyParentDirectories(trashPath); - } - } + await _trashService.PurgeAsync(_trashEntry); } } /// -/// Undoable copy of bytes from outside the project folder (file). External +/// Undoable import of a file or folder from outside the project. External /// imports carry no inbound references or sidecars, so the cascade does not -/// apply; this operation does a direct File.Copy and tracks undo as a delete. +/// apply. Source bytes are read directly (the source is outside the registry); +/// the destination flows through the chokepoint for containment validation. /// -internal class CopyExternalFileOperation : FileOperation +internal class ImportExternalOperation : FileOperation { private readonly string _sourcePath; - private readonly string _destPath; + private readonly ResourceKey _destination; + private readonly bool _isFolder; + private readonly IFileStorage _fileStorage; - public CopyExternalFileOperation(string sourcePath, string destPath) + public ImportExternalOperation( + string sourcePath, + ResourceKey destination, + bool isFolder, + IFileStorage fileStorage) { _sourcePath = sourcePath; - _destPath = destPath; + _destination = destination; + _isFolder = isFolder; + _fileStorage = fileStorage; } public override async Task ExecuteAsync() { - await Task.CompletedTask; - - try + if (_isFolder) { - if (!File.Exists(_sourcePath)) - { - return Result.Fail($"Source file does not exist: {_sourcePath}"); - } - if (File.Exists(_destPath)) + if (!Directory.Exists(_sourcePath)) { - return Result.Fail($"Destination file already exists: {_destPath}"); + return Result.Fail($"Source folder does not exist: '{_sourcePath}'"); } - var destFolder = Path.GetDirectoryName(_destPath); - if (!string.IsNullOrEmpty(destFolder) - && !Directory.Exists(destFolder)) + var infoResult = await _fileStorage.GetInfoAsync(_destination); + if (infoResult.IsSuccess + && infoResult.Value.Kind != StorageItemKind.NotFound) { - Directory.CreateDirectory(destFolder); + return Result.Fail($"Destination already exists: '{_destination}'"); } - File.Copy(_sourcePath, _destPath); - return Result.Ok(); + return await ImportFolderAsync(_sourcePath, _destination); } - catch (Exception ex) - { - return Result.Fail($"Failed to copy external file: {_sourcePath} to {_destPath}") - .WithException(ex); - } - } - public override async Task UndoAsync() - { - await Task.CompletedTask; - - try + if (!File.Exists(_sourcePath)) { - if (File.Exists(_destPath)) - { - File.Delete(_destPath); - } - return Result.Ok(); + return Result.Fail($"Source file does not exist: '{_sourcePath}'"); } - catch (Exception ex) + + var destInfoResult = await _fileStorage.GetInfoAsync(_destination); + if (destInfoResult.IsSuccess + && destInfoResult.Value.Kind != StorageItemKind.NotFound) { - return Result.Fail($"Failed to undo external file copy: {_destPath}") - .WithException(ex); + return Result.Fail($"Destination already exists: '{_destination}'"); } - } -} - -/// -/// Undoable copy of bytes from outside the project folder (folder). Mirrors -/// CopyExternalFileOperation; no cascade applies. -/// -internal class CopyExternalFolderOperation : FileOperation -{ - private readonly string _sourcePath; - private readonly string _destPath; - - public CopyExternalFolderOperation(string sourcePath, string destPath) - { - _sourcePath = sourcePath; - _destPath = destPath; - } - - public override async Task ExecuteAsync() - { - await Task.CompletedTask; try { - if (!Directory.Exists(_sourcePath)) - { - return Result.Fail($"Source folder does not exist: {_sourcePath}"); - } - if (Directory.Exists(_destPath)) - { - return Result.Fail($"Destination folder already exists: {_destPath}"); - } - - ResourceUtils.CopyFolder(_sourcePath, _destPath); - return Result.Ok(); + var bytes = await File.ReadAllBytesAsync(_sourcePath); + return await _fileStorage.WriteAllBytesAsync(_destination, bytes); } catch (Exception ex) { - return Result.Fail($"Failed to copy external folder: {_sourcePath} to {_destPath}") + return Result.Fail($"Failed to import external file from '{_sourcePath}' to '{_destination}'") .WithException(ex); } } public override async Task UndoAsync() { - await Task.CompletedTask; - - try + var infoResult = await _fileStorage.GetInfoAsync(_destination); + if (infoResult.IsFailure + || infoResult.Value.Kind == StorageItemKind.NotFound) { - if (Directory.Exists(_destPath)) - { - Directory.Delete(_destPath, recursive: true); - } return Result.Ok(); } - catch (Exception ex) - { - return Result.Fail($"Failed to undo external folder copy: {_destPath}") - .WithException(ex); - } - } -} -/// -/// Undoable create file operation. -/// Undo deletes the created file. Redo recreates it. -/// -internal class CreateFileOperation : FileOperation -{ - private readonly string _filePath; - private readonly byte[] _content; - - public CreateFileOperation(string filePath, byte[] content) - { - _filePath = filePath; - _content = content; + var deleteResult = await _fileStorage.DeleteAsync(_destination); + return deleteResult.IsSuccess + ? Result.Ok() + : Result.Fail(deleteResult); } - public override async Task ExecuteAsync() + private async Task ImportFolderAsync(string sourceFolderPath, ResourceKey destinationFolder) { - await Task.CompletedTask; - - try + var createResult = await _fileStorage.CreateFolderAsync(destinationFolder); + if (createResult.IsFailure) { - if (File.Exists(_filePath)) - { - return Result.Fail($"File already exists: {_filePath}"); - } - - var parentFolder = Path.GetDirectoryName(_filePath); - if (!Directory.Exists(parentFolder)) - { - return Result.Fail($"Parent folder does not exist: {parentFolder}"); - } - - File.WriteAllBytes(_filePath, _content); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to create file: {_filePath}") - .WithException(ex); + return createResult; } - } - - public override async Task UndoAsync() - { - await Task.CompletedTask; - - try - { - if (File.Exists(_filePath)) - { - File.Delete(_filePath); - } - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to undo create file: {_filePath}") - .WithException(ex); - } - } -} - -/// -/// Undoable create folder operation. -/// Undo deletes the created folder. Redo recreates it. -/// -internal class CreateFolderOperation : FileOperation -{ - private readonly string _folderPath; - - public CreateFolderOperation(string folderPath) - { - _folderPath = folderPath; - } - - public override async Task ExecuteAsync() - { - await Task.CompletedTask; try { - if (Directory.Exists(_folderPath)) + foreach (var file in Directory.GetFiles(sourceFolderPath)) { - return Result.Fail($"Folder already exists: {_folderPath}"); + var fileName = Path.GetFileName(file); + var destinationFile = destinationFolder.Combine(fileName); + var bytes = await File.ReadAllBytesAsync(file); + var writeResult = await _fileStorage.WriteAllBytesAsync(destinationFile, bytes); + if (writeResult.IsFailure) + { + return writeResult; + } } - // Directory.CreateDirectory handles intermediate parent folders automatically - Directory.CreateDirectory(_folderPath); - return Result.Ok(); - } - catch (Exception ex) - { - return Result.Fail($"Failed to create folder: {_folderPath}") - .WithException(ex); - } - } - - public override async Task UndoAsync() - { - await Task.CompletedTask; - - try - { - if (Directory.Exists(_folderPath)) + foreach (var subFolder in Directory.GetDirectories(sourceFolderPath)) { - // Only delete if empty - if user added content, don't delete it - var files = Directory.GetFiles(_folderPath); - var dirs = Directory.GetDirectories(_folderPath); - if (files.Length == 0 && dirs.Length == 0) + var folderName = Path.GetFileName(subFolder); + var destinationSubFolder = destinationFolder.Combine(folderName); + var recurseResult = await ImportFolderAsync(subFolder, destinationSubFolder); + if (recurseResult.IsFailure) { - Directory.Delete(_folderPath); + return recurseResult; } } - return Result.Ok(); } catch (Exception ex) { - return Result.Fail($"Failed to undo create folder: {_folderPath}") + return Result.Fail($"Failed to import external folder from '{sourceFolderPath}' to '{destinationFolder}'") .WithException(ex); } + + return Result.Ok(); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs new file mode 100644 index 000000000..9ea22913c --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs @@ -0,0 +1,465 @@ +using Celbridge.Entities; +using Celbridge.Logging; +using Celbridge.Projects; +using Celbridge.Resources.Helpers; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +public sealed class TrashService : ITrashService +{ + // Retry budget for cross-process sharing-violation races on file/folder + // moves into and out of trash. Antivirus, search indexer, or sync clients + // briefly hold a read handle on a newly-created file; matches the chokepoint's + // own read/write/move retry budget until we have evidence trash traffic + // wants something different. + private const int MaxAttempts = 3; + private const int BaseRetryDelayMs = 50; + + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; + + public TrashService( + ILogger logger, + IWorkspaceWrapper workspaceWrapper) + { + _logger = logger; + _workspaceWrapper = workspaceWrapper; + } + + private IResourceRegistry ResourceRegistry => + _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + private ISidecarService SidecarService => + _workspaceWrapper.WorkspaceService.SidecarService; + + // Entity service is workspace-scoped and may be unavailable during workspace + // teardown. Trash operations are tolerant of a null entity service and skip + // the entity-data cascade in that case. + private IEntityService? EntityService => + _workspaceWrapper.IsWorkspacePageLoaded + ? _workspaceWrapper.WorkspaceService.EntityService + : null; + + private string TrashFolderPath => Path.Combine( + ResourceRegistry.ProjectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.CelbridgeTrashFolder); + + public async Task> MoveToTrashAsync(ResourceKey resource) + { + var resolveResult = ResourceRegistry.ResolveResourcePath(resource); + if (resolveResult.IsFailure) + { + return Result.Fail($"Failed to resolve path for resource: '{resource}'") + .WithErrors(resolveResult); + } + var originalPath = resolveResult.Value; + + bool isFile = File.Exists(originalPath); + bool isFolder = Directory.Exists(originalPath); + if (!isFile + && !isFolder) + { + return Result.Fail($"Resource does not exist: '{resource}'"); + } + + var trashId = Guid.NewGuid().ToString(); + var relativePath = Path.GetRelativePath(ResourceRegistry.ProjectFolderPath, originalPath); + var trashBasePath = Path.Combine(TrashFolderPath, trashId); + var trashPath = Path.Combine(trashBasePath, relativePath); + + if (isFile) + { + return await MoveFileToTrashAsync(resource, originalPath, trashPath, trashBasePath, trashId); + } + + return await MoveFolderToTrashAsync(resource, originalPath, trashPath, trashBasePath, trashId); + } + + private async Task> MoveFileToTrashAsync( + ResourceKey resource, + string originalPath, + string trashPath, + string trashBasePath, + string trashId) + { + string? sidecarOriginalPath = null; + string? sidecarTrashPath = null; + var sidecarKeyResult = SidecarService.GetSidecarKey(resource); + if (sidecarKeyResult.IsSuccess) + { + var sidecarPathResult = ResourceRegistry.ResolveResourcePath(sidecarKeyResult.Value); + if (sidecarPathResult.IsSuccess + && File.Exists(sidecarPathResult.Value)) + { + sidecarOriginalPath = sidecarPathResult.Value; + sidecarTrashPath = trashPath + SidecarHelper.Extension; + } + } + + string? entityDataOriginalPath = null; + string? entityDataTrashPath = null; + var entityService = EntityService; + if (entityService is not null) + { + var candidatePath = entityService.GetEntityDataPath(resource); + if (File.Exists(candidatePath)) + { + entityDataOriginalPath = candidatePath; + var entityRelative = entityService.GetEntityDataRelativePath(resource); + entityDataTrashPath = Path.Combine(trashBasePath, entityRelative); + } + } + + try + { + ClearReadOnlyIfSet(originalPath); + await MoveFileWithDirectoryCreationAsync(originalPath, trashPath); + + if (sidecarOriginalPath is not null + && sidecarTrashPath is not null) + { + ClearReadOnlyIfSet(sidecarOriginalPath); + await MoveFileWithDirectoryCreationAsync(sidecarOriginalPath, sidecarTrashPath); + } + + if (entityDataOriginalPath is not null + && entityDataTrashPath is not null) + { + ClearReadOnlyIfSet(entityDataOriginalPath); + await MoveFileWithDirectoryCreationAsync(entityDataOriginalPath, entityDataTrashPath); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to move resource to trash: '{resource}'"); + return Result.Fail($"Failed to move resource to trash: '{resource}'") + .WithException(ex); + } + + var entityDataFiles = entityDataOriginalPath is not null && entityDataTrashPath is not null + ? new List { new(entityDataOriginalPath, entityDataTrashPath) } + : (IReadOnlyList)Array.Empty(); + + var entry = new TrashEntry( + OriginalResource: resource, + TrashId: trashId, + WasFolder: false, + WasEmptyFolder: false, + OriginalPath: originalPath, + TrashPath: trashPath, + SidecarOriginalPath: sidecarOriginalPath, + SidecarTrashPath: sidecarTrashPath, + EntityDataFiles: entityDataFiles, + DescendantKeys: Array.Empty()); + + return entry; + } + + private async Task> MoveFolderToTrashAsync( + ResourceKey resource, + string originalPath, + string trashPath, + string trashBasePath, + string trashId) + { + var files = Directory.GetFiles(originalPath); + var directories = Directory.GetDirectories(originalPath); + bool wasEmpty = files.Length == 0 + && directories.Length == 0; + + var descendantKeys = new List(); + var entityDataFiles = new List(); + + if (!wasEmpty) + { + // Walking once to gather descendant keys for messaging and entity-data + // pairs for trash. Direct System.IO inside the trash service is + // permitted under cm-9 Decision 7 since trash bookkeeping lives outside + // the registry's reach. + var entityService = EntityService; + foreach (var filePath in Directory.GetFiles(originalPath, "*", SearchOption.AllDirectories)) + { + var keyResult = ResourceRegistry.GetResourceKey(filePath); + if (keyResult.IsSuccess) + { + descendantKeys.Add(keyResult.Value); + + if (entityService is not null) + { + var entityDataPath = entityService.GetEntityDataPath(keyResult.Value); + if (File.Exists(entityDataPath)) + { + var entityRelative = entityService.GetEntityDataRelativePath(keyResult.Value); + var entityTrashPath = Path.Combine(trashBasePath, entityRelative); + entityDataFiles.Add(new TrashedEntityDataFile(entityDataPath, entityTrashPath)); + } + } + } + } + } + + try + { + ClearReadOnlyRecursive(originalPath); + + if (wasEmpty) + { + Directory.Delete(originalPath); + } + else + { + foreach (var entityDataFile in entityDataFiles) + { + await MoveFileWithDirectoryCreationAsync(entityDataFile.OriginalPath, entityDataFile.TrashPath); + } + + await MoveDirectoryWithParentCreationAsync(originalPath, trashPath); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to move folder to trash: '{resource}'"); + return Result.Fail($"Failed to move folder to trash: '{resource}'") + .WithException(ex); + } + + var entry = new TrashEntry( + OriginalResource: resource, + TrashId: trashId, + WasFolder: true, + WasEmptyFolder: wasEmpty, + OriginalPath: originalPath, + TrashPath: wasEmpty ? string.Empty : trashPath, + SidecarOriginalPath: null, + SidecarTrashPath: null, + EntityDataFiles: entityDataFiles, + DescendantKeys: descendantKeys); + + return entry; + } + + public async Task RestoreFromTrashAsync(TrashEntry entry) + { + try + { + if (entry.WasFolder) + { + if (entry.WasEmptyFolder) + { + Directory.CreateDirectory(entry.OriginalPath); + } + else + { + if (!Directory.Exists(entry.TrashPath)) + { + return Result.Fail($"Trash folder does not exist: '{entry.TrashPath}'"); + } + + await MoveDirectoryWithParentCreationAsync(entry.TrashPath, entry.OriginalPath); + + foreach (var entityDataFile in entry.EntityDataFiles) + { + if (File.Exists(entityDataFile.TrashPath)) + { + await MoveFileWithDirectoryCreationAsync(entityDataFile.TrashPath, entityDataFile.OriginalPath); + } + } + + CleanupEmptyParentDirectories(entry.TrashPath); + } + } + else + { + if (!File.Exists(entry.TrashPath)) + { + return Result.Fail($"Trash file does not exist: '{entry.TrashPath}'"); + } + + await MoveFileWithDirectoryCreationAsync(entry.TrashPath, entry.OriginalPath); + + if (entry.SidecarOriginalPath is not null + && entry.SidecarTrashPath is not null + && File.Exists(entry.SidecarTrashPath)) + { + await MoveFileWithDirectoryCreationAsync(entry.SidecarTrashPath, entry.SidecarOriginalPath); + } + + foreach (var entityDataFile in entry.EntityDataFiles) + { + if (File.Exists(entityDataFile.TrashPath)) + { + await MoveFileWithDirectoryCreationAsync(entityDataFile.TrashPath, entityDataFile.OriginalPath); + } + } + + CleanupEmptyParentDirectories(entry.TrashPath); + } + + return Result.Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to restore resource from trash: '{entry.OriginalResource}'"); + return Result.Fail($"Failed to restore resource from trash: '{entry.OriginalResource}'") + .WithException(ex); + } + } + + public Task PurgeAsync(TrashEntry entry) + { + try + { + if (entry.WasFolder) + { + if (!entry.WasEmptyFolder + && Directory.Exists(entry.TrashPath)) + { + Directory.Delete(entry.TrashPath, recursive: true); + CleanupEmptyParentDirectories(entry.TrashPath); + } + + foreach (var entityDataFile in entry.EntityDataFiles) + { + DeleteFileIfExists(entityDataFile.TrashPath); + CleanupEmptyParentDirectories(entityDataFile.TrashPath); + } + } + else + { + DeleteFileIfExists(entry.TrashPath); + + if (entry.SidecarTrashPath is not null) + { + DeleteFileIfExists(entry.SidecarTrashPath); + } + + foreach (var entityDataFile in entry.EntityDataFiles) + { + DeleteFileIfExists(entityDataFile.TrashPath); + } + + CleanupEmptyParentDirectories(entry.TrashPath); + } + } + catch (Exception ex) + { + // Purge runs when an undo entry is evicted or the redo stack is + // cleared. It is best-effort cleanup; orphaned bytes inside the trash + // folder are wiped on the next workspace load. + _logger.LogDebug(ex, $"Best-effort trash purge failed for resource: '{entry.OriginalResource}'"); + } + + return Task.FromResult(Result.Ok()); + } + + // User intent to delete overrides the DOS read-only attribute, matching + // Windows Explorer's "delete read-only file?" confirmation behaviour. The + // cleared state persists through undo; a restored file lands writable and + // the user can re-apply the attribute via the OS file properties dialog. + private static void ClearReadOnlyIfSet(string path) + { + try + { + var info = new FileInfo(path); + if (info.Exists + && info.IsReadOnly) + { + info.IsReadOnly = false; + } + } + catch + { + // Best effort; surface the underlying issue from the subsequent move. + } + } + + // Recursive read-only clear for folder delete. Directory.Move into trash + // (and the empty-folder Directory.Delete) fails if any contained file is + // read-only, so traverse first. + private static void ClearReadOnlyRecursive(string folder) + { + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + { + ClearReadOnlyIfSet(file); + } + } + catch + { + // Best effort traversal. + } + } + + // Move a file into the trash subtree, creating any missing parent folders + // first. Retries briefly on transient IOException for the sharing-violation + // race that follows AV / indexer / sync clients touching a newly-created file. + private static async Task MoveFileWithDirectoryCreationAsync(string sourcePath, string destPath) + { + var destFolder = Path.GetDirectoryName(destPath)!; + Directory.CreateDirectory(destFolder); + await MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); + } + + private static async Task MoveDirectoryWithParentCreationAsync(string sourcePath, string destPath) + { + var destParentFolder = Path.GetDirectoryName(destPath)!; + Directory.CreateDirectory(destParentFolder); + await MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); + } + + private static async Task MoveWithRetryAsync(Action moveOperation) + { + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + moveOperation(); + return; + } + catch (IOException) when (attempt < MaxAttempts) + { + await Task.Delay(BaseRetryDelayMs * attempt); + } + } + } + + private static void DeleteFileIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + // Walk up from the start path removing every empty parent folder until + // either a non-empty folder or a top-level boundary is hit. Trash bookkeeping + // would otherwise accrue empty per-GUID folders after every purge. + private static void CleanupEmptyParentDirectories(string startPath) + { + try + { + var folder = Path.GetDirectoryName(startPath); + while (!string.IsNullOrEmpty(folder) + && Directory.Exists(folder)) + { + if (Directory.GetFiles(folder).Length == 0 + && Directory.GetDirectories(folder).Length == 0) + { + Directory.Delete(folder); + folder = Path.GetDirectoryName(folder); + } + else + { + break; + } + } + } + catch + { + // Best effort cleanup. + } + } +} diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index 3ccbd3262..df6477a6b 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -24,6 +24,7 @@ public class WorkspaceService : IWorkspaceService, IDisposable public IPackageService PackageService { get; } public IResourceService ResourceService { get; } public IFileStorage FileStorage { get; } + public ITrashService TrashService { get; } public IResourceScanner ResourceScanner { get; } public ISidecarService SidecarService { get; } public IExplorerService ExplorerService { get; } @@ -61,6 +62,7 @@ public WorkspaceService( PackageService = serviceProvider.GetRequiredService(); ResourceService = serviceProvider.GetRequiredService(); FileStorage = serviceProvider.GetRequiredService(); + TrashService = serviceProvider.GetRequiredService(); ResourceScanner = serviceProvider.GetRequiredService(); SidecarService = serviceProvider.GetRequiredService(); ExplorerService = serviceProvider.GetRequiredService(); From e2a3a486b855f80b14894450f65cd14387e0308d Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 07:58:45 +0100 Subject: [PATCH 40/48] Introduce IBatchScope and RootPathResolver Return a disposable IBatchScope from BeginBatch so batches commit on Dispose (RAII-style); update callers (copy/delete/transfer/unarchive) to use using scopes. Add BatchScope implementation and discard empty batches while committing partial batches. Rename PathValidator to RootPathResolver, expose GetResourceKey (path->key) and OS-aware comparers, and wire it into ResourceRootHandlerBase. Refactor FileStorage: rename MoveWithRetry to RetryTransientIOAsync, await retries for file/dir ops, add TryMapDescendantKey and broadcast ResourceKeyChanged/ResourceDeleted messages for moves, and centralize transient-IO retry usage. Simplify ResourceOperationService by removing messenger dependency, passing ILogger into ImportExternalOperation, and removing internal broadcast logic (batching now handles commit). Update TrashService to accept IMessengerService and send ResourceDeleted messages on successful soft-deletes. Add and adjust tests (ResourceOperationServiceTests added; PathValidatorTests renamed/expanded to RootPathResolverTests) and minor comment/text updates. These changes improve batch semantics, two-way path/key resolution, retry handling, and resource messaging consistency. --- .../Resources/IResourceOperationService.cs | 20 +- .../Tools/WebView/WebViewTools.Screenshot.cs | 2 +- .../ResourceOperationServiceTests.cs | 201 ++++++++++++++++++ ...datorTests.cs => RootPathResolverTests.cs} | 99 +++++++-- Source/Tests/Resources/TrashServiceTests.cs | 2 + .../Commands/CopyResourceCommand.cs | 11 +- .../Commands/DeleteResourceCommand.cs | 7 +- .../Commands/TransferResourcesCommand.cs | 11 +- .../Commands/UnarchiveResourceCommand.cs | 179 ++++++++-------- .../{PathValidator.cs => RootPathResolver.cs} | 87 ++++++-- .../Services/FileStorage.cs | 67 ++++-- .../Services/ResourceOperationService.cs | 133 ++++-------- .../Services/ResourceOperations.cs | 8 +- .../Services/Roots/ResourceRootHandlerBase.cs | 65 ++---- .../Services/TrashService.cs | 27 ++- 15 files changed, 591 insertions(+), 328 deletions(-) create mode 100644 Source/Tests/Resources/ResourceOperationServiceTests.cs rename Source/Tests/Resources/{PathValidatorTests.cs => RootPathResolverTests.cs} (57%) rename Source/Workspace/Celbridge.Resources/Helpers/{PathValidator.cs => RootPathResolver.cs} (59%) diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs index 38dfb83ff..30ad4ae76 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs @@ -2,6 +2,15 @@ namespace Celbridge.Resources; +/// +/// In-progress batch of resource operations. Disposing the scope commits the +/// accumulated operations as a single undo unit; partial batches commit so +/// the user can Ctrl+Z to reverse them. +/// +public interface IBatchScope : IDisposable +{ +} + /// /// The workspace-scoped resource operation service. Layers session-local undo /// and redo, batched grouping, and soft-delete trash on top of the IFileStorage @@ -63,15 +72,10 @@ public interface IResourceOperationService Task TransferAsync(ResourceKey source, ResourceKey destination, DataTransferMode mode); /// - /// Begin a batch of operations that will be grouped together as a single undo unit. - /// Call CommitBatch() when done to finalize the batch. - /// - void BeginBatch(); - - /// - /// Commit the current batch of operations as a single undo unit. + /// Begins a batch of operations that commit as a single undo unit when the + /// returned scope is disposed. /// - void CommitBatch(); + IBatchScope BeginBatch(); /// /// Returns true if there are operations that can be undone. diff --git a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs index b84ceb381..db4f52d21 100644 --- a/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs +++ b/Source/Core/Celbridge.Tools/Tools/WebView/WebViewTools.Screenshot.cs @@ -84,7 +84,7 @@ public async partial Task Screenshot( if (fileResource is not null) { // Route the write through IWriteBinaryFileCommand so capability - // gating, registry refresh, and PathValidator containment all run. + // gating, registry refresh, and RootPathResolver containment all run. // The base64 round-trip is a one-time in-process cost. No network // or JSON envelope sees the encoded form, so the JSON-escape // corruption that drove the original on-disk redesign cannot recur. diff --git a/Source/Tests/Resources/ResourceOperationServiceTests.cs b/Source/Tests/Resources/ResourceOperationServiceTests.cs new file mode 100644 index 000000000..72fcd49b5 --- /dev/null +++ b/Source/Tests/Resources/ResourceOperationServiceTests.cs @@ -0,0 +1,201 @@ +using Celbridge.Entities; +using Celbridge.Logging; +using Celbridge.Messaging; +using Celbridge.Projects; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for ResourceOperationService — covers the end-of-phase gate property +/// from the cm-9 redesign: a batch that fails mid-way still commits the +/// prior-successful operations and a single UndoAsync reverses them cleanly. +/// +[TestFixture] +public class ResourceOperationServiceTests +{ + private string _tempFolder = null!; + private IResourceRegistry _resourceRegistry = null!; + private IWorkspaceWrapper _workspaceWrapper = null!; + private FileStorage _fileStorage = null!; + private TrashService _trashService = null!; + private ResourceOperationService _operationService = null!; + + [SetUp] + public void Setup() + { + _tempFolder = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(ResourceOperationServiceTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempFolder); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + _resourceRegistry.RootHandlers.Returns(new Dictionary()); + + // Map every key under the default root to a path under the temp folder. + _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(call => + { + var key = call.Arg(); + if (key.IsEmpty) + { + return Result.Ok(_tempFolder); + } + var relativePath = key.Path.Replace('/', Path.DirectorySeparatorChar); + return Result.Ok(Path.Combine(_tempFolder, relativePath)); + }); + + // Inverse mapping (used by FileStorage's descendant-key enumeration on + // folder moves and deletes). + _resourceRegistry.GetResourceKey(Arg.Any()).Returns(call => + { + var fullPath = call.Arg(); + var relativePart = Path.GetRelativePath(_tempFolder, fullPath) + .Replace(Path.DirectorySeparatorChar, '/'); + return Result.Ok(new ResourceKey(relativePart)); + }); + + var resourceScanner = Substitute.For(); + resourceScanner.FindReferencersAsync(Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + resourceScanner.FindAllReferencedTargetsAsync() + .Returns(Task.FromResult>(Array.Empty())); + + var rootHandlerRegistry = Substitute.For(); + rootHandlerRegistry.RootHandlers.Returns(new Dictionary()); + + var resourceService = Substitute.For(); + resourceService.Registry.Returns(_resourceRegistry); + resourceService.RootHandlerRegistry.Returns(rootHandlerRegistry); + + var workspaceService = Substitute.For(); + workspaceService.ResourceService.Returns(resourceService); + workspaceService.ResourceScanner.Returns(resourceScanner); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + // IsWorkspacePageLoaded = false skips the entity-data cascade so the + // tests don't need to wire an IEntityService. + _workspaceWrapper.IsWorkspacePageLoaded.Returns(false); + + var sidecarService = new SidecarService(_workspaceWrapper); + workspaceService.SidecarService.Returns(sidecarService); + + _fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.FileStorage.Returns(_fileStorage); + + _trashService = new TrashService( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.TrashService.Returns(_trashService); + + _operationService = new ResourceOperationService( + Substitute.For>(), + _workspaceWrapper); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempFolder)) + { + Directory.Delete(_tempFolder, true); + } + } + + [Test] + public async Task PartialBatch_FailureMidway_CommitsPriorOps_AndSingleUndoReversesThem() + { + // Pre-create a file outside the batch so the second CreateFileAsync + // inside the batch fails with "Resource already exists". + var existingPath = Path.Combine(_tempFolder, "existing.txt"); + await File.WriteAllTextAsync(existingPath, "preexisting"); + + var newResource = new ResourceKey("new.txt"); + var existingResource = new ResourceKey("existing.txt"); + var newPath = Path.Combine(_tempFolder, "new.txt"); + + using (var batch = _operationService.BeginBatch()) + { + var firstCreate = await _operationService.CreateFileAsync(newResource, new byte[] { 0x01, 0x02 }); + firstCreate.IsSuccess.Should().BeTrue(); + + // The second op fails inside CreateOperation.ExecuteAsync (the + // probe sees the existing file) and is NOT added to the batch. + var secondCreate = await _operationService.CreateFileAsync(existingResource, new byte[] { 0xFF }); + secondCreate.IsFailure.Should().BeTrue(); + } + + // After the using-block commits the partial batch: the newly-created + // file is on disk and the pre-existing file is untouched. + File.Exists(newPath).Should().BeTrue(); + File.Exists(existingPath).Should().BeTrue(); + (await File.ReadAllTextAsync(existingPath)).Should().Be("preexisting"); + + // A single UndoAsync reverses the committed partial batch: the new + // file is deleted; the pre-existing file (never inside the batch) stays. + _operationService.CanUndo.Should().BeTrue(); + var undoResult = await _operationService.UndoAsync(); + undoResult.IsSuccess.Should().BeTrue(); + + File.Exists(newPath).Should().BeFalse(); + File.Exists(existingPath).Should().BeTrue(); + _operationService.CanUndo.Should().BeFalse(); + _operationService.CanRedo.Should().BeTrue(); + } + + [Test] + public async Task EmptyBatch_DoesNotPushAnUndoEntry() + { + using (var batch = _operationService.BeginBatch()) + { + // No operations queued. + } + + // An empty batch is discarded — CanUndo stays false. + _operationService.CanUndo.Should().BeFalse(); + } + + [Test] + public async Task BatchScope_CommitsOnDispose_EvenWhenReturnExitsEarly() + { + var firstResource = new ResourceKey("a.txt"); + var secondResource = new ResourceKey("b.txt"); + + // Wrap in a local async function that returns early from inside the + // using block; the BatchScope's Dispose must still commit on the way out. + async Task RunPartialBatch() + { + using var batch = _operationService.BeginBatch(); + var first = await _operationService.CreateFileAsync(firstResource, new byte[] { 0x01 }); + if (first.IsFailure) + { + return false; + } + + // Early return mid-batch — the second CreateFileAsync never runs. + return true; + } + + var ran = await RunPartialBatch(); + ran.Should().BeTrue(); + + File.Exists(Path.Combine(_tempFolder, "a.txt")).Should().BeTrue(); + File.Exists(Path.Combine(_tempFolder, "b.txt")).Should().BeFalse(); + + // The partially-populated batch did commit — UndoAsync reverses the + // single create. + _operationService.CanUndo.Should().BeTrue(); + var undoResult = await _operationService.UndoAsync(); + undoResult.IsSuccess.Should().BeTrue(); + File.Exists(Path.Combine(_tempFolder, "a.txt")).Should().BeFalse(); + } +} diff --git a/Source/Tests/Resources/PathValidatorTests.cs b/Source/Tests/Resources/RootPathResolverTests.cs similarity index 57% rename from Source/Tests/Resources/PathValidatorTests.cs rename to Source/Tests/Resources/RootPathResolverTests.cs index 84019d309..ae271733a 100644 --- a/Source/Tests/Resources/PathValidatorTests.cs +++ b/Source/Tests/Resources/RootPathResolverTests.cs @@ -3,14 +3,14 @@ namespace Celbridge.Tests.Resources; [TestFixture] -public class PathValidatorTests +public class RootPathResolverTests { private string? _projectFolder; [SetUp] public void Setup() { - _projectFolder = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(PathValidatorTests)}"); + _projectFolder = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(RootPathResolverTests)}"); if (Directory.Exists(_projectFolder)) { Directory.Delete(_projectFolder, true); @@ -32,10 +32,10 @@ public void ValidateAndResolveSucceedsForValidKey() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); var resourceKey = ResourceKey.Create("folder/file.txt"); - var resolveResult = validator.ValidateAndResolve(resourceKey); + var resolveResult = resolver.ValidateAndResolve(resourceKey); var expectedPath = Path.GetFullPath(Path.Combine(_projectFolder, "folder", "file.txt")); resolveResult.IsSuccess.Should().BeTrue(); @@ -47,9 +47,9 @@ public void ValidateAndResolveSucceedsForEmptyKey() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve(ResourceKey.Empty); + var resolveResult = resolver.ValidateAndResolve(ResourceKey.Empty); var expectedPath = Path.GetFullPath(_projectFolder); resolveResult.IsSuccess.Should().BeTrue(); @@ -66,13 +66,13 @@ public void ValidateAndResolveCachesVerifiedFolders() Directory.CreateDirectory(subFolder); File.WriteAllText(Path.Combine(subFolder, "a.txt"), "test"); - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); // First call — verifies the folder - validator.ValidateAndResolve(ResourceKey.Create("cached/a.txt")); + resolver.ValidateAndResolve(ResourceKey.Create("cached/a.txt")); // Second call — should hit the cache (no way to assert directly, but it should not throw) - validator.ValidateAndResolve(ResourceKey.Create("cached/b.txt")); + resolver.ValidateAndResolve(ResourceKey.Create("cached/b.txt")); } [Test] @@ -84,16 +84,16 @@ public void InvalidateCacheClearsVerifiedFolders() Directory.CreateDirectory(subFolder); File.WriteAllText(Path.Combine(subFolder, "a.txt"), "test"); - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); // Cache the folder - validator.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); + resolver.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); // Invalidate - validator.InvalidateCache(); + resolver.InvalidateCache(); // Next call should re-verify (still succeeds since folder is clean) - var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); + var resolveResult = resolver.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); resolveResult.IsSuccess.Should().BeTrue(); resolveResult.Value.Should().NotBeEmpty(); } @@ -104,7 +104,7 @@ public void ValidateAndResolveRejectsReparsePoint() Guard.IsNotNull(_projectFolder); var outsideFolder = Path.Combine( - Path.GetTempPath(), $"Celbridge/{nameof(PathValidatorTests)}_outside"); + Path.GetTempPath(), $"Celbridge/{nameof(RootPathResolverTests)}_outside"); Directory.CreateDirectory(outsideFolder); var symlinkPath = Path.Combine(_projectFolder, "link_folder"); @@ -121,9 +121,9 @@ public void ValidateAndResolveRejectsReparsePoint() try { - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("link_folder/file.txt")); + var resolveResult = resolver.ValidateAndResolve(ResourceKey.Create("link_folder/file.txt")); resolveResult.IsFailure.Should().BeTrue(); resolveResult.FirstErrorMessage.Should().Contain("symbolic link or junction"); } @@ -145,21 +145,78 @@ public void ValidateAndResolveAcceptsNonExistentPath() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); // Non-existent paths should be accepted (for create operations) - var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("new_folder/new_file.txt")); + var resolveResult = resolver.ValidateAndResolve(ResourceKey.Create("new_folder/new_file.txt")); resolveResult.IsSuccess.Should().BeTrue(); resolveResult.Value.Should().NotBeEmpty(); } + [Test] + public void GetResourceKeyReturnsRootOnlyKeyForBackingLocation() + { + Guard.IsNotNull(_projectFolder); + + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); + + var keyResult = resolver.GetResourceKey(_projectFolder); + + keyResult.IsSuccess.Should().BeTrue(); + keyResult.Value.Root.Should().Be(ResourceKey.DefaultRoot); + keyResult.Value.Path.Should().BeEmpty(); + } + + [Test] + public void GetResourceKeyComposesRelativeSegmentsUnderTheRoot() + { + Guard.IsNotNull(_projectFolder); + + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); + var fullPath = Path.Combine(_projectFolder, "folder", "file.txt"); + + var keyResult = resolver.GetResourceKey(fullPath); + + keyResult.IsSuccess.Should().BeTrue(); + keyResult.Value.Path.Should().Be("folder/file.txt"); + } + + [Test] + public void GetResourceKeyFailsForPathOutsideBackingLocation() + { + Guard.IsNotNull(_projectFolder); + + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); + var outsidePath = Path.Combine(Path.GetTempPath(), $"Celbridge/{nameof(RootPathResolverTests)}_unrelated", "stray.txt"); + + var keyResult = resolver.GetResourceKey(outsidePath); + + keyResult.IsFailure.Should().BeTrue(); + keyResult.FirstErrorMessage.Should().Contain("not under root"); + } + + [Test] + public void GetResourceKeyCarriesThroughTheConfiguredRootName() + { + Guard.IsNotNull(_projectFolder); + + var resolver = new RootPathResolver("temp", _projectFolder); + var fullPath = Path.Combine(_projectFolder, "sub", "scratch.txt"); + + var keyResult = resolver.GetResourceKey(fullPath); + + keyResult.IsSuccess.Should().BeTrue(); + keyResult.Value.Root.Should().Be("temp"); + keyResult.Value.Path.Should().Be("sub/scratch.txt"); + } + [Test] public void ValidateAndResolveRejectsIntermediateReparsePoint() { Guard.IsNotNull(_projectFolder); var outsideFolder = Path.Combine( - Path.GetTempPath(), $"Celbridge/{nameof(PathValidatorTests)}_outside2"); + Path.GetTempPath(), $"Celbridge/{nameof(RootPathResolverTests)}_outside2"); Directory.CreateDirectory(outsideFolder); // Create a structure: project/parent/link -> outside @@ -180,9 +237,9 @@ public void ValidateAndResolveRejectsIntermediateReparsePoint() try { - var validator = new PathValidator(ResourceKey.DefaultRoot, _projectFolder); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve(ResourceKey.Create("parent/link/file.txt")); + var resolveResult = resolver.ValidateAndResolve(ResourceKey.Create("parent/link/file.txt")); resolveResult.IsFailure.Should().BeTrue(); resolveResult.FirstErrorMessage.Should().Contain("symbolic link or junction"); } diff --git a/Source/Tests/Resources/TrashServiceTests.cs b/Source/Tests/Resources/TrashServiceTests.cs index 8ef726ab7..f8f8a331f 100644 --- a/Source/Tests/Resources/TrashServiceTests.cs +++ b/Source/Tests/Resources/TrashServiceTests.cs @@ -1,5 +1,6 @@ using Celbridge.Entities; using Celbridge.Logging; +using Celbridge.Messaging; using Celbridge.Projects; using Celbridge.Resources; using Celbridge.Resources.Helpers; @@ -54,6 +55,7 @@ public void Setup() _trashService = new TrashService( Substitute.For>(), + Substitute.For(), _workspaceWrapper); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs index 89bf85aa6..9d8ec0bb8 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/CopyResourceCommand.cs @@ -68,9 +68,6 @@ public override async Task ExecuteAsync() // This prevents duplicate operations when both a folder and its contents are selected. var filteredResources = FilterRedundantResources(SourceResources); - // Begin batch for single undo operation - ResourceOperationService.BeginBatch(); - List failedResources = new(); List failedOutcomes = new(); List copiedFolders = new(); @@ -78,7 +75,8 @@ public override async Task ExecuteAsync() List aggregatedSkipped = new(); ResourceKey? lastParentFolder = null; - try + // Single undo unit for the whole batch; partial success is acceptable. + using (var batch = ResourceOperationService.BeginBatch()) { foreach (var sourceResource in filteredResources) { @@ -107,11 +105,6 @@ public override async Task ExecuteAsync() } } } - finally - { - // Always commit batch - partial success is acceptable - ResourceOperationService.CommitBatch(); - } ResultValue = new CopyCommandResult( aggregatedUpdated, diff --git a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs index 0150757a2..6ed1e4299 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/DeleteResourceCommand.cs @@ -172,8 +172,7 @@ bool IsInsideBatch(ResourceKey candidate) var resourceResults = new List(Resources.Count); var failedItems = new List(); - ResourceOperationService.BeginBatch(); - try + using (var batch = ResourceOperationService.BeginBatch()) { foreach (var resource in Resources) { @@ -241,10 +240,6 @@ bool IsInsideBatch(ResourceKey candidate) FailureMessage: null)); } } - finally - { - ResourceOperationService.CommitBatch(); - } // Distinguish "every resource deleted cleanly" from "policy gate passed // but at least one resource failed mechanically". A human (or agent) diff --git a/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs index 57dfb75c2..d56e254fc 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/TransferResourcesCommand.cs @@ -52,12 +52,10 @@ public override async Task ExecuteAsync() return Result.Ok(); } - // Begin batch for single undo operation - resourceOpService.BeginBatch(); - List failedItems = new(); - try + // Single undo unit for the whole batch; partial success is acceptable. + using (var batch = resourceOpService.BeginBatch()) { foreach (var item in TransferItems) { @@ -68,11 +66,6 @@ public override async Task ExecuteAsync() } } } - finally - { - // Always commit batch - partial success is acceptable - resourceOpService.CommitBatch(); - } // Expand the destination folder so the user can see the newly transferred resources _commandService.Execute(command => diff --git a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs index d92768959..122bf52da 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs @@ -169,126 +169,119 @@ private async Task ExecuteExtractAsync() } } - // Begin batch so all operations are a single undo unit - resourceOpService.BeginBatch(); - - try + // Begin batch so all operations are a single undo unit. + using var batch = resourceOpService.BeginBatch(); + + // Create the destination folder and any missing ancestors through + // the operation service so the whole chain lands in the unarchive's + // undo batch. + var destInfoResult = await fileStorage.GetInfoAsync(DestinationResource); + if (destInfoResult.IsFailure) { - // Create the destination folder and any missing ancestors - // through the operation service so the whole chain lands in - // the unarchive's undo batch. - var destInfoResult = await fileStorage.GetInfoAsync(DestinationResource); - if (destInfoResult.IsFailure) - { - return Result.Fail($"Failed to probe destination resource: '{DestinationResource}'") - .WithErrors(destInfoResult); - } + return Result.Fail($"Failed to probe destination resource: '{DestinationResource}'") + .WithErrors(destInfoResult); + } - if (destInfoResult.Value.Kind == StorageItemKind.NotFound) + if (destInfoResult.Value.Kind == StorageItemKind.NotFound) + { + // CreateFolderAsync on the chokepoint is idempotent and creates + // missing intermediate parents in one call. We still collect + // and create ancestors one-at-a-time so each lands as its own + // undoable operation inside the batch. + var missingAncestorKeys = new List(); + var ancestorKey = DestinationResource.GetParent(); + while (!ancestorKey.IsEmpty) { - // CreateFolderAsync on the chokepoint is idempotent and - // creates missing intermediate parents in one call. We still - // collect and create ancestors one-at-a-time so each lands - // as its own undoable operation inside the batch. - var missingAncestorKeys = new List(); - var ancestorKey = DestinationResource.GetParent(); - while (!ancestorKey.IsEmpty) + var ancestorInfoResult = await fileStorage.GetInfoAsync(ancestorKey); + if (ancestorInfoResult.IsFailure) { - var ancestorInfoResult = await fileStorage.GetInfoAsync(ancestorKey); - if (ancestorInfoResult.IsFailure) - { - return Result.Fail($"Failed to probe ancestor resource: '{ancestorKey}'") - .WithErrors(ancestorInfoResult); - } - if (ancestorInfoResult.Value.Kind != StorageItemKind.NotFound) - { - break; - } - missingAncestorKeys.Add(ancestorKey); - ancestorKey = ancestorKey.GetParent(); + return Result.Fail($"Failed to probe ancestor resource: '{ancestorKey}'") + .WithErrors(ancestorInfoResult); } - missingAncestorKeys.Reverse(); - - foreach (var key in missingAncestorKeys) + if (ancestorInfoResult.Value.Kind != StorageItemKind.NotFound) { - var createAncestorResult = await resourceOpService.CreateFolderAsync(key); - if (createAncestorResult.IsFailure) - { - return createAncestorResult; - } + break; } + missingAncestorKeys.Add(ancestorKey); + ancestorKey = ancestorKey.GetParent(); + } + missingAncestorKeys.Reverse(); - var createDestResult = await resourceOpService.CreateFolderAsync(DestinationResource); - if (createDestResult.IsFailure) + foreach (var key in missingAncestorKeys) + { + var createAncestorResult = await resourceOpService.CreateFolderAsync(key); + if (createAncestorResult.IsFailure) { - return createDestResult; + return createAncestorResult; } } - // Filter shallowest-first so parents are created before children. - var sortedFolderKeys = new List(); - foreach (var folderPath in foldersToCreate.OrderBy(path => path.Length)) + var createDestResult = await resourceOpService.CreateFolderAsync(DestinationResource); + if (createDestResult.IsFailure) { - var folderKey = BuildDescendantKey(DestinationResource, destinationPath, folderPath); - var folderInfoResult = await fileStorage.GetInfoAsync(folderKey); - if (folderInfoResult.IsFailure) - { - return Result.Fail($"Failed to probe folder resource: '{folderKey}'") - .WithErrors(folderInfoResult); - } - if (folderInfoResult.Value.Kind == StorageItemKind.NotFound) - { - sortedFolderKeys.Add(folderKey); - } + return createDestResult; } + } - foreach (var folderKey in sortedFolderKeys) + // Filter shallowest-first so parents are created before children. + var sortedFolderKeys = new List(); + foreach (var folderPath in foldersToCreate.OrderBy(path => path.Length)) + { + var folderKey = BuildDescendantKey(DestinationResource, destinationPath, folderPath); + var folderInfoResult = await fileStorage.GetInfoAsync(folderKey); + if (folderInfoResult.IsFailure) { - var createFolderResult = await resourceOpService.CreateFolderAsync(folderKey); - if (createFolderResult.IsFailure) - { - return createFolderResult; - } + return Result.Fail($"Failed to probe folder resource: '{folderKey}'") + .WithErrors(folderInfoResult); + } + if (folderInfoResult.Value.Kind == StorageItemKind.NotFound) + { + sortedFolderKeys.Add(folderKey); } + } - // Extract files - foreach (var entry in validEntries) + foreach (var folderKey in sortedFolderKeys) + { + var createFolderResult = await resourceOpService.CreateFolderAsync(folderKey); + if (createFolderResult.IsFailure) { - var entryResource = DestinationResource.Combine(entry.FullName); + return createFolderResult; + } + } + + // Extract files + foreach (var entry in validEntries) + { + var entryResource = DestinationResource.Combine(entry.FullName); - // If overwriting, delete existing file first so it's preserved in trash for undo - if (Overwrite) + // If overwriting, delete existing file first so it's preserved in trash for undo + if (Overwrite) + { + var existingInfoResult = await fileStorage.GetInfoAsync(entryResource); + if (existingInfoResult.IsSuccess + && existingInfoResult.Value.Kind == StorageItemKind.File) { - var existingInfoResult = await fileStorage.GetInfoAsync(entryResource); - if (existingInfoResult.IsSuccess - && existingInfoResult.Value.Kind == StorageItemKind.File) + var deleteResult = await resourceOpService.DeleteAsync(entryResource); + if (deleteResult.IsFailure) { - var deleteResult = await resourceOpService.DeleteAsync(entryResource); - if (deleteResult.IsFailure) - { - return deleteResult; - } + return deleteResult; } } + } - // Read entry into byte array - using var entryStream = entry.Open(); - using var memoryStream = new MemoryStream(); - await entryStream.CopyToAsync(memoryStream); - var entryBytes = memoryStream.ToArray(); - - var createResult = await resourceOpService.CreateFileAsync(entryResource, entryBytes); - if (createResult.IsFailure) - { - return createResult; - } + // Read entry into byte array + using var entryStream = entry.Open(); + using var memoryStream = new MemoryStream(); + await entryStream.CopyToAsync(memoryStream); + var entryBytes = memoryStream.ToArray(); - entryCount++; + var createResult = await resourceOpService.CreateFileAsync(entryResource, entryBytes); + if (createResult.IsFailure) + { + return createResult; } - } - finally - { - resourceOpService.CommitBatch(); + + entryCount++; } } catch (IOException exception) diff --git a/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs b/Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs similarity index 59% rename from Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs rename to Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs index c9d251335..d7fe0d601 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/PathValidator.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs @@ -3,33 +3,29 @@ namespace Celbridge.Resources.Helpers; /// -/// Validates that resolved resource paths stay within the backing folder of a single -/// root and do not traverse through symlinks, junctions, or other reparse points. -/// Maintains a cache of verified directory paths to avoid repeated filesystem stat -/// calls. One instance serves exactly one root; its owning root handler constructs -/// it with that root's name and backing location. +/// Resolves between resource keys and absolute filesystem paths for a single +/// root. Validates that resolved paths stay within the root's backing folder +/// and do not traverse through symlinks, junctions, or other reparse points; +/// maintains a cache of verified directory paths to avoid repeated filesystem +/// stat calls. One instance serves exactly one root; its owning root handler +/// constructs it with that root's name and backing location. /// -public class PathValidator +public class RootPathResolver { private readonly string _rootName; private readonly string _backingLocation; - private readonly StringComparer _pathComparer; private readonly HashSet _verifiedFolders; - public PathValidator(string rootName, string backingLocation) + public RootPathResolver(string rootName, string backingLocation) { _rootName = rootName; _backingLocation = backingLocation; - _pathComparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - - _verifiedFolders = new HashSet(_pathComparer); + _verifiedFolders = new HashSet(GetPathComparer()); } /// /// Validates a resource key and resolves it to an absolute filesystem path under the - /// validator's backing location. Returns a failure result if the key fails any validation check. + /// resolver's backing location. Returns a failure result if the key fails any validation check. /// public Result ValidateAndResolve(ResourceKey resource) { @@ -70,6 +66,60 @@ public Result ValidateAndResolve(ResourceKey resource) return resolvedPath; } + /// + /// Computes the resource key for an absolute filesystem path under this resolver's + /// backing location. Returns failure when the path is outside the backing location or + /// the relative segment is not a valid resource key. + /// + public Result GetResourceKey(string absolutePath) + { + try + { + var normalizedPath = Path.GetFullPath(absolutePath); + var normalizedBacking = Path.GetFullPath(_backingLocation) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // No symlink check here: ValidateAndResolve enforces it at the I/O + // boundary, and replaying it on every label call would dominate the + // watcher / enumerate hot path. + var comparison = GetPathComparison(); + + bool isBackingRoot = normalizedPath.Equals(normalizedBacking, comparison); + bool isUnderBacking = normalizedPath.StartsWith( + normalizedBacking + Path.DirectorySeparatorChar, comparison); + + if (!isBackingRoot && !isUnderBacking) + { + return Result.Fail( + $"Path '{absolutePath}' is not under root '{_rootName}' backing location '{_backingLocation}'."); + } + + var relativePart = isBackingRoot + ? string.Empty + : normalizedPath + .Substring(normalizedBacking.Length) + .Replace('\\', '/') + .Trim('/'); + + var keyString = string.IsNullOrEmpty(relativePart) + ? _rootName + ":" + : _rootName + ":" + relativePart; + + if (!ResourceKey.TryCreate(keyString, out var resourceKey)) + { + return Result.Fail( + $"Path '{absolutePath}' produces an invalid resource key: '{keyString}'."); + } + + return resourceKey; + } + catch (Exception ex) + { + return Result.Fail($"An exception occurred when getting the resource key for '{absolutePath}'.") + .WithException(ex); + } + } + /// /// Clears the cache of verified directory paths. Call this when the directory /// structure may have changed (e.g. after ResourceMonitor triggers a registry sync). @@ -145,10 +195,19 @@ private static string NormalizeBackingLocation(string backingLocation) return normalized; } + // StringComparison flavour for string ops; StringComparer flavour for + // collection keys. Both consult the same Windows / non-Windows selector. private static StringComparison GetPathComparison() { return OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } + + private static StringComparer GetPathComparer() + { + return OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + } } diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 3ab1434d7..061b6815a 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -241,11 +241,11 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey // attribute the user has explicitly chosen to override by // invoking a move on the file. ClearReadOnlyIfSet(sourcePath); - await MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); + await RetryTransientIOAsync(() => File.Move(sourcePath, destPath)); } else { - await MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); + await RetryTransientIOAsync(() => Directory.Move(sourcePath, destPath)); } } catch (UnauthorizedAccessException ex) @@ -263,16 +263,26 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey if (source.Root == ResourceKey.DefaultRoot) { - // Announce the source removal synchronously so subscribers update - // before control returns. The watcher's own delete event still - // arrives later via UI-thread dispatch; subscribers must treat - // these messages as idempotent. + // Announce the source removal and the new key identity synchronously + // so subscribers update before control returns. The watcher's own + // events still arrive later via UI-thread dispatch; subscribers must + // treat these messages as idempotent. var sourceRemovedMessage = new ResourceDeletedMessage(source); _messengerService.Send(sourceRemovedMessage); - foreach (var key in sourceDescendantKeys) + + var keyChangedMessage = new ResourceKeyChangedMessage(source, destination); + _messengerService.Send(keyChangedMessage); + + foreach (var descendantSource in sourceDescendantKeys) { - var descendantRemovedMessage = new ResourceDeletedMessage(key); + var descendantRemovedMessage = new ResourceDeletedMessage(descendantSource); _messengerService.Send(descendantRemovedMessage); + + if (TryMapDescendantKey(source, destination, descendantSource, out var descendantDestination)) + { + var descendantKeyChangedMessage = new ResourceKeyChangedMessage(descendantSource, descendantDestination); + _messengerService.Send(descendantKeyChangedMessage); + } } } @@ -282,6 +292,31 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey return moveResult; } + // Maps a descendant of the source folder to the equivalent key under the + // destination folder. + private static bool TryMapDescendantKey( + ResourceKey sourceFolder, + ResourceKey destinationFolder, + ResourceKey descendantSource, + out ResourceKey descendantDestination) + { + var sourcePath = sourceFolder.Path; + var descendantPath = descendantSource.Path; + if (!descendantPath.StartsWith(sourcePath + "/", StringComparison.Ordinal)) + { + descendantDestination = ResourceKey.Empty; + return false; + } + + var relativeSuffix = descendantPath.Substring(sourcePath.Length); + var destinationPath = destinationFolder.Path + relativeSuffix; + var rootName = destinationFolder.Root; + var fullKey = rootName == ResourceKey.DefaultRoot + ? destinationPath + : $"{rootName}:{destinationPath}"; + return ResourceKey.TryCreate(fullKey, out descendantDestination); + } + public async Task> CopyAsync(ResourceKey source, ResourceKey destination) { var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -396,14 +431,14 @@ public async Task> DeleteAsync(ResourceKey source) // Matches OS Explorer's "delete read-only file?" behaviour // (proceed when the user explicitly invokes delete). ClearReadOnlyIfSet(sourcePath); - File.Delete(sourcePath); + await RetryTransientIOAsync(() => File.Delete(sourcePath)); } else { // Recursive delete fails on any contained read-only file, so // strip the attribute throughout the subtree first. ClearReadOnlyRecursive(sourcePath); - Directory.Delete(sourcePath, recursive: true); + await RetryTransientIOAsync(() => Directory.Delete(sourcePath, recursive: true)); } } catch (UnauthorizedAccessException ex) @@ -432,8 +467,6 @@ public async Task> DeleteAsync(ResourceKey source) } } - await Task.CompletedTask; - var deleteResult = new DeleteResult(sidecarOutcome); return deleteResult; } @@ -903,7 +936,7 @@ private async Task TryCascadeSidecarMoveAsync(ResourceKey source Directory.CreateDirectory(destFolder); } - await MoveWithRetryAsync(() => File.Move(sourceSidecarPath, destSidecarPath)); + await RetryTransientIOAsync(() => File.Move(sourceSidecarPath, destSidecarPath)); return SidecarOutcome.Cascaded; } catch (Exception ex) @@ -1200,18 +1233,18 @@ private static Result EnsureParentFolderExists(string resourcePath, ResourceKey } } - // Runs a synchronous move action under the chokepoint's bounded-retry + // Runs a synchronous filesystem action under the chokepoint's bounded-retry // policy. A file briefly held open by AV, indexer, or sync clients after // creation clears within milliseconds; the same retry budget the read and // write paths use catches the common races. Non-IO exceptions and the final // attempt's IOException propagate unchanged so persistent failures surface. - private static async Task MoveWithRetryAsync(Action moveAction) + private static async Task RetryTransientIOAsync(Action action) { for (var attempt = 1; attempt <= MaxAttempts; attempt++) { try { - moveAction(); + action(); return; } catch (IOException) when (attempt < MaxAttempts) @@ -1232,7 +1265,7 @@ private static async Task WriteAtomicAsync(string resourcePath, string stagingFo try { await File.WriteAllBytesAsync(tempPath, bytes); - await MoveWithRetryAsync(() => File.Move(tempPath, resourcePath, overwrite: true)); + await RetryTransientIOAsync(() => File.Move(tempPath, resourcePath, overwrite: true)); } catch { diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 03a812999..48961906a 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -10,16 +10,15 @@ namespace Celbridge.Resources.Services; /// Wraps the IFileStorage chokepoint and the ITrashService soft-delete layer /// with a session-local undo/redo stack and batched grouping. Public methods /// accept ResourceKey; external imports keep a string source path because the -/// source is outside the registry by definition. All actual disk I/O routes -/// through the chokepoint or the trash service; this class owns no direct -/// System.IO calls. +/// source is outside the registry by definition. All disk I/O routes through +/// the chokepoint or the trash service; this class owns no direct System.IO +/// calls and no message broadcasts. /// public class ResourceOperationService : IResourceOperationService { private const int MaxUndoStackSize = 50; private readonly ILogger _logger; - private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; private readonly List _undoStack = new(); @@ -29,11 +28,9 @@ public class ResourceOperationService : IResourceOperationService public ResourceOperationService( ILogger logger, - IMessengerService messengerService, IWorkspaceWrapper workspaceWrapper) { _logger = logger; - _messengerService = messengerService; _workspaceWrapper = workspaceWrapper; } @@ -173,16 +170,6 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey AddOperation(operation); - if (isFolder) - { - SendFolderResourceKeyChangedMessages(source, destination); - } - else - { - var message = new ResourceKeyChangedMessage(source, destination); - _messengerService.Send(message); - } - return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); } @@ -194,7 +181,6 @@ public async Task DeleteAsync(ResourceKey resource) if (result.IsSuccess) { AddOperation(operation); - BroadcastDeleteMessages(operation.TrashEntry); } return result; @@ -204,7 +190,7 @@ public async Task ImportExternalFileAsync(string sourcePath, ResourceKey { sourcePath = Path.GetFullPath(sourcePath); - var operation = new ImportExternalOperation(sourcePath, destination, isFolder: false, FileStorage); + var operation = new ImportExternalOperation(sourcePath, destination, isFolder: false, FileStorage, _logger); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -219,7 +205,7 @@ public async Task ImportExternalFolderAsync(string sourcePath, ResourceK { sourcePath = Path.GetFullPath(sourcePath); - var operation = new ImportExternalOperation(sourcePath, destination, isFolder: true, FileStorage); + var operation = new ImportExternalOperation(sourcePath, destination, isFolder: true, FileStorage, _logger); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -232,13 +218,6 @@ public async Task ImportExternalFolderAsync(string sourcePath, ResourceK public async Task TransferAsync(ResourceKey source, ResourceKey destination, DataTransferMode mode) { - var infoResult = await FileStorage.GetInfoAsync(source); - if (infoResult.IsFailure - || infoResult.Value.Kind == StorageItemKind.NotFound) - { - return Result.Fail($"Source resource does not exist: '{source}'"); - } - if (mode == DataTransferMode.Copy) { return await CopyAsync(source, destination); @@ -247,17 +226,19 @@ public async Task TransferAsync(ResourceKey source, ResourceKey destinat return await MoveAsync(source, destination); } - public void BeginBatch() + public IBatchScope BeginBatch() { if (_currentBatch != null) { _logger.LogWarning("BeginBatch called while a batch is already in progress"); - return; + return new BatchScope(this, isOuter: false); } _currentBatch = new FileOperationBatch(); + return new BatchScope(this, isOuter: true); } - public void CommitBatch() + // Empty batches are discarded; partial batches commit so the user can Ctrl+Z them. + private void CommitBatch() { if (_currentBatch == null) { @@ -274,6 +255,35 @@ public void CommitBatch() _currentBatch = null; } + // Outer scope commits on dispose; a nested BeginBatch returns a no-op + // scope that does nothing. + private sealed class BatchScope : IBatchScope + { + private readonly ResourceOperationService _owner; + private readonly bool _isOuter; + private bool _disposed; + + public BatchScope(ResourceOperationService owner, bool isOuter) + { + _owner = owner; + _isOuter = isOuter; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + if (_isOuter) + { + _owner.CommitBatch(); + } + } + } + public bool CanUndo => _undoStack.Count > 0; public bool CanRedo => _redoStack.Count > 0; @@ -379,69 +389,4 @@ private static async Task PurgeOperationTrashAsync(FileOperation operation) } } - private void BroadcastDeleteMessages(TrashEntry? entry) - { - if (entry is null) - { - return; - } - - var sourceRemovedMessage = new ResourceDeletedMessage(entry.OriginalResource); - _messengerService.Send(sourceRemovedMessage); - - foreach (var descendant in entry.DescendantKeys) - { - var descendantRemovedMessage = new ResourceDeletedMessage(descendant); - _messengerService.Send(descendantRemovedMessage); - } - } - - // After a folder move, broadcast a key-changed message for the folder and - // every descendant resource so opened documents can repoint cleanly. Walks - // the registry-cached source tree because the on-disk source is already - // gone by the time we get here. - private void SendFolderResourceKeyChangedMessages(ResourceKey sourceFolder, ResourceKey destinationFolder) - { - var folderMessage = new ResourceKeyChangedMessage(sourceFolder, destinationFolder); - _messengerService.Send(folderMessage); - - var getResourceResult = ResourceRegistry.GetResource(sourceFolder); - if (getResourceResult.IsFailure) - { - return; - } - - if (getResourceResult.Value is not FolderResource sourceFolderResource) - { - return; - } - - var sourceResources = new List(); - PopulateSourceResources(sourceFolderResource); - - void PopulateSourceResources(FolderResource folderResource) - { - foreach (var childResource in folderResource.Children) - { - if (childResource is FolderResource childFolderResource) - { - var folderKey = ResourceRegistry.GetResourceKey(childFolderResource); - sourceResources.Add(folderKey); - PopulateSourceResources(childFolderResource); - } - else - { - var fileKey = ResourceRegistry.GetResourceKey(childResource); - sourceResources.Add(fileKey); - } - } - } - - foreach (var descendantSource in sourceResources) - { - var descendantDestination = descendantSource.ToString().Replace(sourceFolder, destinationFolder); - var message = new ResourceKeyChangedMessage(descendantSource, descendantDestination); - _messengerService.Send(message); - } - } } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index 76436c396..765d83377 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -1,3 +1,4 @@ +using Celbridge.Logging; using Celbridge.Resources.Helpers; namespace Celbridge.Resources.Services; @@ -339,17 +340,20 @@ internal class ImportExternalOperation : FileOperation private readonly ResourceKey _destination; private readonly bool _isFolder; private readonly IFileStorage _fileStorage; + private readonly ILogger _logger; public ImportExternalOperation( string sourcePath, ResourceKey destination, bool isFolder, - IFileStorage fileStorage) + IFileStorage fileStorage, + ILogger logger) { _sourcePath = sourcePath; _destination = destination; _isFolder = isFolder; _fileStorage = fileStorage; + _logger = logger; } public override async Task ExecuteAsync() @@ -390,6 +394,7 @@ public override async Task ExecuteAsync() } catch (Exception ex) { + _logger.LogError(ex, "Failed to import external file from '{SourcePath}' to '{Destination}'", _sourcePath, _destination); return Result.Fail($"Failed to import external file from '{_sourcePath}' to '{_destination}'") .WithException(ex); } @@ -445,6 +450,7 @@ private async Task ImportFolderAsync(string sourceFolderPath, ResourceKe } catch (Exception ex) { + _logger.LogError(ex, "Failed to import external folder from '{SourceFolderPath}' to '{DestinationFolder}'", sourceFolderPath, destinationFolder); return Result.Fail($"Failed to import external folder from '{sourceFolderPath}' to '{destinationFolder}'") .WithException(ex); } diff --git a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs index 05b2c6a86..e916a10f4 100644 --- a/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs +++ b/Source/Workspace/Celbridge.Resources/Services/Roots/ResourceRootHandlerBase.cs @@ -3,19 +3,21 @@ namespace Celbridge.Resources.Services.Roots; /// -/// Shared implementation for resource root handlers. Owns the backing location and -/// a path validator scoped to this root, and provides the common Resolve and -/// GetResourceKey logic. Concrete subclasses supply the root name and capability flags. +/// Shared implementation for resource root handlers. Owns the backing location +/// and a RootPathResolver scoped to this root; delegates both Resolve (key-to- +/// path) and GetResourceKey (path-to-key) to the resolver so both directions +/// share the same backing-location and OS-aware comparison primitives. Concrete +/// subclasses supply the root name and capability flags. /// public abstract class ResourceRootHandlerBase : IResourceRootHandler { - private readonly PathValidator _pathValidator; + private readonly RootPathResolver _pathResolver; private readonly string _backingLocation; protected ResourceRootHandlerBase(string backingLocation) { _backingLocation = backingLocation; - _pathValidator = new PathValidator(RootName, backingLocation); + _pathResolver = new RootPathResolver(RootName, backingLocation); } public abstract string RootName { get; } @@ -26,59 +28,16 @@ protected ResourceRootHandlerBase(string backingLocation) public Result Resolve(ResourceKey key) { - return _pathValidator.ValidateAndResolve(key); + return _pathResolver.ValidateAndResolve(key); } - public void InvalidatePathCache() + public Result GetResourceKey(string absolutePath) { - _pathValidator.InvalidateCache(); + return _pathResolver.GetResourceKey(absolutePath); } - public Result GetResourceKey(string absolutePath) + public void InvalidatePathCache() { - try - { - var normalizedPath = Path.GetFullPath(absolutePath); - var normalizedBacking = Path.GetFullPath(BackingLocation) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - var comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - bool isBackingRoot = normalizedPath.Equals(normalizedBacking, comparison); - bool isUnderBacking = normalizedPath.StartsWith( - normalizedBacking + Path.DirectorySeparatorChar, comparison); - - if (!isBackingRoot && !isUnderBacking) - { - return Result.Fail( - $"Path '{absolutePath}' is not under root '{RootName}' backing location '{BackingLocation}'."); - } - - var relativePart = isBackingRoot - ? string.Empty - : normalizedPath - .Substring(normalizedBacking.Length) - .Replace('\\', '/') - .Trim('/'); - - var keyString = string.IsNullOrEmpty(relativePart) - ? RootName + ":" - : RootName + ":" + relativePart; - - if (!ResourceKey.TryCreate(keyString, out var resourceKey)) - { - return Result.Fail( - $"Path '{absolutePath}' produces an invalid resource key: '{keyString}'."); - } - - return resourceKey; - } - catch (Exception ex) - { - return Result.Fail($"An exception occurred when getting the resource key for '{absolutePath}'.") - .WithException(ex); - } + _pathResolver.InvalidateCache(); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs index 9ea22913c..ca778da9d 100644 --- a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs @@ -17,13 +17,16 @@ public sealed class TrashService : ITrashService private const int BaseRetryDelayMs = 50; private readonly ILogger _logger; + private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; public TrashService( ILogger logger, + IMessengerService messengerService, IWorkspaceWrapper workspaceWrapper) { _logger = logger; + _messengerService = messengerService; _workspaceWrapper = workspaceWrapper; } @@ -69,12 +72,32 @@ public async Task> MoveToTrashAsync(ResourceKey resource) var trashBasePath = Path.Combine(TrashFolderPath, trashId); var trashPath = Path.Combine(trashBasePath, relativePath); + Result moveResult; if (isFile) { - return await MoveFileToTrashAsync(resource, originalPath, trashPath, trashBasePath, trashId); + moveResult = await MoveFileToTrashAsync(resource, originalPath, trashPath, trashBasePath, trashId); + } + else + { + moveResult = await MoveFolderToTrashAsync(resource, originalPath, trashPath, trashBasePath, trashId); + } + + if (moveResult.IsSuccess + && resource.Root == ResourceKey.DefaultRoot) + { + // Announce the soft-delete synchronously so subscribers update before + // control returns. The watcher's own delete event still arrives later + // via UI-thread dispatch; subscribers must treat these messages as + // idempotent. + var entry = moveResult.Value; + _messengerService.Send(new ResourceDeletedMessage(entry.OriginalResource)); + foreach (var descendant in entry.DescendantKeys) + { + _messengerService.Send(new ResourceDeletedMessage(descendant)); + } } - return await MoveFolderToTrashAsync(resource, originalPath, trashPath, trashBasePath, trashId); + return moveResult; } private async Task> MoveFileToTrashAsync( From ad40801d41694974a118387e42dc7170fe71fda0 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 09:14:42 +0100 Subject: [PATCH 41/48] Track files by size+mtime; coalesce reloads Replace costly SHA256 hashing with lightweight size+mtime tracking for saved files: DocumentViewModel now caches file size and modified-time, uses GetInfoAsync for pre-/post-write checks, and updates IsFileChangedExternallyAsync/UpdateFileTrackingInfoAsync accordingly. This prevents unnecessary hashing on watcher events and detects post-write interleaves by size mismatch. ContributionDocumentView now coalesces concurrent external-reload requests (single in-flight reload with an optional follow-up) and syncs view-model tracking after reloads to short-circuit duplicate watcher events. Tests updated: renamed a test to reflect size-based post-write detection, made the injected test override public, and added a test ensuring watcher self-events after our own save do not cause ReloadRequested. --- .../Tests/Documents/DocumentViewModelTests.cs | 41 +++++-- .../ViewModels/DocumentViewModel.cs | 113 +++++++++--------- .../Views/ContributionDocumentView.xaml.cs | 71 ++++++++++- 3 files changed, 154 insertions(+), 71 deletions(-) diff --git a/Source/Tests/Documents/DocumentViewModelTests.cs b/Source/Tests/Documents/DocumentViewModelTests.cs index c29e9c8c3..503f6b33c 100644 --- a/Source/Tests/Documents/DocumentViewModelTests.cs +++ b/Source/Tests/Documents/DocumentViewModelTests.cs @@ -236,12 +236,14 @@ public async Task Save_RaisesReloadRequested_WhenDiskChangedBeforeSave() } [Test] - public async Task Save_RaisesReloadRequested_WhenPostWriteDiskHashDiffersFromIntendedHash() + public async Task Save_RaisesReloadRequested_WhenPostWriteDiskSizeDiffersFromBytesWritten() { // Simulate an external write that interleaves with our save: the - // ExternalWriteDocumentViewModel rewrites the file with different content - // immediately after we call WriteAllBytesAsync but before - // UpdateFileTrackingInfo runs. + // ExternalWriteDocumentViewModel rewrites the file with different-length + // content immediately after WriteAllBytesAsync but before + // UpdateFileTrackingInfoAsync runs. The post-write size mismatch flags + // the interleave and the reload fires. Same-length interleaves slip + // past this check and rely on the watcher's subsequent event. var externalContent = "external content that overrode our save"; var savingVm = new ExternalWriteDocumentViewModel(_fileStorage, _tempFilePath, externalContent); savingVm.FileResource = new ResourceKey("interleave.md"); @@ -258,6 +260,26 @@ public async Task Save_RaisesReloadRequested_WhenPostWriteDiskHashDiffersFromInt savingVm.Cleanup(); } + [Test] + public async Task OnResourceChanged_DoesNotRaiseReload_AfterOwnSaveCompletes() + { + // After we save, the cache holds the size + mtime of our own write. + // A watcher event for that same write (the self-event the chokepoint's + // atomic write produces) probes the disk, finds the metadata unchanged + // from the cache, and returns without raising ReloadRequested. This is + // the test that proves the Excel-flash regression is gone. + var saveResult = await _vm.SaveDocumentContent("first save"); + saveResult.IsSuccess.Should().BeTrue(); + + var reloadRequested = false; + _vm.ReloadRequested += (_, _) => reloadRequested = true; + + var message = new ResourceChangedMessage(_vm.FileResource); + _messengerService.Send(message); + + reloadRequested.Should().BeFalse(); + } + /// /// Minimal test subclass that exposes DocumentViewModel base class functionality /// for testing text file operations and file-change monitoring. @@ -295,10 +317,11 @@ public void OnTextChanged() /// /// Test subclass that simulates an external write interleaving between our - /// WriteAllBytesAsync call and the post-write disk hash read. The override - /// of UpdateFileTrackingInfo runs immediately before the base reads the disk - /// hash, so by writing different content here we make _lastSavedFileHash - /// reflect external content while our intendedHash reflects ours. + /// WriteAllBytesAsync call and the post-write tracking refresh. The override + /// of UpdateFileTrackingInfoAsync runs immediately before the base reads + /// disk metadata, so by writing different-length content here we make the + /// cached size differ from the bytes we wrote — which is what the + /// post-write size-mismatch check looks for. /// private sealed class ExternalWriteDocumentViewModel : DocumentViewModel { @@ -324,7 +347,7 @@ public Task SaveDocumentContent(string text) protected override IFileStorage GetFileSystem() => _fileStorage; - protected override async Task UpdateFileTrackingInfoAsync() + public override async Task UpdateFileTrackingInfoAsync() { if (!_hasInjected) { diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs index 6a3f9d344..9ff39923b 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -1,7 +1,6 @@ using System.Text; using Celbridge.Logging; using Celbridge.Messaging; -using Celbridge.Utilities; using Celbridge.Workspace; using CommunityToolkit.Mvvm.ComponentModel; @@ -30,9 +29,13 @@ public abstract partial class DocumentViewModel : ObservableObject // Event to notify the view that the document should be reloaded public event EventHandler? ReloadRequested; - // Track the hash and size of the last saved file to detect genuine external changes - private string? _lastSavedFileHash; + // Track the size and modified-time of the last saved file so that the + // watcher's own event for our save hash-matches the cache and short-circuits. + // mtime + size is cheap (one stat per check) and adequate for distinguishing + // self-events from genuine external writes; previous hash-based tracking + // read and SHA256'd the whole file on every watcher event. private long _lastSavedFileSize; + private DateTime? _lastSavedFileMtime; /// /// Marks the document as having unsaved changes and resets the save timer. @@ -105,8 +108,8 @@ private async void OnResourceChanged(object recipient, ResourceChangedMessage me return; } - // Self-events from our own writes hash-match _lastSavedFileHash and are - // ignored. Genuine external changes have a different hash and proceed. + // Self-events from our own writes match the cached size + mtime and are + // ignored. Genuine external changes differ and proceed. if (await IsFileChangedExternallyAsync()) { // External edits supersede any pending or in-flight buffer save. @@ -176,16 +179,14 @@ protected async Task SaveBinaryToFileAsync(string base64Content) } /// - /// Routes the save through IFileStorage (atomic write + bounded retry - /// on transient IO) and raises ReloadRequested when external interleaving is - /// detected either before the write (pre-write hash check) or between the - /// write completing and our tracking-hash read (post-write check). Updates - /// file tracking info on a successful write. + /// Routes the save through IFileStorage (atomic write + bounded retry on + /// transient IO) and raises ReloadRequested when external interleaving is + /// detected either before the write (pre-write size/mtime check) or after + /// (post-write size mismatch against the bytes we wrote). Updates file + /// tracking info on a successful write. /// private async Task SaveBytesToFileAsync(byte[] bytes) { - var intendedHash = FileHashHelper.HashBytes(bytes); - if (await TryDetectPreWriteExternalChangeAsync()) { return Result.Ok(); @@ -200,8 +201,12 @@ private async Task SaveBytesToFileAsync(byte[] bytes) await UpdateFileTrackingInfoAsync(); - if (_lastSavedFileHash is not null - && _lastSavedFileHash != intendedHash) + // Post-write interleave check: if the on-disk size disagrees with what + // we wrote, an external writer slipped in between WriteAllBytesAsync + // returning and our cache refresh. Same-size interleaves slip past this + // check but get picked up by the watcher's own subsequent event (which + // will mtime-mismatch the cache and fire a reload via OnResourceChanged). + if (_lastSavedFileSize != bytes.Length) { _logger?.LogDebug($"External write interleaved with save for '{FileResource}', requesting reload"); RaiseReloadRequested(); @@ -211,31 +216,33 @@ private async Task SaveBytesToFileAsync(byte[] bytes) } /// - /// Reads the current disk hash and compares it to the last-tracked save hash. - /// If the disk has drifted, discards any buffered changes, aligns tracking - /// with the current disk state (so the upcoming watcher event filters as a - /// self-event), raises ReloadRequested, and returns true. Returns false if - /// no drift is detected, if there is no prior tracking info to compare - /// against, or if the disk read fails (the caller falls through to the - /// write attempt, whose retry loop handles transient IO errors). + /// Reads the current disk size + mtime and compares to the last-tracked + /// save. If the disk has drifted, discards any buffered changes, aligns + /// tracking with the current disk state (so the upcoming watcher event + /// filters as a self-event), raises ReloadRequested, and returns true. + /// Returns false if no drift is detected, if there is no prior tracking + /// info to compare against, or if the disk probe fails (the caller falls + /// through to the write attempt, whose retry loop handles transient IO + /// errors). /// private async Task TryDetectPreWriteExternalChangeAsync() { - if (_lastSavedFileHash is null) + if (_lastSavedFileMtime is null) { return false; } var fileStorage = GetFileSystem(); - var hashResult = await fileStorage.ComputeHashAsync(FileResource); - if (hashResult.IsFailure) + var infoResult = await fileStorage.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { - _logger?.LogDebug($"Pre-write hash check failed for '{FilePath}', proceeding to write attempt"); + _logger?.LogDebug($"Pre-write info probe failed for '{FilePath}', proceeding to write attempt"); return false; } - var preWriteHash = hashResult.Value; - if (preWriteHash == _lastSavedFileHash) + if (infoResult.Value.Size == _lastSavedFileSize + && infoResult.Value.ModifiedUtc == _lastSavedFileMtime) { return false; } @@ -270,10 +277,16 @@ public virtual void Cleanup() _messengerService?.UnregisterAll(this); } - protected async Task IsFileChangedExternallyAsync() + /// + /// Returns true when the on-disk size or mtime differs from the last-tracked + /// save. The View's external-reload coalescer calls this between an + /// in-flight reload and a queued follow-up to skip the follow-up when the + /// disk content has not actually changed. + /// + public async Task IsFileChangedExternallyAsync() { - // If we haven't saved yet, any change is considered external - if (_lastSavedFileHash == null) + // If we haven't saved yet, any change is considered external. + if (_lastSavedFileMtime is null) { return true; } @@ -286,44 +299,32 @@ protected async Task IsFileChangedExternallyAsync() return true; } - // Quick check: if file size is different, it's definitely changed. - if (infoResult.Value.Size != _lastSavedFileSize) - { - return true; - } - - // File size is the same; compute hash to check if content actually changed. - // This handles cases where the file was rewritten with identical content. - var hashResult = await fileStorage.ComputeHashAsync(FileResource); - if (hashResult.IsFailure) - { - return true; - } - - return hashResult.Value != _lastSavedFileHash; + return infoResult.Value.Size != _lastSavedFileSize + || infoResult.Value.ModifiedUtc != _lastSavedFileMtime; } - protected virtual async Task UpdateFileTrackingInfoAsync() + /// + /// Reads the current disk size + mtime and caches them as the new tracking + /// baseline. Called after every save and after every external reload so the + /// next watcher event for the same content matches the cache and + /// short-circuits. The body is effectively synchronous because GetInfoAsync + /// is a single stat call with no real awaits; this matters so the UI thread + /// cannot pump a watcher's ResourceChangedMessage between our write + /// returning and the cache becoming current. + /// + public virtual async Task UpdateFileTrackingInfoAsync() { var fileStorage = GetFileSystem(); var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure || infoResult.Value.Kind != StorageItemKind.File) { - _lastSavedFileHash = null; - _lastSavedFileSize = 0; - return; - } - - var hashResult = await fileStorage.ComputeHashAsync(FileResource); - if (hashResult.IsFailure) - { - _lastSavedFileHash = null; + _lastSavedFileMtime = null; _lastSavedFileSize = 0; return; } _lastSavedFileSize = infoResult.Value.Size; - _lastSavedFileHash = hashResult.Value; + _lastSavedFileMtime = infoResult.Value.ModifiedUtc; } } diff --git a/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs index 6aa6d4404..e1dc420fd 100644 --- a/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs +++ b/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs @@ -58,6 +58,15 @@ public sealed partial class ContributionDocumentView : DocumentView, IHostInput private bool _isSaveInProgress; private bool _hasPendingSave; + // Reload coalescing: at most one external-reload runs at a time. FileSystemWatcher + // often emits duplicate Changed events for a single logical write, and the + // editor host cannot tolerate concurrent NotifyExternalChangeAsync calls. + // Requests arriving while a reload is in flight collapse into a single + // follow-up pass. + private readonly object _reloadLock = new(); + private bool _isReloadInProgress; + private bool _hasPendingReload; + // Completed by InitContributionViewAsync with the init outcome. LoadContent // triggers the init on first call and awaits this TCS so the open-document // flow returns only when the WebView and host are ready for RPCs. @@ -699,15 +708,65 @@ private void WebView_GotFocus(object sender, RoutedEventArgs e) private async void ViewModel_ReloadRequested(object? sender, EventArgs e) { - // This method is async void because it's an event handler. All exceptions must be caught - // so that a faulty editor cannot crash the process. - try + // Coalesce concurrent reload requests. FileSystemWatcher commonly emits + // duplicate Changed events for one logical write; a second reload arriving + // mid-flight folds into one follow-up pass instead of racing the first. + lock (_reloadLock) { - await ReloadWithStatePreservationAsync(); + if (_isReloadInProgress) + { + _hasPendingReload = true; + return; + } + _isReloadInProgress = true; } - catch (Exception ex) + + while (true) { - _logger.LogError(ex, "External reload failed for contribution document"); + // async void: catch everything so a faulty editor cannot crash the process. + try + { + await ReloadWithStatePreservationAsync(); + // Sync the ViewModel's external-change tracking with the disk + // content we just loaded so duplicate watcher events for this + // write hash-match the cache on the next iteration. + await _viewModel.UpdateFileTrackingInfoAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "External reload failed for contribution document"); + } + + // Drain any pending request. Skip the follow-up reload when the disk + // content has not actually changed since the reload we just ran (the + // duplicate-watcher-event case). + bool runFollowUp = false; + while (!runFollowUp) + { + bool wasPending; + lock (_reloadLock) + { + wasPending = _hasPendingReload; + _hasPendingReload = false; + if (!wasPending) + { + _isReloadInProgress = false; + return; + } + } + + try + { + runFollowUp = await _viewModel.IsFileChangedExternallyAsync(); + } + catch (Exception ex) + { + // Treat a failed disk probe as "assume changed" so we don't + // silently drop a legitimately-queued reload. + _logger.LogDebug(ex, "External change probe failed; running follow-up reload defensively"); + runFollowUp = true; + } + } } } From c490a891b155c343242a9e72a7f4354ac13f5dd6 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 10:12:38 +0100 Subject: [PATCH 42/48] Add reload hints for document view state Introduce a ReloadHint enum and a TTL-backed ReloadHintStore to let commands register how the next watcher-driven reload should treat editor view state (preserve current view or let disk win). Expose RegisterReloadHint/ConsumeReloadHint on DocumentsService and wire consumption in ContributionDocumentView to pass a preserveViewState flag through the host NotifyExternalChange RPC. Update the spreadsheet module to register DiskWinsOnViewState when set_active_view runs and to honor preserveViewState in the JS restore path. Add unit tests for ReloadHintStore behavior (round-trip, overwrite, TTL expiry). Default behavior is PreserveViewState when no fresh hint is present. --- .../Documents/IDocumentsService.cs | 36 +++++ .../Celbridge.Host/Services/IHostDocument.cs | 6 +- .../Commands/SetActiveViewCommand.cs | 8 ++ .../Package/spreadsheet.js | 33 ++--- .../Tests/Documents/ReloadHintStoreTests.cs | 124 ++++++++++++++++++ Source/Tests/Host/CelbridgeHostTests.cs | 3 +- .../Services/DocumentsService.cs | 11 ++ .../Services/ReloadHintStore.cs | 43 ++++++ .../Views/ContributionDocumentView.xaml.cs | 12 +- 9 files changed, 257 insertions(+), 19 deletions(-) create mode 100644 Source/Tests/Documents/ReloadHintStoreTests.cs create mode 100644 Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs diff --git a/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs b/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs index 7d82e648e..75c79aaf3 100644 --- a/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs +++ b/Source/Core/Celbridge.Foundation/Documents/IDocumentsService.cs @@ -1,5 +1,27 @@ namespace Celbridge.Documents; +/// +/// How an upcoming external reload should treat the editor's current view state. +/// View state is editor-specific — concrete examples include scroll position, cursor +/// or selection, zoom level, and fold state — and the editor decides what to capture +/// and restore. +/// +public enum ReloadHint +{ + /// + /// Default. The editor keeps its current view state across the reload, regardless + /// of any view state encoded in the on-disk file. + /// + PreserveViewState, + + /// + /// The editor adopts the view state encoded in the on-disk file, or its default + /// view state when the file format does not persist view state. Used by commands + /// whose purpose is to update the document's persisted view. + /// + DiskWinsOnViewState +} + /// /// The documents service provides functionality to support the documents panel in the workspace UI. /// @@ -123,4 +145,18 @@ public interface IDocumentsService /// string to persist, or null/empty to clear any existing entry for the resource. /// Task StoreDocumentEditorState(ResourceKey fileResource, string? state); + + /// + /// Records a hint that the next watcher-driven reload of the resource should + /// honour. Overwrites any prior hint for the same resource; hints expire if + /// not consumed within a short window so they do not bleed into later + /// unrelated reloads. + /// + void RegisterReloadHint(ResourceKey fileResource, ReloadHint hint); + + /// + /// Returns the most recently registered hint for the resource and removes it + /// from the store. Returns PreserveViewState when no fresh hint is set. + /// + ReloadHint ConsumeReloadHint(ResourceKey fileResource); } diff --git a/Source/Core/Celbridge.Host/Services/IHostDocument.cs b/Source/Core/Celbridge.Host/Services/IHostDocument.cs index 6ddcaa884..5bf3de5aa 100644 --- a/Source/Core/Celbridge.Host/Services/IHostDocument.cs +++ b/Source/Core/Celbridge.Host/Services/IHostDocument.cs @@ -124,9 +124,11 @@ public static Task NotifyRequestSaveAsync(this CelbridgeHost host) /// /// Notifies the WebView that the document has been externally modified. + /// preserveViewState tells the editor whether to keep its current view state + /// across the reload, or to adopt the view state encoded in the on-disk file. /// - public static Task NotifyExternalChangeAsync(this CelbridgeHost host) - => host.Rpc.NotifyAsync(DocumentRpcMethods.ExternalChange); + public static Task NotifyExternalChangeAsync(this CelbridgeHost host, bool preserveViewState) + => host.Rpc.NotifyAsync(DocumentRpcMethods.ExternalChange, new { preserveViewState }); /// /// Requests the WebView to return its current editor state as an opaque JSON string. diff --git a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs index 2c78929d3..8a72e5799 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs @@ -1,4 +1,5 @@ using Celbridge.Commands; +using Celbridge.Documents; using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -213,6 +214,13 @@ private async Task> ApplyViewStateToWorkbookAsync(Resou worksheet.SheetView.TopLeftCellAddress = scrollAnchor.Address; } + // Tell the next reload of this workbook to honour the on-disk view + // state rather than the user's pre-reload scroll/selection. Without + // this hint the watcher-driven reload would treat the user's local + // view as the source of truth and silently override our changes. + var documentsService = _workspaceWrapper.WorkspaceService.DocumentsService; + documentsService.RegisterReloadHint(workbookResource, ReloadHint.DiskWinsOnViewState); + var saveResult = await SpreadsheetHelper.SaveWorkbookAsync(fileStorage, workbookResource, workbook); if (saveResult.IsFailure) { diff --git a/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.js b/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.js index 15bb8cb29..5532dda7f 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.js +++ b/Source/Modules/Celbridge.Spreadsheet/Package/spreadsheet.js @@ -12,7 +12,7 @@ const client = celbridge; let designer = null; -async function deserializeExcelData(base64Data, viewState = null) { +async function deserializeExcelData(base64Data, viewState = null, preserveView = true) { if (!base64Data) { client.document.notifyImportComplete(true); return; @@ -55,7 +55,7 @@ async function deserializeExcelData(base64Data, viewState = null) { if (viewState) { requestAnimationFrame(() => { - restoreViewState(viewState); + restoreViewState(viewState, preserveView); spread.resumePaint(); client.document.notifyImportComplete(true); resolve(); @@ -146,16 +146,14 @@ function selectionsMatch(a, b) { return true; } -function restoreViewState(state) { - // Active sheet and selection are auto-saved to disk on every change via the - // ActiveSheetChanged and SelectionChanged hooks in listenForChanges, so the - // freshly-imported workbook already reflects the user's pre-reload sheet and - // selection (or the new ones written by an MCP set_active_view, if that's - // what triggered the reload). Scroll position is the one piece of view state - // we deliberately do not auto-save, so we restore it from the in-memory - // snapshot here, but only when disk's active sheet and selection still match - // the snapshot. If either differs, the reload was driven by a deliberate - // view-state change and disk should win for scroll too. +function restoreViewState(state, preserveView = false) { + // Active sheet name is the one piece of identity we always honour: a sheet + // rename collapses the captured snapshot's frame of reference so there is + // no sensible scroll to apply. When preserveView is true (the default for + // external watcher reloads and for data-changing commands) the snapshot's + // scroll wins over disk. When preserveView is false, we only restore scroll + // if disk's selection still matches the snapshot — preserving the original + // contract for view-changing commands like set_active_view. if (!state || !designer) return; try { const spread = designer.getWorkbook(); @@ -163,7 +161,7 @@ function restoreViewState(state) { if (!activeSheet) return; if (activeSheet.name() !== state.sheetName) return; - if (!selectionsMatch(activeSheet.getSelections(), state.selections)) return; + if (!preserveView && !selectionsMatch(activeSheet.getSelections(), state.selections)) return; activeSheet.showRow(state.scrollRow, GC.Spread.Sheets.VerticalPosition.top); activeSheet.showColumn(state.scrollColumn, GC.Spread.Sheets.HorizontalPosition.left); @@ -299,16 +297,21 @@ async function initializeEditor() { console.error('[Spreadsheet] Failed to save:', e); } }, - onExternalChange: async () => { + onExternalChange: async (args) => { // Capture view state locally and pass it through deserializeExcelData so the // suspendPaint + requestAnimationFrame + restoreViewState path preserves scroll // and selection across the re-import. The host also sends onRestoreState after // notifyContentLoaded fires, but that RPC arrives while the SpreadJS viewport // is still settling and showRow/showColumn calls from it do not take effect. + // + // The host passes preserveViewState=true for watcher-driven reloads and for + // data-changing commands; view-changing commands like set_active_view set + // preserveViewState=false so disk's selection and scroll win. + const preserveView = args?.preserveViewState ?? true; const savedViewState = captureViewState(); try { const result = await client.document.load(); - await deserializeExcelData(result.content, savedViewState); + await deserializeExcelData(result.content, savedViewState, preserveView); } catch (e) { console.error('[Spreadsheet] Failed to reload content:', e); } diff --git a/Source/Tests/Documents/ReloadHintStoreTests.cs b/Source/Tests/Documents/ReloadHintStoreTests.cs new file mode 100644 index 000000000..3ed08823b --- /dev/null +++ b/Source/Tests/Documents/ReloadHintStoreTests.cs @@ -0,0 +1,124 @@ +using Celbridge.Documents; +using Celbridge.Documents.Services; + +namespace Celbridge.Tests.Documents; + +/// +/// Tests for ReloadHintStore — register/consume round-trip, consume-removes, +/// overwrite semantics, and TTL expiry. A controllable clock keeps the TTL +/// tests deterministic. +/// +[TestFixture] +public class ReloadHintStoreTests +{ + private DateTime _nowUtc; + private ReloadHintStore _store = null!; + + [SetUp] + public void Setup() + { + _nowUtc = new DateTime(2026, 5, 29, 12, 0, 0, DateTimeKind.Utc); + _store = new ReloadHintStore(TimeSpan.FromSeconds(2), () => _nowUtc); + } + + [Test] + public void Consume_ReturnsPreserveViewState_WhenNoHintRegistered() + { + var resource = new ResourceKey("doc.xlsx"); + + var hint = _store.Consume(resource); + + hint.Should().Be(ReloadHint.PreserveViewState); + } + + [Test] + public void Register_ThenConsume_ReturnsRegisteredHint() + { + var resource = new ResourceKey("doc.xlsx"); + _store.Register(resource, ReloadHint.DiskWinsOnViewState); + + var hint = _store.Consume(resource); + + hint.Should().Be(ReloadHint.DiskWinsOnViewState); + } + + [Test] + public void Consume_RemovesTheEntry_SoSecondConsumeReturnsDefault() + { + var resource = new ResourceKey("doc.xlsx"); + _store.Register(resource, ReloadHint.DiskWinsOnViewState); + + _store.Consume(resource); + var secondHint = _store.Consume(resource); + + secondHint.Should().Be(ReloadHint.PreserveViewState); + } + + [Test] + public void Register_OverwritesPriorHintForTheSameResource() + { + var resource = new ResourceKey("doc.xlsx"); + _store.Register(resource, ReloadHint.PreserveViewState); + _store.Register(resource, ReloadHint.DiskWinsOnViewState); + + var hint = _store.Consume(resource); + + hint.Should().Be(ReloadHint.DiskWinsOnViewState); + } + + [Test] + public void Register_KeepsHintsForDifferentResourcesIndependent() + { + var resourceA = new ResourceKey("a.xlsx"); + var resourceB = new ResourceKey("b.xlsx"); + _store.Register(resourceA, ReloadHint.DiskWinsOnViewState); + _store.Register(resourceB, ReloadHint.PreserveViewState); + + _store.Consume(resourceA).Should().Be(ReloadHint.DiskWinsOnViewState); + _store.Consume(resourceB).Should().Be(ReloadHint.PreserveViewState); + } + + [Test] + public void Consume_ReturnsDefault_WhenHintIsPastTtl() + { + var resource = new ResourceKey("doc.xlsx"); + _store.Register(resource, ReloadHint.DiskWinsOnViewState); + + _nowUtc = _nowUtc.AddSeconds(3); + + var hint = _store.Consume(resource); + + hint.Should().Be(ReloadHint.PreserveViewState); + } + + [Test] + public void Consume_ReturnsHint_WhenStillWithinTtl() + { + var resource = new ResourceKey("doc.xlsx"); + _store.Register(resource, ReloadHint.DiskWinsOnViewState); + + _nowUtc = _nowUtc.AddSeconds(1); + + var hint = _store.Consume(resource); + + hint.Should().Be(ReloadHint.DiskWinsOnViewState); + } + + [Test] + public void Consume_RemovesExpiredEntry_SoFollowupConsumeIsCheap() + { + var resource = new ResourceKey("doc.xlsx"); + _store.Register(resource, ReloadHint.DiskWinsOnViewState); + + _nowUtc = _nowUtc.AddSeconds(3); + _store.Consume(resource); + + // The expired entry was removed by the first Consume call. Register a + // fresh hint and verify it is honoured without leakage from the prior + // expired entry. + _nowUtc = _nowUtc.AddSeconds(1); + _store.Register(resource, ReloadHint.PreserveViewState); + + _store.Consume(resource).Should().Be(ReloadHint.PreserveViewState); + } +} diff --git a/Source/Tests/Host/CelbridgeHostTests.cs b/Source/Tests/Host/CelbridgeHostTests.cs index d0409867d..0115c7899 100644 --- a/Source/Tests/Host/CelbridgeHostTests.cs +++ b/Source/Tests/Host/CelbridgeHostTests.cs @@ -60,11 +60,12 @@ public async Task NotifyExternalChangeAsync_SendsCorrectMethod() _host.StartListening(); // Act - await _host.NotifyExternalChangeAsync(); + await _host.NotifyExternalChangeAsync(preserveViewState: true); // Assert _channel.SentMessages.Should().HaveCount(1); _channel.SentMessages[0].Should().Contain("document/externalChange"); + _channel.SentMessages[0].Should().Contain("preserveViewState"); } [Test] diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs index 5f9a6ecbb..a160ac753 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -26,6 +26,7 @@ public class DocumentsService : IDocumentsService, IDisposable private readonly DocumentEditorPreferenceStore _preferenceStore; private readonly DocumentViewFactory _viewFactory; private readonly DocumentLayoutStore _layoutStore; + private readonly ReloadHintStore _reloadHintStore = new(TimeSpan.FromSeconds(2)); private bool _disposed; private IDocumentsPanel DocumentsPanel => _workspaceWrapper.WorkspaceService.DocumentsPanel; @@ -396,6 +397,16 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource _ = changeDocumentResource(); } + public void RegisterReloadHint(ResourceKey fileResource, ReloadHint hint) + { + _reloadHintStore.Register(fileResource, hint); + } + + public ReloadHint ConsumeReloadHint(ResourceKey fileResource) + { + return _reloadHintStore.Consume(fileResource); + } + public void Dispose() { if (_disposed) diff --git a/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs b/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs new file mode 100644 index 000000000..18ad673a1 --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs @@ -0,0 +1,43 @@ +namespace Celbridge.Documents.Services; + +/// +/// Holds reload hints keyed by ResourceKey with a short TTL. Used by DocumentsService +/// to bridge a command that wrote a file and the watcher-driven reload that follows. +/// +public sealed class ReloadHintStore +{ + private readonly Dictionary _entries = new(); + private readonly TimeSpan _ttl; + private readonly Func _nowUtc; + + public ReloadHintStore(TimeSpan ttl, Func? nowUtc = null) + { + _ttl = ttl; + _nowUtc = nowUtc ?? (() => DateTime.UtcNow); + } + + public void Register(ResourceKey fileResource, ReloadHint hint) + { + var entry = new ReloadHintEntry(hint, _nowUtc() + _ttl); + _entries[fileResource] = entry; + } + + public ReloadHint Consume(ResourceKey fileResource) + { + if (!_entries.TryGetValue(fileResource, out var entry)) + { + return ReloadHint.PreserveViewState; + } + + _entries.Remove(fileResource); + + if (entry.ExpiresUtc < _nowUtc()) + { + return ReloadHint.PreserveViewState; + } + + return entry.Hint; + } + + private readonly record struct ReloadHintEntry(ReloadHint Hint, DateTime ExpiresUtc); +} diff --git a/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs index e1dc420fd..42a355b33 100644 --- a/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs +++ b/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Celbridge.Commands; using Celbridge.Dialog; +using Celbridge.Documents; using Celbridge.Documents.ViewModels; using Celbridge.Explorer; using Celbridge.Host; @@ -11,6 +12,8 @@ using Celbridge.UserInterface; using Celbridge.WebHost; using Celbridge.WebHost.Services; +using Celbridge.Workspace; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Web.WebView2.Core; @@ -495,6 +498,13 @@ private async Task ReloadWithStatePreservationAsync() return; } + // Resolve the workspace-scoped documents service at call time, then drain + // any reload hint registered by the command that triggered this reload. + var workspaceWrapper = _serviceProvider.GetRequiredService(); + var documentsService = workspaceWrapper.WorkspaceService.DocumentsService; + var hint = documentsService.ConsumeReloadHint(_viewModel.FileResource); + bool preserveViewState = hint == ReloadHint.PreserveViewState; + string? savedState = null; try { @@ -517,7 +527,7 @@ void OnReloaded(ContentLoadedReason reason) try { - await Host.NotifyExternalChangeAsync(); + await Host.NotifyExternalChangeAsync(preserveViewState); var completed = await Task.WhenAny(reloadComplete.Task, Task.Delay(TimeSpan.FromSeconds(ReloadStateWaitSeconds))); if (completed != reloadComplete.Task) From 8700681bcb07080f9a874c9c363a4397e0274cac Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 10:19:00 +0100 Subject: [PATCH 43/48] Don't move open tab unless address provided If an already-open document is reopened without an explicit address, preserve its current section instead of moving it to the active section. Added a null-address check to set sectionIndex = existingSection.SectionIndex so tabs aren't unexpectedly yanked from the user's section; tabs are still moved when a different section is explicitly requested. --- .../Celbridge.Documents/Views/DocumentsPanel.xaml.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs index b1478709d..e9541daac 100644 --- a/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs +++ b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs @@ -335,7 +335,15 @@ public async Task> OpenDocument(ResourceKey fileReso return await OpenDocument(fileResource, reopenOptions); } - // If already open in a different section, move it + // Without an explicit address the existing tab stays in its own + // section. Moving it to wherever the active section happens to be + // would yank it from under the user. + if (address is null) + { + sectionIndex = existingSection.SectionIndex; + } + + // If a different section was explicitly requested, move it there. if (existingSection.SectionIndex != sectionIndex) { SectionContainer.MoveTabToSection(existingTab, sectionIndex); From af901d95aa6a4326fdc0242af54a3a1d9d35d813 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 10:57:31 +0100 Subject: [PATCH 44/48] Inject PackageRegistry into PackageService Switch PackageService to accept an injected PackageRegistry instead of constructing it internally. ServiceConfiguration now registers PackageRegistry as a transient service. Adjust PackageService by removing several constructor parameters and unused usings, and assigning the injected registry. Update tests (FileTypeProviderTests, PackageRegistryTests) to create a PackageRegistry and pass it to PackageService. These changes improve DI alignment and testability by centralizing registry construction. --- Source/Tests/Packages/FileTypeProviderTests.cs | 3 ++- Source/Tests/Packages/PackageRegistryTests.cs | 3 ++- .../Celbridge.Packages/ServiceConfiguration.cs | 1 + .../Celbridge.Packages/Services/PackageService.cs | 12 ++---------- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Source/Tests/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index fc355dbbd..c7a7224f4 100644 --- a/Source/Tests/Packages/FileTypeProviderTests.cs +++ b/Source/Tests/Packages/FileTypeProviderTests.cs @@ -60,7 +60,8 @@ public void Setup() workspaceWrapper); workspaceService.FileStorage.Returns(fileStorage); - _service = new PackageService(logger, _moduleService, messengerService, _featureFlags, localizationService, workspaceWrapper); + var registry = new PackageRegistry(logger, _moduleService, _featureFlags, localizationService, workspaceWrapper); + _service = new PackageService(messengerService, registry); } [TearDown] diff --git a/Source/Tests/Packages/PackageRegistryTests.cs b/Source/Tests/Packages/PackageRegistryTests.cs index ed7537f8a..12ec70535 100644 --- a/Source/Tests/Packages/PackageRegistryTests.cs +++ b/Source/Tests/Packages/PackageRegistryTests.cs @@ -56,7 +56,8 @@ public void Setup() workspaceWrapper); workspaceService.FileStorage.Returns(fileStorage); - _service = new PackageService(logger, _moduleService, _messengerService, featureFlags, localizationService, workspaceWrapper); + var registry = new PackageRegistry(logger, _moduleService, featureFlags, localizationService, workspaceWrapper); + _service = new PackageService(_messengerService, registry); } [TearDown] diff --git a/Source/Workspace/Celbridge.Packages/ServiceConfiguration.cs b/Source/Workspace/Celbridge.Packages/ServiceConfiguration.cs index 20da55701..c6998a757 100644 --- a/Source/Workspace/Celbridge.Packages/ServiceConfiguration.cs +++ b/Source/Workspace/Celbridge.Packages/ServiceConfiguration.cs @@ -5,6 +5,7 @@ public static class ServiceConfiguration public static void ConfigureServices(IServiceCollection services) { services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddSingleton(); } diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageService.cs b/Source/Workspace/Celbridge.Packages/Services/PackageService.cs index bb8123f83..206a12651 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageService.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageService.cs @@ -1,10 +1,6 @@ using Celbridge.Console; using Celbridge.Documents; -using Celbridge.Logging; using Celbridge.Messaging; -using Celbridge.Modules; -using Celbridge.Settings; -using Celbridge.Workspace; namespace Celbridge.Packages; @@ -17,15 +13,11 @@ public class PackageService : IPackageService private readonly PackageRegistry _registry; public PackageService( - ILogger logger, - IModuleService moduleService, IMessengerService messengerService, - IFeatureFlags featureFlags, - IPackageLocalizationService localizationService, - IWorkspaceWrapper workspaceWrapper) + PackageRegistry registry) { _messengerService = messengerService; - _registry = new PackageRegistry(logger, moduleService, featureFlags, localizationService, workspaceWrapper); + _registry = registry; } public async Task RegisterPackagesAsync(string projectFolderPath) From 18cbecaada224ccfbd5c57b09a4a749fb8d735ce Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 12:45:11 +0100 Subject: [PATCH 45/48] Add temp:downloads and refactor temp path handling Introduce a dedicated temp:downloads folder and refactor temporary path handling across the codebase: - Add CelbridgeTempDownloadsFolder to ProjectConstants and update docs to clarify that temp: is wiped on workspace load (use project: for persistent data). - WebViewDocumentView: ensure temp:downloads exists, stage downloads under temp:downloads, resolve a unique project:downloads destination, import to project:downloads, and delete the staging copy after import or on interruption; add an in-file GetUniquePath helper and include Projects namespace. - ProjectTemplateService: stop using PathHelper; use ApplicationData.Current.TemporaryFolder for staging new projects. - Remove PathHelper.cs (its functionality was inlined/migrated where needed). - WriteFileCommand: simplify line-ending handling by extracting ResolveTargetSeparatorAsync and use LineEndingHelper consistently. - ResourceService: clear temp: contents on workspace load to enforce the wipe-on-load contract. - Update Celbridge.UserInterface.csproj to reference Celbridge.Utilities and tidy an EmbeddedResource element. These changes centralize download staging under temp:, avoid leftover staging files, and make temp: lifecycle explicit. --- .../Projects/ProjectConstants.cs | 8 +- .../Core/Celbridge.Host/Celbridge.Host.csproj | 5 - .../Helpers/WebViewLocalizationHelper.cs | 39 ------- .../Services/ProjectTemplateService.cs | 11 +- .../Guides/Concepts/resource_keys.md | 2 +- .../Celbridge.UserInterface.csproj | 4 - .../Celbridge.Utilities/Helpers/PathHelper.cs | 72 ------------ .../Views/WebViewDocumentView.xaml.cs | 106 ++++++++++++++++-- .../Commands/WriteFileCommand.cs | 55 +++++---- .../Services/ResourceService.cs | 4 + .../Services/SearchService.cs | 8 +- 11 files changed, 142 insertions(+), 172 deletions(-) delete mode 100644 Source/Core/Celbridge.Host/Helpers/WebViewLocalizationHelper.cs delete mode 100644 Source/Core/Celbridge.Utilities/Helpers/PathHelper.cs diff --git a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs index 196ed6224..0eaaa6582 100644 --- a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs +++ b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs @@ -52,7 +52,8 @@ public static class ProjectConstants public const string CelbridgeFolder = ".celbridge"; /// - /// Sub-folder of .celbridge/ that backs the temp: virtual root. + /// Sub-folder of .celbridge/ that backs the temp: virtual root. Wiped on + /// workspace load; consumers needing persistence write under project:. /// public const string CelbridgeTempFolder = "temp"; @@ -72,4 +73,9 @@ public static class ProjectConstants /// workspace load to clear orphans left by previous crashes. /// public const string CelbridgeStagingFsFolder = "staging-fs"; + + /// + /// Sub-folder of temp: that holds in-progress downloads from the WebView. + /// + public const string CelbridgeTempDownloadsFolder = "downloads"; } diff --git a/Source/Core/Celbridge.Host/Celbridge.Host.csproj b/Source/Core/Celbridge.Host/Celbridge.Host.csproj index 4e3eaf639..a51571d8a 100644 --- a/Source/Core/Celbridge.Host/Celbridge.Host.csproj +++ b/Source/Core/Celbridge.Host/Celbridge.Host.csproj @@ -31,9 +31,4 @@ - - - - - diff --git a/Source/Core/Celbridge.Host/Helpers/WebViewLocalizationHelper.cs b/Source/Core/Celbridge.Host/Helpers/WebViewLocalizationHelper.cs deleted file mode 100644 index 64450a3d4..000000000 --- a/Source/Core/Celbridge.Host/Helpers/WebViewLocalizationHelper.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Xml.Linq; - -namespace Celbridge.Host.Helpers; - -/// -/// Helper for gathering localized strings from .NET resources for WebView editors. -/// -public static class WebViewLocalizationHelper -{ - /// - /// Gathers localized strings matching a key prefix. - /// Reads the resource keys from the embedded .resw file and resolves their values - /// through the provided string localizer. - /// - public static Dictionary GetLocalizedStrings(IStringLocalizer stringLocalizer, string keyPrefix) - { - var assembly = typeof(WebViewLocalizationHelper).Assembly; - using var stream = assembly.GetManifestResourceStream("Celbridge.Strings.Resources.resw"); - - if (stream is null) - { - throw new InvalidOperationException("Could not find embedded resource: Celbridge.Strings.Resources.resw"); - } - - var reswDoc = XDocument.Load(stream); - var strings = new Dictionary(); - - foreach (var data in reswDoc.Descendants("data")) - { - var name = data.Attribute("name")?.Value; - if (name is not null && name.StartsWith(keyPrefix)) - { - strings[name] = stringLocalizer.GetString(name); - } - } - - return strings; - } -} diff --git a/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs b/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs index 864b291d3..4335bda6d 100644 --- a/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs +++ b/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs @@ -1,7 +1,6 @@ using System.IO.Compression; using Celbridge.ApplicationEnvironment; using Celbridge.Python; -using Celbridge.Utilities; using Microsoft.Extensions.Localization; namespace Celbridge.Projects.Services; @@ -50,9 +49,13 @@ public async Task CreateFromTemplateAsync(string projectFilePath, Projec { Guard.IsNotNullOrWhiteSpace(projectFilePath); - // Use a temporary staging folder to prevent leftover files on failure - var tempFile = PathHelper.GetTemporaryFilePath("NewProject", string.Empty); - var tempStagingPath = Path.GetDirectoryName(tempFile); + // Use a temporary staging folder to prevent leftover files on failure. + // The project doesn't exist yet, so temp: isn't available; fall back to + // the application's OS temp folder. + var tempStagingPath = Path.Combine( + ApplicationData.Current.TemporaryFolder.Path, + "NewProject", + Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); try { diff --git a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index a401d6516..c8107afc1 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -18,7 +18,7 @@ A resource key has the optional `root:path` form. When no root prefix is given, ## Roots - `project:` — the visible project tree. The default root; the prefix is optional in input but always present in output. Use for all user content. -- `temp:` — host scratch space (`.celbridge/temp/`). Hidden from the resource tree. Used by host tools, scripts, and agents for transient artifacts and staging output. Contents are not version-controlled. Conventional sub-folders include `temp:staging/...`, `temp:scratch/...`, and `temp:cache/...`. +- `temp:` — host scratch space (`.celbridge/temp/`). Hidden from the resource tree. Used by host tools, scripts, and agents for transient artifacts and staging output. Contents are not version-controlled. **All contents are wiped on workspace load** — if you need data to persist, write under `project:` instead. Conventional sub-folders include `temp:staging/...`, `temp:scratch/...`, `temp:cache/...`, and `temp:downloads/...`. - `logs:` — host diagnostic logs (`.celbridge/logs/`). Hidden from the resource tree. Used by the host engine, Python scripts, agents, and Console panel session loggers. ## Output canonical form diff --git a/Source/Core/Celbridge.UserInterface/Celbridge.UserInterface.csproj b/Source/Core/Celbridge.UserInterface/Celbridge.UserInterface.csproj index 5cfbd62ec..94d033ad4 100644 --- a/Source/Core/Celbridge.UserInterface/Celbridge.UserInterface.csproj +++ b/Source/Core/Celbridge.UserInterface/Celbridge.UserInterface.csproj @@ -33,10 +33,6 @@ - - diff --git a/Source/Core/Celbridge.Utilities/Helpers/PathHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/PathHelper.cs deleted file mode 100644 index cde778069..000000000 --- a/Source/Core/Celbridge.Utilities/Helpers/PathHelper.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Path = System.IO.Path; - -namespace Celbridge.Utilities; - -/// -/// Provides path-related utility methods. -/// -public static class PathHelper -{ - /// - /// Returns a path to a randomly named file in temporary storage. - /// The path includes the specified folder name and extension. - /// - public static string GetTemporaryFilePath(string folderName, string extension) - { - StorageFolder tempFolder = ApplicationData.Current.TemporaryFolder; - var tempFolderPath = tempFolder.Path; - - var randomName = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()); - - string archivePath = string.Empty; - while (string.IsNullOrEmpty(archivePath) || - File.Exists(archivePath)) - { - archivePath = Path.Combine(tempFolderPath, folderName, randomName + extension); - } - - return archivePath; - } - - /// - /// Returns a path which is guaranteed not to clash with any existing file or folder. - /// - public static Result GetUniquePath(string path) - { - try - { - path = Path.GetFullPath(path); - - string directoryPath = Path.GetDirectoryName(path)!; - string nameWithoutExtension = Path.GetFileNameWithoutExtension(path); - string extension = Path.GetExtension(path); - string uniqueName = Path.GetFileName(path); - int count = 1; - - while (File.Exists(Path.Combine(directoryPath, uniqueName)) || - Directory.Exists(Path.Combine(directoryPath, uniqueName))) - { - if (!string.IsNullOrEmpty(extension)) - { - // If it's a file, add the number before the extension - uniqueName = $"{nameWithoutExtension} ({count}){extension}"; - } - else - { - // If it's a folder (or file with no extension), just append the number - uniqueName = $"{nameWithoutExtension} ({count})"; - } - count++; - } - - var output = Path.Combine(directoryPath, uniqueName); - - return Result.Ok(output); - } - catch (Exception ex) - { - return Result.Fail($"An exception occurred when generating a unique path: {path}") - .WithException(ex); - } - } -} diff --git a/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs index 6c306ad3a..94aa741e3 100644 --- a/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs +++ b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs @@ -6,6 +6,7 @@ using Celbridge.Host; using Celbridge.Logging; using Celbridge.Messaging; +using Celbridge.Projects; using Celbridge.UserInterface; using Celbridge.WebHost; using Celbridge.WebHost.Services; @@ -41,6 +42,10 @@ public sealed partial class WebViewDocumentView : DocumentView, IHostInput // webview_* tool namespace is not supported for them. private IDocumentWebViewToolBridge? _toolBridge; + // Sub-folder under the project root where completed WebView downloads + // land. Auto-created by the chokepoint on first write. + private const string DownloadsFolderName = "downloads"; + private static readonly WebViewDocumentOptions DefaultOptions = new( WebViewDocumentRole.ExternalUrl, InterceptTopFrameNavigation: false); @@ -243,6 +248,12 @@ private async void WebViewDocumentView_Loaded(object sender, RoutedEventArgs e) TryRegisterWithToolBridge(); } + // temp:/ is wiped on workspace load, so the downloads sub-folder + // may not exist yet. Ensure it via the chokepoint before the user + // can trigger a download. + var downloadsFolder = new ResourceKey($"temp:{ProjectConstants.CelbridgeTempDownloadsFolder}"); + await FileStorage.CreateFolderAsync(downloadsFolder); + _webView.CoreWebView2.DownloadStarting -= CoreWebView2_DownloadStarting; _webView.CoreWebView2.DownloadStarting += CoreWebView2_DownloadStarting; @@ -535,14 +546,17 @@ private void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2Down var filename = Path.GetFileName(downloadPath); - var resolveResult = ResourceRegistry.ResolveResourcePath(filename); + // Downloads land under project:downloads/ so the project root stays + // uncluttered when a session produces multiple downloads. + var requestedDestResource = new ResourceKey($"{DownloadsFolderName}/{filename}"); + var resolveResult = ResourceRegistry.ResolveResourcePath(requestedDestResource); if (resolveResult.IsFailure) { args.Cancel = true; return; } var requestedPath = resolveResult.Value; - var getResult = PathHelper.GetUniquePath(requestedPath); + var getResult = GetUniquePath(requestedPath); if (getResult.IsFailure) { args.Cancel = true; @@ -558,28 +572,96 @@ private void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2Down } var saveResourceKey = getResourceResult.Value; + // Stage the download under the project's temp: root so the staging + // location lives alongside the rest of the workspace's scratch space + // and the wipe-on-load policy bounds orphan accumulation. var extension = Path.GetExtension(filename); - var tempPath = PathHelper.GetTemporaryFilePath("Downloads", extension); + var randomName = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()); + var downloadResource = new ResourceKey($"temp:{ProjectConstants.CelbridgeTempDownloadsFolder}/{randomName}{extension}"); + var resolveTempResult = ResourceRegistry.ResolveResourcePath(downloadResource); + if (resolveTempResult.IsFailure) + { + args.Cancel = true; + return; + } + var tempPath = resolveTempResult.Value; args.ResultFilePath = tempPath; - args.DownloadOperation.StateChanged += (s, e) => + args.DownloadOperation.StateChanged += async (s, e) => { - if (s.State == CoreWebView2DownloadState.Completed) + // Async-void event handler: any escaping exception ends up on the + // SynchronizationContext's unhandled-exception channel, so wrap + // the body so a WebView-side failure can't crash the host. + try { - _commandService.Execute(command => + if (s.State == CoreWebView2DownloadState.Completed) { - command.ResourceType = ResourceType.File; - command.SourcePath = tempPath; - command.DestResource = saveResourceKey; - }); + var importResult = await _commandService.ExecuteAsync(command => + { + command.ResourceType = ResourceType.File; + command.SourcePath = tempPath; + command.DestResource = saveResourceKey; + }); + + // Move semantics: the chokepoint doesn't support cross-root + // moves (temp: -> project:), so the import above copied + // bytes. Delete the staging copy here so we don't carry two + // copies on disk until temp: is wiped on next workspace load. + if (importResult.IsSuccess) + { + await FileStorage.DeleteAsync(downloadResource); + } + } + else if (s.State == CoreWebView2DownloadState.Interrupted) + { + await FileStorage.DeleteAsync(downloadResource); + } } - else if (s.State == CoreWebView2DownloadState.Interrupted) + catch (Exception ex) { - File.Delete(tempPath); + _logger.LogError(ex, "Download state change handler failed"); } }; } + // Returns a path that doesn't collide with an existing file or folder by + // appending " (N)" before any extension. Used to resolve the user's chosen + // download destination if a file with the same name already exists. + private static Result GetUniquePath(string path) + { + try + { + path = Path.GetFullPath(path); + + string directoryPath = Path.GetDirectoryName(path)!; + string nameWithoutExtension = Path.GetFileNameWithoutExtension(path); + string extension = Path.GetExtension(path); + string uniqueName = Path.GetFileName(path); + int count = 1; + + while (File.Exists(Path.Combine(directoryPath, uniqueName)) || + Directory.Exists(Path.Combine(directoryPath, uniqueName))) + { + if (!string.IsNullOrEmpty(extension)) + { + uniqueName = $"{nameWithoutExtension} ({count}){extension}"; + } + else + { + uniqueName = $"{nameWithoutExtension} ({count})"; + } + count++; + } + + return Path.Combine(directoryPath, uniqueName); + } + catch (Exception ex) + { + return Result.Fail($"Failed to generate a unique path: {path}") + .WithException(ex); + } + } + public override async Task LoadContent() { // Push the role onto the view model so NavigateUrl knows which URL to compute. diff --git a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs index 763278d69..34f23c281 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/WriteFileCommand.cs @@ -6,10 +6,8 @@ namespace Celbridge.Resources.Commands; public class WriteFileCommand : CommandBase, IWriteFileCommand { - // Force a registry update so sidecar classification refreshes on every - // write. Without this, overwriting an existing .cel file with broken TOML - // would leave data_check_project returning the stale "Healthy" status - // while data_get_field correctly rejects the file at read time. + // A write can change a file's sidecar classification (e.g. a .cel file + // becoming invalid TOML), so refresh the registry after every write. public override CommandFlags CommandFlags => CommandFlags.UpdateResources; private readonly ILogger _logger; @@ -28,40 +26,41 @@ public WriteFileCommand( public override async Task ExecuteAsync() { - var workspaceService = _workspaceWrapper.WorkspaceService; - var fileStorage = workspaceService.FileStorage; + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; - // Preserve existing line endings when overwriting. For a new file, - // honour whatever endings the caller's content already uses (so a CSV - // exporter emitting CRLF lands as CRLF on disk); fall back to the - // platform default when the content has no line endings to detect. - string targetSeparator; + var separatorResult = await ResolveTargetSeparatorAsync(fileStorage); + if (separatorResult.IsFailure) + { + return separatorResult; + } + var targetSeparator = separatorResult.Value; + + var contentToWrite = LineEndingHelper.ConvertLineEndings(Content, targetSeparator); + + return await fileStorage.WriteAllTextAsync(FileResource, contentToWrite); + } + + // Preserve the existing file's line endings on overwrite. For a new file, + // honour whatever the caller's content already uses (so a CSV exporter + // emitting CRLF lands as CRLF on disk); fall back to the platform default + // when neither has line endings to detect. + private async Task> ResolveTargetSeparatorAsync(IFileStorage fileStorage) + { var infoResult = await fileStorage.GetInfoAsync(FileResource); if (infoResult.IsFailure || infoResult.Value.Kind != StorageItemKind.File) { - targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(Content); - } - else - { - var readResult = await fileStorage.ReadAllTextAsync(FileResource); - if (readResult.IsFailure) - { - return Result.Fail($"Failed to read existing file: '{FileResource}'") - .WithErrors(readResult); - } - targetSeparator = LineEndingHelper.DetectSeparatorOrDefault(readResult.Value); + return LineEndingHelper.DetectSeparatorOrDefault(Content); } - var contentToWrite = LineEndingHelper.ConvertLineEndings(Content, targetSeparator); - - var writeResult = await fileStorage.WriteAllTextAsync(FileResource, contentToWrite); - if (writeResult.IsFailure) + var readResult = await fileStorage.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) { - return writeResult; + return Result.Fail($"Failed to read existing file: '{FileResource}'") + .WithErrors(readResult); } - return Result.Ok(); + return LineEndingHelper.DetectSeparatorOrDefault(readResult.Value); } // diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index c51f36221..70a190678 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -73,6 +73,10 @@ public ResourceService( var celbridgeTrashFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTrashFolder); var celbridgeStagingFsFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeStagingFsFolder); + // temp:/ is wiped on every workspace load. The contract is that nothing + // under temp: survives a reload; consumers needing persistence write + // under project: instead. + TryClearFolderContents(celbridgeTempFolder); Directory.CreateDirectory(celbridgeTempFolder); Directory.CreateDirectory(celbridgeLogsFolder); diff --git a/Source/Workspace/Celbridge.Search/Services/SearchService.cs b/Source/Workspace/Celbridge.Search/Services/SearchService.cs index 0ead41666..5fab7ee3f 100644 --- a/Source/Workspace/Celbridge.Search/Services/SearchService.cs +++ b/Source/Workspace/Celbridge.Search/Services/SearchService.cs @@ -161,12 +161,8 @@ public async Task SearchAsync( if (!string.IsNullOrEmpty(scope)) { - // Canonicalize the scope so callers can pass a bare path - // ("Data") or the fully-prefixed form ("project:Data") - // interchangeably, matching the convention used by every other - // resource-addressing tool. The comparison runs against the - // canonical ":" form of each candidate, so a bare - // scope without a root prefix never matched otherwise. + // File resources are matched as ":", so a bare scope + // like "Data" is canonicalized to "project:Data" before comparison. string canonicalScope; if (ResourceKey.TryCreate(scope, out var scopeKey)) { From 293397e9948511f522faf30e6ae0e125b4e7333a Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 15:35:17 +0100 Subject: [PATCH 46/48] Refactor resource/storage types & sidecar handling Extract resource/storage enums and DTOs into FileStorageTypes.cs and update IFileStorage/IResourceOperationService/IGetFileInfoCommand/IResourceRegistry to use the new types and cleaner parameter names (dest). Rename project downloads constant to DownloadsFolder and update WebView download paths. Add FileStorageInternals.RunWithRetryAsync and refactor FileStorage to use it; introduce SidecarCascade and ReferenceRewriter and add sidecar fence-line validation in SidecarHelper. Optimize RootPathResolver path normalization and adjust document layout restore to stop persisting editor ids. Harden migration step (recover partial .webview -> .webview.cel conversions) and add/adjust unit tests accordingly. --- CLAUDE.md | 2 +- .../Projects/ProjectConstants.cs | 7 +- .../Resources/FileStorageTypes.cs | 129 ++++ .../Resources/ICopyResourceCommand.cs | 4 +- .../Resources/IDeleteResourceCommand.cs | 78 ++- .../Resources/IFileResource.cs | 14 +- .../Resources/IFileStorage.cs | 109 +-- .../Resources/IGetFileInfoCommand.cs | 5 +- .../Resources/IResourceMonitor.cs | 3 +- .../Resources/IResourceOperationService.cs | 10 +- .../Resources/IResourceRegistry.cs | 10 +- .../Resources/ResourceMessages.cs | 33 +- .../Resources/ResourceType.cs | 14 +- .../MigrationSteps/MigrationStep_0_3_0.cs | 25 +- .../Tools/File/FileTools.GetInfo.cs | 2 +- .../Views/WebViewDocumentView.xaml.cs | 10 +- .../Documents/DocumentLayoutStoreTests.cs | 27 +- .../Steps/MigrationStep_0_3_0_Tests.cs | 46 ++ .../Tests/Resources/DataCheckProjectTests.cs | 4 +- Source/Tests/Resources/FileStorageTests.cs | 4 +- .../ResourceOperationServiceTests.cs | 6 +- .../Tests/Resources/ResourceRegistryTests.cs | 3 +- Source/Tests/Resources/SidecarHelperTests.cs | 30 + Source/Tests/Resources/SidecarServiceTests.cs | 16 + Source/Tests/Search/SearchServiceTests.cs | 2 +- .../Services/DocumentLayoutStore.cs | 30 +- .../Services/ReloadHintStore.cs | 4 +- .../Commands/GetFileInfoCommand.cs | 4 +- .../Commands/UnarchiveResourceCommand.cs | 2 +- .../Helpers/RootPathResolver.cs | 25 +- .../Helpers/SidecarHelper.cs | 32 + .../Services/FileStorage.cs | 662 +++--------------- .../Services/FileStorageInternals.cs | 127 ++++ .../Services/ReferenceRewriter.cs | 234 +++++++ .../Services/ResourceOperationService.cs | 95 ++- .../Services/ResourceOperations.cs | 94 ++- .../Services/ResourceReferenceParser.cs | 18 - .../Services/ResourceRegistry.cs | 12 +- .../Services/ResourceScanner.cs | 10 +- .../Services/SidecarCascade.cs | 181 +++++ .../Services/SidecarService.cs | 4 + .../Services/TrashService.cs | 91 +-- 42 files changed, 1225 insertions(+), 993 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Resources/FileStorageTypes.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/FileStorageInternals.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/ReferenceRewriter.cs create mode 100644 Source/Workspace/Celbridge.Resources/Services/SidecarCascade.cs diff --git a/CLAUDE.md b/CLAUDE.md index 78a05e396..0d067ed1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ Documents auto-save via `DocumentViewModel.OnDataChanged()` → per-view save ti - Do not add "discard unsaved changes?" prompts on close — closing always saves. - Programmatic edit commands (`EditFileCommand`, `MultiEditFileCommand`, `ReplaceFileCommand`, `ApplyRangeEditsCommand`, `WriteFileCommand`, `WriteBinaryFileCommand`) write straight to disk; there is no editor-routed code path. When the target file is open, the on-disk write triggers a watcher event and the document buffer reloads from disk via `editor.setValue`, which clears Monaco's undo history. Preserve that contract when adding new edit code paths — do not route writes through the open editor, and do not try to preserve undo state across a programmatic write. - External edits always win: if a watcher event arrives while a save is queued or in flight, the save is discarded and the buffer reloads from disk. `DocumentViewModel.SaveTextToFileAsync` also raises `ReloadRequested` when the post-write disk hash differs from what we intended to write (i.e. an external write interleaved). -- `MonitoredResourceChangedMessage` fires on every save; `DocumentViewModel` filters self-triggered events by hash. New consumers should expect high-frequency events. +- `ResourceChangedMessage` fires on every save; `DocumentViewModel` filters self-triggered events by hash. New consumers should expect high-frequency events. ## MCP Tools diff --git a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs index 0eaaa6582..4631cdbd0 100644 --- a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs +++ b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs @@ -47,7 +47,7 @@ public static class ProjectConstants public const string WorkspaceSettingsFile = "workspace_settings.db"; /// - /// Hidden folder that contains all Celbridge-internal storage in the new layout. + /// Hidden folder for Celbridge-internal storage. /// public const string CelbridgeFolder = ".celbridge"; @@ -75,7 +75,8 @@ public static class ProjectConstants public const string CelbridgeStagingFsFolder = "staging-fs"; /// - /// Sub-folder of temp: that holds in-progress downloads from the WebView. + /// Folder name used for WebView downloads. Used both for the in-progress + /// staging folder under temp: and for the destination folder under project:. /// - public const string CelbridgeTempDownloadsFolder = "downloads"; + public const string DownloadsFolder = "downloads"; } diff --git a/Source/Core/Celbridge.Foundation/Resources/FileStorageTypes.cs b/Source/Core/Celbridge.Foundation/Resources/FileStorageTypes.cs new file mode 100644 index 000000000..7f7fad087 --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Resources/FileStorageTypes.cs @@ -0,0 +1,129 @@ +namespace Celbridge.Resources; + +/// +/// The outcome of the sidecar cascade attached to a structural operation. +/// +public enum SidecarOutcome +{ + /// + /// No sidecar file existed alongside the source; nothing to cascade. + /// + NotPresent, + + /// + /// A sidecar existed and the operation applied to it successfully. + /// + Cascaded, + + /// + /// A sidecar existed but the cascade step failed. The parent operation still succeeded. + /// + Failed, +} + +/// +/// Why a referencer could not be rewritten during a move's cascade. The +/// ReadOnly and PermissionDenied values align with the same-named concepts +/// on DeleteResourceOutcome; ReadFailed and WriteFailed split the IOFailure +/// concept by operation phase because rewriting a referencer involves both +/// a read and a write. Inspect SkippedReferencer.Message for the specific cause. +/// +public enum ReferencerSkipReason +{ + /// + /// Catch-all for an IO failure during the read of the referencer's bytes + /// (file held open by another process, disk error, network share gone). + /// + ReadFailed, + + /// + /// Catch-all for an IO failure during the write of the rewritten content + /// (file briefly locked, disk full, network share gone, hardware error). + /// + WriteFailed, + + /// + /// The DOS read-only attribute is set on the referencer. Trivially clearable + /// — uncheck the read-only flag and re-run the rename. + /// + ReadOnly, + + /// + /// ACL or POSIX denial. Needs the right account or admin rights. + /// + PermissionDenied, +} + +/// +/// A referencer the move could not rewrite. The reference is left stale and +/// will surface via data_check_project; a re-run of the rename after the +/// underlying issue clears (close the editor, remove the read-only attribute) +/// picks up the residual rewrite because the FS layer is idempotent. +/// +public record SkippedReferencer( + ResourceKey Resource, + ReferencerSkipReason Reason, + string Message); + +/// +/// Result of an integrity-aware move: the list of resources whose references +/// were rewritten, the list of referencers the cascade had to skip (with a +/// reason for each), and the outcome of the paired-sidecar cascade. +/// +public record MoveResult( + IReadOnlyList UpdatedReferencers, + IReadOnlyList SkippedReferencers, + SidecarOutcome Sidecar); + +/// +/// Result of an integrity-aware copy: the outcome of the paired-sidecar cascade. +/// +public record CopyResult( + SidecarOutcome Sidecar); + +/// +/// Result of an integrity-aware delete: the outcome of the paired-sidecar cascade. +/// +public record DeleteResult( + SidecarOutcome Sidecar); + +/// +/// One immediate child of a folder, returned by EnumerateFolderAsync. +/// +public record FolderItem( + ResourceKey Resource, + bool IsFolder, + long Size, + DateTime ModifiedUtc); + +/// +/// Discriminates the outcome of a GetInfoAsync probe. +/// +public enum StorageItemKind +{ + /// + /// The resource does not exist at the resolved path. + /// + NotFound, + + /// + /// The resource exists and is a file. + /// + File, + + /// + /// The resource exists and is a folder. + /// + Folder, +} + +/// +/// Metadata for a single resource, returned by GetInfoAsync. Size is the +/// file size in bytes for File; 0 for Folder and NotFound. ModifiedUtc is +/// the last-modified timestamp for File and Folder; default(DateTime) for +/// NotFound. +/// +public record StorageItemInfo( + StorageItemKind Kind, + long Size, + DateTime ModifiedUtc); diff --git a/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs b/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs index 4949ac222..64a331ac5 100644 --- a/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/ICopyResourceCommand.cs @@ -26,7 +26,7 @@ public interface ICopyResourceCommand : IExecutableCommand List SourceResources { get; set; } /// - /// Location to move the resources to. + /// Destination location for the copy or move. /// ResourceKey DestResource { get; set; } @@ -37,7 +37,7 @@ public interface ICopyResourceCommand : IExecutableCommand DataTransferMode TransferMode { get; set; } /// - /// If a copied resource is a folder, expand the folder after moving it. + /// If a copied resource is a folder, expand the folder after the copy or move. /// bool ExpandCopiedFolder { get; set; } } diff --git a/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs index dfef12173..448c7902f 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IDeleteResourceCommand.cs @@ -3,55 +3,93 @@ namespace Celbridge.Resources; /// -/// How DeleteResourceCommand should respond when the resources being deleted are -/// referenced by other project resources. RequireConfirmation prompts the user -/// via IDialogService; FailIfReferenced refuses the batch and reports -/// the conflicting referencers; BreakReferences proceeds without prompting, -/// leaving the existing references dangling. +/// How DeleteResourceCommand should respond when the resources being deleted +/// are referenced by other project resources. /// public enum DeleteReferencePolicy { + /// + /// Prompt the user via IDialogService and proceed only if confirmed. + /// RequireConfirmation, + + /// + /// Refuse the batch and report the conflicting referencers in the result. + /// FailIfReferenced, + + /// + /// Proceed without prompting, leaving the existing references dangling. + /// BreakReferences, } /// /// Aggregate outcome of a DeleteResourceCommand batch. -/// DeletedAll means every resource in the batch was deleted successfully. -/// DeletedSome means the policy gate passed and execution ran but at least -/// one resource failed mechanically (file locked, IO error, etc.); inspect -/// ResourceResults to see which. This also covers the rare edge where every -/// resource failed — when zero of N succeed, the batch is still classified -/// DeletedSome rather than carving out a separate "none succeeded" value, -/// since the agent's next step (inspect ResourceResults) is the same either way. -/// CancelledByUser and BlockedByReferences are policy-gate failures that leave -/// the filesystem untouched. /// public enum DeleteBatchOutcome { + /// + /// Every resource in the batch was deleted successfully. + /// DeletedAll, + + /// + /// The policy gate passed and execution ran but at least one resource + /// failed mechanically (file locked, IO error, etc.); inspect ResourceResults + /// to see which. Also covers the rare edge where every resource failed — + /// the agent's next step is the same either way (inspect ResourceResults). + /// DeletedSome, + + /// + /// Policy-gate failure: the user declined the confirmation prompt. The + /// filesystem is untouched. + /// CancelledByUser, + + /// + /// Policy-gate failure under FailIfReferenced: at least one resource had + /// external referencers. The filesystem is untouched. + /// BlockedByReferences, } /// /// Per-resource outcome inside a DeleteResourceCommand batch. The non-Deleted /// values are typed so an agent can branch on the cause without parsing -/// FailureMessage: NotFound is the no-op success case (the resource is already -/// gone); Locked means another process holds the file (often fixable by closing -/// the editor or stopping the antivirus); PermissionDenied is an ACL / POSIX -/// denial (needs the right account or admin); IOFailure is the catch-all for -/// disk full, network share gone, hardware error, and any other mechanical -/// failure that doesn't fit the more specific reasons. +/// FailureMessage. The Locked / PermissionDenied / IOFailure values align +/// with the same-named concepts on ReferencerSkipReason (used by the rename +/// cascade) — delete is a single operation so it doesn't need ReferencerSkipReason's +/// ReadFailed / WriteFailed split. /// public enum DeleteResourceOutcome { + /// + /// The resource was deleted successfully. + /// Deleted, + + /// + /// The resource was already gone when the operation ran — a no-op success. + /// NotFound, + + /// + /// Another process holds the file (open editor, antivirus, indexer). Often + /// fixable by closing the offending process and retrying. + /// Locked, + + /// + /// ACL or POSIX denial. Needs the right account or admin rights. + /// PermissionDenied, + + /// + /// Catch-all for any other mechanical failure (disk full, network share + /// gone, hardware error) that doesn't fit the more specific reasons. + /// IOFailure, } diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs b/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs index 09bdb0399..2061a9632 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileResource.cs @@ -3,14 +3,20 @@ namespace Celbridge.Resources; /// -/// Parse health of a .cel file's content. Healthy means the frontmatter parses -/// cleanly; Broken means it does not (malformed TOML, merge-conflict markers, -/// missing fences, or any other parse failure). Applies to any .cel file — -/// paired sidecar, standalone, or orphan. +/// Parse health of a .cel file's content. Applies to any .cel file — paired +/// sidecar, standalone, or orphan. /// public enum CelFileStatus { + /// + /// The frontmatter and content blocks parse cleanly. + /// Healthy, + + /// + /// The file failed to parse: malformed TOML, merge-conflict markers, + /// missing fences, duplicate block names, or any other parse failure. + /// Broken, } diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs index bfaa07594..2384e8e50 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IFileStorage.cs @@ -1,112 +1,15 @@ namespace Celbridge.Resources; -/// -/// The outcome of the sidecar cascade attached to a structural operation. -/// -public enum SidecarOutcome -{ - /// - /// No sidecar file existed alongside the source; nothing to cascade. - /// - NotPresent, - - /// - /// A sidecar existed and the operation applied to it successfully. - /// - Cascaded, - - /// - /// A sidecar existed but the cascade step failed. The parent operation still succeeded. - /// - Failed, -} - -/// -/// Why a referencer could not be rewritten during a move's cascade. -/// ReadOnly is the DOS read-only attribute (trivially clearable); -/// PermissionDenied is an ACL / POSIX denial (needs the right account or admin). -/// ReadFailed and WriteFailed are catch-alls. Inspect SkippedReferencer.Message -/// for the specific cause. -/// -public enum ReferencerSkipReason -{ - ReadFailed, - WriteFailed, - ReadOnly, - PermissionDenied, -} - -/// -/// A referencer the move could not rewrite. The reference is left stale and -/// will surface via data_check_project; a re-run of the rename after the -/// underlying issue clears (close the editor, remove the read-only attribute) -/// picks up the residual rewrite because the FS layer is idempotent. -/// -public record SkippedReferencer( - ResourceKey Resource, - ReferencerSkipReason Reason, - string Message); - -/// -/// Result of an integrity-aware move: the list of resources whose references -/// were rewritten, the list of referencers the cascade had to skip (with a -/// reason for each), and the outcome of the paired-sidecar cascade. -/// -public record MoveResult( - IReadOnlyList UpdatedReferencers, - IReadOnlyList SkippedReferencers, - SidecarOutcome Sidecar); - -/// -/// Result of an integrity-aware copy: the outcome of the paired-sidecar cascade. -/// -public record CopyResult( - SidecarOutcome Sidecar); - -/// -/// Result of an integrity-aware delete: the outcome of the paired-sidecar cascade. -/// -public record DeleteResult( - SidecarOutcome Sidecar); - -/// -/// One immediate child of a folder, returned by EnumerateFolderAsync. -/// -public record FolderItem( - ResourceKey Resource, - bool IsFolder, - long Size, - DateTime ModifiedUtc); - -/// -/// Discriminates the outcome of a GetInfoAsync probe. -/// -public enum StorageItemKind -{ - NotFound, - File, - Folder, -} - -/// -/// Metadata for a single resource, returned by GetInfoAsync. Size is the -/// file size in bytes for File; 0 for Folder and NotFound. ModifiedUtc is -/// the last-modified timestamp for File and Folder; default(DateTime) for -/// NotFound. -/// -public record StorageItemInfo( - StorageItemKind Kind, - long Size, - DateTime ModifiedUtc); - /// /// The chokepoint for disk reads, writes, and structural operations against any /// resource addressable by a ResourceKey — files under the project tree as well /// as files under registered non-project roots (e.g. temp:, logs:). Callers pass /// a ResourceKey; the layer dispatches via the registered root handlers so /// containment and symlink validation run automatically. Reads and writes have -/// bounded retry on transient IO failures; writes are additionally atomic via -/// temp-file rename. Structural operations on project: resources additionally +/// bounded retry on transient IO failures; the buffered-bytes write paths +/// (WriteAllBytesAsync / WriteAllTextAsync) are additionally atomic via +/// temp-file rename within a single volume — the streaming OpenWriteAsync +/// path is not atomic. Structural operations on project: resources additionally /// cascade the paired sidecar, and rewrite references that live inside /// scannable file types (see ResourceScanner for the current allowlist); /// operations on non-project roots are pure byte moves. @@ -155,13 +58,13 @@ public interface IFileStorage /// Moves the resource and cascades reference rewrites and the paired /// sidecar. Cross-root moves are not supported. /// - Task> MoveAsync(ResourceKey source, ResourceKey destination); + Task> MoveAsync(ResourceKey source, ResourceKey dest); /// /// Copies the resource and cascades the paired sidecar to the destination. /// References inside the copied content keep pointing at their original targets. /// - Task> CopyAsync(ResourceKey source, ResourceKey destination); + Task> CopyAsync(ResourceKey source, ResourceKey dest); /// /// Deletes the resource and cascades the paired sidecar. diff --git a/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs b/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs index a4f27e38a..c3d474862 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IGetFileInfoCommand.cs @@ -17,7 +17,7 @@ public record class FileInfoSnapshot( string Extension, bool IsText, int? LineCount, - string? SidecarKey, + ResourceKey? SidecarKey, CelFileStatus? SidecarStatus); /// @@ -27,5 +27,8 @@ public record class FileInfoSnapshot( /// public interface IGetFileInfoCommand : IExecutableCommand { + /// + /// The file or folder resource to probe. Set by the caller before the command runs. + /// ResourceKey Resource { get; set; } } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs index cbb360bd2..32c14d51f 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceMonitor.cs @@ -1,7 +1,8 @@ namespace Celbridge.Resources; /// -/// Interface for monitoring file system changes in the project folder and scheduling resource updates. +/// Watches the project folder and any other registered watched roots for file +/// system changes, debouncing and scheduling resource-registry updates. /// public interface IResourceMonitor { diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs index 30ad4ae76..8119b3d6d 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceOperationService.cs @@ -36,14 +36,14 @@ public interface IResourceOperationService /// Copies a file or folder from one resource location to another. The /// returned CopyResult carries the paired-sidecar cascade outcome. /// - Task> CopyAsync(ResourceKey source, ResourceKey destination); + Task> CopyAsync(ResourceKey source, ResourceKey dest); /// /// Moves a file or folder from one resource location to another. The /// returned MoveResult carries the reference-rewrite and paired-sidecar /// cascade outcomes. /// - Task> MoveAsync(ResourceKey source, ResourceKey destination); + Task> MoveAsync(ResourceKey source, ResourceKey dest); /// /// Soft-deletes the resource via the trash service, preserving undo. The @@ -56,20 +56,20 @@ public interface IResourceOperationService /// destination. The source path is taken as-is; the destination receives /// containment validation through the chokepoint. /// - Task ImportExternalFileAsync(string sourcePath, ResourceKey destination); + Task ImportExternalFileAsync(string sourcePath, ResourceKey dest); /// /// Imports a folder from outside the project into a registry-addressable /// destination. The source is enumerated recursively; each file lands at /// its corresponding destination key through the chokepoint. /// - Task ImportExternalFolderAsync(string sourcePath, ResourceKey destination); + Task ImportExternalFolderAsync(string sourcePath, ResourceKey dest); /// /// Copies or moves the resource depending on the transfer mode. Dispatches /// file vs folder internally via the chokepoint's GetInfoAsync probe. /// - Task TransferAsync(ResourceKey source, ResourceKey destination, DataTransferMode mode); + Task TransferAsync(ResourceKey source, ResourceKey dest, DataTransferMode mode); /// /// Begins a batch of operations that commit as a single undo unit when the diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs index 9a1abc3e2..e3e82cc74 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs @@ -15,6 +15,12 @@ public record CelFileReport( IReadOnlyList Broken, IReadOnlyList Orphan); +/// +/// A file resource paired with its absolute filesystem path, as returned by +/// IResourceRegistry.GetAllFileResources. +/// +public record FileResourceEntry(ResourceKey Resource, string Path); + /// /// A data structure representing the resources in the project folder. /// @@ -106,13 +112,13 @@ public interface IResourceRegistry /// Returns all file resources for the project root with their resource keys and absolute paths. /// The results are sorted by path for stable ordering. /// - List<(ResourceKey Resource, string Path)> GetAllFileResources(); + IReadOnlyList GetAllFileResources(); /// /// Returns all file resources for the specified root with their resource keys and absolute paths. /// Returns an empty list for roots without indexed tree state. /// - List<(ResourceKey Resource, string Path)> GetAllFileResources(string root); + IReadOnlyList GetAllFileResources(string root); /// /// Returns the parent file resource of a sidecar key, or a failure result diff --git a/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs b/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs index 9a3b0392a..21c7f5b17 100644 --- a/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs +++ b/Source/Core/Celbridge.Foundation/Resources/ResourceMessages.cs @@ -1,17 +1,46 @@ namespace Celbridge.Resources; /// -/// Types of resource operations that can fail. +/// Types of resource operations that can fail. Carried by +/// ResourceOperationFailedMessage so subscribers can branch on the operation +/// kind without parsing the message text. /// public enum ResourceOperationType { + /// + /// Soft-delete via the trash service. + /// Delete, + + /// + /// Copy a resource to a different location, leaving the source in place. + /// Copy, + + /// + /// Move a resource to a different folder. + /// Move, + + /// + /// Rename a resource within its current folder. + /// Rename, + + /// + /// Create a new file or folder resource. + /// Create, + + /// + /// Compress a folder or set of resources into a .zip archive. + /// Archive, - Extract + + /// + /// Extract the contents of a .zip archive into the project tree. + /// + Extract, } /// diff --git a/Source/Core/Celbridge.Foundation/Resources/ResourceType.cs b/Source/Core/Celbridge.Foundation/Resources/ResourceType.cs index e1e5e9b97..d59d388cb 100644 --- a/Source/Core/Celbridge.Foundation/Resources/ResourceType.cs +++ b/Source/Core/Celbridge.Foundation/Resources/ResourceType.cs @@ -1,11 +1,23 @@ namespace Celbridge.Resources; /// -/// Types of resource that can a project can contain. +/// The kind of a resource registered with the project. /// public enum ResourceType { + /// + /// Sentinel value used when no resource has been resolved yet, or when + /// resolution failed. + /// Invalid, + + /// + /// A file resource. + /// File, + + /// + /// A folder resource. + /// Folder, } diff --git a/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs index 7b23a2840..5e3c2d5f1 100644 --- a/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs +++ b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs @@ -68,12 +68,6 @@ private async Task ConvertWebViewFilesAsync(MigrationContext context, st } var newPath = fullOldPath + ".cel"; - if (File.Exists(newPath)) - { - return Result.Fail( - $"Cannot convert '{fullOldPath}' to '{newPath}'. Target file already exists."); - } - var convertResult = await ConvertWebViewFileAsync(fullOldPath, newPath); if (convertResult.IsFailure) { @@ -106,14 +100,9 @@ private async Task ConvertWebViewFileAsync(string oldPath, string newPat { var originalText = await File.ReadAllTextAsync(oldPath); var sourceUrl = ExtractSourceUrlFromJson(originalText); + var tomlText = BuildWebViewTomlContent(sourceUrl); - var tomlBuilder = new StringBuilder(); - tomlBuilder.Append(WebViewTomlSourceUrlKey); - tomlBuilder.Append(" = "); - tomlBuilder.Append(QuoteTomlBasicString(sourceUrl)); - tomlBuilder.Append('\n'); - - await File.WriteAllTextAsync(newPath, tomlBuilder.ToString()); + await File.WriteAllTextAsync(newPath, tomlText); return Result.Ok(); } catch (Exception ex) @@ -123,6 +112,16 @@ private async Task ConvertWebViewFileAsync(string oldPath, string newPat } } + private static string BuildWebViewTomlContent(string sourceUrl) + { + var tomlBuilder = new StringBuilder(); + tomlBuilder.Append(WebViewTomlSourceUrlKey); + tomlBuilder.Append(" = "); + tomlBuilder.Append(QuoteTomlBasicString(sourceUrl)); + tomlBuilder.Append('\n'); + return tomlBuilder.ToString(); + } + private static string ExtractSourceUrlFromJson(string jsonText) { // A pre-0.3.0 .webview file always parsed as a JSON object with a diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs index 54567f588..5e5ffbab9 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs @@ -70,7 +70,7 @@ public async partial Task GetInfo(string resource) snapshot.Extension, snapshot.IsText, snapshot.LineCount, - snapshot.SidecarKey, + snapshot.SidecarKey?.ToString(), sidecarStatusText); return ToolResponse.Success(SerializeJson(fileResult)); } diff --git a/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs index 94aa741e3..3e50209c5 100644 --- a/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs +++ b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs @@ -42,10 +42,6 @@ public sealed partial class WebViewDocumentView : DocumentView, IHostInput // webview_* tool namespace is not supported for them. private IDocumentWebViewToolBridge? _toolBridge; - // Sub-folder under the project root where completed WebView downloads - // land. Auto-created by the chokepoint on first write. - private const string DownloadsFolderName = "downloads"; - private static readonly WebViewDocumentOptions DefaultOptions = new( WebViewDocumentRole.ExternalUrl, InterceptTopFrameNavigation: false); @@ -251,7 +247,7 @@ private async void WebViewDocumentView_Loaded(object sender, RoutedEventArgs e) // temp:/ is wiped on workspace load, so the downloads sub-folder // may not exist yet. Ensure it via the chokepoint before the user // can trigger a download. - var downloadsFolder = new ResourceKey($"temp:{ProjectConstants.CelbridgeTempDownloadsFolder}"); + var downloadsFolder = new ResourceKey($"temp:{ProjectConstants.DownloadsFolder}"); await FileStorage.CreateFolderAsync(downloadsFolder); _webView.CoreWebView2.DownloadStarting -= CoreWebView2_DownloadStarting; @@ -548,7 +544,7 @@ private void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2Down // Downloads land under project:downloads/ so the project root stays // uncluttered when a session produces multiple downloads. - var requestedDestResource = new ResourceKey($"{DownloadsFolderName}/{filename}"); + var requestedDestResource = new ResourceKey($"{ProjectConstants.DownloadsFolder}/{filename}"); var resolveResult = ResourceRegistry.ResolveResourcePath(requestedDestResource); if (resolveResult.IsFailure) { @@ -577,7 +573,7 @@ private void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2Down // and the wipe-on-load policy bounds orphan accumulation. var extension = Path.GetExtension(filename); var randomName = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()); - var downloadResource = new ResourceKey($"temp:{ProjectConstants.CelbridgeTempDownloadsFolder}/{randomName}{extension}"); + var downloadResource = new ResourceKey($"temp:{ProjectConstants.DownloadsFolder}/{randomName}{extension}"); var resolveTempResult = ResourceRegistry.ResolveResourcePath(downloadResource); if (resolveTempResult.IsFailure) { diff --git a/Source/Tests/Documents/DocumentLayoutStoreTests.cs b/Source/Tests/Documents/DocumentLayoutStoreTests.cs index cdf203171..e6680a98f 100644 --- a/Source/Tests/Documents/DocumentLayoutStoreTests.cs +++ b/Source/Tests/Documents/DocumentLayoutStoreTests.cs @@ -137,11 +137,11 @@ public async Task RestorePanelStateAsync_MalformedLayoutJson_DoesNotThrow() [Test] public async Task RestorePanelStateAsync_RestoresStoredAddressesViaPanelOpen() { - // One stored doc: the store should call panel.OpenDocument with the - // parsed editor id and target address. + // One stored doc: the store should call panel.OpenDocument with an + // empty editor id (sidecar wins at restore) and the saved address. var stored = new List { - new("notes/readme.md", WindowIndex: 0, SectionIndex: 0, TabOrder: 2, DocumentEditorId: "test.editor"), + new("notes/readme.md", WindowIndex: 0, SectionIndex: 0, TabOrder: 2), }; _workspaceSettings.GetPropertyAsync>("DocumentLayout") .Returns(Task.FromResult?>(stored)); @@ -151,7 +151,7 @@ public async Task RestorePanelStateAsync_RestoresStoredAddressesViaPanelOpen() await _documentsPanel.Received(1).OpenDocument( new ResourceKey("notes/readme.md"), Arg.Is(options => - options.EditorId == new DocumentEditorId("test.editor") + options.EditorId == DocumentEditorId.Empty && options.Activate == false && options.Address!.SectionIndex == 0 && options.Address.TabOrder == 2)); @@ -217,25 +217,6 @@ public async Task RestorePanelStateAsync_InaccessibleFile_IsSkipped() await _documentsPanel.DidNotReceive().OpenDocument(Arg.Any(), Arg.Any()); } - [Test] - public async Task RestorePanelStateAsync_MalformedEditorId_FallsBackToEmpty() - { - // A persisted editor id that no longer parses (renamed format, etc.) - // should be treated as "no preference" rather than aborting the open. - var stored = new List - { - new("notes/readme.md", 0, 0, 0, "totally not a valid id"), - }; - _workspaceSettings.GetPropertyAsync>("DocumentLayout") - .Returns(Task.FromResult?>(stored)); - - await _store.RestorePanelStateAsync(); - - await _documentsPanel.Received(1).OpenDocument( - Arg.Any(), - Arg.Is(options => options.EditorId.IsEmpty)); - } - [Test] public async Task RestorePanelStateAsync_SectionIndexLargerThanCount_ClampsToLastSection() { diff --git a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs index e08f6ddfe..fca75518f 100644 --- a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs +++ b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs @@ -183,6 +183,52 @@ public async Task ApplyAsync_IsIdempotent() File.Exists(Path.Combine(_projectFolderPath, "page.webview.cel")).Should().BeTrue(); } + [Test] + public async Task ApplyAsync_TreatsMalformedJsonAsEmptySourceUrl() + { + // A pre-0.3.0 .webview file with malformed JSON should not abort the + // migration: the conversion treats the URL as empty and continues so + // the file lands at the new extension and the user can supply a URL. + WriteMinimalProjectFile(); + var oldWebViewPath = Path.Combine(_projectFolderPath, "broken.webview"); + await File.WriteAllTextAsync(oldWebViewPath, "{ not valid json"); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + result.IsSuccess.Should().BeTrue(); + var newPath = Path.Combine(_projectFolderPath, "broken.webview.cel"); + File.Exists(newPath).Should().BeTrue(); + + var newText = await File.ReadAllTextAsync(newPath); + newText.Should().Contain("source_url = \"\""); + } + + [Test] + public async Task ApplyAsync_EscapesSpecialCharactersInSourceUrl() + { + // Quote and backslash characters in the sourceUrl must be escaped on + // the TOML basic-string side or the resulting file fails to parse. + WriteMinimalProjectFile(); + var oldWebViewPath = Path.Combine(_projectFolderPath, "tricky.webview"); + await File.WriteAllTextAsync(oldWebViewPath, "{\"sourceUrl\": \"https://example.com/q?x=\\\"a\\\"&y=back\\\\slash\"}"); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + result.IsSuccess.Should().BeTrue(); + var newPath = Path.Combine(_projectFolderPath, "tricky.webview.cel"); + var newText = await File.ReadAllTextAsync(newPath); + var parsed = Toml.Parse(newText); + parsed.HasErrors.Should().BeFalse(); + + var root = (TomlTable)parsed.ToModel(); + root.TryGetValue("source_url", out var urlValue).Should().BeTrue(); + urlValue.Should().Be("https://example.com/q?x=\"a\"&y=back\\slash"); + } + private void WriteMinimalProjectFile() { var content = """ diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index 9a3dec051..b98e5d72a 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -116,7 +116,7 @@ public void TearDown() public async Task CleanProject_AllReportListsAreEmpty() { // Fixture uses .json because the scanner only walks allowlisted - // data-bearing extensions. See ResourceScanRules.ScannableExtensions. + // data-bearing extensions. See ResourceScanner.ScannableExtensions. File.WriteAllText(Path.Combine(_projectFolderPath, "a.json"), "{}"); File.WriteAllText(Path.Combine(_projectFolderPath, "b.json"), "{ \"target\": \"project:a.json\" }"); @@ -150,7 +150,7 @@ public async Task BrokenReference_IsReportedWithSourceAndTarget() public async Task NonAllowlistedExtensions_AreExcludedFromScan() { // .md is not on the allowlist (along with .txt, .rst, .yaml, and every - // other extension not enumerated in ResourceScanRules.ScannableExtensions). + // other extension not enumerated in ResourceScanner.ScannableExtensions). // A "project:..." literal inside an off-allowlist file is treated as // descriptive prose, not as an active reference — no cascade rewrite, // no broken-reference detection. This test guards the allowlist gate diff --git a/Source/Tests/Resources/FileStorageTests.cs b/Source/Tests/Resources/FileStorageTests.cs index 767fb2754..869c9760c 100644 --- a/Source/Tests/Resources/FileStorageTests.cs +++ b/Source/Tests/Resources/FileStorageTests.cs @@ -541,8 +541,8 @@ public async Task MoveAsync_RewritesJsonEscapedReferencer() { // The reference sits inside a JSON-escape sequence \"project:...\" // (e.g. an MCP tool response stored as a JSON string). The scanner - // detects it via the two-char \" opener and the cascade must rewrite - // it via the matching trailing-\\ boundary on IsNonKeyBoundary. + // detects it via the two-char \" opener and the cascade rewrites it + // through the same parser path so the trailing \" is recognised. var sourceKey = new ResourceKey("foo.md"); var destKey = new ResourceKey("bar.md"); var referencerKey = new ResourceKey("payload.json"); diff --git a/Source/Tests/Resources/ResourceOperationServiceTests.cs b/Source/Tests/Resources/ResourceOperationServiceTests.cs index 72fcd49b5..379533073 100644 --- a/Source/Tests/Resources/ResourceOperationServiceTests.cs +++ b/Source/Tests/Resources/ResourceOperationServiceTests.cs @@ -9,9 +9,9 @@ namespace Celbridge.Tests.Resources; /// -/// Tests for ResourceOperationService — covers the end-of-phase gate property -/// from the cm-9 redesign: a batch that fails mid-way still commits the -/// prior-successful operations and a single UndoAsync reverses them cleanly. +/// Tests for ResourceOperationService — covers the batch property that a +/// batch failing mid-way still commits the prior-successful operations, +/// and a single UndoAsync reverses them cleanly. /// [TestFixture] public class ResourceOperationServiceTests diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 537dbd6db..77085e009 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -398,7 +398,8 @@ public void GetAllFileResourcesScopesToProjectRoot() var explicitProject = resourceRegistry.GetAllFileResources(ResourceKey.DefaultRoot); explicitProject.Count.Should().Be(defaultResults.Count); - // Other roots return empty in vr-2 (no indexed tree state). + // Other roots return empty because the registry indexes only the project + // tree; temp and logs are reachable through their root handlers, not here. resourceRegistry.GetAllFileResources("temp").Should().BeEmpty(); } diff --git a/Source/Tests/Resources/SidecarHelperTests.cs b/Source/Tests/Resources/SidecarHelperTests.cs index cff246d9c..ded419598 100644 --- a/Source/Tests/Resources/SidecarHelperTests.cs +++ b/Source/Tests/Resources/SidecarHelperTests.cs @@ -180,6 +180,36 @@ public void Compose_RejectsInvalidBlockName() act.Should().Throw(); } + [Test] + public void Compose_RejectsBlockContentContainingFenceLine() + { + var blocks = new List + { + new("block-a", "ok line\n+++ \"sneaky\"\nmore content\n"), + }; + + var act = () => SidecarHelper.Compose(new Dictionary(), blocks); + + act.Should().Throw(); + } + + [Test] + public void BlockContentContainsFenceLine_ReturnsTrueForLineMatchingFence() + { + SidecarHelper.BlockContentContainsFenceLine("+++ \"block-a\"").Should().BeTrue(); + SidecarHelper.BlockContentContainsFenceLine("first line\r\n+++ \"block-a\"\r\nlast") + .Should().BeTrue(); + } + + [Test] + public void BlockContentContainsFenceLine_ReturnsFalseForOrdinaryContent() + { + SidecarHelper.BlockContentContainsFenceLine("").Should().BeFalse(); + SidecarHelper.BlockContentContainsFenceLine("+++ no quote").Should().BeFalse(); + SidecarHelper.BlockContentContainsFenceLine("line one\nline two\n").Should().BeFalse(); + SidecarHelper.BlockContentContainsFenceLine("+++\"missing-space\"").Should().BeFalse(); + } + [Test] public void Parse_TreatsBlockContentWithoutTrailingNewlineAsEquivalent() { diff --git a/Source/Tests/Resources/SidecarServiceTests.cs b/Source/Tests/Resources/SidecarServiceTests.cs index bd0fbffec..c977e5840 100644 --- a/Source/Tests/Resources/SidecarServiceTests.cs +++ b/Source/Tests/Resources/SidecarServiceTests.cs @@ -243,6 +243,22 @@ public async Task WriteBlockAsync_RejectsInvalidBlockId() await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); } + [Test] + public async Task WriteBlockAsync_RejectsContentContainingFenceLine() + { + // Block content that contains a line matching the fence regex would + // cause Parse to split it incorrectly on read. The service rejects this + // up front so the bytes never land on disk. + var writeResult = await _sidecarService.WriteBlockAsync( + new ResourceKey("photo.png"), + "block-a", + "first\n+++ \"sneaky\"\nlast\n"); + + writeResult.IsFailure.Should().BeTrue(); + writeResult.FirstErrorMessage.Should().Contain("fence regex"); + await _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } + [Test] public void GetSidecarKey_FailsForNonProjectRoot() { diff --git a/Source/Tests/Search/SearchServiceTests.cs b/Source/Tests/Search/SearchServiceTests.cs index 771428cfb..f86440445 100644 --- a/Source/Tests/Search/SearchServiceTests.cs +++ b/Source/Tests/Search/SearchServiceTests.cs @@ -21,7 +21,7 @@ public void SetUp() _resourceRegistry = Substitute.For(); _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); - _resourceRegistry.GetAllFileResources().Returns(new List<(ResourceKey Resource, string Path)>()); + _resourceRegistry.GetAllFileResources().Returns(new List()); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs index ac96a20d6..27b98008d 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs @@ -36,9 +36,12 @@ public DocumentLayoutStore( /// /// Serialization DTO for a single open document tab. Public so the - /// workspace-settings deserializer can reach it through the store. + /// workspace-settings deserializer can reach it through the store. The + /// document's editor is recovered from the sidecar at restore time (or + /// from the per-extension default), so the layout never needs to persist + /// the editor id directly. /// - public record StoredDocumentAddress(string Resource, int WindowIndex, int SectionIndex, int TabOrder, string DocumentEditorId = ""); + public record StoredDocumentAddress(string Resource, int WindowIndex, int SectionIndex, int TabOrder); public async Task StoreDocumentLayoutAsync() { @@ -50,8 +53,7 @@ public async Task StoreDocumentLayoutAsync() document.FileResource.ToString(), document.Address.WindowIndex, document.Address.SectionIndex, - document.Address.TabOrder, - document.EditorId.ToString())) + document.Address.TabOrder)) .OrderBy(address => address.WindowIndex) .ThenBy(address => address.SectionIndex) .ThenBy(address => address.TabOrder) @@ -262,27 +264,17 @@ private async Task RestoreDocumentsAsync( int targetSection = Math.Min(stored.SectionIndex, currentSectionCount - 1); var address = new DocumentAddress(stored.WindowIndex, targetSection, stored.TabOrder); - // Use TryParse rather than the throwing constructor: a persisted editor id may reference - // a package or contribution that has since been renamed or uninstalled, and an invalid - // value should fall back to the default editor instead of aborting the restore. - DocumentEditorId editorId; - if (string.IsNullOrEmpty(stored.DocumentEditorId)) - { - editorId = DocumentEditorId.Empty; - } - else if (!DocumentEditorId.TryParse(stored.DocumentEditorId, out editorId)) - { - _logger.LogWarning($"Stored document editor id '{stored.DocumentEditorId}' is invalid and will be ignored for resource '{fileResource}'"); - editorId = DocumentEditorId.Empty; - } - + // Editor selection is resolved from the sidecar (or the per-extension + // default), not from any persisted layout state. Passing Empty here + // lets the factory consult the live sidecar instead of pinning a + // possibly-stale id captured at the last shutdown. string? editorStateJson = null; editorStates?.TryGetValue(fileResource.ToString(), out editorStateJson); var restoreOptions = new OpenDocumentOptions( Address: address, Activate: false, - EditorId: editorId, + EditorId: DocumentEditorId.Empty, EditorStateJson: editorStateJson); var openResult = await DocumentsPanel.OpenDocument(fileResource, restoreOptions); diff --git a/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs b/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs index 18ad673a1..542b7617c 100644 --- a/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs +++ b/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs @@ -1,5 +1,7 @@ namespace Celbridge.Documents.Services; +internal readonly record struct ReloadHintEntry(ReloadHint Hint, DateTime ExpiresUtc); + /// /// Holds reload hints keyed by ResourceKey with a short TTL. Used by DocumentsService /// to bridge a command that wrote a file and the watcher-driven reload that follows. @@ -38,6 +40,4 @@ public ReloadHint Consume(ResourceKey fileResource) return entry.Hint; } - - private readonly record struct ReloadHintEntry(ReloadHint Hint, DateTime ExpiresUtc); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs index 71f2fb6f7..fd649890b 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/GetFileInfoCommand.cs @@ -70,14 +70,14 @@ public override async Task ExecuteAsync() // Surface the paired sidecar's key and current parse state when // the registry has recorded one for this file. Sidecars belong to // file resources only; folders don't have their own sidecars in v1. - string? sidecarKey = null; + ResourceKey? sidecarKey = null; CelFileStatus? sidecarStatus = null; var resourceResult = resourceRegistry.GetResource(Resource); if (resourceResult.IsSuccess && resourceResult.Value is IFileResource fileResource && fileResource.Sidecar is not null) { - sidecarKey = fileResource.Sidecar.Key.ToString(); + sidecarKey = fileResource.Sidecar.Key; sidecarStatus = fileResource.Sidecar.Status; } diff --git a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs index 122bf52da..38afd9b27 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/UnarchiveResourceCommand.cs @@ -69,7 +69,7 @@ private async Task ExecuteExtractAsync() // Path resolution is still needed for entry-name validation and the // zip-slip canonicalization check below; the operation-service writes - // themselves take ResourceKey arguments after cm-9c. + // themselves take ResourceKey arguments. var resolveDestinationResult = resourceRegistry.ResolveResourcePath(DestinationResource); if (resolveDestinationResult.IsFailure) { diff --git a/Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs b/Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs index d7fe0d601..52a6c8af6 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/RootPathResolver.cs @@ -14,12 +14,19 @@ public class RootPathResolver { private readonly string _rootName; private readonly string _backingLocation; + // Pre-computed once at construction so the GetResourceKey / ValidateAndResolve + // hot paths don't repeat Path.GetFullPath + TrimEnd on every call. + private readonly string _normalizedBackingWithSeparator; + private readonly string _normalizedBackingTrimmed; private readonly HashSet _verifiedFolders; public RootPathResolver(string rootName, string backingLocation) { _rootName = rootName; _backingLocation = backingLocation; + _normalizedBackingWithSeparator = NormalizeBackingLocation(backingLocation); + _normalizedBackingTrimmed = _normalizedBackingWithSeparator + .TrimEnd(Path.DirectorySeparatorChar); _verifiedFolders = new HashSet(GetPathComparer()); } @@ -45,19 +52,15 @@ public Result ValidateAndResolve(ResourceKey resource) var combinedPath = Path.Combine(_backingLocation, pathPortion); var resolvedPath = Path.GetFullPath(combinedPath); - var normalizedBackingLocation = NormalizeBackingLocation(_backingLocation); + var isBackingRoot = resolvedPath.Equals(_normalizedBackingTrimmed, GetPathComparison()); - var isBackingRoot = resolvedPath.Equals( - normalizedBackingLocation.TrimEnd(Path.DirectorySeparatorChar), - GetPathComparison()); - - if (!isBackingRoot && !resolvedPath.StartsWith(normalizedBackingLocation, GetPathComparison())) + if (!isBackingRoot && !resolvedPath.StartsWith(_normalizedBackingWithSeparator, GetPathComparison())) { return Result.Fail( $"Resource key '{resource}' resolves to a path outside the '{_rootName}' root."); } - var reparseResult = CheckForReparsePoints(resolvedPath, normalizedBackingLocation); + var reparseResult = CheckForReparsePoints(resolvedPath, _normalizedBackingWithSeparator); if (reparseResult.IsFailure) { return Result.Fail(reparseResult.FirstErrorMessage); @@ -76,17 +79,15 @@ public Result GetResourceKey(string absolutePath) try { var normalizedPath = Path.GetFullPath(absolutePath); - var normalizedBacking = Path.GetFullPath(_backingLocation) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // No symlink check here: ValidateAndResolve enforces it at the I/O // boundary, and replaying it on every label call would dominate the // watcher / enumerate hot path. var comparison = GetPathComparison(); - bool isBackingRoot = normalizedPath.Equals(normalizedBacking, comparison); + bool isBackingRoot = normalizedPath.Equals(_normalizedBackingTrimmed, comparison); bool isUnderBacking = normalizedPath.StartsWith( - normalizedBacking + Path.DirectorySeparatorChar, comparison); + _normalizedBackingWithSeparator, comparison); if (!isBackingRoot && !isUnderBacking) { @@ -97,7 +98,7 @@ public Result GetResourceKey(string absolutePath) var relativePart = isBackingRoot ? string.Empty : normalizedPath - .Substring(normalizedBacking.Length) + .Substring(_normalizedBackingTrimmed.Length) .Replace('\\', '/') .Trim('/'); diff --git a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs index b415ffdb7..4864bcb94 100644 --- a/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs +++ b/Source/Workspace/Celbridge.Resources/Helpers/SidecarHelper.cs @@ -52,6 +52,34 @@ public static bool IsValidBlockName(string name) && BlockNameRegex.IsMatch(name); } + /// + /// True when the block content contains a line that would parse as a fence + /// (e.g. '+++ "evil"'). Block content with a fence line round-trips to a + /// file whose Parse splits it incorrectly, so writers must reject this up + /// front. There is no escape mechanism. + /// + public static bool BlockContentContainsFenceLine(string content) + { + if (string.IsNullOrEmpty(content)) + { + return false; + } + + var lines = content.Split('\n'); + foreach (var rawLine in lines) + { + var line = rawLine.EndsWith('\r') + ? rawLine.Substring(0, rawLine.Length - 1) + : rawLine; + if (FenceLineRegex.IsMatch(line)) + { + return true; + } + } + + return false; + } + /// /// True when the value can be written through the structured frontmatter /// surface: scalars (string, numeric, bool, datetime) and lists of those. @@ -251,6 +279,10 @@ public static string Compose( { throw new ArgumentException($"Block name '{block.Name}' does not match the block-naming rules."); } + if (BlockContentContainsFenceLine(block.Content)) + { + throw new ArgumentException($"Block '{block.Name}' content contains a line matching the fence regex; this would corrupt the file on round-trip."); + } // Each fence line starts on its own line. If we already wrote // frontmatter or a prior block, ensure a newline before this fence. diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 061b6815a..a6fc20098 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -8,13 +8,6 @@ namespace Celbridge.Resources.Services; public sealed class FileStorage : IFileStorage { - // Bounded retry for transient IO failures (file briefly locked by AV, - // backup software, sync clients, concurrent writers, etc.). Total - // worst-case wait across all attempts is BaseRetryDelayMs * (1 + 2 + ... - // + (MaxAttempts - 1)) = 150ms with the values below. - private const int MaxAttempts = 3; - private const int BaseRetryDelayMs = 50; - // Buffer size used when opening file streams. Matches the default System.IO // FileStream buffer size when none is supplied. private const int StreamBufferSize = 4096; @@ -22,6 +15,8 @@ public sealed class FileStorage : IFileStorage private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly SidecarCascade _sidecarCascade; + private readonly ReferenceRewriter _referenceRewriter; // The resource registry is workspace-scoped and transient: a constructor- // injected instance is a different object from the one held by ResourceService, @@ -36,6 +31,8 @@ public FileStorage( _logger = logger; _messengerService = messengerService; _workspaceWrapper = workspaceWrapper; + _sidecarCascade = new SidecarCascade(logger, workspaceWrapper); + _referenceRewriter = new ReferenceRewriter(logger, workspaceWrapper, this); } public async Task> ReadAllBytesAsync(ResourceKey resource) @@ -43,14 +40,15 @@ public async Task> ReadAllBytesAsync(ResourceKey resource) var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); } var resourcePath = resolveResult.Value; - return await RunWithRetryAsync( + return await FileStorageInternals.RunWithRetryAsync( + _logger, operationLabel: "Read", - resource: resource, + resourceLabel: resource, resourcePath: resourcePath, operation: () => File.ReadAllBytesAsync(resourcePath), shouldRetry: IsTransientReadIOException); @@ -61,14 +59,15 @@ public async Task> ReadAllTextAsync(ResourceKey resource) var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); } var resourcePath = resolveResult.Value; - return await RunWithRetryAsync( + return await FileStorageInternals.RunWithRetryAsync( + _logger, operationLabel: "Read", - resource: resource, + resourceLabel: resource, resourcePath: resourcePath, operation: () => File.ReadAllTextAsync(resourcePath), shouldRetry: IsTransientReadIOException); @@ -79,14 +78,15 @@ public async Task> OpenReadAsync(ResourceKey resource) var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); } var resourcePath = resolveResult.Value; - return await RunWithRetryAsync( + return await FileStorageInternals.RunWithRetryAsync( + _logger, operationLabel: "Read", - resource: resource, + resourceLabel: resource, resourcePath: resourcePath, operation: () => Task.FromResult(new FileStream( resourcePath, @@ -125,7 +125,7 @@ public async Task> OpenWriteAsync(ResourceKey resource) var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") + var failure = Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); return failure; } @@ -143,6 +143,9 @@ public async Task> OpenWriteAsync(ResourceKey resource) // stream is open no other process can read partial bytes. The // trade-off is that another reader hitting the file mid-write sees // a sharing-violation IOException, not stale-or-partial content. + // Unlike the buffered-bytes write paths, this stream writes directly + // to the destination: a crash or unhandled exception before the + // stream is fully written and disposed truncates the file in place. var stream = new FileStream( resourcePath, FileMode.Create, @@ -154,17 +157,17 @@ public async Task> OpenWriteAsync(ResourceKey resource) } catch (Exception ex) { - var failure = Result.Fail($"Failed to open write stream for resource: '{resource}'") + var failure = Result.Fail($"Failed to open write stream for resource: '{resource}'") .WithException(ex); return failure; } } - public async Task> MoveAsync(ResourceKey source, ResourceKey destination) + public async Task> MoveAsync(ResourceKey source, ResourceKey dest) { - if (source.Root != destination.Root) + if (source.Root != dest.Root) { - return Result.Fail($"Cross-root move not supported: '{source}' to '{destination}'"); + return Result.Fail($"Cross-root move not supported: '{source}' to '{dest}'"); } var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -172,15 +175,15 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey var resolveSourceResult = registry.ResolveResourcePath(source); if (resolveSourceResult.IsFailure) { - return Result.Fail($"Failed to resolve path for source resource: '{source}'") + return Result.Fail($"Failed to resolve path for source resource: '{source}'") .WithErrors(resolveSourceResult); } var sourcePath = resolveSourceResult.Value; - var resolveDestResult = registry.ResolveResourcePath(destination); + var resolveDestResult = registry.ResolveResourcePath(dest); if (resolveDestResult.IsFailure) { - return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") + return Result.Fail($"Failed to resolve path for destination resource: '{dest}'") .WithErrors(resolveDestResult); } var destPath = resolveDestResult.Value; @@ -190,20 +193,20 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey if (!sourceIsFile && !sourceIsFolder) { - return Result.Fail($"Source resource does not exist: '{source}'"); + return Result.Fail($"Source resource does not exist: '{source}'"); } var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; - if (!IsRootWritable(rootHandlerRegistry, destination)) + if (!IsRootWritable(rootHandlerRegistry, dest)) { - return Result.Fail($"Root '{destination.Root}' is read-only."); + return Result.Fail($"Root '{dest.Root}' is read-only."); } bool isSameLocation = string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase); if (!isSameLocation && (File.Exists(destPath) || Directory.Exists(destPath))) { - return Result.Fail($"Destination already exists: '{destination}'"); + return Result.Fail($"Destination already exists: '{dest}'"); } var updatedReferencers = new List(); @@ -211,7 +214,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey if (source.Root == ResourceKey.DefaultRoot) { - var rewriteResult = await RewriteReferencesForMoveAsync(source, destination, sourceIsFolder, updatedReferencers, skippedReferencers); + var rewriteResult = await _referenceRewriter.RewriteForMoveAsync(source, dest, sourceIsFolder, updatedReferencers, skippedReferencers); if (rewriteResult.IsFailure) { return Result.Fail(rewriteResult); @@ -240,26 +243,26 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey // Clear read-only so the move itself is not blocked by an // attribute the user has explicitly chosen to override by // invoking a move on the file. - ClearReadOnlyIfSet(sourcePath); - await RetryTransientIOAsync(() => File.Move(sourcePath, destPath)); + FileStorageInternals.ClearReadOnlyIfSet(sourcePath); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Move", sourcePath, () => File.Move(sourcePath, destPath)); } else { - await RetryTransientIOAsync(() => Directory.Move(sourcePath, destPath)); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Move", sourcePath, () => Directory.Move(sourcePath, destPath)); } } catch (UnauthorizedAccessException ex) { - return Result.Fail($"Failed to move resource '{source}' to '{destination}': access denied (permissions or file in use).") + return Result.Fail($"Failed to move resource '{source}' to '{dest}': access denied (permissions or file in use).") .WithException(ex); } catch (Exception ex) { - return Result.Fail($"Failed to move resource: '{source}' to '{destination}'") + return Result.Fail($"Failed to move resource: '{source}' to '{dest}'") .WithException(ex); } - var sidecarOutcome = await TryCascadeSidecarMoveAsync(source, destination); + var sidecarOutcome = await _sidecarCascade.TryMoveAsync(source, dest); if (source.Root == ResourceKey.DefaultRoot) { @@ -270,7 +273,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey var sourceRemovedMessage = new ResourceDeletedMessage(source); _messengerService.Send(sourceRemovedMessage); - var keyChangedMessage = new ResourceKeyChangedMessage(source, destination); + var keyChangedMessage = new ResourceKeyChangedMessage(source, dest); _messengerService.Send(keyChangedMessage); foreach (var descendantSource in sourceDescendantKeys) @@ -278,7 +281,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey var descendantRemovedMessage = new ResourceDeletedMessage(descendantSource); _messengerService.Send(descendantRemovedMessage); - if (TryMapDescendantKey(source, destination, descendantSource, out var descendantDestination)) + if (TryMapDescendantKey(source, dest, descendantSource, out var descendantDestination)) { var descendantKeyChangedMessage = new ResourceKeyChangedMessage(descendantSource, descendantDestination); _messengerService.Send(descendantKeyChangedMessage); @@ -296,7 +299,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey // destination folder. private static bool TryMapDescendantKey( ResourceKey sourceFolder, - ResourceKey destinationFolder, + ResourceKey destFolder, ResourceKey descendantSource, out ResourceKey descendantDestination) { @@ -309,30 +312,30 @@ private static bool TryMapDescendantKey( } var relativeSuffix = descendantPath.Substring(sourcePath.Length); - var destinationPath = destinationFolder.Path + relativeSuffix; - var rootName = destinationFolder.Root; + var destPath = destFolder.Path + relativeSuffix; + var rootName = destFolder.Root; var fullKey = rootName == ResourceKey.DefaultRoot - ? destinationPath - : $"{rootName}:{destinationPath}"; + ? destPath + : $"{rootName}:{destPath}"; return ResourceKey.TryCreate(fullKey, out descendantDestination); } - public async Task> CopyAsync(ResourceKey source, ResourceKey destination) + public async Task> CopyAsync(ResourceKey source, ResourceKey dest) { var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; var resolveSourceResult = registry.ResolveResourcePath(source); if (resolveSourceResult.IsFailure) { - return Result.Fail($"Failed to resolve path for source resource: '{source}'") + return Result.Fail($"Failed to resolve path for source resource: '{source}'") .WithErrors(resolveSourceResult); } var sourcePath = resolveSourceResult.Value; - var resolveDestResult = registry.ResolveResourcePath(destination); + var resolveDestResult = registry.ResolveResourcePath(dest); if (resolveDestResult.IsFailure) { - return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") + return Result.Fail($"Failed to resolve path for destination resource: '{dest}'") .WithErrors(resolveDestResult); } var destPath = resolveDestResult.Value; @@ -342,19 +345,19 @@ public async Task> CopyAsync(ResourceKey source, ResourceKey if (!sourceIsFile && !sourceIsFolder) { - return Result.Fail($"Source resource does not exist: '{source}'"); + return Result.Fail($"Source resource does not exist: '{source}'"); } var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; - if (!IsRootWritable(rootHandlerRegistry, destination)) + if (!IsRootWritable(rootHandlerRegistry, dest)) { - return Result.Fail($"Root '{destination.Root}' is read-only."); + return Result.Fail($"Root '{dest.Root}' is read-only."); } if (File.Exists(destPath) || Directory.Exists(destPath)) { - return Result.Fail($"Destination already exists: '{destination}'"); + return Result.Fail($"Destination already exists: '{dest}'"); } try @@ -368,22 +371,20 @@ public async Task> CopyAsync(ResourceKey source, ResourceKey if (sourceIsFile) { - File.Copy(sourcePath, destPath); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Copy", sourcePath, () => File.Copy(sourcePath, destPath)); } else { - CopyFolderRecursive(sourcePath, destPath); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Copy", sourcePath, () => CopyFolderRecursive(sourcePath, destPath)); } } catch (Exception ex) { - return Result.Fail($"Failed to copy resource: '{source}' to '{destination}'") + return Result.Fail($"Failed to copy resource: '{source}' to '{dest}'") .WithException(ex); } - var sidecarOutcome = TryCascadeSidecarCopy(source, destination); - - await Task.CompletedTask; + var sidecarOutcome = _sidecarCascade.TryCopy(source, dest); var copyResult = new CopyResult(sidecarOutcome); return copyResult; @@ -396,7 +397,7 @@ public async Task> DeleteAsync(ResourceKey source) var resolveResult = registry.ResolveResourcePath(source); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{source}'") + return Result.Fail($"Failed to resolve path for resource: '{source}'") .WithErrors(resolveResult); } var sourcePath = resolveResult.Value; @@ -406,16 +407,16 @@ public async Task> DeleteAsync(ResourceKey source) if (!sourceIsFile && !sourceIsFolder) { - return Result.Fail($"Resource does not exist: '{source}'"); + return Result.Fail($"Resource does not exist: '{source}'"); } var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; if (!IsRootWritable(rootHandlerRegistry, source)) { - return Result.Fail($"Root '{source.Root}' is read-only."); + return Result.Fail($"Root '{source.Root}' is read-only."); } - var sidecarOutcome = TryCascadeSidecarDelete(source); + var sidecarOutcome = _sidecarCascade.TryDelete(source); // Capture descendant keys (folders only) before the disk delete so the // post-delete eager-notify can drop their stale index entries too. @@ -430,25 +431,25 @@ public async Task> DeleteAsync(ResourceKey source) // Clear read-only so File.Delete doesn't trip on the attribute. // Matches OS Explorer's "delete read-only file?" behaviour // (proceed when the user explicitly invokes delete). - ClearReadOnlyIfSet(sourcePath); - await RetryTransientIOAsync(() => File.Delete(sourcePath)); + FileStorageInternals.ClearReadOnlyIfSet(sourcePath); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Delete", sourcePath, () => File.Delete(sourcePath)); } else { // Recursive delete fails on any contained read-only file, so // strip the attribute throughout the subtree first. - ClearReadOnlyRecursive(sourcePath); - await RetryTransientIOAsync(() => Directory.Delete(sourcePath, recursive: true)); + FileStorageInternals.ClearReadOnlyRecursive(sourcePath); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Delete", sourcePath, () => Directory.Delete(sourcePath, recursive: true)); } } catch (UnauthorizedAccessException ex) { - return Result.Fail($"Failed to delete resource '{source}': access denied (permissions or file in use).") + return Result.Fail($"Failed to delete resource '{source}': access denied (permissions or file in use).") .WithException(ex); } catch (Exception ex) { - return Result.Fail($"Failed to delete resource: '{source}'") + return Result.Fail($"Failed to delete resource: '{source}'") .WithException(ex); } @@ -542,7 +543,7 @@ public async Task> GetInfoAsync(ResourceKey resource) var resolveResult = ResolvePath(resource); if (resolveResult.IsFailure) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'") + return Result.Fail($"Failed to resolve path for resource: '{resource}'") .WithErrors(resolveResult); } var resourcePath = resolveResult.Value; @@ -580,7 +581,7 @@ public async Task> GetInfoAsync(ResourceKey resource) } catch (Exception ex) { - return Result.Fail($"Failed to get info for resource: '{resource}'") + return Result.Fail($"Failed to get info for resource: '{resource}'") .WithException(ex); } } @@ -592,14 +593,14 @@ public async Task>> EnumerateFolderAsync(Resour var resolveResult = ResolvePath(folder); if (resolveResult.IsFailure) { - return Result>.Fail($"Failed to resolve path for resource: '{folder}'") + return Result.Fail($"Failed to resolve path for resource: '{folder}'") .WithErrors(resolveResult); } var folderPath = resolveResult.Value; if (!Directory.Exists(folderPath)) { - return Result>.Fail($"Resource is not a folder: '{folder}'"); + return Result.Fail($"Resource is not a folder: '{folder}'"); } try @@ -622,11 +623,11 @@ public async Task>> EnumerateFolderAsync(Resour ModifiedUtc: info.LastWriteTimeUtc)); } - return Result>.Ok(entries); + return entries; } catch (Exception ex) { - return Result>.Fail($"Failed to enumerate folder: '{folder}'") + return Result.Fail($"Failed to enumerate folder: '{folder}'") .WithException(ex); } } @@ -636,7 +637,7 @@ public async Task> ComputeHashAsync(ResourceKey resource) var readResult = await ReadAllBytesAsync(resource); if (readResult.IsFailure) { - return Result.Fail($"Failed to compute hash for resource: '{resource}'") + return Result.Fail($"Failed to compute hash for resource: '{resource}'") .WithErrors(readResult); } @@ -657,437 +658,6 @@ private static bool IsRootWritable(IRootHandlerRegistry rootHandlerRegistry, Res || handler.Capabilities.IsWritable; } - // Re-writes every "project:" literal in every referencer of source - // (and, for folders, every "project:/" literal). The rewrite is - // performed via this layer's own ReadAllTextAsync / WriteAllTextAsync so the - // atomic-write semantics apply to each touched file. On any failure the - // operation aborts; previously-rewritten files are left at their new state - // and the source bytes are still in place, so a re-run completes the work. - private async Task RewriteReferencesForMoveAsync( - ResourceKey source, - ResourceKey destination, - bool sourceIsFolder, - List updatedReferencers, - List skippedReferencers) - { - var scanner = _workspaceWrapper.WorkspaceService.ResourceScanner; - - var referencerSet = new HashSet(); - foreach (var referencer in await scanner.FindReferencersAsync(source)) - { - referencerSet.Add(referencer); - } - - if (sourceIsFolder) - { - // Children of source contribute prefix-form references; gather every - // referencer of every descendant target so the prefix rewrite reaches - // each file that names a child key. - foreach (var target in await scanner.FindAllReferencedTargetsAsync()) - { - if (target.IsDescendantOf(source)) - { - foreach (var referencer in await scanner.FindReferencersAsync(target)) - { - referencerSet.Add(referencer); - } - } - } - } - - var sourceLiteral = source.FullKey; - var destLiteral = destination.FullKey; - - var orderedReferencers = referencerSet - .OrderBy(r => r.ToString(), StringComparer.Ordinal) - .ToList(); - - // Per-referencer failures (typically file locked by an external editor - // for a moment, or marked read-only by the user) are logged and skipped - // rather than aborting the whole move. The parent move still completes; - // data_check_project surfaces any references that remained stale, and a - // subsequent rerun of the rename picks up the residual rewrites because - // the FS layer is idempotent under partial completion (the source bytes - // are still in place between the rewrite loop and the parent move, and - // the next scanner call re-derives the referencer set). - foreach (var referencer in orderedReferencers) - { - var readResult = await ReadAllTextAsync(referencer); - if (readResult.IsFailure) - { - var message = $"read failed for '{referencer}'"; - _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {message}. The reference is left as-is and will surface via data_check_project."); - skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadFailed, message)); - continue; - } - var originalText = readResult.Value; - - var rewritten = RewriteReferenceLiterals(originalText, sourceLiteral, destLiteral, sourceIsFolder); - if (rewritten == originalText) - { - continue; - } - - // Honor the DOS read-only attribute as a "do not modify" hint - // BEFORE attempting the write. The atomic temp+rename path would - // surface this as a write failure on Windows (MoveFileEx checks - // the target's read-only bit) but silently succeed on Linux - // (rename only checks write permission on the parent directory, - // not on the target). Pre-checking closes that cross-platform - // gap so the user's "don't touch this file" intent is honored - // identically on every platform. - if (IsReferencerReadOnly(referencer)) - { - const string readOnlyMessage = "file is read-only"; - _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {readOnlyMessage}. The reference is left as-is and will surface via data_check_project."); - skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadOnly, readOnlyMessage)); - continue; - } - - var writeResult = await WriteAllTextAsync(referencer, rewritten); - if (writeResult.IsFailure) - { - // The referencer cascade does not override user-set read-only - // or ACL permissions: the user invoked a move on `source`, not - // on this incidental referencer. Skip with a clear message so - // the user (or the calling agent) knows exactly why and can - // decide whether to fix the permissions and rerun the rename. - var classification = ClassifyReferencerWriteFailure(referencer, writeResult); - _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{destination}': {classification.Message}. The reference is left as-is and will surface via data_check_project."); - skippedReferencers.Add(new SkippedReferencer(referencer, classification.Reason, classification.Message)); - continue; - } - - updatedReferencers.Add(referencer); - } - - return Result.Ok(); - } - - private bool IsReferencerReadOnly(ResourceKey referencer) - { - var resolveResult = ResolvePath(referencer); - if (resolveResult.IsFailure) - { - return false; - } - - try - { - var info = new FileInfo(resolveResult.Value); - return info.Exists - && info.IsReadOnly; - } - catch - { - return false; - } - } - - private (ReferencerSkipReason Reason, string Message) ClassifyReferencerWriteFailure(ResourceKey referencer, Result writeResult) - { - var resolveResult = ResolvePath(referencer); - if (resolveResult.IsFailure) - { - return (ReferencerSkipReason.WriteFailed, "write failed (could not resolve path)"); - } - - // Check the DOS read-only attribute first. We split it from the ACL / - // POSIX denial case because the fixes are different: read-only is - // trivially clearable ("uncheck the read-only flag"), whereas an ACL - // deny typically needs the right user account or admin rights. Agents - // that want to auto-clear read-only-and-retry can switch on ReadOnly; - // agents that want a coarse "permissions thing" check can match both. - try - { - var info = new FileInfo(resolveResult.Value); - if (info.Exists - && info.IsReadOnly) - { - return (ReferencerSkipReason.ReadOnly, "file is read-only"); - } - } - catch - { - // Fall through to the exception-based classification. - } - - // UnauthorizedAccessException from the underlying File.Move (after the - // atomic temp-write) typically means an ACL deny on Windows or a POSIX - // permission failure on Unix. - if (writeResult.FirstException is UnauthorizedAccessException) - { - return (ReferencerSkipReason.PermissionDenied, "permission denied (no write access to file)"); - } - - // Catch-all for any other write failure: actual file locks, disk full, - // quota exceeded, network share gone, antivirus interference. The - // hedged message tells the user where to look without overcommitting - // to a specific cause we can't detect. - return (ReferencerSkipReason.WriteFailed, "write failed (file may be locked or another IO issue)"); - } - - // Replaces every quoted occurrence of sourceLiteral with destLiteral. The - // boundary check (ResourceReferenceParser.IsNonKeyBoundary on the bytes - // immediately before and after the match) keeps incidental substring - // matches untouched — only the canonical quoted form gets rewritten. - // - // Both sides of the match must have a real boundary character; matches at - // position 0 or at end-of-text are not eligible because under the - // always-quoted contract every tracked reference is wrapped in a quote - // (or its \" / \' escape) on both sides. - // - // Folder cascade: the trailing-boundary check also accepts '/' so a folder - // rename rewrites descendant references via prefix substitution — - // "project:/" becomes "project:/" because - // sourceLiteral matched only the "" prefix. - private static string RewriteReferenceLiterals(string text, string sourceLiteral, string destLiteral, bool sourceIsFolder) - { - if (string.IsNullOrEmpty(text)) - { - return text; - } - - var builder = new StringBuilder(text.Length); - int cursor = 0; - - while (cursor < text.Length) - { - int matchIndex = text.IndexOf(sourceLiteral, cursor, StringComparison.Ordinal); - if (matchIndex < 0) - { - builder.Append(text, cursor, text.Length - cursor); - break; - } - - builder.Append(text, cursor, matchIndex - cursor); - - int afterMatch = matchIndex + sourceLiteral.Length; - - bool leadingOk = matchIndex > 0 - && ResourceReferenceParser.IsNonKeyBoundary(text[matchIndex - 1]); - bool trailingExact = afterMatch < text.Length - && ResourceReferenceParser.IsNonKeyBoundary(text[afterMatch]); - bool trailingFolderPrefix = sourceIsFolder - && afterMatch < text.Length - && text[afterMatch] == '/'; - - if (leadingOk - && (trailingExact || trailingFolderPrefix)) - { - builder.Append(destLiteral); - cursor = afterMatch; - } - else - { - // Boundary check failed. Preserve the matched byte and advance - // by one so the next scan can find an overlapping occurrence. - builder.Append(text[matchIndex]); - cursor = matchIndex + 1; - } - } - - return builder.ToString(); - } - - private async Task TryCascadeSidecarMoveAsync(ResourceKey source, ResourceKey destination) - { - var sourceSidecar = AppendSidecarSuffix(source); - var destSidecar = AppendSidecarSuffix(destination); - if (sourceSidecar is null - || destSidecar is null) - { - return SidecarOutcome.NotPresent; - } - - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveSourceResult = registry.ResolveResourcePath(sourceSidecar.Value); - if (resolveSourceResult.IsFailure) - { - return SidecarOutcome.NotPresent; - } - var sourceSidecarPath = resolveSourceResult.Value; - if (!File.Exists(sourceSidecarPath)) - { - return SidecarOutcome.NotPresent; - } - - var resolveDestResult = registry.ResolveResourcePath(destSidecar.Value); - if (resolveDestResult.IsFailure) - { - _logger.LogWarning($"Failed to resolve sidecar destination '{destSidecar}' for move from '{source}'. Sidecar bytes remain at the source path."); - return SidecarOutcome.Failed; - } - var destSidecarPath = resolveDestResult.Value; - - if (File.Exists(destSidecarPath)) - { - _logger.LogWarning($"Sidecar destination '{destSidecar}' already exists. Parent move completed but sidecar was not cascaded."); - return SidecarOutcome.Failed; - } - - try - { - var destFolder = Path.GetDirectoryName(destSidecarPath); - if (!string.IsNullOrEmpty(destFolder) - && !Directory.Exists(destFolder)) - { - Directory.CreateDirectory(destFolder); - } - - await RetryTransientIOAsync(() => File.Move(sourceSidecarPath, destSidecarPath)); - return SidecarOutcome.Cascaded; - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Failed to cascade sidecar move from '{sourceSidecar}' to '{destSidecar}'."); - return SidecarOutcome.Failed; - } - } - - private SidecarOutcome TryCascadeSidecarCopy(ResourceKey source, ResourceKey destination) - { - var sourceSidecar = AppendSidecarSuffix(source); - var destSidecar = AppendSidecarSuffix(destination); - if (sourceSidecar is null - || destSidecar is null) - { - return SidecarOutcome.NotPresent; - } - - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveSourceResult = registry.ResolveResourcePath(sourceSidecar.Value); - if (resolveSourceResult.IsFailure) - { - return SidecarOutcome.NotPresent; - } - var sourceSidecarPath = resolveSourceResult.Value; - if (!File.Exists(sourceSidecarPath)) - { - return SidecarOutcome.NotPresent; - } - - var resolveDestResult = registry.ResolveResourcePath(destSidecar.Value); - if (resolveDestResult.IsFailure) - { - _logger.LogWarning($"Failed to resolve sidecar destination '{destSidecar}' for copy from '{source}'."); - return SidecarOutcome.Failed; - } - var destSidecarPath = resolveDestResult.Value; - - if (File.Exists(destSidecarPath)) - { - _logger.LogWarning($"Sidecar destination '{destSidecar}' already exists. Parent copy completed but sidecar was not cascaded."); - return SidecarOutcome.Failed; - } - - try - { - var destFolder = Path.GetDirectoryName(destSidecarPath); - if (!string.IsNullOrEmpty(destFolder) - && !Directory.Exists(destFolder)) - { - Directory.CreateDirectory(destFolder); - } - - File.Copy(sourceSidecarPath, destSidecarPath); - return SidecarOutcome.Cascaded; - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Failed to cascade sidecar copy from '{sourceSidecar}' to '{destSidecar}'."); - return SidecarOutcome.Failed; - } - } - - private SidecarOutcome TryCascadeSidecarDelete(ResourceKey source) - { - var sourceSidecar = AppendSidecarSuffix(source); - if (sourceSidecar is null) - { - return SidecarOutcome.NotPresent; - } - - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - - var resolveResult = registry.ResolveResourcePath(sourceSidecar.Value); - if (resolveResult.IsFailure) - { - return SidecarOutcome.NotPresent; - } - var sidecarPath = resolveResult.Value; - if (!File.Exists(sidecarPath)) - { - return SidecarOutcome.NotPresent; - } - - try - { - File.Delete(sidecarPath); - return SidecarOutcome.Cascaded; - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Failed to cascade sidecar delete for '{sourceSidecar}'."); - return SidecarOutcome.Failed; - } - } - - // Returns the sidecar resource key for the given parent, or null when no - // valid sidecar key can be derived (root-only key, or the parent itself - // is already a sidecar key — in which case there is nothing to cascade). - private ResourceKey? AppendSidecarSuffix(ResourceKey key) - { - var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; - var result = sidecarService.GetSidecarKey(key); - if (result.IsSuccess) - { - return result.Value; - } - return null; - } - - // Clears the read-only attribute from a file before the FS layer performs - // a move or delete. User intent to move or delete a file overrides the - // read-only marker the same way Windows Explorer's "delete" prompt does. - // Best-effort: any IO failure surfaces when the subsequent move/delete - // itself fails; we don't pre-flight check. - private static void ClearReadOnlyIfSet(string path) - { - try - { - var info = new FileInfo(path); - if (info.Exists - && info.IsReadOnly) - { - info.IsReadOnly = false; - } - } - catch - { - // Best effort; surface the underlying issue from the caller's operation. - } - } - - // Recursive read-only clear for folder delete. Directory.Delete(recursive: true) - // fails if any contained file is read-only, so traverse first. - private static void ClearReadOnlyRecursive(string folder) - { - try - { - foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) - { - ClearReadOnlyIfSet(file); - } - } - catch - { - // Best effort. - } - } - // Recursive folder copy. Mirrors ResourceUtils.CopyFolder but stays internal // to the FS layer so the chokepoint owns the destination structure. private static void CopyFolderRecursive(string sourceFolder, string destFolder) @@ -1109,58 +679,6 @@ private static void CopyFolderRecursive(string sourceFolder, string destFolder) } } - // Runs an IO operation under the chokepoint's bounded-retry policy. A file - // briefly held open by an external editor, antivirus, or backup product - // clears within milliseconds, so 3 attempts at 50/100/150ms backoff catches - // the common cases without imposing meaningful latency on the typical-case - // success. shouldRetry decides whether a particular IOException is worth - // retrying; read paths exclude FileNotFoundException and - // DirectoryNotFoundException because the file is genuinely missing. - // UnauthorizedAccessException is never retried — for reads and writes it - // almost always means a permission issue (e.g. an ACL the user can't get - // past), not a transient lock. - private async Task> RunWithRetryAsync( - string operationLabel, - ResourceKey resource, - string resourcePath, - Func> operation, - Func? shouldRetry = null) - where T : notnull - { - IOException? lastException = null; - - for (var attempt = 1; attempt <= MaxAttempts; attempt++) - { - try - { - var value = await operation(); - if (attempt > 1) - { - _logger.LogWarning($"{operationLabel} succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); - } - return Result.Ok(value); - } - catch (IOException ex) when (shouldRetry?.Invoke(ex) ?? true) - { - lastException = ex; - if (attempt < MaxAttempts) - { - var delay = BaseRetryDelayMs * attempt; - _logger.LogWarning(ex, $"{operationLabel} attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); - await Task.Delay(delay); - } - } - catch (Exception ex) - { - return Result.Fail($"Failed to {operationLabel.ToLowerInvariant()} file: '{resource}'") - .WithException(ex); - } - } - - return Result.Fail($"Failed to {operationLabel.ToLowerInvariant()} file after {MaxAttempts} attempts: '{resource}'") - .WithException(lastException!); - } - private async Task WriteWithRetryAsync(ResourceKey resource, byte[] bytes) { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -1199,13 +717,14 @@ private async Task WriteWithRetryAsync(ResourceKey resource, byte[] byte .WithException(ex); } - var runResult = await RunWithRetryAsync( + var runResult = await FileStorageInternals.RunWithRetryAsync( + _logger, operationLabel: "Write", - resource: resource, + resourceLabel: resource, resourcePath: resourcePath, operation: async () => { - await WriteAtomicAsync(resourcePath, stagingFolder, bytes); + await WriteAtomicAsync(_logger, resourcePath, stagingFolder, bytes); return true; }); @@ -1233,39 +752,18 @@ private static Result EnsureParentFolderExists(string resourcePath, ResourceKey } } - // Runs a synchronous filesystem action under the chokepoint's bounded-retry - // policy. A file briefly held open by AV, indexer, or sync clients after - // creation clears within milliseconds; the same retry budget the read and - // write paths use catches the common races. Non-IO exceptions and the final - // attempt's IOException propagate unchanged so persistent failures surface. - private static async Task RetryTransientIOAsync(Action action) - { - for (var attempt = 1; attempt <= MaxAttempts; attempt++) - { - try - { - action(); - return; - } - catch (IOException) when (attempt < MaxAttempts) - { - await Task.Delay(BaseRetryDelayMs * attempt); - } - } - } - // Writes bytes to a uniquely-named temp file inside the project's central // staging folder, then atomically replaces the destination via File.Move. // A unique filename per write prevents concurrent writers to the same // destination from clobbering each other's intermediate state. - private static async Task WriteAtomicAsync(string resourcePath, string stagingFolder, byte[] bytes) + private static async Task WriteAtomicAsync(ILogger logger, string resourcePath, string stagingFolder, byte[] bytes) { var tempPath = Path.Combine(stagingFolder, Guid.NewGuid().ToString("N") + ".tmp"); try { await File.WriteAllBytesAsync(tempPath, bytes); - await RetryTransientIOAsync(() => File.Move(tempPath, resourcePath, overwrite: true)); + await FileStorageInternals.RetryTransientIOAsync(logger, "Atomic rename", resourcePath, () => File.Move(tempPath, resourcePath, overwrite: true)); } catch { diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorageInternals.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorageInternals.cs new file mode 100644 index 000000000..44ac6323e --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorageInternals.cs @@ -0,0 +1,127 @@ +using Celbridge.Logging; + +namespace Celbridge.Resources.Services; + +/// +/// Helpers shared by FileStorage and TrashService for the OS-level transient +/// failure modes both layers hit (sharing violations from AV / indexer / sync +/// clients, DOS read-only attribute). +/// +internal static class FileStorageInternals +{ + // 3 attempts at 50/100/150ms backoff catches the common short locks. + public const int MaxAttempts = 3; + public const int BaseRetryDelayMs = 50; + + /// + /// Runs an async IO operation under the bounded-retry policy. shouldRetry + /// filters which IOExceptions to retry (defaults to all); non-IO exceptions + /// propagate immediately. + /// + public static async Task> RunWithRetryAsync( + ILogger logger, + string operationLabel, + string resourceLabel, + string resourcePath, + Func> operation, + Func? shouldRetry = null) + where T : notnull + { + IOException? lastException = null; + + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + var value = await operation(); + if (attempt > 1) + { + logger.LogWarning($"{operationLabel} succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); + } + return value; + } + catch (IOException ex) when (shouldRetry?.Invoke(ex) ?? true) + { + lastException = ex; + if (attempt < MaxAttempts) + { + var delay = BaseRetryDelayMs * attempt; + logger.LogWarning(ex, $"{operationLabel} attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); + await Task.Delay(delay); + } + } + catch (Exception ex) + { + return Result.Fail($"Failed to {operationLabel.ToLowerInvariant()} file: '{resourceLabel}'") + .WithException(ex); + } + } + + return Result.Fail($"Failed to {operationLabel.ToLowerInvariant()} file after {MaxAttempts} attempts: '{resourceLabel}'") + .WithException(lastException!); + } + + /// + /// Sync counterpart of RunWithRetryAsync. Used by the trash service's + /// move-to-trash path and the chokepoint's atomic-rename step. + /// + public static async Task RetryTransientIOAsync(ILogger logger, string operationLabel, string resourcePath, Action action) + { + for (var attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + action(); + if (attempt > 1) + { + logger.LogWarning($"{operationLabel} succeeded for '{resourcePath}' on attempt {attempt} of {MaxAttempts} after transient IO failures"); + } + return; + } + catch (IOException ex) when (attempt < MaxAttempts) + { + var delay = BaseRetryDelayMs * attempt; + logger.LogWarning(ex, $"{operationLabel} attempt {attempt} failed for '{resourcePath}', retrying after {delay}ms"); + await Task.Delay(delay); + } + } + } + + /// + /// Clears the DOS read-only attribute before a move or delete. The user's + /// invocation of the operation overrides the attribute, matching OS + /// Explorer's "delete read-only file?" behaviour. Best-effort. + /// + public static void ClearReadOnlyIfSet(string path) + { + try + { + var info = new FileInfo(path); + if (info.Exists + && info.IsReadOnly) + { + info.IsReadOnly = false; + } + } + catch + { + } + } + + /// + /// Recursive read-only clear for folder operations. + /// + public static void ClearReadOnlyRecursive(string folder) + { + try + { + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + { + ClearReadOnlyIfSet(file); + } + } + catch + { + } + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ReferenceRewriter.cs b/Source/Workspace/Celbridge.Resources/Services/ReferenceRewriter.cs new file mode 100644 index 000000000..8b93aa49a --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/ReferenceRewriter.cs @@ -0,0 +1,234 @@ +using System.Text; +using Celbridge.Logging; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +/// +/// Rewrites "project:" reference literals across the project tree when a +/// resource is renamed or moved. Reads and writes go back through IFileStorage +/// so referencer files inherit the chokepoint's atomic-write semantics. +/// +internal sealed class ReferenceRewriter +{ + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; + private readonly IFileStorage _fileStorage; + + public ReferenceRewriter(ILogger logger, IWorkspaceWrapper workspaceWrapper, IFileStorage fileStorage) + { + _logger = logger; + _workspaceWrapper = workspaceWrapper; + _fileStorage = fileStorage; + } + + /// + /// Rewrites every "project:" literal (and, for folders, every + /// "project:/" literal) in every referencer of source. + /// Successful rewrites land in updatedReferencers; failures land in + /// skippedReferencers with a reason. The parent move always proceeds — + /// data_check_project surfaces residuals; the chokepoint is idempotent so + /// a rerun completes them. + /// + public async Task RewriteForMoveAsync( + ResourceKey source, + ResourceKey dest, + bool sourceIsFolder, + List updatedReferencers, + List skippedReferencers) + { + var scanner = _workspaceWrapper.WorkspaceService.ResourceScanner; + + var referencerSet = new HashSet(); + foreach (var referencer in await scanner.FindReferencersAsync(source)) + { + referencerSet.Add(referencer); + } + + if (sourceIsFolder) + { + // Folder rename also rewrites every "project:/" form, + // so gather referencers of every descendant target too. + foreach (var target in await scanner.FindAllReferencedTargetsAsync()) + { + if (target.IsDescendantOf(source)) + { + foreach (var referencer in await scanner.FindReferencersAsync(target)) + { + referencerSet.Add(referencer); + } + } + } + } + + var orderedReferencers = referencerSet + .OrderBy(r => r.ToString(), StringComparer.Ordinal) + .ToList(); + + foreach (var referencer in orderedReferencers) + { + var readResult = await _fileStorage.ReadAllTextAsync(referencer); + if (readResult.IsFailure) + { + var message = $"read failed for '{referencer}'"; + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{dest}': {message}. The reference is left as-is and will surface via data_check_project."); + skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadFailed, message)); + continue; + } + var originalText = readResult.Value; + + var rewritten = RewriteReferenceLiterals(originalText, source, dest, sourceIsFolder); + if (rewritten == originalText) + { + continue; + } + + // Pre-check the DOS read-only attribute. Windows surfaces it as a + // write failure but POSIX rename would silently succeed, so the + // pre-check honors "don't modify this file" identically on both. + if (IsReferencerReadOnly(referencer)) + { + const string readOnlyMessage = "file is read-only"; + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{dest}': {readOnlyMessage}. The reference is left as-is and will surface via data_check_project."); + skippedReferencers.Add(new SkippedReferencer(referencer, ReferencerSkipReason.ReadOnly, readOnlyMessage)); + continue; + } + + var writeResult = await _fileStorage.WriteAllTextAsync(referencer, rewritten); + if (writeResult.IsFailure) + { + var classification = ClassifyReferencerWriteFailure(referencer, writeResult); + _logger.LogWarning($"Could not rewrite references in '{referencer}' for rename of '{source}' to '{dest}': {classification.Message}. The reference is left as-is and will surface via data_check_project."); + skippedReferencers.Add(new SkippedReferencer(referencer, classification.Reason, classification.Message)); + continue; + } + + updatedReferencers.Add(referencer); + } + + return Result.Ok(); + } + + private bool IsReferencerReadOnly(ResourceKey referencer) + { + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = registry.ResolveResourcePath(referencer); + if (resolveResult.IsFailure) + { + return false; + } + + try + { + var info = new FileInfo(resolveResult.Value); + return info.Exists + && info.IsReadOnly; + } + catch + { + return false; + } + } + + private (ReferencerSkipReason Reason, string Message) ClassifyReferencerWriteFailure(ResourceKey referencer, Result writeResult) + { + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resolveResult = registry.ResolveResourcePath(referencer); + if (resolveResult.IsFailure) + { + return (ReferencerSkipReason.WriteFailed, "write failed (could not resolve path)"); + } + + // ReadOnly is split from PermissionDenied because the fix is different: + // read-only is trivially clearable; an ACL deny needs the right account. + try + { + var info = new FileInfo(resolveResult.Value); + if (info.Exists + && info.IsReadOnly) + { + return (ReferencerSkipReason.ReadOnly, "file is read-only"); + } + } + catch + { + } + + if (writeResult.FirstException is UnauthorizedAccessException) + { + return (ReferencerSkipReason.PermissionDenied, "permission denied (no write access to file)"); + } + + return (ReferencerSkipReason.WriteFailed, "write failed (file may be locked or another IO issue)"); + } + + // Replaces every reference whose parsed key matches sourceKey, or + // (for folders) begins with sourceKey/, with the equivalent literal + // targeting destKey. Detection and rewrite both go through + // ResourceReferenceParser.TryParseReferenceAt so the parse contract cannot + // drift between them. + private static string RewriteReferenceLiterals(string text, ResourceKey sourceKey, ResourceKey destKey, bool sourceIsFolder) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + var sourceKeyString = sourceKey.FullKey; + var destKeyString = destKey.FullKey; + var sourceFolderPrefix = sourceKeyString + "/"; + + var builder = new StringBuilder(text.Length); + int cursor = 0; + + while (cursor < text.Length) + { + int markerIndex = text.IndexOf(ResourceReferenceParser.ReferenceMarker, cursor, StringComparison.Ordinal); + if (markerIndex < 0) + { + builder.Append(text, cursor, text.Length - cursor); + break; + } + + var parsed = ResourceReferenceParser.TryParseReferenceAt(text, markerIndex); + if (parsed is null) + { + // Not a tracked reference. Advance one char so a later + // overlapping match can still hit. + builder.Append(text, cursor, markerIndex - cursor + 1); + cursor = markerIndex + 1; + continue; + } + + var parsedKeyString = parsed.Key.FullKey; + string? rewrittenKeyString = null; + + if (string.Equals(parsedKeyString, sourceKeyString, StringComparison.Ordinal)) + { + rewrittenKeyString = destKeyString; + } + else if (sourceIsFolder + && parsedKeyString.StartsWith(sourceFolderPrefix, StringComparison.Ordinal)) + { + rewrittenKeyString = destKeyString + parsedKeyString.Substring(sourceKeyString.Length); + } + + // Emit everything up to (but not including) the marker. + builder.Append(text, cursor, markerIndex - cursor); + + if (rewrittenKeyString is not null) + { + builder.Append(rewrittenKeyString); + } + else + { + // The parsed key didn't match; preserve the original literal. + builder.Append(text, markerIndex, parsed.EndIndex - markerIndex); + } + + cursor = parsed.EndIndex; + } + + return builder.ToString(); + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 48961906a..73ef16a9f 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -80,97 +80,97 @@ public async Task CreateFolderAsync(ResourceKey resource) return result; } - public async Task> CopyAsync(ResourceKey source, ResourceKey destination) + public async Task> CopyAsync(ResourceKey source, ResourceKey dest) { var sourcePathResult = ResourceRegistry.ResolveResourcePath(source); if (sourcePathResult.IsFailure) { - return Result.Fail($"Failed to resolve path for source resource: '{source}'") + return Result.Fail($"Failed to resolve path for source resource: '{source}'") .WithErrors(sourcePathResult); } var sourcePath = sourcePathResult.Value; - var destinationPathResult = ResourceRegistry.ResolveResourcePath(destination); - if (destinationPathResult.IsFailure) + var destPathResult = ResourceRegistry.ResolveResourcePath(dest); + if (destPathResult.IsFailure) { - return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") - .WithErrors(destinationPathResult); + return Result.Fail($"Failed to resolve path for destination resource: '{dest}'") + .WithErrors(destPathResult); } - var destinationPath = destinationPathResult.Value; + var destPath = destPathResult.Value; var infoResult = await FileStorage.GetInfoAsync(source); if (infoResult.IsFailure || infoResult.Value.Kind == StorageItemKind.NotFound) { - return Result.Fail($"Source resource does not exist: '{source}'"); + return Result.Fail($"Source resource does not exist: '{source}'"); } bool isFolder = infoResult.Value.Kind == StorageItemKind.Folder; var entityHelper = new EntityFileHelper(EntityService, ResourceRegistry); var operation = new CopyOperation( source, - destination, + dest, isFolder, sourcePath, - destinationPath, + destPath, entityHelper, FileStorage); var executeResult = await operation.ExecuteAsync(); if (executeResult.IsFailure) { - return Result.Fail(executeResult); + return Result.Fail(executeResult); } AddOperation(operation); return operation.LastCopyResult ?? EmptyCopyResult; } - public async Task> MoveAsync(ResourceKey source, ResourceKey destination) + public async Task> MoveAsync(ResourceKey source, ResourceKey dest) { var sourcePathResult = ResourceRegistry.ResolveResourcePath(source); if (sourcePathResult.IsFailure) { - return Result.Fail($"Failed to resolve path for source resource: '{source}'") + return Result.Fail($"Failed to resolve path for source resource: '{source}'") .WithErrors(sourcePathResult); } var sourcePath = sourcePathResult.Value; - var destinationPathResult = ResourceRegistry.ResolveResourcePath(destination); - if (destinationPathResult.IsFailure) + var destPathResult = ResourceRegistry.ResolveResourcePath(dest); + if (destPathResult.IsFailure) { - return Result.Fail($"Failed to resolve path for destination resource: '{destination}'") - .WithErrors(destinationPathResult); + return Result.Fail($"Failed to resolve path for destination resource: '{dest}'") + .WithErrors(destPathResult); } - var destinationPath = destinationPathResult.Value; + var destPath = destPathResult.Value; var infoResult = await FileStorage.GetInfoAsync(source); if (infoResult.IsFailure || infoResult.Value.Kind == StorageItemKind.NotFound) { - return Result.Fail($"Source resource does not exist: '{source}'"); + return Result.Fail($"Source resource does not exist: '{source}'"); } bool isFolder = infoResult.Value.Kind == StorageItemKind.Folder; var entityHelper = new EntityFileHelper(EntityService, ResourceRegistry); var operation = new MoveOperation( source, - destination, + dest, isFolder, sourcePath, - destinationPath, + destPath, entityHelper, FileStorage); var executeResult = await operation.ExecuteAsync(); if (executeResult.IsFailure) { - return Result.Fail(executeResult); + return Result.Fail(executeResult); } AddOperation(operation); - return Result.Ok(operation.LastMoveResult ?? EmptyMoveResult); + return operation.LastMoveResult ?? EmptyMoveResult; } public async Task DeleteAsync(ResourceKey resource) @@ -186,11 +186,11 @@ public async Task DeleteAsync(ResourceKey resource) return result; } - public async Task ImportExternalFileAsync(string sourcePath, ResourceKey destination) + public async Task ImportExternalFileAsync(string sourcePath, ResourceKey dest) { sourcePath = Path.GetFullPath(sourcePath); - var operation = new ImportExternalOperation(sourcePath, destination, isFolder: false, FileStorage, _logger); + var operation = new ImportExternalOperation(sourcePath, dest, isFolder: false, FileStorage, _logger); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -201,11 +201,11 @@ public async Task ImportExternalFileAsync(string sourcePath, ResourceKey return result; } - public async Task ImportExternalFolderAsync(string sourcePath, ResourceKey destination) + public async Task ImportExternalFolderAsync(string sourcePath, ResourceKey dest) { sourcePath = Path.GetFullPath(sourcePath); - var operation = new ImportExternalOperation(sourcePath, destination, isFolder: true, FileStorage, _logger); + var operation = new ImportExternalOperation(sourcePath, dest, isFolder: true, FileStorage, _logger); var result = await operation.ExecuteAsync(); if (result.IsSuccess) @@ -216,14 +216,14 @@ public async Task ImportExternalFolderAsync(string sourcePath, ResourceK return result; } - public async Task TransferAsync(ResourceKey source, ResourceKey destination, DataTransferMode mode) + public async Task TransferAsync(ResourceKey source, ResourceKey dest, DataTransferMode mode) { if (mode == DataTransferMode.Copy) { - return await CopyAsync(source, destination); + return await CopyAsync(source, dest); } - return await MoveAsync(source, destination); + return await MoveAsync(source, dest); } public IBatchScope BeginBatch() @@ -295,11 +295,16 @@ public async Task UndoAsync() } var operation = _undoStack[^1]; - _undoStack.RemoveAt(_undoStack.Count - 1); + // Run the undo against the in-place operation; only move it between + // stacks once the outcome is known. A failed undo leaves the operation + // on the undo stack so the user can retry once the underlying issue + // clears (file unlocked, permission granted), instead of losing the + // entry to the abyss. var result = await operation.UndoAsync(); if (result.IsSuccess) { + _undoStack.RemoveAt(_undoStack.Count - 1); _redoStack.Add(operation); } else @@ -318,11 +323,13 @@ public async Task RedoAsync() } var operation = _redoStack[^1]; - _redoStack.RemoveAt(_redoStack.Count - 1); + // Mirror of UndoAsync: only move the operation between stacks on + // success. A failed redo stays on the redo stack so the user can retry. var result = await operation.RedoAsync(); if (result.IsSuccess) { + _redoStack.RemoveAt(_redoStack.Count - 1); _undoStack.Add(operation); } else @@ -356,7 +363,7 @@ private void TrimUndoStack() var oldestOperation = _undoStack[0]; _undoStack.RemoveAt(0); - _ = PurgeOperationTrashAsync(oldestOperation); + FireAndForgetPurge(oldestOperation); } } @@ -366,14 +373,30 @@ private void ClearRedoStack() { foreach (var operation in _redoStack) { - _ = PurgeOperationTrashAsync(operation); + FireAndForgetPurge(operation); } _redoStack.Clear(); } + // Schedules a best-effort purge of the operation's trash bytes without + // blocking the caller. The wrapper guarantees that any exception thrown + // inside the chain is logged rather than escaping as an unobserved task + // exception — internal purge already logs at Warning, but a programming + // error introduced later in the chain would otherwise be invisible. + private void FireAndForgetPurge(FileOperation operation) + { + _ = PurgeOperationTrashAsync(operation).ContinueWith(task => + { + if (task.Exception is not null) + { + _logger.LogWarning(task.Exception, "Unhandled exception while purging operation trash."); + } + }, TaskScheduler.Default); + } + // Recursively walks operation batches and purges any trash bytes a - // DeleteOperation was keeping alive. Fire-and-forget at the call site - // because trash purge is best-effort cleanup. + // DeleteOperation was keeping alive. Called via FireAndForgetPurge at the + // call site because trash purge is best-effort cleanup. private static async Task PurgeOperationTrashAsync(FileOperation operation) { if (operation is FileOperationBatch batch) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs index 765d83377..99a8f63f2 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperations.cs @@ -133,29 +133,29 @@ public override async Task UndoAsync() internal class CopyOperation : FileOperation { private readonly ResourceKey _source; - private readonly ResourceKey _destination; + private readonly ResourceKey _dest; private readonly bool _isFolder; private readonly EntityFileHelper _entityHelper; private readonly IFileStorage _fileStorage; private readonly string _sourcePath; - private readonly string _destinationPath; + private readonly string _destPath; public CopyResult? LastCopyResult { get; private set; } public CopyOperation( ResourceKey source, - ResourceKey destination, + ResourceKey dest, bool isFolder, string sourcePath, - string destinationPath, + string destPath, EntityFileHelper entityHelper, IFileStorage fileStorage) { _source = source; - _destination = destination; + _dest = dest; _isFolder = isFolder; _sourcePath = sourcePath; - _destinationPath = destinationPath; + _destPath = destPath; _entityHelper = entityHelper; _fileStorage = fileStorage; } @@ -164,10 +164,10 @@ public override async Task ExecuteAsync() { if (!_isFolder) { - _entityHelper.CopyEntityDataFile(_sourcePath, _destinationPath); + _entityHelper.CopyEntityDataFile(_sourcePath, _destPath); } - var copyResult = await _fileStorage.CopyAsync(_source, _destination); + var copyResult = await _fileStorage.CopyAsync(_source, _dest); if (copyResult.IsFailure) { return Result.Fail(copyResult); @@ -175,7 +175,7 @@ public override async Task ExecuteAsync() if (_isFolder) { - _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destinationPath); + _entityHelper.CopyFolderEntityDataFiles(_sourcePath, _destPath); } LastCopyResult = copyResult.Value; @@ -186,14 +186,14 @@ public override async Task UndoAsync() { if (_isFolder) { - _entityHelper.DeleteFolderEntityDataFiles(_destinationPath); + _entityHelper.DeleteFolderEntityDataFiles(_destPath); } else { - _entityHelper.DeleteEntityDataFile(_destinationPath); + _entityHelper.DeleteEntityDataFile(_destPath); } - var deleteResult = await _fileStorage.DeleteAsync(_destination); + var deleteResult = await _fileStorage.DeleteAsync(_dest); return deleteResult.IsSuccess ? Result.Ok() : Result.Fail(deleteResult); @@ -208,29 +208,29 @@ public override async Task UndoAsync() internal class MoveOperation : FileOperation { private readonly ResourceKey _source; - private readonly ResourceKey _destination; + private readonly ResourceKey _dest; private readonly bool _isFolder; private readonly EntityFileHelper _entityHelper; private readonly IFileStorage _fileStorage; private readonly string _sourcePath; - private readonly string _destinationPath; + private readonly string _destPath; public MoveResult? LastMoveResult { get; private set; } public MoveOperation( ResourceKey source, - ResourceKey destination, + ResourceKey dest, bool isFolder, string sourcePath, - string destinationPath, + string destPath, EntityFileHelper entityHelper, IFileStorage fileStorage) { _source = source; - _destination = destination; + _dest = dest; _isFolder = isFolder; _sourcePath = sourcePath; - _destinationPath = destinationPath; + _destPath = destPath; _entityHelper = entityHelper; _fileStorage = fileStorage; } @@ -241,16 +241,36 @@ public override async Task ExecuteAsync() // helper can compute keys against the original location. if (_isFolder) { - _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destinationPath); + _entityHelper.MoveFolderEntityDataFiles(_sourcePath, _destPath); } else { - _entityHelper.MoveEntityDataFile(_sourcePath, _destinationPath); + _entityHelper.MoveEntityDataFile(_sourcePath, _destPath); } - var moveResult = await _fileStorage.MoveAsync(_source, _destination); + var moveResult = await _fileStorage.MoveAsync(_source, _dest); if (moveResult.IsFailure) { + // Best-effort rollback of the entity-data cascade so the bytes + // stay paired with the source on failure. Errors here are swallowed + // because the chokepoint failure is the load-bearing problem; the + // entity system is on its way out and the precise post-failure + // state is not worth a partial-recovery report. + try + { + if (_isFolder) + { + _entityHelper.MoveFolderEntityDataFiles(_destPath, _sourcePath); + } + else + { + _entityHelper.MoveEntityDataFile(_destPath, _sourcePath); + } + } + catch + { + } + return Result.Fail(moveResult); } @@ -262,14 +282,14 @@ public override async Task UndoAsync() { if (_isFolder) { - _entityHelper.MoveFolderEntityDataFiles(_destinationPath, _sourcePath); + _entityHelper.MoveFolderEntityDataFiles(_destPath, _sourcePath); } else { - _entityHelper.MoveEntityDataFile(_destinationPath, _sourcePath); + _entityHelper.MoveEntityDataFile(_destPath, _sourcePath); } - var moveResult = await _fileStorage.MoveAsync(_destination, _source); + var moveResult = await _fileStorage.MoveAsync(_dest, _source); return moveResult.IsSuccess ? Result.Ok() : Result.Fail(moveResult); @@ -337,20 +357,20 @@ public async Task CleanupAsync() internal class ImportExternalOperation : FileOperation { private readonly string _sourcePath; - private readonly ResourceKey _destination; + private readonly ResourceKey _dest; private readonly bool _isFolder; private readonly IFileStorage _fileStorage; private readonly ILogger _logger; public ImportExternalOperation( string sourcePath, - ResourceKey destination, + ResourceKey dest, bool isFolder, IFileStorage fileStorage, ILogger logger) { _sourcePath = sourcePath; - _destination = destination; + _dest = dest; _isFolder = isFolder; _fileStorage = fileStorage; _logger = logger; @@ -365,14 +385,14 @@ public override async Task ExecuteAsync() return Result.Fail($"Source folder does not exist: '{_sourcePath}'"); } - var infoResult = await _fileStorage.GetInfoAsync(_destination); + var infoResult = await _fileStorage.GetInfoAsync(_dest); if (infoResult.IsSuccess && infoResult.Value.Kind != StorageItemKind.NotFound) { - return Result.Fail($"Destination already exists: '{_destination}'"); + return Result.Fail($"Destination already exists: '{_dest}'"); } - return await ImportFolderAsync(_sourcePath, _destination); + return await ImportFolderAsync(_sourcePath, _dest); } if (!File.Exists(_sourcePath)) @@ -380,36 +400,36 @@ public override async Task ExecuteAsync() return Result.Fail($"Source file does not exist: '{_sourcePath}'"); } - var destInfoResult = await _fileStorage.GetInfoAsync(_destination); + var destInfoResult = await _fileStorage.GetInfoAsync(_dest); if (destInfoResult.IsSuccess && destInfoResult.Value.Kind != StorageItemKind.NotFound) { - return Result.Fail($"Destination already exists: '{_destination}'"); + return Result.Fail($"Destination already exists: '{_dest}'"); } try { var bytes = await File.ReadAllBytesAsync(_sourcePath); - return await _fileStorage.WriteAllBytesAsync(_destination, bytes); + return await _fileStorage.WriteAllBytesAsync(_dest, bytes); } catch (Exception ex) { - _logger.LogError(ex, "Failed to import external file from '{SourcePath}' to '{Destination}'", _sourcePath, _destination); - return Result.Fail($"Failed to import external file from '{_sourcePath}' to '{_destination}'") + _logger.LogError(ex, "Failed to import external file from '{SourcePath}' to '{Destination}'", _sourcePath, _dest); + return Result.Fail($"Failed to import external file from '{_sourcePath}' to '{_dest}'") .WithException(ex); } } public override async Task UndoAsync() { - var infoResult = await _fileStorage.GetInfoAsync(_destination); + var infoResult = await _fileStorage.GetInfoAsync(_dest); if (infoResult.IsFailure || infoResult.Value.Kind == StorageItemKind.NotFound) { return Result.Ok(); } - var deleteResult = await _fileStorage.DeleteAsync(_destination); + var deleteResult = await _fileStorage.DeleteAsync(_dest); return deleteResult.IsSuccess ? Result.Ok() : Result.Fail(deleteResult); diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs index 4e74942dd..0f33a4069 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceReferenceParser.cs @@ -34,24 +34,6 @@ private static readonly (char First, char Second)[] EscapedQuoteOpeners = ('\\', '\''), }; - /// - /// Returns true if the character can sit immediately adjacent to a - /// tracked reference — one of the quote forms, or the leading backslash - /// of an escaped quote. - /// - public static bool IsNonKeyBoundary(char c) - { - switch (c) - { - case '"': - case '\'': - case '\\': - return true; - default: - return false; - } - } - /// /// Attempts to parse a single reference at the given marker position. /// must point at the 'p' of a "project:" diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index c80279684..133b57e69 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -235,21 +235,21 @@ public Result UpdateResourceRegistry() } } - public List<(ResourceKey Resource, string Path)> GetAllFileResources() + public IReadOnlyList GetAllFileResources() { return GetAllFileResources(ResourceKey.DefaultRoot); } - public List<(ResourceKey Resource, string Path)> GetAllFileResources(string root) + public IReadOnlyList GetAllFileResources(string root) { // Only the project root has an indexed tree in the registry. // Other roots (e.g. temp:, logs:) are addressable but not enumerated here. if (root != ResourceKey.DefaultRoot) { - return new List<(ResourceKey Resource, string Path)>(); + return Array.Empty(); } - var fileResources = new List<(ResourceKey Resource, string Path)>(); + var fileResources = new List(); CollectFileResources(_projectFolder, fileResources); // Sort by path for stable ordering @@ -263,7 +263,7 @@ public Result UpdateResourceRegistry() /// private void CollectFileResources( IFolderResource folder, - List<(ResourceKey Resource, string Path)> fileResources) + List fileResources) { foreach (var child in folder.Children) { @@ -273,7 +273,7 @@ private void CollectFileResources( var resolveResult = ResolveResourcePath(resourceKey); if (resolveResult.IsSuccess) { - fileResources.Add((resourceKey, resolveResult.Value)); + fileResources.Add(new FileResourceEntry(resourceKey, resolveResult.Value)); } } else if (child is IFolderResource childFolder) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs index 79fefb92e..0837e22a9 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceScanner.cs @@ -233,8 +233,8 @@ private static HashSet ScanReferences(string text) // each. Reads the registry's snapshot directly; mutation commands carry // CommandFlags.UpdateResources so the snapshot reflects the latest disk // state by the time a tool that consults the scanner runs. Files are - // filtered through ResourceScanRules.ScannableExtensions — only the - // allowlisted data-bearing extensions participate. + // filtered through ScannableExtensions — only the allowlisted + // data-bearing extensions participate. private async Task EnumerateProjectTextFilesAsync(Func visit) { var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -242,7 +242,8 @@ private async Task EnumerateProjectTextFilesAsync(Func { - var (resourceKey, absolutePath) = file; + var resourceKey = file.Resource; + var absolutePath = file.Path; if (!IsScannableFile(absolutePath)) { return; @@ -265,7 +266,8 @@ private async Task EnumerateProjectSidecarFilesAsync(Func { - var (resourceKey, absolutePath) = file; + var resourceKey = file.Resource; + var absolutePath = file.Path; if (!IsSidecarPath(absolutePath)) { return; diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarCascade.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarCascade.cs new file mode 100644 index 000000000..9d49d06c5 --- /dev/null +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarCascade.cs @@ -0,0 +1,181 @@ +using Celbridge.Logging; +using Celbridge.Workspace; + +namespace Celbridge.Resources.Services; + +/// +/// Cascades the paired sidecar of a structural operation: when the parent +/// file moves, copies, or deletes, the sidecar follows. The parent operation +/// has already succeeded by the time the cascade runs, so failure surfaces +/// as a SidecarOutcome on the parent's result rather than aborting. +/// +internal sealed class SidecarCascade +{ + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; + + public SidecarCascade(ILogger logger, IWorkspaceWrapper workspaceWrapper) + { + _logger = logger; + _workspaceWrapper = workspaceWrapper; + } + + public async Task TryMoveAsync(ResourceKey source, ResourceKey dest) + { + var sourceSidecar = AppendSidecarSuffix(source); + var destSidecar = AppendSidecarSuffix(dest); + if (sourceSidecar is null + || destSidecar is null) + { + return SidecarOutcome.NotPresent; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveSourceResult = registry.ResolveResourcePath(sourceSidecar.Value); + if (resolveSourceResult.IsFailure) + { + return SidecarOutcome.NotPresent; + } + var sourceSidecarPath = resolveSourceResult.Value; + if (!File.Exists(sourceSidecarPath)) + { + return SidecarOutcome.NotPresent; + } + + var resolveDestResult = registry.ResolveResourcePath(destSidecar.Value); + if (resolveDestResult.IsFailure) + { + _logger.LogWarning($"Failed to resolve sidecar destination '{destSidecar}' for move from '{source}'. Sidecar bytes remain at the source path."); + return SidecarOutcome.Failed; + } + var destSidecarPath = resolveDestResult.Value; + + if (File.Exists(destSidecarPath)) + { + _logger.LogWarning($"Sidecar destination '{destSidecar}' already exists. Parent move completed but sidecar was not cascaded."); + return SidecarOutcome.Failed; + } + + try + { + var destFolder = Path.GetDirectoryName(destSidecarPath); + if (!string.IsNullOrEmpty(destFolder) + && !Directory.Exists(destFolder)) + { + Directory.CreateDirectory(destFolder); + } + + await FileStorageInternals.RetryTransientIOAsync(_logger, "Sidecar move", sourceSidecarPath, () => File.Move(sourceSidecarPath, destSidecarPath)); + return SidecarOutcome.Cascaded; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to cascade sidecar move from '{sourceSidecar}' to '{destSidecar}'."); + return SidecarOutcome.Failed; + } + } + + public SidecarOutcome TryCopy(ResourceKey source, ResourceKey dest) + { + var sourceSidecar = AppendSidecarSuffix(source); + var destSidecar = AppendSidecarSuffix(dest); + if (sourceSidecar is null + || destSidecar is null) + { + return SidecarOutcome.NotPresent; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveSourceResult = registry.ResolveResourcePath(sourceSidecar.Value); + if (resolveSourceResult.IsFailure) + { + return SidecarOutcome.NotPresent; + } + var sourceSidecarPath = resolveSourceResult.Value; + if (!File.Exists(sourceSidecarPath)) + { + return SidecarOutcome.NotPresent; + } + + var resolveDestResult = registry.ResolveResourcePath(destSidecar.Value); + if (resolveDestResult.IsFailure) + { + _logger.LogWarning($"Failed to resolve sidecar destination '{destSidecar}' for copy from '{source}'."); + return SidecarOutcome.Failed; + } + var destSidecarPath = resolveDestResult.Value; + + if (File.Exists(destSidecarPath)) + { + _logger.LogWarning($"Sidecar destination '{destSidecar}' already exists. Parent copy completed but sidecar was not cascaded."); + return SidecarOutcome.Failed; + } + + try + { + var destFolder = Path.GetDirectoryName(destSidecarPath); + if (!string.IsNullOrEmpty(destFolder) + && !Directory.Exists(destFolder)) + { + Directory.CreateDirectory(destFolder); + } + + File.Copy(sourceSidecarPath, destSidecarPath); + return SidecarOutcome.Cascaded; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to cascade sidecar copy from '{sourceSidecar}' to '{destSidecar}'."); + return SidecarOutcome.Failed; + } + } + + public SidecarOutcome TryDelete(ResourceKey source) + { + var sourceSidecar = AppendSidecarSuffix(source); + if (sourceSidecar is null) + { + return SidecarOutcome.NotPresent; + } + + var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var resolveResult = registry.ResolveResourcePath(sourceSidecar.Value); + if (resolveResult.IsFailure) + { + return SidecarOutcome.NotPresent; + } + var sidecarPath = resolveResult.Value; + if (!File.Exists(sidecarPath)) + { + return SidecarOutcome.NotPresent; + } + + try + { + File.Delete(sidecarPath); + return SidecarOutcome.Cascaded; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to cascade sidecar delete for '{sourceSidecar}'."); + return SidecarOutcome.Failed; + } + } + + // Returns the sidecar resource key for the given parent, or null when no + // valid sidecar key can be derived (root-only key, or the parent is itself + // a .cel file). + private ResourceKey? AppendSidecarSuffix(ResourceKey key) + { + var sidecarService = _workspaceWrapper.WorkspaceService.SidecarService; + var result = sidecarService.GetSidecarKey(key); + if (result.IsSuccess) + { + return result.Value; + } + return null; + } +} diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs index b99ac5a8a..e080636e3 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -180,6 +180,10 @@ public async Task WriteBlockAsync(ResourceKey resource, string blockId, { return Result.Fail("Block content is null."); } + if (SidecarHelper.BlockContentContainsFenceLine(content)) + { + return Result.Fail($"Block '{blockId}' content contains a line matching the fence regex (e.g. '+++ \"name\"'); this would corrupt the sidecar on round-trip."); + } return await MutateBlocksAsync( resource, diff --git a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs index ca778da9d..ce7616e5d 100644 --- a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs @@ -8,14 +8,6 @@ namespace Celbridge.Resources.Services; public sealed class TrashService : ITrashService { - // Retry budget for cross-process sharing-violation races on file/folder - // moves into and out of trash. Antivirus, search indexer, or sync clients - // briefly hold a read handle on a newly-created file; matches the chokepoint's - // own read/write/move retry budget until we have evidence trash traffic - // wants something different. - private const int MaxAttempts = 3; - private const int BaseRetryDelayMs = 50; - private readonly ILogger _logger; private readonly IMessengerService _messengerService; private readonly IWorkspaceWrapper _workspaceWrapper; @@ -137,20 +129,20 @@ private async Task> MoveFileToTrashAsync( try { - ClearReadOnlyIfSet(originalPath); + FileStorageInternals.ClearReadOnlyIfSet(originalPath); await MoveFileWithDirectoryCreationAsync(originalPath, trashPath); if (sidecarOriginalPath is not null && sidecarTrashPath is not null) { - ClearReadOnlyIfSet(sidecarOriginalPath); + FileStorageInternals.ClearReadOnlyIfSet(sidecarOriginalPath); await MoveFileWithDirectoryCreationAsync(sidecarOriginalPath, sidecarTrashPath); } if (entityDataOriginalPath is not null && entityDataTrashPath is not null) { - ClearReadOnlyIfSet(entityDataOriginalPath); + FileStorageInternals.ClearReadOnlyIfSet(entityDataOriginalPath); await MoveFileWithDirectoryCreationAsync(entityDataOriginalPath, entityDataTrashPath); } } @@ -198,9 +190,9 @@ private async Task> MoveFolderToTrashAsync( if (!wasEmpty) { // Walking once to gather descendant keys for messaging and entity-data - // pairs for trash. Direct System.IO inside the trash service is - // permitted under cm-9 Decision 7 since trash bookkeeping lives outside - // the registry's reach. + // pairs for trash. The trash folder lives outside the registry's reach, + // so direct System.IO here is deliberate; chokepoint dispatch would + // fail because the trash paths do not resolve to ResourceKeys. var entityService = EntityService; foreach (var filePath in Directory.GetFiles(originalPath, "*", SearchOption.AllDirectories)) { @@ -225,7 +217,7 @@ private async Task> MoveFolderToTrashAsync( try { - ClearReadOnlyRecursive(originalPath); + FileStorageInternals.ClearReadOnlyRecursive(originalPath); if (wasEmpty) { @@ -370,83 +362,30 @@ public Task PurgeAsync(TrashEntry entry) { // Purge runs when an undo entry is evicted or the redo stack is // cleared. It is best-effort cleanup; orphaned bytes inside the trash - // folder are wiped on the next workspace load. - _logger.LogDebug(ex, $"Best-effort trash purge failed for resource: '{entry.OriginalResource}'"); + // folder are wiped on the next workspace load. Logged at Warning + // because a failure here can indicate a real problem (locked file, + // permissions, disk error) that the user benefits from seeing. + _logger.LogWarning(ex, $"Best-effort trash purge failed for resource: '{entry.OriginalResource}'"); } return Task.FromResult(Result.Ok()); } - // User intent to delete overrides the DOS read-only attribute, matching - // Windows Explorer's "delete read-only file?" confirmation behaviour. The - // cleared state persists through undo; a restored file lands writable and - // the user can re-apply the attribute via the OS file properties dialog. - private static void ClearReadOnlyIfSet(string path) - { - try - { - var info = new FileInfo(path); - if (info.Exists - && info.IsReadOnly) - { - info.IsReadOnly = false; - } - } - catch - { - // Best effort; surface the underlying issue from the subsequent move. - } - } - - // Recursive read-only clear for folder delete. Directory.Move into trash - // (and the empty-folder Directory.Delete) fails if any contained file is - // read-only, so traverse first. - private static void ClearReadOnlyRecursive(string folder) - { - try - { - foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) - { - ClearReadOnlyIfSet(file); - } - } - catch - { - // Best effort traversal. - } - } - // Move a file into the trash subtree, creating any missing parent folders // first. Retries briefly on transient IOException for the sharing-violation // race that follows AV / indexer / sync clients touching a newly-created file. - private static async Task MoveFileWithDirectoryCreationAsync(string sourcePath, string destPath) + private async Task MoveFileWithDirectoryCreationAsync(string sourcePath, string destPath) { var destFolder = Path.GetDirectoryName(destPath)!; Directory.CreateDirectory(destFolder); - await MoveWithRetryAsync(() => File.Move(sourcePath, destPath)); + await FileStorageInternals.RetryTransientIOAsync(_logger, "Trash move", sourcePath, () => File.Move(sourcePath, destPath)); } - private static async Task MoveDirectoryWithParentCreationAsync(string sourcePath, string destPath) + private async Task MoveDirectoryWithParentCreationAsync(string sourcePath, string destPath) { var destParentFolder = Path.GetDirectoryName(destPath)!; Directory.CreateDirectory(destParentFolder); - await MoveWithRetryAsync(() => Directory.Move(sourcePath, destPath)); - } - - private static async Task MoveWithRetryAsync(Action moveOperation) - { - for (var attempt = 1; attempt <= MaxAttempts; attempt++) - { - try - { - moveOperation(); - return; - } - catch (IOException) when (attempt < MaxAttempts) - { - await Task.Delay(BaseRetryDelayMs * attempt); - } - } + await FileStorageInternals.RetryTransientIOAsync(_logger, "Trash move", sourcePath, () => Directory.Move(sourcePath, destPath)); } private static void DeleteFileIfExists(string path) From e76b05468a48e3ae19e98690611056e954e087ff Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 16:15:23 +0100 Subject: [PATCH 47/48] Add LegacyConstants and refactor project constants Introduce LegacyConstants to hold legacy (user-visible) "celbridge/" folder names and move legacy-specific names out of ProjectConstants. ProjectConstants is cleaned up to reflect the new hidden ".celbridge/" layout (rename Celbridge* constants to shorter names, add PythonFolder). Update callers across projects and tests to use LegacyConstants for legacy paths and ProjectConstants for the new .celbridge subfolders, and adjust resource/cleanup, staging, logs, trash and Python-related code accordingly to use the correct constants. This separates legacy locations from the current layout while retaining compatibility for migration and cleanup code. --- .../Projects/LegacyConstants.cs | 41 +++++++++++++++ .../Projects/ProjectConstants.cs | 52 ++++++------------- .../Services/ProjectFactory.cs | 13 ++--- .../Services/ProjectMigrationService.cs | 2 +- .../Services/ProjectTemplateService.cs | 3 -- .../Steps/MigrationStep_0_2_7_Tests.cs | 2 +- .../Steps/MigrationStep_0_3_0_Tests.cs | 2 +- Source/Tests/Projects/ProjectFactoryTests.cs | 14 ++--- Source/Tests/Resources/FileStorageTests.cs | 2 +- .../Services/EntityRegistry.cs | 2 +- .../Services/EntityService.cs | 2 +- .../Services/PythonService.cs | 6 +-- .../Services/FileStorage.cs | 2 +- .../Services/ProjectTreeBuilder.cs | 2 +- .../Services/ResourceMonitor.cs | 2 +- .../Services/ResourceService.cs | 18 +++---- .../Services/TrashService.cs | 2 +- .../Services/WorkspaceService.cs | 6 ++- 18 files changed, 96 insertions(+), 77 deletions(-) create mode 100644 Source/Core/Celbridge.Foundation/Projects/LegacyConstants.cs diff --git a/Source/Core/Celbridge.Foundation/Projects/LegacyConstants.cs b/Source/Core/Celbridge.Foundation/Projects/LegacyConstants.cs new file mode 100644 index 000000000..218f79c8f --- /dev/null +++ b/Source/Core/Celbridge.Foundation/Projects/LegacyConstants.cs @@ -0,0 +1,41 @@ +namespace Celbridge.Projects; + +/// +/// String constants for the legacy project layout (the user-visible 'celbridge/' +/// meta-data folder and its sub-folders) that predates the '.celbridge/' hidden +/// layout. Retained while the entity service and a handful of cleanup paths +/// still reference the old locations; delete this file when those last +/// consumers move over. +/// +public static class LegacyConstants +{ + /// + /// Legacy user-visible meta-data folder. Replaced by ProjectConstants.CelbridgeFolder. + /// + public const string MetaDataFolder = "celbridge"; + + /// + /// Sub-folder of celbridge/ containing entity data files. + /// + public const string EntitiesFolder = "entities"; + + /// + /// Sub-folder of celbridge/ for ephemeral cached state. Workspace settings + /// and the Python fingerprint have moved into .celbridge/; this constant + /// is unused at runtime and is kept only so cleanup code can identify + /// orphan folders left over from earlier versions. + /// + public const string CacheFolder = ".cache"; + + /// + /// Legacy soft-delete folder under celbridge/. Replaced by + /// ProjectConstants.TrashFolder under .celbridge/. + /// + public const string TrashFolder = ".trash"; + + /// + /// Legacy in-flight atomic-write staging folder under celbridge/. Replaced + /// by ProjectConstants.StagingFsFolder under .celbridge/. + /// + public const string TempFolder = ".temp"; +} diff --git a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs index 4631cdbd0..76245a23d 100644 --- a/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs +++ b/Source/Core/Celbridge.Foundation/Projects/ProjectConstants.cs @@ -1,7 +1,7 @@ namespace Celbridge.Projects; /// -/// Strings constants for project files and folders. +/// String constants for project files and folders. /// public static class ProjectConstants { @@ -10,37 +10,6 @@ public static class ProjectConstants /// public const string ProjectFileExtension = ".celbridge"; - /// - /// Folder containing the project meta data. - /// - public const string MetaDataFolder = "celbridge"; - - /// - /// Folder containing entity data files. - /// - public const string EntitiesFolder = "entities"; - - /// - /// Folder containing ephemeral cached state, such as workspace settings. - /// - public const string CacheFolder = ".cache"; - - /// - /// Folder containing Python logs. - /// - public const string LogsFolder = ".logs"; - - /// - /// Folder containing soft-deleted files for undo support. - /// - public const string TrashFolder = ".trash"; - - /// - /// Folder containing in-flight temp files for atomic writes. - /// Wiped on workspace load to clear orphans left by previous crashes. - /// - public const string TempFolder = ".temp"; - /// /// File containing the workspace settings data. /// @@ -55,24 +24,35 @@ public static class ProjectConstants /// Sub-folder of .celbridge/ that backs the temp: virtual root. Wiped on /// workspace load; consumers needing persistence write under project:. /// - public const string CelbridgeTempFolder = "temp"; + public const string TempFolder = "temp"; /// /// Sub-folder of .celbridge/ that backs the logs: virtual root. /// - public const string CelbridgeLogsFolder = "logs"; + public const string LogsFolder = "logs"; /// /// Sub-folder of .celbridge/ for soft-deleted files. Cleared on every workspace load. /// - public const string CelbridgeTrashFolder = "trash"; + public const string TrashFolder = "trash"; /// /// Sub-folder of .celbridge/ that stages in-flight temp files for atomic /// writes performed by the resource file-system chokepoint. Wiped on /// workspace load to clear orphans left by previous crashes. /// - public const string CelbridgeStagingFsFolder = "staging-fs"; + public const string StagingFsFolder = "staging-fs"; + + /// + /// Sub-folder of .celbridge/ that holds the Python fingerprint and the + /// IPython profile data. + /// + public const string PythonFolder = "python"; + + /// + /// Sub-folder of .celbridge/ that holds the workspace settings database. + /// + public const string SettingsFolder = "settings"; /// /// Folder name used for WebView downloads. Used both for the in-progress diff --git a/Source/Core/Celbridge.Projects/Services/ProjectFactory.cs b/Source/Core/Celbridge.Projects/Services/ProjectFactory.cs index ee23c3979..d3ad04c23 100644 --- a/Source/Core/Celbridge.Projects/Services/ProjectFactory.cs +++ b/Source/Core/Celbridge.Projects/Services/ProjectFactory.cs @@ -15,8 +15,10 @@ public ProjectFactory(ILogger logger) } /// - /// Loads a project from the specified file path. - /// Creates data folder if missing, parses config, returns populated Project. + /// Loads a project from the specified file path: parses its config and + /// returns a populated Project. The legacy data folder is not created + /// here; the entity service creates it on demand when an entity file is + /// first written. /// public Task> LoadAsync(string projectFilePath, MigrationResult migrationResult) { @@ -34,7 +36,7 @@ public Task> LoadAsync(string projectFilePath, MigrationResult { var projectName = Path.GetFileNameWithoutExtension(projectFilePath); var projectFolderPath = Path.GetDirectoryName(projectFilePath)!; - var projectDataFolderPath = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder); + var projectDataFolderPath = Path.Combine(projectFolderPath, LegacyConstants.MetaDataFolder); bool migrationSucceeded = migrationResult.OperationResult.IsSuccess; @@ -62,11 +64,6 @@ public Task> LoadAsync(string projectFilePath, MigrationResult config = new ProjectConfig(); } - if (!Directory.Exists(projectDataFolderPath)) - { - Directory.CreateDirectory(projectDataFolderPath); - } - var project = new Project( projectFilePath, projectName, diff --git a/Source/Core/Celbridge.Projects/Services/ProjectMigrationService.cs b/Source/Core/Celbridge.Projects/Services/ProjectMigrationService.cs index 27ff3aa19..b734d48f8 100644 --- a/Source/Core/Celbridge.Projects/Services/ProjectMigrationService.cs +++ b/Source/Core/Celbridge.Projects/Services/ProjectMigrationService.cs @@ -253,7 +253,7 @@ private async Task MigrateProjectAsync(string projectFilePath, // Create migration context var projectFolderPath = Path.GetDirectoryName(projectFilePath)!; - var projectDataFolderPath = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder); + var projectDataFolderPath = Path.Combine(projectFolderPath, LegacyConstants.MetaDataFolder); // Local function to write the project file. // Line endings are normalized for the current platform. diff --git a/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs b/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs index 4335bda6d..229232575 100644 --- a/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs +++ b/Source/Core/Celbridge.Projects/Services/ProjectTemplateService.cs @@ -75,9 +75,6 @@ public async Task CreateFromTemplateAsync(string projectFilePath, Projec // Create the staging folder Directory.CreateDirectory(tempStagingPath!); - var stagingDataFolderPath = Path.Combine(tempStagingPath!, ProjectConstants.MetaDataFolder); - Directory.CreateDirectory(stagingDataFolderPath); - // Get Celbridge application version var appVersion = _environmentService.GetEnvironmentInfo().AppVersion; diff --git a/Source/Tests/Migration/Steps/MigrationStep_0_2_7_Tests.cs b/Source/Tests/Migration/Steps/MigrationStep_0_2_7_Tests.cs index 3cfc97470..c7b2e2f1a 100644 --- a/Source/Tests/Migration/Steps/MigrationStep_0_2_7_Tests.cs +++ b/Source/Tests/Migration/Steps/MigrationStep_0_2_7_Tests.cs @@ -28,7 +28,7 @@ public void SetUp() Directory.CreateDirectory(_projectFolderPath); _projectFilePath = Path.Combine(_projectFolderPath, "test.celbridge"); - _projectDataFolderPath = Path.Combine(_projectFolderPath, ProjectConstants.MetaDataFolder); + _projectDataFolderPath = Path.Combine(_projectFolderPath, LegacyConstants.MetaDataFolder); } [TearDown] diff --git a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs index fca75518f..377436d62 100644 --- a/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs +++ b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs @@ -31,7 +31,7 @@ public void SetUp() Directory.CreateDirectory(_projectFolderPath); _projectFilePath = Path.Combine(_projectFolderPath, "test.celbridge"); - _projectDataFolderPath = Path.Combine(_projectFolderPath, ProjectConstants.MetaDataFolder); + _projectDataFolderPath = Path.Combine(_projectFolderPath, LegacyConstants.MetaDataFolder); } [TearDown] diff --git a/Source/Tests/Projects/ProjectFactoryTests.cs b/Source/Tests/Projects/ProjectFactoryTests.cs index 5f738c51e..f546dce5a 100644 --- a/Source/Tests/Projects/ProjectFactoryTests.cs +++ b/Source/Tests/Projects/ProjectFactoryTests.cs @@ -80,23 +80,23 @@ public async Task LoadAsync_WithValidFile_ReturnsProject() } [Test] - public async Task LoadAsync_WithValidFile_CreatesDataFolder() + public async Task LoadAsync_WithValidFile_DoesNotCreateLegacyDataFolder() { - // Arrange + // The legacy 'celbridge/' folder is created on demand when the entity + // service first writes a file there; project load alone must not bring + // it into existence. var projectPath = CreateValidProjectFile(); var migrationResult = CreateSuccessfulMigrationResult(); - var expectedDataFolder = Path.Combine( + var legacyDataFolder = Path.Combine( Path.GetDirectoryName(projectPath)!, - ProjectConstants.MetaDataFolder); + LegacyConstants.MetaDataFolder); try { - // Act var result = await _factory.LoadAsync(projectPath, migrationResult); - // Assert result.IsSuccess.Should().BeTrue(); - Directory.Exists(expectedDataFolder).Should().BeTrue(); + Directory.Exists(legacyDataFolder).Should().BeFalse(); } finally { diff --git a/Source/Tests/Resources/FileStorageTests.cs b/Source/Tests/Resources/FileStorageTests.cs index 869c9760c..62d5bb92f 100644 --- a/Source/Tests/Resources/FileStorageTests.cs +++ b/Source/Tests/Resources/FileStorageTests.cs @@ -153,7 +153,7 @@ public async Task WriteAllBytesAsync_StagesTempInCelbridgeStagingFolder_AndLeave var stagingFolder = Path.Combine( _tempFolder, ProjectConstants.CelbridgeFolder, - ProjectConstants.CelbridgeStagingFsFolder); + ProjectConstants.StagingFsFolder); Directory.Exists(stagingFolder).Should().BeTrue(); Directory.GetFiles(stagingFolder).Should().BeEmpty(); } diff --git a/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs b/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs index ba2abf4e5..9de77d0db 100644 --- a/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs +++ b/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs @@ -423,7 +423,7 @@ private Result CreateEntitySchema() private string GetEntitiesFolderPath() { var projectDataFolderPath = _projectService.CurrentProject!.ProjectDataFolderPath; - var path = Path.Combine(projectDataFolderPath, ProjectConstants.EntitiesFolder); + var path = Path.Combine(projectDataFolderPath, LegacyConstants.EntitiesFolder); return path; } } diff --git a/Source/Workspace/Celbridge.Entities/Services/EntityService.cs b/Source/Workspace/Celbridge.Entities/Services/EntityService.cs index c7c926865..937020d2d 100644 --- a/Source/Workspace/Celbridge.Entities/Services/EntityService.cs +++ b/Source/Workspace/Celbridge.Entities/Services/EntityService.cs @@ -92,7 +92,7 @@ public string GetEntityDataPath(ResourceKey resource) public string GetEntityDataRelativePath(ResourceKey resource) { - var relativePath = $"{ProjectConstants.MetaDataFolder}/{ProjectConstants.EntitiesFolder}/{resource.Path}.json"; + var relativePath = $"{LegacyConstants.MetaDataFolder}/{LegacyConstants.EntitiesFolder}/{resource.Path}.json"; return relativePath; } diff --git a/Source/Workspace/Celbridge.Python/Services/PythonService.cs b/Source/Workspace/Celbridge.Python/Services/PythonService.cs index b0cc98f1b..3dfbecdf5 100644 --- a/Source/Workspace/Celbridge.Python/Services/PythonService.cs +++ b/Source/Workspace/Celbridge.Python/Services/PythonService.cs @@ -109,7 +109,7 @@ public async Task InitializePython() // Load the saved fingerprint once for use in both the pre-install check // and the offline mode comparison later. - var cacheDir = Path.Combine(workingDir, ProjectConstants.MetaDataFolder, ProjectConstants.CacheFolder); + var cacheDir = Path.Combine(workingDir, ProjectConstants.CelbridgeFolder, ProjectConstants.PythonFolder); var savedFingerprint = LoadSavedFingerprint(cacheDir); // If no fingerprint file exists, delete the installer's version marker BEFORE @@ -158,13 +158,13 @@ public async Task InitializePython() // Prepare the per-process environment variables for the terminal. // These are injected into the child process environment block rather than set // process-wide, so multiple terminals can have different configurations. - var ipythonDir = Path.Combine(workingDir, ProjectConstants.MetaDataFolder, ProjectConstants.CacheFolder, IPythonCacheFolderName); + var ipythonDir = Path.Combine(workingDir, ProjectConstants.CelbridgeFolder, ProjectConstants.PythonFolder, IPythonCacheFolderName); Directory.CreateDirectory(ipythonDir); var configuration = environmentInfo.Configuration; var celbridgeVersion = configuration == "Debug" ? $"{appVersion} (Debug)" : $"{appVersion}"; - var pythonLogFolder = Path.Combine(workingDir, ProjectConstants.CelbridgeFolder, ProjectConstants.CelbridgeLogsFolder); + var pythonLogFolder = Path.Combine(workingDir, ProjectConstants.CelbridgeFolder, ProjectConstants.LogsFolder); // Find a free TCP port for JSON-RPC communication var rpcPort = GetAvailableTcpPort(); diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index a6fc20098..035697f25 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -706,7 +706,7 @@ private async Task WriteWithRetryAsync(ResourceKey resource, byte[] byte var stagingFolder = Path.Combine( resourceRegistry.ProjectFolderPath, ProjectConstants.CelbridgeFolder, - ProjectConstants.CelbridgeStagingFsFolder); + ProjectConstants.StagingFsFolder); try { Directory.CreateDirectory(stagingFolder); diff --git a/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs b/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs index 9d54ac732..faa430692 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ProjectTreeBuilder.cs @@ -79,7 +79,7 @@ private static void RemoveHiddenFolders(List folderPaths, bool isProject var dirInfo = new DirectoryInfo(path); if (isProjectFolder - && dirInfo.Name == ProjectConstants.MetaDataFolder) + && dirInfo.Name == LegacyConstants.MetaDataFolder) { return true; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs index 06d6dd9f7..fae4a22a5 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs @@ -440,7 +440,7 @@ private bool ShouldIgnorePath(IResourceRootHandler handler, string fullPath) { var firstSegment = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0]; - if (firstSegment.Equals(ProjectConstants.MetaDataFolder, StringComparison.OrdinalIgnoreCase) || + if (firstSegment.Equals(LegacyConstants.MetaDataFolder, StringComparison.OrdinalIgnoreCase) || firstSegment.Equals(ProjectConstants.CelbridgeFolder, StringComparison.OrdinalIgnoreCase)) { return true; diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs index 70a190678..4ba33037a 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceService.cs @@ -68,10 +68,10 @@ public ResourceService( // staging-fs/. These need to exist before downstream services start reading // or watching them. var celbridgeFolder = Path.Combine(projectFolderPath, ProjectConstants.CelbridgeFolder); - var celbridgeTempFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTempFolder); - var celbridgeLogsFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeLogsFolder); - var celbridgeTrashFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeTrashFolder); - var celbridgeStagingFsFolder = Path.Combine(celbridgeFolder, ProjectConstants.CelbridgeStagingFsFolder); + var celbridgeTempFolder = Path.Combine(celbridgeFolder, ProjectConstants.TempFolder); + var celbridgeLogsFolder = Path.Combine(celbridgeFolder, ProjectConstants.LogsFolder); + var celbridgeTrashFolder = Path.Combine(celbridgeFolder, ProjectConstants.TrashFolder); + var celbridgeStagingFsFolder = Path.Combine(celbridgeFolder, ProjectConstants.StagingFsFolder); // temp:/ is wiped on every workspace load. The contract is that nothing // under temp: survives a reload; consumers needing persistence write @@ -92,9 +92,9 @@ public ResourceService( Directory.CreateDirectory(celbridgeStagingFsFolder); // Legacy /celbridge/.trash/ from before this redesign: discard. - // The other legacy /celbridge/.cache/, .logs/ folders are - // left alone (no live data; they retire alongside the entity service). - var legacyTrashFolder = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TrashFolder); + // The other legacy /celbridge/.cache/ folder is left alone + // (no live data; it retires alongside the entity service). + var legacyTrashFolder = Path.Combine(projectFolderPath, LegacyConstants.MetaDataFolder, LegacyConstants.TrashFolder); if (Directory.Exists(legacyTrashFolder)) { try @@ -110,7 +110,7 @@ public ResourceService( // Clean up the legacy temp folder from previous sessions. The atomic-write // staging area has moved to .celbridge/staging-fs/; any orphans here are // from before the chokepoint landed. - var legacyTempFolder = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.TempFolder); + var legacyTempFolder = Path.Combine(projectFolderPath, LegacyConstants.MetaDataFolder, LegacyConstants.TempFolder); if (Directory.Exists(legacyTempFolder)) { try @@ -195,7 +195,7 @@ protected virtual void Dispose(bool disposing) var trashFolderPath = Path.Combine( Registry.ProjectFolderPath, ProjectConstants.CelbridgeFolder, - ProjectConstants.CelbridgeTrashFolder); + ProjectConstants.TrashFolder); if (Directory.Exists(trashFolderPath)) { try diff --git a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs index ce7616e5d..e1f9dc3ed 100644 --- a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs @@ -39,7 +39,7 @@ public TrashService( private string TrashFolderPath => Path.Combine( ResourceRegistry.ProjectFolderPath, ProjectConstants.CelbridgeFolder, - ProjectConstants.CelbridgeTrashFolder); + ProjectConstants.TrashFolder); public async Task> MoveToTrashAsync(ResourceKey resource) { diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index df6477a6b..656fcdf3b 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -82,8 +82,12 @@ public WorkspaceService( var project = projectService.CurrentProject; Guard.IsNotNull(project); - var workspaceSettingsFolder = Path.Combine(project.ProjectFolderPath, ProjectConstants.MetaDataFolder, ProjectConstants.CacheFolder); + var workspaceSettingsFolder = Path.Combine( + project.ProjectFolderPath, + ProjectConstants.CelbridgeFolder, + ProjectConstants.SettingsFolder); Guard.IsNotNullOrEmpty(workspaceSettingsFolder); + Directory.CreateDirectory(workspaceSettingsFolder); WorkspaceSettingsService.WorkspaceSettingsFolderPath = workspaceSettingsFolder; _messengerService.Register(this, OnWorkspaceStateDirtyMessage); From f0428c5379a699b35d8db89a8cffd57a6e6c947c Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 29 May 2026 17:43:35 +0100 Subject: [PATCH 48/48] Rename CelFileReport to SidecarReport Replace the CelFileReport type with SidecarReport and update related APIs and callers. IResourceClassifier.ClassifyResources now returns SidecarReport; ResourceRegistry stores and exposes SidecarReport from UpdateResourceRegistry. The classifier no longer returns a sidecar-to-parent lookup and GetSidecarParent was removed. Root handler access was moved to IRootHandlerRegistry (exposed via IResourceService.RootHandlerRegistry) and callers/tests were updated accordingly. Other cleanups: delete unused IFileOperationService, simplify SidecarService return values, tighten ResourceOperationService return handling, adjust FileTools to use the root handler registry, improve ResourceMonitor logging and file-ignore checks, and update tests to match the new APIs. --- .../Resources/IFileOperationService.cs | 0 .../Resources/IResourceClassifier.cs | 13 +-- .../Resources/IResourceRegistry.cs | 56 +++--------- .../Tools/File/FileTools.Search.cs | 6 +- .../Tests/Resources/DataCheckProjectTests.cs | 7 +- Source/Tests/Resources/FileStorageTests.cs | 5 +- .../Resources/ResourceClassifierTestHelper.cs | 7 +- .../Resources/ResourceClassifierTests.cs | 12 +-- .../Tests/Resources/ResourceCommandTests.cs | 7 +- .../ResourceOperationServiceTests.cs | 1 - .../Tests/Resources/ResourceRegistryTests.cs | 38 +++------ .../Resources/SidecarClassificationTests.cs | 2 +- .../Tests/Resources/SidecarTrackingTests.cs | 24 +----- .../Commands/ProjectCheckCommand.cs | 6 +- .../Services/FileStorage.cs | 7 +- .../Services/ResourceClassifier.cs | 8 +- .../Services/ResourceMonitor.cs | 85 ++++++++++--------- .../Services/ResourceOperationService.cs | 13 +-- .../Services/ResourceRegistry.cs | 73 +++------------- .../Services/SidecarService.cs | 24 +++--- .../Services/TrashService.cs | 5 +- 21 files changed, 145 insertions(+), 254 deletions(-) delete mode 100644 Source/Core/Celbridge.Foundation/Resources/IFileOperationService.cs diff --git a/Source/Core/Celbridge.Foundation/Resources/IFileOperationService.cs b/Source/Core/Celbridge.Foundation/Resources/IFileOperationService.cs deleted file mode 100644 index e69de29bb..000000000 diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs index 661a00690..a7ce77d2c 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceClassifier.cs @@ -1,13 +1,5 @@ namespace Celbridge.Resources; -/// -/// Result of a single classification pass: the .cel parse-state report and -/// the sidecar-to-parent lookup. -/// -public sealed record ResourceClassificationResult( - CelFileReport Report, - IReadOnlyDictionary SidecarToParent); - /// /// Classifies every file in the project tree. Stamps each FileResource with /// its FileKind (PlainData, Sidecar, Standalone, Orphan, or InvalidSidecar), @@ -18,8 +10,7 @@ public interface IResourceClassifier { /// /// Walks the project root, stamps FileKind and Sidecar on every visited - /// FileResource, and returns the .cel parse-state report and - /// sidecar-to-parent lookup. + /// FileResource, and returns the .cel parse-state report. /// - ResourceClassificationResult ClassifyResources(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry); + SidecarReport ClassifyResources(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry); } diff --git a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs index e3e82cc74..721f676aa 100644 --- a/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs +++ b/Source/Core/Celbridge.Foundation/Resources/IResourceRegistry.cs @@ -1,16 +1,14 @@ namespace Celbridge.Resources; /// -/// Snapshot of every .cel file the registry knows about, partitioned by parse -/// state and orphan-ness. Used for project-load diagnostics and by -/// data_check_project to surface attention states. -/// -/// Parse state (Healthy / Broken) and orphan-ness are orthogonal dimensions: -/// an orphan .cel file with malformed content appears in both Broken and Orphan. -/// Files whose names end in .cel.cel are classified as Broken and never as a -/// regular sidecar. +/// Snapshot of the .cel files in the project tree, partitioned by parse state +/// and orphan-ness. Produced by the classifier on every UpdateResourceRegistry +/// pass; consumed by project-load diagnostics and data_check_project. +/// Parse state (Healthy / Broken) and orphan-ness are orthogonal: an orphan +/// .cel file with malformed content appears in both Broken and Orphan. Files +/// ending in .cel.cel are surfaced as Broken and are never treated as sidecars. /// -public record CelFileReport( +public record SidecarReport( IReadOnlyList Healthy, IReadOnlyList Broken, IReadOnlyList Orphan); @@ -52,9 +50,9 @@ public interface IResourceRegistry List GetResourceKeys(IEnumerable resources); /// - /// Returns the resource key for a resource at the specified path in the project. - /// The resource key will be generated even if the resource does not exist yet in the project. - /// Fails if the path is not within the project folder. + /// Returns the resource key for an absolute filesystem path under any registered root. + /// The resource key is generated even if no resource exists at that path yet. + /// Fails when the path is not under any registered root. /// Result GetResourceKey(string resourcePath); @@ -90,24 +88,6 @@ public interface IResourceRegistry /// Result UpdateResourceRegistry(); - /// - /// Registers a handler for the specified root name. The handler takes effect - /// immediately; subsequent resolution calls for that root delegate to it. - /// Replaces any handler previously registered for the same root name. - /// - void RegisterRootHandler(IResourceRootHandler handler); - - /// - /// The currently registered root handlers, keyed by root name. - /// - IReadOnlyDictionary RootHandlers { get; } - - /// - /// Returns true if the resource key's root is registered with this registry. - /// Use this for early validation at trust boundaries without performing a full resolve. - /// - bool IsResolvable(ResourceKey key); - /// /// Returns all file resources for the project root with their resource keys and absolute paths. /// The results are sorted by path for stable ordering. @@ -121,17 +101,9 @@ public interface IResourceRegistry IReadOnlyList GetAllFileResources(string root); /// - /// Returns the parent file resource of a sidecar key, or a failure result - /// if the sidecar has no corresponding parent. Sidecars at "foo.png.cel" - /// resolve to "foo.png"; sidecars whose name ends in ".cel.cel" are invalid - /// and never have a parent. - /// - Result GetSidecarParent(ResourceKey sidecar); - - /// - /// Returns a snapshot of every .cel file the registry knows about, - /// partitioned by parse state, orphan-ness, and the .cel.cel invalid - /// category. Used for project-load diagnostics and by data_check_project. + /// Returns the SidecarReport from the last completed UpdateResourceRegistry + /// pass. Project-load diagnostics and data_check_project consume this to + /// surface broken and orphan .cel files. /// - CelFileReport GetCelFileReport(); + SidecarReport GetSidecarReport(); } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs index 7dbb2b90b..2211558a5 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs @@ -18,7 +18,9 @@ public partial class FileTools public async partial Task Search(string pattern, bool includeMetadata = false, string type = "") { var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var resourceService = workspaceWrapper.WorkspaceService.ResourceService; + var resourceRegistry = resourceService.Registry; + var rootHandlerRegistry = resourceService.RootHandlerRegistry; var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; var regexPattern = GlobHelper.PathGlobToRegex(pattern); @@ -32,7 +34,7 @@ public async partial Task Search(string pattern, bool includeMet var patternRoot = ExtractRootPrefix(pattern); if (patternRoot is not null && patternRoot != ResourceKey.DefaultRoot - && resourceRegistry.RootHandlers.ContainsKey(patternRoot)) + && rootHandlerRegistry.RootHandlers.ContainsKey(patternRoot)) { return await SearchNonDefaultRootAsync( fileStorage, patternRoot, regex, isFolderSearch, includeMetadata); diff --git a/Source/Tests/Resources/DataCheckProjectTests.cs b/Source/Tests/Resources/DataCheckProjectTests.cs index b98e5d72a..48f89dfff 100644 --- a/Source/Tests/Resources/DataCheckProjectTests.cs +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -23,6 +23,7 @@ public class DataCheckProjectTests private string _projectFolderPath = null!; private string _logsBackingFolder = null!; private ResourceRegistry _resourceRegistry = null!; + private RootHandlerRegistry _rootHandlerRegistry = null!; private IMessengerService _messengerService = null!; private IWorkspaceWrapper _workspaceWrapper = null!; private ProjectCheckCommand _command = null!; @@ -46,21 +47,23 @@ public void Setup() _messengerService = new MessengerService(); var fileIconService = new FileIconService(); + _rootHandlerRegistry = new RootHandlerRegistry(); _resourceRegistry = new ResourceRegistry( Substitute.For>(), _messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), - new RootHandlerRegistry()); + _rootHandlerRegistry); _resourceRegistry.InitializeProjectRoot(_projectFolderPath); // ProjectCheckCommand writes its latest report to logs:project-check.log, // so the chokepoint needs a logs: root or the write step fails. - _resourceRegistry.RegisterRootHandler( + _rootHandlerRegistry.RegisterRootHandler( new LogsRootHandler(_logsBackingFolder)); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); + resourceService.RootHandlerRegistry.Returns(_rootHandlerRegistry); var workspaceService = Substitute.For(); workspaceService.ResourceService.Returns(resourceService); diff --git a/Source/Tests/Resources/FileStorageTests.cs b/Source/Tests/Resources/FileStorageTests.cs index 62d5bb92f..eb4b3e8af 100644 --- a/Source/Tests/Resources/FileStorageTests.cs +++ b/Source/Tests/Resources/FileStorageTests.cs @@ -31,14 +31,17 @@ public void Setup() _resourceRegistry = Substitute.For(); _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); - _resourceRegistry.RootHandlers.Returns(new Dictionary()); _resourceScanner = Substitute.For(); _resourceScanner.FindReferencersAsync(Arg.Any()).Returns(Task.FromResult>(Array.Empty())); _resourceScanner.FindAllReferencedTargetsAsync().Returns(Task.FromResult>(Array.Empty())); + var rootHandlerRegistry = Substitute.For(); + rootHandlerRegistry.RootHandlers.Returns(new Dictionary()); + var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); + resourceService.RootHandlerRegistry.Returns(rootHandlerRegistry); var workspaceService = Substitute.For(); workspaceService.ResourceService.Returns(resourceService); diff --git a/Source/Tests/Resources/ResourceClassifierTestHelper.cs b/Source/Tests/Resources/ResourceClassifierTestHelper.cs index 597ec9ad0..7045d9917 100644 --- a/Source/Tests/Resources/ResourceClassifierTestHelper.cs +++ b/Source/Tests/Resources/ResourceClassifierTestHelper.cs @@ -20,15 +20,12 @@ internal static class ResourceClassifierTestHelper public static IResourceClassifier BuildEmptyStub() { var stub = Substitute.For(); - var emptyReport = new CelFileReport( + var emptyReport = new SidecarReport( Healthy: Array.Empty(), Broken: Array.Empty(), Orphan: Array.Empty()); - var emptyResult = new ResourceClassificationResult( - emptyReport, - new Dictionary()); stub.ClassifyResources(Arg.Any(), Arg.Any()) - .Returns(emptyResult); + .Returns(emptyReport); return stub; } diff --git a/Source/Tests/Resources/ResourceClassifierTests.cs b/Source/Tests/Resources/ResourceClassifierTests.cs index 5c3ee6807..94187ca0a 100644 --- a/Source/Tests/Resources/ResourceClassifierTests.cs +++ b/Source/Tests/Resources/ResourceClassifierTests.cs @@ -63,7 +63,7 @@ public void StandaloneCelWithMultiPartExtensionRegistration_IsNotReportedAsOrpha var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetCelFileReport(); + var report = registry.GetSidecarReport(); report.Orphan.Should().NotContain(new ResourceKey("feature.note.cel")); report.Healthy.Should().Contain(new ResourceKey("feature.note.cel")); } @@ -86,7 +86,7 @@ public void BareCelExtensionRegistration_DoesNotPreventOrphanReport() var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetCelFileReport(); + var report = registry.GetSidecarReport(); report.Orphan.Should().Contain(new ResourceKey("orphaned.png.cel")); } @@ -103,7 +103,7 @@ public void OrphanCelWithNoFactoryClaim_IsStillReportedAsOrphan() var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetCelFileReport(); + var report = registry.GetSidecarReport(); report.Orphan.Should().Contain(new ResourceKey("scratch.unknown.cel")); } @@ -127,7 +127,7 @@ public void ParentedSidecar_IsNeverConsultedAgainstEditorRegistry() editorRegistry.DidNotReceive().IsExtensionSupported(Arg.Any()); - var report = registry.GetCelFileReport(); + var report = registry.GetSidecarReport(); report.Healthy.Should().Contain(new ResourceKey("foo.png.cel")); report.Orphan.Should().NotContain(new ResourceKey("foo.png.cel")); } @@ -152,7 +152,7 @@ public void NestedFolders_PairCorrectly_AndReportUsesRelativeKeys() noteResource.Sidecar!.Key.Should().Be(new ResourceKey("subfolder/note.md.cel")); noteResource.Sidecar.Status.Should().Be(CelFileStatus.Healthy); - registry.GetCelFileReport() + registry.GetSidecarReport() .Healthy.Should().Contain(new ResourceKey("subfolder/note.md.cel")); } @@ -163,7 +163,7 @@ public void EmptyTree_ProducesEmptyReport() var registry = BuildRegistry(classifier); registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = registry.GetCelFileReport(); + var report = registry.GetSidecarReport(); report.Healthy.Should().BeEmpty(); report.Broken.Should().BeEmpty(); report.Orphan.Should().BeEmpty(); diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index bf46cdff3..cf37d1a7d 100644 --- a/Source/Tests/Resources/ResourceCommandTests.cs +++ b/Source/Tests/Resources/ResourceCommandTests.cs @@ -22,6 +22,7 @@ public class ResourceCommandTests { private string _projectFolderPath = null!; private ResourceRegistry _resourceRegistry = null!; + private RootHandlerRegistry _rootHandlerRegistry = null!; private IWorkspaceWrapper _workspaceWrapper = null!; private const string FolderName = "Folder"; @@ -49,12 +50,14 @@ public void Setup() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + _rootHandlerRegistry = new RootHandlerRegistry(); + _resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), _rootHandlerRegistry); _resourceRegistry.InitializeProjectRoot(_projectFolderPath); _resourceRegistry.UpdateResourceRegistry(); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); + resourceService.RootHandlerRegistry.Returns(_rootHandlerRegistry); var workspaceService = Substitute.For(); workspaceService.ResourceService.Returns(resourceService); @@ -292,7 +295,7 @@ private string SetupLogsRoot(params string[] entries) File.WriteAllText(fullPath, "log content"); } } - _resourceRegistry.RegisterRootHandler(new LogsRootHandler(logsBacking)); + _rootHandlerRegistry.RegisterRootHandler(new LogsRootHandler(logsBacking)); return logsBacking; } diff --git a/Source/Tests/Resources/ResourceOperationServiceTests.cs b/Source/Tests/Resources/ResourceOperationServiceTests.cs index 379533073..cf37a41f0 100644 --- a/Source/Tests/Resources/ResourceOperationServiceTests.cs +++ b/Source/Tests/Resources/ResourceOperationServiceTests.cs @@ -35,7 +35,6 @@ public void Setup() _resourceRegistry = Substitute.For(); _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); - _resourceRegistry.RootHandlers.Returns(new Dictionary()); // Map every key under the default root to a path under the temp folder. _resourceRegistry.ResolveResourcePath(Arg.Any()).Returns(call => diff --git a/Source/Tests/Resources/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 77085e009..d714fc590 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -330,38 +330,22 @@ public void ProjectRootHandlerIsRegisteredOnProjectFolderPathSet() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var rootHandlerRegistry = new RootHandlerRegistry(); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), rootHandlerRegistry); // Before ProjectFolderPath is set, no handler is registered. - resourceRegistry.RootHandlers.Should().BeEmpty(); + rootHandlerRegistry.RootHandlers.Should().BeEmpty(); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); - resourceRegistry.RootHandlers.Should().ContainKey(ResourceKey.DefaultRoot); - var handler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; + rootHandlerRegistry.RootHandlers.Should().ContainKey(ResourceKey.DefaultRoot); + var handler = rootHandlerRegistry.RootHandlers[ResourceKey.DefaultRoot]; handler.RootName.Should().Be(ResourceKey.DefaultRoot); handler.BackingLocation.Should().Be(_resourceFolderPath); handler.Capabilities.IsWritable.Should().BeTrue(); handler.Capabilities.IsWatched.Should().BeTrue(); } - [Test] - public void IsResolvableReturnsTrueForProjectRootAndFalseForUnknownRoot() - { - Guard.IsNotNull(_resourceFolderPath); - - var messengerService = new MessengerService(); - var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); - resourceRegistry.InitializeProjectRoot(_resourceFolderPath); - - resourceRegistry.IsResolvable(ResourceKey.Create("foo/bar")).Should().BeTrue(); - resourceRegistry.IsResolvable(ResourceKey.Create("project:foo/bar")).Should().BeTrue(); - resourceRegistry.IsResolvable(ResourceKey.Empty).Should().BeTrue(); - resourceRegistry.IsResolvable(ResourceKey.Create("temp:foo/bar")).Should().BeFalse(); - resourceRegistry.IsResolvable(ResourceKey.Create("unknown:foo")).Should().BeFalse(); - } - [Test] public void ResolveResourcePathFailsClearlyForUnregisteredRoot() { @@ -410,10 +394,11 @@ public void RegisterRootHandlerReplacesExistingHandler() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var rootHandlerRegistry = new RootHandlerRegistry(); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), rootHandlerRegistry); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); - var originalHandler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; + var originalHandler = rootHandlerRegistry.RootHandlers[ResourceKey.DefaultRoot]; // Setting the path again replaces the handler with a new instance for the new path. var alternatePath = Path.Combine( @@ -423,7 +408,7 @@ public void RegisterRootHandlerReplacesExistingHandler() try { resourceRegistry.InitializeProjectRoot(alternatePath); - var newHandler = resourceRegistry.RootHandlers[ResourceKey.DefaultRoot]; + var newHandler = rootHandlerRegistry.RootHandlers[ResourceKey.DefaultRoot]; newHandler.Should().NotBeSameAs(originalHandler); newHandler.BackingLocation.Should().Be(alternatePath); } @@ -443,7 +428,8 @@ public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + var rootHandlerRegistry = new RootHandlerRegistry(); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), rootHandlerRegistry); resourceRegistry.InitializeProjectRoot(_resourceFolderPath); // Register a temp root whose backing folder is nested inside the project folder. @@ -452,7 +438,7 @@ public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() var tempBacking = Path.Combine(_resourceFolderPath, ".celbridge", "temp"); Directory.CreateDirectory(tempBacking); - resourceRegistry.RegisterRootHandler(new TempRootHandler(tempBacking)); + rootHandlerRegistry.RegisterRootHandler(new TempRootHandler(tempBacking)); // A path under the project tree but outside .celbridge/temp/ goes to project. var projectFilePath = Path.Combine(_resourceFolderPath, FileNameA); diff --git a/Source/Tests/Resources/SidecarClassificationTests.cs b/Source/Tests/Resources/SidecarClassificationTests.cs index ebebf1bef..4e37e90f2 100644 --- a/Source/Tests/Resources/SidecarClassificationTests.cs +++ b/Source/Tests/Resources/SidecarClassificationTests.cs @@ -104,7 +104,7 @@ public void MergeConflictMarkers_ClassifiedAsBroken_BytesUntouched() GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); File.ReadAllText(sidecarPath).Should().Be(originalContent); - _registry.GetCelFileReport().Broken.Should().Contain(new ResourceKey("foo.png.cel")); + _registry.GetSidecarReport().Broken.Should().Contain(new ResourceKey("foo.png.cel")); } [Test] diff --git a/Source/Tests/Resources/SidecarTrackingTests.cs b/Source/Tests/Resources/SidecarTrackingTests.cs index 7833d7042..0b96f45ca 100644 --- a/Source/Tests/Resources/SidecarTrackingTests.cs +++ b/Source/Tests/Resources/SidecarTrackingTests.cs @@ -75,22 +75,6 @@ public void HealthySidecar_IsPairedWithStatusHealthy() fileResource!.Sidecar.Should().NotBeNull(); fileResource.Sidecar!.Key.Should().Be(new ResourceKey("foo.png.cel")); fileResource.Sidecar.Status.Should().Be(CelFileStatus.Healthy); - - var parentResult = _registry.GetSidecarParent(new ResourceKey("foo.png.cel")); - parentResult.IsSuccess.Should().BeTrue(); - parentResult.Value.Name.Should().Be("foo.png"); - } - - [Test] - public void GetSidecarParent_FailsForNonSidecarKey() - { - File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); - _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - - var result = _registry.GetSidecarParent(new ResourceKey("foo.png")); - - result.IsSuccess.Should().BeFalse(); - result.FirstErrorMessage.Should().Contain("not a sidecar key"); } [Test] @@ -101,7 +85,7 @@ public void OrphanSidecar_AppearsInReportOrphan() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetCelFileReport(); + var report = _registry.GetSidecarReport(); report.Orphan.Should().Contain(new ResourceKey("foo.png.cel")); } @@ -116,7 +100,7 @@ public void CelCelFile_AppearsInReportBroken() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetCelFileReport(); + var report = _registry.GetSidecarReport(); report.Broken.Should().Contain(new ResourceKey("foo.png.cel.cel")); // foo.png.cel is still healthy and paired with foo.png; the .cel.cel @@ -135,7 +119,7 @@ public void UnparseableSidecar_AppearsInReportBroken() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetCelFileReport(); + var report = _registry.GetSidecarReport(); report.Broken.Should().Contain(new ResourceKey("foo.png.cel")); var parent = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; @@ -168,7 +152,7 @@ public void BrokenOrphan_AppearsInBothBrokenAndOrphan() _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); - var report = _registry.GetCelFileReport(); + var report = _registry.GetSidecarReport(); report.Broken.Should().Contain(new ResourceKey("lonely.cel")); report.Orphan.Should().Contain(new ResourceKey("lonely.cel")); } diff --git a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs index 53d115cd5..63dd9940e 100644 --- a/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs +++ b/Source/Workspace/Celbridge.Resources/Commands/ProjectCheckCommand.cs @@ -67,11 +67,11 @@ public override async Task ExecuteAsync() return string.Compare(a.Source.ToString(), b.Source.ToString(), StringComparison.Ordinal); }); - var celFileReport = registry.GetCelFileReport(); - var orphanCelFiles = celFileReport.Orphan + var sidecarReport = registry.GetSidecarReport(); + var orphanCelFiles = sidecarReport.Orphan .OrderBy(k => k.ToString(), StringComparer.Ordinal) .ToList(); - var brokenCelFiles = celFileReport.Broken + var brokenCelFiles = sidecarReport.Broken .OrderBy(k => k.ToString(), StringComparer.Ordinal) .ToList(); diff --git a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs index 035697f25..637ad28aa 100644 --- a/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs +++ b/Source/Workspace/Celbridge.Resources/Services/FileStorage.cs @@ -650,8 +650,11 @@ private Result ResolvePath(ResourceKey resource) return resourceRegistry.ResolveResourcePath(resource); } - // Roots that don't have a registered handler are assumed writable — the - // default project root falls into this category and is always writable. + // In production the caller always invokes IsRootWritable after ResolvePath + // has succeeded, so a registered handler is guaranteed to be present. Unit + // tests that stub ResolveResourcePath directly can reach this without a + // handler in RootHandlers; treat that case as writable so those tests don't + // need to populate the handler dictionary just to exercise writes. private static bool IsRootWritable(IRootHandlerRegistry rootHandlerRegistry, ResourceKey key) { return !rootHandlerRegistry.RootHandlers.TryGetValue(key.Root, out var handler) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs index aa4569118..6384ca225 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceClassifier.cs @@ -18,24 +18,21 @@ public ResourceClassifier( _workspaceWrapper = workspaceWrapper; } - public ResourceClassificationResult ClassifyResources(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry) + public SidecarReport ClassifyResources(IFolderResource projectRoot, IRootHandlerRegistry rootHandlerRegistry) { var healthy = new List(); var broken = new List(); var orphan = new List(); - var sidecarToParent = new Dictionary(); var editorRegistry = ResolveEditorRegistry(); ProcessFolder(projectRoot); - var report = new CelFileReport( + return new SidecarReport( Healthy: healthy, Broken: broken, Orphan: orphan); - return new ResourceClassificationResult(report, sidecarToParent); - void ProcessFolder(IFolderResource folder) { // Sibling name lookup keeps the per-file pairing checks O(1). @@ -135,7 +132,6 @@ void ClassifySidecarFile(FileResource sidecarFile, Dictionary if (siblingByName.TryGetValue(parentName, out var parentSibling) && parentSibling is FileResource parentFile) { - sidecarToParent[sidecarKey] = ResourceTreeNavigator.BuildKey(parentFile); parentFile.Sidecar = new SidecarLink(sidecarKey, status); sidecarFile.FileKind = FileKind.Sidecar; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs index fae4a22a5..c74be02ab 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceMonitor.cs @@ -62,8 +62,8 @@ public Result Initialize() // Spin up one FileSystemWatcher per registered root that opted in via Capabilities.IsWatched. // WorkspaceLoader calls Initialize after the workspace finishes constructing, so the wrapper // returns the configured registry instance here. - var registry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - foreach (var handler in registry.RootHandlers.Values) + var rootHandlerRegistry = _workspaceWrapper.WorkspaceService.ResourceService.RootHandlerRegistry; + foreach (var handler in rootHandlerRegistry.RootHandlers.Values) { if (!handler.Capabilities.IsWatched) { @@ -143,7 +143,7 @@ public void Shutdown() } catch (Exception ex) { - _logger.LogError($"Error occurred while shutting down resource monitor: {ex.Message}"); + _logger.LogError(ex, "Error occurred while shutting down resource monitor"); } } @@ -214,7 +214,14 @@ private void OnFileSystemRenamed(IResourceRootHandler handler, RenamedEventArgs private void OnFileSystemError(object sender, ErrorEventArgs e) { var exception = e.GetException(); - _logger.LogError($"File system watcher error: {exception?.Message ?? "Unknown error"}"); + if (exception is not null) + { + _logger.LogError(exception, "File system watcher error"); + } + else + { + _logger.LogError("File system watcher error (no exception attached)"); + } } private void ScheduleResourceUpdateIfProjectRoot(IResourceRootHandler handler) @@ -341,8 +348,8 @@ private void OnResourceRenamed(IResourceRootHandler handler, string oldFullPath, var oldResourceKey = BuildResourceKey(handler, oldFullPath); var newResourceKey = BuildResourceKey(handler, newFullPath); - if ((oldResourceKey.IsEmpty || newResourceKey.IsEmpty) && - handler.RootName == ResourceKey.DefaultRoot) + if ((oldResourceKey.IsEmpty || newResourceKey.IsEmpty) + && handler.RootName == ResourceKey.DefaultRoot) { return; } @@ -360,11 +367,12 @@ private bool ShouldIgnorePath(IResourceRootHandler handler, string fullPath) { var fileName = Path.GetFileName(fullPath); - // Cross-platform: Unix hidden files start with '.' - if (fileName.StartsWith(".") || - fileName.StartsWith("~") || - fileName.EndsWith(".tmp") || - fileName.EndsWith("~")) // Emacs/many editors' backup files (e.g. "foo.md~") + // Unix hidden files start with '.'. Trailing '~' catches the backup + // files written by Emacs and many other editors (e.g. "foo.md~"). + if (fileName.StartsWith(".") + || fileName.StartsWith("~") + || fileName.EndsWith(".tmp") + || fileName.EndsWith("~")) { return true; } @@ -378,42 +386,42 @@ private bool ShouldIgnorePath(IResourceRootHandler handler, string fullPath) return true; } - // Common editor swap/lock/partial-download patterns from other tools. - if (fileName.EndsWith(".swp", StringComparison.OrdinalIgnoreCase) || // Vim swap - fileName.EndsWith(".swo", StringComparison.OrdinalIgnoreCase) || // Vim swap (second) - fileName.EndsWith(".swn", StringComparison.OrdinalIgnoreCase) || // Vim swap (third) - fileName.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) || // Chrome/Edge partial download - fileName.EndsWith(".part", StringComparison.OrdinalIgnoreCase)) // Firefox/wget partial download + // Swap files (.swp/.swo/.swn from Vim), partial-download files + // (.crdownload from Chrome/Edge, .part from Firefox/wget). + if (fileName.EndsWith(".swp", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".swo", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".swn", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".part", StringComparison.OrdinalIgnoreCase)) { return true; } - // Ignore Office temporary files - // Excel creates temp files with random hex names (e.g., "FED4B600") during save operations - // Excel also creates lock files like "~$filename.xlsx" - if (fileName.StartsWith("~$") || // Excel lock files - fileName.StartsWith("~WRL")) // Word temporary files + // Office temporary files. Excel writes lock files like "~$filename.xlsx"; + // Word writes temporary files prefixed "~WRL". + if (fileName.StartsWith("~$") + || fileName.StartsWith("~WRL")) { return true; } - // Ignore Python temporary and cache files - if (fileName.EndsWith(".pyc") || // Compiled Python files - fileName.EndsWith(".pyo") || // Optimized Python files - fileName.EndsWith(".pyd") || // Python dynamic modules - fileName.StartsWith("__pycache__")) // Python cache directory + // Python build artifacts: compiled (.pyc), optimised (.pyo), + // dynamic-module (.pyd), and the __pycache__ folder. + if (fileName.EndsWith(".pyc") + || fileName.EndsWith(".pyo") + || fileName.EndsWith(".pyd") + || fileName.StartsWith("__pycache__")) { return true; } - // Windows-specific checks if (OperatingSystem.IsWindows()) { try { var attributes = File.GetAttributes(fullPath); - if ((attributes & System.IO.FileAttributes.Hidden) != 0 || - (attributes & System.IO.FileAttributes.System) != 0) + if ((attributes & System.IO.FileAttributes.Hidden) != 0 + || (attributes & System.IO.FileAttributes.System) != 0) { return true; } @@ -440,21 +448,22 @@ private bool ShouldIgnorePath(IResourceRootHandler handler, string fullPath) { var firstSegment = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0]; - if (firstSegment.Equals(LegacyConstants.MetaDataFolder, StringComparison.OrdinalIgnoreCase) || - firstSegment.Equals(ProjectConstants.CelbridgeFolder, StringComparison.OrdinalIgnoreCase)) + if (firstSegment.Equals(LegacyConstants.MetaDataFolder, StringComparison.OrdinalIgnoreCase) + || firstSegment.Equals(ProjectConstants.CelbridgeFolder, StringComparison.OrdinalIgnoreCase)) { return true; } } } - // Ignore specific files and folders at any level + // Ignore tool folders at any depth. var pathParts = fullPath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (pathParts.Any(part => part.Equals(".vs", StringComparison.OrdinalIgnoreCase) || - part.Equals("bin", StringComparison.OrdinalIgnoreCase) || - part.Equals("obj", StringComparison.OrdinalIgnoreCase) || - part.Equals(".git", StringComparison.OrdinalIgnoreCase) || - part.Equals("__pycache__", StringComparison.OrdinalIgnoreCase))) + if (pathParts.Any(part => + part.Equals(".vs", StringComparison.OrdinalIgnoreCase) + || part.Equals("bin", StringComparison.OrdinalIgnoreCase) + || part.Equals("obj", StringComparison.OrdinalIgnoreCase) + || part.Equals(".git", StringComparison.OrdinalIgnoreCase) + || part.Equals("__pycache__", StringComparison.OrdinalIgnoreCase))) { return true; } diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs index 73ef16a9f..e258a571a 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceOperationService.cs @@ -34,14 +34,6 @@ public ResourceOperationService( _workspaceWrapper = workspaceWrapper; } - // Default outcomes returned when the chokepoint's cascade did not run - // (typically because the source is outside the project tree). - private static readonly CopyResult EmptyCopyResult = new(SidecarOutcome.NotPresent); - private static readonly MoveResult EmptyMoveResult = new( - Array.Empty(), - Array.Empty(), - SidecarOutcome.NotPresent); - private IEntityService? EntityService => _workspaceWrapper.IsWorkspacePageLoaded ? _workspaceWrapper.WorkspaceService.EntityService : null; @@ -123,7 +115,8 @@ public async Task> CopyAsync(ResourceKey source, ResourceKey } AddOperation(operation); - return operation.LastCopyResult ?? EmptyCopyResult; + + return operation.LastCopyResult!; } public async Task> MoveAsync(ResourceKey source, ResourceKey dest) @@ -170,7 +163,7 @@ public async Task> MoveAsync(ResourceKey source, ResourceKey AddOperation(operation); - return operation.LastMoveResult ?? EmptyMoveResult; + return operation.LastMoveResult!; } public async Task DeleteAsync(ResourceKey resource) diff --git a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs index 133b57e69..598f8d8e8 100644 --- a/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs +++ b/Source/Workspace/Celbridge.Resources/Services/ResourceRegistry.cs @@ -16,11 +16,10 @@ public class ResourceRegistry : IResourceRegistry // The report is rebuilt atomically per pass so readers always see a coherent // snapshot. private readonly object _sidecarLock = new(); - private CelFileReport _celFileReport = new( + private SidecarReport _sidecarReport = new( Healthy: Array.Empty(), Broken: Array.Empty(), Orphan: Array.Empty()); - private readonly Dictionary _sidecarToParent = new(); private string _projectFolderPath = string.Empty; @@ -39,8 +38,6 @@ public void InitializeProjectRoot(string projectFolderPath) public IFolderResource ProjectFolder => _projectFolder; - public IReadOnlyDictionary RootHandlers => _rootHandlerRegistry.RootHandlers; - public ResourceRegistry( ILogger logger, IMessengerService messengerService, @@ -55,16 +52,6 @@ public ResourceRegistry( _rootHandlerRegistry = rootHandlerRegistry; } - public void RegisterRootHandler(IResourceRootHandler handler) - { - _rootHandlerRegistry.RegisterRootHandler(handler); - } - - public bool IsResolvable(ResourceKey key) - { - return _rootHandlerRegistry.IsResolvable(key); - } - public ResourceKey GetResourceKey(IResource resource) { return ResourceTreeNavigator.BuildKey(resource); @@ -192,24 +179,18 @@ public Result UpdateResourceRegistry() var newRoot = (FolderResource)_projectTreeBuilder.BuildTree(ProjectFolderPath); // Sidecar pairing runs on the new tree before publication. The - // pairing service sets each parent FileResource.Sidecar in place - // and returns the report and sidecar-to-parent lookup, which are - // swapped under the lock alongside the tree reference. The root - // handler registry is handed in so per-sidecar path resolution - // goes through the same reparse-point chokepoint as every other - // resource operation. - var pairings = _resourceClassifier.ClassifyResources(newRoot, _rootHandlerRegistry); + // classifier sets each parent FileResource.Sidecar in place and + // returns the report, which is swapped under the lock alongside + // the tree reference. The root handler registry is handed in so + // per-sidecar path resolution goes through the same reparse-point + // chokepoint as every other resource operation. + var sidecarReport = _resourceClassifier.ClassifyResources(newRoot, _rootHandlerRegistry); Volatile.Write(ref _projectFolder, newRoot); lock (_sidecarLock) { - _sidecarToParent.Clear(); - foreach (var entry in pairings.SidecarToParent) - { - _sidecarToParent[entry.Key] = entry.Value; - } - _celFileReport = pairings.Report; + _sidecarReport = sidecarReport; } _rootHandlerRegistry.InvalidatePathCache(); @@ -283,45 +264,11 @@ private void CollectFileResources( } } - public Result GetSidecarParent(ResourceKey sidecar) - { - if (!sidecar.Path.EndsWith(SidecarHelper.Extension, StringComparison.OrdinalIgnoreCase)) - { - return Result.Fail( - $"Resource key '{sidecar}' is not a sidecar key (does not end in '{SidecarHelper.Extension}')."); - } - - lock (_sidecarLock) - { - if (!_sidecarToParent.TryGetValue(sidecar, out var parentKey)) - { - return Result.Fail( - $"No parent file is paired with sidecar '{sidecar}'."); - } - - var resourceResult = GetResource(parentKey); - if (resourceResult.IsFailure) - { - return Result.Fail( - $"Failed to resolve parent file '{parentKey}' for sidecar '{sidecar}'.") - .WithErrors(resourceResult); - } - - if (resourceResult.Value is not IFileResource fileResource) - { - return Result.Fail( - $"Parent of sidecar '{sidecar}' is not a file resource."); - } - - return Result.Ok(fileResource); - } - } - - public CelFileReport GetCelFileReport() + public SidecarReport GetSidecarReport() { lock (_sidecarLock) { - return _celFileReport; + return _sidecarReport; } } diff --git a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs index e080636e3..f4b3e0ed6 100644 --- a/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/SidecarService.cs @@ -25,17 +25,18 @@ public Result GetSidecarKey(ResourceKey parent) { if (parent.IsEmpty) { - return Result.Fail("Cannot build a sidecar key for an empty resource."); + return Result.Fail("Cannot build a sidecar key for an empty resource."); } if (parent.Root != ResourceKey.DefaultRoot) { - return Result.Fail($"Sidecars are only supported on the project root; resource '{parent}' is on root '{parent.Root}'."); + return Result.Fail($"Sidecars are only supported on the project root; resource '{parent}' is on root '{parent.Root}'."); } if (IsSidecarKey(parent)) { - return Result.Fail($"Cannot build a sidecar key for sidecar resource '{parent}': pass the parent resource key instead."); + return Result.Fail($"Cannot build a sidecar key for sidecar resource '{parent}': pass the parent resource key instead."); } - return Result.Ok(new ResourceKey(parent.FullKey + SidecarHelper.Extension)); + + return new ResourceKey(parent.FullKey + SidecarHelper.Extension); } public bool IsValidBlockName(string name) => SidecarHelper.IsValidBlockName(name); @@ -47,7 +48,7 @@ public async Task> ReadAsync(ResourceKey resource) var resolveResult = ResolveSidecarKey(resource); if (resolveResult.IsFailure) { - return Result.Fail(resolveResult); + return Result.Fail(resolveResult); } var sidecarKey = resolveResult.Value; @@ -57,22 +58,22 @@ public async Task> ReadAsync(ResourceKey resource) if (infoResult.IsFailure || infoResult.Value.Kind == StorageItemKind.NotFound) { - return Result.Ok(new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null)); + return new SidecarReadResult(SidecarReadOutcome.NoSidecar, null, null); } var readResult = await fileStorage.ReadAllTextAsync(sidecarKey); if (readResult.IsFailure) { - return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Broken, null, readResult.FirstErrorMessage)); + return new SidecarReadResult(SidecarReadOutcome.Broken, null, readResult.FirstErrorMessage); } var parseResult = SidecarHelper.Parse(readResult.Value); if (parseResult.IsFailure) { - return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Broken, null, parseResult.FirstErrorMessage)); + return new SidecarReadResult(SidecarReadOutcome.Broken, null, parseResult.FirstErrorMessage); } - return Result.Ok(new SidecarReadResult(SidecarReadOutcome.Healthy, parseResult.Value, null)); + return new SidecarReadResult(SidecarReadOutcome.Healthy, parseResult.Value, null); } public async Task SetFieldAsync(ResourceKey resource, string field, object value) @@ -345,16 +346,17 @@ private Result ResolveSidecarKey(ResourceKey resource) { if (resource.IsEmpty) { - return Result.Fail("Cannot resolve sidecar key for an empty resource."); + return Result.Fail("Cannot resolve sidecar key for an empty resource."); } if (resource.Root != ResourceKey.DefaultRoot) { - return Result.Fail($"Sidecars are only supported on the project root; resource '{resource}' is on root '{resource.Root}'."); + return Result.Fail($"Sidecars are only supported on the project root; resource '{resource}' is on root '{resource.Root}'."); } if (IsSidecarKey(resource)) { return resource; } + return GetSidecarKey(resource); } } diff --git a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs index e1f9dc3ed..5ceeccc3f 100644 --- a/Source/Workspace/Celbridge.Resources/Services/TrashService.cs +++ b/Source/Workspace/Celbridge.Resources/Services/TrashService.cs @@ -322,8 +322,9 @@ public async Task RestoreFromTrashAsync(TrashEntry entry) } } - public Task PurgeAsync(TrashEntry entry) + public async Task PurgeAsync(TrashEntry entry) { + await Task.CompletedTask; try { if (entry.WasFolder) @@ -368,7 +369,7 @@ public Task PurgeAsync(TrashEntry entry) _logger.LogWarning(ex, $"Best-effort trash purge failed for resource: '{entry.OriginalResource}'"); } - return Task.FromResult(Result.Ok()); + return Result.Ok(); } // Move a file into the trash subtree, creating any missing parent folders