feat: add tag manager, search filtering and export

This commit is contained in:
yyc12345 committed 2026-10-04 09:43:25 +08:00
1 parent 830f1153fd
commit 0fdd94d109
28 files changed
+1022 -24

No files matched your search

@@ -1,5 +1,6 @@
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Search;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
@@ -257,6 +258,17 @@ public class WorkspaceRepositoryTests
fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
}
[TestMethod]
public void GetCardIdsMatchingRunsTheCompiledSearch()
{
using var fixture = new RepositoryFixture();
CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("T"));
IReadOnlyCollection<long> ids = fixture.Repository.GetCardIdsMatching(compiled);
CollectionAssert.AreEquivalent(new[] { fixture.CardId }, ids.ToList());
}
[TestMethod]
public void GetTagsWithUsageCountsAssignments()
{
+1
View File
@@ -12,6 +12,7 @@
<Application.Styles>
<!-- Semi.Avalonia provides the light and dark palettes; icons need their own theme. -->
<semi:SemiTheme/>
<semi:ColorPickerSemiTheme/>
<StyleInclude Source="avares://IconPacks.Avalonia.Lucide/Lucide.axaml"/>
</Application.Styles>
+54
View File
@@ -28,6 +28,9 @@
<data name="Common_Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Common_Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="Main_Export" xml:space="preserve">
<value>Export</value>
</data>
@@ -70,6 +73,18 @@
<data name="Board_NoColumns" xml:space="preserve">
<value>No columns yet.</value>
</data>
<data name="Board_FilterActive" xml:space="preserve">
<value>Board is filtered</value>
</data>
<data name="Board_FilterInactive" xml:space="preserve">
<value>Board is not filtered</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="Sort_IdAsc" xml:space="preserve">
<value>ID Ascending</value>
</data>
@@ -166,6 +181,39 @@
<data name="TagDialog_NoTargets" xml:space="preserve">
<value>There are no more tags to add.</value>
</data>
<data name="Tags_AddTag" xml:space="preserve">
<value>Add Tag</value>
</data>
<data name="Tags_Empty" xml:space="preserve">
<value>No tags yet.</value>
</data>
<data name="Tags_Usage" xml:space="preserve">
<value>{0} cards</value>
</data>
<data name="TagEditor_AddTitle" xml:space="preserve">
<value>New Tag</value>
</data>
<data name="TagEditor_EditTitle" xml:space="preserve">
<value>Edit Tag</value>
</data>
<data name="TagEditor_Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="TagEditor_Color" xml:space="preserve">
<value>Color</value>
</data>
<data name="TagEditor_Description" xml:space="preserve">
<value>Description</value>
</data>
<data name="TagEditor_InvalidName" xml:space="preserve">
<value>The name is empty or already used.</value>
</data>
<data name="DeleteTag_Confirm_Title" xml:space="preserve">
<value>Delete Tag</value>
</data>
<data name="DeleteTag_Confirm_Message" xml:space="preserve">
<value>Delete tag "{0}"? It will be removed from {1} card(s).</value>
</data>
<data name="MoveDialog_Title" xml:space="preserve">
<value>Move Card</value>
</data>
@@ -391,4 +439,10 @@
<data name="Export_TagsTableHeader" xml:space="preserve">
<value>| Name | Color | Description | Usage |</value>
</data>
<data name="Export_Done_Title" xml:space="preserve">
<value>Export Complete</value>
</data>
<data name="Export_Done_Message" xml:space="preserve">
<value>Exported to: {0}</value>
</data>
</root>
@@ -28,6 +28,9 @@
<data name="Common_Delete" xml:space="preserve">
<value>删除</value>
</data>
<data name="Common_Edit" xml:space="preserve">
<value>编辑</value>
</data>
<data name="Main_Export" xml:space="preserve">
<value>导出</value>
</data>
@@ -70,6 +73,18 @@
<data name="Board_NoColumns" xml:space="preserve">
<value>还没有列。</value>
</data>
<data name="Board_FilterActive" xml:space="preserve">
<value>看板处于过滤状态</value>
</data>
<data name="Board_FilterInactive" 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="Sort_IdAsc" xml:space="preserve">
<value>ID 正序</value>
</data>
@@ -166,6 +181,39 @@
<data name="TagDialog_NoTargets" xml:space="preserve">
<value>没有可添加的标签。</value>
</data>
<data name="Tags_AddTag" xml:space="preserve">
<value>添加标签</value>
</data>
<data name="Tags_Empty" xml:space="preserve">
<value>还没有标签。</value>
</data>
<data name="Tags_Usage" xml:space="preserve">
<value>{0} 张卡片</value>
</data>
<data name="TagEditor_AddTitle" xml:space="preserve">
<value>新建标签</value>
</data>
<data name="TagEditor_EditTitle" xml:space="preserve">
<value>编辑标签</value>
</data>
<data name="TagEditor_Name" xml:space="preserve">
<value>名称</value>
</data>
<data name="TagEditor_Color" xml:space="preserve">
<value>颜色</value>
</data>
<data name="TagEditor_Description" xml:space="preserve">
<value>描述</value>
</data>
<data name="TagEditor_InvalidName" xml:space="preserve">
<value>名称为空或已被使用。</value>
</data>
<data name="DeleteTag_Confirm_Title" xml:space="preserve">
<value>删除标签</value>
</data>
<data name="DeleteTag_Confirm_Message" xml:space="preserve">
<value>删除标签“{0}”?将从 {1} 张卡片中移除。</value>
</data>
<data name="MoveDialog_Title" xml:space="preserve">
<value>移动卡片</value>
</data>
@@ -391,4 +439,10 @@
<data name="Export_TagsTableHeader" xml:space="preserve">
<value>| 名称 | 颜色 | 描述 | 使用数 |</value>
</data>
<data name="Export_Done_Title" xml:space="preserve">
<value>导出完成</value>
</data>
<data name="Export_Done_Message" xml:space="preserve">
<value>已导出到:{0}</value>
</data>
</root>
@@ -1,5 +1,6 @@
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Search;
namespace YKanBan.Storage.Workspace;
@@ -172,6 +173,28 @@ public sealed class WorkspaceRepository : IDisposable
return cards;
}
/// <summary>
/// Returns the ids of the cards matching a compiled search predicate.
/// </summary>
/// <param name="search">The compiled search predicate and its parameters.</param>
/// <returns>The matching card ids.</returns>
public IReadOnlyCollection<long> GetCardIdsMatching(CompiledSearch search)
{
using var command = CreateCommand($"SELECT c.id FROM cards c WHERE {search.Predicate};");
foreach ((string name, object value) in search.Parameters)
{
command.Parameters.AddWithValue(name, value);
}
using var reader = command.ExecuteReader();
var ids = new HashSet<long>();
while (reader.Read())
{
ids.Add(reader.GetInt64(0));
}
return ids;
}
/// <summary>
/// Returns the card with the given id, with its tags loaded.
/// </summary>
+79 -6
View File
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.Search;
using YKanBan.Services;
using YKanBan.Storage.AppData;
using YKanBan.Storage.Workspace;
@@ -19,6 +20,9 @@ public sealed partial class BoardViewModel : ViewModelBase
private readonly AppServices _services;
private readonly WorkspaceRepository _repository;
// The active search result filter, or null when the whole board is shown.
private IReadOnlyCollection<long>? _matchedCardIds;
/// <summary>
/// Initializes the board, loading its current content.
/// </summary>
@@ -66,6 +70,21 @@ public sealed partial class BoardViewModel : ViewModelBase
[ObservableProperty]
private double columnWidth;
/// <summary>
/// Gets a value indicating whether a search filter is currently applied.
/// </summary>
public bool IsFiltering => _matchedCardIds is not null;
/// <summary>
/// Gets the localized filter status text shown as the status icon tooltip.
/// </summary>
public string FilterStatusText => IsFiltering ? Resources.Board_FilterActive : Resources.Board_FilterInactive;
/// <summary>
/// Gets the status icon opacity: full while filtering, dimmed otherwise.
/// </summary>
public double FilterIconOpacity => IsFiltering ? 1.0 : 0.4;
/// <summary>
/// Gets a value indicating whether the board has any columns.
/// </summary>
@@ -99,8 +118,8 @@ public sealed partial class BoardViewModel : ViewModelBase
/// <summary>Gets the interaction that opens the move-card dialog.</summary>
public Interaction<CardViewModel> MoveCard { get; } = new();
/// <summary>Gets the interaction that runs the current search.</summary>
public Interaction<Unit> RunSearch { get; } = new();
/// <summary>Gets the interaction that shows the search error dialog, carrying the English parser detail.</summary>
public Interaction<string> ShowSearchError { get; } = new();
#endregion
@@ -113,16 +132,64 @@ public sealed partial class BoardViewModel : ViewModelBase
private Task AddNewColumnAsync() => AddColumn.Handle(Unit.Value);
/// <summary>
/// Runs the current search (wired up with the search feature).
/// Runs the current search. A blank query shows everything; an invalid
/// expression opens the error dialog and keeps the previous results and text.
/// </summary>
[RelayCommand]
private Task SearchAsync() => RunSearch.Handle(Unit.Value);
private async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(SearchText))
{
ApplyFilter(null);
return;
}
try
{
SearchNode? query = SearchQueryParser.Parse(SearchText);
if (query is null)
{
ApplyFilter(null);
return;
}
CompiledSearch compiled = SearchSqlCompiler.Compile(query);
ApplyFilter(_repository.GetCardIdsMatching(compiled));
}
catch (SearchSyntaxException exception)
{
// The error dialog carries the parser's non-localized English detail.
await ShowSearchError.Handle(exception.Message);
}
}
/// <summary>
/// Clears the search box.
/// Clears the search box and restores the unfiltered board.
/// </summary>
[RelayCommand]
private void ClearSearch() => SearchText = string.Empty;
private void ClearSearch()
{
SearchText = string.Empty;
ApplyFilter(null);
}
/// <summary>
/// Applies a new result filter (null clears it) and rebuilds the board when it changed.
/// </summary>
/// <param name="matchedIds">The new filter, or <see langword="null"/> for no filter.</param>
private void ApplyFilter(IReadOnlyCollection<long>? matchedIds)
{
if (_matchedCardIds is null && matchedIds is null)
{
return;
}
_matchedCardIds = matchedIds;
OnPropertyChanged(nameof(IsFiltering));
OnPropertyChanged(nameof(FilterStatusText));
OnPropertyChanged(nameof(FilterIconOpacity));
Reload();
}
#endregion
@@ -182,6 +249,12 @@ public sealed partial class BoardViewModel : ViewModelBase
IReadOnlyList<ColumnModel> columns = _repository.GetColumns();
IReadOnlyList<CardModel> cards = _repository.GetCards();
// An active search hides non-matching cards while every column stays visible.
if (_matchedCardIds is not null)
{
cards = cards.Where(card => _matchedCardIds.Contains(card.Id)).ToList();
}
// Group cards by column once so each column can be built independently.
Dictionary<long, List<CardModel>> cardsByColumn = cards
.GroupBy(card => card.ColumnId)
@@ -0,0 +1,29 @@
using System.Globalization;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation shown before deleting a tag, naming the tag and how many
/// cards it will be removed from.
/// </summary>
public sealed class DeleteTagConfirmViewModel : ViewModelBase
{
/// <summary>
/// Initializes the confirmation.
/// </summary>
/// <param name="tagName">The name of the tag to delete.</param>
/// <param name="usageCount">The number of cards using the tag.</param>
public DeleteTagConfirmViewModel(string tagName, long usageCount)
{
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.DeleteTag_Confirm_Message,
tagName,
usageCount);
}
/// <summary>
/// Gets the localized confirmation message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,25 @@
using System.Globalization;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The confirmation dialog shown after a successful Markdown export, naming the
/// written file.
/// </summary>
public sealed class ExportDoneViewModel : ViewModelBase
{
/// <summary>
/// Initializes the dialog with the exported file path.
/// </summary>
/// <param name="filePath">The path the export was written to.</param>
public ExportDoneViewModel(string filePath)
{
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Export_Done_Message, filePath);
}
/// <summary>
/// Gets the localized confirmation message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,27 @@
using System.Globalization;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The pure-information dialog shown when the search expression is invalid. It
/// wraps the parser's non-localized English detail in the localized message.
/// </summary>
public sealed class SearchErrorViewModel : ViewModelBase
{
/// <summary>
/// Initializes the dialog with the parser's English detail.
/// </summary>
/// <param name="detail">The non-localized parser error description.</param>
public SearchErrorViewModel(string detail)
{
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture,
Resources.Search_Error_Message,
detail);
}
/// <summary>
/// Gets the localized error message.
/// </summary>
public string Message { get; }
}
@@ -0,0 +1,97 @@
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Data.Sqlite;
using YKanBan.Models;
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels.Dialogs;
/// <summary>
/// The add/edit tag dialog: a name, a color chosen with a color picker and an
/// optional description. The write happens when the dialog is accepted; a
/// duplicate or empty name keeps the dialog open with an inline error.
/// </summary>
public sealed partial class TagEditorViewModel : ViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly TagModel? _existing;
/// <summary>
/// Initializes the dialog for a new or existing tag.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="existing">The tag to edit, or <see langword="null"/> to add one.</param>
public TagEditorViewModel(WorkspaceRepository repository, TagModel? existing)
{
_repository = repository;
_existing = existing;
name = existing?.Name ?? string.Empty;
description = existing?.Description ?? string.Empty;
color = existing is not null
? Color.FromRgb(existing.Color.R, existing.Color.G, existing.Color.B)
: Colors.Gray;
}
/// <summary>
/// Gets a value indicating whether this dialog creates a new tag.
/// </summary>
public bool IsNew => _existing is null;
/// <summary>
/// Gets the localized window title.
/// </summary>
public string WindowTitle => IsNew ? Resources.TagEditor_AddTitle : Resources.TagEditor_EditTitle;
/// <summary>
/// Gets or sets the tag name.
/// </summary>
[ObservableProperty]
private string name;
/// <summary>
/// Gets or sets the tag color.
/// </summary>
[ObservableProperty]
private Color color;
/// <summary>
/// Gets or sets the tag description.
/// </summary>
[ObservableProperty]
private string description;
/// <summary>
/// Gets or sets the inline error shown when the name is rejected.
/// </summary>
[ObservableProperty]
private string? errorMessage;
/// <summary>
/// Writes the tag when accepted.
/// </summary>
/// <returns><see langword="true"/> when the dialog may close.</returns>
public bool TryAccept()
{
ErrorMessage = null;
var rgb = new RgbColor(Color.R, Color.G, Color.B);
try
{
if (_existing is null)
{
_repository.AddTag(Name, rgb, Description);
}
else
{
_repository.UpdateTag(_existing.Id, Name, rgb, Description);
}
return true;
}
catch (SqliteException)
{
ErrorMessage = Resources.TagEditor_InvalidName;
return false;
}
}
}
@@ -0,0 +1,78 @@
using System.Globalization;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.ViewModels.Board;
namespace YKanBan.ViewModels.Tags;
/// <summary>
/// One row of the tag manager: a color swatch, the name, the description and the
/// usage count, plus edit and delete actions.
/// </summary>
public sealed partial class TagItemViewModel
{
private readonly TagsViewModel _owner;
/// <summary>
/// Initializes the row.
/// </summary>
/// <param name="owner">The owning tag manager.</param>
/// <param name="tag">The tag model.</param>
/// <param name="usageCount">The number of cards using the tag.</param>
public TagItemViewModel(TagsViewModel owner, TagModel tag, long usageCount)
{
_owner = owner;
Tag = tag;
Badge = new TagBadgeViewModel(tag);
UsageCount = usageCount;
UsageText = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Tags_Usage, usageCount);
}
/// <summary>
/// Gets the underlying tag model.
/// </summary>
public TagModel Tag { get; }
/// <summary>
/// Gets the colored badge for the swatch and name.
/// </summary>
public TagBadgeViewModel Badge { get; }
/// <summary>
/// Gets the tag name.
/// </summary>
public string Name => Tag.Name;
/// <summary>
/// Gets the tag description.
/// </summary>
public string Description => Tag.Description;
/// <summary>
/// Gets a value indicating whether the tag has a description.
/// </summary>
public bool HasDescription => Tag.Description.Length > 0;
/// <summary>
/// Gets the number of cards using the tag.
/// </summary>
public long UsageCount { get; }
/// <summary>
/// Gets the localized usage count.
/// </summary>
public string UsageText { get; }
/// <summary>
/// Asks the manager to edit this tag.
/// </summary>
[RelayCommand]
private void Edit() => _ = _owner.RequestEditAsync(this);
/// <summary>
/// Asks the manager to delete this tag.
/// </summary>
[RelayCommand]
private void Delete() => _ = _owner.RequestDeleteAsync(this);
}
+102
View File
@@ -0,0 +1,102 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Input;
using YKanBan.Models;
using YKanBan.Storage.Workspace;
namespace YKanBan.ViewModels.Tags;
/// <summary>
/// The tags tab: a manager for the workspace's tags (create, rename, recolor,
/// re-describe and delete), showing each tag's color, name, description and
/// usage count. Tag changes also refresh the board.
/// </summary>
public sealed partial class TagsViewModel : ViewModelBase
{
private readonly WorkspaceRepository _repository;
private readonly Action _boardChanged;
/// <summary>
/// Initializes the manager, loading the current tags.
/// </summary>
/// <param name="repository">The workspace repository.</param>
/// <param name="boardChanged">Invoked after a tag change so the board refreshes.</param>
public TagsViewModel(WorkspaceRepository repository, Action boardChanged)
{
_repository = repository;
_boardChanged = boardChanged;
Reload();
}
/// <summary>
/// Gets the workspace repository used for tag access.
/// </summary>
public WorkspaceRepository Repository => _repository;
/// <summary>
/// Gets the tags in id order.
/// </summary>
public ObservableCollection<TagItemViewModel> Tags { get; } = [];
/// <summary>
/// Gets a value indicating whether the workspace has any tags.
/// </summary>
public bool HasTags => Tags.Count > 0;
/// <summary>
/// Gets a value indicating whether the workspace has no tags.
/// </summary>
public bool HasNoTags => Tags.Count == 0;
/// <summary>Gets the interaction that opens the new-tag editor.</summary>
public Interaction<Unit> AddTag { get; } = new();
/// <summary>Gets the interaction that opens the tag editor.</summary>
public Interaction<TagItemViewModel> EditTag { get; } = new();
/// <summary>Gets the interaction that confirms and deletes a tag.</summary>
public Interaction<TagItemViewModel> DeleteTag { get; } = new();
/// <summary>
/// Opens the new-tag editor.
/// </summary>
[RelayCommand]
private Task AddNewTagAsync() => AddTag.Handle(Unit.Value);
/// <summary>
/// Requests the editor for a tag.
/// </summary>
/// <param name="item">The tag to edit.</param>
/// <returns>A task completing after the editor closes.</returns>
public Task RequestEditAsync(TagItemViewModel item) => EditTag.Handle(item);
/// <summary>
/// Requests deletion of a tag.
/// </summary>
/// <param name="item">The tag to delete.</param>
/// <returns>A task completing after confirmation.</returns>
public Task RequestDeleteAsync(TagItemViewModel item) => DeleteTag.Handle(item);
/// <summary>
/// Reloads the tag list from the repository.
/// </summary>
public void Reload()
{
Tags.Clear();
foreach ((TagModel tag, long usage) in _repository.GetTagsWithUsage())
{
Tags.Add(new TagItemViewModel(this, tag, usage));
}
OnPropertyChanged(nameof(HasTags));
OnPropertyChanged(nameof(HasNoTags));
}
/// <summary>
/// Reloads the tags and refreshes the board after a tag change.
/// </summary>
public void RefreshAfterChange()
{
Reload();
_boardChanged();
}
}
+16 -5
View File
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.Input;
using YKanBan.Services;
using YKanBan.Storage.Workspace;
using YKanBan.ViewModels.Board;
using YKanBan.ViewModels.Tags;
namespace YKanBan.ViewModels;
@@ -32,7 +33,10 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
WorkspacePath = session.FolderPath;
_showSettings = showSettings;
_showAbout = showAbout;
// Both tabs share the same repository; tag changes refresh the board.
Board = new BoardViewModel(services, session);
Tags = new TagsViewModel(session.Repository, Board.Reload);
}
/// <summary>
@@ -55,6 +59,16 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
/// </summary>
public BoardViewModel Board { get; }
/// <summary>
/// Gets the tags tab view model.
/// </summary>
public TagsViewModel Tags { get; }
/// <summary>
/// Gets the interaction that runs the export flow.
/// </summary>
public Interaction<Unit> Export { get; } = new();
/// <summary>
/// Opens the settings dialog through the registered interaction.
/// </summary>
@@ -68,11 +82,8 @@ public sealed partial class WorkspaceViewModel : ViewModelBase
private async Task OpenAboutAsync() => await _showAbout.Handle(Unit.Value);
/// <summary>
/// Raised by the export button; the export flow is wired up with the export
/// feature.
/// Runs the export flow through the registered interaction.
/// </summary>
[RelayCommand]
private static void Export()
{
}
private async Task RunExportAsync() => await Export.Handle(Unit.Value);
}
+19 -7
View File
@@ -13,28 +13,40 @@
<Grid RowDefinitions="Auto,*">
<!-- Toolbar: search, add column and the combined display options. -->
<Grid Grid.Row="0" Margin="8" ColumnDefinitions="*,Auto,Auto,Auto,Auto">
<TextBox Grid.Column="0" Text="{Binding SearchText}" VerticalAlignment="Center"
Watermark="{x:Static lang:Resources.Board_SearchPlaceholder}"/>
<Grid Grid.Row="0" Margin="8" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
<!-- Wrapped in a transparent-border hit area so the tooltip triggers across the whole 16x16 box. -->
<Border Grid.Column="0" Background="Transparent" Padding="2" Margin="0,0,6,0"
VerticalAlignment="Center"
ToolTip.Tip="{Binding FilterStatusText}">
<icons:PackIconLucide Kind="ListFilter" Width="16" Height="16"
Opacity="{Binding FilterIconOpacity}"/>
</Border>
<Button Grid.Column="1" Margin="4,0,0,0" Command="{Binding ClearSearchCommand}"
<TextBox Grid.Column="1" Text="{Binding SearchText}" VerticalAlignment="Center"
Watermark="{x:Static lang:Resources.Board_SearchPlaceholder}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SearchCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Margin="4,0,0,0" Command="{Binding ClearSearchCommand}"
ToolTip.Tip="{x:Static lang:Resources.Board_ClearSearch}">
<icons:PackIconLucide Kind="X" Width="16" Height="16"/>
</Button>
<Button Grid.Column="2" Margin="4,0,0,0" Command="{Binding SearchCommand}"
<Button Grid.Column="3" Margin="4,0,0,0" Command="{Binding SearchCommand}"
ToolTip.Tip="{x:Static lang:Resources.Board_Search}">
<icons:PackIconLucide Kind="Search" Width="16" Height="16"/>
</Button>
<Button Grid.Column="3" Margin="12,0,0,0" Command="{Binding AddNewColumnCommand}">
<Button Grid.Column="4" Margin="12,0,0,0" Command="{Binding AddNewColumnCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_AddColumn}"/>
</StackPanel>
</Button>
<Button Grid.Column="4" Margin="4,0,0,0">
<Button Grid.Column="5" Margin="4,0,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="SlidersHorizontal" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Board_Display}"/>
+16 -3
View File
@@ -27,9 +27,7 @@ public partial class BoardView : UserControl
board.EditCard.RegisterHandler(card => EditCardAsync(board, null, card));
board.DeleteCard.RegisterHandler(card => DeleteCardAsync(board, card));
board.MoveCard.RegisterHandler(card => MoveCardAsync(board, card));
// The search button is present in the shell; searching arrives with the search feature.
board.RunSearch.RegisterHandler(_ => Task.CompletedTask);
board.ShowSearchError.RegisterHandler(ShowSearchErrorAsync);
}
}
@@ -145,4 +143,19 @@ public partial class BoardView : UserControl
board.Reload();
}
}
/// <summary>Shows the search syntax error dialog.</summary>
private async Task ShowSearchErrorAsync(string detail)
{
if (OwningWindow is not Window owner)
{
return;
}
var dialog = new SearchErrorDialog
{
DataContext = new SearchErrorViewModel(detail),
};
await dialog.ShowDialog(owner);
}
}
@@ -0,0 +1,24 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="420" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.DeleteTagConfirmDialog"
x:DataType="vm:DeleteTagConfirmViewModel"
Title="{x:Static lang:Resources.DeleteTag_Confirm_Title}"
Width="420" Height="200" MinWidth="420" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="{x:Static lang:Resources.Common_Delete}" Click="OnConfirmClick"
MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick"
MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class DeleteTagConfirmDialog : Window
{
public DeleteTagConfirmDialog()
{
InitializeComponent();
}
/// <summary>Confirms the deletion.</summary>
private void OnConfirmClick(object? sender, RoutedEventArgs e) => Close(true);
/// <summary>Cancels the deletion.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,21 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="460" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.ExportDoneDialog"
x:DataType="vm:ExportDoneViewModel"
Title="{x:Static lang:Resources.Export_Done_Title}"
Width="460" Height="200" MinWidth="460" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<Button Grid.Row="1" Content="{x:Static lang:Resources.Common_Ok}"
Click="OnOkClick" HorizontalAlignment="Right" MinWidth="90"
IsDefault="True" IsCancel="True"/>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class ExportDoneDialog : Window
{
public ExportDoneDialog()
{
InitializeComponent();
}
/// <summary>Closes the dialog.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,21 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="440" d:DesignHeight="200"
x:Class="YKanBan.Views.Dialogs.SearchErrorDialog"
x:DataType="vm:SearchErrorViewModel"
Title="{x:Static lang:Resources.Search_Error_Title}"
Width="440" Height="200" MinWidth="440" MinHeight="200" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<TextBlock Grid.Row="0" Text="{Binding Message}" TextWrapping="Wrap" VerticalAlignment="Top"/>
<Button Grid.Row="1" Content="{x:Static lang:Resources.Common_Ok}"
Click="OnOkClick" HorizontalAlignment="Right" MinWidth="90"
IsDefault="True" IsCancel="True"/>
</Grid>
</Window>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace YKanBan.Views.Dialogs;
public partial class SearchErrorDialog : Window
{
public SearchErrorDialog()
{
InitializeComponent();
}
/// <summary>Closes the dialog.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e) => Close();
}
@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.Dialogs"
xmlns:lang="using:YKanBan"
xmlns:conv="using:Avalonia.Data.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="440" d:DesignHeight="380"
x:Class="YKanBan.Views.Dialogs.TagEditorDialog"
x:DataType="vm:TagEditorViewModel"
Title="{Binding WindowTitle}"
Width="440" Height="380" MinWidth="440" MinHeight="380" CanResize="True"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid RowDefinitions="*,Auto" Margin="20">
<StackPanel Grid.Row="0" Spacing="6">
<TextBlock Text="{x:Static lang:Resources.TagEditor_Name}"/>
<TextBox Text="{Binding Name}"/>
<TextBlock Text="{x:Static lang:Resources.TagEditor_Color}" Margin="0,6,0,0"/>
<ColorPicker Color="{Binding Color, Mode=TwoWay}" HorizontalAlignment="Left"/>
<TextBlock Text="{x:Static lang:Resources.TagEditor_Description}" Margin="0,6,0,0"/>
<TextBox Text="{Binding Description}" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Height="70"/>
<TextBlock Text="{Binding ErrorMessage}"
IsVisible="{Binding ErrorMessage, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
Foreground="{DynamicResource ErrorTextBrush}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="8" Margin="0,16,0,0">
<Button Content="{x:Static lang:Resources.Common_Ok}" Click="OnOkClick" MinWidth="90" IsDefault="True"/>
<Button Content="{x:Static lang:Resources.Common_Cancel}" Click="OnCancelClick" MinWidth="90" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using YKanBan.ViewModels.Dialogs;
namespace YKanBan.Views.Dialogs;
public partial class TagEditorDialog : Window
{
public TagEditorDialog()
{
InitializeComponent();
}
/// <summary>Writes the tag and closes when accepted.</summary>
private void OnOkClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TagEditorViewModel viewModel && viewModel.TryAccept())
{
Close(true);
}
}
/// <summary>Closes without writing.</summary>
private void OnCancelClick(object? sender, RoutedEventArgs e) => Close(false);
}
+62
View File
@@ -0,0 +1,62 @@
<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,*" Margin="8">
<Button Grid.Row="0" Command="{Binding AddNewTagCommand}"
HorizontalAlignment="Right" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="Plus" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Tags_AddTag}"/>
</StackPanel>
</Button>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagItemViewModel">
<Border BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="0,0,0,1" Padding="8">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Border Grid.Column="0" Width="16" Height="16" CornerRadius="4"
Background="{Binding Badge.Background}"
VerticalAlignment="Center" Margin="0,0,10,0"/>
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Description}" IsVisible="{Binding HasDescription}"
Opacity="0.7" FontSize="12" TextWrapping="Wrap"/>
<TextBlock Text="{Binding UsageText}" Opacity="0.6" FontSize="12"/>
</StackPanel>
<Button Grid.Column="2" Command="{Binding EditCommand}" Padding="4,2" Margin="4,0"
VerticalAlignment="Center"
ToolTip.Tip="{x:Static lang:Resources.Common_Edit}">
<icons:PackIconLucide Kind="Pencil" Width="16" Height="16"/>
</Button>
<Button Grid.Column="3" Command="{Binding DeleteCommand}" Padding="4,2"
VerticalAlignment="Center"
ToolTip.Tip="{x:Static lang:Resources.Common_Delete}">
<icons:PackIconLucide Kind="Trash2" Width="16" Height="16"/>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Grid.Row="1" Text="{x:Static lang:Resources.Tags_Empty}"
IsVisible="{Binding HasNoTags}"
HorizontalAlignment="Center" VerticalAlignment="Center" Opacity="0.6"/>
</Grid>
</UserControl>
+76
View File
@@ -0,0 +1,76 @@
using Avalonia.Controls;
using YKanBan.ViewModels.Dialogs;
using YKanBan.ViewModels.Tags;
using YKanBan.Views.Dialogs;
namespace YKanBan.Views.Tags;
public partial class TagsView : UserControl
{
private bool _registered;
public TagsView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is TagsViewModel tags && !_registered)
{
_registered = true;
tags.AddTag.RegisterHandler(_ => EditTagAsync(tags, null));
tags.EditTag.RegisterHandler(item => EditTagAsync(tags, item));
tags.DeleteTag.RegisterHandler(item => DeleteTagAsync(tags, item));
}
}
/// <summary>Gets the window that owns this view, used as the dialog owner.</summary>
private Window? OwningWindow => TopLevel.GetTopLevel(this) as Window;
/// <summary>Shows the add/edit tag dialog and refreshes on success.</summary>
private async Task EditTagAsync(TagsViewModel tags, TagItemViewModel? item)
{
if (OwningWindow is not Window owner)
{
return;
}
var dialog = new TagEditorDialog
{
DataContext = new TagEditorViewModel(tags.Repository, item?.Tag),
};
if (await dialog.ShowDialog<bool>(owner))
{
tags.RefreshAfterChange();
}
}
/// <summary>Confirms and deletes a tag, then refreshes.</summary>
private async Task DeleteTagAsync(TagsViewModel tags, TagItemViewModel item)
{
if (OwningWindow is not Window owner)
{
return;
}
bool confirmed = true;
if (App.Services?.Config.Confirmations.DeleteTag == true)
{
var dialog = new DeleteTagConfirmDialog
{
DataContext = new DeleteTagConfirmViewModel(item.Name, item.UsageCount),
};
confirmed = await dialog.ShowDialog<bool>(owner);
}
if (!confirmed)
{
return;
}
tags.Repository.DeleteTag(item.Tag.Id);
tags.RefreshAfterChange();
}
}
+5 -2
View File
@@ -4,6 +4,7 @@
xmlns:lang="using:YKanBan"
xmlns:icons="using:IconPacks.Avalonia.Lucide"
xmlns:board="using:YKanBan.Views.Board"
xmlns:tags="using:YKanBan.Views.Tags"
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="640"
@@ -27,7 +28,7 @@
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Command="{Binding ExportCommand}">
<Button Command="{Binding RunExportCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<icons:PackIconLucide Kind="FileDown" Width="16" Height="16"/>
<TextBlock Text="{x:Static lang:Resources.Main_Export}"/>
@@ -54,7 +55,9 @@
<TabItem Header="{x:Static lang:Resources.Tab_Board}">
<board:BoardView DataContext="{Binding Board}"/>
</TabItem>
<TabItem Header="{x:Static lang:Resources.Tab_Tags}"/>
<TabItem Header="{x:Static lang:Resources.Tab_Tags}">
<tags:TagsView DataContext="{Binding Tags}"/>
</TabItem>
</TabControl>
</Grid>
+68
View File
@@ -1,11 +1,79 @@
using System.Text;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using YKanBan.Export;
using YKanBan.ViewModels;
using YKanBan.ViewModels.Dialogs;
using YKanBan.Views.Dialogs;
using Resx = YKanBan.Resources;
namespace YKanBan.Views;
public partial class WorkspaceView : UserControl
{
private bool _registered;
public WorkspaceView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is WorkspaceViewModel viewModel && !_registered)
{
_registered = true;
viewModel.Export.RegisterHandler(ExportAsync);
}
}
/// <summary>Prompts for a file and writes the Markdown board export to it.</summary>
private async Task ExportAsync(Unit input)
{
if (DataContext is not WorkspaceViewModel viewModel)
{
return;
}
if (TopLevel.GetTopLevel(this)?.StorageProvider is not { } storage)
{
return;
}
DateTimeOffset exportedAt = DateTimeOffset.Now;
IStorageFile? file = await storage.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = Resx.Main_Export,
SuggestedFileName = MarkdownExporter.BuildFileName(viewModel.WorkspaceName, exportedAt),
DefaultExtension = "md",
FileTypeChoices = [new FilePickerFileType("Markdown") { Patterns = ["*.md"] }],
});
if (file is null)
{
return;
}
var repository = viewModel.Session.Repository;
string markdown = MarkdownExporter.Export(
viewModel.WorkspaceName,
repository.GetColumns(),
repository.GetCards(),
repository.GetTagsWithUsage(),
exportedAt);
await using (Stream stream = await file.OpenWriteAsync())
await using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))
{
await writer.WriteAsync(markdown);
}
if (TopLevel.GetTopLevel(this) is Window owner)
{
var dialog = new ExportDoneDialog
{
DataContext = new ExportDoneViewModel(file.TryGetLocalPath() ?? file.Name),
};
await dialog.ShowDialog(owner);
}
}
}
+1 -1
View File
@@ -39,7 +39,6 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.6" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.6" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.6" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.6">
@@ -48,6 +47,7 @@
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="Semi.Avalonia" Version="11.2.1.10" />
<PackageReference Include="Semi.Avalonia.ColorPicker" Version="11.2.1.10" />
<PackageReference Include="IconPacks.Avalonia.Lucide" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.20" />
<PackageReference Include="Pidgin" Version="3.5.1" />