diff --git a/client/src/common/queue.ts b/client/src/common/queue.ts index f0a36b3a45f..9ee59ab7a60 100644 --- a/client/src/common/queue.ts +++ b/client/src/common/queue.ts @@ -26,6 +26,10 @@ class FIFO { length(): number { return this.entries.length; } + + has(findFunction: (val: T) => boolean): boolean { + return this.entries.find(findFunction) !== undefined; + } } export { FIFO }; diff --git a/client/src/components/sidebar/SidebarRecentFileItem.vue b/client/src/components/sidebar/SidebarRecentFileItem.vue new file mode 100644 index 00000000000..f1d562e662e --- /dev/null +++ b/client/src/components/sidebar/SidebarRecentFileItem.vue @@ -0,0 +1,81 @@ + + + + + + + diff --git a/client/src/components/sidebar/index.ts b/client/src/components/sidebar/index.ts index fc1878d2250..bb2d2e859f4 100644 --- a/client/src/components/sidebar/index.ts +++ b/client/src/components/sidebar/index.ts @@ -1,5 +1,6 @@ // Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +import SidebarRecentFileItem from '@/components/sidebar/SidebarRecentFileItem.vue'; import SidebarWorkspaceItem from '@/components/sidebar/SidebarWorkspaceItem.vue'; -export { SidebarWorkspaceItem }; +export { SidebarRecentFileItem, SidebarWorkspaceItem }; diff --git a/client/src/locales/en-US.json b/client/src/locales/en-US.json index 62461a82d80..1861be8c814 100644 --- a/client/src/locales/en-US.json +++ b/client/src/locales/en-US.json @@ -702,6 +702,7 @@ "manageOrganization": "Manage my organization", "organizationInfo": "Information", "goToHome": "My workspaces", + "recentDocuments": "Recent documents", "back": "Back", "trial": { "tag": "Trial version", @@ -1695,6 +1696,7 @@ }, "fileViewers": { "openWithDefault": "Open with default application", - "openingFile": "Opening file..." + "openingFile": "Opening file...", + "fileTooBig": "File is too big to be opened using a viewer." } } diff --git a/client/src/locales/fr-FR.json b/client/src/locales/fr-FR.json index c97199fabd9..961657dce9b 100644 --- a/client/src/locales/fr-FR.json +++ b/client/src/locales/fr-FR.json @@ -702,6 +702,7 @@ "manageOrganization": "Gérer mon organisation", "organizationInfo": "Informations", "goToHome": "Mes espaces", + "recentDocuments": "Documents récents", "back": "Retour", "trial": { "tag": "Version d'essai", @@ -1695,6 +1696,7 @@ }, "fileViewers": { "openWithDefault": "Ouvrir avec l'application par défaut", - "openingFile": "Ouverture du fichier..." + "openingFile": "Ouverture du fichier...", + "fileTooBig": "Le fichier est trop large pour être ouvert avec un visualiseur." } } diff --git a/client/src/services/fileOpener.ts b/client/src/services/fileOpener.ts new file mode 100644 index 00000000000..60caeb42438 --- /dev/null +++ b/client/src/services/fileOpener.ts @@ -0,0 +1,117 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +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 { DateTime } from 'luxon'; +import { Base64, openSpinnerModal } from 'megashark-lib'; + +interface OpenPathOptions { + skipViewers?: boolean; + atTime?: DateTime; +} + +const OPEN_FILE_SIZE_LIMIT = 15_000_000; + +async function openWithSystem( + workspaceHandle: WorkspaceHandle, + entry: EntryStat | WorkspaceHistoryEntryStat, + informationManager: InformationManager, +): Promise { + if (entry.isFile()) { + recentFileManager.addFile({ + entryId: entry.id, + path: entry.path, + workspaceHandle: workspaceHandle, + name: entry.name, + }); + } + + const result = await getSystemPath(workspaceHandle, entry.path); + + if (!result.ok) { + await informationManager.present( + new Information({ + message: entry.isFile() ? 'FoldersPage.open.fileFailed' : 'FoldersPage.open.folderFailed', + level: InformationLevel.Error, + }), + PresentationMode.Modal, + ); + } else { + window.electronAPI.openFile(result.value); + } +} + +async function openPath( + workspaceHandle: WorkspaceHandle, + path: FsPath, + informationManager: InformationManager, + options: OpenPathOptions, +): Promise { + let statsResult; + if (options.atTime) { + statsResult = await entryStatAt(workspaceHandle, path, options.atTime); + } else { + statsResult = await entryStat(workspaceHandle, path); + } + if (!statsResult.ok) { + await informationManager.present( + new Information({ + message: 'FoldersPage.open.fileFailed', + level: InformationLevel.Error, + }), + PresentationMode.Modal, + ); + return; + } + + const entry = statsResult.value; + + if (!entry.isFile()) { + await openWithSystem(workspaceHandle, entry, informationManager); + return; + } + if (isDesktop() && options.skipViewers) { + await openWithSystem(workspaceHandle, entry, informationManager); + return; + } + + const modal = await openSpinnerModal('fileViewers.openingFile'); + const contentType = await detectFileContentType(workspaceHandle, entry.path, options.atTime); + + try { + if (!contentType || contentType.type === FileContentType.Unknown) { + await openWithSystem(workspaceHandle, entry, informationManager); + } else { + if ((entry as any).size > OPEN_FILE_SIZE_LIMIT) { + informationManager.present( + new Information({ + message: 'fileViewers.fileTooBig', + level: InformationLevel.Warning, + }), + PresentationMode.Toast, + ); + await openWithSystem(workspaceHandle, entry, informationManager); + return; + } + + recentFileManager.addFile({ + entryId: entry.id, + path: entry.path, + workspaceHandle: workspaceHandle, + name: entry.name, + contentType: contentType, + }); + + await navigateTo(Routes.Viewer, { + query: { workspaceHandle: workspaceHandle, documentPath: entry.path, fileTypeInfo: Base64.fromObject(contentType) }, + }); + } + } finally { + await modal.dismiss(); + } +} + +export { openPath }; diff --git a/client/src/services/recentFiles.ts b/client/src/services/recentFiles.ts new file mode 100644 index 00000000000..529e60210ce --- /dev/null +++ b/client/src/services/recentFiles.ts @@ -0,0 +1,52 @@ +// 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> = ref([]); + + constructor() { + this.files.value = new Array(); + } + + 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(); + } + + getFiles(): Array { + return this.files.value; + } +} + +const recentFileManager = new RecentFileManager(); + +export { RecentFile, recentFileManager }; diff --git a/client/src/views/files/FoldersPage.vue b/client/src/views/files/FoldersPage.vue index 7b37a656bdb..523ba7961df 100644 --- a/client/src/views/files/FoldersPage.vue +++ b/client/src/views/files/FoldersPage.vue @@ -217,8 +217,6 @@ import { Clipboard, asyncComputed, MsSpinner, - openSpinnerModal, - Base64, } from 'megashark-lib'; import * as parsec from '@/parsec'; @@ -249,9 +247,6 @@ import { EntryStat, WorkspaceStatFolderChildrenErrorTag, EntryStatFile, - getSystemPath, - WorkspaceHandle, - isDesktop, EntryName, } from '@/parsec'; import { Routes, currentRouteIs, getCurrentRouteQuery, getDocumentPath, getWorkspaceHandle, navigateTo, watchRoute } from '@/router'; @@ -276,7 +271,7 @@ import { IonContent, IonPage, IonText, modalController, popoverController } from import { arrowRedo, copy, folderOpen, informationCircle, link, pencil, trashBin } from 'ionicons/icons'; import { Ref, computed, inject, onMounted, onUnmounted, ref } from 'vue'; import { EntrySyncedData, EventData, EventDistributor, EventDistributorKey, Events } from '@/services/eventDistributor'; -import { detectFileContentType, FileContentType } from '@/common/fileTypes'; +import { openPath } from '@/services/fileOpener'; interface FoldersPageSavedData { displayState?: DisplayState; @@ -330,7 +325,6 @@ const storageManager: StorageManager = inject(StorageManagerKey)!; const eventDistributor: EventDistributor = inject(EventDistributorKey)!; const FOLDERS_PAGE_DATA_KEY = 'FoldersPage'; -const OPEN_FILE_SIZE_LIMIT = 15_000_000; const sortProperty = ref(SortProperty.Name); const sortAsc = ref(true); @@ -1180,22 +1174,6 @@ async function showHistory(entries: EntryModel[]): Promise { }); } -async function openWithSystem(workspaceHandle: WorkspaceHandle, entry: EntryStatFile): Promise { - const result = await getSystemPath(workspaceHandle, entry.path); - - if (!result.ok) { - await informationManager.present( - new Information({ - message: 'FoldersPage.open.fileFailed', - level: InformationLevel.Error, - }), - PresentationMode.Modal, - ); - } else { - window.electronAPI.openFile(result.value); - } -} - async function openEntries(entries: EntryModel[]): Promise { if (entries.length !== 1 || !workspaceInfo.value || !entries[0].isFile()) { return; @@ -1206,37 +1184,7 @@ async function openEntries(entries: EntryModel[]): Promise { const config = await storageManager.retrieveConfig(); - if (isDesktop() && config.skipViewers) { - await openWithSystem(workspaceHandle, entry); - return; - } - - const modal = await openSpinnerModal('fileViewers.openingFile'); - const contentType = await detectFileContentType(workspaceHandle, entry.path); - - try { - if (!contentType || contentType.type === FileContentType.Unknown) { - await openWithSystem(workspaceHandle, entry); - } else { - if (entry.size > OPEN_FILE_SIZE_LIMIT) { - informationManager.present( - new Information({ - message: 'FILE TOO BIG', - level: InformationLevel.Warning, - }), - PresentationMode.Toast, - ); - await openWithSystem(workspaceHandle, entry); - return; - } - - await navigateTo(Routes.Viewer, { - query: { workspaceHandle: workspaceHandle, documentPath: entry.path, fileTypeInfo: Base64.fromObject(contentType) }, - }); - } - } finally { - await modal.dismiss(); - } + await openPath(workspaceHandle, entry.path, informationManager, { skipViewers: config.skipViewers }); } async function openGlobalContextMenu(event: Event): Promise { diff --git a/client/src/views/sidebar-menu/SidebarMenuPage.vue b/client/src/views/sidebar-menu/SidebarMenuPage.vue index cf3806bcae7..64b6292e5f2 100644 --- a/client/src/views/sidebar-menu/SidebarMenuPage.vue +++ b/client/src/views/sidebar-menu/SidebarMenuPage.vue @@ -158,19 +158,19 @@ -
+
- + {{ $msTranslate('SideMenu.favorites') }}
@@ -186,28 +186,23 @@ /> -
- - + -
+
- + {{ $msTranslate('SideMenu.workspaces') }}
{{ $msTranslate('SideMenu.noWorkspace') }} @@ -231,6 +226,43 @@ />
+ +
+ + +
+ + +
+ + + {{ $msTranslate('SideMenu.recentDocuments') }} + +
+
+ + +
+
+
> = ref([]); const eventDistributor: EventDistributor = inject(EventDistributorKey)!; @@ -535,6 +582,16 @@ async function openOrganizationChoice(event: Event): Promise { async function openPricingLink(): Promise { window.open(I18n.translate({ key: 'app.pricingLink' }), '_blank'); } + +async function openRecentFile(file: RecentFile): Promise { + const config = await storageManager.retrieveConfig(); + + await openPath(file.workspaceHandle, file.path, informationManager, { skipViewers: config.skipViewers }); +} + +async function removeRecentFile(file: RecentFile): Promise { + recentFileManager.removeFile(file); +}