├── .gitignore ├── README.md ├── build ├── background.tiff ├── icon.icns ├── icon.ico └── icon.png ├── package.json ├── src ├── main │ ├── index.js │ └── menu.js └── renderer │ ├── Dashboard.js │ └── index.js ├── static └── logo.svg └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💻 Storybook Desktop 2 | 3 | Desktop app for all your Storybooks. 4 | 5 | ## Getting started 6 | 7 | This project is still in an early stage. To get the app, you'll have to build it yourself right now. Here's how: 8 | 9 | ```sh 10 | git clone https://github.com/storybookjs/desktop.git 11 | cd desktop 12 | yarn 13 | ``` 14 | 15 | To run the development build: 16 | 17 | ```sh 18 | yarn dev 19 | ``` 20 | 21 | To build the production app: 22 | 23 | ```sh 24 | yarn dist 25 | ``` 26 | 27 | ## Credits 28 | 29 | This project is based on https://github.com/electron-userland/electron-webpack-quick-start 30 | -------------------------------------------------------------------------------- /build/background.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/desktop/40b0dbf2caef35d2b274d79bbd4bcf1bc477c33d/build/background.tiff -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/desktop/40b0dbf2caef35d2b274d79bbd4bcf1bc477c33d/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/desktop/40b0dbf2caef35d2b274d79bbd4bcf1bc477c33d/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storybookjs/desktop/40b0dbf2caef35d2b274d79bbd4bcf1bc477c33d/build/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storybook/desktop", 3 | "productName": "Storybook", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "electron-webpack dev", 8 | "compile": "electron-webpack", 9 | "dist": "yarn compile && electron-builder", 10 | "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null" 11 | }, 12 | "build": { 13 | "appId": "org.js.storybook.desktop" 14 | }, 15 | "dependencies": { 16 | "@storybook/design-system": "^0.0.43", 17 | "electron-context-menu": "^0.13.0", 18 | "fix-path": "^2.1.0", 19 | "react": "^16.8.6", 20 | "react-dom": "^16.8.6", 21 | "source-map-support": "^0.5.12", 22 | "styled-components": "^4.3.2" 23 | }, 24 | "devDependencies": { 25 | "@babel/preset-react": "^7.0.0", 26 | "electron": "5.0.6", 27 | "electron-builder": "^21.0.11", 28 | "electron-webpack": "^2.7.4", 29 | "webpack": "~4.35.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/index.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron" 2 | import contextMenu from "electron-context-menu" 3 | import * as path from "path" 4 | import { format as formatUrl } from "url" 5 | 6 | import MenuBuilder from "./menu" 7 | 8 | process.env.NODE_ENV === "production" && require("fix-path")() 9 | 10 | contextMenu({ 11 | showLookUpSelection: true, 12 | showCopyImageAddress: true, 13 | showSaveImageAs: true, 14 | showInspectElement: true 15 | }) 16 | 17 | let mainWindow 18 | 19 | app.on("window-all-closed", () => app.quit()) 20 | app.on("ready", () => (mainWindow = createMainWindow())) 21 | 22 | function createMainWindow() { 23 | const isDevelopment = process.env.NODE_ENV !== "production" 24 | 25 | const window = new BrowserWindow({ 26 | width: 512, 27 | height: 512, 28 | titleBarStyle: "hiddenInset", 29 | webPreferences: { nodeIntegration: true } 30 | }) 31 | 32 | if (isDevelopment) { 33 | window.webContents.openDevTools() 34 | } 35 | 36 | if (isDevelopment) { 37 | window.loadURL(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`) 38 | } else { 39 | window.loadURL( 40 | formatUrl({ 41 | pathname: path.join(__dirname, "index.html"), 42 | protocol: "file", 43 | slashes: true 44 | }) 45 | ) 46 | } 47 | 48 | window.on("closed", () => { 49 | mainWindow = null 50 | }) 51 | 52 | window.webContents.on("devtools-opened", () => { 53 | window.focus() 54 | setImmediate(() => { 55 | window.focus() 56 | }) 57 | }) 58 | 59 | new MenuBuilder(window).buildMenu() 60 | 61 | return window 62 | } 63 | -------------------------------------------------------------------------------- /src/main/menu.js: -------------------------------------------------------------------------------- 1 | import { app, Menu, shell } from "electron" 2 | 3 | export default class MenuBuilder { 4 | constructor(mainWindow) { 5 | this.mainWindow = mainWindow 6 | } 7 | 8 | buildMenu() { 9 | if (process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true") { 10 | this.setupDevelopmentEnvironment() 11 | } 12 | 13 | const template = process.platform === "darwin" ? this.buildDarwinTemplate() : this.buildDefaultTemplate() 14 | 15 | const menu = Menu.buildFromTemplate(template) 16 | Menu.setApplicationMenu(menu) 17 | 18 | return menu 19 | } 20 | 21 | setupDevelopmentEnvironment() { 22 | this.mainWindow.openDevTools() 23 | this.mainWindow.webContents.on("context-menu", (e, props) => { 24 | const { x, y } = props 25 | 26 | Menu.buildFromTemplate([ 27 | { 28 | label: "Inspect element", 29 | click: () => { 30 | this.mainWindow.inspectElement(x, y) 31 | } 32 | } 33 | ]).popup(this.mainWindow) 34 | }) 35 | } 36 | 37 | buildDarwinTemplate() { 38 | const subMenuAbout = { 39 | label: "Storybook", 40 | submenu: [ 41 | { 42 | label: "About Storybook", 43 | click() { 44 | shell.openExternal("https://storybook.js.org/") 45 | } 46 | }, 47 | { type: "separator" }, 48 | { 49 | label: "Hide Storybook", 50 | accelerator: "Command+H", 51 | selector: "hide:" 52 | }, 53 | { 54 | label: "Hide Others", 55 | accelerator: "Command+Shift+H", 56 | selector: "hideOtherApplications:" 57 | }, 58 | { label: "Show All", selector: "unhideAllApplications:" }, 59 | { type: "separator" }, 60 | { 61 | label: "Quit", 62 | accelerator: "Command+Q", 63 | click: () => { 64 | app.quit() 65 | } 66 | } 67 | ] 68 | } 69 | const subMenuEdit = { 70 | label: "Edit", 71 | submenu: [ 72 | { label: "Undo", accelerator: "Command+Z", selector: "undo:" }, 73 | { label: "Redo", accelerator: "Shift+Command+Z", selector: "redo:" }, 74 | { type: "separator" }, 75 | { label: "Cut", accelerator: "Command+X", selector: "cut:" }, 76 | { label: "Copy", accelerator: "Command+C", selector: "copy:" }, 77 | { label: "Paste", accelerator: "Command+V", selector: "paste:" }, 78 | { 79 | label: "Select All", 80 | accelerator: "Command+A", 81 | selector: "selectAll:" 82 | } 83 | ] 84 | } 85 | const subMenuViewDev = { 86 | label: "View", 87 | submenu: [ 88 | { 89 | label: "Reload", 90 | accelerator: "Command+R", 91 | click: () => { 92 | this.mainWindow.webContents.reload() 93 | } 94 | }, 95 | { 96 | label: "Toggle Full Screen", 97 | accelerator: "Ctrl+Command+F", 98 | click: () => { 99 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) 100 | } 101 | }, 102 | { 103 | label: "Toggle Developer Tools", 104 | accelerator: "Alt+Command+I", 105 | click: () => { 106 | this.mainWindow.toggleDevTools() 107 | } 108 | } 109 | ] 110 | } 111 | const subMenuViewProd = { 112 | label: "View", 113 | submenu: [ 114 | { 115 | label: "Toggle Full Screen", 116 | accelerator: "Ctrl+Command+F", 117 | click: () => { 118 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) 119 | } 120 | } 121 | ] 122 | } 123 | const subMenuWindow = { 124 | label: "Window", 125 | submenu: [ 126 | { 127 | label: "Minimize", 128 | accelerator: "Command+M", 129 | selector: "performMiniaturize:" 130 | }, 131 | { label: "Close", accelerator: "Command+W", selector: "performClose:" }, 132 | { type: "separator" }, 133 | { label: "Bring All to Front", selector: "arrangeInFront:" } 134 | ] 135 | } 136 | const subMenuHelp = { 137 | label: "Help", 138 | submenu: [ 139 | { 140 | label: "Learn Storybook", 141 | click() { 142 | shell.openExternal("https://www.learnstorybook.com/") 143 | } 144 | }, 145 | { 146 | label: "Support", 147 | click() { 148 | shell.openExternal("https://storybook.js.org/support/") 149 | } 150 | }, 151 | { 152 | label: "Community", 153 | click() { 154 | shell.openExternal("https://storybook.js.org/community/") 155 | } 156 | }, 157 | { 158 | label: "Storybook on GitHub", 159 | click() { 160 | shell.openExternal("https://github.com/storybookjs/storybook") 161 | } 162 | } 163 | ] 164 | } 165 | 166 | const subMenuView = process.env.NODE_ENV === "development" ? subMenuViewDev : subMenuViewProd 167 | 168 | return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp] 169 | } 170 | 171 | buildDefaultTemplate() { 172 | const templateDefault = [ 173 | { 174 | label: "&File", 175 | submenu: [ 176 | { 177 | label: "&Open", 178 | accelerator: "Ctrl+O" 179 | }, 180 | { 181 | label: "&Close", 182 | accelerator: "Ctrl+W", 183 | click: () => { 184 | this.mainWindow.close() 185 | } 186 | } 187 | ] 188 | }, 189 | { 190 | label: "&View", 191 | submenu: 192 | process.env.NODE_ENV === "development" 193 | ? [ 194 | { 195 | label: "&Reload", 196 | accelerator: "Ctrl+R", 197 | click: () => { 198 | this.mainWindow.webContents.reload() 199 | } 200 | }, 201 | { 202 | label: "Toggle &Full Screen", 203 | accelerator: "F11", 204 | click: () => { 205 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) 206 | } 207 | }, 208 | { 209 | label: "Toggle &Developer Tools", 210 | accelerator: "Alt+Ctrl+I", 211 | click: () => { 212 | this.mainWindow.toggleDevTools() 213 | } 214 | } 215 | ] 216 | : [ 217 | { 218 | label: "Toggle &Full Screen", 219 | accelerator: "F11", 220 | click: () => { 221 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) 222 | } 223 | } 224 | ] 225 | }, 226 | { 227 | label: "Help", 228 | submenu: [ 229 | { 230 | label: "Learn More", 231 | click() { 232 | shell.openExternal("http://electron.atom.io") 233 | } 234 | }, 235 | { 236 | label: "Documentation", 237 | click() { 238 | shell.openExternal("https://github.com/atom/electron/tree/master/docs#readme") 239 | } 240 | }, 241 | { 242 | label: "Community Discussions", 243 | click() { 244 | shell.openExternal("https://discuss.atom.io/c/electron") 245 | } 246 | }, 247 | { 248 | label: "Search Issues", 249 | click() { 250 | shell.openExternal("https://github.com/atom/electron/issues") 251 | } 252 | } 253 | ] 254 | } 255 | ] 256 | 257 | return templateDefault 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/renderer/Dashboard.js: -------------------------------------------------------------------------------- 1 | import { remote } from "electron" 2 | import React from "react" 3 | import styled, { css, keyframes } from "styled-components" 4 | import { styles } from "@storybook/design-system" 5 | import fs from "fs" 6 | import path from "path" 7 | import { homedir } from "os" 8 | import { spawn } from "child_process" 9 | import logo from "../../static/logo.svg" 10 | 11 | const move = keyframes` 12 | 0% { 13 | background-position: 0 0; 14 | } 15 | 100% { 16 | background-position: 50px 50px; 17 | } 18 | ` 19 | 20 | const writeFile = (filePath, data) => 21 | new Promise((resolve, reject) => fs.writeFile(filePath, data, err => (err ? reject(err) : resolve(data)))) 22 | 23 | const readFile = filePath => 24 | new Promise((resolve, reject) => fs.readFile(filePath, "utf-8", (err, data) => (err ? reject(err) : resolve(data)))) 25 | 26 | const parseJson = data => { 27 | try { 28 | return Promise.resolve(JSON.parse(data)) 29 | } catch (e) { 30 | return Promise.reject(e) 31 | } 32 | } 33 | 34 | const parseMessage = data => { 35 | const message = data.toString() 36 | const lines = message 37 | .replace(/[^\x00-\x7F]/g, "") 38 | .split("\n") 39 | .map(line => line.trim()) 40 | .filter(Boolean) 41 | .reverse() 42 | return { message, lines } 43 | } 44 | 45 | const { app, dialog, BrowserWindow } = remote 46 | const homedirRegExp = new RegExp("^" + homedir()) 47 | const projectsFilePath = path.join(app.getPath("userData"), "projects.json") 48 | 49 | const Grid = styled.div` 50 | -webkit-app-region: drag; 51 | display: grid; 52 | height: 100%; 53 | grid-template-columns: 1fr; 54 | grid-template-rows: 38px 1fr auto; 55 | grid-template-areas: "controls" "projects" "menu"; 56 | ` 57 | 58 | const Menu = styled.div` 59 | margin: 12px; 60 | grid-area: menu; 61 | text-align: center; 62 | display: flex; 63 | flex-direction: column; 64 | align-items: center; 65 | justify-content: center; 66 | & > * { 67 | -webkit-app-region: no-drag; 68 | } 69 | ` 70 | 71 | const Projects = styled.ol` 72 | -webkit-app-region: no-drag; 73 | grid-area: projects; 74 | margin: 0 8px; 75 | padding: 0; 76 | background: white; 77 | border-radius: 4px; 78 | box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.1); 79 | overflow: auto; 80 | ` 81 | 82 | const Controls = styled.div` 83 | user-select: none; 84 | grid-area: controls; 85 | display: flex; 86 | justify-content: center; 87 | align-items: center; 88 | ` 89 | const Image = styled.img` 90 | height: 20px; 91 | ` 92 | const Project = styled.li` 93 | position: relative; 94 | padding: 12px; 95 | border-bottom: 1px solid ${styles.color.mediumlight}; 96 | cursor: ${props => (props.isDisabled ? "default" : "pointer")}; 97 | word-wrap: break-word; 98 | transition: all 0.2s; 99 | &:hover { 100 | background: ${props => (props.isDisabled ? "transparent" : styles.color.lighter)}; 101 | } 102 | &:before { 103 | content: ""; 104 | position: absolute; 105 | top: 0; 106 | left: 0; 107 | bottom: 0; 108 | right: 0; 109 | opacity: 0; 110 | transition: opacity 0.3s; 111 | background-image: linear-gradient( 112 | -45deg, 113 | rgba(0, 0, 0, 0.05) 25%, 114 | transparent 25%, 115 | transparent 50%, 116 | rgba(0, 0, 0, 0.05) 50%, 117 | rgba(0, 0, 0, 0.05) 75%, 118 | transparent 75%, 119 | transparent 120 | ); 121 | background-size: 50px 50px; 122 | } 123 | ${props => 124 | props.isLoading && 125 | css` 126 | background: ${styles.color.lighter}!important; 127 | &:before { 128 | opacity: 1; 129 | animation: ${move} 2s linear infinite; 130 | } 131 | `} 132 | ` 133 | const Name = styled.strong` 134 | display: block; 135 | font-weight: 800; 136 | ` 137 | const Location = styled.small` 138 | display: block; 139 | height: 16px; 140 | color: ${styles.color.mediumdark}; 141 | ` 142 | const Output = styled.small` 143 | display: block; 144 | height: 14px; 145 | color: ${props => (props.error ? styles.color.negative : styles.color.mediumdark)}; 146 | font-family: monospace; 147 | margin-top: 2px; 148 | overflow: hidden; 149 | white-space: nowrap; 150 | ` 151 | const Button = styled.button` 152 | font-size: 0.75em; 153 | background: ${styles.color.primary}; 154 | color: white; 155 | border: 0; 156 | border-radius: 20px; 157 | padding: 8px 12px; 158 | font-weight: bold; 159 | outline: 0; 160 | cursor: pointer; 161 | ` 162 | const Remove = styled.button` 163 | position: absolute; 164 | right: 0; 165 | top: 0; 166 | border: 0; 167 | background: none; 168 | outline: 0; 169 | ` 170 | 171 | const getProjectInfo = async path => { 172 | const file = await readFile(path) 173 | const { name, scripts } = await parseJson(file) 174 | if (typeof scripts !== "object") { 175 | throw new Error("Invalid package.json, expecting a `scripts` object.") 176 | } 177 | const location = path.replace(/(\\|\/)package\.json$/i, "").replace(homedirRegExp, "~") 178 | return { name, path, location } 179 | } 180 | 181 | const getStartScript = async path => { 182 | const file = await readFile(path) 183 | const { scripts } = await parseJson(file) 184 | if (typeof scripts !== "object") { 185 | throw new Error("Invalid package.json, expecting a `scripts` object.") 186 | } 187 | const script = Object.keys(scripts).find(key => ~scripts[key].indexOf("start-storybook")) 188 | return { script, command: scripts[script] } 189 | } 190 | 191 | const Dashboard = () => { 192 | const [loading, setLoading] = React.useState() 193 | const [projects, setProjects] = React.useState() 194 | const [out, setOut] = React.useState() 195 | const [err, setErr] = React.useState() 196 | 197 | React.useEffect(() => { 198 | readFile(projectsFilePath) 199 | .then(parseJson) 200 | .then(setProjects) 201 | .catch(() => setProjects([])) 202 | }, []) 203 | 204 | const addProject = async () => { 205 | const res = await dialog.showOpenDialog(remote.getCurrentWindow(), { 206 | properties: ["openFile"], 207 | filters: [{ name: "package.json", extensions: ["json"] }] 208 | }) 209 | const filePaths = res ? res.filePaths || res : [] 210 | if (!filePaths[0].endsWith("/package.json")) { 211 | throw new Error("Expecting a file called `package.json`.") 212 | } 213 | const project = await getProjectInfo(filePaths[0]) 214 | if (projects.find(p => p.path === project.path)) return 215 | const newProjects = [...projects, project] 216 | setProjects(newProjects) 217 | writeFile(projectsFilePath, JSON.stringify(newProjects)) 218 | } 219 | 220 | const removeProject = (e, project) => { 221 | e.stopPropagation() 222 | const newProjects = projects.filter(p => p.path !== project.path) 223 | setProjects(newProjects) 224 | writeFile(projectsFilePath, JSON.stringify(newProjects)) 225 | } 226 | 227 | const openProject = async project => { 228 | if (loading) return 229 | 230 | try { 231 | setLoading(project.path) 232 | const { script, command } = await getStartScript(project.path) 233 | 234 | console.log(`Starting 'npm run ${script} -- --ci' (${command})`) 235 | const cp = spawn(`npm run ${script} -- --ci`, { 236 | cwd: path.dirname(project.path), 237 | shell: true 238 | }) 239 | process.on("close", () => cp.kill()) 240 | 241 | cp.on("error", err => console.error(err)) 242 | cp.on("close", code => code && console.error(`Storybook exited with code: ${code}`)) 243 | 244 | let mainWindow = remote.getCurrentWindow() 245 | cp.stdout.on("data", data => { 246 | const { message, lines } = parseMessage(data) 247 | console.log(message) 248 | setOut(lines[0]) 249 | 250 | if (new RegExp("Storybook .* started").test(message)) { 251 | const [, url] = message.match(/Local:\s+([^\s]+)/) 252 | console.log(`Opening ${url}`) 253 | 254 | let childWindow = new BrowserWindow({ 255 | show: false, 256 | width: 1280, 257 | height: 860, 258 | titleBarStyle: "hidden" 259 | }) 260 | childWindow.loadURL(url) 261 | childWindow.once("ready-to-show", () => { 262 | mainWindow.hide() 263 | childWindow.show() 264 | childWindow.focus() 265 | // childWindow.webContents.openDevTools() 266 | }) 267 | childWindow.once("closed", () => { 268 | setLoading(null) 269 | mainWindow.show() 270 | mainWindow.focus() 271 | mainWindow = null 272 | childWindow = null 273 | console.log(`Terminating Storybook child process`) 274 | cp.kill() 275 | }) 276 | } 277 | }) 278 | } catch (e) { 279 | const { message, lines } = parseMessage(e.message) 280 | console.error(message) 281 | setErr(lines[0]) 282 | setTimeout(() => setLoading(false), 5000) 283 | } 284 | } 285 | 286 | return ( 287 | 288 | 289 | 290 | 291 | 292 | 295 | 296 | 297 | {projects && 298 | projects.map(project => { 299 | const isLoading = loading === project.path 300 | return ( 301 | openProject(project)} isLoading={isLoading}> 302 | {project.name} 303 | {isLoading ? {err || out} : {project.location}} 304 | removeProject(e, project)}>× 305 | 306 | ) 307 | })} 308 | 309 | 310 | ) 311 | } 312 | 313 | export default Dashboard 314 | -------------------------------------------------------------------------------- /src/renderer/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { render } from "react-dom" 3 | import { createGlobalStyle } from "styled-components" 4 | import { styles } from "@storybook/design-system" 5 | 6 | import Dashboard from "./Dashboard" 7 | 8 | const GlobalStyle = createGlobalStyle` 9 | @import url('https://fonts.googleapis.com/css?family=Nunito+Sans:400,700,800,900'); 10 | html, body, #app { 11 | height:100%; 12 | } 13 | body { 14 | background: ${styles.background.app}; 15 | margin: 0; 16 | font-family: "Nunito Sans", sans-serif; 17 | } 18 | * { 19 | font-family: inherit; 20 | } 21 | ` 22 | 23 | const App = () => ( 24 | <> 25 | 26 | 27 | 28 | ) 29 | 30 | render(, document.getElementById("app")) 31 | -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------