diff --git a/docs/ui/README.md b/docs/ui/README.md index 7c98452..0f48da0 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -92,7 +92,7 @@ People opens from the local contact cache and mail history, then refreshes in th ## Mail navigation, search and mailbox layout -Search uses a labelled Filters button, a compact results header, and rows that stretch across the results panel. Opening the filter form preserves plain terms such as `bob`; focusing a nonempty search field or pressing Enter reruns the search, even while an older provider search is pending. References reviewed in Mobbin: [Front advanced search](https://mobbin.com/screens/43b2e9ff-ac6c-4240-9d4a-201fc24fd597), [Skiff search](https://mobbin.com/screens/cce393ee-75b8-48b4-934d-346f1b1583ab), and [Notion Mail filters](https://mobbin.com/screens/37573e33-3175-4605-9082-f0fc04d436be). +Search uses a compact results header with an Advanced filter text button, a full-width loading line above the results, and rows that stretch across the results panel. The main search input has no separate Search/Filters buttons; results dismiss on outside click. Opening the filter form preserves plain terms such as `bob`; focusing a nonempty search field or pressing Enter reruns the search, even while an older provider search is pending. References reviewed in Mobbin: [Front advanced search](https://mobbin.com/screens/43b2e9ff-ac6c-4240-9d4a-201fc24fd597), [Skiff search](https://mobbin.com/screens/cce393ee-75b8-48b4-934d-346f1b1583ab), and [Notion Mail filters](https://mobbin.com/screens/37573e33-3175-4605-9082-f0fc04d436be). Double-click opens the clicked message immediately and hydrates its cached conversation afterward. Thread headers retain selectable text without an extra down-arrow button. Mail-list refreshes restore selected row identities and preserve scroll unless the user navigates or scrolls meanwhile. Sync-issue drafts have a direct × delete action with per-row progress; deleting another issue does not wait for the previous network deletion. @@ -123,3 +123,70 @@ The next-event chip opens today's cached agenda with a live Now marker, past/cur | Event preview | [Light](screenshots/event-details-light.png) · [Dark](screenshots/event-details-dark.png) | All examples use fictional data from the offline native preview. Regression tests cover persisted retry counts, badge recovery, default contact ownership, safe partial updates, richer contact search, cached agenda boundaries, selection-dependent updates and keyboard-accessible thread headers. + +### Separate mail window actions + +**Settings → Mail & notifications → Separate mail windows** provides independent **Stay open** / **Close** choices for Reply, Reply all, Forward, Archive, Delete, Move, junk status, read status, flag and pin. Existing profiles default to Stay open. Close happens after a move/state action is durably queued, before the main-window feedback animation or provider sync finishes. Reply/forward closes the source preview after the composer opens. Cancelling a folder picker or rejecting an action keeps the preview open. Pending work and retry failures remain available in the main window. + +The pending-action line is anchored to the bottom of the entire conversation header in both the reading pane and separate previews. + +| Change | Preview | +| --- | --- | +| Window action settings (light) | [Screenshot](screenshots/preview-action-settings-light.png) | +| Window action settings (dark) | [Screenshot](screenshots/preview-action-settings-dark.png) | +| Thread header pending action | [Screenshot](screenshots/thread-action-bottom-light.png) | + + +### Advanced search and smart badges + +The unfocused global search input renders structured filters as green badges when valid and red badges for invalid syntax or unresolved account/mail-folder references. For example, `type:Mails` is invalid while `type:mail` is valid. Focusing the input restores the exact query text. Settings now places Close to the left of its title. + +Advanced filter automatically generates the query from grouped controls. Types, accounts/shared mailboxes, included/excluded folders, states and categories support multiple choices. Date conditions use date/time pickers and comparison operators; Attachments offers Yes, No and Not set. Mail-specific controls are hidden and omitted from the query when Mail is not among the selected types. Hidden values remain available if Mail is selected again. The editable source is the main search box; the form's generated-query preview is read-only. + +Repeat `type:`, `account:`, `in:` or `category:` for alternatives. Repeated `is:` conditions are combined (unread and flagged, for example); read plus unread is rejected. `notin:` excludes a folder and its descendants. The folder browser is filtered by selected accounts, supports multiple checked folders, and retains removable selected-path entries. Picker-qualified `ownerID::path` values avoid matching the same path in another mailbox/account. Unqualified paths match across selected accounts. + +With **Drive as the only selected type**, the path browser loads Drive folders on expansion. Drive folder searches include descendants and filter the synced file index before applying the result limit; the results panel explicitly identifies this cached scope. This does not add a new Drive provider. Other mail-only filters affect the Mail portion of multi-type searches. + +| Search UI | Preview | +| --- | --- | +| Smart filters (light / dark) | [Light](screenshots/search-badges-light.png) · [Dark](screenshots/search-badges-dark.png) | +| Advanced filter | [Light](screenshots/search-options-light.png) · [Dark](screenshots/search-options-dark.png) | +| Multi-select types | [Screenshot](screenshots/search-type-multiselect-light.png) | +| Date/time conditions | [Light](screenshots/search-date-filters-light.png) · [Dark](screenshots/search-date-filters-dark.png) | +| Folder exclusions | [Screenshot](screenshots/search-folder-exclusions-dark.png) | +| Drive folder browser | [Screenshot](screenshots/search-drive-folders-dark.png) | + +Native checks exercise badge/text focus transitions, outside-click dismissal, full-width progress, mail-group visibility, form submission, folder browsing and the left Settings close button. All screenshots use fictional offline data. + + +### Workspace follow-up (PR #20) + +People opens cached contacts immediately and starts background loading, including when the mailbox tree is not populated yet. New contact and Table/Cards live in one toolbar, with no Refresh action. The table uses aligned Name, Email, Company / role and Actions columns; cards separate identity, contact details and actions. + +To Do uses the same account/folder styles as mail and calendar. Double-click or Enter opens the selected task. The due-date picker binds to a local `DateTime` adapter while provider dates remain `DateTimeOffset`; editing a saved due date no longer produces a cast error. Due date and time have separate labels. Refresh is handled by synchronization rather than a toolbar button. + +Notes expansion preserves tree containers and handles the native expand event. Errors use a short per-node summary with full details in a flyout; cached notes remain available. Microsoft's OneNote library limit (10008) still applies; this change does not bypass it. See [Microsoft's error documentation](https://learn.microsoft.com/en-us/graph/onenote-error-codes). + +Settings → Mail & notifications now defaults the primary reply action to Reply all; turning the setting off chooses Reply to sender. Explicit choices remain in the menu, and open previews follow preference changes. Signature editing and template previews open in independent windows, keeping native browser surfaces out of the scrolling settings page. The next-event header is opaque and mail actions collapse into More earlier on narrow windows. + +Mobbin references reviewed: [Qatalog cards](https://mobbin.com/screens/e22383a1-0ec8-44c0-92a6-43a4a756dd7c), [Pipedrive contacts table](https://mobbin.com/screens/2eb73474-8d4d-431f-9a7c-3d5e8c6d5921), [Asana task details](https://mobbin.com/screens/e7810f1e-ed82-419f-9041-bae74f62d627). + +Run `dotnet run --project tools/BetterMail.UiPreview -c Release -- /tmp/bettermail-workspaces --workspaces` under Xvfb to verify first-load contacts, task double-click/date binding, Notes expansion and signature surface separation. Screenshots use fictional offline data only. + +![Contact cards](screenshots/people-cards-light.png) +![Contact table](screenshots/people-table-dark.png) +![Task editor](screenshots/task-editor-dark.png) +![Narrow mail header](screenshots/mail-header-narrow-dark.png) +![Notes status](screenshots/notes-limit-dark.png) +![Signature settings](screenshots/signature-settings-dark.png) +![Default reply](screenshots/default-reply-settings-dark.png) + + +Task refresh follow-up: F5 remains available for an explicit refresh. On workspace sync completion, To Do reads the updated local cache and reconciles rows without clearing navigation or changing editor fields. Reopening the module with unchanged accounts also consumes the cache. Regression coverage verifies remote additions, updates and removals while preserving selection and unsaved edits. + + +Reading-pane performance: full-body and thread queries now use a dedicated, persistent encrypted read-only WAL connection, separate from both sync writes and folder navigation. Thread membership is selected through the thread index before message lookup. The regression holds the writer gate, an uncommitted write transaction and the folder-reader gate while checking that the full compressed body renders in the reading pane. It also checks committed-snapshot isolation and cancellation of a superseded read. + +Event descriptions are now editable. Unchanged HTML is preserved; edited descriptions save as plain text. Mail menus and thread More menus offer Create event, opening an unsaved calendar event populated from the full cached email body and subject. The account's editable calendar is preferred, and an existing open editor is protected. Older API callers that omit a body leave existing descriptions untouched. Regression coverage exercises description edits, clearing, HTML preservation, and missing email bodies. The offline native preview exercises email-to-event creation with fictional data. + +![Event created from fictional email](screenshots/event-from-email-dark.png) diff --git a/docs/ui/screenshots/default-reply-settings-dark.png b/docs/ui/screenshots/default-reply-settings-dark.png new file mode 100644 index 0000000..8924748 Binary files /dev/null and b/docs/ui/screenshots/default-reply-settings-dark.png differ diff --git a/docs/ui/screenshots/event-from-email-dark.png b/docs/ui/screenshots/event-from-email-dark.png new file mode 100644 index 0000000..18d94e9 Binary files /dev/null and b/docs/ui/screenshots/event-from-email-dark.png differ diff --git a/docs/ui/screenshots/mail-header-narrow-dark.png b/docs/ui/screenshots/mail-header-narrow-dark.png new file mode 100644 index 0000000..bfc8e6f Binary files /dev/null and b/docs/ui/screenshots/mail-header-narrow-dark.png differ diff --git a/docs/ui/screenshots/notes-expanded-dark.png b/docs/ui/screenshots/notes-expanded-dark.png new file mode 100644 index 0000000..86a2b05 Binary files /dev/null and b/docs/ui/screenshots/notes-expanded-dark.png differ diff --git a/docs/ui/screenshots/notes-limit-dark.png b/docs/ui/screenshots/notes-limit-dark.png new file mode 100644 index 0000000..20bfce4 Binary files /dev/null and b/docs/ui/screenshots/notes-limit-dark.png differ diff --git a/docs/ui/screenshots/people-cards-dark.png b/docs/ui/screenshots/people-cards-dark.png index d8b2f19..18c4711 100644 Binary files a/docs/ui/screenshots/people-cards-dark.png and b/docs/ui/screenshots/people-cards-dark.png differ diff --git a/docs/ui/screenshots/people-cards-light.png b/docs/ui/screenshots/people-cards-light.png index 79e8f81..bc04de8 100644 Binary files a/docs/ui/screenshots/people-cards-light.png and b/docs/ui/screenshots/people-cards-light.png differ diff --git a/docs/ui/screenshots/people-table-dark.png b/docs/ui/screenshots/people-table-dark.png new file mode 100644 index 0000000..4f08d07 Binary files /dev/null and b/docs/ui/screenshots/people-table-dark.png differ diff --git a/docs/ui/screenshots/people-table-light.png b/docs/ui/screenshots/people-table-light.png index 7709f7e..9610a91 100644 Binary files a/docs/ui/screenshots/people-table-light.png and b/docs/ui/screenshots/people-table-light.png differ diff --git a/docs/ui/screenshots/preview-action-settings-dark.png b/docs/ui/screenshots/preview-action-settings-dark.png new file mode 100644 index 0000000..b5544e9 Binary files /dev/null and b/docs/ui/screenshots/preview-action-settings-dark.png differ diff --git a/docs/ui/screenshots/preview-action-settings-light.png b/docs/ui/screenshots/preview-action-settings-light.png new file mode 100644 index 0000000..980bded Binary files /dev/null and b/docs/ui/screenshots/preview-action-settings-light.png differ diff --git a/docs/ui/screenshots/search-badges-dark.png b/docs/ui/screenshots/search-badges-dark.png new file mode 100644 index 0000000..e020fa8 Binary files /dev/null and b/docs/ui/screenshots/search-badges-dark.png differ diff --git a/docs/ui/screenshots/search-badges-light.png b/docs/ui/screenshots/search-badges-light.png new file mode 100644 index 0000000..6e7effa Binary files /dev/null and b/docs/ui/screenshots/search-badges-light.png differ diff --git a/docs/ui/screenshots/search-date-filters-dark.png b/docs/ui/screenshots/search-date-filters-dark.png new file mode 100644 index 0000000..ea8f4a3 Binary files /dev/null and b/docs/ui/screenshots/search-date-filters-dark.png differ diff --git a/docs/ui/screenshots/search-date-filters-light.png b/docs/ui/screenshots/search-date-filters-light.png new file mode 100644 index 0000000..91ffd2c Binary files /dev/null and b/docs/ui/screenshots/search-date-filters-light.png differ diff --git a/docs/ui/screenshots/search-drive-folders-dark.png b/docs/ui/screenshots/search-drive-folders-dark.png new file mode 100644 index 0000000..f3c9063 Binary files /dev/null and b/docs/ui/screenshots/search-drive-folders-dark.png differ diff --git a/docs/ui/screenshots/search-folder-exclusions-dark.png b/docs/ui/screenshots/search-folder-exclusions-dark.png new file mode 100644 index 0000000..0ea94a3 Binary files /dev/null and b/docs/ui/screenshots/search-folder-exclusions-dark.png differ diff --git a/docs/ui/screenshots/search-options-dark.png b/docs/ui/screenshots/search-options-dark.png index baa42d1..142084b 100644 Binary files a/docs/ui/screenshots/search-options-dark.png and b/docs/ui/screenshots/search-options-dark.png differ diff --git a/docs/ui/screenshots/search-options-light.png b/docs/ui/screenshots/search-options-light.png index fba357c..5b73aca 100644 Binary files a/docs/ui/screenshots/search-options-light.png and b/docs/ui/screenshots/search-options-light.png differ diff --git a/docs/ui/screenshots/search-results-dark.png b/docs/ui/screenshots/search-results-dark.png index 5fd3a3e..c1d9d2c 100644 Binary files a/docs/ui/screenshots/search-results-dark.png and b/docs/ui/screenshots/search-results-dark.png differ diff --git a/docs/ui/screenshots/search-results-light.png b/docs/ui/screenshots/search-results-light.png index c4e88c4..50f17b0 100644 Binary files a/docs/ui/screenshots/search-results-light.png and b/docs/ui/screenshots/search-results-light.png differ diff --git a/docs/ui/screenshots/search-syntax-dark.png b/docs/ui/screenshots/search-syntax-dark.png index 3480e3d..3f93fa8 100644 Binary files a/docs/ui/screenshots/search-syntax-dark.png and b/docs/ui/screenshots/search-syntax-dark.png differ diff --git a/docs/ui/screenshots/search-type-multiselect-light.png b/docs/ui/screenshots/search-type-multiselect-light.png new file mode 100644 index 0000000..635cb3f Binary files /dev/null and b/docs/ui/screenshots/search-type-multiselect-light.png differ diff --git a/docs/ui/screenshots/signature-settings-dark.png b/docs/ui/screenshots/signature-settings-dark.png new file mode 100644 index 0000000..9cdd342 Binary files /dev/null and b/docs/ui/screenshots/signature-settings-dark.png differ diff --git a/docs/ui/screenshots/task-editor-dark.png b/docs/ui/screenshots/task-editor-dark.png new file mode 100644 index 0000000..21bf81a Binary files /dev/null and b/docs/ui/screenshots/task-editor-dark.png differ diff --git a/docs/ui/screenshots/tasks-dark.png b/docs/ui/screenshots/tasks-dark.png new file mode 100644 index 0000000..3e810fd Binary files /dev/null and b/docs/ui/screenshots/tasks-dark.png differ diff --git a/docs/ui/screenshots/thread-action-bottom-light.png b/docs/ui/screenshots/thread-action-bottom-light.png new file mode 100644 index 0000000..a41bb2a Binary files /dev/null and b/docs/ui/screenshots/thread-action-bottom-light.png differ diff --git a/src/BetterMail.App/App.axaml.cs b/src/BetterMail.App/App.axaml.cs index 8aa415e..280198d 100644 --- a/src/BetterMail.App/App.axaml.cs +++ b/src/BetterMail.App/App.axaml.cs @@ -130,6 +130,8 @@ private async Task StartMainWindowAsync( viewModel.DefaultMailPromptShown = preferences.DefaultMailPromptShown; viewModel.MailSyncRange = preferences.MailSyncRange; viewModel.ConfigureMailQuickActions(preferences.MailQuickActions); + viewModel.ConfigurePreviewActions(preferences.ClosePreviewAfterActions); + viewModel.DefaultReplyAll = preferences.DefaultReplyAll; viewModel.ConfigureAccountOrder(preferences.AccountOrder); viewModel.DefaultContactOwnerId = preferences.DefaultContactOwnerId; viewModel.ConfigureMailboxLayout(preferences.MailboxOrder, preferences.CollapsedMailboxes); @@ -145,6 +147,8 @@ private async Task StartMainWindowAsync( nameof(MainWindowViewModel.DefaultMailPromptShown) or nameof(MainWindowViewModel.MailSyncRange) or nameof(MainWindowViewModel.MailQuickActionsVersion) or + nameof(MainWindowViewModel.PreviewActionsVersion) or + nameof(MainWindowViewModel.DefaultReplyAll) or nameof(MainWindowViewModel.SenderPreferencesVersion) or nameof(MainWindowViewModel.AccountOrderVersion) or nameof(MainWindowViewModel.ContactImagesEnabled) or @@ -168,7 +172,8 @@ private async Task StartMainWindowAsync( PeopleCardView: viewModel.PeopleCardView, MailboxOrder: viewModel.GetMailboxOrderPreferences(), CollapsedMailboxes: viewModel.GetCollapsedMailboxPreferences(), - DefaultContactOwnerId: viewModel.DefaultContactOwnerId)); + DefaultContactOwnerId: viewModel.DefaultContactOwnerId, + ClosePreviewAfterActions: viewModel.GetClosePreviewActions(), DefaultReplyAll: viewModel.DefaultReplyAll)); } }; viewModel.ConfigureSenderPreferences( diff --git a/src/BetterMail.App/AppPreferences.cs b/src/BetterMail.App/AppPreferences.cs index 167404a..9d3b546 100644 --- a/src/BetterMail.App/AppPreferences.cs +++ b/src/BetterMail.App/AppPreferences.cs @@ -20,7 +20,7 @@ public sealed record AppPreferences( bool MailSenderImagesEnabled = false, bool PeopleCardView = false, List? MailboxOrder = null, - List? CollapsedMailboxes = null, string? DefaultContactOwnerId = null); + List? CollapsedMailboxes = null, string? DefaultContactOwnerId = null, List? ClosePreviewAfterActions = null, bool DefaultReplyAll = true); public static class AppPreferencesStore { diff --git a/src/BetterMail.App/CalendarWorkspaceView.axaml b/src/BetterMail.App/CalendarWorkspaceView.axaml index c27011b..358a5c3 100644 --- a/src/BetterMail.App/CalendarWorkspaceView.axaml +++ b/src/BetterMail.App/CalendarWorkspaceView.axaml @@ -226,6 +226,11 @@ DisplayMemberBinding="{Binding DisplayName}" AutomationProperties.Name="Event calendar" TabIndex="141" /> + + + _editorSubject; set => SetProperty(ref _editorSubject, value); } + public string EditorDescription { get => _editorDescription; set => SetProperty(ref _editorDescription, value); } public string EditorLocation { get => _editorLocation; set => SetProperty(ref _editorLocation, value); } public string EditorAttendees { get => _editorAttendees; set => SetProperty(ref _editorAttendees, value); } public DateTime? EditorStartDate { get => _editorStartDate; set => SetProperty(ref _editorStartDate, value); } @@ -590,6 +595,7 @@ private Task OpenNewEventAsync() var start = SelectedDate.Date == _now().Date ? _now().AddMinutes(30) : StartOfDay(SelectedDate).AddHours(9); start = new DateTimeOffset(start.Year, start.Month, start.Day, start.Hour, start.Minute / 30 * 30, 0, start.Offset); EditorSubject = ""; + SetEditorBody(null, false); EditorLocation = ""; EditorAttendees = ""; SetEditorTimes(start, start.AddHours(1)); @@ -612,6 +618,7 @@ private Task OpenEditEventAsync(CalendarEventItem item) option.Account.AccountId == item.Source.Account.AccountId && option.Calendar.Info.ProviderId == item.Source.Calendar.Info.ProviderId); EditorSubject = item.Source.Event.Subject; + SetEditorBody(item.Source.Event.Body, item.Source.Event.BodyIsHtml); EditorLocation = item.Source.Event.Location ?? ""; EditorAttendees = string.Join("; ", (item.Source.Event.Attendees ?? []).Select(attendee => attendee.Address.Address)); SetEditorTimes(item.Source.Event.StartsAt, item.Source.Event.EndsAt); @@ -627,6 +634,32 @@ private Task OpenEditEventAsync(CalendarEventItem item) return Task.CompletedTask; } + internal async Task OpenFromEmailAsync(MailMessage message, string? accountId) + { + if (message.Body is null) throw new InvalidOperationException("The full email content is not available yet. Try again after it has loaded."); + await OpenNewEventAsync(); + SelectedEditorCalendar = EditableCalendars.FirstOrDefault(option => option.Account.AccountId == accountId) + ?? EditableCalendars.FirstOrDefault(); + EditorSubject = message.Subject; + SetEditorBody(message.Body, message.IsHtml); + } + + private void SetEditorBody(string? body, bool isHtml) + { + _originalBody = body ?? ""; + _originalBodyIsHtml = isHtml; + if (isHtml) + { + var document = new AngleSharp.Html.Parser.HtmlParser().ParseDocument(_originalBody); + foreach (var element in document.QuerySelectorAll("script, style, head")) element.Remove(); + foreach (var element in document.QuerySelectorAll("p, div, br, li, tr, h1, h2, h3")) + element.AppendChild(document.CreateTextNode("\n")); + _originalDescription = document.Body?.TextContent.Trim() ?? ""; + } + else _originalDescription = _originalBody; + EditorDescription = _originalDescription; + } + private void SetEditorTimes(DateTimeOffset start, DateTimeOffset end) { start = start.ToLocalTime(); @@ -716,7 +749,9 @@ internal CalendarEventDraft BuildDraft(string calendarId) attendees, EditorReminderOn, EditorReminderMinutes, - EditorRecurs ? BuildRecurrence(DateOnly.FromDateTime(start.Date)) : null); + EditorRecurs ? BuildRecurrence(DateOnly.FromDateTime(start.Date)) : null, + EditorDescription == _originalDescription ? _originalBody : EditorDescription, + EditorDescription == _originalDescription && _originalBodyIsHtml); } private CalendarRecurrence BuildRecurrence(DateOnly startDate) diff --git a/src/BetterMail.App/ConversationThreadView.axaml b/src/BetterMail.App/ConversationThreadView.axaml index 2654b86..fe0af92 100644 --- a/src/BetterMail.App/ConversationThreadView.axaml +++ b/src/BetterMail.App/ConversationThreadView.axaml @@ -43,8 +43,7 @@ - + { + if (args.Source is TreeViewItem { DataContext: NoteTreeNode node } && DataContext is NotesWorkspaceViewModel vm) + { + node.IsExpanded = true; + await vm.LoadChildrenAsync(node); + } + }); DataContextChanged += (_, _) => { if (_viewModel is not null) diff --git a/src/BetterMail.App/NotesWorkspaceViewModel.cs b/src/BetterMail.App/NotesWorkspaceViewModel.cs index d704c2f..3bdab07 100644 --- a/src/BetterMail.App/NotesWorkspaceViewModel.cs +++ b/src/BetterMail.App/NotesWorkspaceViewModel.cs @@ -95,7 +95,7 @@ private set public bool HasPartialErrors => AccountRoots.Any(static root => root.HasError); public string PartialErrorText => string.Join( Environment.NewLine, - AccountRoots.SelectMany(static root => root.Errors())); + AccountRoots.SelectMany(static root => root.Errors()).Distinct()); public bool IsLoadingPage { get => _isLoadingPage; private set => SetProperty(ref _isLoadingPage, value); } public Uri? PageBodyUri { get => _pageBodyUri; private set => SetProperty(ref _pageBodyUri, value); } public bool HasSelectedPage => SelectedPage is not null; @@ -571,14 +571,7 @@ private void RaisePartialErrors() private void ApplyFilter(string query) { - VisibleRoots.Clear(); - foreach (var root in AccountRoots) - { - if (root.ApplyFilter(query)) - { - VisibleRoots.Add(root); - } - } + CollectionUpdates.Reconcile(VisibleRoots, AccountRoots.Where(root => root.ApplyFilter(query)).ToArray(), root => root.Account.AccountId); } internal static string PlainTextToNoteHtml(string text) @@ -673,9 +666,11 @@ public string? Error if (SetProperty(ref _error, value)) { RaisePropertyChanged(nameof(HasError)); + RaisePropertyChanged(nameof(ErrorSummary)); } } } + public string ErrorSummary => string.IsNullOrWhiteSpace(Error) ? "" : Error.Contains("10008", StringComparison.Ordinal) ? "Library limit · cached notes only" : "Could not sync · see details"; public bool HasError => !string.IsNullOrWhiteSpace(Error) || AllChildren.Any(static child => child.HasError); @@ -704,21 +699,9 @@ public void ReplaceChildren(IEnumerable children) public bool ApplyFilter(string query) { - Children.Clear(); - var selfMatches = query.Length == 0 || - DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || - SecondaryText.Contains(query, StringComparison.OrdinalIgnoreCase); - foreach (var child in AllChildren) - { - if (child.Kind != NoteNodeKind.Placeholder && child.ApplyFilter(query)) - { - Children.Add(child); - } - else if (query.Length == 0 && child.Kind == NoteNodeKind.Placeholder) - { - Children.Add(child); - } - } + var selfMatches = query.Length == 0 || DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || SecondaryText.Contains(query, StringComparison.OrdinalIgnoreCase); + var visible = AllChildren.Where(child => child.Kind == NoteNodeKind.Placeholder ? query.Length == 0 : child.ApplyFilter(query)).ToArray(); + CollectionUpdates.Reconcile(Children, visible, child => child); if (query.Length > 0 && Children.Count > 0) { IsExpanded = true; @@ -791,7 +774,7 @@ private static NoteTreeNode Placeholder(MailAccount account, NoteTreeNode parent new( NoteNodeKind.Placeholder, account, - "Expand to load", + "Loading…", "", parent, null, diff --git a/src/BetterMail.App/SearchFoldersWindow.cs b/src/BetterMail.App/SearchFoldersWindow.cs new file mode 100644 index 0000000..aa7e50e --- /dev/null +++ b/src/BetterMail.App/SearchFoldersWindow.cs @@ -0,0 +1,108 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using BetterMail.Core; + +namespace BetterMail.App; + +public sealed class SearchFoldersWindow : Window +{ + private readonly HashSet _selected; + private readonly TextBlock _notice = new() { TextWrapping = Avalonia.Media.TextWrapping.Wrap }; + private readonly TreeView _tree = new(); + private readonly CancellationTokenSource _closed = new(); + private readonly MainWindowViewModel? _owner; + private readonly Dictionary _boxes = []; + private readonly StackPanel _selectedRows = new() { Spacing = 3 }; + public SearchFoldersWindow(MainWindowViewModel? owner, bool drive, IReadOnlyList accounts, IEnumerable selected, bool excluding) + { + _owner = owner; _selected = selected.ToHashSet(StringComparer.OrdinalIgnoreCase); + Title = excluding ? "Exclude folders" : "Include folders"; + Width = 620; Height = 720; MinWidth = 400; MinHeight = 400; + var apply = new Button { Content = "Use selected folders" }; apply.Click += (_, _) => Close((IReadOnlyList)_selected.ToArray()); + var cancel = new Button { Content = "Cancel" }; cancel.Click += (_, _) => Close(); + var clear = new Button { Content = "Clear selection" }; clear.Click += (_, _) => { _selected.Clear(); foreach (var box in _boxes.Values) box.IsChecked = false; RefreshSelected(); }; + var find = new TextBox { PlaceholderText = "Find loaded folders" }; + find.TextChanged += (_, _) => { foreach (var node in _tree.Items.OfType()) Filter(node, find.Text ?? ""); }; + var grid = new Grid { Margin = new(20), RowDefinitions = new("Auto,Auto,*,Auto,Auto"), RowSpacing = 10 }; + grid.Children.Add(new TextBlock { Text = Title, FontSize = 20 }); + Grid.SetRow(find, 1); grid.Children.Add(find); + var scroll = new ScrollViewer { Content = _tree }; Grid.SetRow(scroll, 2); grid.Children.Add(scroll); + var selection = new StackPanel { Spacing = 5, Children = { _notice, new ScrollViewer { MaxHeight = 100, Content = _selectedRows } } }; + Grid.SetRow(selection, 3); grid.Children.Add(selection); + var footer = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { clear, cancel, apply } }; + Grid.SetRow(footer, 4); grid.Children.Add(footer); Content = grid; + Closed += (_, _) => _closed.Cancel(); + if (owner is not null) + { + if (drive) + { + _tree.ItemsSource = owner.Accounts.Where(account => account.Capabilities.HasFlag(ProviderCapabilities.Files) && + (accounts.Count == 0 || accounts.Contains(account.AccountId))).Select(account => DriveNode(account, null)).ToArray(); + } + else + { + var mailboxIds = owner.Mailboxes.Where(mailbox => accounts.Count == 0 || accounts.Contains(mailbox.Id) || accounts.Contains(mailbox.AccountId)).Select(mailbox => mailbox.Id).ToHashSet(); + _tree.ItemsSource = MailMoveFolder.Build(owner.Folders.Where(folder => mailboxIds.Contains(folder.MailboxId))).Select(MailNode).ToArray(); + } + } + RefreshSelected(); + _notice.Text += drive ? " Expand folders to browse Drive." : " Folder choices are limited to the selected accounts."; + } + private TreeViewItem MailNode(MailMoveFolder folder) => new() + { + Header = folder.Folder is { } item ? Choice(item.MailboxId + "::" + _owner!.SearchFolderPath(item), folder.Name) : new TextBlock { Text = folder.Name }, + Tag = folder.Name, IsExpanded = folder.Folder is null, + ItemsSource = folder.Children.Select(MailNode).ToArray() + }; + private TreeViewItem DriveNode(MailAccount account, CloudDriveItem? item) + { + var path = item?.Path ?? "/"; + var node = new TreeViewItem { Header = Choice(account.AccountId + "::" + path, item?.Name ?? account.DisplayName + " · " + account.EmailAddress), + Tag = item?.Name ?? account.EmailAddress, ItemsSource = new[] { new TreeViewItem { Header = "Expand to load folders" } } }; + var loaded = false; var loading = false; + node.Expanded += async (_, args) => + { + if (args.Source != node || loaded || loading) return; + loading = true; + try + { + if (_owner!.FilesProvider is null) throw new InvalidOperationException("Drive is unavailable."); + var children = await _owner.FilesProvider.GetDriveItemsAsync(account, item, _closed.Token); + if (_closed.IsCancellationRequested) return; + node.ItemsSource = children.Where(child => child.IsFolder).OrderBy(child => child.Name, StringComparer.OrdinalIgnoreCase) + .Select(child => DriveNode(account, child)).ToArray(); + loaded = true; + } + catch (OperationCanceledException) { } + catch (Exception error) { _notice.Text = "Folders could not be loaded: " + error.Message + " Collapse and expand to retry."; } + finally { loading = false; } + }; + return node; + } + private CheckBox Choice(string value, string label) + { + var box = new CheckBox { Content = label, IsChecked = _selected.Contains(value) }; + _boxes[value] = box; + box.IsCheckedChanged += (_, _) => { if (box.IsChecked == true) _selected.Add(value); else _selected.Remove(value); RefreshSelected(); }; + return box; + } + private void RefreshSelected() + { + _notice.Text = $"{_selected.Count} selected"; + _selectedRows.Children.Clear(); + foreach (var value in _selected.ToArray()) + { + var remove = new Button { Content = "× " + value, HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Left }; + remove.Click += (_, _) => { _selected.Remove(value); if (_boxes.TryGetValue(value, out var box)) box.IsChecked = false; RefreshSelected(); }; + _selectedRows.Children.Add(remove); + } + } + private static bool Filter(TreeViewItem node, string text) + { + var childMatch = false; + foreach (var child in node.Items.OfType()) childMatch |= Filter(child, text); + node.IsVisible = childMatch || (node.Tag as string ?? "").Contains(text, StringComparison.OrdinalIgnoreCase); + if (text.Length > 0 && childMatch) node.IsExpanded = true; + return node.IsVisible; + } +} diff --git a/src/BetterMail.App/SearchOptionsWindow.axaml b/src/BetterMail.App/SearchOptionsWindow.axaml index bd1ecd1..1e32e42 100644 --- a/src/BetterMail.App/SearchOptionsWindow.axaml +++ b/src/BetterMail.App/SearchOptionsWindow.axaml @@ -1,18 +1,27 @@ + + + - - - - - - -