Fixed the workspace list not following a moved chat (#1048)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-11 13:42:15 +02:00
1 parent 5ee4dfa2b5
commit 1c13300988
9 files changed
+362 -41

No files matched your search

@@ -3580,6 +3580,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Add fil
-- Changelog
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Changelog"
-- Transcription: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1004696017"] = "Transcription: {0}"
-- {0}, plus approx. {1} for your message
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus approx. {1} for your message"
@@ -1028,7 +1028,7 @@ public partial class ChatComponent : MSGComponentBase
this.RefreshCurrentProfileAndChatTemplate();
var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
? string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath))
: promptName;
this.ChatThread = this.NewChatThread(threadName) with
@@ -1036,7 +1036,16 @@ public partial class ChatComponent : MSGComponentBase
DataSourceOptions = this.earlyDataSourceOptions,
};
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
//
// Like saving, this goes through the workspace component whenever there is one, so its tree
// lists the new chat right away, together with the progress of the transcription, instead
// of only after the first message was sent:
//
if (this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.SyncForegroundChatAsync();
@@ -1391,8 +1400,16 @@ public partial class ChatComponent : MSGComponentBase
if (workspaceId == Guid.Empty)
return;
//
// Like saving and copying, the move goes through the workspace component whenever there is
// one, so its tree shows the chat in the new place right away:
//
var previousWorkspaceId = this.ChatThread!.WorkspaceId;
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId);
if (this.Workspaces is not null)
await this.Workspaces.MoveChatAsync(this.ChatThread!, workspaceId);
else
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId);
await this.FollowMovedChatAsync(previousWorkspaceId);
}
@@ -78,7 +78,7 @@ else
<div style="justify-self: end;">
<MudTooltip Text="@T("Move to workspace")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.MoveChatAsync(treeItem.Path))"/>
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.MoveChatFromTreeAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Copy chat")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
@@ -938,7 +938,24 @@ public partial class Workspaces : MSGComponentBase
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task MoveChatAsync(string? chatPath)
/// <summary>
/// Moves the given chat into another workspace and shows it there in the tree.
/// </summary>
/// <param name="chat">The chat to move. It gets the new workspace and the new paths of its attachments.</param>
/// <param name="targetWorkspaceId">The workspace to move the chat into.</param>
/// <remarks>
/// Neither asks for the target workspace nor follows the chat in the chat view: both depend on
/// where the move was asked for. Whoever moves a chat while this component exists goes through
/// here, though, since the tree draws what it has built before and would otherwise keep showing
/// the chat in its old place.
/// </remarks>
public async Task MoveChatAsync(ChatThread chat, Guid targetWorkspaceId)
{
await WorkspaceBehaviour.MoveChatAsync(chat, targetWorkspaceId);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task MoveChatFromTreeAsync(string? chatPath)
{
var storedChat = await this.LoadChatAsync(chatPath, false);
if (storedChat is null)
@@ -969,11 +986,9 @@ public partial class Workspaces : MSGComponentBase
if (workspaceId == Guid.Empty)
return;
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
await this.MoveChatAsync(chat, workspaceId);
if (ReferenceEquals(chat, this.CurrentChatThread))
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task AddChatAsync(string workspacePath)
@@ -3582,6 +3582,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Datei h
-- Changelog
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Änderungsprotokoll"
-- Transcription: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1004696017"] = "Transkription: {0}"
-- {0}, plus approx. {1} for your message
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus ca. {1} für Ihre Nachricht"
@@ -3582,6 +3582,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T4014053962"] = "Add fil
-- Changelog
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHANGELOG::T3017574265"] = "Changelog"
-- Transcription: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1004696017"] = "Transcription: {0}"
-- {0}, plus approx. {1} for your message
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus approx. {1} for your message"
@@ -82,6 +82,21 @@ public static class WorkspaceBehaviour
private static readonly string TEMPORARY_CHATS_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "tempChats");
/// <summary>
/// Only for tests: the data directory which the roots of the workspaces and of the temporary chats are taken from.
/// </summary>
/// <remarks>
/// The app sets its data directory once at startup, and both roots stay fixed from then on. The
/// tests give every test a data directory of its own, though, while the fixed roots keep the one
/// of whichever test touched this class first. Null, the default and the only value the app ever
/// sees, keeps the fixed roots.
/// </remarks>
internal static string? TestDataDirectory { get; set; }
private static string WorkspaceRootDirectory => TestDataDirectory is null ? WORKSPACE_ROOT_DIRECTORY : Path.Join(TestDataDirectory, "workspaces");
private static string TemporaryChatsRootDirectory => TestDataDirectory is null ? TEMPORARY_CHATS_ROOT_DIRECTORY : Path.Join(TestDataDirectory, "tempChats");
private static string ChatSemaphoreKey(Guid workspaceId, Guid chatId) => $"{workspaceId}_{chatId}";
private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) =>
@@ -170,9 +185,9 @@ public static class WorkspaceBehaviour
private static async Task<List<WorkspaceChatCacheEntry>> ReadTemporaryChatsCoreAsync()
{
var chats = new List<WorkspaceChatCacheEntry>();
Directory.CreateDirectory(TEMPORARY_CHATS_ROOT_DIRECTORY);
Directory.CreateDirectory(TemporaryChatsRootDirectory);
foreach (var tempChatPath in Directory.EnumerateDirectories(TEMPORARY_CHATS_ROOT_DIRECTORY))
foreach (var tempChatPath in Directory.EnumerateDirectories(TemporaryChatsRootDirectory))
{
if (!Guid.TryParse(Path.GetFileName(tempChatPath), out var chatId))
continue;
@@ -202,8 +217,8 @@ public static class WorkspaceBehaviour
WORKSPACE_TREE_CACHE.Workspaces.Clear();
WORKSPACE_TREE_CACHE.WorkspaceOrder.Clear();
Directory.CreateDirectory(WORKSPACE_ROOT_DIRECTORY);
foreach (var workspacePath in Directory.EnumerateDirectories(WORKSPACE_ROOT_DIRECTORY))
Directory.CreateDirectory(WorkspaceRootDirectory);
foreach (var workspacePath in Directory.EnumerateDirectories(WorkspaceRootDirectory))
{
if (!Guid.TryParse(Path.GetFileName(workspacePath), out var workspaceId))
continue;
@@ -345,24 +360,7 @@ public static class WorkspaceBehaviour
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
return;
var chatCacheEntry = new WorkspaceChatCacheEntry
{
WorkspaceId = workspaceId,
ChatId = chatId,
ChatPath = chatDirectory,
ChatName = string.IsNullOrWhiteSpace(chatName) ? TB("Unnamed chat") : chatName,
LastEditTime = lastEditTime,
IsTemporary = workspaceId == Guid.Empty,
};
if (workspaceId == Guid.Empty)
{
UpsertChatInCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatCacheEntry);
return;
}
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded)
UpsertChatInCache(workspace.Chats, chatCacheEntry);
UpdateCacheAfterChatStoredCore(workspaceId, chatId, chatDirectory, chatName, lastEditTime);
}
finally
{
@@ -370,6 +368,29 @@ public static class WorkspaceBehaviour
}
}
/// <summary>Puts a stored chat into the cache. The caller holds the cache semaphore.</summary>
private static void UpdateCacheAfterChatStoredCore(Guid workspaceId, Guid chatId, string chatDirectory, string chatName, DateTimeOffset lastEditTime)
{
var chatCacheEntry = new WorkspaceChatCacheEntry
{
WorkspaceId = workspaceId,
ChatId = chatId,
ChatPath = chatDirectory,
ChatName = string.IsNullOrWhiteSpace(chatName) ? TB("Unnamed chat") : chatName,
LastEditTime = lastEditTime,
IsTemporary = workspaceId == Guid.Empty,
};
if (workspaceId == Guid.Empty)
{
UpsertChatInCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatCacheEntry);
return;
}
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded)
UpsertChatInCache(workspace.Chats, chatCacheEntry);
}
private static async Task UpdateCacheAfterChatDeleted(Guid workspaceId, Guid chatId)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
@@ -378,14 +399,61 @@ public static class WorkspaceBehaviour
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
return;
if (workspaceId == Guid.Empty)
UpdateCacheAfterChatDeletedCore(workspaceId, chatId);
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
/// <summary>Takes a deleted chat out of the cache. The caller holds the cache semaphore.</summary>
private static void UpdateCacheAfterChatDeletedCore(Guid workspaceId, Guid chatId)
{
if (workspaceId == Guid.Empty)
{
DeleteChatFromCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatId);
return;
}
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded)
DeleteChatFromCache(workspace.Chats, chatId);
}
/// <summary>
/// Moves the cache entry of a chat from the workspace it came from to the one it lives in now.
/// </summary>
/// <remarks>
/// Both happen within one hold of the cache semaphore, so no snapshot shows the chat twice or
/// not at all. Throwing the whole cache away would get this one chat right as well, but it also
/// forgets the chats of every workspace loaded so far: the workspace list then shows all of them
/// empty until the user clicks each one.<br/><br/>
///
/// A target workspace the cache does not know yet is the exception. It appears only when the
/// workspaces are read from the disk again, which is why the cache is thrown away in that case.
/// </remarks>
/// <param name="sourceWorkspaceId">The workspace the chat came from, or Guid.Empty for a temporary chat.</param>
/// <param name="targetWorkspaceId">The workspace the chat lives in now, or Guid.Empty for a temporary chat.</param>
/// <param name="chatId">The chat which was moved.</param>
/// <param name="targetDirectory">The directory of the chat after the move.</param>
/// <param name="chatName">The name of the chat.</param>
/// <param name="lastEditTime">When the chat was written last, which orders the chats in the list.</param>
private static async Task UpdateCacheAfterChatMoved(Guid sourceWorkspaceId, Guid targetWorkspaceId, Guid chatId, string targetDirectory, string chatName, DateTimeOffset lastEditTime)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
return;
if (targetWorkspaceId != Guid.Empty && !WORKSPACE_TREE_CACHE.Workspaces.ContainsKey(targetWorkspaceId))
{
DeleteChatFromCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatId);
WORKSPACE_TREE_CACHE_INVALIDATED = true;
return;
}
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded)
DeleteChatFromCache(workspace.Chats, chatId);
UpdateCacheAfterChatDeletedCore(sourceWorkspaceId, chatId);
UpdateCacheAfterChatStoredCore(targetWorkspaceId, chatId, targetDirectory, chatName, lastEditTime);
}
finally
{
@@ -683,19 +751,24 @@ public static class WorkspaceBehaviour
return new(false, default);
var workspaceId = Guid.NewGuid();
var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString());
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
Directory.CreateDirectory(workspacePath);
var workspaceNamePath = Path.Join(workspacePath, "name");
await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8);
//
// The workspace directory was created just now, so there are no chats to read from it.
// Counting them as loaded lets a chat moved into the new workspace show up there right
// away, instead of only after the user clicked the workspace:
//
var workspace = new WorkspaceCacheEntry
{
WorkspaceId = workspaceId,
WorkspacePath = workspacePath,
WorkspaceName = normalizedWorkspaceName,
Chats = [],
ChatsLoaded = false,
ChatsLoaded = true,
};
WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = workspace;
WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId);
@@ -985,6 +1058,9 @@ public static class WorkspaceBehaviour
await FinalizeStagedTranscriptsAsync(chat, targetDirectory);
await StoreMovedChatFilesAsync(chat, targetDirectory);
var lastEditTime = File.GetLastWriteTimeUtc(Path.Join(targetDirectory, "thread.json"));
await UpdateCacheAfterChatMoved(sourceWorkspaceId, targetWorkspaceId, chat.ChatId, targetDirectory, chat.Name, lastEditTime);
}
catch
{
@@ -994,10 +1070,10 @@ public static class WorkspaceBehaviour
{
UpdateAttachmentPathsAfterMove(chat, targetDirectory, sourceDirectory);
chat.WorkspaceId = sourceWorkspaceId;
if (Directory.Exists(targetDirectory) && !Directory.Exists(sourceDirectory))
Directory.Move(targetDirectory, sourceDirectory);
if (Directory.Exists(sourceDirectory))
await StoreMovedChatFilesAsync(chat, sourceDirectory);
}
@@ -1006,13 +1082,15 @@ public static class WorkspaceBehaviour
LOG.LogError(rollbackError, "Could not roll back moving chat '{ChatId}' to workspace '{WorkspaceId}'.", chat.ChatId, targetWorkspaceId);
}
}
// After a failed move, it is unclear where the chat ended up, so the cache reads it from the disk again:
InvalidateWorkspaceTreeCache();
throw;
}
finally
{
orderedSemaphores[1].Release();
orderedSemaphores[0].Release();
InvalidateWorkspaceTreeCache();
}
}
@@ -1205,7 +1283,7 @@ public static class WorkspaceBehaviour
// Not in cache — read from disk and update cache in the same semaphore scope
// to avoid a second semaphore acquisition via UpdateWorkspaceNameInCacheAsync:
var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString());
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
var workspaceNamePath = Path.Join(workspacePath, "name");
string workspaceName;
@@ -1311,7 +1389,7 @@ public static class WorkspaceBehaviour
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
{
var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString());
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
var workspaceNamePath = Path.Join(workspacePath, "name");
if (!Path.Exists(workspacePath))
@@ -31,6 +31,10 @@
- Fixed a security issue in how AI Studio prepares images for the Visual Briefing assistant. AI Studio now protects all parts of its internal connection in one central place, and an automated test checks that no part is left out.
- Fixed the transcripts of your audio and video files disappearing when you edited your last message in a chat. You no longer need to attach the files again. Thanks, Bernhard, for reporting this issue.
- Fixed the message you had not sent yet losing its text or the transcripts of your audio and video files when you moved the chat to a workspace.
- Fixed the workspace list no longer showing the chats of your workspaces after you moved a chat to another workspace.
- Fixed a chat you moved from the disappearing chats into a workspace still being listed among the disappearing chats.
- Fixed a new chat not appearing in the workspace list while AI Studio transcribed an audio or video file you had attached to it.
- Fixed a chat you started by attaching an audio or video file getting a German name, regardless of the language you use AI Studio in.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.0
+198
View File
@@ -0,0 +1,198 @@
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what the workspace list gets to see after a chat moved into another workspace.
/// </summary>
/// <remarks>
/// The workspace list draws from a cache, which holds the chats of every workspace loaded so far.
/// A move has to take the chat out of the place it came from and put it where it lives now, while
/// everything else in the cache stays as it was. Otherwise, the list shows the chat in its old place,
/// or every workspace empty until the user clicks it.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class WorkspaceTreeCacheTests
{
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public async Task PrepareDataDirectory()
{
//
// The data directory is static state of the whole application, which is why this fixture
// does not run alongside others. The workspace behaviour keeps the roots of the workspaces
// and of the temporary chats fixed, as the app never changes its data directory, so it gets
// told about the one of this test separately. The cache is static as well, so it starts
// over from what the new data directory holds: nothing.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-workspace-tree-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
}
[TearDown]
public void RemoveDataDirectory()
{
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
// Whoever reads the cache next must not get the workspaces of this test:
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task AMovedDisappearingChatLeavesTheDisappearingChats()
{
var targetId = await CreateLoadedWorkspaceAsync("Projects");
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var target = snapshot.Workspaces.Single(workspace => workspace.WorkspaceId == targetId);
var expectedPath = Path.Combine(this.testDirectory, "workspaces", targetId.ToString(), chat.ChatId.ToString());
Assert.Multiple(() =>
{
Assert.That(snapshot.TemporaryChats.Select(entry => entry.ChatId), Does.Not.Contain(chat.ChatId), "The chat lives in a workspace now, so the disappearing chats must not list it any longer.");
Assert.That(target.ChatsLoaded, Is.True, "The chats of the target workspace were loaded before the move, and they still are.");
Assert.That(target.Chats.Select(entry => entry.ChatPath), Does.Contain(expectedPath), "The target workspace lists the chat with the directory it lives in now.");
});
}
[Test]
public async Task MovingAChatKeepsTheChatsOfOtherWorkspacesLoaded()
{
var otherId = await CreateLoadedWorkspaceAsync("Archive");
var otherChat = await StoreChatAsync(otherId, "Old notes");
var targetId = await CreateLoadedWorkspaceAsync("Projects");
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
var other = await GetWorkspaceAsync(otherId);
Assert.Multiple(() =>
{
Assert.That(other.ChatsLoaded, Is.True, "A move must not make the cache forget the chats of a workspace which has nothing to do with it. The list would show that workspace empty.");
Assert.That(other.Chats.Select(entry => entry.ChatId), Does.Contain(otherChat.ChatId));
});
}
[Test]
public async Task AChatMovedBetweenWorkspacesLeavesItsFormerWorkspace()
{
var sourceId = await CreateLoadedWorkspaceAsync("Archive");
var targetId = await CreateLoadedWorkspaceAsync("Projects");
var chat = await StoreChatAsync(sourceId, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
var source = await GetWorkspaceAsync(sourceId);
var target = await GetWorkspaceAsync(targetId);
Assert.Multiple(() =>
{
Assert.That(source.Chats.Select(entry => entry.ChatId), Does.Not.Contain(chat.ChatId), "The chat left this workspace.");
Assert.That(target.Chats.Select(entry => entry.ChatId), Does.Contain(chat.ChatId), "The chat arrived in this workspace.");
});
}
[Test]
public async Task AWorkspaceWithoutLoadedChatsFindsTheMovedChatOnTheDisk()
{
var targetResult = await WorkspaceBehaviour.TryCreateWorkspaceAsync("Projects");
var targetId = targetResult.Workspace.WorkspaceId;
// As after a start of the app: the workspaces are known, but none of their chats were read yet.
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
var chats = await WorkspaceBehaviour.GetWorkspaceChatsAsync(targetId);
Assert.That(chats.Select(entry => entry.ChatId), Does.Contain(chat.ChatId), "Loading the chats of the workspace later reads them from the disk, the moved one included.");
}
[Test]
public async Task AChatMovedIntoANewWorkspaceShowsUpThereRightAway()
{
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
// As in the dialog for moving a chat, which can create the workspace it moves the chat into:
var targetResult = await WorkspaceBehaviour.TryCreateWorkspaceAsync("Projects");
await WorkspaceBehaviour.MoveChatAsync(chat, targetResult.Workspace.WorkspaceId);
var target = await GetWorkspaceAsync(targetResult.Workspace.WorkspaceId);
Assert.Multiple(() =>
{
Assert.That(target.ChatsLoaded, Is.True, "A workspace created just now has no chats on the disk, so there is nothing left to load.");
Assert.That(target.Chats.Select(entry => entry.ChatId), Does.Contain(chat.ChatId), "Otherwise, the user has to click the new workspace before the chat shows up.");
});
}
[Test]
public async Task AWorkspaceUnknownToTheCacheShowsUpAfterTheMove()
{
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
var targetId = Guid.NewGuid();
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var chats = await WorkspaceBehaviour.GetWorkspaceChatsAsync(targetId);
Assert.Multiple(() =>
{
Assert.That(snapshot.Workspaces.Select(workspace => workspace.WorkspaceId), Does.Contain(targetId), "The move created the workspace directory, so the list has to show it.");
Assert.That(chats.Select(entry => entry.ChatId), Does.Contain(chat.ChatId));
});
}
/// <summary>
/// Creates a workspace the way the app finds it after a start, with its chats loaded.
/// </summary>
/// <remarks>
/// The app reads the workspaces first and their chats afterward, one workspace after the other.
/// Reloading the cache and then loading the chats of the workspace mimics that.
/// </remarks>
/// <param name="name">The name of the workspace.</param>
/// <returns>The ID of the workspace.</returns>
private static async Task<Guid> CreateLoadedWorkspaceAsync(string name)
{
var result = await WorkspaceBehaviour.TryCreateWorkspaceAsync(name);
Assert.That(result.Success, Is.True, $"The workspace '{name}' could not be created.");
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
await WorkspaceBehaviour.TryPrefetchRemainingChatsAsync();
return result.Workspace.WorkspaceId;
}
private static async Task<ChatThread> StoreChatAsync(Guid workspaceId, string name)
{
var chat = new ChatThread { WorkspaceId = workspaceId, ChatId = Guid.NewGuid(), Name = name };
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, $"The chat '{name}' could not be stored.");
return chat;
}
private static async Task<WorkspaceTreeWorkspace> GetWorkspaceAsync(Guid workspaceId)
{
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
return snapshot.Workspaces.Single(workspace => workspace.WorkspaceId == workspaceId);
}
}