mirror of
https://github.com/desktop/desktop
synced 2024-10-30 17:25:09 +00:00
Merge pull request #17533 from desktop/commit-expansion-feature-flag2
Commit Summary Expansion: Add feature flag and make a copy of `CommitSummary` component to modify
This commit is contained in:
commit
f80d29f63c
5 changed files with 889 additions and 0 deletions
|
@ -99,3 +99,5 @@ export function enableSectionList(): boolean {
|
|||
}
|
||||
|
||||
export const enableRepoRulesBeta = () => true
|
||||
|
||||
export const enableCommitDetailsHeaderExpansion = enableDevelopmentFeatures
|
||||
|
|
664
app/src/ui/history/expandable-commit-summary.tsx
Normal file
664
app/src/ui/history/expandable-commit-summary.tsx
Normal file
|
@ -0,0 +1,664 @@
|
|||
import * as React from 'react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import { Octicon } from '../octicons'
|
||||
import * as OcticonSymbol from '../octicons/octicons.generated'
|
||||
import { RichText } from '../lib/rich-text'
|
||||
import { Repository } from '../../models/repository'
|
||||
import { Commit } from '../../models/commit'
|
||||
import { getAvatarUsersForCommit, IAvatarUser } from '../../models/avatar'
|
||||
import { AvatarStack } from '../lib/avatar-stack'
|
||||
import { CommitAttribution } from '../lib/commit-attribution'
|
||||
import { Tokenizer, TokenResult } from '../../lib/text-token-parser'
|
||||
import { wrapRichTextCommitMessage } from '../../lib/wrap-rich-text-commit-message'
|
||||
import { DiffOptions } from '../diff/diff-options'
|
||||
import { IChangesetData } from '../../lib/git'
|
||||
import { TooltippedContent } from '../lib/tooltipped-content'
|
||||
import { AppFileStatusKind } from '../../models/status'
|
||||
import uniqWith from 'lodash/uniqWith'
|
||||
import { LinkButton } from '../lib/link-button'
|
||||
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
|
||||
import { TooltippedCommitSHA } from '../lib/tooltipped-commit-sha'
|
||||
import memoizeOne from 'memoize-one'
|
||||
|
||||
interface IExpandableCommitSummaryProps {
|
||||
readonly repository: Repository
|
||||
readonly selectedCommits: ReadonlyArray<Commit>
|
||||
readonly shasInDiff: ReadonlyArray<string>
|
||||
readonly changesetData: IChangesetData
|
||||
readonly emoji: Map<string, string>
|
||||
|
||||
/**
|
||||
* Whether or not the commit body container should
|
||||
* be rendered expanded or not. In expanded mode the
|
||||
* commit body container takes over the diff view
|
||||
* allowing for full height, scrollable reading of
|
||||
* the commit message body.
|
||||
*/
|
||||
readonly isExpanded: boolean
|
||||
|
||||
readonly onExpandChanged: (isExpanded: boolean) => void
|
||||
|
||||
readonly onDescriptionBottomChanged: (descriptionBottom: number) => void
|
||||
|
||||
readonly hideDescriptionBorder: boolean
|
||||
|
||||
readonly hideWhitespaceInDiff: boolean
|
||||
|
||||
/** Whether we should display side by side diffs. */
|
||||
readonly showSideBySideDiff: boolean
|
||||
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => Promise<void>
|
||||
|
||||
/** Called when the user changes the side by side diffs setting. */
|
||||
readonly onShowSideBySideDiffChanged: (checked: boolean) => void
|
||||
|
||||
/** Called when the user opens the diff options popover */
|
||||
readonly onDiffOptionsOpened: () => void
|
||||
|
||||
/** Called to highlight certain shas in the history */
|
||||
readonly onHighlightShas: (shasToHighlight: ReadonlyArray<string>) => void
|
||||
|
||||
/** Called to show unreachable commits dialog */
|
||||
readonly showUnreachableCommits: (tab: UnreachableCommitsTab) => void
|
||||
}
|
||||
|
||||
interface IExpandableCommitSummaryState {
|
||||
/**
|
||||
* The commit message summary, i.e. the first line in the commit message.
|
||||
* Note that this may differ from the body property in the commit object
|
||||
* passed through props, see the createState method for more details.
|
||||
*/
|
||||
readonly summary: ReadonlyArray<TokenResult>
|
||||
|
||||
/**
|
||||
* Whether the commit summary was empty.
|
||||
*/
|
||||
readonly hasEmptySummary: boolean
|
||||
|
||||
/**
|
||||
* The commit message body, i.e. anything after the first line of text in the
|
||||
* commit message. Note that this may differ from the body property in the
|
||||
* commit object passed through props, see the createState method for more
|
||||
* details.
|
||||
*/
|
||||
readonly body: ReadonlyArray<TokenResult>
|
||||
|
||||
/**
|
||||
* Whether or not the commit body text overflows its container. Used in
|
||||
* conjunction with the isExpanded prop.
|
||||
*/
|
||||
readonly isOverflowed: boolean
|
||||
|
||||
/**
|
||||
* The avatars associated with this commit. Used when rendering
|
||||
* the avatar stack and calculated whenever the commit prop changes.
|
||||
*/
|
||||
readonly avatarUsers: ReadonlyArray<IAvatarUser>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the state object for the ExpandableCommitSummary component.
|
||||
*
|
||||
* Ensures that the commit summary never exceeds 72 characters and wraps it
|
||||
* into the commit body if it does.
|
||||
*
|
||||
* @param isOverflowed Whether or not the component should render the commit
|
||||
* body in expanded mode, see the documentation for the
|
||||
* isOverflowed state property.
|
||||
*
|
||||
* @param props The current commit summary prop object.
|
||||
*/
|
||||
function createState(
|
||||
isOverflowed: boolean,
|
||||
props: IExpandableCommitSummaryProps
|
||||
): IExpandableCommitSummaryState {
|
||||
const { emoji, repository, selectedCommits } = props
|
||||
const tokenizer = new Tokenizer(emoji, repository)
|
||||
|
||||
const { summary, body } = wrapRichTextCommitMessage(
|
||||
getCommitSummary(selectedCommits),
|
||||
selectedCommits[0].body,
|
||||
tokenizer
|
||||
)
|
||||
|
||||
const hasEmptySummary =
|
||||
selectedCommits.length === 1 && selectedCommits[0].summary.length === 0
|
||||
|
||||
const allAvatarUsers = selectedCommits.flatMap(c =>
|
||||
getAvatarUsersForCommit(repository.gitHubRepository, c)
|
||||
)
|
||||
|
||||
const avatarUsers = uniqWith(
|
||||
allAvatarUsers,
|
||||
(a, b) => a.email === b.email && a.name === b.name
|
||||
)
|
||||
|
||||
return { isOverflowed, summary, body, avatarUsers, hasEmptySummary }
|
||||
}
|
||||
|
||||
function getCommitSummary(selectedCommits: ReadonlyArray<Commit>) {
|
||||
return selectedCommits[0].summary.length === 0
|
||||
? 'Empty commit message'
|
||||
: selectedCommits[0].summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function which determines if two commit objects
|
||||
* have the same commit summary and body.
|
||||
*/
|
||||
function messageEquals(x: Commit, y: Commit) {
|
||||
return x.summary === y.summary && x.body === y.body
|
||||
}
|
||||
|
||||
export class ExpandableCommitSummary extends React.Component<
|
||||
IExpandableCommitSummaryProps,
|
||||
IExpandableCommitSummaryState
|
||||
> {
|
||||
private descriptionScrollViewRef: HTMLDivElement | null = null
|
||||
private readonly resizeObserver: ResizeObserver | null = null
|
||||
private updateOverflowTimeoutId: NodeJS.Immediate | null = null
|
||||
private descriptionRef: HTMLDivElement | null = null
|
||||
|
||||
private getCountCommitsNotInDiff = memoizeOne(
|
||||
(
|
||||
selectedCommits: ReadonlyArray<Commit>,
|
||||
shasInDiff: ReadonlyArray<string>
|
||||
) => {
|
||||
if (selectedCommits.length === 1) {
|
||||
return 0
|
||||
} else {
|
||||
const shas = new Set(shasInDiff)
|
||||
return selectedCommits.reduce(
|
||||
(acc, c) => acc + (shas.has(c.sha) ? 0 : 1),
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
public constructor(props: IExpandableCommitSummaryProps) {
|
||||
super(props)
|
||||
|
||||
this.state = createState(false, props)
|
||||
|
||||
const ResizeObserverClass: typeof ResizeObserver = (window as any)
|
||||
.ResizeObserver
|
||||
|
||||
if (ResizeObserverClass || false) {
|
||||
this.resizeObserver = new ResizeObserverClass(entries => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === this.descriptionScrollViewRef) {
|
||||
// We might end up causing a recursive update by updating the state
|
||||
// when we're reacting to a resize so we'll defer it until after
|
||||
// react is done with this frame.
|
||||
if (this.updateOverflowTimeoutId !== null) {
|
||||
clearImmediate(this.updateOverflowTimeoutId)
|
||||
}
|
||||
|
||||
this.updateOverflowTimeoutId = setImmediate(this.onResized)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private onResized = () => {
|
||||
if (this.descriptionRef) {
|
||||
const descriptionBottom =
|
||||
this.descriptionRef.getBoundingClientRect().bottom
|
||||
this.props.onDescriptionBottomChanged(descriptionBottom)
|
||||
}
|
||||
|
||||
if (this.props.isExpanded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updateOverflow()
|
||||
}
|
||||
|
||||
private onDescriptionScrollViewRef = (ref: HTMLDivElement | null) => {
|
||||
this.descriptionScrollViewRef = ref
|
||||
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
|
||||
if (ref) {
|
||||
this.resizeObserver.observe(ref)
|
||||
} else {
|
||||
this.setState({ isOverflowed: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onDescriptionRef = (ref: HTMLDivElement | null) => {
|
||||
this.descriptionRef = ref
|
||||
}
|
||||
|
||||
private renderExpander() {
|
||||
if (
|
||||
!this.state.body.length ||
|
||||
(!this.props.isExpanded && !this.state.isOverflowed)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const expanded = this.props.isExpanded
|
||||
const onClick = expanded ? this.onCollapse : this.onExpand
|
||||
const icon = expanded ? OcticonSymbol.fold : OcticonSymbol.unfold
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="expander">
|
||||
<Octicon symbol={icon} />
|
||||
{expanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
private onExpand = () => {
|
||||
this.props.onExpandChanged(true)
|
||||
}
|
||||
|
||||
private onCollapse = () => {
|
||||
if (this.descriptionScrollViewRef) {
|
||||
this.descriptionScrollViewRef.scrollTop = 0
|
||||
}
|
||||
|
||||
this.props.onExpandChanged(false)
|
||||
}
|
||||
|
||||
private updateOverflow() {
|
||||
const scrollView = this.descriptionScrollViewRef
|
||||
if (scrollView) {
|
||||
this.setState({
|
||||
isOverflowed: scrollView.scrollHeight > scrollView.offsetHeight,
|
||||
})
|
||||
} else {
|
||||
if (this.state.isOverflowed) {
|
||||
this.setState({ isOverflowed: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// No need to check if it overflows if we're expanded
|
||||
if (!this.props.isExpanded) {
|
||||
this.updateOverflow()
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUpdate(nextProps: IExpandableCommitSummaryProps) {
|
||||
if (
|
||||
nextProps.selectedCommits.length !== this.props.selectedCommits.length ||
|
||||
!nextProps.selectedCommits.every((nextCommit, i) =>
|
||||
messageEquals(nextCommit, this.props.selectedCommits[i])
|
||||
)
|
||||
) {
|
||||
this.setState(createState(false, nextProps))
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidUpdate(
|
||||
prevProps: IExpandableCommitSummaryProps,
|
||||
prevState: IExpandableCommitSummaryState
|
||||
) {
|
||||
// No need to check if it overflows if we're expanded
|
||||
if (!this.props.isExpanded) {
|
||||
// If the body has changed or we've just toggled the expanded
|
||||
// state we'll recalculate whether we overflow or not.
|
||||
if (prevState.body !== this.state.body || prevProps.isExpanded) {
|
||||
this.updateOverflow()
|
||||
}
|
||||
} else {
|
||||
// Clear overflow state if we're expanded, we don't need it.
|
||||
if (this.state.isOverflowed) {
|
||||
this.setState({ isOverflowed: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderDescription() {
|
||||
if (this.state.body.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="commit-summary-description-container"
|
||||
ref={this.onDescriptionRef}
|
||||
>
|
||||
<div
|
||||
className="commit-summary-description-scroll-view"
|
||||
ref={this.onDescriptionScrollViewRef}
|
||||
>
|
||||
<RichText
|
||||
className="commit-summary-description"
|
||||
emoji={this.props.emoji}
|
||||
repository={this.props.repository}
|
||||
text={this.state.body}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{this.renderExpander()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onHighlightShasInDiff = () => {
|
||||
this.props.onHighlightShas(this.props.shasInDiff)
|
||||
}
|
||||
|
||||
private onHighlightShasNotInDiff = () => {
|
||||
const { onHighlightShas, selectedCommits, shasInDiff } = this.props
|
||||
onHighlightShas(
|
||||
selectedCommits.filter(c => !shasInDiff.includes(c.sha)).map(c => c.sha)
|
||||
)
|
||||
}
|
||||
|
||||
private onRemoveHighlightOfShas = () => {
|
||||
this.props.onHighlightShas([])
|
||||
}
|
||||
|
||||
private showUnreachableCommits = () => {
|
||||
this.props.showUnreachableCommits(UnreachableCommitsTab.Unreachable)
|
||||
}
|
||||
|
||||
private showReachableCommits = () => {
|
||||
this.props.showUnreachableCommits(UnreachableCommitsTab.Reachable)
|
||||
}
|
||||
|
||||
private renderCommitsNotReachable = () => {
|
||||
const { selectedCommits, shasInDiff } = this.props
|
||||
if (selectedCommits.length === 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const excludedCommitsCount = this.getCountCommitsNotInDiff(
|
||||
selectedCommits,
|
||||
shasInDiff
|
||||
)
|
||||
|
||||
if (excludedCommitsCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const commitsPluralized = excludedCommitsCount > 1 ? 'commits' : 'commit'
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
|
||||
<div
|
||||
className="commit-unreachable-info"
|
||||
onMouseOver={this.onHighlightShasNotInDiff}
|
||||
onMouseOut={this.onRemoveHighlightOfShas}
|
||||
>
|
||||
<Octicon symbol={OcticonSymbol.info} />
|
||||
<LinkButton onClick={this.showUnreachableCommits}>
|
||||
{excludedCommitsCount} unreachable {commitsPluralized}
|
||||
</LinkButton>{' '}
|
||||
not included.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderAuthors = () => {
|
||||
const { selectedCommits, repository } = this.props
|
||||
const { avatarUsers } = this.state
|
||||
if (selectedCommits.length > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="commit-summary-meta-item without-truncation">
|
||||
<AvatarStack users={avatarUsers} />
|
||||
<CommitAttribution
|
||||
gitHubRepository={repository.gitHubRepository}
|
||||
commits={selectedCommits}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
private renderCommitRef = () => {
|
||||
const { selectedCommits } = this.props
|
||||
if (selectedCommits.length > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="commit-summary-meta-item without-truncation"
|
||||
aria-label="SHA"
|
||||
>
|
||||
<Octicon symbol={OcticonSymbol.gitCommit} />
|
||||
<TooltippedCommitSHA
|
||||
className="selectable"
|
||||
commit={selectedCommits[0]}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
private renderSummary = () => {
|
||||
const { selectedCommits, shasInDiff } = this.props
|
||||
const { summary, hasEmptySummary } = this.state
|
||||
const summaryClassNames = classNames('commit-summary-title', {
|
||||
'empty-summary': hasEmptySummary,
|
||||
})
|
||||
|
||||
if (selectedCommits.length === 1) {
|
||||
return (
|
||||
<RichText
|
||||
className={summaryClassNames}
|
||||
emoji={this.props.emoji}
|
||||
repository={this.props.repository}
|
||||
text={summary}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const commitsNotInDiff = this.getCountCommitsNotInDiff(
|
||||
selectedCommits,
|
||||
shasInDiff
|
||||
)
|
||||
const numInDiff = selectedCommits.length - commitsNotInDiff
|
||||
const commitsPluralized = numInDiff > 1 ? 'commits' : 'commit'
|
||||
return (
|
||||
<div className={summaryClassNames}>
|
||||
Showing changes from{' '}
|
||||
{commitsNotInDiff > 0 ? (
|
||||
<LinkButton
|
||||
onMouseOver={this.onHighlightShasInDiff}
|
||||
onMouseOut={this.onRemoveHighlightOfShas}
|
||||
onClick={this.showReachableCommits}
|
||||
>
|
||||
{numInDiff} {commitsPluralized}
|
||||
</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
{numInDiff} {commitsPluralized}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const className = classNames({
|
||||
expanded: this.props.isExpanded,
|
||||
collapsed: !this.props.isExpanded,
|
||||
'has-expander': this.props.isExpanded || this.state.isOverflowed,
|
||||
'hide-description-border': this.props.hideDescriptionBorder,
|
||||
})
|
||||
|
||||
return (
|
||||
<div id="commit-summary" className={className}>
|
||||
<div className="commit-summary-header">
|
||||
{this.renderSummary()}
|
||||
<ul className="commit-summary-meta">
|
||||
{this.renderAuthors()}
|
||||
{this.renderCommitRef()}
|
||||
{this.renderChangedFilesDescription()}
|
||||
{this.renderLinesChanged()}
|
||||
{this.renderTags()}
|
||||
|
||||
<li className="commit-summary-meta-item without-truncation">
|
||||
<DiffOptions
|
||||
isInteractiveDiff={false}
|
||||
hideWhitespaceChanges={this.props.hideWhitespaceInDiff}
|
||||
onHideWhitespaceChangesChanged={
|
||||
this.props.onHideWhitespaceInDiffChanged
|
||||
}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onShowSideBySideDiffChanged={
|
||||
this.props.onShowSideBySideDiffChanged
|
||||
}
|
||||
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{this.renderDescription()}
|
||||
{this.renderCommitsNotReachable()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderChangedFilesDescription = () => {
|
||||
const fileCount = this.props.changesetData.files.length
|
||||
const filesPlural = fileCount === 1 ? 'file' : 'files'
|
||||
const filesShortDescription = `${fileCount} changed ${filesPlural}`
|
||||
|
||||
let filesAdded = 0
|
||||
let filesModified = 0
|
||||
let filesRemoved = 0
|
||||
let filesRenamed = 0
|
||||
for (const file of this.props.changesetData.files) {
|
||||
switch (file.status.kind) {
|
||||
case AppFileStatusKind.New:
|
||||
filesAdded += 1
|
||||
break
|
||||
case AppFileStatusKind.Modified:
|
||||
filesModified += 1
|
||||
break
|
||||
case AppFileStatusKind.Deleted:
|
||||
filesRemoved += 1
|
||||
break
|
||||
case AppFileStatusKind.Renamed:
|
||||
filesRenamed += 1
|
||||
}
|
||||
}
|
||||
|
||||
const hasFileDescription =
|
||||
filesAdded + filesModified + filesRemoved + filesRenamed > 0
|
||||
|
||||
const filesLongDescription = (
|
||||
<>
|
||||
{filesAdded > 0 ? (
|
||||
<span>
|
||||
<Octicon
|
||||
className="files-added-icon"
|
||||
symbol={OcticonSymbol.diffAdded}
|
||||
/>
|
||||
{filesAdded} added
|
||||
</span>
|
||||
) : null}
|
||||
{filesModified > 0 ? (
|
||||
<span>
|
||||
<Octicon
|
||||
className="files-modified-icon"
|
||||
symbol={OcticonSymbol.diffModified}
|
||||
/>
|
||||
{filesModified} modified
|
||||
</span>
|
||||
) : null}
|
||||
{filesRemoved > 0 ? (
|
||||
<span>
|
||||
<Octicon
|
||||
className="files-deleted-icon"
|
||||
symbol={OcticonSymbol.diffRemoved}
|
||||
/>
|
||||
{filesRemoved} deleted
|
||||
</span>
|
||||
) : null}
|
||||
{filesRenamed > 0 ? (
|
||||
<span>
|
||||
<Octicon
|
||||
className="files-renamed-icon"
|
||||
symbol={OcticonSymbol.diffRenamed}
|
||||
/>
|
||||
{filesRenamed} renamed
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<TooltippedContent
|
||||
className="commit-summary-meta-item without-truncation"
|
||||
tooltipClassName="changed-files-description-tooltip"
|
||||
tooltip={
|
||||
fileCount > 0 && hasFileDescription ? filesLongDescription : undefined
|
||||
}
|
||||
>
|
||||
<Octicon symbol={OcticonSymbol.diff} />
|
||||
{filesShortDescription}
|
||||
</TooltippedContent>
|
||||
)
|
||||
}
|
||||
|
||||
private renderLinesChanged() {
|
||||
const linesAdded = this.props.changesetData.linesAdded
|
||||
const linesDeleted = this.props.changesetData.linesDeleted
|
||||
if (linesAdded + linesDeleted === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const linesAddedPlural = linesAdded === 1 ? 'line' : 'lines'
|
||||
const linesDeletedPlural = linesDeleted === 1 ? 'line' : 'lines'
|
||||
const linesAddedTitle = `${linesAdded} ${linesAddedPlural} added`
|
||||
const linesDeletedTitle = `${linesDeleted} ${linesDeletedPlural} deleted`
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltippedContent
|
||||
tagName="li"
|
||||
className="commit-summary-meta-item without-truncation lines-added"
|
||||
tooltip={linesAddedTitle}
|
||||
>
|
||||
+{linesAdded}
|
||||
</TooltippedContent>
|
||||
<TooltippedContent
|
||||
tagName="li"
|
||||
className="commit-summary-meta-item without-truncation lines-deleted"
|
||||
tooltip={linesDeletedTitle}
|
||||
>
|
||||
-{linesDeleted}
|
||||
</TooltippedContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
private renderTags() {
|
||||
const { selectedCommits } = this.props
|
||||
if (selectedCommits.length > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const tags = selectedCommits[0].tags
|
||||
|
||||
if (tags.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="commit-summary-meta-item" title={tags.join('\n')}>
|
||||
<span>
|
||||
<Octicon symbol={OcticonSymbol.tag} />
|
||||
</span>
|
||||
|
||||
<span className="tags selectable">{tags.join(', ')}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -35,6 +35,8 @@ import { IConstrainedValue } from '../../lib/app-state'
|
|||
import { clamp } from '../../lib/clamp'
|
||||
import { pathExists } from '../lib/path-exists'
|
||||
import { UnreachableCommitsTab } from './unreachable-commits-dialog'
|
||||
import { enableCommitDetailsHeaderExpansion } from '../../lib/feature-flag'
|
||||
import { ExpandableCommitSummary } from './expandable-commit-summary'
|
||||
|
||||
interface ISelectedCommitsProps {
|
||||
readonly repository: Repository
|
||||
|
@ -176,6 +178,28 @@ export class SelectedCommits extends React.Component<
|
|||
}
|
||||
|
||||
private renderCommitSummary(commits: ReadonlyArray<Commit>) {
|
||||
if (enableCommitDetailsHeaderExpansion()) {
|
||||
return (
|
||||
<ExpandableCommitSummary
|
||||
selectedCommits={commits}
|
||||
shasInDiff={this.props.shasInDiff}
|
||||
changesetData={this.props.changesetData}
|
||||
emoji={this.props.emoji}
|
||||
repository={this.props.repository}
|
||||
onExpandChanged={this.onExpandChanged}
|
||||
isExpanded={this.state.isExpanded}
|
||||
onDescriptionBottomChanged={this.onDescriptionBottomChanged}
|
||||
hideDescriptionBorder={this.state.hideDescriptionBorder}
|
||||
hideWhitespaceInDiff={this.props.hideWhitespaceInDiff}
|
||||
showSideBySideDiff={this.props.showSideBySideDiff}
|
||||
onHideWhitespaceInDiffChanged={this.onHideWhitespaceInDiffChanged}
|
||||
onShowSideBySideDiffChanged={this.onShowSideBySideDiffChanged}
|
||||
onDiffOptionsOpened={this.props.onDiffOptionsOpened}
|
||||
onHighlightShas={this.onHighlightShas}
|
||||
showUnreachableCommits={this.showUnreachableCommits}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CommitSummary
|
||||
selectedCommits={commits}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@import 'history/history';
|
||||
@import 'history/commit-list';
|
||||
@import 'history/commit-summary';
|
||||
@import 'history/expandable-commit-summary';
|
||||
@import 'history/file-list';
|
||||
@import 'history/multiple_commits_selected';
|
||||
|
|
198
app/styles/ui/history/_expandable-commit-summary.scss
Normal file
198
app/styles/ui/history/_expandable-commit-summary.scss
Normal file
|
@ -0,0 +1,198 @@
|
|||
@import '../../mixins';
|
||||
|
||||
/** A React component holding the selected commit's detailed information */
|
||||
#commit-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.expander {
|
||||
position: absolute;
|
||||
width: 75px;
|
||||
top: var(--spacing);
|
||||
right: var(--spacing);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
|
||||
svg {
|
||||
vertical-align: text-top;
|
||||
margin-right: var(--spacing-half);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.commit-summary-description-scroll-view {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
display: revert;
|
||||
|
||||
&:before {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hide-description-border {
|
||||
.commit-summary-description-container {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-expander {
|
||||
.commit-summary-description {
|
||||
padding-right: 100px;
|
||||
}
|
||||
|
||||
// When the description area can be, but isn't yet, expanded
|
||||
// we'll add a small shadow towards the bottom to hint that
|
||||
// there's more content available.
|
||||
&:not(.expanded) {
|
||||
.commit-summary-description:before {
|
||||
content: '';
|
||||
background: var(--box-overflow-shadow-background);
|
||||
position: absolute;
|
||||
height: 30px;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.commit-unreachable-info {
|
||||
padding: var(--spacing-half) var(--spacing);
|
||||
border-bottom: var(--base-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.octicon {
|
||||
margin-right: var(--spacing-half);
|
||||
}
|
||||
|
||||
.link-button-component {
|
||||
margin-right: var(--spacing-half);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: This isn't a real class, it's an SCSS hack to allow us to only write
|
||||
// the suffixes of class names that all start with commit-summary. It's quite
|
||||
// confusing and also has the added downside of making it impossible to search
|
||||
// for a class name one finds in tsx. We might want to consider not allowing
|
||||
// this in the future but that's for... the future.
|
||||
.commit-summary {
|
||||
&-title,
|
||||
&-meta {
|
||||
padding: var(--spacing);
|
||||
|
||||
.lines-added {
|
||||
color: var(--color-new);
|
||||
}
|
||||
|
||||
.lines-deleted {
|
||||
color: var(--color-deleted);
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 16px;
|
||||
padding: var(--spacing);
|
||||
word-wrap: break-word;
|
||||
|
||||
&.empty-summary {
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
&-description-container {
|
||||
display: flex;
|
||||
// So that we have something to position the expander against
|
||||
position: relative;
|
||||
border-bottom: var(--base-border);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&-description-scroll-view {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
// Maximum amount of commit description lines to show before collapsing
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
// Enable text selection inside the title and description elements.
|
||||
&-title,
|
||||
&-description {
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
|
||||
* {
|
||||
user-select: unset;
|
||||
pointer-events: unset;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-family: var(--font-family-monospace);
|
||||
font-size: var(--font-size-sm);
|
||||
word-wrap: break-word;
|
||||
white-space: pre-line;
|
||||
padding: var(--spacing);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 var(--spacing) var(--spacing);
|
||||
}
|
||||
|
||||
&-meta-item:not(.without-truncation) {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-meta-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 0;
|
||||
margin-right: var(--spacing);
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
|
||||
.avatar,
|
||||
.octicon {
|
||||
display: inline-block;
|
||||
margin-right: var(--spacing-third);
|
||||
vertical-align: bottom; // For some reason, `bottom` places the text in the middle
|
||||
}
|
||||
|
||||
.selectable {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.tags {
|
||||
@include ellipsis;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
border-bottom: var(--base-border);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue