472 lines
18 KiB
JavaScript
472 lines
18 KiB
JavaScript
|
|
/* ==================================================================== tabs */
|
|
var nextTabId = 1;
|
|
var NEW_GRAMMAR = 'grammar MyGrammar;\n\nprog : expr EOF ;\n\nexpr : term (\'+\' term)* ;\n\nterm : INT | ID | \'(\' expr \')\' ;\n\nINT : [0-9]+ ;\nID : [a-zA-Z_] [a-zA-Z_0-9]* ;\nWS : [ \\t\\r\\n]+ -> skip ;\n';
|
|
|
|
function grammarName(text) {
|
|
var m = /^\s*(?:lexer\s+|parser\s+)?grammar\s+([A-Za-z_]\w*)\s*;/m.exec(text.slice(0, 20000));
|
|
return m ? m[1] : '';
|
|
}
|
|
function getTab(id) {
|
|
for (var i = 0; i < S.tabs.length; i++) if (S.tabs[i].id === id) return S.tabs[i];
|
|
return null;
|
|
}
|
|
function tabTitle(t) {
|
|
if (t.path) return basename(t.path);
|
|
if (t.kind === 'grammar') { var n = grammarName(t.ed.ta.value); return (n || 'Untitled') + '.g4'; }
|
|
return 'Input ' + t.num;
|
|
}
|
|
function firstOfKind(kind) {
|
|
for (var i = 0; i < S.tabs.length; i++) if (S.tabs[i].kind === kind) return S.tabs[i];
|
|
return null;
|
|
}
|
|
function ensureLast() {
|
|
var g = getTab(S.lastGrammar), i = getTab(S.lastInput);
|
|
if (!g || g.kind !== 'grammar') { g = firstOfKind('grammar'); S.lastGrammar = g ? g.id : null; }
|
|
if (!i || i.kind !== 'input') { i = firstOfKind('input'); S.lastInput = i ? i.id : null; }
|
|
}
|
|
|
|
function addTab(o) {
|
|
var t = { id: 't' + (nextTabId++), kind: o.kind, path: o.path || '', num: 0, saved: o.text, etag: o.etag || '',
|
|
eol: o.eol || 'lf', bom: !!o.bom, dirty: false, rev: 0, banner: '', diskEtag: '', shown: '' };
|
|
if (!t.path) t.num = ++S.untitled[t.kind];
|
|
t.ed = makeEditor(t);
|
|
t.ed.onInput = function () { onEdit(t); };
|
|
t.ed.onCaret = updatePos;
|
|
t.ed.onPick = function () { if (t.kind === 'input') focusDiagramAt(t); };
|
|
setEditorText(t.ed, o.text);
|
|
$('editors').appendChild(t.ed.root);
|
|
S.tabs.push(t);
|
|
return t;
|
|
}
|
|
|
|
function onEdit(t) {
|
|
t.rev++;
|
|
if (t.kind === 'grammar') S.grev++;
|
|
t.dirty = t.ed.ta.value !== t.saved;
|
|
var sig = (t.dirty ? '*' : '') + tabTitle(t);
|
|
if (sig !== t.shown) renderTabs();
|
|
refreshPanels();
|
|
if (t.kind === 'grammar' || t.id === S.lastInput) scheduleRun(t.kind === 'grammar' ? 800 : 500);
|
|
}
|
|
|
|
function activate(id, focus) {
|
|
var t = getTab(id);
|
|
if (!t) return;
|
|
S.active = id;
|
|
if (t.kind === 'grammar') S.lastGrammar = id; else S.lastInput = id;
|
|
ensureLast();
|
|
S.tabs.forEach(function (x) { x.ed.root.hidden = x.id !== id; });
|
|
$('welcome').hidden = true;
|
|
renderTabs();
|
|
updateBanner(t);
|
|
updatePos();
|
|
refreshPanels();
|
|
if (focus !== false) t.ed.ta.focus();
|
|
rememberSession();
|
|
if (S.prefs.autorun) {
|
|
var it = getTab(S.lastInput), r = it ? S.results[it.id] : null;
|
|
if (S.lastGrammar && it && (!r || r.rev !== it.rev || r.grev !== S.grev || r.main !== S.lastGrammar)) scheduleRun(120);
|
|
}
|
|
}
|
|
|
|
function showEmpty() {
|
|
S.active = null;
|
|
$('welcome').hidden = false;
|
|
renderTabs();
|
|
updatePos();
|
|
refreshPanels();
|
|
}
|
|
|
|
function renderTabs() {
|
|
var bar = $('tabs');
|
|
clear(bar);
|
|
var activeEl = null;
|
|
S.tabs.forEach(function (t) {
|
|
var title = tabTitle(t);
|
|
t.shown = (t.dirty ? '*' : '') + title;
|
|
var on = t.id === S.active;
|
|
var d = el('div', 'tab');
|
|
d.setAttribute('role', 'tab');
|
|
d.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
d.tabIndex = on ? 0 : -1;
|
|
d.title = t.path || 'Not saved to disk yet';
|
|
if (t.dirty) { var dot = el('span', 'dot'); dot.title = 'Unsaved changes'; d.appendChild(dot); }
|
|
d.appendChild(el('span', '', title));
|
|
var kind = el('button', 'kind', t.kind === 'grammar' ? 'G' : 'I');
|
|
kind.type = 'button';
|
|
kind.title = (t.kind === 'grammar' ? 'Grammar' : 'Input') + ' - click to treat this file as ' + (t.kind === 'grammar' ? 'an input' : 'a grammar');
|
|
kind.onclick = function (ev) { ev.stopPropagation(); toggleKind(t); };
|
|
d.appendChild(kind);
|
|
var x = el('button', 'tx', '\u00d7');
|
|
x.type = 'button';
|
|
x.setAttribute('aria-label', 'Close ' + title);
|
|
x.onclick = function (ev) { ev.stopPropagation(); closeTab(t.id); };
|
|
d.appendChild(x);
|
|
d.onclick = function () { activate(t.id); };
|
|
d.onauxclick = function (ev) { if (ev.button === 1) { ev.preventDefault(); closeTab(t.id); } };
|
|
d.onkeydown = function (ev) {
|
|
var i = S.tabs.indexOf(t), n = S.tabs.length;
|
|
if (ev.key === 'ArrowRight') { ev.preventDefault(); activate(S.tabs[(i + 1) % n].id, false); $('tabs').querySelector('[aria-selected=true]').focus(); }
|
|
else if (ev.key === 'ArrowLeft') { ev.preventDefault(); activate(S.tabs[(i + n - 1) % n].id, false); $('tabs').querySelector('[aria-selected=true]').focus(); }
|
|
else if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); activate(t.id); }
|
|
};
|
|
bar.appendChild(d);
|
|
if (on) activeEl = d;
|
|
});
|
|
if (activeEl && activeEl.scrollIntoView) activeEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
}
|
|
|
|
function toggleKind(t) {
|
|
t.kind = t.kind === 'grammar' ? 'input' : 'grammar';
|
|
delete S.results[t.id];
|
|
S.grev++;
|
|
ensureLast();
|
|
paintEditor(t.ed);
|
|
if (t.id === S.active) activate(t.id, false); else renderTabs();
|
|
refreshPanels();
|
|
scheduleRun(200);
|
|
}
|
|
|
|
function closeTab(id, force) {
|
|
var t = getTab(id);
|
|
if (!t) return Promise.resolve(true);
|
|
function proceed() {
|
|
var idx = S.tabs.indexOf(t);
|
|
S.tabs.splice(idx, 1);
|
|
t.ed.root.remove();
|
|
delete S.results[id];
|
|
if (t.kind === 'grammar') S.grev++;
|
|
if (S.lastGrammar === id) S.lastGrammar = null;
|
|
if (S.lastInput === id) S.lastInput = null;
|
|
ensureLast();
|
|
if (S.active === id) {
|
|
var next = S.tabs[Math.min(idx, S.tabs.length - 1)];
|
|
S.active = null;
|
|
if (next) activate(next.id); else showEmpty();
|
|
} else {
|
|
renderTabs();
|
|
refreshPanels();
|
|
}
|
|
rememberSession();
|
|
scheduleRun(300);
|
|
}
|
|
if (!t.dirty || force) { proceed(); return Promise.resolve(true); }
|
|
return ask('Unsaved changes', '"' + tabTitle(t) + '" has unsaved changes.',
|
|
[{ label: 'Save', value: 'save', primary: true }, { label: "Don't save", value: 'discard' }, { label: 'Cancel', value: null }])
|
|
.then(function (v) {
|
|
if (v === 'discard') { proceed(); return true; }
|
|
if (v === 'save') return saveTab(t).then(function (ok) { if (ok) proceed(); return ok; });
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function updatePos() {
|
|
var t = getTab(S.active);
|
|
var p = $('pos');
|
|
if (!t) { p.textContent = ''; return; }
|
|
var c = caretInfo(t.ed);
|
|
p.textContent = 'Ln ' + c.line + ', Col ' + c.col;
|
|
}
|
|
|
|
function rememberSession() {
|
|
if (!S.info) return;
|
|
var active = getTab(S.active);
|
|
store.set('session:' + S.info.root, {
|
|
open: S.tabs.filter(function (t) { return t.path; }).map(function (t) { return { path: t.path, kind: t.kind }; }),
|
|
active: active ? active.path : ''
|
|
});
|
|
}
|
|
|
|
/* ========================================================== files on disk */
|
|
function openFile(path, o) {
|
|
var ex = null;
|
|
S.tabs.forEach(function (t) { if (t.path === path) ex = t; });
|
|
if (ex) { activate(ex.id); return Promise.resolve(ex); }
|
|
return api('GET', '/api/file?path=' + encodeURIComponent(path)).then(function (f) {
|
|
var t = addTab({ kind: (o && o.kind) || (isGrammarFile(f.path) ? 'grammar' : 'input'), path: f.path, text: f.text,
|
|
etag: f.etag, eol: f.eol, bom: f.bom });
|
|
S.lastDir = dirname(f.path);
|
|
if (f.lossy) toast(basename(f.path) + ' contains bytes that are not valid UTF-8; they were replaced.', 'error');
|
|
activate(t.id, !(o && o.noFocus));
|
|
return t;
|
|
}).catch(function (e) {
|
|
toast('Cannot open ' + path + ': ' + e.message, 'error');
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function saveError(e) {
|
|
toast('Save failed: ' + e.message, 'error');
|
|
return false;
|
|
}
|
|
|
|
function doSave(t, path, extra) {
|
|
var text = t.ed.ta.value;
|
|
var body = { path: path, text: text, eol: t.eol, bom: t.bom };
|
|
Object.keys(extra || {}).forEach(function (k) { body[k] = extra[k]; });
|
|
return api('POST', '/api/save', body).then(function (r) {
|
|
t.path = r.path;
|
|
t.saved = text;
|
|
t.etag = r.etag;
|
|
t.dirty = t.ed.ta.value !== text;
|
|
t.banner = '';
|
|
S.lastDir = dirname(r.path);
|
|
renderTabs();
|
|
updateBanner(t);
|
|
rememberSession();
|
|
toast('Saved ' + basename(r.path));
|
|
var gn = t.kind === 'grammar' ? grammarName(text) : '';
|
|
if (gn && gn !== stem(basename(r.path))) {
|
|
toast('ANTLR expects the file to be called ' + gn + '.g4 (the grammar is named ' + gn + ').', 'error');
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function saveTab(t) {
|
|
if (!t.path) return saveAs(t);
|
|
return doSave(t, t.path, { baseEtag: t.etag }).catch(function (e) {
|
|
if (e.status === 409 && e.data && e.data.changed) {
|
|
return ask('File changed on disk', basename(t.path) + ' was modified outside this editor after you opened it. Saving now overwrites those changes.',
|
|
[{ label: 'Overwrite', value: 'o', primary: true }, { label: 'Cancel', value: null }]).then(function (v) {
|
|
if (v !== 'o') return false;
|
|
return doSave(t, t.path, { force: true }).catch(saveError);
|
|
});
|
|
}
|
|
return saveError(e);
|
|
});
|
|
}
|
|
|
|
function saveAs(t) {
|
|
var suggest = t.path ? basename(t.path) : (t.kind === 'grammar' ? tabTitle(t) : 'input' + t.num + '.txt');
|
|
return fileDialog({ title: 'Save as', mode: 'save', dir: t.path ? dirname(t.path) : S.lastDir, name: suggest, okLabel: 'Save' }).then(function (path) {
|
|
if (!path) return false;
|
|
return doSave(t, path, { create: true }).catch(function (e) {
|
|
if (e.status === 409 && e.data && e.data.exists) {
|
|
return ask('Replace file?', path + ' already exists. Replace it?', [{ label: 'Replace', value: 'r', primary: true }, { label: 'Cancel', value: null }])
|
|
.then(function (v) { return v === 'r' ? doSave(t, path, {}).catch(saveError) : false; });
|
|
}
|
|
return saveError(e);
|
|
});
|
|
});
|
|
}
|
|
|
|
function saveAll() {
|
|
var chain = Promise.resolve(true);
|
|
S.tabs.filter(function (t) { return t.dirty; }).forEach(function (t) { chain = chain.then(function () { return saveTab(t); }); });
|
|
return chain;
|
|
}
|
|
|
|
function reloadTab(t, quiet) {
|
|
if (!t.path) return Promise.resolve(false);
|
|
return api('GET', '/api/file?path=' + encodeURIComponent(t.path)).then(function (f) {
|
|
var caret = Math.min(t.ed.ta.selectionStart, f.text.length);
|
|
setEditorText(t.ed, f.text);
|
|
t.ed.ta.setSelectionRange(caret, caret);
|
|
t.saved = f.text;
|
|
t.etag = f.etag;
|
|
t.eol = f.eol;
|
|
t.bom = f.bom;
|
|
t.dirty = false;
|
|
t.banner = '';
|
|
t.rev++;
|
|
if (t.kind === 'grammar') S.grev++;
|
|
renderTabs();
|
|
updateBanner(t);
|
|
refreshPanels();
|
|
toast((quiet ? 'Reloaded ' + basename(t.path) + ' (changed on disk)' : 'Reloaded ' + basename(t.path)));
|
|
if (t.kind === 'grammar' || t.id === S.lastInput) scheduleRun(300);
|
|
return true;
|
|
}).catch(function (e) {
|
|
toast('Cannot reload ' + t.path + ': ' + e.message, 'error');
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function reloadActive() {
|
|
var t = getTab(S.active);
|
|
if (!t || !t.path) return;
|
|
if (!t.dirty) { reloadTab(t, false); return; }
|
|
ask('Reload from disk', 'Discard your unsaved changes to ' + tabTitle(t) + ' and reload it?', [{ label: 'Reload', value: 'r', primary: true }, { label: 'Cancel', value: null }])
|
|
.then(function (v) { if (v === 'r') reloadTab(t, false); });
|
|
}
|
|
|
|
function newGrammar() {
|
|
var t = addTab({ kind: 'grammar', text: NEW_GRAMMAR });
|
|
activate(t.id);
|
|
scheduleRun(300);
|
|
}
|
|
function newInput() {
|
|
var t = addTab({ kind: 'input', text: '' });
|
|
activate(t.id);
|
|
}
|
|
|
|
function updateBanner(t) {
|
|
var b = t.ed.banner;
|
|
clear(b);
|
|
if (!t.banner) { b.hidden = true; return; }
|
|
b.hidden = false;
|
|
function btn(label, fn) { var x = el('button', 'btn sm', label); x.type = 'button'; x.onclick = fn; return x; }
|
|
if (t.banner === 'deleted') {
|
|
b.appendChild(el('span', '', 'This file was deleted on disk.'));
|
|
b.appendChild(btn('Keep editing', function () { t.banner = ''; t.etag = ''; updateBanner(t); }));
|
|
} else {
|
|
b.appendChild(el('span', '', 'This file changed on disk.'));
|
|
b.appendChild(btn('Reload', function () { reloadTab(t, false); }));
|
|
b.appendChild(btn('Keep my version', function () { t.etag = t.diskEtag; t.banner = ''; updateBanner(t); }));
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------ external change polling */
|
|
var polling = false;
|
|
function pollDisk() {
|
|
if (polling || document.hidden) return;
|
|
var paths = S.tabs.filter(function (t) { return t.path; }).map(function (t) { return t.path; });
|
|
if (!paths.length) return;
|
|
polling = true;
|
|
api('POST', '/api/stat', { paths: paths }).then(function (r) {
|
|
S.tabs.forEach(function (t) {
|
|
if (!t.path || !(t.path in r.etags)) return;
|
|
var tag = r.etags[t.path];
|
|
if (tag === t.etag) return;
|
|
if (tag === null) {
|
|
if (t.banner !== 'deleted' && t.etag) { t.banner = 'deleted'; updateBanner(t); }
|
|
return;
|
|
}
|
|
if (!t.dirty) { reloadTab(t, true); return; }
|
|
if (t.diskEtag !== tag || t.banner !== 'changed') { t.diskEtag = tag; t.banner = 'changed'; updateBanner(t); }
|
|
});
|
|
}).catch(function () { /* server gone or busy: try again later */ }).then(function () { polling = false; });
|
|
}
|
|
setInterval(pollDisk, 2000);
|
|
window.addEventListener('focus', pollDisk);
|
|
window.addEventListener('beforeunload', function (ev) {
|
|
if (S.tabs.some(function (t) { return t.dirty; })) { ev.preventDefault(); ev.returnValue = ''; }
|
|
});
|
|
|
|
/* ======================================================== open / save dialog */
|
|
function fileDialog(o) {
|
|
var cur = o.dir !== undefined && o.dir !== null ? o.dir : (S.lastDir || '');
|
|
var chosen = '';
|
|
var entries = [];
|
|
var crumbs = el('div', 'crumbs');
|
|
var list = el('ul', 'flist');
|
|
list.setAttribute('role', 'listbox');
|
|
var filter = null, nameIn = null, ctxRef = null;
|
|
|
|
function load(dir) {
|
|
api('GET', '/api/dir?path=' + encodeURIComponent(dir)).then(function (d) {
|
|
cur = d.path;
|
|
entries = d.entries;
|
|
chosen = '';
|
|
drawCrumbs();
|
|
drawList();
|
|
}).catch(function (e) {
|
|
toast(e.message, 'error');
|
|
if (dir) load('');
|
|
});
|
|
}
|
|
function crumb(label, path) {
|
|
var b = el('button', '', label);
|
|
b.type = 'button';
|
|
b.onclick = function () { load(path); };
|
|
return b;
|
|
}
|
|
function drawCrumbs() {
|
|
clear(crumbs);
|
|
crumbs.appendChild(crumb(S.info.rootName || 'workspace', ''));
|
|
var acc = '';
|
|
(cur ? cur.split('/') : []).forEach(function (p) {
|
|
acc = joinPath(acc, p);
|
|
crumbs.appendChild(el('span', 'muted', '/'));
|
|
crumbs.appendChild(crumb(p, acc));
|
|
});
|
|
}
|
|
function drawList() {
|
|
clear(list);
|
|
var q = filter ? filter.value.toLowerCase() : '';
|
|
var shown = entries.filter(function (e) { return !q || e.name.toLowerCase().indexOf(q) >= 0; });
|
|
if (!shown.length) list.appendChild(el('li', 'muted', entries.length ? 'No matches' : 'Empty folder'));
|
|
shown.forEach(function (e) {
|
|
var li = el('li');
|
|
li.setAttribute('role', 'option');
|
|
li.tabIndex = 0;
|
|
li.appendChild(el('span', '', (e.dir ? '\u25b8 ' : '') + e.name));
|
|
if (!e.dir) li.appendChild(el('span', 'sz', human(e.size)));
|
|
var path = joinPath(cur, e.name);
|
|
function select() {
|
|
Array.prototype.forEach.call(list.children, function (c) { c.removeAttribute('aria-selected'); });
|
|
li.setAttribute('aria-selected', 'true');
|
|
chosen = path;
|
|
if (nameIn) nameIn.value = e.name;
|
|
}
|
|
li.onclick = function () { if (e.dir) load(path); else select(); };
|
|
li.ondblclick = function () { if (!e.dir) { select(); ctxRef.close('ok'); } };
|
|
li.onkeydown = function (ev) {
|
|
if (ev.key === 'Enter') { ev.preventDefault(); ev.stopPropagation(); if (e.dir) load(path); else { select(); ctxRef.close('ok'); } }
|
|
else if (ev.key === ' ') { ev.preventDefault(); if (e.dir) load(path); else select(); }
|
|
else if (ev.key === 'ArrowDown' && li.nextSibling) { ev.preventDefault(); li.nextSibling.focus(); }
|
|
else if (ev.key === 'ArrowUp' && li.previousSibling) { ev.preventDefault(); li.previousSibling.focus(); }
|
|
};
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
|
|
return modal({
|
|
title: o.title,
|
|
build: function (ctx) {
|
|
ctxRef = ctx;
|
|
var wrap = el('div');
|
|
wrap.appendChild(crumbs);
|
|
if (o.mode === 'open') {
|
|
filter = el('input');
|
|
filter.type = 'text';
|
|
filter.placeholder = 'Filter';
|
|
filter.setAttribute('aria-label', 'Filter files');
|
|
filter.setAttribute('data-focus', '1');
|
|
filter.oninput = drawList;
|
|
filter.onkeydown = function (ev) { if (ev.key === 'ArrowDown' && list.firstChild) { ev.preventDefault(); list.firstChild.focus(); } };
|
|
wrap.appendChild(filter);
|
|
}
|
|
wrap.appendChild(list);
|
|
if (o.mode === 'save') {
|
|
var lab = el('p', 'muted', 'File name');
|
|
lab.style.margin = '10px 0 4px';
|
|
wrap.appendChild(lab);
|
|
nameIn = el('input');
|
|
nameIn.type = 'text';
|
|
nameIn.value = o.name || '';
|
|
nameIn.setAttribute('aria-label', 'File name');
|
|
nameIn.setAttribute('data-focus', '1');
|
|
wrap.appendChild(nameIn);
|
|
setTimeout(function () { nameIn.focus(); nameIn.select(); }, 0);
|
|
}
|
|
load(cur);
|
|
return wrap;
|
|
},
|
|
buttons: [
|
|
{ label: 'Cancel', value: null },
|
|
{ label: o.okLabel || 'Open', value: 'ok', primary: true, check: function () {
|
|
if (o.mode === 'open' && !chosen) { toast('Select a file first'); return false; }
|
|
if (o.mode === 'save') {
|
|
var n = nameIn.value.trim();
|
|
if (!n || /[\/\\]/.test(n)) { toast('Enter a file name without slashes'); return false; }
|
|
}
|
|
return true;
|
|
} }
|
|
]
|
|
}).then(function (v) {
|
|
if (v !== 'ok') return null;
|
|
S.lastDir = cur;
|
|
return o.mode === 'open' ? chosen : joinPath(cur, nameIn.value.trim());
|
|
});
|
|
}
|
|
|
|
function openDialog() {
|
|
var t = getTab(S.active);
|
|
fileDialog({ title: 'Open file', mode: 'open', dir: t && t.path ? dirname(t.path) : S.lastDir, okLabel: 'Open' }).then(function (p) {
|
|
if (p) openFile(p);
|
|
});
|
|
}
|