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}");