From 6743e1572e66edae391b8211b60cb71a0d3f30ee Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 02:58:43 -0400 Subject: [PATCH] feat: add workspace open-failed page and tighten launch argument rules --- .../Launching/LaunchArgumentsTests.cs | 42 ++++++++- .../OpenFailedPageViewModelTests.cs | 88 +++++++++++++++++++ YKanBan/Assets/Locales/Resources.resx | 21 +++++ YKanBan/Assets/Locales/Resources.zh-Hans.resx | 21 +++++ YKanBan/Launching/LaunchArguments.cs | 29 ++++-- .../ErrorPages/OpenFailedPageViewModel.cs | 64 ++++++++++++++ YKanBan/ViewModels/MainWindowViewModel.cs | 11 ++- .../Views/ErrorPages/OpenFailedPageView.axaml | 24 +++++ .../ErrorPages/OpenFailedPageView.axaml.cs | 11 +++ 9 files changed, 297 insertions(+), 14 deletions(-) create mode 100644 YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs create mode 100644 YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs create mode 100644 YKanBan/Views/ErrorPages/OpenFailedPageView.axaml create mode 100644 YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs diff --git a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs index 3413b64..ea8b071 100644 --- a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs +++ b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs @@ -31,10 +31,46 @@ public class LaunchArgumentsTests } [TestMethod] - public void SinglePathIsAccepted() + public void SingleAbsolutePathIsAccepted() { - bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path); + string absolute = Path.Combine(Path.GetTempPath(), "Repo", "MyProject"); + bool parsed = LaunchArguments.TryParse([absolute], out string? path); Assert.IsTrue(parsed); - Assert.AreEqual(@"C:\Repo\MyProject", path); + Assert.AreEqual(absolute, path); + } + + [TestMethod] + public void RelativePathResolvesAgainstCurrentDirectory() + { + Assert.IsTrue(LaunchArguments.TryParse(["MyProject"], out string? path)); + Assert.AreEqual(Path.Combine(Environment.CurrentDirectory, "MyProject"), path); + + Assert.IsTrue(LaunchArguments.TryParse(["."], out path)); + Assert.AreEqual(Path.GetFullPath(Environment.CurrentDirectory), path); + } + + [TestMethod] + [DataRow("-")] + [DataRow("-x")] + [DataRow("--help")] + [DataRow("--path=foo")] + public void OptionLikeArgumentIsRejected(string argument) + { + Assert.IsFalse(LaunchArguments.TryParse([argument], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void OptionAlongsidePathIsRejected() + { + Assert.IsFalse(LaunchArguments.TryParse(["--help", "MyProject"], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void MalformedPathIsRejected() + { + Assert.IsFalse(LaunchArguments.TryParse(["a\0b"], out string? path)); + Assert.IsNull(path); } } diff --git a/YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs b/YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs new file mode 100644 index 0000000..cdd2744 --- /dev/null +++ b/YKanBan.Tests/ViewModels/OpenFailedPageViewModelTests.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using YKanBan.Storage; +using YKanBan.ViewModels.ErrorPages; + +namespace YKanBan.Tests.ViewModels; + +/// +/// Tests for the open-failed page's exception-type → localized message mapping +/// and its English details. Mutates the global , +/// therefore marked do-not-parallelize. +/// +[TestClass] +[DoNotParallelize] +public class OpenFailedPageViewModelTests +{ + private const string Folder = "/work/repo"; + + private static void WithCulture(string language, Action action) + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo(language); + action(); + } + finally + { + Resources.Culture = original; + } + } + + private static string Expected(string pattern, params object[] arguments) => + string.Format(Resources.Culture, pattern, [Folder, .. arguments]); + + [TestMethod] + [DataRow("en")] + [DataRow("zh-Hans")] + public void EachRecognizedExceptionTypeHasItsOwnMessage(string language) + { + WithCulture(language, () => + { + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_SchemaTooNew, 3L, 1), + OpenFailedPageViewModel.BuildMessage(Folder, new SchemaVersionException(3, 1))); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Database), + OpenFailedPageViewModel.BuildMessage(Folder, new SqliteException("file is not a database", 26))); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_AccessDenied), + OpenFailedPageViewModel.BuildMessage(Folder, new UnauthorizedAccessException())); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Io), + OpenFailedPageViewModel.BuildMessage(Folder, new IOException())); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Io), + OpenFailedPageViewModel.BuildMessage(Folder, new DirectoryNotFoundException())); + }); + } + + [TestMethod] + public void UnrecognizedExceptionGetsTheGenericMessage() + { + WithCulture("en", () => + { + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Generic), + OpenFailedPageViewModel.BuildMessage(Folder, new InvalidOperationException())); + Assert.AreEqual( + Expected(Resources.Error_OpenFailed_Generic), + OpenFailedPageViewModel.BuildMessage(Folder, new WorkspaceDirectoryMissingException(Folder))); + }); + } + + [TestMethod] + public void DetailsCarryTheEnglishExceptionTextInAnyLanguage() + { + WithCulture("zh-Hans", () => + { + var exception = new SchemaVersionException(3, 1); + var page = new OpenFailedPageViewModel(Folder, exception); + + Assert.AreEqual(Folder, page.FolderPath); + StringAssert.Contains(page.Details, typeof(SchemaVersionException).FullName!); + StringAssert.Contains(page.Details, exception.Message); + }); + } +} diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index 7962c94..af49c83 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -70,6 +70,27 @@ The workspace is already open in another instance: {0} + + Cannot open workspace + + + The workspace database was created by a newer version of YKanBan (schema version {1}; this version supports up to {2}): {0} + + + The workspace database cannot be opened or is damaged: {0} + + + Access to the workspace was denied: {0} + + + A file system error occurred while opening the workspace: {0} + + + An unexpected error occurred while opening the workspace: {0} + + + Details + Settings diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index a53f829..83e7641 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -70,6 +70,27 @@ 该工作区已在另一个实例中打开:{0} + + 无法打开工作区 + + + 工作区数据库由更新版本的 YKanBan 创建(schema 版本 {1},当前版本最高支持 {2}):{0} + + + 工作区数据库无法打开或已损坏:{0} + + + 没有访问该工作区的权限:{0} + + + 打开工作区时发生文件系统错误:{0} + + + 打开工作区时发生意外错误:{0} + + + 详细信息 + 设置 diff --git a/YKanBan/Launching/LaunchArguments.cs b/YKanBan/Launching/LaunchArguments.cs index e96d3e6..434ff12 100644 --- a/YKanBan/Launching/LaunchArguments.cs +++ b/YKanBan/Launching/LaunchArguments.cs @@ -5,8 +5,11 @@ namespace YKanBan.Launching; /// /// Parses raw command-line arguments. The only accepted form is a single, /// non-blank workspace path (ykanban <path>); zero arguments, -/// more than one argument, or a blank argument is rejected as an argument -/// error. The path itself is not checked for existence here. +/// more than one argument, a blank argument, an argument starting with +/// - (any option form, --help included) or a malformed path is +/// rejected as an argument error. A relative path is resolved against the +/// process's current directory; the path itself is not checked for existence +/// here. /// public static class LaunchArguments { @@ -15,17 +18,25 @@ public static class LaunchArguments /// ykanban <path> contract. /// /// The raw arguments, excluding the executable name. - /// The parsed workspace path on success, otherwise . - /// when exactly one non-blank path was supplied. + /// The absolute workspace path on success, otherwise . + /// when exactly one usable path was supplied. public static bool TryParse(IReadOnlyList arguments, [NotNullWhen(true)] out string? folderPath) { - if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) + folderPath = null; + if (arguments.Count != 1 || string.IsNullOrWhiteSpace(arguments[0]) || arguments[0].StartsWith('-')) { - folderPath = arguments[0]; - return true; + return false; } - folderPath = null; - return false; + try + { + folderPath = Path.GetFullPath(arguments[0]); + return true; + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) + { + // A path the OS cannot even represent is a bad argument, not a missing folder. + return false; + } } } diff --git a/YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs new file mode 100644 index 0000000..d4f9978 --- /dev/null +++ b/YKanBan/ViewModels/ErrorPages/OpenFailedPageViewModel.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using YKanBan.Storage; + +namespace YKanBan.ViewModels.ErrorPages; + +/// +/// The full-window page shown when a workspace cannot be opened or initialized: +/// a schema newer than this build, an unopenable or damaged database, an I/O or +/// permission error, or any unrecognized failure. It shows a localized message +/// chosen by exception type plus the read-only, copyable English exception +/// details, and has no operations. +/// +public sealed class OpenFailedPageViewModel : ViewModelBase +{ + /// + /// Initializes the page for a failed open or initialization. + /// + /// The workspace folder. + /// The failure. + public OpenFailedPageViewModel(string folderPath, Exception exception) + { + FolderPath = folderPath; + Message = BuildMessage(folderPath, exception); + + // Exception texts are English: ours by convention, .NET and SQLite ones by design. + Details = exception.ToString(); + } + + /// + /// Gets the workspace folder. + /// + public string FolderPath { get; } + + /// + /// Gets the localized explanatory message. + /// + public string Message { get; } + + /// + /// Gets the English exception details for diagnosis. + /// + public string Details { get; } + + /// + /// Maps the exception type to its localized message; unrecognized types get the generic one. + /// + /// The workspace folder. + /// The failure. + /// The localized message. + public static string BuildMessage(string folderPath, Exception exception) + { + CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentCulture; + return exception switch + { + SchemaVersionException schema => string.Format( + culture, Resources.Error_OpenFailed_SchemaTooNew, folderPath, schema.StoredVersion, schema.SupportedVersion), + SqliteException => string.Format(culture, Resources.Error_OpenFailed_Database, folderPath), + UnauthorizedAccessException => string.Format(culture, Resources.Error_OpenFailed_AccessDenied, folderPath), + IOException => string.Format(culture, Resources.Error_OpenFailed_Io, folderPath), + _ => string.Format(culture, Resources.Error_OpenFailed_Generic, folderPath), + }; + } +} diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs index 9f1f1d3..074024d 100644 --- a/YKanBan/ViewModels/MainWindowViewModel.cs +++ b/YKanBan/ViewModels/MainWindowViewModel.cs @@ -89,6 +89,12 @@ public sealed partial class MainWindowViewModel : ViewModelBase { Content = new LockConflictPageViewModel(folderPath); } + catch (Exception exception) + { + // Newer schema, damaged database, I/O or permission errors, and anything unrecognized. + Debug.WriteLine(exception); + Content = new OpenFailedPageViewModel(folderPath, exception); + } } /// @@ -120,10 +126,11 @@ public sealed partial class MainWindowViewModel : ViewModelBase WorkspacePreset.AddPresetColumns(page.FolderPath); OpenWorkspace(page.FolderPath); } - catch (Exception exception) when (exception is YKanBanException or IOException or UnauthorizedAccessException) + catch (Exception exception) { - // Initialization failed; the page remains so the user can retry. + // Initialization failed: switch to the open-failed page, which has no operations. Debug.WriteLine(exception); + Content = new OpenFailedPageViewModel(page.FolderPath, exception); } return Task.CompletedTask; diff --git a/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml new file mode 100644 index 0000000..ec14b6a --- /dev/null +++ b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs new file mode 100644 index 0000000..7ac4c22 --- /dev/null +++ b/YKanBan/Views/ErrorPages/OpenFailedPageView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace YKanBan.Views.ErrorPages; + +public partial class OpenFailedPageView : UserControl +{ + public OpenFailedPageView() + { + InitializeComponent(); + } +}