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); }