From 62a3c96dc05582f57fa07852b921875f1810e0bb Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Fri, 2 Oct 2026 18:02:22 +0800 Subject: [PATCH] feat(m5): gui shell - AppServices composition root: owns config, global workspace registry (app.db), platform path comparer and the LRU workspace connection pool; configuration persisted once at shutdown and services disposed - AppDataPaths centralizes app.json / app.db locations; AppConfig gains a DefaultLanguage constant - MVVM shell: MainWindowViewModel hosts SidebarViewModel and the current content page (welcome / workspace / localized failure state), mapping typed open failures to ResX messages - SidebarViewModel: persistent workspace list with name-asc, name-desc and recently-updated sorting (selection persisted), open-project folder picker and initialize confirmation via Interaction handlers registered by the view; workspace entries open/remove - Pages (welcome, workspace, message), dialogs (settings, about, confirm) with their view models; settings/about opened from the window - App startup applies configured culture (RTL flow direction) and theme before the main window; forwarded IPC commands activate the window and switch workspace from the launch path - Full UI text sourced from ResX in both en and zh-Hans; no hardcoded strings, theme colors via ThemeDictionaries --- YKanBan/App.axaml | 20 ++- YKanBan/App.axaml.cs | 78 ++++++++-- YKanBan/Assets/Locales/Resources.resx | 135 +++++++++++++++++ YKanBan/Assets/Locales/Resources.zh-Hans.resx | 135 +++++++++++++++++ YKanBan/Program.cs | 4 + YKanBan/Services/AppServices.cs | 51 +++++++ YKanBan/Storage/AppData/AppConfig.cs | 5 +- YKanBan/Storage/AppData/AppDataPaths.cs | 15 ++ .../Dialogs/AboutDialogViewModel.cs | 26 ++++ .../Dialogs/ConfirmDialogViewModel.cs | 25 ++++ .../Dialogs/SettingsDialogViewModel.cs | 107 +++++++++++++ YKanBan/ViewModels/Interaction.cs | 26 ++++ YKanBan/ViewModels/MainWindowViewModel.cs | 100 ++++++++++++- YKanBan/ViewModels/OptionItem.cs | 6 + .../ViewModels/Pages/MessagePageViewModel.cs | 17 +++ .../ViewModels/Pages/WelcomePageViewModel.cs | 6 + .../Pages/WorkspacePageViewModel.cs | 22 +++ YKanBan/ViewModels/SidebarViewModel.cs | 140 ++++++++++++++++++ YKanBan/ViewModels/WorkspaceItemViewModel.cs | 46 ++++++ YKanBan/Views/Dialogs/AboutDialog.axaml | 30 ++++ YKanBan/Views/Dialogs/AboutDialog.axaml.cs | 11 ++ YKanBan/Views/Dialogs/ConfirmDialog.axaml | 19 +++ YKanBan/Views/Dialogs/ConfirmDialog.axaml.cs | 16 ++ YKanBan/Views/Dialogs/SettingsDialog.axaml | 50 +++++++ YKanBan/Views/Dialogs/SettingsDialog.axaml.cs | 11 ++ YKanBan/Views/MainWindow.axaml | 102 ++++++++++++- YKanBan/Views/MainWindow.axaml.cs | 67 ++++++++- YKanBan/Views/Pages/MessagePage.axaml | 10 ++ YKanBan/Views/Pages/MessagePage.axaml.cs | 11 ++ YKanBan/Views/Pages/WelcomePage.axaml | 11 ++ YKanBan/Views/Pages/WelcomePage.axaml.cs | 11 ++ YKanBan/Views/Pages/WorkspacePage.axaml | 10 ++ YKanBan/Views/Pages/WorkspacePage.axaml.cs | 11 ++ 33 files changed, 1315 insertions(+), 19 deletions(-) create mode 100644 YKanBan/Services/AppServices.cs create mode 100644 YKanBan/Storage/AppData/AppDataPaths.cs create mode 100644 YKanBan/ViewModels/Dialogs/AboutDialogViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/ConfirmDialogViewModel.cs create mode 100644 YKanBan/ViewModels/Dialogs/SettingsDialogViewModel.cs create mode 100644 YKanBan/ViewModels/Interaction.cs create mode 100644 YKanBan/ViewModels/OptionItem.cs create mode 100644 YKanBan/ViewModels/Pages/MessagePageViewModel.cs create mode 100644 YKanBan/ViewModels/Pages/WelcomePageViewModel.cs create mode 100644 YKanBan/ViewModels/Pages/WorkspacePageViewModel.cs create mode 100644 YKanBan/ViewModels/SidebarViewModel.cs create mode 100644 YKanBan/ViewModels/WorkspaceItemViewModel.cs create mode 100644 YKanBan/Views/Dialogs/AboutDialog.axaml create mode 100644 YKanBan/Views/Dialogs/AboutDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/ConfirmDialog.axaml create mode 100644 YKanBan/Views/Dialogs/ConfirmDialog.axaml.cs create mode 100644 YKanBan/Views/Dialogs/SettingsDialog.axaml create mode 100644 YKanBan/Views/Dialogs/SettingsDialog.axaml.cs create mode 100644 YKanBan/Views/Pages/MessagePage.axaml create mode 100644 YKanBan/Views/Pages/MessagePage.axaml.cs create mode 100644 YKanBan/Views/Pages/WelcomePage.axaml create mode 100644 YKanBan/Views/Pages/WelcomePage.axaml.cs create mode 100644 YKanBan/Views/Pages/WorkspacePage.axaml create mode 100644 YKanBan/Views/Pages/WorkspacePage.axaml.cs 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/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(); + } +}