diff --git a/src/client/components/file_tree/controller.ts b/src/client/components/file_tree/controller.ts
new file mode 100644
index 00000000..224dc10f
--- /dev/null
+++ b/src/client/components/file_tree/controller.ts
@@ -0,0 +1,37 @@
+import { action } from 'mobx'
+
+import { FileTreeModel, TreeNode } from './model'
+
+export class FileTreeController {
+ constructor(private model: FileTreeModel) {}
+
+ static of(model: FileTreeModel) {
+ return new FileTreeController(model)
+ }
+
+ @action
+ toggleNode(node: TreeNode) {
+ node.expanded = !node.expanded
+ }
+
+ @action
+ selectNode(node: TreeNode) {
+ this.model.selectedNode = node
+ }
+
+ sortNodes(a: TreeNode, b: TreeNode) {
+ if (a.leaf) {
+ if (b.leaf) {
+ return a.label.localeCompare(b.label)
+ } else {
+ return 1
+ }
+ } else {
+ if (b.leaf) {
+ return -1
+ } else {
+ return a.label.localeCompare(b.label)
+ }
+ }
+ }
+}
diff --git a/src/client/components/file_tree/model.ts b/src/client/components/file_tree/model.ts
new file mode 100644
index 00000000..1794139f
--- /dev/null
+++ b/src/client/components/file_tree/model.ts
@@ -0,0 +1,82 @@
+import { observable } from 'mobx'
+import { createTransformer } from 'mobx-utils'
+
+export interface TreeNode {
+ label: string
+ children: TreeNode[]
+ expanded: boolean
+ leaf: boolean
+ file?: File
+}
+
+export interface File {
+ path: string
+ data?: any
+}
+
+export class FileTreeModel {
+ @observable rootNode: TreeNode
+ @observable selectedNode?: TreeNode
+
+ constructor(files: File[]) {
+ this.rootNode = createTreeFromFiles(files)
+ }
+
+ static of = createTransformer((files: File[]): FileTreeModel => {
+ return new FileTreeModel(files)
+ })
+}
+
+function createTreeFromFiles(files: File[]): TreeNode {
+ const root = {
+ label: 'root',
+ children: [],
+ leaf: false,
+ expanded: true,
+ }
+
+ files.forEach(file => {
+ const path = normalizePath(file.path)
+ createTreeNode(path.split('/'), file, root)
+ })
+
+ return root
+}
+
+export function createTreeNode(pathSegments: string[], file: File, parent: TreeNode): void {
+ if (pathSegments.length === 0) {
+ throw Error('[createTreeNode]: pathSegments cannot be empty')
+ } else if (pathSegments.length === 1) {
+ const node = getOrCreateChild(parent, pathSegments[0])
+ node.leaf = true
+ node.file = file
+ } else {
+ const intermediateParent = getOrCreateChild(parent, pathSegments[0])
+ intermediateParent.leaf = false
+
+ createTreeNode(pathSegments.slice(1), file, intermediateParent)
+ }
+}
+
+function getOrCreateChild(parent: TreeNode, childLabel: string): TreeNode {
+ const existingChild = parent.children.find(parent => parent.label === childLabel)
+
+ if (existingChild) {
+ return existingChild
+ }
+
+ const newChild = {
+ label: childLabel,
+ children: [],
+ leaf: false,
+ expanded: false,
+ }
+
+ parent.children.push(newChild)
+
+ return newChild
+}
+
+function normalizePath(path: string) {
+ return path.replace(/\/\//g, '/') // Collapse consecutive slashes
+}
diff --git a/src/client/components/file_tree/stories/file_tree.stories.tsx b/src/client/components/file_tree/stories/file_tree.stories.tsx
new file mode 100644
index 00000000..e5024b20
--- /dev/null
+++ b/src/client/components/file_tree/stories/file_tree.stories.tsx
@@ -0,0 +1,90 @@
+import { action } from '@storybook/addon-actions'
+import { storiesOf } from '@storybook/react'
+import { action as mobxAction, observable } from 'mobx'
+import { observer } from 'mobx-react'
+import * as React from 'react'
+
+import { File } from '../model'
+import { FileBrowser } from '../view'
+
+const actions = {
+ onSelect: action('onSelect'),
+}
+
+storiesOf('components.file_tree', module)
+ .addDecorator(story => {
+ return
{story()}
+ })
+ .add('empty', () => {
+ return
+ })
+ .add('interactive', () => {
+ const files = getFiles()
+ return
+ })
+ .add('no animation', () => {
+ const files = getFiles()
+ return
+ })
+
+function getFiles(): File[] {
+ return [
+ {
+ path: 'TopLevelFile.yaml',
+ },
+ {
+ path: 'scripts/igus1/Stand.yaml',
+ },
+ {
+ path: 'scripts/igus1/GetupFront.yaml',
+ },
+ {
+ path: 'scripts/igus1/GetupBack.yaml',
+ },
+ {
+ path: 'scripts/igus2/Stand.yaml',
+ },
+ {
+ path: 'scripts/igus2/GetupFront.yaml',
+ },
+ {
+ path: 'scripts/igus2/GetupBack.yaml',
+ },
+ {
+ path: 'config/igus1/NUsight.yaml',
+ },
+ {
+ path: 'config/igus1/DataLogger.yaml',
+ },
+ {
+ path: 'config/igus1/WalkEngine.yaml',
+ },
+ {
+ path: 'config/igus2/NUsight.yaml',
+ },
+ {
+ path: 'config/igus2/DataLogger.yaml',
+ },
+ {
+ path: 'config/igus2/WalkEngine.yaml',
+ },
+ {
+ path: 'AnotherTopLevelFile.yaml',
+ },
+ ]
+}
diff --git a/src/client/components/file_tree/style.css b/src/client/components/file_tree/style.css
new file mode 100644
index 00000000..42738269
--- /dev/null
+++ b/src/client/components/file_tree/style.css
@@ -0,0 +1,7 @@
+.placeholder {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 8px;
+ text-align: center;
+ color: #757575;
+}
diff --git a/src/client/components/file_tree/tree_node/dropdown.svg b/src/client/components/file_tree/tree_node/dropdown.svg
new file mode 100644
index 00000000..c0638497
--- /dev/null
+++ b/src/client/components/file_tree/tree_node/dropdown.svg
@@ -0,0 +1 @@
+
diff --git a/src/client/components/file_tree/tree_node/file.svg b/src/client/components/file_tree/tree_node/file.svg
new file mode 100644
index 00000000..ffb611b5
--- /dev/null
+++ b/src/client/components/file_tree/tree_node/file.svg
@@ -0,0 +1 @@
+
diff --git a/src/client/components/file_tree/tree_node/folder.svg b/src/client/components/file_tree/tree_node/folder.svg
new file mode 100644
index 00000000..e4b37277
--- /dev/null
+++ b/src/client/components/file_tree/tree_node/folder.svg
@@ -0,0 +1 @@
+
diff --git a/src/client/components/file_tree/tree_node/folder_open.svg b/src/client/components/file_tree/tree_node/folder_open.svg
new file mode 100644
index 00000000..ff43ec94
--- /dev/null
+++ b/src/client/components/file_tree/tree_node/folder_open.svg
@@ -0,0 +1 @@
+
diff --git a/src/client/components/file_tree/tree_node/style.css b/src/client/components/file_tree/tree_node/style.css
new file mode 100644
index 00000000..f0eeb2d4
--- /dev/null
+++ b/src/client/components/file_tree/tree_node/style.css
@@ -0,0 +1,84 @@
+.node {
+ display: flex;
+ flex-direction: column;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+.nodeHeader {
+ display: flex;
+ align-items: center;
+ height: 24px;
+ padding: 4px 8px;
+ cursor: pointer;
+ user-select: none;
+ font-weight: normal;
+}
+
+.nodeHeader:hover {
+ background-color: #EEE;
+ color: #1197d3;
+}
+
+.nodeHeader:hover .icon {
+ color: #1197d3;
+}
+
+.nodeHeaderSelected,
+.nodeHeaderSelected:hover {
+ background-color: #1197d3;
+ color: white;
+}
+
+.nodeHeaderSelected .icon,
+.nodeHeaderSelected:hover .icon {
+ color: white;
+}
+
+.icon {
+ color: #757575;
+}
+
+.dropdownIcon {
+ width: 18px;
+ height: 18px;
+ margin-right: 4px;
+}
+
+.dropdownIcon svg {
+ display: block;
+ width: 18px;
+ height: 18px;
+}
+
+.dropdownIconExpanded {
+ transform: rotate(90deg);
+}
+
+.dropdownIconAnimate {
+ transition: transform 0.2s ease;
+}
+
+.nodeIcon {
+ width: 16px;
+ height: 16px;
+ margin-right: 6px;
+}
+
+.nodeIcon svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+}
+
+.nodeLabel {
+ flex-grow: 1;
+ line-height: 1;
+}
+
+.nodeChildren {
+ padding: 0;
+ margin: 0;
+ border: none;
+}
diff --git a/src/client/components/file_tree/tree_node/view.tsx b/src/client/components/file_tree/tree_node/view.tsx
new file mode 100644
index 00000000..eeca87e4
--- /dev/null
+++ b/src/client/components/file_tree/tree_node/view.tsx
@@ -0,0 +1,94 @@
+import classNames from 'classnames'
+import { observer } from 'mobx-react'
+import * as React from 'react'
+
+import { Collapsible } from '../../collapsible/view'
+import { FileTreeController } from '../controller'
+import { File, FileTreeModel, TreeNode } from '../model'
+
+import DropdownIcon from './dropdown.svg'
+import FileIcon from './file.svg'
+import FolderIcon from './folder.svg'
+import FolderOpenIcon from './folder_open.svg'
+import * as style from './style.css'
+
+export interface FileTreeNodeProps {
+ controller: FileTreeController
+ model: FileTreeModel
+ node: TreeNode
+ level: number
+ animate?: boolean
+ onSelect?(file: File): void
+}
+
+export const FileTreeNode = observer((props: FileTreeNodeProps) => {
+ const { model, controller, node, level, animate, onSelect } = props
+
+ const children = props.node.children
+ const hasChildren = children.length > 0
+
+ const onClick = () => {
+ if (node.leaf) {
+ controller.selectNode(node)
+ onSelect && onSelect(node.file!) // The leaf node will always have a file
+ } else {
+ controller.toggleNode(node)
+ }
+ }
+
+ // Using inline padding-left to indent so that the hover and selected background indicators
+ // are full width. Padding is the default left padding of 8px plus each level's indent of 22px.
+ const headerInlineStyle = {
+ paddingLeft: 8 + (level * 22) + 'px',
+ }
+
+ const nodeHeaderClassNames = classNames(style.nodeHeader, {
+ [style.nodeHeaderSelected]: model.selectedNode === node,
+ })
+
+ const dropdownIconClassNames = classNames(style.dropdownIcon, {
+ [style.dropdownIconExpanded]: node.expanded,
+ [style.dropdownIconAnimate]: animate,
+ })
+
+ return (
+
+ -
+
+
+ { hasChildren ? : null }
+
+
+
+ {
+ node.leaf
+ ?
+ : (
+ node.expanded
+ ?
+ :
+ )
+ }
+
+
+
{ node.label }
+
+
+
+ { children.sort(controller.sortNodes).map((node, i) =>
+ ,
+ )
+ }
+
+
+
+ )
+})
diff --git a/src/client/components/file_tree/view.tsx b/src/client/components/file_tree/view.tsx
new file mode 100644
index 00000000..6a8513ae
--- /dev/null
+++ b/src/client/components/file_tree/view.tsx
@@ -0,0 +1,37 @@
+import { observer } from 'mobx-react'
+import * as React from 'react'
+
+import { FileTreeController } from './controller'
+import { FileTreeModel } from './model'
+import { File } from './model'
+import * as style from './style.css'
+import { FileTreeNode } from './tree_node/view'
+
+export type FileBrowserProps = {
+ files: File[]
+ animate?: boolean
+ onSelect?(file: File): void
+}
+
+export const FileBrowser = observer((props: FileBrowserProps) => {
+ const model = FileTreeModel.of(props.files)
+ const controller = FileTreeController.of(model)
+
+ return
+ { model.rootNode.children.length === 0 &&
No files
}
+ { model.rootNode.children
+ .sort(controller.sortNodes)
+ .map((node, i) => (
+
+ ))
+ }
+
+})