Improved workspace performance (#680)
Some checks are pending
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-03-05 18:37:18 +01:00 committed by GitHub
parent 721d5c9070
commit 906d9ba058
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 994 additions and 494 deletions

View File

@ -2557,8 +2557,8 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure
-- Move chat -- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat"
-- Unnamed workspace -- Loading chats...
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1307384014"] = "Unnamed workspace" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..."
-- Delete -- Delete
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete"
@ -2614,9 +2614,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Please enter
-- Please enter a workspace name. -- Please enter a workspace name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name."
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3310482275"] = "Unnamed chat"
-- Rename -- Rename
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename"
@ -4981,6 +4978,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one co
-- Localization -- Localization
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization" UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization"
-- Reload your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T194629703"] = "Reload your workspaces"
-- Hide your workspaces -- Hide your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Hide your workspaces" UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Hide your workspaces"
@ -6519,3 +6519,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed w
-- Delete Chat -- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"

View File

@ -56,6 +56,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool autoSaveEnabled; private bool autoSaveEnabled;
private string currentWorkspaceName = string.Empty; private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty; private Guid currentWorkspaceId = Guid.Empty;
private Guid currentChatThreadId = Guid.Empty;
private CancellationTokenSource? cancellationTokenSource; private CancellationTokenSource? cancellationTokenSource;
private HashSet<FileAttachment> chatDocumentPaths = []; private HashSet<FileAttachment> chatDocumentPaths = [];
@ -197,8 +198,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// //
if (this.ChatThread is not null) if (this.ChatThread is not null)
{ {
this.currentChatThreadId = this.ChatThread.ChatId;
this.currentWorkspaceId = this.ChatThread.WorkspaceId; this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
} }
@ -214,12 +216,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.mustStoreChat = false; this.mustStoreChat = false;
if(this.Workspaces is not null) if(this.Workspaces is not null)
await this.Workspaces.StoreChat(this.ChatThread); await this.Workspaces.StoreChatAsync(this.ChatThread);
else else
await WorkspaceBehaviour.StoreChat(this.ChatThread); await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
this.currentWorkspaceId = this.ChatThread.WorkspaceId; this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
} }
@ -227,14 +229,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{ {
this.Logger.LogInformation($"Try to load the chat '{this.loadChat.ChatId}' now."); this.Logger.LogInformation($"Try to load the chat '{this.loadChat.ChatId}' now.");
this.mustLoadChat = false; this.mustLoadChat = false;
this.ChatThread = await WorkspaceBehaviour.LoadChat(this.loadChat); this.ChatThread = await WorkspaceBehaviour.LoadChatAsync(this.loadChat);
if(this.ChatThread is not null) if(this.ChatThread is not null)
{ {
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully."); this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully.");
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
await this.SelectProviderWhenLoadingChat(); await this.SelectProviderWhenLoadingChat();
} }
@ -260,8 +262,50 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
} }
protected override async Task OnParametersSetAsync()
{
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await base.OnParametersSetAsync();
}
#endregion #endregion
private async Task SyncWorkspaceHeaderWithChatThreadAsync()
{
if (this.ChatThread is null)
{
if (this.currentChatThreadId != Guid.Empty || this.currentWorkspaceId != Guid.Empty || !string.IsNullOrWhiteSpace(this.currentWorkspaceName))
{
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty;
this.WorkspaceName(this.currentWorkspaceName);
}
return;
}
// Guard: If ChatThread ID and WorkspaceId haven't changed, skip entirely.
// Using ID-based comparison instead of name-based to correctly handle
// temporary chats where the workspace name is always empty.
if (this.currentChatThreadId == this.ChatThread.ChatId
&& this.currentWorkspaceId == this.ChatThread.WorkspaceId)
return;
this.currentChatThreadId = this.ChatThread.ChatId;
this.currentWorkspaceId = this.ChatThread.WorkspaceId;
var loadedWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
// Only notify the parent when the name actually changed to prevent
// an infinite render loop: WorkspaceName → UpdateWorkspaceName →
// StateHasChanged → re-render → OnParametersSetAsync → WorkspaceName → ...
if (this.currentWorkspaceName != loadedWorkspaceName)
{
this.currentWorkspaceName = loadedWorkspaceName;
this.WorkspaceName(this.currentWorkspaceName);
}
}
private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE; private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE;
private string ProviderPlaceholder => this.IsProviderSelected ? T("Type your input here...") : T("Select a provider first"); private string ProviderPlaceholder => this.IsProviderSelected ? T("Type your input here...") : T("Select a provider first");
@ -428,8 +472,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) if(!this.ChatThread.IsLLMProviderAllowed(this.Provider))
return; return;
// We need to blur the focus away from the input field // Blur the focus away from the input field to be able to clear it:
// to be able to clear the field:
await this.inputField.BlurAsync(); await this.inputField.BlurAsync();
// Create a new chat thread if necessary: // Create a new chat thread if necessary:
@ -520,8 +563,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Clear the input field: // Clear the input field:
await this.inputField.FocusAsync(); await this.inputField.FocusAsync();
this.userInput = string.Empty; this.userInput = string.Empty;
this.chatDocumentPaths.Clear(); this.chatDocumentPaths.Clear();
await this.inputField.BlurAsync(); await this.inputField.BlurAsync();
// Enable the stream state for the chat component: // Enable the stream state for the chat component:
@ -583,9 +628,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// the workspace gets updated automatically when the chat is saved. // the workspace gets updated automatically when the chat is saved.
// //
if (this.Workspaces is not null) if (this.Workspaces is not null)
await this.Workspaces.StoreChat(this.ChatThread); await this.Workspaces.StoreChatAsync(this.ChatThread);
else else
await WorkspaceBehaviour.StoreChat(this.ChatThread); await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
} }
@ -621,9 +666,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
chatPath = Path.Join(SettingsManager.DataDirectory, "workspaces", this.ChatThread.WorkspaceId.ToString(), this.ChatThread.ChatId.ToString()); chatPath = Path.Join(SettingsManager.DataDirectory, "workspaces", this.ChatThread.WorkspaceId.ToString(), this.ChatThread.ChatId.ToString());
if(this.Workspaces is null) if(this.Workspaces is null)
await WorkspaceBehaviour.DeleteChat(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
else else
await this.Workspaces.DeleteChat(chatPath, askForConfirmation: false, unloadChat: true); await this.Workspaces.DeleteChatAsync(chatPath, askForConfirmation: false, unloadChat: true);
} }
// //
@ -665,6 +710,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// to reset the chat thread: // to reset the chat thread:
// //
this.ChatThread = null; this.ChatThread = null;
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty; this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty; this.currentWorkspaceName = string.Empty;
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
@ -739,13 +785,13 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
return; return;
// Delete the chat from the current workspace or the temporary storage: // Delete the chat from the current workspace or the temporary storage:
await WorkspaceBehaviour.DeleteChat(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false); await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
this.ChatThread!.WorkspaceId = workspaceId; this.ChatThread!.WorkspaceId = workspaceId;
await this.SaveThread(); await this.SaveThread();
this.currentWorkspaceId = this.ChatThread.WorkspaceId; this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
} }
@ -758,12 +804,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.ChatThread is not null) if (this.ChatThread is not null)
{ {
this.currentWorkspaceId = this.ChatThread.WorkspaceId; this.currentWorkspaceId = this.ChatThread.WorkspaceId;
this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceName(this.ChatThread.WorkspaceId); this.currentWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(this.ChatThread.WorkspaceId);
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
this.currentChatThreadId = this.ChatThread.ChatId;
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
} }
else else
{ {
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty; this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty; this.currentWorkspaceName = string.Empty;
this.WorkspaceName(this.currentWorkspaceName); this.WorkspaceName(this.currentWorkspaceName);
@ -785,6 +833,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.isStreaming = false; this.isStreaming = false;
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.userInput = string.Empty;
this.currentChatThreadId = Guid.Empty;
this.currentWorkspaceId = Guid.Empty; this.currentWorkspaceId = Guid.Empty;
this.currentWorkspaceName = string.Empty; this.currentWorkspaceName = string.Empty;

View File

@ -4,6 +4,7 @@ public enum TreeItemType
{ {
NONE, NONE,
LOADING,
CHAT, CHAT,
WORKSPACE, WORKSPACE,
} }

View File

@ -1,93 +1,114 @@
@inherits MSGComponentBase @inherits MSGComponentBase
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true" Class="ma-3"> @if (this.isInitialLoading)
<ItemTemplate Context="item"> {
@switch (item.Value) <MudStack Class="ma-3" Spacing="1">
<MudSkeleton Width="40%" Height="30px"/>
@for (var i = 0; i < 10; i++)
{ {
case TreeDivider: <MudSkeleton Width="95%" Height="26px"/>
<li style="min-height: 1em;">
<MudDivider Style="margin-top: 1em; width: 90%; border-width: 3pt;"/>
</li>
break;
case TreeItemData treeItem:
@if (treeItem.Type is TreeItemType.CHAT)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.LoadChat(treeItem.Path, true))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@if (string.IsNullOrWhiteSpace(treeItem.Text))
{
@T("Empty chat")
}
else
{
@treeItem.ShortenedText
}
</MudText>
<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" OnClick="@(() => this.MoveChat(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameChat(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteChat(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else if (treeItem.Type is TreeItemType.WORKSPACE)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameWorkspace(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteWorkspace(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
</div>
</BodyContent>
</MudTreeViewItem>
}
break;
case TreeButton treeButton:
<li>
<div class="mud-treeview-item-content" style="background-color: unset;">
<div class="mud-treeview-item-arrow"></div>
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="@treeButton.Action">
@treeButton.Text
</MudButton>
</div>
</li>
break;
} }
</ItemTemplate> </MudStack>
</MudTreeView> }
else
{
<MudTreeView T="ITreeItem" Items="@this.treeItems" SelectionMode="SelectionMode.SingleSelection" Hover="@true" ExpandOnClick="@true" Class="ma-3">
<ItemTemplate Context="item">
@switch (item.Value)
{
case TreeDivider:
<li style="min-height: 1em;">
<MudDivider Style="margin-top: 1em; width: 90%; border-width: 3pt;"/>
</li>
break;
case TreeItemData treeItem:
@if (treeItem.Type is TreeItemType.LOADING)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@false" Items="@treeItem.Children">
<BodyContent>
<MudSkeleton Width="85%" Height="22px"/>
</BodyContent>
</MudTreeViewItem>
}
else if (treeItem.Type is TreeItemType.CHAT)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.LoadChatAsync(treeItem.Path, true))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@if (string.IsNullOrWhiteSpace(treeItem.Text))
{
@T("Empty chat")
}
else
{
@treeItem.ShortenedText
}
</MudText>
<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" OnClick="@(() => this.MoveChatAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameChatAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteChatAsync(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else if (treeItem.Type is TreeItemType.WORKSPACE)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children" OnClick="@(() => this.OnWorkspaceClicked(treeItem))">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
<div style="justify-self: end;">
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameWorkspaceAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteWorkspaceAsync(treeItem.Path))"/>
</MudTooltip>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
}
else
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@treeItem.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
</div>
</BodyContent>
</MudTreeViewItem>
}
break;
case TreeButton treeButton:
<li>
<div class="mud-treeview-item-content" style="background-color: unset;">
<div class="mud-treeview-item-arrow"></div>
<MudButton StartIcon="@treeButton.Icon" Variant="Variant.Filled" OnClick="@treeButton.Action">
@treeButton.Text
</MudButton>
</div>
</li>
break;
}
</ItemTemplate>
</MudTreeView>
}

View File

@ -1,4 +1,4 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AIStudio.Chat; using AIStudio.Chat;
@ -29,31 +29,64 @@ public partial class Workspaces : MSGComponentBase
public bool ExpandRootNodes { get; set; } = true; public bool ExpandRootNodes { get; set; } = true;
private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom; private const Placement WORKSPACE_ITEM_TOOLTIP_PLACEMENT = Placement.Bottom;
private readonly SemaphoreSlim treeLoadingSemaphore = new(1, 1);
private readonly List<TreeItemData<ITreeItem>> treeItems = [];
private readonly HashSet<Guid> loadingWorkspaceChatLists = [];
private readonly List<TreeItemData<ITreeItem>> treeItems = new(); private CancellationTokenSource? prefetchCancellationTokenSource;
private bool isInitialLoading = true;
private bool isDisposed;
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await base.OnInitializedAsync(); await base.OnInitializedAsync();
_ = this.LoadTreeItemsAsync(startPrefetch: true);
//
// Notice: In order to get the server-based loading to work, we need to respect the following rules:
// - We must have initial tree items
// - Those initial tree items cannot have children
// - When assigning the tree items to the MudTreeViewItem component, we must set the Value property to the value of the item
//
// We won't await the loading of the tree items here,
// to avoid blocking the UI thread:
_ = this.LoadTreeItems();
} }
#endregion #endregion
private async Task LoadTreeItems() private async Task LoadTreeItemsAsync(bool startPrefetch = true, bool forceReload = false)
{
await this.treeLoadingSemaphore.WaitAsync();
try
{
if (this.isDisposed)
return;
if (forceReload)
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.BuildTreeItems(snapshot);
this.isInitialLoading = false;
}
finally
{
this.treeLoadingSemaphore.Release();
}
await this.SafeStateHasChanged();
if (startPrefetch)
await this.StartPrefetchAsync();
}
private void BuildTreeItems(WorkspaceTreeCacheSnapshot snapshot)
{ {
this.treeItems.Clear(); this.treeItems.Clear();
var workspaceChildren = new List<TreeItemData<ITreeItem>>();
foreach (var workspace in snapshot.Workspaces)
workspaceChildren.Add(this.CreateWorkspaceTreeItem(workspace));
workspaceChildren.Add(new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 1, T("Add workspace"), Icons.Material.Filled.LibraryAdd, this.AddWorkspaceAsync),
});
this.treeItems.Add(new TreeItemData<ITreeItem> this.treeItems.Add(new TreeItemData<ITreeItem>
{ {
Expanded = this.ExpandRootNodes, Expanded = this.ExpandRootNodes,
@ -66,7 +99,7 @@ public partial class Workspaces : MSGComponentBase
Icon = Icons.Material.Filled.Folder, Icon = Icons.Material.Filled.Folder,
Expandable = true, Expandable = true,
Path = "root", Path = "root",
Children = await this.LoadWorkspaces(), Children = workspaceChildren,
}, },
}); });
@ -76,7 +109,10 @@ public partial class Workspaces : MSGComponentBase
Value = new TreeDivider(), Value = new TreeDivider(),
}); });
await this.InvokeAsync(this.StateHasChanged); var temporaryChatsChildren = new List<TreeItemData<ITreeItem>>();
foreach (var temporaryChat in snapshot.TemporaryChats.OrderByDescending(x => x.LastEditTime))
temporaryChatsChildren.Add(CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer));
this.treeItems.Add(new TreeItemData<ITreeItem> this.treeItems.Add(new TreeItemData<ITreeItem>
{ {
Expanded = this.ExpandRootNodes, Expanded = this.ExpandRootNodes,
@ -89,227 +125,212 @@ public partial class Workspaces : MSGComponentBase
Icon = Icons.Material.Filled.Timer, Icon = Icons.Material.Filled.Timer,
Expandable = true, Expandable = true,
Path = "temp", Path = "temp",
Children = await this.LoadTemporaryChats(), Children = temporaryChatsChildren,
}, },
}); });
}
private TreeItemData<ITreeItem> CreateWorkspaceTreeItem(WorkspaceTreeWorkspace workspace)
{
var children = new List<TreeItemData<ITreeItem>>();
if (workspace.ChatsLoaded)
{
foreach (var workspaceChat in workspace.Chats.OrderByDescending(x => x.LastEditTime))
children.Add(CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat));
}
else if (this.loadingWorkspaceChatLists.Contains(workspace.WorkspaceId))
children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath));
children.Add(new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 2, T("Add chat"), Icons.Material.Filled.AddComment, () => this.AddChatAsync(workspace.WorkspacePath)),
});
return new TreeItemData<ITreeItem>
{
Expandable = true,
Value = new TreeItemData
{
Type = TreeItemType.WORKSPACE,
Depth = 1,
Branch = WorkspaceBranch.WORKSPACES,
Text = workspace.Name,
Icon = Icons.Material.Filled.Description,
Expandable = true,
Path = workspace.WorkspacePath,
Children = children,
},
};
}
private IReadOnlyCollection<TreeItemData<ITreeItem>> CreateLoadingRows(string workspacePath)
{
return
[
this.CreateLoadingTreeItem(workspacePath, "loading_1"),
this.CreateLoadingTreeItem(workspacePath, "loading_2"),
this.CreateLoadingTreeItem(workspacePath, "loading_3"),
];
}
private TreeItemData<ITreeItem> CreateLoadingTreeItem(string workspacePath, string suffix)
{
return new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeItemData
{
Type = TreeItemType.LOADING,
Depth = 2,
Branch = WorkspaceBranch.WORKSPACES,
Text = T("Loading chats..."),
Icon = Icons.Material.Filled.HourglassTop,
Expandable = false,
Path = Path.Join(workspacePath, suffix),
},
};
}
private static TreeItemData<ITreeItem> CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon)
{
return new TreeItemData<ITreeItem>
{
Expandable = false,
Value = new TreeItemData
{
Type = TreeItemType.CHAT,
Depth = depth,
Branch = branch,
Text = chat.Name,
Icon = icon,
Expandable = false,
Path = chat.ChatPath,
LastEditTime = chat.LastEditTime,
},
};
}
private async Task SafeStateHasChanged()
{
if (this.isDisposed)
return;
await this.InvokeAsync(this.StateHasChanged); await this.InvokeAsync(this.StateHasChanged);
} }
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadTemporaryChats() private async Task StartPrefetchAsync()
{ {
var tempChildren = new List<TreeItemData>(); if (this.prefetchCancellationTokenSource is not null)
// Get the temp root directory:
var temporaryDirectories = Path.Join(SettingsManager.DataDirectory, "tempChats");
// Ensure the directory exists:
Directory.CreateDirectory(temporaryDirectories);
// Enumerate the chat directories:
foreach (var tempChatDirPath in Directory.EnumerateDirectories(temporaryDirectories))
{ {
// Read or create the `name` file (self-heal): await this.prefetchCancellationTokenSource.CancelAsync();
var chatNamePath = Path.Join(tempChatDirPath, "name"); this.prefetchCancellationTokenSource.Dispose();
string chatName;
try
{
if (!File.Exists(chatNamePath))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
else
{
chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(chatName))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
}
}
catch
{
chatName = T("Unnamed chat");
}
// Read the last change time of the chat:
var chatThreadPath = Path.Join(tempChatDirPath, "thread.json");
var lastEditTime = File.GetLastWriteTimeUtc(chatThreadPath);
tempChildren.Add(new TreeItemData
{
Type = TreeItemType.CHAT,
Depth = 1,
Branch = WorkspaceBranch.TEMPORARY_CHATS,
Text = chatName,
Icon = Icons.Material.Filled.Timer,
Expandable = false,
Path = tempChatDirPath,
LastEditTime = lastEditTime,
});
} }
var result = new List<TreeItemData<ITreeItem>>(tempChildren.OrderByDescending(n => n.LastEditTime).Select(n => new TreeItemData<ITreeItem> this.prefetchCancellationTokenSource = new CancellationTokenSource();
{ await this.PrefetchWorkspaceChatsAsync(this.prefetchCancellationTokenSource.Token);
Expandable = false,
Value = n,
}));
return result;
} }
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadWorkspaces() private async Task PrefetchWorkspaceChatsAsync(CancellationToken cancellationToken)
{ {
var workspaces = new List<TreeItemData<ITreeItem>>(); try
//
// Search for workspace folders in the data directory:
//
// Get the workspace root directory:
var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces");
// Ensure the directory exists:
Directory.CreateDirectory(workspaceDirectories);
// Enumerate the workspace directories:
foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories))
{ {
// Read or create the `name` file (self-heal): await WorkspaceBehaviour.TryPrefetchRemainingChatsAsync(async _ =>
var workspaceNamePath = Path.Join(workspaceDirPath, "name");
string workspaceName;
try
{ {
if (!File.Exists(workspaceNamePath)) if (this.isDisposed || cancellationToken.IsCancellationRequested)
{ return;
workspaceName = T("Unnamed workspace");
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
}
else
{
workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(workspaceName))
{
workspaceName = T("Unnamed workspace");
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
}
}
}
catch
{
workspaceName = T("Unnamed workspace");
}
workspaces.Add(new TreeItemData<ITreeItem> await this.LoadTreeItemsAsync(startPrefetch: false);
{ }, cancellationToken);
Expandable = true, }
Value = new TreeItemData catch (OperationCanceledException)
{ {
Type = TreeItemType.WORKSPACE, // Expected when the component is hidden or disposed.
Depth = 1, }
Branch = WorkspaceBranch.WORKSPACES, catch (Exception ex)
Text = workspaceName, {
Icon = Icons.Material.Filled.Description, this.Logger.LogWarning(ex, "Failed while prefetching workspace chats.");
Expandable = true, }
Path = workspaceDirPath, }
Children = await this.LoadWorkspaceChats(workspaceDirPath),
}, private async Task OnWorkspaceClicked(TreeItemData treeItem)
}); {
if (treeItem.Type is not TreeItemType.WORKSPACE)
return;
if (!Guid.TryParse(Path.GetFileName(treeItem.Path), out var workspaceId))
return;
await this.EnsureWorkspaceChatsLoadedAsync(workspaceId);
}
private async Task EnsureWorkspaceChatsLoadedAsync(Guid workspaceId)
{
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var hasWorkspace = false;
var chatsLoaded = false;
foreach (var workspace in snapshot.Workspaces)
{
if (workspace.WorkspaceId != workspaceId)
continue;
hasWorkspace = true;
chatsLoaded = workspace.ChatsLoaded;
break;
} }
workspaces.Add(new TreeItemData<ITreeItem> if (!hasWorkspace || chatsLoaded || !this.loadingWorkspaceChatLists.Add(workspaceId))
return;
await this.LoadTreeItemsAsync(startPrefetch: false);
try
{ {
Expandable = false, await WorkspaceBehaviour.GetWorkspaceChatsAsync(workspaceId);
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 1, T("Add workspace"),Icons.Material.Filled.LibraryAdd, this.AddWorkspace), }
}); finally
return workspaces;
}
private async Task<IReadOnlyCollection<TreeItemData<ITreeItem>>> LoadWorkspaceChats(string workspacePath)
{
var workspaceChats = new List<TreeItemData>();
// Enumerate the workspace directory:
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
{ {
// Read or create the `name` file (self-heal): this.loadingWorkspaceChatLists.Remove(workspaceId);
var chatNamePath = Path.Join(chatPath, "name");
string chatName;
try
{
if (!File.Exists(chatNamePath))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
else
{
chatName = await File.ReadAllTextAsync(chatNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(chatName))
{
chatName = T("Unnamed chat");
await File.WriteAllTextAsync(chatNamePath, chatName, Encoding.UTF8);
}
}
}
catch
{
chatName = T("Unnamed chat");
}
// Read the last change time of the chat:
var chatThreadPath = Path.Join(chatPath, "thread.json");
var lastEditTime = File.GetLastWriteTimeUtc(chatThreadPath);
workspaceChats.Add(new TreeItemData
{
Type = TreeItemType.CHAT,
Depth = 2,
Branch = WorkspaceBranch.WORKSPACES,
Text = chatName,
Icon = Icons.Material.Filled.Chat,
Expandable = false,
Path = chatPath,
LastEditTime = lastEditTime,
});
} }
var result = new List<TreeItemData<ITreeItem>>(workspaceChats.OrderByDescending(n => n.LastEditTime).Select(n => new TreeItemData<ITreeItem> await this.LoadTreeItemsAsync(startPrefetch: false);
{
Expandable = false,
Value = n,
}));
result.Add(new()
{
Expandable = false,
Value = new TreeButton(WorkspaceBranch.WORKSPACES, 2, T("Add chat"),Icons.Material.Filled.AddComment, () => this.AddChat(workspacePath)),
});
return result;
} }
public async Task StoreChat(ChatThread chat, bool reloadTreeItems = true) public async Task ForceRefreshFromDiskAsync()
{ {
await WorkspaceBehaviour.StoreChat(chat); if (this.prefetchCancellationTokenSource is not null)
{
await this.prefetchCancellationTokenSource.CancelAsync();
this.prefetchCancellationTokenSource.Dispose();
this.prefetchCancellationTokenSource = null;
}
// Reload the tree items: this.loadingWorkspaceChatLists.Clear();
if(reloadTreeItems) this.isInitialLoading = true;
await this.LoadTreeItems();
this.StateHasChanged(); await this.SafeStateHasChanged();
await this.LoadTreeItemsAsync(startPrefetch: true, forceReload: true);
} }
private async Task<ChatThread?> LoadChat(string? chatPath, bool switchToChat) public async Task StoreChatAsync(ChatThread chat, bool reloadTreeItems = false)
{ {
if(string.IsNullOrWhiteSpace(chatPath)) await WorkspaceBehaviour.StoreChatAsync(chat);
if (reloadTreeItems)
this.loadingWorkspaceChatLists.Clear();
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task<ChatThread?> LoadChatAsync(string? chatPath, bool switchToChat)
{
if (string.IsNullOrWhiteSpace(chatPath))
return null; return null;
if(!Directory.Exists(chatPath)) if (!Directory.Exists(chatPath))
return null; return null;
// Check if the chat has unsaved changes:
if (switchToChat && await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES)) if (switchToChat && await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{ {
var dialogParameters = new DialogParameters<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
@ -344,15 +365,15 @@ public partial class Workspaces : MSGComponentBase
return null; return null;
} }
public async Task DeleteChat(string? chatPath, bool askForConfirmation = true, bool unloadChat = true) public async Task DeleteChatAsync(string? chatPath, bool askForConfirmation = true, bool unloadChat = true)
{ {
var chat = await this.LoadChat(chatPath, false); var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null) if (chat is null)
return; return;
if (askForConfirmation) if (askForConfirmation)
{ {
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(chat.WorkspaceId); var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId);
var dialogParameters = new DialogParameters<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
{ {
{ {
@ -370,16 +391,10 @@ public partial class Workspaces : MSGComponentBase
return; return;
} }
string chatDirectory; await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false);
if (chat.WorkspaceId == Guid.Empty) await this.LoadTreeItemsAsync(startPrefetch: false);
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString());
else
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
Directory.Delete(chatDirectory, true); if (unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId)
await this.LoadTreeItems();
if(unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId)
{ {
this.CurrentChatThread = null; this.CurrentChatThread = null;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
@ -387,9 +402,9 @@ public partial class Workspaces : MSGComponentBase
} }
} }
private async Task RenameChat(string? chatPath) private async Task RenameChatAsync(string? chatPath)
{ {
var chat = await this.LoadChat(chatPath, false); var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null) if (chat is null)
return; return;
@ -410,24 +425,24 @@ public partial class Workspaces : MSGComponentBase
return; return;
chat.Name = (dialogResult.Data as string)!; chat.Name = (dialogResult.Data as string)!;
if(this.CurrentChatThread?.ChatId == chat.ChatId) if (this.CurrentChatThread?.ChatId == chat.ChatId)
{ {
this.CurrentChatThread.Name = chat.Name; this.CurrentChatThread.Name = chat.Name;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
} }
await this.StoreChat(chat); await WorkspaceBehaviour.StoreChatAsync(chat);
await this.LoadTreeItems(); await this.LoadTreeItemsAsync(startPrefetch: false);
} }
private async Task RenameWorkspace(string? workspacePath) private async Task RenameWorkspaceAsync(string? workspacePath)
{ {
if(workspacePath is null) if (workspacePath is null)
return; return;
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(workspaceId); var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId);
var dialogParameters = new DialogParameters<SingleInputDialog> var dialogParameters = new DialogParameters<SingleInputDialog>
{ {
{ x => x.Message, string.Format(T("Please enter a new or edit the name for your workspace '{0}':"), workspaceName) }, { x => x.Message, string.Format(T("Please enter a new or edit the name for your workspace '{0}':"), workspaceName) },
@ -447,10 +462,11 @@ public partial class Workspaces : MSGComponentBase
var alteredWorkspaceName = (dialogResult.Data as string)!; var alteredWorkspaceName = (dialogResult.Data as string)!;
var workspaceNamePath = Path.Join(workspacePath, "name"); var workspaceNamePath = Path.Join(workspacePath, "name");
await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8); await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8);
await this.LoadTreeItems(); await WorkspaceBehaviour.UpdateWorkspaceNameInCacheAsync(workspaceId, alteredWorkspaceName);
await this.LoadTreeItemsAsync(startPrefetch: false);
} }
private async Task AddWorkspace() private async Task AddWorkspaceAsync()
{ {
var dialogParameters = new DialogParameters<SingleInputDialog> var dialogParameters = new DialogParameters<SingleInputDialog>
{ {
@ -472,23 +488,23 @@ public partial class Workspaces : MSGComponentBase
var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString());
Directory.CreateDirectory(workspacePath); Directory.CreateDirectory(workspacePath);
var workspaceName = (dialogResult.Data as string)!;
var workspaceNamePath = Path.Join(workspacePath, "name"); var workspaceNamePath = Path.Join(workspacePath, "name");
await File.WriteAllTextAsync(workspaceNamePath, (dialogResult.Data as string)!, Encoding.UTF8); await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
await WorkspaceBehaviour.AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName);
await this.LoadTreeItems(); await this.LoadTreeItemsAsync(startPrefetch: false);
} }
private async Task DeleteWorkspace(string? workspacePath) private async Task DeleteWorkspaceAsync(string? workspacePath)
{ {
if(workspacePath is null) if (workspacePath is null)
return; return;
var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); var workspaceId = Guid.Parse(Path.GetFileName(workspacePath));
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceName(workspaceId); var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId);
// Determine how many chats are in the workspace:
var chatCount = Directory.EnumerateDirectories(workspacePath).Count(); var chatCount = Directory.EnumerateDirectories(workspacePath).Count();
var dialogParameters = new DialogParameters<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
{ {
{ x => x.Message, string.Format(T("Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace."), workspaceName, chatCount) }, { x => x.Message, string.Format(T("Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace."), workspaceName, chatCount) },
@ -500,12 +516,13 @@ public partial class Workspaces : MSGComponentBase
return; return;
Directory.Delete(workspacePath, true); Directory.Delete(workspacePath, true);
await this.LoadTreeItems(); await WorkspaceBehaviour.RemoveWorkspaceFromCacheAsync(workspaceId);
await this.LoadTreeItemsAsync(startPrefetch: false);
} }
private async Task MoveChat(string? chatPath) private async Task MoveChatAsync(string? chatPath)
{ {
var chat = await this.LoadChat(chatPath, false); var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null) if (chat is null)
return; return;
@ -525,22 +542,9 @@ public partial class Workspaces : MSGComponentBase
if (workspaceId == Guid.Empty) if (workspaceId == Guid.Empty)
return; return;
// Delete the chat from the current workspace or the temporary storage: await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false);
if (chat.WorkspaceId == Guid.Empty)
{
// Case: The chat is stored in the temporary storage:
await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false);
}
else
{
// Case: The chat is stored in a workspace.
await this.DeleteChat(Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()), askForConfirmation: false, unloadChat: false);
}
// Update the chat's workspace:
chat.WorkspaceId = workspaceId; chat.WorkspaceId = workspaceId;
// Handle the case where the chat is the active chat:
if (this.CurrentChatThread?.ChatId == chat.ChatId) if (this.CurrentChatThread?.ChatId == chat.ChatId)
{ {
this.CurrentChatThread = chat; this.CurrentChatThread = chat;
@ -548,12 +552,12 @@ public partial class Workspaces : MSGComponentBase
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED); await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
} }
await this.StoreChat(chat); await WorkspaceBehaviour.StoreChatAsync(chat);
await this.LoadTreeItemsAsync(startPrefetch: false);
} }
private async Task AddChat(string workspacePath) private async Task AddChatAsync(string workspacePath)
{ {
// Check if the chat has unsaved changes:
if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES)) if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{ {
var dialogParameters = new DialogParameters<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
@ -579,9 +583,9 @@ public partial class Workspaces : MSGComponentBase
var chatPath = Path.Join(workspacePath, chat.ChatId.ToString()); var chatPath = Path.Join(workspacePath, chat.ChatId.ToString());
await this.StoreChat(chat); await WorkspaceBehaviour.StoreChatAsync(chat);
await this.LoadChat(chatPath, switchToChat: true); await this.LoadChatAsync(chatPath, switchToChat: true);
await this.LoadTreeItems(); await this.LoadTreeItemsAsync(startPrefetch: false);
} }
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase
@ -591,11 +595,20 @@ public partial class Workspaces : MSGComponentBase
switch (triggeredEvent) switch (triggeredEvent)
{ {
case Event.PLUGINS_RELOADED: case Event.PLUGINS_RELOADED:
await this.LoadTreeItems(); await this.ForceRefreshFromDiskAsync();
await this.InvokeAsync(this.StateHasChanged);
break; break;
} }
} }
protected override void DisposeResources()
{
this.isDisposed = true;
this.prefetchCancellationTokenSource?.Cancel();
this.prefetchCancellationTokenSource?.Dispose();
this.prefetchCancellationTokenSource = null;
base.DisposeResources();
}
#endregion #endregion
} }

View File

@ -1,7 +1,4 @@
using System.Text;
using AIStudio.Components; using AIStudio.Components;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@ -30,24 +27,9 @@ public partial class WorkspaceSelectionDialog : MSGComponentBase
{ {
this.selectedWorkspace = this.SelectedWorkspace; this.selectedWorkspace = this.SelectedWorkspace;
// Get the workspace root directory: var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var workspaceDirectories = Path.Join(SettingsManager.DataDirectory, "workspaces"); foreach (var workspace in snapshot.Workspaces)
if(!Directory.Exists(workspaceDirectories)) this.workspaces[workspace.Name] = workspace.WorkspaceId;
{
await base.OnInitializedAsync();
return;
}
// Enumerate the workspace directories:
foreach (var workspaceDirPath in Directory.EnumerateDirectories(workspaceDirectories))
{
// Read the `name` file:
var workspaceNamePath = Path.Join(workspaceDirPath, "name");
var workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
// Add the workspace to the list:
this.workspaces.Add(workspaceName, Guid.Parse(Path.GetFileName(workspaceDirPath)));
}
this.StateHasChanged(); this.StateHasChanged();
await base.OnInitializedAsync(); await base.OnInitializedAsync();

View File

@ -48,6 +48,9 @@
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/> <MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Reload your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Medium" OnClick="@this.RefreshWorkspaces"/>
</MudTooltip>
<MudTooltip Text="@T("Hide your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Hide your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" Class="me-1" OnClick="@(() => this.ToggleWorkspaceSidebar())"/> <MudIconButton Size="Size.Medium" Icon="@this.WorkspaceSidebarToggleIcon" Class="me-1" OnClick="@(() => this.ToggleWorkspaceSidebar())"/>
</MudTooltip> </MudTooltip>
@ -71,6 +74,9 @@
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/> <MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Reload your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Medium" OnClick="@this.RefreshWorkspaces"/>
</MudTooltip>
</MudStack> </MudStack>
</HeaderContent> </HeaderContent>
<ChildContent> <ChildContent>
@ -137,6 +143,9 @@
<MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Configure your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/> <MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Medium" OnClick="@(async () => await this.OpenWorkspacesSettingsDialog())"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Reload your workspaces")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Medium" OnClick="@this.RefreshWorkspaces"/>
</MudTooltip>
<MudIconButton Icon="@Icons.Material.Filled.Close" Color="Color.Error" Size="Size.Medium" OnClick="@(() => this.ToggleWorkspacesOverlay())"/> <MudIconButton Icon="@Icons.Material.Filled.Close" Color="Color.Error" Size="Size.Medium" OnClick="@(() => this.ToggleWorkspacesOverlay())"/>
</MudStack> </MudStack>
</MudDrawerHeader> </MudDrawerHeader>

View File

@ -98,6 +98,14 @@ public partial class Chat : MSGComponentBase
await this.DialogService.ShowAsync<SettingsDialogWorkspaces>(T("Open Workspaces Configuration"), dialogParameters, DialogOptions.FULLSCREEN); await this.DialogService.ShowAsync<SettingsDialogWorkspaces>(T("Open Workspaces Configuration"), dialogParameters, DialogOptions.FULLSCREEN);
} }
private async Task RefreshWorkspaces()
{
if (this.workspaces is null)
return;
await this.workspaces.ForceRefreshFromDiskAsync();
}
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase
protected override void DisposeResources() protected override void DisposeResources()

View File

@ -2559,8 +2559,8 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie
-- Move chat -- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben"
-- Unnamed workspace -- Loading chats...
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1307384014"] = "Unbenannter Arbeitsbereich" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Chats werden geladen..."
-- Delete -- Delete
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Löschen" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Löschen"
@ -2616,9 +2616,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Bitte geben S
-- Please enter a workspace name. -- Please enter a workspace name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein."
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3310482275"] = "Unbenannter Chat"
-- Rename -- Rename
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen"
@ -4983,6 +4980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Lerne jeden Tag ei
-- Localization -- Localization
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Lokalisierung" UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Lokalisierung"
-- Reload your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T194629703"] = "Arbeitsbereiche neu laden"
-- Hide your workspaces -- Hide your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Arbeitsbereiche ausblenden" UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Arbeitsbereiche ausblenden"
@ -6521,3 +6521,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannt
-- Delete Chat -- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen" UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat"

View File

@ -2559,8 +2559,8 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure
-- Move chat -- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat"
-- Unnamed workspace -- Loading chats...
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1307384014"] = "Unnamed workspace" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..."
-- Delete -- Delete
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1469573738"] = "Delete"
@ -2616,9 +2616,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Please enter
-- Please enter a workspace name. -- Please enter a workspace name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name."
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3310482275"] = "Unnamed chat"
-- Rename -- Rename
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename"
@ -4983,6 +4980,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T878695986"] = "Learn about one co
-- Localization -- Localization
UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization" UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T897888480"] = "Localization"
-- Reload your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T194629703"] = "Reload your workspaces"
-- Hide your workspaces -- Hide your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Hide your workspaces" UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T2351468526"] = "Hide your workspaces"
@ -6521,3 +6521,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed w
-- Delete Chat -- Delete Chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"

View File

@ -67,6 +67,7 @@ public sealed class TemporaryChatService(ILogger<TemporaryChatService> logger, S
{ {
logger.LogInformation($"Deleting temporary chat storage directory '{tempChatDirPath}' due to maintenance policy."); logger.LogInformation($"Deleting temporary chat storage directory '{tempChatDirPath}' due to maintenance policy.");
Directory.Delete(tempChatDirPath, true); Directory.Delete(tempChatDirPath, true);
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
} }
} }

View File

@ -12,21 +12,73 @@ namespace AIStudio.Tools;
public static class WorkspaceBehaviour public static class WorkspaceBehaviour
{ {
private sealed class WorkspaceChatCacheEntry
{
public Guid WorkspaceId { get; init; }
public Guid ChatId { get; init; }
public string ChatPath { get; init; } = string.Empty;
public string ChatName { get; set; } = string.Empty;
public DateTimeOffset LastEditTime { get; set; }
public bool IsTemporary { get; init; }
}
private sealed class WorkspaceCacheEntry
{
public Guid WorkspaceId { get; init; }
public string WorkspacePath { get; init; } = string.Empty;
public string WorkspaceName { get; set; } = string.Empty;
public bool ChatsLoaded { get; set; }
public List<WorkspaceChatCacheEntry> Chats { get; set; } = [];
}
private sealed class WorkspaceTreeCacheState
{
public Dictionary<Guid, WorkspaceCacheEntry> Workspaces { get; } = [];
public List<Guid> WorkspaceOrder { get; } = [];
public List<WorkspaceChatCacheEntry> TemporaryChats { get; set; } = [];
public bool IsShellLoaded { get; set; }
}
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(WorkspaceBehaviour)); private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(WorkspaceBehaviour));
private static readonly ConcurrentDictionary<string, SemaphoreSlim> CHAT_STORAGE_SEMAPHORES = new();
private static readonly SemaphoreSlim WORKSPACE_TREE_CACHE_SEMAPHORE = new(1, 1);
private static readonly WorkspaceTreeCacheState WORKSPACE_TREE_CACHE = new();
private static readonly TimeSpan SEMAPHORE_TIMEOUT = TimeSpan.FromSeconds(6);
private static volatile bool WORKSPACE_TREE_CACHE_INVALIDATED = true;
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour));
/// <summary> public static readonly JsonSerializerOptions JSON_OPTIONS = new()
/// Semaphores for synchronizing chat storage operations per chat. {
/// This prevents race conditions when multiple threads try to write WriteIndented = true,
/// the same chat file simultaneously. AllowTrailingCommas = true,
/// </summary> PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
private static readonly ConcurrentDictionary<string, SemaphoreSlim> CHAT_STORAGE_SEMAPHORES = new(); DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
}
};
/// <summary> private static readonly TimeSpan PREFETCH_DELAY_DURATION = TimeSpan.FromMilliseconds(45);
/// Timeout for acquiring the chat storage semaphore.
/// </summary> private static string WorkspaceRootDirectory => Path.Join(SettingsManager.DataDirectory, "workspaces");
private static readonly TimeSpan SEMAPHORE_TIMEOUT = TimeSpan.FromSeconds(6);
private static string TemporaryChatsRootDirectory => Path.Join(SettingsManager.DataDirectory, "tempChats");
private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId)
{ {
@ -34,13 +86,6 @@ public static class WorkspaceBehaviour
return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
} }
/// <summary>
/// Tries to acquire the chat storage semaphore within the configured timeout.
/// </summary>
/// <param name="workspaceId">The workspace ID.</param>
/// <param name="chatId">The chat ID.</param>
/// <param name="callerName">The name of the calling method for logging purposes.</param>
/// <returns>A tuple containing whether the semaphore was acquired and the semaphore instance.</returns>
private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName) private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName)
{ {
var semaphore = GetChatSemaphore(workspaceId, chatId); var semaphore = GetChatSemaphore(workspaceId, chatId);
@ -56,18 +101,357 @@ public static class WorkspaceBehaviour
return (acquired, semaphore); return (acquired, semaphore);
} }
public static readonly JsonSerializerOptions JSON_OPTIONS = new() private static WorkspaceTreeChat ToPublicChat(WorkspaceChatCacheEntry chat) => new(chat.WorkspaceId, chat.ChatId, chat.ChatPath, chat.ChatName, chat.LastEditTime, chat.IsTemporary);
private static WorkspaceTreeWorkspace ToPublicWorkspace(WorkspaceCacheEntry workspace) => new(workspace.WorkspaceId,
workspace.WorkspacePath,
workspace.WorkspaceName,
workspace.ChatsLoaded,
workspace.Chats.Select(ToPublicChat).ToList());
private static async Task<string> ReadNameOrDefaultAsync(string nameFilePath, string fallbackName)
{ {
WriteIndented = true, try
AllowTrailingCommas = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters =
{ {
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper), if (!File.Exists(nameFilePath))
return fallbackName;
var name = await File.ReadAllTextAsync(nameFilePath, Encoding.UTF8);
return string.IsNullOrWhiteSpace(name) ? fallbackName : name;
} }
}; catch
{
return fallbackName;
}
}
private static async Task<List<WorkspaceChatCacheEntry>> ReadWorkspaceChatsCoreAsync(Guid workspaceId, string workspacePath)
{
var chats = new List<WorkspaceChatCacheEntry>();
if (!Directory.Exists(workspacePath))
return chats;
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
{
if (!Guid.TryParse(Path.GetFileName(chatPath), out var chatId))
continue;
var chatName = await ReadNameOrDefaultAsync(Path.Join(chatPath, "name"), TB("Unnamed chat"));
var chatThreadPath = Path.Join(chatPath, "thread.json");
var lastEditTime = File.Exists(chatThreadPath) ? File.GetLastWriteTimeUtc(chatThreadPath) : DateTimeOffset.MinValue;
chats.Add(new WorkspaceChatCacheEntry
{
WorkspaceId = workspaceId,
ChatId = chatId,
ChatPath = chatPath,
ChatName = chatName,
LastEditTime = lastEditTime,
IsTemporary = false,
});
}
return chats.OrderByDescending(x => x.LastEditTime).ToList();
}
private static async Task<List<WorkspaceChatCacheEntry>> ReadTemporaryChatsCoreAsync()
{
var chats = new List<WorkspaceChatCacheEntry>();
Directory.CreateDirectory(TemporaryChatsRootDirectory);
foreach (var tempChatPath in Directory.EnumerateDirectories(TemporaryChatsRootDirectory))
{
if (!Guid.TryParse(Path.GetFileName(tempChatPath), out var chatId))
continue;
var chatName = await ReadNameOrDefaultAsync(Path.Join(tempChatPath, "name"), TB("Unnamed chat"));
var chatThreadPath = Path.Join(tempChatPath, "thread.json");
var lastEditTime = File.Exists(chatThreadPath) ? File.GetLastWriteTimeUtc(chatThreadPath) : DateTimeOffset.MinValue;
chats.Add(new WorkspaceChatCacheEntry
{
WorkspaceId = Guid.Empty,
ChatId = chatId,
ChatPath = tempChatPath,
ChatName = chatName,
LastEditTime = lastEditTime,
IsTemporary = true,
});
}
return chats.OrderByDescending(x => x.LastEditTime).ToList();
}
private static async Task EnsureTreeShellLoadedCoreAsync()
{
if (!WORKSPACE_TREE_CACHE_INVALIDATED && WORKSPACE_TREE_CACHE.IsShellLoaded)
return;
WORKSPACE_TREE_CACHE.Workspaces.Clear();
WORKSPACE_TREE_CACHE.WorkspaceOrder.Clear();
Directory.CreateDirectory(WorkspaceRootDirectory);
foreach (var workspacePath in Directory.EnumerateDirectories(WorkspaceRootDirectory))
{
if (!Guid.TryParse(Path.GetFileName(workspacePath), out var workspaceId))
continue;
var workspaceName = await ReadNameOrDefaultAsync(Path.Join(workspacePath, "name"), TB("Unnamed workspace"));
WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = new WorkspaceCacheEntry
{
WorkspaceId = workspaceId,
WorkspacePath = workspacePath,
WorkspaceName = workspaceName,
ChatsLoaded = false,
Chats = [],
};
WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId);
}
WORKSPACE_TREE_CACHE.TemporaryChats = await ReadTemporaryChatsCoreAsync();
WORKSPACE_TREE_CACHE.IsShellLoaded = true;
WORKSPACE_TREE_CACHE_INVALIDATED = false;
}
private static void UpsertChatInCache(List<WorkspaceChatCacheEntry> chats, WorkspaceChatCacheEntry chat)
{
var existingIndex = chats.FindIndex(existing => existing.ChatId == chat.ChatId);
if (existingIndex >= 0)
chats[existingIndex] = chat;
else
chats.Add(chat);
chats.Sort((a, b) => b.LastEditTime.CompareTo(a.LastEditTime));
}
private static void DeleteChatFromCache(List<WorkspaceChatCacheEntry> chats, Guid chatId)
{
var existingIndex = chats.FindIndex(existing => existing.ChatId == chatId);
if (existingIndex >= 0)
chats.RemoveAt(existingIndex);
}
private static async Task UpdateCacheAfterChatStored(Guid workspaceId, Guid chatId, string chatDirectory, string chatName, DateTimeOffset lastEditTime)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
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);
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
private static async Task UpdateCacheAfterChatDeleted(Guid workspaceId, Guid chatId)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
return;
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);
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static void InvalidateWorkspaceTreeCache()
{
WORKSPACE_TREE_CACHE_INVALIDATED = true;
}
public static async Task ForceReloadWorkspaceTreeAsync()
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
WORKSPACE_TREE_CACHE_INVALIDATED = false;
WORKSPACE_TREE_CACHE.IsShellLoaded = false;
await EnsureTreeShellLoadedCoreAsync();
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static async Task<WorkspaceTreeCacheSnapshot> GetOrLoadWorkspaceTreeShellAsync()
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
await EnsureTreeShellLoadedCoreAsync();
var workspaces = WORKSPACE_TREE_CACHE.WorkspaceOrder
.Where(workspaceId => WORKSPACE_TREE_CACHE.Workspaces.ContainsKey(workspaceId))
.Select(workspaceId => ToPublicWorkspace(WORKSPACE_TREE_CACHE.Workspaces[workspaceId]))
.ToList();
var temporaryChats = WORKSPACE_TREE_CACHE.TemporaryChats.Select(ToPublicChat).ToList();
return new WorkspaceTreeCacheSnapshot(workspaces, temporaryChats);
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static async Task<IReadOnlyList<WorkspaceTreeChat>> GetWorkspaceChatsAsync(Guid workspaceId, bool forceRefresh = false)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
await EnsureTreeShellLoadedCoreAsync();
if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
return [];
if (forceRefresh || !workspace.ChatsLoaded)
{
workspace.Chats = await ReadWorkspaceChatsCoreAsync(workspaceId, workspace.WorkspacePath);
workspace.ChatsLoaded = true;
}
return workspace.Chats.Select(ToPublicChat).ToList();
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static async Task TryPrefetchRemainingChatsAsync(Func<Guid, Task>? onWorkspaceUpdated = null, CancellationToken token = default)
{
while (true)
{
token.ThrowIfCancellationRequested();
Guid? workspaceToPrefetch = null;
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(token);
try
{
await EnsureTreeShellLoadedCoreAsync();
foreach (var workspaceId in WORKSPACE_TREE_CACHE.WorkspaceOrder)
{
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && !workspace.ChatsLoaded)
{
workspaceToPrefetch = workspaceId;
break;
}
}
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
if (workspaceToPrefetch is null)
return;
await GetWorkspaceChatsAsync(workspaceToPrefetch.Value);
if (onWorkspaceUpdated is not null)
{
try
{
await onWorkspaceUpdated(workspaceToPrefetch.Value);
}
catch (Exception ex)
{
LOG.LogWarning(ex, "Failed to process callback after prefetching workspace '{WorkspaceId}'.", workspaceToPrefetch.Value);
}
}
await Task.Delay(PREFETCH_DELAY_DURATION, token);
}
}
public static async Task AddWorkspaceToCacheAsync(Guid workspaceId, string workspacePath, string workspaceName)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
await EnsureTreeShellLoadedCoreAsync();
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
{
workspace.WorkspaceName = workspaceName;
return;
}
WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = new WorkspaceCacheEntry
{
WorkspaceId = workspaceId,
WorkspacePath = workspacePath,
WorkspaceName = workspaceName,
Chats = [],
ChatsLoaded = false,
};
WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId);
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static async Task UpdateWorkspaceNameInCacheAsync(Guid workspaceId, string workspaceName)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
await EnsureTreeShellLoadedCoreAsync();
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
workspace.WorkspaceName = workspaceName;
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId)
{
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
try
{
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
return;
WORKSPACE_TREE_CACHE.Workspaces.Remove(workspaceId);
WORKSPACE_TREE_CACHE.WorkspaceOrder.Remove(workspaceId);
}
finally
{
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
}
}
public static bool IsChatExisting(LoadChat loadChat) public static bool IsChatExisting(LoadChat loadChat)
{ {
@ -78,31 +462,28 @@ public static class WorkspaceBehaviour
return Directory.Exists(chatPath); return Directory.Exists(chatPath);
} }
public static async Task StoreChat(ChatThread chat) public static async Task StoreChatAsync(ChatThread chat)
{ {
// Try to acquire the semaphore for this specific chat to prevent concurrent writes to the same file: var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChatAsync));
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChat));
if (!acquired) if (!acquired)
return; return;
try try
{ {
string chatDirectory; var chatDirectory = chat.WorkspaceId == Guid.Empty
if (chat.WorkspaceId == Guid.Empty) ? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString())
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); : Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
else
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
// Ensure the directory exists:
Directory.CreateDirectory(chatDirectory); Directory.CreateDirectory(chatDirectory);
// Save the chat name:
var chatNamePath = Path.Join(chatDirectory, "name"); var chatNamePath = Path.Join(chatDirectory, "name");
await File.WriteAllTextAsync(chatNamePath, chat.Name); await File.WriteAllTextAsync(chatNamePath, chat.Name);
// Save the thread as thread.json:
var chatPath = Path.Join(chatDirectory, "thread.json"); var chatPath = Path.Join(chatDirectory, "thread.json");
await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8);
var lastEditTime = File.GetLastWriteTimeUtc(chatPath);
await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime);
} }
finally finally
{ {
@ -110,10 +491,9 @@ public static class WorkspaceBehaviour
} }
} }
public static async Task<ChatThread?> LoadChat(LoadChat loadChat) public static async Task<ChatThread?> LoadChatAsync(LoadChat loadChat)
{ {
// Try to acquire the semaphore for this specific chat to prevent concurrent read/writes to the same file: var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChatAsync));
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChat));
if (!acquired) if (!acquired)
return null; return null;
@ -123,12 +503,11 @@ public static class WorkspaceBehaviour
? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString()) ? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString())
: Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString()); : Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString());
if(!Directory.Exists(chatPath)) if (!Directory.Exists(chatPath))
return null; return null;
var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8);
var chat = JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS); return JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS);
return chat;
} }
catch (Exception) catch (Exception)
{ {
@ -140,51 +519,68 @@ public static class WorkspaceBehaviour
} }
} }
public static async Task<string> LoadWorkspaceName(Guid workspaceId) public static async Task<string> LoadWorkspaceNameAsync(Guid workspaceId)
{ {
if(workspaceId == Guid.Empty) if (workspaceId == Guid.Empty)
return string.Empty; return string.Empty;
var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
var workspaceNamePath = Path.Join(workspacePath, "name");
try try
{ {
// If the name file does not exist or is empty, self-heal with a default name. await EnsureTreeShellLoadedCoreAsync();
if (!File.Exists(workspaceNamePath)) if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var cachedWorkspace) && !string.IsNullOrWhiteSpace(cachedWorkspace.WorkspaceName))
return cachedWorkspace.WorkspaceName;
// 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(WorkspaceRootDirectory, workspaceId.ToString());
var workspaceNamePath = Path.Join(workspacePath, "name");
string workspaceName;
try
{ {
var defaultName = TB("Unnamed workspace"); if (!File.Exists(workspaceNamePath))
Directory.CreateDirectory(workspacePath); {
await File.WriteAllTextAsync(workspaceNamePath, defaultName, Encoding.UTF8); workspaceName = TB("Unnamed workspace");
return defaultName; Directory.CreateDirectory(workspacePath);
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
}
else
{
workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(workspaceName))
{
workspaceName = TB("Unnamed workspace");
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
}
}
}
catch
{
workspaceName = TB("Unnamed workspace");
} }
var name = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8); // Update the cache directly (we already hold the semaphore):
if (string.IsNullOrWhiteSpace(name)) if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
{ workspace.WorkspaceName = workspaceName;
var defaultName = TB("Unnamed workspace");
await File.WriteAllTextAsync(workspaceNamePath, defaultName, Encoding.UTF8);
return defaultName;
}
return name; return workspaceName;
} }
catch finally
{ {
// On any error, return a localized default without throwing. WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
return TB("Unnamed workspace");
} }
} }
public static async Task DeleteChat(IDialogService dialogService, Guid workspaceId, Guid chatId, bool askForConfirmation = true) public static async Task DeleteChatAsync(IDialogService dialogService, Guid workspaceId, Guid chatId, bool askForConfirmation = true)
{ {
var chat = await LoadChat(new(workspaceId, chatId)); var chat = await LoadChatAsync(new(workspaceId, chatId));
if (chat is null) if (chat is null)
return; return;
if (askForConfirmation) if (askForConfirmation)
{ {
var workspaceName = await LoadWorkspaceName(chat.WorkspaceId); var workspaceName = await LoadWorkspaceNameAsync(chat.WorkspaceId);
var dialogParameters = new DialogParameters<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
{ {
{ {
@ -202,20 +598,20 @@ public static class WorkspaceBehaviour
return; return;
} }
string chatDirectory; var chatDirectory = chat.WorkspaceId == Guid.Empty
if (chat.WorkspaceId == Guid.Empty) ? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString())
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); : Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
else
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
// Try to acquire the semaphore to prevent deleting while another thread is writing: var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChatAsync));
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChat));
if (!acquired) if (!acquired)
return; return;
try try
{ {
Directory.Delete(chatDirectory, true); if (Directory.Exists(chatDirectory))
Directory.Delete(chatDirectory, true);
await UpdateCacheAfterChatDeleted(workspaceId, chatId);
} }
finally finally
{ {
@ -225,16 +621,14 @@ public static class WorkspaceBehaviour
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
{ {
var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
var workspaceNamePath = Path.Join(workspacePath, "name"); var workspaceNamePath = Path.Join(workspacePath, "name");
if(!Path.Exists(workspacePath)) if (!Path.Exists(workspacePath))
Directory.CreateDirectory(workspacePath); Directory.CreateDirectory(workspacePath);
try try
{ {
// When the name file is missing or empty, write it (self-heal).
// Otherwise, keep the existing name:
if (!File.Exists(workspaceNamePath)) if (!File.Exists(workspaceNamePath))
{ {
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
@ -250,6 +644,8 @@ public static class WorkspaceBehaviour
{ {
// Ignore IO issues to avoid interrupting background initialization. // Ignore IO issues to avoid interrupting background initialization.
} }
await AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName);
} }
public static async Task EnsureBiasWorkspace() => await EnsureWorkspace(KnownWorkspaces.BIAS_WORKSPACE_ID, "Bias of the Day"); public static async Task EnsureBiasWorkspace() => await EnsureWorkspace(KnownWorkspaces.BIAS_WORKSPACE_ID, "Bias of the Day");

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools;
public readonly record struct WorkspaceTreeCacheSnapshot(IReadOnlyList<WorkspaceTreeWorkspace> Workspaces, IReadOnlyList<WorkspaceTreeChat> TemporaryChats);

View File

@ -0,0 +1,4 @@
// ReSharper disable NotAccessedPositionalProperty.Global
namespace AIStudio.Tools;
public readonly record struct WorkspaceTreeChat(Guid WorkspaceId, Guid ChatId, string ChatPath, string Name, DateTimeOffset LastEditTime, bool IsTemporary);

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools;
public readonly record struct WorkspaceTreeWorkspace(Guid WorkspaceId, string WorkspacePath, string Name, bool ChatsLoaded, IReadOnlyList<WorkspaceTreeChat> Chats);

View File

@ -1,6 +1,7 @@
# v26.3.1, build 235 (2026-03-xx xx:xx UTC) # v26.3.1, build 235 (2026-03-xx xx:xx UTC)
- Improved the performance by caching the OS language detection and requesting the user language only once per app start. - Improved the performance by caching the OS language detection and requesting the user language only once per app start.
- Improved the chat performance by reducing unnecessary UI updates, making chats smoother and more responsive, especially in longer conversations. - Improved the chat performance by reducing unnecessary UI updates, making chats smoother and more responsive, especially in longer conversations.
- Improved the workspace loading experience: when opening the chat for the first time, your workspaces now appear faster and load step by step in the background, with placeholder rows so the app feels responsive right away.
- Improved the user-language logging by limiting language detection logs to a single entry per app start. - Improved the user-language logging by limiting language detection logs to a single entry per app start.
- Improved the logbook readability by removing non-readable special characters from log entries. - Improved the logbook readability by removing non-readable special characters from log entries.
- Improved the logbook reliability by significantly reducing duplicate log entries. - Improved the logbook reliability by significantly reducing duplicate log entries.