├── public ├── favicon.ico ├── manifest.json └── index.html ├── .prettierrc ├── src ├── index.js ├── App.test.js ├── indicators.jsx ├── environment.js ├── App.styles.jsx ├── global.styles.jsx ├── commands.js ├── fixtures.js ├── history.styles.jsx ├── App.jsx ├── history.jsx └── prompt.jsx ├── dat.json ├── .datignore ├── .gitignore ├── package.json └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdesserts/webterm/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /dat.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "dat://34cbbc7e8bbdfbb963fab1d103cf86f87ff97776b0ae1c8ca05d1755fe1e1fdb/", 3 | "title": "WebTerm", 4 | "description": "", 5 | "web_root": "/build", 6 | "fallback_page": "/index.html" 7 | } -------------------------------------------------------------------------------- /.datignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/indicators.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Indicator = styled.span` 4 | font-family: 'Courier New'; 5 | font-size: 20px; 6 | width: 1rem; 7 | line-height: 1rem; 8 | font-weight: 600; 9 | color: var(--light-grey); 10 | ` 11 | 12 | export const InputIndicator = Indicator.extend.attrs({ children: '»' })`` 13 | export const OutputIndicator = Indicator.extend.attrs({ children: '«' })`` -------------------------------------------------------------------------------- /src/environment.js: -------------------------------------------------------------------------------- 1 | export class Environment { 2 | constructor({ home, cwd, onChange }) { 3 | let home_url = new URL(home); 4 | let cwd_url = new URL(cwd); 5 | this.home = home_url; 6 | this.cwd = cwd_url; 7 | this.archive = new window.DatArchive(cwd_url.origin); 8 | this._onChange = onChange; 9 | } 10 | 11 | async setCWD(dir) { 12 | let onChange = this._onChange; 13 | let env = new Environment({ 14 | home: this.home, 15 | cwd: dir, 16 | onChange, 17 | }); 18 | onChange(env); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webterm", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test --env=jsdom", 9 | "eject": "react-scripts eject", 10 | "precommit": "pretty-quick --staged", 11 | "prettier": "prettier --write src/**/*.{js,json,css,md}" 12 | }, 13 | "dependencies": { 14 | "pretty-quick": "^1.4.1", 15 | "react": "^16.2.0", 16 | "react-dom": "^16.2.0", 17 | "react-scripts": "1.1.1", 18 | "styled-components": "^3.2.1", 19 | "husky": "^0.14.3", 20 | "prettier": "1.11.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/App.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Terminal = styled.main` 4 | display: grid; 5 | grid-template-rows: 1fr auto auto; 6 | height: 100%; 7 | position: relative; 8 | `; 9 | 10 | export const Seperator = styled.hr` 11 | border: none; 12 | width: 100%; 13 | margin: 0; 14 | `; 15 | 16 | export const Titlebar = styled.div` 17 | color: var(--light-grey); 18 | padding: 16px; 19 | text-align: center; 20 | display: block; 21 | position: absolute; 22 | pointer-events: none; 23 | background: no-repeat linear-gradient(to bottom, var(--black), transparent); 24 | height: 140px; 25 | width: 100%; 26 | top: 0; 27 | z-index: 1; 28 | `; 29 | -------------------------------------------------------------------------------- /src/global.styles.jsx: -------------------------------------------------------------------------------- 1 | import { injectGlobal } from 'styled-components'; 2 | 3 | injectGlobal` 4 | :root { 5 | --pitch-black: #0E100E; 6 | --black: #151715; 7 | --grey: #4F4F4F; 8 | --light-grey: #BDBDBD; 9 | --white: #F2F2F2; 10 | --magenta: #FF3672; 11 | --violet: #4D5EFF; 12 | --gold: #FFC24D; 13 | /* font-family: "Helvetica Neue", "Arial Nova", Helvetica, Arial, sans-serif; */ 14 | font-family: "Courier New", "Courier", monospace; 15 | font-size: 16px; 16 | line-height: 1.3; 17 | letter-spacing: .5px; 18 | 19 | background-color: var(--black); 20 | color: var(--white); 21 | } 22 | 23 | *, *::before, *::after { 24 | box-sizing: border-box; 25 | } 26 | 27 | body { background-color: transparent; } 28 | 29 | html, body, #root { 30 | width: 100vw 31 | height: 100vh 32 | margin: 0; 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /src/commands.js: -------------------------------------------------------------------------------- 1 | import { commands } from './fixtures'; 2 | 3 | export async function execute(input, env) { 4 | const result = { 5 | timestamp: new Date(Date.now()), 6 | input, 7 | command: null, 8 | url: env.cwd, 9 | }; 10 | 11 | const parsed_command = await parse(input); 12 | if (!parsed_command) return result; 13 | let { name, args } = parsed_command; 14 | 15 | const command = await commands.find(command => name === command.name); 16 | 17 | if (!command) 18 | return Object.assign(result, { output: new Error('Unknown Command') }); 19 | 20 | const output = await command.execute(env, args); 21 | return Object.assign(result, { output, command: { name, args } }); 22 | } 23 | 24 | function parse(input) { 25 | if (typeof input !== 'string') return null; 26 | let [name, ...args] = input.trim().split(' '); 27 | if (!name) return null; 28 | return { name, args }; 29 | } 30 | -------------------------------------------------------------------------------- /src/fixtures.js: -------------------------------------------------------------------------------- 1 | export const HOME = 2 | 'dat://87ed2e3b160f261a032af03921a3bd09227d0a4cde73466c17114816cae43336/'; 3 | 4 | export const commands = [ 5 | { name: 'ls', execute: ls }, 6 | { name: 'cd', execute: cd }, 7 | { name: 'pwd', execute: pwd }, 8 | { name: 'echo', execute: echo }, 9 | ]; 10 | 11 | async function ls(env, args, opts = {}) { 12 | let { archive, cwd } = env; 13 | 14 | // read 15 | let listing = await archive.readdir(cwd.pathname, { stat: true }); 16 | 17 | return listing 18 | .filter(entry => { 19 | if (opts.all || opts.a) return true; 20 | return entry.name.startsWith('.') === false; 21 | }) 22 | .sort((a, b) => { 23 | // dirs on top 24 | if (a.stat.isDirectory() && !b.stat.isDirectory()) return -1; 25 | if (!a.stat.isDirectory() && b.stat.isDirectory()) return 1; 26 | return a.name.localeCompare(b.name); 27 | }) 28 | .map(entry => entry.name); 29 | } 30 | 31 | async function cd(env, args, opts = {}) { 32 | let [dir] = args; 33 | if (!dir) env.setCWD(env.cwd.origin); 34 | else { 35 | if (!dir.endsWith('/')) dir += '/'; 36 | env.setCWD(new URL(dir, env.cwd)); 37 | } 38 | return undefined; 39 | } 40 | 41 | async function echo(env, args, opts = {}) { 42 | return args.join(' '); 43 | } 44 | 45 | async function pwd(env, args, opts = {}) { 46 | return env.cwd; 47 | } 48 | -------------------------------------------------------------------------------- /src/history.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as indicators from './indicators'; 3 | 4 | export const HistoryWrapper = styled.div` 5 | overflow: auto; 6 | padding-top: 100px; 7 | display: grid; 8 | `; 9 | 10 | export const CenteredContent = styled.div` 11 | margin: 0 auto; 12 | display: grid; 13 | max-width: 600px; 14 | width: 100%; 15 | grid-row-gap: 16px; 16 | align-content: end; 17 | padding: 16px; 18 | `; 19 | 20 | export const Result = styled.div` 21 | display: grid; 22 | grid-template-columns: min-content 2fr 1fr; 23 | grid-gap: 8px 8px; 24 | align-items: top; 25 | line-height: 1.2rem; 26 | max-width: 100%; 27 | `; 28 | 29 | export const Input = styled.code` 30 | color: var(--white); 31 | word-wrap: break-word; 32 | overflow: hidden; 33 | `; 34 | 35 | export const Command = styled.span` 36 | color: var(--magenta); 37 | `; 38 | 39 | export const Args = styled.span``; 40 | 41 | export const Location = styled.span` 42 | color: var(--light-grey); 43 | margin-left: auto; 44 | `; 45 | 46 | export const OutputList = styled.ul` 47 | margin: 0; 48 | padding: 0; 49 | list-style: none; 50 | `; 51 | 52 | export const Output = styled.div` 53 | background-color: var(--pitch-black); 54 | border-radius: 2px; 55 | padding: 16px; 56 | grid-column: span 3; 57 | overflow: auto; 58 | 59 | display: grid; 60 | grid-template-columns: auto 1fr; 61 | grid-gap: 8px; 62 | `; 63 | 64 | export const InputIndicator = styled(indicators.InputIndicator)` 65 | margin-left: 16px; 66 | `; 67 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import * as commands from './commands'; 3 | import { Environment } from './environment'; 4 | import * as fixtures from './fixtures'; 5 | import './global.styles.jsx'; 6 | 7 | import { Prompt } from './prompt'; 8 | import { History } from './history'; 9 | import * as el from './App.styles'; 10 | 11 | class App extends Component { 12 | onEnvironmentChange = env => { 13 | this.setState({ env }); 14 | }; 15 | 16 | state = { 17 | history: [], 18 | env: new Environment({ 19 | home: fixtures.HOME, 20 | cwd: fixtures.HOME, 21 | onChange: this.onEnvironmentChange, 22 | }), 23 | }; 24 | 25 | constructor(props) { 26 | super(props); 27 | this.onSubmit = this.onSubmit.bind(this); 28 | } 29 | 30 | async onSubmit(input) { 31 | let { env } = this.state; 32 | const result = await commands.execute(input, env); 33 | const new_history = this.state.history.concat([result]); 34 | this.setState({ history: new_history }); 35 | } 36 | 37 | render() { 38 | let { env } = this.state; 39 | let isHome = env.cwd.origin === env.home.origin; 40 | return ( 41 | 42 | {env.cwd.origin} 43 | 44 | 45 | 51 | 52 | ); 53 | } 54 | } 55 | 56 | export default App; 57 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Web Term 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/history.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { OutputIndicator } from './indicators'; 3 | import * as el from './history.styles'; 4 | 5 | export function OutputValue({ output }) { 6 | if (Array.isArray(output)) { 7 | return ( 8 | 9 | {output.map((item, i) => ( 10 |
  • 11 | 12 |
  • 13 | ))} 14 |
    15 | ); 16 | } else if (typeof output === 'string' || output === null) { 17 | return output; 18 | } else if (output instanceof Error || output instanceof URL) { 19 | return output.toString(); 20 | } else { 21 | throw new Error( 22 | `we have not implemented rendering for this type of output yet: ${typeof output}`, 23 | ); 24 | } 25 | } 26 | 27 | export function History({ home, history }) { 28 | return ( 29 | { 31 | if (ref) ref.scrollTop = ref.scrollHeight; 32 | }} 33 | > 34 | 35 | {history.map(result => ( 36 | 41 | ))} 42 | 43 | 44 | ); 45 | } 46 | 47 | export function CommandResult({ home, result }) { 48 | let isHome = home.origin === result.url.origin; 49 | return ( 50 | 51 | 52 | 53 | {result.command ? ( 54 | 55 | {result.command.name}{' '} 56 | {result.command.args.join(' ')} 57 | 58 | ) : ( 59 | result.input 60 | )} 61 | 62 | 63 | {isHome ? '~' : ''} 64 | {result.url.pathname} 65 | 66 | {result.output ? ( 67 | 68 | 69 | 70 | 71 | ) : null} 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebTerm 2 | 3 | In his original blog post, [Reimagining the browser as a Network OS][network-os], Paul Frazee outlines the idea of a terminal for the web, and more importantly, what an OS built on top of the p2p web might look like. You can view his [original pull request here][original-pr]. This app is a personal attempt at continuing his work. 4 | 5 | [network-os]: https://pfrazee.hashbase.io/blog/reimagining-the-browser-as-a-network-os 6 | [original-pr]: https://github.com/beakerbrowser/beaker/pull/589 7 | 8 | ## Ideas & Goals 9 | 10 | Although this project is heavily inspired by Paul's PR, it is a ground up rewrite, and it's still very early on in its development. Some bits are still missing. Ultimately I have a few personal goals with this experiment: 11 | 12 | * **It's just a Web App** – The original PR was based on a `term://` protocol built into Beaker itself. Although I'm not ruling out the possibility of this being built into Beaker eventually, most of the current features don't _require_ this to be built into the browser. I want to see how far we can get in userland with nothing but Beaker's core APIs. 13 | * **It's just Dat** - In this experiment, your file system is built on top of dat. Consiquentially, this experiment should take full advantage of everything dat has to offer. You should be able to: 14 | 1. Easily sync your workspace between multiple devices. 15 | 2. Quickly undo changes to your filesystem (never fear `rm -rf` again). 16 | 3. Easily collaborate with multiple authors on projects (w/ multiwritter) 17 | * **User Friendliness** – The terminal is notoriously scary for new developers. What if we changed that? What if commands and their APIs were discoverable? What if documentation was consistantly available? How can we maintain the power of the terminal while simultaneously removing some of its footguns? 18 | * **Multi Language** – One important piece that I would like to explore is the ability to write commands with Web Assembly. Although the current wasm API is limited I would like to provide first-class support in the command spec. That way if you want to write your commands in Rust or something similar and run them in the browser, you can. 19 | 20 | ## Contributing 21 | 22 | The current project is so young that I'm not really looking for contributors ATM, but nevertheless I would love to hear your ideas. Feel free to [post an issue][] or message me [on twitter][]. I also stream this project's development live [on twitch][]. 23 | 24 | [on twitter]: https://twitter.com/webdesserts 25 | [on twitch]: https://www.twitch.tv/webdesserts 26 | [post an issue]: https://github.com/webdesserts/webterm/issues 27 | -------------------------------------------------------------------------------- /src/prompt.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { InputIndicator } from './indicators'; 5 | 6 | const CenteredContent = styled.div` 7 | margin: 0 auto; 8 | max-width: 600px; 9 | width: 100%; 10 | display: grid; 11 | grid-template-columns: 24px 1fr; 12 | grid-gap: 8px 0; 13 | padding: 16px 16px 16px 24px; 14 | align-items: baseline; 15 | `; 16 | 17 | const Location = styled.div` 18 | grid-column: span 2; 19 | color: var(--light-grey); 20 | `; 21 | 22 | const Input = styled.input.attrs({ type: 'text' })` 23 | background-color: transparent; 24 | border: none; 25 | color: var(--white); 26 | font: inherit; 27 | border-bottom: 1px solid var(--light-grey); 28 | padding: 4px 8px; 29 | &:focus { 30 | outline: none; 31 | border-color: var(--magenta); 32 | } 33 | `; 34 | 35 | export class Prompt extends React.Component { 36 | static propTypes = { 37 | onSubmit: PropTypes.func, 38 | history: PropTypes.array, 39 | url: PropTypes.instanceOf(URL), 40 | }; 41 | 42 | static defaultProps = { 43 | onSubmit: () => {}, 44 | }; 45 | 46 | state = { 47 | value: '', 48 | historyIndex: null, 49 | }; 50 | 51 | componentWillReceiveProps(nextProps) { 52 | this.setState({ historyIndex: nextProps.history.length }); 53 | } 54 | 55 | onKeyDown = event => { 56 | let { history, onSubmit } = this.props; 57 | let { value, historyIndex: currentHistoryIndex } = this.state; 58 | 59 | if (event.key === 'Enter') { 60 | onSubmit(value); 61 | this.setState({ value: '' }); 62 | } else if (event.key === 'ArrowUp') { 63 | let historyIndex = currentHistoryIndex - 1; 64 | let command = history[historyIndex]; 65 | if (command) { 66 | this.setState({ 67 | historyIndex, 68 | value: command.input, 69 | }); 70 | } 71 | } else if (event.key === 'ArrowDown') { 72 | let historyIndex = currentHistoryIndex + 1; 73 | let command = history[historyIndex]; 74 | if (command) { 75 | this.setState({ 76 | historyIndex, 77 | value: command.input, 78 | }); 79 | } 80 | } 81 | }; 82 | 83 | onChange = event => { 84 | let { value } = event.target; 85 | this.setState({ value }); 86 | }; 87 | 88 | render() { 89 | let { isHome, url } = this.props; 90 | let { value } = this.state; 91 | return ( 92 | 93 | 94 | 95 | {isHome ? '~' : ''} 96 | {url.pathname} 97 | 98 | 99 | 106 | 107 | 108 | ); 109 | } 110 | } 111 | --------------------------------------------------------------------------------