diff --git a/Source/Core/Celbridge.Foundation/Packages/IPackageLocalizationService.cs b/Source/Core/Celbridge.Foundation/Packages/IPackageLocalizationService.cs index 0dd76740..aca60da8 100644 --- a/Source/Core/Celbridge.Foundation/Packages/IPackageLocalizationService.cs +++ b/Source/Core/Celbridge.Foundation/Packages/IPackageLocalizationService.cs @@ -6,10 +6,11 @@ namespace Celbridge.Packages; public interface IPackageLocalizationService { /// - /// Loads localization strings from a package's localization folder. - /// Uses convention: {packageFolder}/localization/{locale}.json - /// If locale is null, uses the current UI culture. - /// Falls back to "en.json" if the requested locale is not found, then to an empty dictionary. + /// Loads localization strings for the supplied package. Uses the convention + /// {package.PackageFolder}/localization/{locale}.json, with package.Origin + /// selecting whether the underlying read crosses the IFileStorage chokepoint. + /// If locale is null, uses the current UI culture. Falls back to "en.json" + /// if the requested locale is not found, then to an empty dictionary. /// - Dictionary LoadStrings(string packageFolder, string? locale = null); + Dictionary LoadStrings(PackageInfo package, string? locale = null); } diff --git a/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs b/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs index 0d3e9ac7..c25e2e64 100644 --- a/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs +++ b/Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs @@ -1,5 +1,27 @@ namespace Celbridge.Packages; +/// +/// Discovery origin of a package. Determines whether file reads cross the +/// IFileStorage chokepoint (Project) or stay on direct File.* IO (Bundled, +/// until the bundled-from-assembly migration lands). +/// +public enum PackageOrigin +{ + /// + /// First-party package shipped inside a Celbridge module DLL. Read sites + /// stay on direct File.* IO against the install folder. + /// + Bundled, + + /// + /// User package discovered under the project's packages/ folder. Read + /// sites resolve a ResourceKey via IResourceRegistry and read through + /// IFileStorage so the chokepoint contract holds for every project-tree + /// byte. + /// + Project +} + /// /// Package identity, permissions, and hosting information. /// Shared across all contributions from the same package. @@ -42,6 +64,13 @@ public partial record PackageInfo /// public bool DevToolsBlocked { get; init; } + /// + /// Whether the package was discovered as a bundled (in-module) or project + /// (project-tree) package. Drives the read path selection at every site + /// that loads bytes for the package. + /// + public PackageOrigin Origin { get; init; } + /// /// The folder containing the package (set during loading, not from TOML). /// diff --git a/Source/Tests/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index c7a7224f..5a422929 100644 --- a/Source/Tests/Packages/FileTypeProviderTests.cs +++ b/Source/Tests/Packages/FileTypeProviderTests.cs @@ -34,8 +34,6 @@ public void Setup() var logger = Substitute.For>(); var messengerService = Substitute.For(); - var localizationLogger = Substitute.For>(); - var localizationService = new PackageLocalizationService(localizationLogger); var resourceRegistry = Substitute.For(); resourceRegistry.ProjectFolderPath.Returns(_tempProjectFolder); @@ -44,6 +42,17 @@ public void Setup() var key = callInfo.Arg(); return Result.Ok(Path.Combine(_tempProjectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); }); + resourceRegistry.GetResourceKey(Arg.Any()).Returns(callInfo => + { + var path = callInfo.Arg(); + if (!path.StartsWith(_tempProjectFolder, StringComparison.OrdinalIgnoreCase)) + { + return Result.Fail($"Path '{path}' is not under the project root"); + } + var relative = Path.GetRelativePath(_tempProjectFolder, path) + .Replace(Path.DirectorySeparatorChar, '/'); + return Result.Ok(new ResourceKey(relative)); + }); var resourceService = Substitute.For(); resourceService.Registry.Returns(resourceRegistry); @@ -60,6 +69,9 @@ public void Setup() workspaceWrapper); workspaceService.FileStorage.Returns(fileStorage); + var localizationLogger = Substitute.For>(); + var localizationService = new PackageLocalizationService(localizationLogger, workspaceWrapper); + var registry = new PackageRegistry(logger, _moduleService, _featureFlags, localizationService, workspaceWrapper); _service = new PackageService(messengerService, registry); } diff --git a/Source/Tests/Packages/LocalizationHelperTests.cs b/Source/Tests/Packages/LocalizationHelperTests.cs index 99b00ee4..37fc142d 100644 --- a/Source/Tests/Packages/LocalizationHelperTests.cs +++ b/Source/Tests/Packages/LocalizationHelperTests.cs @@ -1,4 +1,5 @@ using Celbridge.Packages; +using Celbridge.Workspace; namespace Celbridge.Tests.Packages; @@ -7,6 +8,7 @@ public class PackageLocalizationServiceTests { private string _tempFolder = null!; private IPackageLocalizationService _service = null!; + private PackageInfo _bundledPackage = null!; [SetUp] public void Setup() @@ -15,7 +17,20 @@ public void Setup() Directory.CreateDirectory(_tempFolder); var logger = Substitute.For>(); - _service = new PackageLocalizationService(logger); + + // The localization service only consults the workspace wrapper for + // Project-origin packages; tests use Bundled-origin packages so the + // wrapper is never dereferenced. + var workspaceWrapper = Substitute.For(); + _service = new PackageLocalizationService(logger, workspaceWrapper); + + _bundledPackage = new PackageInfo + { + Id = "test.package", + Name = "Test Package", + PackageFolder = _tempFolder, + Origin = PackageOrigin.Bundled + }; } [TearDown] @@ -39,7 +54,7 @@ public void LoadStrings_ExactLocale_ReturnsStrings() } """); - var result = _service.LoadStrings(_tempFolder, "fr"); + var result = _service.LoadStrings(_bundledPackage, "fr"); result.Should().HaveCount(2); result["Greeting"].Should().Be("Bonjour"); @@ -58,7 +73,7 @@ public void LoadStrings_MissingLocale_FallsBackToEnglish() } """); - var result = _service.LoadStrings(_tempFolder, "ja"); + var result = _service.LoadStrings(_bundledPackage, "ja"); result.Should().HaveCount(2); result["Hello"].Should().Be("Hello"); @@ -71,7 +86,7 @@ public void LoadStrings_NoLocaleFiles_ReturnsEmptyDictionary() var localizationFolder = Path.Combine(_tempFolder, PackageLocalizationService.LocalizationFolder); Directory.CreateDirectory(localizationFolder); - var result = _service.LoadStrings(_tempFolder, "de"); + var result = _service.LoadStrings(_bundledPackage, "de"); result.Should().BeEmpty(); } @@ -79,7 +94,7 @@ public void LoadStrings_NoLocaleFiles_ReturnsEmptyDictionary() [Test] public void LoadStrings_NoLocalizationFolder_ReturnsEmptyDictionary() { - var result = _service.LoadStrings(_tempFolder, "en"); + var result = _service.LoadStrings(_bundledPackage, "en"); result.Should().BeEmpty(); } @@ -91,7 +106,7 @@ public void LoadStrings_InvalidJson_ReturnsEmptyDictionary() Directory.CreateDirectory(localizationFolder); File.WriteAllText(Path.Combine(localizationFolder, "en.json"), "{ not valid json }"); - var result = _service.LoadStrings(_tempFolder, "en"); + var result = _service.LoadStrings(_bundledPackage, "en"); result.Should().BeEmpty(); } @@ -107,7 +122,7 @@ public void LoadStrings_EnglishRequested_DoesNotDoubleLoad() } """); - var result = _service.LoadStrings(_tempFolder, "en"); + var result = _service.LoadStrings(_bundledPackage, "en"); result.Should().HaveCount(1); result["Key"].Should().Be("Value"); @@ -126,7 +141,7 @@ public void LoadStrings_CommentsAndTrailingCommas_AreAllowed() } """); - var result = _service.LoadStrings(_tempFolder, "en"); + var result = _service.LoadStrings(_bundledPackage, "en"); result.Should().HaveCount(2); result["Key1"].Should().Be("Value1"); diff --git a/Source/Tests/Packages/PackageRegistryTests.cs b/Source/Tests/Packages/PackageRegistryTests.cs index 12ec7053..61ba7b68 100644 --- a/Source/Tests/Packages/PackageRegistryTests.cs +++ b/Source/Tests/Packages/PackageRegistryTests.cs @@ -26,8 +26,6 @@ public void Setup() var logger = Substitute.For>(); _messengerService = Substitute.For(); - var localizationLogger = Substitute.For>(); - var localizationService = new PackageLocalizationService(localizationLogger); _moduleService = Substitute.For(); _moduleService.GetBundledPackages().Returns(new List()); var featureFlags = Substitute.For(); @@ -40,6 +38,17 @@ public void Setup() var key = callInfo.Arg(); return Result.Ok(Path.Combine(_tempProjectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar))); }); + _resourceRegistry.GetResourceKey(Arg.Any()).Returns(callInfo => + { + var path = callInfo.Arg(); + if (!path.StartsWith(_tempProjectFolder, StringComparison.OrdinalIgnoreCase)) + { + return Result.Fail($"Path '{path}' is not under the project root"); + } + var relative = Path.GetRelativePath(_tempProjectFolder, path) + .Replace(Path.DirectorySeparatorChar, '/'); + return Result.Ok(new ResourceKey(relative)); + }); var resourceService = Substitute.For(); resourceService.Registry.Returns(_resourceRegistry); @@ -56,6 +65,9 @@ public void Setup() workspaceWrapper); workspaceService.FileStorage.Returns(fileStorage); + var localizationLogger = Substitute.For>(); + var localizationService = new PackageLocalizationService(localizationLogger, workspaceWrapper); + var registry = new PackageRegistry(logger, _moduleService, featureFlags, localizationService, workspaceWrapper); _service = new PackageService(_messengerService, registry); } diff --git a/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs b/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs index 2a308839..0f6ed8d3 100644 --- a/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs +++ b/Source/Workspace/Celbridge.Documents/Services/CustomDocumentViewFactory.cs @@ -45,7 +45,7 @@ private string ResolveDisplayName(IPackageLocalizationService localizationServic // value when the key is not present (which also handles plain strings). var displayKey = _contribution.DisplayName; - var localizationStrings = localizationService.LoadStrings(_contribution.Package.PackageFolder); + var localizationStrings = localizationService.LoadStrings(_contribution.Package); if (localizationStrings.TryGetValue(displayKey, out var localizedName)) { return localizedName; diff --git a/Source/Workspace/Celbridge.Packages/Services/DirectPackageReader.cs b/Source/Workspace/Celbridge.Packages/Services/DirectPackageReader.cs new file mode 100644 index 00000000..039347e9 --- /dev/null +++ b/Source/Workspace/Celbridge.Packages/Services/DirectPackageReader.cs @@ -0,0 +1,41 @@ +namespace Celbridge.Packages; + +/// +/// Reader used for bundled packages. Reads come straight off disk because +/// bundled assets sit outside any IResourceRegistry root and cannot be +/// addressed by a ResourceKey. Replaced by an assembly-resource reader when +/// the bundled-from-assembly migration lands. +/// +public sealed class DirectPackageReader : IPackageReader +{ + public bool Exists(string absolutePath) + { + return File.Exists(absolutePath); + } + + public Result ReadAllText(string absolutePath) + { + try + { + return File.ReadAllText(absolutePath); + } + catch (Exception ex) + { + return Result.Fail($"Failed to read file: '{absolutePath}'") + .WithException(ex); + } + } + + public Result ReadAllBytes(string absolutePath) + { + try + { + return File.ReadAllBytes(absolutePath); + } + catch (Exception ex) + { + return Result.Fail($"Failed to read file: '{absolutePath}'") + .WithException(ex); + } + } +} diff --git a/Source/Workspace/Celbridge.Packages/Services/FileStoragePackageReader.cs b/Source/Workspace/Celbridge.Packages/Services/FileStoragePackageReader.cs new file mode 100644 index 00000000..3b53b30f --- /dev/null +++ b/Source/Workspace/Celbridge.Packages/Services/FileStoragePackageReader.cs @@ -0,0 +1,78 @@ +using Celbridge.Resources; + +namespace Celbridge.Packages; + +/// +/// Reader used for project packages. Reverse-resolves the absolute path to a +/// ResourceKey via IResourceRegistry and routes every read through IFileStorage +/// so the chokepoint contract holds for project-tree bytes. +/// +/// IPackageReader is sync to match its sync callers (PackageManifestLoader, +/// PackageLocalizationService) but IFileStorage is genuinely async. Each call +/// is dispatched through Task.Run before the blocking wait so the async work +/// starts on a thread-pool thread whose continuations do not try to resume on +/// the caller's SynchronizationContext. Without that, calling this reader from +/// the UI thread (workspace load, template fetch) deadlocks: the await +/// continuations inside FileStorage would post back to the UI thread the +/// outer GetResult is blocking. +/// +public sealed class FileStoragePackageReader : IPackageReader +{ + private readonly IFileStorage _fileStorage; + private readonly IResourceRegistry _resourceRegistry; + + public FileStoragePackageReader( + IFileStorage fileStorage, + IResourceRegistry resourceRegistry) + { + _fileStorage = fileStorage; + _resourceRegistry = resourceRegistry; + } + + public bool Exists(string absolutePath) + { + var keyResult = _resourceRegistry.GetResourceKey(absolutePath); + if (keyResult.IsFailure) + { + return false; + } + + var infoResult = Task.Run(() => _fileStorage.GetInfoAsync(keyResult.Value)) + .GetAwaiter() + .GetResult(); + if (infoResult.IsFailure) + { + return false; + } + + return infoResult.Value.Kind != StorageItemKind.NotFound; + } + + public Result ReadAllText(string absolutePath) + { + var keyResult = _resourceRegistry.GetResourceKey(absolutePath); + if (keyResult.IsFailure) + { + return Result.Fail($"Could not resolve resource key for path: '{absolutePath}'") + .WithErrors(keyResult); + } + + return Task.Run(() => _fileStorage.ReadAllTextAsync(keyResult.Value)) + .GetAwaiter() + .GetResult(); + } + + public Result ReadAllBytes(string absolutePath) + { + var keyResult = _resourceRegistry.GetResourceKey(absolutePath); + if (keyResult.IsFailure) + { + return Result.Fail($"Could not resolve resource key for path: '{absolutePath}'") + .WithErrors(keyResult); + } + + return Task.Run(() => _fileStorage.ReadAllBytesAsync(keyResult.Value)) + .GetAwaiter() + .GetResult(); + } +} diff --git a/Source/Workspace/Celbridge.Packages/Services/IPackageReader.cs b/Source/Workspace/Celbridge.Packages/Services/IPackageReader.cs new file mode 100644 index 00000000..c091c829 --- /dev/null +++ b/Source/Workspace/Celbridge.Packages/Services/IPackageReader.cs @@ -0,0 +1,26 @@ +namespace Celbridge.Packages; + +/// +/// Synchronous file-read primitives used by the package layer to load +/// manifests, localization, templates, and extensions lists. The interface +/// keeps PackageManifestLoader and PackageLocalizationService unaware of +/// whether the bytes ultimately come from disk (bundled) or from IFileStorage +/// (project), so each loader can stay on a single sync code path. +/// +public interface IPackageReader +{ + /// + /// True when a file exists at the given absolute path. + /// + bool Exists(string absolutePath); + + /// + /// Reads the file at the given absolute path as UTF-8 text. + /// + Result ReadAllText(string absolutePath); + + /// + /// Reads the file at the given absolute path as raw bytes. + /// + Result ReadAllBytes(string absolutePath); +} diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageLocalizationService.cs b/Source/Workspace/Celbridge.Packages/Services/PackageLocalizationService.cs index c0ab9ab6..b7f7e02e 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageLocalizationService.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageLocalizationService.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using Celbridge.Logging; +using Celbridge.Workspace; namespace Celbridge.Packages; @@ -24,27 +25,28 @@ public class PackageLocalizationService : IPackageLocalizationService AllowTrailingCommas = true }; + private static readonly IPackageReader BundledReader = new DirectPackageReader(); + private readonly ILogger _logger; + private readonly IWorkspaceWrapper _workspaceWrapper; - public PackageLocalizationService(ILogger logger) + public PackageLocalizationService( + ILogger logger, + IWorkspaceWrapper workspaceWrapper) { _logger = logger; + _workspaceWrapper = workspaceWrapper; } - /// - /// Loads localization strings from a package's localization folder. - /// Uses convention: {packageFolder}/localization/{locale}.json - /// If locale is null, uses the current UI culture. - /// Falls back to "en.json" if the requested locale is not found, then to an empty dictionary. - /// - public Dictionary LoadStrings(string packageFolder, string? locale = null) + public Dictionary LoadStrings(PackageInfo package, string? locale = null) { locale ??= CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; - var localizationFolder = Path.Combine(packageFolder, LocalizationFolder); + var reader = GetReaderForPackage(package); + var localizationFolder = Path.Combine(package.PackageFolder, LocalizationFolder); var localePath = Path.Combine(localizationFolder, $"{locale}.json"); - var result = TryLoadJsonFile(localePath); + var result = TryLoadJsonFile(reader, localePath); if (result is not null) { return result; @@ -53,7 +55,7 @@ public Dictionary LoadStrings(string packageFolder, string? loca if (locale != FallbackLocale) { var fallbackPath = Path.Combine(localizationFolder, $"{FallbackLocale}.json"); - result = TryLoadJsonFile(fallbackPath); + result = TryLoadJsonFile(reader, fallbackPath); if (result is not null) { return result; @@ -63,22 +65,44 @@ public Dictionary LoadStrings(string packageFolder, string? loca return new Dictionary(); } - private Dictionary? TryLoadJsonFile(string path) + // Project packages route through the chokepoint by reverse-resolving the path + // to a ResourceKey; bundled packages stay on direct File.* IO. The project + // reader is constructed on demand because the workspace-scoped IFileStorage + // and IResourceRegistry must be looked up at call time. + private IPackageReader GetReaderForPackage(PackageInfo package) { - if (!File.Exists(path)) + if (package.Origin == PackageOrigin.Project) + { + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + return new FileStoragePackageReader(fileStorage, resourceRegistry); + } + + return BundledReader; + } + + private Dictionary? TryLoadJsonFile(IPackageReader reader, string path) + { + if (!reader.Exists(path)) + { + return null; + } + + var readResult = reader.ReadAllText(path); + if (readResult.IsFailure) { + _logger.LogWarning($"Failed to load localization file: {path}. {readResult.FirstErrorMessage}"); return null; } try { - var json = File.ReadAllText(path); - var dictionary = JsonSerializer.Deserialize>(json, _jsonOptions); + var dictionary = JsonSerializer.Deserialize>(readResult.Value, _jsonOptions); return dictionary; } catch (Exception exception) { - _logger.LogWarning($"Failed to load localization file: {path}. {exception.Message}"); + _logger.LogWarning(exception, $"Failed to parse localization file: {path}"); return null; } } diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageManifestLoader.cs b/Source/Workspace/Celbridge.Packages/Services/PackageManifestLoader.cs index c9b0b74b..f6832d2e 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageManifestLoader.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageManifestLoader.cs @@ -54,17 +54,30 @@ public static class PackageManifestLoader /// hostNameOverride, when non-null, replaces the default package-id-derived virtual host name. /// secrets, when non-empty, populates PackageInfo.Secrets for WebView injection. /// devToolsBlocked, when true, permanently disables DevTools on WebViews hosting this package. + /// origin tags the resulting PackageInfo so downstream read sites can pick the right IO path. + /// reader is the file-read primitive used for every byte the loader pulls; when null a + /// DirectPackageReader is used, which preserves the legacy direct-disk behaviour for + /// callers (tests, bundled discovery) that have no IFileStorage to route through. /// public static Result LoadPackage( string packageTomlPath, string? hostNameOverride = null, IReadOnlyDictionary? secrets = null, - bool devToolsBlocked = false) + bool devToolsBlocked = false, + PackageOrigin origin = PackageOrigin.Bundled, + IPackageReader? reader = null) { + reader ??= new DirectPackageReader(); try { var packageFolder = Path.GetFullPath(Path.GetDirectoryName(packageTomlPath) ?? string.Empty); - var toml = File.ReadAllText(packageTomlPath); + var readResult = reader.ReadAllText(packageTomlPath); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read package manifest: {packageTomlPath}") + .WithErrors(readResult); + } + var toml = readResult.Value; var parsed = Toml.Parse(toml); if (parsed.HasErrors) @@ -122,7 +135,8 @@ public static Result LoadPackage( HostName = hostName, RequiresTools = requiresTools, Secrets = packageSecrets, - DevToolsBlocked = devToolsBlocked + DevToolsBlocked = devToolsBlocked, + Origin = origin }; var documentPaths = new List(); @@ -146,7 +160,7 @@ public static Result LoadPackage( foreach (var relativePath in documentPaths) { var fullPath = Path.Combine(packageFolder, relativePath); - var loadResult = LoadDocument(fullPath, packageInfo); + var loadResult = LoadDocument(fullPath, packageInfo, reader); if (loadResult.IsSuccess) { documentEditors.Add(loadResult.Value); @@ -172,16 +186,23 @@ public static Result LoadPackage( /// private static Result LoadDocument( string documentTomlPath, - PackageInfo packageInfo) + PackageInfo packageInfo, + IPackageReader reader) { try { - if (!File.Exists(documentTomlPath)) + if (!reader.Exists(documentTomlPath)) { return Result.Fail($"Document manifest not found: {documentTomlPath}"); } - var toml = File.ReadAllText(documentTomlPath); + var readResult = reader.ReadAllText(documentTomlPath); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read document manifest: {documentTomlPath}") + .WithErrors(readResult); + } + var toml = readResult.Value; var parsed = Toml.Parse(toml); if (parsed.HasErrors) @@ -239,7 +260,7 @@ private static Result LoadDocument( $"A [[document_file_types]] entry cannot specify both '{ExtensionKey}' and '{ExtensionsFileKey}': {documentTomlPath}"); } - var expandResult = ExpandExtensionsFile(packageInfo.PackageFolder, extensionsFilePath, fileTypeDisplayName); + var expandResult = ExpandExtensionsFile(packageInfo.PackageFolder, extensionsFilePath, fileTypeDisplayName, reader); if (expandResult.IsFailure) { return Result.Fail($"Failed to expand '{ExtensionsFileKey}' in {documentTomlPath}") @@ -431,17 +452,24 @@ private static CodeDocumentEditorContribution BuildCodeContribution( private static Result> ExpandExtensionsFile( string packageFolder, string relativePath, - string displayName) + string displayName, + IPackageReader reader) { var fullPath = Path.Combine(packageFolder, relativePath); - if (!File.Exists(fullPath)) + if (!reader.Exists(fullPath)) { return Result.Fail($"Extensions file not found: {fullPath}"); } try { - var json = File.ReadAllText(fullPath); + var readResult = reader.ReadAllText(fullPath); + if (readResult.IsFailure) + { + return Result.Fail($"Failed to read extensions file: {fullPath}") + .WithErrors(readResult); + } + var json = readResult.Value; using var document = JsonDocument.Parse(json); if (document.RootElement.ValueKind != JsonValueKind.Object) diff --git a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs index 222b55b3..e23aeba1 100644 --- a/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs +++ b/Source/Workspace/Celbridge.Packages/Services/PackageRegistry.cs @@ -29,6 +29,11 @@ public class PackageRegistry private List _bundledPackages = []; private List _projectPackages = []; + // Bundled packages live outside any IResourceRegistry root so their reads + // stay on direct File.* IO. One reader is reused across the discovery and + // template-fetch paths to keep the bundled branch cheap. + private static readonly IPackageReader BundledReader = new DirectPackageReader(); + public PackageRegistry( ILogger logger, IModuleService moduleService, @@ -130,7 +135,7 @@ public IReadOnlyList GetDocumentTypes() continue; } - var localizationStrings = _localizationService.LoadStrings(contribution.Package.PackageFolder); + var localizationStrings = _localizationService.LoadStrings(contribution.Package); var displayKey = contribution.FileTypes[0].DisplayName; string displayName; if (localizationStrings.TryGetValue(displayKey, out var localizedName)) @@ -151,11 +156,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. + // Reads the default template bytes for the given file extension, picking the + // first contribution that handles the extension and declares a default template. + // The reader is chosen by package origin: bundled packages stay on direct File.* + // because their bytes live outside any registry root, project packages route + // through IFileStorage by reverse-resolving the template path. public byte[]? GetDefaultTemplateContent(string fileExtension) { var normalizedExtension = fileExtension.ToLowerInvariant(); @@ -180,26 +185,44 @@ public IReadOnlyList GetDocumentTypes() } var templatePath = Path.Combine(contribution.Package.PackageFolder, defaultTemplate.TemplateFile); - if (!File.Exists(templatePath)) + var reader = GetReaderForPackage(contribution.Package); + + if (!reader.Exists(templatePath)) { _logger.LogWarning($"Template file not found: {templatePath}"); continue; } - try - { - return File.ReadAllBytes(templatePath); - } - catch (Exception exception) + var readResult = reader.ReadAllBytes(templatePath); + if (readResult.IsFailure) { - _logger.LogWarning($"Failed to read template file: {templatePath}. {exception.Message}"); + _logger.LogWarning($"Failed to read template file: {templatePath}. {readResult.FirstErrorMessage}"); continue; } + + return readResult.Value; } return null; } + // Selects the file-read primitive that matches a package's discovery origin. + // Project packages are read through the chokepoint; bundled packages stay on + // direct File.* IO. The project reader is constructed on demand because the + // workspace-scoped IFileStorage and IResourceRegistry must be looked up at + // call time rather than cached. + private IPackageReader GetReaderForPackage(PackageInfo package) + { + if (package.Origin == PackageOrigin.Project) + { + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + return new FileStoragePackageReader(fileStorage, resourceRegistry); + } + + return BundledReader; + } + private List DiscoverBundledPackages() { var failures = new List(); @@ -209,7 +232,7 @@ private List DiscoverBundledPackages() foreach (var descriptor in descriptors) { var manifestPath = Path.Combine(descriptor.Folder, ManifestFileName); - if (!File.Exists(manifestPath)) + if (!BundledReader.Exists(manifestPath)) { // A bundled package with no manifest is a build-time error. // Either the descriptor folder is wrong or the package content @@ -228,7 +251,9 @@ private List DiscoverBundledPackages() manifestPath, descriptor.HostNameOverride, descriptor.Secrets, - descriptor.DevToolsBlocked); + descriptor.DevToolsBlocked, + origin: PackageOrigin.Bundled, + reader: BundledReader); if (loadResult.IsFailure) { _logger.LogError(loadResult, $"Failed to load bundled package: {manifestPath}"); @@ -299,6 +324,7 @@ private async Task> DiscoverProjectPackagesAsync(string } var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + var projectReader = new FileStoragePackageReader(fileStorage, resourceRegistry); var candidates = new List(); foreach (var item in enumerateResult.Value) @@ -326,7 +352,12 @@ private async Task> DiscoverProjectPackagesAsync(string var manifestPath = resolveResult.Value; var packageFolder = Path.GetDirectoryName(manifestPath)!; - var loadResult = PackageManifestLoader.LoadPackage(manifestPath, hostNameOverride: null, secrets: null); + var loadResult = PackageManifestLoader.LoadPackage( + manifestPath, + hostNameOverride: null, + secrets: null, + origin: PackageOrigin.Project, + reader: projectReader); if (loadResult.IsFailure) { _logger.LogWarning(loadResult, $"Skipping invalid project package: {manifestPath}");