Skip to content

Commit 0d14b4e

Browse files
authored
Merge pull request #24 from Coding/hackape/treeify-git-commit-view
Treeify git commit view (coding/WebIDE#71)
2 parents ba80158 + 6e2e8ca commit 0d14b4e

File tree

12 files changed

+580
-129
lines changed

12 files changed

+580
-129
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/* @flow weak */
2+
import React, { Component } from 'react'
3+
import { bindActionCreators } from 'redux'
4+
import { dispatchCommand } from '../../commands'
5+
import cx from 'classnames'
6+
import { connect } from 'react-redux'
7+
8+
import * as GitActions from './actions'
9+
import Menu from '../Menu'
10+
11+
@connect(state => state.GitState.branches,
12+
dispatch => bindActionCreators(GitActions, dispatch) )
13+
export default class GitBranchWidget extends Component {
14+
constructor (props) {
15+
super(props)
16+
this.state = {
17+
isActive: false
18+
}
19+
}
20+
21+
componentWillMount () {
22+
this.props.getCurrentBranch()
23+
}
24+
25+
render () {
26+
const {current: currentBranch, local: localBranches, remote: remoteBranches} = this.props
27+
return (
28+
<div className='status-bar-menu-item' onClick={e => { e.stopPropagation(); this.toggleActive(true, true) }}>
29+
<span>On branch: {currentBranch}</span>
30+
{ this.state.isActive ?
31+
<Menu className={cx('bottom-up to-left', {active: this.state.isActive})}
32+
items={this.makeBrancheMenuItems(localBranches, remoteBranches)}
33+
deactivate={this.toggleActive.bind(this, false)} />
34+
: null }
35+
</div>
36+
)
37+
}
38+
39+
toggleActive (isActive, isTogglingEnabled) {
40+
if (isTogglingEnabled)
41+
isActive = !this.state.isActive
42+
if (isActive) {
43+
this.props.getBranches()
44+
}
45+
this.setState({isActive})
46+
}
47+
48+
makeBrancheMenuItems (localBranches, remoteBranches) {
49+
if (!localBranches && !remoteBranches)
50+
return [{name: 'Fetching Branches...', isDisabled: true}]
51+
52+
var localBranchItems = localBranches.map(branch => ({
53+
name: branch,
54+
items: [{
55+
name: 'Checkout',
56+
command: () => { this.props.checkoutBranch(branch) }
57+
}]
58+
}))
59+
60+
var remoteBranchItems = remoteBranches.map(remoteBranch => {
61+
var localBranch = remoteBranch.split('/').slice(1).join('/')
62+
return {
63+
name: remoteBranch,
64+
items: [{
65+
name: 'Checkout to new local branch',
66+
// @todo: should prompt to input local branch name
67+
command: () => { this.props.checkoutBranch(localBranch, remoteBranch) }
68+
}]
69+
}
70+
})
71+
return [
72+
{name: 'New Branch', command: () => dispatchCommand('git:new_branch')},
73+
{name: '-', isDisabled: true},
74+
{name: 'Local Branches', isDisabled: true},
75+
...localBranchItems,
76+
{name: '-', isDisabled: true},
77+
{name: 'Remote Branches', isDisabled: true},
78+
...remoteBranchItems
79+
]
80+
}
81+
}

app/components/Git/GitCommitView.jsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/* @flow weak */
2+
import React, { Component } from 'react'
3+
import { bindActionCreators } from 'redux'
4+
import { dispatchCommand } from '../../commands'
5+
import { connect } from 'react-redux'
6+
import cx from 'classnames'
7+
import _ from 'lodash'
8+
9+
import * as GitActions from './actions'
10+
import GitFileTree from './GitFileTree'
11+
12+
var GitCommitView = ({workingDir, stagingArea, ...actionProps}) => {
13+
const {isClean, files} = workingDir
14+
const {updateCommitMessage, updateStagingArea, commit} = actionProps
15+
if (isClean) return <h1 className=''>Your working directory is clean. Nothing to commit.</h1>
16+
return (
17+
<div>
18+
<GitFileTree />
19+
<hr />
20+
<div className='git-commit-message-container'>
21+
<textarea name='git-commit-message' id='git-commit-message' rows='4'
22+
onChange={e => updateCommitMessage(e.target.value)} />
23+
</div>
24+
<hr />
25+
<div className='modal-ops'>
26+
<button className='btn btn-default' onClick={e => dispatchCommand('modal:dismiss')}>Cancel</button>
27+
<button className='btn btn-primary' onClick={e => commit(stagingArea)}>Commit</button>
28+
</div>
29+
</div>
30+
)
31+
}
32+
33+
export default connect(
34+
state => state.GitState,
35+
dispatch => bindActionCreators(GitActions, dispatch)
36+
)(GitCommitView)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* @flow weak */
2+
import React, { Component } from 'react'
3+
import { bindActionCreators } from 'redux'
4+
import { dispatchCommand } from '../../commands'
5+
import cx from 'classnames'
6+
import { connect } from 'react-redux'
7+
8+
import * as GitActions from './actions'
9+
10+
var GitCommitView = ({workingDir, stagingArea, ...actionProps}) => {
11+
const {isClean, files} = workingDir
12+
const {updateCommitMessage, updateStagingArea, commit} = actionProps
13+
if (isClean) return <h1 className=''>Your working directory is clean. Nothing to commit.</h1>
14+
return (
15+
<div>
16+
<div className='git-status-files-container'>
17+
{ files.map(file =>
18+
<label className='git-status-file' key={file.name}>
19+
<div className='file-add-checkbox'>
20+
<input type='checkbox'
21+
checked={stagingArea.files.indexOf(file.name) != -1}
22+
onChange={e => updateStagingArea(e.target.checked ? 'stage' : 'unstage', file)} />
23+
</div>
24+
<div className={cx('file-status-indicator', file.status.toLowerCase())}>
25+
<i className={cx('fa', {
26+
'fa-pencil-square': file.status == 'MODIFIED',
27+
'fa-plus-square': file.status == 'UNTRACKED',
28+
'fa-minus-square': file.status == 'MISSING'
29+
})} /></div>
30+
<div className='file-path'>{file.name}</div>
31+
</label>
32+
) }
33+
</div>
34+
<hr />
35+
<div className='git-commit-message-container'>
36+
<textarea name='git-commit-message' id='git-commit-message' rows='4'
37+
onChange={e => updateCommitMessage(e.target.value)} />
38+
</div>
39+
<hr />
40+
<div className='modal-ops'>
41+
<button className='btn btn-default' onClick={e => dispatchCommand('modal:dismiss')}>Cancel</button>
42+
<button className='btn btn-primary' onClick={e => commit(stagingArea)}>Commit</button>
43+
</div>
44+
</div>
45+
)
46+
}
47+
48+
export default connect(
49+
state => state.GitState,
50+
dispatch => bindActionCreators(GitActions, dispatch)
51+
)(GitCommitView)

app/components/Git/GitFileTree.jsx

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/* @flow weak */
2+
import React, { Component } from 'react'
3+
import { bindActionCreators } from 'redux'
4+
import { dispatchCommand } from '../../commands'
5+
import { connect } from 'react-redux'
6+
import cx from 'classnames'
7+
import _ from 'lodash'
8+
9+
import * as GitActions from './actions'
10+
11+
class GitFileTree extends Component {
12+
constructor (props) {
13+
super(props)
14+
this.state = {
15+
showTreeView: true,
16+
showShrinkPathView: true,
17+
displayOnly: false
18+
}
19+
}
20+
21+
render () {
22+
return (
23+
<div className='git-filetree-container' tabIndex={1} >
24+
<GitFileTreeNode path='/'
25+
showTreeView={this.state.showTreeView}
26+
showShrinkPathView={this.state.showShrinkPathView}
27+
displayOnly={this.state.displayOnly} />
28+
</div>
29+
)
30+
}
31+
}
32+
33+
34+
class _GitFileTreeNode extends Component {
35+
constructor (props) {
36+
super(props)
37+
}
38+
39+
render () {
40+
const {
41+
node,
42+
statusFiles,
43+
displayOnly,
44+
showTreeView,
45+
showShrinkPathView,
46+
visualParentPath, // for usage in shrinkPathView
47+
...actionProps} = this.props
48+
const {toggleNodeFold, selectNode, toggleStaging} = actionProps
49+
50+
const childrenStagingStatus = this.getChildrenStagingStatus()
51+
const FILETREE_INDENT = 14
52+
let indentCompensation = this.getIndentCompensation()
53+
54+
return (
55+
<div className='filetree-node-container'>
56+
{ node.isRoot ?
57+
(<div className='filetree-node' ref={r => this.nodeDOM = r} >
58+
{ displayOnly ? null
59+
: <span className='filetree-node-checkbox'
60+
style={{marginRight: 0}}
61+
onClick={e => toggleStaging(node)} >
62+
<i className={cx('fa', {
63+
'fa-check-square': (!node.isDir && node.isStaged) || childrenStagingStatus === 'ALL',
64+
'fa-square-o': (!node.isDir && !node.isStaged) || childrenStagingStatus === 'NONE',
65+
'fa-minus-square': childrenStagingStatus === 'SOME',
66+
})}></i>
67+
</span>
68+
}
69+
<span className='filetree-node-label'>File Status
70+
{ displayOnly ? <span> ({node.leafNodes.length} changed) </span>
71+
: <span> ({this.getStagedLeafNodes().length} staged / {node.leafNodes.length} changed) </span>
72+
}
73+
</span>
74+
</div>)
75+
76+
: !showTreeView && node.isDir ? null // flatView
77+
78+
: showShrinkPathView && this.shouldHideAtShrinkPath() ? null
79+
80+
: (<div className={cx('filetree-node', {'focus':node.isFocused})}
81+
ref={r => this.nodeDOM = r}
82+
onClick={e => selectNode(node)} >
83+
{ displayOnly ? null
84+
: <span className='filetree-node-checkbox'
85+
onClick={e => toggleStaging(node)} >
86+
<i className={cx('fa', {
87+
'fa-check-square': (!node.isDir && node.isStaged) || childrenStagingStatus === 'ALL',
88+
'fa-square-o': (!node.isDir && !node.isStaged) || childrenStagingStatus === 'NONE',
89+
'fa-minus-square': childrenStagingStatus === 'SOME',
90+
})}></i>
91+
</span>
92+
}
93+
<span className='filetree-node-arrow'
94+
onClick={e => toggleNodeFold(node, null, e.altKey)}
95+
style={{
96+
'marginLeft': !showTreeView ? '0' : `${(node.depth - 1 - indentCompensation)*FILETREE_INDENT}px`
97+
}} >
98+
<i className={cx({
99+
'fa fa-angle-right': node.isFolded,
100+
'fa fa-angle-down': !node.isFolded,
101+
'hidden': !node.isDir || node.childrenCount === 0
102+
})}></i>
103+
</span>
104+
<span className='filetree-node-icon'>
105+
<i className={cx('fa file-status-indicator', node.status.toLowerCase(), {
106+
'fa-folder-o': node.isDir,
107+
'fa-pencil-square': node.status == 'MODIFIED',
108+
'fa-plus-square': node.status == 'UNTRACKED',
109+
'fa-minus-square': node.status == 'MISSING'
110+
})}></i>
111+
</span>
112+
<span className='filetree-node-label'>
113+
{ showTreeView ?
114+
(node.isDir && showShrinkPathView ?
115+
node.path.replace(visualParentPath, '').replace(/^\//, '')
116+
: node.name)
117+
: node.path}
118+
</span>
119+
<div className='filetree-node-bg'></div>
120+
</div>)
121+
122+
}
123+
124+
{ node.isDir ?
125+
<div className={cx('filetree-node-children', {isFolded: node.isFolded})}>
126+
{node.children.map(childNodePath =>
127+
<GitFileTreeNode
128+
key={childNodePath}
129+
path={childNodePath}
130+
showTreeView={showTreeView}
131+
showShrinkPathView={showShrinkPathView}
132+
visualParentPath={showShrinkPathView && this.shouldHideAtShrinkPath() ? visualParentPath : node.path}
133+
indentCompensation={indentCompensation}
134+
displayOnly={displayOnly} />
135+
)}
136+
</div>
137+
: null }
138+
</div>
139+
)
140+
}
141+
142+
143+
componentDidUpdate () {
144+
if (this.props.node.isFocused) {
145+
this.nodeDOM.scrollIntoViewIfNeeded && this.nodeDOM.scrollIntoViewIfNeeded()
146+
}
147+
}
148+
149+
getStagedLeafNodes () {
150+
const {node, statusFiles} = this.props
151+
if (!node.isDir) return []
152+
return node.leafNodes.filter(leafNodePath =>
153+
statusFiles.get(leafNodePath).get('isStaged')
154+
)
155+
}
156+
157+
getChildrenStagingStatus () {
158+
const {node, statusFiles} = this.props
159+
if (!node.isDir) return false
160+
let stagedLeafNodes = this.getStagedLeafNodes()
161+
if (stagedLeafNodes.length == 0) {
162+
return 'NONE'
163+
} else if (stagedLeafNodes.length === node.leafNodes.length) {
164+
return 'ALL'
165+
} else {
166+
return 'SOME'
167+
}
168+
}
169+
170+
shouldHideAtShrinkPath () {
171+
const {node, statusFiles} = this.props
172+
if (!node.isDir) return false
173+
if (node.children.length !== 1) return false
174+
175+
let childNode = statusFiles.get(node.children[0])
176+
if (!childNode.isDir) return false
177+
return true
178+
}
179+
180+
getIndentCompensation () {
181+
let {node, visualParentPath, indentCompensation, showShrinkPathView} = this.props
182+
// if not showShrinkPathView, indentCompensation is uneccssary, set to 0
183+
if (!showShrinkPathView) return 0
184+
185+
// else, let's do some math:
186+
if (!indentCompensation) indentCompensation = 0
187+
let indentOffset
188+
if (node.isRoot) {
189+
indentOffset = 1
190+
} else {
191+
indentOffset = (node.path.split('/').length - visualParentPath.split('/').length) || 1
192+
}
193+
return indentCompensation + indentOffset - 1
194+
}
195+
196+
}
197+
const GitFileTreeNode = connect(
198+
(state, ownProps) => ({
199+
statusFiles: state.GitState.statusFiles,
200+
node: state.GitState.statusFiles.get(ownProps.path)
201+
}),
202+
dispatch => bindActionCreators(GitActions, dispatch)
203+
)(_GitFileTreeNode)
204+
205+
export default GitFileTree

app/components/Git/actions.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,17 @@ export function newBranch (branch) {
216216
}))
217217
})
218218
}
219+
220+
export const GIT_STATUS_FOLD_NODE = 'GIT_STATUS_FOLD_NODE'
221+
export const toggleNodeFold = createAction(GIT_STATUS_FOLD_NODE,
222+
(node, shouldBeFolded = null, deep = false) => {
223+
let isFolded = (typeof shouldBeFolded === 'boolean') ? shouldBeFolded : !node.isFolded
224+
return {node, isFolded, deep}
225+
}
226+
)
227+
228+
export const GIT_STATUS_SELECT_NODE = 'GIT_STATUS_SELECT_NODE'
229+
export const selectNode = createAction(GIT_STATUS_SELECT_NODE, node => node)
230+
231+
export const GIT_STATUS_STAGE_NODE = 'GIT_STATUS_STAGE_NODE'
232+
export const toggleStaging = createAction(GIT_STATUS_STAGE_NODE, node => node)

0 commit comments

Comments
 (0)