diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000000..a9666206d9e --- /dev/null +++ b/.babelrc @@ -0,0 +1,17 @@ +{ + "presets": ["react", "es2015", "stage-0"], + "env": { + "development": { + "plugins": [ + ["react-transform", { + "transforms": [{ + "transform": "react-transform-hmr", + "imports": ["react"], + "locals": ["module"] + }] + }] + ] + } + }, + "plugins": ["transform-runtime"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..49c140adee4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +typings/ +node_modules/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..efcaee5e85d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +notifications: + email: + on_success: never + on_failure: change + +language: node_js +node_js: + - "node" + +env: + - CXX=g++-4.8 + +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 + +before_script: + - export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start diff --git a/README.md b/README.md index e69de29bb2d..174fbdb1710 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,24 @@ +# Desktop tech proof-of-concept + +Just playin around with some computering. + +``` +npm install +``` + +If you want hot loading: + +``` +# Start the dev server +npm start + +# And in another Terminal, launch the app +npm run dev +``` + +Or if you don't care: + +``` +npm run build +npm run dev +``` diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000000..a3aae564f7c --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,24 @@ +environment: + nodejs_version: "5" + +branches: + only: + - master + +skip_tags: true + +version: "{build}" + +install: + - ps: Install-Product node $env:nodejs_version + - npm install + +test_script: + - node --version + - npm --version + - npm test + +build: off + +cache: + - node_modules diff --git a/dev_server.js b/dev_server.js new file mode 100644 index 00000000000..4ee7cf5df9a --- /dev/null +++ b/dev_server.js @@ -0,0 +1,22 @@ +var express = require('express') +var webpack = require('webpack') +var config = require('./webpack.config') + +var app = express() +var compiler = webpack(config) +var port = process.env.PORT || 3000 + +app.use(require('webpack-dev-middleware')(compiler, { + publicPath: config.output.publicPath +})) + +app.use(require('webpack-hot-middleware')(compiler)) + +app.listen(port, 'localhost', err => { + if (err) { + console.log(err) + return + } + + console.log(`Listening at http://localhost:${port}`) +}) diff --git a/lib/app.tsx b/lib/app.tsx new file mode 100644 index 00000000000..1e4fad51336 --- /dev/null +++ b/lib/app.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import ThingList from './thing-list' +import Info from './info' + +const Octokat = require('octokat') + +type AppState = { + selectedRow: number +} + +type AppProps = { + style?: Object +} + +const AppStyle = { + display: 'flex', + flexDirection: 'row', + flexGrow: 1 +} + +export default class App extends React.Component { + private octo: any + + public constructor(props: AppProps) { + super(props) + + this.octo = new Octokat({ + token: process.env.GITHUB_ACCESS_TOKEN + }) + + this.state = {selectedRow: -1} + } + + public async componentDidMount() { + const zen = await this.octo.zen.read() + console.log('zen', zen) + } + + public render() { + const completeStyle = Object.assign({}, this.props.style, AppStyle) + return ( +
+ this.handleSelectionChanged(row)}/> + +
+ ) + } + + private handleSelectionChanged(row: number) { + this.setState({selectedRow: row}) + } +} diff --git a/lib/browser/app-window.ts b/lib/browser/app-window.ts new file mode 100644 index 00000000000..78d92990300 --- /dev/null +++ b/lib/browser/app-window.ts @@ -0,0 +1,35 @@ +import {BrowserWindow} from 'electron' + +import Stats from './stats' + +export default class AppWindow extends BrowserWindow { + private stats: Stats + + public constructor(stats: Stats) { + super({width: 800, height: 600, show: false, titleBarStyle: 'hidden'}) + + this.stats = stats + } + + public load() { + let startLoad: number = null + this.webContents.on('did-finish-load', () => { + if (process.env.NODE_ENV === 'development') { + this.webContents.openDevTools() + } + + this.show() + + const now = Date.now() + this.rendererLog(`Loading: ${now - startLoad}ms`) + this.rendererLog(`Launch: ${now - this.stats.launchTime}ms`) + }) + + startLoad = Date.now() + this.loadURL(`file://${__dirname}/../../static/index.html`) + } + + private rendererLog(msg: string) { + this.webContents.send('log', msg) + } +} diff --git a/lib/browser/main.ts b/lib/browser/main.ts new file mode 100644 index 00000000000..6c3989b3c40 --- /dev/null +++ b/lib/browser/main.ts @@ -0,0 +1,25 @@ +import {app} from 'electron' + +import AppWindow from './app-window' +import Stats from './stats' + +const stats = new Stats() + +let mainWindow: AppWindow = null + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) + +app.on('ready', () => { + stats.readyTime = Date.now() + + mainWindow = new AppWindow(stats) + mainWindow.on('closed', () => { + mainWindow = null + }) + + mainWindow.load() +}) diff --git a/lib/browser/stats.ts b/lib/browser/stats.ts new file mode 100644 index 00000000000..6399c38e74c --- /dev/null +++ b/lib/browser/stats.ts @@ -0,0 +1,8 @@ +export default class Stats { + public launchTime: number + public readyTime: number + + public constructor() { + this.launchTime = Date.now() + } +} diff --git a/lib/index.tsx b/lib/index.tsx new file mode 100644 index 00000000000..f9415a081aa --- /dev/null +++ b/lib/index.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' + +import {ipcRenderer} from 'electron' + +import App from './app' + +ipcRenderer.on('log', (event, msg) => { + console.log(msg) +}) + +const style = { + paddingTop: process.platform === 'darwin' ? 20 : 0 +} + +ReactDOM.render(, document.getElementById('content')) diff --git a/lib/info.tsx b/lib/info.tsx new file mode 100644 index 00000000000..ad8f0226cb5 --- /dev/null +++ b/lib/info.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' + +const LOLZ = [ + 'http://www.reactiongifs.com/r/drkrm.gif', + 'http://www.reactiongifs.com/r/wvy1.gif', + 'http://www.reactiongifs.com/r/ihniwid.gif', + 'http://www.reactiongifs.com/r/dTa.gif', + 'http://www.reactiongifs.com/r/didit.gif' +] + +type InfoProps = { + selectedRow: number +} + +const ContainerStyle = { + display: 'flex', + flexDirection: 'column', + flex: 1 +} + +const ImageStyle = { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + flex: 1 +} + +export default class Info extends React.Component { + private renderNoSelection() { + return ( +
No row selected!
+ ) + } + + public render() { + const row = this.props.selectedRow + if (row < 0) { + return this.renderNoSelection() + } + + const img = LOLZ[row % LOLZ.length] + return ( +
+ Row {row + 1} is selected! + +
+ +
+
+ ) + } +} diff --git a/lib/list.tsx b/lib/list.tsx new file mode 100644 index 00000000000..513c5de08b1 --- /dev/null +++ b/lib/list.tsx @@ -0,0 +1,162 @@ +import * as React from 'react' +import * as ReactDOM from 'react-dom' + +type ListProps = { + renderItem: (row: number) => JSX.Element, + itemCount: number, + itemHeight: number, + selectedRow?: number, + onSelectionChanged?: (row: number) => void, + style?: Object +} + +type ListState = { + scrollPosition: number, + numberOfItemsToRender: number +} + +export default class List extends React.Component { + public refs: { + [key: string]: any, + list: Element + } + + private firstRender: boolean + + public constructor(props: ListProps) { + super(props) + + this.firstRender = true + + this.state = {scrollPosition: 0, numberOfItemsToRender: 0} + } + + private handleKeyDown(e: React.KeyboardEvent) { + let direction: 'up' | 'down' + if (e.key === 'ArrowDown') { + direction = 'down' + } else if (e.key === 'ArrowUp') { + direction = 'up' + } else { + return + } + + this.moveSelection(direction) + + e.preventDefault() + } + + private moveSelection(direction: 'up' | 'down') { + let newRow = this.props.selectedRow + if (direction === 'up') { + newRow = this.props.selectedRow - 1 + if (newRow < 0) { + newRow = this.props.itemCount - 1 + } + } else { + newRow = this.props.selectedRow + 1 + if (newRow > this.props.itemCount - 1) { + newRow = 0 + } + } + + this.props.onSelectionChanged(newRow) + this.scrollRowToVisible(newRow) + } + + private scrollRowToVisible(row: number) { + const top = row * this.props.itemHeight + const bottom = top + this.props.itemHeight + const list = this.refs.list + const rangeStart = list.scrollTop + const rangeEnd = list.scrollTop + list.clientHeight + + if (top < rangeStart) { + this.refs.list.scrollTop = top + } else if (bottom > rangeEnd) { + this.refs.list.scrollTop = bottom - list.clientHeight + } + } + + private updateScrollPosition() { + const newScrollPosition = Math.round(this.refs.list.scrollTop / this.props.itemHeight) + const difference = Math.abs(this.state.scrollPosition - newScrollPosition) + + if (difference >= this.state.numberOfItemsToRender / 5) { + this.setState({scrollPosition: newScrollPosition, numberOfItemsToRender: this.state.numberOfItemsToRender}) + } + } + + private renderItems(startPosition: number, endPosition: number): JSX.Element[] { + const items: JSX.Element[] = [] + for (let row = startPosition; row < endPosition; row++) { + const element = this.props.renderItem(row) + items.push( +
this.handleMouseDown(row)}> + {element} +
+ ) + } + + return items + } + + public componentDidUpdate() { + this.updateVisibleRows() + } + + private updateVisibleRows() { + const element = ReactDOM.findDOMNode(this) + const numberOfVisibleRows = Math.ceil(element.clientHeight / this.props.itemHeight) + const numberOfRowsToRender = numberOfVisibleRows * 3 + if (numberOfRowsToRender !== this.state.numberOfItemsToRender) { + this.setState({scrollPosition: this.state.scrollPosition, numberOfItemsToRender: numberOfRowsToRender}) + } + } + + public render() { + const startPosition = Math.max(this.state.scrollPosition - this.state.numberOfItemsToRender, 0) + const endPosition = Math.min(this.state.scrollPosition + this.state.numberOfItemsToRender, this.props.itemCount) + const listRequiredStyle = { + overflow: 'auto', + transform: 'translate3d(0, 0, 0)' + } + const listStyle = Object.assign({}, this.props.style, listRequiredStyle) + const containerStyle = { + position: 'relative', + overflow: 'hidden', + height: this.props.itemHeight * this.props.itemCount + } + + if (this.firstRender) { + // We don't know how tall we are until we've rendered. So the first time + // we render, we'll need to do it again :\ + process.nextTick(() => this.updateVisibleRows()) + } + + this.firstRender = false + + return ( +
this.updateScrollPosition()} + onKeyDown={e => this.handleKeyDown(e)}> +
+ {this.renderItems(startPosition, endPosition)} +
+
+ ) + } + + private handleMouseDown(row: number) { + this.props.onSelectionChanged(row) + } +} diff --git a/lib/thing-list.tsx b/lib/thing-list.tsx new file mode 100644 index 00000000000..203daed8a3a --- /dev/null +++ b/lib/thing-list.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' + +import List from './list' + +type ThingListProps = { + selectedRow: number, + onSelectionChanged: (row: number) => void +} + +const RowHeight = 44 + +export default class ThingList extends React.Component { + public constructor(props: ThingListProps) { + super(props) + } + + private renderRow(row: number): JSX.Element { + const selected = row === this.props.selectedRow + const inlineStyle = { + display: 'flex', + flexDirection: 'column', + padding: 4, + backgroundColor: selected ? 'blue' : 'white', + color: selected ? 'white' : 'black', + height: RowHeight + } + const whiteness = 140 + return ( +
+
Item {row + 1}
+
Some subtitle
+
+ ) + } + + public render() { + return ( + this.renderRow(row)} + selectedRow={this.props.selectedRow} + onSelectionChanged={row => this.props.onSelectionChanged(row)} + style={{width: 120}}/> + ) + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000000..b9870a5a77b --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "desktop", + "version": "0.0.1", + "description": "", + "main": "./build/lib/browser/main.js", + "scripts": { + "test": "electron-mocha --renderer --require ts-node/register test/*.ts test/*.tsx", + "postinstall": "typings install", + "start-server": "node dev_server.js", + "start": "npm run build && npm-run-all --parallel dev start-server", + "dev": "node script/run", + "build": "tsc && webpack && cp -R static build", + "clean": "rm -rf build", + "lint": "tslint lib/**/*.ts lib/**/*.tsx" + }, + "author": "", + "license": "MIT", + "dependencies": { + "babel-runtime": "^6.6.1", + "octokat": "^0.5.0-beta.0", + "react": "^15.0.2", + "react-dom": "^15.0.2", + "typescript": "^1.8.10", + "typings": "^0.7.12" + }, + "devDependencies": { + "babel-core": "^6.7.6", + "babel-loader": "^6.2.4", + "babel-plugin-react-transform": "^2.0.2", + "babel-plugin-transform-runtime": "^6.8.0", + "babel-preset-es2015": "^6.6.0", + "babel-preset-react": "^6.5.0", + "babel-preset-react-hmre": "^1.1.1", + "babel-preset-stage-0": "^6.5.0", + "chai": "^3.5.0", + "css-loader": "^0.23.1", + "electron-mocha": "^1.2.2", + "electron-prebuilt": "^0.37.5", + "express": "^4.13.4", + "mocha": "^2.4.5", + "npm-run-all": "^1.8.0", + "react-addons-test-utils": "^15.0.2", + "react-transform-hmr": "^1.0.4", + "style-loader": "^0.13.1", + "ts-loader": "^0.8.2", + "ts-node": "^0.7.2", + "tslint": "^3.9.0", + "webpack": "^1.12.15", + "webpack-dev-middleware": "^1.6.1", + "webpack-hot-middleware": "^2.10.0", + "webpack-target-electron-renderer": "^0.4.0" + } +} diff --git a/script/run b/script/run new file mode 100644 index 00000000000..a799c691c76 --- /dev/null +++ b/script/run @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +// This gets us the path to electron.exe within the node_modules directory. +var electron = require('electron-prebuilt'); +var proc = require('child_process'); + +proc.spawn(electron, [ ".", "--debug" ], { env: { "NODE_ENV" : "development" } }); diff --git a/script/run.cmd b/script/run.cmd new file mode 100644 index 00000000000..ab96794c890 --- /dev/null +++ b/script/run.cmd @@ -0,0 +1,5 @@ +@IF EXIST "%~dp0\node.exe" ( + "%~dp0\node.exe" "%~dp0\run" %* +) ELSE ( + node "%~dp0\run" %* +) diff --git a/static/index.html b/static/index.html new file mode 100644 index 00000000000..453cb970710 --- /dev/null +++ b/static/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 00000000000..2f9bed3ea29 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,37 @@ +* { + font-family: system, -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Lucida Grande"; +} + +html, body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +:not(input):not(textarea), +:not(input):not(textarea)::after, +:not(input):not(textarea)::before { + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +input, button, textarea, :focus { + outline: none; +} + +img { + user-drag: none; + -webkit-user-drag: none; + user-select: none; + pointer-events: none; +} + +#content { + display: flex; + + height: 100%; + width: 100%; +} diff --git a/test/app-test.tsx b/test/app-test.tsx new file mode 100644 index 00000000000..8d08d459f45 --- /dev/null +++ b/test/app-test.tsx @@ -0,0 +1,16 @@ +import * as chai from 'chai' +const expect = chai.expect + +import * as React from 'react' +import * as ReactDOM from 'react-dom' +import * as TestUtils from 'react-addons-test-utils' + +import App from '../lib/app' + +describe('App', () => { + it('renders', () => { + const app = TestUtils.renderIntoDocument() as React.Component + const node = ReactDOM.findDOMNode(app) + expect(node).not.to.equal(null) + }) +}) diff --git a/test/test.ts b/test/test.ts new file mode 100644 index 00000000000..d79deb385fb --- /dev/null +++ b/test/test.ts @@ -0,0 +1,16 @@ +import * as chai from 'chai' +const expect = chai.expect + +describe('Array', () => { + describe('#indexOf()', () => { + it('should return -1 when the value is not present', () => { + expect([1, 2, 3].indexOf(5)).to.equal(-1) + expect([1, 2, 3].indexOf(0)).to.equal(-1) + }) + + it('should return the index when the value is present', () => { + expect([1, 2, 3].indexOf(2)).to.equal(1) + expect([1, 2, 3].indexOf(3)).to.equal(2) + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000..e1864277414 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "target": "es6", + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "jsx": "react", + "outDir": "./build" + }, + "exclude": [ + "node_modules", + "typings/main", + "typings/main.d.ts" + ], + "compileOnSave": false +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 00000000000..d1675e6f4b6 --- /dev/null +++ b/tslint.json @@ -0,0 +1,70 @@ +{ + "rules": { + "class-name": true, + "curly": true, + "indent": [ + true, + "spaces" + ], + "member-access": [ + true, + "check-accessor", + "check-constructor" + ], + "member-ordering": [ + true, + "static-before-instance", + "variables-before-functions" + ], + "no-construct": true, + "no-duplicate-key": true, + "no-duplicate-variable": true, + "no-eval": true, + "no-internal-module": true, + "no-invalid-this": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-unused-variable": [true, "react"], + "no-use-before-declare": true, + "no-var-keyword": true, + "one-line": [ + true, + "check-open-brace", + "check-whitespace" + ], + "quotemark": [ + true, + "singe" + ], + "semicolon": [ + false, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": [ + true, + "ban-keywords" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} diff --git a/typings.json b/typings.json new file mode 100644 index 00000000000..695f20dae2b --- /dev/null +++ b/typings.json @@ -0,0 +1,11 @@ +{ + "ambientDependencies": { + "chai": "registry:dt/chai#3.4.0+20160317120654", + "github-electron": "registry:dt/github-electron#0.37.4+20160412150407", + "mocha": "registry:dt/mocha#2.2.5+20160317120654", + "node": "registry:dt/node#4.0.0+20160330064709", + "react": "registry:dt/react#0.14.0+20160412154040", + "react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160427035638", + "react-dom": "registry:dt/react-dom#0.14.0+20160412154040" + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 00000000000..30449ad0a2b --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,56 @@ +var path = require('path') +var webpack = require('webpack') +var webpackTargetElectronRenderer = require('webpack-target-electron-renderer') + +var config = { + devtool: 'cheap-module-eval-source-map', + entry: [ + 'webpack-hot-middleware/client?path=http://localhost:3000/__webpack_hmr', + './lib/index' + ], + output: { + filename: 'bundle.js', + path: path.join(__dirname, 'build'), + libraryTarget: 'commonjs2', + publicPath: 'http://localhost:3000/build/' + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin(), + new webpack.DefinePlugin({ + // TODO: This is obviously wrong for production builds. + __DEV__: true, + 'process.env': { + NODE_ENV: JSON.stringify('development') + } + }) + ], + module: { + loaders: [ + { + test: /\.tsx?$/, + loaders: ['babel', 'ts'], + include: path.join(__dirname, 'lib') + } + ] + }, + resolve: { + extensions: ['', '.js', '.ts', '.tsx'], + packageMains: ['webpack', 'browser', 'web', 'browserify', ['jam', 'main'], 'main'] + }, + target: 'electron', + externals: function (context, request, callback) { + try { + // Attempt to resolve the module via Node + require.resolve(request) + callback(null, request) + } catch (e) { + // Node couldn't find it, so it must be user-aliased + callback() + } + } +} + +config.target = webpackTargetElectronRenderer(config) + +module.exports = config