mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 19:21:50 +00:00
Remove discussions feature from webapp (#10262)
This commit is contained in:
parent
25785db184
commit
ccda05c7b0
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -216,8 +216,6 @@ Dockerfile @sourcegraph/distribution
|
||||
|
||||
# Code discussions
|
||||
**/*discussion* @slimsag
|
||||
/web/src/discussions @slimsag
|
||||
/web/src/repo/blob/discussions @slimsag
|
||||
/cmd/frontend/types/discussions.go @slimsag
|
||||
/cmd/frontend/internal/pkg/discussions @slimsag
|
||||
/cmd/frontend/graphqlbackend/discussion* @slimsag
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -9,6 +9,7 @@
|
||||
"**/out": true,
|
||||
"**/__fixtures__/**": true,
|
||||
"**/.cache": true,
|
||||
"**/.nyc_output": true,
|
||||
},
|
||||
"files.associations": {
|
||||
"**/dev/critical-config.json": "jsonc",
|
||||
|
||||
@ -100,7 +100,7 @@ export interface ModeSpec {
|
||||
mode: string
|
||||
}
|
||||
|
||||
type BlobViewState = 'def' | 'references' | 'discussions' | 'impl'
|
||||
type BlobViewState = 'def' | 'references' | 'impl'
|
||||
|
||||
export interface ViewStateSpec {
|
||||
/**
|
||||
|
||||
@ -217,7 +217,6 @@ body,
|
||||
// Pages
|
||||
@import './Layout';
|
||||
@import './api/APIConsole';
|
||||
@import './discussions/DiscussionsPage';
|
||||
@import './extensions/ExtensionsArea';
|
||||
@import './extensions/extension/ExtensionArea';
|
||||
@import './global/GlobalAlerts';
|
||||
@ -259,7 +258,6 @@ body,
|
||||
@import './components/DismissibleAlert';
|
||||
@import './marketing/SurveyPage';
|
||||
@import './components/SingleValueCard';
|
||||
@import './repo/blob/discussions/DiscussionsTree';
|
||||
@import './components/SearchResult';
|
||||
@import './components/SearchResultMatch';
|
||||
@import './components/diff/FileDiffNode';
|
||||
|
||||
@ -10,11 +10,11 @@ import sanitizeHtml from 'sanitize-html'
|
||||
import { Markdown } from '../../../shared/src/components/Markdown'
|
||||
import * as GQL from '../../../shared/src/graphql/schema'
|
||||
import { highlightNode } from '../../../shared/src/util/dom'
|
||||
import { renderMarkdown } from '../discussions/backend'
|
||||
import { highlightCode } from '../search/backend'
|
||||
import { HighlightRange } from './SearchResult'
|
||||
import { ThemeProps } from '../../../shared/src/theme'
|
||||
import * as H from 'history'
|
||||
import { renderMarkdown } from '../../../shared/src/util/markdown'
|
||||
|
||||
interface SearchResultMatchProps extends ThemeProps {
|
||||
item: GQL.ISearchResultMatch
|
||||
@ -57,12 +57,10 @@ export class SearchResultMatch extends React.Component<SearchResultMatchProps, S
|
||||
.pipe(
|
||||
filter(([, isVisible]) => isVisible),
|
||||
distinctUntilChanged((a, b) => isEqual(a, b)),
|
||||
switchMap(([props]) =>
|
||||
props.item.body.html
|
||||
? of(sanitizeHtml(props.item.body.html))
|
||||
: renderMarkdown({ markdown: props.item.body.text })
|
||||
),
|
||||
switchMap(markdownHTML => {
|
||||
switchMap(([props]) => {
|
||||
const markdownHTML = props.item.body.html
|
||||
? sanitizeHtml(props.item.body.html)
|
||||
: renderMarkdown(props.item.body.text)
|
||||
if (this.bodyIsCode()) {
|
||||
const lang = this.getLanguage() || 'txt'
|
||||
const parser = new DOMParser()
|
||||
@ -75,7 +73,7 @@ export class SearchResultMatch extends React.Component<SearchResultMatchProps, S
|
||||
code: codeContent,
|
||||
fuzzyLanguage: lang,
|
||||
disableTimeout: false,
|
||||
isLightTheme: this.props.isLightTheme,
|
||||
isLightTheme: props.isLightTheme,
|
||||
}).pipe(
|
||||
switchMap(highlightedStr => {
|
||||
const highlightedMarkdown = decode(markdownHTML).replace(
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
background: #2a3a51;
|
||||
}
|
||||
|
||||
100% {
|
||||
background: var(--theme-bg-plain);
|
||||
}
|
||||
}
|
||||
|
||||
.discussions-comment {
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&--targeted {
|
||||
.discussions-comment__content {
|
||||
animation: highlight 3s;
|
||||
}
|
||||
}
|
||||
|
||||
&__top-area {
|
||||
display: flex;
|
||||
line-height: 1rem;
|
||||
border-bottom: 1px solid #2a3a51;
|
||||
border-top: 1px solid #2a3a51;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: $color-bg-2;
|
||||
}
|
||||
&__top-right-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__content {
|
||||
background: var(--theme-bg-plain);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__share,
|
||||
&__report {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
&__toolbar-btn,
|
||||
&__share,
|
||||
&__report {
|
||||
padding: 0.425rem 0.175rem;
|
||||
}
|
||||
|
||||
&__reports:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlight-light {
|
||||
0% {
|
||||
background: $color-light-bg-3;
|
||||
}
|
||||
|
||||
100% {
|
||||
background: var(--theme-bg-plain);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
.discussions-comment {
|
||||
&:first-of-type {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&--targeted {
|
||||
.discussions-comment__content {
|
||||
animation: highlight-light 3s;
|
||||
}
|
||||
}
|
||||
|
||||
&__top-area {
|
||||
border-color: $color-light-border;
|
||||
background: $color-light-bg-2;
|
||||
}
|
||||
|
||||
&__content {
|
||||
code,
|
||||
pre {
|
||||
background: $color-light-bg-4;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: $color-light-text-2;
|
||||
}
|
||||
}
|
||||
|
||||
&__admin {
|
||||
background: $color-light-bg-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,199 +0,0 @@
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as H from 'history'
|
||||
import CommentCheckIcon from 'mdi-react/CommentCheckIcon'
|
||||
import CommentRemoveIcon from 'mdi-react/CommentRemoveIcon'
|
||||
import FlagVariantIcon from 'mdi-react/FlagVariantIcon'
|
||||
import LinkIcon from 'mdi-react/LinkIcon'
|
||||
import * as React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Observable } from 'rxjs'
|
||||
import { WithLinkPreviews } from '../../../shared/src/components/linkPreviews/WithLinkPreviews'
|
||||
import { Markdown } from '../../../shared/src/components/Markdown'
|
||||
import { ExtensionsControllerProps } from '../../../shared/src/extensions/controller'
|
||||
import * as GQL from '../../../shared/src/graphql/schema'
|
||||
import { asError } from '../../../shared/src/util/errors'
|
||||
import { LINK_PREVIEW_CLASS } from '../components/linkPreviews/styles'
|
||||
import { Timestamp } from '../components/time/Timestamp'
|
||||
import { setElementTooltip } from '../components/tooltip/Tooltip'
|
||||
import { eventLogger } from '../tracking/eventLogger'
|
||||
import { UserAvatar } from '../user/UserAvatar'
|
||||
|
||||
interface Props extends ExtensionsControllerProps {
|
||||
comment: GQL.IDiscussionComment
|
||||
threadID: GQL.ID
|
||||
location: H.Location
|
||||
|
||||
/**
|
||||
* When specified, a report icon will be displayed inline and this function
|
||||
* will be called when a report has been submitted.
|
||||
*/
|
||||
onReport?: (comment: GQL.IDiscussionComment, reason: string) => Observable<void>
|
||||
|
||||
/**
|
||||
* When specified, this function is called to handle the
|
||||
* "Clear reports / mark as read" button clicks.
|
||||
*/
|
||||
onClearReports?: (comment: GQL.IDiscussionComment) => Observable<void>
|
||||
|
||||
/**
|
||||
* When specified, this function is called to handle the "delete comment"
|
||||
* button clicks.
|
||||
*/
|
||||
onDelete?: (comment: GQL.IDiscussionComment) => Observable<void>
|
||||
history: H.History
|
||||
}
|
||||
|
||||
interface State {
|
||||
copiedLink: boolean
|
||||
}
|
||||
|
||||
export class DiscussionsComment extends React.PureComponent<Props> {
|
||||
private scrollToElement: HTMLElement | null = null
|
||||
|
||||
public state: State = {
|
||||
copiedLink: false,
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.scrollToElement) {
|
||||
this.scrollToElement.scrollIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const { location, comment, onReport, onClearReports, onDelete } = this.props
|
||||
const isTargeted = new URLSearchParams(location.hash).get('commentID') === comment.idWithoutKind
|
||||
|
||||
// TODO(slimsag:discussions): ASAP: markdown links, headings, etc lead to #
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`discussions-comment${isTargeted ? ' discussions-comment--targeted' : ''}`}
|
||||
ref={isTargeted ? this.setScrollToElement : undefined}
|
||||
>
|
||||
<div className="discussions-comment__top-area">
|
||||
<span className="discussions-comment__author">
|
||||
<Link to={`/users/${comment.author.username}`} data-tooltip={comment.author.displayName}>
|
||||
<UserAvatar user={comment.author} className="icon-inline icon-sm" />
|
||||
</Link>
|
||||
<Link
|
||||
to={`/users/${comment.author.username}`}
|
||||
data-tooltip={comment.author.displayName}
|
||||
className="ml-1 mr-1"
|
||||
>
|
||||
{comment.author.username}
|
||||
</Link>
|
||||
<span className="mr-1">commented</span>
|
||||
<Timestamp date={comment.createdAt} />
|
||||
</span>
|
||||
<span className="discussions-comment__spacer" />
|
||||
<span className="discussions-comment__top-right-area">
|
||||
{this.props.comment.inlineURL && (
|
||||
<Link
|
||||
className="btn btn-link btn-sm discussions-comment__share"
|
||||
data-tooltip="Copy link to this comment"
|
||||
to={this.props.comment.inlineURL}
|
||||
onClick={this.onShareLinkClick}
|
||||
>
|
||||
{this.state.copiedLink ? 'Copied!' : <LinkIcon className="icon-inline" />}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{comment.canReport && onReport && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm discussions-comment__report"
|
||||
data-tooltip="Report this comment"
|
||||
onClick={this.onReportClick}
|
||||
>
|
||||
<FlagVariantIcon className="icon-inline" />
|
||||
</button>
|
||||
)}
|
||||
{comment.reports.length > 0 && (
|
||||
<>
|
||||
<span
|
||||
className="ml-1 mr-1 discussions-comment__reports"
|
||||
data-tooltip={comment.reports.join('\n\n')}
|
||||
>
|
||||
{comment.reports.length} reports
|
||||
</span>
|
||||
{comment.canClearReports && onClearReports && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm discussions-comment__toolbar-btn"
|
||||
data-tooltip="Clear reports / mark as good message"
|
||||
onClick={this.onClearReportsClick}
|
||||
>
|
||||
<CommentCheckIcon className="icon-inline" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{comment.canDelete && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-sm discussions-comment__toolbar-btn"
|
||||
data-tooltip="Delete comment forever"
|
||||
onClick={this.onDeleteClick}
|
||||
>
|
||||
<CommentRemoveIcon className="icon-inline" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="discussions-comment__content">
|
||||
<WithLinkPreviews
|
||||
dangerousInnerHTML={comment.html}
|
||||
extensionsController={this.props.extensionsController}
|
||||
setElementTooltip={setElementTooltip}
|
||||
linkPreviewContentClass={LINK_PREVIEW_CLASS}
|
||||
>
|
||||
{props => <Markdown {...props} history={this.props.history} />}
|
||||
</WithLinkPreviews>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onShareLinkClick: React.MouseEventHandler<HTMLAnchorElement> = event => {
|
||||
if (event.metaKey || event.altKey || event.ctrlKey) {
|
||||
return
|
||||
}
|
||||
eventLogger.log('ShareCommentButtonClicked')
|
||||
copy(window.context.externalURL + this.props.comment.inlineURL!) // ! because this method is only called when inlineURL exists
|
||||
this.setState({ copiedLink: true })
|
||||
setTimeout(() => {
|
||||
this.setState({ copiedLink: false })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private onReportClick: React.MouseEventHandler<HTMLElement> = () => {
|
||||
eventLogger.log('ReportCommentButtonClicked')
|
||||
const reason = prompt('Report reason:', 'spam, offensive material, etc')
|
||||
if (!reason) {
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line rxjs/no-ignored-subscription
|
||||
this.props.onReport!(this.props.comment, reason).subscribe({
|
||||
error: error => alert('Error reporting comment: ' + asError(error).message),
|
||||
})
|
||||
}
|
||||
|
||||
private onClearReportsClick: React.MouseEventHandler<HTMLElement> = () => {
|
||||
// eslint-disable-next-line rxjs/no-ignored-subscription
|
||||
this.props.onClearReports!(this.props.comment).subscribe({
|
||||
error: error => alert('Error clearing comment reports: ' + asError(error).message),
|
||||
})
|
||||
}
|
||||
|
||||
private onDeleteClick: React.MouseEventHandler<HTMLElement> = () => {
|
||||
// eslint-disable-next-line rxjs/no-ignored-subscription
|
||||
this.props.onDelete!(this.props.comment).subscribe({
|
||||
error: error => alert('Error deleting comment: ' + asError(error).message),
|
||||
})
|
||||
}
|
||||
|
||||
private setScrollToElement = (ref: HTMLElement | null): void => {
|
||||
this.scrollToElement = ref
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
.discussions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* needed for Firefox/Edge scrolling to work properly; See sourcegraph/sourcegraph#12340 and https://codepen.io/slimsag/pen/mjPXyN */
|
||||
&--no-flex {
|
||||
display: block;
|
||||
}
|
||||
&__row {
|
||||
border-top: 1px solid $border-color;
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
&--active,
|
||||
&:hover {
|
||||
background-color: $color-bg-2;
|
||||
}
|
||||
|
||||
&-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
.discussions-list {
|
||||
&__row {
|
||||
color: $color-light-text-2;
|
||||
&-title {
|
||||
color: $color-light-text-2;
|
||||
}
|
||||
&--active,
|
||||
&:hover {
|
||||
background-color: $color-light-bg-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import * as React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Observable } from 'rxjs'
|
||||
import { ChatIcon } from '../../../shared/src/components/icons'
|
||||
import * as GQL from '../../../shared/src/graphql/schema'
|
||||
import { FilteredConnection, FilteredConnectionQueryArgs } from '../components/FilteredConnection'
|
||||
import { Timestamp } from '../components/time/Timestamp'
|
||||
import { fetchDiscussionThreads } from './backend'
|
||||
|
||||
interface DiscussionNodeProps {
|
||||
node: Pick<
|
||||
GQL.IDiscussionThread,
|
||||
'idWithoutKind' | 'title' | 'author' | 'inlineURL' | 'comments' | 'createdAt' | 'target'
|
||||
>
|
||||
location: H.Location
|
||||
withRepo?: boolean
|
||||
}
|
||||
|
||||
const DiscussionNode: React.FunctionComponent<DiscussionNodeProps> = ({ node, location, withRepo }) => {
|
||||
const currentURL = location.pathname + location.search + location.hash
|
||||
|
||||
// TODO(slimsag:discussions): future: Improve rendering of discussions when there is no inline URL
|
||||
const inlineURL = node.inlineURL || ''
|
||||
|
||||
return (
|
||||
<li className={'discussions-list__row' + (currentURL === inlineURL ? ' discussions-list__row--active' : '')}>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h3 className="discussions-list__row-title mb-0">
|
||||
<Link to={inlineURL}>{node.title}</Link>
|
||||
</h3>
|
||||
<Link to={inlineURL} className="text-muted">
|
||||
<ChatIcon className="icon-inline mr-1" />
|
||||
{node.comments.totalCount}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
#{node.idWithoutKind} created <Timestamp date={node.createdAt} /> by{' '}
|
||||
<Link to={`/users/${node.author.username}`} data-tooltip={node.author.displayName}>
|
||||
{node.author.username}
|
||||
</Link>{' '}
|
||||
{withRepo && (
|
||||
<>
|
||||
in <Link to={node.target.repository.name}>{node.target.repository.name}</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
class FilteredDiscussionsConnection extends FilteredConnection<
|
||||
DiscussionNodeProps['node'],
|
||||
Pick<DiscussionNodeProps, 'location'>
|
||||
> {}
|
||||
|
||||
interface Props {
|
||||
repoID: GQL.ID | undefined
|
||||
rev: string | undefined
|
||||
filePath: string | undefined
|
||||
history: H.History
|
||||
location: H.Location
|
||||
|
||||
autoFocus?: boolean
|
||||
defaultFirst?: number
|
||||
hideSearch?: boolean
|
||||
noun?: string
|
||||
pluralNoun?: string
|
||||
noFlex?: boolean
|
||||
withRepo?: boolean
|
||||
compact: boolean
|
||||
}
|
||||
|
||||
export class DiscussionsList extends React.PureComponent<Props> {
|
||||
public render(): JSX.Element | null {
|
||||
const nodeComponentProps: Pick<DiscussionNodeProps, 'location' | 'withRepo'> = {
|
||||
location: this.props.location,
|
||||
withRepo: this.props.withRepo,
|
||||
}
|
||||
return (
|
||||
<FilteredDiscussionsConnection
|
||||
className={'discussions-list' + this.props.noFlex ? 'discussions-list--no-flex' : ''}
|
||||
autoFocus={this.props.autoFocus !== undefined ? this.props.autoFocus : true}
|
||||
compact={this.props.compact}
|
||||
noun={this.props.noun || 'discussion'}
|
||||
pluralNoun={this.props.pluralNoun || 'discussions'}
|
||||
queryConnection={this.fetchThreads}
|
||||
nodeComponent={DiscussionNode}
|
||||
nodeComponentProps={nodeComponentProps}
|
||||
updateOnChange={`${String(this.props.repoID)}:${String(this.props.rev)}:${String(this.props.filePath)}`}
|
||||
defaultFirst={this.props.defaultFirst || 100}
|
||||
hideSearch={this.props.hideSearch}
|
||||
useURLQuery={false}
|
||||
history={this.props.history}
|
||||
location={this.props.location}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private fetchThreads = (args: FilteredConnectionQueryArgs): Observable<GQL.IDiscussionThreadConnection> =>
|
||||
fetchDiscussionThreads({
|
||||
...args,
|
||||
targetRepositoryID: this.props.repoID,
|
||||
targetRepositoryPath: this.props.filePath,
|
||||
})
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
.discussions-page {
|
||||
overflow: auto;
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import * as React from 'react'
|
||||
import { RouteComponentProps } from 'react-router-dom'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { isDiscussionsEnabled } from '.'
|
||||
import { SettingsCascadeProps } from '../../../shared/src/settings/settings'
|
||||
import { ErrorNotSupportedPage } from '../components/ErrorNotSupportedPage'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { DiscussionsList } from './DiscussionsList'
|
||||
import { eventLogger } from '../tracking/eventLogger'
|
||||
|
||||
interface Props extends SettingsCascadeProps, RouteComponentProps<{}> {
|
||||
history: H.History
|
||||
location: H.Location
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
/**
|
||||
* A page for viewing code discussions on this site.
|
||||
*/
|
||||
export class DiscussionsPage extends React.PureComponent<Props, State> {
|
||||
public state: State = {}
|
||||
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
public componentDidMount(): void {
|
||||
eventLogger.logViewEvent('Discussions')
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
if (!isDiscussionsEnabled(this.props.settingsCascade)) {
|
||||
return <ErrorNotSupportedPage />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="discussions-page container mt-3">
|
||||
<PageTitle title="Discussions" />
|
||||
<h2>All discussions</h2>
|
||||
<DiscussionsList
|
||||
withRepo={true}
|
||||
repoID={undefined}
|
||||
rev={undefined}
|
||||
filePath="/**"
|
||||
history={this.props.history}
|
||||
location={this.props.location}
|
||||
noun="discussion"
|
||||
pluralNoun="discussions"
|
||||
defaultFirst={6}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,287 +0,0 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { gql } from '../../../shared/src/graphql/graphql'
|
||||
import * as GQL from '../../../shared/src/graphql/schema'
|
||||
import { createAggregateError } from '../../../shared/src/util/errors'
|
||||
import { memoizeObservable } from '../../../shared/src/util/memoizeObservable'
|
||||
import { mutateGraphQL, queryGraphQL } from '../backend/graphql'
|
||||
|
||||
const discussionCommentFieldsFragment = gql`
|
||||
fragment DiscussionCommentFields on DiscussionComment {
|
||||
id
|
||||
idWithoutKind
|
||||
author {
|
||||
...UserFields
|
||||
}
|
||||
html
|
||||
inlineURL
|
||||
createdAt
|
||||
updatedAt
|
||||
reports
|
||||
canReport
|
||||
canDelete
|
||||
canClearReports
|
||||
}
|
||||
`
|
||||
|
||||
const discussionThreadFieldsFragment = gql`
|
||||
fragment DiscussionThreadFields on DiscussionThread {
|
||||
id
|
||||
idWithoutKind
|
||||
author {
|
||||
...UserFields
|
||||
}
|
||||
title
|
||||
target {
|
||||
__typename
|
||||
... on DiscussionThreadTargetRepo {
|
||||
repository {
|
||||
name
|
||||
}
|
||||
path
|
||||
branch {
|
||||
displayName
|
||||
}
|
||||
revision {
|
||||
displayName
|
||||
}
|
||||
selection {
|
||||
startLine
|
||||
startCharacter
|
||||
endLine
|
||||
endCharacter
|
||||
linesBefore
|
||||
lines
|
||||
linesAfter
|
||||
}
|
||||
}
|
||||
}
|
||||
inlineURL
|
||||
createdAt
|
||||
updatedAt
|
||||
archivedAt
|
||||
}
|
||||
|
||||
fragment UserFields on User {
|
||||
displayName
|
||||
username
|
||||
avatarURL
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Creates a new discussion thread.
|
||||
*
|
||||
* @returns Observable that emits the new discussion thread.
|
||||
*/
|
||||
export function createThread(input: GQL.IDiscussionThreadCreateInput): Observable<GQL.IDiscussionThread> {
|
||||
return mutateGraphQL(
|
||||
gql`
|
||||
mutation CreateThread($input: DiscussionThreadCreateInput!) {
|
||||
discussions {
|
||||
createThread(input: $input) {
|
||||
...DiscussionThreadFields
|
||||
comments {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${discussionThreadFieldsFragment}
|
||||
`,
|
||||
{ input }
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data || !data.discussions || !data.discussions.createThread || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.discussions.createThread
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches discussion threads.
|
||||
*/
|
||||
export function fetchDiscussionThreads(opts: {
|
||||
first?: number
|
||||
query?: string
|
||||
threadID?: GQL.ID
|
||||
authorUserID?: GQL.ID
|
||||
targetRepositoryID?: GQL.ID
|
||||
targetRepositoryName?: string
|
||||
targetRepositoryGitCloneURL?: string
|
||||
targetRepositoryPath?: string
|
||||
}): Observable<GQL.IDiscussionThreadConnection> {
|
||||
return queryGraphQL(
|
||||
gql`
|
||||
query DiscussionThreads(
|
||||
$first: Int
|
||||
$query: String
|
||||
$threadID: ID
|
||||
$authorUserID: ID
|
||||
$targetRepositoryID: ID
|
||||
$targetRepositoryName: String
|
||||
$targetRepositoryGitCloneURL: String
|
||||
$targetRepositoryPath: String
|
||||
) {
|
||||
discussionThreads(
|
||||
first: $first
|
||||
query: $query
|
||||
threadID: $threadID
|
||||
authorUserID: $authorUserID
|
||||
targetRepositoryID: $targetRepositoryID
|
||||
targetRepositoryName: $targetRepositoryName
|
||||
targetRepositoryGitCloneURL: $targetRepositoryGitCloneURL
|
||||
targetRepositoryPath: $targetRepositoryPath
|
||||
) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
nodes {
|
||||
...DiscussionThreadFields
|
||||
comments {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${discussionThreadFieldsFragment}
|
||||
`,
|
||||
opts
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data || !data.discussionThreads || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.discussionThreads
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a discussion thread and its comments.
|
||||
*/
|
||||
export function fetchDiscussionThreadAndComments(threadIDWithoutKind: string): Observable<GQL.IDiscussionThread> {
|
||||
return queryGraphQL(
|
||||
gql`
|
||||
query DiscussionThreadComments($threadIDWithoutKind: String!) {
|
||||
discussionThread(idWithoutKind: $threadIDWithoutKind) {
|
||||
...DiscussionThreadFields
|
||||
comments {
|
||||
totalCount
|
||||
nodes {
|
||||
...DiscussionCommentFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${discussionThreadFieldsFragment}
|
||||
${discussionCommentFieldsFragment}
|
||||
`,
|
||||
{ threadIDWithoutKind }
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data || !data.discussionThread || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.discussionThread
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a comment to an existing discussion thread.
|
||||
*
|
||||
* @returns Observable that emits the updated discussion thread and its comments.
|
||||
*/
|
||||
export function addCommentToThread(threadID: GQL.ID, contents: string): Observable<GQL.IDiscussionThread> {
|
||||
return mutateGraphQL(
|
||||
gql`
|
||||
mutation AddCommentToThread($threadID: ID!, $contents: String!) {
|
||||
discussions {
|
||||
addCommentToThread(threadID: $threadID, contents: $contents) {
|
||||
...DiscussionThreadFields
|
||||
comments {
|
||||
totalCount
|
||||
nodes {
|
||||
...DiscussionCommentFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${discussionThreadFieldsFragment}
|
||||
${discussionCommentFieldsFragment}
|
||||
`,
|
||||
{ threadID, contents }
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data || !data.discussions || !data.discussions.addCommentToThread || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.discussions.addCommentToThread
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing comment in a discussion thread.
|
||||
*
|
||||
* @returns Observable that emits the updated discussion thread and its comments.
|
||||
*/
|
||||
export function updateComment(input: GQL.IDiscussionCommentUpdateInput): Observable<GQL.IDiscussionThread> {
|
||||
return mutateGraphQL(
|
||||
gql`
|
||||
mutation UpdateComment($input: DiscussionCommentUpdateInput!) {
|
||||
discussions {
|
||||
updateComment(input: $input) {
|
||||
...DiscussionThreadFields
|
||||
comments {
|
||||
totalCount
|
||||
nodes {
|
||||
...DiscussionCommentFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${discussionThreadFieldsFragment}
|
||||
${discussionCommentFieldsFragment}
|
||||
`,
|
||||
{ input }
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data || !data.discussions || !data.discussions.updateComment || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.discussions.updateComment
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Markdown to HTML.
|
||||
*
|
||||
* @returns Observable that emits the HTML string, which is already sanitized and escaped and thus is always safe to render.
|
||||
*/
|
||||
export const renderMarkdown = memoizeObservable(
|
||||
(ctx: { markdown: string; options?: GQL.IMarkdownOptions }): Observable<string> =>
|
||||
queryGraphQL(
|
||||
gql`
|
||||
query RenderMarkdown($markdown: String!, $options: MarkdownOptions) {
|
||||
renderMarkdown(markdown: $markdown, options: $options)
|
||||
}
|
||||
`,
|
||||
ctx
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.renderMarkdown
|
||||
})
|
||||
),
|
||||
ctx => `${ctx.markdown}:${String(ctx.options)}`
|
||||
)
|
||||
@ -1,9 +0,0 @@
|
||||
import { isExtensionEnabled } from '../../../shared/src/extensions/extension'
|
||||
import { SettingsCascadeOrError } from '../../../shared/src/settings/settings'
|
||||
|
||||
/**
|
||||
* Tells whether or not the code discussions extensions is enabled or not.
|
||||
*/
|
||||
export function isDiscussionsEnabled(settingsCascade: SettingsCascadeOrError): boolean {
|
||||
return isExtensionEnabled(settingsCascade.final, 'sourcegraph/code-discussions')
|
||||
}
|
||||
@ -820,11 +820,10 @@ describe('e2e test suite', () => {
|
||||
})
|
||||
|
||||
describe('directory page', () => {
|
||||
// TODO(slimsag:discussions): temporarily disabled because the discussions feature flag removes this component.
|
||||
/*
|
||||
it('shows a row for each file in the directory', async () => {
|
||||
await driver.page.goto(sourcegraphBaseUrl + '/github.com/gorilla/securecookie@e59506cc896acb7f7bf732d4fdf5e25f7ccd8983')
|
||||
await enableOrAddRepositoryIfNeeded()
|
||||
await driver.page.goto(
|
||||
sourcegraphBaseUrl + '/github.com/gorilla/securecookie@e59506cc896acb7f7bf732d4fdf5e25f7ccd8983'
|
||||
)
|
||||
await driver.page.waitForSelector('.tree-page__entries-directories', { visible: true })
|
||||
await retry(async () =>
|
||||
assert.equal(
|
||||
@ -843,7 +842,6 @@ describe('e2e test suite', () => {
|
||||
)
|
||||
)
|
||||
})
|
||||
*/
|
||||
|
||||
test('shows commit information on a row', async () => {
|
||||
await driver.page.goto(
|
||||
@ -878,19 +876,17 @@ describe('e2e test suite', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// TODO(slimsag:discussions): temporarily disabled because the discussions feature flag removes this component.
|
||||
/*
|
||||
it('navigates when clicking on a row', async () => {
|
||||
await driver.page.goto(sourcegraphBaseUrl + '/github.com/sourcegraph/jsonrpc2@c6c7b9aa99fb76ee5460ccd3912ba35d419d493d')
|
||||
await enableOrAddRepositoryIfNeeded()
|
||||
await driver.page.goto(
|
||||
sourcegraphBaseUrl + '/github.com/sourcegraph/jsonrpc2@c6c7b9aa99fb76ee5460ccd3912ba35d419d493d'
|
||||
)
|
||||
// click on directory
|
||||
await driver.page.waitForSelector('.tree-entry', { visible: true })
|
||||
await driver.page.click('.tree-entry')
|
||||
await assertWindowLocation(
|
||||
await driver.assertWindowLocation(
|
||||
'/github.com/sourcegraph/jsonrpc2@c6c7b9aa99fb76ee5460ccd3912ba35d419d493d/-/tree/websocket'
|
||||
)
|
||||
})
|
||||
*/
|
||||
})
|
||||
|
||||
describe('rev resolution', () => {
|
||||
|
||||
@ -10,7 +10,6 @@ import * as GQL from '../../../shared/src/graphql/schema'
|
||||
import { PlatformContextProps } from '../../../shared/src/platform/context'
|
||||
import { SettingsCascadeProps } from '../../../shared/src/settings/settings'
|
||||
import { WebActionsNavItems, WebCommandListPopoverButton } from '../components/shared'
|
||||
import { isDiscussionsEnabled } from '../discussions'
|
||||
import { ThemeProps } from '../../../shared/src/theme'
|
||||
import { EventLoggerProps } from '../tracking/eventLogger'
|
||||
import { fetchAllStatusMessages, StatusMessagesNavItem } from './StatusMessagesNavItem'
|
||||
@ -129,7 +128,6 @@ export class NavLinks extends React.PureComponent<Props> {
|
||||
{...this.props}
|
||||
authenticatedUser={this.props.authenticatedUser}
|
||||
showDotComMarketing={this.props.showDotComMarketing}
|
||||
showDiscussions={isDiscussionsEnabled(this.props.settingsCascade)}
|
||||
keyboardShortcutForSwitchTheme={KEYBOARD_SHORTCUT_SWITCH_THEME}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@ -48,7 +48,6 @@ add('Site admin', () => (
|
||||
themePreference={ThemePreference.Light}
|
||||
location={H.createMemoryHistory().location}
|
||||
onThemePreferenceChange={onThemePreferenceChange}
|
||||
showDiscussions={true}
|
||||
showDotComMarketing={true}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
|
||||
@ -31,7 +31,6 @@ describe('UserNavItem', () => {
|
||||
location={history.location}
|
||||
authenticatedUser={USER}
|
||||
showDotComMarketing={true}
|
||||
showDiscussions={true}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
@ -16,7 +16,6 @@ interface Props extends ThemeProps, ThemePreferenceProps {
|
||||
'username' | 'avatarURL' | 'settingsURL' | 'organizations' | 'siteAdmin' | 'session'
|
||||
>
|
||||
showDotComMarketing: boolean
|
||||
showDiscussions: boolean
|
||||
keyboardShortcutForSwitchTheme?: KeyboardShortcut
|
||||
}
|
||||
|
||||
@ -68,11 +67,6 @@ export class UserNavItem extends React.PureComponent<Props, State> {
|
||||
<Link to="/extensions" className="dropdown-item">
|
||||
Extensions
|
||||
</Link>
|
||||
{this.props.showDiscussions && (
|
||||
<Link to="/discussions" className="dropdown-item">
|
||||
Discussions
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/users/${this.props.authenticatedUser.username}/searches`} className="dropdown-item">
|
||||
Saved searches
|
||||
</Link>
|
||||
|
||||
@ -52,13 +52,6 @@ exports[`UserNavItem simple 1`] = `
|
||||
>
|
||||
Extensions
|
||||
</a>
|
||||
<a
|
||||
className="dropdown-item"
|
||||
href="/discussions"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Discussions
|
||||
</a>
|
||||
<a
|
||||
className="dropdown-item"
|
||||
href="/users/u/searches"
|
||||
|
||||
@ -26,8 +26,6 @@ import { memoizeObservable } from '../../../shared/src/util/memoizeObservable'
|
||||
import { queryGraphQL } from '../backend/graphql'
|
||||
import { FilteredConnection } from '../components/FilteredConnection'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { isDiscussionsEnabled } from '../discussions'
|
||||
import { DiscussionsList } from '../discussions/DiscussionsList'
|
||||
import { PatternTypeProps, CaseSensitivityProps } from '../search'
|
||||
import { eventLogger, EventLoggerProps } from '../tracking/eventLogger'
|
||||
import { basename } from '../util/path'
|
||||
@ -309,22 +307,6 @@ export const TreePage: React.FunctionComponent<Props> = ({
|
||||
parentPath={filePath}
|
||||
entries={treeOrError.entries}
|
||||
/>
|
||||
{isDiscussionsEnabled(settingsCascade) && (
|
||||
<div className="tree-page__section mt-2 tree-page__section--discussions">
|
||||
<h3 className="tree-page__section-header">Discussions</h3>
|
||||
<DiscussionsList
|
||||
{...props}
|
||||
repoID={repoID}
|
||||
rev={rev}
|
||||
filePath={filePath + '/**' || undefined}
|
||||
noun="discussion in this tree"
|
||||
pluralNoun="discussions in this tree"
|
||||
defaultFirst={2}
|
||||
hideSearch={true}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable react/jsx-no-bind */}
|
||||
<ActionsContainer
|
||||
{...props}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
@import './LineDecorationAttachment';
|
||||
@import './discussions/DiscussionsGutterOverlay.scss';
|
||||
|
||||
.blob {
|
||||
position: relative;
|
||||
|
||||
@ -32,10 +32,8 @@ import {
|
||||
} from '../../../../shared/src/util/url'
|
||||
import { getHover } from '../../backend/features'
|
||||
import { WebHoverOverlay } from '../../components/shared'
|
||||
import { isDiscussionsEnabled } from '../../discussions'
|
||||
import { ThemeProps } from '../../../../shared/src/theme'
|
||||
import { EventLoggerProps } from '../../tracking/eventLogger'
|
||||
import { DiscussionsGutterOverlay } from './discussions/DiscussionsGutterOverlay'
|
||||
import { LineDecorationAttachment } from './LineDecorationAttachment'
|
||||
|
||||
/**
|
||||
@ -65,9 +63,6 @@ interface BlobProps
|
||||
}
|
||||
|
||||
interface BlobState extends HoverState<HoverContext, HoverMerged, ActionItemAction> {
|
||||
/** The desired position of the discussions gutter overlay */
|
||||
discussionsGutterOverlayPosition?: { left: number; top: number }
|
||||
|
||||
/**
|
||||
* lineDecorationAttachmentIDs is a map from line numbers with portal nodes created to portal IDs. It's used to
|
||||
* render the portals for {@link LineDecorationAttachment}. The line numbers are taken from the blob so they
|
||||
@ -295,16 +290,6 @@ export class Blob extends React.Component<BlobProps, BlobState> {
|
||||
const row = element.parentElement as HTMLTableRowElement
|
||||
row.classList.add('selected')
|
||||
}
|
||||
|
||||
// Update overlay position for discussions gutter icon.
|
||||
if (codeCells.length > 0) {
|
||||
const blobBounds = codeView.parentElement!.getBoundingClientRect()
|
||||
const row = codeCells[0].element.parentElement as HTMLTableRowElement
|
||||
const targetBounds = row.cells[0].getBoundingClientRect()
|
||||
const left = targetBounds.left - blobBounds.left
|
||||
const top = targetBounds.top + codeView.parentElement!.scrollTop - blobBounds.top
|
||||
this.setState({ discussionsGutterOverlayPosition: { left, top } })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@ -518,15 +503,6 @@ export class Blob extends React.Component<BlobProps, BlobState> {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{isDiscussionsEnabled(this.props.settingsCascade) &&
|
||||
this.state.selectedPosition &&
|
||||
this.state.selectedPosition.line !== undefined && (
|
||||
<DiscussionsGutterOverlay
|
||||
overlayPosition={this.state.discussionsGutterOverlayPosition}
|
||||
selectedPosition={this.state.selectedPosition}
|
||||
{...this.props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,11 +22,9 @@ import {
|
||||
import { queryGraphQL } from '../../backend/graphql'
|
||||
import { HeroPage } from '../../components/HeroPage'
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
import { isDiscussionsEnabled } from '../../discussions'
|
||||
import { eventLogger, EventLoggerProps } from '../../tracking/eventLogger'
|
||||
import { RepoHeaderContributionsLifecycleProps } from '../RepoHeader'
|
||||
import { RepoHeaderContributionPortal } from '../RepoHeaderContributionPortal'
|
||||
import { ToggleDiscussionsPanel } from './actions/ToggleDiscussions'
|
||||
import { ToggleHistoryPanel } from './actions/ToggleHistoryPanel'
|
||||
import { ToggleLineWrap } from './actions/ToggleLineWrap'
|
||||
import { ToggleRenderedFileMode } from './actions/ToggleRenderedFileMode'
|
||||
@ -229,20 +227,6 @@ export class BlobPage extends React.PureComponent<Props, State> {
|
||||
repoHeaderContributionsLifecycleProps={this.props.repoHeaderContributionsLifecycleProps}
|
||||
/>
|
||||
)}
|
||||
{isDiscussionsEnabled(this.props.settingsCascade) && (
|
||||
<RepoHeaderContributionPortal
|
||||
position="right"
|
||||
priority={20}
|
||||
element={
|
||||
<ToggleDiscussionsPanel
|
||||
key="toggle-blob-discussion-panel"
|
||||
location={this.props.location}
|
||||
history={this.props.history}
|
||||
/>
|
||||
}
|
||||
repoHeaderContributionsLifecycleProps={this.props.repoHeaderContributionsLifecycleProps}
|
||||
/>
|
||||
)}
|
||||
<RepoHeaderContributionPortal
|
||||
position="right"
|
||||
priority={30}
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import * as React from 'react'
|
||||
import { fromEvent, Subject, Subscription } from 'rxjs'
|
||||
import { filter } from 'rxjs/operators'
|
||||
import { ChatIcon } from '../../../../../shared/src/components/icons'
|
||||
import { LinkOrButton } from '../../../../../shared/src/components/LinkOrButton'
|
||||
import {
|
||||
lprToRange,
|
||||
parseHash,
|
||||
toPositionOrRangeHash,
|
||||
toViewStateHashComponent,
|
||||
} from '../../../../../shared/src/util/url'
|
||||
import { Tooltip } from '../../../components/tooltip/Tooltip'
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
import { BlobPanelTabID } from '../panel/BlobPanel'
|
||||
/**
|
||||
* A repository header action that toggles the visibility of the discussions panel.
|
||||
*/
|
||||
export class ToggleDiscussionsPanel extends React.PureComponent<{
|
||||
location: H.Location
|
||||
history: H.History
|
||||
}> {
|
||||
private toggles = new Subject<boolean>()
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
/**
|
||||
* Reports the current visibility (derived from the location).
|
||||
*/
|
||||
public static isVisible(location: H.Location): boolean {
|
||||
return parseHash<BlobPanelTabID>(location.hash).viewState === 'discussions'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the location object (that can be passed to H.History's push/replace methods) that sets visibility to
|
||||
* the given value.
|
||||
*/
|
||||
private static locationWithVisibility(location: H.Location, visible: boolean): H.LocationDescriptorObject {
|
||||
const hash = parseHash<BlobPanelTabID>(location.hash)
|
||||
if (visible) {
|
||||
hash.viewState = 'discussions' // defaults to last-viewed tab, or first tab
|
||||
} else {
|
||||
delete hash.viewState
|
||||
}
|
||||
return { hash: toPositionOrRangeHash({ range: lprToRange(hash) }) + toViewStateHashComponent(hash.viewState) }
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.subscriptions.add(
|
||||
this.toggles.subscribe(() => {
|
||||
const visible = ToggleDiscussionsPanel.isVisible(this.props.location)
|
||||
eventLogger.log(visible ? 'HideDiscussionsPanel' : 'ShowDiscussionsPanel')
|
||||
this.props.history.push(ToggleDiscussionsPanel.locationWithVisibility(this.props.location, !visible))
|
||||
Tooltip.forceUpdate()
|
||||
})
|
||||
)
|
||||
|
||||
// Toggle when the user presses 'alt+d' or 'opt+d'.
|
||||
this.subscriptions.add(
|
||||
fromEvent<KeyboardEvent>(window, 'keydown')
|
||||
.pipe(filter(event => event.altKey && event.key === 'd'))
|
||||
.subscribe(event => {
|
||||
event.preventDefault()
|
||||
this.toggles.next()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const visible = ToggleDiscussionsPanel.isVisible(this.props.location)
|
||||
return (
|
||||
<LinkOrButton
|
||||
onSelect={this.onClick}
|
||||
data-tooltip={`${visible ? 'Hide' : 'Show'} discussions (Alt+D/Opt+D)`}
|
||||
>
|
||||
<ChatIcon className="icon-inline" />
|
||||
</LinkOrButton>
|
||||
)
|
||||
}
|
||||
|
||||
private onClick = (): void => this.toggles.next()
|
||||
}
|
||||
@ -24,10 +24,6 @@ export class ToggleRenderedFileMode extends React.PureComponent<Props> {
|
||||
const q = new URLSearchParams(location.search)
|
||||
|
||||
if (!q.has(ToggleRenderedFileMode.URL_QUERY_PARAM)) {
|
||||
const isDiscussions = new URLSearchParams(location.hash).get('tab') === 'discussions'
|
||||
if (isDiscussions) {
|
||||
return 'code'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return q.get(ToggleRenderedFileMode.URL_QUERY_PARAM) === 'code' ? 'code' : 'rendered' // default to rendered
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
@import './DiscussionsInput';
|
||||
@import './DiscussionsNavbar';
|
||||
|
||||
.discussions-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
&__content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import InformationVariantIcon from 'mdi-react/InformationVariantIcon'
|
||||
import * as React from 'react'
|
||||
import { throwError, Observable } from 'rxjs'
|
||||
import { catchError, map, tap } from 'rxjs/operators'
|
||||
import { ExtensionsControllerProps } from '../../../../../shared/src/extensions/controller'
|
||||
import * as GQL from '../../../../../shared/src/graphql/schema'
|
||||
import { asError } from '../../../../../shared/src/util/errors'
|
||||
import { parseHash } from '../../../../../shared/src/util/url'
|
||||
import { createThread } from '../../../discussions/backend'
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
import { DiscussionsInput, TitleMode } from './DiscussionsInput'
|
||||
import { DiscussionsNavbar } from './DiscussionsNavbar'
|
||||
|
||||
interface Props extends ExtensionsControllerProps {
|
||||
repoID: GQL.ID
|
||||
repoName: string
|
||||
commitID: string
|
||||
rev: string | undefined
|
||||
filePath: string
|
||||
history: H.History
|
||||
location: H.Location
|
||||
}
|
||||
|
||||
interface State {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export class DiscussionsCreate extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
return (
|
||||
<div className="discussions-create">
|
||||
<DiscussionsNavbar {...this.props} threadTitle={this.state.title} />
|
||||
<div className="discussions-create__content">
|
||||
{this.state.title && this.state.title.length > 60 && (
|
||||
<div className="alert alert-info p-1 mt-3 ml-3 mr-3 mb-0">
|
||||
<small>
|
||||
<InformationVariantIcon className="icon-inline" />
|
||||
The first line of your message will become the title of your discussion. A good title is
|
||||
usually 50 characters or less.
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<DiscussionsInput
|
||||
submitLabel="Create discussion"
|
||||
titleMode={TitleMode.Implicit}
|
||||
onTitleChange={this.onTitleChange}
|
||||
onSubmit={this.onSubmit}
|
||||
{...this.props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onSubmit = (title: string, contents: string): Observable<void> => {
|
||||
eventLogger.log('CreatedDiscussion')
|
||||
|
||||
const lpr = parseHash(window.location.hash)
|
||||
|
||||
// lpr is one-based, discussions is zero-based.
|
||||
// lpr endings are inclusive, discussions is exclusive.
|
||||
const startLine = lpr.line ? lpr.line - 1 : 0
|
||||
const startCharacter = lpr.character ? lpr.character - 1 : 0
|
||||
const endLine = lpr.endLine ? lpr.endLine : startLine + 1
|
||||
const endCharacter = lpr.endCharacter || 0
|
||||
|
||||
return createThread({
|
||||
title,
|
||||
contents,
|
||||
targetRepo: {
|
||||
repositoryID: this.props.repoID,
|
||||
path: this.props.filePath,
|
||||
branch: this.props.rev,
|
||||
revision: this.props.commitID,
|
||||
selection: {
|
||||
startLine,
|
||||
startCharacter,
|
||||
endLine,
|
||||
endCharacter,
|
||||
linesBefore: null,
|
||||
lines: null,
|
||||
linesAfter: null,
|
||||
},
|
||||
},
|
||||
}).pipe(
|
||||
tap(thread => {
|
||||
const location = this.props.location
|
||||
const hash = new URLSearchParams(location.hash.slice('#'.length))
|
||||
hash.set('tab', 'discussions')
|
||||
hash.set('threadID', thread.idWithoutKind)
|
||||
// TODO(slimsag:discussions): ASAP: focus the new thread's range
|
||||
this.props.history.push(location.pathname + location.search + '#' + hash.toString())
|
||||
}),
|
||||
map(() => undefined),
|
||||
catchError(e => throwError(new Error('Error creating thread: ' + asError(e).message)))
|
||||
)
|
||||
}
|
||||
|
||||
private onTitleChange = (newTitle: string): void => this.setState({ title: newTitle })
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
.discussions-gutter-overlay {
|
||||
&__link {
|
||||
color: $blue;
|
||||
position: relative;
|
||||
top: -1.5px;
|
||||
}
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import * as React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChatIcon } from '../../../../../shared/src/components/icons'
|
||||
import { LineOrPositionOrRange, RepoFile } from '../../../../../shared/src/util/url'
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
|
||||
interface DiscussionsGutterOverlayProps extends RepoFile {
|
||||
location: H.Location
|
||||
|
||||
/** The currently selected position. */
|
||||
selectedPosition: LineOrPositionOrRange
|
||||
|
||||
/** The position of the tooltip (assigned to `style`) */
|
||||
overlayPosition?: { left: number; top: number }
|
||||
}
|
||||
|
||||
const onCreateDiscussionClick = (): void => eventLogger.log('CreateDiscussionClicked')
|
||||
|
||||
export const DiscussionsGutterOverlay: React.FunctionComponent<DiscussionsGutterOverlayProps> = props => {
|
||||
const hash = new URLSearchParams(props.location.hash.slice('#'.length))
|
||||
const onDiscussionsNew = hash.get('tab') === 'discussions' && hash.get('createThread') === 'true'
|
||||
hash.delete('threadID')
|
||||
hash.delete('commentID')
|
||||
if (onDiscussionsNew) {
|
||||
hash.delete('tab')
|
||||
hash.delete('createThread')
|
||||
} else {
|
||||
hash.set('tab', 'discussions')
|
||||
hash.set('createThread', 'true')
|
||||
}
|
||||
const newURL = props.location.pathname + props.location.search + '#' + hash.toString()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="discussions-gutter-overlay"
|
||||
// needed for dynamic styling
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={
|
||||
props.overlayPosition
|
||||
? {
|
||||
position: 'absolute',
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
left: props.overlayPosition.left + 'px',
|
||||
top: props.overlayPosition.top + 'px',
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
}
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="discussions-gutter-overlay__link btn btn-sm btn-link btn-icon"
|
||||
onClick={onCreateDiscussionClick}
|
||||
data-tooltip={onDiscussionsNew ? 'Close discussions' : 'Create a discussion for this selection'}
|
||||
to={newURL}
|
||||
>
|
||||
<ChatIcon className="icon-inline" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
.discussions-input {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #212c45;
|
||||
|
||||
&__text-box {
|
||||
min-height: 7rem;
|
||||
}
|
||||
&__preview {
|
||||
padding: 1rem;
|
||||
min-height: 7rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
text-align: right;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__error-icon {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
.discussions-input {
|
||||
border-color: $color-light-border;
|
||||
|
||||
&__button {
|
||||
color: $color-light-text-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,350 +0,0 @@
|
||||
import { LoadingSpinner } from '@sourcegraph/react-loading-spinner'
|
||||
import * as H from 'history'
|
||||
import { uniqueId } from 'lodash'
|
||||
import * as React from 'react'
|
||||
import { concat, merge, Observable, of, Subject, Subscription } from 'rxjs'
|
||||
import { catchError, filter, map, mergeMap, startWith, switchMap, tap, withLatestFrom } from 'rxjs/operators'
|
||||
import { CodeEditorData, EditorId } from '../../../../../shared/src/api/client/services/editorService'
|
||||
import { TextModel } from '../../../../../shared/src/api/client/services/modelService'
|
||||
import { COMMENT_URI_SCHEME } from '../../../../../shared/src/api/client/types/textDocument'
|
||||
import { EditorTextField } from '../../../../../shared/src/components/editorTextField/EditorTextField'
|
||||
import { Markdown } from '../../../../../shared/src/components/Markdown'
|
||||
import {
|
||||
Spacer,
|
||||
TabBorderClassName,
|
||||
TabsWithLocalStorageViewStatePersistence,
|
||||
} from '../../../../../shared/src/components/Tabs'
|
||||
import { ExtensionsControllerProps } from '../../../../../shared/src/extensions/controller'
|
||||
import { asError } from '../../../../../shared/src/util/errors'
|
||||
import { Form } from '../../../components/Form'
|
||||
import { WebEditorCompletionWidget } from '../../../components/shared'
|
||||
import { renderMarkdown } from '../../../discussions/backend'
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
import { ErrorAlert } from '../../../components/alerts'
|
||||
|
||||
/**
|
||||
* How & whether or not to render a title input field.
|
||||
*/
|
||||
export enum TitleMode {
|
||||
/** Explicitly show a separate title input field. */
|
||||
Explicit,
|
||||
|
||||
/** Implicitly use the first line of the main textarea as the title field (like Git commit messages). */
|
||||
Implicit,
|
||||
|
||||
/** No title input at all, e.g. for replying to discussion threads. */
|
||||
None,
|
||||
}
|
||||
|
||||
interface Props extends ExtensionsControllerProps {
|
||||
location: H.Location
|
||||
history: H.History
|
||||
|
||||
/** The initial contents (used when editing an existing comment). */
|
||||
initialContents?: string
|
||||
|
||||
/** The label to display on the submit button. */
|
||||
submitLabel: string
|
||||
|
||||
/** Called when the submit button is clicked. */
|
||||
onSubmit: (title: string, comment: string) => Observable<void>
|
||||
|
||||
/** How & whether or not to render a title input field. */
|
||||
titleMode: TitleMode
|
||||
|
||||
/** Called when the title value changes. */
|
||||
onTitleChange?: (title: string) => void
|
||||
}
|
||||
|
||||
interface State {
|
||||
titleInputValue: string
|
||||
textAreaValue: string
|
||||
editorId?: string
|
||||
modelUri?: string
|
||||
submitting: boolean
|
||||
error?: Error
|
||||
|
||||
previewLoading?: boolean
|
||||
previewHTML?: string
|
||||
}
|
||||
|
||||
type Update = (s: State) => State
|
||||
|
||||
export class DiscussionsInput extends React.PureComponent<Props, State> {
|
||||
private componentUpdates = new Subject<Props>()
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
private submits = new Subject<React.FormEvent<HTMLFormElement>>()
|
||||
private nextSubmit = (e: React.FormEvent<HTMLFormElement>): void => this.submits.next(e)
|
||||
|
||||
private titleInputChanges = new Subject<string>()
|
||||
private nextTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void =>
|
||||
this.titleInputChanges.next(e.currentTarget.value)
|
||||
|
||||
private textAreaKeyDowns = new Subject<React.KeyboardEvent<HTMLTextAreaElement>>()
|
||||
private nextTextAreaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
this.textAreaKeyDowns.next(e)
|
||||
}
|
||||
|
||||
private valueChanges = new Subject<string>()
|
||||
private nextTextAreaChange = (value: string): void => {
|
||||
this.valueChanges.next(value)
|
||||
}
|
||||
|
||||
private tabChanges = new Subject<string>()
|
||||
private nextTabChange = (tab: string): void => this.tabChanges.next(tab)
|
||||
|
||||
private textAreaRef = React.createRef<HTMLTextAreaElement>()
|
||||
|
||||
public state: State = {
|
||||
titleInputValue: '',
|
||||
textAreaValue: '',
|
||||
submitting: false,
|
||||
}
|
||||
|
||||
// TODO(slimsag:discussions): ASAP: "preview" tab does not get reset after you submit a comment
|
||||
|
||||
public componentDidMount(): void {
|
||||
const textAreaValueChanges = this.valueChanges.pipe(startWith(this.props.initialContents || ''))
|
||||
|
||||
// Update input model and editor.
|
||||
const editorResets = new Subject<void>()
|
||||
this.subscriptions.add(
|
||||
editorResets.subscribe(() => {
|
||||
this.setState({ editorId: undefined, modelUri: undefined })
|
||||
})
|
||||
)
|
||||
const editorInstantiations = editorResets.pipe(
|
||||
startWith(undefined),
|
||||
switchMap(
|
||||
() =>
|
||||
new Observable<EditorId & { modelUri: CodeEditorData['resource'] }>(sub => {
|
||||
const model: TextModel = {
|
||||
uri: uniqueId(`${COMMENT_URI_SCHEME}://`),
|
||||
languageId: 'plaintext',
|
||||
text: this.props.initialContents || '',
|
||||
}
|
||||
this.props.extensionsController.services.model.addModel(model)
|
||||
const editor = this.props.extensionsController.services.editor.addEditor({
|
||||
type: 'CodeEditor',
|
||||
resource: model.uri,
|
||||
selections: [],
|
||||
isActive: true,
|
||||
})
|
||||
sub.next({ editorId: editor.editorId, modelUri: model.uri })
|
||||
return () => {
|
||||
this.props.extensionsController.services.editor.removeEditor(editor)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
this.subscriptions.add(
|
||||
merge(
|
||||
this.titleInputChanges.pipe(
|
||||
tap(titleInputValue => this.props.onTitleChange && this.props.onTitleChange(titleInputValue)),
|
||||
map((titleInputValue): Update => state => ({ ...state, titleInputValue }))
|
||||
),
|
||||
|
||||
textAreaValueChanges.pipe(
|
||||
map(
|
||||
(textAreaValue): Update => state => {
|
||||
if (this.props.titleMode === TitleMode.Implicit) {
|
||||
this.titleInputChanges.next(textAreaValue.trimLeft().split('\n')[0])
|
||||
}
|
||||
return { ...state, textAreaValue }
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
editorInstantiations.pipe(
|
||||
map(({ editorId, modelUri }): Update => state => ({ ...state, editorId, modelUri }))
|
||||
),
|
||||
|
||||
// Handle tab changes by logging the event and fetching preview data.
|
||||
this.tabChanges.pipe(
|
||||
tap(tab => {
|
||||
if (tab === 'write') {
|
||||
eventLogger.log('DiscussionsInputWriteTabSelected')
|
||||
} else if (tab === 'preview') {
|
||||
eventLogger.log('DiscussionsInputPreviewTabSelected')
|
||||
}
|
||||
}),
|
||||
filter(tab => tab === 'preview'),
|
||||
withLatestFrom(this.valueChanges),
|
||||
mergeMap(([, textAreaValue]) =>
|
||||
concat(
|
||||
of<Update>(state => ({ ...state, previewHTML: undefined, previewLoading: true })),
|
||||
renderMarkdown({ markdown: this.trimImplicitTitle(textAreaValue) }).pipe(
|
||||
map(
|
||||
(previewHTML): Update => state => ({
|
||||
...state,
|
||||
previewHTML,
|
||||
previewLoading: false,
|
||||
})
|
||||
),
|
||||
catchError((error): Update[] => {
|
||||
console.error(error)
|
||||
return [
|
||||
state => ({
|
||||
...state,
|
||||
error: new Error('Error rendering Markdown: ' + asError(error).message),
|
||||
previewLoading: false,
|
||||
}),
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Combine form submits and keyboard shortcut submits
|
||||
merge(
|
||||
this.submits.pipe(tap(e => e.preventDefault())),
|
||||
|
||||
// cmd+enter (darwin) or ctrl+enter (linux/win)
|
||||
this.textAreaKeyDowns.pipe(
|
||||
filter(e => (e.ctrlKey || e.metaKey) && e.key === 'Enter' && this.canSubmit())
|
||||
)
|
||||
).pipe(
|
||||
withLatestFrom(
|
||||
this.valueChanges,
|
||||
this.titleInputChanges.pipe(startWith('')),
|
||||
this.componentUpdates.pipe(startWith(this.props))
|
||||
),
|
||||
mergeMap(([, textAreaValue, titleInputValue, props]) =>
|
||||
concat(
|
||||
// Start with setting submitting: true
|
||||
of<Update>(state => ({ ...state, submitting: true })),
|
||||
props.onSubmit(titleInputValue, this.trimImplicitTitle(textAreaValue)).pipe(
|
||||
map(
|
||||
(): Update => state => ({
|
||||
...state,
|
||||
submitting: false,
|
||||
titleInputValue: '',
|
||||
textAreaValue: '',
|
||||
})
|
||||
),
|
||||
tap(() => editorResets.next()),
|
||||
catchError((error): Update[] => {
|
||||
console.error(error)
|
||||
return [
|
||||
state => ({
|
||||
...state,
|
||||
error: asError(error),
|
||||
submitting: false,
|
||||
}),
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
).subscribe(
|
||||
updateState => this.setState(state => updateState(state)),
|
||||
err => console.error(err)
|
||||
)
|
||||
)
|
||||
this.componentUpdates.next(this.props)
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
this.componentUpdates.next(this.props)
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const { titleInputValue, editorId, modelUri, error, previewLoading, previewHTML } = this.state
|
||||
|
||||
if (!editorId || !modelUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className="discussions-input" onSubmit={this.nextSubmit}>
|
||||
{this.props.titleMode === TitleMode.Explicit && (
|
||||
<input
|
||||
className="form-control discussions-input__title"
|
||||
placeholder="Title"
|
||||
autoFocus={true}
|
||||
onChange={this.nextTitleInputChange}
|
||||
value={titleInputValue}
|
||||
/>
|
||||
)}
|
||||
{/* TODO(slimsag:discussions): local storage persistence is not ideal here. */}
|
||||
<TabsWithLocalStorageViewStatePersistence
|
||||
tabs={[
|
||||
{ id: 'write', label: 'Write' },
|
||||
{ id: 'preview', label: 'Preview' },
|
||||
]}
|
||||
storageKey="discussions-input-last-tab"
|
||||
tabBarEndFragment={
|
||||
<>
|
||||
<Spacer />
|
||||
<small className={TabBorderClassName}>Markdown supported.</small>
|
||||
</>
|
||||
}
|
||||
tabClassName="tab-bar__tab--h5like"
|
||||
onSelectTab={this.nextTabChange}
|
||||
>
|
||||
<div key="write">
|
||||
{this.textAreaRef.current && (
|
||||
<WebEditorCompletionWidget
|
||||
textArea={this.textAreaRef.current}
|
||||
editorId={editorId}
|
||||
extensionsController={this.props.extensionsController}
|
||||
/>
|
||||
)}
|
||||
<EditorTextField
|
||||
className="form-control discussions-input__text-box"
|
||||
placeholder="Leave a comment"
|
||||
editorId={editorId}
|
||||
modelUri={modelUri}
|
||||
onValueChange={this.nextTextAreaChange}
|
||||
onKeyDown={this.nextTextAreaKeyDown}
|
||||
textAreaRef={this.textAreaRef}
|
||||
autoFocus={this.props.titleMode !== TitleMode.Explicit}
|
||||
extensionsController={this.props.extensionsController}
|
||||
/>
|
||||
</div>
|
||||
<div key="preview" className="discussions-input__preview">
|
||||
{previewLoading && <LoadingSpinner className="icon-inline" />}
|
||||
{!previewLoading && previewHTML && (
|
||||
<Markdown dangerousInnerHTML={previewHTML} history={this.props.history} />
|
||||
)}
|
||||
</div>
|
||||
</TabsWithLocalStorageViewStatePersistence>
|
||||
<div className="discussions-input__row">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary discussions-input__button"
|
||||
disabled={!this.canSubmit()}
|
||||
>
|
||||
{this.props.submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<ErrorAlert className="discussions-input__error" error={error} history={this.props.history} />
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
/** Trims the implicit title string out of the comment (e.g. textarea value). */
|
||||
private trimImplicitTitle = (comment: string): string => {
|
||||
if (this.props.titleMode !== TitleMode.Implicit) {
|
||||
return comment
|
||||
}
|
||||
return comment.trimLeft().split('\n').slice(1).join('\n')
|
||||
}
|
||||
|
||||
private canSubmit = (): boolean => {
|
||||
const textAreaEmpty = !this.state.textAreaValue.trim()
|
||||
const titleRequired = this.props.titleMode !== TitleMode.None
|
||||
const titleEmpty = !this.state.titleInputValue.trim()
|
||||
return !this.state.submitting && !textAreaEmpty && (!titleRequired || !titleEmpty)
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
.discussions-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #212c45;
|
||||
&__title {
|
||||
display: inline-block;
|
||||
}
|
||||
&__title-container {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
.discussions-navbar {
|
||||
border-color: $color-light-border;
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import ChevronRightIcon from 'mdi-react/ChevronRightIcon'
|
||||
import * as React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import * as GQL from '../../../../../shared/src/graphql/schema'
|
||||
|
||||
interface Props {
|
||||
threadID?: GQL.ID
|
||||
threadTitle?: string
|
||||
commentID?: GQL.ID
|
||||
commentContent?: string
|
||||
filePath: string
|
||||
location: H.Location
|
||||
}
|
||||
|
||||
export class DiscussionsNavbar extends React.PureComponent<Props> {
|
||||
public render(): JSX.Element | null {
|
||||
// TODO(slimsag:discussions): make ID number smaller and grey like on thread list
|
||||
const { threadID, threadTitle, commentID, commentContent, filePath, location } = this.props
|
||||
return (
|
||||
<div className="discussions-navbar">
|
||||
<Link to={this.locationWith(location)}>{filePath}</Link>
|
||||
<ChevronRightIcon className="icon-inline" />
|
||||
{threadID !== undefined && commentID !== undefined && (
|
||||
<>
|
||||
<Link to={this.locationWith(location, threadID)}>
|
||||
{threadTitle !== undefined && `${threadTitle} `}#{threadID}
|
||||
</Link>
|
||||
<ChevronRightIcon className="icon-inline" />
|
||||
<strong>
|
||||
{commentContent !== undefined && `${commentContent} `}#{this.props.commentID}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
{threadID !== undefined && commentID === undefined && (
|
||||
<strong>
|
||||
{threadTitle !== undefined && `${threadTitle} `}#{threadID}
|
||||
</strong>
|
||||
)}
|
||||
{threadID === undefined && commentID === undefined && (
|
||||
<>
|
||||
{!threadTitle && <strong>New discussion</strong>}
|
||||
{threadTitle && (
|
||||
<>
|
||||
<strong className="discussions-navbar__title-container">
|
||||
New discussion:{' '}
|
||||
<small className="discussions-navbar__title text-muted">"{threadTitle}"</small>
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private locationWith(location: H.Location, threadID?: GQL.ID): string {
|
||||
// TODO(slimsag:discussions): future: for correctness, this should not
|
||||
// assume the current location and instead use this.props.filePath etc.
|
||||
const hash = new URLSearchParams(location.hash.slice('#'.length))
|
||||
hash.set('tab', 'discussions')
|
||||
hash.delete('createThread')
|
||||
hash.delete('commentID')
|
||||
if (threadID) {
|
||||
hash.set('threadID', threadID)
|
||||
} else {
|
||||
hash.delete('threadID')
|
||||
}
|
||||
return location.pathname + location.search + '#' + hash.toString()
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
@import '../../../discussions/DiscussionsComment.scss';
|
||||
|
||||
.discussions-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* needed for Firefox/Edge scrolling to work properly; See sourcegraph/sourcegraph#12340 and https://codepen.io/slimsag/pen/mjPXyN */
|
||||
|
||||
&__comments {
|
||||
overflow: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@ -1,223 +0,0 @@
|
||||
import { LoadingSpinner } from '@sourcegraph/react-loading-spinner'
|
||||
import * as H from 'history'
|
||||
import { isEqual } from 'lodash'
|
||||
import * as React from 'react'
|
||||
import { Redirect } from 'react-router'
|
||||
import { combineLatest, Subject, Subscription, throwError, Observable } from 'rxjs'
|
||||
import { catchError, delay, distinctUntilChanged, map, repeatWhen, startWith, switchMap, tap } from 'rxjs/operators'
|
||||
import { ExtensionsControllerProps } from '../../../../../shared/src/extensions/controller'
|
||||
import * as GQL from '../../../../../shared/src/graphql/schema'
|
||||
import { asError } from '../../../../../shared/src/util/errors'
|
||||
import { addCommentToThread, fetchDiscussionThreadAndComments, updateComment } from '../../../discussions/backend'
|
||||
import { DiscussionsComment } from '../../../discussions/DiscussionsComment'
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
import { formatHash } from '../../../util/url'
|
||||
import { DiscussionsInput, TitleMode } from './DiscussionsInput'
|
||||
import { DiscussionsNavbar } from './DiscussionsNavbar'
|
||||
import { ErrorAlert } from '../../../components/alerts'
|
||||
|
||||
interface Props extends ExtensionsControllerProps {
|
||||
threadIDWithoutKind: string
|
||||
commentIDWithoutKind?: string
|
||||
repoID: GQL.ID
|
||||
rev: string | undefined
|
||||
filePath: string
|
||||
history: H.History
|
||||
location: H.Location
|
||||
}
|
||||
|
||||
interface State {
|
||||
loading: boolean
|
||||
error?: any
|
||||
thread?: GQL.IDiscussionThread
|
||||
}
|
||||
|
||||
export class DiscussionsThread extends React.PureComponent<Props, State> {
|
||||
private componentUpdates = new Subject<Props>()
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
eventLogger.logViewEvent('DiscussionsThread')
|
||||
|
||||
// TODO(slimsag:discussions): ASAP: changing threadID manually in URL does not work. Can't click links to threads/comments effectively.
|
||||
this.subscriptions.add(
|
||||
combineLatest(this.componentUpdates.pipe(startWith(this.props)))
|
||||
.pipe(
|
||||
distinctUntilChanged(([a], [b]) => a.threadIDWithoutKind === b.threadIDWithoutKind),
|
||||
switchMap(([props]) =>
|
||||
fetchDiscussionThreadAndComments(props.threadIDWithoutKind).pipe(
|
||||
map(thread => ({ thread, error: undefined, loading: false })),
|
||||
catchError(error => {
|
||||
console.error(error)
|
||||
return [{ error, loading: false }]
|
||||
}),
|
||||
repeatWhen(delay(2500))
|
||||
)
|
||||
)
|
||||
)
|
||||
.subscribe(
|
||||
stateUpdate => this.setState(state => ({ ...state, ...stateUpdate })),
|
||||
err => console.error(err)
|
||||
)
|
||||
)
|
||||
this.componentUpdates.next(this.props)
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
this.componentUpdates.next(this.props)
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
// TODO(slimsag:discussions): future: test error state + cleanup CSS
|
||||
|
||||
const { error, loading, thread } = this.state
|
||||
const { location, commentIDWithoutKind } = this.props
|
||||
|
||||
// If the thread is loaded, ensure that the URL hash is updated to
|
||||
// reflect the line that the discussion was created on.
|
||||
if (thread) {
|
||||
const desiredHash = this.urlHashWithLine(
|
||||
thread,
|
||||
commentIDWithoutKind ? { idWithoutKind: commentIDWithoutKind } : undefined
|
||||
)
|
||||
if (!hashesEqual(desiredHash, location.hash)) {
|
||||
const discussionURL = location.pathname + location.search + desiredHash
|
||||
return <Redirect to={discussionURL} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="discussions-thread">
|
||||
<DiscussionsNavbar {...this.props} threadTitle={thread ? thread.title : undefined} />
|
||||
{loading && <LoadingSpinner className="icon-inline" />}
|
||||
{error && (
|
||||
<ErrorAlert
|
||||
className="discussions-thread__error"
|
||||
prefix="Error loading thread"
|
||||
error={error}
|
||||
history={this.props.history}
|
||||
/>
|
||||
)}
|
||||
{thread && (
|
||||
<div className="discussions-thread__comments">
|
||||
{thread.comments.nodes.map(node => (
|
||||
<DiscussionsComment
|
||||
key={node.id}
|
||||
{...this.props}
|
||||
threadID={thread.id}
|
||||
comment={node}
|
||||
onReport={this.onCommentReport}
|
||||
onClearReports={this.onCommentClearReports}
|
||||
onDelete={this.onCommentDelete}
|
||||
extensionsController={this.props.extensionsController}
|
||||
/>
|
||||
))}
|
||||
<DiscussionsInput
|
||||
key="input"
|
||||
submitLabel="Comment"
|
||||
titleMode={TitleMode.None}
|
||||
onSubmit={this.onSubmit}
|
||||
{...this.props}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a URL hash for linking to the given discussion thread and the
|
||||
* line that it was created on.
|
||||
*
|
||||
* @param thread The thread to link to.
|
||||
*/
|
||||
private urlHashWithLine(
|
||||
thread: Pick<GQL.IDiscussionThread, 'idWithoutKind' | 'target'>,
|
||||
comment?: Pick<GQL.IDiscussionComment, 'idWithoutKind'>
|
||||
): string {
|
||||
const hash = new URLSearchParams()
|
||||
hash.set('tab', 'discussions')
|
||||
hash.set('threadID', thread.idWithoutKind)
|
||||
if (comment) {
|
||||
hash.set('commentID', comment.idWithoutKind)
|
||||
}
|
||||
|
||||
return thread.target.__typename === 'DiscussionThreadTargetRepo' && thread.target.selection !== null
|
||||
? formatHash(
|
||||
{
|
||||
line: thread.target.selection.startLine + 1,
|
||||
character: thread.target.selection.startCharacter,
|
||||
endLine:
|
||||
// The 0th character means the selection ended at the end of the previous
|
||||
// line.
|
||||
(thread.target.selection.endCharacter === 0
|
||||
? thread.target.selection.endLine - 1
|
||||
: thread.target.selection.endLine) + 1,
|
||||
endCharacter: thread.target.selection.endCharacter,
|
||||
},
|
||||
hash
|
||||
)
|
||||
: '#' + hash.toString()
|
||||
}
|
||||
|
||||
private onSubmit = (title: string, contents: string): Observable<void> => {
|
||||
eventLogger.log('RepliedToDiscussion')
|
||||
if (!this.state.thread) {
|
||||
throw new Error('no thread')
|
||||
}
|
||||
return addCommentToThread(this.state.thread.id, contents).pipe(
|
||||
tap(thread => this.setState({ thread })),
|
||||
map(() => undefined),
|
||||
catchError(e => throwError(new Error('Error creating comment: ' + asError(e).message)))
|
||||
)
|
||||
}
|
||||
|
||||
private onCommentReport = (comment: GQL.IDiscussionComment, reason: string): Observable<void> =>
|
||||
updateComment({ commentID: comment.id, report: reason }).pipe(
|
||||
tap(thread => this.setState({ thread })),
|
||||
map(() => undefined)
|
||||
)
|
||||
|
||||
private onCommentClearReports = (comment: GQL.IDiscussionComment): Observable<void> =>
|
||||
updateComment({ commentID: comment.id, clearReports: true }).pipe(
|
||||
tap(thread => this.setState({ thread })),
|
||||
map(() => undefined)
|
||||
)
|
||||
|
||||
private onCommentDelete = (comment: GQL.IDiscussionComment): Observable<void> =>
|
||||
// TODO: Support deleting the whole thread, and/or fix this when it is deleting the 1st comment
|
||||
// in a thread. See https://github.com/sourcegraph/sourcegraph/issues/429.
|
||||
updateComment({ commentID: comment.id, delete: true }).pipe(
|
||||
tap(thread => this.setState({ thread })),
|
||||
map(thread => undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns Whether the 2 URI fragments contain the same keys and values (assuming they contain a
|
||||
* `#` then HTML-form-encoded keys and values like `a=b&c=d`).
|
||||
*/
|
||||
function hashesEqual(a: string, b: string): boolean {
|
||||
if (a.startsWith('#')) {
|
||||
a = a.slice(1)
|
||||
}
|
||||
if (b.startsWith('#')) {
|
||||
b = b.slice(1)
|
||||
}
|
||||
const canonicalize = (hash: string): string[] =>
|
||||
Array.from(new URLSearchParams(hash).entries())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.sort()
|
||||
return isEqual(canonicalize(a), canonicalize(b))
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
@import './DiscussionsThread.scss';
|
||||
@import './DiscussionsCreate.scss';
|
||||
@import '../../../discussions/DiscussionsList.scss';
|
||||
@ -1,55 +0,0 @@
|
||||
import * as H from 'history'
|
||||
import * as React from 'react'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { ExtensionsControllerProps } from '../../../../../shared/src/extensions/controller'
|
||||
import * as GQL from '../../../../../shared/src/graphql/schema'
|
||||
import { DiscussionsList } from '../../../discussions/DiscussionsList'
|
||||
import { registerDiscussionsContributions } from './contributions'
|
||||
import { DiscussionsCreate } from './DiscussionsCreate'
|
||||
import { DiscussionsThread } from './DiscussionsThread'
|
||||
|
||||
interface Props extends ExtensionsControllerProps {
|
||||
repoID: GQL.ID
|
||||
repoName: string
|
||||
commitID: string
|
||||
rev: string | undefined
|
||||
filePath: string
|
||||
history: H.History
|
||||
location: H.Location
|
||||
compact: boolean
|
||||
}
|
||||
|
||||
export class DiscussionsTree extends React.PureComponent<Props> {
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.subscriptions.add(registerDiscussionsContributions(this.props))
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const hash = new URLSearchParams(this.props.location.hash.slice('#'.length))
|
||||
const threadIDWithoutKind = hash.get('threadID')
|
||||
const commentIDWithoutKind = hash.get('commentID')
|
||||
|
||||
if (threadIDWithoutKind && commentIDWithoutKind) {
|
||||
return (
|
||||
<DiscussionsThread
|
||||
threadIDWithoutKind={threadIDWithoutKind}
|
||||
commentIDWithoutKind={commentIDWithoutKind}
|
||||
{...this.props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (threadIDWithoutKind) {
|
||||
return <DiscussionsThread threadIDWithoutKind={threadIDWithoutKind} {...this.props} />
|
||||
}
|
||||
if (hash.get('createThread') === 'true') {
|
||||
return <DiscussionsCreate {...this.props} />
|
||||
}
|
||||
return <DiscussionsList {...this.props} />
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import { Subscription, Unsubscribable } from 'rxjs'
|
||||
import { registerDiscussionsMentionCompletionContributions } from './mentionCompletion'
|
||||
|
||||
/**
|
||||
* Registers contributions for discussions-related functionality.
|
||||
*/
|
||||
export function registerDiscussionsContributions(
|
||||
args: Parameters<typeof registerDiscussionsMentionCompletionContributions>[0]
|
||||
): Unsubscribable {
|
||||
const subscriptions = new Subscription()
|
||||
subscriptions.add(registerDiscussionsMentionCompletionContributions(args))
|
||||
return subscriptions
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import { of, Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { isDefined } from '../../../../../shared/src/util/types'
|
||||
import { provideMentionCompletions } from './mentionCompletion'
|
||||
|
||||
describe('provideMentionCompletions', () => {
|
||||
const mockQueryUsernamesFunction = (query: string): Observable<string[]> =>
|
||||
of<string[]>(['alice', query.replace('@', '') || undefined].filter(isDefined))
|
||||
test('gets completion items at cursor with @', async () =>
|
||||
expect(
|
||||
await provideMentionCompletions('hello @ world', { line: 0, character: 7 }, mockQueryUsernamesFunction)
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
).toEqual({
|
||||
items: [{ label: 'alice', insertText: '@alice ' }],
|
||||
}))
|
||||
|
||||
test('gets completion items at cursor with @ and partial token', async () =>
|
||||
expect(
|
||||
await provideMentionCompletions('hello @ab world', { line: 0, character: 8 }, mockQueryUsernamesFunction)
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
).toEqual({
|
||||
items: [
|
||||
{ label: 'alice', insertText: '@alice ' },
|
||||
{ label: 'ab', insertText: '@ab ' },
|
||||
],
|
||||
}))
|
||||
|
||||
test('supports multiple lines', async () =>
|
||||
expect(
|
||||
await provideMentionCompletions('hello\n@ab', { line: 1, character: 3 }, mockQueryUsernamesFunction)
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
).toEqual({
|
||||
items: [
|
||||
{ label: 'alice', insertText: '@alice ' },
|
||||
{ label: 'ab', insertText: '@ab ' },
|
||||
],
|
||||
}))
|
||||
|
||||
test('empty when no @ trigger at cursor token', async () =>
|
||||
expect(
|
||||
await provideMentionCompletions('hello @a world', { line: 0, character: 3 }, mockQueryUsernamesFunction)
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
).toEqual(null))
|
||||
|
||||
test('empty for email address-like strings to reduce annoyance', async () =>
|
||||
expect(
|
||||
await provideMentionCompletions(
|
||||
'hello alice@ world',
|
||||
{ line: 0, character: 12 },
|
||||
mockQueryUsernamesFunction
|
||||
)
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
).toEqual(null))
|
||||
})
|
||||
@ -1,74 +0,0 @@
|
||||
import { Position } from '@sourcegraph/extension-api-types'
|
||||
import { Observable, of, Subscription, Unsubscribable } from 'rxjs'
|
||||
import { first, map, switchMap } from 'rxjs/operators'
|
||||
import { CompletionList } from 'sourcegraph'
|
||||
import { COMMENT_URI_SCHEME, positionToOffset } from '../../../../../shared/src/api/client/types/textDocument'
|
||||
import { ExtensionsControllerProps } from '../../../../../shared/src/extensions/controller'
|
||||
import { getWordAtText } from '../../../../../shared/src/util/wordHelpers'
|
||||
import { fetchAllUsers } from '../../../site-admin/backend'
|
||||
import { ModelService } from '../../../../../shared/src/api/client/services/modelService'
|
||||
|
||||
/**
|
||||
* Registers contributions for username mention completion in discussion comments.
|
||||
*/
|
||||
export function registerDiscussionsMentionCompletionContributions({
|
||||
extensionsController,
|
||||
}:
|
||||
| ExtensionsControllerProps
|
||||
| {
|
||||
extensionsController: {
|
||||
services: {
|
||||
completionItems: {
|
||||
registerProvider: ExtensionsControllerProps['extensionsController']['services']['completionItems']['registerProvider']
|
||||
}
|
||||
model: Pick<ModelService, 'observeModel'>
|
||||
}
|
||||
}
|
||||
}): Unsubscribable {
|
||||
const subscriptions = new Subscription()
|
||||
subscriptions.add(
|
||||
extensionsController.services.completionItems.registerProvider(
|
||||
{
|
||||
documentSelector: [{ scheme: COMMENT_URI_SCHEME }],
|
||||
},
|
||||
params =>
|
||||
extensionsController.services.model.observeModel(params.textDocument.uri).pipe(
|
||||
switchMap(({ text }) => (text ? provideMentionCompletions(text, params.position) : of(null))),
|
||||
first()
|
||||
)
|
||||
)
|
||||
)
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides username mention completions for the cursor position. This is usually not called
|
||||
* directly; it is registered in {@link registerDiscussionsMentionCompletionContributions} and
|
||||
* invoked via the completion item provider registry.
|
||||
*
|
||||
* @param queryUsernamesFunction For mocking in tests.
|
||||
*/
|
||||
export function provideMentionCompletions(
|
||||
text: string,
|
||||
position: Position,
|
||||
queryUsernamesFunction = queryUsernames
|
||||
): Observable<CompletionList | null> {
|
||||
// Check the text that the user is currently typing to see if they have typed "@" (and aren't
|
||||
// typing an email address, i.e., the word begins with "@").
|
||||
const word = getWordAtText(positionToOffset(text, position), text)
|
||||
if (word?.word.startsWith('@')) {
|
||||
return queryUsernamesFunction(word.word.slice(1)).pipe(
|
||||
map(usernames => ({ items: usernames.map(username => ({ label: username, insertText: `@${username} ` })) }))
|
||||
)
|
||||
}
|
||||
return of(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds usernames matching the query.
|
||||
*
|
||||
* @param query A partial username.
|
||||
*/
|
||||
function queryUsernames(query: string): Observable<string[]> {
|
||||
return fetchAllUsers({ first: 100, query }).pipe(map(({ nodes }) => nodes.map(({ username }) => username)))
|
||||
}
|
||||
@ -18,10 +18,8 @@ import * as GQL from '../../../../../shared/src/graphql/schema'
|
||||
import { PlatformContextProps } from '../../../../../shared/src/platform/context'
|
||||
import { SettingsCascadeProps } from '../../../../../shared/src/settings/settings'
|
||||
import { AbsoluteRepoFile, ModeSpec, parseHash, UIPositionSpec } from '../../../../../shared/src/util/url'
|
||||
import { isDiscussionsEnabled } from '../../../discussions'
|
||||
import { RepoHeaderContributionsLifecycleProps } from '../../RepoHeader'
|
||||
import { RepoRevSidebarCommits } from '../../RepoRevSidebarCommits'
|
||||
import { DiscussionsTree } from '../discussions/DiscussionsTree'
|
||||
import { ThemeProps } from '../../../../../shared/src/theme'
|
||||
interface Props
|
||||
extends AbsoluteRepoFile,
|
||||
@ -41,7 +39,7 @@ interface Props
|
||||
authenticatedUser: GQL.IUser | null
|
||||
}
|
||||
|
||||
export type BlobPanelTabID = 'info' | 'def' | 'references' | 'discussions' | 'impl' | 'typedef' | 'history'
|
||||
export type BlobPanelTabID = 'info' | 'def' | 'references' | 'impl' | 'typedef' | 'history'
|
||||
|
||||
/** The subject (what the contextual information refers to). */
|
||||
interface PanelSubject extends AbsoluteRepoFile, ModeSpec, Partial<UIPositionSpec> {
|
||||
@ -180,36 +178,6 @@ export class BlobPanel extends React.PureComponent<Props> {
|
||||
}))
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
// Code discussions view.
|
||||
registrationOptions: { id: 'discussions', container: ContributableViewContainer.Panel },
|
||||
provider: subjectChanges.pipe(
|
||||
map((subject: PanelSubject) =>
|
||||
isDiscussionsEnabled(this.props.settingsCascade)
|
||||
? {
|
||||
title: 'Discussions',
|
||||
content: '',
|
||||
priority: 140,
|
||||
locationProvider: null,
|
||||
reactElement: (
|
||||
<DiscussionsTree
|
||||
repoID={this.props.repoID}
|
||||
repoName={subject.repoName}
|
||||
commitID={subject.commitID}
|
||||
rev={subject.rev}
|
||||
filePath={subject.filePath}
|
||||
history={this.props.history}
|
||||
location={this.props.location}
|
||||
compact={true}
|
||||
extensionsController={this.props.extensionsController}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null
|
||||
)
|
||||
),
|
||||
},
|
||||
].filter(
|
||||
(v): v is Entry<PanelViewProviderRegistrationOptions, Observable<PanelViewWithComponent | null>> =>
|
||||
!!v
|
||||
|
||||
@ -111,11 +111,6 @@ export const routes: readonly LayoutRouteProps<any>[] = [
|
||||
render: lazyComponent(() => import('./explore/ExploreArea'), 'ExploreArea'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/discussions',
|
||||
render: lazyComponent(() => import('./discussions/DiscussionsPage'), 'DiscussionsPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/search/scope/:id',
|
||||
render: lazyComponent(() => import('./search/ScopePage'), 'ScopePage'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user