diff --git a/YKanBan/App.axaml b/YKanBan/App.axaml
index 9fe79d9..8b5e408 100644
--- a/YKanBan/App.axaml
+++ b/YKanBan/App.axaml
@@ -1,9 +1,27 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/App.axaml.cs b/YKanBan/App.axaml.cs
index 45aefff..f4884bd 100644
--- a/YKanBan/App.axaml.cs
+++ b/YKanBan/App.axaml.cs
@@ -1,18 +1,29 @@
-using System.Linq;
+using System.Globalization;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using Avalonia.Styling;
using Avalonia.Threading;
using YKanBan.Ipc;
+using YKanBan.Services;
+using YKanBan.Storage.AppData;
using YKanBan.ViewModels;
using YKanBan.Views;
+using Resx = YKanBan.Resources;
namespace YKanBan;
public partial class App : Application
{
+ /// Composition root, created before the main window.
+ public static AppServices Services { get; } = new();
+
+ /// The language applied at startup; a live selection differing from it needs a restart.
+ public static string StartupLanguage { get; private set; } = AppConfig.DefaultLanguage;
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -23,26 +34,69 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
DisableAvaloniaDataAnnotationValidation();
+
+ // Startup order: apply the configured culture (and its RTL flow direction)
+ // and the theme BEFORE creating the main window.
+ CultureInfo culture = ResolveCulture(Services.Config.Language);
+ StartupLanguage = Services.Config.Language;
+ Resx.Culture = culture;
+ ApplyTheme(Services.Config.Theme);
+
+ var mainViewModel = new MainWindowViewModel(Services);
desktop.MainWindow = new MainWindow
{
- DataContext = new MainWindowViewModel(),
+ DataContext = mainViewModel,
+
+ // RTL detection drives the whole window flow direction; the two
+ // supported languages are LTR, but the infrastructure stays in
+ // place for future languages.
+ FlowDirection = culture.TextInfo.IsRightToLeft
+ ? FlowDirection.RightToLeft
+ : FlowDirection.LeftToRight,
};
// Primary instance: serve launch commands forwarded by secondary instances.
- Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, command));
+ Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, mainViewModel, command));
}
base.OnFrameworkInitializationCompleted();
}
- ///
- /// Brings the main window to front for a forwarded launch command. Both
- /// ShowMain and OpenWorkspace activate the window; switching to the
- /// requested workspace lands together with the workspace UI.
- ///
- private static void HandleLaunchCommand(IClassicDesktopStyleApplicationLifetime desktop, LaunchCommand command)
+ /// Resolves the configured language to a culture, falling back to English.
+ public static CultureInfo ResolveCulture(string language)
{
- // IPC delivery happens on a background thread; window access must hop to the UI thread.
+ try
+ {
+ return new CultureInfo(language);
+ }
+ catch (CultureNotFoundException)
+ {
+ return new CultureInfo(AppConfig.DefaultLanguage);
+ }
+ }
+
+ /// Applies a theme selection immediately; Default follows the operating system.
+ public static void ApplyTheme(ThemeOption option)
+ {
+ if (Current is null)
+ {
+ return;
+ }
+ Current.RequestedThemeVariant = option switch
+ {
+ ThemeOption.Light => ThemeVariant.Light,
+ ThemeOption.Dark => ThemeVariant.Dark,
+ _ => ThemeVariant.Default,
+ };
+ }
+
+ /// Activates the window for a forwarded command; OpenWorkspace also switches to it.
+ private static void HandleLaunchCommand(
+ IClassicDesktopStyleApplicationLifetime desktop,
+ MainWindowViewModel mainViewModel,
+ LaunchCommand command)
+ {
+ // IPC delivery happens on a background thread; hop to the UI thread.
Dispatcher.UIThread.Post(() =>
{
if (desktop.MainWindow is { } window)
@@ -50,6 +104,10 @@ public partial class App : Application
window.Show();
window.Activate();
}
+ if (command is LaunchCommand.OpenWorkspace open)
+ {
+ mainViewModel.OpenWorkspaceFromLaunch(open.Path);
+ }
});
}
diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx
index d09d982..689fb72 100644
--- a/YKanBan/Assets/Locales/Resources.resx
+++ b/YKanBan/Assets/Locales/Resources.resx
@@ -51,4 +51,139 @@
| Name | Color | Description | Usage |
+
+ YKanBan
+
+
+ Yes
+
+
+ No
+
+
+ Close
+
+
+ Welcome
+
+
+ Open project…
+
+
+ Sort
+
+
+ Name A to Z
+
+
+ Name Z to A
+
+
+ Recently updated
+
+
+ Settings
+
+
+ About
+
+
+ Remove from list
+
+
+ Open or create a workspace from the left.
+
+
+ Initialize workspace?
+
+
+ There is no .ykanban structure in "{0}". Initialize it here?
+
+
+ Settings
+
+
+ Language
+
+
+ Takes effect after restart
+
+
+ English
+
+
+ 简体中文
+
+
+ Theme
+
+
+ Light
+
+
+ Dark
+
+
+ Follow system
+
+
+ Confirmations
+
+
+ Ask before deleting a card
+
+
+ Ask before deleting a column
+
+
+ Ask before deleting a tag
+
+
+ Ask before discarding card edits
+
+
+ About
+
+
+ Version {0}
+
+
+ Repository
+
+
+ (to be filled)
+
+
+ License
+
+
+ (to be filled)
+
+
+ Workspace locked
+
+
+ The workspace is locked by another instance.
+
+
+ No workspace here
+
+
+ The folder has no .ykanban structure.
+
+
+ Folder missing
+
+
+ The folder does not exist on disk.
+
+
+ Incompatible database
+
+
+ The workspace was written by a newer version.
+
+
+ Open failed
+
diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
index 326e877..b931a33 100644
--- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx
+++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
@@ -51,4 +51,139 @@
| 名称 | 颜色 | 描述 | 使用数 |
+
+ YKanBan
+
+
+ 是
+
+
+ 否
+
+
+ 关闭
+
+
+ 欢迎页
+
+
+ 打开项目…
+
+
+ 排序
+
+
+ 名称 A 到 Z
+
+
+ 名称 Z 到 A
+
+
+ 最近更新
+
+
+ 设置
+
+
+ 关于
+
+
+ 从列表移除
+
+
+ 从左侧打开或新建工作区。
+
+
+ 初始化工作区?
+
+
+ “{0}”中不存在 .ykanban 数据。在此初始化?
+
+
+ 设置
+
+
+ 语言
+
+
+ 重启后生效
+
+
+ English
+
+
+ 简体中文
+
+
+ 主题
+
+
+ 浅色
+
+
+ 深色
+
+
+ 跟随系统
+
+
+ 操作确认
+
+
+ 删除卡片前确认
+
+
+ 删除列前确认
+
+
+ 删除标签前确认
+
+
+ 放弃卡片编辑前确认
+
+
+ 关于
+
+
+ 版本 {0}
+
+
+ 仓库
+
+
+ (待填写)
+
+
+ 许可证
+
+
+ (待填写)
+
+
+ 工作区已被占用
+
+
+ 该工作区正被另一实例锁定。
+
+
+ 此处没有工作区
+
+
+ 该文件夹没有 .ykanban 数据。
+
+
+ 文件夹不存在
+
+
+ 该文件夹在磁盘上不存在。
+
+
+ 数据库版本不兼容
+
+
+ 该工作区由更新版本的程序创建。
+
+
+ 打开失败
+
diff --git a/YKanBan/Program.cs b/YKanBan/Program.cs
index 88c9c0c..d661cf5 100644
--- a/YKanBan/Program.cs
+++ b/YKanBan/Program.cs
@@ -24,6 +24,10 @@ sealed class Program
SingleInstance = singleInstance;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+
+ // Lifetime ended: persist the configuration once, then release the services.
+ App.Services.SaveConfiguration();
+ App.Services.Dispose();
}
public static AppBuilder BuildAvaloniaApp()
diff --git a/YKanBan/Services/AppServices.cs b/YKanBan/Services/AppServices.cs
new file mode 100644
index 0000000..923f518
--- /dev/null
+++ b/YKanBan/Services/AppServices.cs
@@ -0,0 +1,51 @@
+using YKanBan.Platform;
+using YKanBan.Storage.AppData;
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.Services;
+
+///
+/// Composition root: owns the application-wide configuration, the global
+/// workspace registry and the workspace connection pool for the whole
+/// process lifetime. The configuration is persisted once at shutdown.
+///
+public sealed class AppServices : IDisposable
+{
+ /// Maximum number of simultaneously open workspaces.
+ private const int PoolCapacity = 8;
+
+ /// Loads the configuration and opens the global services.
+ public AppServices()
+ {
+ ConfigStore = new AppConfigStore(AppDataPaths.AppConfigJsonPath);
+ Config = ConfigStore.Load();
+ Database = new AppDatabase(AppDataPaths.AppDatabasePath);
+ PathComparison = PathComparer.ForCurrentPlatform();
+ Pool = new WorkspaceConnectionPool(PoolCapacity, Database, PathComparison);
+ }
+
+ /// The mutable application configuration; persisted by .
+ public AppConfig Config { get; }
+
+ /// Store for app.json.
+ public AppConfigStore ConfigStore { get; }
+
+ /// Global workspace registry (app.db).
+ public AppDatabase Database { get; }
+
+ /// LRU pool of open workspace sessions.
+ public WorkspaceConnectionPool Pool { get; }
+
+ /// Platform path comparison rules.
+ public PathComparer PathComparison { get; }
+
+ /// Persists the configuration; called once at shutdown.
+ public void SaveConfiguration() => ConfigStore.Save(Config);
+
+ /// Releases the pool and the registry.
+ public void Dispose()
+ {
+ Pool.Dispose();
+ Database.Dispose();
+ }
+}
diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs
index bd9959b..43a026f 100644
--- a/YKanBan/Storage/AppData/AppConfig.cs
+++ b/YKanBan/Storage/AppData/AppConfig.cs
@@ -130,13 +130,16 @@ public sealed class AppConfig
/// Current configuration format version.
public const int CurrentFormatVersion = 1;
+ /// The default UI language code.
+ public const string DefaultLanguage = "en";
+
/// Configuration format version of this file.
[JsonProperty("version")]
public int Version { get; set; } = CurrentFormatVersion;
/// UI language code: "en" (default) or "zh-Hans".
[JsonProperty("language")]
- public string Language { get; set; } = "en";
+ public string Language { get; set; } = DefaultLanguage;
/// UI theme selection (default: follow the system).
[JsonProperty("theme")]
diff --git a/YKanBan/Storage/AppData/AppDataPaths.cs b/YKanBan/Storage/AppData/AppDataPaths.cs
new file mode 100644
index 0000000..dfe5564
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppDataPaths.cs
@@ -0,0 +1,15 @@
+namespace YKanBan.Storage.AppData;
+
+/// Well-known locations under the per-user application data folder.
+public static class AppDataPaths
+{
+ /// The per-user YKanBan application data folder.
+ public static string Directory =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "YKanBan");
+
+ /// Full path of app.json.
+ public static string AppConfigJsonPath => Path.Combine(Directory, "app.json");
+
+ /// Full path of app.db.
+ public static string AppDatabasePath => Path.Combine(Directory, "app.db");
+}
diff --git a/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs
new file mode 100644
index 0000000..7c9353c
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs
@@ -0,0 +1,26 @@
+using System.Reflection;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+/// The about dialog data: application identity and version.
+public sealed class AboutDialogViewModel : ViewModelBase
+{
+ /// Display name of the application.
+ public string ApplicationName => Resources.App_Name;
+
+ /// Localized "Version x.y.z" text.
+ public string VersionText => string.Format(
+ System.Globalization.CultureInfo.CurrentCulture,
+ Resources.About_Version,
+ AssemblyVersion);
+
+ /// Repository URL (from resources).
+ public string RepositoryUrl => Resources.About_RepositoryUrl;
+
+ /// License name (from resources).
+ public string LicenseValue => Resources.About_LicenseValue;
+
+ /// The assembly version, formatted as major.minor.patch.
+ public static string AssemblyVersion =>
+ Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
+}
diff --git a/YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs
new file mode 100644
index 0000000..1431ea2
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs
@@ -0,0 +1,25 @@
+namespace YKanBan.ViewModels.Dialogs;
+
+/// A generic confirmation dialog: localized title, message and yes/no answers.
+public sealed class ConfirmDialogViewModel : ViewModelBase
+{
+ public ConfirmDialogViewModel(string title, string message)
+ {
+ Title = title;
+ Message = message;
+ ConfirmText = Resources.Common_Yes;
+ CancelText = Resources.Common_No;
+ }
+
+ /// Localized dialog heading.
+ public string Title { get; }
+
+ /// Localized message body.
+ public string Message { get; }
+
+ /// Label of the confirming button.
+ public string ConfirmText { get; }
+
+ /// Label of the cancelling button.
+ public string CancelText { get; }
+}
diff --git a/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
new file mode 100644
index 0000000..38d68a7
--- /dev/null
+++ b/YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs
@@ -0,0 +1,107 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using YKanBan.Services;
+using YKanBan.Storage.AppData;
+
+namespace YKanBan.ViewModels.Dialogs;
+
+///
+/// The settings dialog: language (restart to apply), theme (applies
+/// immediately) and the four confirmation-dialog toggles. All changes write
+/// straight into the configuration, which persists at shutdown.
+///
+public partial class SettingsDialogViewModel : ViewModelBase
+{
+ private readonly AppConfig _config;
+ private readonly string _startupLanguage;
+
+ public SettingsDialogViewModel(AppServices services)
+ {
+ _config = services.Config;
+ _startupLanguage = App.StartupLanguage;
+
+ Languages =
+ [
+ new OptionItem(AppConfig.DefaultLanguage, Resources.Settings_LanguageEnglish),
+ new OptionItem("zh-Hans", Resources.Settings_LanguageChinese),
+ ];
+ selectedLanguage = Languages.FirstOrDefault(option => (string)option.Value == _config.Language)
+ ?? Languages[0];
+
+ Themes =
+ [
+ new OptionItem(ThemeOption.Light, Resources.Settings_ThemeLight),
+ new OptionItem(ThemeOption.Dark, Resources.Settings_ThemeDark),
+ new OptionItem(ThemeOption.FollowSystem, Resources.Settings_ThemeFollowSystem),
+ ];
+ selectedTheme = Themes.FirstOrDefault(option => option.Value.Equals(_config.Theme)) ?? Themes[^1];
+
+ confirmDeleteCard = _config.Confirmations.DeleteCard;
+ confirmDeleteColumn = _config.Confirmations.DeleteColumn;
+ confirmDeleteTag = _config.Confirmations.DeleteTag;
+ confirmDiscardEdit = _config.Confirmations.DiscardEdit;
+ }
+
+ /// Available languages.
+ public IReadOnlyList Languages { get; }
+
+ /// Available themes.
+ public IReadOnlyList Themes { get; }
+
+ /// The selected language; takes effect after a restart.
+ [ObservableProperty]
+ private OptionItem? selectedLanguage;
+
+ /// The selected theme; applies immediately.
+ [ObservableProperty]
+ private OptionItem? selectedTheme;
+
+ /// Whether deleting a card asks for confirmation.
+ [ObservableProperty]
+ private bool confirmDeleteCard;
+
+ /// Whether deleting a column asks for confirmation.
+ [ObservableProperty]
+ private bool confirmDeleteColumn;
+
+ /// Whether deleting a tag asks for confirmation.
+ [ObservableProperty]
+ private bool confirmDeleteTag;
+
+ /// Whether discarding card edits asks for confirmation.
+ [ObservableProperty]
+ private bool confirmDiscardEdit;
+
+ /// True while the selected language differs from the one applied at startup.
+ [ObservableProperty]
+ private bool restartNoticeVisible;
+
+ /// Stores the language and refreshes the restart notice.
+ partial void OnSelectedLanguageChanged(OptionItem? value)
+ {
+ if (value is null)
+ {
+ return;
+ }
+ _config.Language = (string)value.Value;
+ RestartNoticeVisible = _config.Language != _startupLanguage;
+ }
+
+ /// Stores the theme and applies it immediately.
+ partial void OnSelectedThemeChanged(OptionItem? value)
+ {
+ if (value is null)
+ {
+ return;
+ }
+ _config.Theme = (ThemeOption)value.Value;
+ App.ApplyTheme(_config.Theme);
+ }
+
+ partial void OnConfirmDeleteCardChanged(bool value) => _config.Confirmations.DeleteCard = value;
+
+ partial void OnConfirmDeleteColumnChanged(bool value) => _config.Confirmations.DeleteColumn = value;
+
+ partial void OnConfirmDeleteTagChanged(bool value) => _config.Confirmations.DeleteTag = value;
+
+ partial void OnConfirmDiscardEditChanged(bool value) => _config.Confirmations.DiscardEdit = value;
+}
diff --git a/YKanBan/ViewModels/Interaction.cs b/YKanBan/ViewModels/Interaction.cs
new file mode 100644
index 0000000..7ea76a8
--- /dev/null
+++ b/YKanBan/ViewModels/Interaction.cs
@@ -0,0 +1,26 @@
+namespace YKanBan.ViewModels;
+
+/// Input placeholder for interactions that carry no meaningful input.
+public readonly record struct Unit;
+
+///
+/// A view-model side request that the owning view fulfils through a
+/// registered handler (dialogs, pickers) — the classic MVVM interaction
+/// pattern, implemented locally without extra dependencies.
+///
+/// The request payload.
+/// The handler's answer.
+public sealed class Interaction
+{
+ private Func>? _handler;
+
+ /// Registers the single handler; the view calls this once.
+ public void RegisterHandler(Func> handler) => _handler = handler;
+
+ /// Invokes the registered handler.
+ /// No handler has been registered.
+ public Task Handle(TInput input) =>
+ _handler is null
+ ? throw new InvalidOperationException("No handler registered for this interaction.")
+ : _handler(input);
+}
diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs
index dd8a2fd..a3c0a99 100644
--- a/YKanBan/ViewModels/MainWindowViewModel.cs
+++ b/YKanBan/ViewModels/MainWindowViewModel.cs
@@ -1,5 +1,103 @@
-namespace YKanBan.ViewModels;
+using CommunityToolkit.Mvvm.ComponentModel;
+using YKanBan.Services;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.ViewModels.Pages;
+namespace YKanBan.ViewModels;
+
+///
+/// Root view model: owns the sidebar and the current content page (welcome,
+/// an open workspace, or a failure state page).
+///
public partial class MainWindowViewModel : ViewModelBase
{
+ public MainWindowViewModel(AppServices services)
+ {
+ Services = services;
+ Sidebar = new SidebarViewModel(this, services);
+ CurrentContent = new WelcomePageViewModel();
+ }
+
+ /// The composition root.
+ public AppServices Services { get; }
+
+ /// The workspace sidebar.
+ public SidebarViewModel Sidebar { get; }
+
+ /// The currently displayed content page.
+ [ObservableProperty]
+ private object? currentContent;
+
+ /// Shows the welcome page (no workspace active).
+ public void ShowWelcome()
+ {
+ CurrentContent = new WelcomePageViewModel();
+ }
+
+ ///
+ /// Opens (or switches to) a workspace from the UI flow: a plain folder
+ /// prompts for initialization before opening.
+ ///
+ public async Task OpenWorkspaceWithInitializePromptAsync(string folderPath)
+ {
+ string canonical = Services.PathComparison.Normalize(folderPath);
+
+ if (!WorkspaceInitializer.IsWorkspace(canonical))
+ {
+ if (!Directory.Exists(canonical))
+ {
+ ShowFailure(Resources.State_DirectoryMissing_Title, Resources.State_DirectoryMissing_Detail);
+ return;
+ }
+
+ bool confirmed = await Sidebar.ConfirmInitialize.Handle(canonical);
+ if (!confirmed)
+ {
+ return;
+ }
+ WorkspaceInitializer.Initialize(canonical);
+ WorkspacePreset.SeedColumns(canonical);
+ }
+
+ TryOpenWorkspaceSession(canonical);
+ }
+
+ /// Opens (or switches to) a workspace without any initialization prompt (IPC entry).
+ public void OpenWorkspaceFromLaunch(string folderPath)
+ {
+ TryOpenWorkspaceSession(Services.PathComparison.Normalize(folderPath));
+ }
+
+ /// Opens the session through the pool; typed failures map to localized state pages.
+ public void TryOpenWorkspaceSession(string canonicalPath)
+ {
+ try
+ {
+ WorkspaceSession session = Services.Pool.Open(canonicalPath);
+ CurrentContent = new WorkspacePageViewModel(session);
+ Sidebar.RebuildWorkspaceList();
+ }
+ catch (Exception ex)
+ {
+ (string title, string detail) = DescribeOpenFailure(ex);
+ ShowFailure(title, detail);
+ }
+ }
+
+ /// Displays a localized failure state page.
+ private void ShowFailure(string title, string detail)
+ {
+ CurrentContent = new MessagePageViewModel(title, detail);
+ }
+
+ /// Maps an open failure to localized title/detail text by exception type.
+ private static (string Title, string Detail) DescribeOpenFailure(Exception exception) => exception switch
+ {
+ WorkspaceLockException => (Resources.State_LockConflict_Title, Resources.State_LockConflict_Detail),
+ WorkspaceNotInitializedException => (Resources.State_NotInitialized_Title, Resources.State_NotInitialized_Detail),
+ WorkspaceDirectoryMissingException => (Resources.State_DirectoryMissing_Title, Resources.State_DirectoryMissing_Detail),
+ SchemaVersionException => (Resources.State_SchemaVersion_Title, Resources.State_SchemaVersion_Detail),
+ _ => (Resources.State_Unknown_Title, exception.Message),
+ };
}
diff --git a/YKanBan/ViewModels/OptionItem.cs b/YKanBan/ViewModels/OptionItem.cs
new file mode 100644
index 0000000..7cbdbf1
--- /dev/null
+++ b/YKanBan/ViewModels/OptionItem.cs
@@ -0,0 +1,6 @@
+namespace YKanBan.ViewModels;
+
+/// A selectable option item for combo boxes: a value plus its localized label.
+/// The backing value (enum value or language code).
+/// The localized display label.
+public sealed record OptionItem(object Value, string Label);
diff --git a/YKanBan/ViewModels/Pages/MessagePageViewModel.cs b/YKanBan/ViewModels/Pages/MessagePageViewModel.cs
new file mode 100644
index 0000000..23de102
--- /dev/null
+++ b/YKanBan/ViewModels/Pages/MessagePageViewModel.cs
@@ -0,0 +1,17 @@
+namespace YKanBan.ViewModels.Pages;
+
+/// A failure state page: a localized title with a detail line.
+public sealed class MessagePageViewModel : ViewModelBase
+{
+ public MessagePageViewModel(string title, string detail)
+ {
+ Title = title;
+ Detail = detail;
+ }
+
+ /// Localized failure heading.
+ public string Title { get; }
+
+ /// Localized failure explanation.
+ public string Detail { get; }
+}
diff --git a/YKanBan/ViewModels/Pages/WelcomePageViewModel.cs b/YKanBan/ViewModels/Pages/WelcomePageViewModel.cs
new file mode 100644
index 0000000..65569d5
--- /dev/null
+++ b/YKanBan/ViewModels/Pages/WelcomePageViewModel.cs
@@ -0,0 +1,6 @@
+namespace YKanBan.ViewModels.Pages;
+
+/// The welcome page: application identity plus a hint to open a workspace.
+public sealed class WelcomePageViewModel : ViewModelBase
+{
+}
diff --git a/YKanBan/ViewModels/Pages/WorkspacePageViewModel.cs b/YKanBan/ViewModels/Pages/WorkspacePageViewModel.cs
new file mode 100644
index 0000000..ebd6ad3
--- /dev/null
+++ b/YKanBan/ViewModels/Pages/WorkspacePageViewModel.cs
@@ -0,0 +1,22 @@
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.ViewModels.Pages;
+
+///
+/// An open workspace page. The board, tags and search views mount here in
+/// the later UI milestones; the frame shows the workspace identity for now.
+///
+public sealed class WorkspacePageViewModel : ViewModelBase
+{
+ public WorkspacePageViewModel(WorkspaceSession session)
+ {
+ FullPath = session.FolderPath;
+ Name = Path.GetFileName(session.FolderPath);
+ }
+
+ /// Canonical workspace folder path.
+ public string FullPath { get; }
+
+ /// Display name: the folder name.
+ public string Name { get; }
+}
diff --git a/YKanBan/ViewModels/SidebarViewModel.cs b/YKanBan/ViewModels/SidebarViewModel.cs
new file mode 100644
index 0000000..6ed5697
--- /dev/null
+++ b/YKanBan/ViewModels/SidebarViewModel.cs
@@ -0,0 +1,140 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using YKanBan.Services;
+using YKanBan.Storage.AppData;
+using YKanBan.Storage.Workspace;
+using YKanBan.ViewModels.Pages;
+
+namespace YKanBan.ViewModels;
+
+///
+/// The sidebar: welcome and open-project buttons, the persistent workspace
+/// list with its sort options, and the settings/about entries at the bottom.
+///
+public partial class SidebarViewModel : ViewModelBase
+{
+ private readonly MainWindowViewModel _owner;
+ private readonly AppServices _services;
+
+ public SidebarViewModel(MainWindowViewModel owner, AppServices services)
+ {
+ _owner = owner;
+ _services = services;
+
+ SortOptions =
+ [
+ new OptionItem(SidebarSortOption.NameAsc, Resources.Sidebar_Sort_NameAsc),
+ new OptionItem(SidebarSortOption.NameDesc, Resources.Sidebar_Sort_NameDesc),
+ new OptionItem(SidebarSortOption.RecentlyUpdated, Resources.Sidebar_Sort_RecentlyUpdated),
+ ];
+ selectedSort = SortOptions.Single(option => option.Value.Equals(services.Config.Sort.Sidebar));
+
+ RebuildWorkspaceList();
+ }
+
+ /// Known workspaces in display order.
+ public ObservableCollection Workspaces { get; } = [];
+
+ /// The available list sort modes.
+ public IReadOnlyList SortOptions { get; }
+
+ /// Asks the view for a folder via the system folder picker; null when cancelled.
+ public Interaction PickFolder { get; } = new();
+
+ /// Asks the view to confirm initializing a plain folder as a workspace.
+ public Interaction ConfirmInitialize { get; } = new();
+ /// The selected list sort mode; persisted with the configuration.
+ [ObservableProperty]
+ private OptionItem? selectedSort;
+
+ /// Shows the welcome page.
+ [RelayCommand]
+ private void ShowWelcome()
+ {
+ _owner.ShowWelcome();
+ }
+
+ /// Picks a folder and opens it as a workspace (offering initialization).
+ [RelayCommand]
+ private async Task OpenProjectAsync()
+ {
+ string? folder = await PickFolder.Handle(default);
+ if (folder is null)
+ {
+ return;
+ }
+ await _owner.OpenWorkspaceWithInitializePromptAsync(folder);
+ }
+
+ /// Rebuilds the list from the persistent registry, marking open workspaces.
+ public void RebuildWorkspaceList()
+ {
+ Workspaces.Clear();
+ foreach (WorkspaceRecord record in _services.Database.GetWorkspaces())
+ {
+ bool isOpen = _services.Pool.Find(record.Path) is not null;
+ Workspaces.Add(new WorkspaceItemViewModel(this, record.Path, Path.GetFileName(record.Path), record.LastEditedAt, isOpen));
+ }
+ ReorderWorkspaceList();
+ }
+
+ /// Opens a workspace when its sidebar entry is activated.
+ public void OpenWorkspace(WorkspaceItemViewModel item)
+ {
+ _owner.TryOpenWorkspaceSession(item.FullPath);
+ }
+
+ /// Releases the workspace resources and forgets its sidebar entry.
+ public void RemoveWorkspace(WorkspaceItemViewModel item)
+ {
+ _services.Pool.Remove(item.FullPath);
+ RebuildWorkspaceList();
+
+ // Removing the active workspace falls back to the welcome page.
+ if (_owner.CurrentContent is WorkspacePageViewModel active && active.FullPath == item.FullPath)
+ {
+ _owner.ShowWelcome();
+ }
+ }
+
+ /// Applies the selected sort mode to the list in place.
+ partial void OnSelectedSortChanged(OptionItem? value)
+ {
+ if (value is null)
+ {
+ return;
+ }
+ _services.Config.Sort.Sidebar = (SidebarSortOption)value.Value;
+ ReorderWorkspaceList();
+ }
+
+ /// Sorts the workspace collection by the current mode.
+ private void ReorderWorkspaceList()
+ {
+ if (SelectedSort?.Value is not SidebarSortOption mode)
+ {
+ return;
+ }
+
+ Comparison comparison = mode switch
+ {
+ SidebarSortOption.NameAsc => (a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase),
+ SidebarSortOption.NameDesc => (a, b) => string.Compare(b.Name, a.Name, StringComparison.OrdinalIgnoreCase),
+ SidebarSortOption.RecentlyUpdated => (a, b) => b.LastEditedAt.CompareTo(a.LastEditedAt),
+ _ => (a, b) => 0,
+ };
+
+ // Move each item into its sorted position (in-place keeps bindings alive).
+ List ordered = Workspaces.ToList();
+ ordered.Sort(comparison);
+ for (int index = 0; index < ordered.Count; index++)
+ {
+ int current = Workspaces.IndexOf(ordered[index]);
+ if (current != index)
+ {
+ Workspaces.Move(current, index);
+ }
+ }
+ }
+}
diff --git a/YKanBan/ViewModels/WorkspaceItemViewModel.cs b/YKanBan/ViewModels/WorkspaceItemViewModel.cs
new file mode 100644
index 0000000..203afe4
--- /dev/null
+++ b/YKanBan/ViewModels/WorkspaceItemViewModel.cs
@@ -0,0 +1,46 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace YKanBan.ViewModels;
+
+/// One sidebar workspace entry.
+public partial class WorkspaceItemViewModel : ViewModelBase
+{
+ private readonly SidebarViewModel _owner;
+
+ public WorkspaceItemViewModel(SidebarViewModel owner, string fullPath, string name, long lastEditedAt, bool isOpen)
+ {
+ _owner = owner;
+ FullPath = fullPath;
+ Name = name;
+ LastEditedAt = lastEditedAt;
+ IsOpen = isOpen;
+ }
+
+ /// Canonical workspace folder path.
+ public string FullPath { get; }
+
+ /// Display name: the folder name.
+ public string Name { get; }
+
+ /// Last edit time in Unix seconds, used by the "recently updated" sort.
+ public long LastEditedAt { get; }
+
+ /// Whether the workspace currently has an open session.
+ [ObservableProperty]
+ private bool isOpen;
+
+ /// Opens (or switches to) this workspace.
+ [RelayCommand]
+ private void Open()
+ {
+ _owner.OpenWorkspace(this);
+ }
+
+ /// Releases the workspace resources and removes the sidebar entry.
+ [RelayCommand]
+ private void Remove()
+ {
+ _owner.RemoveWorkspace(this);
+ }
+}
diff --git a/YKanBan/Views/Dialogs/AboutDialog.axaml b/YKanBan/Views/Dialogs/AboutDialog.axaml
new file mode 100644
index 0000000..7d55c33
--- /dev/null
+++ b/YKanBan/Views/Dialogs/AboutDialog.axaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/AboutDialog.axaml.cs b/YKanBan/Views/Dialogs/AboutDialog.axaml.cs
new file mode 100644
index 0000000..b753b73
--- /dev/null
+++ b/YKanBan/Views/Dialogs/AboutDialog.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class AboutDialog : Window
+{
+ public AboutDialog()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/Dialogs/ConfirmDialog.axaml b/YKanBan/Views/Dialogs/ConfirmDialog.axaml
new file mode 100644
index 0000000..3414a62
--- /dev/null
+++ b/YKanBan/Views/Dialogs/ConfirmDialog.axaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/ConfirmDialog.axaml.cs b/YKanBan/Views/Dialogs/ConfirmDialog.axaml.cs
new file mode 100644
index 0000000..4f2043f
--- /dev/null
+++ b/YKanBan/Views/Dialogs/ConfirmDialog.axaml.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class ConfirmDialog : Window
+{
+ public ConfirmDialog()
+ {
+ InitializeComponent();
+ }
+
+ private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
+
+ private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/YKanBan/Views/Dialogs/SettingsDialog.axaml b/YKanBan/Views/Dialogs/SettingsDialog.axaml
new file mode 100644
index 0000000..8cc725c
--- /dev/null
+++ b/YKanBan/Views/Dialogs/SettingsDialog.axaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
new file mode 100644
index 0000000..54c6561
--- /dev/null
+++ b/YKanBan/Views/Dialogs/SettingsDialog.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.Dialogs;
+
+public partial class SettingsDialog : Window
+{
+ public SettingsDialog()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/MainWindow.axaml b/YKanBan/Views/MainWindow.axaml
index 3f92fd5..2c46e5c 100644
--- a/YKanBan/Views/MainWindow.axaml
+++ b/YKanBan/Views/MainWindow.axaml
@@ -1,15 +1,107 @@
+ Icon="/Assets/avalonia-logo.ico"
+ Title="{x:Static lang:Resources.App_Name}"
+ Width="1000" Height="640" MinWidth="720" MinHeight="480">
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/YKanBan/Views/MainWindow.axaml.cs b/YKanBan/Views/MainWindow.axaml.cs
index d50dd51..c3c95df 100644
--- a/YKanBan/Views/MainWindow.axaml.cs
+++ b/YKanBan/Views/MainWindow.axaml.cs
@@ -1,11 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using YKanBan.ViewModels;
+using YKanBan.ViewModels.Dialogs;
+using YKanBan.Views.Dialogs;
+using Resx = YKanBan.Resources;
namespace YKanBan.Views;
public partial class MainWindow : Window
{
+ private bool _interactionsRegistered;
+
public MainWindow()
{
InitializeComponent();
}
-}
\ No newline at end of file
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is MainWindowViewModel viewModel && !_interactionsRegistered)
+ {
+ _interactionsRegistered = true;
+ RegisterInteractionHandlers(viewModel);
+ }
+ }
+
+ /// Wires the view model's dialog interactions to this window.
+ private void RegisterInteractionHandlers(MainWindowViewModel viewModel)
+ {
+ viewModel.Sidebar.PickFolder.RegisterHandler(async _ =>
+ {
+ IReadOnlyList folders = await StorageProvider.OpenFolderPickerAsync(
+ new FolderPickerOpenOptions
+ {
+ Title = Resx.Sidebar_OpenProject,
+ AllowMultiple = false,
+ });
+ return folders.Count == 1 ? folders[0].TryGetLocalPath() : null;
+ });
+
+ viewModel.Sidebar.ConfirmInitialize.RegisterHandler(async folderPath =>
+ {
+ var dialog = new ConfirmDialog
+ {
+ DataContext = new ConfirmDialogViewModel(
+ Resx.Dialog_InitializeTitle,
+ string.Format(CultureInfo.CurrentCulture, Resx.Dialog_InitializeMessage, folderPath)),
+ };
+ return await dialog.ShowDialog(this);
+ });
+ }
+
+ private async void OnSettingsClick(object? sender, RoutedEventArgs e)
+ {
+ var dialog = new SettingsDialog
+ {
+ DataContext = new SettingsDialogViewModel(App.Services),
+ };
+ await dialog.ShowDialog(this);
+ }
+
+ private async void OnAboutClick(object? sender, RoutedEventArgs e)
+ {
+ var dialog = new AboutDialog
+ {
+ DataContext = new AboutDialogViewModel(),
+ };
+ await dialog.ShowDialog(this);
+ }
+}
diff --git a/YKanBan/Views/Pages/MessagePage.axaml b/YKanBan/Views/Pages/MessagePage.axaml
new file mode 100644
index 0000000..1d58901
--- /dev/null
+++ b/YKanBan/Views/Pages/MessagePage.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/YKanBan/Views/Pages/MessagePage.axaml.cs b/YKanBan/Views/Pages/MessagePage.axaml.cs
new file mode 100644
index 0000000..b626c74
--- /dev/null
+++ b/YKanBan/Views/Pages/MessagePage.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.Pages;
+
+public partial class MessagePage : UserControl
+{
+ public MessagePage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/Pages/WelcomePage.axaml b/YKanBan/Views/Pages/WelcomePage.axaml
new file mode 100644
index 0000000..b4eead4
--- /dev/null
+++ b/YKanBan/Views/Pages/WelcomePage.axaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/YKanBan/Views/Pages/WelcomePage.axaml.cs b/YKanBan/Views/Pages/WelcomePage.axaml.cs
new file mode 100644
index 0000000..f771a9e
--- /dev/null
+++ b/YKanBan/Views/Pages/WelcomePage.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.Pages;
+
+public partial class WelcomePage : UserControl
+{
+ public WelcomePage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/Pages/WorkspacePage.axaml b/YKanBan/Views/Pages/WorkspacePage.axaml
new file mode 100644
index 0000000..a6dc5fa
--- /dev/null
+++ b/YKanBan/Views/Pages/WorkspacePage.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/YKanBan/Views/Pages/WorkspacePage.axaml.cs b/YKanBan/Views/Pages/WorkspacePage.axaml.cs
new file mode 100644
index 0000000..60ed88a
--- /dev/null
+++ b/YKanBan/Views/Pages/WorkspacePage.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YKanBan.Views.Pages;
+
+public partial class WorkspacePage : UserControl
+{
+ public WorkspacePage()
+ {
+ InitializeComponent();
+ }
+}