480 строки
18 KiB
TypeScript
480 строки
18 KiB
TypeScript
///<reference path='refs.ts'/>
|
|
|
|
module TDev {
|
|
export interface ExternalEditor {
|
|
// 3 ields are for our UI
|
|
company: string;
|
|
name: string;
|
|
description: string;
|
|
// Unique
|
|
id: string;
|
|
// The domain root for the external editor.
|
|
origin: string;
|
|
// The path from the domain root to the editor main document.
|
|
path: string;
|
|
}
|
|
|
|
var externalEditorsCache: ExternalEditor[] = null;
|
|
|
|
export function getExternalEditors(): ExternalEditor[] {
|
|
if (!externalEditorsCache) {
|
|
// Detect at run-time where we're running from!
|
|
var url = Ticker.mainJsName.replace(/main.js$/, "");
|
|
var match = url.match(/(https?:\/\/[^\/]+)(.*)/);
|
|
var origin = match[1];
|
|
var path = match[2];
|
|
externalEditorsCache = [ /* {
|
|
name: "C++ Editor",
|
|
description: "Directly write C++ code using Ace (OUTDATED)",
|
|
id: "ace",
|
|
origin: origin,
|
|
path: path + "ace/editor.html"
|
|
icon: ""
|
|
}, */ {
|
|
company: "Microsoft",
|
|
name: "Block Editor",
|
|
description: "Drag and drop",
|
|
id: "blockly",
|
|
origin: origin,
|
|
path: path + "blockly/editor.html"
|
|
}];
|
|
}
|
|
return externalEditorsCache;
|
|
}
|
|
|
|
// Assumes that [id] is a valid external editor id.
|
|
export function editorById(id: string): ExternalEditor {
|
|
var r = getExternalEditors().filter(x => x.id == id);
|
|
return r[0];
|
|
}
|
|
|
|
export module External {
|
|
export var TheChannel: Channel = null;
|
|
// We need that to setup the simulator.
|
|
export var microbitScriptId = "lwhfye";
|
|
|
|
import J = AST.Json;
|
|
|
|
export function makeOutMbedErrorMsg(json: any) {
|
|
var errorMsg = "unknown error";
|
|
// This JSON format is *very* unstructured...
|
|
if (json.mbedresponse) {
|
|
var messages = json.messages.filter(m =>
|
|
m.severity == "error" || m.type == "Error"
|
|
);
|
|
errorMsg = messages.map(m => m.message + "\n" + m.text).join("\n");
|
|
}
|
|
return errorMsg;
|
|
}
|
|
|
|
export function pullLatestLibraryVersion(pubId: string): Promise { // of string
|
|
return Browser.TheApiCacheMgr.getAsync(pubId, Cloud.isOffline())
|
|
.then((script: JsonScript) => {
|
|
if (script) {
|
|
return script.updateid;
|
|
} else {
|
|
// in case the one above fails, we also try the stale one from the
|
|
// cache
|
|
return Browser.TheApiCacheMgr.getAsync(pubId, true)
|
|
.then(() => Promise.delay(2000))
|
|
.then((script: JsonScript) => {
|
|
if (script)
|
|
return script.updateid;
|
|
else
|
|
return pubId;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// This function modifies its argument by adding an extra [J.JLibrary]
|
|
// to its [decls] field that references the device's library.
|
|
function addLibrary(name: string, pubId: string, app: J.JApp) {
|
|
var lib = <AST.LibraryRef> AST.Parser.parseDecl(
|
|
'meta import ' + AST.Lexer.quoteId(name) + ' {' +
|
|
' pub "' + pubId + '"'+
|
|
'}'
|
|
);
|
|
var jLib = <J.JLibrary> J.addIdsAndDumpNode(lib);
|
|
jLib.id = name;
|
|
app.decls.push(jLib);
|
|
}
|
|
|
|
function addLibraries(app: J.JApp, libs: { [i: string]: string }): Promise {
|
|
var keys = Object.keys(libs);
|
|
var latestVersions = keys.map((name: string) => {
|
|
return pullLatestLibraryVersion(libs[name]);
|
|
});
|
|
return Promise.join(latestVersions).then((latestVersions: string[]) => {
|
|
latestVersions.map((pubId: string, i: number) => {
|
|
addLibrary(keys[i], pubId, app);
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseScript(text: string): Promise { // of AST.App
|
|
return AST.loadScriptAsync((id: string) => {
|
|
if (id == "")
|
|
return Promise.as(text);
|
|
else
|
|
return World.getAnyScriptAsync(id);
|
|
}, "").then((resp: AST.LoadScriptResult) => {
|
|
// Otherwise, eventually, this will result in our script being
|
|
// saved in the TouchDevelop format...
|
|
var s = Script;
|
|
Script = null;
|
|
// The function writes its result in a global
|
|
return Promise.as(s);
|
|
});
|
|
}
|
|
|
|
// Takes a [JApp] and runs its through various hoops to make sure
|
|
// everything is type-checked and resolved properly.
|
|
function roundtrip(a: J.JApp, libs: { [i: string]: string }): Promise { // of J.JApp
|
|
return addLibraries(a, libs).then(() => {
|
|
var text = J.serialize(a);
|
|
return parseScript(text).then((a: AST.App) => {
|
|
if (AST.TypeChecker.tcApp(a) > 0) {
|
|
throw new Error("We received a script with errors and cannot compile it. " +
|
|
"Try converting then fixing the errors manually.");
|
|
}
|
|
return Promise.as(J.dump(a)); }
|
|
);
|
|
});
|
|
}
|
|
|
|
class ExternalHost extends EditorHost {
|
|
public updateButtonsVisibility() {
|
|
}
|
|
|
|
public showWall() {
|
|
super.showWall();
|
|
document.getElementById("wallOverlay").style.display = "none";
|
|
var w = <HTMLElement> document.querySelector(".wallFullScreenContainer");
|
|
w.style.height = "100%";
|
|
w.style.display = "";
|
|
var logo = div("wallFullScreenLogo", HTML.mkImg(Cloud.artUrl("hrztfaux")));
|
|
|
|
elt("externalEditorSide").setChildren([w, logo]);
|
|
}
|
|
|
|
public fullWallWidth() {
|
|
return (<HTMLElement> document.querySelector(".wallFullScreenContainer")).offsetWidth;
|
|
}
|
|
|
|
public fullWallHeight() {
|
|
return (<HTMLElement> document.querySelector(".wallFullScreenContainer")).offsetHeight;
|
|
}
|
|
}
|
|
|
|
function typeCheckAndRun(text: string, mainName = "main") {
|
|
parseScript(text).then((a: AST.App) => {
|
|
J.setStableId(a);
|
|
// The call to [tcApp] also has the desired side-effect of resolving
|
|
// names.
|
|
if (AST.TypeChecker.tcApp(a) > 0) {
|
|
ModalDialog.info(lf("Type-checking error"),
|
|
lf("We received a script with errors and cannot run it. Try converting then fixing the errors manually."));
|
|
}
|
|
// The compiler expects this global to be set. However, this is
|
|
// dangerous, since the sync code might want to write the *translated*
|
|
// script text to storage for us. Fortunately, the compiler is
|
|
// synchronous, so it shouldn't happen.
|
|
Script = a;
|
|
var compiledScript = AST.Compiler.getCompiledScript(a, {});
|
|
Script = null;
|
|
var rt = TheEditor.currentRt;
|
|
if (!rt)
|
|
rt = TheEditor.currentRt = new Runtime();
|
|
rt.initFrom(compiledScript);
|
|
if (!(rt.host instanceof ExternalHost))
|
|
rt.setHost(new ExternalHost());
|
|
rt.initPageStack();
|
|
(<EditorHost> rt.host).showWall();
|
|
|
|
var main = compiledScript.actionsByName[mainName];
|
|
rt.stopAsync().done(() => {
|
|
rt.run(main, []);
|
|
// So that key events, such as escape, are not caught by Blockly.
|
|
if (document.activeElement instanceof HTMLElement)
|
|
(<HTMLElement> document.activeElement).blur();
|
|
});
|
|
});
|
|
}
|
|
|
|
export class Channel {
|
|
constructor(
|
|
private editor: ExternalEditor,
|
|
private iframe: HTMLIFrameElement,
|
|
public guid: string) {
|
|
}
|
|
|
|
public post(message: Message) {
|
|
// The notification that the script has been successfully saved
|
|
// to cloud may take a while to arrive; the user may have
|
|
// discarded the editor in the meanwhile.
|
|
if (!this.iframe || !this.iframe.contentWindow)
|
|
return;
|
|
this.iframe.contentWindow.postMessage(message, this.editor.origin);
|
|
}
|
|
|
|
public receive(event) {
|
|
if (event.origin != this.editor.origin)
|
|
return;
|
|
|
|
switch ((<Message> event.data).type) {
|
|
case MessageType.Save: {
|
|
var message = <Message_Save> event.data;
|
|
World.getInstalledHeaderAsync(this.guid).then((header: Cloud.Header) => {
|
|
var scriptText = message.script.scriptText;
|
|
var editorState = JSON.stringify(message.script.editorState);
|
|
header.scriptVersion.baseSnapshot = message.script.baseSnapshot;
|
|
// This may be over-optimistic (the external editor may serve on
|
|
// top of a version that's already outdated), but the sync code
|
|
// will re-flag the pending merge later on.
|
|
header.pendingMerge = null;
|
|
|
|
var metadata = message.script.metadata;
|
|
Object.keys(metadata).forEach(k => {
|
|
var v = metadata[k];
|
|
if (k == "name")
|
|
v = v || "unnamed";
|
|
header.meta[k] = v;
|
|
});
|
|
// [name] deserves a special treatment because it
|
|
// appears both on the header and in the metadata.
|
|
header.name = metadata.name;
|
|
|
|
// Writes into local storage. Also clears the scriptVersionInCloud
|
|
// field (fifth argument).
|
|
World.updateInstalledScriptAsync(header, scriptText, editorState, false, "").then(() => {
|
|
console.log("[external] script saved properly");
|
|
this.post(<Message_SaveAck>{
|
|
type: MessageType.SaveAck,
|
|
where: SaveLocation.Local,
|
|
status: Status.Ok,
|
|
});
|
|
});
|
|
|
|
// Schedules a cloud sync; set the right state so
|
|
// that [scheduleSaveToCloudAsync] writes the
|
|
// baseSnapshot where we can read it back.
|
|
localStorage["editorScriptToSaveDirty"] = this.guid;
|
|
TheEditor.scheduleSaveToCloudAsync().then((response: Cloud.PostUserInstalledResponse) => {
|
|
// Reading the code of [scheduleSaveToCloudAsync], an early falsy return
|
|
// means that a sync is already scheduled.
|
|
if (!response)
|
|
return;
|
|
|
|
if (response.numErrors) {
|
|
this.post(<Message_SaveAck>{
|
|
type: MessageType.SaveAck,
|
|
where: SaveLocation.Cloud,
|
|
status: Status.Error,
|
|
error: (<any> response.headers[0]).error,
|
|
});
|
|
// Couldn't sync! Chances are high that we need to do a merge.
|
|
// Because [syncAsync] is not called on a regular basis when an
|
|
// external editor is open, we need to trigger the download of
|
|
// the newer version from the cloud *now*.
|
|
World.syncAsync().then(() => {
|
|
World.getInstalledScriptVersionInCloud(this.guid).then((json: string) => {
|
|
var m: PendingMerge = JSON.parse(json || "{}");
|
|
if ("theirs" in m) {
|
|
this.post(<Message_Merge>{
|
|
type: MessageType.Merge,
|
|
merge: m
|
|
});
|
|
} else {
|
|
console.log("[external] cloud error was not because of a due merge");
|
|
}
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
var newCloudSnapshot = response.headers[0].scriptVersion.baseSnapshot;
|
|
console.log("[external] accepted, new cloud version ", newCloudSnapshot);
|
|
// Note: currently, [response.retry] is always false. The reason is,
|
|
// every call of us to [updateInstalledScriptAsync] is immediately
|
|
// followed by a call to [scheduleSaveToCloudAsync]. Furthermore,
|
|
// the latter function has its own tracking mechanism where updates
|
|
// are delayed, and it sort-of knows if it missed an update and
|
|
// should retry. In that case, it doesn't return until the second
|
|
// update has been processed, and we only get called after the cloud
|
|
// is, indeed, in sync. (If we were to offer external editors a way
|
|
// to decide whether to save to cloud or not, then this would no
|
|
// longer be true.)
|
|
this.post(<Message_SaveAck>{
|
|
type: MessageType.SaveAck,
|
|
where: SaveLocation.Cloud,
|
|
status: Status.Ok,
|
|
newBaseSnapshot: newCloudSnapshot,
|
|
cloudIsInSync: !response.retry,
|
|
});
|
|
});
|
|
});
|
|
break;
|
|
}
|
|
|
|
case MessageType.Quit:
|
|
TheEditor.goToHub("list:installed-scripts:script:"+this.guid+":overview");
|
|
TheChannel = null;
|
|
break;
|
|
|
|
case MessageType.Compile:
|
|
if (Cloud.anonMode(lf("Native compilation")))
|
|
return;
|
|
|
|
var message1 = <Message_Compile> event.data;
|
|
var cpp;
|
|
switch (message1.language) {
|
|
case Language.CPlusPlus:
|
|
cpp = Promise.as(message1.text);
|
|
break;
|
|
case Language.TouchDevelop:
|
|
cpp = roundtrip(message1.text, message1.libs).then((a: J.JApp) => {
|
|
return Embedded.compile(a);
|
|
});
|
|
break;
|
|
}
|
|
TheEditor.compileWithUi(this.guid, cpp, message1.name).then(json => {
|
|
console.log(json);
|
|
// Aborted because of a retry, perhaps.
|
|
if (!json)
|
|
return;
|
|
|
|
if (json.success) {
|
|
this.post(<Message_CompileAck>{
|
|
type: MessageType.CompileAck,
|
|
status: Status.Ok
|
|
});
|
|
document.location.href = json.hexurl;
|
|
} else {
|
|
var errorMsg = makeOutMbedErrorMsg(json);
|
|
this.post(<Message_CompileAck>{
|
|
type: MessageType.CompileAck,
|
|
status: Status.Error,
|
|
error: errorMsg
|
|
});
|
|
}
|
|
}, (json: string) => {
|
|
// Failure
|
|
console.log(json);
|
|
this.post(<Message_CompileAck>{
|
|
type: MessageType.CompileAck,
|
|
status: Status.Error,
|
|
error: "early error"
|
|
});
|
|
});
|
|
break;
|
|
|
|
case MessageType.Upgrade:
|
|
var message2 = <Message_Upgrade> event.data;
|
|
var ast: AST.Json.JApp = message2.ast;
|
|
addLibraries(ast, message2.libs).then(() => {;
|
|
console.log("Attempting to serialize", ast);
|
|
var text = J.serialize(ast);
|
|
console.log("Attempting to edit script text", text);
|
|
Browser.TheHost.openNewScriptAsync({
|
|
editorName: "touchdevelop",
|
|
scriptName: message2.name,
|
|
scriptText: text
|
|
}).done();
|
|
}).done();
|
|
break;
|
|
|
|
case MessageType.Run:
|
|
var message3 = <Message_Run> event.data;
|
|
document.getElementById("externalEditorSide").classList.remove("dismissed");
|
|
// So that key events such as escape are caught by the editor, not
|
|
// the inner iframe.
|
|
var ast: AST.Json.JApp = message3.ast;
|
|
addLibraries(ast, message3.libs).then(() => {
|
|
var text = J.serialize(ast);
|
|
typeCheckAndRun(text);
|
|
}).done();
|
|
break;
|
|
|
|
default:
|
|
// Apparently the runtime loop of the simulator is implemented using
|
|
// messages sent to all origins... see [rt/util.ts]. So just don't do
|
|
// anything if we receive an unrecognized message.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface ScriptData {
|
|
guid: string;
|
|
scriptText: string;
|
|
editorState: EditorState;
|
|
scriptVersionInCloud: string;
|
|
baseSnapshot: string;
|
|
metadata: Metadata;
|
|
};
|
|
|
|
// The [scriptVersionInCloud] name is the one that's used by [world.ts];
|
|
// actually, it hasn't much to do, really, with the script version
|
|
// that's in the cloud. It's more of an unused field (in the new "lite
|
|
// cloud" context) that we use to store extra information attached to
|
|
// the script.
|
|
export function loadAndSetup(editor: ExternalEditor, data: ScriptData) {
|
|
// The [scheduleSaveToCloudAsync] method on [Editor] needs the
|
|
// [guid] field of this global to match for us to read back the
|
|
// [baseSnapshot] field afterwards.
|
|
ScriptEditorWorldInfo = <EditorWorldInfo>{
|
|
guid: data.guid,
|
|
baseId: null,
|
|
baseUserId: null,
|
|
status: null,
|
|
version: null,
|
|
baseSnapshot: null,
|
|
};
|
|
|
|
// Clear leftover iframes and simulators.
|
|
document.getElementById("externalEditorSide").setChildren([]);
|
|
var iframeDiv = document.getElementById("externalEditorFrame");
|
|
iframeDiv.setChildren([]);
|
|
|
|
// Load the editor; send the initial message.
|
|
var iframe = document.createElement("iframe");
|
|
// allow-popups is for the Blockly help menu item
|
|
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-popups");
|
|
iframe.addEventListener("load", function () {
|
|
TheChannel = new Channel(editor, iframe, data.guid);
|
|
var extra = JSON.parse(data.scriptVersionInCloud || "{}");
|
|
TheChannel.post(<Message_Init> {
|
|
type: MessageType.Init,
|
|
script: data,
|
|
merge: ("theirs" in extra) ? extra : null,
|
|
fota: Cloud.isFota(),
|
|
});
|
|
});
|
|
iframe.setAttribute("src", editor.origin + editor.path);
|
|
iframeDiv.appendChild(iframe);
|
|
|
|
// Change the hash and the window title.
|
|
TheEditor.historyMgr.setHash("edit:" + data.guid, editor.name);
|
|
|
|
// Start the simulator
|
|
pullLatestLibraryVersion(microbitScriptId)
|
|
.then((pubId: string) => ScriptCache.getScriptAsync(pubId))
|
|
.then((s: string) => {
|
|
typeCheckAndRun(s, "_libinit");
|
|
})
|
|
.done();
|
|
}
|
|
|
|
export function pickUpNewBaseVersion() {
|
|
if (TheChannel)
|
|
TheChannel.post(<Message_NewBaseVersion> {
|
|
type: MessageType.NewBaseVersion,
|
|
baseSnapshot: ScriptEditorWorldInfo.baseSnapshot
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// vim: set ts=2 sw=2 sts=2:
|