tildefriends/core/client.js

1436 lines
36 KiB
JavaScript
Raw Permalink Normal View History

import {LitElement, html, css, svg} from '/lit/lit-all.min.js';
let cm6;
let gSocket;
let gCurrentFile;
let gFiles = {};
let gApp = {files: {}, emoji: '📦'};
let gEditor;
let gOriginalInput;
let kErrorColor = "#dc322f";
let kStatusColor = "#fff";
// Functions that server-side app code can call through the app object.
const k_api = {
setDocument: {args: ['content'], func: api_setDocument},
postMessage: {args: ['message'], func: api_postMessage},
error: {args: ['error'], func: api_error},
localStorageSet: {args: ['key', 'value'], func: api_localStorageSet},
localStorageGet: {args: ['key'], func: api_localStorageGet},
requestPermission: {args: ['permission', 'id'], func: api_requestPermission},
print: {args: ['...'], func: api_print},
setHash: {args: ['hash'], func: api_setHash},
};
// TODO(tasiaiso): this is only used once, move it down ?
const k_global_style = css`
a:link {
color: #268bd2;
}
a:visited {
color: #6c71c4;
}
a:hover {
color: #859900;
}
a:active {
color: #2aa198;
}
`;
/**
* Class that represents the top bar
*/
class TfNavigationElement extends LitElement {
static get properties() {
return {
credentials: {type: Object},
permissions: {type: Object},
show_permissions: {type: Boolean},
status: {type: Object},
spark_lines: {type: Object},
version: {type: Object},
show_version: {type: Boolean},
};
}
constructor() {
super();
this.permissions = {};
this.show_permissions = false;
this.status = {};
this.spark_lines = {};
}
/**
* TODOC
* @param {*} event
*/
toggle_edit(event) {
event.preventDefault();
if (editing()) {
closeEditor();
} else {
edit();
}
}
/**
* TODOC
* @param {*} key
*/
reset_permission(key) {
send({action: "resetPermission", permission: key});
}
/**
* TODOC
* @param {*} key
* @param {*} options
* @returns
*/
get_spark_line(key, options) {
if (!this.spark_lines[key]) {
let spark_line = document.createElement('tf-sparkline');
spark_line.style.display = 'flex';
spark_line.style.flexDirection = 'row';
spark_line.style.flex = '0 50 5em';
spark_line.title = key;
if (options) {
if (options.max) {
spark_line.max = options.max;
}
}
this.spark_lines[key] = spark_line;
this.requestUpdate();
}
return this.spark_lines[key];
}
/**
* TODOC
* @returns
*/
render_login() {
if (this?.credentials?.session?.name) {
return html`<a id="login" href="/login/logout?return=${url() + hash()}">logout ${this.credentials.session.name}</a>`;
} else {
return html`<a id="login" href="/login?return=${url() + hash()}">login</a>`;
}
}
/**
* TODOC
* @returns
*/
render_permissions() {
if (this.show_permissions) {
return html`
<link type="text/css" rel="stylesheet" href="/static/w3.css">
<div style="position: absolute; top: 0; padding: 0; margin: 0; z-index: 100; display: flex; justify-content: center; width: 100%">
<div style="background-color: #444; padding: 1em; margin: 0 auto; border-left: 4px solid #fff; border-right: 4px solid #fff; border-bottom: 4px solid #fff">
<div>This app has the following permissions:</div>
${Object.keys(this.permissions).map(key => html`
<div>
<span>${key}</span>: ${this.permissions[key] ? ' Allowed' : ' Denied'}
<button @click=${() => this.reset_permission(key)} class='w3-button w3-red">Reset</button>
</div>
`)}
<button @click=${() => this.show_permissions = false} class="w3-button w3-blue">Close</button>
</div>
</div>
`;
}
}
/**
* TODOC
* @returns
*/
render() {
let self = this;
return html`
<style>
${k_global_style}
.tooltip {
position: absolute;
z-index: 1;
display: none;
border: 1px solid black;
padding: 4px;
color: black;
background: white;
}
.tooltip_parent:hover .tooltip {
display: inline-block;
}
</style>
<div style="margin: 4px; display: flex; flex-direction: row; flex-wrap: nowrap; gap: 3px; align-items: center">
<span style="cursor: pointer" @click=${() => this.show_version = !this.show_version}>😎</span>
<span ?hidden=${!this.show_version} style="flex: 0 0; white-space: nowrap" title=${this.version?.name + ' ' + Object.entries(this.version || {}).filter(x => ['name', 'number'].indexOf(x[0]) == -1).map(x => `\n* ${x[0]}: ${x[1]}`)}>${this.version?.number}</span>
<a accesskey="h" @mouseover=${set_access_key_title} data-tip="Open home app." href="/" style="color: #fff; white-space: nowrap">TF</a>
<a accesskey="a" @mouseover=${set_access_key_title} data-tip="Open apps list." href="/~core/apps/">apps</a>
<a accesskey="e" @mouseover=${set_access_key_title} data-tip="Toggle the app editor." href="#" @click=${this.toggle_edit}>edit</a>
<a accesskey="p" @mouseover=${set_access_key_title} data-tip="View and change permissions." href="#" @click=${() => self.show_permissions = !self.show_permissions}>🎛</a>
<span style="display: inline-block; vertical-align: top; white-space: pre; color: ${this.status.color ?? kErrorColor}">${this.status.message}</span>
<span id="requests"></span>
${this.render_permissions()}
<span style="flex: 1 1; display: flex; flex-direction: row; white-space: nowrap; margin: 0; padding: 0">${Object.keys(this.spark_lines).sort().map(x => this.spark_lines[x]).map(x => [html`<span style="font-size: xx-small">${x.dataset.emoji}</span>`, x])}</span>
<span style="flex: 0 0; white-space: nowrap">${this.render_login()}</span>
</div>
`;
}
}
customElements.define('tf-navigation', TfNavigationElement);
/**
* TODOC
*/
class TfFilesElement extends LitElement {
static get properties() {
return {
current: {type: String},
files: {type: Object},
dropping: {type: Number},
};
}
constructor() {
super();
this.files = {};
this.dropping = 0;
}
/**
* TODOC
* @param {*} file
*/
file_click(file) {
this.dispatchEvent(new CustomEvent('file_click', {
detail: {
file: file,
},
bubbles: true,
composed: true,
}));
}
/**
* TODOC
* @param {*} file
* @returns
*/
render_file(file) {
let classes = ['file'];
if (file == this.current) {
classes.push('current');
}
if (!this.files[file].clean) {
classes.push('dirty');
}
return html`<div class="${classes.join(' ')}" @click=${x => this.file_click(file)}>${file}</div>`;
}
/**
* TODOC
* @param {*} event
*/
async drop(event) {
event.preventDefault();
event.stopPropagation();
this.dropping = 0;
for (let file of event.dataTransfer.files) {
let buffer = await file.arrayBuffer();
let text = new TextDecoder('latin1').decode(buffer);
gFiles[file.name] = {
doc: new cm6.EditorState.create({doc: text, extensions: cm6.extensions}),
buffer: buffer,
isNew: true,
};
gCurrentFile = file.name;
}
openFile(gCurrentFile);
updateFiles();
}
/**
* TODOC
* @param {*} event
*/
drag_enter(event) {
this.dropping++;
event.preventDefault();
}
/**
* TODOC
* @param {*} event
*/
drag_leave(event) {
this.dropping--;
}
/**
* TODOC
* @returns
*/
render() {
let self = this;
return html`
<style>
div.file {
padding: 0.5em;
cursor: pointer;
}
div.file:hover {
background-color: #1a9188;
}
div.file::before {
content: '📄 ';
}
div.file.current {
font-weight: bold;
background-color: #2aa198;
}
div.file.dirty::after {
content: '*';
}
</style>
<div @drop=${this.drop} @dragenter=${this.drag_enter} @dragleave=${this.drag_leave}>
${Object.keys(this.files).sort().map(x => self.render_file(x))}
</div>
<div
?hidden=${this.dropping == 0}
@drop=${this.drop} @dragenter=${this.drag_enter} @dragleave=${this.drag_leave}
style="text-align: center; vertical-align: middle; outline: 16px solid red; margin: -8px; background-color: rgba(255, 0, 0, 0.5); position: absolute; left: 16px; top: 16px; width: calc(100% - 16px); height: calc(100% - 16px); z-index: 1000">
Drop File(s)
</div>
`;
}
}
customElements.define('tf-files', TfFilesElement);
/**
* TODOC
*/
class TfFilesPaneElement extends LitElement {
static get properties() {
return {
expanded: {type: Boolean},
current: {type: String},
files: {type: Object},
};
}
constructor() {
super();
this.expanded = window.localStorage.getItem('files') != '0';
this.files = {};
}
/**
* TODOC
* @param {*} expanded
*/
set_expanded(expanded) {
this.expanded = expanded;
window.localStorage.setItem('files', expanded ? '1' : '0');
}
/**
* TODOC
* @returns
*/
render() {
let self = this;
let expander = this.expanded ?
html`<div class="w3-button w3-bar-item w3-blue" style="flex: 0 0 auto; display: flex; flex-direction: row" @click=${() => self.set_expanded(false)}>
<span style="flex: 1 1" font-weight: bold; text-align: center; flex: 1">Files</span>
<span style="flex: 0 0">«</span>
</div>` :
html`<div class="w3-button w3-bar-item w3-blue" @click=${() => self.set_expanded(true)}>»</div>`;
let content = html`
<tf-files style="flex: 1 1; overflow: auto" .files=${self.files} current=${self.current} @file_click=${event => openFile(event.detail.file)}></tf-files>
<div><button class="w3-bar-item w3-button w3-blue" style="width: 100%; flex: 0 0" @click=${() => newFile()} accesskey="n" @mouseover=${set_access_key_title} data-tip="Add a new, empty file to the app">📄 New File</button></div>
<div><button class="w3-bar-item w3-button w3-blue" style="width: 100%; flex: 0 0" @click=${() => removeFile()} accesskey="r" @mouseover=${set_access_key_title} data-tip="Remove the selected file from the app">🚮 Remove File</button></div>
`;
return html`
<link type="text/css" rel="stylesheet" href="/static/w3.css">
<div style="display: flex; flex-direction: column; height: 100%">
${expander}
${this.expanded ? content : undefined}
</div>
`;
}
}
customElements.define('tf-files-pane', TfFilesPaneElement);
/**
* TODOC
*/
class TfSparkLineElement extends LitElement {
static get properties() {
return {
lines: {type: Array},
min: {type: Number},
max: {type: Number},
};
}
constructor() {
super();
this.min = 0;
this.max = 1.0;
this.lines = [];
this.k_values_max = 100;
}
/**
* TODOC
* @param {*} key
* @param {*} value
*/
append(key, value) {
let line = null;
for (let it of this.lines) {
if (it.name == key) {
line = it;
break;
}
}
if (!line) {
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
line = {
name: key,
style: k_colors[this.lines.length % k_colors.length],
values: Array(this.k_values_max).fill(0),
};
this.lines.push(line);
}
if (line.values.length >= this.k_values_max) {
line.values.shift();
}
line.values.push(value);
this.requestUpdate();
}
/**
* TODOC
* @param {*} line
* @returns
*/
render_line(line) {
if (line?.values?.length >= 2) {
let max = Math.max(this.max, ...line.values);
let points = [].concat(...line.values.map((x, i) => [50.0 * i / (line.values.length - 1), 10.0 - 10.0 * (x - this.min) / (max - this.min)]));
return svg`<polyline points=${points.join(' ')} stroke=${line.style} fill="none"/>`;
}
}
/**
* TODOC
* @returns
*/
render() {
let max = Math.round(10.0 * Math.max(...this.lines.map(line => line.values[line.values.length - 1]))) / 10.0;
return html`
<svg style="max-width: 7.5em; max-height: 1.5em; margin: 0; padding: 0; background: #000" viewBox="0 0 50 10" xmlns="http://www.w3.org/2000/svg">
${this.lines.map(x => this.render_line(x))}
<text x="0" y="1em" style="font: 8px sans-serif; fill: #fff">${max}</text>
</svg>
`;
}
}
customElements.define('tf-sparkline', TfSparkLineElement);
// TODOC
window.addEventListener("keydown", function(event) {
if (event.keyCode == 83 && (event.altKey || event.ctrlKey)) {
if (editing()) {
save();
event.preventDefault();
}
} else if (event.keyCode == 66 && event.altKey) {
if (editing()) {
closeEditor();
event.preventDefault();
}
}
});
/**
* TODOC
* @param {*} nodes
* @param {*} callback
* @returns
*/
function ensureLoaded(nodes, callback) {
if (!nodes.length) {
callback();
return;
}
let search = nodes.shift();
let head = document.head;
let found = false;
for (let i = 0; i < head.childNodes.length; i++) {
if (head.childNodes[i].tagName == search.tagName) {
let match = true;
for (let attribute in search.attributes) {
if (head.childNodes[i].attributes[attribute].value != search.attributes[attribute]) {
match = false;
}
}
if (match) {
found = true;
break;
}
}
}
if (found) {
ensureLoaded(nodes, callback);
} else {
let node = document.createElement(search.tagName);
node.onreadystatechange = node.onload = function() {
ensureLoaded(nodes, callback);
};
for (let attribute in search.attributes) {
node.setAttribute(attribute, search.attributes[attribute]);
}
head.insertBefore(node, head.firstChild);
}
}
/**
* TODOC
* @returns
*/
function editing() {
return document.getElementById("editPane").style.display != 'none';
}
/**
* TODOC
* @returns
*/
function is_edit_only() {
return window.location.search == '?editonly=1' || window.innerWidth < 1024;
}
/**
* TODOC
* @returns
*/
async function edit() {
if (editing()) {
return;
}
window.localStorage.setItem('editing', '1');
document.getElementById("editPane").style.display = 'flex';
document.getElementById('viewPane').style.display = is_edit_only() ? 'none' : 'flex';
try {
if (!gEditor) {
cm6 = await import('/codemirror/cm6.js');
gEditor = cm6.TildeFriendsEditorView(document.getElementById("editor"));
}
gEditor.onDocChange = updateFiles;
await load();
} catch (error) {
alert(`${error.message}\n\n${error.stack}`);
closeEditor();
}
}
/**
* TODOC
*/
function trace() {
window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}`);
}
/**
* TODOC
* @param {*} name
* @returns
*/
function guessMode(name) {
return name.endsWith(".js") ? "javascript" :
name.endsWith(".html") ? "htmlmixed" :
null;
}
/**
* TODOC
* @param {*} name
* @param {*} id
* @returns
*/
function loadFile(name, id) {
return fetch('/' + id + '/view').then(function(response) {
if (!response.ok) {
alert(`Request failed for ${name}: ${response.status} ${response.statusText}`);
return 'missing file!';
}
return response.text();
}).then(function(text) {
gFiles[name].doc = cm6.EditorState.create({doc: text, extensions: cm6.extensions});
gFiles[name].original = gFiles[name].doc.doc.toString();
if (!Object.values(gFiles).some(x => !x.doc)) {
openFile(Object.keys(gFiles).sort()[0]);
}
});
}
/**
* TODOC
* @param {*} path
* @returns
*/
async function load(path) {
let response = await fetch((path || url()) + 'view');
let json;
if (response.ok) {
json = await response.json();
} else if (response.status != 404) {
throw new Error(response.status + ' ' + response.statusText);
}
gFiles = {};
let isApp = false;
let promises = [];
if (json && json['type'] == 'tildefriends-app') {
isApp = true;
Object.keys(json['files']).forEach(function(name) {
gFiles[name] = {};
promises.push(loadFile(name, json['files'][name]));
});
if (Object.keys(json['files']).length == 0) {
document.getElementById("editPane").style.display = 'flex';
}
gApp = json;
gApp.emoji = gApp.emoji || '📦';
document.getElementById('icon').innerHTML = gApp.emoji;
}
if (!isApp) {
document.getElementById("editPane").style.display = 'flex';
let text = '// New script.\n';
gCurrentFile = 'app.js';
gFiles[gCurrentFile] = {
doc: cm6.EditorState.create({doc: text, extensions: cm6.extensions}),
};
openFile(gCurrentFile);
}
return Promise.all(promises);
}
/**
* TODOC
*/
function closeEditor() {
window.localStorage.setItem('editing', '0');
document.getElementById("editPane").style.display = 'none';
document.getElementById('viewPane').style.display = 'flex';
}
/**
* TODOC
* @returns
*/
function explodePath() {
return /^\/~([^\/]+)\/([^\/]+)(.*)/.exec(window.location.pathname);
}
/**
* TODOC
* @param {*} save_to
* @returns
*/
function save(save_to) {
document.getElementById("save").disabled = true;
if (gCurrentFile) {
gFiles[gCurrentFile].doc = gEditor.state;
if (!gFiles[gCurrentFile].isNew && !gFiles[gCurrentFile].doc.doc.toString() == gFiles[gCurrentFile].original) {
delete gFiles[gCurrentFile].buffer;
}
}
let save_path = save_to;
if (!save_path) {
let name = document.getElementById("name");
if (name && name.value) {
save_path = name.value;
} else {
save_path = url();
}
}
let promises = [];
for (let name of Object.keys(gFiles)) {
let file = gFiles[name];
if (!file.isNew && file.doc.doc.toString() == file.original) {
continue;
}
delete file.id;
delete file.isNew;
promises.push(fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/binary',
},
body: file.buffer ?? file.doc.doc.toString(),
}).then(function(response) {
if (!response.ok) {
throw new Error('Saving "' + name + '": ' + response.status + ' ' + response.statusText);
}
return response.text();
}).then(function(text) {
file.id = text;
if (file.id.charAt(0) == '/') {
file.id = file.id.substr(1);
}
}));
}
return Promise.all(promises).then(function() {
let app = {
type: "tildefriends-app",
files: Object.fromEntries(Object.keys(gFiles).map(x => [x, gFiles[x].id || gApp.files[x]])),
emoji: gApp.emoji || '📦',
};
Object.values(gFiles).forEach(function(file) { delete file.id; });
gApp = JSON.parse(JSON.stringify(app));
return fetch(save_path + 'save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(app),
}).then(function(response) {
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
if (save_path != window.location.pathname) {
alert('Saved to ' + save_path + '.');
} else {
reconnect(save_path);
}
});
}).catch(function(error) {
alert(error);
}).finally(function() {
document.getElementById("save").disabled = false;
Object.values(gFiles).forEach(function(file) {
file.original = file.doc.doc.toString();
});
updateFiles();
});
}
/**
* TODOC
*/
function changeIcon() {
let value = prompt('Enter a new app icon emoji:');
if (value !== undefined) {
gApp.emoji = value || '📦';
document.getElementById('icon').innerHTML = gApp.emoji;
}
}
/**
* TODOC
*/
function deleteApp() {
let name = document.getElementById("name");
let path = name && name.value ? name.value : url();
if (confirm(`Are you sure you want to delete the app '${path}'?`)) {
fetch(path + 'delete').then(function(response) {
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
alert('Deleted.');
}).catch(function(error) {
alert(error);
});
}
}
/**
* TODOC
* @returns
*/
function url() {
let hash = window.location.href.indexOf('#');
let question = window.location.href.indexOf('?');
let end = -1;
if (hash != -1 && (hash < end || end == -1))
{
end = hash;
}
if (question != -1 && (question < end || end == -1))
{
end = question;
}
return end != -1 ? window.location.href.substring(0, end) : window.location.href;
}
/**
* TODOC
* @returns
*/
function hash() {
return window.location.hash != "#" ? window.location.hash : "";
}
/**
* TODOC
* @param {*} content
*/
function api_setDocument(content) {
let iframe = document.getElementById("document");
iframe.srcdoc = content;
}
/**
* TODOC
* @param {*} message
*/
function api_postMessage(message) {
let iframe = document.getElementById("document");
iframe.contentWindow.postMessage(message, "*");
}
/**
* TODOC
* @param {*} error
*/
function api_error(error) {
if (error) {
if (typeof(error) == 'string') {
setStatusMessage('⚠️ ' + error, '#f00');
} else {
setStatusMessage('⚠️ ' + error.message + '\n' + error.stack, '#f00');
}
}
console.log('error', error);
}
/**
* TODOC
* @param {*} key
* @param {*} value
*/
function api_localStorageSet(key, value) {
window.localStorage.setItem('app:' + key, value);
}
/**
* TODOC
* @param {*} key
* @returns
*/
function api_localStorageGet(key) {
return window.localStorage.getItem('app:' + key);
}
/**
* TODOC
* @param {*} permission
* @param {*} id
* @returns
*/
function api_requestPermission(permission, id) {
let outer = document.createElement('div');
outer.classList.add('permissions');
let container = document.createElement('div');
container.classList.add('permissions_contents');
let div = document.createElement('div');
div.appendChild(document.createTextNode('This app is requesting the following permission:'));
let span = document.createElement('span');
span.style = 'font-weight: bold';
span.appendChild(document.createTextNode(permission));
div.appendChild(span);
container.appendChild(div);
div = document.createElement('div');
div.style = 'padding: 1em';
let check = document.createElement('input');
check.id = 'permissions_remember_check';
check.type = 'checkbox';
check.classList.add('w3-check');
check.classList.add('w3-blue');
div.appendChild(check);
let label = document.createElement('label');
label.htmlFor = check.id;
label.appendChild(document.createTextNode('Remember this decision.'));
div.appendChild(label);
container.appendChild(div);
const k_options = [
{
id: 'allow',
text: '✅ Allow',
grant: ['allow once', 'allow'],
},
{
id: 'deny',
text: '❌ Deny',
grant: ['deny once', 'deny'],
},
];
return new Promise(function(resolve, reject) {
div = document.createElement('div');
for (let option of k_options) {
let button = document.createElement('button');
button.classList.add('w3-button');
button.classList.add('w3-blue');
button.innerText = option.text;
button.id = option.id;
button.onclick = function() {
resolve(option.grant[check.checked ? 1 : 0]);
document.body.removeChild(outer);
}
div.appendChild(button);
}
container.appendChild(div);
outer.appendChild(container);
document.body.appendChild(outer);
});
}
/**
* TODOC
*/
function api_print() {
console.log('app>', ...arguments);
}
/**
* TODOC
* @param {*} hash
*/
function api_setHash(hash) {
window.location.hash = hash;
}
/**
* TODOC
* @param {*} message
*/
function _receive_websocket_message(message) {
if (message && message.action == "session") {
setStatusMessage("🟢 Executing...", kStatusColor);
document.getElementsByTagName('tf-navigation')[0].credentials = message.credentials;
} else if (message && message.action == 'permissions') {
document.getElementsByTagName('tf-navigation')[0].permissions = message.permissions ?? {};
} else if (message && message.action == "ready") {
setStatusMessage(null);
if (window.location.hash) {
send({event: "hashChange", hash: window.location.hash});
}
document.getElementsByTagName('tf-navigation')[0].version = message.version;
document.getElementById('viewPane').style.display = message.edit_only ? 'none' : 'flex';
send({action: 'enableStats', enabled: true});
} else if (message && message.action == "ping") {
send({action: "pong"});
} else if (message && message.action == "stats") {
let now = new Date().getTime();
for (let key of Object.keys(message.stats)) {
const k_groups = {
rpc_in: {group: 'rpc', name: 'in'},
rpc_out: {group: 'rpc', name: 'out'},
cpu_percent: {group: 'cpu', name: 'main'},
thread_percent: {group: 'cpu', name: 'work'},
arena_percent: {group: 'memory', name: 'm'},
js_malloc_percent: {group: 'memory', name: 'js'},
memory_percent: {group: 'memory', name: 'tot'},
sqlite3_memory_percent: {group: 'memory', name: 'sql'},
tf_malloc_percent: {group: 'memory', name: 'tf'},
tls_malloc_percent: {group: 'memory', name: 'tls'},
uv_malloc_percent: {group: 'memory', name: 'uv'},
messages_stored: {group: 'store', name: 'messages'},
blobs_stored: {group: 'store', name: 'blobs'},
socket_count: {group: 'socket', name: 'total'},
socket_open_count: {group: 'socket', name: 'open'},
import_count: {group: 'functions', name: 'imports'},
export_count: {group: 'functions', name: 'exports'},
};
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
let graph_key = k_groups[key]?.group || key;
if (['cpu', 'rpc', 'store', 'memory'].indexOf(graph_key) != -1) {
let line = document.getElementsByTagName('tf-navigation')[0].get_spark_line(graph_key, { max: 100 });
line.dataset.emoji = {
'cpu': '💻',
'rpc': '🔁',
'store': '💾',
'memory': '🐏',
}[graph_key];
line.append(key, message.stats[key]);
}
}
} else if (message &&
message.message === 'tfrpc' &&
message.method) {
let api = k_api[message.method];
let id = message.id;
let params = message.params;
if (api) {
Promise.resolve(api.func(...params)).then(function(result) {
send({
message: 'tfrpc',
id: id,
result: result,
});
}).catch(function(error) {
send({
message: 'tfrpc',
id: id,
error: error,
});
});
}
}
}
/**
* TODOC
* @param {*} message
* @param {*} color
*/
function setStatusMessage(message, color) {
document.getElementsByTagName('tf-navigation')[0].status = {message: message, color: color};
}
/**
* TODOC
* @param {*} value
*/
function send(value) {
try {
if (gSocket && gSocket.readyState == gSocket.OPEN) {
gSocket.send(JSON.stringify(value));
}
} catch (error) {
setStatusMessage('🤷 Send failed: ' + error.toString(), kErrorColor);
}
}
/**
* TODOC
* @param {*} sourceData
* @param {*} maxWidth
* @param {*} maxHeight
* @param {*} callback
*/
function fixImage(sourceData, maxWidth, maxHeight, callback) {
let result = sourceData;
let image = new Image();
image.crossOrigin = "anonymous";
image.referrerPolicy = "no-referrer";
image.onload = function() {
if (image.width > maxWidth || image.height > maxHeight) {
let downScale = Math.min(maxWidth / image.width, maxHeight / image.height);
let canvas = document.createElement("canvas");
canvas.width = image.width * downScale;
canvas.height = image.height * downScale;
let context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
image.width = canvas.width;
image.height = canvas.height;
context.drawImage(image, 0, 0, image.width, image.height);
result = canvas.toDataURL();
}
callback(result);
};
image.src = sourceData;
}
/**
* TODOC
* @param {*} image
*/
function sendImage(image) {
fixImage(image, 320, 240, function(result) {
send({image: result});
});
}
/**
* TODOC
*/
function hashChange() {
send({event: 'hashChange', hash: window.location.hash});
}
/**
* TODOC
*/
function focus() {
if (gSocket && gSocket.readyState == gSocket.CLOSED) {
connectSocket();
} else {
send({event: "focus"});
}
}
/**
* TODOC
*/
function blur() {
if (gSocket && gSocket.readyState == gSocket.OPEN) {
send({event: "blur"});
}
}
/**
* TODOC
* @param {*} event
*/
function message(event) {
if (event.data && event.data.event == "resizeMe" && event.data.width && event.data.height) {
let iframe = document.getElementById("iframe_" + event.data.name);
iframe.setAttribute("width", event.data.width);
iframe.setAttribute("height", event.data.height);
} else if (event.data && event.data.action == "setHash") {
window.location.hash = event.data.hash;
} else if (event.data && event.data.action == 'storeBlob') {
fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/binary',
},
body: event.data.blob.buffer,
}).then(function(response) {
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
return response.text();
}).then(function(text) {
let iframe = document.getElementById("document");
iframe.contentWindow.postMessage({'storeBlobComplete': {name: event.data.blob.name, path: text, type: event.data.blob.type, context: event.data.context}}, '*');
});
} else {
send({event: "message", message: event.data});
}
}
/**
* TODOC
* @param {*} path
*/
function reconnect(path) {
let oldSocket = gSocket;
gSocket = null
if (oldSocket) {
oldSocket.onopen = null;
oldSocket.onclose = null;
oldSocket.onmessage = null;
oldSocket.close();
}
connectSocket(path);
}
/**
* TODOC
* @param {*} path
*/
function connectSocket(path) {
if (!gSocket || gSocket.readyState != gSocket.OPEN) {
if (gSocket) {
gSocket.onopen = null;
gSocket.onclose = null;
gSocket.onmessage = null;
gSocket.close();
}
setStatusMessage("⚪ Connecting...", kStatusColor);
gSocket = new WebSocket(
(window.location.protocol == "https:" ? "wss://" : "ws://")
+ window.location.hostname
+ (window.location.port.length ? ":" + window.location.port : "")
+ "/app/socket");
gSocket.onopen = function() {
setStatusMessage("🟡 Authenticating...", kStatusColor);
let connect_path = path ?? window.location.pathname;
gSocket.send(JSON.stringify({
action: "hello",
path: connect_path,
url: window.location.href,
edit_only: editing() && is_edit_only(),
api: Object.entries(k_api).map(([key, value]) => [].concat([key], value.args)),
}));
}
gSocket.onmessage = function(event) {
_receive_websocket_message(JSON.parse(event.data));
}
gSocket.onclose = function(event) {
const k_codes = {
1000: 'Normal closure',
1001: 'Going away',
1002: 'Protocol error',
1003: 'Unsupported data',
1005: 'No status received',
1006: 'Abnormal closure',
1007: 'Invalid frame payload data',
1008: 'Policy violation',
1009: 'Message too big',
1010: 'Missing extension',
1011: 'Internal error',
1012: 'Service restart',
1013: 'Try again later',
1014: 'Bad gateway',
1015: 'TLS handshake',
};
setStatusMessage("🔴 Closed: " + (k_codes[event.code] || event.code), kErrorColor);
}
}
}
/**
* TODOC
* @param {*} name
*/
function openFile(name) {
let newDoc = (name && gFiles[name]) ? gFiles[name].doc : cm6.EditorState.create({doc: "", extensions: cm6.extensions});
let oldDoc = gEditor.state;
gEditor.setState(newDoc);
if (gFiles[gCurrentFile]) {
gFiles[gCurrentFile].doc = oldDoc;
if (!gFiles[gCurrentFile].isNew && gFiles[gCurrentFile].doc.doc.toString() == oldDoc.doc.toString()) {
delete gFiles[gCurrentFile].buffer;
}
}
gCurrentFile = name;
updateFiles();
gEditor.focus();
}
/**
* TODOC
*/
function updateFiles() {
let files = document.getElementsByTagName("tf-files-pane")[0];
if (files) {
files.files = Object.fromEntries(Object.keys(gFiles).map(file => [file, {
clean: (file == gCurrentFile ? gEditor.state.doc.toString() : gFiles[file].doc.doc.toString()) == gFiles[file].original,
}]));
files.current = gCurrentFile;
}
gEditor.focus();
}
/**
* TODOC
* @param {*} name
*/
function makeNewFile(name) {
gFiles[name] = {
doc: cm6.EditorState.create({extensions: cm6.extensions}),
};
openFile(name);
}
/**
* TODOC
*/
function newFile() {
let name = prompt("Name of new file:", "file.js");
if (name && !gFiles[name]) {
makeNewFile(name);
}
}
/**
* TODOC
*/
function removeFile() {
if (confirm("Remove " + gCurrentFile + "?")) {
delete gFiles[gCurrentFile];
openFile(Object.keys(gFiles)[0]);
}
}
/**
* TODOC
*/
async function appExport() {
let JsZip = (await import('/static/jszip.min.js')).default;
let owner = window.location.pathname.split('/')[1].replace('~', '');
let name = window.location.pathname.split('/')[2];
let zip = new JsZip();
zip.file(`${name}.json`, JSON.stringify({
type: "tildefriends-app",
emoji: gApp.emoji || '📦',
}));
for (let file of Object.keys(gFiles)) {
zip.file(`${name}/${file}`, gFiles[file].buffer ?? gFiles[file].doc.doc.toString());
}
let content = await zip.generateAsync({
type: 'base64',
compression: 'DEFLATE',
});
let a = document.createElement('a');
a.href = `data:application/zip;base64,${content}`;
a.download = `${owner}_${name}.zip`;
a.click();
}
/**
* TODOC
* @param {*} name
* @param {*} file
* @returns
*/
async function save_file_to_blob_id(name, file) {
console.log(`Saving ${name}.`);
let response = await fetch('/save', {
method: 'POST',
headers: {
'Content-Type': 'application/binary',
},
body: file,
});
if (!response.ok) {
throw new Error('Saving "' + name + '": ' + response.status + ' ' + response.statusText);
}
let blob_id = await response.text();
if (blob_id.charAt(0) == '/') {
blob_id = blob_id.substr(1);
}
return blob_id;
}
/**
* TODOC
*/
async function appImport() {
let JsZip = (await import('/static/jszip.min.js')).default;
let input = document.createElement('input');
input.type = 'file';
input.click();
input.onchange = async function() {
try {
for (let file of input.files) {
if (file.type != 'application/zip') {
console.log(`This does not look like a .zip (${file.type}).`);
continue;
}
let buffer = new Uint8Array(await file.arrayBuffer());
console.log('ZIP', file.name, file.type, buffer, buffer?.byteLength, buffer?.length);
let zip = new JsZip();
await zip.loadAsync(buffer);
let app_object;
let app_name;
for (let [name, object] of Object.entries(zip.files)) {
if (name.endsWith('.json') && name.indexOf('/') == -1) {
try {
let parsed = JSON.parse(await object.async('text'));
if (parsed.type == 'tildefriends-app') {
app_object = parsed;
app_name = name.substring(0, name.length - '.json'.length);
break;
}
} catch (e) {
console.log(e);
}
}
}
if (app_object) {
app_object.files = {};
for (let [name, object] of Object.entries(zip.files)) {
if (!name.startsWith(app_name + '/') || name.endsWith('/')) {
continue;
}
app_object.files[name.substring(app_name.length + '/'.length)] = await save_file_to_blob_id(name, await object.async('arrayBuffer'));
}
let path = '/' + await save_file_to_blob_id(`${app_name}.json`, JSON.stringify(app_object)) + '/';
console.log('Redirecting to:', path);
window.location.pathname = path;
}
}
} catch (e) {
alert(e.toString());
}
}
}
/**
*
*/
async function sourcePretty() {
let prettier = (await import('/prettier/standalone.mjs')).default;
let babel = (await import('/prettier/babel.mjs')).default;
let estree = (await import('/prettier/estree.mjs')).default;
let source = gEditor.state.doc.toString();
let formatted = await prettier.format(source, {
parser: 'babel',
plugins: [babel, estree],
useTabs: true,
});
if (source !== formatted) {
gEditor.dispatch({
changes: {
from: 0,
to: gEditor.state.doc.length,
insert: formatted,
}
});
}
}
// TODOC
window.addEventListener("load", function() {
window.addEventListener("hashchange", hashChange);
window.addEventListener("focus", focus);
window.addEventListener("blur", blur);
window.addEventListener("message", message, false);
window.addEventListener("online", connectSocket);
document.getElementById("name").value = window.location.pathname;
document.getElementById('closeEditor').addEventListener('click', () => closeEditor());
document.getElementById('save').addEventListener('click', () => save());
document.getElementById('icon').addEventListener('click', () => changeIcon());
document.getElementById('delete').addEventListener('click', () => deleteApp());
document.getElementById('export').addEventListener('click', () => appExport());
document.getElementById('import').addEventListener('click', () => appImport());
document.getElementById('pretty').addEventListener('click', () => sourcePretty());
document.getElementById('trace_button').addEventListener('click', function(event) {
event.preventDefault();
trace();
});
connectSocket(window.location.pathname);
if (window.localStorage.getItem('editing') == '1') {
edit();
} else {
closeEditor();
}
});