├── .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 |
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 ? : {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 |
--------------------------------------------------------------------------------