From a9408ef4b975d899590601908c4e16d093426c5d Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 13 Feb 2026 07:21:29 +0000 Subject: [PATCH 1/5] Drag files to document section to open --- .../Resources/Strings/en-US/Resources.resw | 6 +- .../Documents/IDocumentsService.cs | 6 + .../Documents/IOpenDocumentCommand.cs | 6 + .../Commands/OpenDocumentCommand.cs | 15 ++- .../Services/DocumentsService.cs | 35 ++++++ .../ViewModels/DocumentsPanelViewModel.cs | 19 +++ .../Views/DocumentSection.xaml | 1 + .../Views/DocumentSection.xaml.cs | 119 +++++++++++++++--- .../Views/DocumentSectionContainer.xaml.cs | 11 ++ .../Views/DocumentsPanel.xaml.cs | 61 ++++++++- 10 files changed, 258 insertions(+), 21 deletions(-) diff --git a/app/Celbridge/Resources/Strings/en-US/Resources.resw b/app/Celbridge/Resources/Strings/en-US/Resources.resw index 8ae2b81ce..f0524f3ae 100644 --- a/app/Celbridge/Resources/Strings/en-US/Resources.resw +++ b/app/Celbridge/Resources/Strings/en-US/Resources.resw @@ -519,8 +519,8 @@ Do you wish to continue? Three Editors - - No documents open + + Drop files here to open them Close @@ -843,4 +843,4 @@ Do you wish to continue? Collapse All Search Results - \ No newline at end of file + diff --git a/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs b/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs index 72977d151..9d887cbb1 100644 --- a/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs +++ b/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs @@ -50,6 +50,12 @@ public interface IDocumentsService /// Task OpenDocument(ResourceKey fileResource, bool forceReload, string location); + /// + /// Opens a file resource as a document in a specific section of the documents panel. + /// If the document is already open in another section, it will be moved to the target section. + /// + Task OpenDocumentAtSection(ResourceKey fileResource, bool forceReload, string location, int sectionIndex); + /// /// Closes an opened document in the documents panel. /// forceClose forces the document to close without allowing the document to cancel the close operation. diff --git a/app/Core/Celbridge.Foundation/Documents/IOpenDocumentCommand.cs b/app/Core/Celbridge.Foundation/Documents/IOpenDocumentCommand.cs index 51d509239..65bb1a058 100644 --- a/app/Core/Celbridge.Foundation/Documents/IOpenDocumentCommand.cs +++ b/app/Core/Celbridge.Foundation/Documents/IOpenDocumentCommand.cs @@ -21,4 +21,10 @@ public interface IOpenDocumentCommand : IExecutableCommand /// Optional location within the document to navigate to when opening. /// string Location { get; set; } + + /// + /// Optional target section index (0, 1, or 2) to open the document in. + /// If null, the document opens in the active section. + /// + int? TargetSectionIndex { get; set; } } diff --git a/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs index 0fb72bb3f..d3da3497f 100644 --- a/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs +++ b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs @@ -19,6 +19,8 @@ public class OpenDocumentCommand : CommandBase, IOpenDocumentCommand public string Location { get; set; } = string.Empty; + public int? TargetSectionIndex { get; set; } + public OpenDocumentCommand( IStringLocalizer stringLocalizer, IDialogService dialogService, @@ -45,7 +47,18 @@ public override async Task ExecuteAsync() return Result.Fail($"This file format is not supported: '{FileResource}'"); } - var openResult = await documentsService.OpenDocument(FileResource, ForceReload, Location); + Result openResult; + if (TargetSectionIndex.HasValue) + { + // Open in the specified section + openResult = await documentsService.OpenDocumentAtSection(FileResource, ForceReload, Location, TargetSectionIndex.Value); + } + else + { + // Open in the active section (default behavior) + openResult = await documentsService.OpenDocument(FileResource, ForceReload, Location); + } + if (openResult.IsFailure) { // Alert the user that the document failed to open diff --git a/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs index b2dbbe73b..2f4231388 100644 --- a/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -255,6 +255,41 @@ public async Task OpenDocument(ResourceKey fileResource, bool forceReloa return Result.Ok(); } + public async Task OpenDocumentAtSection(ResourceKey fileResource, bool forceReload, string location, int sectionIndex) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + + var filePath = resourceRegistry.GetResourcePath(fileResource); + if (string.IsNullOrEmpty(filePath) || + !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}'"); + } + + var address = new DocumentAddress(WindowIndex: 0, SectionIndex: sectionIndex, TabOrder: 0); + var openResult = await DocumentsPanel.OpenDocumentAtAddress(fileResource, filePath, address); + if (openResult.IsFailure) + { + return Result.Fail($"Failed to open document for file resource '{fileResource}' at section {sectionIndex}") + .WithErrors(openResult); + } + + // Navigate to location if specified + if (!string.IsNullOrEmpty(location)) + { + await DocumentsPanel.NavigateToLocation(fileResource, location); + } + + _logger.LogTrace($"Opened document for file resource '{fileResource}' at section {sectionIndex}"); + + return Result.Ok(); + } + public async Task CloseDocument(ResourceKey fileResource, bool forceClose) { var closeResult = await DocumentsPanel.CloseDocument(fileResource, forceClose); diff --git a/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs b/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs index 56873a48f..9dd7825f0 100644 --- a/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs +++ b/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs @@ -10,6 +10,7 @@ public partial class DocumentsPanelViewModel : ObservableObject private readonly IMessengerService _messengerService; private readonly ICommandService _commandService; private readonly IDocumentsService _documentsService; + private readonly IWorkspaceWrapper _workspaceWrapper; public DocumentsPanelViewModel( IMessengerService messengerService, @@ -18,6 +19,7 @@ public DocumentsPanelViewModel( { _messengerService = messengerService; _commandService = commandService; + _workspaceWrapper = workspaceWrapper; _documentsService = workspaceWrapper.WorkspaceService.DocumentsService; } @@ -75,4 +77,21 @@ public void OnSectionRatiosChanged(List ratios) var message = new SectionRatiosChangedMessage(ratios); _messengerService.Send(message); } + + public ResourceKey GetResourceKey(IFileResource fileResource) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + return resourceRegistry.GetResourceKey(fileResource); + } + + public bool IsDocumentSupported(ResourceKey fileResource) + { + return _documentsService.IsDocumentSupported(fileResource); + } + + public string GetFilePath(ResourceKey fileResource) + { + var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; + return resourceRegistry.GetResourcePath(fileResource); + } } diff --git a/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml b/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml index 93c5ff945..e27e2a14d 100644 --- a/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml +++ b/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml @@ -9,6 +9,7 @@ ; + /// /// A document section containing a TabView for managing document tabs. /// Multiple sections can be displayed side-by-side in the DocumentSectionContainer. /// public sealed partial class DocumentSection : UserControl { + private readonly IDocumentSectionLogger _logger; private readonly IStringLocalizer _stringLocalizer; private readonly IPanelFocusService _panelFocusService; private bool _isShuttingDown = false; @@ -29,7 +33,7 @@ public sealed partial class DocumentSection : UserControl private static DocumentSection? _dragSourceSection; // Localized strings - private string NoDocumentsOpenString => _stringLocalizer.GetString("DocumentSection_NoDocumentsOpen"); + private string NoDocumentsOpenString => _stringLocalizer.GetString("DocumentSection_DropFilesPrompt"); /// /// The section index (0, 1, or 2) identifying this section's position. @@ -61,10 +65,16 @@ public sealed partial class DocumentSection : UserControl /// public event Action? TabDroppedInside; + /// + /// Event raised when resource files are dropped into this section from the ResourceTree. + /// + public event Action>? FilesDropped; + public DocumentSection() { InitializeComponent(); + _logger = ServiceLocator.AcquireService(); _stringLocalizer = ServiceLocator.AcquireService(); _panelFocusService = ServiceLocator.AcquireService(); @@ -108,10 +118,19 @@ public List GetOpenDocuments() foreach (var tabItem in TabView.TabItems) { var tab = tabItem as DocumentTab; - Guard.IsNotNull(tab); + if (tab is null) + { + // Log unexpected item type - TabView may contain internal items during drag operations + _logger.LogWarning($"GetOpenDocuments: Unexpected item type in TabView.TabItems: {tabItem?.GetType().Name ?? "null"}"); + continue; + } var fileResource = tab.ViewModel.FileResource; - Guard.IsFalse(openDocuments.Contains(fileResource)); + if (openDocuments.Contains(fileResource)) + { + _logger.LogWarning($"GetOpenDocuments: Duplicate file resource: {fileResource}"); + continue; + } openDocuments.Add(fileResource); } @@ -139,10 +158,7 @@ public bool ContainsDocument(ResourceKey fileResource) { foreach (var tabItem in TabView.TabItems) { - var tab = tabItem as DocumentTab; - Guard.IsNotNull(tab); - - if (fileResource == tab.ViewModel.FileResource) + if (tabItem is DocumentTab tab && fileResource == tab.ViewModel.FileResource) { return true; } @@ -157,10 +173,7 @@ public bool ContainsDocument(ResourceKey fileResource) { foreach (var tabItem in TabView.TabItems) { - var tab = tabItem as DocumentTab; - Guard.IsNotNull(tab); - - if (fileResource == tab.ViewModel.FileResource) + if (tabItem is DocumentTab tab && fileResource == tab.ViewModel.FileResource) { return tab; } @@ -251,9 +264,10 @@ public IEnumerable GetAllTabs() { foreach (var tabItem in TabView.TabItems) { - var tab = tabItem as DocumentTab; - Guard.IsNotNull(tab); - yield return tab; + if (tabItem is DocumentTab tab) + { + yield return tab; + } } } @@ -304,8 +318,10 @@ public void Shutdown() foreach (var tabItem in TabView.TabItems) { - var documentTab = tabItem as DocumentTab; - Guard.IsNotNull(documentTab); + if (tabItem is not DocumentTab documentTab) + { + continue; + } documentTab.ContextMenuActionRequested -= OnDocumentTabContextMenuAction; documentTab.DragStarted -= OnDocumentTabDragStarted; @@ -416,9 +432,29 @@ private void RootGrid_DragOver(object sender, DragEventArgs e) e.DragUIOverride.IsCaptionVisible = false; e.DragUIOverride.IsGlyphVisible = false; e.Handled = true; + return; + } + + // Accept file drags from ResourceTree (check both Data and DataView for cross-control drags) + var hasDataProps = e.Data?.Properties?.ContainsKey("DraggedResources") == true; + var hasDataViewProps = e.DataView?.Properties?.ContainsKey("DraggedResources") == true; + + if (hasDataProps || hasDataViewProps) + { + // Match the source's requested operation (Move) for compatibility + e.AcceptedOperation = DataPackageOperation.Move; + e.DragUIOverride.Caption = "Open"; + e.DragUIOverride.IsCaptionVisible = true; + e.DragUIOverride.IsGlyphVisible = false; + e.Handled = true; } } + private void RootGrid_DragLeave(object sender, DragEventArgs e) + { + // Intentionally empty - drag leave doesn't need special handling + } + private void RootGrid_Drop(object sender, DragEventArgs e) { if (_isShuttingDown) @@ -437,6 +473,24 @@ private void RootGrid_Drop(object sender, DragEventArgs e) // Raise event to notify container to move the tab TabDroppedInside?.Invoke(this, tab); e.Handled = true; + return; + } + + // Handle file drop from ResourceTree (check both Data and DataView for cross-control drags) + List? draggedResources = null; + if (e.Data?.Properties?.TryGetValue("DraggedResources", out var draggedObj) == true) + { + draggedResources = draggedObj as List; + } + else if (e.DataView?.Properties?.TryGetValue("DraggedResources", out var draggedViewObj) == true) + { + draggedResources = draggedViewObj as List; + } + + if (draggedResources != null) + { + FilesDropped?.Invoke(this, draggedResources); + e.Handled = true; } } @@ -449,6 +503,21 @@ private void TabView_DragOver(object sender, DragEventArgs e) e.DragUIOverride.IsCaptionVisible = false; e.DragUIOverride.IsGlyphVisible = false; e.Handled = true; + return; + } + + // Accept file drags from ResourceTree (check both Data and DataView for cross-control drags) + var hasDataProps = e.Data?.Properties?.ContainsKey("DraggedResources") == true; + var hasDataViewProps = e.DataView?.Properties?.ContainsKey("DraggedResources") == true; + + if (hasDataProps || hasDataViewProps) + { + // Match the source's requested operation (Move) for compatibility + e.AcceptedOperation = DataPackageOperation.Move; + e.DragUIOverride.Caption = "Open"; + e.DragUIOverride.IsCaptionVisible = true; + e.DragUIOverride.IsGlyphVisible = false; + e.Handled = true; } } @@ -470,6 +539,24 @@ private void TabView_Drop(object sender, DragEventArgs e) // Raise event to notify container to move the tab TabDroppedInside?.Invoke(this, tab); e.Handled = true; + return; + } + + // Handle file drop from ResourceTree (check both Data and DataView for cross-control drags) + List? draggedResources = null; + if (e.Data?.Properties?.TryGetValue("DraggedResources", out var draggedObj) == true) + { + draggedResources = draggedObj as List; + } + else if (e.DataView?.Properties?.TryGetValue("DraggedResources", out var draggedViewObj) == true) + { + draggedResources = draggedViewObj as List; + } + + if (draggedResources != null) + { + FilesDropped?.Invoke(this, draggedResources); + e.Handled = true; } } diff --git a/app/Workspace/Celbridge.Documents/Views/DocumentSectionContainer.xaml.cs b/app/Workspace/Celbridge.Documents/Views/DocumentSectionContainer.xaml.cs index 8f30c3488..52f002abc 100644 --- a/app/Workspace/Celbridge.Documents/Views/DocumentSectionContainer.xaml.cs +++ b/app/Workspace/Celbridge.Documents/Views/DocumentSectionContainer.xaml.cs @@ -57,6 +57,11 @@ public sealed partial class DocumentSectionContainer : UserControl /// public event Action>? SectionRatiosChanged; + /// + /// Event raised when resource files are dropped into a section from the ResourceTree. + /// + public event Action>? FilesDropped; + /// /// Gets the current number of sections. /// @@ -494,6 +499,7 @@ private void CreateSection(int index) section.CloseRequested += OnSectionCloseRequested; section.ContextMenuActionRequested += OnSectionContextMenuActionRequested; section.TabDroppedInside += OnSectionTabDroppedInside; + section.FilesDropped += OnSectionFilesDropped; _sections.Add(section); } @@ -722,6 +728,11 @@ private void OnSectionTabDroppedInside(DocumentSection targetSection, DocumentTa } } + private void OnSectionFilesDropped(DocumentSection targetSection, List resources) + { + FilesDropped?.Invoke(targetSection, resources); + } + private void NotifyLayoutChanged() { // Re-fire OpenDocumentsChanged for all visible sections to ensure the layout is persisted diff --git a/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs b/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs index b3355067f..0eb43835e 100644 --- a/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs +++ b/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs @@ -69,6 +69,7 @@ public DocumentsPanel( SectionContainer.ContextMenuActionRequested += OnSectionContextMenuActionRequested; SectionContainer.SectionCountChanged += OnSectionCountChanged; SectionContainer.SectionRatiosChanged += OnSectionRatiosChanged; + SectionContainer.FilesDropped += OnSectionFilesDropped; // Wire up toolbar events DocumentToolbar.SectionCountChangeRequested += OnToolbarSectionCountChangeRequested; @@ -121,6 +122,60 @@ private void OnSectionRatiosChanged(List ratios) ViewModel.OnSectionRatiosChanged(ratios); } + private void OnSectionFilesDropped(DocumentSection targetSection, List resources) + { + HandleDroppedFiles(targetSection, resources); + } + + private void HandleDroppedFiles(DocumentSection targetSection, List resources) + { + if (_isShuttingDown) + { + return; + } + + var targetSectionIndex = targetSection.SectionIndex; + + foreach (var resource in resources) + { + if (resource is not IFileResource fileResource) + { + continue; + } + + var fileResourceKey = ViewModel.GetResourceKey(fileResource); + if (!ViewModel.IsDocumentSupported(fileResourceKey)) + { + continue; + } + + // Check if the file is already open in any section + var (existingSection, existingTab) = SectionContainer.FindDocumentTab(fileResourceKey); + if (existingTab != null && existingSection != null) + { + // Already open - move to target section if different, otherwise just select it + if (existingSection.SectionIndex != targetSectionIndex) + { + SectionContainer.MoveTabToSection(existingTab, targetSectionIndex); + } + else + { + existingSection.SelectTab(existingTab); + SectionContainer.ActivateDocument(fileResourceKey, targetSectionIndex); + } + } + else + { + // Not open - use the command to open in the target section + _commandService.Execute(command => + { + command.FileResource = fileResourceKey; + command.TargetSectionIndex = targetSectionIndex; + }); + } + } + } + private void OnToolbarSectionCountChangeRequested(int requestedCount) { SectionContainer.SetSectionCount(requestedCount); @@ -303,9 +358,10 @@ public async Task OpenDocumentAtAddress(ResourceKey fileResource, string SectionContainer.MoveTabToSection(existingTab, sectionIndex); } - // Activate the tab + // Activate the tab and make it the active document var targetSection = SectionContainer.GetSection(sectionIndex); targetSection.SelectTab(existingTab); + SectionContainer.ActivateDocument(fileResource, sectionIndex); return Result.Ok(); } @@ -335,6 +391,9 @@ public async Task OpenDocumentAtAddress(ResourceKey fileResource, string targetSectionForNew.RefreshSelectedTab(); UpdateAllTabDisplayNames(); + // Make the newly opened document the active document + SectionContainer.ActivateDocument(fileResource, sectionIndex); + return Result.Ok(); } From 6b99deb6fffe3d704d45580555cd4e66d870c08d Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 13 Feb 2026 11:39:21 +0000 Subject: [PATCH 2/5] Extract cross-cutting utilities to a Celbridge.Utilities project --- app/Celbridge.Tests/Celbridge.Tests.csproj | 1 + .../Migration/ProjectMigrationServiceTests.cs | 100 ++++------- .../TestHelpers/MigrationTestHelper.cs | 15 +- app/Celbridge.Tests/Search/FileFilterTests.cs | 2 +- app/Celbridge.sln | 16 ++ app/Celbridge/App.xaml.cs | 17 +- app/Celbridge/Celbridge.csproj | 1 + .../IEnvironmentService.cs | 17 ++ .../Utilities/EnvironmentInfo.cs | 6 - .../Utilities/IUtilityService.cs | 43 ----- .../Utilities/PathConstants.cs | 8 - .../Utilities/Services/UtilityService.cs | 170 ------------------ .../Celbridge.Projects.csproj | 1 + .../Services/ProjectMigrationService.cs | 142 +++++++-------- .../Services/ProjectTemplateService.cs | 13 +- .../Services/PanelFocusService.cs | 2 - .../Views/Controls/FocusIndicator.xaml.cs | 2 - .../Celbridge.Utilities.csproj | 29 +++ app/Core/Celbridge.Utilities/GlobalUsings.cs | 2 + .../ServiceConfiguration.cs | 7 +- .../Services/DumpFile.cs | 9 +- .../Services/EnvironmentService.cs | 35 ++++ .../Services/PathHelper.cs | 72 ++++++++ .../Services}/TextBinarySniffer.cs | 48 +++++ .../Celbridge.Core/Celbridge.Core.csproj | 1 + .../Celbridge.HTML/Celbridge.HTML.csproj | 1 + .../Celbridge.Markdown.csproj | 1 + .../Celbridge.Screenplay.csproj | 1 + .../Celbridge.Spreadsheet.csproj | 1 + .../Assets/DocumentTypes/FileViewerTypes.json | 1 - .../Celbridge.Documents.csproj | 1 + .../Celbridge.Documents/GlobalUsings.cs | 2 +- .../Services/DocumentsService.cs | 1 - .../Views/WebAppDocumentView.xaml.cs | 7 +- .../Services}/ComponentEditorBase.cs | 26 ++- .../Services/ComponentEditorHelper.cs | 6 +- .../Celbridge.Python/Celbridge.Python.csproj | 1 + .../Services/PythonService.cs | 24 +-- .../Celbridge.Search/Celbridge.Search.csproj | 1 + .../Celbridge.Search/GlobalUsings.cs | 1 + .../Celbridge.Search/Services/FileFilter.cs | 21 +-- 41 files changed, 407 insertions(+), 448 deletions(-) create mode 100644 app/Core/Celbridge.Foundation/ApplicationEnvironment/IEnvironmentService.cs delete mode 100644 app/Core/Celbridge.Foundation/Utilities/EnvironmentInfo.cs delete mode 100644 app/Core/Celbridge.Foundation/Utilities/IUtilityService.cs delete mode 100644 app/Core/Celbridge.Foundation/Utilities/PathConstants.cs delete mode 100644 app/Core/Celbridge.Foundation/Utilities/Services/UtilityService.cs create mode 100644 app/Core/Celbridge.Utilities/Celbridge.Utilities.csproj create mode 100644 app/Core/Celbridge.Utilities/GlobalUsings.cs rename app/Core/{Celbridge.Foundation/Utilities => Celbridge.Utilities}/ServiceConfiguration.cs (58%) rename app/Core/{Celbridge.Foundation/Utilities => Celbridge.Utilities}/Services/DumpFile.cs (91%) create mode 100644 app/Core/Celbridge.Utilities/Services/EnvironmentService.cs create mode 100644 app/Core/Celbridge.Utilities/Services/PathHelper.cs rename app/Core/{Celbridge.Foundation/Utilities => Celbridge.Utilities/Services}/TextBinarySniffer.cs (78%) rename app/{Core/Celbridge.Foundation/Entities => Workspace/Celbridge.Entities/Services}/ComponentEditorBase.cs (95%) diff --git a/app/Celbridge.Tests/Celbridge.Tests.csproj b/app/Celbridge.Tests/Celbridge.Tests.csproj index 2762b8595..cc0344529 100644 --- a/app/Celbridge.Tests/Celbridge.Tests.csproj +++ b/app/Celbridge.Tests/Celbridge.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/app/Celbridge.Tests/Migration/ProjectMigrationServiceTests.cs b/app/Celbridge.Tests/Migration/ProjectMigrationServiceTests.cs index 4d3037d07..5d1421d2a 100644 --- a/app/Celbridge.Tests/Migration/ProjectMigrationServiceTests.cs +++ b/app/Celbridge.Tests/Migration/ProjectMigrationServiceTests.cs @@ -1,8 +1,8 @@ +using Celbridge.ApplicationEnvironment; using Celbridge.Logging; using Celbridge.Projects; using Celbridge.Projects.Services; using Celbridge.Tests.Migration.TestHelpers; -using Celbridge.Utilities; namespace Celbridge.Tests.Migration; @@ -14,7 +14,7 @@ public class ProjectMigrationServiceTests { private ILogger _mockLogger = null!; private ILogger _mockRegistryLogger = null!; - private IUtilityService _mockUtilityService = null!; + private IEnvironmentService _mockEnvironmentService = null!; private MigrationStepRegistry _registry = null!; [SetUp] @@ -22,7 +22,7 @@ public void Setup() { _mockLogger = MigrationTestHelper.CreateMockLogger(); _mockRegistryLogger = MigrationTestHelper.CreateMockLogger(); - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService("1.0.0"); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService("1.0.0"); _registry = new MigrationStepRegistry(_mockRegistryLogger); } @@ -32,7 +32,7 @@ public void Setup() public async Task CheckMigrationAsync_NonExistentFile_ReturnsFailedStatus() { // Arrange - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var nonExistentPath = Path.Combine(Path.GetTempPath(), "nonexistent.celbridge"); // Act @@ -48,7 +48,7 @@ public async Task CheckMigrationAsync_NonExistentFile_ReturnsFailedStatus() public async Task CheckMigrationAsync_InvalidToml_ReturnsInvalidConfig() { // Arrange - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateInvalidTomlFile(); try @@ -75,8 +75,8 @@ public async Task CheckMigrationAsync_SameVersion_ReturnsComplete() { // Arrange var appVersion = "1.0.0"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile(appVersion); try @@ -101,8 +101,8 @@ public async Task CheckMigrationAsync_SentinelVersion_ReturnsComplete_DoesNotMod { // Arrange var appVersion = "1.0.0"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile(""); try @@ -139,8 +139,8 @@ public async Task CheckMigrationAsync_NewerProjectVersion_ReturnsIncompatibleVer // Arrange var appVersion = "1.0.0"; var projectVersion = "2.0.0"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile(projectVersion); try @@ -167,8 +167,8 @@ public async Task CheckMigrationAsync_NewerProjectVersion_ReturnsIncompatibleVer public async Task CheckMigrationAsync_EmptyProjectVersion_ReturnsInvalidVersion() { // Arrange - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService("1.0.0"); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService("1.0.0"); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile(""); try @@ -190,8 +190,8 @@ public async Task CheckMigrationAsync_EmptyProjectVersion_ReturnsInvalidVersion( public async Task CheckMigrationAsync_InvalidVersionFormat_ReturnsInvalidVersion() { // Arrange - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService("1.0.0"); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService("1.0.0"); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile("not.a.version"); try @@ -218,9 +218,9 @@ public async Task CheckMigrationAsync_LegacyVersionFormat_ReturnsUpgradeRequired { // Arrange var appVersion = "0.1.5"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); - + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); + // Create file with legacy "version" property (pre-0.1.5) var projectPath = MigrationTestHelper.CreateTempProjectFile("", legacyVersion: "0.1.4"); @@ -245,14 +245,14 @@ public async Task PerformMigrationUpgradeAsync_LegacyVersionFormat_PerformsMigra { // Arrange var appVersion = "0.1.5"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + // Use real registry which will discover MigrationStep_0_1_5 var registry = new MigrationStepRegistry(_mockRegistryLogger); registry.Initialize(); - - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, registry); - + + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, registry); + // Create file with legacy "version" property (pre-0.1.5) var projectPath = MigrationTestHelper.CreateTempProjectFile("", legacyVersion: "0.1.4"); @@ -277,9 +277,9 @@ public async Task CheckMigrationAsync_LegacyVersion4Part_ReturnsUpgradeRequired( { // Arrange var appVersion = "0.1.5"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); - + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); + // Create file with legacy 4-part version var projectPath = MigrationTestHelper.CreateTempProjectFile("", legacyVersion: "0.1.4.2"); @@ -308,8 +308,8 @@ public async Task CheckMigrationAsync_OlderVersion_ReturnsUpgradeRequired() // Arrange var appVersion = "1.0.1"; var projectVersion = "1.0.0"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile(projectVersion); try @@ -339,8 +339,8 @@ public async Task PerformMigrationUpgradeAsync_OlderVersion_NoSteps_UpdatesVersi // Arrange var appVersion = "1.0.1"; var projectVersion = "1.0.0"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, _registry); + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, _registry); var projectPath = MigrationTestHelper.CreateTempProjectFile(projectVersion); try @@ -374,13 +374,13 @@ public async Task PerformMigrationUpgradeAsync_WithMigrationSteps_ExecutesStepsI // Arrange var appVersion = "0.2.0"; var projectVersion = "0.1.4"; - _mockUtilityService = MigrationTestHelper.CreateMockUtilityService(appVersion); - + _mockEnvironmentService = MigrationTestHelper.CreateMockEnvironmentService(appVersion); + // Use real registry which will discover MigrationStep_0_1_5 var registry = new MigrationStepRegistry(_mockRegistryLogger); registry.Initialize(); - - var service = new ProjectMigrationService(_mockLogger, _mockUtilityService, registry); + + var service = new ProjectMigrationService(_mockLogger, _mockEnvironmentService, registry); var projectPath = MigrationTestHelper.CreateTempProjectFile("", legacyVersion: projectVersion); try @@ -398,7 +398,7 @@ public async Task PerformMigrationUpgradeAsync_WithMigrationSteps_ExecutesStepsI var content = File.ReadAllText(projectPath); content.Should().Contain("celbridge-version"); content.Should().NotContain("\r\nversion = "); - + var updatedVersion = MigrationTestHelper.ReadVersionFromFile(projectPath); updatedVersion.Should().Be(appVersion); } @@ -409,34 +409,12 @@ public async Task PerformMigrationUpgradeAsync_WithMigrationSteps_ExecutesStepsI } #endregion +} + + + - #region Edge Cases - [Test] - public async Task CheckMigrationAsync_Exception_ReturnsFailedStatus() - { - // Arrange - var mockUtilityService = Substitute.For(); - mockUtilityService.GetEnvironmentInfo() - .Returns(x => throw new InvalidOperationException("Test exception")); - - var service = new ProjectMigrationService(_mockLogger, mockUtilityService, _registry); - var projectPath = MigrationTestHelper.CreateTempProjectFile("1.0.0"); - try - { - // Act - var result = await service.CheckMigrationAsync(projectPath); - // Assert - result.Status.Should().Be(MigrationStatus.Failed); - result.OperationResult.IsFailure.Should().BeTrue(); - } - finally - { - MigrationTestHelper.CleanupTempFile(projectPath); - } - } - #endregion -} diff --git a/app/Celbridge.Tests/Migration/TestHelpers/MigrationTestHelper.cs b/app/Celbridge.Tests/Migration/TestHelpers/MigrationTestHelper.cs index e89455558..714f25123 100644 --- a/app/Celbridge.Tests/Migration/TestHelpers/MigrationTestHelper.cs +++ b/app/Celbridge.Tests/Migration/TestHelpers/MigrationTestHelper.cs @@ -1,5 +1,5 @@ +using Celbridge.ApplicationEnvironment; using Celbridge.Logging; -using Celbridge.Utilities; namespace Celbridge.Tests.Migration.TestHelpers; @@ -17,14 +17,13 @@ public static ILogger CreateMockLogger() } /// - /// Creates a mock IUtilityService with a configurable application version. + /// Creates a mock IEnvironmentService with the specified application version. /// - public static IUtilityService CreateMockUtilityService(string appVersion) + public static IEnvironmentService CreateMockEnvironmentService(string appVersion) { - var mockUtilityService = Substitute.For(); - mockUtilityService.GetEnvironmentInfo() - .Returns(new EnvironmentInfo(appVersion, "Test", "Debug")); - return mockUtilityService; + var mock = Substitute.For(); + mock.GetEnvironmentInfo().Returns(new EnvironmentInfo(appVersion, "Test", "Debug")); + return mock; } /// @@ -89,7 +88,7 @@ public static string CreateInvalidTomlFile() { var content = File.ReadAllText(projectFilePath); var lines = content.Split('\n'); - + foreach (var line in lines) { if (line.Contains("celbridge-version")) diff --git a/app/Celbridge.Tests/Search/FileFilterTests.cs b/app/Celbridge.Tests/Search/FileFilterTests.cs index 4cd8e1c40..493a98198 100644 --- a/app/Celbridge.Tests/Search/FileFilterTests.cs +++ b/app/Celbridge.Tests/Search/FileFilterTests.cs @@ -1,4 +1,4 @@ -using Celbridge.Search.Services; +using Celbridge.Search; namespace Celbridge.Tests.Search; diff --git a/app/Celbridge.sln b/app/Celbridge.sln index 4a1f5a096..471002c32 100644 --- a/app/Celbridge.sln +++ b/app/Celbridge.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.2.11415.280 @@ -72,6 +73,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Celbridge.Resources", "Work EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Celbridge.Search", "Workspace\Celbridge.Search\Celbridge.Search.csproj", "{AB53754B-F6A7-4535-92BC-D1A494C16109}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Celbridge.Utilities", "Core\Celbridge.Utilities\Celbridge.Utilities.csproj", "{A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -398,6 +401,18 @@ Global {AB53754B-F6A7-4535-92BC-D1A494C16109}.Release|x64.Build.0 = Release|Any CPU {AB53754B-F6A7-4535-92BC-D1A494C16109}.Release|x86.ActiveCfg = Release|Any CPU {AB53754B-F6A7-4535-92BC-D1A494C16109}.Release|x86.Build.0 = Release|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Debug|x64.Build.0 = Debug|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Debug|x86.Build.0 = Debug|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Release|Any CPU.Build.0 = Release|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Release|x64.ActiveCfg = Release|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Release|x64.Build.0 = Release|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Release|x86.ActiveCfg = Release|Any CPU + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -428,6 +443,7 @@ Global {0CC828C3-E5EF-ACF1-8343-A355D840D0B3} = {CBDD78F8-9AF9-4805-8B33-6CDA18F7E94B} {9D409061-B39F-4C0F-8769-BB23244444A8} = {00D6636E-63D7-4B7C-AEB0-9A433AA10316} {AB53754B-F6A7-4535-92BC-D1A494C16109} = {00D6636E-63D7-4B7C-AEB0-9A433AA10316} + {A6BA7B1F-2F18-4AAF-B1C5-12EBA4573B67} = {CBDD78F8-9AF9-4805-8B33-6CDA18F7E94B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {695F2378-3B43-4460-9FB8-6D27C121D85D} diff --git a/app/Celbridge/App.xaml.cs b/app/Celbridge/App.xaml.cs index 6e8707ba9..b756ba48e 100644 --- a/app/Celbridge/App.xaml.cs +++ b/app/Celbridge/App.xaml.cs @@ -1,11 +1,11 @@ using Celbridge.Commands.Services; using Celbridge.Commands; +using Celbridge.ApplicationEnvironment; using Celbridge.Modules.Services; using Celbridge.Modules; using Celbridge.UserInterface.Services; using Celbridge.UserInterface.Views; using Celbridge.UserInterface; -using Celbridge.Utilities; using Microsoft.Extensions.Localization; #if WINDOWS @@ -122,7 +122,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) // modules from untrusted sources. The core set of modules shipped with the application will be trusted by default. // Modules must only depend on the Celbridge.BaseLibrary project, and may not depend on other modules. // Modules may use Nuget packages - var modules = new List() + var modules = new List() { "Celbridge.Core", "Celbridge.HTML", @@ -147,8 +147,8 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) ServiceLocator.Initialize(Host.Services); var logger = Host.Services.GetRequiredService>(); - var utilityService = Host.Services.GetRequiredService(); - var environmentInfo = utilityService.GetEnvironmentInfo(); + var environmentService = Host.Services.GetRequiredService(); + var environmentInfo = environmentService.GetEnvironmentInfo(); logger.LogDebug(environmentInfo.ToString()); // Check if the application was opened with a project file argument @@ -190,16 +190,16 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) // Do not repeat app initialization when the Window already has content, // just ensure that the window is active - if (MainWindow.Content is not Grid rootGrid || + if (MainWindow.Content is not Grid rootGrid || rootGrid.Name != "RootContainer") { // Create a Frame to act as the navigation context and navigate to the first page var rootFrame = new Frame(); // Create a root container Grid to hold both the Frame and the FullscreenToolbar overlay - rootGrid = new Grid - { - Name = "RootContainer" + rootGrid = new Grid + { + Name = "RootContainer" }; rootGrid.Children.Add(rootFrame); @@ -374,3 +374,4 @@ private static void SetupLoggingEnvironment() Environment.SetEnvironmentVariable("CELBRIDGE_LOG_FILE", logFilePath); } } + diff --git a/app/Celbridge/Celbridge.csproj b/app/Celbridge/Celbridge.csproj index 07dc2e8ca..215249039 100644 --- a/app/Celbridge/Celbridge.csproj +++ b/app/Celbridge/Celbridge.csproj @@ -59,6 +59,7 @@ + diff --git a/app/Core/Celbridge.Foundation/ApplicationEnvironment/IEnvironmentService.cs b/app/Core/Celbridge.Foundation/ApplicationEnvironment/IEnvironmentService.cs new file mode 100644 index 000000000..154bd07ad --- /dev/null +++ b/app/Core/Celbridge.Foundation/ApplicationEnvironment/IEnvironmentService.cs @@ -0,0 +1,17 @@ +namespace Celbridge.ApplicationEnvironment; + +/// +/// Describes the runtime application environment. +/// +public record EnvironmentInfo(string AppVersion, string Platform, string Configuration); + +/// +/// Provides information about the runtime application environment. +/// +public interface IEnvironmentService +{ + /// + /// Returns environment information for the runtime application. + /// + EnvironmentInfo GetEnvironmentInfo(); +} diff --git a/app/Core/Celbridge.Foundation/Utilities/EnvironmentInfo.cs b/app/Core/Celbridge.Foundation/Utilities/EnvironmentInfo.cs deleted file mode 100644 index cddec43a4..000000000 --- a/app/Core/Celbridge.Foundation/Utilities/EnvironmentInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Celbridge.Utilities; - -/// -/// Describes the runtime application environment. -/// -public record EnvironmentInfo(string AppVersion, string Platform, string Configuration); diff --git a/app/Core/Celbridge.Foundation/Utilities/IUtilityService.cs b/app/Core/Celbridge.Foundation/Utilities/IUtilityService.cs deleted file mode 100644 index 119945445..000000000 --- a/app/Core/Celbridge.Foundation/Utilities/IUtilityService.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Celbridge.Utilities; - -/// -/// Provides access to common low-level utility methods. -/// -public interface IUtilityService -{ - /// - /// Returns a path to a randomly named file in temporary storage. - /// The path includes the specified folder name and extension. - /// - string GetTemporaryFilePath(string folderName, string extension); - - /// - /// Returns a path which is guaranteed not to clash with any existing file or folder. - /// - Result GetUniquePath(string path); - - /// - /// Returns environment information the runtime application. - /// - EnvironmentInfo GetEnvironmentInfo(); - - /// - /// Returns the current UTC time in "yyyyMMdd_HHmmss" format. - /// - string GetTimestamp(); - - /// - /// Deletes old files in the specified folder that start with the specified prefix. - /// - Result DeleteOldFiles(string folderPath, string filePrefix, int maxFilesToKeep); - - /// - /// Converts a hex color string to an ARGB tuple. - /// - (byte a, byte r, byte g, byte b) ColorFromHex(string hex); - - /// - /// Load the content of an embedded resource from the assembly containing the specified type. - /// - Result LoadEmbeddedResource(Type type, string resourcePath); -} diff --git a/app/Core/Celbridge.Foundation/Utilities/PathConstants.cs b/app/Core/Celbridge.Foundation/Utilities/PathConstants.cs deleted file mode 100644 index e9c38c86c..000000000 --- a/app/Core/Celbridge.Foundation/Utilities/PathConstants.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Celbridge.Utilities.Services; -public static class PathConstants -{ - /// - /// Folder name for temporary folder containing archived deleted files. - /// - public const string DeletedFilesFolder = "DeletedFiles"; -} diff --git a/app/Core/Celbridge.Foundation/Utilities/Services/UtilityService.cs b/app/Core/Celbridge.Foundation/Utilities/Services/UtilityService.cs deleted file mode 100644 index 2580d52f2..000000000 --- a/app/Core/Celbridge.Foundation/Utilities/Services/UtilityService.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System.Reflection; -using Celbridge.Utilities; - -namespace Celbridge.Messaging.Services; - -public class UtilityService : IUtilityService -{ - public 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; - } - - public 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); - } - } - - public EnvironmentInfo GetEnvironmentInfo() - { -#if WINDOWS - var platform = "Windows"; - var packageVersion = Package.Current.Id.Version; - var appVersion = $"{packageVersion.Major}.{packageVersion.Minor}.{packageVersion.Build}"; -#else - var platform = "SkiaGtk"; - var version = Assembly.GetExecutingAssembly().GetName().Version; - var appVersion = version != null - ? $"{version.Major}.{version.Minor}.{version.Build}" - : "unknown"; -#endif - -#if DEBUG - var configuration = "Debug"; -#else - var configuration = "Release"; -#endif - - var environmentInfo = new EnvironmentInfo(appVersion, platform, configuration); - - return environmentInfo; - } - - public string GetTimestamp() - { - // Get the current date and time in the desired format - return DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); - } - - public Result DeleteOldFiles(string folderPath, string filePrefix, int maxFilesToKeep) - { - try - { - // Get all files in the folder that start with the specified prefix - var files = Directory.GetFiles(folderPath) - .Where(file => Path.GetFileName(file).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) - .Select(file => new FileInfo(file)) - .OrderByDescending(file => file.CreationTime) - .ToList(); - - int keep = Math.Max(0, maxFilesToKeep - 1); - - // If the number of files is greater than the maximum allowed, delete the oldest files - if (files.Count > maxFilesToKeep) - { - var filesToDelete = files.Skip(maxFilesToKeep); - - foreach (var file in filesToDelete) - { - file.Delete(); - } - } - } - catch (Exception ex) - { - Result.Fail($"An exception occurred when deleting old files.") - .WithException(ex); - } - - return Result.Ok(); - } - - public (byte a, byte r, byte g, byte b) ColorFromHex(string hex) - { - hex = hex.TrimStart('#'); - - byte a = 255; // Default alpha value - byte r = byte.Parse(hex.Substring(0, 2), System.Globalization.NumberStyles.HexNumber); - byte g = byte.Parse(hex.Substring(2, 2), System.Globalization.NumberStyles.HexNumber); - byte b = byte.Parse(hex.Substring(4, 2), System.Globalization.NumberStyles.HexNumber); - - if (hex.Length == 8) - { - a = byte.Parse(hex.Substring(6, 2), System.Globalization.NumberStyles.HexNumber); - } - - return (a, r, g, b); - } - - public Result LoadEmbeddedResource(Type type, string resourcePath) - { - // Load the component config JSON from an embedded resource - var assembly = type.Assembly; - var stream = assembly.GetManifestResourceStream(resourcePath); - if (stream is null) - { - return Result.Fail($"Embedded resource '{resourcePath}' not found in assembly '{assembly}'"); - } - - try - { - using (stream) - using (StreamReader reader = new StreamReader(stream)) - { - var data = reader.ReadToEnd(); - return Result.Ok(data); - } - } - catch (Exception ex) - { - return Result.Fail($"An exception occurred when reading content of embedded resource: {resourcePath}") - .WithException(ex); - } - } -} diff --git a/app/Core/Celbridge.Projects/Celbridge.Projects.csproj b/app/Core/Celbridge.Projects/Celbridge.Projects.csproj index 5fca0c8a3..1ba809904 100644 --- a/app/Core/Celbridge.Projects/Celbridge.Projects.csproj +++ b/app/Core/Celbridge.Projects/Celbridge.Projects.csproj @@ -29,6 +29,7 @@ + diff --git a/app/Core/Celbridge.Projects/Services/ProjectMigrationService.cs b/app/Core/Celbridge.Projects/Services/ProjectMigrationService.cs index 1e6a61297..9fcf54a56 100644 --- a/app/Core/Celbridge.Projects/Services/ProjectMigrationService.cs +++ b/app/Core/Celbridge.Projects/Services/ProjectMigrationService.cs @@ -1,6 +1,6 @@ -using Celbridge.Logging; -using Celbridge.Utilities; using System.Text.RegularExpressions; +using Celbridge.ApplicationEnvironment; +using Celbridge.Logging; using Tomlyn; using Tomlyn.Model; @@ -46,16 +46,16 @@ public class ProjectMigrationService : IProjectMigrationService private const string ApplicationVersionSentinel = ""; private readonly ILogger _logger; - private readonly IUtilityService _utilityService; + private readonly IEnvironmentService _environmentService; private readonly MigrationStepRegistry _migrationRegistry; public ProjectMigrationService( ILogger logger, - IUtilityService utilityService, + IEnvironmentService environmentService, IMigrationStepRegistry migrationRegistry) { _logger = logger; - _utilityService = utilityService; + _environmentService = environmentService; _migrationRegistry = (MigrationStepRegistry)migrationRegistry; _migrationRegistry.Initialize(); } @@ -139,7 +139,7 @@ private async Task ParseProjectVersionInfoAsync(string projectFileP } // Get current application version - var envInfo = _utilityService.GetEnvironmentInfo(); + var envInfo = _environmentService.GetEnvironmentInfo(); var applicationVersion = envInfo.AppVersion; return ParseResult.Success(new ProjectVersionInfo(root, projectVersion, applicationVersion)); @@ -165,57 +165,57 @@ private MigrationResult ResolveMigrationStatus(string projectVersion, string app switch (versionState) { case VersionComparisonState.SameVersion: - { - // If using the "" sentinel value, treat as same version but DO NOT update the project file - if (usingSentinelVersion) { - _logger.LogInformation( - "Project version is sentinel '' - treating as current version without updating file: {CurrentVersion}", - applicationVersion); + // If using the "" sentinel value, treat as same version but DO NOT update the project file + if (usingSentinelVersion) + { + _logger.LogInformation( + "Project version is sentinel '' - treating as current version without updating file: {CurrentVersion}", + applicationVersion); - // Return the same app version for both old and new to suppress the upgrade notification banner - return MigrationResult.WithVersions(MigrationStatus.Complete, Result.Ok(), applicationVersion, applicationVersion); - } + // Return the same app version for both old and new to suppress the upgrade notification banner + return MigrationResult.WithVersions(MigrationStatus.Complete, Result.Ok(), applicationVersion, applicationVersion); + } - _logger.LogDebug("Project version matches application version: {Version}", applicationVersion); + _logger.LogDebug("Project version matches application version: {Version}", applicationVersion); - return MigrationResult.WithVersions(MigrationStatus.Complete, Result.Ok(), applicationVersion, applicationVersion); - } + return MigrationResult.WithVersions(MigrationStatus.Complete, Result.Ok(), applicationVersion, applicationVersion); + } case VersionComparisonState.OlderVersion: - { - _logger.LogInformation( - "Project upgrade required: project version {ProjectVersion}, current version {CurrentVersion}", - projectVersion, - applicationVersion); + { + _logger.LogInformation( + "Project upgrade required: project version {ProjectVersion}, current version {CurrentVersion}", + projectVersion, + applicationVersion); - // Return UpgradeRequired status - caller must get user confirmation before calling PerformMigrationUpgradeAsync - return MigrationResult.WithVersions(MigrationStatus.UpgradeRequired, Result.Ok(), projectVersion, applicationVersion); - } + // Return UpgradeRequired status - caller must get user confirmation before calling PerformMigrationUpgradeAsync + return MigrationResult.WithVersions(MigrationStatus.UpgradeRequired, Result.Ok(), projectVersion, applicationVersion); + } case VersionComparisonState.NewerVersion: - { - var errorResult = Result.Fail( - $"This project was created with a newer version of Celbridge (v{projectVersion}). " + - $"Your current Celbridge version is v{applicationVersion}. " + - $"Please upgrade Celbridge or correct the version number in the .celbridge file."); + { + var errorResult = Result.Fail( + $"This project was created with a newer version of Celbridge (v{projectVersion}). " + + $"Your current Celbridge version is v{applicationVersion}. " + + $"Please upgrade Celbridge or correct the version number in the .celbridge file."); - return MigrationResult.FromStatus(MigrationStatus.IncompatibleVersion, errorResult); - } + return MigrationResult.FromStatus(MigrationStatus.IncompatibleVersion, errorResult); + } case VersionComparisonState.InvalidVersion: - { - var errorResult = Result.Fail( - $"Project version '{projectVersion}' or application version '{applicationVersion}' is not in a recognized format. " + - $"Please correct the version number in the .celbridge file and reload the project."); - return MigrationResult.FromStatus(MigrationStatus.InvalidVersion, errorResult); - } + { + var errorResult = Result.Fail( + $"Project version '{projectVersion}' or application version '{applicationVersion}' is not in a recognized format. " + + $"Please correct the version number in the .celbridge file and reload the project."); + return MigrationResult.FromStatus(MigrationStatus.InvalidVersion, errorResult); + } default: - { - var errorResult = Result.Fail($"Unknown version comparison state: {versionState}"); - return MigrationResult.FromStatus(MigrationStatus.Failed, errorResult); - } + { + var errorResult = Result.Fail($"Unknown version comparison state: {versionState}"); + return MigrationResult.FromStatus(MigrationStatus.Failed, errorResult); + } } } @@ -229,11 +229,11 @@ private async Task MigrateProjectAsync(string projectFilePath, // Get the list of steps required to migrate from current version to application version var requiredSteps = _migrationRegistry.GetRequiredSteps(projectVer, applicationVer); - + if (requiredSteps.Count == 0) { _logger.LogInformation("No migration steps required"); - + // We still need to update the version number if it differs if (projectVersion != applicationVersion) { @@ -244,12 +244,12 @@ private async Task MigrateProjectAsync(string projectFilePath, return MigrationResult.FromStatus(MigrationStatus.Failed, errorResult); } } - + return MigrationResult.WithVersions(MigrationStatus.Complete, Result.Ok(), projectVersion, applicationVersion); } - + _logger.LogInformation($"Executing {requiredSteps.Count} migration steps"); - + // Create migration context var projectFolderPath = Path.GetDirectoryName(projectFilePath)!; var projectDataFolderPath = Path.Combine(projectFolderPath, ProjectConstants.MetaDataFolder); @@ -284,13 +284,13 @@ private async Task MigrateProjectAsync(string projectFilePath, OriginalVersion = projectVersion, WriteProjectFileAsync = writeProjectFileAsync }; - + // Execute migration steps in order string currentVersion = projectVersion; foreach (var step in requiredSteps) { _logger.LogInformation($"Applying migration step: {step.GetType().Name} (Target: {step.TargetVersion})"); - + var stepResult = await step.ApplyAsync(context); if (stepResult.IsFailure) { @@ -311,7 +311,7 @@ private async Task MigrateProjectAsync(string projectFilePath, currentVersion = stepVersionString; _logger.LogInformation($"Successfully applied migration step to version {currentVersion}"); - + // Refresh the configuration after each step so subsequent steps see the updated state var readResult = await ReadProjectConfigAsync(projectFilePath); if (readResult.IsFailure) @@ -338,7 +338,7 @@ private async Task MigrateProjectAsync(string projectFilePath, } _logger.LogInformation($"Project migration completed successfully: {projectVersion} >> {finalVersion}"); - + return MigrationResult.WithVersions(MigrationStatus.Complete, Result.Ok(), projectVersion, finalVersion); } @@ -357,7 +357,7 @@ private VersionComparisonState CompareVersions(string projectVersion, string app _logger.LogInformation("Project version '' - using current application version"); return VersionComparisonState.SameVersion; } - + // Handle null or whitespace-only project version - we can't safely upgrade in this case. if (string.IsNullOrWhiteSpace(projectVersion)) { @@ -377,12 +377,12 @@ private VersionComparisonState CompareVersions(string projectVersion, string app // Normalize versions to 3-part format (major.minor.patch) var normalizedProjectVersion = NormalizeVersion(projectVersion); var normalizedAppVersion = NormalizeVersion(applicationVersion); - + var projectVer = new Version(normalizedProjectVersion); var appVer = new Version(normalizedAppVersion); - + int comparison = projectVer.CompareTo(appVer); - + if (comparison < 0) { return VersionComparisonState.OlderVersion; @@ -425,7 +425,7 @@ private VersionComparisonState CompareVersions(string projectVersion, string app private string NormalizeVersion(string versionString) { var parts = versionString.Split('.'); - + if (parts.Length == 3) { // Modern 3-part format - validate and return @@ -436,7 +436,7 @@ private string NormalizeVersion(string versionString) throw new ArgumentException( $"Version string '{versionString}' contains invalid numeric parts. All parts must be non-negative integers."); } - + return $"{major}.{minor}.{patch}"; } else if (parts.Length == 4) @@ -449,14 +449,14 @@ private string NormalizeVersion(string versionString) throw new ArgumentException( $"Version string '{versionString}' contains invalid numeric parts. All parts must be non-negative integers."); } - + var normalized3Part = $"{major}.{minor}.{patch}"; - + _logger.LogInformation( "Legacy 4-part version '{Version}' detected. Truncating to 3-part format: {NormalizedVersion}", versionString, normalized3Part); - + return normalized3Part; } else @@ -471,24 +471,24 @@ private async Task WriteApplicationVersionAsync(string projectFilePath, try { var originalText = await File.ReadAllTextAsync(projectFilePath); - + // Normalize to \n for processing var normalizedText = originalText.Replace("\r\n", "\n").Replace("\r", "\n"); - + var updatedText = normalizedText; - + // Update existing celbridge-version line in [celbridge] section // Pattern matches: optional whitespace, celbridge-version, =, quoted version var pattern = @"^(\s*)celbridge-version\s*=\s*""[^""]*"""; var match = Regex.Match(updatedText, pattern, RegexOptions.Multiline); - + if (match.Success) { // Preserve the original indentation from capture group 1 var leadingWhitespace = match.Groups[1].Value; updatedText = Regex.Replace( - updatedText, - pattern, + updatedText, + pattern, $"{leadingWhitespace}celbridge-version = \"{applicationVersion}\"", RegexOptions.Multiline); } @@ -498,17 +498,17 @@ private async Task WriteApplicationVersionAsync(string projectFilePath, // This should only happen if the file is corrupted or in old format return Result.Fail("Cannot update version: no celbridge-version line found in project file"); } - + // Only write if content actually changed if (updatedText != normalizedText) { // Normalize line endings to platform standard before writing updatedText = updatedText.Replace("\n", Environment.NewLine); - + await File.WriteAllTextAsync(projectFilePath, updatedText); _logger.LogInformation("Updated project file with application version {ApplicationVersion}", applicationVersion); } - + return Result.Ok(); } catch (Exception ex) @@ -524,7 +524,7 @@ private async Task> ReadProjectConfigAsync(string projectFileP { var text = await File.ReadAllTextAsync(projectFilePath); var parse = Toml.Parse(text); - + if (parse.HasErrors) { return Result.Fail($"Failed to parse project TOML file: {string.Join("; ", parse.Diagnostics)}"); diff --git a/app/Core/Celbridge.Projects/Services/ProjectTemplateService.cs b/app/Core/Celbridge.Projects/Services/ProjectTemplateService.cs index ee1376f33..864b291d3 100644 --- a/app/Core/Celbridge.Projects/Services/ProjectTemplateService.cs +++ b/app/Core/Celbridge.Projects/Services/ProjectTemplateService.cs @@ -1,7 +1,8 @@ +using System.IO.Compression; +using Celbridge.ApplicationEnvironment; using Celbridge.Python; using Celbridge.Utilities; using Microsoft.Extensions.Localization; -using System.IO.Compression; namespace Celbridge.Projects.Services; @@ -10,15 +11,15 @@ public class ProjectTemplateService : IProjectTemplateService private const string TemplateProjectFileName = "project.celbridge"; private readonly List _templates; - private readonly IUtilityService _utilityService; + private readonly IEnvironmentService _environmentService; private readonly IPythonConfigService _pythonConfigService; public ProjectTemplateService( IStringLocalizer stringLocalizer, - IUtilityService utilityService, + IEnvironmentService environmentService, IPythonConfigService pythonConfigService) { - _utilityService = utilityService; + _environmentService = environmentService; _pythonConfigService = pythonConfigService; _templates = @@ -50,7 +51,7 @@ public async Task CreateFromTemplateAsync(string projectFilePath, Projec Guard.IsNotNullOrWhiteSpace(projectFilePath); // Use a temporary staging folder to prevent leftover files on failure - var tempFile = _utilityService.GetTemporaryFilePath("NewProject", string.Empty); + var tempFile = PathHelper.GetTemporaryFilePath("NewProject", string.Empty); var tempStagingPath = Path.GetDirectoryName(tempFile); try @@ -75,7 +76,7 @@ public async Task CreateFromTemplateAsync(string projectFilePath, Projec Directory.CreateDirectory(stagingDataFolderPath); // Get Celbridge application version - var appVersion = _utilityService.GetEnvironmentInfo().AppVersion; + var appVersion = _environmentService.GetEnvironmentInfo().AppVersion; // Extract template zip to staging location var templateAsset = new Uri($"ms-appx:///Assets/Templates/{template.Id}.zip"); diff --git a/app/Core/Celbridge.UserInterface/Services/PanelFocusService.cs b/app/Core/Celbridge.UserInterface/Services/PanelFocusService.cs index 29be7f5a1..9d6e87530 100644 --- a/app/Core/Celbridge.UserInterface/Services/PanelFocusService.cs +++ b/app/Core/Celbridge.UserInterface/Services/PanelFocusService.cs @@ -1,5 +1,3 @@ -using Celbridge.Messaging; - namespace Celbridge.UserInterface.Services; /// diff --git a/app/Core/Celbridge.UserInterface/Views/Controls/FocusIndicator.xaml.cs b/app/Core/Celbridge.UserInterface/Views/Controls/FocusIndicator.xaml.cs index 5799fa998..550a55b2e 100644 --- a/app/Core/Celbridge.UserInterface/Views/Controls/FocusIndicator.xaml.cs +++ b/app/Core/Celbridge.UserInterface/Views/Controls/FocusIndicator.xaml.cs @@ -1,5 +1,3 @@ -using Celbridge.Messaging; - namespace Celbridge.UserInterface.Views.Controls; /// diff --git a/app/Core/Celbridge.Utilities/Celbridge.Utilities.csproj b/app/Core/Celbridge.Utilities/Celbridge.Utilities.csproj new file mode 100644 index 000000000..94263583a --- /dev/null +++ b/app/Core/Celbridge.Utilities/Celbridge.Utilities.csproj @@ -0,0 +1,29 @@ + + + net9.0;net9.0-windows10.0.22621;net9.0-desktop + true + true + Library + true + enable + enable + + + CSharpMarkup; + Lottie; + Hosting; + Toolkit; + Logging; + Mvvm; + Configuration; + Localization; + ThemeService; + + + + + + + + + diff --git a/app/Core/Celbridge.Utilities/GlobalUsings.cs b/app/Core/Celbridge.Utilities/GlobalUsings.cs new file mode 100644 index 000000000..c13139336 --- /dev/null +++ b/app/Core/Celbridge.Utilities/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Celbridge.ApplicationEnvironment; +global using Celbridge.Core; diff --git a/app/Core/Celbridge.Foundation/Utilities/ServiceConfiguration.cs b/app/Core/Celbridge.Utilities/ServiceConfiguration.cs similarity index 58% rename from app/Core/Celbridge.Foundation/Utilities/ServiceConfiguration.cs rename to app/Core/Celbridge.Utilities/ServiceConfiguration.cs index 8feaf0bb2..1382d7c8c 100644 --- a/app/Core/Celbridge.Foundation/Utilities/ServiceConfiguration.cs +++ b/app/Core/Celbridge.Utilities/ServiceConfiguration.cs @@ -1,5 +1,3 @@ -using Celbridge.Messaging.Services; -using Celbridge.Utilities.Services; using Microsoft.Extensions.DependencyInjection; namespace Celbridge.Utilities; @@ -8,10 +6,7 @@ public static class ServiceConfiguration { public static void ConfigureServices(IServiceCollection services) { - // - // Register services - // - services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); } } diff --git a/app/Core/Celbridge.Foundation/Utilities/Services/DumpFile.cs b/app/Core/Celbridge.Utilities/Services/DumpFile.cs similarity index 91% rename from app/Core/Celbridge.Foundation/Utilities/Services/DumpFile.cs rename to app/Core/Celbridge.Utilities/Services/DumpFile.cs index ce4a4d20e..4b1f7fc26 100644 --- a/app/Core/Celbridge.Foundation/Utilities/Services/DumpFile.cs +++ b/app/Core/Celbridge.Utilities/Services/DumpFile.cs @@ -1,5 +1,10 @@ -namespace Celbridge.Utilities.Services; +using Path = System.IO.Path; +namespace Celbridge.Utilities; + +/// +/// A simple dump file utility for writing diagnostic output. +/// public class DumpFile : IDumpFile { private string _dumpFilePath = string.Empty; @@ -40,10 +45,8 @@ public Result WriteLine(string line) using (var fileStream = new FileStream(_dumpFilePath, FileMode.Append, FileAccess.Write)) using (var writer = new StreamWriter(fileStream)) { - // Write the line of text, adding a newline writer.WriteLine(line); } - } catch (Exception ex) { diff --git a/app/Core/Celbridge.Utilities/Services/EnvironmentService.cs b/app/Core/Celbridge.Utilities/Services/EnvironmentService.cs new file mode 100644 index 000000000..8d761a9ec --- /dev/null +++ b/app/Core/Celbridge.Utilities/Services/EnvironmentService.cs @@ -0,0 +1,35 @@ +using System.Reflection; + +namespace Celbridge.Utilities; + +/// +/// Provides information about the runtime application environment. +/// +public class EnvironmentService : IEnvironmentService +{ + /// + /// Returns environment information for the runtime application. + /// + public EnvironmentInfo GetEnvironmentInfo() + { +#if WINDOWS + var platform = "Windows"; + var packageVersion = Package.Current.Id.Version; + var appVersion = $"{packageVersion.Major}.{packageVersion.Minor}.{packageVersion.Build}"; +#else + var platform = "SkiaGtk"; + var version = Assembly.GetExecutingAssembly().GetName().Version; + var appVersion = version != null + ? $"{version.Major}.{version.Minor}.{version.Build}" + : "unknown"; +#endif + +#if DEBUG + var configuration = "Debug"; +#else + var configuration = "Release"; +#endif + + return new EnvironmentInfo(appVersion, platform, configuration); + } +} diff --git a/app/Core/Celbridge.Utilities/Services/PathHelper.cs b/app/Core/Celbridge.Utilities/Services/PathHelper.cs new file mode 100644 index 000000000..cde778069 --- /dev/null +++ b/app/Core/Celbridge.Utilities/Services/PathHelper.cs @@ -0,0 +1,72 @@ +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/app/Core/Celbridge.Foundation/Utilities/TextBinarySniffer.cs b/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs similarity index 78% rename from app/Core/Celbridge.Foundation/Utilities/TextBinarySniffer.cs rename to app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs index e76896c4d..81dd04a48 100644 --- a/app/Core/Celbridge.Foundation/Utilities/TextBinarySniffer.cs +++ b/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs @@ -11,6 +11,54 @@ public static class TextBinarySniffer { private const int SampleSize = 8192; + /// + /// Known binary file extensions for fast-path detection. + /// + private static readonly HashSet _binaryExtensions = new(StringComparer.OrdinalIgnoreCase) + { + // Executables and libraries + ".exe", ".dll", ".pdb", ".obj", ".o", ".a", ".lib", + ".so", ".dylib", ".bin", ".dat", + // Archives + ".zip", ".tar", ".gz", ".7z", ".rar", ".bz2", + // Images + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg", + // Audio + ".mp3", ".wav", ".ogg", ".flac", ".aac", + // Video + ".mp4", ".avi", ".mkv", ".mov", ".webm", + // Documents + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + // Fonts + ".ttf", ".otf", ".woff", ".woff2", ".eot", + // Compiled code + ".pyc", ".pyo", ".class", + // Databases + ".db", ".sqlite", ".sqlite3", + // Packages + ".nupkg", ".snupkg", ".vsix", ".msi", ".cab" + }; + + /// + /// Quickly checks if a file extension indicates a binary file format. + /// This is a fast path that avoids reading file content. + /// + public static bool IsBinaryExtension(string extension) + { + if (string.IsNullOrEmpty(extension)) + { + return false; + } + + // Normalize: ensure it starts with a dot + if (!extension.StartsWith('.')) + { + extension = "." + extension; + } + + return _binaryExtensions.Contains(extension); + } + /// /// Determines if a file is likely a text file by examining its content. /// diff --git a/app/Modules/Celbridge.Core/Celbridge.Core.csproj b/app/Modules/Celbridge.Core/Celbridge.Core.csproj index 16647bb0f..f228c7dc4 100644 --- a/app/Modules/Celbridge.Core/Celbridge.Core.csproj +++ b/app/Modules/Celbridge.Core/Celbridge.Core.csproj @@ -15,6 +15,7 @@ + diff --git a/app/Modules/Celbridge.HTML/Celbridge.HTML.csproj b/app/Modules/Celbridge.HTML/Celbridge.HTML.csproj index 84a5d3f74..cc48b3fdc 100644 --- a/app/Modules/Celbridge.HTML/Celbridge.HTML.csproj +++ b/app/Modules/Celbridge.HTML/Celbridge.HTML.csproj @@ -16,6 +16,7 @@ + diff --git a/app/Modules/Celbridge.Markdown/Celbridge.Markdown.csproj b/app/Modules/Celbridge.Markdown/Celbridge.Markdown.csproj index efa288667..c606e64f3 100644 --- a/app/Modules/Celbridge.Markdown/Celbridge.Markdown.csproj +++ b/app/Modules/Celbridge.Markdown/Celbridge.Markdown.csproj @@ -22,6 +22,7 @@ + diff --git a/app/Modules/Celbridge.Screenplay/Celbridge.Screenplay.csproj b/app/Modules/Celbridge.Screenplay/Celbridge.Screenplay.csproj index 734c61203..34454472c 100644 --- a/app/Modules/Celbridge.Screenplay/Celbridge.Screenplay.csproj +++ b/app/Modules/Celbridge.Screenplay/Celbridge.Screenplay.csproj @@ -20,6 +20,7 @@ + diff --git a/app/Modules/Celbridge.Spreadsheet/Celbridge.Spreadsheet.csproj b/app/Modules/Celbridge.Spreadsheet/Celbridge.Spreadsheet.csproj index 16647bb0f..f228c7dc4 100644 --- a/app/Modules/Celbridge.Spreadsheet/Celbridge.Spreadsheet.csproj +++ b/app/Modules/Celbridge.Spreadsheet/Celbridge.Spreadsheet.csproj @@ -15,6 +15,7 @@ + diff --git a/app/Workspace/Celbridge.Documents/Assets/DocumentTypes/FileViewerTypes.json b/app/Workspace/Celbridge.Documents/Assets/DocumentTypes/FileViewerTypes.json index 3f4137544..07ba7b7ca 100644 --- a/app/Workspace/Celbridge.Documents/Assets/DocumentTypes/FileViewerTypes.json +++ b/app/Workspace/Celbridge.Documents/Assets/DocumentTypes/FileViewerTypes.json @@ -11,7 +11,6 @@ ".pdf", ".png", ".svg", - ".ttf", ".wav", ".webm", ".webp", diff --git a/app/Workspace/Celbridge.Documents/Celbridge.Documents.csproj b/app/Workspace/Celbridge.Documents/Celbridge.Documents.csproj index 0df231a22..c33f54612 100644 --- a/app/Workspace/Celbridge.Documents/Celbridge.Documents.csproj +++ b/app/Workspace/Celbridge.Documents/Celbridge.Documents.csproj @@ -39,6 +39,7 @@ + diff --git a/app/Workspace/Celbridge.Documents/GlobalUsings.cs b/app/Workspace/Celbridge.Documents/GlobalUsings.cs index 00a84cfc3..d017d4b68 100644 --- a/app/Workspace/Celbridge.Documents/GlobalUsings.cs +++ b/app/Workspace/Celbridge.Documents/GlobalUsings.cs @@ -1,6 +1,6 @@ global using Celbridge.Core; global using Celbridge.Resources; +global using Celbridge.Utilities; global using Microsoft.Extensions.DependencyInjection; global using Path = System.IO.Path; - diff --git a/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs index 2f4231388..e19e1daa5 100644 --- a/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -3,7 +3,6 @@ using Celbridge.Documents.Views; using Celbridge.Logging; using Celbridge.Messaging; -using Celbridge.Utilities; using Celbridge.Workspace; namespace Celbridge.Documents.Services; diff --git a/app/Workspace/Celbridge.Documents/Views/WebAppDocumentView.xaml.cs b/app/Workspace/Celbridge.Documents/Views/WebAppDocumentView.xaml.cs index 4d0fc9ba0..2729db01f 100644 --- a/app/Workspace/Celbridge.Documents/Views/WebAppDocumentView.xaml.cs +++ b/app/Workspace/Celbridge.Documents/Views/WebAppDocumentView.xaml.cs @@ -3,7 +3,6 @@ using Celbridge.Logging; using Celbridge.Messaging; using Celbridge.UserInterface.Helpers; -using Celbridge.Utilities; using Celbridge.Workspace; using Microsoft.Web.WebView2.Core; @@ -14,7 +13,6 @@ public sealed partial class WebAppDocumentView : DocumentView private readonly ILogger _logger; private readonly ICommandService _commandService; private readonly IMessengerService _messengerService; - private readonly IUtilityService _utilityService; private readonly IWorkspaceWrapper _workspaceWrapper; private IResourceRegistry ResourceRegistry => _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -28,7 +26,6 @@ public WebAppDocumentView() _logger = ServiceLocator.AcquireService>(); _commandService = ServiceLocator.AcquireService(); _messengerService = ServiceLocator.AcquireService(); - _utilityService = ServiceLocator.AcquireService(); _workspaceWrapper = ServiceLocator.AcquireService(); ViewModel = ServiceLocator.AcquireService(); @@ -166,7 +163,7 @@ private void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2Down // Map the download path to a unique path in the project folder // var requestedPath = ResourceRegistry.GetResourcePath(filename); - var getResult = _utilityService.GetUniquePath(requestedPath); + var getResult = PathHelper.GetUniquePath(requestedPath); if (getResult.IsFailure) { // Don't allow the download to proceed if we can't generate a unique path @@ -190,7 +187,7 @@ private void CoreWebView2_DownloadStarting(CoreWebView2 sender, CoreWebView2Down // Redirect download to a temporary path // var extension = Path.GetExtension(filename); - var tempPath = _utilityService.GetTemporaryFilePath("Downloads", extension); + var tempPath = PathHelper.GetTemporaryFilePath("Downloads", extension); args.ResultFilePath = tempPath; // diff --git a/app/Core/Celbridge.Foundation/Entities/ComponentEditorBase.cs b/app/Workspace/Celbridge.Entities/Services/ComponentEditorBase.cs similarity index 95% rename from app/Core/Celbridge.Foundation/Entities/ComponentEditorBase.cs rename to app/Workspace/Celbridge.Entities/Services/ComponentEditorBase.cs index 4adb91920..4856d5d63 100644 --- a/app/Core/Celbridge.Foundation/Entities/ComponentEditorBase.cs +++ b/app/Workspace/Celbridge.Entities/Services/ComponentEditorBase.cs @@ -1,4 +1,3 @@ -using Celbridge.Utilities; using System.Reflection; using System.Text.Json; @@ -134,26 +133,35 @@ protected virtual Result TrySetProperty(string propertyPath, string jsonValue) } public virtual void OnButtonClicked(string buttonId) - {} + { } /// /// Loads a text file from an embedded resource. /// protected string LoadEmbeddedResource(string resourcePath) { - var utilityService = ServiceLocator.AcquireService(); - // Get the type of the component editor class. // The embedded resource must be in the same assembly as the class that inherits // from ComponentEditorBase. - var loadResult = utilityService.LoadEmbeddedResource(GetType(), resourcePath); - if (loadResult.IsFailure) + var assembly = GetType().Assembly; + var stream = assembly.GetManifestResourceStream(resourcePath); + if (stream is null) { return string.Empty; } - var content = loadResult.Value; - return content; + try + { + using (stream) + using (StreamReader reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + catch + { + return string.Empty; + } } /// @@ -171,7 +179,7 @@ protected virtual void NotifyFormPropertyChanged(string propertyPath) /// Event handler called when a form property has changed /// protected virtual void OnFormPropertyChanged(string propertyPath) - {} + { } private void OnComponentPropertyChanged(string propertyPath) { diff --git a/app/Workspace/Celbridge.Entities/Services/ComponentEditorHelper.cs b/app/Workspace/Celbridge.Entities/Services/ComponentEditorHelper.cs index a65683ddf..978c3c907 100644 --- a/app/Workspace/Celbridge.Entities/Services/ComponentEditorHelper.cs +++ b/app/Workspace/Celbridge.Entities/Services/ComponentEditorHelper.cs @@ -1,5 +1,4 @@ using Celbridge.Logging; -using Celbridge.Utilities; namespace Celbridge.Entities.Services; @@ -7,18 +6,15 @@ public class ComponentEditorHelper : IComponentEditorHelper { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private readonly IUtilityService _utilityService; public event Action? ComponentPropertyChanged; public ComponentEditorHelper( IServiceProvider serviceProvider, - ILogger logger, - IUtilityService utilityService) + ILogger logger) { _serviceProvider = serviceProvider; _logger = logger; - _utilityService = utilityService; } protected IComponentProxy? _component; diff --git a/app/Workspace/Celbridge.Python/Celbridge.Python.csproj b/app/Workspace/Celbridge.Python/Celbridge.Python.csproj index d92322339..9297b0f01 100644 --- a/app/Workspace/Celbridge.Python/Celbridge.Python.csproj +++ b/app/Workspace/Celbridge.Python/Celbridge.Python.csproj @@ -24,6 +24,7 @@ + diff --git a/app/Workspace/Celbridge.Python/Services/PythonService.cs b/app/Workspace/Celbridge.Python/Services/PythonService.cs index 302f1def8..6e1b09657 100644 --- a/app/Workspace/Celbridge.Python/Services/PythonService.cs +++ b/app/Workspace/Celbridge.Python/Services/PythonService.cs @@ -1,7 +1,7 @@ +using Celbridge.ApplicationEnvironment; using Celbridge.Console; using Celbridge.Messaging; using Celbridge.Projects; -using Celbridge.Utilities; using Celbridge.Workspace; using Microsoft.Extensions.Logging; @@ -35,7 +35,7 @@ public class PythonService : IPythonService, IDisposable private readonly IProjectService _projectService; private readonly IWorkspaceWrapper _workspaceWrapper; - private readonly IUtilityService _utilityService; + private readonly IEnvironmentService _environmentService; private readonly IMessengerService _messengerService; private readonly ILogger _logger; private readonly Func _rpcServiceFactory; @@ -51,7 +51,7 @@ public class PythonService : IPythonService, IDisposable public PythonService( IProjectService projectService, IWorkspaceWrapper workspaceWrapper, - IUtilityService utilityService, + IEnvironmentService environmentService, IMessengerService messengerService, ILogger logger, Func rpcServiceFactory, @@ -59,7 +59,7 @@ public PythonService( { _projectService = projectService; _workspaceWrapper = workspaceWrapper; - _utilityService = utilityService; + _environmentService = environmentService; _messengerService = messengerService; _logger = logger; _rpcServiceFactory = rpcServiceFactory; @@ -100,12 +100,12 @@ public async Task InitializePython() // Ensure that python support files are installed var workingDir = project.ProjectFolderPath; - var appVersion = _utilityService.GetEnvironmentInfo().AppVersion; + var appVersion = _environmentService.GetEnvironmentInfo().AppVersion; var installResult = await PythonInstaller.InstallPythonAsync(appVersion); if (installResult.IsFailure) { var errorMessage = new ConsoleErrorMessage( - ConsoleErrorType.PythonHostPreInitError, + ConsoleErrorType.PythonHostPreInitError, "Failed to install Python support files"); _messengerService.Send(errorMessage); return Result.Fail("Failed to ensure Python support files are installed") @@ -120,7 +120,7 @@ public async Task InitializePython() if (!File.Exists(uvExePath)) { var errorMessage = new ConsoleErrorMessage( - ConsoleErrorType.PythonHostPreInitError, + ConsoleErrorType.PythonHostPreInitError, $"uv not found at '{uvExePath}'"); _messengerService.Send(errorMessage); return Result.Fail($"uv not found at '{uvExePath}'"); @@ -146,7 +146,7 @@ public async Task InitializePython() Directory.CreateDirectory(ipythonDir); // Set the Celbridge version number as an environment variable so we can print it at startup. - var environmentInfo = _utilityService.GetEnvironmentInfo(); + var environmentInfo = _environmentService.GetEnvironmentInfo(); var version = environmentInfo.AppVersion; var configuration = environmentInfo.Configuration; var celbridgeVersion = configuration == "Debug" ? $"{version} (Debug)" : $"{version}"; @@ -193,7 +193,7 @@ public async Task InitializePython() "--with", hostWheelPath, "--with", IPythonCacheFolderName }; - + // Add any additional packages specified in the project config var pythonPackages = pythonConfig.Dependencies; if (pythonPackages is not null) @@ -201,7 +201,7 @@ public async Task InitializePython() foreach (var pythonPackage in pythonPackages) { packageArgs.Add("--with"); - packageArgs.Add(pythonPackage); + packageArgs.Add(pythonPackage); } } @@ -214,7 +214,7 @@ public async Task InitializePython() .Add("--no-project") // ignore pyproject.toml file if present (dependencies are passed via --with instead) .Add("--python", pythonVersion!) // python interpreter version .Add("--managed-python") // only use uv-managed Python, ignore system Python - //.Add("--refresh-package", "celbridge_host") // uncomment to always refresh the celbridge_host package + //.Add("--refresh-package", "celbridge_host") // uncomment to always refresh the celbridge_host package .Add(packageArgs.ToArray()) // specify the packages to install .Add("python") // run the python interpreter .Add("-m", "IPython") // use IPython @@ -225,7 +225,7 @@ public async Task InitializePython() .ToString(); var terminal = _workspaceWrapper.WorkspaceService.ConsoleService.Terminal; - + // Start the terminal process // Any errors during Python/uv initialization will be displayed in the terminal terminal.Start(commandLine, workingDir); diff --git a/app/Workspace/Celbridge.Search/Celbridge.Search.csproj b/app/Workspace/Celbridge.Search/Celbridge.Search.csproj index 66d790326..249f11821 100644 --- a/app/Workspace/Celbridge.Search/Celbridge.Search.csproj +++ b/app/Workspace/Celbridge.Search/Celbridge.Search.csproj @@ -24,6 +24,7 @@ + diff --git a/app/Workspace/Celbridge.Search/GlobalUsings.cs b/app/Workspace/Celbridge.Search/GlobalUsings.cs index 46d7f468a..179e332c3 100644 --- a/app/Workspace/Celbridge.Search/GlobalUsings.cs +++ b/app/Workspace/Celbridge.Search/GlobalUsings.cs @@ -1 +1,2 @@ global using Celbridge.Core; +global using Celbridge.Utilities; diff --git a/app/Workspace/Celbridge.Search/Services/FileFilter.cs b/app/Workspace/Celbridge.Search/Services/FileFilter.cs index aa53b74ce..299e901c6 100644 --- a/app/Workspace/Celbridge.Search/Services/FileFilter.cs +++ b/app/Workspace/Celbridge.Search/Services/FileFilter.cs @@ -1,7 +1,6 @@ -using Celbridge.Utilities; using Path = System.IO.Path; -namespace Celbridge.Search.Services; +namespace Celbridge.Search; /// /// Determines which files should be included in search operations. @@ -16,22 +15,6 @@ public class FileFilter ".celbridge" }; - private readonly HashSet _binaryExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".exe", ".dll", ".pdb", ".obj", ".o", ".a", ".lib", - ".so", ".dylib", ".bin", ".dat", - ".zip", ".tar", ".gz", ".7z", ".rar", ".bz2", - ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg", - ".mp3", ".wav", ".ogg", ".flac", ".aac", - ".mp4", ".avi", ".mkv", ".mov", ".webm", - ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", - ".ttf", ".otf", ".woff", ".woff2", ".eot", - ".pyc", ".pyo", ".class", - ".db", ".sqlite", ".sqlite3", - ".nupkg", ".snupkg", - ".vsix", ".msi", ".cab" - }; - /// /// Checks if a file should be included in search based on its path. /// @@ -52,7 +35,7 @@ public bool ShouldSearchFile(string filePath) // Skip excluded file types var extension = Path.GetExtension(filePath).ToLowerInvariant(); - if (_metadataExtensions.Contains(extension) || _binaryExtensions.Contains(extension)) + if (_metadataExtensions.Contains(extension) || TextBinarySniffer.IsBinaryExtension(extension)) { return false; } From e56e4916c919cd16d5cf21b1d115f01381a73d34 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 13 Feb 2026 12:11:38 +0000 Subject: [PATCH 3/5] Add unit tests for TextBinarySniffer --- .../Utilities/TextBinarySnifferTests.cs | 520 ++++++++++++++++++ .../Services/TextBinarySniffer.cs | 209 ++++++- 2 files changed, 726 insertions(+), 3 deletions(-) create mode 100644 app/Celbridge.Tests/Utilities/TextBinarySnifferTests.cs diff --git a/app/Celbridge.Tests/Utilities/TextBinarySnifferTests.cs b/app/Celbridge.Tests/Utilities/TextBinarySnifferTests.cs new file mode 100644 index 000000000..3cbfab651 --- /dev/null +++ b/app/Celbridge.Tests/Utilities/TextBinarySnifferTests.cs @@ -0,0 +1,520 @@ +using Celbridge.Utilities; +using System.Text; + +namespace Celbridge.Tests.Utilities; + +[TestFixture] +public class TextBinarySnifferTests +{ + private string _testFilesDir = null!; + + [SetUp] + public void SetUp() + { + _testFilesDir = Path.Combine(Path.GetTempPath(), "TextBinarySnifferTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testFilesDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testFilesDir)) + { + Directory.Delete(_testFilesDir, recursive: true); + } + } + + #region Binary Extension Tests + + [Test] + public void IsBinaryExtension_KnownBinaryExtensions_ReturnsTrue() + { + TextBinarySniffer.IsBinaryExtension(".exe").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension(".dll").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension(".png").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension(".jpg").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension(".pdf").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension(".zip").Should().BeTrue(); + } + + [Test] + public void IsBinaryExtension_TextExtensions_ReturnsFalse() + { + TextBinarySniffer.IsBinaryExtension(".txt").Should().BeFalse(); + TextBinarySniffer.IsBinaryExtension(".cs").Should().BeFalse(); + TextBinarySniffer.IsBinaryExtension(".json").Should().BeFalse(); + TextBinarySniffer.IsBinaryExtension(".xml").Should().BeFalse(); + } + + [Test] + public void IsBinaryExtension_WithoutLeadingDot_ReturnsCorrectResult() + { + TextBinarySniffer.IsBinaryExtension("exe").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension("txt").Should().BeFalse(); + } + + [Test] + public void IsBinaryExtension_CaseInsensitive_ReturnsCorrectResult() + { + TextBinarySniffer.IsBinaryExtension(".EXE").Should().BeTrue(); + TextBinarySniffer.IsBinaryExtension(".Png").Should().BeTrue(); + } + + [Test] + public void IsBinaryExtension_SVG_IsBinary() + { + // As per design doc: SVG treated as binary (opened in WebView2, not edited as text) + TextBinarySniffer.IsBinaryExtension(".svg").Should().BeTrue(); + } + + #endregion + + #region UTF-8 Tests + + [Test] + public void IsTextFile_UTF8WithBOM_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "utf8-bom.txt"); + var content = "Hello, World! 你好世界"; + var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(content)).ToArray(); + File.WriteAllBytes(filePath, bytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_UTF8WithoutBOM_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "utf8-no-bom.txt"); + var content = "Hello, World! 你好世界 Привет мир"; + File.WriteAllText(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_PlainASCII_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "ascii.txt"); + File.WriteAllText(filePath, "Hello World\nThis is a test\n"); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_ASCIIWithTabs_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "tabs.txt"); + File.WriteAllText(filePath, "Column1\tColumn2\tColumn3\nValue1\tValue2\tValue3\n"); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_WithANSIEscapeCodes_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "ansi.txt"); + // ANSI escape codes for colored terminal output + var content = "\x1b[31mRed Text\x1b[0m\n\x1b[32mGreen Text\x1b[0m"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + #endregion + + #region UTF-16 Tests (Key scenarios from design doc) + + [Test] + public void IsTextFile_UTF16LEWithBOM_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "utf16le-bom.txt"); + var content = "Hello World"; + File.WriteAllText(filePath, content, Encoding.Unicode); // UTF-16 LE with BOM + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("UTF-16 with BOM should be detected as text"); + } + + [Test] + public void IsTextFile_UTF16BEWithBOM_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "utf16be-bom.txt"); + var content = "Hello World"; + File.WriteAllText(filePath, content, Encoding.BigEndianUnicode); // UTF-16 BE with BOM + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("UTF-16 BE with BOM should be detected as text"); + } + + [Test] + public void IsTextFile_UTF16BEWithoutBOM_ReturnsTrue() + { + // UTF-16 BE without BOM should also be detected as text + var filePath = Path.Combine(_testFilesDir, "utf16be-no-bom.txt"); + var content = "Hello World"; + var bytes = Encoding.BigEndianUnicode.GetBytes(content); + + // Write without BOM + var noBomBytes = bytes.Skip(Encoding.BigEndianUnicode.GetPreamble().Length).ToArray(); + File.WriteAllBytes(filePath, noBomBytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("UTF-16 BE without BOM is a valid text encoding"); + } + + [Test] + public void IsTextFile_UTF16LEWithoutBOM_ReturnsTrue() + { + // UTF-16 LE without BOM is a valid text encoding used by Windows tools + var filePath = Path.Combine(_testFilesDir, "utf16le-no-bom.txt"); + var content = "Hello World"; // In UTF-16 LE: 48 00 65 00 6C 00 6C 00 6F 00... + var bytes = Encoding.Unicode.GetBytes(content); + + // Write without BOM (skip the BOM that Encoding.Unicode normally adds) + var noBomBytes = bytes.Skip(Encoding.Unicode.GetPreamble().Length).ToArray(); + File.WriteAllBytes(filePath, noBomBytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("UTF-16 LE without BOM is a valid text encoding"); + } + + #endregion + + #region UTF-32 Tests + + [Test] + public void IsTextFile_UTF32LEWithBOM_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "utf32le-bom.txt"); + var content = "Hello"; + File.WriteAllText(filePath, content, Encoding.UTF32); // UTF-32 LE with BOM + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("UTF-32 with BOM should be detected as text"); + } + + [Test] + public void IsTextFile_UTF32WithoutBOM_ReturnsTrue() + { + // UTF-32 without BOM should be detected as text + var filePath = Path.Combine(_testFilesDir, "utf32-no-bom.txt"); + var content = "Hello World"; + var bytes = Encoding.UTF32.GetBytes(content); + + // Write without BOM + var noBomBytes = bytes.Skip(Encoding.UTF32.GetPreamble().Length).ToArray(); + File.WriteAllBytes(filePath, noBomBytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("UTF-32 without BOM is a valid text encoding"); + } + + #endregion + + #region Legacy Encoding Tests + + [Test] + public void IsTextFile_Latin1Text_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "latin1.txt"); + // Latin-1 text with accented characters + var content = "Café résumé naïve"; + var bytes = Encoding.Latin1.GetBytes(content); + File.WriteAllBytes(filePath, bytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("Legacy 8-bit encodings like Latin-1 should be detected as text"); + } + + // Note: Skipping Windows-1252 test as it requires registering a code page provider in .NET Core/9 + + #endregion + + #region Binary File Tests + + [Test] + public void IsTextFile_BinaryFileWithNULBytes_ReturnsFalse() + { + var filePath = Path.Combine(_testFilesDir, "binary.bin"); + // Simulate binary data with NUL bytes + var bytes = new byte[] { 0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0x00, 0x00 }; + File.WriteAllBytes(filePath, bytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeFalse("Binary data with NUL bytes should be detected as binary"); + } + + [Test] + public void IsTextFile_HighControlCharacterRatio_ReturnsFalse() + { + var filePath = Path.Combine(_testFilesDir, "mostly-control.bin"); + // Create content with >2% suspicious control characters + var bytes = new byte[1000]; + for (int i = 0; i < 1000; i++) + { + if (i < 30) // 3% control characters (above 2% threshold) + { + bytes[i] = 0x01; // Suspicious control char + } + else + { + bytes[i] = (byte)'A'; + } + } + File.WriteAllBytes(filePath, bytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeFalse("Content with >2% suspicious control characters should be binary"); + } + + [Test] + public void IsTextFile_JustBelowThreshold_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "just-below-threshold.txt"); + // Create content with exactly 2% suspicious control characters (at threshold) + var bytes = new byte[1000]; + for (int i = 0; i < 1000; i++) + { + if (i < 20) // Exactly 2% + { + bytes[i] = 0x01; // Suspicious control char + } + else + { + bytes[i] = (byte)'A'; + } + } + File.WriteAllBytes(filePath, bytes); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("Content with exactly 2% control characters should pass (at threshold)"); + } + + #endregion + + #region Edge Cases + + [Test] + public void IsTextFile_EmptyFile_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "empty.txt"); + File.WriteAllText(filePath, string.Empty); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue("Empty files should be treated as text"); + } + + [Test] + public void IsTextFile_VerySmallFile_Works() + { + var filePath = Path.Combine(_testFilesDir, "tiny.txt"); + File.WriteAllText(filePath, "Hi"); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_NonExistentFile_ReturnsFailure() + { + var filePath = Path.Combine(_testFilesDir, "does-not-exist.txt"); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeFalse(); + result.FirstErrorMessage.Should().Contain("does not exist"); + } + + [Test] + public void IsTextFile_NullPath_ReturnsFailure() + { + var result = TextBinarySniffer.IsTextFile(null!); + + result.IsSuccess.Should().BeFalse(); + result.FirstErrorMessage.Should().Contain("null or empty"); + } + + #endregion + + #region IsTextContent Tests + + [Test] + public void IsTextContent_PlainText_ReturnsTrue() + { + var result = TextBinarySniffer.IsTextContent("Hello, World!"); + + result.Should().BeTrue(); + } + + [Test] + public void IsTextContent_EmptyString_ReturnsTrue() + { + var result = TextBinarySniffer.IsTextContent(string.Empty); + + result.Should().BeTrue(); + } + + [Test] + public void IsTextContent_NullString_ReturnsTrue() + { + var result = TextBinarySniffer.IsTextContent(null!); + + result.Should().BeTrue(); + } + + [Test] + public void IsTextContent_Unicode_ReturnsTrue() + { + var result = TextBinarySniffer.IsTextContent("Hello 世界 🌍"); + + result.Should().BeTrue(); + } + + #endregion + + #region Real-World File Format Tests + + [Test] + public void IsTextFile_CSharpSourceFile_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "Program.cs"); + var content = @"using System; + +namespace MyApp +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine(""Hello, World!""); + } + } +}"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_JSONFile_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "data.json"); + var content = @"{ + ""name"": ""John Doe"", + ""age"": 30, + ""city"": ""New York"" +}"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_XMLFile_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "data.xml"); + var content = @" + + Value +"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_MarkdownFile_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "README.md"); + var content = @"# Title + +This is a **markdown** file with *formatting*. + +- Item 1 +- Item 2 +"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_WindowsLineEndings_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "windows.txt"); + var content = "Line 1\r\nLine 2\r\nLine 3\r\n"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + [Test] + public void IsTextFile_UnixLineEndings_ReturnsTrue() + { + var filePath = Path.Combine(_testFilesDir, "unix.txt"); + var content = "Line 1\nLine 2\nLine 3\n"; + File.WriteAllText(filePath, content); + + var result = TextBinarySniffer.IsTextFile(filePath); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeTrue(); + } + + #endregion +} diff --git a/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs b/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs index 81dd04a48..1d3da867f 100644 --- a/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs +++ b/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs @@ -144,11 +144,16 @@ private static bool IsTextBytes(ReadOnlySpan bytes) return true; } - // 2. NUL bytes without BOM => binary - // (UTF-16/UTF-32 without BOM is rare; treating as binary is the pragmatic choice) + // 2. Check for NUL bytes - could be UTF-16/UTF-32 without BOM or actual binary if (bytes.IndexOf((byte)0) >= 0) { - return false; + // Try to detect UTF-16/UTF-32 without BOM before rejecting as binary + if (IsValidUtf16(bytes) || IsValidUtf32(bytes)) + { + return true; // Valid UTF-16/32 text encoding + } + + return false; // Actual binary with NUL bytes } // 3. Strict UTF-8 decode test @@ -191,6 +196,204 @@ private static bool IsValidUtf8(ReadOnlySpan bytes) } } + /// + /// Checks if the bytes appear to be valid UTF-16 (LE or BE) without BOM. + /// Uses heuristics: tries to decode and checks if result is valid text. + /// Also checks for UTF-16 structural patterns to avoid false positives. + /// + private static bool IsValidUtf16(ReadOnlySpan bytes) + { + // Need at least 2 bytes for UTF-16 + if (bytes.Length < 2) + { + return false; + } + + // UTF-16 requires even number of bytes + if (bytes.Length % 2 != 0) + { + return false; + } + + // Check for UTF-16 LE patterns first (most common) + if (LooksLikeUtf16LE(bytes) && TryDecodeUtf16(bytes, Encoding.Unicode)) + { + return true; + } + + // Check for UTF-16 BE patterns + if (LooksLikeUtf16BE(bytes) && TryDecodeUtf16(bytes, Encoding.BigEndianUnicode)) + { + return true; + } + + return false; + } + + /// + /// Checks if bytes match UTF-16 LE patterns (every other byte is often 0x00 for ASCII-range text). + /// + private static bool LooksLikeUtf16LE(ReadOnlySpan bytes) + { + // For UTF-16 LE, ASCII characters have pattern: [char, 0x00] + // Count how many even-positioned bytes are printable ASCII and odd-positioned are 0x00 + int asciiLikeCount = 0; + int totalPairs = bytes.Length / 2; + + for (int i = 0; i < bytes.Length - 1; i += 2) + { + byte lowByte = bytes[i]; + byte highByte = bytes[i + 1]; + + // Check if this looks like an ASCII character in UTF-16 LE + if (highByte == 0x00 && (lowByte >= 0x20 && lowByte <= 0x7E || lowByte == 0x09 || lowByte == 0x0A || lowByte == 0x0D)) + { + asciiLikeCount++; + } + } + + // If at least 50% of pairs look like ASCII in UTF-16 LE, it's likely UTF-16 LE + return asciiLikeCount >= totalPairs * 0.5; + } + + /// + /// Checks if bytes match UTF-16 BE patterns. + /// + private static bool LooksLikeUtf16BE(ReadOnlySpan bytes) + { + // For UTF-16 BE, ASCII characters have pattern: [0x00, char] + int asciiLikeCount = 0; + int totalPairs = bytes.Length / 2; + + for (int i = 0; i < bytes.Length - 1; i += 2) + { + byte highByte = bytes[i]; + byte lowByte = bytes[i + 1]; + + // Check if this looks like an ASCII character in UTF-16 BE + if (highByte == 0x00 && (lowByte >= 0x20 && lowByte <= 0x7E || lowByte == 0x09 || lowByte == 0x0A || lowByte == 0x0D)) + { + asciiLikeCount++; + } + } + + // If at least 50% of pairs look like ASCII in UTF-16 BE, it's likely UTF-16 BE + return asciiLikeCount >= totalPairs * 0.5; + } + + /// + /// Checks if the bytes appear to be valid UTF-32 without BOM. + /// + private static bool IsValidUtf32(ReadOnlySpan bytes) + { + // Need at least 4 bytes for UTF-32 + if (bytes.Length < 4) + { + return false; + } + + try + { + var encoding = new UTF32Encoding(bigEndian: false, byteOrderMark: false, throwOnInvalidCharacters: true); + var chars = encoding.GetChars(bytes.ToArray()); + + // Validate that decoded text looks reasonable (not mostly control characters) + return IsDecodedTextValid(chars); + } + catch + { + // Try big-endian UTF-32 + try + { + var encoding = new UTF32Encoding(bigEndian: true, byteOrderMark: false, throwOnInvalidCharacters: true); + var chars = encoding.GetChars(bytes.ToArray()); + return IsDecodedTextValid(chars); + } + catch + { + return false; + } + } + } + + /// + /// Attempts to decode bytes as UTF-16 and validates the result. + /// + private static bool TryDecodeUtf16(ReadOnlySpan bytes, Encoding encoding) + { + try + { + var decoder = encoding.GetDecoder(); + decoder.Fallback = DecoderFallback.ExceptionFallback; + + var chars = encoding.GetChars(bytes.ToArray()); + + // Validate that decoded text looks reasonable + return IsDecodedTextValid(chars); + } + catch + { + return false; + } + } + + /// + /// Validates that decoded text contains reasonable characters (not binary garbage). + /// Checks for valid character patterns and absence of excessive control characters. + /// + private static bool IsDecodedTextValid(char[] chars) + { + if (chars.Length == 0) + { + return true; + } + + int suspicious = 0; + int printable = 0; + + foreach (char c in chars) + { + // Allow common whitespace and control characters + if (c == '\t' || c == '\n' || c == '\r' || c == '\f') + { + continue; + } + + // Check for printable characters (basic ASCII and Unicode) + if (c >= 0x20 && c < 0x7F) // ASCII printable + { + printable++; + } + else if (c >= 0x80 && c < 0xFFFE) // Unicode range (excluding special markers) + { + // Most Unicode characters are valid for text + // Exclude replacement characters and other special markers + if (c == 0xFFFD || c == 0xFFFE || c == 0xFFFF) + { + suspicious++; + } + else + { + printable++; + } + } + else if (c < 0x20) // Control characters (excluding allowed ones above) + { + suspicious++; + } + } + + // If we have very few printable characters, it's likely binary + if (chars.Length > 10 && printable < chars.Length * 0.3) + { + return false; + } + + // Check suspicious character ratio (similar to the 2% threshold for bytes) + double ratio = (double)suspicious / chars.Length; + return ratio <= 0.05; // Slightly more lenient for decoded text + } + /// /// Checks if the bytes appear to be mostly printable text characters. /// Allows common control characters (tab, LF, CR, FF, ESC) and high bytes (for UTF-8/legacy encodings). From 10ed0b829d3c150b47e257beb449ae76daae6a9e Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 13 Feb 2026 13:16:33 +0000 Subject: [PATCH 4/5] Display "Open with Application" dialog for unsupported file types. --- .../Resources/Strings/en-US/Resources.resw | 9 ++++- .../Dialog/IConfirmationDialog.cs | 10 +++++ .../Dialog/IDialogFactory.cs | 4 +- .../Dialog/IDialogService.cs | 4 +- .../Services/Dialogs/DialogFactory.cs | 6 ++- .../Services/Dialogs/DialogService.cs | 4 +- .../Views/Dialogs/ConfirmationDialog.xaml | 4 +- .../Views/Dialogs/ConfirmationDialog.xaml.cs | 38 ++++++++++++++++--- .../Commands/OpenDocumentCommand.cs | 23 ++++++++--- .../Views/DocumentsPanel.xaml.cs | 4 -- .../Menu/Options/OpenMenuOption.cs | 7 +--- .../Views/ResourceTree.xaml.cs | 5 --- 12 files changed, 81 insertions(+), 37 deletions(-) diff --git a/app/Celbridge/Resources/Strings/en-US/Resources.resw b/app/Celbridge/Resources/Strings/en-US/Resources.resw index f0524f3ae..cd373474b 100644 --- a/app/Celbridge/Resources/Strings/en-US/Resources.resw +++ b/app/Celbridge/Resources/Strings/en-US/Resources.resw @@ -414,8 +414,13 @@ An error occurred while attempting to save '{0}'. + + Unsupported File Format + - The file format is not supported: '{0}' + Celbridge does not support '{0}' files. + +Open with default application instead? Loading Screenplay @@ -843,4 +848,4 @@ Do you wish to continue? Collapse All Search Results - + \ No newline at end of file diff --git a/app/Core/Celbridge.Foundation/Dialog/IConfirmationDialog.cs b/app/Core/Celbridge.Foundation/Dialog/IConfirmationDialog.cs index 8844ca586..fa536bfcb 100644 --- a/app/Core/Celbridge.Foundation/Dialog/IConfirmationDialog.cs +++ b/app/Core/Celbridge.Foundation/Dialog/IConfirmationDialog.cs @@ -15,6 +15,16 @@ public interface IConfirmationDialog /// string MessageText { get; set; } + /// + /// The text for the primary (confirm) button. If null, uses default "OK" text. + /// + string? PrimaryButtonText { get; set; } + + /// + /// The text for the secondary (cancel) button. If null, uses default "Cancel" text. + /// + string? SecondaryButtonText { get; set; } + /// /// Present the confirms dialog to the user. /// The async call completes when the user closes the dialog by accepting or cancelling the action. diff --git a/app/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs b/app/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs index 4cb557f34..13a64e7d2 100644 --- a/app/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs +++ b/app/Core/Celbridge.Foundation/Dialog/IDialogFactory.cs @@ -13,9 +13,9 @@ public interface IDialogFactory IAlertDialog CreateAlertDialog(string titleText, string messageText); /// - /// Create an Confirmation Dialog with configurable title and message text. + /// Create a Confirmation Dialog with configurable title, message text, and optional button text. /// - IConfirmationDialog CreateConfirmationDialog(string titleText, string messageText); + IConfirmationDialog CreateConfirmationDialog(string titleText, string messageText, string? primaryButtonText = null, string? secondaryButtonText = null); /// /// Create a Progress Dialog. diff --git a/app/Core/Celbridge.Foundation/Dialog/IDialogService.cs b/app/Core/Celbridge.Foundation/Dialog/IDialogService.cs index a55da553d..a1605f067 100644 --- a/app/Core/Celbridge.Foundation/Dialog/IDialogService.cs +++ b/app/Core/Celbridge.Foundation/Dialog/IDialogService.cs @@ -14,9 +14,9 @@ public interface IDialogService Task ShowAlertDialogAsync(string titleText, string messageText); /// - /// Display an Confirmation Dialog with configurable title and message text. + /// Display a Confirmation Dialog with configurable title, message text, and optional button text. /// - Task> ShowConfirmationDialogAsync(string titleText, string messageText); + Task> ShowConfirmationDialogAsync(string titleText, string messageText, string? primaryButtonText = null, string? secondaryButtonText = null); /// /// Acquire a progress dialog token. diff --git a/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs b/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs index 95274a7a1..7aa76f63a 100644 --- a/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs +++ b/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogFactory.cs @@ -17,12 +17,14 @@ public IAlertDialog CreateAlertDialog(string titleText, string messageText) return dialog; } - public IConfirmationDialog CreateConfirmationDialog(string titleText, string messageText) + public IConfirmationDialog CreateConfirmationDialog(string titleText, string messageText, string? primaryButtonText = null, string? secondaryButtonText = null) { var dialog = new ConfirmationDialog { TitleText = titleText, - MessageText = messageText + MessageText = messageText, + PrimaryButtonText = primaryButtonText, + SecondaryButtonText = secondaryButtonText }; return dialog; diff --git a/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs b/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs index 25f6e9af2..30dd70c37 100644 --- a/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs +++ b/app/Core/Celbridge.UserInterface/Services/Dialogs/DialogService.cs @@ -28,9 +28,9 @@ await ShowDialogAsync(async () => }); } - public async Task> ShowConfirmationDialogAsync(string titleText, string messageText) + public async Task> ShowConfirmationDialogAsync(string titleText, string messageText, string? primaryButtonText = null, string? secondaryButtonText = null) { - var dialog = _dialogFactory.CreateConfirmationDialog(titleText, messageText); + var dialog = _dialogFactory.CreateConfirmationDialog(titleText, messageText, primaryButtonText, secondaryButtonText); var showResult = await ShowDialogAsync(dialog.ShowDialogAsync); return Result.Ok(showResult); } diff --git a/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml b/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml index 6988fd76f..cc65ff2c1 100644 --- a/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml +++ b/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml @@ -5,8 +5,8 @@ xmlns:local="using:Celbridge.UserInterface.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" Title="{x:Bind ViewModel.TitleText, Mode=OneWay}" - PrimaryButtonText="{x:Bind OkString}" - SecondaryButtonText="{x:Bind CancelString}"> + PrimaryButtonText="{x:Bind PrimaryButtonDisplayText, Mode=OneWay}" + SecondaryButtonText="{x:Bind SecondaryButtonDisplayText, Mode=OneWay}"> diff --git a/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs b/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs index f57b652ff..eac6f018a 100644 --- a/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs +++ b/app/Core/Celbridge.UserInterface/Views/Dialogs/ConfirmationDialog.xaml.cs @@ -5,6 +5,8 @@ namespace Celbridge.UserInterface.Views; public sealed partial class ConfirmationDialog : ContentDialog, IConfirmationDialog { private readonly IStringLocalizer _stringLocalizer; + private string? _primaryButtonText; + private string? _secondaryButtonText; public ConfirmationDialogViewModel ViewModel { get; } @@ -14,14 +16,34 @@ public string TitleText set => ViewModel.TitleText = value; } - public string MessageText - { + public string MessageText + { get => ViewModel.MessageText; - set => ViewModel.MessageText = value; + set => ViewModel.MessageText = value; + } + + public string? PrimaryButtonText + { + get => _primaryButtonText; + set + { + _primaryButtonText = value; + OnPropertyChanged(nameof(PrimaryButtonDisplayText)); + } + } + + public string? SecondaryButtonText + { + get => _secondaryButtonText; + set + { + _secondaryButtonText = value; + OnPropertyChanged(nameof(SecondaryButtonDisplayText)); + } } - public string OkString => _stringLocalizer.GetString("DialogButton_Ok"); - public string CancelString => _stringLocalizer.GetString("DialogButton_Cancel"); + public string PrimaryButtonDisplayText => _primaryButtonText ?? _stringLocalizer.GetString("DialogButton_Ok"); + public string SecondaryButtonDisplayText => _secondaryButtonText ?? _stringLocalizer.GetString("DialogButton_Cancel"); public ConfirmationDialog() { @@ -46,4 +68,10 @@ public async Task ShowDialogAsync() return false; } + + private void OnPropertyChanged(string propertyName) + { + // Trigger binding update for the property + this.Bindings.Update(); + } } diff --git a/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs index d3da3497f..a8c0773b2 100644 --- a/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs +++ b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs @@ -1,5 +1,6 @@ using Celbridge.Commands; using Celbridge.Dialog; +using Celbridge.Explorer; using Celbridge.Workspace; using Microsoft.Extensions.Localization; @@ -11,6 +12,7 @@ public class OpenDocumentCommand : CommandBase, IOpenDocumentCommand private readonly IStringLocalizer _stringLocalizer; private readonly IDialogService _dialogService; + private readonly ICommandService _commandService; private readonly IWorkspaceWrapper _workspaceWrapper; public ResourceKey FileResource { get; set; } @@ -24,10 +26,12 @@ public class OpenDocumentCommand : CommandBase, IOpenDocumentCommand public OpenDocumentCommand( IStringLocalizer stringLocalizer, IDialogService dialogService, + ICommandService commandService, IWorkspaceWrapper workspaceWrapper) { _stringLocalizer = stringLocalizer; _dialogService = dialogService; + _commandService = commandService; _workspaceWrapper = workspaceWrapper; } @@ -38,11 +42,20 @@ public override async Task ExecuteAsync() var viewType = documentsService.GetDocumentViewType(FileResource); if (viewType == DocumentViewType.UnsupportedFormat) { - // Alert the user that the file format is not supported - var file = Path.GetFileName(FileResource); - var title = _stringLocalizer.GetString("Documents_OpenDocumentFailedTitle"); - var message = _stringLocalizer.GetString("Documents_OpenDocumentFailedNotSupported", file); - await _dialogService.ShowAlertDialogAsync(title, message); + var extension = Path.GetExtension(FileResource); + var title = _stringLocalizer.GetString("Documents_UnsupportedFileFormatTitle"); + var message = _stringLocalizer.GetString("Documents_OpenDocumentFailedNotSupported", extension); + var primaryButtonText = _stringLocalizer.GetString("ResourceTree_OpenApplication"); + var secondaryButtonText = _stringLocalizer.GetString("DialogButton_Cancel"); + + var confirmResult = await _dialogService.ShowConfirmationDialogAsync(title, message, primaryButtonText, secondaryButtonText); + if (confirmResult.IsSuccess && confirmResult.Value) + { + _commandService.Execute(command => + { + command.Resource = FileResource; + }); + } return Result.Fail($"This file format is not supported: '{FileResource}'"); } diff --git a/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs b/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs index 0eb43835e..ab26a0175 100644 --- a/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs +++ b/app/Workspace/Celbridge.Documents/Views/DocumentsPanel.xaml.cs @@ -144,10 +144,6 @@ private void HandleDroppedFiles(DocumentSection targetSection, List r } var fileResourceKey = ViewModel.GetResourceKey(fileResource); - if (!ViewModel.IsDocumentSupported(fileResourceKey)) - { - continue; - } // Check if the file is already open in any section var (existingSection, existingTab) = SectionContainer.FindDocumentTab(fileResourceKey); diff --git a/app/Workspace/Celbridge.Explorer/Menu/Options/OpenMenuOption.cs b/app/Workspace/Celbridge.Explorer/Menu/Options/OpenMenuOption.cs index 4ddf2f370..90680d995 100644 --- a/app/Workspace/Celbridge.Explorer/Menu/Options/OpenMenuOption.cs +++ b/app/Workspace/Celbridge.Explorer/Menu/Options/OpenMenuOption.cs @@ -41,12 +41,7 @@ public MenuItemState GetState(ExplorerMenuContext context) return new MenuItemState(IsVisible: false, IsEnabled: false); } - var documentsService = _workspaceWrapper.WorkspaceService.DocumentsService; - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - var resourceKey = resourceRegistry.GetResourceKey(clickedFile); - var isSupported = documentsService.IsDocumentSupported(resourceKey); - - return new MenuItemState(IsVisible: true, IsEnabled: isSupported); + return new MenuItemState(IsVisible: true, IsEnabled: true); } public void Execute(ExplorerMenuContext context) diff --git a/app/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml.cs b/app/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml.cs index 97f8b4539..492f9b781 100644 --- a/app/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml.cs +++ b/app/Workspace/Celbridge.Explorer/Views/ResourceTree.xaml.cs @@ -312,11 +312,6 @@ private void OpenResource(ResourceViewItem item) else if (item.Resource is IFileResource fileResource) { var resourceKey = _resourceRegistry.GetResourceKey(fileResource); - if (!_documentsService.IsDocumentSupported(resourceKey)) - { - return; - } - _commandService.Execute(command => { command.FileResource = resourceKey; From f8befadb7f6fbe7ca9ad36566325b6a284e25416 Mon Sep 17 00:00:00 2001 From: Chris Gregan Date: Fri, 13 Feb 2026 13:36:47 +0000 Subject: [PATCH 5/5] Refactor OpenDocument methods --- .../Documents/IDocumentsService.cs | 12 ++++-------- .../Commands/OpenDocumentCommand.cs | 2 +- .../Celbridge.Documents/Services/DocumentsService.cs | 9 ++------- .../ViewModels/DocumentsPanelViewModel.cs | 11 ----------- .../Celbridge.Documents/Views/DocumentSection.xaml | 1 - .../Views/DocumentSection.xaml.cs | 5 ----- 6 files changed, 7 insertions(+), 33 deletions(-) diff --git a/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs b/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs index 9d887cbb1..490b9d2c6 100644 --- a/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs +++ b/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs @@ -41,20 +41,16 @@ public interface IDocumentsService string GetDocumentLanguage(ResourceKey fileResource); /// - /// Opens a file resource as a document in the documents panel. + /// Opens a file resource as a document in the documents panel, optionally reloading if already open + /// and navigating to a specific location. /// - Task OpenDocument(ResourceKey fileResource, bool forceReload); - - /// - /// Opens a file resource as a document in the documents panel and navigates to a specific location. - /// - Task OpenDocument(ResourceKey fileResource, bool forceReload, string location); + Task OpenDocument(ResourceKey fileResource, bool forceReload = false, string location = ""); /// /// Opens a file resource as a document in a specific section of the documents panel. /// If the document is already open in another section, it will be moved to the target section. /// - Task OpenDocumentAtSection(ResourceKey fileResource, bool forceReload, string location, int sectionIndex); + Task OpenDocumentAtSection(ResourceKey fileResource, int sectionIndex, bool forceReload = false, string location = ""); /// /// Closes an opened document in the documents panel. diff --git a/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs index a8c0773b2..4831ce8b4 100644 --- a/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs +++ b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs @@ -64,7 +64,7 @@ public override async Task ExecuteAsync() if (TargetSectionIndex.HasValue) { // Open in the specified section - openResult = await documentsService.OpenDocumentAtSection(FileResource, ForceReload, Location, TargetSectionIndex.Value); + openResult = await documentsService.OpenDocumentAtSection(FileResource, TargetSectionIndex.Value, ForceReload, Location); } else { diff --git a/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs b/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs index e19e1daa5..5cd3b39b9 100644 --- a/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs +++ b/app/Workspace/Celbridge.Documents/Services/DocumentsService.cs @@ -221,12 +221,7 @@ public bool CanAccessFile(string resourcePath) } } - public async Task OpenDocument(ResourceKey fileResource, bool forceReload) - { - return await OpenDocument(fileResource, forceReload, string.Empty); - } - - public async Task OpenDocument(ResourceKey fileResource, bool forceReload, string location) + public async Task OpenDocument(ResourceKey fileResource, bool forceReload = false, string location = "") { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; @@ -254,7 +249,7 @@ public async Task OpenDocument(ResourceKey fileResource, bool forceReloa return Result.Ok(); } - public async Task OpenDocumentAtSection(ResourceKey fileResource, bool forceReload, string location, int sectionIndex) + public async Task OpenDocumentAtSection(ResourceKey fileResource, int sectionIndex, bool forceReload = false, string location = "") { var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; diff --git a/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs b/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs index 9dd7825f0..1e14abb59 100644 --- a/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs +++ b/app/Workspace/Celbridge.Documents/ViewModels/DocumentsPanelViewModel.cs @@ -83,15 +83,4 @@ public ResourceKey GetResourceKey(IFileResource fileResource) var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; return resourceRegistry.GetResourceKey(fileResource); } - - public bool IsDocumentSupported(ResourceKey fileResource) - { - return _documentsService.IsDocumentSupported(fileResource); - } - - public string GetFilePath(ResourceKey fileResource) - { - var resourceRegistry = _workspaceWrapper.WorkspaceService.ResourceService.Registry; - return resourceRegistry.GetResourcePath(fileResource); - } } diff --git a/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml b/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml index e27e2a14d..93c5ff945 100644 --- a/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml +++ b/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml @@ -9,7 +9,6 @@