feat(tabs): groundwork for the tab editor

The two halves that can be settled without a window, before the window
exists.

Concurrency first, because the editor is the first thing that ever
writes SelectedChannels after load. All three channel-filter fields are
read without a lock from the pending-message thread, the filter worker
and the draw thread, and mutating a live Dictionary while Matches walks
it is the standard way to get a wrong answer on somebody else's stack.
ReplaceChannelFilter builds the replacements and swaps the references,
so a reader sees the old set or the new one and never half of either.

It deliberately stops short of making the three writes one atomic step.
A reader can catch the new dictionary with the old ExtraChat flag for a
single message, which the editor's closing clear-and-refilter
reconsiders anyway. Doing better would mean one reference for all three,
and all three are serialized fields whose shape the config file already
has.

Then the index maths, which is where temp tabs make this more than list
arithmetic. They live in the same collection, they are not editable --
their name is a conversation partner and deleting one would be deleting
a conversation -- and a move has to step over them rather than swap with
them, or moving a tab down and back up would not return it to where it
started. Reversible in the editable order, which is the order the user
sees; the sidebar draws temp tabs under their own headers regardless of
where they sit in the list.

Twelve facts, and one of them started out asserting the wrong property:
that the whole list is restored by a move and its reverse. It is not,
and it does not need to be.

The last editable tab cannot be deleted. The message list has no empty
state, so a window with nothing to draw is not a state to offer.
This commit is contained in:
2026-08-18 23:39:43 +02:00
parent 8e38e3e805
commit 6bb020547d
2 changed files with 101 additions and 0 deletions
+28
View File
@@ -472,6 +472,34 @@ public class Tab
[NonSerialized]
internal float _cardHoverAlpha;
// Copy-on-write for the three channel-filter fields. They are read without
// any lock from the pending-message thread, the filter worker and the draw
// thread, and until v1.12.0 nothing ever wrote them after load -- so the
// tab editor is their first writer, and mutating a live Dictionary while
// Matches enumerates it is the classic way to get a wrong answer or an
// exception on somebody else's thread.
//
// Building the replacements and swapping the references means a reader sees
// either the old set or the new one, never half of either.
//
// What this deliberately does not do is make the three writes one atomic
// step. A reader can catch the new dictionary with the old ExtraChat flag
// for a single message. That is harmless: the editor finishes by clearing
// and refiltering every tab, so any message placed by a mixed view is
// reconsidered a moment later. Making it truly atomic would mean one
// reference for all three, and these three are serialized fields with a
// shape the config file already has.
internal void ReplaceChannelFilter(
Dictionary<ChatType, (ChatSource, ChatSource)> selected,
bool extraChatAll,
HashSet<Guid> extraChatChannels
)
{
Volatile.Write(ref SelectedChannels, selected);
Volatile.Write(ref ExtraChatChannels, extraChatChannels);
Volatile.Write(ref ExtraChatAll, extraChatAll);
}
public bool Matches(Message message)
{
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
+73
View File
@@ -13,6 +13,79 @@ internal static class TabLifecycleHelpers
public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned;
// A temp tab belongs to its conversation, not to the user's layout. It is
// created and dropped by the auto-tell service, its name is the partner, and
// deleting it in an editor would be deleting a conversation. Pinning is the
// only editing gesture it accepts, and that lives in the context menu.
public static bool IsEditable(Tab t) => !t.IsTempTab;
// Where a new tab lands: after the last editable one, so it never appears
// among the temp tabs at the bottom of the list.
public static int InsertIndexForNewTab(IReadOnlyList<Tab> tabs)
{
for (var i = tabs.Count - 1; i >= 0; i--)
if (IsEditable(tabs[i]))
return i + 1;
return 0;
}
// Where an editable tab ends up when the user moves it by one step.
//
// Steps over temp tabs rather than swapping with them: a swap would push a
// conversation into the middle of the layout. Reversible in the editable
// order, which is the order the user sees -- the temp tab keeps its slot in
// the collection and the sidebar draws it under its own section header
// anyway. Returns the original index when there is no editable neighbour in
// that direction, which the caller reads as "no move".
public static int MoveIndex(IReadOnlyList<Tab> tabs, int index, int delta)
{
if (index < 0 || index >= tabs.Count || delta == 0)
return index;
if (!IsEditable(tabs[index]))
return index;
var step = Math.Sign(delta);
for (var i = index + step; i >= 0 && i < tabs.Count; i += step)
if (IsEditable(tabs[i]))
return i;
return index;
}
// Which tab the editor should select after deleting the one at index.
//
// The next editable tab below, else the one above, else nothing. Returning
// the index the caller should select *after* the removal, so it is already
// shifted.
public static int SelectionAfterDelete(IReadOnlyList<Tab> tabs, int deletedIndex)
{
for (var i = deletedIndex + 1; i < tabs.Count; i++)
if (IsEditable(tabs[i]))
return i - 1;
for (var i = deletedIndex - 1; i >= 0; i--)
if (IsEditable(tabs[i]))
return i;
return -1;
}
// Deleting the last editable tab leaves a window with nothing to draw, and
// the message list has no empty state. The editor offers templates instead
// of a delete in that situation.
public static bool CanDelete(IReadOnlyList<Tab> tabs, int index)
{
if (index < 0 || index >= tabs.Count || !IsEditable(tabs[index]))
return false;
var editable = 0;
foreach (var tab in tabs)
if (IsEditable(tab))
editable++;
return editable > 1;
}
public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t);
public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);