├── HISTORY.md ├── ink-console.gif ├── .editorconfig ├── src ├── consoleMethods.ts ├── example.tsx ├── actions │ ├── pin.ts │ ├── pageUp.ts │ ├── pageDown.ts │ ├── shrink.ts │ ├── index.ts │ ├── down.ts │ ├── expand.ts │ ├── topStop.ts │ ├── up.ts │ └── __test__ │ │ ├── down.test.ts │ │ └── expand.test.ts ├── __test__ │ ├── renderNormalEntry.test.ts │ ├── __snapshots__ │ │ └── renderDirOutput.test.ts.snap │ ├── getDepth.test.ts │ └── renderDirOutput.test.ts ├── Props.ts ├── renderNormalEntry.ts ├── LogEntry.ts ├── getDepth.ts ├── renderEntry.ts ├── State.ts ├── Counter.tsx ├── renderString.ts ├── LogCatcher.ts ├── countRows.ts ├── renderDirOutput.ts └── index.tsx ├── .travis.yml ├── tsconfig.json ├── .gitignore ├── LICENSE.md ├── README.md └── package.json /HISTORY.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.0.1: 2017-xx-xx 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /ink-console.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForbesLindesay/ink-console/HEAD/ink-console.gif -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/consoleMethods.ts: -------------------------------------------------------------------------------- 1 | export type LogMethod = 'dir' | 'log' | 'info' | 'warn' | 'error'; 2 | const consoleMethods: LogMethod[] = ['dir', 'log', 'info', 'warn', 'error']; 3 | export default consoleMethods; 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | sudo: false 4 | 5 | node_js: 6 | - "6" 7 | - "8" 8 | 9 | script: npm test && npm run prettier:check 10 | 11 | notifications: 12 | email: 13 | on_success: never 14 | -------------------------------------------------------------------------------- /src/example.tsx: -------------------------------------------------------------------------------- 1 | import {h, render} from 'ts-ink'; 2 | import Console from './'; 3 | import Counter from './Counter'; 4 | 5 | render( 6 |
7 | 8 |
9 | 10 |
, 11 | ); 12 | -------------------------------------------------------------------------------- /src/actions/pin.ts: -------------------------------------------------------------------------------- 1 | import State from '../State'; 2 | 3 | export default function pin(s: State): State { 4 | return { 5 | ...s, 6 | pinned: true, 7 | lastEntryToDisplayIndex: s.log.length - 1, 8 | offset: 0, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/__test__/renderNormalEntry.test.ts: -------------------------------------------------------------------------------- 1 | import renderNormalEntry from '../renderNormalEntry'; 2 | 3 | test('renderNormalEntry', () => { 4 | expect( 5 | renderNormalEntry({type: 'log', values: ['Foo', 10, {foo: 'bar'}]}), 6 | ).toBe("Foo 10 { foo: 'bar' }"); 7 | }); 8 | -------------------------------------------------------------------------------- /src/Props.ts: -------------------------------------------------------------------------------- 1 | import {ILogCatcher} from './LogCatcher'; 2 | 3 | export default interface Props { 4 | /** 5 | * How many lines to display 6 | */ 7 | lines: number; 8 | /** 9 | * A record of log messages 10 | */ 11 | logCatcher?: ILogCatcher; 12 | }; 13 | -------------------------------------------------------------------------------- /src/renderNormalEntry.ts: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util'; 2 | import {NormalEntry} from './LogEntry'; 3 | 4 | export default function renderNormalEntry(entry: NormalEntry): string { 5 | return entry.values 6 | .map(v => (typeof v === 'string' ? v : inspect(v))) 7 | .join(' '); 8 | } 9 | -------------------------------------------------------------------------------- /src/actions/pageUp.ts: -------------------------------------------------------------------------------- 1 | import Props from '../Props'; 2 | import State from '../State'; 3 | import up from './up'; 4 | 5 | export default function pageUp(s: State, props: Props): State { 6 | for (let i = 0; i < props.lines; i++) { 7 | s = up(s, props); 8 | } 9 | return s; 10 | } 11 | -------------------------------------------------------------------------------- /src/LogEntry.ts: -------------------------------------------------------------------------------- 1 | export interface DirEntry { 2 | type: 'dir'; 3 | value: any; 4 | } 5 | 6 | export interface NormalEntry { 7 | type: 'log' | 'info' | 'warn' | 'error'; 8 | values: any[]; 9 | } 10 | 11 | export type LogEntry = DirEntry | NormalEntry; 12 | 13 | export default LogEntry; 14 | -------------------------------------------------------------------------------- /src/actions/pageDown.ts: -------------------------------------------------------------------------------- 1 | import Props from '../Props'; 2 | import State from '../State'; 3 | import down from './down'; 4 | 5 | export default function pageDown(s: State, props: Props): State { 6 | for (let i = 0; i < props.lines; i++) { 7 | s = down(s); 8 | } 9 | return s; 10 | } 11 | -------------------------------------------------------------------------------- /src/actions/shrink.ts: -------------------------------------------------------------------------------- 1 | import Props from '../Props'; 2 | import State from '../State'; 3 | import topStop from './topStop'; 4 | 5 | export default function expand(s: State, p: Props): State { 6 | if (s.depth <= 0) { 7 | return s; 8 | } 9 | return topStop({...s, depth: s.depth - 1}, p); 10 | } 11 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import down from './down'; 2 | import expand from './expand'; 3 | import pageDown from './pageDown'; 4 | import pageUp from './pageUp'; 5 | import pin from './pin'; 6 | import shrink from './shrink'; 7 | import up from './up'; 8 | 9 | export {down, expand, pageDown, pageUp, pin, shrink, up}; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "declaration": true, 5 | "jsx": "react", 6 | "jsxFactory": "h", 7 | "lib":[ 8 | "dom", 9 | "es2017" 10 | ], 11 | "module": "commonjs", 12 | "outDir": "lib", 13 | "strict": true, 14 | "target": "es2017", 15 | "noUnusedLocals": true 16 | } 17 | } -------------------------------------------------------------------------------- /src/getDepth.ts: -------------------------------------------------------------------------------- 1 | export default function getDepth(value: any): number { 2 | if (Array.isArray(value)) { 3 | return Math.max(0, ...value.map((v: any) => 1 + getDepth(v))); 4 | } else if (value && typeof value === 'object') { 5 | return Math.max( 6 | 0, 7 | ...Object.keys(value).map(name => 1 + getDepth(value[name])), 8 | ); 9 | } 10 | return 0; 11 | } 12 | -------------------------------------------------------------------------------- /src/__test__/__snapshots__/renderDirOutput.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderDirOutput: {}, depth=0 1`] = `"{}"`; 4 | 5 | exports[`renderDirOutput: {}, depth=1 1`] = `"{}"`; 6 | 7 | exports[`renderDirOutput: {v: {}}, depth=1 1`] = ` 8 | "{ 9 | v: {}, 10 | }" 11 | `; 12 | 13 | exports[`renderDirOutput: {v: {}}, depth=2 1`] = ` 14 | "{ 15 | v: {}, 16 | }" 17 | `; 18 | -------------------------------------------------------------------------------- /src/renderEntry.ts: -------------------------------------------------------------------------------- 1 | import LogEntry from './LogEntry'; 2 | import renderDirOutput, {DirOutputOptions} from './renderDirOutput'; 3 | import renderNormalEntry from './renderNormalEntry'; 4 | 5 | export default function renderEntry( 6 | entry: LogEntry, 7 | options: DirOutputOptions, 8 | ): string { 9 | if (entry.type === 'dir') { 10 | return renderDirOutput(entry, options); 11 | } 12 | return renderNormalEntry(entry); 13 | } 14 | -------------------------------------------------------------------------------- /src/__test__/getDepth.test.ts: -------------------------------------------------------------------------------- 1 | import getDepth from '../getDepth'; 2 | 3 | test('{}', () => { 4 | expect(getDepth({})).toBe(0); 5 | expect(getDepth({value: {}})).toBe(1); 6 | expect(getDepth({value: {value: {}}})).toBe(2); 7 | }); 8 | test('[]', () => { 9 | expect(getDepth([])).toBe(0); 10 | expect(getDepth([[]])).toBe(1); 11 | expect(getDepth([[], [[]]])).toBe(2); 12 | }); 13 | test('42', () => { 14 | expect(getDepth(42)).toBe(0); 15 | }); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | 13 | # Compiled binary addons (http://nodejs.org/api/addons.html) 14 | build/Release 15 | 16 | # Dependency directory 17 | node_modules 18 | 19 | # Users Environment Variables 20 | .lock-wscript 21 | 22 | # Babel build output 23 | /lib 24 | 25 | # Config files 26 | environment.toml 27 | .env 28 | 29 | package-lock.json -------------------------------------------------------------------------------- /src/State.ts: -------------------------------------------------------------------------------- 1 | import LogEntry from './LogEntry'; 2 | 3 | export default interface State { 4 | /** 5 | * All log entries, starting with the oldest entry 6 | */ 7 | log: LogEntry[]; 8 | /** 9 | * Is the view pinned to the bottom 10 | */ 11 | pinned: boolean; 12 | /** 13 | * What is the index of the last entry visible on the screen 14 | */ 15 | lastEntryToDisplayIndex: number; 16 | /** 17 | * How many lines of the last entry are hidden due to scrolling 18 | */ 19 | offset: number; 20 | /** 21 | * What depth should log entries with nested objects be rendered to 22 | */ 23 | depth: number; 24 | }; 25 | -------------------------------------------------------------------------------- /src/actions/down.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import countRows from '../countRows'; 3 | import State from '../State'; 4 | 5 | export default function down(s: State): State { 6 | if (s.pinned) { 7 | return s; 8 | } 9 | if (s.offset === 0) { 10 | if (s.lastEntryToDisplayIndex >= s.log.length - 1) { 11 | return s; 12 | } 13 | const lastEntryToDisplayIndex = s.lastEntryToDisplayIndex + 1; 14 | const lastEntryLines = countRows(s.log[lastEntryToDisplayIndex], s.depth); 15 | assert(lastEntryLines >= 1, 'All log entries should be at least one line'); 16 | return { 17 | ...s, 18 | lastEntryToDisplayIndex, 19 | offset: lastEntryLines - 1, 20 | }; 21 | } else { 22 | return { 23 | ...s, 24 | offset: s.offset - 1, 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import {h, Component, Text} from 'ts-ink'; 2 | 3 | let obj: any = {v: 42}; 4 | 5 | /** 6 | * A simple example of an ink component 7 | */ 8 | export default class Counter extends Component<{}, {i: number}> { 9 | state = { 10 | i: 0, 11 | }; 12 | timer: any; 13 | 14 | componentDidMount() { 15 | this.timer = setInterval(() => { 16 | console.log(this.state.i + 1); 17 | console.dir(obj); 18 | obj = {v: [obj]}; 19 | this.setState({ 20 | i: this.state.i + 1, 21 | }); 22 | }, 500); 23 | } 24 | 25 | componentWillUnmount() { 26 | if (this.timer != null) { 27 | clearInterval(this.timer); 28 | } 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | {this.state.i} tests passed 35 | 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/actions/expand.ts: -------------------------------------------------------------------------------- 1 | import countRows from '../countRows'; 2 | import getDepth from '../getDepth'; 3 | import Props from '../Props'; 4 | import State from '../State'; 5 | 6 | export default function expand(s: State, p: Props): State { 7 | if (s.log.length === 0) { 8 | return s; 9 | } 10 | let {lastEntryToDisplayIndex, offset} = s; 11 | if (s.pinned) { 12 | lastEntryToDisplayIndex = s.log.length - 1; 13 | offset = 0; 14 | } 15 | let lines = -1 * offset; 16 | let maxDepth = 0; 17 | for (let i = lastEntryToDisplayIndex; i >= 0 && lines < p.lines; i--) { 18 | const entry = s.log[i]; 19 | lines += countRows(entry, s.depth); 20 | if (entry.type === 'dir') { 21 | maxDepth = Math.max(maxDepth, getDepth(entry.value)); 22 | } 23 | } 24 | if (s.depth >= maxDepth) { 25 | return s; 26 | } else { 27 | return {...s, depth: s.depth + 1}; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/actions/topStop.ts: -------------------------------------------------------------------------------- 1 | import countRows from '../countRows'; 2 | import Props from '../Props'; 3 | import State from '../State'; 4 | import down from './down'; 5 | 6 | export default function topStop(s: State, props: Props): State { 7 | let lines = 0; 8 | for ( 9 | let i = Math.max(0, s.lastEntryToDisplayIndex - props.lines); 10 | i < s.lastEntryToDisplayIndex; 11 | i++ 12 | ) { 13 | lines += countRows(s.log[i], s.depth); 14 | if (lines >= props.lines) { 15 | return s; 16 | } 17 | } 18 | lines += countRows(s.log[s.lastEntryToDisplayIndex], s.depth) - s.offset; 19 | if (lines >= props.lines) { 20 | return s; 21 | } 22 | const updatedS = down(s); 23 | if (updatedS === s) { 24 | // we've hit the bottom, there just isn't enough log available 25 | // pin to the bottom, so we'll get that log when it arrives 26 | return {...s, pinned: true}; 27 | } 28 | return topStop(updatedS, props); 29 | } 30 | -------------------------------------------------------------------------------- /src/__test__/renderDirOutput.test.ts: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util'; 2 | import renderDirOutput from '../renderDirOutput'; 3 | 4 | test('renderDirOutput', () => { 5 | expect( 6 | renderDirOutput({type: 'dir', value: 5}, {depth: 0, expandKey: 'l'}), 7 | ).toBe(inspect(5, {colors: true})); 8 | expect( 9 | renderDirOutput({type: 'dir', value: 'foo'}, {depth: 0, expandKey: 'l'}), 10 | ).toBe(inspect('foo', {colors: true})); 11 | expect( 12 | renderDirOutput({type: 'dir', value: {}}, {depth: 0, expandKey: 'l'}), 13 | ).toMatchSnapshot('{}, depth=0'); 14 | expect( 15 | renderDirOutput({type: 'dir', value: {}}, {depth: 1, expandKey: 'l'}), 16 | ).toMatchSnapshot('{}, depth=1'); 17 | expect( 18 | renderDirOutput({type: 'dir', value: {v: {}}}, {depth: 1, expandKey: 'l'}), 19 | ).toMatchSnapshot('{v: {}}, depth=1'); 20 | expect( 21 | renderDirOutput({type: 'dir', value: {v: {}}}, {depth: 2, expandKey: 'l'}), 22 | ).toMatchSnapshot('{v: {}}, depth=2'); 23 | }); 24 | -------------------------------------------------------------------------------- /src/renderString.ts: -------------------------------------------------------------------------------- 1 | import renderEntry from './renderEntry'; 2 | import Props from './Props'; 3 | import State from './State'; 4 | 5 | export default function renderString(s: State, p: Props): string { 6 | const dirOptions = {depth: s.depth, expandKey: 'l'}; 7 | let {lastEntryToDisplayIndex, offset} = s; 8 | if (s.pinned) { 9 | lastEntryToDisplayIndex = s.log.length - 1; 10 | offset = 0; 11 | } 12 | const output: string[] = []; 13 | function addLine(str: string) { 14 | if (output.length < p.lines) { 15 | output.push(str); 16 | } 17 | } 18 | if (s.log.length) { 19 | // TODO: make expand key configurable 20 | const lastEntry = renderEntry( 21 | s.log[lastEntryToDisplayIndex], 22 | dirOptions, 23 | ).split('\n'); 24 | lastEntry.slice(0, lastEntry.length - offset).reverse().forEach(addLine); 25 | for ( 26 | let i = lastEntryToDisplayIndex - 1; 27 | i >= 0 && output.length < p.lines; 28 | i-- 29 | ) { 30 | renderEntry(s.log[i], dirOptions).split('\n').reverse().forEach(addLine); 31 | } 32 | } 33 | output.reverse(); 34 | while (output.length < p.lines) { 35 | output.push(''); 36 | } 37 | return output.join('\n'); 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017 [Forbes Lindesay](https://github.com/ForbesLindesay) 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | > SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/actions/up.ts: -------------------------------------------------------------------------------- 1 | import countRows from '../countRows'; 2 | import Props from '../Props'; 3 | import State from '../State'; 4 | import topStop from './topStop'; 5 | 6 | export default function up(s: State, props: Props): State { 7 | if (s.pinned) { 8 | let lines = 0; 9 | let i = 0; 10 | while (lines < props.lines + 1 && i < s.log.length) { 11 | lines += countRows(s.log[i], s.depth); 12 | i++; 13 | } 14 | if (lines < props.lines + 1) { 15 | // if there aren't enough lines, do not start scrolling 16 | return s; 17 | } 18 | // go up one from the bottom position 19 | return up( 20 | { 21 | ...s, 22 | pinned: false, 23 | lastEntryToDisplayIndex: s.log.length - 1, 24 | offset: 0, 25 | }, 26 | props, 27 | ); 28 | } else { 29 | const offset = s.offset + 1; 30 | if (offset === countRows(s.log[s.lastEntryToDisplayIndex], s.depth)) { 31 | return topStop( 32 | { 33 | ...s, 34 | offset: 0, 35 | lastEntryToDisplayIndex: s.lastEntryToDisplayIndex - 1, 36 | }, 37 | props, 38 | ); 39 | } else { 40 | return topStop({...s, offset}, props); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/actions/__test__/down.test.ts: -------------------------------------------------------------------------------- 1 | import down from '../down'; 2 | 3 | test('down', () => { 4 | const pinnedState = { 5 | pinned: true, 6 | log: [], 7 | depth: 0, 8 | offset: 0, 9 | lastEntryToDisplayIndex: 0, 10 | }; 11 | expect(down(pinnedState)).toBe(pinnedState); 12 | const noRows = { 13 | pinned: false, 14 | log: [], 15 | depth: 0, 16 | offset: 0, 17 | lastEntryToDisplayIndex: 0, 18 | }; 19 | expect(down(noRows)).toBe(noRows); 20 | const lastRow = { 21 | pinned: false, 22 | log: [{type: 'log' as 'log', values: []}], 23 | depth: 0, 24 | offset: 0, 25 | lastEntryToDisplayIndex: 0, 26 | }; 27 | expect(down(lastRow)).toBe(lastRow); 28 | const noOffset = { 29 | pinned: false, 30 | log: [ 31 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 32 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 33 | ], 34 | depth: 0, 35 | offset: 0, 36 | lastEntryToDisplayIndex: 0, 37 | }; 38 | expect(down(noOffset)).toEqual({ 39 | ...noOffset, 40 | lastEntryToDisplayIndex: 1, 41 | offset: 2, 42 | }); 43 | const withOffset = { 44 | ...noOffset, 45 | lastEntryToDisplayIndex: 1, 46 | offset: 2, 47 | }; 48 | expect(down(withOffset)).toEqual({ 49 | ...withOffset, 50 | offset: 1, 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/LogCatcher.ts: -------------------------------------------------------------------------------- 1 | import consoleMethods from './consoleMethods'; 2 | import LogEntry from './LogEntry'; 3 | 4 | export interface ILogCatcher { 5 | getLog(): LogEntry[]; 6 | onUpdate(fn: () => void): () => void; 7 | } 8 | export default class LogCatcher implements ILogCatcher { 9 | private readonly _log: LogEntry[] = []; 10 | private readonly _reset: (() => void)[]; 11 | private readonly _handlers: Set<() => void> = new Set(); 12 | constructor() { 13 | this._reset = consoleMethods.map(method => { 14 | const originalFn = console[method]; 15 | const customLog = (...args: any[]) => { 16 | this._log.push( 17 | method === 'dir' 18 | ? {type: 'dir', value: args[0]} 19 | : {type: method, values: args}, 20 | ); 21 | for (const value of this._handlers) { 22 | value(); 23 | } 24 | }; 25 | (customLog as any).restore = (originalFn as any).restore; 26 | console[method] = customLog; 27 | return () => { 28 | if (console[method] === customLog) { 29 | console[method] = originalFn; 30 | } 31 | }; 32 | }); 33 | } 34 | 35 | getLog() { 36 | return this._log.slice(); 37 | } 38 | 39 | onUpdate(fn: () => void) { 40 | this._handlers.add(fn); 41 | return () => this._handlers.delete(fn); 42 | } 43 | 44 | dispose() { 45 | this._reset.forEach(fn => fn()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ink-console 2 | 3 | Render a scrollable terminal log in your ink app 4 | 5 | [![Build Status](https://img.shields.io/travis/ForbesLindesay/ink-console/master.svg)](https://travis-ci.org/ForbesLindesay/ink-console) 6 | [![Dependency Status](https://img.shields.io/david/ForbesLindesay/ink-console/master.svg)](http://david-dm.org/ForbesLindesay/ink-console) 7 | [![NPM version](https://img.shields.io/npm/v/ink-console.svg)](https://www.npmjs.org/package/ink-console) 8 | 9 | ![Demo](ink-console.gif) 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm install ink-console --save 15 | ``` 16 | 17 | ## Basic Usage 18 | 19 | ```js 20 | import {h, render} from 'ts-ink'; 21 | import Console from 'ink-console'; 22 | import Counter from './Counter'; 23 | 24 | render( 25 |
26 | 27 |
28 | 29 |
, 30 | ); 31 | ``` 32 | 33 | ## Advanced Usage 34 | 35 | ```js 36 | import {h, render} from 'ts-ink'; 37 | import Console, {LogCatcher} from 'ink-console'; 38 | import Counter from './Counter'; 39 | 40 | // defining the log catcher outside the component 41 | // lets you render the same global console.log in 42 | // multiple separate locations 43 | // e.g. you can preserve the log even if it is not always visible 44 | const logCatcher = new LogCatcher(); 45 | 46 | render( 47 |
48 | 49 |
50 | 51 |
, 52 | ); 53 | ``` 54 | 55 | ## License 56 | 57 | MIT 58 | -------------------------------------------------------------------------------- /src/countRows.ts: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util'; 2 | import LogEntry from './LogEntry'; 3 | 4 | function sum(values: number[]): number { 5 | return values.reduce((acc, count) => acc + count, 0); 6 | } 7 | function countDirValueRows(value: any, depth: number): number { 8 | if (Array.isArray(value)) { 9 | if (depth === 0 || value.length === 0) { 10 | return 1; 11 | } 12 | return ( 13 | // +2 for row with the `[` character and row with the `]` character 14 | 2 + sum(value.map((v: any) => countDirValueRows(v, depth - 1))) 15 | ); 16 | } else if (value && typeof value === 'object') { 17 | if (depth === 0 || Object.keys(value).length === 0) { 18 | return 1; 19 | } 20 | return ( 21 | 2 + // +2 for row with the `{` character and row with the `}` character 22 | sum( 23 | Object.keys(value).map(name => 24 | countDirValueRows(value[name], depth - 1), 25 | ), 26 | ) 27 | ); 28 | } 29 | return inspect(value).split('\n').length; 30 | } 31 | 32 | function countNormalValueRows(values: any[]): number { 33 | return ( 34 | 1 + 35 | sum( 36 | values.map( 37 | v => (typeof v === 'string' ? v : inspect(v)).split('\n').length - 1, 38 | ), 39 | ) 40 | ); 41 | } 42 | 43 | export default function countRows(entry: LogEntry, depth: number): number { 44 | if (entry.type === 'dir') { 45 | return countDirValueRows(entry.value, depth); 46 | } else { 47 | return countNormalValueRows(entry.values); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/actions/__test__/expand.test.ts: -------------------------------------------------------------------------------- 1 | import expand from '../expand'; 2 | 3 | test('down', () => { 4 | const noLogEntries = { 5 | pinned: true, 6 | log: [], 7 | depth: 0, 8 | offset: 0, 9 | lastEntryToDisplayIndex: 0, 10 | }; 11 | expect(expand(noLogEntries, {lines: 20})).toBe(noLogEntries); 12 | 13 | const noDirEntries = { 14 | pinned: true, 15 | log: [ 16 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 17 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 18 | ], 19 | depth: 0, 20 | offset: 0, 21 | lastEntryToDisplayIndex: 0, 22 | }; 23 | expect(expand(noDirEntries, {lines: 20})).toBe(noDirEntries); 24 | 25 | const expandable = { 26 | pinned: true, 27 | log: [ 28 | {type: 'dir' as 'dir', value: ['foo\nbar\nbaz']}, 29 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 30 | ], 31 | depth: 0, 32 | offset: 0, 33 | lastEntryToDisplayIndex: 0, 34 | }; 35 | expect(expand(expandable, {lines: 20})).toEqual({...expandable, depth: 1}); 36 | 37 | const semiExpandable = { 38 | pinned: true, 39 | log: [ 40 | {type: 'dir' as 'dir', value: ['foo\nbar\nbaz']}, 41 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 42 | ], 43 | depth: 1, 44 | offset: 0, 45 | lastEntryToDisplayIndex: 0, 46 | }; 47 | expect(expand(semiExpandable, {lines: 20})).toBe(semiExpandable); 48 | 49 | const nonPinned = { 50 | pinned: false, 51 | log: [ 52 | {type: 'log' as 'log', values: ['foo\nbar\nbaz']}, 53 | {type: 'dir' as 'dir', value: ['foo\nbar\nbaz']}, 54 | ], 55 | depth: 0, 56 | offset: 0, 57 | lastEntryToDisplayIndex: 0, 58 | }; 59 | expect(expand(nonPinned, {lines: 20})).toBe(nonPinned); 60 | }); 61 | -------------------------------------------------------------------------------- /src/renderDirOutput.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import {inspect} from 'util'; 3 | import indentString = require('indent-string'); 4 | import {DirEntry} from './LogEntry'; 5 | 6 | export interface DirOutputOptions { 7 | depth: number; 8 | expandKey: string; 9 | } 10 | 11 | export function renderDirOutputValue( 12 | value: any, 13 | depth: number, 14 | options: DirOutputOptions, 15 | ): string { 16 | if (Array.isArray(value)) { 17 | if (value.length === 0) { 18 | return '[]'; 19 | } 20 | if (depth === 0) { 21 | return ( 22 | '[Array] ' + chalk.blue('(press ' + options.expandKey + ' to expand)') 23 | ); 24 | } else { 25 | return ( 26 | '[\n' + 27 | indentString( 28 | value 29 | .map(v => renderDirOutputValue(v, depth - 1, options) + ',') 30 | .join('\n'), 31 | 2, 32 | ) + 33 | '\n]' 34 | ); 35 | } 36 | } else if (value && typeof value === 'object') { 37 | if (Object.keys(value).length === 0) { 38 | return '{}'; 39 | } 40 | if (depth === 0) { 41 | return ( 42 | '[Object] ' + chalk.blue('(press ' + options.expandKey + ' to expand)') 43 | ); 44 | } else { 45 | return ( 46 | '{\n' + 47 | indentString( 48 | Object.keys(value) 49 | .map( 50 | name => 51 | name + 52 | ': ' + 53 | renderDirOutputValue(value[name], depth - 1, options) + 54 | ',', 55 | ) 56 | .join('\n'), 57 | 2, 58 | ) + 59 | '\n}' 60 | ); 61 | } 62 | } else { 63 | return inspect(value, {colors: true}); 64 | } 65 | } 66 | 67 | export default function renderDirOutput( 68 | entry: DirEntry, 69 | options: DirOutputOptions, 70 | ) { 71 | return renderDirOutputValue(entry.value, options.depth, options); 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ink-console", 3 | "version": "1.0.1", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "description": "Render a scrollable terminal log in your ink app", 7 | "keywords": [], 8 | "files": [ 9 | "lib/" 10 | ], 11 | "dependencies": { 12 | "@types/chalk": "^0.4.31", 13 | "@types/indent-string": "^3.0.0", 14 | "@types/node": "^8.0.14", 15 | "chalk": "^2.0.1", 16 | "indent-string": "^3.1.0", 17 | "ts-ink": "^1.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/jest": "*", 21 | "flowgen2": "*", 22 | "husky": "*", 23 | "jest": "*", 24 | "lint-staged": "*", 25 | "prettier": "*", 26 | "ts-jest": "*", 27 | "ts-node": "^3.2.1", 28 | "typescript": "*" 29 | }, 30 | "scripts": { 31 | "precommit": "lint-staged", 32 | "prepublish": "npm run build", 33 | "prettier": "prettier --parser typescript --single-quote --trailing-comma all --no-bracket-spacing --write \"src/**/*.{ts,tsx}\"", 34 | "prettier:check": "prettier --parser typescript --single-quote --trailing-comma all --no-bracket-spacing --list-different \"src/**/*.{ts,tsx}\"", 35 | "build": "tsc && flowgen lib/**/*", 36 | "test": "jest ./src --coverage", 37 | "watch": "jest ./src --coverage --watch" 38 | }, 39 | "lint-staged": { 40 | "*.{ts,tsx}": [ 41 | "prettier --parser typescript --single-quote --trailing-comma all --no-bracket-spacing --write", 42 | "git add" 43 | ] 44 | }, 45 | "jest": { 46 | "testEnvironment": "node", 47 | "moduleFileExtensions": [ 48 | "ts", 49 | "tsx", 50 | "js" 51 | ], 52 | "transform": { 53 | "\\.(ts|tsx)$": "/node_modules/ts-jest/preprocessor.js" 54 | }, 55 | "testMatch": [ 56 | "**/*.test.(ts|tsx|js)" 57 | ] 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "https://github.com/ForbesLindesay/ink-console.git" 62 | }, 63 | "author": { 64 | "name": "Forbes Lindesay", 65 | "url": "http://github.com/ForbesLindesay" 66 | }, 67 | "license": "MIT" 68 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import {h, Component, Text} from 'ts-ink'; 2 | 3 | import LogCatcher from './LogCatcher'; 4 | 5 | import Props from './Props'; 6 | import renderString from './renderString'; 7 | import State from './State'; 8 | 9 | import * as actions from './actions'; 10 | 11 | const CONTRACT_KEY = 'h'; 12 | const EXPAND_KEY = 'l'; 13 | const UP_KEY = 'k'; 14 | const DOWN_KEY = 'j'; 15 | const PAGE_UP_KEY = 'K'; 16 | const PAGE_DOWN_KEY = 'J'; 17 | const PIN_KEY = 'G'; 18 | 19 | export {LogCatcher, Props}; 20 | 21 | export default class LogOutput extends Component { 22 | readonly state: Readonly = { 23 | log: [], 24 | pinned: true, 25 | lastEntryToDisplayIndex: 0, 26 | offset: 0, 27 | depth: 2, 28 | }; 29 | _reset: undefined | (() => void); 30 | _logCatcher: undefined | LogCatcher; 31 | 32 | private handleKeyPress = (ch: string) => { 33 | switch (ch) { 34 | case UP_KEY: 35 | this.setState(s => actions.up(s, this.props)); 36 | break; 37 | case DOWN_KEY: 38 | this.setState(actions.down); 39 | break; 40 | case PAGE_UP_KEY: 41 | this.setState(s => actions.pageUp(s, this.props)); 42 | break; 43 | case PAGE_DOWN_KEY: 44 | this.setState(s => actions.pageDown(s, this.props)); 45 | break; 46 | case PIN_KEY: 47 | this.setState(actions.pin); 48 | break; 49 | case EXPAND_KEY: 50 | this.setState(s => actions.expand(s, this.props)); 51 | break; 52 | case CONTRACT_KEY: 53 | this.setState(s => actions.shrink(s, this.props)); 54 | break; 55 | } 56 | }; 57 | 58 | render() { 59 | return ( 60 |
61 |
62 | Log Output 63 |
64 |
65 |
{renderString(this.state, this.props)}
66 |
67 |
68 | 69 | (Move up: {UP_KEY}, Move down:{' '} 70 | {DOWN_KEY}, Page up:{' '} 71 | {PAGE_UP_KEY}, Page down:{' '} 72 | {PAGE_DOWN_KEY}, Pin to end of log:{' '} 73 | {PIN_KEY}, Expand objects:{' '} 74 | {EXPAND_KEY}, Shrink objects:{' '} 75 | {CONTRACT_KEY}) 76 | 77 |
78 |
79 | ); 80 | } 81 | componentDidMount() { 82 | process.stdin.on('keypress', this.handleKeyPress); 83 | this._updateLogCatcher(this.props); 84 | } 85 | private _handleLogCatcherUpdate = () => { 86 | if (this.props.logCatcher) { 87 | this.setState({log: this.props.logCatcher.getLog()}); 88 | } else if (this._logCatcher) { 89 | this.setState({log: this._logCatcher.getLog()}); 90 | } 91 | }; 92 | private _updateLogCatcher(props: Props) { 93 | if (this._reset) { 94 | this._reset(); 95 | } 96 | if (this._logCatcher) { 97 | this._logCatcher.dispose(); 98 | this._logCatcher = undefined; 99 | } 100 | if (this.props.logCatcher) { 101 | this._reset = this.props.logCatcher.onUpdate( 102 | this._handleLogCatcherUpdate, 103 | ); 104 | } else { 105 | this._logCatcher = new LogCatcher(); 106 | this._reset = this._logCatcher.onUpdate(this._handleLogCatcherUpdate); 107 | } 108 | this._handleLogCatcherUpdate(); 109 | } 110 | componentWillReceiveProps(props: Props) { 111 | if (this.props.logCatcher !== props.logCatcher) { 112 | this._updateLogCatcher(props); 113 | } 114 | } 115 | componentWillUnmount() { 116 | process.stdin.removeListener('keypress', this.handleKeyPress); 117 | if (this._reset) { 118 | this._reset(); 119 | } 120 | if (this._logCatcher) { 121 | this._logCatcher.dispose(); 122 | this._logCatcher = undefined; 123 | } 124 | } 125 | } 126 | --------------------------------------------------------------------------------