feat: add workspace open-failed page and tighten launch argument rules
This commit is contained in:
1 parent
cde9e8e20c
commit
6743e1572e
9 files changed
+296
-13
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Storage;
|
||||
using YKanBan.ViewModels.ErrorPages;
|
||||
|
||||
namespace YKanBan.Tests.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the open-failed page's exception-type → localized message mapping
|
||||
/// and its English details. Mutates the global <see cref="Resources.Culture"/>,
|
||||
/// therefore marked do-not-parallelize.
|
||||
/// </summary>
|
||||
[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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,27 @@
|
||||
<data name="Error_LockConflict_Message" xml:space="preserve">
|
||||
<value>The workspace is already open in another instance: {0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Title" xml:space="preserve">
|
||||
<value>Cannot open workspace</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_SchemaTooNew" xml:space="preserve">
|
||||
<value>The workspace database was created by a newer version of YKanBan (schema version {1}; this version supports up to {2}): {0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Database" xml:space="preserve">
|
||||
<value>The workspace database cannot be opened or is damaged: {0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_AccessDenied" xml:space="preserve">
|
||||
<value>Access to the workspace was denied: {0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Io" xml:space="preserve">
|
||||
<value>A file system error occurred while opening the workspace: {0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Generic" xml:space="preserve">
|
||||
<value>An unexpected error occurred while opening the workspace: {0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Details" xml:space="preserve">
|
||||
<value>Details</value>
|
||||
</data>
|
||||
<data name="Settings_Title" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
</data>
|
||||
|
||||
@@ -70,6 +70,27 @@
|
||||
<data name="Error_LockConflict_Message" xml:space="preserve">
|
||||
<value>该工作区已在另一个实例中打开:{0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Title" xml:space="preserve">
|
||||
<value>无法打开工作区</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_SchemaTooNew" xml:space="preserve">
|
||||
<value>工作区数据库由更新版本的 YKanBan 创建(schema 版本 {1},当前版本最高支持 {2}):{0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Database" xml:space="preserve">
|
||||
<value>工作区数据库无法打开或已损坏:{0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_AccessDenied" xml:space="preserve">
|
||||
<value>没有访问该工作区的权限:{0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Io" xml:space="preserve">
|
||||
<value>打开工作区时发生文件系统错误:{0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Generic" xml:space="preserve">
|
||||
<value>打开工作区时发生意外错误:{0}</value>
|
||||
</data>
|
||||
<data name="Error_OpenFailed_Details" xml:space="preserve">
|
||||
<value>详细信息</value>
|
||||
</data>
|
||||
<data name="Settings_Title" xml:space="preserve">
|
||||
<value>设置</value>
|
||||
</data>
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace YKanBan.Launching;
|
||||
/// <summary>
|
||||
/// Parses raw command-line arguments. The only accepted form is a single,
|
||||
/// non-blank workspace path (<c>ykanban <path></c>); 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
|
||||
/// <c>-</c> (any option form, <c>--help</c> 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.
|
||||
/// </summary>
|
||||
public static class LaunchArguments
|
||||
{
|
||||
@@ -15,17 +18,25 @@ public static class LaunchArguments
|
||||
/// <c>ykanban <path></c> contract.
|
||||
/// </summary>
|
||||
/// <param name="arguments">The raw arguments, excluding the executable name.</param>
|
||||
/// <param name="folderPath">The parsed workspace path on success, otherwise <see langword="null"/>.</param>
|
||||
/// <returns><see langword="true"/> when exactly one non-blank path was supplied.</returns>
|
||||
/// <param name="folderPath">The absolute workspace path on success, otherwise <see langword="null"/>.</param>
|
||||
/// <returns><see langword="true"/> when exactly one usable path was supplied.</returns>
|
||||
public static bool TryParse(IReadOnlyList<string> 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using YKanBan.Storage;
|
||||
|
||||
namespace YKanBan.ViewModels.ErrorPages;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class OpenFailedPageViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the page for a failed open or initialization.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The workspace folder.</param>
|
||||
/// <param name="exception">The failure.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workspace folder.
|
||||
/// </summary>
|
||||
public string FolderPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the localized explanatory message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the English exception details for diagnosis.
|
||||
/// </summary>
|
||||
public string Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps the exception type to its localized message; unrecognized types get the generic one.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The workspace folder.</param>
|
||||
/// <param name="exception">The failure.</param>
|
||||
/// <returns>The localized message.</returns>
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:YKanBan.ViewModels.ErrorPages"
|
||||
xmlns:lang="using:YKanBan"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
|
||||
x:Class="YKanBan.Views.ErrorPages.OpenFailedPageView"
|
||||
x:DataType="vm:OpenFailedPageViewModel">
|
||||
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
|
||||
MaxWidth="720" Margin="32">
|
||||
<TextBlock Text="{x:Static lang:Resources.Error_OpenFailed_Title}"
|
||||
FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Message}"
|
||||
TextWrapping="Wrap" HorizontalAlignment="Center" TextAlignment="Center"/>
|
||||
<TextBlock Text="{x:Static lang:Resources.Error_OpenFailed_Details}" FontWeight="SemiBold"/>
|
||||
<!-- English diagnostics stay left-to-right whatever the UI flow direction. -->
|
||||
<TextBox Text="{Binding Details, Mode=OneWay}"
|
||||
IsReadOnly="True" TextWrapping="Wrap" AcceptsReturn="True"
|
||||
FlowDirection="LeftToRight"
|
||||
FontFamily="Consolas,Menlo,monospace" MaxHeight="240"/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace YKanBan.Views.ErrorPages;
|
||||
|
||||
public partial class OpenFailedPageView : UserControl
|
||||
{
|
||||
public OpenFailedPageView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user