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.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/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/Celbridge/Resources/Strings/en-US/Resources.resw b/app/Celbridge/Resources/Strings/en-US/Resources.resw
index 8ae2b81ce..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
@@ -519,8 +524,8 @@ Do you wish to continue?
Three Editors
-
- No documents open
+
+ Drop files here to open them
Close
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/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.Foundation/Documents/IDocumentsService.cs b/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs
index 72977d151..490b9d2c6 100644
--- a/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs
+++ b/app/Core/Celbridge.Foundation/Documents/IDocumentsService.cs
@@ -41,14 +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);
+ Task OpenDocument(ResourceKey fileResource, bool forceReload = false, string location = "");
///
- /// Opens a file resource as a document in the documents panel and navigates to a specific 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 OpenDocument(ResourceKey fileResource, bool forceReload, string location);
+ Task OpenDocumentAtSection(ResourceKey fileResource, int sectionIndex, bool forceReload = false, string location = "");
///
/// Closes an opened document in the documents panel.
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/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.Foundation/Utilities/TextBinarySniffer.cs b/app/Core/Celbridge.Foundation/Utilities/TextBinarySniffer.cs
deleted file mode 100644
index e76896c4d..000000000
--- a/app/Core/Celbridge.Foundation/Utilities/TextBinarySniffer.cs
+++ /dev/null
@@ -1,182 +0,0 @@
-using System.Buffers;
-using System.Text;
-
-namespace Celbridge.Utilities;
-
-///
-/// Provides heuristic detection of whether a file or stream contains text or binary data.
-/// Handles UTF-8, UTF-16, UTF-32, and legacy 8-bit text encodings.
-///
-public static class TextBinarySniffer
-{
- private const int SampleSize = 8192;
-
- ///
- /// Determines if a file is likely a text file by examining its content.
- ///
- public static Result IsTextFile(string path)
- {
- if (string.IsNullOrEmpty(path))
- {
- return Result.Fail("File path is null or empty");
- }
-
- if (!File.Exists(path))
- {
- return Result.Fail($"File does not exist: {path}");
- }
-
- try
- {
- using var stream = File.OpenRead(path);
- return Result.Ok(IsTextStream(stream));
- }
- catch (Exception ex)
- {
- return Result.Fail($"Failed to read file: {path}")
- .WithException(ex);
- }
- }
-
- ///
- /// Determines if the provided content appears to be text (not binary).
- ///
- public static bool IsTextContent(string content)
- {
- if (string.IsNullOrEmpty(content))
- {
- return true; // Empty content is considered text
- }
-
- var bytes = Encoding.UTF8.GetBytes(content);
- return IsTextBytes(bytes);
- }
-
- ///
- /// Determines if a stream contains text data by examining its content.
- ///
- private static bool IsTextStream(Stream stream)
- {
- if (!stream.CanRead)
- {
- throw new ArgumentException("Stream must be readable.", nameof(stream));
- }
-
- byte[] rented = ArrayPool.Shared.Rent(SampleSize);
- try
- {
- int read = stream.Read(rented, 0, SampleSize);
- if (read == 0)
- {
- return true; // Empty file, treat as text
- }
-
- var bytes = new ReadOnlySpan(rented, 0, read);
- return IsTextBytes(bytes);
- }
- finally
- {
- ArrayPool.Shared.Return(rented);
- }
- }
-
- ///
- /// Determines if the provided bytes appear to be text data.
- ///
- private static bool IsTextBytes(ReadOnlySpan bytes)
- {
- if (bytes.Length == 0)
- {
- return true; // Empty content, treat as text
- }
-
- // 1. BOM => text
- if (HasTextBom(bytes))
- {
- return true;
- }
-
- // 2. NUL bytes without BOM => binary
- // (UTF-16/UTF-32 without BOM is rare; treating as binary is the pragmatic choice)
- if (bytes.IndexOf((byte)0) >= 0)
- {
- return false;
- }
-
- // 3. Strict UTF-8 decode test
- if (IsValidUtf8(bytes))
- {
- // If it's valid UTF-8, still guard against "mostly control chars"
- return LooksLikeMostlyText(bytes);
- }
-
- // 4. If it's not valid UTF-8, it might still be legacy 8-bit text.
- // Decide using a control/printable heuristic.
- return LooksLikeMostlyText(bytes);
- }
-
- ///
- /// Checks if the buffer starts with a Unicode Byte Order Mark (BOM).
- ///
- private static bool HasTextBom(ReadOnlySpan b) =>
- b.StartsWith([(byte)0xEF, (byte)0xBB, (byte)0xBF]) || // UTF-8
- b.StartsWith([(byte)0xFF, (byte)0xFE, (byte)0x00, (byte)0x00]) || // UTF-32 LE (check before UTF-16 LE)
- b.StartsWith([(byte)0x00, (byte)0x00, (byte)0xFE, (byte)0xFF]) || // UTF-32 BE
- b.StartsWith([(byte)0xFF, (byte)0xFE]) || // UTF-16 LE
- b.StartsWith([(byte)0xFE, (byte)0xFF]); // UTF-16 BE
-
- ///
- /// Validates whether the bytes represent valid UTF-8 encoded text.
- ///
- private static bool IsValidUtf8(ReadOnlySpan bytes)
- {
- // Strict mode: throw on invalid sequences
- var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
- try
- {
- utf8.GetCharCount(bytes);
- return true;
- }
- catch (DecoderFallbackException)
- {
- return false;
- }
- }
-
- ///
- /// 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).
- ///
- private static bool LooksLikeMostlyText(ReadOnlySpan bytes)
- {
- int suspicious = 0;
-
- foreach (byte b in bytes)
- {
- // Allow common control characters: tab, LF, CR, form-feed, ESC (for ANSI logs)
- if (b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C || b == 0x1B)
- {
- continue;
- }
-
- // ASCII printable range
- if (b >= 0x20 && b <= 0x7E)
- {
- continue;
- }
-
- // High bytes (>= 0x80) could be UTF-8 multibyte or legacy 8-bit text
- // Don't count them as suspicious
- if (b >= 0x80)
- {
- continue;
- }
-
- suspicious++;
- }
-
- // Threshold: if more than 2% suspicious control characters, it's probably binary
- double ratio = (double)suspicious / bytes.Length;
- return ratio <= 0.02;
- }
-}
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/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/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.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/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.Utilities/Services/TextBinarySniffer.cs b/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs
new file mode 100644
index 000000000..1d3da867f
--- /dev/null
+++ b/app/Core/Celbridge.Utilities/Services/TextBinarySniffer.cs
@@ -0,0 +1,433 @@
+using System.Buffers;
+using System.Text;
+
+namespace Celbridge.Utilities;
+
+///
+/// Provides heuristic detection of whether a file or stream contains text or binary data.
+/// Handles UTF-8, UTF-16, UTF-32, and legacy 8-bit text encodings.
+///
+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.
+ ///
+ public static Result IsTextFile(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return Result.Fail("File path is null or empty");
+ }
+
+ if (!File.Exists(path))
+ {
+ return Result.Fail($"File does not exist: {path}");
+ }
+
+ try
+ {
+ using var stream = File.OpenRead(path);
+ return Result.Ok(IsTextStream(stream));
+ }
+ catch (Exception ex)
+ {
+ return Result.Fail($"Failed to read file: {path}")
+ .WithException(ex);
+ }
+ }
+
+ ///
+ /// Determines if the provided content appears to be text (not binary).
+ ///
+ public static bool IsTextContent(string content)
+ {
+ if (string.IsNullOrEmpty(content))
+ {
+ return true; // Empty content is considered text
+ }
+
+ var bytes = Encoding.UTF8.GetBytes(content);
+ return IsTextBytes(bytes);
+ }
+
+ ///
+ /// Determines if a stream contains text data by examining its content.
+ ///
+ private static bool IsTextStream(Stream stream)
+ {
+ if (!stream.CanRead)
+ {
+ throw new ArgumentException("Stream must be readable.", nameof(stream));
+ }
+
+ byte[] rented = ArrayPool.Shared.Rent(SampleSize);
+ try
+ {
+ int read = stream.Read(rented, 0, SampleSize);
+ if (read == 0)
+ {
+ return true; // Empty file, treat as text
+ }
+
+ var bytes = new ReadOnlySpan(rented, 0, read);
+ return IsTextBytes(bytes);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(rented);
+ }
+ }
+
+ ///
+ /// Determines if the provided bytes appear to be text data.
+ ///
+ private static bool IsTextBytes(ReadOnlySpan bytes)
+ {
+ if (bytes.Length == 0)
+ {
+ return true; // Empty content, treat as text
+ }
+
+ // 1. BOM => text
+ if (HasTextBom(bytes))
+ {
+ return true;
+ }
+
+ // 2. Check for NUL bytes - could be UTF-16/UTF-32 without BOM or actual binary
+ if (bytes.IndexOf((byte)0) >= 0)
+ {
+ // 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
+ if (IsValidUtf8(bytes))
+ {
+ // If it's valid UTF-8, still guard against "mostly control chars"
+ return LooksLikeMostlyText(bytes);
+ }
+
+ // 4. If it's not valid UTF-8, it might still be legacy 8-bit text.
+ // Decide using a control/printable heuristic.
+ return LooksLikeMostlyText(bytes);
+ }
+
+ ///
+ /// Checks if the buffer starts with a Unicode Byte Order Mark (BOM).
+ ///
+ private static bool HasTextBom(ReadOnlySpan b) =>
+ b.StartsWith([(byte)0xEF, (byte)0xBB, (byte)0xBF]) || // UTF-8
+ b.StartsWith([(byte)0xFF, (byte)0xFE, (byte)0x00, (byte)0x00]) || // UTF-32 LE (check before UTF-16 LE)
+ b.StartsWith([(byte)0x00, (byte)0x00, (byte)0xFE, (byte)0xFF]) || // UTF-32 BE
+ b.StartsWith([(byte)0xFF, (byte)0xFE]) || // UTF-16 LE
+ b.StartsWith([(byte)0xFE, (byte)0xFF]); // UTF-16 BE
+
+ ///
+ /// Validates whether the bytes represent valid UTF-8 encoded text.
+ ///
+ private static bool IsValidUtf8(ReadOnlySpan bytes)
+ {
+ // Strict mode: throw on invalid sequences
+ var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+ try
+ {
+ utf8.GetCharCount(bytes);
+ return true;
+ }
+ catch (DecoderFallbackException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// 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).
+ ///
+ private static bool LooksLikeMostlyText(ReadOnlySpan bytes)
+ {
+ int suspicious = 0;
+
+ foreach (byte b in bytes)
+ {
+ // Allow common control characters: tab, LF, CR, form-feed, ESC (for ANSI logs)
+ if (b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C || b == 0x1B)
+ {
+ continue;
+ }
+
+ // ASCII printable range
+ if (b >= 0x20 && b <= 0x7E)
+ {
+ continue;
+ }
+
+ // High bytes (>= 0x80) could be UTF-8 multibyte or legacy 8-bit text
+ // Don't count them as suspicious
+ if (b >= 0x80)
+ {
+ continue;
+ }
+
+ suspicious++;
+ }
+
+ // Threshold: if more than 2% suspicious control characters, it's probably binary
+ double ratio = (double)suspicious / bytes.Length;
+ return ratio <= 0.02;
+ }
+}
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/Commands/OpenDocumentCommand.cs b/app/Workspace/Celbridge.Documents/Commands/OpenDocumentCommand.cs
index 0fb72bb3f..4831ce8b4 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; }
@@ -19,13 +21,17 @@ public class OpenDocumentCommand : CommandBase, IOpenDocumentCommand
public string Location { get; set; } = string.Empty;
+ public int? TargetSectionIndex { get; set; }
+
public OpenDocumentCommand(
IStringLocalizer stringLocalizer,
IDialogService dialogService,
+ ICommandService commandService,
IWorkspaceWrapper workspaceWrapper)
{
_stringLocalizer = stringLocalizer;
_dialogService = dialogService;
+ _commandService = commandService;
_workspaceWrapper = workspaceWrapper;
}
@@ -36,16 +42,36 @@ 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}'");
}
- var openResult = await documentsService.OpenDocument(FileResource, ForceReload, Location);
+ Result openResult;
+ if (TargetSectionIndex.HasValue)
+ {
+ // Open in the specified section
+ openResult = await documentsService.OpenDocumentAtSection(FileResource, TargetSectionIndex.Value, ForceReload, Location);
+ }
+ 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/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 b2dbbe73b..5cd3b39b9 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;
@@ -222,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;
@@ -255,6 +249,41 @@ public async Task OpenDocument(ResourceKey fileResource, bool forceReloa
return Result.Ok();
}
+ public async Task OpenDocumentAtSection(ResourceKey fileResource, int sectionIndex, bool forceReload = false, string location = "")
+ {
+ 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..1e14abb59 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,10 @@ 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);
+ }
}
diff --git a/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml.cs b/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml.cs
index 0ac791b05..6da13f6d7 100644
--- a/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml.cs
+++ b/app/Workspace/Celbridge.Documents/Views/DocumentSection.xaml.cs
@@ -1,3 +1,4 @@
+using Celbridge.Logging;
using Celbridge.UserInterface;
using Celbridge.UserInterface.Helpers;
using Microsoft.Extensions.Localization;
@@ -7,12 +8,15 @@
namespace Celbridge.Documents.Views;
+using IDocumentSectionLogger = ILogger;
+
///
/// 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,6 +432,21 @@ 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;
}
}
@@ -437,6 +468,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 +498,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 +534,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..ab26a0175 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,56 @@ 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);
+
+ // 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 +354,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 +387,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();
}
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.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;
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;
}