diff --git a/src/index.js b/src/index.js index 5e96f83..21109c8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,145 +1,161 @@ const blessed = require("neo-blessed") -const { reduce } = require("m.xyz") +const { converge, reduce, read, pipe, map, count, max } = require("m.xyz") const { fork } = require("child_process") -const pkg = require("../package.json") const projectPkg = require(`${process.cwd()}/package.json`) +const { filesUI } = require("./ui.files/files") +const { resultUI } = require("./ui.result/result") +const { commandUI } = require("./ui.cli/cli") -const fileList = require("./widgets/file.list") -const cliInput = require("./widgets/cli.input") -const resultTextbox = require("./widgets/result.textbox") +const { FileList } = require("./ui.files/files.list") +const { store } = require("./index.state") -const executor = fork(`${__dirname}/lib/executor.js`) +// +module.exports = ({ requireModules, fileGlob }) => { + const executorRunArgs = reduce( + (acc, item) => [...acc, "-r", item], + [], + requireModules + ) + + // Separate node process to offload test file execution + const executor = fork(`${__dirname}/lib/executor.js`, executorRunArgs) + + executor.on("message", ({ path, stdout, stderr, code }) => { + FileList.update(path, { + isRunning: false, + stdout, + stderr, + code, + }) + }) -const screen = blessed.screen({ - title: `${pkg.name} v${pkg.version}`, + /** + * + */ - // The width of tabs within an element's content - tabSize: 2, + const screen = blessed.screen({ + title: `${projectPkg.name} v${projectPkg.version}`, - // Automatically position child elements with border and padding in mind - autoPadding: true, + // The width of tabs within an element's content + tabSize: 2, - // Whether the focused element grabs all keypresses - grabKeys: true, + // Automatically position child elements with border and padding in mind + autoPadding: true, - // Prevent keypresses from being received by any element - lockKeys: true, + // Whether the focused element grabs all keypresses + grabKeys: true, - // Automatically "dock" borders with other elements instead of overlapping, - // depending on position - dockBorders: true, + // Prevent keypresses from being received by any element + // lockKeys: true, - // Allow for rendering of East Asian double-width characters, utf-16 - // surrogate pairs, and unicode combining characters. - fullUnicode: true, + // Automatically "dock" borders with other elements instead of overlapping, + // depending on position + dockBorders: true, - debug: true, -}) + // Allow for rendering of East Asian double-width characters, utf-16 + // surrogate pairs, and unicode combining characters. + fullUnicode: true, + + debug: true, + }) -module.exports = ({ requireModules, fileGlob }) => { /** * List with test file names */ - const list = fileList({ + const [filesRef, renderFilesUI] = filesUI({ parent: screen, - fileGlob, + onChange: path => { + store.dispatch({ + type: "USE-STATE.SET", + payload: { id: "fileSelectId", value: path }, + }) + }, + onRun: path => { + store.dispatch({ + type: "USE-STATE.SET", + payload: { id: "fileSelectId", value: path }, + }) + + executor.send({ + path, + runArgs: executorRunArgs, + }) + + FileList.update(path, { + isRunning: true, + }) + }, }) + filesRef.focus() + /** * Text box with test results */ - const box = resultTextbox({ + const [resultRef, renderResultUI] = resultUI({ parent: screen, - label: `${projectPkg.name} v${projectPkg.version}`, - width: `100%-${list.width}`, - }) - - list.on("run", (file, path) => { - // box.setLabel(` ${name} `) - box.setContent(JSON.stringify({ file, path }, 2, 2)) - - executor.on("message", ({ stdout, stderr }) => { - box.setContent(`${box.getContent()}\n${stdout}${stderr}`) - - screen.render() - }) - - executor.send({ - path, - runArgs: reduce((acc, item) => [...acc, "-r", item], [], requireModules), - }) - - screen.render() }) - list.focus() - /** * Command Line Interface input for: * * - searching & highlighting through files */ - const input = cliInput({ + const handleCLIHide = () => { + store.dispatch({ + type: "USE-STATE.SET", + payload: { id: "isCLIVisible", value: false }, + }) + } + + const [, renderCommandUI] = commandUI({ parent: screen, width: "100%", height: "1px", top: "100%-1", left: "0", + onChange: source => { + store.dispatch({ + type: "USE-STATE.SET", + payload: { id: "cliQuery", value: source }, + }) + }, + onSubmit: source => { + filesRef.selectFirstWith(source) + + handleCLIHide() + }, + onCancel: handleCLIHide, + onBlur: handleCLIHide, }) - input.on("change", value => { - list.setHighlight(value) + screen.on("keypress", (code, key) => { + if (key.full === "right") { + resultRef.focus() + } - screen.render() + if (key.full === "left") { + filesRef.focus() + } }) - input.on("cancel", () => { - list.setHighlight("") - input.hide() - - screen.render() - }) - - input.on("submit", () => { - input.hide() - - screen.render() - }) - - input.hide() - - /** - * Global shortcuts - */ - screen.key("/", () => { - input.setValue("") - input.show() - input.focus() - input.setFront() - - screen.render() - }) - - screen.key("C-l", () => { - list.setHighlight("") - input.hide() - - screen.render() + store.dispatch({ + type: "USE-STATE.SET", + payload: { id: "isCLIVisible", value: true }, + }) }) screen.key("S-tab", () => { screen.focusPrevious() - screen.render() }) screen.key("tab", () => { screen.focusNext() - screen.render() }) /* eslint-disable unicorn/no-process-exit */ @@ -147,5 +163,49 @@ module.exports = ({ requireModules, fileGlob }) => { return process.exit(0) }) - screen.render() + // Load all files matching glob + FileList.read(fileGlob) + + /* + * When any part of state changes, re-render all UI wigets + */ + store.subscribe(() => { + const currentState = store.getState() + const [cliQuery, fileSelectId, isCLIVisible] = converge( + (...params) => params, + [ + read(["USE-STATE", "cliQuery"], ""), + read(["USE-STATE", "fileSelectId"], null), + read(["USE-STATE", "isCLIVisible"], false), + ] + )(currentState) + + // + const { items, byId } = FileList.selector(currentState) + const files = items() + const listWidth = pipe(map([read("name"), count]), max)(files) + 6 + + renderFilesUI({ + items: files, + highlight: cliQuery, + width: listWidth, + }) + + // + const { name, stdout } = byId(fileSelectId, {}) + + renderResultUI({ + width: `100%-${listWidth}`, + label: name, + content: stdout, + }) + + // + renderCommandUI({ + value: cliQuery, + isVisible: isCLIVisible, + }) + + screen.render() + }) } diff --git a/src/index.state.js b/src/index.state.js new file mode 100644 index 0000000..7b903c5 --- /dev/null +++ b/src/index.state.js @@ -0,0 +1,26 @@ +const { createStore, combineReducers } = require("redux") + +const { FileList } = require("./ui.files/files.list") + +// Global state store +const store = createStore( + combineReducers({ + "USE-STATE": (state = {}, { type, payload: { id, value } = {} }) => { + switch (type) { + case "USE-STATE.SET": + return { + ...state, + [id]: value, + } + default: + return state + } + }, + [FileList.name]: FileList.reducer, + }) +) + +// Link list to app store +FileList.set({ dispatch: store.dispatch }) + +module.exports = { store } diff --git a/src/lib/depends-on.js b/src/lib/depends-on.js new file mode 100644 index 0000000..2df1cb8 --- /dev/null +++ b/src/lib/depends-on.js @@ -0,0 +1,9 @@ +// const espree = require("espree") +// const fs = require("fs") +// const path = require("path") + +// fs.readFile(path.resolve(`${__dirname}/all/all.js`), "utf8", (err, data) => { +// console.log(data) +// const ast = espree.parse(data, { ecmaVersion: 8, sourceType: "module" }) +// console.log(ast.body[0]) +// }) diff --git a/src/ui.cli/cli.js b/src/ui.cli/cli.js new file mode 100644 index 0000000..0e11876 --- /dev/null +++ b/src/ui.cli/cli.js @@ -0,0 +1,96 @@ +const blessed = require("neo-blessed") + +const commandUI = ({ + parent, + width, + height, + top, + left, + onChange, + onCancel, + onSubmit, + onBlur, +}) => { + const label = blessed.box({ + parent, + style: { + bg: "gray", + }, + content: "/", + left, + top, + }) + + const input = blessed.text({ + parent, + tags: true, + keys: true, + vi: true, + mouse: true, + style: { + bg: "gray", + }, + width, + height, + top, + left: `${left}+1`, + }) + + input.on("blur", onBlur) + + input.on("keypress", (code, key) => { + const value = input.getContent() + + if (/[\d .=A-Za-z\-]/.test(code)) { + onChange(`${value}${code}`) + } + + if (key.full === "backspace") { + onChange(value.substr(0, value.length - 1)) + } + + if (key.full === "escape") { + onCancel() + } + + if (key.full === "enter") { + onSubmit(value) + } + }) + + return [ + input, + ({ value, isVisible }) => { + if (isVisible) { + label.show() + input.show() + } else { + label.hide() + input.hide() + } + + input.setContent(value) + + /* + * Similar to: + * + * useEffect(() => { + * if (isVisible === true) {} + * }, [isVisible]) + */ + + if (isVisible !== input._.isVisible && isVisible === true) { + input.focus() + } + + /** + * Persist state data + */ + + input._.value = value + input._.isVisible = isVisible + }, + ] +} + +module.exports = { commandUI } diff --git a/src/ui.files/files.js b/src/ui.files/files.js new file mode 100644 index 0000000..3922ba4 --- /dev/null +++ b/src/ui.files/files.js @@ -0,0 +1,145 @@ +const blessed = require("neo-blessed") +const isDeepEqual = require("fast-deep-equal") +const { + read, + map, + hasWith, + replace, + findIndexWith, + contains, +} = require("m.xyz") + +const { loaderUI } = require("../ui.loader/loader") + +const filesUI = ({ parent, onRun, onChange }) => { + const [, renderLabelUI] = loaderUI({ + parent, + top: 0, + left: 0, + height: 1, + }) + + const borderTopLine = blessed.line({ + parent, + orientation: "horizontal", + top: 1, + left: 0, + height: 1, + }) + + const list = blessed.list({ + parent, + tags: true, + keys: true, + vi: true, + mouse: true, + + padding: { + top: 0, + left: 0, + bottom: 0, + right: 1, + }, + top: 2, + left: 0, + height: "100%-3", + + scrollbar: { + style: { + bg: "white", + }, + }, + + style: { + focus: { + selected: { + bg: "gray", + }, + scrollbar: { + bg: "blue", + }, + }, + + // Unselected item + item: {}, + + // Selected item + selected: { + // bg: "gray", + }, + }, + }) + + list.key(["down", "up"], () => { + onChange(read([list.selected, "id"], null)(list._.items), list.selected) + }) + + list.on("select", (item, index) => { + onRun(read([index, "id"], null)(list._.items), index) + }) + + list.on("element click", () => { + onChange(read([list.selected, "id"], null)(list._.items), list.selected) + }) + + list.selectFirstWith = source => { + const index = findIndexWith({ name: contains(source) }, list._.items) + + if (index !== -1) { + list.select(index) + } + } + + return [ + list, + ({ items, highlight, width }) => { + renderLabelUI({ + content: `${items.length} test files`, + width, + isLoading: hasWith({ isRunning: true }, items), + }) + + borderTopLine.position.width = width + + list.position.width = width + + /* + * Similar to: + * + * useEffect(() => { + * ... + * }, [items, highlight]) + */ + if (!isDeepEqual(items, list._.items) || highlight !== list._.highlight) { + list.setItems( + map(({ name, code, isRunning }) => { + const color = isRunning + ? "blue" + : code === 0 + ? "green" + : code === null + ? "gray" + : "red" + const text = replace( + highlight, + `{yellow-bg}{black-fg}${highlight}{/black-fg}{/yellow-bg}` + )(name) + + return `{${color}-fg}■{/} ${text}` + }, items) + ) + } + + /** + * Persist state data + */ + + list._.items = items + list._.highlight = highlight + }, + ] +} + +module.exports = { + filesUI, +} diff --git a/src/ui.files/files.list.js b/src/ui.files/files.list.js new file mode 100644 index 0000000..4cca135 --- /dev/null +++ b/src/ui.files/files.list.js @@ -0,0 +1,34 @@ +const { flatten, distinct, split, last, map, pipe } = require("m.xyz") +const { buildList } = require("just-a-list.redux") +const { sep } = require("path") +const glob = require("glob") + +const FileList = buildList({ + name: "FILES", + + // @signature (fileGlob: String[]): Object[] + read: fileGlob => { + return pipe( + map(item => glob.sync(item, { absolute: true })), + flatten, + distinct, + map(item => { + return { + id: item, + name: pipe(split(sep), last)(item), + isRunning: false, + code: null, + } + }) + )(fileGlob) + }, + + update: (id, data) => ({ + id, + ...data, + }), +}) + +module.exports = { + FileList, +} diff --git a/src/ui.loader/loader.js b/src/ui.loader/loader.js new file mode 100644 index 0000000..d124d77 --- /dev/null +++ b/src/ui.loader/loader.js @@ -0,0 +1,53 @@ +const blessed = require("neo-blessed") +const { is } = require("m.xyz") + +const asciiFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +const loaderUI = ({ parent, top, left, height }) => { + const labelBox = blessed.box({ + parent, + tags: true, + keys: false, + vi: false, + mouse: false, + top, + left, + height, + }) + + let spinnerInterval = null + let spinnerFrame = 0 + + return [ + labelBox, + ({ content, width, isLoading }) => { + labelBox.setContent( + isLoading ? `${asciiFrames[spinnerFrame]} ${content}` : ` ${content}` + ) + labelBox.position.width = width + + if (isLoading && !is(spinnerInterval)) { + spinnerInterval = setInterval(() => { + spinnerFrame = (spinnerFrame + 1) % asciiFrames.length + + labelBox.setContent(`${asciiFrames[spinnerFrame]} ${content}`) + labelBox.screen.render() + }, 70) + } + + if (!isLoading && is(spinnerInterval)) { + spinnerInterval = clearInterval(spinnerInterval) + } + + /** + * Persist state data + */ + + labelBox._.content = content + labelBox._.width = width + labelBox._.isLoading = isLoading + }, + ] +} + +module.exports = { loaderUI } diff --git a/src/widgets/result.textbox.js b/src/ui.result/result.js similarity index 52% rename from src/widgets/result.textbox.js rename to src/ui.result/result.js index 1fefabb..cb2d1b3 100644 --- a/src/widgets/result.textbox.js +++ b/src/ui.result/result.js @@ -1,8 +1,7 @@ const blessed = require("neo-blessed") -module.exports = ({ parent, label, width }) => { - // Separate title element since we cant have only borderTop - blessed.box({ +const resultUI = ({ parent }) => { + const labelBox = blessed.box({ parent, tags: true, keys: false, @@ -10,32 +9,29 @@ module.exports = ({ parent, label, width }) => { mouse: false, top: 0, right: 0, - width, height: 1, - content: label, align: "right", }) - blessed.line({ + const borderTopLine = blessed.line({ parent, orientation: "horizontal", top: 1, right: 0, - width, height: 1, }) - const box = blessed.box({ + const contentBox = blessed.log({ parent, tags: true, keys: true, vi: true, mouse: true, scrollable: true, + scrollOnInput: true, top: 2, right: 0, - width, height: "100%-3", scrollbar: { @@ -60,5 +56,26 @@ module.exports = ({ parent, label, width }) => { }, }) - return box + return [ + contentBox, + ({ width, label, content }) => { + labelBox.setContent(label) + labelBox.position.width = width + + borderTopLine.position.width = width + + contentBox.setContent(content) + contentBox.position.width = width + + /** + * Persist state data + */ + + contentBox._.width = width + contentBox._.label = label + contentBox._.content = content + }, + ] } + +module.exports = { resultUI } diff --git a/src/widgets/cli.input.js b/src/widgets/cli.input.js deleted file mode 100644 index 118a995..0000000 --- a/src/widgets/cli.input.js +++ /dev/null @@ -1,61 +0,0 @@ -const { isEmpty } = require("m.xyz") -const blessed = require("neo-blessed") - -module.exports = ({ parent, width, height, top, left }) => { - let value = "" - const input = blessed.text({ - parent, - tags: true, - keys: true, - vi: true, - mouse: true, - style: {}, - width, - height, - top, - left, - }) - - input.on("keypress", (code, key) => { - if (!isEmpty(code) && /[\d .=A-Za-z\-]/.test(code)) { - value = `${value}${code}` - input.setContent(`/${value}`) - - input.emit("change", value) - } - - if (key.full === "backspace") { - if (value.length === 0) { - value = "" - input.setContent(`/${value}`) - - input.emit("cancel") - } else { - value = value.substr(0, value.length - 1) - input.setContent(`/${value}`) - - input.emit("change", value) - } - } - - if (key.full === "escape") { - value = "" - input.setContent(`/${value}`) - - input.emit("cancel") - } - - if (key.full === "enter") { - value = "" - input.setContent(`/${value}`) - - input.emit("submit", value) - } - }) - - input.setValue = source => { - input.setContent(`/${source}`) - } - - return input -} diff --git a/src/widgets/file.list.js b/src/widgets/file.list.js deleted file mode 100644 index c4107e6..0000000 --- a/src/widgets/file.list.js +++ /dev/null @@ -1,171 +0,0 @@ -const { sep } = require("path") -const blessed = require("neo-blessed") -const glob = require("glob") -const { - count, - read, - max, - flatten, - distinct, - split, - last, - reduce, - map, - pipe, - converge, - findIndexWith, - contains, - replace, - isEmpty, -} = require("m.xyz") - -module.exports = ({ parent, fileGlob }) => { - /** - * State - */ - - const filePaths = pipe( - map(item => - glob.sync(item, { - absolute: true, - }) - ), - flatten, - distinct - )(fileGlob) - - const pathNameMap = reduce( - (acc, item) => ({ - ...acc, - [item]: { - name: pipe(split(sep), last)(item), - isLoading: false, - code: null, - }, - }), - {}, - filePaths - ) - - const listWidth = - pipe(Object.values, map([read("name"), count]), max)(pathNameMap) + 6 - - /** - * Render - */ - - blessed.box({ - parent, - tags: true, - keys: false, - vi: false, - mouse: false, - top: 0, - left: 0, - width: listWidth, - height: 1, - content: `${filePaths.length} test files`, - }) - - blessed.line({ - parent, - orientation: "horizontal", - top: 1, - left: 0, - width: listWidth, - height: 1, - }) - - const list = blessed.list({ - parent, - items: pipe( - Object.values, - map([read("name"), source => `{gray-fg}■{/gray-fg} ${source}`]) - )(pathNameMap), - - tags: true, - keys: true, - vi: true, - mouse: true, - - padding: { - top: 0, - left: 0, - bottom: 0, - right: 1, - }, - - top: 2, - left: 0, - height: "100%-3", - width: listWidth, - - scrollbar: { - style: { - bg: "white", - }, - }, - - style: { - border: { - fg: "white", - }, - - focus: { - border: { - fg: "blue", - }, - selected: { - bg: "blue", - }, - scrollbar: { - bg: "blue", - }, - }, - - // Style for an unselected item - item: {}, - - // Style for a selected item - selected: { - bg: "white", - }, - }, - }) - - list.on("select", (el, selected) => { - const path = filePaths[selected] - const name = pathNameMap[path] - - list.emit("run", name, path) - }) - - list.setHighlight = query => { - pipe( - Object.values, - converge( - (firstMatchIndex, highlightedItems) => { - if (!isEmpty(query)) { - list.select(firstMatchIndex) - } - list.setItems(highlightedItems) - }, - [ - findIndexWith({ - name: contains(query), - }), - map([ - read("name"), - source => - `{gray-fg}■{/gray-fg} ${replace( - query, - `{underline}${query}{/underline}` - )(source)}`, - ]), - ] - ) - )(pathNameMap) - } - - return list -}