From 6ea0df43e378769540d8d0940a5010bd8d2cc4ac Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 07:34:34 -0400 Subject: [PATCH] feat: implement tags tab, board search execution and markdown export wiring --- .../TestUtilities/ScriptedDialogService.cs | 50 +++ .../ViewModels/BoardViewModelTests.cs | 44 +- .../ViewModels/TagsAndSearchViewModelTests.cs | 400 ++++++++++++++++++ YKanBan/Assets/Locales/Resources.resx | 48 +++ YKanBan/Assets/Locales/Resources.zh-Hans.resx | 48 +++ YKanBan/ViewModels/Board/BoardViewModel.cs | 80 +++- YKanBan/ViewModels/Dialogs/IDialogService.cs | 7 + YKanBan/ViewModels/Dialogs/TagColors.cs | 26 ++ .../Dialogs/TagEditorDialogViewModel.cs | 146 +++++++ .../Dialogs/TagPickerDialogViewModel.cs | 11 +- YKanBan/ViewModels/Tags/TagRowViewModel.cs | 77 ++++ YKanBan/ViewModels/Tags/TagsViewModel.cs | 144 +++++++ YKanBan/ViewModels/WorkspaceViewModel.cs | 48 ++- YKanBan/Views/Board/BoardView.axaml | 71 +++- YKanBan/Views/DialogService.cs | 22 + .../Views/Dialogs/ColumnEditorDialog.axaml | 2 +- YKanBan/Views/Dialogs/TagEditorDialog.axaml | 61 +++ .../Views/Dialogs/TagEditorDialog.axaml.cs | 9 + YKanBan/Views/Tags/TagsView.axaml | 76 ++++ YKanBan/Views/Tags/TagsView.axaml.cs | 11 + YKanBan/Views/WorkspaceView.axaml | 6 +- docs/PLAN.md | 2 +- 22 files changed, 1300 insertions(+), 89 deletions(-) create mode 100644 YKanBan.Tests/TestUtilities/ScriptedDialogService.cs create mode 100644 YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs create mode 100644 YKanBan/ViewModels/Dialogs/TagColors.cs create mode 100644 YKanBan/ViewModels/Dialogs/TagEditorDialogViewModel.cs create mode 100644 YKanBan/ViewModels/Tags/TagRowViewModel.cs create mode 100644 YKanBan/ViewModels/Tags/TagsViewModel.cs create mode 100644 YKanBan/Views/Dialogs/TagEditorDialog.axaml create mode 100644 YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs create mode 100644 YKanBan/Views/Tags/TagsView.axaml create mode 100644 YKanBan/Views/Tags/TagsView.axaml.cs diff --git a/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs b/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs new file mode 100644 index 0000000..8562ca2 --- /dev/null +++ b/YKanBan.Tests/TestUtilities/ScriptedDialogService.cs @@ -0,0 +1,50 @@ +using YKanBan.ViewModels.Dialogs; + +namespace YKanBan.Tests.TestUtilities; + +/// +/// Records every dialog shown and lets a test script answer it. The result +/// is whatever the dialog closed with; a dialog the script leaves open +/// counts as dismissed. The save-file dialog answers with +/// . +/// +internal sealed class ScriptedDialogService : IDialogService +{ + public List Shown { get; } = []; + + public Func Script { get; set; } = _ => Task.CompletedTask; + + public string? SaveFilePath { get; set; } + + public List SuggestedFileNames { get; } = []; + + public async Task ShowAsync(DialogViewModelBase dialog) + { + Shown.Add(dialog); + bool? result = null; + dialog.CloseRequested += (_, closed) => result ??= closed; + await Script(dialog); + return result ?? false; + } + + public Task PickSaveFilePathAsync(string suggestedFileName) + { + SuggestedFileNames.Add(suggestedFileName); + return Task.FromResult(SaveFilePath); + } + + /// Answers every confirmation dialog with the given choice. + public static Func AnswerConfirmations(bool confirm) => dialog => + { + if (dialog is ConfirmDialogViewModel confirmation) + { + if (confirm) + { + confirmation.ConfirmCommand.Execute(null); + return Task.CompletedTask; + } + return confirmation.CancelCommand.ExecuteAsync(null); + } + return Task.CompletedTask; + }; +} diff --git a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs index 5958e89..123bc92 100644 --- a/YKanBan.Tests/ViewModels/BoardViewModelTests.cs +++ b/YKanBan.Tests/ViewModels/BoardViewModelTests.cs @@ -16,42 +16,6 @@ namespace YKanBan.Tests.ViewModels; [TestClass] public class BoardViewModelTests { - /// - /// Records every dialog shown and lets a test script answer it. The result - /// is whatever the dialog closed with; a dialog the script leaves open - /// counts as dismissed. - /// - private sealed class ScriptedDialogService : IDialogService - { - public List Shown { get; } = []; - - public Func Script { get; set; } = _ => Task.CompletedTask; - - public async Task ShowAsync(DialogViewModelBase dialog) - { - Shown.Add(dialog); - bool? result = null; - dialog.CloseRequested += (_, closed) => result ??= closed; - await Script(dialog); - return result ?? false; - } - } - - /// Answers every confirmation dialog with the given choice. - private static Func AnswerConfirmations(bool confirm) => dialog => - { - if (dialog is ConfirmDialogViewModel confirmation) - { - if (confirm) - { - confirmation.ConfirmCommand.Execute(null); - return Task.CompletedTask; - } - return confirmation.CancelCommand.ExecuteAsync(null); - } - return Task.CompletedTask; - }; - private sealed class Fixture : IDisposable { public TempDirectory Directory { get; } = new(); @@ -144,7 +108,7 @@ public class BoardViewModelTests fixture.Repository.AddCard(column.Id, "", "", []); fixture.Repository.AddCard(column.Id, "", "", []); BoardViewModel board = fixture.CreateBoard(); - fixture.Dialogs.Script = AnswerConfirmations(false); + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false); await board.Columns[0].DeleteCommand.ExecuteAsync(null); @@ -174,7 +138,7 @@ public class BoardViewModelTests ColumnModel column = fixture.Repository.AddColumn("A", ""); fixture.Repository.AddCard(column.Id, "gone", "", []); BoardViewModel board = fixture.CreateBoard(); - fixture.Dialogs.Script = AnswerConfirmations(true); + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true); await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null); @@ -271,11 +235,11 @@ public class BoardViewModelTests editor.CloseRequested += (_, result) => closedWith = result; editor.Content = "draft"; - fixture.Dialogs.Script = AnswerConfirmations(false); + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false); await editor.RequestCancelAsync(); Assert.IsNull(closedWith, "Declining the discard keeps the editor open."); - fixture.Dialogs.Script = AnswerConfirmations(true); + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true); await editor.RequestCancelAsync(); Assert.IsFalse(closedWith); Assert.AreEqual(0, fixture.Repository.GetCards().Count); diff --git a/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs b/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs new file mode 100644 index 0000000..7b56cb2 --- /dev/null +++ b/YKanBan.Tests/ViewModels/TagsAndSearchViewModelTests.cs @@ -0,0 +1,400 @@ +using YKanBan.Models; +using YKanBan.Storage.AppData; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; +using YKanBan.ViewModels; +using YKanBan.ViewModels.Board; +using YKanBan.ViewModels.Dialogs; +using YKanBan.ViewModels.Tags; + +namespace YKanBan.Tests.ViewModels; + +/// +/// View-model tests of the search bar, the tags tab, the cross-tab refresh and +/// the export wiring, against a real temporary workspace with a scripted +/// dialog service. +/// +[TestClass] +public class TagsAndSearchViewModelTests +{ + private sealed class Fixture : IDisposable + { + public TempDirectory Directory { get; } = new(); + + public WorkspaceSession Session { get; } + + public WorkspaceRepository Repository => Session.Repository; + + public AppConfig Config { get; } = new(); + + public ScriptedDialogService Dialogs { get; } = new(); + + public Fixture() + { + WorkspaceInitializer.Initialize(Directory.FullPath); + Session = WorkspaceSession.Open(Directory.FullPath); + } + + public BoardViewModel CreateBoard() => new(Repository, Config, Dialogs); + + public TagsViewModel CreateTags() => new(Repository, Config, Dialogs); + + public WorkspaceViewModel CreateWorkspace() => + new(Session, new Interaction(), new Interaction(), Config, Dialogs); + + public void Dispose() + { + Session.Dispose(); + Directory.Dispose(); + } + } + + private static string[] VisibleTitles(BoardViewModel board) => + board.Columns.SelectMany(column => column.Cards).Select(card => card.Title).Order(StringComparer.Ordinal).ToArray(); + + /// Moves a visible card through its menu command. + private static Task MoveAsync(BoardViewModel board, long cardId, long columnId) => + board.Columns.SelectMany(column => column.Cards).Single(card => card.Card.Id == cardId) + .MoveToCommand.ExecuteAsync(columnId); + + #region Search + + [TestMethod] + public async Task SearchFiltersCardsAndKeepsEmptyColumns() + { + using var fixture = new Fixture(); + ColumnModel first = fixture.Repository.AddColumn("A", ""); + ColumnModel second = fixture.Repository.AddColumn("B", ""); + TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), ""); + fixture.Repository.AddCard(first.Id, "crash", "", [bug.Id]); + fixture.Repository.AddCard(second.Id, "idea", "", []); + BoardViewModel board = fixture.CreateBoard(); + + board.SearchText = "tag:BUG"; + await board.SearchCommand.ExecuteAsync(null); + + CollectionAssert.AreEqual(new[] { "crash" }, VisibleTitles(board)); + Assert.AreEqual(2, board.Columns.Count); + Assert.AreEqual(0, board.Columns[1].CardCount); + Assert.IsNotNull(board.ActiveFilter); + Assert.AreEqual(0, fixture.Dialogs.Shown.Count); + } + + [TestMethod] + public async Task InvalidSearchShowsTheErrorAndKeepsTextAndPreviousResult() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "alpha", "", []); + fixture.Repository.AddCard(column.Id, "beta", "", []); + BoardViewModel board = fixture.CreateBoard(); + board.SearchText = "alpha"; + await board.SearchCommand.ExecuteAsync(null); + + board.SearchText = "alpha AND"; + await board.SearchCommand.ExecuteAsync(null); + + var error = (MessageDialogViewModel)fixture.Dialogs.Shown.Single(); + Assert.AreEqual(Resources.Search_Error_Title, error.Title); + Assert.AreEqual("alpha AND", board.SearchText); + CollectionAssert.AreEqual(new[] { "alpha" }, VisibleTitles(board)); + } + + [TestMethod] + public async Task WritesRefreshWithTheLastSuccessfulExpressionNotTheBoxText() + { + using var fixture = new Fixture(); + ColumnModel first = fixture.Repository.AddColumn("A", ""); + ColumnModel second = fixture.Repository.AddColumn("B", ""); + CardModel alpha = fixture.Repository.AddCard(first.Id, "alpha", "", []); + fixture.Repository.AddCard(first.Id, "beta", "", []); + BoardViewModel board = fixture.CreateBoard(); + board.SearchText = "alpha"; + await board.SearchCommand.ExecuteAsync(null); + + board.SearchText = "beta"; + await MoveAsync(board, alpha.Id, second.Id); + + CollectionAssert.AreEqual(new[] { "alpha" }, VisibleTitles(board)); + Assert.AreEqual(1, board.Columns[1].CardCount); + } + + [TestMethod] + public async Task BlankSearchAndClearShowEveryCard() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "alpha", "", []); + fixture.Repository.AddCard(column.Id, "beta", "", []); + BoardViewModel board = fixture.CreateBoard(); + + board.SearchText = "alpha"; + await board.SearchCommand.ExecuteAsync(null); + board.SearchText = " "; + await board.SearchCommand.ExecuteAsync(null); + Assert.IsNull(board.ActiveFilter); + CollectionAssert.AreEqual(new[] { "alpha", "beta" }, VisibleTitles(board)); + + board.SearchText = "alpha"; + await board.SearchCommand.ExecuteAsync(null); + Assert.IsTrue(board.HasSearchText); + board.ClearSearchCommand.Execute(null); + + Assert.AreEqual("", board.SearchText); + Assert.IsFalse(board.HasSearchText); + Assert.IsNull(board.ActiveFilter); + CollectionAssert.AreEqual(new[] { "alpha", "beta" }, VisibleTitles(board)); + } + + [TestMethod] + public async Task DataWrittenIsRaisedForWritesButNotForSearchOrSort() + { + using var fixture = new Fixture(); + ColumnModel first = fixture.Repository.AddColumn("A", ""); + ColumnModel second = fixture.Repository.AddColumn("B", ""); + CardModel card = fixture.Repository.AddCard(first.Id, "x", "", []); + BoardViewModel board = fixture.CreateBoard(); + int raised = 0; + board.DataWritten += (_, _) => raised++; + + board.SearchText = "x"; + await board.SearchCommand.ExecuteAsync(null); + board.SelectedSort = board.SortOptions[1]; + Assert.AreEqual(0, raised); + + await MoveAsync(board, card.Id, second.Id); + Assert.AreEqual(1, raised); + } + + #endregion + + #region Tags tab + + [TestMethod] + public void TagsListInIdOrderWithUsageCounts() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), "Broken"); + TagModel unused = fixture.Repository.AddTag("unused", new RgbColor(4, 5, 6), ""); + fixture.Repository.AddCard(column.Id, "a", "", [bug.Id]); + fixture.Repository.AddCard(column.Id, "b", "", [bug.Id]); + + TagsViewModel tags = fixture.CreateTags(); + + CollectionAssert.AreEqual(new[] { bug.Id, unused.Id }, tags.Tags.Select(row => row.Tag.Id).ToArray()); + CollectionAssert.AreEqual(new long[] { 2, 0 }, tags.Tags.Select(row => row.UsageCount).ToArray()); + Assert.IsTrue(tags.Tags[0].HasDescription); + Assert.AreEqual("#010203", tags.Tags[0].ColorText); + Assert.IsFalse(tags.IsEmpty); + } + + [TestMethod] + public async Task NewTagIsWrittenAndAnnounced() + { + using var fixture = new Fixture(); + TagsViewModel tags = fixture.CreateTags(); + int changed = 0; + tags.TagsChanged += (_, _) => changed++; + fixture.Dialogs.Script = dialog => + { + var editor = (TagEditorDialogViewModel)dialog; + editor.Name = "ui"; + editor.PickSwatchCommand.Execute(TagColors.Palette[2].ToString()); + editor.Description = "Looks"; + editor.ConfirmCommand.Execute(null); + return Task.CompletedTask; + }; + + await tags.AddTagCommand.ExecuteAsync(null); + + TagModel created = fixture.Repository.GetTags().Single(); + Assert.AreEqual("ui", created.Name); + Assert.AreEqual(TagColors.Palette[2], created.Color); + Assert.AreEqual("Looks", created.Description); + Assert.AreEqual(1, tags.Tags.Count); + Assert.AreEqual(1, changed); + } + + [TestMethod] + public async Task TagEditorKeepsTheDialogOpenOnDuplicateNameOrInvalidColor() + { + using var fixture = new Fixture(); + fixture.Repository.AddTag("Bug", new RgbColor(1, 2, 3), ""); + TagsViewModel tags = fixture.CreateTags(); + TagEditorDialogViewModel? editor = null; + bool closed = false; + fixture.Dialogs.Script = dialog => + { + editor = (TagEditorDialogViewModel)dialog; + editor.CloseRequested += (_, _) => closed = true; + editor.Name = "bug"; + editor.ConfirmCommand.Execute(null); + return Task.CompletedTask; + }; + + await tags.AddTagCommand.ExecuteAsync(null); + + Assert.IsNotNull(editor); + Assert.IsTrue(editor.HasError); + Assert.IsFalse(closed); + Assert.AreEqual("bug", editor.Name); + + editor.Name = "other"; + editor.Color = "red"; + editor.ConfirmCommand.Execute(null); + Assert.AreEqual(Resources.Error_Tag_InvalidColor, editor.ErrorMessage); + Assert.IsFalse(closed); + Assert.AreEqual(1, fixture.Repository.GetTags().Count); + } + + [TestMethod] + public async Task EditingATagRenamesRecolorsAndDescribesIt() + { + using var fixture = new Fixture(); + TagModel tag = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), ""); + TagsViewModel tags = fixture.CreateTags(); + fixture.Dialogs.Script = dialog => + { + var editor = (TagEditorDialogViewModel)dialog; + Assert.AreEqual("bug", editor.Name); + Assert.AreEqual("#010203", editor.Color); + editor.Name = "defect"; + editor.Color = "#ABCDEF"; + editor.Description = "Broken"; + editor.ConfirmCommand.Execute(null); + return Task.CompletedTask; + }; + + await tags.Tags[0].EditCommand.ExecuteAsync(null); + + TagModel updated = fixture.Repository.GetTags().Single(); + Assert.AreEqual(tag.Id, updated.Id); + Assert.AreEqual("defect", updated.Name); + Assert.AreEqual(new RgbColor(0xab, 0xcd, 0xef), updated.Color); + Assert.AreEqual("Broken", updated.Description); + Assert.AreEqual("defect", tags.Tags[0].Badge.Name); + } + + [TestMethod] + public async Task DeletingATagAsksFirstAndKeepsTheCards() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), ""); + CardModel card = fixture.Repository.AddCard(column.Id, "a", "", [bug.Id]); + TagsViewModel tags = fixture.CreateTags(); + + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false); + await tags.Tags[0].DeleteCommand.ExecuteAsync(null); + Assert.AreEqual(1, fixture.Repository.GetTags().Count); + + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true); + await tags.Tags[0].DeleteCommand.ExecuteAsync(null); + + Assert.AreEqual(0, fixture.Repository.GetTags().Count); + Assert.IsTrue(tags.IsEmpty); + CardModel kept = fixture.Repository.GetCard(card.Id)!; + Assert.AreEqual(0, kept.Tags.Count); + } + + [TestMethod] + public async Task DeletingATagWithoutConfirmationShowsNoDialog() + { + using var fixture = new Fixture(); + fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), ""); + fixture.Config.Confirmations.DeleteTag = false; + TagsViewModel tags = fixture.CreateTags(); + + await tags.Tags[0].DeleteCommand.ExecuteAsync(null); + + Assert.AreEqual(0, fixture.Dialogs.Shown.Count); + Assert.AreEqual(0, fixture.Repository.GetTags().Count); + } + + #endregion + + #region Workspace wiring and export + + [TestMethod] + public async Task TagWritesRefreshTheBoardAndBoardWritesRefreshTheTags() + { + using var fixture = new Fixture(); + ColumnModel first = fixture.Repository.AddColumn("A", ""); + ColumnModel second = fixture.Repository.AddColumn("B", ""); + TagModel bug = fixture.Repository.AddTag("bug", new RgbColor(1, 2, 3), ""); + CardModel card = fixture.Repository.AddCard(first.Id, "a", "", [bug.Id]); + WorkspaceViewModel workspace = fixture.CreateWorkspace(); + + // A filtered board drops the card once the tag is gone. + workspace.Board.SearchText = "tag:bug"; + await workspace.Board.SearchCommand.ExecuteAsync(null); + fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true); + await workspace.Tags.Tags[0].DeleteCommand.ExecuteAsync(null); + Assert.AreEqual(0, workspace.Board.Columns.Sum(column => column.CardCount)); + + // Re-tag through the repository, then a board write updates the usage count. + workspace.Board.ClearSearchCommand.Execute(null); + TagModel ui = fixture.Repository.AddTag("ui", new RgbColor(4, 5, 6), ""); + fixture.Repository.UpdateCard(card.Id, "a", "", [ui.Id]); + Assert.AreEqual(0, workspace.Tags.Tags.Count); + await MoveAsync(workspace.Board, card.Id, second.Id); + Assert.AreEqual(1, workspace.Tags.Tags.Single().UsageCount); + } + + [TestMethod] + public async Task ExportWritesTheWholeWorkspaceIgnoringTheSearch() + { + using var fixture = new Fixture(); + ColumnModel column = fixture.Repository.AddColumn("A", ""); + fixture.Repository.AddCard(column.Id, "alpha", "", []); + fixture.Repository.AddCard(column.Id, "beta", "", []); + WorkspaceViewModel workspace = fixture.CreateWorkspace(); + workspace.Board.SearchText = "alpha"; + await workspace.Board.SearchCommand.ExecuteAsync(null); + + string path = Path.Combine(fixture.Directory.FullPath, "out.md"); + fixture.Dialogs.SaveFilePath = path; + await workspace.ExportCommand.ExecuteAsync(null); + + string suggested = fixture.Dialogs.SuggestedFileNames.Single(); + StringAssert.StartsWith(suggested, workspace.WorkspaceName); + StringAssert.EndsWith(suggested, ".md"); + string markdown = File.ReadAllText(path); + StringAssert.Contains(markdown, "alpha"); + StringAssert.Contains(markdown, "beta"); + byte[] bytes = File.ReadAllBytes(path); + Assert.AreNotEqual(0xEF, bytes[0], "The export must not start with a UTF-8 BOM."); + Assert.AreEqual(0, fixture.Dialogs.Shown.Count); + } + + [TestMethod] + public async Task CancelledExportWritesNothing() + { + using var fixture = new Fixture(); + WorkspaceViewModel workspace = fixture.CreateWorkspace(); + fixture.Dialogs.SaveFilePath = null; + + await workspace.ExportCommand.ExecuteAsync(null); + + Assert.AreEqual(1, fixture.Dialogs.SuggestedFileNames.Count); + Assert.AreEqual(0, fixture.Dialogs.Shown.Count); + Assert.AreEqual(0, System.IO.Directory.GetFiles(fixture.Directory.FullPath, "*.md").Length); + } + + [TestMethod] + public async Task ExportWriteFailureIsReportedInAMessageDialog() + { + using var fixture = new Fixture(); + WorkspaceViewModel workspace = fixture.CreateWorkspace(); + fixture.Dialogs.SaveFilePath = Path.Combine(fixture.Directory.FullPath, "missing-folder", "out.md"); + + await workspace.ExportCommand.ExecuteAsync(null); + + var message = (MessageDialogViewModel)fixture.Dialogs.Shown.Single(); + Assert.AreEqual(Resources.Export_Failed_Title, message.Title); + Assert.IsTrue(message.HasDetails); + } + + #endregion +} diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index 04ddc8f..8dca7d8 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -340,4 +340,52 @@ The change could not be written to the workspace. The board shows the stored state. + + New tag + + + Edit tag + + + Color + + + New tag + + + {0} tag(s) + + + {0} card(s) + + + No tags yet. + + + Delete tag "{0}"? It will be removed from {1} card(s). + + + Search, e.g. tag:bug OR title:login + + + Search + + + Clear + + + Invalid search + + + The query expression is invalid. Details: {0} + + + Markdown document + + + Export failed + + + The export file could not be written. + diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index 88e44a6..691ca62 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -340,4 +340,52 @@ 更改未能写入工作区,看板显示的是已保存的状态。 + + 新建标签 + + + 编辑标签 + + + 颜色 + + + 新建标签 + + + 共 {0} 个标签 + + + {0} 张卡片 + + + 还没有标签。 + + + 删除标签“{0}”?将从 {1} 张卡片移除该标签。 + + + 搜索,例如 tag:bug OR title:登录 + + + 搜索 + + + 清空 + + + 搜索表达式错误 + + + 查询表达式有误,详情:{0} + + + Markdown 文档 + + + 导出失败 + + + 无法写入导出文件。 + diff --git a/YKanBan/ViewModels/Board/BoardViewModel.cs b/YKanBan/ViewModels/Board/BoardViewModel.cs index 5509b64..ad5fb97 100644 --- a/YKanBan/ViewModels/Board/BoardViewModel.cs +++ b/YKanBan/ViewModels/Board/BoardViewModel.cs @@ -11,8 +11,12 @@ using YKanBan.ViewModels.Dialogs; namespace YKanBan.ViewModels.Board; /// -/// The board tab: the toolbar (new column, column width, card sort) and the -/// columns laid out left to right, each listing its cards top to bottom. +/// The board tab: the toolbar (search bar, new column, column width, card sort) +/// and the columns laid out left to right, each listing its cards top to bottom. +/// +/// The search runs only on an explicit search (button or Enter), never while +/// typing. An invalid expression is reported in a message dialog and changes +/// nothing: the box keeps its text and the board keeps the previous result. /// /// Every write is followed by , which re-queries with the /// most recently successful search expression (), not @@ -50,6 +54,12 @@ public sealed partial class BoardViewModel : ViewModelBase Refresh(); } + /// + /// Raised after every board write (the refresh included), so the tags tab + /// can update its usage counts. + /// + public event EventHandler? DataWritten; + /// /// Gets the columns in id order. /// @@ -70,6 +80,18 @@ public sealed partial class BoardViewModel : ViewModelBase /// public CompiledSearch? ActiveFilter { get; set; } + /// + /// Gets or sets the search box text; it only takes effect on . + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSearchText))] + private string searchText = ""; + + /// + /// Gets whether the search box holds text (shows the clear button). + /// + public bool HasSearchText => SearchText.Length > 0; + /// /// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board. /// @@ -99,6 +121,43 @@ public sealed partial class BoardViewModel : ViewModelBase } } + /// + /// Parses and runs the search box text; blank text shows every card. An + /// invalid expression only shows the error dialog. + /// + /// A task completing after the refresh or the error dialog. + [RelayCommand] + private async Task SearchAsync() + { + CompiledSearch? filter; + try + { + SearchNode? query = SearchQueryParser.Parse(SearchText); + filter = query is null ? null : SearchSqlCompiler.Compile(query); + } + catch (SearchSyntaxException exception) + { + await _dialogs.ShowMessageAsync( + Resources.Search_Error_Title, + string.Format(Resources.Culture, Resources.Search_Error_Message, exception.Message)); + return; + } + + ActiveFilter = filter; + Refresh(); + } + + /// + /// Empties the search box and shows every card again. + /// + [RelayCommand] + private void ClearSearch() + { + SearchText = ""; + ActiveFilter = null; + Refresh(); + } + /// /// Opens the new-column dialog. /// @@ -109,7 +168,7 @@ public sealed partial class BoardViewModel : ViewModelBase var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description)); if (await _dialogs.ShowAsync(dialog)) { - Refresh(); + RefreshAfterWrite(); } } @@ -137,7 +196,7 @@ public sealed partial class BoardViewModel : ViewModelBase column, (title, description) => _repository.UpdateColumn(column.Id, title, description)); if (await _dialogs.ShowAsync(dialog)) { - Refresh(); + RefreshAfterWrite(); } } @@ -176,7 +235,7 @@ public sealed partial class BoardViewModel : ViewModelBase await _dialogs.ShowAsync(editor); // Refresh even after a cancel: the nested picker may have created tags. - Refresh(); + RefreshAfterWrite(); } /// @@ -196,7 +255,7 @@ public sealed partial class BoardViewModel : ViewModelBase var editor = CardEditorDialogViewModel.ForExistingCard( _repository, _dialogs, card, _config.Confirmations.DiscardEdit); await _dialogs.ShowAsync(editor); - Refresh(); + RefreshAfterWrite(); } /// @@ -255,7 +314,16 @@ public sealed partial class BoardViewModel : ViewModelBase await _dialogs.ShowMessageAsync( Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message); } + RefreshAfterWrite(); + } + + /// + /// Refreshes after a write and announces it. + /// + private void RefreshAfterWrite() + { Refresh(); + DataWritten?.Invoke(this, EventArgs.Empty); } /// diff --git a/YKanBan/ViewModels/Dialogs/IDialogService.cs b/YKanBan/ViewModels/Dialogs/IDialogService.cs index f73ad88..c8be85e 100644 --- a/YKanBan/ViewModels/Dialogs/IDialogService.cs +++ b/YKanBan/ViewModels/Dialogs/IDialogService.cs @@ -13,6 +13,13 @@ public interface IDialogService /// The dialog view model; its window is resolved by naming convention. /// The dialog result: when it was accepted. Task ShowAsync(DialogViewModelBase dialog); + + /// + /// Shows the system save-file dialog for a Markdown file. + /// + /// The proposed file name. + /// The chosen local path, or when the user cancelled. + Task PickSaveFilePathAsync(string suggestedFileName); } /// diff --git a/YKanBan/ViewModels/Dialogs/TagColors.cs b/YKanBan/ViewModels/Dialogs/TagColors.cs new file mode 100644 index 0000000..437267a --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/TagColors.cs @@ -0,0 +1,26 @@ +using YKanBan.Models; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The default colors proposed for new tags, shared by the tag picker and the +/// tag editor. +/// +public static class TagColors +{ + /// + /// Gets the palette, offered as swatches and cycled for new-tag defaults. + /// + public static IReadOnlyList Palette { get; } = + [ + new(0xd7, 0x3a, 0x4a), new(0x00, 0x75, 0xca), new(0x0e, 0x8a, 0x16), new(0xfb, 0xca, 0x04), + new(0x7c, 0x3a, 0xed), new(0xe9, 0x9b, 0x3c), new(0x00, 0x8b, 0x8b), new(0xcf, 0xd3, 0xd7), + ]; + + /// + /// Returns the default color of a new tag, cycled by the number of existing tags. + /// + /// The number of tags already in the workspace. + /// The proposed color. + public static RgbColor DefaultFor(int existingCount) => Palette[existingCount % Palette.Count]; +} diff --git a/YKanBan/ViewModels/Dialogs/TagEditorDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/TagEditorDialogViewModel.cs new file mode 100644 index 0000000..e4b4996 --- /dev/null +++ b/YKanBan/ViewModels/Dialogs/TagEditorDialogViewModel.cs @@ -0,0 +1,146 @@ +using Avalonia.Media; +using Avalonia.Media.Immutable; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; + +namespace YKanBan.ViewModels.Dialogs; + +/// +/// The new-tag / edit-tag dialog of the tags tab: name (required, not trimmed), +/// #RRGGBB color with palette swatches and a live preview, and an optional +/// description. Confirming writes through the supplied save callback; an invalid +/// color or a schema-constraint violation (empty or duplicate name) is shown +/// inside the dialog, which stays open with the user's input intact. +/// +public sealed partial class TagEditorDialogViewModel : DialogViewModelBase +{ + private readonly Action _save; + + /// + /// Initializes the dialog. + /// + /// The tag being edited, or for a new tag. + /// The color proposed for a new tag. + /// Writes the name, color and description; throws the repository's typed exceptions. + public TagEditorDialogViewModel(TagModel? existing, RgbColor defaultColor, Action save) + { + _save = save; + Title = existing is null ? Resources.TagEditor_TitleNew : Resources.TagEditor_TitleEdit; + name = existing?.Name ?? ""; + color = (existing?.Color ?? defaultColor).ToString(); + description = existing?.Description ?? ""; + Swatches = TagColors.Palette.Select(swatch => new ColorSwatch(swatch.ToString(), ToBrush(swatch))).ToList(); + UpdateColorPreview(); + } + + /// + /// Gets the localized window title. + /// + public string Title { get; } + + /// + /// Gets the palette swatches; clicking one sets . + /// + public IReadOnlyList Swatches { get; } + + /// + /// Gets or sets the tag name being edited (not trimmed). + /// + [ObservableProperty] + private string name; + + /// + /// Gets or sets the #RRGGBB color text being edited. + /// + [ObservableProperty] + private string color; + + /// + /// Gets or sets the tag description being edited. + /// + [ObservableProperty] + private string description; + + /// + /// Gets the preview of , transparent while it is not valid. + /// + [ObservableProperty] + private IBrush colorPreview = Brushes.Transparent; + + /// + /// Gets the localized error of the last confirmation, or . + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + /// + /// Gets whether an error is shown. + /// + public bool HasError => ErrorMessage is not null; + + /// + /// Takes a swatch color. + /// + /// The swatch's #RRGGBB text. + [RelayCommand] + private void PickSwatch(string hex) => Color = hex; + + /// + /// Writes the tag; closes on success, otherwise shows the error. + /// + [RelayCommand] + private void Confirm() + { + if (!RgbColor.TryParse(Color, out RgbColor parsed)) + { + ErrorMessage = Resources.Error_Tag_InvalidColor; + return; + } + + try + { + _save(Name, parsed, Description); + } + catch (Exception exception) + { + ErrorMessage = WriteErrorMessages.Describe(exception); + return; + } + Close(true); + } + + /// + /// Closes without writing. + /// + [RelayCommand] + private Task CancelAsync() => RequestCancelAsync(); + + /// + /// Refreshes the color preview. + /// + /// The new color text. + partial void OnColorChanged(string value) => UpdateColorPreview(); + + /// + /// Recomputes from . + /// + private void UpdateColorPreview() => + ColorPreview = RgbColor.TryParse(Color, out RgbColor parsed) ? ToBrush(parsed) : Brushes.Transparent; + + /// + /// Creates the thread-agnostic brush of a tag color. + /// + /// The color. + /// The brush. + private static IBrush ToBrush(RgbColor value) => + new ImmutableSolidColorBrush(Avalonia.Media.Color.FromRgb(value.R, value.G, value.B)); +} + +/// +/// A palette swatch of the tag editor. +/// +/// The #RRGGBB text. +/// The fill. +public sealed record ColorSwatch(string Hex, IBrush Brush); diff --git a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs index 170508b..f500f75 100644 --- a/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs +++ b/YKanBan/ViewModels/Dialogs/TagPickerDialogViewModel.cs @@ -21,15 +21,6 @@ namespace YKanBan.ViewModels.Dialogs; /// public sealed partial class TagPickerDialogViewModel : DialogViewModelBase { - /// - /// Default colors proposed for new tags, cycled by the number of existing tags. - /// - private static readonly RgbColor[] Palette = - [ - new(0xd7, 0x3a, 0x4a), new(0x00, 0x75, 0xca), new(0x0e, 0x8a, 0x16), new(0xfb, 0xca, 0x04), - new(0x7c, 0x3a, 0xed), new(0xe9, 0x9b, 0x3c), new(0x00, 0x8b, 0x8b), new(0xcf, 0xd3, 0xd7), - ]; - private readonly WorkspaceRepository _repository; private readonly IReadOnlyList _available; @@ -48,7 +39,7 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase .ToList(); FilteredTags = new ObservableCollection(_available); - newColor = Palette[all.Count % Palette.Length].ToString(); + newColor = TagColors.DefaultFor(all.Count).ToString(); UpdateColorPreview(); } diff --git a/YKanBan/ViewModels/Tags/TagRowViewModel.cs b/YKanBan/ViewModels/Tags/TagRowViewModel.cs new file mode 100644 index 0000000..f5df2d1 --- /dev/null +++ b/YKanBan/ViewModels/Tags/TagRowViewModel.cs @@ -0,0 +1,77 @@ +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.ViewModels.Board; + +namespace YKanBan.ViewModels.Tags; + +/// +/// One row of the tags tab: the colored badge, the description and the usage +/// count, with edit and delete actions. +/// +public sealed partial class TagRowViewModel : ViewModelBase +{ + private readonly TagsViewModel _owner; + + /// + /// Initializes the row. + /// + /// The tags tab performing the actions. + /// The tag shown. + /// The number of cards carrying the tag. + public TagRowViewModel(TagsViewModel owner, TagModel tag, long usageCount) + { + _owner = owner; + Tag = tag; + Badge = new TagBadgeViewModel(tag); + UsageCount = usageCount; + } + + /// + /// Gets the tag shown. + /// + public TagModel Tag { get; } + + /// + /// Gets the colored badge (color + name). + /// + public TagBadgeViewModel Badge { get; } + + /// + /// Gets the tag description. + /// + public string Description => Tag.Description; + + /// + /// Gets whether the tag has a description. + /// + public bool HasDescription => Tag.Description.Length > 0; + + /// + /// Gets the #RRGGBB color text. + /// + public string ColorText => Tag.Color.ToString(); + + /// + /// Gets the number of cards carrying the tag. + /// + public long UsageCount { get; } + + /// + /// Gets the localized usage count, for example "3 cards". + /// + public string UsageText => string.Format(Resources.Culture, Resources.Tags_UsageCount, UsageCount); + + /// + /// Opens the edit dialog. + /// + /// A task completing when the dialog closes. + [RelayCommand] + private Task EditAsync() => _owner.EditTagAsync(Tag); + + /// + /// Deletes the tag. + /// + /// A task completing when the tag is deleted or the deletion was declined. + [RelayCommand] + private Task DeleteAsync() => _owner.DeleteTagAsync(Tag, UsageCount); +} diff --git a/YKanBan/ViewModels/Tags/TagsViewModel.cs b/YKanBan/ViewModels/Tags/TagsViewModel.cs new file mode 100644 index 0000000..dbd5abe --- /dev/null +++ b/YKanBan/ViewModels/Tags/TagsViewModel.cs @@ -0,0 +1,144 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using CommunityToolkit.Mvvm.Input; +using YKanBan.Models; +using YKanBan.Storage.AppData; +using YKanBan.Storage.Workspace; +using YKanBan.ViewModels.Dialogs; + +namespace YKanBan.ViewModels.Tags; + +/// +/// The tags tab, a tag manager in the style of the GitHub issue labels page: +/// every tag with its color, name, description and usage count, plus creating, +/// editing (rename, recolor, describe) and deleting tags. Unreferenced tags are +/// never cleaned up automatically, so this is where they are removed. +/// +public sealed partial class TagsViewModel : ViewModelBase +{ + private readonly WorkspaceRepository _repository; + private readonly AppConfig _config; + private readonly IDialogService _dialogs; + + /// + /// Initializes the tab and loads it. + /// + /// The workspace repository. + /// The live application configuration (delete confirmation). + /// The modal dialog service. + public TagsViewModel(WorkspaceRepository repository, AppConfig config, IDialogService dialogs) + { + _repository = repository; + _config = config; + _dialogs = dialogs; + Refresh(); + } + + /// + /// Raised after every tag write, so the board can show the new names and colors. + /// + public event EventHandler? TagsChanged; + + /// + /// Gets the tags in id order. + /// + public ObservableCollection Tags { get; } = []; + + /// + /// Gets the localized total, for example "3 tags". + /// + public string Summary => string.Format(Resources.Culture, Resources.Tags_Summary, Tags.Count); + + /// + /// Gets whether the workspace has no tags yet. + /// + public bool IsEmpty => Tags.Count == 0; + + /// + /// Reloads the tags with their usage counts. + /// + public void Refresh() + { + Tags.Clear(); + foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage()) + { + Tags.Add(new TagRowViewModel(this, tag, usage)); + } + OnPropertyChanged(nameof(Summary)); + OnPropertyChanged(nameof(IsEmpty)); + } + + /// + /// Opens the new-tag dialog. + /// + /// A task completing when the dialog closes. + [RelayCommand] + private async Task AddTagAsync() + { + var dialog = new TagEditorDialogViewModel( + null, + TagColors.DefaultFor(Tags.Count), + (name, color, description) => _repository.AddTag(name, color, description)); + if (await _dialogs.ShowAsync(dialog)) + { + AfterWrite(); + } + } + + /// + /// Opens the edit-tag dialog. + /// + /// The tag to edit. + /// A task completing when the dialog closes. + internal async Task EditTagAsync(TagModel tag) + { + var dialog = new TagEditorDialogViewModel( + tag, + tag.Color, + (name, color, description) => _repository.UpdateTag(tag.Id, name, color, description)); + if (await _dialogs.ShowAsync(dialog)) + { + AfterWrite(); + } + } + + /// + /// Deletes a tag (removing it from its cards), asking first when that + /// confirmation is enabled. + /// + /// The tag to delete. + /// The number of cards carrying it. + /// A task completing when the tag is deleted or the deletion was declined. + internal async Task DeleteTagAsync(TagModel tag, long usageCount) + { + if (_config.Confirmations.DeleteTag) + { + string message = string.Format(Resources.Culture, Resources.Confirm_DeleteTag, tag.Name, usageCount); + if (!await _dialogs.ConfirmAsync(message, Resources.Common_Delete)) + { + return; + } + } + + try + { + _repository.DeleteTag(tag.Id); + } + catch (Exception exception) + { + Debug.WriteLine(exception); + await _dialogs.ShowMessageAsync( + Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message); + } + AfterWrite(); + } + + /// + /// Reloads the list and tells the board that tags changed. + /// + private void AfterWrite() + { + Refresh(); + TagsChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/YKanBan/ViewModels/WorkspaceViewModel.cs b/YKanBan/ViewModels/WorkspaceViewModel.cs index cc9d384..b4019f7 100644 --- a/YKanBan/ViewModels/WorkspaceViewModel.cs +++ b/YKanBan/ViewModels/WorkspaceViewModel.cs @@ -1,20 +1,26 @@ +using System.Diagnostics; +using System.Text; using CommunityToolkit.Mvvm.Input; +using YKanBan.Export; using YKanBan.Storage.AppData; using YKanBan.Storage.Workspace; using YKanBan.ViewModels.Board; using YKanBan.ViewModels.Dialogs; +using YKanBan.ViewModels.Tags; namespace YKanBan.ViewModels; /// /// The normal workspace view: the top display area (name, path and the export, -/// settings and about buttons) plus the tags/board tabs. The tab contents are -/// filled in by the board and tag features. +/// settings and about buttons) plus the board and tags tabs. A write on either +/// tab refreshes the other: tag edits change the board's badges and filter +/// matches, card edits change the tags' usage counts. /// public sealed partial class WorkspaceViewModel : ViewModelBase { private readonly Interaction _showSettings; private readonly Interaction _showAbout; + private readonly IDialogService _dialogs; /// /// Initializes the view for an open workspace. @@ -23,7 +29,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase /// The interaction that opens the settings dialog. /// The interaction that opens the about dialog. /// The live application configuration. - /// The modal dialog service of the board. + /// The modal dialog service. public WorkspaceViewModel( WorkspaceSession session, Interaction showSettings, @@ -36,7 +42,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase WorkspacePath = session.FolderPath; _showSettings = showSettings; _showAbout = showAbout; + _dialogs = dialogs; Board = new BoardViewModel(session.Repository, config, dialogs); + Tags = new TagsViewModel(session.Repository, config, dialogs); + Board.DataWritten += (_, _) => Tags.Refresh(); + Tags.TagsChanged += (_, _) => Board.Refresh(); } /// @@ -59,6 +69,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase /// public BoardViewModel Board { get; } + /// + /// Gets the tags tab. + /// + public TagsViewModel Tags { get; } + /// /// Opens the settings dialog through the registered interaction. /// @@ -72,11 +87,32 @@ public sealed partial class WorkspaceViewModel : ViewModelBase private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value); /// - /// Raised by the export button; the export flow is wired up with the export - /// feature. + /// Exports the whole workspace to a Markdown file chosen in the system save + /// dialog. The export ignores the board's search filter and card sort; the + /// default file name and all template text follow the UI language. /// + /// A task completing when the file is written or the export was cancelled. [RelayCommand] - private static void Export() + private async Task ExportAsync() { + DateTimeOffset exportedAt = DateTimeOffset.Now; + string? path = await _dialogs.PickSaveFilePathAsync(MarkdownExporter.BuildFileName(WorkspaceName, exportedAt)); + if (path is null) + { + return; + } + + try + { + WorkspaceRepository repository = Session.Repository; + string markdown = MarkdownExporter.Export( + WorkspaceName, repository.GetColumns(), repository.GetCards(), repository.GetTagsWithUsage(), exportedAt); + await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + catch (Exception exception) + { + Debug.WriteLine(exception); + await _dialogs.ShowMessageAsync(Resources.Export_Failed_Title, Resources.Export_Failed_Message, exception.Message); + } } } diff --git a/YKanBan/Views/Board/BoardView.axaml b/YKanBan/Views/Board/BoardView.axaml index f349d02..e9055a5 100644 --- a/YKanBan/Views/Board/BoardView.axaml +++ b/YKanBan/Views/Board/BoardView.axaml @@ -13,29 +13,54 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YKanBan/Views/DialogService.cs b/YKanBan/Views/DialogService.cs index 3376051..ec54cba 100644 --- a/YKanBan/Views/DialogService.cs +++ b/YKanBan/Views/DialogService.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Platform.Storage; using YKanBan.ViewModels.Dialogs; using YKanBan.Views.Dialogs; @@ -50,6 +51,27 @@ public sealed class DialogService : IDialogService } } + /// + public async Task PickSaveFilePathAsync(string suggestedFileName) + { + if (!_owners.TryPeek(out Window? owner)) + { + throw new InvalidOperationException("The dialog service has no main window attached."); + } + + IStorageFile? file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + SuggestedFileName = suggestedFileName, + DefaultExtension = "md", + ShowOverwritePrompt = true, + FileTypeChoices = + [ + new FilePickerFileType(YKanBan.Resources.Export_FileTypeMarkdown) { Patterns = ["*.md"] }, + ], + }); + return file?.TryGetLocalPath(); + } + /// /// Creates the window class matching a dialog view model by naming convention. /// diff --git a/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml index f47e319..b56d831 100644 --- a/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml +++ b/YKanBan/Views/Dialogs/ColumnEditorDialog.axaml @@ -6,7 +6,7 @@ x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog" x:DataType="vm:ColumnEditorDialogViewModel" Title="{Binding Title}" - Width="440" + SizeToContent="Height" Width="440" WindowStartupLocation="CenterOwner"> diff --git a/YKanBan/Views/Dialogs/TagEditorDialog.axaml b/YKanBan/Views/Dialogs/TagEditorDialog.axaml new file mode 100644 index 0000000..37ec9ba --- /dev/null +++ b/YKanBan/Views/Dialogs/TagEditorDialog.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/YKanBan/Views/Tags/TagsView.axaml.cs b/YKanBan/Views/Tags/TagsView.axaml.cs new file mode 100644 index 0000000..b4cba3e --- /dev/null +++ b/YKanBan/Views/Tags/TagsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace YKanBan.Views.Tags; + +public partial class TagsView : UserControl +{ + public TagsView() + { + InitializeComponent(); + } +} diff --git a/YKanBan/Views/WorkspaceView.axaml b/YKanBan/Views/WorkspaceView.axaml index f7595a5..7b9274e 100644 --- a/YKanBan/Views/WorkspaceView.axaml +++ b/YKanBan/Views/WorkspaceView.axaml @@ -48,12 +48,14 @@ - + - + + + diff --git a/docs/PLAN.md b/docs/PLAN.md index 8088b28..1617db1 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -374,7 +374,7 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号, | M4 GUI 框架 | 已完成 | 主题/图标接入、主窗口布局(顶部区 + TabControl + 全窗口错误页体系)、设置/关于模态;全 UI 文本资源化(硬规则生效)、语言设置项、RTL/FlowDirection 检测;亮/暗/跟随系统主题基础设施与设置项、ThemeDictionaries 颜色硬规则 | 人工 | | **M4R v1.9 回补** | 已完成 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 | | M5 看板界面 | 已完成(待人工验收) | 看板 tab 全部交互与模态(含约束冲突模态内提示、卡片列表虚拟化、嵌套模态新建标签立即写库、写库后按最近成功表达式刷新) | 人工 | -| M6 标签与搜索集成 | 待办 | 标签 tab、搜索执行与空列保留、导出接线(含文件名/时间戳 i18n 模式) | 人工 | +| M6 标签与搜索集成 | 已完成(待人工验收) | 标签 tab、搜索执行与空列保留、导出接线(含文件名/时间戳 i18n 模式) | 人工 | | M7 发布与文档 | 待办 | 单一 workflow(4 RID 矩阵 build→test→artifact,osx-x64 用 Intel 运行器)文件就绪(结构自查)、根目录 USAGE.md、README(引用 USAGE.md)、人工验收清单(含语言切换重启生效、导出语言跟随界面、主题三态切换、app.json 多实例脏写四项) | workflow 结构自查通过;实际触发与产物验证视网络条件推迟 | **M4R 清单**(v1.9 对已完成里程碑的回补,进入 M5 前完成):