From d5687bff5abb4231a2a1b3af4ce1bf3a72dc51e6 Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Fri, 13 Sep 2024 15:31:34 -0700 Subject: [PATCH 1/3] Port time machine code over the arcade stable --- localtypings/pxtarget.d.ts | 5 + package.json | 2 + pxteditor/editor.ts | 1 + pxteditor/history.ts | 514 +++++++++++++++++ pxtlib/main.ts | 1 + pxtlib/shell.ts | 4 + react-common/components/controls/Tree.tsx | 155 +++++ react-common/styles/controls/Tree.less | 33 ++ .../styles/react-common-variables.less | 8 +- react-common/styles/react-common.less | 1 + theme/pxt.less | 1 + theme/timeMachine.less | 154 +++++ webapp/src/app.tsx | 16 + webapp/src/container.tsx | 7 + webapp/src/dialogs.tsx | 91 +++ webapp/src/editortoolbar.tsx | 2 +- webapp/src/headerbar.tsx | 11 +- webapp/src/timeMachine.tsx | 528 ++++++++++++++++++ webapp/src/workspace.ts | 190 +++++-- 19 files changed, 1674 insertions(+), 50 deletions(-) create mode 100644 pxteditor/history.ts create mode 100644 react-common/components/controls/Tree.tsx create mode 100644 react-common/styles/controls/Tree.less create mode 100644 theme/timeMachine.less create mode 100644 webapp/src/timeMachine.tsx diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index 77f7bba78a24..8f18633ba660 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -481,6 +481,11 @@ declare namespace pxt { songEditor?: boolean; // enable the song asset type and field editor multiplayer?: boolean; // enable multiplayer features shareToKiosk?: boolean; // enable sharing to a kiosk + blocklySoundVolume?: number; // A number between 0 and 1 that sets the volume for blockly sounds (e.g. connect, disconnect, click) + timeMachine?: boolean; + timeMachineQueryParams?: string[]; // An array of query params to pass to timemachine iframe embed + timeMachineDiffInterval?: number; // An interval in milliseconds at which to take diffs to store in project history. Defaults to 5 minutes + timeMachineSnapshotInterval?: number; // An interval in milliseconds at which to take full project snapshots in project history. Defaults to 15 minutes } interface DownloadDialogTheme { diff --git a/package.json b/package.json index 56ad5816d095..89d735204fb1 100644 --- a/package.json +++ b/package.json @@ -69,11 +69,13 @@ "@fortawesome/fontawesome-free": "^5.15.4", "@microsoft/applicationinsights-web": "^2.8.11", "@microsoft/immersive-reader-sdk": "1.1.0", + "@types/diff-match-patch": "^1.0.36", "@zip.js/zip.js": "2.4.20", "browserify": "16.2.3", "chai": "^3.5.0", "cssnano": "4.1.10", "dashjs": "^4.4.0", + "diff-match-patch": "^1.0.5", "dompurify": "2.0.17", "faye-websocket": "0.11.1", "karma": "6.3.10", diff --git a/pxteditor/editor.ts b/pxteditor/editor.ts index 3d4bd3276d34..083a0d93830c 100644 --- a/pxteditor/editor.ts +++ b/pxteditor/editor.ts @@ -357,6 +357,7 @@ namespace pxt.editor { showLanguagePicker(): void; showShareDialog(title?: string, forMultiplayer?: boolean): void; showAboutDialog(): void; + showTurnBackTimeDialogAsync(): Promise; showLoginDialog(continuationHash?: string): void; showProfileDialog(location?: string): void; diff --git a/pxteditor/history.ts b/pxteditor/history.ts new file mode 100644 index 000000000000..a3ea610da152 --- /dev/null +++ b/pxteditor/history.ts @@ -0,0 +1,514 @@ +namespace pxt.editor { + import ScriptText = pxt.workspace.ScriptText; + + export interface HistoryFile { + entries: HistoryEntry[]; + snapshots: SnapshotEntry[]; + shares: ShareEntry[]; + lastSaveTime: number; + } + + export interface HistoryEntry { + timestamp: number; + editorVersion: string; + changes: FileChange[]; + } + + export interface SnapshotEntry { + timestamp: number; + editorVersion: string; + text: ScriptText; + } + + export interface ShareEntry { + timestamp: number; + id: string; + } + + export type FileChange = FileAddedChange | FileRemovedChange | FileEditedChange; + + export interface FileAddedChange { + type: "added"; + filename: string; + value: string; + } + + export interface FileRemovedChange { + type: "removed"; + filename: string; + value: string; + } + + export interface FileEditedChange { + type: "edited"; + filename: string; + + // We always store the current file so this is a backwards patch + patch: any; + } + + export interface CollapseHistoryOptions { + interval: number; + minTime?: number; + maxTime?: number; + } + + // 5 minutes. This is overridden in pxtarget.json + const DEFAULT_DIFF_HISTORY_INTERVAL = 1000 * 60 * 5; + + // 15 minutes. This is overridden in pxtarget.json + const DEFAULT_SNAPSHOT_HISTORY_INTERVAL = 1000 * 60 * 15; + + const ONE_DAY = 1000 * 60 * 60 * 24; + + export function collapseHistory(history: HistoryEntry[], text: ScriptText, options: CollapseHistoryOptions, diff: (a: string, b: string) => unknown, patch: (p: unknown, text: string) => string) { + const newHistory: HistoryEntry[] = []; + + let current = {...text}; + let lastVersion = pxt.appTarget?.versions?.target; + let lastTime: number = undefined; + let lastTimeIndex: number = undefined; + let lastTimeText: ScriptText = undefined; + + let { interval, minTime, maxTime } = options; + + if (minTime === undefined) { + minTime = 0; + } + if (maxTime === undefined) { + maxTime = history[history.length - 1].timestamp; + } + + for (let i = history.length - 1; i >= 0; i--) { + const entry = history[i]; + + if (entry.timestamp > maxTime) { + newHistory.unshift(entry); + current = applyDiff(current, entry, patch); + continue; + } + else if (entry.timestamp < minTime) { + if (lastTimeIndex !== undefined) { + if (lastTimeIndex - i > 1) { + newHistory.unshift({ + timestamp: lastTime, + editorVersion: lastVersion, + changes: diffScriptText(current, lastTimeText, lastTime, diff).changes + }) + } + else { + newHistory.unshift(history[lastTimeIndex]); + } + } + newHistory.unshift(entry); + lastTimeIndex = undefined; + continue; + } + else if (lastTimeIndex === undefined) { + lastTimeText = {...current}; + lastTime = entry.timestamp; + lastVersion = entry.editorVersion; + + lastTimeIndex = i; + current = applyDiff(current, entry, patch); + continue; + } + + if (lastTime - entry.timestamp > interval) { + if (lastTimeIndex - i > 1) { + newHistory.unshift({ + timestamp: lastTime, + editorVersion: lastVersion, + changes: diffScriptText(current, lastTimeText, lastTime, diff).changes + }) + } + else { + newHistory.unshift(history[lastTimeIndex]); + } + + lastTimeText = {...current} + current = applyDiff(current, entry, patch); + + lastTimeIndex = i; + lastTime = entry.timestamp; + lastVersion = entry.editorVersion; + } + else { + current = applyDiff(current, entry, patch); + } + } + + if (lastTimeIndex !== undefined) { + if (lastTimeIndex) { + newHistory.unshift({ + timestamp: lastTime, + editorVersion: lastVersion, + changes: diffScriptText(current, lastTimeText, lastTime, diff).changes + }) + } + else { + newHistory.unshift(history[0]); + } + } + + return newHistory; + } + + export function diffScriptText(oldVersion: ScriptText, newVersion: ScriptText, time: number, diff: (a: string, b: string) => unknown): HistoryEntry { + const changes: FileChange[] = []; + + for (const file of Object.keys(oldVersion)) { + if (!(file.endsWith(".ts") || file.endsWith(".jres") || file.endsWith(".py") || file.endsWith(".blocks") || file === "pxt.json")) continue; + if (newVersion[file] == undefined) { + changes.push({ + type: "removed", + filename: file, + value: oldVersion[file] + }); + } + else if (oldVersion[file] !== newVersion[file]) { + changes.push({ + type: "edited", + filename: file, + patch: diff(newVersion[file], oldVersion[file]) + }); + } + } + + for (const file of Object.keys(newVersion)) { + if (!(file.endsWith(".ts") || file.endsWith(".jres") || file.endsWith(".py") || file.endsWith(".blocks") || file === "pxt.json")) continue; + + if (oldVersion[file] == undefined) { + changes.push({ + type: "added", + filename: file, + value: newVersion[file] + }); + } + } + + if (!changes.length) return undefined; + + return { + timestamp: time, + editorVersion: pxt.appTarget?.versions?.target, + changes + } + } + + export function applyDiff(text: ScriptText, history: HistoryEntry, patch: (p: unknown, text: string) => string) { + const result = { ...text }; + for (const change of history.changes) { + if (change.type === "added") { + delete result[change.filename] + } + else if (change.type === "removed") { + result[change.filename] = change.value; + } + else { + result[change.filename] = patch(change.patch, text[change.filename]); + } + } + + return result; + } + + export function createSnapshot(text: ScriptText) { + try { + const result: ScriptText = {}; + const config: pxt.PackageConfig = JSON.parse(text[pxt.CONFIG_NAME]); + + for (const file of config.files) { + // these files will just get regenrated + if (file === pxt.IMAGES_CODE || file === pxt.TILEMAP_CODE) { + result[file] = ""; + } + else { + result[file] = text[file]; + } + } + + result[pxt.CONFIG_NAME] = text[pxt.CONFIG_NAME]; + + // main.ts will also be regenerated if blocks/python + if (config.preferredEditor === pxt.BLOCKS_PROJECT_NAME) { + if (result[pxt.MAIN_BLOCKS]) result[pxt.MAIN_TS] = ""; + } + else if (config.preferredEditor === pxt.PYTHON_PROJECT_NAME) { + if (result[pxt.MAIN_PY]) result[pxt.MAIN_TS] = ""; + } + + if (config.testFiles) { + for (const file of config.testFiles) { + result[file] = text[file]; + } + } + + return result; + } + catch(e) { + return { ...text } + } + } + + export function applySnapshot(text: ScriptText, snapshot: ScriptText) { + try { + const result: ScriptText = { ...snapshot }; + const config: pxt.PackageConfig = JSON.parse(text[pxt.CONFIG_NAME]); + + // preserve any files from the current text that aren't in the config; this is just to make + // sure that our internal files like history, markdown, serial output are preserved + for (const file of Object.keys(text)) { + if (config.files.indexOf(file) === -1 && config.testFiles?.indexOf(file) === -1 && !result[file]) { + result[file] = text[file]; + } + } + + return result; + } + catch (e) { + const result = { ...text }; + for (const file of Object.keys(snapshot)) { + result[file] = snapshot[file] + } + + return result; + } + } + + export function parseHistoryFile(text: string): HistoryFile { + const result: HistoryFile = JSON.parse(text); + + if (!result.entries) result.entries = []; + if (!result.shares) result.shares = []; + if (!result.snapshots) result.snapshots = []; + + return result; + } + + export function updateHistory(previousText: ScriptText, toWrite: ScriptText, currentTime: number, shares: pxt.workspace.PublishVersion[], diff: (a: string, b: string) => unknown, patch: (p: unknown, text: string) => string) { + let history: HistoryFile; + + // Always base the history off of what was in the previousText, + // which is written to disk. The new text could have corrupted it + // in some way + if (previousText[pxt.HISTORY_FILE]) { + history = parseHistoryFile(previousText[pxt.HISTORY_FILE]); + if (history.lastSaveTime === undefined) { + history.lastSaveTime = currentTime; + } + } + else { + history = { + entries: [], + snapshots: [takeSnapshot(previousText, currentTime - 1)], + shares: [], + lastSaveTime: currentTime + }; + } + + // First save any new project shares + for (const share of shares) { + if (!history.shares.some(s => s.id === share.id)) { + history.shares.push({ + id: share.id, + timestamp: currentTime, + }); + } + } + + // If no source changed, we can bail at this point + if (scriptEquals(previousText, toWrite)) { + toWrite[pxt.HISTORY_FILE] = JSON.stringify(history); + return; + } + + // Next, update the diff entries. We always update this, but may + // combine it with the previous diff if it's been less than the + // interval time + let shouldCombine = false; + if (history.entries.length === 1) { + const topTime = history.entries[history.entries.length - 1].timestamp; + if (currentTime - topTime < diffInterval()) { + shouldCombine = true; + } + } + else if (history.entries.length > 1) { + const topTime = history.entries[history.entries.length - 1].timestamp; + const prevTime = history.entries[history.entries.length - 2].timestamp; + + if (currentTime - topTime < diffInterval() && topTime - prevTime < diffInterval()) { + shouldCombine = true; + } + } + + if (shouldCombine) { + // Roll back the last diff and create a new one + const prevEntry = history.entries.pop(); + const prevText = applyDiff(previousText, prevEntry, patch); + + const diffed = diffScriptText(prevText, toWrite, prevEntry.timestamp, diff); + if (diffed) { + history.entries.push(diffed); + } + } + else { + const diffed = diffScriptText(previousText, toWrite, history.lastSaveTime, diff); + + if (diffed) { + history.entries.push(diffed); + } + } + + history.lastSaveTime = currentTime; + + // Finally, update the snapshots. These are failsafes in case something + // goes wrong with the diff history. We keep one snapshot per interval for + // the past 24 hours and one snapshot per day prior to that + if (history.snapshots.length == 0) { + history.snapshots.push(takeSnapshot(previousText, currentTime - 1)); + } + else if (currentTime - history.snapshots[history.snapshots.length - 1].timestamp >= snapshotInterval()) { + history.snapshots.push(takeSnapshot(previousText, currentTime)); + + const trimmed: SnapshotEntry[] = []; + let currentDay = Math.floor(currentTime / ONE_DAY) * ONE_DAY; + + for (let i = 0; i < history.snapshots.length; i++) { + const current = history.snapshots[history.snapshots.length - 1 - i]; + if (currentTime - current.timestamp < ONE_DAY || i === history.snapshots.length - 1) { + trimmed.unshift(current); + } + else if (current.timestamp < currentDay) { + trimmed.unshift(current); + currentDay = Math.floor(current.timestamp / ONE_DAY) * ONE_DAY; + } + } + + history.snapshots = trimmed; + } + + toWrite[pxt.HISTORY_FILE] = JSON.stringify(history); + } + + export function pushSnapshotOnHistory(text: ScriptText, currentTime: number) { + let history: HistoryFile; + + if (text[pxt.HISTORY_FILE]) { + history = parseHistoryFile(text[pxt.HISTORY_FILE]); + } + else { + history = { + entries: [], + snapshots: [], + shares: [], + lastSaveTime: currentTime + }; + } + + history.snapshots.push(takeSnapshot(text, currentTime)); + + text[pxt.HISTORY_FILE] = JSON.stringify(history); + } + + export function updateShareHistory(text: ScriptText, currentTime: number, shares: pxt.workspace.PublishVersion[]) { + let history: HistoryFile; + + if (text[pxt.HISTORY_FILE]) { + history = parseHistoryFile(text[pxt.HISTORY_FILE]); + } + else { + history = { + entries: [], + snapshots: [], + shares: [], + lastSaveTime: currentTime + }; + } + + for (const share of shares) { + if (!history.shares.some(s => s.id === share.id)) { + history.shares.push({ + id: share.id, + timestamp: currentTime, + }); + } + } + + text[pxt.HISTORY_FILE] = JSON.stringify(history); + } + + export function getTextAtTime(text: ScriptText, history: HistoryFile, time: number, patch: (p: unknown, text: string) => string) { + let currentText = { ...text }; + + for (let i = 0; i < history.entries.length; i++) { + const index = history.entries.length - 1 - i; + const entry = history.entries[index]; + currentText = applyDiff(currentText, entry, patch); + if (entry.timestamp === time) { + const version = index > 0 ? history.entries[index - 1].editorVersion : entry.editorVersion; + return patchConfigEditorVersion(currentText, version) + } + } + + return { files: currentText, editorVersion: pxt.appTarget.versions.target }; + } + + export function patchConfigEditorVersion(text: ScriptText, editorVersion: string) { + text = { ...text }; + + // Attempt to update the version in pxt.json + try { + const config = JSON.parse(text[pxt.CONFIG_NAME]) as pxt.PackageConfig; + if (config.targetVersions) { + config.targetVersions.target = editorVersion; + } + text[pxt.CONFIG_NAME] = JSON.stringify(config, null, 4); + } + catch (e) { + } + + return { + files: text, + editorVersion + }; + } + + function takeSnapshot(text: ScriptText, time: number) { + return { + timestamp: time, + editorVersion: pxt.appTarget.versions.target, + text: createSnapshot(text) + }; + } + + function scriptEquals(a: ScriptText, b: ScriptText) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (bKeys.indexOf(key) === -1) return false; + if (a[key] !== b[key]) return false; + } + + return true; + } + + function diffInterval() { + if (pxt.appTarget?.appTheme?.timeMachineDiffInterval != undefined) { + return pxt.appTarget.appTheme.timeMachineDiffInterval; + } + + return DEFAULT_DIFF_HISTORY_INTERVAL; + } + + function snapshotInterval() { + if (pxt.appTarget?.appTheme?.timeMachineSnapshotInterval != undefined) { + return pxt.appTarget.appTheme.timeMachineSnapshotInterval; + } + + return DEFAULT_SNAPSHOT_HISTORY_INTERVAL; + } +} \ No newline at end of file diff --git a/pxtlib/main.ts b/pxtlib/main.ts index 1aa2a9464be4..cfd36d9562bc 100644 --- a/pxtlib/main.ts +++ b/pxtlib/main.ts @@ -509,6 +509,7 @@ namespace pxt { export const TUTORIAL_CUSTOM_TS = "tutorial.custom.ts"; export const BREAKPOINT_TABLET = 991; // TODO (shakao) revisit when tutorial stuff is more settled export const PALETTES_FILE = "_palettes.json"; + export const HISTORY_FILE = "_history"; export function outputName(trg: pxtc.CompileTarget = null) { if (!trg) trg = appTarget.compile diff --git a/pxtlib/shell.ts b/pxtlib/shell.ts index be3cf77ef62d..8e77f97e67f7 100644 --- a/pxtlib/shell.ts +++ b/pxtlib/shell.ts @@ -56,6 +56,10 @@ namespace pxt.shell { return layoutType == EditorLayoutType.Sandbox; } + export function isTimeMachineEmbed() { + return /[?&]timeMachine=1/i.test(window.location.href); + } + export function isReadOnly() { return (!pxt.BrowserUtils.hasWindow() || (isSandboxMode() && !/[?&]edit=1/i.test(window.location.href)) || diff --git a/react-common/components/controls/Tree.tsx b/react-common/components/controls/Tree.tsx new file mode 100644 index 000000000000..9b39a5b28f0e --- /dev/null +++ b/react-common/components/controls/Tree.tsx @@ -0,0 +1,155 @@ +import * as React from "react"; + +import { ContainerProps, classList } from "../util"; +import { FocusList } from "./FocusList"; + +export interface TreeProps extends ContainerProps { + role?: "tree" | "group"; +} + +export interface TreeItemProps extends ContainerProps { + role?: "treeitem"; + onClick?: () => void; + initiallyExpanded?: boolean; + title?: string; +} + +export interface TreeItemBodyProps extends ContainerProps { +} + +export const Tree = (props: TreeProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + } = props; + + if (!role || role === "tree") { + return ( + + {children} + + ) + } + + return ( +
+ {children} +
+ ) +}; + +export const TreeItem = (props: TreeItemProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + initiallyExpanded, + onClick, + title + } = props; + + const [expanded, setExpanded] = React.useState(initiallyExpanded); + const mappedChildren = React.Children.toArray(children); + const hasSubtree = mappedChildren.length > 1; + + const subtreeContainer = React.useRef() + + React.useEffect(() => { + if (!hasSubtree) return; + + if (expanded) { + const focusable = subtreeContainer.current.querySelectorAll(`[tabindex]:not([tabindex="-1"]),[data-isfocusable]`); + focusable.forEach(f => f.setAttribute("data-isfocusable", "true")); + } + else { + const focusable = subtreeContainer.current.querySelectorAll(`[tabindex]:not([tabindex="-1"]),[data-isfocusable]`); + focusable.forEach(f => f.setAttribute("data-isfocusable", "false")); + } + }, [expanded, hasSubtree]); + + const onTreeItemClick = React.useCallback(() => { + if (hasSubtree) { + setExpanded(!expanded); + } + if (onClick) { + onClick(); + } + }, [hasSubtree, expanded]) + + return ( +
+
+ {hasSubtree && + + } + {mappedChildren[0]} +
+
+ {hasSubtree ? mappedChildren[1] : undefined} +
+
+ ); +} + +export const TreeItemBody = (props: TreeItemBodyProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + } = props; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/react-common/styles/controls/Tree.less b/react-common/styles/controls/Tree.less new file mode 100644 index 000000000000..9848836ad26a --- /dev/null +++ b/react-common/styles/controls/Tree.less @@ -0,0 +1,33 @@ +.common-tree { + display: flex; + flex-direction: column; +} + +.common-tree.subtree .common-treeitem { + // The width of the chevron is 1.81rem + 0.25rem margin + padding-left: 2.06rem; +} + +.common-treeitem { + display: flex; + flex-direction: row; + align-items: center; + + height: 3rem; + cursor: pointer; + + background-color: @treeitemBackgroundColor; + + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.common-treeitem:hover { + filter: grayscale(.15) brightness(.85) contrast(1.3); +} + +.common-treeitem-container { + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/react-common/styles/react-common-variables.less b/react-common/styles/react-common-variables.less index 32d0aacf4525..32eead9827fa 100644 --- a/react-common/styles/react-common-variables.less +++ b/react-common/styles/react-common-variables.less @@ -132,4 +132,10 @@ /**************************************************** * Extension Card * ****************************************************/ - @loadingAnimation: linear-gradient(-45deg, #ffffff46, #f3f3f350, #dee2ff6e, #f1f1f149); \ No newline at end of file + @loadingAnimation: linear-gradient(-45deg, #ffffff46, #f3f3f350, #dee2ff6e, #f1f1f149); + + /**************************************************** + * Tree View * + ****************************************************/ + + @treeitemBackgroundColor: #ffffff; \ No newline at end of file diff --git a/react-common/styles/react-common.less b/react-common/styles/react-common.less index 334eb17a2e26..925b1d0dc2f3 100644 --- a/react-common/styles/react-common.less +++ b/react-common/styles/react-common.less @@ -17,6 +17,7 @@ @import "controls/RadioButtonGroup.less"; @import "controls/Spinner.less"; @import "controls/Textarea.less"; +@import "controls/Tree.less"; @import "./react-common-variables.less"; @import "fontawesome-free/less/solid.less"; diff --git a/theme/pxt.less b/theme/pxt.less index be0d4bb0c03f..78ba405fde51 100644 --- a/theme/pxt.less +++ b/theme/pxt.less @@ -36,6 +36,7 @@ @import 'extensionErrors'; @import 'webusb'; +@import 'timeMachine'; @import 'image-editor/imageEditor'; @import 'music-editor/MusicEditor'; diff --git a/theme/timeMachine.less b/theme/timeMachine.less new file mode 100644 index 000000000000..bb636d40037e --- /dev/null +++ b/theme/timeMachine.less @@ -0,0 +1,154 @@ +.time-machine { + display: flex; + flex-direction: column; + top: 0; + left: 0; + bottom: 0; + right: 0; + position: absolute; + z-index: @modalFullscreenZIndex + 1; + background-color: white; +} + +.time-machine-header { + height: @mainMenuHeight; + width: 100%; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + background-color: @teal; + flex-shrink: 0; + + .common-button { + min-width: 9rem; + } + + .common-button.menu-button { + .common-button-label { + font-size: 16px; + } + + i.fas { + font-size: 1em; + } + } +} + +.time-machine-actions-container { + display: flex; + align-items: center; + justify-content: center; +} + +.time-machine-actions { + display: flex; + flex-direction: row; + align-items: center; + + .time-machine-label { + color: white; + margin-right: 1rem; + text-wrap: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } +} + +.time-machine-content { + display: flex; + flex-direction: row; + flex-grow: 1; +} + +.time-machine-preview { + position: relative; + flex-grow: 1; + overflow: hidden; + + & > iframe { + position: relative; + height: 100%; + width: 100%; + transition: opacity 0.25s; + z-index: 1; + } + + // Loading div that appears while projects are importing + & > div { + position: absolute; + top: 0; + background: @primaryColor; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + + .common-spinner { + width: 5rem; + height: 5rem; + } + } +} + +.time-machine-timeline { + width: 20rem; + height: 100%; + position: relative; + + border-left: solid 2px darken(desaturate(@editorToolsBackground, 60%), 10%); + + display: flex; + flex-direction: column; + overflow: hidden; + + .time-machine-timeline-slider { + flex-grow: 1; + } + + .common-treeitem { + border-left: solid 2px @treeitemBackgroundColor; + user-select: none; + } + + .common-treeitem.selected { + border-left: solid 2px @primaryColor; + filter: grayscale(.15) brightness(.85) contrast(1.3); + } + + .time-machine-tree-container { + flex-grow: 1; + overflow-y: auto; + padding-right: 1rem; + padding-left: 1rem; + padding-bottom: 1rem; + } + + h3 { + padding-right: 1rem; + padding-left: 1rem; + padding-top: 1rem; + flex-shrink: 0; + } +} + +@media @tabletAndBelow { + .time-machine-header { + display: flex; + flex-direction: row; + + .time-machine-back-button { + flex-grow: 1; + flex-shrink: 0; + } + } + + .time-machine-timeline { + width: 15rem; + } +} + +@media @mobileAndBelow { + .time-machine-header { + height: @mobileMenuHeight; + } +} \ No newline at end of file diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index fcaad5a029fe..4a40d67cc276 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -4208,6 +4208,22 @@ export class ProjectView dialogs.showAboutDialogAsync(this); } + async showTurnBackTimeDialogAsync() { + let simWasRunning = this.isSimulatorRunning(); + if (simWasRunning) { + this.stopSimulator(); + } + + await dialogs.showTurnBackTimeDialogAsync(this.state.header, () => { + this.reloadHeaderAsync(); + simWasRunning = false; + }); + + if (simWasRunning) { + this.startSimulator(); + } + } + showLoginDialog(continuationHash?: string) { this.loginDialog.show(continuationHash); } diff --git a/webapp/src/container.tsx b/webapp/src/container.tsx index c217c1a60bb1..7787e3b9a801 100644 --- a/webapp/src/container.tsx +++ b/webapp/src/container.tsx @@ -135,6 +135,7 @@ export class SettingsMenu extends data.Component : undefined} {showSave ? : undefined} {!isController ? : undefined} + {targetTheme.timeMachine ? : undefined} {showSimCollapse ? : undefined}
{targetTheme.selectLanguage ? : undefined} diff --git a/webapp/src/dialogs.tsx b/webapp/src/dialogs.tsx index 053cfbc2a7e8..288e3e80b7ac 100644 --- a/webapp/src/dialogs.tsx +++ b/webapp/src/dialogs.tsx @@ -6,10 +6,13 @@ import * as core from "./core"; import * as coretsx from "./coretsx"; import * as pkg from "./package"; import * as cloudsync from "./cloudsync"; +import * as workspace from "./workspace"; import Cloud = pxt.Cloud; import Util = pxt.Util; import { fireClickOnEnter } from "./util"; +import { invalidate } from "./data"; +import { TimeMachine } from "./timeMachine"; let dontShowDownloadFlag = false; @@ -852,4 +855,92 @@ export function clearDontShowDownloadDialogFlag() { export function isDontShowDownloadDialogFlagSet() { return dontShowDownloadFlag; +} + +export async function showTurnBackTimeDialogAsync(header: pxt.workspace.Header, reloadHeader: () => void) { + const text = await workspace.getTextAsync(header.id, true); + let history: pxt.editor.HistoryFile; + + if (text?.[pxt.HISTORY_FILE]) { + history = pxt.editor.parseHistoryFile(text[pxt.HISTORY_FILE]); + } + else { + history = { + entries: [], + snapshots: [], + shares: [], + lastSaveTime: Date.now() + }; + } + + const loadProject = async (text: pxt.workspace.ScriptText, editorVersion: string) => { + core.hideDialog(); + + header.targetVersion = editorVersion; + + await workspace.saveSnapshotAsync(header.id); + await workspace.saveAsync(header, text); + reloadHeader(); + } + + const copyProject = async (text: pxt.workspace.ScriptText, editorVersion: string, timestamp?: number) => { + core.hideDialog(); + + let newHistory = history + + if (timestamp != undefined) { + newHistory = { + entries: history.entries.slice(0, history.entries.findIndex(e => e.timestamp === timestamp)), + snapshots: history.snapshots.filter(s => s.timestamp <= timestamp), + shares: history.shares.filter(s => s.timestamp <= timestamp), + lastSaveTime: timestamp + } + } + + text[pxt.HISTORY_FILE] = JSON.stringify(newHistory); + + const date = timestamp ? new Date(timestamp) : new Date(); + + const dateString = date.toLocaleDateString( + pxt.U.userLanguage(), + { + year: "numeric", + month: "numeric", + day: "numeric" + } + ); + + const timeString = date.toLocaleTimeString( + pxt.U.userLanguage(), + { + timeStyle: "short" + } as any + ); + + const newHeader: pxt.workspace.Header = { + ...header, + targetVersion: editorVersion + } + + await workspace.duplicateAsync(newHeader, `${newHeader.name} ${dateString} ${timeString}`, text); + + invalidate("headers:"); + + core.infoNotification(lf("Project copy saved to My Projects")) + } + + await core.dialogAsync({ + header: lf("Turn back time"), + className: "time-machine-dialog", + size: "fullscreen", + hasCloseIcon: true, + jsx: ( + + ) + }) } \ No newline at end of file diff --git a/webapp/src/editortoolbar.tsx b/webapp/src/editortoolbar.tsx index 347a0eec9910..bab3be9e9ec1 100644 --- a/webapp/src/editortoolbar.tsx +++ b/webapp/src/editortoolbar.tsx @@ -387,7 +387,7 @@ export class EditorToolbar extends data.Component {showUndoRedo &&
{this.getUndoRedo(computer)}
} {showZoomControls &&
{this.getZoomControl(computer)}
} - {targetTheme.bigRunButton && + {targetTheme.bigRunButton && !pxt.shell.isTimeMachineEmbed() &&
{ @@ -87,6 +87,8 @@ export class HeaderBar extends data.Component { return "home"; } else if (pxt.shell.isSandboxMode()) { return "sandbox"; + } else if (pxt.shell.isTimeMachineEmbed()) { + return "time-machine"; } else if (debugging) { return "debugging"; } else if (!pxt.BrowserUtils.useOldTutorialLayout() && !!tutorialOptions?.tutorial) { @@ -99,6 +101,9 @@ export class HeaderBar extends data.Component { } getOrganizationLogo(targetTheme: pxt.AppTheme, highContrast?: boolean, view?: string) { + if (view === "time-machine") { + return <>; + } return
{targetTheme.organizationWideLogo || targetTheme.organizationLogo ? {lf("{0} @@ -108,6 +113,9 @@ export class HeaderBar extends data.Component { } getTargetLogo(targetTheme: pxt.AppTheme, highContrast?: boolean, view?: string) { + if (view === "time-machine") { + return <>; + } // TODO: "sandbox" view components are temporary share page layout return
{targetTheme.useTextLogo @@ -142,6 +150,7 @@ export class HeaderBar extends data.Component { return case "sandbox": case "editor": + case "time-machine": if (hideToggle) { // Label for single language switch (languageRestriction) { diff --git a/webapp/src/timeMachine.tsx b/webapp/src/timeMachine.tsx new file mode 100644 index 000000000000..ea92ab73f4b8 --- /dev/null +++ b/webapp/src/timeMachine.tsx @@ -0,0 +1,528 @@ +import * as React from "react"; +import * as workspace from "./workspace"; +import { Tree, TreeItem, TreeItemBody } from "../../react-common/components/controls/Tree"; +import { createPortal } from "react-dom"; +import { Button } from "../../react-common/components/controls/Button"; +import { hideDialog, warningNotification } from "./core"; +import { FocusTrap } from "../../react-common/components/controls/FocusTrap"; +import { classList } from "../../react-common/components/util"; + +import ScriptText = pxt.workspace.ScriptText; +import HistoryFile = pxt.editor.HistoryFile; +import applySnapshot = pxt.editor.applySnapshot; +import patchConfigEditorVersion = pxt.editor.patchConfigEditorVersion; + + +interface TimeMachineProps { + onProjectLoad: (text: ScriptText, editorVersion: string, timestamp?: number) => void; + onProjectCopy: (text: ScriptText, editorVersion: string, timestamp?: number) => void; + text: ScriptText; + history: HistoryFile; +} + +interface PendingMessage { + original: pxt.editor.EditorMessageRequest; + handler: (response: any) => void; +} + +interface TimelineEntry { + label: string; + entries: TimeEntry[]; +} + +interface TimeEntry { + label: string; + timestamp: number; + kind: "snapshot" | "diff" | "share"; +} + +interface Project { + files: ScriptText; + editorVersion: string; +} + +type FrameState = "loading" | "loaded" | "loading-project" | "loaded-project"; + +export const TimeMachine = (props: TimeMachineProps) => { + const { text, history, onProjectLoad, onProjectCopy } = props; + + // undefined here is a standin for "now" + const [selected, setSelected] = React.useState(undefined); + const [loading, setLoading] = React.useState("loading"); + const [entries, setEntries] = React.useState(getTimelineEntries(history)); + + const iframeRef = React.useRef(); + const fetchingScriptLock = React.useRef(false); + + const importProject = React.useRef<(text: ScriptText) => Promise>(); + + React.useEffect(() => { + const iframe = iframeRef.current; + let nextId = 1; + let workspaceReady: boolean; + const messageQueue: pxt.editor.EditorMessageRequest[] = []; + const pendingMessages: {[index: string]: PendingMessage} = {}; + + const postMessageCore = (message: pxt.editor.EditorMessageRequest | pxt.editor.EditorMessageResponse) => { + iframe.contentWindow!.postMessage(message, "*"); + }; + + const sendMessageAsync = (message?: pxt.editor.EditorMessageRequest) => { + return new Promise(resolve => { + const sendMessageCore = (message: any) => { + message.response = true; + message.id = "time_machine_" + nextId++; + pendingMessages[message.id] = { + original: message, + handler: resolve + }; + postMessageCore(message); + } + + if (!workspaceReady) { + if (message) messageQueue.push(message); + } + else { + while (messageQueue.length) { + sendMessageCore(messageQueue.shift()); + } + if (message) sendMessageCore(message); + } + }); + }; + + const onMessageReceived = (event: MessageEvent) => { + const data = event.data as pxt.editor.EditorMessageRequest; + + if (data.type === "pxteditor" && data.id && pendingMessages[data.id]) { + const pending = pendingMessages[data.id]; + pending.handler(data); + delete pendingMessages[data.id]; + return; + } + + switch (data.action) { + case "newproject": + if (!workspaceReady) { + workspaceReady = true; + setLoading("loaded") + sendMessageAsync(); + } + break; + case "workspacesync": + postMessageCore({ + type: "pxthost", + id: data.id, + success: true, + projects: [] + } as pxt.editor.EditorWorkspaceSyncResponse); + break; + } + }; + + let pendingLoad: ScriptText; + let currentlyLoading = false; + + const loadProject = async (project: ScriptText) => { + if (currentlyLoading) { + pendingLoad = project; + return; + } + + currentlyLoading = true; + setLoading("loading-project"); + await sendMessageAsync({ + type: "pxteditor", + action: "importproject", + project: { + text: project + } + } as pxt.editor.EditorMessageImportProjectRequest); + + currentlyLoading = false; + + if (pendingLoad) { + loadProject(pendingLoad); + pendingLoad = undefined; + return; + } + + setLoading("loaded-project"); + + }; + + importProject.current = loadProject; + + window.addEventListener("message", onMessageReceived); + return () => { + window.removeEventListener("message", onMessageReceived); + }; + }, []); + + React.useEffect(() => { + if (loading === "loaded" && importProject.current) { + importProject.current(text) + } + }, [loading, importProject.current, text]); + + React.useEffect(() => { + setEntries(getTimelineEntries(history)); + }, [history]); + + const onTimeSelected = async (entry: TimeEntry) => { + if (!importProject.current || fetchingScriptLock.current) return; + + if (entry.timestamp === -1) { + entry = undefined; + } + + setSelected(entry); + + if (entry === undefined) { + importProject.current(text); + return; + } + + fetchingScriptLock.current = true; + + try { + const { files } = await getTextAtTimestampAsync(text, history, entry); + importProject.current(files) + } + catch (e) { + if (entry.kind === "share") { + warningNotification(lf("Unable to fetch shared project. Are you offline?")); + } + else { + warningNotification(lf("Unable to restore project version. Try selecting a different version.")) + } + + setSelected(undefined); + importProject.current(text); + } + finally { + fetchingScriptLock.current = false; + } + }; + + const onGoPressed = React.useCallback(async () => { + if (selected === undefined) { + hideDialog(); + } + else { + const { files, editorVersion } = await getTextAtTimestampAsync(text, history, selected); + onProjectLoad(files, editorVersion, selected.timestamp); + } + }, [selected, onProjectLoad]); + + const onSaveCopySelect = React.useCallback(async () => { + const { files, editorVersion } = await getTextAtTimestampAsync(text, history, selected); + onProjectCopy(files, editorVersion, selected?.timestamp) + }, [selected, onProjectCopy]); + + let queryParams = [ + "timeMachine", + "controller", + "skillsMap", + "noproject", + "nocookiebanner" + ]; + + if (pxt.appTarget?.appTheme.timeMachineQueryParams) { + queryParams = queryParams.concat(pxt.appTarget.appTheme.timeMachineQueryParams); + } + + const argString = queryParams.map(p => p.indexOf("=") === -1 ? `${p}=1` : p).join("&"); + + const url = `${window.location.origin + window.location.pathname}?${argString}`; + + return createPortal( + +
+
+
+
+
+
+ {selected ? formatFullDate(selected.timestamp) : lf("Now")} +
+
+
+
+
+
+
+
+
+ {/* eslint-disable @microsoft/sdl/react-iframe-missing-sandbox */} +