Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve UX for cloud save #7836

Merged
merged 22 commits into from
Feb 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3a3b14d
Showing more cloud state on the project cards
darzu Jan 25, 2021
33853f8
showing last cloud edit time and state in UX
darzu Jan 26, 2021
a9b14ac
Better date appearance; better alignment; todo lst
darzu Jan 26, 2021
f4a6721
dynamic homescreen update works
darzu Jan 27, 2021
cd0cdf2
Dim the cloud state message in the editor
darzu Jan 27, 2021
aea5f7e
Notify virtual api of header change at the start of cloud save (so we…
darzu Jan 27, 2021
7771961
Keep cloudInProgressSyncStartTime in memory only
darzu Jan 27, 2021
fb12457
cloud sync update without reload is working!
darzu Jan 29, 2021
bba3df4
update home page project list when num headers changes
darzu Jan 29, 2021
2ffc909
Fix how delete propegates
darzu Jan 29, 2021
8dad070
Update virtual APIs & DataSubscriber to propegate specific path that …
darzu Jan 29, 2021
7ec869d
Revert virtual API changes. one-to-many subscriptions need more work …
darzu Jan 29, 2021
47c87cc
Remove cloud sync message on home screen
darzu Jan 29, 2021
e83a3b7
Don't show cloud state unless saving
darzu Jan 29, 2021
077f9f6
Keep a "saved!" indicator around right after we cloud save.
darzu Jan 29, 2021
e6dabd1
Move CloudStateSummary out of pxt localtypings
darzu Jan 29, 2021
a2694af
clean up TODOs
darzu Jan 29, 2021
309463c
remove TODO
darzu Feb 1, 2021
aa81125
remove todo; fix render bug
darzu Feb 1, 2021
8306787
remove debug logging
darzu Feb 1, 2021
5b26a14
merge with master
darzu Feb 1, 2021
c9bb1c0
Don't download deleted projects
darzu Feb 2, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions localtypings/pxtpackage.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ declare namespace pxt {

type CodeCardType = "file" | "example" | "codeExample" | "tutorial" | "side" | "template" | "package" | "hw" | "forumUrl" | "forumExample" | "sharedExample" | "link";
type CodeCardEditorType = "blocks" | "js" | "py";
type CodeCardCloudState = "local" | "cloud";

interface Map<T> {
[index: string]: T;
Expand Down Expand Up @@ -169,8 +168,8 @@ declare namespace pxt {
cardType?: CodeCardType;
editor?: CodeCardEditorType;
otherActions?: CodeCardAction[];
cloudState?: CodeCardCloudState;
directOpen?: boolean; // skip the details view, directly do the card action
projectId?: string; // the project's header ID

header?: string;

Expand Down
4 changes: 4 additions & 0 deletions theme/common.less
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,10 @@ p.ui.font.small {
font-size: 0.95rem;
}

#projectNameArea .cloudState {
color: rgba(0, 0, 0, 0.5);
}

/*******************************
Media Adjustments
*******************************/
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1332,7 +1332,8 @@ export class ProjectView
// We are too late; the editor has already been loaded.
// Call the onChanges handler to update the editor.
pxt.tickEvent(`identity.syncOnProjectOpen.timedout`, { 'elapsedSec': elapsed})
cloud.onChangesSynced(changes)
if (changes.length)
cloud.forceReloadForCloudSync()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this call trigger a reload while in the editor? I've seen the toast "Cloud synchronization complete. Reloading" happen in the editor, which was jarring. Logged it here: microsoft/pxt-arcade#3005

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it can. Unfortunately I don't know a good way to avoid this.

The problem is that if the user opens up a project at version A but after that we sync with the cloud and determine they should be fast-forwarded to version B, then we need to reload the editor from underneath them. Currently, there isn't a way to reload the editor's content without doing a page reload. Likely we could fix this using virtual APIs but it's another chunk of work..

} else {
// We're not too late, update the local var so that the
// first load has the new info.
Expand Down
187 changes: 117 additions & 70 deletions webapp/src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ type WorkspaceProvider = pxt.workspace.WorkspaceProvider;

import U = pxt.Util;

const state = {
uploadCount: 0,
downloadCount: 0
};

type CloudProject = {
id: string;
header: string;
Expand All @@ -37,6 +32,24 @@ export function excludeLocalOnlyMetadataFields(h: Header): Header {
return clone
}

export type CloudStateSummary = ""/*none*/ | "saved" | "justSaved" | "offline" | "syncing" | "conflict" | "localEdits";
export function getCloudSummary(h: pxt.workspace.Header, md: CloudTempMetadata): CloudStateSummary {
if (!h.cloudUserId || !md)
return "" // none
if (!auth.loggedInSync())
return "offline"
if (md.cloudInProgressSyncStartTime > 0)
return "syncing"
if (!h.cloudCurrent)
return "localEdits"
if (md.justSaved)
return "justSaved"
if (h.cloudLastSyncTime > 0)
return "saved"
pxt.reportError("cloudsave", `Invalid project cloud state for project ${h.name}(${h.id.substr(0, 4)}..): user: ${h.cloudUserId}, inProg: ${md.cloudInProgressSyncStartTime}, cloudCurr: ${h.cloudCurrent}, lastCloud: ${h.cloudLastSyncTime}`);
return ""
}

async function listAsync(): Promise<Header[]> {
return new Promise(async (resolve, reject) => {
// Note: Cosmos & our backend does not return e-tags each individual item in a list operation
Expand Down Expand Up @@ -85,19 +98,42 @@ function getAsync(h: Header): Promise<File> {
});
}

// temporary per-project cloud metadata is only kept in memory and shouldn't be persisted to storage.
export interface CloudTempMetadata {
cloudInProgressSyncStartTime?: number,
justSaved?: boolean,
}
const temporaryHeaderMetadata: { [key: string]: CloudTempMetadata } = {};
export function getCloudTempMetadata(headerId: string): CloudTempMetadata {
return temporaryHeaderMetadata[headerId] || {};
}
function updateCloudTempMetadata(headerId: string, props: Partial<CloudTempMetadata>) {
const oldMd = temporaryHeaderMetadata[headerId] || {};
const newMd = { ...oldMd, ...props }
temporaryHeaderMetadata[headerId] = newMd
data.invalidate(`${HEADER_CLOUDSTATE}:${headerId}`);
}

function setAsync(h: Header, prevVersion: Version, text?: ScriptText): Promise<Version> {
return new Promise(async (resolve, reject) => {
const userId = auth.user()?.id;
h.cloudUserId = userId;
h.cloudCurrent = false;
h.cloudVersion = prevVersion;
updateCloudTempMetadata(h.id, { cloudInProgressSyncStartTime: U.nowSeconds() })
const project: CloudProject = {
id: h.id,
header: JSON.stringify(excludeLocalOnlyMetadataFields(h)),
text: text ? JSON.stringify(text) : undefined,
version: prevVersion
}
const result = await auth.apiAsync<string>('/api/user/project', project);
updateCloudTempMetadata(h.id, { cloudInProgressSyncStartTime: 0, justSaved: true })
setTimeout(() => {
// slightly hacky, but we want to keep around a "saved!" message for a small time after
// a save succeeds so we notify metadata subscribers again afte a delay.
updateCloudTempMetadata(h.id, { justSaved: false })
}, 1000);
if (result.success) {
h.cloudCurrent = true;
h.cloudVersion = result.resp;
Expand All @@ -124,8 +160,8 @@ export async function saveAsync(h: Header, text?: ScriptText): Promise<CloudSave
if (!res) {
// wait to synchronize
pxt.debug('save to cloud failed; synchronizing...')
pxt.tickEvent(`identity.cloudSaveFailedTriggeringFullSync`);
await syncAsync()
pxt.tickEvent(`identity.cloudSaveFailedTriggeringPartialSync`);
await syncAsync([h])
return CloudSaveResult.SyncError;
} else {
return CloudSaveResult.Success;
Expand Down Expand Up @@ -216,31 +252,55 @@ async function syncAsyncInternal(hdrs?: Header[]): Promise<Header[]> {
})
const lastCloudChange = remoteHeaders.length ? Math.max(...remoteHeaders.map(h => h.modificationTime)) : syncStart
pxt.log(`Last cloud project change was ${agoStr(lastCloudChange)}`);
const remoteHeaderMap = U.toDictionary(remoteHeaders, h => h.id);
const remoteHeadersToProcess = U.toDictionary(remoteHeaders, h => h.id);
const localHeaderChanges: pxt.Map<Header> = {}
const toCloud = transferToCloud;
const fromCloud = async (loc: Header, rem: File) => {
const newLoc = await transferFromCloud(loc, rem)
localHeaderChanges[newLoc.id] = newLoc
return newLoc
}
let tasks = localCloudHeaders.map(async (local) => {
const remote = remoteHeaderMap[local.id];
delete remoteHeaderMap[local.id];
let didProjectCountChange = false;
let tasks: Promise<Header>[] = localCloudHeaders.map(async (local) => {
// track the fact that we're checking for updates on each project
updateCloudTempMetadata(local.id, { cloudInProgressSyncStartTime: U.nowSeconds() });

const remote = remoteHeadersToProcess[local.id];
delete remoteHeadersToProcess[local.id];
if (remote) {
local.cloudLastSyncTime = remote.cloudLastSyncTime
// Note that we use modification time to detect differences. If we had full (or partial) history, we could
// use version numbers. However we cannot currently use etags since the Cosmos list operations
// don't return etags per-version. And because of how etags work, the record itself can never
// have the latest etag version.
if (local.modificationTime !== remote.modificationTime) {
if (local.modificationTime !== remote.modificationTime || local.isDeleted !== remote.isDeleted) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to switch to cosmos etag here. We're will see sync bugs with modificationTime.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm pro switching to etags. It'll take a cloud deploy.

const projShorthand = `'${local.name}' (${local.id.substr(0, 5)}...)`;
const remoteFile = await getWithCacheAsync(local);
// delete always wins no matter what
if (local.isDeleted) {
// Mark remote copy as deleted.
pxt.debug(`Propegating ${projShorthand} delete to cloud.`)
const newHdr = await toCloud(local, remoteFile.version)
pxt.tickEvent(`identity.sync.localDeleteUpdatedCloud`)
return newHdr
}
if (remote.isDeleted) {
// Delete local copy.
pxt.debug(`Propegating ${projShorthand} delete from cloud.`)
const newHdr = await fromCloud(local, remoteFile);
didProjectCountChange = true;
pxt.tickEvent(`identity.sync.cloudDeleteUpdatedLocal`)
return newHdr
}
// if it's not a delete...
if (local.cloudCurrent) {
// No local changes, download latest.
await fromCloud(local, remoteFile);
const newHdr = await fromCloud(local, remoteFile);
pxt.tickEvent(`identity.sync.noConflict.localProjectUpdatedFromCloud`)
return newHdr
} else {
// Possible conflict.
const conflictStr = `conflict found for '${local.name}' (${local.id.substr(0, 5)}...). Last cloud change was ${agoStr(remoteFile.header.modificationTime)} and last local change was ${agoStr(local.modificationTime)}.`
const conflictStr = `conflict found for ${projShorthand}. Last cloud change was ${agoStr(remoteFile.header.modificationTime)} and last local change was ${agoStr(local.modificationTime)}.`
// last write wins.
if (local.modificationTime > remoteFile.header.modificationTime) {
if (local.cloudVersion === remoteFile.version) {
Expand All @@ -253,35 +313,17 @@ async function syncAsyncInternal(hdrs?: Header[]): Promise<Header[]> {
pxt.log(conflictStr + ' Local will overwrite cloud.')
pxt.tickEvent(`identity.sync.conflict.localOverwrittingCloud`)
}
await toCloud(local, remoteFile.version);
return await toCloud(local, remoteFile.version);
} else {
// conflict and remote wins
// TODO: Pop a dialog and/or show the user a diff. Better yet, handle merges.
pxt.log(conflictStr + ' Cloud will overwrite local.')
pxt.tickEvent(`identity.sync.conflict.cloudOverwritingLocal`)
await fromCloud(local, remoteFile);
return await fromCloud(local, remoteFile);
}
}
} else {
if (local.isDeleted) {
// Delete remote copy.
//return deleteAsync(local, local.cloudVersion);
// Mark remote copy as deleted.
remote.isDeleted = true;
await toCloud(local, remote.cloudVersion)
pxt.tickEvent(`identity.sync.localDeleteUpdatedCloud`)
}
if (remote.isDeleted) {
// Delete local copy.
local.isDeleted = true;
localHeaderChanges[local.id] = local
await workspace.forceSaveAsync(local, {}, true)
.then(() => { data.clearCache(); })
pxt.tickEvent(`identity.sync.cloudDeleteUpdatedLocal`)
}
// Nothing to do. We're up to date locally.
return Promise.resolve();
}
return local // no changes
} else if (!partialSync) {
if (local.cloudVersion) {
pxt.debug(`Project ${local.id} incorrectly thinks it is synced to the cloud (ver: ${local.cloudVersion})`)
Expand All @@ -292,18 +334,32 @@ async function syncAsyncInternal(hdrs?: Header[]): Promise<Header[]> {
}
// Local cloud synced project exists, but it didn't make it to the server,
// so let's push it now.
await toCloud(local, null)
return await toCloud(local, null)
}
else {
// no remote verison so nothing to do
return local
}
});
tasks = [...tasks, ...remoteHeaders.map(async (remote) => {
if (remoteHeaderMap[remote.id]) {
tasks = [...tasks, ...U.values(remoteHeadersToProcess)
.filter(h => !h.isDeleted) // don't bother downloading deleted projects
.map(async (remote) => {
// Project exists remotely and not locally, download it.
const remoteFile = await getWithCacheAsync(remote);
pxt.debug(`importing new cloud project '${remoteFile.header.name}' (${remoteFile.header.id})`)
await fromCloud(null, remoteFile)
const res = await fromCloud(null, remoteFile)
pxt.tickEvent(`identity.sync.importCloudProject`)
didProjectCountChange = true;
return res;
})]
tasks = tasks.map(async t => {
const newHdr = await t
// reset cloud state sync metadata if there is any
if (getCloudTempMetadata(newHdr.id).cloudInProgressSyncStartTime > 0) {
updateCloudTempMetadata(newHdr.id, { cloudInProgressSyncStartTime: 0 });
}
})]
return newHdr;
})
await Promise.all(tasks);

// sanity check: all cloud headers should have a new sync time
Expand All @@ -316,9 +372,11 @@ async function syncAsyncInternal(hdrs?: Header[]): Promise<Header[]> {
const elapsed = U.nowSeconds() - syncStart;
const localHeaderChangesList = U.values(localHeaderChanges)
pxt.log(`Cloud sync finished after ${elapsed} seconds with ${localHeaderChangesList.length} local changes.`);
pxt.tickEvent(`identity.sync.finished`, {elapsed})
if (!partialSync) {
onChangesSynced(localHeaderChangesList)
pxt.tickEvent(`identity.sync.finished`, { elapsed })
if (didProjectCountChange) {
// headers are individually invalidated as they are synced, but if new projects come along we also need to
// update the global headers list.
data.invalidate("headers:");
}

return localHeaderChangesList
Expand All @@ -329,17 +387,15 @@ async function syncAsyncInternal(hdrs?: Header[]): Promise<Header[]> {
return [];
}

export function onChangesSynced(changes: Header[]) {
if (changes.length) {
// TODO: This is too heavy handed. We can be more fine grain here with some work.
// preferably with just the virtual data APIs we can push updates to the whole editor.
core.infoNotification(lf("Cloud synchronization finished. Reloading... "));
setTimeout(() => {
pxt.log("Forcing reload.")
pxt.tickEvent(`identity.sync.forcingReload`)
location.reload();
}, 3000);
}
export function forceReloadForCloudSync() {
// TODO: This is too heavy handed. We can be more fine grain here with some work.
// preferably with just the virtual data APIs we can push updates to the whole editor.
core.infoNotification(lf("Cloud synchronization finished. Reloading... "));
setTimeout(() => {
pxt.log("Forcing reload.")
pxt.tickEvent(`identity.sync.forcingReload`)
location.reload();
}, 3000);
}

export async function convertCloudToLocal(userId: string) {
Expand All @@ -363,24 +419,15 @@ export async function convertCloudToLocal(userId: string) {
* Virtual API
*/

const MODULE = "cloud";
const FIELD_UPLOADING = "uploading";
const FIELD_DOWNLOADING = "downloading";
const FIELD_WORKING = "working";
export const UPLOADING = `${MODULE}:${FIELD_UPLOADING}`;
export const DOWNLOADING = `${MODULE}:${FIELD_DOWNLOADING}`;
export const WORKING = `${MODULE}:${FIELD_WORKING}`;
export const HEADER_CLOUDSTATE = "header-cloudstate"

function cloudApiHandler(p: string): any {
switch (data.stripProtocol(p)) {
case FIELD_UPLOADING: return state.uploadCount > 0;
case FIELD_DOWNLOADING: return state.downloadCount > 0;
case WORKING: return cloudApiHandler(UPLOADING) || cloudApiHandler(DOWNLOADING);
}
return null;
function cloudHeaderMetadataHandler(p: string): any {
p = data.stripProtocol(p)
if (p == "*") return workspace.getHeaders().map(h => getCloudTempMetadata(h.id))
return getCloudTempMetadata(p)
}

export function init() {
// 'cloudws' because 'cloud' protocol is already taken.
data.mountVirtualApi("cloudws", { getSync: cloudApiHandler });
data.mountVirtualApi(HEADER_CLOUDSTATE, { getSync: cloudHeaderMetadataHandler });
}

Loading