From 82fa7e6eddb5baebbae322604ef3b75753d0c7c8 Mon Sep 17 00:00:00 2001
From: Maxime GRANDCOLAS <maxime.grandcolas@scille.fr>
Date: Tue, 14 Jan 2025 15:23:20 +0100
Subject: [PATCH] [MS] Replaced workspaces by recent workspaces in sidebar

---
 .../sidebar/SidebarRecentFileItem.vue         |   2 +-
 .../sidebar/SidebarWorkspaceItem.vue          |   2 +-
 client/src/locales/en-US.json                 |   1 +
 client/src/locales/fr-FR.json                 |   1 +
 client/src/parsec/workspace.ts                |   8 +-
 client/src/router/navigation.ts               |   2 +
 client/src/services/fileOpener.ts             |   6 +-
 client/src/services/recentDocuments.ts        | 135 ++++++++++++++++++
 client/src/services/recentFiles.ts            |  52 -------
 client/src/views/layouts/ConnectedLayout.vue  |   5 +
 .../views/sidebar-menu/SidebarMenuPage.vue    |  23 ++-
 .../src/views/workspaces/WorkspacesPage.vue   |  14 ++
 12 files changed, 179 insertions(+), 72 deletions(-)
 create mode 100644 client/src/services/recentDocuments.ts
 delete mode 100644 client/src/services/recentFiles.ts

diff --git a/client/src/components/sidebar/SidebarRecentFileItem.vue b/client/src/components/sidebar/SidebarRecentFileItem.vue
index 8d091c9da26..cac3ba109f0 100644
--- a/client/src/components/sidebar/SidebarRecentFileItem.vue
+++ b/client/src/components/sidebar/SidebarRecentFileItem.vue
@@ -23,7 +23,7 @@
 <script setup lang="ts">
 import { IonItem, IonText, IonIcon } from '@ionic/vue';
 import { close } from 'ionicons/icons';
-import { RecentFile } from '@/services/recentFiles';
+import { RecentFile } from '@/services/recentDocuments';
 
 defineProps<{
   file: RecentFile;
diff --git a/client/src/components/sidebar/SidebarWorkspaceItem.vue b/client/src/components/sidebar/SidebarWorkspaceItem.vue
index c7c281bca6e..574e45817f0 100644
--- a/client/src/components/sidebar/SidebarWorkspaceItem.vue
+++ b/client/src/components/sidebar/SidebarWorkspaceItem.vue
@@ -23,7 +23,7 @@
 
 <script setup lang="ts">
 import { IonItem, IonText, IonIcon } from '@ionic/vue';
-import { WorkspaceInfo, WorkspaceHandle } from '@/parsec';
+import { WorkspaceHandle, WorkspaceInfo } from '@/parsec';
 import { ellipsisHorizontal } from 'ionicons/icons';
 import { currentRouteIsWorkspaceRoute } from '@/router';
 import { onMounted, onBeforeUnmount, ref } from 'vue';
diff --git a/client/src/locales/en-US.json b/client/src/locales/en-US.json
index 3815f561032..f704c7144da 100644
--- a/client/src/locales/en-US.json
+++ b/client/src/locales/en-US.json
@@ -698,6 +698,7 @@
     },
     "SideMenu": {
         "workspaces": "Workspaces",
+        "recentWorkspaces": "Recent workspaces",
         "favorites": "Favorites",
         "noWorkspace": "No workspaces",
         "users": "Users",
diff --git a/client/src/locales/fr-FR.json b/client/src/locales/fr-FR.json
index faf810459e9..d5956ce56d3 100644
--- a/client/src/locales/fr-FR.json
+++ b/client/src/locales/fr-FR.json
@@ -698,6 +698,7 @@
     },
     "SideMenu": {
         "workspaces": "Espaces de travail",
+        "recentWorkspaces": "Espaces récents",
         "favorites": "Favoris",
         "noWorkspace": "Aucun espace de travail",
         "users": "Utilisateurs",
diff --git a/client/src/parsec/workspace.ts b/client/src/parsec/workspace.ts
index 0dccb13c3c0..2defadd114b 100644
--- a/client/src/parsec/workspace.ts
+++ b/client/src/parsec/workspace.ts
@@ -68,8 +68,12 @@ export async function initializeWorkspace(
   return startedWorkspaceResult;
 }
 
-export async function listWorkspaces(): Promise<Result<Array<WorkspaceInfo>, ClientListWorkspacesError>> {
-  const handle = getParsecHandle();
+export async function listWorkspaces(
+  handle: ConnectionHandle | null = null,
+): Promise<Result<Array<WorkspaceInfo>, ClientListWorkspacesError>> {
+  if (!handle) {
+    handle = getParsecHandle();
+  }
 
   if (handle !== null && !needsMocks()) {
     const result = await libparsec.clientListWorkspaces(handle);
diff --git a/client/src/router/navigation.ts b/client/src/router/navigation.ts
index b1d9e55d5a1..2c05e6595c1 100644
--- a/client/src/router/navigation.ts
+++ b/client/src/router/navigation.ts
@@ -3,6 +3,7 @@
 import { ConnectionHandle, EntryName, WorkspaceHandle } from '@/parsec';
 import { getConnectionHandle } from '@/router/params';
 import { ClientAreaQuery, Query, RouteBackup, Routes, getCurrentRoute, getRouter } from '@/router/types';
+import { recentDocumentManager } from '@/services/recentDocuments';
 import { Base64 } from 'megashark-lib';
 import { LocationQueryRaw, RouteParamsRaw } from 'vue-router';
 
@@ -95,6 +96,7 @@ export async function switchOrganization(handle: ConnectionHandle | null, backup
       window.electronAPI.log('error', 'Trying to switch to an organization for which we have no backup information');
       return;
     }
+    recentDocumentManager.resetHistory();
     const backup = routesBackup[backupIndex];
     try {
       await navigateTo(Routes.Loading, { skipHandle: true, replace: true, query: { loginInfo: Base64.fromObject(backup) } });
diff --git a/client/src/services/fileOpener.ts b/client/src/services/fileOpener.ts
index bcaca199a86..ad1f3100e8c 100644
--- a/client/src/services/fileOpener.ts
+++ b/client/src/services/fileOpener.ts
@@ -4,7 +4,7 @@ import { detectFileContentType, FileContentType } from '@/common/fileTypes';
 import { entryStat, EntryStat, entryStatAt, FsPath, getSystemPath, isDesktop, WorkspaceHandle, WorkspaceHistoryEntryStat } from '@/parsec';
 import { navigateTo, Routes } from '@/router';
 import { Information, InformationLevel, InformationManager, PresentationMode } from '@/services/informationManager';
-import { recentFileManager } from '@/services/recentFiles';
+import { recentDocumentManager } from '@/services/recentDocuments';
 import { DateTime } from 'luxon';
 import { Base64, openSpinnerModal } from 'megashark-lib';
 
@@ -21,7 +21,7 @@ async function openWithSystem(
   informationManager: InformationManager,
 ): Promise<void> {
   if (entry.isFile()) {
-    recentFileManager.addFile({
+    recentDocumentManager.addFile({
       entryId: entry.id,
       path: entry.path,
       workspaceHandle: workspaceHandle,
@@ -113,7 +113,7 @@ async function openPath(
         return;
       }
 
-      recentFileManager.addFile({
+      recentDocumentManager.addFile({
         entryId: entry.id,
         path: entry.path,
         workspaceHandle: workspaceHandle,
diff --git a/client/src/services/recentDocuments.ts b/client/src/services/recentDocuments.ts
new file mode 100644
index 00000000000..e1c5817469c
--- /dev/null
+++ b/client/src/services/recentDocuments.ts
@@ -0,0 +1,135 @@
+// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
+
+import { DetectedFileType } from '@/common/fileTypes';
+import {
+  ConnectionHandle,
+  EntryID,
+  EntryName,
+  FsPath,
+  getClientInfo,
+  listWorkspaces,
+  UserID,
+  WorkspaceHandle,
+  WorkspaceID,
+  WorkspaceInfo,
+} from '@/parsec';
+import { StorageManager } from '@/services/storageManager';
+import { Ref, ref } from 'vue';
+
+interface RecentFile {
+  entryId: EntryID;
+  workspaceHandle: WorkspaceHandle;
+  path: FsPath;
+  name: EntryName;
+  contentType?: DetectedFileType;
+}
+
+type RecentWorkspace = WorkspaceInfo;
+
+const FILE_HISTORY_SIZE = 5;
+const WORKSPACE_HISTORY_SIZE = 5;
+
+interface RecentDocumentStorageData {
+  workspaces: Array<WorkspaceID>;
+}
+
+class RecentDocumentManager {
+  files: Ref<Array<RecentFile>> = ref([]);
+  workspaces: Ref<Array<RecentWorkspace>> = ref([]);
+
+  constructor() {
+    this.files.value = new Array<RecentFile>();
+    this.workspaces.value = new Array<RecentWorkspace>();
+  }
+
+  private _getStorageDataKey(userId: UserID): string {
+    return `recentDocuments_${userId}`;
+  }
+
+  async loadFromStorage(storage: StorageManager, handle: ConnectionHandle | null = null): Promise<void> {
+    const clientInfoResult = await getClientInfo(handle);
+    if (!clientInfoResult.ok) {
+      window.electronAPI.log('error', `Failed to load recent workspaces: ${JSON.stringify(clientInfoResult.error)}`);
+      return;
+    }
+    const dataKey = this._getStorageDataKey(clientInfoResult.value.userId);
+    const storedData = await storage.retrieveComponentData<RecentDocumentStorageData>(dataKey, { workspaces: [] });
+    if (!storedData || !storedData.workspaces) {
+      return;
+    }
+    const workspacesResult = await listWorkspaces(handle);
+    if (!workspacesResult.ok) {
+      window.electronAPI.log('error', `Failed to load recent workspaces: ${JSON.stringify(workspacesResult.error)}`);
+      return;
+    }
+    for (const workspace of workspacesResult.value) {
+      if (storedData.workspaces.includes(workspace.id)) {
+        this.addWorkspace(workspace);
+      }
+    }
+    console.log('Loaded', this.workspaces.value);
+  }
+
+  async saveToStorage(storage: StorageManager): Promise<void> {
+    const clientInfoResult = await getClientInfo();
+    if (!clientInfoResult.ok) {
+      window.electronAPI.log('error', `Failed to save recent workspaces: ${JSON.stringify(clientInfoResult.error)}`);
+      return;
+    }
+    const dataKey = this._getStorageDataKey(clientInfoResult.value.userId);
+    await storage.storeComponentData<RecentDocumentStorageData>(dataKey, {
+      workspaces: this.workspaces.value.map((workspace) => workspace.id),
+    });
+  }
+
+  addFile(file: RecentFile): void {
+    const exists = this.files.value.find((item) => item.entryId === file.entryId) !== undefined;
+    if (exists) {
+      return;
+    }
+    if (this.files.value.unshift(file) > FILE_HISTORY_SIZE) {
+      this.files.value.pop();
+    }
+  }
+
+  addWorkspace(workspace: RecentWorkspace): void {
+    const exists = this.workspaces.value.find((item) => item.id === workspace.id) !== undefined;
+    if (exists) {
+      return;
+    }
+    if (this.workspaces.value.unshift(workspace) > WORKSPACE_HISTORY_SIZE) {
+      this.workspaces.value.pop();
+    }
+  }
+
+  removeFile(file: RecentFile): void {
+    const index = this.files.value.findIndex((item) => item.entryId === file.entryId);
+    if (index !== -1) {
+      this.files.value.splice(index, 1);
+    }
+  }
+
+  removeWorkspace(workspace: RecentWorkspace): void {
+    const index = this.workspaces.value.findIndex((item) => item.id === workspace.id);
+    if (index !== -1) {
+      this.workspaces.value.splice(index, 1);
+    }
+  }
+
+  resetHistory(): void {
+    this.files.value = new Array<RecentFile>();
+    this.workspaces.value = new Array<RecentWorkspace>();
+  }
+
+  getFiles(): Array<RecentFile> {
+    return this.files.value;
+  }
+
+  getWorkspaces(): Array<RecentWorkspace> {
+    return this.workspaces.value;
+  }
+}
+
+const recentDocumentManager = new RecentDocumentManager();
+
+export { recentDocumentManager, RecentFile, RecentWorkspace };
diff --git a/client/src/services/recentFiles.ts b/client/src/services/recentFiles.ts
deleted file mode 100644
index 529e60210ce..00000000000
--- a/client/src/services/recentFiles.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
-
-import { DetectedFileType } from '@/common/fileTypes';
-import { EntryID, EntryName, FsPath, WorkspaceHandle } from '@/parsec';
-import { Ref, ref } from 'vue';
-
-interface RecentFile {
-  entryId: EntryID;
-  workspaceHandle: WorkspaceHandle;
-  path: FsPath;
-  name: EntryName;
-  contentType?: DetectedFileType;
-}
-
-const HISTORY_SIZE = 5;
-
-class RecentFileManager {
-  files: Ref<Array<RecentFile>> = ref([]);
-
-  constructor() {
-    this.files.value = new Array<RecentFile>();
-  }
-
-  addFile(file: RecentFile): void {
-    const exists = this.files.value.find((item) => item.entryId === file.entryId) !== undefined;
-    if (exists) {
-      return;
-    }
-    if (this.files.value.unshift(file) > HISTORY_SIZE) {
-      this.files.value.pop();
-    }
-  }
-
-  removeFile(file: RecentFile): void {
-    const index = this.files.value.findIndex((item) => item.entryId === file.entryId);
-    if (index !== -1) {
-      this.files.value.splice(index, 1);
-    }
-  }
-
-  resetHistory(): void {
-    this.files.value = new Array<RecentFile>();
-  }
-
-  getFiles(): Array<RecentFile> {
-    return this.files.value;
-  }
-}
-
-const recentFileManager = new RecentFileManager();
-
-export { RecentFile, recentFileManager };
diff --git a/client/src/views/layouts/ConnectedLayout.vue b/client/src/views/layouts/ConnectedLayout.vue
index 28d1dc604e3..23989a0c0b6 100644
--- a/client/src/views/layouts/ConnectedLayout.vue
+++ b/client/src/views/layouts/ConnectedLayout.vue
@@ -21,6 +21,7 @@ import useUploadMenu from '@/services/fileUploadMenu';
 import { MsModalResult, openSpinnerModal } from 'megashark-lib';
 import { DateTime } from 'luxon';
 import { StorageManagerKey, StorageManager } from '@/services/storageManager';
+import { recentDocumentManager } from '@/services/recentDocuments';
 
 const injectionProvider: InjectionProvider = inject(InjectionProviderKey)!;
 const storageManager: StorageManager = inject(StorageManagerKey)!;
@@ -64,6 +65,9 @@ onMounted(async () => {
     },
   );
 
+  recentDocumentManager.resetHistory();
+  await recentDocumentManager.loadFromStorage(storageManager, handle);
+
   initialized.value = true;
 
   const connInfo = getConnectionInfo();
@@ -133,6 +137,7 @@ async function logout(): Promise<void> {
       window.electronAPI.log('error', `Error when logging out: ${JSON.stringify(logoutResult.error)}`);
     }
   }
+  recentDocumentManager.resetHistory();
 
   await modal.dismiss();
   await navigateTo(Routes.Home, { replace: true, skipHandle: true });
diff --git a/client/src/views/sidebar-menu/SidebarMenuPage.vue b/client/src/views/sidebar-menu/SidebarMenuPage.vue
index 214408776a9..e50111dc759 100644
--- a/client/src/views/sidebar-menu/SidebarMenuPage.vue
+++ b/client/src/views/sidebar-menu/SidebarMenuPage.vue
@@ -182,7 +182,8 @@
 
             <!-- list of workspaces -->
             <sidebar-menu-list
-              title="SideMenu.workspaces"
+              v-if="recentDocumentManager.getWorkspaces().length > 0"
+              title="SideMenu.recentWorkspaces"
               :icon="business"
               class="workspaces"
               v-model:is-content-visible="workspacesMenuVisible"
@@ -195,7 +196,7 @@
                 {{ $msTranslate('SideMenu.noWorkspace') }}
               </ion-text>
               <sidebar-workspace-item
-                v-for="workspace in nonFavoriteWorkspaces"
+                v-for="workspace in recentDocumentManager.getWorkspaces()"
                 :workspace="workspace"
                 :key="workspace.id"
                 @workspace-clicked="goToWorkspace"
@@ -208,7 +209,7 @@
 
           <div
             class="list-sidebar-divider"
-            v-if="recentFileManager.getFiles().length > 0 && !currentRouteIsOrganizationManagementRoute()"
+            v-if="recentDocumentManager.getFiles().length > 0 && !currentRouteIsOrganizationManagementRoute()"
           />
 
           <!-- last opened files -->
@@ -219,12 +220,12 @@
             <sidebar-menu-list
               title="SideMenu.recentDocuments"
               :icon="documentIcon"
-              v-show="recentFileManager.getFiles().length > 0"
+              v-show="recentDocumentManager.getFiles().length > 0"
               v-model:is-content-visible="recentFilesMenuVisible"
               @update:is-content-visible="onRecentFilesMenuVisibilityChanged"
             >
               <sidebar-recent-file-item
-                v-for="file in recentFileManager.getFiles()"
+                v-for="file in recentDocumentManager.getFiles()"
                 :file="file"
                 :key="file.entryId"
                 @file-clicked="openRecentFile"
@@ -383,7 +384,7 @@ import {
 } from 'ionicons/icons';
 import { Ref, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue';
 import { Duration } from 'luxon';
-import { recentFileManager, RecentFile } from '@/services/recentFiles';
+import { recentDocumentManager, RecentFile } from '@/services/recentDocuments';
 import { openPath } from '@/services/fileOpener';
 import { SIDEBAR_MENU_DATA_KEY, SidebarDefaultData, SidebarSavedData } from '@/views/sidebar-menu/utils';
 import { FileContentType } from '@/common/fileTypes';
@@ -516,7 +517,7 @@ onMounted(async () => {
   // Adds fake files by default in dev mode
   // to make sure we keep the feature in mind
   if (needsMocks()) {
-    recentFileManager.addFile({
+    recentDocumentManager.addFile({
       entryId: crypto.randomUUID().toString(),
       workspaceHandle: 1337,
       path: '/a/b/File_Fake image.png',
@@ -527,7 +528,7 @@ onMounted(async () => {
         mimeType: 'image/png',
       },
     });
-    recentFileManager.addFile({
+    recentDocumentManager.addFile({
       entryId: crypto.randomUUID().toString(),
       workspaceHandle: 1337,
       path: '/a/b/File_Fake PDF document.pdf',
@@ -569,10 +570,6 @@ const favoritesWorkspaces = computed(() => {
     });
 });
 
-const nonFavoriteWorkspaces = computed(() => {
-  return workspaces.value.filter((workspace: WorkspaceInfo) => !favorites.value.includes(workspace.id));
-});
-
 function onMove(detail: GestureDetail): void {
   if (detail.currentX < MIN_WIDTH) {
     computedWidth.value = MIN_WIDTH;
@@ -610,7 +607,7 @@ async function openRecentFile(file: RecentFile): Promise<void> {
 }
 
 async function removeRecentFile(file: RecentFile): Promise<void> {
-  recentFileManager.removeFile(file);
+  recentDocumentManager.removeFile(file);
 }
 
 async function onWorkspacesMenuVisibilityChanged(visible: boolean): Promise<void> {
diff --git a/client/src/views/workspaces/WorkspacesPage.vue b/client/src/views/workspaces/WorkspacesPage.vue
index 647c7958324..28644998191 100644
--- a/client/src/views/workspaces/WorkspacesPage.vue
+++ b/client/src/views/workspaces/WorkspacesPage.vue
@@ -213,6 +213,7 @@ import {
 } from '@ionic/vue';
 import { addCircle } from 'ionicons/icons';
 import { Ref, computed, inject, onMounted, onUnmounted, ref } from 'vue';
+import { recentDocumentManager } from '@/services/recentDocuments';
 
 enum SortWorkspaceBy {
   Name = 'name',
@@ -384,6 +385,9 @@ async function handleFileLink(fileLink: ParsecWorkspacePathAddr): Promise<boolea
     path = await Path.parent(path);
   }
 
+  recentDocumentManager.addWorkspace(workspace);
+  await recentDocumentManager.saveToStorage(storageManager);
+
   await navigateToWorkspace(workspace.handle, path, selectFile, true);
   return true;
 }
@@ -420,6 +424,14 @@ async function refreshWorkspacesList(): Promise<void> {
       }
     }
     workspaceList.value = result.value;
+    const recentWorkspaces = recentDocumentManager.getWorkspaces();
+    // If a workspace is listed in recent workspaces but not present in the list, remove it
+    for (const recentWorkspace of recentWorkspaces) {
+      if (workspaceList.value.find((wk) => wk.id === recentWorkspace.id) === undefined) {
+        recentDocumentManager.removeWorkspace(recentWorkspace);
+      }
+    }
+    await recentDocumentManager.saveToStorage(storageManager);
   } else {
     informationManager.present(
       new Information({
@@ -505,6 +517,8 @@ async function openCreateWorkspaceModal(): Promise<void> {
 }
 
 async function onWorkspaceClick(workspace: WorkspaceInfo): Promise<void> {
+  recentDocumentManager.addWorkspace(workspace);
+  await recentDocumentManager.saveToStorage(storageManager);
   await navigateToWorkspace(workspace.handle);
 }