feat(ui): show an open-failed page and release a workspace that fails to build

This commit is contained in:
yyc12345 committed 2026-10-04 16:14:30 +08:00
1 parent 647232c447
commit 7f277d1630
7 files changed
+238 -1

No files matched your search

@@ -0,0 +1,82 @@
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_DatabaseMissing),
OpenFailedPageViewModel.BuildMessage(Folder, new WorkspaceDatabaseMissingException("/work/repo/.ykanban/ykanban.db")));
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);
});
}
}
+24
View File
@@ -445,4 +445,28 @@
<data name="Export_Done_Message" xml:space="preserve">
<value>Exported to: {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_DatabaseMissing" xml:space="preserve">
<value>The workspace database (.ykanban/ykanban.db) is missing. Restore it from a backup together with its -wal and -shm files, or delete the .ykanban folder to initialize the workspace again: {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>
</root>
@@ -445,4 +445,28 @@
<data name="Export_Done_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_DatabaseMissing" xml:space="preserve">
<value>工作区数据库(.ykanban/ykanban.db)不存在。请从备份恢复(连同 -wal、-shm 文件),或删除 .ykanban 文件夹后重新初始化:{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>
</root>
@@ -0,0 +1,63 @@
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, a missing, 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),
WorkspaceDatabaseMissingException => string.Format(culture, Resources.Error_OpenFailed_DatabaseMissing, folderPath),
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),
};
}
}
+12 -1
View File
@@ -80,6 +80,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase {
Content = new NotInitializedPageViewModel(folderPath, InitializeWorkspace);
} catch (WorkspaceLockException) {
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);
}
}
@@ -95,7 +99,14 @@ public sealed partial class MainWindowViewModel : ViewModelBase {
/// <param name="folderPath">The workspace folder.</param>
/// <param name="session">The opened workspace session.</param>
private void OpenWorkspace(string folderPath, WorkspaceSession session) {
Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout);
try {
Content = new WorkspaceViewModel(_services, session, ShowSettings, ShowAbout);
} catch {
// The first board read failed: the caller shows an error page, which holds no workspace.
_services.CloseWorkspace();
throw;
}
Title = $"{new DirectoryInfo(folderPath).Name} - {Resources.App_Name}";
}
@@ -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,9 @@
using Avalonia.Controls;
namespace YKanBan.Views.ErrorPages;
public partial class OpenFailedPageView : UserControl {
public OpenFailedPageView() {
InitializeComponent();
}
}