diff --git a/CLAUDE.md b/CLAUDE.md index e4e30166c..0d067ed1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,13 +81,14 @@ 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 ## 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, 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) @@ -100,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/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 3ba33e6a1..90f652bdc 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/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.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/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..5e3c2d5f1 --- /dev/null +++ b/Source/Core/Celbridge.Projects/MigrationSteps/MigrationStep_0_3_0.cs @@ -0,0 +1,258 @@ +using System.Text; +using System.Text.Json; +using Celbridge.Projects.Services; + +namespace Celbridge.Projects.MigrationSteps; + +/// +/// 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 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 webViewConvertResult = await ConvertWebViewFilesAsync(context, projectDataFolderPath); + if (webViewConvertResult.IsFailure) + { + return webViewConvertResult; + } + + var configRewriteResult = await RewriteProjectConfigAsync(context); + if (configRewriteResult.IsFailure) + { + return configRewriteResult; + } + + 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"; + 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 tomlText = BuildWebViewTomlContent(sourceUrl); + + await File.WriteAllTextAsync(newPath, tomlText); + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail($"Failed to read or write WebView file during conversion: '{oldPath}'") + .WithException(ex); + } + } + + 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 + // 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 + // the old extension in comments stay untouched. + var updatedText = RewriteQuotedExtensions(originalText, WebViewOldExtension, WebViewNewExtension); + + 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 bool IsInsideMetaDataFolder(string fullPath, string projectDataFolderPath) + { + if (string.IsNullOrEmpty(projectDataFolderPath)) + { + return false; + } + + return fullPath.StartsWith(projectDataFolderPath, StringComparison.OrdinalIgnoreCase); + } +} 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 864b291d3..229232575 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 { @@ -72,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/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md index cec7c77c9..c8107afc1 100644 --- a/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md +++ b/Source/Core/Celbridge.Tools/Guides/Concepts/resource_keys.md @@ -1,19 +1,173 @@ # 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 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. **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 + +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 `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`). + +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 - 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. -- Case sensitivity follows the underlying filesystem; on Windows the system is case-preserving but case-insensitive. +- 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. +- 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. + +## 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.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 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. + +The current allowlist: + +| Category | Extensions | +|---|---| +| Sidecars | `.cel` | +| Scripts | `.js`, `.py`, `.ipy`, `.ipynb` | +| Tabular data | `.csv`, `.tsv` | +| Structured data and configuration | `.json`, `.jsonl`, `.ndjson`, `.yaml`, `.yml`, `.toml`, `.xml` | + +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. + +### Files that are NOT scanned + +Every extension not in the allowlist is skipped. The most common implications: + +- **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. + +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`. -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. +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/Core/Celbridge.Tools/Guides/Namespaces/data.md b/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md new file mode 100644 index 000000000..9a4d1ebad --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Namespaces/data.md @@ -0,0 +1,81 @@ +# data + +The `data` namespace reads and writes per-resource data stored in `.cel` sidecar files. A sidecar lives alongside its parent file (`foo.png.cel` next to `foo.png`) and carries TOML frontmatter plus zero-or-more named content blocks. The host scans `.cel` files on demand for tag queries and project-health checks; there is no persistent index. + +## Must-knows + +- **Sidecars are addressed by their parent resource.** `data_get_field docs/notes.md priority` consults the sidecar at `docs/notes.md.cel`. Passing the sidecar's own resource key (`docs/notes.md.cel`) is rejected with a clear error. +- **Sidecars are created on first write.** `data_set_field`, `data_add_tag`, and `data_write_block` create the sidecar when missing. `data_remove_field`, `data_remove_tag`, and `data_remove_block` never create files and never delete sidecars (empty sidecars are kept). +- **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 + +**Per-resource read.** + +- `data_get_field` — read a single field value from a resource's sidecar. +- `data_get_info` — return frontmatter inline plus the list of block IDs and their byte sizes in one response. +- `data_read_block` — return the verbatim content of a named block. + +**Per-resource write.** + +- `data_set_field` — write a single field, creating the sidecar if missing. +- `data_remove_field` — remove a single field; no-op when absent. +- `data_write_block` — create or overwrite a named block. +- `data_remove_block` — remove a named block; no-op when absent. + +**Tag affordances.** + +- `data_add_tag` — append a tag, creating the sidecar if missing. +- `data_remove_tag` — remove a tag; no-op when absent. +- `data_find_tag` — find every resource whose `tags` list contains the given value. + +**Project-wide health.** + +- `data_check_project` — report broken `project:` references, orphan `.cel` files, and any `.cel` file that fails to parse cleanly. + +## When to use which surface + +- "What does this sidecar carry?" → `data_get_info`. +- "What does this specific field hold?" → `data_get_field`. +- "What resources are tagged X?" → `data_find_tag "X"`. +- "Tag this resource so a future agent can find it" → `data_add_tag`. +- "Read or write the prose body that an editor stores alongside this file" → `data_read_block` / `data_write_block`. +- "Is the project in a consistent state?" → `data_check_project`. + +## Sidecar file format + +A `.cel` sidecar is TOML frontmatter optionally followed by one or more named content blocks. The format has three on-disk shapes: + +**Empty sidecar** — a zero-byte file (or one containing only whitespace). Carries no fields and no blocks. This is the canonical "minimal valid" sidecar shape. + +**Frontmatter only** — plain TOML, no fence delimiters. There is no enclosing `+++` block: + +```toml +editor = "celbridge.notes.note-document" +tags = ["meeting", "draft"] +priority = "high" +``` + +**Frontmatter plus named blocks** — TOML at the top, then each block introduced by a fence line of the exact form `+++ ""` (one space, double-quoted ID, nothing else). Block content runs from the line after the fence to the line before the next fence (or to EOF): + +```toml +editor = "celbridge.notes.note-document" +tags = ["meeting"] ++++ "celbridge.notes.note-document.content" +# Meeting Notes + +Body of the note. ++++ "celbridge.notes.note-document.revisions" +rev-1 +rev-2 +``` + +Block IDs follow `[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*`. The fence regex is strict: unquoted `+++` lines are NOT fences — they parse as TOML frontmatter (which will usually fail), classifying the sidecar as broken. When repairing a sidecar by hand with `file_write`, use one of the three shapes above. + +## Notes + +- Sidecars can also be read and written directly through the `file` namespace (`file_read docs/notes.md.cel`). Use the file tools for one-shot inspection or for repairing broken sidecars by hand; use the data tools for routine indexed field and tag access. +- For genuinely free-form search across `.cel` contents, use `file_grep --glob "*.cel"` and parse hits caller-side. 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/data_add_tag.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_add_tag.md new file mode 100644 index 000000000..928903213 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_add_tag.md @@ -0,0 +1,5 @@ +# data_add_tag + +Appends a tag string to the resource's `tags` frontmatter list. Creates the sidecar if it does not exist. Idempotent: adding a tag that is already present is a no-op. + +Use the `tag:value` convention (`priority:high`, `status:draft`) to piggyback structured queries onto the tag surface — `data_find_tag "priority:high"` then enumerates resources carrying that value. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md new file mode 100644 index 000000000..19dfbe0d5 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_check_project.md @@ -0,0 +1,21 @@ +# data_check_project + +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. `*.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: + +```json +{ + "brokenReferences": [{"source": "...", "missingTarget": "..."}], + "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 `.cel` file. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_find_tag.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_find_tag.md new file mode 100644 index 000000000..1a9bb9a4f --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_find_tag.md @@ -0,0 +1,7 @@ +# data_find_tag + +Enumerates every resource in the project whose `.cel` sidecar `tags` list contains the given tag value. Runs an on-demand parallel scan over the project's sidecar files; results are sorted by resource key. + +The response is a bare JSON array of resource keys (e.g. `["docs/notes.md", "drafts/article.md"]`), not an object. An empty list when no sidecar carries the tag. + +For non-tag searches across `.cel` contents, use `file_grep --glob "*.cel"` and parse hits caller-side. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_get_field.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_field.md new file mode 100644 index 000000000..95c6f658f --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_field.md @@ -0,0 +1,5 @@ +# data_get_field + +Reads a single frontmatter field from a resource's `.cel` sidecar and returns the value as JSON. + +Errors when the resource has no sidecar, when the sidecar fails to parse, or when the named field is not present. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md new file mode 100644 index 000000000..0fc50d952 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_get_info.md @@ -0,0 +1,28 @@ +# data_get_info + +Returns the parsed frontmatter of a resource's `.cel` sidecar inline plus an ordered list of block descriptors (id and byte size). One call answers both "what fields does this carry?" and "what blocks are present?" + +Response shape: + +```json +{ + "hasSidecar": true, + "fields": { + "editor": "celbridge.notes.note-document", + "tags": ["meeting"], + "priority": "high" + }, + "blocks": [ + {"id": "celbridge.notes.note-document.content", "size": 1234}, + {"id": "celbridge.notes.note-document.revisions", "size": 567} + ] +} +``` + +`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/data_read_block.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_read_block.md new file mode 100644 index 000000000..c964dac23 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_read_block.md @@ -0,0 +1,7 @@ +# data_read_block + +Returns the verbatim content of a named block in the resource's `.cel` sidecar. + +Errors when the resource has no sidecar, when the sidecar is broken, or when the named block does not exist. + +For partial reads of large blocks, prefer `file_read docs/notes.md.cel` with the existing line offset / limit parameters. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_block.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_block.md new file mode 100644 index 000000000..766f80cdb --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_block.md @@ -0,0 +1,3 @@ +# data_remove_block + +Removes a named block from the resource's `.cel` sidecar. No-op when the block is absent or the sidecar does not exist. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_field.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_field.md new file mode 100644 index 000000000..14aa5ee0d --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_field.md @@ -0,0 +1,3 @@ +# data_remove_field + +Removes a single frontmatter field from a resource's `.cel` sidecar. No-op when the field is absent or the sidecar does not exist. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_tag.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_tag.md new file mode 100644 index 000000000..c16266d0a --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_remove_tag.md @@ -0,0 +1,3 @@ +# data_remove_tag + +Removes a tag string from the resource's `tags` frontmatter list. No-op when the tag is absent. Drops the `tags` field entirely when the list goes empty after removal. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_set_field.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_set_field.md new file mode 100644 index 000000000..668fc3fe8 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_set_field.md @@ -0,0 +1,7 @@ +# data_set_field + +Writes a single frontmatter field on a resource's `.cel` sidecar. Creates the sidecar if it does not exist. + +The `value_json` argument carries the value as a JSON-encoded string: `"high"`, `42`, `true`, `["a", "b"]`. Only scalars (string, number, bool) and lists of scalars are accepted; nested objects are rejected at write time. + +To mutate the `tags` list prefer `data_add_tag` / `data_remove_tag` so concurrent edits don't clobber each other's append. diff --git a/Source/Core/Celbridge.Tools/Guides/Tools/data_write_block.md b/Source/Core/Celbridge.Tools/Guides/Tools/data_write_block.md new file mode 100644 index 000000000..22eb5e6e1 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Guides/Tools/data_write_block.md @@ -0,0 +1,5 @@ +# data_write_block + +Writes a named content block in the resource's `.cel` sidecar. Creates the sidecar if missing; overwrites the existing block if one with the same ID is present, otherwise appends a new block. + +Block content is opaque to the host. Whatever the editor stores round-trips through Parse and Compose unchanged, subject to the documented constraint that block content cannot contain a line matching the fence regex `^\+\+\+ "..."`. 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..239bf18e9 100644 --- a/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md +++ b/Source/Core/Celbridge.Tools/Guides/Tools/explorer_delete.md @@ -2,15 +2,66 @@ 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"] + } +} +``` + +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. + - `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..6ab64e7c9 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. + +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 + +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..6f4de3a90 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,36 @@ Resolved against the source: ## Returns -`"ok"` on success. +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", + "updatedReferencers": ["project:doc.md", ...], + "skippedReferencers": [ + { "resource": "project:locked.md", "reason": "ReadOnly", "message": "file is read-only" }, + ... + ], + "failedResources": ["project:source.txt", ...] +} +``` + +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`). + - `"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 `data_check_project`. 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. +- 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. 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/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/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/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.AddTag.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.AddTag.cs new file mode 100644 index 000000000..042d08c18 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.AddTag.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Append a tag to a resource's tags list (creates the sidecar if missing; idempotent). + [McpServerTool(Name = "data_add_tag", Idempotent = true)] + [ToolAlias("data.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); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + if (string.IsNullOrEmpty(tag)) + { + return ToolResponse.Error("tag must be a non-empty string."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.Tag = tag; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs new file mode 100644 index 000000000..93afb20d0 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.CheckProject.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// 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")] + 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(), + orphanCelFiles = report.OrphanCelFiles + .Select(o => o.ToString()) + .ToArray(), + brokenCelFiles = report.BrokenCelFiles + .Select(b => b.ToString()) + .ToArray(), + }; + + return ToolResponse.Success(SerializeJson(payload)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.FindTag.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.FindTag.cs new file mode 100644 index 000000000..27fa340a0 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.FindTag.cs @@ -0,0 +1,31 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Find every resource whose .cel sidecar tags list contains the given tag value. + [McpServerTool(Name = "data_find_tag", ReadOnly = true)] + [ToolAlias("data.find_tag")] + [RelatedGuides("resource_keys")] + public async partial Task FindTag(string tag) + { + if (string.IsNullOrEmpty(tag)) + { + return ToolResponse.Error("tag must be a non-empty string."); + } + + var commandResult = await ExecuteCommandAsync>(command => + { + command.Tag = tag; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + var keys = commandResult.Value.Select(m => m.ToString()).ToArray(); + return ToolResponse.Success(SerializeJson(keys)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetField.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetField.cs new file mode 100644 index 000000000..0ede8dbe9 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetField.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Read a single frontmatter field from a resource's .cel sidecar. + [McpServerTool(Name = "data_get_field", ReadOnly = true)] + [ToolAlias("data.get_field")] + [RelatedGuides("resource_keys")] + public async partial Task GetField(string resource, string field) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.Field = field; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success(SerializeJson(commandResult.Value)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs new file mode 100644 index 000000000..7d0d3e468 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.GetInfo.cs @@ -0,0 +1,48 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Return a resource's .cel sidecar frontmatter inline plus the ordered list of block descriptors. + [McpServerTool(Name = "data_get_info", ReadOnly = true)] + [ToolAlias("data.get_info")] + [RelatedGuides("resource_keys")] + public async partial Task GetInfo(string resource) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + var report = commandResult.Value; + var payload = new + { + hasSidecar = report.HasSidecar, + fields = report.Fields, + blocks = report.Blocks + .Select(b => new + { + id = b.Id, + size = b.Size, + }) + .ToArray(), + }; + return ToolResponse.Success(SerializeJson(payload)); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.ReadBlock.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.ReadBlock.cs new file mode 100644 index 000000000..e9a98af6a --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.ReadBlock.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Read a named content block from a resource's .cel sidecar. + [McpServerTool(Name = "data_read_block", ReadOnly = true)] + [ToolAlias("data.read_block")] + [RelatedGuides("resource_keys")] + public async partial Task ReadBlock(string resource, string blockId) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + var sidecarService = GetRequiredService().WorkspaceService.SidecarService; + if (!sidecarService.IsValidBlockName(blockId)) + { + return ToolResponse.Error($"block_id '{blockId}' does not match the block-naming rules (lowercase letters, digits, hyphens, dotted segments)."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.BlockId = blockId; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success(commandResult.Value); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveBlock.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveBlock.cs new file mode 100644 index 000000000..729241e37 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveBlock.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Remove a named content block from a resource's .cel sidecar (no-op if absent). + [McpServerTool(Name = "data_remove_block", Destructive = true, Idempotent = true)] + [ToolAlias("data.remove_block")] + [RelatedGuides("resource_keys")] + public async partial Task RemoveBlock(string resource, string blockId) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + var sidecarService = GetRequiredService().WorkspaceService.SidecarService; + if (!sidecarService.IsValidBlockName(blockId)) + { + return ToolResponse.Error($"block_id '{blockId}' does not match the block-naming rules (lowercase letters, digits, hyphens, dotted segments)."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.BlockId = blockId; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveField.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveField.cs new file mode 100644 index 000000000..2a9f846cc --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveField.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Remove a single frontmatter field from a resource's .cel sidecar (no-op if absent). + [McpServerTool(Name = "data_remove_field", Destructive = true, Idempotent = true)] + [ToolAlias("data.remove_field")] + [RelatedGuides("resource_keys")] + public async partial Task RemoveField(string resource, string field) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.Field = field; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveTag.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveTag.cs new file mode 100644 index 000000000..d1066dc23 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.RemoveTag.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Remove a tag from a resource's tags list (no-op if absent; idempotent). + [McpServerTool(Name = "data_remove_tag", Idempotent = true)] + [ToolAlias("data.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); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + if (string.IsNullOrEmpty(tag)) + { + return ToolResponse.Error("tag must be a non-empty string."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.Tag = tag; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.SetField.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.SetField.cs new file mode 100644 index 000000000..c6b495430 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.SetField.cs @@ -0,0 +1,54 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Write a single frontmatter field on a resource's .cel sidecar (creates the sidecar if missing). + [McpServerTool(Name = "data_set_field", Idempotent = true)] + [ToolAlias("data.set_field")] + [RelatedGuides("resource_keys")] + public async partial Task SetField(string resource, string field, string valueJson) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + if (string.IsNullOrEmpty(field)) + { + return ToolResponse.Error("field must be a non-empty string."); + } + + var parseResult = TryParseJsonValue(valueJson); + if (parseResult.IsFailure) + { + return ToolResponse.Error(parseResult); + } + var parsedValue = parseResult.Value; + + var sidecarService = GetRequiredService().WorkspaceService.SidecarService; + if (!sidecarService.IsIndexableValue(parsedValue)) + { + return ToolResponse.Error($"Field '{field}' value is not indexable. Only scalar (string/number/bool) and list-of-scalar values are supported."); + } + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.Field = field; + command.Value = parsedValue; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.WriteBlock.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.WriteBlock.cs new file mode 100644 index 000000000..91b06106d --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.WriteBlock.cs @@ -0,0 +1,43 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +public partial class DataTools +{ + /// Write a named content block in a resource's .cel sidecar (creates the sidecar if missing; overwrites existing block of the same name). + [McpServerTool(Name = "data_write_block", Idempotent = true)] + [ToolAlias("data.write_block")] + [RelatedGuides("resource_keys")] + public async partial Task WriteBlock(string resource, string blockId, string content) + { + if (!ResourceKey.TryCreate(resource, out var resourceKey)) + { + return ToolResponse.InvalidResourceKey(resource); + } + var sidecarError = ValidateNotSidecarKey(resourceKey, resource); + if (sidecarError is not null) + { + return sidecarError; + } + var sidecarService = GetRequiredService().WorkspaceService.SidecarService; + if (!sidecarService.IsValidBlockName(blockId)) + { + return ToolResponse.Error($"block_id '{blockId}' does not match the block-naming rules (lowercase letters, digits, hyphens, dotted segments)."); + } + content ??= string.Empty; + + var commandResult = await ExecuteCommandAsync(command => + { + command.Resource = resourceKey; + command.BlockId = blockId; + command.Content = content; + }); + if (commandResult.IsFailure) + { + return ToolResponse.Error(commandResult); + } + + return ToolResponse.Success("ok"); + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Data/DataTools.cs b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.cs new file mode 100644 index 000000000..1006fece5 --- /dev/null +++ b/Source/Core/Celbridge.Tools/Tools/Data/DataTools.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Celbridge.Tools; + +/// +/// MCP tools for resource .cel sidecar data: per-resource frontmatter +/// read / write, tag affordances, named content blocks, and project-wide +/// consistency checks. +/// +[McpServerToolType] +public partial class DataTools : AgentToolBase +{ + public DataTools(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 data layer can accept + /// (scalar or list-of-scalar). Fails for unsupported shapes + /// (nested objects, mixed-type arrays). + /// + private static Result TryParseJsonValue(string valueJson) + { + if (string.IsNullOrWhiteSpace(valueJson)) + { + return Result.Fail("value_json must be a non-empty JSON-encoded value."); + } + + JsonElement element; + try + { + element = JsonSerializer.Deserialize(valueJson); + } + catch (JsonException ex) + { + return Result.Fail($"value_json is not valid JSON: {ex.Message}"); + } + + var converted = ConvertJsonElement(element); + if (converted is null) + { + return Result.Fail("value_json must be a scalar (string, number, boolean) or list of scalars; nested objects are not supported."); + } + + return converted; + } + + 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; + } + } + + /// + /// Returns an error response when the resource key names a .cel sidecar + /// file rather than its parent. Returns null when the key is a valid + /// parent-shaped resource. + /// + private CallToolResult? ValidateNotSidecarKey(ResourceKey resource, string original) + { + var sidecarService = GetRequiredService().WorkspaceService.SidecarService; + if (sidecarService.IsSidecarKey(resource)) + { + return ToolResponse.Error($"Resource '{original}' is a .cel sidecar key. Pass the parent resource key instead."); + } + return null; + } +} diff --git a/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs b/Source/Core/Celbridge.Tools/Tools/Document/DocumentStateProvider.cs index 3eefa913e..364b6eee9 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; @@ -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.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..71e437d30 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; @@ -6,16 +7,21 @@ namespace Celbridge.Tools; public partial class ExplorerTools { /// Remove a resource from the project (file or folder); undoable via explorer_undo. - [McpServerTool(Name = "explorer_delete", Destructive = true)] + [McpServerTool(Name = "explorer_delete", Destructive = true, Idempotent = 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.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/Explorer/ExplorerTools.Move.cs b/Source/Core/Celbridge.Tools/Tools/Explorer/ExplorerTools.Move.cs index 472e736d1..b187d7c86 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,60 @@ 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; + + // 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, + 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.Tools/Tools/File/FileTools.Edit.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Edit.cs index fe5c1b67a..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 resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + 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(resourceRegistry, fileResourceKey); + fileLines = await ReadFileLinesForContextAsync(fileStorage, fileResourceKey); } foreach (var range in editValue.AffectedRanges) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.GetInfo.cs index 655e13bb7..5e5ffbab9 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.CelFileStatus.Healthy => "healthy", + Celbridge.Resources.CelFileStatus.Broken => "broken", + _ => "none", + }; + var fileResult = new FileInfoResult( "file", snapshot.Size, snapshot.ModifiedUtc.ToString("o"), snapshot.Extension, snapshot.IsText, - snapshot.LineCount); + snapshot.LineCount, + snapshot.SidecarKey?.ToString(), + sidecarStatusText); return ToolResponse.Success(SerializeJson(fileResult)); } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Grep.cs index c32918afd..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 resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; 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, fileStorage); } 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(fileStorage, 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 fileStorage.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(IFileStorage fileStorage, ResourceKey resource) + { + var openResult = await fileStorage.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, 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 @@ -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 fileStorage.GetInfoAsync(fileResourceKey); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { continue; } - var fileLines = await File.ReadAllLinesAsync(filePath); + var fileLines = await ReadFileLinesStreamedAsync(fileStorage, 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..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 resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; string[]? fileLines = null; if (resultValue.AffectedRanges.Count > 0) { - fileLines = await ReadFileLinesForContextAsync(resourceRegistry, 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 b7d7806da..e0504b8d1 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 fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileStorage.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + // 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 != StorageItemKind.File) { - 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); + var readResult = await fileStorage.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 a5f74ba79..ba5329419 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 fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileStorage.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + return ToolResponse.Error(infoResult); } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != StorageItemKind.File) { - return ToolResponse.Error($"File not found: '{resource}'"); + return ToolResponse.Error($"File not found: '{resourceKey}'"); } - var bytes = await File.ReadAllBytesAsync(resourcePath); + var bytesResult = await fileStorage.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 a29315179..07119aaa6 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 fileStorage = workspaceWrapper.WorkspaceService.FileStorage; - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileStorage.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - return ToolResponse.Error($"Failed to resolve path for resource: '{resource}'"); + return ToolResponse.Error(infoResult); } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != StorageItemKind.File) { - return ToolResponse.Error($"File not found: '{resource}'"); + 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 '{resource}' 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 fileStorage.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 c9ebd20a9..6294f0777 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) @@ -38,7 +39,7 @@ public async partial Task ReadMany(string resources, int offset } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; var entries = new List(); foreach (var resourceString in resourceKeys) @@ -49,27 +50,35 @@ public async partial Task ReadMany(string resources, int offset continue; } - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + // 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 infoResult = await fileStorage.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) { - entries.Add(new ReadManyFileEntry(resourceString, Error: $"Failed to resolve path for resource: '{resourceString}'")); + entries.Add(new ReadManyFileEntry(canonicalResource, Error: infoResult.FirstErrorMessage)); continue; } - var resourcePath = resolveResult.Value; - - if (!File.Exists(resourcePath)) + if (infoResult.Value.Kind != StorageItemKind.File) { - entries.Add(new ReadManyFileEntry(resourceString, Error: $"File not found: '{resourceString}'")); + entries.Add(new ReadManyFileEntry(canonicalResource, Error: $"File not found: '{canonicalResource}'")); continue; } - var fileText = await File.ReadAllTextAsync(resourcePath); + var readResult = await fileStorage.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) { // 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 +90,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/File/FileTools.Replace.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Replace.cs index 17b5c6ac2..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 resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; + 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(resourceRegistry, 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 867551428..2211558a5 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Search.cs @@ -15,20 +15,35 @@ 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 resourceService = workspaceWrapper.WorkspaceService.ResourceService; + var resourceRegistry = resourceService.Registry; + var rootHandlerRegistry = resourceService.RootHandlerRegistry; + var fileStorage = workspaceWrapper.WorkspaceService.FileStorage; 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 + && rootHandlerRegistry.RootHandlers.ContainsKey(patternRoot)) + { + return await SearchNonDefaultRootAsync( + fileStorage, patternRoot, regex, isFolderSearch, includeMetadata); + } + 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())) @@ -39,16 +54,16 @@ public partial CallToolResult Search(string pattern, bool includeMetadata = fals var results = new List(); foreach (var folderKey in matchingFolders) { - var resolvePathResult = resourceRegistry.ResolveResourcePath(folderKey); - if (resolvePathResult.IsFailure) + var infoResult = await fileStorage.GetInfoAsync(folderKey); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.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)); } @@ -68,11 +83,16 @@ public partial CallToolResult Search(string pattern, bool includeMetadata = fals var results = new List(); foreach (var match in matches) { - var fileInfo = new FileInfo(match.Path); + var infoResult = await fileStorage.GetInfoAsync(match.Resource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.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)); } @@ -80,4 +100,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( + IFileStorage fileStorage, + string rootName, + Regex regex, + bool isFolderSearch, + bool includeMetadata) + { + var rootKey = new ResourceKey(rootName + ":"); + var allEntries = new List(); + await CollectRecursiveAsync(fileStorage, 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( + IFileStorage fileStorage, + ResourceKey folder, + List entries) + { + var enumerateResult = await fileStorage.EnumerateFolderAsync(folder); + if (enumerateResult.IsFailure) + { + return; + } + + foreach (var entry in enumerateResult.Value) + { + entries.Add(entry); + if (entry.IsFolder) + { + await CollectRecursiveAsync(fileStorage, entry.Resource, entries); + } + } + } } diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Write.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Write.cs index d2a46dc24..ad4a13b18 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.Write.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.Write.cs @@ -12,7 +12,7 @@ public record class WriteFileResult(int LineCount); public partial class FileTools { /// Wholesale-replace a text file with new content, creating it if missing. - [McpServerTool(Name = "file_write")] + [McpServerTool(Name = "file_write", Idempotent = true)] [ToolAlias("file.write")] [RelatedGuides("resource_keys", "editing_documents", "file_changes")] public async partial Task Write(string fileResource, string content) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.WriteBinary.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.WriteBinary.cs index 2057ed155..4ca861ca9 100644 --- a/Source/Core/Celbridge.Tools/Tools/File/FileTools.WriteBinary.cs +++ b/Source/Core/Celbridge.Tools/Tools/File/FileTools.WriteBinary.cs @@ -6,7 +6,7 @@ namespace Celbridge.Tools; public partial class FileTools { /// Wholesale-replace a binary file from base64-encoded bytes, creating it if missing. - [McpServerTool(Name = "file_write_binary")] + [McpServerTool(Name = "file_write_binary", Idempotent = true)] [ToolAlias("file.write_binary")] [RelatedGuides("resource_keys", "editing_documents", "file_changes")] public async partial Task WriteBinary(string fileResource, string base64Content) diff --git a/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs b/Source/Core/Celbridge.Tools/Tools/File/FileTools.cs index 728489da2..75bfea563 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(IFileStorage fileStorage, ResourceKey fileResourceKey) { - var resolveResult = resourceRegistry.ResolveResourcePath(fileResourceKey); - if (resolveResult.IsFailure) + var infoResult = await fileStorage.GetInfoAsync(fileResourceKey); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { return null; } - var resourcePath = resolveResult.Value; - if (!File.Exists(resourcePath)) + var readResult = await fileStorage.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/Package/PackageTools.Create.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Create.cs index 86b8e635d..c69676cb5 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 fileStorage = workspaceService.FileStorage; 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 fileStorage.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..9af63baf3 100644 --- a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs +++ b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Install.cs @@ -1,9 +1,6 @@ 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,32 +70,16 @@ public async partial Task Install(string packageName, bool confi } var workspaceWrapper = GetRequiredService(); - var resourceRegistry = workspaceWrapper.WorkspaceService.ResourceService.Registry; - - // Write the downloaded zip to a temporary cache file in the project - 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; - - var tempFolder = Path.GetDirectoryName(tempArchivePath); - if (!string.IsNullOrEmpty(tempFolder) && !Directory.Exists(tempFolder)) - { - Directory.CreateDirectory(tempFolder); - } - - try - { - await File.WriteAllBytesAsync(tempArchivePath, downloadResult.Value); - } - catch (System.IO.IOException exception) + var workspaceService = workspaceWrapper.WorkspaceService; + 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 fileStorage.WriteAllBytesAsync(tempArchiveResource, downloadResult.Value); + if (writeArchiveResult.IsFailure) { - 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}"); @@ -124,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 fileStorage.DeleteAsync(tempArchiveResource); } } } diff --git a/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs b/Source/Core/Celbridge.Tools/Tools/Package/PackageTools.Publish.cs index 3fe357e3d..112485b3f 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(resolveSourceResult.FirstErrorMessage); } 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.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 12acdc69b..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)) { @@ -64,7 +74,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.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 26f8728d1..1500f1659 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 fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(resourceKey); + if (infoResult.IsFailure) + { + return Result.Fail($"Failed to inspect workbook: '{resourceKey}'") + .WithErrors(infoResult); + } - var resolveResult = resourceRegistry.ResolveResourcePath(resourceKey); - if (resolveResult.IsFailure) + var info = infoResult.Value; + if (info.Kind == StorageItemKind.NotFound) { - return Result.Fail($"Failed to resolve path for resource: '{resource}'"); + return Result.Fail($"File not found: '{resourceKey}'"); } - var workbookPath = resolveResult.Value; + if (info.Kind != StorageItemKind.File) + { + return Result.Fail($"Resource is not a file: '{resourceKey}'"); + } + + return resourceKey; + } + + // 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; - if (!File.Exists(workbookPath)) + var openResult = await fileStorage.OpenReadAsync(resource); + if (openResult.IsFailure) { - return Result.Fail($"File not found: '{resource}'"); + return Result.Fail($"Failed to open workbook: '{resource}'") + .WithErrors(openResult); } - return workbookPath; + return openResult.Value; } 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 9b76f1ba6..7a2a53efe 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, IFileStorage fileStorage) { var extension = ExtensionForFormat(format); if (extension is null) @@ -53,12 +54,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 fileName = GenerateAutoName(extension, folderAbsolutePath); - var combined = string.IsNullOrEmpty(folderResourceKey) ? fileName : folderResourceKey + "/" + fileName; + var folderResource = key; + var folderPath = key.Path; + var fileName = await GenerateAutoNameAsync(extension, fileStorage, folderResource); + 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 +71,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( @@ -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, 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 @@ -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(fileStorage, 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(fileStorage, 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(IFileStorage fileStorage, 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 fileStorage.GetInfoAsync(candidateKey); + return infoResult.IsSuccess + && 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 e8d999105..db4f52d21 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 fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + var resolveResult = await WebViewScreenshotResolver.ResolveAsync(saveTo, format, fileStorage); if (resolveResult.IsFailure) { return ToolResponse.Error(resolveResult); @@ -83,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/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/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.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 6ca709ca9..78b7c71be 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 IFileStorage? _fileStorage; 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; + _fileStorage = workspaceService.FileStorage; _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 || _fileStorage 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 _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)) { - 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 != StorageItemKind.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; @@ -110,7 +129,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/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/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs b/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs new file mode 100644 index 000000000..fe071ea2e --- /dev/null +++ b/Source/Core/Celbridge.Utilities/Helpers/FileHashHelper.cs @@ -0,0 +1,123 @@ +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 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) + { + 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/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/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/Core/Celbridge.Utilities/Services/PathHelper.cs b/Source/Core/Celbridge.Utilities/Services/PathHelper.cs deleted file mode 100644 index cde778069..000000000 --- a/Source/Core/Celbridge.Utilities/Services/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/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/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..85f04d10a 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,13 @@ public Result Initialize() public IReadOnlyList CreateDocumentEditorFactories(IServiceProvider serviceProvider) { - return []; + var stringLocalizer = serviceProvider.GetRequiredService(); + return + [ + new ProjectFileFactory(stringLocalizer), + new PackageManifestFactory(stringLocalizer), + new DocumentContributionFactory(stringLocalizer), + ]; } public Result CreateActivity(string activityName) 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", 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.toml index a3856c3de..4d2bba168 100644 --- a/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml +++ b/Source/Modules/Celbridge.DocumentEditors/Editors/CodeEditor/markdown.document.toml @@ -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/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/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.Spreadsheet/Commands/AddSheetsCommand.cs b/Source/Modules/Celbridge.Spreadsheet/Commands/AddSheetsCommand.cs index 9d5d9f41c..9e134f23e 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..e0ec78118 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..f04b59e16 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..b284947ab 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..df75dd281 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..41cb4a023 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..f6a07fb25 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..e186beddc 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..6c3f1fa84 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..9051fe118 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..2bda5291d 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..eb3169f4b 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..8a72e5799 100644 --- a/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs +++ b/Source/Modules/Celbridge.Spreadsheet/Commands/SetActiveViewCommand.cs @@ -1,4 +1,6 @@ using Celbridge.Commands; +using Celbridge.Documents; +using Celbridge.Spreadsheet.Helpers; using Celbridge.Workspace; using ClosedXML.Excel; @@ -25,14 +27,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 +85,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 +103,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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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 +214,18 @@ private Result ApplyViewStateToWorkbook(string workbookPath) worksheet.SheetView.TopLeftCellAddress = scrollAnchor.Address; } - SpreadsheetHelper.RecalculateAndSave(workbook); + // 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) + { + 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..5db5b623e 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..5b9004378 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..9fb2f356f 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..da3eebd06 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var loadResult = await SpreadsheetHelper.LoadWorkbookAsync(fileStorage, 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(fileStorage, 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..42fcca072 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 fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.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 == StorageItemKind.NotFound) { return Result.Fail($"Workbook file not found: '{fileResource}'"); } + if (info.Kind != StorageItemKind.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( + IFileStorage fileStorage, + ResourceKey fileResource) + { + var bytesResult = await fileStorage.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( + IFileStorage fileStorage, + 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 fileStorage.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/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/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/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 cea2090f4..9acdb6bc9 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) { @@ -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/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs b/Source/Modules/Celbridge.WebView/ViewModels/WebViewDocumentViewModel.cs index 30018eade..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. /// @@ -41,7 +44,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; @@ -56,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() @@ -72,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; @@ -105,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/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs b/Source/Modules/Celbridge.WebView/Views/WebViewDocumentView.xaml.cs index 542d42dde..3e50209c5 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; @@ -218,6 +219,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) @@ -240,6 +244,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.DownloadsFolder}"); + await FileStorage.CreateFolderAsync(downloadsFolder); + _webView.CoreWebView2.DownloadStarting -= CoreWebView2_DownloadStarting; _webView.CoreWebView2.DownloadStarting += CoreWebView2_DownloadStarting; @@ -392,6 +402,7 @@ private void TeardownWebViewState() if (_webView is not null) { + _webView.GotFocus -= WebView_GotFocus; AppWebViewContainer.Children.Remove(_webView); _webView.Close(); _webView = null; @@ -531,14 +542,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($"{ProjectConstants.DownloadsFolder}/{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; @@ -554,28 +568,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.DownloadsFolder}/{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) + { + 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) { - command.ResourceType = ResourceType.File; - command.SourcePath = tempPath; - command.DestResource = saveResourceKey; - }); + 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. @@ -606,6 +688,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); 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/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/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..e6680a98f --- /dev/null +++ b/Source/Tests/Documents/DocumentLayoutStoreTests.cs @@ -0,0 +1,361 @@ +using Celbridge.Commands; +using Celbridge.Messaging; +using Celbridge.Resources; +using Celbridge.Resources.Services; +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 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(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + _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); + + // 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); + + _store = new DocumentLayoutStore( + _workspaceWrapper, + _commandService, + 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 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), + }; + _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 == DocumentEditorId.Empty + && 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. + // FileStorage.GetInfoAsync reports NotFound; 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_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..1fc6b6831 --- /dev/null +++ b/Source/Tests/Documents/DocumentViewFactoryTests.cs @@ -0,0 +1,406 @@ +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); + result.Value.EditorId.Should().Be(sidecarEditorId); + } + + [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) + { + // 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()); + 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/DocumentViewModelTests.cs b/Source/Tests/Documents/DocumentViewModelTests.cs index 84d3affef..503f6b33c 100644 --- a/Source/Tests/Documents/DocumentViewModelTests.cs +++ b/Source/Tests/Documents/DocumentViewModelTests.cs @@ -16,7 +16,8 @@ namespace Celbridge.Tests.Documents; public class DocumentViewModelTests { private IMessengerService _messengerService = null!; - private IResourceFileWriter _fileWriter = null!; + private IFileStorage _fileStorage = null!; + private IResourceRegistry _resourceRegistry = null!; private TestDocumentViewModel _vm = null!; private string _tempFolder = null!; private string _tempFilePath = null!; @@ -32,16 +33,16 @@ 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 FileStorage 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); - 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); @@ -49,14 +50,15 @@ public void Setup() var workspaceWrapper = Substitute.For(); workspaceWrapper.WorkspaceService.Returns(workspaceService); - _fileWriter = new ResourceFileWriter(Substitute.For>(), workspaceWrapper); + _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(_fileWriter); + _vm = new TestDocumentViewModel(_fileStorage); _vm.FileResource = new ResourceKey("test.md"); _vm.FilePath = _tempFilePath; } @@ -88,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(); @@ -135,9 +144,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 FileStorage(Substitute.For>(), _messengerService, failingWrapper); - var failingVm = new TestDocumentViewModel(failingWriter) + var failingVm = new TestDocumentViewModel(failingFileSystem) { FileResource = new ResourceKey("test.md"), FilePath = _tempFilePath @@ -160,37 +169,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(); @@ -227,14 +236,16 @@ 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(_fileWriter, _tempFilePath, externalContent); + var savingVm = new ExternalWriteDocumentViewModel(_fileStorage, _tempFilePath, externalContent); savingVm.FileResource = new ResourceKey("interleave.md"); savingVm.FilePath = _tempFilePath; @@ -249,17 +260,37 @@ 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. /// private sealed class TestDocumentViewModel : DocumentViewModel { - private readonly IResourceFileWriter _writer; + private readonly IFileStorage _fileStorage; - public TestDocumentViewModel(IResourceFileWriter writer) + public TestDocumentViewModel(IFileStorage fileStorage) { - _writer = writer; + _fileStorage = fileStorage; EnableFileChangeMonitoring(); } @@ -281,26 +312,27 @@ public void OnTextChanged() SaveTimer = SaveDelay; } - protected override IResourceFileWriter GetFileWriter() => _writer; + protected override IFileStorage GetFileSystem() => _fileStorage; } /// /// 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 { - private readonly IResourceFileWriter _writer; + private readonly IFileStorage _fileStorage; private readonly string _injectedFilePath; private readonly string _externalContent; private bool _hasInjected; - public ExternalWriteDocumentViewModel(IResourceFileWriter writer, string filePath, string externalContent) + public ExternalWriteDocumentViewModel(IFileStorage fileStorage, string filePath, string externalContent) { - _writer = writer; + _fileStorage = fileStorage; _injectedFilePath = filePath; _externalContent = externalContent; EnableFileChangeMonitoring(); @@ -313,16 +345,16 @@ public Task SaveDocumentContent(string text) return SaveTextToFileAsync(text); } - protected override IResourceFileWriter GetFileWriter() => _writer; + protected override IFileStorage GetFileSystem() => _fileStorage; - protected override void UpdateFileTrackingInfo() + public override async Task UpdateFileTrackingInfoAsync() { if (!_hasInjected) { _hasInjected = true; File.WriteAllText(_injectedFilePath, _externalContent); } - base.UpdateFileTrackingInfo(); + await base.UpdateFileTrackingInfoAsync(); } } } diff --git a/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs b/Source/Tests/Documents/MultiPartExtensionResolutionTests.cs new file mode 100644 index 000000000..8e32a3f88 --- /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(Substitute.For()); + + var noteCelFactory = CreateMockFactory("test.note-cel", ".note.cel", EditorPriority.Specialized); + var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); + + registry.RegisterFactory(noteCelFactory); + registry.RegisterFactory(celFactory); + + var fileResource = new ResourceKey("foo.note.cel"); + var result = registry.GetFactory(fileResource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(noteCelFactory); + } + + [Test] + public void GetFactory_FallsBackToSingleCelWhenNoMultiPartFactoryRegistered() + { + 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); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(celFactory); + } + + [Test] + public void GetFactory_MultiPartWinsEvenWhenSingleCelIsAlsoRegistered() + { + 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 noteCelFactory = CreateMockFactory("test.note-cel", ".note.cel", EditorPriority.Specialized); + var celFactory = CreateMockFactory("test.cel-fallback", ".cel", EditorPriority.General); + + registry.RegisterFactory(noteCelFactory); + registry.RegisterFactory(celFactory); + + var fileResource = new ResourceKey("foo.note.cel"); + var result = registry.GetFactory(fileResource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(noteCelFactory); + } + + [Test] + public void GetFactory_FactoryRegisteringMultipleMultiPartExtensionsMatchesBoth() + { + var registry = new DocumentEditorRegistry(Substitute.For()); + + 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.theme.cel")); + + noteResult.IsSuccess.Should().BeTrue(); + noteResult.Value.Should().Be(multiFactory); + modResult.IsSuccess.Should().BeTrue(); + modResult.Value.Should().Be(multiFactory); + } + + [Test] + public void GetFactory_SpecializedStillBeatsGeneralOnSameMultiPartExtension() + { + var registry = new DocumentEditorRegistry(Substitute.For()); + + 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.note.cel")); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(specialized); + } + + [Test] + public void GetFactory_MatchesByExactFilenameBeforeExtension() + { + var registry = new DocumentEditorRegistry(Substitute.For()); + + 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")); + var otherTomlResult = registry.GetFactory(new ResourceKey("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(Substitute.For()); + + 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(Substitute.For()); + + 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()).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()).Returns(canHandle); + return factory; + } +} 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/Explorer/OpenWithMenuOptionTests.cs b/Source/Tests/Explorer/OpenWithMenuOptionTests.cs index 7d1fbc086..19b3bb445 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); @@ -56,12 +70,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); @@ -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.toml"); + 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/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/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 new file mode 100644 index 000000000..377436d62 --- /dev/null +++ b/Source/Tests/Migration/Steps/MigrationStep_0_3_0_Tests.cs @@ -0,0 +1,270 @@ +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 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 +{ + 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, LegacyConstants.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_ConvertsWebViewFromJsonToToml() + { + WriteMinimalProjectFile(); + var oldWebViewPath = Path.Combine(_projectFolderPath, "page.webview"); + await File.WriteAllTextAsync(oldWebViewPath, "{\"sourceUrl\": \"https://example.com/path\"}"); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + 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() + { + WriteMinimalProjectFile(); + var oldWebViewPath = Path.Combine(_projectFolderPath, "empty.webview"); + await File.WriteAllTextAsync(oldWebViewPath, "{}"); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + 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() + { + var content = """ + [celbridge] + celbridge-version = "0.2.7" + + [project] + name = "TestProject" + entry = "Sites/index.webview" + """; + await File.WriteAllTextAsync(_projectFilePath, content); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + 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() + { + WriteMinimalProjectFile(); + Directory.CreateDirectory(_projectDataFolderPath); + + var metadataWebView = Path.Combine(_projectDataFolderPath, "stale.webview"); + await File.WriteAllTextAsync(metadataWebView, "{}"); + + var context = CreateContext(); + + var result = await _step.ApplyAsync(context); + + result.IsSuccess.Should().BeTrue(); + File.Exists(metadataWebView).Should().BeTrue(); + File.Exists(Path.Combine(_projectDataFolderPath, "stale.webview.cel")).Should().BeFalse(); + } + + [Test] + public async Task ApplyAsync_IsIdempotent() + { + WriteMinimalProjectFile(); + await File.WriteAllTextAsync(Path.Combine(_projectFolderPath, "page.webview"), "{\"sourceUrl\": \"https://example.com\"}"); + + var context = CreateContext(); + + var firstResult = await _step.ApplyAsync(context); + var secondResult = await _step.ApplyAsync(context); + + firstResult.IsSuccess.Should().BeTrue(); + secondResult.IsSuccess.Should().BeTrue(); + File.Exists(Path.Combine(_projectFolderPath, "page.webview")).Should().BeFalse(); + 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 = """ + [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/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/Packages/FileTypeProviderTests.cs b/Source/Tests/Packages/FileTypeProviderTests.cs index 93cf76fdc..c7a7224f4 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,32 @@ 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 fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + + var registry = new PackageRegistry(logger, _moduleService, _featureFlags, localizationService, workspaceWrapper); + _service = new PackageService(messengerService, registry); } [TearDown] @@ -54,9 +82,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 +101,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 +111,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 +133,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 +153,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 +173,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 +193,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 +225,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 +242,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 +264,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 +288,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 +368,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 20255b2ae..12ec70535 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,32 @@ 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 fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + + var registry = new PackageRegistry(logger, _moduleService, featureFlags, localizationService, workspaceWrapper); + _service = new PackageService(_messengerService, registry); } [TearDown] @@ -41,29 +70,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 +101,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 +116,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 +125,7 @@ public void RegisterPackages_InvalidManifest_SkipsAndContinues() Directory.CreateDirectory(badDir); File.WriteAllText(Path.Combine(badDir, "package.toml"), "{ invalid toml }"); - _service.RegisterPackages(_tempProjectFolder); + await _service.RegisterPackagesAsync(_tempProjectFolder); var contributions = _service.GetAllDocumentEditors(); contributions.Should().HaveCount(1); @@ -104,7 +133,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 +141,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 +149,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 +163,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 +180,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 +194,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 +215,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 +223,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 +238,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 +251,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 +267,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 +275,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 +283,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 +291,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 +320,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 +352,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 +375,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.toml, @@ -356,10 +394,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 +405,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 +413,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/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/ApplyRangeEditsCommandTests.cs b/Source/Tests/Resources/ApplyRangeEditsCommandTests.cs index 25d00f945..429915a48 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,8 +37,8 @@ public void Setup() _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); - var fileWriter = new ResourceFileWriter(Substitute.For>(), _workspaceWrapper); - resourceService.FileWriter.Returns(fileWriter); + 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 new file mode 100644 index 000000000..48f89dfff --- /dev/null +++ b/Source/Tests/Resources/DataCheckProjectTests.cs @@ -0,0 +1,309 @@ +using Celbridge.Explorer.Services; +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; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for ProjectCheckCommand — the engine behind the data_check_project +/// MCP tool. The command runs the on-demand ResourceScanner over the project's +/// text files and consults the registry's sidecar report. +/// +[TestFixture] +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!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(DataCheckProjectTests), + 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(); + _rootHandlerRegistry = new RootHandlerRegistry(); + _resourceRegistry = new ResourceRegistry( + Substitute.For>(), + _messengerService, + new ProjectTreeBuilder(fileIconService), + ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), + _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. + _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); + + _workspaceWrapper = Substitute.For(); + _workspaceWrapper.IsWorkspacePageLoaded.Returns(true); + _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + var fileStorage = new FileStorage( + Substitute.For>(), + _messengerService, + _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + + var scanner = new ResourceScanner( + Substitute.For>(), + _workspaceWrapper); + workspaceService.ResourceScanner.Returns(scanner); + + _command = new ProjectCheckCommand( + Substitute.For>(), + _workspaceWrapper); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + if (Directory.Exists(_logsBackingFolder)) + { + try + { + Directory.Delete(_logsBackingFolder, true); + } + catch + { + // Best effort + } + } + } + + [Test] + public async Task CleanProject_AllReportListsAreEmpty() + { + // Fixture uses .json because the scanner only walks allowlisted + // data-bearing extensions. See ResourceScanner.ScannableExtensions. + File.WriteAllText(Path.Combine(_projectFolderPath, "a.json"), "{}"); + File.WriteAllText(Path.Combine(_projectFolderPath, "b.json"), + "{ \"target\": \"project:a.json\" }"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenReferences.Should().BeEmpty(); + _command.ResultValue.OrphanCelFiles.Should().BeEmpty(); + _command.ResultValue.BrokenCelFiles.Should().BeEmpty(); + } + + [Test] + public async Task BrokenReference_IsReportedWithSourceAndTarget() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "source.json"), + "{ \"target\": \"project:missing.json\" }"); + + _resourceRegistry.UpdateResourceRegistry().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.json")); + entry.MissingTarget.Should().Be(new ResourceKey("missing.json")); + } + + [Test] + public async Task NonAllowlistedExtensions_AreExcludedFromScan() + { + // .md is not on the allowlist (along with .txt, .rst, .yaml, and every + // 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 + // using markdown as a representative example. + File.WriteAllText(Path.Combine(_projectFolderPath, "notes.md"), + "This documentation mentions \"project:missing.json\" 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 SidecarOfNonAllowlistedParent_IsStillScanned() + { + // 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.json\"\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] + public async Task OrphanCelFile_AppearsInReport() + { + // foo.png is the would-be parent; only the sidecar exists. + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "tags = [\"orphaned\"]\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.OrphanCelFiles + .Should().Contain(new ResourceKey("foo.png.cel")); + } + + [Test] + public async Task BrokenCelFile_AppearsInReport() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md"), "Body."); + File.WriteAllText(Path.Combine(_projectFolderPath, "doc.md.cel"), + "this = is not valid = toml ###\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenCelFiles + .Should().Contain(new ResourceKey("doc.md.cel")); + } + + [Test] + public async Task InvalidCelSuffix_AppearsInBrokenList() + { + // .cel.cel files are classified Broken per the sidecar pairing rules. + File.WriteAllText(Path.Combine(_projectFolderPath, "weird.cel.cel"), + "tags = [\"x\"]\n"); + + _resourceRegistry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + (await _command.ExecuteAsync()).IsSuccess.Should().BeTrue(); + + _command.ResultValue.BrokenCelFiles + .Should().Contain(new ResourceKey("weird.cel.cel")); + } + + [Test] + public async Task MultipleBrokenReferences_OrderedDeterministically() + { + 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.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); + + var keys = _command.ResultValue.BrokenReferences + .Select(r => (r.MissingTarget.ToString(), r.Source.ToString())) + .ToList(); + + 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"); + } + + [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/EditFileCommandTests.cs b/Source/Tests/Resources/EditFileCommandTests.cs index d77087eff..767034ec0 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,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 fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/FileStorageTests.cs b/Source/Tests/Resources/FileStorageTests.cs new file mode 100644 index 000000000..eb4b3e8af --- /dev/null +++ b/Source/Tests/Resources/FileStorageTests.cs @@ -0,0 +1,832 @@ +using Celbridge.Messaging; +using Celbridge.Projects; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// Tests for FileStorage — atomic writes, retry on transient IO, +/// parent-folder creation, ResolveResourcePath integration, reads, and +/// stream-open happy paths. +/// +[TestFixture] +public class FileStorageTests +{ + private string _tempFolder = null!; + private IResourceRegistry _resourceRegistry = null!; + private IResourceScanner _resourceScanner = null!; + private FileStorage _fileStorage = null!; + + [SetUp] + public void Setup() + { + _tempFolder = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(FileStorageTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempFolder); + + _resourceRegistry = Substitute.For(); + _resourceRegistry.ProjectFolderPath.Returns(_tempFolder); + + _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); + + var workspaceWrapper = Substitute.For(); + workspaceWrapper.WorkspaceService.Returns(workspaceService); + + var sidecarService = new SidecarService(workspaceWrapper); + workspaceService.SidecarService.Returns(sidecarService); + + _fileStorage = new FileStorage( + Substitute.For>(), + 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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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.StagingFsFolder); + 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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.OpenWriteAsync(resource); + + openResult.IsSuccess.Should().BeTrue(); + await using (var stream = openResult.Value) + { + stream.WriteByte(0x99); + } + + File.Exists(path).Should().BeTrue(); + } + + [Test] + public async Task GetInfoAsync_ReturnsFile_WithSizeAndModifiedUtc_WhenFilePresent() + { + 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 _fileStorage.GetInfoAsync(resource); + + result.IsSuccess.Should().BeTrue(); + var info = result.Value; + info.Kind.Should().Be(StorageItemKind.File); + info.Size.Should().Be(bytes.Length); + info.ModifiedUtc.Should().Be(expectedModifiedUtc); + } + + [Test] + 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 _fileStorage.GetInfoAsync(resource); + + result.IsSuccess.Should().BeTrue(); + var info = result.Value; + info.Kind.Should().Be(StorageItemKind.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 _fileStorage.GetInfoAsync(resource); + + result.IsSuccess.Should().BeTrue(); + var info = result.Value; + info.Kind.Should().Be(StorageItemKind.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 _fileStorage.GetInfoAsync(resource); + + result.IsSuccess.Should().BeTrue(); + result.Value.Kind.Should().Be(StorageItemKind.File); + result.Value.Size.Should().Be("scratch".Length); + } + + [Test] + public async Task GetInfoAsync_ReturnsFailure_WhenResolveFails() + { + var resource = new ResourceKey("bad.txt"); + _resourceRegistry.ResolveResourcePath(resource) + .Returns(Result.Fail("simulated resolve failure")); + + var result = await _fileStorage.GetInfoAsync(resource); + + result.IsFailure.Should().BeTrue(); + } + + [Test] + public async Task MoveAsync_MovesFile_WhenNoReferencersAndNoSidecar() + { + 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 _fileStorage.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 async Task MoveAsync_RejectsCrossRootMove() + { + var sourceKey = new ResourceKey("project:a.txt"); + var destKey = new ResourceKey("temp:a.txt"); + + var result = await _fileStorage.MoveAsync(sourceKey, destKey); + + result.IsFailure.Should().BeTrue(); + result.FirstErrorMessage.Should().Contain("Cross-root"); + } + + [Test] + public async Task MoveAsync_CascadesSidecarWithFile() + { + 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 _fileStorage.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 _fileStorage.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.json"); + + var sourcePath = Path.Combine(_tempFolder, "source.txt"); + var destPath = Path.Combine(_tempFolder, "dest.txt"); + var referencerPath = Path.Combine(_tempFolder, "doc.json"); + 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")); + + _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); + + var result = await _fileStorage.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.json"); + + var sourcePath = Path.Combine(_tempFolder, "source.txt"); + var destPath = Path.Combine(_tempFolder, "dest.txt"); + var referencerPath = Path.Combine(_tempFolder, "doc.json"); + 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")); + + _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); + + var result = await _fileStorage.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.json"); + + var sourcePath = Path.Combine(_tempFolder, "My Document.md"); + var destPath = Path.Combine(_tempFolder, "My Renamed Document.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."); + + _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")); + + _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); + + var result = await _fileStorage.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 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"); + + 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")); + + _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); + + var result = await _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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 _fileStorage.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.json"); + + var sourcePath = Path.Combine(_tempFolder, "target.txt"); + var destPath = Path.Combine(_tempFolder, "target2.txt"); + 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; + + 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.json")).Returns(Result.Ok(referencerPath)); + + _resourceScanner.FindReferencersAsync(sourceKey).Returns(Task.FromResult>(new[] { referencerKey })); + + var result = await _fileStorage.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 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() + { + // 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 _fileStorage.ReadAllBytesAsync(resource); + stopwatch.Stop(); + + result.IsFailure.Should().BeTrue(); + stopwatch.ElapsedMilliseconds.Should().BeLessThan(50); + } +} diff --git a/Source/Tests/Resources/MultiEditFileCommandTests.cs b/Source/Tests/Resources/MultiEditFileCommandTests.cs index cb2b23b16..2b19a4689 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,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 fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] 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/ReplaceFileCommandTests.cs b/Source/Tests/Resources/ReplaceFileCommandTests.cs index c93625d8a..3e220d21b 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,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 fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] diff --git a/Source/Tests/Resources/ResourceClassifierTestHelper.cs b/Source/Tests/Resources/ResourceClassifierTestHelper.cs new file mode 100644 index 000000000..7045d9917 --- /dev/null +++ b/Source/Tests/Resources/ResourceClassifierTestHelper.cs @@ -0,0 +1,68 @@ +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Resources; + +/// +/// 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 ResourceClassifierTestHelper +{ + /// + /// 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 IResourceClassifier BuildEmptyStub() + { + var stub = Substitute.For(); + var emptyReport = new SidecarReport( + Healthy: Array.Empty(), + Broken: Array.Empty(), + Orphan: Array.Empty()); + stub.ClassifyResources(Arg.Any(), Arg.Any()) + .Returns(emptyReport); + return stub; + } + + /// + /// 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 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 BuildClassifier(editorRegistry); + } + + /// + /// Builds a real ResourceClassifier wrapped around the supplied editor + /// registry. Use when the test wants to stub specific standalone-form + /// recognition rules (e.g. foo.webview.cel, foo.note.cel). + /// + public static ResourceClassifier BuildClassifier(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 ResourceClassifier( + Substitute.For>(), + workspaceWrapper); + } +} diff --git a/Source/Tests/Resources/ResourceClassifierTests.cs b/Source/Tests/Resources/ResourceClassifierTests.cs new file mode 100644 index 000000000..94187ca0a --- /dev/null +++ b/Source/Tests/Resources/ResourceClassifierTests.cs @@ -0,0 +1,256 @@ +using Celbridge.Resources; +using Celbridge.Resources.Services; + +namespace Celbridge.Tests.Resources; + +/// +/// 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 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 ResourceClassifierTests +{ + private string _projectFolderPath = null!; + + [SetUp] + public void Setup() + { + _projectFolderPath = Path.Combine( + Path.GetTempPath(), + "Celbridge", + nameof(ResourceClassifierTests), + 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.IsExtensionSupported(".note.cel").Returns(true); + + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); + 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 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 classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Orphan.Should().Contain(new ResourceKey("orphaned.png.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 classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); + 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.IsExtensionSupported(Arg.Any()).Returns(false); + + var classifier = ResourceClassifierTestHelper.BuildClassifier(editorRegistry); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + editorRegistry.DidNotReceive().IsExtensionSupported(Arg.Any()); + + 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 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(CelFileStatus.Healthy); + + registry.GetSidecarReport() + .Healthy.Should().Contain(new ResourceKey("subfolder/note.md.cel")); + } + + [Test] + public void EmptyTree_ProducesEmptyReport() + { + var classifier = ResourceClassifierTestHelper.BuildClassifierWithNoFactories(); + var registry = BuildRegistry(classifier); + registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var report = registry.GetSidecarReport(); + report.Healthy.Should().BeEmpty(); + report.Broken.Should().BeEmpty(); + report.Orphan.Should().BeEmpty(); + } + + [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()), + classifier, + new RootHandlerRegistry()); + registry.InitializeProjectRoot(_projectFolderPath); + return registry; + } +} diff --git a/Source/Tests/Resources/ResourceCommandTests.cs b/Source/Tests/Resources/ResourceCommandTests.cs index a13c2a360..cf37d1a7d 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; @@ -19,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"; @@ -46,18 +50,29 @@ public void Setup() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - _resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - _resourceRegistry.ProjectFolderPath = _projectFolderPath; + _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); _workspaceWrapper = Substitute.For(); _workspaceWrapper.WorkspaceService.Returns(workspaceService); + + // ListFolderContentsCommand and GetFileTreeCommand route through the + // FileStorage chokepoint, so the workspace needs a real instance + // (a Substitute would return null for EnumerateFolderAsync). + var fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] @@ -257,4 +272,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"); + } + } + _rootHandlerRegistry.RegisterRootHandler(new LogsRootHandler(logsBacking)); + 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/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/ResourceKeyTests.cs b/Source/Tests/Resources/ResourceKeyTests.cs index dbd8827b5..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,6 +183,128 @@ 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] + 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("project: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 ToStringEmitsCanonicalForm() + { + // 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:"); + } + + [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/ResourceOperationServiceTests.cs b/Source/Tests/Resources/ResourceOperationServiceTests.cs new file mode 100644 index 000000000..cf37a41f0 --- /dev/null +++ b/Source/Tests/Resources/ResourceOperationServiceTests.cs @@ -0,0 +1,200 @@ +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 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 +{ + 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); + + // 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/ResourceRegistryTests.cs b/Source/Tests/Resources/ResourceRegistryTests.cs index 1ae52a10b..d714fc590 100644 --- a/Source/Tests/Resources/ResourceRegistryTests.cs +++ b/Source/Tests/Resources/ResourceRegistryTests.cs @@ -1,7 +1,10 @@ using Celbridge.Explorer.Services; using Celbridge.Messaging.Services; +using Celbridge.Resources; +using Celbridge.Resources.Helpers; using Celbridge.Resources.Models; using Celbridge.Resources.Services; +using Celbridge.Resources.Services.Roots; using Celbridge.UserInterface.Services; using Celbridge.Workspace; @@ -69,17 +72,17 @@ public void ICanUpdateTheResourceTree() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + 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.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); // // 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(); @@ -108,15 +111,15 @@ public void ICanExpandAFolderResource() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var workspaceWrapper = Substitute.For(); var folderStateService = new FolderStateService(workspaceWrapper); 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. @@ -124,9 +127,10 @@ 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.RootFolder.Children[0] as FolderResource)!; + var folderResource = (resourceRegistry.ProjectFolder.Children[0] as FolderResource)!; var folderPath = resourceRegistry.GetResourceKey(folderResource); folderStateService.IsExpanded(folderPath).Should().BeTrue(); } @@ -138,8 +142,8 @@ public void ResolveResourcePathReturnsCorrectAbsolutePath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Create(FileNameA)); resolveResult.IsSuccess.Should().BeTrue(); @@ -154,8 +158,8 @@ public void ResolveResourcePathWithEmptyKeyReturnsProjectFolder() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath(ResourceKey.Empty); resolveResult.IsSuccess.Should().BeTrue(); @@ -170,8 +174,8 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( ResourceKey.Create($"{FolderNameA}/{FileNameB}")); @@ -181,6 +185,58 @@ public void ResolveResourcePathWithNestedKeyReturnsCorrectPath() resolveResult.Value.Should().Be(expectedPath); } + [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 + // 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, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); + 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()); + 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, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); + var updateResult = resourceRegistry.UpdateResourceRegistry(); + updateResult.IsSuccess.Should().BeTrue(updateResult.FirstErrorMessage); + + 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() { @@ -188,8 +244,8 @@ public void ResolveResourcePathAcceptsNonExistentPath() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + 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 var resolveResult = resourceRegistry.ResolveResourcePath( @@ -205,8 +261,8 @@ public void ResolveResourcePathRoundTripsWithGetResourceKey() var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var filePath = Path.Combine(_resourceFolderPath, FileNameA); var getKeyResult = resourceRegistry.GetResourceKey(filePath); @@ -246,8 +302,8 @@ public void ResolveResourcePathRejectsSymlinksWithinProject() { var messengerService = new MessengerService(); var fileIconService = new FileIconService(); - var resourceRegistry = new ResourceRegistry(messengerService, fileIconService); - resourceRegistry.ProjectFolderPath = _resourceFolderPath; + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), new RootHandlerRegistry()); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); var resolveResult = resourceRegistry.ResolveResourcePath( ResourceKey.Create("escape_link/secret.txt")); @@ -266,4 +322,142 @@ public void ResolveResourcePathRejectsSymlinksWithinProject() } } } + + [Test] + public void ProjectRootHandlerIsRegisteredOnProjectFolderPathSet() + { + Guard.IsNotNull(_resourceFolderPath); + + var messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + 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. + rootHandlerRegistry.RootHandlers.Should().BeEmpty(); + + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); + + 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 ResolveResourcePathFailsClearlyForUnregisteredRoot() + { + 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); + + var resolveResult = resourceRegistry.ResolveResourcePath( + ResourceKey.Create("temp:foo/bar")); + resolveResult.IsFailure.Should().BeTrue(); + resolveResult.FirstErrorMessage.Should().Contain("'temp'"); + resolveResult.FirstErrorMessage.Should().Contain("not registered"); + } + + [Test] + public void GetAllFileResourcesScopesToProjectRoot() + { + 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.UpdateResourceRegistry(); + + // Default form enumerates the project tree. + var defaultResults = resourceRegistry.GetAllFileResources(); + defaultResults.Should().NotBeEmpty(); + + // Explicit project root produces the same result. + var explicitProject = resourceRegistry.GetAllFileResources(ResourceKey.DefaultRoot); + explicitProject.Count.Should().Be(defaultResults.Count); + + // 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(); + } + + [Test] + public void RegisterRootHandlerReplacesExistingHandler() + { + Guard.IsNotNull(_resourceFolderPath); + + var messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + var rootHandlerRegistry = new RootHandlerRegistry(); + var resourceRegistry = new ResourceRegistry(Substitute.For>(), messengerService, new ProjectTreeBuilder(fileIconService), ResourceClassifierTestHelper.BuildEmptyStub(), rootHandlerRegistry); + resourceRegistry.InitializeProjectRoot(_resourceFolderPath); + + 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( + Path.GetTempPath(), $"Celbridge/{nameof(ResourceRegistryTests)}_alt"); + Directory.CreateDirectory(alternatePath); + + try + { + resourceRegistry.InitializeProjectRoot(alternatePath); + var newHandler = rootHandlerRegistry.RootHandlers[ResourceKey.DefaultRoot]; + newHandler.Should().NotBeSameAs(originalHandler); + newHandler.BackingLocation.Should().Be(alternatePath); + } + finally + { + if (Directory.Exists(alternatePath)) + { + Directory.Delete(alternatePath, true); + } + } + } + + [Test] + public void GetResourceKeyFromPathDispatchesToLongestPrefixRoot() + { + Guard.IsNotNull(_resourceFolderPath); + + var messengerService = new MessengerService(); + var fileIconService = new FileIconService(); + 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. + // 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); + + rootHandlerRegistry.RegisterRootHandler(new TempRootHandler(tempBacking)); + + // 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/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..eb1361b49 --- /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.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); + var alternatePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(alternatePath); + + try + { + var secondHandler = new ProjectRootHandler(alternatePath); + + _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.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.RegisterRootHandler( + new TempRootHandler(tempBacking)); + + // 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)); + + 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)); + + 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/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 91daeb617..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(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); var resourceKey = ResourceKey.Create("folder/file.txt"); - var resolveResult = validator.ValidateAndResolve(_projectFolder, 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(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve(_projectFolder, 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(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); // First call — verifies the folder - validator.ValidateAndResolve(_projectFolder, 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(_projectFolder, ResourceKey.Create("cached/b.txt")); + resolver.ValidateAndResolve(ResourceKey.Create("cached/b.txt")); } [Test] @@ -84,17 +84,16 @@ public void InvalidateCacheClearsVerifiedFolders() Directory.CreateDirectory(subFolder); File.WriteAllText(Path.Combine(subFolder, "a.txt"), "test"); - var validator = new PathValidator(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); // Cache the folder - validator.ValidateAndResolve(_projectFolder, 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( - _projectFolder, ResourceKey.Create("ephemeral/a.txt")); + var resolveResult = resolver.ValidateAndResolve(ResourceKey.Create("ephemeral/a.txt")); resolveResult.IsSuccess.Should().BeTrue(); resolveResult.Value.Should().NotBeEmpty(); } @@ -105,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"); @@ -122,10 +121,9 @@ public void ValidateAndResolveRejectsReparsePoint() try { - var validator = new PathValidator(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve( - _projectFolder, 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"); } @@ -147,22 +145,78 @@ public void ValidateAndResolveAcceptsNonExistentPath() { Guard.IsNotNull(_projectFolder); - var validator = new PathValidator(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); // Non-existent paths should be accepted (for create operations) - var resolveResult = validator.ValidateAndResolve( - _projectFolder, 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 @@ -183,10 +237,9 @@ public void ValidateAndResolveRejectsIntermediateReparsePoint() try { - var validator = new PathValidator(); + var resolver = new RootPathResolver(ResourceKey.DefaultRoot, _projectFolder); - var resolveResult = validator.ValidateAndResolve( - _projectFolder, 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/SidecarClassificationTests.cs b/Source/Tests/Resources/SidecarClassificationTests.cs new file mode 100644 index 000000000..4e37e90f2 --- /dev/null +++ b/Source/Tests/Resources/SidecarClassificationTests.cs @@ -0,0 +1,157 @@ +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 ProjectTreeBuilder(new FileIconService()), + ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), + new RootHandlerRegistry()); + _registry.InitializeProjectRoot(_projectFolderPath); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_projectFolderPath)) + { + try + { + Directory.Delete(_projectFolderPath, true); + } + catch + { + // Best effort + } + } + } + + private SidecarLink? GetParentSidecar(string parentName) + { + var resource = _registry.GetResource(new ResourceKey(parentName)).Value as IFileResource; + return resource!.Sidecar; + } + + [Test] + public void MalformedTomlPrefix_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = "not = valid = toml = !!!"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + } + + [Test] + public void UnterminatedTomlString_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = "key = \"unterminated\nstring = true\n"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.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 = + "<<<<<<< HEAD\n" + + "tags = [\"theirs\"]\n" + + "=======\n" + + "tags = [\"ours\"]\n" + + ">>>>>>> branch\n"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + _registry.GetSidecarReport().Broken.Should().Contain(new ResourceKey("foo.png.cel")); + } + + [Test] + public void DuplicateBlockNames_ClassifiedAsBroken_BytesUntouched() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var originalContent = + "tags = [\"x\"]\n" + + "+++ \"a\"\nfirst\n" + + "+++ \"a\"\nsecond"; + File.WriteAllText(sidecarPath, originalContent); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Broken); + File.ReadAllText(sidecarPath).Should().Be(originalContent); + } + + [Test] + public void BomAndCrlf_ClassifiedAsHealthy() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + var sidecarPath = Path.Combine(_projectFolderPath, "foo.png.cel"); + var content = "key = \"value\"\r\n"; + File.WriteAllText(sidecarPath, content); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + GetParentSidecar("foo.png")!.Status.Should().Be(CelFileStatus.Healthy); + } + + [Test] + public void ProjectLoads_EvenWhenSidecarStateIsBroken() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "good.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "good.png.cel"), + "tags = [\"x\"]\n"); + + File.WriteAllText(Path.Combine(_projectFolderPath, "bad.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "bad.png.cel"), + "malformed = \n"); + + var result = _registry.UpdateResourceRegistry(); + result.IsSuccess.Should().BeTrue(); + + GetParentSidecar("good.png")!.Status.Should().Be(CelFileStatus.Healthy); + GetParentSidecar("bad.png")!.Status.Should().Be(CelFileStatus.Broken); + } +} diff --git a/Source/Tests/Resources/SidecarHelperTests.cs b/Source/Tests/Resources/SidecarHelperTests.cs new file mode 100644 index 000000000..ded419598 --- /dev/null +++ b/Source/Tests/Resources/SidecarHelperTests.cs @@ -0,0 +1,305 @@ +using Celbridge.Resources; +using Celbridge.Resources.Helpers; + +namespace Celbridge.Tests.Resources; + +[TestFixture] +public class SidecarHelperTests +{ + [Test] + public void Parse_RoundTripsFrontmatterOnlyFile() + { + var text = "tags = [\"a\", \"b\"]\npriority = \"high\"\n"; + + 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.Blocks.Should().BeEmpty(); + } + + [Test] + public void Parse_AcceptsEmptyFile() + { + var result = SidecarHelper.Parse(string.Empty); + + result.IsSuccess.Should().BeTrue(); + result.Value.Frontmatter.Should().BeEmpty(); + result.Value.Blocks.Should().BeEmpty(); + } + + [Test] + public void Parse_AcceptsFileWithSingleNamedBlock() + { + var text = "tags = [\"meeting\"]\n+++ \"celbridge.notes.note-document.content\"\n# Meeting Notes\n\nBody text."; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Frontmatter.Should().ContainKey("tags"); + result.Value.Blocks.Should().HaveCount(1); + result.Value.Blocks[0].Name.Should().Be("celbridge.notes.note-document.content"); + result.Value.Blocks[0].Content.Should().Contain("# Meeting Notes"); + result.Value.Blocks[0].Content.Should().Contain("Body text."); + } + + [Test] + public void Parse_PreservesOrderOfMultipleBlocks() + { + var text = "tags = [\"pixel-art\"]\n+++ \"celbridge.piskel.canvas\"\nfirst block content\n+++ \"celbridge.piskel.layers\"\nsecond block content"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Blocks.Should().HaveCount(2); + result.Value.Blocks[0].Name.Should().Be("celbridge.piskel.canvas"); + result.Value.Blocks[1].Name.Should().Be("celbridge.piskel.layers"); + result.Value.Blocks[0].Content.Should().Contain("first block content"); + result.Value.Blocks[1].Content.Should().Contain("second block content"); + } + + [Test] + public void Parse_AcceptsFileWithBlocksAndNoFrontmatter() + { + var text = "+++ \"only.block\"\njust the block, no frontmatter"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Frontmatter.Should().BeEmpty(); + result.Value.Blocks.Should().HaveCount(1); + result.Value.Blocks[0].Content.Should().Contain("just the block"); + } + + [Test] + public void Parse_RejectsDuplicateBlockNames() + { + var text = "+++ \"a\"\nfirst\n+++ \"a\"\nsecond"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeFalse(); + result.FirstErrorMessage.Should().Contain("duplicate block name"); + } + + [Test] + public void Parse_RejectsMalformedFrontmatterToml() + { + var text = "not valid toml at all = !!!"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeFalse(); + } + + [Test] + public void Parse_DoesNotTreatUppercaseFenceAsFence() + { + // A line "+++ \"Block\"" with uppercase B doesn't match the regex, so + // it remains part of the frontmatter, which causes the TOML parse to + // fail and the file classifies as broken. + var text = "+++ \"Block\"\nbody"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeFalse(); + } + + [Test] + public void Compose_RoundTripsFrontmatterOnly() + { + var frontmatter = new Dictionary + { + ["title"] = "My Notes", + ["tags"] = new List { "meeting", "todo" }, + }; + + var composed = SidecarHelper.Compose(frontmatter, Array.Empty()); + var parseResult = SidecarHelper.Parse(composed); + + parseResult.IsSuccess.Should().BeTrue(); + parseResult.Value.Frontmatter["title"].Should().Be("My Notes"); + parseResult.Value.Blocks.Should().BeEmpty(); + } + + [Test] + public void Compose_RoundTripsFrontmatterPlusBlocks() + { + var frontmatter = new Dictionary + { + ["editor"] = "celbridge.notes.note-document", + ["tags"] = new List { "meeting" }, + }; + var blocks = new List + { + new("celbridge.notes.note-document.content", "# Meeting Notes\n\nBody.\n"), + new("celbridge.notes.note-document.revisions", "rev-1\nrev-2\n"), + }; + + var composed = SidecarHelper.Compose(frontmatter, blocks); + var parseResult = SidecarHelper.Parse(composed); + + parseResult.IsSuccess.Should().BeTrue(); + parseResult.Value.Frontmatter["editor"].Should().Be("celbridge.notes.note-document"); + parseResult.Value.Blocks.Should().HaveCount(2); + parseResult.Value.Blocks[0].Name.Should().Be("celbridge.notes.note-document.content"); + parseResult.Value.Blocks[0].Content.Should().Contain("# Meeting Notes"); + parseResult.Value.Blocks[1].Name.Should().Be("celbridge.notes.note-document.revisions"); + parseResult.Value.Blocks[1].Content.Should().Contain("rev-1"); + } + + [Test] + public void Compose_HandlesEmptyFrontmatter() + { + var blocks = new List + { + new("just.block", "hello body"), + }; + + var composed = SidecarHelper.Compose(new Dictionary(), blocks); + var parseResult = SidecarHelper.Parse(composed); + + parseResult.IsSuccess.Should().BeTrue(); + parseResult.Value.Frontmatter.Should().BeEmpty(); + parseResult.Value.Blocks.Should().HaveCount(1); + parseResult.Value.Blocks[0].Content.Should().Contain("hello body"); + } + + [Test] + public void Compose_RejectsInvalidBlockName() + { + var blocks = new List + { + new("Bad Name", "content"), + }; + + var act = () => SidecarHelper.Compose(new Dictionary(), blocks); + + 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() + { + // Writing "three" and writing "three\n" both store as the same on-disk + // bytes and parse back to the same SidecarBlock.Content value. The + // trailing newline is a between-blocks separator, not part of content. + var blocksA = new List + { + new("test.alpha", "three"), + }; + var blocksB = new List + { + new("test.alpha", "three\n"), + }; + + var composedA = SidecarHelper.Compose(new Dictionary(), blocksA); + var composedB = SidecarHelper.Compose(new Dictionary(), blocksB); + + composedA.Should().Be(composedB); + + var parsedA = SidecarHelper.Parse(composedA).Value; + var parsedB = SidecarHelper.Parse(composedB).Value; + + parsedA.Blocks[0].Content.Should().Be("three"); + parsedB.Blocks[0].Content.Should().Be("three"); + } + + [Test] + public void Parse_BlockContentSizeIsStableAcrossAdjacentAppends() + { + // After appending a second block, the first block's byte count must + // not change. Previously the first block "gained" a trailing newline + // when it stopped being the last block, shifting reported sizes. + var singleBlock = new List + { + new("test.alpha", "three"), + }; + var twoBlocks = new List + { + new("test.alpha", "three"), + new("test.beta", "four"), + }; + + var composedSingle = SidecarHelper.Compose(new Dictionary(), singleBlock); + var composedTwo = SidecarHelper.Compose(new Dictionary(), twoBlocks); + + var parsedSingle = SidecarHelper.Parse(composedSingle).Value; + var parsedTwo = SidecarHelper.Parse(composedTwo).Value; + + parsedSingle.Blocks[0].Content.Should().Be("three"); + parsedTwo.Blocks[0].Content.Should().Be("three"); + 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() + { + // A CRLF-terminated block content line yields the same logical content + // value as an LF-terminated one. Round-trip preserves the line bytes + // but does not leak the separator into Content. + var text = "+++ \"test.alpha\"\r\nline-one\r\n+++ \"test.beta\"\r\nline-two\r\n"; + + var result = SidecarHelper.Parse(text); + + result.IsSuccess.Should().BeTrue(); + result.Value.Blocks[0].Content.Should().Be("line-one"); + result.Value.Blocks[1].Content.Should().Be("line-two"); + } +} diff --git a/Source/Tests/Resources/SidecarServiceTests.cs b/Source/Tests/Resources/SidecarServiceTests.cs new file mode 100644 index 000000000..c977e5840 --- /dev/null +++ b/Source/Tests/Resources/SidecarServiceTests.cs @@ -0,0 +1,354 @@ +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 IFileStorage _fileStorage = null!; + private SidecarService _sidecarService = null!; + + [SetUp] + public void Setup() + { + _fileStorage = Substitute.For(); + // Default: nothing exists on disk. Tests opt-in per resource. + _fileStorage.GetInfoAsync(Arg.Any()) + .Returns(Task.FromResult(Result.Ok(new StorageItemInfo(StorageItemKind.NotFound, 0, default)))); + + var workspaceService = Substitute.For(); + workspaceService.FileStorage.Returns(_fileStorage); + + 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"); + + _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); + + 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"); + + _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); + + 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 _fileStorage.DidNotReceive().GetInfoAsync(new ResourceKey("design.widget.cel.cel")); + await _fileStorage.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"); + + _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 _fileStorage.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"); + + _fileStorage.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 _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 _fileStorage.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"; + + _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; + _fileStorage.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"); + + _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 _fileStorage.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"); + + _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 _fileStorage.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 _fileStorage.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 _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() + { + // 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"); + + _fileStorage.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 _fileStorage.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 _fileStorage.DidNotReceive().WriteAllTextAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/Source/Tests/Resources/SidecarTrackingTests.cs b/Source/Tests/Resources/SidecarTrackingTests.cs new file mode 100644 index 000000000..0b96f45ca --- /dev/null +++ b/Source/Tests/Resources/SidecarTrackingTests.cs @@ -0,0 +1,163 @@ +using Celbridge.Explorer.Services; +using Celbridge.Messaging.Services; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.UserInterface.Services; + +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 ProjectTreeBuilder(new FileIconService()), + ResourceClassifierTestHelper.BuildClassifierWithNoFactories(), + new RootHandlerRegistry()); + _registry.InitializeProjectRoot(_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"), + "tags = [\"meeting\"]\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(CelFileStatus.Healthy); + } + + [Test] + public void OrphanSidecar_AppearsInReportOrphan() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "tags = [\"x\"]\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"), + "tags = [\"a\"]\n"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel.cel"), + "should = \"not be paired\"\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(CelFileStatus.Healthy); + } + + [Test] + public void UnparseableSidecar_AppearsInReportBroken() + { + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png"), "data"); + File.WriteAllText(Path.Combine(_projectFolderPath, "foo.png.cel"), + "not = valid = toml = !!!"); + + _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(CelFileStatus.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, "tags = [\"x\"]\n"); + + _registry.UpdateResourceRegistry().IsSuccess.Should().BeTrue(); + + var parent1 = _registry.GetResource(new ResourceKey("foo.png")).Value as IFileResource; + parent1!.Sidecar!.Status.Should().Be(CelFileStatus.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 = invalid toml here = !!!"); + + _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")); + } + + // 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/Resources/TrashServiceTests.cs b/Source/Tests/Resources/TrashServiceTests.cs new file mode 100644 index 000000000..f8f8a331f --- /dev/null +++ b/Source/Tests/Resources/TrashServiceTests.cs @@ -0,0 +1,285 @@ +using Celbridge.Entities; +using Celbridge.Logging; +using Celbridge.Messaging; +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>(), + 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/Tests/Resources/VirtualRootHandlerTests.cs b/Source/Tests/Resources/VirtualRootHandlerTests.cs new file mode 100644 index 000000000..5e83836db --- /dev/null +++ b/Source/Tests/Resources/VirtualRootHandlerTests.cs @@ -0,0 +1,148 @@ +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 handler = new TempRootHandler(_tempBacking); + + 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 handler = new LogsRootHandler(_logsBacking); + + 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 handler = new TempRootHandler(_tempBacking); + + var resolveResult = handler.Resolve(ResourceKey.Create("temp:")); + resolveResult.IsSuccess.Should().BeTrue(); + resolveResult.Value.Should().Be( + Path.GetFullPath(_tempBacking).TrimEnd(Path.DirectorySeparatorChar)); + } + + [Test] + public void HandlersResolveSameKeyToDifferentBackings() + { + Guard.IsNotNull(_tempBacking); + Guard.IsNotNull(_logsBacking); + + 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"); + 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 handler = new TempRootHandler(_tempBacking); + + 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 handler = new LogsRootHandler(_logsBacking); + + 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 handler = new TempRootHandler(_tempBacking); + + // 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/Tests/Resources/WriteBinaryFileCommandTests.cs b/Source/Tests/Resources/WriteBinaryFileCommandTests.cs index 70ce833e7..884537637 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,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 fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] @@ -69,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 6f2016c32..9301aa37a 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,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 fileStorage = new FileStorage(Substitute.For>(), Substitute.For(), _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [TearDown] @@ -68,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"); @@ -87,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 deleted file mode 100644 index 2134ec542..000000000 --- a/Source/Tests/Search/FileFilterTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Celbridge.Search; -using Celbridge.Utilities; - -namespace Celbridge.Tests.Search; - -[TestFixture] -public class FileFilterTests -{ - private FileFilter _filter = 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); - } - - [TearDown] - public void TearDown() - { - if (Directory.Exists(_testDir)) - { - Directory.Delete(_testDir, true); - } - } - - [Test] - public void ShouldSearchFile_RegularTextFile_ReturnsTrue() - { - var filePath = Path.Combine(_testDir, "test.txt"); - File.WriteAllText(filePath, "test content"); - - _filter.ShouldSearchFile(filePath).Should().BeTrue(); - } - - [Test] - public void ShouldSearchFile_NonExistentFile_ReturnsFalse() - { - var filePath = Path.Combine(_testDir, "nonexistent.txt"); - - _filter.ShouldSearchFile(filePath).Should().BeFalse(); - } - - [Test] - public void ShouldSearchFile_MetadataExtension_ReturnsFalse() - { - var filePath = Path.Combine(_testDir, "test.celbridge"); - File.WriteAllText(filePath, "metadata"); - - _filter.ShouldSearchFile(filePath).Should().BeFalse(); - } - - [Test] - public void ShouldSearchFile_WebviewExtension_ReturnsFalse() - { - var filePath = Path.Combine(_testDir, "test.webview"); - File.WriteAllText(filePath, "webview data"); - - _filter.ShouldSearchFile(filePath).Should().BeFalse(); - } - - [Test] - public void ShouldSearchFile_BinaryExtension_ReturnsFalse() - { - var filePath = Path.Combine(_testDir, "test.exe"); - File.WriteAllBytes(filePath, new byte[] { 0x00, 0x01, 0x02 }); - - _filter.ShouldSearchFile(filePath).Should().BeFalse(); - } - - [Test] - public void ShouldSearchFile_ImageExtension_ReturnsFalse() - { - var filePath = Path.Combine(_testDir, "test.png"); - File.WriteAllBytes(filePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); - - _filter.ShouldSearchFile(filePath).Should().BeFalse(); - } - - [Test] - public void ShouldSearchFile_CSharpFile_ReturnsTrue() - { - var filePath = Path.Combine(_testDir, "Test.cs"); - File.WriteAllText(filePath, "public class Test { }"); - - _filter.ShouldSearchFile(filePath).Should().BeTrue(); - } - - [Test] - public void ShouldSearchFile_MarkdownFile_ReturnsTrue() - { - var filePath = Path.Combine(_testDir, "README.md"); - File.WriteAllText(filePath, "# Readme"); - - _filter.ShouldSearchFile(filePath).Should().BeTrue(); - } - - [Test] - public void ShouldSearchFile_LargeFile_ReturnsFalse() - { - var filePath = Path.Combine(_testDir, "large.txt"); - // Create a file larger than 1MB - using (var fs = File.Create(filePath)) - { - fs.SetLength(1024 * 1024 + 1); - } - - _filter.ShouldSearchFile(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(); - } -} diff --git a/Source/Tests/Search/SearchServiceFilterTests.cs b/Source/Tests/Search/SearchServiceFilterTests.cs new file mode 100644 index 000000000..80a9e2ccd --- /dev/null +++ b/Source/Tests/Search/SearchServiceFilterTests.cs @@ -0,0 +1,151 @@ +using Celbridge.Messaging; +using Celbridge.Resources; +using Celbridge.Resources.Services; +using Celbridge.Search.Services; +using Celbridge.Workspace; + +namespace Celbridge.Tests.Search; + +[TestFixture] +public class SearchServiceFilterTests +{ + private SearchService _service = null!; + private IResourceRegistry _resourceRegistry = null!; + private string _testDir = null!; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + + _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); + workspaceWrapper.IsWorkspacePageLoaded.Returns(false); + + // 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] + public void TearDown() + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, true); + } + } + + 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 async Task ShouldSearchFile_RegularTextFile_ReturnsTrue() + { + var (resource, filePath) = MakeResource("test.txt"); + File.WriteAllText(filePath, "test content"); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeTrue(); + } + + [Test] + public async Task ShouldSearchFile_NonExistentFile_ReturnsFalse() + { + var (resource, filePath) = MakeResource("nonexistent.txt"); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); + } + + [Test] + public async Task ShouldSearchFile_MetadataExtension_ReturnsFalse() + { + var (resource, filePath) = MakeResource("test.celbridge"); + File.WriteAllText(filePath, "metadata"); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); + } + + [Test] + public async Task ShouldSearchFile_CelExtension_ReturnsFalse() + { + // .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"); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); + } + + [Test] + public async Task ShouldSearchFile_BinaryExtension_ReturnsFalse() + { + var (resource, filePath) = MakeResource("test.exe"); + File.WriteAllBytes(filePath, new byte[] { 0x00, 0x01, 0x02 }); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); + } + + [Test] + public async Task ShouldSearchFile_ImageExtension_ReturnsFalse() + { + var (resource, filePath) = MakeResource("test.png"); + File.WriteAllBytes(filePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); + } + + [Test] + public async Task ShouldSearchFile_CSharpFile_ReturnsTrue() + { + var (resource, filePath) = MakeResource("Test.cs"); + File.WriteAllText(filePath, "public class Test { }"); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeTrue(); + } + + [Test] + public async Task ShouldSearchFile_MarkdownFile_ReturnsTrue() + { + var (resource, filePath) = MakeResource("README.md"); + File.WriteAllText(filePath, "# Readme"); + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeTrue(); + } + + [Test] + public async Task ShouldSearchFile_LargeFile_ReturnsFalse() + { + var (resource, filePath) = MakeResource("large.txt"); + // Create a file larger than 1MB + using (var fs = File.Create(filePath)) + { + fs.SetLength(1024 * 1024 + 1); + } + + (await _service.ShouldSearchFileAsync(resource, filePath)).Should().BeFalse(); + } +} 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/Tests/Spreadsheet/SpreadsheetCommandTests.cs b/Source/Tests/Spreadsheet/SpreadsheetCommandTests.cs index 4d80c02c2..853f2dea3 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 fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + _workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); } [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/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 59548778a..3e8756e7c 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 FileStorage against the temp folder so the + // chokepoint reads tests rely on probe and read the actual files. + var fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + _services.GetRequiredService().Returns(workspaceWrapper); } @@ -596,6 +607,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_EmitsCanonicalRootPath() + { + // 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)); + + 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("project:Scripts/missing.py"); + } + private static JsonElement ParseResult(CallToolResult result) { var json = result.Content.OfType().Single().Text; diff --git a/Source/Tests/Tools/FileToolsReadImageTests.cs b/Source/Tests/Tools/FileToolsReadImageTests.cs index 1d6066abe..1fa462193 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 fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + _services.GetRequiredService().Returns(workspaceWrapper); } @@ -136,7 +145,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/SpreadsheetToolTests.cs b/Source/Tests/Tools/SpreadsheetToolTests.cs index 3bce706be..39c3efe0c 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 fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); + workspaceService.FileStorage.Returns(fileStorage); + _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; @@ -229,14 +240,15 @@ 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] 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); @@ -710,7 +722,7 @@ public async Task FreezePanes_NegativeRows_ReturnsError() } [Test] - public void ReadFormat_DispatchesToReaderAndReturnsGrid() + public async Task ReadFormat_DispatchesToReaderAndReturnsGrid() { CreatePlaceholderFile("data/styles.xlsx"); @@ -725,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"); @@ -741,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"); @@ -900,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[] { @@ -914,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"); @@ -928,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"); @@ -1143,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"); @@ -1168,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 41f871416..0a854ba4a 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 IFileStorage _fileStorage = 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); + + _fileStorage = new FileStorage( + Substitute.For>(), + Substitute.For(), + workspaceWrapper); } [TearDown] @@ -24,155 +52,147 @@ 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", _fileStorage); result.IsSuccess.Should().BeTrue(); - var key = result.Value.ToString(); - key.Should().StartWith("screenshots/screenshot-"); - key.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$"); + var path = result.Value.Path; + path.Should().StartWith("screenshots/screenshot-"); + path.Should().EndWith(".jpg"); + 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", _fileStorage); result.IsSuccess.Should().BeTrue(); - result.Value.ToString().Should().EndWith(".png"); + 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", _fileStorage); result.IsSuccess.Should().BeTrue(); - result.Value.ToString().Should().Be("docs/output.png"); + 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", _fileStorage); result.IsSuccess.Should().BeTrue(); - result.Value.ToString().Should().Be("docs/output.jpg"); + 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", _fileStorage); 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", _fileStorage); 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", _fileStorage); + var resultPng = await WebViewScreenshotResolver.ResolveAsync(saveTo: "docs/output.txt", format: "png", _fileStorage); 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", _fileStorage); 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] - 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", _fileStorage); 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] - 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", _fileStorage); 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)); + 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", _fileStorage); 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 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] - 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", _fileStorage); 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", _fileStorage); 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", _fileStorage); 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", _fileStorage); result.IsFailure.Should().BeTrue(); result.FirstErrorMessage.Should().Contain("Unsupported screenshot format"); 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.Console/ViewModels/ConsolePanelViewModel.cs b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs index f34f70c98..77c6c7999 100644 --- a/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs +++ b/Source/Workspace/Celbridge.Console/ViewModels/ConsolePanelViewModel.cs @@ -1,7 +1,7 @@ -using System.Security.Cryptography; 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 +14,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; @@ -47,6 +48,15 @@ private record LogEntryException(string Type, string Message, string StackTrace) [ObservableProperty] private string _migrationBannerMessage = string.Empty; + [ObservableProperty] + private bool _isProjectCheckBannerVisible; + + [ObservableProperty] + private string _projectCheckBannerTitle = string.Empty; + + [ObservableProperty] + private string _projectCheckBannerMessage = string.Empty; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(MaximizeRestoreGlyph))] [NotifyPropertyChangedFor(nameof(MaximizeRestoreTooltip))] @@ -70,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, @@ -86,6 +96,7 @@ public ConsolePanelViewModel( _dispatcher = dispatcher; _stringLocalizer = stringLocalizer; _projectService = projectService; + _workspaceWrapper = workspaceWrapper; _commandService = commandService; _layoutService = layoutService; @@ -96,13 +107,16 @@ 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); - // Store the original project file contents - StoreProjectFileHash(); + // Snapshot the project file contents so subsequent changes can be + // 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(); // Check if the project was migrated and show banner if needed CheckMigrationStatus(); @@ -189,6 +203,18 @@ private void HandleConsoleError(ConsoleErrorMessage message) ErrorBannerMessage = _stringLocalizer.GetString("ConsolePanel_PackageLoadErrorMessage"); break; + case ConsoleErrorType.ProjectCheckError: + // Project check findings are advisory, not blocking — the + // project loaded fine. Route to the dismissable warning + // banner rather than the non-dismissable error banner, and + // return early so the error-banner side effects below do + // not fire. + ProjectCheckBannerTitle = _stringLocalizer.GetString("ConsolePanel_ProjectCheckFindingsTitle"); + ProjectCheckBannerMessage = _stringLocalizer.GetString("ConsolePanel_ProjectCheckFindingsMessage", configFile); + IsProjectCheckBannerVisible = true; + ShowConsolePanel(); + return; + default: throw new ArgumentOutOfRangeException(); } @@ -200,6 +226,11 @@ private void HandleConsoleError(ConsoleErrorMessage message) IsProjectChangeBannerVisible = false; } + public void OnProjectCheckBannerClosed() + { + IsProjectCheckBannerVisible = false; + } + public void OnReloadProjectClicked() { // Send message to request project reload @@ -217,7 +248,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; @@ -233,72 +264,86 @@ private void OnMonitoredResourceChanged(object recipient, MonitoredResourceChang { // 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var hashResult = await fileStorage.ComputeHashAsync(projectFileResource); + if (hashResult.IsFailure) { _originalProjectFileHash = null; + return; } + + _originalProjectFileHash = hashResult.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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var hashResult = await fileStorage.ComputeHashAsync(projectFileResource); + if (hashResult.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 = hashResult.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 is null + || !string.Equals(currentHash, _originalProjectFileHash, StringComparison.Ordinal)) + { + // 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.Console/Views/ConsolePanel.xaml b/Source/Workspace/Celbridge.Console/Views/ConsolePanel.xaml index ab01ea050..1b2724eda 100644 --- a/Source/Workspace/Celbridge.Console/Views/ConsolePanel.xaml +++ b/Source/Workspace/Celbridge.Console/Views/ConsolePanel.xaml @@ -14,6 +14,7 @@ + @@ -78,9 +79,18 @@ Message="{x:Bind ViewModel.MigrationBannerMessage, Mode=OneWay}" Closed="{x:Bind ViewModel.OnMigrationBannerClosed}"/> + + 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..2a308839e 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) @@ -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/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 f58153a17..63bc4605f 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentEditorRegistry.cs @@ -7,11 +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. /// @@ -19,9 +26,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 +50,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 ".document.toml" 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 +69,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(); } @@ -59,24 +90,79 @@ 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) + { + 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) { - var extension = Path.GetExtension(fileResource.ToString()).ToLowerInvariant(); + // 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(); - // Check factories registered for this specific extension - if (_extensionToFactories.TryGetValue(extension, out var factoryList)) + 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)) + 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}'"); + 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) + && seenEditorIds.Add(factory.EditorId)) + { + candidates.Add(factory); + } + } + } + } + + 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; } /// @@ -99,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(); @@ -158,6 +244,36 @@ public IReadOnlyList GetAllSupportedExtensions() return _extensionToFactories.Keys.ToList().AsReadOnly(); } + // Yields the extension suffixes of a filename from longest to shortest. + // "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; + + // 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 +294,7 @@ public void Dispose() _factories.Clear(); _extensionToFactories.Clear(); + _filenameToFactories.Clear(); _registeredEditorIds.Clear(); _idToFactory.Clear(); } diff --git a/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs new file mode 100644 index 000000000..27b98008d --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentLayoutStore.cs @@ -0,0 +1,338 @@ +using Celbridge.Commands; +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 ILogger _logger; + + private IDocumentsPanel DocumentsPanel => _workspaceWrapper.WorkspaceService.DocumentsPanel; + + public DocumentLayoutStore( + IWorkspaceWrapper workspaceWrapper, + ICommandService commandService, + ILogger logger) + { + _workspaceWrapper = workspaceWrapper; + _commandService = commandService; + _logger = logger; + } + + /// + /// Serialization DTO for a single open document tab. Public so the + /// 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); + + 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)) + .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) + { + await OpenDefaultReadmeAsync(); + 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 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; + } + + // 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); + + // 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: DocumentEditorId.Empty, + 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 async Task OpenDefaultReadmeAsync() + { + 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(normalizedResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) + { + 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..33a469d86 --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentViewFactory.cs @@ -0,0 +1,269 @@ +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 loaded; the caller drives + /// SetFileResource and LoadContent. + /// + public async Task> CreateAsync( + ResourceKey fileResource, + DocumentEditorId requestedEditorId) + { + 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); + } + + 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 sidecarView = await CreateFromSidecarPreferenceAsync(fileResource); + if (sidecarView is not null) + { + return sidecarView.OkResult(); + } + + var extensionView = await CreateFromExtensionPreferenceAsync(fileResource); + if (extensionView is not null) + { + return extensionView.OkResult(); + } + + var factoryView = CreateFromPriorityFactory(fileResource); + if (factoryView is not null) + { + return factoryView.OkResult(); + } + + return CreateTextFallback(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 + || 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.Value; + } + + _logger.LogWarning(createResult, + $"Sidecar editor '{sidecarEditorId}' failed to create view for '{fileResource}'; falling through"); + return null; + } + + // 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); + 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.Value; + } + + return null; + } + + // Highest-priority factory for the resource. Placeholder factories + // (package.toml, *.celbridge, *.document.toml) 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 + || factoryResult.Value.IsPlaceholder) + { + return null; + } + + var factory = factoryResult.Value; + var createResult = factory.CreateDocumentView(fileResource); + if (createResult.IsSuccess) + { + return createResult.Value; + } + + _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 "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)) + { + 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. 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(); + } + + 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 400992911..a160ac753 100644 --- a/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/Source/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -1,9 +1,11 @@ using Celbridge.Commands; +using Celbridge.Documents.Helpers; using Celbridge.Documents.Views; using Celbridge.Logging; using Celbridge.Messaging; using Celbridge.Modules; using Celbridge.Packages; +using Celbridge.Resources; using Celbridge.Settings; using Celbridge.Workspace; @@ -11,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; @@ -23,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 DocumentEditorPreferenceStore _preferenceStore; + private readonly DocumentViewFactory _viewFactory; + private readonly DocumentLayoutStore _layoutStore; + private readonly ReloadHintStore _reloadHintStore = new(TimeSpan.FromSeconds(2)); + 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. @@ -44,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( @@ -72,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(); @@ -92,6 +98,31 @@ public DocumentsService( { throw new InvalidProgramException("Failed to initialize file type helper"); } + + _fileTypeClassifier = new FileTypeClassifier( + _fileTypeHelper, + _textBinarySniffer, + _workspaceWrapper, + _documentEditorRegistry); + + _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, + serviceProvider.GetRequiredService>()); } private void RegisterModuleDocumentEditorFactories(IModuleService moduleService) @@ -158,36 +189,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) @@ -204,6 +223,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 // @@ -227,54 +254,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(); + public DocumentViewType GetDocumentViewType(ResourceKey fileResource) => + _fileTypeClassifier.GetDocumentViewType(fileResource); - // 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; - } - - // 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) { @@ -282,97 +266,24 @@ 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); - - if (editorId.IsEmpty) - { - // Empty editorId signals that the user wants to clear the preference for this extension - await workspaceSettings.DeletePropertyAsync(preferenceKey); - return; - } - - 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; + public Task GetEditorPreferenceAsync(string extension) => + _preferenceStore.GetExtensionPreferenceAsync(extension); - var resolveResult = resourceRegistry.ResolveResourcePath(fileResource); - if (resolveResult.IsFailure) - { - return Result.Fail($"Failed to resolve path for resource: '{fileResource}'") - .WithErrors(resolveResult); - } - var filePath = resolveResult.Value; + public Task GetPreferredEditorAsync(ResourceKey fileResource) => + _preferenceStore.GetPreferredEditorAsync(fileResource); - 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); - 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); @@ -435,396 +346,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 async Task StoreActiveDocument() - { - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - Guard.IsNotNull(workspaceSettings); - - var fileResource = ActiveDocument.ToString(); - - await workspaceSettings.SetPropertyAsync(ActiveDocumentKey, fileResource); - } - - public async Task StoreSectionRatios(List ratios) - { - 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"); - } + public Task StoreDocumentLayout() => _layoutStore.StoreDocumentLayoutAsync(); - int currentSectionCount = DocumentsPanel.SectionCount; + public Task StoreActiveDocument() => _layoutStore.StoreActiveDocumentAsync(ActiveDocument); - 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; - } + public Task StoreDocumentEditorStates() => _layoutStore.StoreDocumentEditorStatesAsync(); - private async Task OpenDefaultReadme(IResourceRegistry resourceRegistry) - { - var readmeResource = new ResourceKey("readme.md"); + public Task StoreDocumentEditorState(ResourceKey fileResource, string? state) => + _layoutStore.StoreDocumentEditorStateAsync(fileResource, state); - 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; - }); - } - } - } + public Task RestorePanelState() => _layoutStore.RestorePanelStateAsync(); - private async Task> CreateDocumentViewInternalAsync(ResourceKey fileResource, DocumentEditorId documentEditorId = default) + private 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; - - // 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}'"); + return _viewFactory.CreateAsync(fileResource, documentEditorId); } private void OnDocumentResourceChangedMessage(object recipient, DocumentResourceChangedMessage message) @@ -841,16 +376,15 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource } var newResourcePath = resolveResult.Value; - 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 () => { + 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) { @@ -863,33 +397,25 @@ private void OnDocumentResourceChangedMessage(object recipient, DocumentResource _ = changeDocumentResource(); } - private bool _disposed; + public void RegisterReloadHint(ResourceKey fileResource, ReloadHint hint) + { + _reloadHintStore.Register(fileResource, hint); + } - public void Dispose() + public ReloadHint ConsumeReloadHint(ResourceKey fileResource) { - Dispose(true); - GC.SuppressFinalize(this); + return _reloadHintStore.Consume(fileResource); } - protected virtual void Dispose(bool disposing) + public void Dispose() { - 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/Services/ReloadHintStore.cs b/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs new file mode 100644 index 000000000..542b7617c --- /dev/null +++ b/Source/Workspace/Celbridge.Documents/Services/ReloadHintStore.cs @@ -0,0 +1,43 @@ +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. +/// +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; + } +} diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/ContributionDocumentViewModel.cs index e05c704b8..47cf98c23 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { - return GetDefaultTemplateContent(); + return await GetDefaultTemplateContentAsync(); } if (IsBinary) { - var bytes = await File.ReadAllBytesAsync(FilePath); + var bytesResult = await fileStorage.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 fileStorage.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 IFileStorage 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var textResult = await fileStorage.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 28e02df80..0a9ab5522 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentTabViewModel.cs @@ -115,12 +115,12 @@ public bool HasMultipleCompatibleEditors() var extension = Path.GetExtension(FileResource.ToString()).ToLowerInvariant(); var factories = _workspaceWrapper.WorkspaceService.DocumentsService.DocumentEditorRegistry - .GetFactoriesForFileExtension(extension); + .GetFactoriesForExtension(extension); 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var infoResult = await fileStorage.GetInfoAsync(FileResource); + if (infoResult.IsSuccess + && infoResult.Value.Kind == StorageItemKind.File) { return; } @@ -181,7 +184,10 @@ public async Task> CloseDocument(bool forceClose) { Guard.IsNotNull(DocumentView); - if (!File.Exists(FilePath)) + var fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var closeInfoResult = await fileStorage.GetInfoAsync(FileResource); + if (closeInfoResult.IsFailure + || 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 7c6c68a7a..9ff39923b 100644 --- a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs +++ b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentViewModel.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using System.Text; using Celbridge.Logging; using Celbridge.Messaging; @@ -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. @@ -59,11 +62,11 @@ public Result UpdateSaveTimer(double deltaTime) if (SaveTimer <= 0) { SaveTimer = 0; - return Result.Ok(true); + return true; } } - return Result.Ok(false); + return false; } /// @@ -76,7 +79,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,20 +97,20 @@ 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 async void OnResourceChanged(object recipient, ResourceChangedMessage message) { if (message.Resource != FileResource) { return; } - // Self-events from our own writes hash-match _lastSavedFileHash and are - // ignored. Genuine external changes have a different hash and proceed. - if (IsFileChangedExternally()) + // 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. // Discard the queued save so the buffer reload wins. @@ -119,11 +122,11 @@ private void OnMonitoredResourceChanged(object recipient, MonitoredResourceChang } } - private void OnDocumentSaveCompleted(object recipient, DocumentSaveCompletedMessage message) + private async void OnDocumentSaveCompleted(object recipient, DocumentSaveCompletedMessage message) { if (message.DocumentResource == FileResource) { - UpdateFileTrackingInfo(); + await UpdateFileTrackingInfoAsync(); } } @@ -133,17 +136,16 @@ private void OnDocumentSaveCompleted(object recipient, DocumentSaveCompletedMess /// protected async Task> LoadTextFromFileAsync() { - try - { - var text = await File.ReadAllTextAsync(FilePath); - UpdateFileTrackingInfo(); - return Result.Ok(text); - } - catch (Exception ex) + var fileStorage = GetFileSystem(); + var readResult = await fileStorage.ReadAllTextAsync(FileResource); + if (readResult.IsFailure) { return Result.Fail($"Failed to load file: '{FilePath}'") - .WithException(ex); + .WithErrors(readResult); } + + await UpdateFileTrackingInfoAsync(); + return readResult.Value; } /// @@ -177,32 +179,34 @@ protected async Task SaveBinaryToFileAsync(string base64Content) } /// - /// Routes the save through IResourceFileWriter (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 = ComputeBytesHash(bytes); - - if (TryDetectPreWriteExternalChange()) + if (await TryDetectPreWriteExternalChangeAsync()) { return Result.Ok(); } - var writer = GetFileWriter(); - var writeResult = await writer.WriteAllBytesAsync(FileResource, bytes); + var fileStorage = GetFileSystem(); + var writeResult = await fileStorage.WriteAllBytesAsync(FileResource, bytes); if (writeResult.IsFailure) { return writeResult; } - UpdateFileTrackingInfo(); + 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(); @@ -212,55 +216,56 @@ 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 bool TryDetectPreWriteExternalChange() + private async Task TryDetectPreWriteExternalChangeAsync() { - if (_lastSavedFileHash is null - || !File.Exists(FilePath)) + if (_lastSavedFileMtime is null) { return false; } - try + var fileStorage = GetFileSystem(); + var infoResult = await fileStorage.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { - 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 info probe failed for '{FilePath}', proceeding to write attempt"); + return false; } - catch (IOException ex) + + if (infoResult.Value.Size == _lastSavedFileSize + && infoResult.Value.ModifiedUtc == _lastSavedFileMtime) { - _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; } /// - /// 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 IFileStorage GetFileSystem() { var workspaceWrapper = ServiceLocator.AcquireService(); - return workspaceWrapper.WorkspaceService.ResourceService.FileWriter; + return workspaceWrapper.WorkspaceService.FileStorage; } /// @@ -272,75 +277,54 @@ public virtual void Cleanup() _messengerService?.UnregisterAll(this); } - protected bool IsFileChangedExternally() + /// + /// 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; } - try - { - 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 currentHash != _lastSavedFileHash; - } - catch (Exception) + var fileStorage = GetFileSystem(); + var infoResult = await fileStorage.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { - // If we can't read the file, assume it changed (safer to reload) return true; } + + return infoResult.Value.Size != _lastSavedFileSize + || infoResult.Value.ModifiedUtc != _lastSavedFileMtime; } - protected virtual void UpdateFileTrackingInfo() + /// + /// 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() { - try - { - if (File.Exists(FilePath)) - { - var fileInfo = new FileInfo(FilePath); - _lastSavedFileSize = fileInfo.Length; - _lastSavedFileHash = ComputeFileHash(FilePath); - } - } - catch (Exception) + var fileStorage = GetFileSystem(); + var infoResult = await fileStorage.GetInfoAsync(FileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.File) { - // If we can't read the file, clear our tracking info - _lastSavedFileHash = null; + _lastSavedFileMtime = 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); - } - private static string ComputeBytesHash(byte[] bytes) - { - using var sha256 = SHA256.Create(); - var hashBytes = sha256.ComputeHash(bytes); - return Convert.ToBase64String(hashBytes); + _lastSavedFileSize = infoResult.Value.Size; + _lastSavedFileMtime = infoResult.Value.ModifiedUtc; } } diff --git a/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs b/Source/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs index 67e17fa99..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,41 +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.GetFactoriesForFileExtension(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) - { - var resolveResult = ResolveResourcePath(fileResource); - var filePath = resolveResult.IsSuccess ? resolveResult.Value : string.Empty; - - foreach (var factory in factories) - { - if (factory.CanHandleResource(fileResource, filePath)) - { - 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( @@ -187,7 +172,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/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.Documents/Views/ContributionDocumentView.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/ContributionDocumentView.xaml.cs index 6aa6d4404..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; @@ -58,6 +61,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. @@ -486,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 { @@ -508,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) @@ -699,15 +718,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; + } + } } } diff --git a/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentView.cs index edbe47a70..3247ed337 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 IFileStorage? _fileStorage; /// /// Provides access to the resource registry for file resource validation. @@ -25,6 +26,23 @@ protected IResourceRegistry ResourceRegistry } } + /// + /// Provides access to the file storage chokepoint. + /// Lazily initialized from the workspace wrapper. + /// + protected IFileStorage FileStorage + { + get + { + if (_fileStorage is null) + { + var workspaceWrapper = ServiceLocator.AcquireService(); + _fileStorage = workspaceWrapper.WorkspaceService.FileStorage; + } + return _fileStorage; + } + } + /// /// Returns the ViewModel for this document view. /// Used by the base class to provide default SetFileResource and FileResource implementations. @@ -33,35 +51,55 @@ 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. /// 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 FileStorage.GetInfoAsync(fileResource); + if (infoResult.IsFailure + || infoResult.Value.Kind != StorageItemKind.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.Documents/Views/DocumentsPanel.xaml.cs b/Source/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs index 569434dcc..e9541daac 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) @@ -336,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); @@ -410,7 +417,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(); @@ -656,9 +663,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. @@ -680,12 +688,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) 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.Entities/Services/EntityRegistry.cs b/Source/Workspace/Celbridge.Entities/Services/EntityRegistry.cs index 26f576edc..9de77d0db 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; } @@ -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 680a24492..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}.json"; + var relativePath = $"{LegacyConstants.MetaDataFolder}/{LegacyConstants.EntitiesFolder}/{resource.Path}.json"; return relativePath; } diff --git a/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs b/Source/Workspace/Celbridge.Explorer/Commands/AddResourceDialogCommand.cs index f4e3f7fa2..e367e01bb 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + 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 fileStorage.GetInfoAsync(candidateKey); + if (infoResult.IsSuccess + && infoResult.Value.Kind == StorageItemKind.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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; 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 fileStorage.GetInfoAsync(candidateKey); + if (infoResult.IsSuccess + && infoResult.Value.Kind == StorageItemKind.NotFound) { defaultFileName = candidateName; break; 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/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.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..e6c3c8d92 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/CopyPathMenuOption.cs @@ -36,17 +36,20 @@ 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); - 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 => { 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/OpenWithMenuOption.cs b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs index 4b1e19008..4dd6ee8f0 100644 --- a/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs +++ b/Source/Workspace/Celbridge.Explorer/Menu/Options/OpenWithMenuOption.cs @@ -3,14 +3,14 @@ using Celbridge.Dialog; using Celbridge.Documents; using Celbridge.Logging; +using Celbridge.Resources; using Celbridge.Workspace; using Microsoft.Extensions.Localization; 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 { @@ -50,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 @@ -81,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) { @@ -105,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; @@ -147,6 +151,20 @@ 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. 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 = DocumentConstants.SidecarEditorFieldName; + command.Value = selectedFactory.EditorId; + }); + _commandService.Execute(command => { command.FileResource = resourceKey; 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..d799fd8b0 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); } @@ -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) { @@ -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.Explorer/Views/ResourceTree.Keyboard.cs b/Source/Workspace/Celbridge.Explorer/Views/ResourceTree.Keyboard.cs index c8cb8a1f1..fd8b63437 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; } @@ -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.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 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; 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, @@ -28,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; } @@ -63,11 +103,9 @@ public async Task SearchAsync( return new SearchResults(searchTerm, fileResults, 0, 0, false, false); } - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var projectFolder = resourceRegistry.ProjectFolderPath; + var projectFolder = ResourceRegistry.ProjectFolderPath; - if (string.IsNullOrEmpty(projectFolder) || - !Directory.Exists(projectFolder)) + if (string.IsNullOrEmpty(projectFolder)) { return new SearchResults(searchTerm, fileResults, 0, 0, false, false); } @@ -101,7 +139,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) { @@ -123,45 +161,55 @@ public async Task SearchAsync( if (!string.IsNullOrEmpty(scope)) { + // 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)) + { + 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( + 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 +223,7 @@ await Task.Run(() => return new SearchResults(searchTerm, fileResults, searchState.TotalMatches, fileResults.Count, false, searchState.ReachedMaxResults); } - private SearchFileResult? SearchFile( + private async Task SearchFileAsync( string filePath, string rootDirectory, ResourceKey resourceKey, @@ -189,53 +237,56 @@ await Task.Run(() => try { // Check if file should be searched (size, extension filters) - if (!_fileFilter.ShouldSearchFile(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; } - // 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 FileStorage.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 +300,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; @@ -283,8 +340,7 @@ public async Task ReplaceInFileAsync( return new ReplaceResult(false, 0); } - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveReplaceResult = resourceRegistry.ResolveResourcePath(resource); + var resolveReplaceResult = ResourceRegistry.ResolveResourcePath(resource); if (resolveReplaceResult.IsFailure) { return new ReplaceResult(false, 0); @@ -293,13 +349,34 @@ public async Task ReplaceInFileAsync( try { - return await Task.Run(() => ReplaceInFile( - filePath, + 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), 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) { @@ -317,50 +394,6 @@ public async Task ReplaceInFileAsync( } } - private ReplaceResult ReplaceInFile( - string filePath, - string searchText, - string replaceText, - bool matchCase, - bool wholeWord, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - string content; - try - { - content = File.ReadAllText(filePath); - } - catch (IOException) - { - return new ReplaceResult(false, 0); - } - - var (newContent, totalReplacements) = _textReplacer.ReplaceAll( - content, - searchText, - replaceText, - matchCase, - wholeWord); - - if (totalReplacements == 0) - { - return new ReplaceResult(true, 0); - } - - try - { - File.WriteAllText(filePath, newContent); - } - catch (IOException) - { - return new ReplaceResult(false, 0); - } - - return new ReplaceResult(true, totalReplacements); - } - public async Task ReplaceMatchAsync( ResourceKey resource, string searchText, @@ -381,8 +414,7 @@ public async Task ReplaceMatchAsync( return new ReplaceMatchResult(false); } - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resolveMatchResult = resourceRegistry.ResolveResourcePath(resource); + var resolveMatchResult = ResourceRegistry.ResolveResourcePath(resource); if (resolveMatchResult.IsFailure) { return new ReplaceMatchResult(false); @@ -391,15 +423,36 @@ public async Task ReplaceMatchAsync( try { - return await Task.Run(() => ReplaceMatch( - filePath, + 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), 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) { @@ -417,54 +470,6 @@ public async Task ReplaceMatchAsync( } } - private ReplaceMatchResult ReplaceMatch( - string filePath, - string searchText, - string replaceText, - int lineNumber, - int originalMatchStart, - bool matchCase, - bool wholeWord, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - string content; - try - { - content = File.ReadAllText(filePath); - } - catch (IOException) - { - return new ReplaceMatchResult(false); - } - - var (newContent, success) = _textReplacer.ReplaceMatch( - content, - searchText, - replaceText, - lineNumber, - originalMatchStart, - matchCase, - wholeWord); - - if (!success) - { - return new ReplaceMatchResult(false); - } - - try - { - File.WriteAllText(filePath, newContent); - } - catch (IOException) - { - return new ReplaceMatchResult(false); - } - - return new ReplaceMatchResult(true); - } - public async Task ReplaceAllAsync( List fileResults, string searchText, @@ -542,11 +547,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) { @@ -559,7 +562,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; } } @@ -592,8 +595,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) @@ -624,8 +626,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() @@ -635,8 +636,7 @@ public async Task ClearHistoryAsync() return; } - var workspaceSettings = _workspaceWrapper.WorkspaceService.WorkspaceSettings; - await workspaceSettings.DeletePropertyAsync(SearchHistoryKey); + await WorkspaceSettings.DeletePropertyAsync(SearchHistoryKey); } public void Dispose() 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); } 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/DataTransferService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/DataTransferService.cs index 6e0112fa6..b5954e82c 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 fileStorage = _workspaceWrapper.WorkspaceService.FileStorage; + var destInfoResult = await fileStorage.GetInfoAsync(destFolderResource); + if (destInfoResult.IsFailure + || destInfoResult.Value.Kind != StorageItemKind.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/ProjectCheckReporter.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/ProjectCheckReporter.cs new file mode 100644 index 000000000..5d7980f48 --- /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.OrphanCelFiles.Count > 0) + { + var entries = report.OrphanCelFiles + .Select(o => $"'{o.FullKey}'") + .ToList(); + LogFindingsCategory( + $"Project consistency check: {entries.Count} orphan .cel file(s).", + entries); + } + if (report.BrokenCelFiles.Count > 0) + { + var entries = report.BrokenCelFiles + .Select(b => $"'{b.FullKey}'") + .ToList(); + LogFindingsCategory( + $"Project consistency check: {entries.Count} broken .cel file(s).", + entries); + } + + var totalFindings = report.BrokenReferences.Count + + report.OrphanCelFiles.Count + + report.BrokenCelFiles.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 36fc919b9..6678b202a 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; @@ -16,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, @@ -23,7 +25,8 @@ public WorkspaceLoader( IUserInterfaceService userInterfaceService, IFeatureFlags featureFlags, IProjectService projectService, - IServerService serverService) + IServerService serverService, + ProjectCheckReporter projectCheckReporter) { _logger = logger; _workspaceWrapper = workspaceWrapper; @@ -31,6 +34,7 @@ public WorkspaceLoader( _featureFlags = featureFlags; _projectService = projectService; _serverService = serverService; + _projectCheckReporter = projectCheckReporter; } public async Task LoadWorkspaceAsync() @@ -109,8 +113,31 @@ 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"); + } + + // Register packages before the first resource scan so the sidecar + // pairing pass sees package-contributed document-editor factories. + try + { + var packageService = workspaceService.PackageService; + await packageService.RegisterPackagesAsync(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) { @@ -144,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(); @@ -179,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 @@ -209,6 +228,28 @@ public async Task LoadWorkspaceAsync() return Result.Ok(); } + // 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 + { + var commandService = ServiceLocator.AcquireService(); + var reportResult = await commandService.ExecuteAsync(); + if (reportResult.IsFailure) + { + _logger.LogWarning(reportResult, "Project consistency check failed."); + return; + } + + _projectCheckReporter.Report(reportResult.Value); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Project consistency check threw an unexpected exception."); + } + } + private async Task TryInitializePythonAsync(IWorkspaceService workspaceService) { var projectService = ServiceLocator.AcquireService(); diff --git a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs index de7c9b7a4..656fcdf3b 100644 --- a/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs +++ b/Source/Workspace/Celbridge.WorkspaceUI/Services/WorkspaceService.cs @@ -23,6 +23,10 @@ public class WorkspaceService : IWorkspaceService, IDisposable public IWorkspaceSettings WorkspaceSettings => WorkspaceSettingsService.WorkspaceSettings!; 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; } public IDocumentsService DocumentsService { get; } public IInspectorService InspectorService { get; } @@ -57,6 +61,10 @@ public WorkspaceService( WorkspaceSettingsService = serviceProvider.GetRequiredService(); PackageService = serviceProvider.GetRequiredService(); ResourceService = serviceProvider.GetRequiredService(); + FileStorage = serviceProvider.GetRequiredService(); + TrashService = serviceProvider.GetRequiredService(); + ResourceScanner = serviceProvider.GetRequiredService(); + SidecarService = serviceProvider.GetRequiredService(); ExplorerService = serviceProvider.GetRequiredService(); DocumentsService = serviceProvider.GetRequiredService(); InspectorService = serviceProvider.GetRequiredService(); @@ -74,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);