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/Dialogs/TagEditorDialog.axaml.cs b/YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs
new file mode 100644
index 0000000..7ee2a05
--- /dev/null
+++ b/YKanBan/Views/Dialogs/TagEditorDialog.axaml.cs
@@ -0,0 +1,9 @@
+namespace YKanBan.Views.Dialogs;
+
+public partial class TagEditorDialog : DialogWindow
+{
+ public TagEditorDialog()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/YKanBan/Views/Tags/TagsView.axaml b/YKanBan/Views/Tags/TagsView.axaml
new file mode 100644
index 0000000..a999f3a
--- /dev/null
+++ b/YKanBan/Views/Tags/TagsView.axaml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 前完成):