This commit is contained in:
Dmitry Vasilev
2022-09-10 02:48:13 +08:00
commit fad075ad37
45 changed files with 38501 additions and 0 deletions

173
src/editor/calltree.js Normal file
View File

@@ -0,0 +1,173 @@
import {exec} from '../index.js'
import {el, stringify, fn_link, scrollIntoViewIfNeeded} from './domutils.js'
import {FLAGS} from '../feature_flags.js'
import {stringify_for_header} from './value_explorer.js'
import {find_node} from '../ast_utils.js'
import {is_expandable, root_calltree_node} from '../calltree.js'
// TODO perf - quadratic difficulty
const join = arr => arr.reduce(
(acc, el) => acc.length == 0
? [el]
: [...acc, ',', el],
[],
)
export class CallTree {
constructor(ui, container) {
this.ui = ui
this.container = container
this.container.addEventListener('keydown', (e) => {
// Do not scroll
e.preventDefault()
if(e.key == 'F1') {
this.ui.editor.focus()
}
if(e.key == 'F2') {
this.ui.editor.focus_value_explorer(this.container)
}
if(e.key == 'a') {
if(FLAGS.embed_value_explorer) {
exec('calltree.select_arguments')
} else {
// TODO make clear that arguments are shown
this.ui.eval.show_value(this.state.current_calltree_node.args)
this.ui.eval.focus_value_or_error(this.container)
}
}
if(e.key == 'r' || e.key == 'Enter') {
if(FLAGS.embed_value_explorer) {
exec('calltree.select_return_value')
} else {
// TODO make clear that return value is shown
this.ui.eval.show_value_or_error(this.state.current_calltree_node)
this.ui.eval.focus_value_or_error(this.container)
}
}
if(e.key == 'ArrowDown' || e.key == 'j'){
exec('calltree.arrow_down')
}
if(e.key == 'ArrowUp' || e.key == 'k'){
exec('calltree.arrow_up')
}
if(e.key == 'ArrowLeft' || e.key == 'h'){
exec('calltree.arrow_left')
}
if(e.key == 'ArrowRight' || e.key == 'l'){
exec('calltree.arrow_right')
}
})
}
on_click_node(id) {
exec('calltree.click', id)
}
clear_calltree(){
this.container.innerHTML = ''
this.node_to_el = new Map()
this.state = null
}
render_node(n, current_node){
const is_expanded = this.state.calltree_node_is_expanded[n.id]
const result = el('div', 'callnode',
el('div', {
'class': (n == current_node ? 'call_el active' : 'call_el'),
click: () => this.on_click_node(n.id),
},
!is_expandable(n)
? '\xa0'
: is_expanded ? '▼' : '▶',
n.toplevel
? el('span', '',
el('i', '',
'toplevel: ' + (n.module == '' ? '*scratch*' : n.module),
),
n.ok ? '' : el('span', 'call_header error', '\xa0', n.error.toString()),
)
: el('span',
'call_header '
+ (n.ok ? '' : 'error')
+ (n.fn.__location == null ? ' native' : '')
,
// TODO show `this` argument
n.fn.__location == null
? fn_link(n.fn)
: n.fn.name
,
'(' ,
...join(
n.args.map(
a => typeof(a) == 'function'
? fn_link(a)
: stringify_for_header(a)
)
),
')' ,
// TODO: show error message only where it was thrown, not every frame?
': ', (n.ok ? stringify_for_header(n.value) : n.error.toString())
),
),
(n.children == null || !is_expanded)
? null
: n.children.map(c => this.render_node(c, current_node))
)
this.node_to_el.set(n.id, result)
result.is_expanded = is_expanded
return result
}
render_active(node, is_active) {
const dom = this.node_to_el.get(node.id).getElementsByClassName('call_el')[0]
if(is_active) {
dom.classList.add('active')
} else {
dom.classList.remove('active')
}
}
render_select_node(state) {
this.render_active(this.state.current_calltree_node, false)
this.state = state
this.render_active(this.state.current_calltree_node, true)
scrollIntoViewIfNeeded(
this.container,
this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0]
)
}
render_expand_node(state) {
this.state = state
const current_node = this.state.current_calltree_node
const prev_dom_node = this.node_to_el.get(current_node.id)
const next = this.render_node(current_node, current_node)
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
}
// TODO on hover highlight line where function defined/
// TODO hover ?
render_calltree(state){
this.clear_calltree()
this.state = state
const root = root_calltree_node(this.state)
const current_node = state.current_calltree_node
this.container.appendChild(this.render_node(root, current_node))
this.render_select_node(state, root, current_node)
}
}

117
src/editor/domutils.js Normal file
View File

@@ -0,0 +1,117 @@
export function el(tag, className, ...children){
const result = document.createElement(tag)
if(typeof(className) == 'string'){
result.setAttribute('class', className)
} else {
const attrs = className
for(let attrName in attrs){
const value = attrs[attrName]
if(['change','click'].includes(attrName)){
result.addEventListener(attrName, value)
} else if(attrName == 'checked') {
if(attrs[attrName]){
result.setAttribute(attrName, "checked")
}
} else {
result.setAttribute(attrName, value)
}
}
}
children.forEach(child => {
const append = child => {
if(typeof(child) == 'undefined') {
throw new Error('illegal state')
} else if(child !== null) {
result.appendChild(
typeof(child) == 'string'
? document.createTextNode(child)
: child
)
}
}
if(Array.isArray(child)) {
child.forEach(append)
} else {
append(child)
}
})
return result
}
export function stringify(val){
function fn_to_str(fn){
// TODO if name is 'anonymous', then change name for code
return fn.__location == null
? `<span>${fn.name}</span>`
: `<a
href='javascript:void(0)'
data-location=${JSON.stringify(fn.__location)}
><i>fn</i> ${fn.name}</a>`
}
if(typeof(val) == 'undefined') {
return 'undefined'
} else if(typeof(val) == 'function'){
return fn_to_str(val)
} else {
return JSON.stringify(val, (key, value) => {
if(typeof(value) == 'function'){
return fn_to_str(value)
} else {
return value
}
})
}
}
export function fn_link(fn){
const str = stringify(fn)
const c = document.createElement('div')
c.innerHTML = str
return c.children[0]
}
// Idea is borrowed from:
// https://mhk-bit.medium.com/scroll-into-view-if-needed-10a96e0bdb61
// https://stackoverflow.com/questions/37137450/scroll-all-nested-scrollbars-to-bring-an-html-element-into-view
export const scrollIntoViewIfNeeded = (container, target) => {
// Target is outside the viewport from the top
if(target.offsetTop - container.scrollTop - container.offsetTop < 0){
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
target.scrollIntoView(true);
// Do not scroll horizontally
container.scrollLeft = 0
}
// Target is outside the view from the bottom
if(target.offsetTop - container.scrollTop - container.offsetTop - container.clientHeight + target.clientHeight > 0) {
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
target.scrollIntoView(false);
// Do not scroll horizontally
container.scrollLeft = 0
}
/*
Also works
// Target is outside the view from the top
if (target.getBoundingClientRect().y < container.getBoundingClientRect().y) {
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
target.scrollIntoView();
}
// Target is outside the view from the bottom
if (
target.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom +
// Adjust for scrollbar size
container.offsetHeight - container.clientHeight
> 0
) {
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
target.scrollIntoView(false);
}
*/
};

469
src/editor/editor.js Normal file
View File

@@ -0,0 +1,469 @@
import {exec, get_state} from '../index.js'
import {ValueExplorer} from './value_explorer.js'
import {el, stringify, fn_link} from './domutils.js'
import {FLAGS} from '../feature_flags.js'
/*
normalize events 'change' and 'changeSelection':
- change is debounced
- changeSelection must not fire if 'change' is fired. So for every keystroke,
either change or changeSelection should be fired, not both
- changeSelection fired only once (ace fires it multiple times for single
keystroke)
*/
const normalize_events = (ace_editor, {
on_change,
on_change_selection,
is_change_selection_supressed,
on_change_immediate,
}) => {
const TIMEOUT = 1000
let state
const set_initial_state = () => {
state = {}
}
set_initial_state()
const flush = () => {
if(state.change_args != null) {
on_change(...state.change_args)
} else if(state.change_selection_args != null) {
on_change_selection(...state.change_selection_args)
}
set_initial_state()
}
ace_editor.on('change', (...args) => {
on_change_immediate()
if(state.tid != null) {
clearTimeout(state.tid)
}
state.change_args = args
state.tid = setTimeout(() => {
state.tid = null
flush()
}, TIMEOUT)
})
ace_editor.on('changeSelection', (...args) => {
if(is_change_selection_supressed()) {
return
}
if(state.tid != null) {
// flush is already by `change`, skip `changeSelection`
return
}
state.change_selection_args = args
if(!state.is_flush_set) {
state.is_flush_set = true
Promise.resolve().then(() => {
if(state.tid == null) {
flush()
}
})
}
})
}
export class Editor {
constructor(ui, editor_container){
this.ui = ui
this.editor_container = editor_container
this.markers = {}
this.sessions = {}
this.ace_editor = ace.edit(this.editor_container)
this.ace_editor.setOptions({
behavioursEnabled: false,
// Scroll past end for value explorer
scrollPastEnd: 100 /* Allows to scroll 100*<screen size> */,
})
normalize_events(this.ace_editor, {
on_change: () => {
try {
exec('input', this.ace_editor.getValue(), this.get_caret_position())
} catch(e) {
// Do not throw Error to ACE because it breaks typing
console.error(e)
this.ui.set_status(e.message)
}
},
on_change_immediate: () => {
this.update_value_explorer_margin()
},
on_change_selection: () => {
try {
if(!this.is_change_selection_supressed) {
exec('move_cursor', this.get_caret_position())
}
} catch(e) {
// Do not throw Error to ACE because it breaks typing
console.error(e)
this.ui.set_status(e.message)
}
},
is_change_selection_supressed: () => {
return this.is_change_selection_supressed
}
})
this.focus()
this.init_keyboard()
}
focus() {
this.ace_editor.focus()
}
supress_change_selection(action) {
try {
this.is_change_selection_supressed = true
action()
} finally {
this.is_change_selection_supressed = false
}
}
ensure_session(file, code) {
let session = this.sessions[file]
if(session == null) {
session = ace.createEditSession(code)
this.sessions[file] = session
session.setUseWorker(false)
session.setOptions({
mode: "ace/mode/javascript",
tabSize: 2,
useSoftTabs: true,
})
}
return session
}
get_session(file) {
return this.sessions[file]
}
switch_session(file) {
// Supress selection change triggered by switching sessions
this.supress_change_selection(() => {
this.ace_editor.setSession(this.get_session(file))
})
}
unembed_value_explorer() {
if(this.widget != null) {
this.ace_editor.getSession().widgetManager.removeLineWidget(this.widget)
this.widget = null
}
}
update_value_explorer_margin() {
if(this.widget != null) {
this.widget.content.style.marginLeft =
(this.ace_editor.getSession().getScreenWidth() + 1) + 'ch'
}
}
embed_value_explorer({index, result: {ok, value, error}}) {
this.unembed_value_explorer()
const session = this.ace_editor.getSession()
const pos = session.doc.indexToPosition(index)
const row = pos.row
const line_height = this.ace_editor.renderer.lineHeight
let content
const container = el('div', {'class': 'embed_value_explorer_container'},
el('div', {'class': 'embed_value_explorer_wrapper'},
content = el('div', {
// Ace editor cannot render widget before the first line. So we
// render in on the next line and apply translate
'style': `transform: translate(0px, -${line_height}px)`,
'class': 'embed_value_explorer_content',
tabindex: 0
})
)
)
let initial_scroll_top
const escape = () => {
if(initial_scroll_top != null) {
// restore scroll
session.setScrollTop(initial_scroll_top)
}
if(this.widget.return_to == null) {
this.focus()
} else {
this.widget.return_to.focus()
}
// TODO select root in value explorer
}
container.addEventListener('keydown', e => {
if(e.key == 'Escape') {
escape()
}
})
if(ok) {
const exp = new ValueExplorer({
container: content,
event_target: container,
on_escape: escape,
scroll_to_element: t => {
if(initial_scroll_top == null) {
initial_scroll_top = session.getScrollTop()
}
let scroll
const out_of_bottom = t.getBoundingClientRect().bottom - this.editor_container.getBoundingClientRect().bottom
if(out_of_bottom > 0) {
session.setScrollTop(session.getScrollTop() + out_of_bottom)
}
const out_of_top = this.editor_container.getBoundingClientRect().top - t.getBoundingClientRect().top
if(out_of_top > 0) {
session.setScrollTop(session.getScrollTop() - out_of_top)
}
},
})
exp.render(value)
} else {
content.appendChild(el('span', 'eval_error', error.toString()))
}
this.widget = {
row,
fixedWidth: true,
el: container,
content,
}
this.update_value_explorer_margin()
const LineWidgets = require("ace/line_widgets").LineWidgets;
if (!session.widgetManager) {
session.widgetManager = new LineWidgets(session);
session.widgetManager.attach(this.ace_editor);
}
session.widgetManager.addLineWidget(this.widget)
}
focus_value_explorer(return_to) {
if(FLAGS.embed_value_explorer) {
if(this.widget != null) {
this.widget.return_to = return_to
this.widget.content.focus({preventScroll: true})
}
} else {
if(get_state().selection_state != null) {
this.ui.eval.focus_value_or_error()
}
}
}
set_keyboard_handler(type) {
if(type != null) {
localStorage.keyboard = type
}
this.ace_editor.setKeyboardHandler(
type == 'vim' ? "ace/keyboard/vim" : null
)
}
init_keyboard(){
this.set_keyboard_handler(localStorage.keyboard)
const VimApi = require("ace/keyboard/vim").CodeMirror.Vim
this.ace_editor.commands.bindKey("F1", "switch_window");
VimApi._mapCommand({
keys: '<C-w>',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "switch_window" }
})
this.ace_editor.commands.addCommand({
name: 'switch_window',
exec: (editor) => {
this.ui.calltree_container.focus()
}
})
this.ace_editor.commands.bindKey("F3", "goto_definition");
VimApi._mapCommand({
keys: 'gd',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "goto_definition" }
})
this.ace_editor.commands.addCommand({
name: 'goto_definition',
exec: (editor) => {
this.goto_definition()
}
})
this.ace_editor.commands.bindKey("F2", "focus_value_explorer");
this.ace_editor.commands.addCommand({
name: 'focus_value_explorer',
exec: (editor) => {
this.focus_value_explorer()
}
})
this.ace_editor.commands.bindKey("ctrl-i", 'step_into')
VimApi._mapCommand({
keys: '\\i',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "step_into" }
})
this.ace_editor.commands.addCommand({
name: 'step_into',
exec: (editor) => {
exec('step_into', this.get_caret_position())
}
})
this.ace_editor.commands.bindKey("ctrl-o", 'step_out')
VimApi._mapCommand({
keys: '\\o',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "step_out" }
})
this.ace_editor.commands.addCommand({
name: 'step_out',
exec: (editor) => {
exec('calltree.arrow_left')
}
})
this.ace_editor.commands.addCommand({
name: 'expand_selection',
exec: () => {
exec('eval_selection', this.get_caret_position(), true)
}
})
this.ace_editor.commands.addCommand({
name: 'collapse_selection',
exec: () => {
exec('eval_selection', this.get_caret_position(), false)
}
})
this.ace_editor.commands.bindKey("ctrl-j", 'expand_selection')
this.ace_editor.commands.bindKey("ctrl-down", 'expand_selection')
this.ace_editor.commands.bindKey("ctrl-k", 'collapse_selection')
this.ace_editor.commands.bindKey("ctrl-up", 'collapse_selection')
this.ace_editor.commands.addCommand({
name: 'edit',
exec: (editor, input) => {
const module = input.args == null ? '' : input.args[0]
exec('change_current_module', module)
}
})
VimApi.defineEx("edit", "e", function(cm, input) {
cm.ace.execCommand("edit", input)
})
// TODO remove my custom binding
VimApi.map('jj', '<Esc>', 'insert')
}
add_marker(file, className, from, to){
const session = this.get_session(file)
const from_pos = session.doc.indexToPosition(from)
const to_pos = session.doc.indexToPosition(to)
const markerId = session.addMarker(
new ace.Range(from_pos.row,from_pos.column,to_pos.row,to_pos.column),
className
)
if(this.markers[file] == null){
this.markers[file] = []
}
this.markers[file].push({className, from, to, markerId})
}
remove_markers_of_type(file, type){
if(this.markers[file] == null){
this.markers[file] = []
}
const for_removal = this.markers[file].filter(h => h.className == type)
const session = this.get_session(file)
for(let marker of for_removal){
session.removeMarker(marker.markerId)
}
this.markers[file] = this.markers[file].filter(h => h.className != type)
}
get_caret_position(file){
const session = file == null
? this.ace_editor.getSession()
: this.get_session(file)
// Session was not created for file
if(session == null) {
return null
}
return session.doc.positionToIndex(session.selection.getCursor())
}
set_caret_position(index){
if(index == null) {
throw new Error('illegal state')
}
const pos = this.ace_editor.session.doc.indexToPosition(index)
console.log('set caret position', index, pos)
this.supress_change_selection(() => {
const pos = this.ace_editor.session.doc.indexToPosition(index)
this.ace_editor.moveCursorToPosition(pos)
// Moving cursor performs selection, clear it
this.ace_editor.clearSelection()
const first = this.ace_editor.renderer.getFirstVisibleRow()
const last = this.ace_editor.renderer.getLastVisibleRow()
if(pos.row < first || pos.row > last) {
this.ace_editor.scrollToLine(pos.row)
}
})
}
goto_definition(){
const index = this.get_caret_position()
exec('goto_definition', index)
}
for_each_session(cb) {
for(let file in this.sessions) {
cb(file, this.sessions[file])
}
}
}

67
src/editor/eval.js Normal file
View File

@@ -0,0 +1,67 @@
import {ValueExplorer} from './value_explorer.js'
import {el} from './domutils.js'
export class Eval {
constructor(ui, container) {
this.ui = ui
this.container = container
this.container.addEventListener('keydown', (e) => {
if(e.key == 'Escape') {
this.escape()
}
})
// TODO jump to fn location, view function calls
// container.addEventListener('click', jump_to_fn_location)
}
escape() {
if(this.focusedFrom == null) {
this.ui.editor.focus()
} else {
this.focusedFrom.focus()
this.focusedFrom = null
}
}
show_value(value){
this.container.innerHTML = ''
const container = el('div', {'class': 'eval_content', tabindex: 0})
this.container.appendChild(container)
const explorer = new ValueExplorer({
container,
on_escape: () => this.escape()
})
explorer.render(value)
}
show_error(error){
this.container.innerHTML = ''
this.container.appendChild(el('span', 'eval_error', error.toString()))
}
show_value_or_error({ok, value, error}){
if(ok) {
this.show_value(value)
} else {
this.show_error(error)
}
}
clear_value_or_error() {
this.container.innerHTML = ''
}
focus_value_or_error(from) {
this.focusedFrom = from
if(this.container.childElementCount != 1) {
throw new Error('illegal state')
}
this.container.children[0].focus()
}
}

164
src/editor/files.js Normal file
View File

@@ -0,0 +1,164 @@
import {el} from './domutils.js'
import {map_find} from '../utils.js'
import {load_dir, create_file} from '../filesystem.js'
import {exec, get_state} from '../index.js'
export class Files {
constructor(ui) {
this.ui = ui
this.el = el('div', 'files_container')
this.render(get_state())
}
open_directory() {
load_dir(true).then(dir => {
exec('load_dir', dir)
})
}
render(state) {
if(state.project_dir == null) {
this.el.innerHTML = ''
this.el.appendChild(
el('div', 'allow_file_access',
el('a', {
href: 'javascript:void(0)',
click: this.open_directory.bind(this),
},
`Allow access to local project folder`,
),
el('div', 'subtitle', `Your files will never leave your device`)
)
)
} else {
this.render_files(state.project_dir, state.current_module)
}
}
render_files(dir, current_module) {
const files = this.el.querySelector('.files')
const children = [
this.render_file({name: '*scratch*', path: ''}, current_module),
this.render_file(dir, current_module),
]
if(files == null) {
this.el.innerHTML = ''
this.el.appendChild(
el('div', 'file_actions',
el('a', {
href: 'javascript: void(0)',
click: this.create_file.bind(this, false),
},
'Create file'
),
el('a', {
href: 'javascript: void(0)',
click: this.create_file.bind(this, true),
}, 'Create dir'),
)
)
this.el.appendChild(
el('div', 'files',
children
)
)
} else {
// Replace to preserve scroll position
files.replaceChildren(...children)
}
}
render_file(file, current_module) {
const result = el('div', 'file',
el('div', {
'class': 'file_title' + (file.path == current_module ? ' active' : ''),
click: e => this.on_click(e, file)
},
el('span', 'icon',
file.kind == 'directory'
? '\u{1F4C1}' // folder icon
: '\xa0',
),
file.name,
),
file.children == null
? null
: file.children.map(c => this.render_file(c, current_module))
)
if(file.path == current_module) {
this.active_el = result
this.active_file = file
}
return result
}
async create_file(is_dir) {
if(this.active_file == null) {
throw new Error('no active file')
}
let name = prompt(`Enter ${is_dir ? 'directory' : 'file'} name`)
if(name == null) {
return
}
let dir
const root = get_state().project_dir
if(this.active_file.path == '' /* scratch */) {
// Create in root directory
dir = root
} else {
if(this.active_file.kind == 'directory') {
dir = this.active_file
} else {
const find_parent = (dir, parent) => {
if(dir.path == this.active_file.path) {
return parent
}
if(dir.children == null) {
return null
}
return map_find(dir.children, c => find_parent(c, dir))
}
dir = find_parent(root)
if(dir == null) {
throw new Error('illegal state')
}
}
}
const path = dir == root ? name : dir.path + '/' + name
await create_file(path, is_dir)
// Reload all files for simplicity
load_dir(false).then(dir => {
if(is_dir) {
exec('load_dir', dir)
} else {
exec('create_file', dir, path)
}
})
}
on_click(e, file) {
e.stopPropagation()
this.active_el.querySelector('.file_title').classList.remove('active')
this.active_el = e.currentTarget.parentElement
e.currentTarget.classList.add('active')
this.active_file = file
if(file.kind != 'directory') {
exec('change_current_module', file.path)
}
}
}

267
src/editor/ui.js Normal file
View File

@@ -0,0 +1,267 @@
import {exec, get_state} from '../index.js'
import {Editor} from './editor.js'
import {Files} from './files.js'
import {CallTree} from './calltree.js'
import {Eval} from './eval.js'
import {el} from './domutils.js'
import {FLAGS} from '../feature_flags.js'
export class UI {
constructor(container, state){
this.change_entrypoint = this.change_entrypoint.bind(this)
this.files = new Files(this)
container.appendChild(
(this.root = el('div',
'root ' + (FLAGS.embed_value_explorer ? 'embed_value_explorer' : ''),
this.editor_container = el('div', 'editor_container'),
FLAGS.embed_value_explorer
? null
: (this.eval_container = el('div', {class: 'eval'})),
el('div', 'bottom',
this.calltree_container = el('div', {"class": 'calltree', tabindex: 0}),
this.problems_container = el('div', {"class": 'problems', tabindex: 0}),
this.entrypoint_select = el('div', 'entrypoint_select')
),
this.files.el,
this.statusbar = el('div', 'statusbar',
this.status = el('div', 'status'),
this.current_module = el('div', 'current_module'),
/*
// Fullscreen cancelled on escape, TODO
el('a', {
"class" : 'request_fullscreen',
href: 'javascript:void(0)',
click: e => document.body.requestFullscreen(),
},
'Fullscreen'
),
*/
this.options = el('div', 'options',
el('label', {'for': 'standard'},
el('input', {
id: 'standard',
type: 'radio',
name: 'keyboard',
checked: localStorage.keyboard == 'standard'
|| localStorage.keyboard == null,
change: () => {
this.editor.set_keyboard_handler('standard')
}
}),
'Standard'
),
el('label', {'for': 'vim'},
el('input', {
id: 'vim',
type: 'radio',
name: 'keyboard',
checked: localStorage.keyboard == 'vim',
change: () => {
this.editor.set_keyboard_handler('vim')
}
}),
'VIM'
)
),
el('a', {
'class': 'show_help',
href: 'javascript: void(0)',
click: () => this.help_dialog.showModal(),
},
'Help',
),
el('a', {
'class': 'github',
href: 'https://github.com/leporello-js/leporello-js',
target: '__blank',
}, 'Github'),
this.help_dialog = this.render_help(),
)
))
)
this.root.addEventListener('keydown', () => this.clear_status(), true)
this.root.addEventListener('click', () => this.clear_status(), true)
this.editor_container.addEventListener('keydown', e => {
if(
e.key.toLowerCase() == 'w' && e.ctrlKey == true
||
// We bind F1 later, this one to work from embed_value_explorer
e.key == 'F1'
){
this.calltree_container.focus()
}
})
this.calltree_container.addEventListener('keydown', e => {
if(
(e.key.toLowerCase() == 'w' && e.ctrlKey == true)
||
e.key == 'Escape'
){
this.editor.focus()
}
})
if(!FLAGS.embed_value_explorer) {
this.eval = new Eval(this, this.eval_container)
} else {
// Stub
this.eval = {
show_value_or_error(){},
clear_value_or_error(){},
focus_value_or_error(){},
}
}
this.editor = new Editor(this, this.editor_container)
this.calltree = new CallTree(this, this.calltree_container)
// TODO jump to another module
// TODO use exec
const jump_to_fn_location = (e) => {
let loc
if((loc = e.target.dataset.location) != null){
loc = JSON.parse(loc)
this.editor.set_caret_position(loc.index)
this.editor.focus()
}
}
// TODO when click in calltree, do not jump to location, navigateCallTree
// instead
this.calltree_container.addEventListener('click', jump_to_fn_location)
this.render_entrypoint_select(state)
this.render_current_module(state.current_module)
}
render_entrypoint_select(state) {
this.entrypoint_select.replaceChildren(
el('span', 'entrypoint_title', 'entrypoint'),
el('select', {
click: e => e.stopPropagation(),
change: this.change_entrypoint,
},
Object.keys(state.files).sort().map(f =>
el('option',
state.entrypoint == f
? { value: f, selected: true }
: { value: f},
f == '' ? "*scratch*" : f
)
)
)
)
}
change_entrypoint(e) {
const file = e.target.value
const index = this.editor.get_caret_position(file)
// if index is null, session was not created, and index after session
// creation will be 0
?? 0
exec('change_entrypoint', file, index)
this.editor.focus()
}
render_calltree(state) {
this.calltree_container.style = ''
this.problems_container.style = 'display: none'
this.calltree.render_calltree(state)
}
render_problems(problems) {
this.calltree_container.style = 'display: none'
this.problems_container.style = ''
this.problems_container.innerHTML = ''
problems.forEach(p => {
const s = this.editor.get_session(p.module)
const pos = s.doc.indexToPosition(p.index)
const module = p.module == '' ? "*scratch*" : p.module
this.problems_container.appendChild(
el('div', 'problem',
el('a', {
href: 'javascript:void(0)',
click: () => exec('goto_problem', p)
},
`${module}:${pos.row + 1}:${pos.column} - ${p.message}`
)
)
)
})
}
set_status(text){
this.current_module.style = 'display: none'
this.status.style = ''
this.status.innerText = text
}
clear_status(){
this.render_current_module(get_state().current_module)
}
render_current_module(current_module) {
this.status.style = 'display: none'
this.current_module.innerText =
current_module == ''
? '*scratch*'
: current_module
this.current_module.style = ''
}
render_help() {
const options = [
['Switch between editor and call tree', 'F1 or Ctrl-w'],
['Go from call tree to editor', 'F1 or Esc'],
['Focus value explorer', 'F2'],
['Navigate value explorer', '← → ↑ ↓ or hjkl'],
['Leave value explorer', 'Esc'],
['Jump to definition', 'F3', 'gd'],
['Expand selection to eval expression', 'Ctrl-↓ or Ctrl-j'],
['Collapse selection', 'Ctrl-↑ or Ctrl-k'],
['Navigate call tree view', '← → ↑ ↓ or hjkl'],
['Step into call', 'Ctrl-i', '\\i'],
['Step out of call', 'Ctrl-o', '\\o'],
['When in call tree view, jump to return statement', 'Enter'],
['When in call tree view, jump to function arguments', 'a'],
]
return el('dialog', 'help_dialog',
el('table', 'help',
el('thead', '',
el('th', '', 'Action'),
el('th', 'key', 'Standard'),
el('th', 'key', 'VIM'),
),
el('tbody', '',
options.map(([text, standard, vim]) =>
el('tr', '',
el('td', '', text),
el('td',
vim == null
? {'class': 'key spanned', colspan: 2}
: {'class': 'key'},
standard
),
vim == null
? null
: el('td', 'key', vim),
)
)
)
),
el('form', {method: 'dialog'},
el('button', null, 'Close'),
),
)
}
}

View File

@@ -0,0 +1,356 @@
// TODO large arrays/objects
// TODO maps, sets
// TODO show Errors in red
import {el, stringify, scrollIntoViewIfNeeded} from './domutils.js'
const displayed_entries = object => {
if(Array.isArray(object)) {
return object.map((v, i) => [i, v])
} else {
const result = Object.entries(object)
return (object instanceof Error)
? [['message', object.message], ...result]
: result
}
}
const is_expandable = v => typeof(v) == 'object'
&& v != null
&& displayed_entries(v).length != 0
export const stringify_for_header = v => {
const type = typeof(v)
if(v === null) {
return 'null'
} else if(v === undefined) {
return 'undefined'
} else if(type == 'function') {
// TODO clickable link, 'fn', cursive
return 'fn ' + v.name
} else if(v instanceof Error) {
return v.toString()
} else if(type == 'object') {
if(Array.isArray(v)) {
if(v.length == 0) {
return '[]'
} else {
return '[…]'
}
} else {
if(displayed_entries(v).length == 0) {
return '{}'
} else {
return '{…}'
}
}
} else if(type == 'string') {
return JSON.stringify(v)
} else {
return v.toString()
}
}
const header = object => {
if(typeof(object) == 'undefined') {
return 'undefined'
} else if(object == null) {
return 'null'
} else if(typeof(object) == 'object') {
if(object instanceof Error) {
return object.toString()
} else if(Array.isArray(object)) {
return '['
+ object
.map(stringify_for_header)
.join(', ')
+ ']'
} else {
const inner = displayed_entries(object)
.map(([k,v]) => {
const value = stringify_for_header(v)
return `${k}: ${value}`
})
.join(', ')
return `{${inner}}`
}
} else if(typeof(object) == 'function') {
// TODO clickable link, 'fn', cursive
return 'fn ' + object.name
} else if(typeof(object) == 'string') {
return JSON.stringify(object)
} else {
return object.toString()
}
return header
}
const get_path = (o, path) => {
if(path.length == 0) {
return o
} else {
const [start, ...rest] = path
return get_path(o[start], rest)
}
}
export class ValueExplorer {
constructor({
container,
event_target = container,
scroll_to_element,
on_escape = () => {},
} = {}
) {
this.container = container
this.scroll_to_element = scroll_to_element
this.on_escape = on_escape
event_target.addEventListener('keydown', (e) => {
/*
Right -
- does not has children - nothing
- has children - first click expands, second jumps to first element
Left -
- root - nothing
- not root collapse node, goes to parent if already collapsed
Up - goes to prev visible element
Down - goes to next visible element
Click - select and toggles expand
*/
const current_object = get_path(this.value, this.current_path)
if(e.key == 'ArrowDown' || e.key == 'j'){
// Do not scroll
e.preventDefault()
if(is_expandable(current_object) && this.is_expanded(this.current_path)) {
this.select_path(this.current_path.concat(
displayed_entries(current_object)[0][0]
))
} else {
const next = p => {
if(p.length == 0) {
return null
}
const parent = p.slice(0, p.length - 1)
const children = displayed_entries(get_path(this.value, parent))
const child_index = children.findIndex(([k,v]) =>
k == p[p.length - 1]
)
const next_child = children[child_index + 1]
if(next_child == null) {
return next(parent)
} else {
return [...parent, next_child[0]]
}
}
const next_path = next(this.current_path)
if(next_path != null) {
this.select_path(next_path)
}
}
}
if(e.key == 'ArrowUp' || e.key == 'k'){
// Do not scroll
e.preventDefault()
if(this.current_path.length == 0) {
this.on_escape()
return
}
const parent = this.current_path.slice(0, this.current_path.length - 1)
const children = displayed_entries(get_path(this.value, parent))
const child_index = children.findIndex(([k,v]) =>
k == this.current_path[this.current_path.length - 1]
)
const next_child = children[child_index - 1]
if(next_child == null) {
this.select_path(parent)
} else {
const last = p => {
if(!is_expandable(get_path(this.value, p)) || !this.is_expanded(p)) {
return p
} else {
const children = displayed_entries(get_path(this.value, p))
.map(([k,v]) => k)
return last([...p, children[children.length - 1]])
}
}
this.select_path(last([...parent, next_child[0]]))
}
}
if(e.key == 'ArrowLeft' || e.key == 'h'){
// Do not scroll
e.preventDefault()
const is_expanded = this.is_expanded(this.current_path)
if(!is_expandable(current_object) || !is_expanded) {
if(this.current_path.length != 0) {
const parent = this.current_path.slice(0, this.current_path.length - 1)
this.select_path(parent)
} else {
this.on_escape()
}
} else {
this.toggle_expanded()
}
}
if(e.key == 'ArrowRight' || e.key == 'l'){
// Do not scroll
e.preventDefault()
if(is_expandable(current_object)) {
const is_expanded = this.is_expanded(this.current_path)
if(!is_expanded) {
this.toggle_expanded()
} else {
const children = displayed_entries(get_path(this.value, this.current_path))
this.select_path(
[
...this.current_path,
children[0][0],
]
)
}
}
}
})
}
get_node_data(path, node_data = this.node_data) {
if(path.length == 0) {
return node_data
} else {
const [start, ...rest] = path
return this.get_node_data(rest, node_data.children[start])
}
}
is_expanded(path) {
return this.get_node_data(path).is_expanded
}
on_click(path) {
this.select_path(path)
this.toggle_expanded()
}
clear() {
this.container.innerHTML = ''
this.node_data = {is_expanded: true}
}
render(value) {
this.clear()
this.value = value
const path = []
this.container.appendChild(this.render_value_explorer_node(null, value, path, this.node_data))
this.select_path(path)
}
select_path(current_path) {
if(this.current_path != null) {
this.set_active(this.current_path, false)
}
this.current_path = current_path
this.set_active(this.current_path, true)
// Check that was already added to document
if(document.contains(this.container)) {
const target = this.get_node_data(current_path).el.getElementsByClassName('value_explorer_header')[0]
if(this.scroll_to_element == null) {
scrollIntoViewIfNeeded(this.container.parentNode, target)
} else {
this.scroll_to_element(target)
}
}
}
set_active(path, is_active) {
const el = this.get_node_data(path).el.getElementsByClassName('value_explorer_header')[0]
if(is_active) {
el.classList.add('active')
} else {
el.classList.remove('active')
}
}
set_expanded(fn) {
if(typeof(fn) == 'boolean') {
return this.set_expanded(() => fn)
}
const val = this.is_expanded(this.current_path)
const data = this.get_node_data(this.current_path)
data.is_expanded = fn(data.is_expanded)
const prev_dom_node = data.el
const key = this.current_path.length == 0
? null
: this.current_path[this.current_path.length - 1]
const value = get_path(this.value, this.current_path)
const next = this.render_value_explorer_node(key, value, this.current_path, data)
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
}
toggle_expanded() {
this.set_expanded(e => !e)
this.set_active(this.current_path, true)
}
render_value_explorer_node(key, value, path, node_data) {
const is_exp = is_expandable(value)
const is_expanded = is_exp && node_data.is_expanded
node_data.children = {}
const result = el('div', 'value_explorer_node',
el('span', {
class: 'value_explorer_header',
click: this.on_click.bind(this, path),
},
is_exp
? (is_expanded ? '▼' : '▶')
: '\xa0',
key == null
? null
: el('span', 'value_explorer_key', key.toString(), ': '),
key == null || !is_exp || !is_expanded
// Full header
? header(value)
// Short header
: Array.isArray(value)
? 'Array(' + value.length + ')'
: ''
),
(is_exp && is_expanded)
? displayed_entries(value).map(([k,v]) => {
node_data.children[k] = {}
return this.render_value_explorer_node(k, v, [...path, k], node_data.children[k])
})
: []
)
node_data.el = result
return result
}
}