diff --git a/client/src/parsec/file.ts b/client/src/parsec/file.ts index fb3dd32ba62..c62ded13f7b 100644 --- a/client/src/parsec/file.ts +++ b/client/src/parsec/file.ts @@ -165,7 +165,15 @@ export async function statFolderChildren( path: FsPath, ): Promise, WorkspaceStatFolderChildrenError>> { if (!needsMocks()) { - const result = await libparsec.workspaceStatFolderChildren(workspaceHandle, path); + const watchResult = await libparsec.workspaceWatchEntryOneshot(workspaceHandle, path); + + let result; + if (!watchResult.ok) { + result = await libparsec.workspaceStatFolderChildren(workspaceHandle, path); + } else { + result = await libparsec.workspaceStatFolderChildrenById(workspaceHandle, watchResult.value); + } + if (!result.ok) { return result; } diff --git a/client/src/parsec/login.ts b/client/src/parsec/login.ts index b0a5f681db4..5a6c7f4fa98 100644 --- a/client/src/parsec/login.ts +++ b/client/src/parsec/login.ts @@ -41,8 +41,6 @@ import { DateTime } from 'luxon'; export interface LoggedInDeviceInfo { handle: ConnectionHandle; device: AvailableDevice; - // Used to simulate update events, remove when we have real events - intervalId: any; } const loggedInDevices: Array = []; @@ -159,6 +157,15 @@ export async function login( case ClientEventTag.WorkspacesSelfListChanged: eventDistributor.dispatchEvent(Events.WorkspaceUpdated); break; + case ClientEventTag.WorkspaceWatchedEntryChanged: + eventDistributor.dispatchEvent(Events.EntryUpdated, undefined, 300); + break; + case ClientEventTag.WorkspaceOpsInboundSyncDone: + eventDistributor.dispatchEvent(Events.EntrySynced, { workspaceId: event.realmId, entryId: event.entryId }); + break; + case ClientEventTag.WorkspaceOpsOutboundSyncDone: + eventDistributor.dispatchEvent(Events.EntrySynced, { workspaceId: event.realmId, entryId: event.entryId }); + break; default: window.electronAPI.log('debug', `Unhandled event ${event.tag}`); break; @@ -177,11 +184,7 @@ export async function login( const clientConfig = getClientConfig(); const result = await libparsec.clientStart(clientConfig, callback, accessStrategy); if (result.ok) { - // Simulate an update event every 10s to force a refresh - const intervalId = setInterval(() => { - eventDistributor.dispatchEvent(Events.EntryUpdated); - }, 10000); - loggedInDevices.push({ handle: result.value, device: device, intervalId: intervalId }); + loggedInDevices.push({ handle: result.value, device: device }); } return result; } else { @@ -189,7 +192,7 @@ export async function login( accessStrategy.tag === DeviceAccessStrategyTag.Password && ['P@ssw0rd.', 'AVeryL0ngP@ssw0rd'].includes((accessStrategy as DeviceAccessStrategyPassword).password) ) { - loggedInDevices.push({ handle: DEFAULT_HANDLE, device: device, intervalId: null }); + loggedInDevices.push({ handle: DEFAULT_HANDLE, device: device }); return { ok: true, value: DEFAULT_HANDLE }; } return { @@ -212,10 +215,7 @@ export async function logout(handle?: ConnectionHandle | undefined | null): Prom if (result.ok) { const index = loggedInDevices.findIndex((info) => info.handle === handle); if (index !== -1) { - const removed = loggedInDevices.splice(index, 1); - if (removed && removed.length > 0) { - clearInterval(removed[0].intervalId); - } + loggedInDevices.splice(index, 1); } } return result; diff --git a/client/src/services/eventDistributor.ts b/client/src/services/eventDistributor.ts index 4b35fdb0f02..c488f819bcb 100644 --- a/client/src/services/eventDistributor.ts +++ b/client/src/services/eventDistributor.ts @@ -1,6 +1,6 @@ // Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS -import { InvitationStatus, InvitationToken, WorkspaceID } from '@/parsec'; +import { EntryID, InvitationStatus, InvitationToken, WorkspaceID } from '@/parsec'; import { v4 as uuid4 } from 'uuid'; export const EventDistributorKey = 'eventDistributor'; @@ -17,6 +17,7 @@ enum Events { UpdateAvailability = 1 << 8, WorkspaceUpdated = 1 << 9, EntryUpdated = 1 << 10, + EntrySynced = 1 << 11, } interface WorkspaceCreatedData { @@ -33,7 +34,12 @@ interface UpdateAvailabilityData { version?: string; } -type EventData = WorkspaceCreatedData | InvitationUpdatedData | UpdateAvailabilityData; +interface EntrySyncedData { + workspaceId: WorkspaceID; + entryId: EntryID; +} + +type EventData = WorkspaceCreatedData | InvitationUpdatedData | UpdateAvailabilityData | EntrySyncedData; interface Callback { id: string; @@ -43,16 +49,45 @@ interface Callback { class EventDistributor { private callbacks: Array; + private timeouts: Map; constructor() { this.callbacks = []; + this.timeouts = new Map(); } - async dispatchEvent(event: Events, data?: EventData): Promise { - for (const cb of this.callbacks) { - if (event & cb.events) { - await cb.funct(event, data); + async dispatchEvent(event: Events, data?: EventData, aggregateTime?: number): Promise { + async function sendToAll(callbacks: Array, event: Events, data?: EventData): Promise { + for (const cb of callbacks) { + if (event & cb.events) { + await cb.funct(event, data); + } + } + } + + // In some cases, events can occur very close to each other, leading to some heavy operations. + // We can aggregate those cases in order to distribute only one event if multiple occur in a short + // time lapse. + if (aggregateTime !== undefined) { + if (data) { + // Can't have data with an aggregateTime, we wouldn't know what data to use + console.warn('Cannot have an aggregate time with data, ignoring this event.'); + return; + } + // Clear previous interval if any + if (this.timeouts.has(event)) { + const interval = this.timeouts.get(event); + this.timeouts.delete(event); + window.clearInterval(interval); } + // Create a new timeout + const interval = window.setTimeout(async () => { + await sendToAll(this.callbacks, event, undefined); + }, aggregateTime); + // Add it to the list + this.timeouts.set(event, interval); + } else { + await sendToAll(this.callbacks, event, data); } } @@ -67,4 +102,4 @@ class EventDistributor { } } -export { EventData, EventDistributor, Events, InvitationUpdatedData, UpdateAvailabilityData, WorkspaceCreatedData }; +export { EntrySyncedData, EventData, EventDistributor, Events, InvitationUpdatedData, UpdateAvailabilityData, WorkspaceCreatedData }; diff --git a/client/src/views/files/FoldersPage.vue b/client/src/views/files/FoldersPage.vue index c4a05342033..22c3b15aab6 100644 --- a/client/src/views/files/FoldersPage.vue +++ b/client/src/views/files/FoldersPage.vue @@ -242,7 +242,7 @@ import FileDetailsModal from '@/views/files/FileDetailsModal.vue'; import { IonContent, IonPage, IonText, modalController, popoverController } from '@ionic/vue'; import { arrowRedo, copy, folderOpen, informationCircle, link, pencil, trashBin } from 'ionicons/icons'; import { Ref, computed, inject, onMounted, onUnmounted, ref } from 'vue'; -import { EventData, EventDistributor, EventDistributorKey, Events } from '@/services/eventDistributor'; +import { EntrySyncedData, EventData, EventDistributor, EventDistributorKey, Events } from '@/services/eventDistributor'; interface FoldersPageSavedData { displayState?: DisplayState; @@ -400,12 +400,24 @@ onMounted(async () => { await defineShortcuts(); eventCbId = await eventDistributor.registerCallback( - Events.EntryUpdated | Events.WorkspaceUpdated, - async (event: Events, _data?: EventData) => { + Events.EntryUpdated | Events.WorkspaceUpdated | Events.EntrySynced, + async (event: Events, data?: EventData) => { if (event === Events.EntryUpdated) { await listFolder(); } else if (event === Events.WorkspaceUpdated && workspaceInfo.value) { await updateWorkspaceInfo(workspaceInfo.value.id); + } else if (event === Events.EntrySynced) { + const syncedData = data as EntrySyncedData; + if (!workspaceInfo.value || workspaceInfo.value.id !== syncedData.workspaceId) { + return; + } + let entry: EntryModel | undefined = files.value.getEntries().find((e) => e.id === syncedData.entryId); + if (!entry) { + entry = folders.value.getEntries().find((e) => e.id === syncedData.entryId); + } + if (entry) { + entry.needSync = false; + } } }, );