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();
+ }
+}