feat: implement tags tab, board search execution and markdown export wiring

This commit is contained in:
doyaGu committed 2026-10-03 07:34:34 -04:00
1 parent e3f58ce150
commit 6ea0df43e3
22 files changed
+1300 -89

No files matched your search

@@ -0,0 +1,50 @@
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Tests.TestUtilities;
/// <summary>
/// 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
/// <see cref="SaveFilePath"/>.
/// </summary>
internal sealed class ScriptedDialogService : IDialogService
{
public List<DialogViewModelBase> Shown { get; } = [];
public Func<DialogViewModelBase, Task> Script { get; set; } = _ => Task.CompletedTask;
public string? SaveFilePath { get; set; }
public List<string> SuggestedFileNames { get; } = [];
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
{
Shown.Add(dialog);
bool? result = null;
dialog.CloseRequested += (_, closed) => result ??= closed;
await Script(dialog);
return result ?? false;
}
public Task<string?> PickSaveFilePathAsync(string suggestedFileName)
{
SuggestedFileNames.Add(suggestedFileName);
return Task.FromResult(SaveFilePath);
}
/// <summary>Answers every confirmation dialog with the given choice.</summary>
public static Func<DialogViewModelBase, Task> 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;
};
}
@@ -16,42 +16,6 @@ namespace YKanBan.Tests.ViewModels;
[TestClass] [TestClass]
public class BoardViewModelTests public class BoardViewModelTests
{ {
/// <summary>
/// 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.
/// </summary>
private sealed class ScriptedDialogService : IDialogService
{
public List<DialogViewModelBase> Shown { get; } = [];
public Func<DialogViewModelBase, Task> Script { get; set; } = _ => Task.CompletedTask;
public async Task<bool> ShowAsync(DialogViewModelBase dialog)
{
Shown.Add(dialog);
bool? result = null;
dialog.CloseRequested += (_, closed) => result ??= closed;
await Script(dialog);
return result ?? false;
}
}
/// <summary>Answers every confirmation dialog with the given choice.</summary>
private static Func<DialogViewModelBase, Task> 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 private sealed class Fixture : IDisposable
{ {
public TempDirectory Directory { get; } = new(); public TempDirectory Directory { get; } = new();
@@ -144,7 +108,7 @@ public class BoardViewModelTests
fixture.Repository.AddCard(column.Id, "", "", []); fixture.Repository.AddCard(column.Id, "", "", []);
fixture.Repository.AddCard(column.Id, "", "", []); fixture.Repository.AddCard(column.Id, "", "", []);
BoardViewModel board = fixture.CreateBoard(); BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = AnswerConfirmations(false); fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
await board.Columns[0].DeleteCommand.ExecuteAsync(null); await board.Columns[0].DeleteCommand.ExecuteAsync(null);
@@ -174,7 +138,7 @@ public class BoardViewModelTests
ColumnModel column = fixture.Repository.AddColumn("A", ""); ColumnModel column = fixture.Repository.AddColumn("A", "");
fixture.Repository.AddCard(column.Id, "gone", "", []); fixture.Repository.AddCard(column.Id, "gone", "", []);
BoardViewModel board = fixture.CreateBoard(); BoardViewModel board = fixture.CreateBoard();
fixture.Dialogs.Script = AnswerConfirmations(true); fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null); await board.Columns[0].Cards[0].DeleteCommand.ExecuteAsync(null);
@@ -271,11 +235,11 @@ public class BoardViewModelTests
editor.CloseRequested += (_, result) => closedWith = result; editor.CloseRequested += (_, result) => closedWith = result;
editor.Content = "draft"; editor.Content = "draft";
fixture.Dialogs.Script = AnswerConfirmations(false); fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(false);
await editor.RequestCancelAsync(); await editor.RequestCancelAsync();
Assert.IsNull(closedWith, "Declining the discard keeps the editor open."); Assert.IsNull(closedWith, "Declining the discard keeps the editor open.");
fixture.Dialogs.Script = AnswerConfirmations(true); fixture.Dialogs.Script = ScriptedDialogService.AnswerConfirmations(true);
await editor.RequestCancelAsync(); await editor.RequestCancelAsync();
Assert.IsFalse(closedWith); Assert.IsFalse(closedWith);
Assert.AreEqual(0, fixture.Repository.GetCards().Count); Assert.AreEqual(0, fixture.Repository.GetCards().Count);
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<Unit>(), new Interaction<Unit>(), 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();
/// <summary>Moves a visible card through its menu command.</summary>
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
}
+48
View File
@@ -340,4 +340,52 @@
<data name="Error_Operation_Message" xml:space="preserve"> <data name="Error_Operation_Message" xml:space="preserve">
<value>The change could not be written to the workspace. The board shows the stored state.</value> <value>The change could not be written to the workspace. The board shows the stored state.</value>
</data> </data>
<data name="TagEditor_TitleNew" xml:space="preserve">
<value>New tag</value>
</data>
<data name="TagEditor_TitleEdit" xml:space="preserve">
<value>Edit tag</value>
</data>
<data name="TagEditor_ColorLabel" xml:space="preserve">
<value>Color</value>
</data>
<data name="Tags_AddTag" xml:space="preserve">
<value>New tag</value>
</data>
<data name="Tags_Summary" xml:space="preserve">
<value>{0} tag(s)</value>
</data>
<data name="Tags_UsageCount" xml:space="preserve">
<value>{0} card(s)</value>
</data>
<data name="Tags_Empty" xml:space="preserve">
<value>No tags yet.</value>
</data>
<data name="Confirm_DeleteTag" xml:space="preserve">
<value>Delete tag "{0}"? It will be removed from {1} card(s).</value>
</data>
<data name="Board_SearchWatermark" xml:space="preserve">
<value>Search, e.g. tag:bug OR title:login</value>
</data>
<data name="Board_Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="Board_ClearSearch" xml:space="preserve">
<value>Clear</value>
</data>
<data name="Search_Error_Title" xml:space="preserve">
<value>Invalid search</value>
</data>
<data name="Search_Error_Message" xml:space="preserve">
<value>The query expression is invalid. Details: {0}</value>
</data>
<data name="Export_FileTypeMarkdown" xml:space="preserve">
<value>Markdown document</value>
</data>
<data name="Export_Failed_Title" xml:space="preserve">
<value>Export failed</value>
</data>
<data name="Export_Failed_Message" xml:space="preserve">
<value>The export file could not be written.</value>
</data>
</root> </root>
@@ -340,4 +340,52 @@
<data name="Error_Operation_Message" xml:space="preserve"> <data name="Error_Operation_Message" xml:space="preserve">
<value>更改未能写入工作区,看板显示的是已保存的状态。</value> <value>更改未能写入工作区,看板显示的是已保存的状态。</value>
</data> </data>
<data name="TagEditor_TitleNew" xml:space="preserve">
<value>新建标签</value>
</data>
<data name="TagEditor_TitleEdit" xml:space="preserve">
<value>编辑标签</value>
</data>
<data name="TagEditor_ColorLabel" xml:space="preserve">
<value>颜色</value>
</data>
<data name="Tags_AddTag" xml:space="preserve">
<value>新建标签</value>
</data>
<data name="Tags_Summary" xml:space="preserve">
<value>共 {0} 个标签</value>
</data>
<data name="Tags_UsageCount" xml:space="preserve">
<value>{0} 张卡片</value>
</data>
<data name="Tags_Empty" xml:space="preserve">
<value>还没有标签。</value>
</data>
<data name="Confirm_DeleteTag" xml:space="preserve">
<value>删除标签“{0}”?将从 {1} 张卡片移除该标签。</value>
</data>
<data name="Board_SearchWatermark" xml:space="preserve">
<value>搜索,例如 tag:bug OR title:登录</value>
</data>
<data name="Board_Search" xml:space="preserve">
<value>搜索</value>
</data>
<data name="Board_ClearSearch" xml:space="preserve">
<value>清空</value>
</data>
<data name="Search_Error_Title" xml:space="preserve">
<value>搜索表达式错误</value>
</data>
<data name="Search_Error_Message" xml:space="preserve">
<value>查询表达式有误,详情:{0}</value>
</data>
<data name="Export_FileTypeMarkdown" xml:space="preserve">
<value>Markdown 文档</value>
</data>
<data name="Export_Failed_Title" xml:space="preserve">
<value>导出失败</value>
</data>
<data name="Export_Failed_Message" xml:space="preserve">
<value>无法写入导出文件。</value>
</data>
</root> </root>
+74 -6
View File
@@ -11,8 +11,12 @@ using YKanBan.ViewModels.Dialogs;
namespace YKanBan.ViewModels.Board; namespace YKanBan.ViewModels.Board;
/// <summary> /// <summary>
/// The board tab: the toolbar (new column, column width, card sort) and the /// The board tab: the toolbar (search bar, new column, column width, card sort)
/// columns laid out left to right, each listing its cards top to bottom. /// 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 <see cref="Refresh"/>, which re-queries with the /// Every write is followed by <see cref="Refresh"/>, which re-queries with the
/// most recently successful search expression (<see cref="ActiveFilter"/>), not /// most recently successful search expression (<see cref="ActiveFilter"/>), not
@@ -50,6 +54,12 @@ public sealed partial class BoardViewModel : ViewModelBase
Refresh(); Refresh();
} }
/// <summary>
/// Raised after every board write (the refresh included), so the tags tab
/// can update its usage counts.
/// </summary>
public event EventHandler? DataWritten;
/// <summary> /// <summary>
/// Gets the columns in id order. /// Gets the columns in id order.
/// </summary> /// </summary>
@@ -70,6 +80,18 @@ public sealed partial class BoardViewModel : ViewModelBase
/// </summary> /// </summary>
public CompiledSearch? ActiveFilter { get; set; } public CompiledSearch? ActiveFilter { get; set; }
/// <summary>
/// Gets or sets the search box text; it only takes effect on <see cref="SearchCommand"/>.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSearchText))]
private string searchText = "";
/// <summary>
/// Gets whether the search box holds text (shows the clear button).
/// </summary>
public bool HasSearchText => SearchText.Length > 0;
/// <summary> /// <summary>
/// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board. /// Gets or sets the selected card sort option; changing it is persisted and re-sorts the board.
/// </summary> /// </summary>
@@ -99,6 +121,43 @@ public sealed partial class BoardViewModel : ViewModelBase
} }
} }
/// <summary>
/// Parses and runs the search box text; blank text shows every card. An
/// invalid expression only shows the error dialog.
/// </summary>
/// <returns>A task completing after the refresh or the error dialog.</returns>
[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();
}
/// <summary>
/// Empties the search box and shows every card again.
/// </summary>
[RelayCommand]
private void ClearSearch()
{
SearchText = "";
ActiveFilter = null;
Refresh();
}
/// <summary> /// <summary>
/// Opens the new-column dialog. /// Opens the new-column dialog.
/// </summary> /// </summary>
@@ -109,7 +168,7 @@ public sealed partial class BoardViewModel : ViewModelBase
var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description)); var dialog = new ColumnEditorDialogViewModel(null, (title, description) => _repository.AddColumn(title, description));
if (await _dialogs.ShowAsync(dialog)) 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)); column, (title, description) => _repository.UpdateColumn(column.Id, title, description));
if (await _dialogs.ShowAsync(dialog)) if (await _dialogs.ShowAsync(dialog))
{ {
Refresh(); RefreshAfterWrite();
} }
} }
@@ -176,7 +235,7 @@ public sealed partial class BoardViewModel : ViewModelBase
await _dialogs.ShowAsync(editor); await _dialogs.ShowAsync(editor);
// Refresh even after a cancel: the nested picker may have created tags. // Refresh even after a cancel: the nested picker may have created tags.
Refresh(); RefreshAfterWrite();
} }
/// <summary> /// <summary>
@@ -196,7 +255,7 @@ public sealed partial class BoardViewModel : ViewModelBase
var editor = CardEditorDialogViewModel.ForExistingCard( var editor = CardEditorDialogViewModel.ForExistingCard(
_repository, _dialogs, card, _config.Confirmations.DiscardEdit); _repository, _dialogs, card, _config.Confirmations.DiscardEdit);
await _dialogs.ShowAsync(editor); await _dialogs.ShowAsync(editor);
Refresh(); RefreshAfterWrite();
} }
/// <summary> /// <summary>
@@ -255,7 +314,16 @@ public sealed partial class BoardViewModel : ViewModelBase
await _dialogs.ShowMessageAsync( await _dialogs.ShowMessageAsync(
Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message); Resources.Error_Operation_Title, Resources.Error_Operation_Message, exception.Message);
} }
RefreshAfterWrite();
}
/// <summary>
/// Refreshes after a write and announces it.
/// </summary>
private void RefreshAfterWrite()
{
Refresh(); Refresh();
DataWritten?.Invoke(this, EventArgs.Empty);
} }
/// <summary> /// <summary>
@@ -13,6 +13,13 @@ public interface IDialogService
/// <param name="dialog">The dialog view model; its window is resolved by naming convention.</param> /// <param name="dialog">The dialog view model; its window is resolved by naming convention.</param>
/// <returns>The dialog result: <see langword="true"/> when it was accepted.</returns> /// <returns>The dialog result: <see langword="true"/> when it was accepted.</returns>
Task<bool> ShowAsync(DialogViewModelBase dialog); Task<bool> ShowAsync(DialogViewModelBase dialog);
/// <summary>
/// Shows the system save-file dialog for a Markdown file.
/// </summary>
/// <param name="suggestedFileName">The proposed file name.</param>
/// <returns>The chosen local path, or <see langword="null"/> when the user cancelled.</returns>
Task<string?> PickSaveFilePathAsync(string suggestedFileName);
} }
/// <summary> /// <summary>
+26
View File
@@ -0,0 +1,26 @@
using YKanBan.Models;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The default colors proposed for new tags, shared by the tag picker and the
/// tag editor.
/// </summary>
public static class TagColors
{
/// <summary>
/// Gets the palette, offered as swatches and cycled for new-tag defaults.
/// </summary>
public static IReadOnlyList<RgbColor> 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),
];
/// <summary>
/// Returns the default color of a new tag, cycled by the number of existing tags.
/// </summary>
/// <param name="existingCount">The number of tags already in the workspace.</param>
/// <returns>The proposed color.</returns>
public static RgbColor DefaultFor(int existingCount) => Palette[existingCount % Palette.Count];
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed partial class TagEditorDialogViewModel : DialogViewModelBase
{
private readonly Action<string, RgbColor, string> _save;
/// <summary>
/// Initializes the dialog.
/// </summary>
/// <param name="existing">The tag being edited, or <see langword="null"/> for a new tag.</param>
/// <param name="defaultColor">The color proposed for a new tag.</param>
/// <param name="save">Writes the name, color and description; throws the repository's typed exceptions.</param>
public TagEditorDialogViewModel(TagModel? existing, RgbColor defaultColor, Action<string, RgbColor, string> 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();
}
/// <summary>
/// Gets the localized window title.
/// </summary>
public string Title { get; }
/// <summary>
/// Gets the palette swatches; clicking one sets <see cref="Color"/>.
/// </summary>
public IReadOnlyList<ColorSwatch> Swatches { get; }
/// <summary>
/// Gets or sets the tag name being edited (not trimmed).
/// </summary>
[ObservableProperty]
private string name;
/// <summary>
/// Gets or sets the #RRGGBB color text being edited.
/// </summary>
[ObservableProperty]
private string color;
/// <summary>
/// Gets or sets the tag description being edited.
/// </summary>
[ObservableProperty]
private string description;
/// <summary>
/// Gets the preview of <see cref="Color"/>, transparent while it is not valid.
/// </summary>
[ObservableProperty]
private IBrush colorPreview = Brushes.Transparent;
/// <summary>
/// Gets the localized error of the last confirmation, or <see langword="null"/>.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
private string? errorMessage;
/// <summary>
/// Gets whether an error is shown.
/// </summary>
public bool HasError => ErrorMessage is not null;
/// <summary>
/// Takes a swatch color.
/// </summary>
/// <param name="hex">The swatch's #RRGGBB text.</param>
[RelayCommand]
private void PickSwatch(string hex) => Color = hex;
/// <summary>
/// Writes the tag; closes on success, otherwise shows the error.
/// </summary>
[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);
}
/// <summary>
/// Closes without writing.
/// </summary>
[RelayCommand]
private Task CancelAsync() => RequestCancelAsync();
/// <summary>
/// Refreshes the color preview.
/// </summary>
/// <param name="value">The new color text.</param>
partial void OnColorChanged(string value) => UpdateColorPreview();
/// <summary>
/// Recomputes <see cref="ColorPreview"/> from <see cref="Color"/>.
/// </summary>
private void UpdateColorPreview() =>
ColorPreview = RgbColor.TryParse(Color, out RgbColor parsed) ? ToBrush(parsed) : Brushes.Transparent;
/// <summary>
/// Creates the thread-agnostic brush of a tag color.
/// </summary>
/// <param name="value">The color.</param>
/// <returns>The brush.</returns>
private static IBrush ToBrush(RgbColor value) =>
new ImmutableSolidColorBrush(Avalonia.Media.Color.FromRgb(value.R, value.G, value.B));
}
/// <summary>
/// A palette swatch of the tag editor.
/// </summary>
/// <param name="Hex">The #RRGGBB text.</param>
/// <param name="Brush">The fill.</param>
public sealed record ColorSwatch(string Hex, IBrush Brush);
@@ -21,15 +21,6 @@ namespace YKanBan.ViewModels.Dialogs;
/// </summary> /// </summary>
public sealed partial class TagPickerDialogViewModel : DialogViewModelBase public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
{ {
/// <summary>
/// Default colors proposed for new tags, cycled by the number of existing tags.
/// </summary>
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 WorkspaceRepository _repository;
private readonly IReadOnlyList<TagBadgeViewModel> _available; private readonly IReadOnlyList<TagBadgeViewModel> _available;
@@ -48,7 +39,7 @@ public sealed partial class TagPickerDialogViewModel : DialogViewModelBase
.ToList(); .ToList();
FilteredTags = new ObservableCollection<TagBadgeViewModel>(_available); FilteredTags = new ObservableCollection<TagBadgeViewModel>(_available);
newColor = Palette[all.Count % Palette.Length].ToString(); newColor = TagColors.DefaultFor(all.Count).ToString();
UpdateColorPreview(); UpdateColorPreview();
} }
@@ -0,0 +1,77 @@
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels.Tags;
/// <summary>
/// One row of the tags tab: the colored badge, the description and the usage
/// count, with edit and delete actions.
/// </summary>
public sealed partial class TagRowViewModel : ViewModelBase
{
private readonly TagsViewModel _owner;
/// <summary>
/// Initializes the row.
/// </summary>
/// <param name="owner">The tags tab performing the actions.</param>
/// <param name="tag">The tag shown.</param>
/// <param name="usageCount">The number of cards carrying the tag.</param>
public TagRowViewModel(TagsViewModel owner, TagModel tag, long usageCount)
{
_owner = owner;
Tag = tag;
Badge = new TagBadgeViewModel(tag);
UsageCount = usageCount;
}
/// <summary>
/// Gets the tag shown.
/// </summary>
public TagModel Tag { get; }
/// <summary>
/// Gets the colored badge (color + name).
/// </summary>
public TagBadgeViewModel Badge { get; }
/// <summary>
/// Gets the tag description.
/// </summary>
public string Description => Tag.Description;
/// <summary>
/// Gets whether the tag has a description.
/// </summary>
public bool HasDescription => Tag.Description.Length > 0;
/// <summary>
/// Gets the #RRGGBB color text.
/// </summary>
public string ColorText => Tag.Color.ToString();
/// <summary>
/// Gets the number of cards carrying the tag.
/// </summary>
public long UsageCount { get; }
/// <summary>
/// Gets the localized usage count, for example "3 cards".
/// </summary>
public string UsageText => string.Format(Resources.Culture, Resources.Tags_UsageCount, UsageCount);
/// <summary>
/// Opens the edit dialog.
/// </summary>
/// <returns>A task completing when the dialog closes.</returns>
[RelayCommand]
private Task EditAsync() => _owner.EditTagAsync(Tag);
/// <summary>
/// Deletes the tag.
/// </summary>
/// <returns>A task completing when the tag is deleted or the deletion was declined.</returns>
[RelayCommand]
private Task DeleteAsync() => _owner.DeleteTagAsync(Tag, UsageCount);
}
+144
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed partial class TagsViewModel : ViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly AppConfig _config;
private readonly IDialogService _dialogs;
/// <summary>
/// Initializes the tab and loads it.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="config">The live application configuration (delete confirmation).</param>
/// <param name="dialogs">The modal dialog service.</param>
public TagsViewModel(WorkspaceRepository repository, AppConfig config, IDialogService dialogs)
{
_repository = repository;
_config = config;
_dialogs = dialogs;
Refresh();
}
/// <summary>
/// Raised after every tag write, so the board can show the new names and colors.
/// </summary>
public event EventHandler? TagsChanged;
/// <summary>
/// Gets the tags in id order.
/// </summary>
public ObservableCollection<TagRowViewModel> Tags { get; } = [];
/// <summary>
/// Gets the localized total, for example "3 tags".
/// </summary>
public string Summary => string.Format(Resources.Culture, Resources.Tags_Summary, Tags.Count);
/// <summary>
/// Gets whether the workspace has no tags yet.
/// </summary>
public bool IsEmpty => Tags.Count == 0;
/// <summary>
/// Reloads the tags with their usage counts.
/// </summary>
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));
}
/// <summary>
/// Opens the new-tag dialog.
/// </summary>
/// <returns>A task completing when the dialog closes.</returns>
[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();
}
}
/// <summary>
/// Opens the edit-tag dialog.
/// </summary>
/// <param name="tag">The tag to edit.</param>
/// <returns>A task completing when the dialog closes.</returns>
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();
}
}
/// <summary>
/// Deletes a tag (removing it from its cards), asking first when that
/// confirmation is enabled.
/// </summary>
/// <param name="tag">The tag to delete.</param>
/// <param name="usageCount">The number of cards carrying it.</param>
/// <returns>A task completing when the tag is deleted or the deletion was declined.</returns>
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();
}
/// <summary>
/// Reloads the list and tells the board that tags changed.
/// </summary>
private void AfterWrite()
{
Refresh();
TagsChanged?.Invoke(this, EventArgs.Empty);
}
}
+42 -6
View File
@@ -1,20 +1,26 @@
using System.Diagnostics;
using System.Text;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using YKanBan.Export;
using YKanBan.Storage.AppData; using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace; using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board; using YKanBan.ViewModels.Board;
using YKanBan.ViewModels.Dialogs; using YKanBan.ViewModels.Dialogs;
using YKanBan.ViewModels.Tags;
namespace YKanBan.ViewModels; namespace YKanBan.ViewModels;
/// <summary> /// <summary>
/// The normal workspace view: the top display area (name, path and the export, /// 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 /// settings and about buttons) plus the board and tags tabs. A write on either
/// filled in by the board and tag features. /// tab refreshes the other: tag edits change the board's badges and filter
/// matches, card edits change the tags' usage counts.
/// </summary> /// </summary>
public sealed partial class WorkspaceViewModel : ViewModelBase public sealed partial class WorkspaceViewModel : ViewModelBase
{ {
private readonly Interaction<Unit> _showSettings; private readonly Interaction<Unit> _showSettings;
private readonly Interaction<Unit> _showAbout; private readonly Interaction<Unit> _showAbout;
private readonly IDialogService _dialogs;
/// <summary> /// <summary>
/// Initializes the view for an open workspace. /// Initializes the view for an open workspace.
@@ -23,7 +29,7 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// <param name="showSettings">The interaction that opens the settings dialog.</param> /// <param name="showSettings">The interaction that opens the settings dialog.</param>
/// <param name="showAbout">The interaction that opens the about dialog.</param> /// <param name="showAbout">The interaction that opens the about dialog.</param>
/// <param name="config">The live application configuration.</param> /// <param name="config">The live application configuration.</param>
/// <param name="dialogs">The modal dialog service of the board.</param> /// <param name="dialogs">The modal dialog service.</param>
public WorkspaceViewModel( public WorkspaceViewModel(
WorkspaceSession session, WorkspaceSession session,
Interaction<Unit> showSettings, Interaction<Unit> showSettings,
@@ -36,7 +42,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
WorkspacePath = session.FolderPath; WorkspacePath = session.FolderPath;
_showSettings = showSettings; _showSettings = showSettings;
_showAbout = showAbout; _showAbout = showAbout;
_dialogs = dialogs;
Board = new BoardViewModel(session.Repository, config, dialogs); Board = new BoardViewModel(session.Repository, config, dialogs);
Tags = new TagsViewModel(session.Repository, config, dialogs);
Board.DataWritten += (_, _) => Tags.Refresh();
Tags.TagsChanged += (_, _) => Board.Refresh();
} }
/// <summary> /// <summary>
@@ -59,6 +69,11 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// </summary> /// </summary>
public BoardViewModel Board { get; } public BoardViewModel Board { get; }
/// <summary>
/// Gets the tags tab.
/// </summary>
public TagsViewModel Tags { get; }
/// <summary> /// <summary>
/// Opens the settings dialog through the registered interaction. /// Opens the settings dialog through the registered interaction.
/// </summary> /// </summary>
@@ -72,11 +87,32 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value); private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
/// <summary> /// <summary>
/// Raised by the export button; the export flow is wired up with the export /// Exports the whole workspace to a Markdown file chosen in the system save
/// feature. /// dialog. The export ignores the board's search filter and card sort; the
/// default file name and all template text follow the UI language.
/// </summary> /// </summary>
/// <returns>A task completing when the file is written or the export was cancelled.</returns>
[RelayCommand] [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);
}
} }
} }
+48 -23
View File
@@ -13,29 +13,54 @@
<Grid RowDefinitions="Auto,*" RowSpacing="8"> <Grid RowDefinitions="Auto,*" RowSpacing="8">
<!-- Toolbar. The search bar joins on the left with the search feature. --> <!-- Toolbar: search bar on the left; new column, column width and card sort on the right. -->
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto" ColumnSpacing="16">
<Button Command="{Binding AddColumnCommand}"> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="6"> <TextBox Text="{Binding SearchText}" Width="420"
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/> Watermark="{x:Static lang:Resources.Board_SearchWatermark}">
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/> <TextBox.KeyBindings>
</StackPanel> <KeyBinding Gesture="Enter" Command="{Binding SearchCommand}"/>
</Button> </TextBox.KeyBindings>
<Button Command="{Binding AdjustColumnWidthCommand}"> <TextBox.InnerLeftContent>
<StackPanel Orientation="Horizontal" Spacing="6"> <icons:PackIconLucide Kind="Search" Width="14" Height="14" Margin="8,0,0,0" Opacity="0.6"
<icons:PackIconLucide Kind="MoveHorizontal" Width="16" Height="16"/> VerticalAlignment="Center"/>
<TextBlock Text="{x:Static lang:Resources.Board_ColumnWidth}"/> </TextBox.InnerLeftContent>
</StackPanel> <TextBox.InnerRightContent>
</Button> <Button Command="{Binding ClearSearchCommand}" IsVisible="{Binding HasSearchText}"
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SelectedSort}" MinWidth="160" Theme="{StaticResource BorderlessButton}" Padding="6,0"
ToolTip.Tip="{x:Static lang:Resources.Board_Sort}"> MinHeight="0" Height="24" VerticalAlignment="Center"
<ComboBox.ItemTemplate> ToolTip.Tip="{x:Static lang:Resources.Board_ClearSearch}">
<DataTemplate x:DataType="vmBase:OptionItem"> <icons:PackIconLucide Kind="X" Width="14" Height="14"/>
<TextBlock Text="{Binding Label}"/> </Button>
</DataTemplate> </TextBox.InnerRightContent>
</ComboBox.ItemTemplate> </TextBox>
</ComboBox> <Button Command="{Binding SearchCommand}" Classes="Primary">
</StackPanel> <TextBlock Text="{x:Static lang:Resources.Board_Search}"/>
</Button>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Command="{Binding AddColumnCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
</StackPanel>
</Button>
<Button Command="{Binding AdjustColumnWidthCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="MoveHorizontal" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_ColumnWidth}"/>
</StackPanel>
</Button>
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SelectedSort}" MinWidth="160"
ToolTip.Tip="{x:Static lang:Resources.Board_Sort}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vmBase:OptionItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
<!-- Columns left to right; each column scrolls its own virtualized card list. --> <!-- Columns left to right; each column scrolls its own virtualized card list. -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"> <ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
+22
View File
@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage;
using YKanBan.ViewModels.Dialogs; using YKanBan.ViewModels.Dialogs;
using YKanBan.Views.Dialogs; using YKanBan.Views.Dialogs;
@@ -50,6 +51,27 @@ public sealed class DialogService : IDialogService
} }
} }
/// <inheritdoc/>
public async Task<string?> 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();
}
/// <summary> /// <summary>
/// Creates the window class matching a dialog view model by naming convention. /// Creates the window class matching a dialog view model by naming convention.
/// </summary> /// </summary>
@@ -6,7 +6,7 @@
x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog" x:Class="YKanBan.Views.Dialogs.ColumnEditorDialog"
x:DataType="vm:ColumnEditorDialogViewModel" x:DataType="vm:ColumnEditorDialogViewModel"
Title="{Binding Title}" Title="{Binding Title}"
Width="440" SizeToContent="Height" Width="440"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="14"> <StackPanel Margin="20" Spacing="14">
@@ -0,0 +1,61 @@
<dialogs:DialogWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:dialogs="using:YKanBan.Views.Dialogs"
xmlns:lang="using:YKanBan"
x:Class="YKanBan.Views.Dialogs.TagEditorDialog"
x:DataType="vm:TagEditorDialogViewModel"
x:Name="Root"
Title="{Binding Title}"
SizeToContent="Height" Width="440"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="20" Spacing="14">
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Field_Name}"/>
<TextBox Text="{Binding Name}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="{x:Static lang:Resources.TagEditor_ColorLabel}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Color}" Width="120" Watermark="{x:Static lang:Resources.Field_Color}"/>
<Border Width="28" Height="28" CornerRadius="4" Background="{Binding ColorPreview}"
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Swatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ColorSwatch">
<Button Command="{Binding #Root.((vm:TagEditorDialogViewModel)DataContext).PickSwatchCommand}"
CommandParameter="{Binding Hex}"
ToolTip.Tip="{Binding Hex}"
Width="24" Height="24" Padding="0" CornerRadius="4"
Background="{Binding Brush}"
BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="{x:Static lang:Resources.Field_Description}"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="72" MaxHeight="200"/>
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" IsVisible="{Binding HasError}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Ok}" Command="{Binding ConfirmCommand}"
Classes="Primary" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Command="{Binding CancelCommand}"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</StackPanel>
</dialogs:DialogWindow>
@@ -0,0 +1,9 @@
namespace YKanBan.Views.Dialogs;
public partial class TagEditorDialog : DialogWindow
{
public TagEditorDialog()
{
InitializeComponent();
}
}
+76
View File
@@ -0,0 +1,76 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Tags"
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
x:Class="YKanBan.Views.Tags.TagsView"
x:DataType="vm:TagsViewModel">
<Grid RowDefinitions="Auto,*" RowSpacing="8">
<!-- Toolbar: tag total on the left, new tag on the right. -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Summary}" VerticalAlignment="Center" Opacity="0.7"/>
<Button Grid.Column="1" Command="{Binding AddTagCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Tags_AddTag}"/>
</StackPanel>
</Button>
</Grid>
<!-- Tag list: badge, description, usage count, edit and delete. -->
<Border Grid.Row="1" BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="1" CornerRadius="6">
<Panel>
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}"/>
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagRowViewModel">
<Border BorderBrush="{DynamicResource PanelBorderBrush}" BorderThickness="0,0,0,1"
Padding="16,10">
<Grid ColumnDefinitions="240,*,Auto,Auto" ColumnSpacing="16">
<Border Grid.Column="0" HorizontalAlignment="Left" VerticalAlignment="Center"
Background="{Binding Badge.Background}" CornerRadius="10" Padding="10,2"
ToolTip.Tip="{Binding ColorText}">
<TextBlock Text="{Binding Badge.Name}" Foreground="{Binding Badge.Foreground}"
FontSize="12" TextTrimming="CharacterEllipsis"/>
</Border>
<TextBlock Grid.Column="1" Text="{Binding Description}" VerticalAlignment="Center"
Opacity="0.75" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="2" Text="{Binding UsageText}" VerticalAlignment="Center"
Opacity="0.7"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
<Button Command="{Binding EditCommand}" Theme="{StaticResource BorderlessButton}"
Padding="6" ToolTip.Tip="{x:Static lang:Resources.Menu_Edit}">
<icons:PackIconLucide Kind="Pencil" Width="14" Height="14"/>
</Button>
<Button Command="{Binding DeleteCommand}" Theme="{StaticResource BorderlessButton}"
Padding="6" ToolTip.Tip="{x:Static lang:Resources.Menu_Delete}">
<icons:PackIconLucide Kind="Trash2" Width="14" Height="14"/>
</Button>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{x:Static lang:Resources.Tags_Empty}" IsVisible="{Binding IsEmpty}"
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
</Panel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace YKanBan.Views.Tags;
public partial class TagsView : UserControl
{
public TagsView()
{
InitializeComponent();
}
}
+4 -2
View File
@@ -48,12 +48,14 @@
</Grid> </Grid>
</Border> </Border>
<!-- Board and tags tabs; the tags tab is filled in by the tag feature. --> <!-- Board and tags tabs. -->
<TabControl Grid.Row="1" Margin="8"> <TabControl Grid.Row="1" Margin="8">
<TabItem Header="{x:Static lang:Resources.Tab_Board}"> <TabItem Header="{x:Static lang:Resources.Tab_Board}">
<ContentControl Content="{Binding Board}" Margin="0,8,0,0"/> <ContentControl Content="{Binding Board}" Margin="0,8,0,0"/>
</TabItem> </TabItem>
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/> <TabItem Header="{x:Static lang:Resources.Tab_Tags}">
<ContentControl Content="{Binding Tags}" Margin="0,8,0,0"/>
</TabItem>
</TabControl> </TabControl>
</Grid> </Grid>
+1 -1
View File
@@ -374,7 +374,7 @@ phrase := '"' 任意文本 '"' (内部支持转义:\" → 字面引号,
| M4 GUI 框架 | 已完成 | 主题/图标接入、主窗口布局(顶部区 + TabControl + 全窗口错误页体系)、设置/关于模态;全 UI 文本资源化(硬规则生效)、语言设置项、RTL/FlowDirection 检测;亮/暗/跟随系统主题基础设施与设置项、ThemeDictionaries 颜色硬规则 | 人工 | | M4 GUI 框架 | 已完成 | 主题/图标接入、主窗口布局(顶部区 + TabControl + 全窗口错误页体系)、设置/关于模态;全 UI 文本资源化(硬规则生效)、语言设置项、RTL/FlowDirection 检测;亮/暗/跟随系统主题基础设施与设置项、ThemeDictionaries 颜色硬规则 | 人工 |
| **M4R v1.9 回补** | 已完成 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 | | **M4R v1.9 回补** | 已完成 | 见下方清单 | 本地 Windows build + test 通过;新增页面人工 |
| M5 看板界面 | 已完成(待人工验收) | 看板 tab 全部交互与模态(含约束冲突模态内提示、卡片列表虚拟化、嵌套模态新建标签立即写库、写库后按最近成功表达式刷新) | 人工 | | 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 结构自查通过;实际触发与产物验证视网络条件推迟 | | M7 发布与文档 | 待办 | 单一 workflow(4 RID 矩阵 build→test→artifact,osx-x64 用 Intel 运行器)文件就绪(结构自查)、根目录 USAGE.md、README(引用 USAGE.md)、人工验收清单(含语言切换重启生效、导出语言跟随界面、主题三态切换、app.json 多实例脏写四项) | workflow 结构自查通过;实际触发与产物验证视网络条件推迟 |
**M4R 清单**(v1.9 对已完成里程碑的回补,进入 M5 前完成): **M4R 清单**(v1.9 对已完成里程碑的回补,进入 M5 前完成):