add 'changelists' page

This commit is contained in:
Jason Harris 2024-08-05 14:38:51 -05:00
parent f3257cb357
commit 51685f116e
9 changed files with 586 additions and 72 deletions

View File

@ -0,0 +1,25 @@
fragment Changelist on GitCommit {
id
subject
body
author {
date
person {
name
email
...Avatar_Person
}
}
committer {
date
person {
name
email
...Avatar_Person
}
}
perforceChangelist {
canonicalURL
cid
}
}

View File

@ -0,0 +1,156 @@
<svelte:options immutable />
<script lang="ts">
import Avatar from '$lib/Avatar.svelte'
import Icon from '$lib/Icon.svelte'
import Timestamp from '$lib/Timestamp.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import type { Changelist } from './Changelist.gql'
import { isViewportMobile } from './stores'
import Button from './wildcard/Button.svelte'
export let changelist: Changelist
export let alwaysExpanded: boolean = false
$: expanded = alwaysExpanded
$: author = changelist.author
$: commitDate = new Date(author.date)
$: authorAvatarTooltip = author.person.name + (author ? ' (author)' : '')
</script>
<div class="root">
<div class="avatar">
<Tooltip tooltip={authorAvatarTooltip}>
<Avatar avatar={author.person} />
</Tooltip>
</div>
<div class="title">
<a class="subject" href={changelist.perforceChangelist?.canonicalURL}>{changelist.subject}</a>
{#if !alwaysExpanded && changelist.body && !$isViewportMobile}
<Button
variant="secondary"
size="sm"
on:click={() => (expanded = !expanded)}
aria-label="{expanded ? 'Hide' : 'Show'} changelist message"
>
<Icon icon={ILucideEllipsis} inline aria-hidden />
</Button>
{/if}
</div>
<div class="author">
submitted by <strong>{author.person.name}</strong>
<Timestamp date={commitDate} />
</div>
{#if changelist.body}
<div class="message" class:expanded>
{#if $isViewportMobile}
{#if expanded}
<Button variant="secondary" size="lg" display="block" on:click={() => (expanded = false)}>
Close
</Button>
{:else}
<Button variant="secondary" size="sm" display="block" on:click={() => (expanded = true)}>
Show changelist message
</Button>
{/if}
{/if}
<pre>{changelist.body}</pre>
</div>
{/if}
</div>
<style lang="scss">
.root {
display: grid;
overflow: hidden;
grid-template-columns: auto 1fr;
grid-template-areas: 'avatar title' 'avatar author' '. message';
column-gap: 1rem;
@media (--mobile) {
grid-template-columns: auto 1fr;
grid-template-areas: 'avatar title' 'author author' 'message message';
row-gap: 0.5rem;
}
}
.avatar {
grid-area: avatar;
display: flex;
gap: 0.25rem;
align-self: center;
}
.title {
grid-area: title;
align-self: center;
display: flex;
gap: 0.5rem;
align-items: center;
overflow: hidden;
.subject {
font-weight: 600;
flex: 0 1 auto;
color: var(--body-color);
min-width: 0;
@media (--sm-breakpoint-up) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.author {
grid-area: author;
color: var(--text-muted);
}
.message {
grid-area: message;
overflow: hidden;
@media (--mobile) {
&.expanded {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0;
display: flex;
flex-direction: column;
background-color: var(--color-bg-1);
}
}
}
pre {
display: none;
margin-top: 0.5rem;
margin-bottom: 1.5rem;
font-size: 0.75rem;
max-width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
.expanded & {
display: block;
}
@media (--mobile) {
padding: 0.5rem;
overflow: auto;
margin: 0;
}
}
</style>

View File

@ -17,7 +17,7 @@
export let name: string
export let seeAllItemsURL: string
export let getData: (query: string) => PromiseLike<Result<T>>
export let getData: ((query: string) => PromiseLike<Result<T>>) | (() => PromiseLike<Result<T>>)
export let onSelect: (item: T) => void
export let toOption: (item: T) => ComboboxOptionProps<string>

View File

@ -13,6 +13,9 @@
export type RepositoryCommits = { nodes: RevPickerGitCommit[] }
export type RepositoryGitCommit = RevPickerGitCommit
export type DepotChangelists = { nodes: RevPickerChangelist[] }
export type DepotChangelist = RevPickerChangelist
const branchesHotkey: Keys = {
key: 'shift+b',
}
@ -28,6 +31,7 @@
<script lang="ts">
import type { Placement } from '@floating-ui/dom'
import type { ComponentProps } from 'svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'
import { goto } from '$app/navigation'
@ -42,9 +46,10 @@
import ButtonGroup from '$lib/wildcard/ButtonGroup.svelte'
import CopyButton from '$lib/wildcard/CopyButton.svelte'
import { RevPickerChangelist } from '../../routes/[...repo=reporev]/layout.gql'
import Picker from './Picker.svelte'
import RepositoryRevPickerItem from './RepositoryRevPickerItem.svelte'
import type { ComponentProps } from 'svelte'
type $$Props = HTMLButtonAttributes & {
repoURL: string
@ -57,6 +62,7 @@
getRepositoryTags: (query: string) => PromiseLike<RepositoryTags>
getRepositoryCommits: (query: string) => PromiseLike<RepositoryCommits>
getRepositoryBranches: (query: string) => PromiseLike<RepositoryBranches>
getDepotChangelists: (query: string) => PromiseLike<DepotChangelists>
}
export let repoURL: $$Props['repoURL']
@ -75,6 +81,7 @@
export let getRepositoryTags: (query: string) => PromiseLike<RepositoryTags>
export let getRepositoryCommits: (query: string) => PromiseLike<RepositoryCommits>
export let getRepositoryBranches: (query: string) => PromiseLike<RepositoryBranches>
export let getDepotChangelists: (query: string) => PromiseLike<DepotChangelists>
function defaultHandleSelect(revision: string) {
goto(replaceRevisionInURL(location.pathname + location.search + location.hash, revision))
@ -86,6 +93,7 @@
$: isOnSpecificRev = revisionLabel !== defaultBranch
const buttonClass = getButtonClassName({ variant: 'secondary', outline: false, size: 'sm' })
$: isPerforceDepot = getDepotChangelists !== null
</script>
<Popover let:registerTrigger let:registerTarget let:toggle {placement}>
@ -121,73 +129,97 @@
<div slot="content" class="content" let:toggle>
<Tabs>
<TabPanel title="Branches" shortcut={branchesHotkey}>
<Picker
name="branches"
seeAllItemsURL={`${repoURL}/-/branches`}
getData={getRepositoryBranches}
toOption={branch => ({ value: branch.id, label: branch.displayName })}
onSelect={branch => {
toggle(false)
onSelect(branch.abbrevName)
}}
let:value
>
<RepositoryRevPickerItem
icon={ILucideGitBranch}
label={value.displayName}
author={value.target.commit?.author}
{#if !isPerforceDepot}
<TabPanel title="Branches" shortcut={branchesHotkey}>
<Picker
name="branches"
seeAllItemsURL={`${repoURL}/-/branches`}
getData={getRepositoryBranches}
toOption={branch => ({ value: branch.id, label: branch.displayName })}
onSelect={branch => {
toggle(false)
onSelect(branch.abbrevName)
}}
let:value
>
<svelte:fragment slot="title">
<Icon icon={ILucideGitBranch} inline aria-hidden="true" />
<Badge variant="link">{value.displayName}</Badge>
{#if value.displayName === defaultBranch}
<Badge variant="secondary" small>DEFAULT</Badge>
{/if}
</svelte:fragment>
</RepositoryRevPickerItem>
</Picker>
</TabPanel>
<TabPanel title="Tags" shortcut={tagsHotkey}>
<Picker
name="tags"
seeAllItemsURL={`${repoURL}/-/tags`}
getData={getRepositoryTags}
toOption={tag => ({ value: tag.id, label: tag.displayName })}
onSelect={tag => {
toggle(false)
onSelect(tag.abbrevName)
}}
let:value
>
<RepositoryRevPickerItem
icon={ILucideTag}
label={value.displayName}
author={value.target.commit?.author}
/>
</Picker>
</TabPanel>
<TabPanel title="Commits" shortcut={commitsHotkey}>
<Picker
name="commits"
seeAllItemsURL={`${repoURL}/-/commits`}
getData={getRepositoryCommits}
toOption={commit => ({ value: commit.id, label: commit.oid })}
onSelect={commit => {
toggle(false)
onSelect(commit.oid)
}}
let:value
>
<RepositoryRevPickerItem label="" author={value.author}>
<svelte:fragment slot="title">
<Icon icon={ILucideGitCommitVertical} inline aria-hidden="true" />
<Badge variant="link">{value.abbreviatedOID}</Badge>
<span class="commit-subject">{value.subject}</span>
</svelte:fragment>
</RepositoryRevPickerItem>
</Picker>
</TabPanel>
<RepositoryRevPickerItem
icon={ILucideGitBranch}
label={value.displayName}
author={value.target.commit?.author}
>
<svelte:fragment slot="title">
<Icon icon={ILucideGitBranch} inline aria-hidden="true" />
<Badge variant="link">{value.displayName}</Badge>
{#if value.displayName === defaultBranch}
<Badge variant="secondary" small>DEFAULT</Badge>
{/if}
</svelte:fragment>
</RepositoryRevPickerItem>
</Picker>
</TabPanel>
<TabPanel title="Tags" shortcut={tagsHotkey}>
<Picker
name="tags"
seeAllItemsURL={`${repoURL}/-/tags`}
getData={getRepositoryTags}
toOption={tag => ({ value: tag.id, label: tag.displayName })}
onSelect={tag => {
toggle(false)
onSelect(tag.abbrevName)
}}
let:value
>
<RepositoryRevPickerItem
icon={ILucideTag}
label={value.displayName}
author={value.target.commit?.author}
/>
</Picker>
</TabPanel>
<TabPanel title="Commits" shortcut={commitsHotkey}>
<Picker
name="commits"
seeAllItemsURL={`${repoURL}/-/commits`}
getData={getRepositoryCommits}
toOption={commit => ({ value: commit.id, label: commit.oid })}
onSelect={commit => {
toggle(false)
onSelect(commit.oid)
}}
let:value
>
<RepositoryRevPickerItem label="" author={value.author}>
<svelte:fragment slot="title">
<Icon icon={ILucideGitCommitVertical} inline aria-hidden="true" />
<Badge variant="link">{value.abbreviatedOID}</Badge>
<span class="commit-subject">{value.subject}</span>
</svelte:fragment>
</RepositoryRevPickerItem>
</Picker>
</TabPanel>
{:else}
<TabPanel title="Changelists" shortcut={commitsHotkey}>
<Picker
name="changelists"
seeAllItemsURL={`${repoURL}/-/changelists`}
getData={getDepotChangelists}
toOption={changelist => ({ value: changelist.id, label: changelist.perforceChangelist?.cid })}
onSelect={changelist => {
toggle(false)
onSelect(changelist.perforceChangelist?.cid ?? '')
}}
let:value
>
<RepositoryRevPickerItem label="" author={value.author}>
<svelte:fragment slot="title">
<Icon icon={ILucideGitCommitVertical} inline aria-hidden="true" />
<Badge variant="link">{value.perforceChangelist?.cid}</Badge>
<span class="changelist-subject">{value.subject}</span>
</svelte:fragment>
</RepositoryRevPickerItem>
</Picker>
</TabPanel>
{/if}
</Tabs>
</div>
</Popover>
@ -258,7 +290,8 @@
}
}
.commit-subject {
.commit-subject,
.changelist-subject {
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,215 @@
<script lang="ts">
// @sg EnableRollout
import { get } from 'svelte/store'
import { navigating } from '$app/stores'
import Changelist from '$lib/Changelist.svelte'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import RepositoryRevPicker from '$lib/repo/RepositoryRevPicker.svelte'
import Scroller, { type Capture as ScrollerCapture } from '$lib/Scroller.svelte'
import { Alert, Badge } from '$lib/wildcard'
import type { PageData, Snapshot } from './$types'
export let data: PageData
// This tracks the number of changelists that have been loaded and the current scroll
// position, so both can be restored when the user refreshes the page or navigates
// back to it.
export const snapshot: Snapshot<{
changelists: ReturnType<typeof data.changelistsQuery.capture>
scroller: ScrollerCapture
}> = {
capture() {
return {
changelists: changelistsQuery.capture(),
scroller: scroller.capture(),
}
},
async restore(snapshot) {
if (get(navigating)?.type === 'popstate') {
await changelistsQuery?.restore(snapshot.changelists)
}
scroller.restore(snapshot.scroller)
},
}
function fetchMore() {
changelistsQuery?.fetchMore()
}
let scroller: Scroller
$: changelistsQuery = data.changelistsQuery
$: changelists = $changelistsQuery.data
$: pageTitle = (() => {
const parts = ['Commits']
if (data.path) {
parts.push(data.path)
}
parts.push(data.displayRepoName, 'Sourcegraph')
return parts.join(' - ')
})()
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<header>
<h2>
Changelists
{#if data.path}
in <code>{data.path}</code>
{/if}
</h2>
<div>
<RepositoryRevPicker
repoURL={data.repoURL}
revision={data.revision}
commitID={data.resolvedRevision.commitID}
defaultBranch={data.defaultBranch}
placement="bottom-start"
getRepositoryBranches={data.getRepoBranches}
getRepositoryCommits={data.getRepoCommits}
getRepositoryTags={data.getRepoTags}
getDepotChangelists={data.getDepotChangelists}
/>
</div>
</header>
<section>
<Scroller bind:this={scroller} margin={600} on:more={fetchMore}>
{#if changelists}
<ul class="changelists">
{#each changelists as changelist (changelist.perforceChangelist?.canonicalURL)}
<li>
<div class="changelist">
<Changelist {changelist} />
</div>
<ul class="actions">
<li>
Changelist ID:
<Badge variant="link">
<a href={changelist.perforceChangelist?.canonicalURL} title="View changelist"
>{changelist.perforceChangelist?.cid}</a
>
</Badge>
</li>
<li>
<a href="/{data.repoName}@changelist/{changelist.perforceChangelist?.cid}"
>Browse files</a
></li
>
</ul>
</li>
{:else}
<li>
<Alert variant="info">No changelists found</Alert>
</li>
{/each}
</ul>
{/if}
{#if $changelistsQuery.fetching}
<div class="footer">
<LoadingSpinner />
</div>
{:else if !$changelistsQuery.fetching && $changelistsQuery.error}
<div class="footer">
<Alert variant="danger">
Unable to fetch changelists: {$changelistsQuery.error.message}
</Alert>
</div>
{/if}
</Scroller>
</section>
<style lang="scss">
section {
overflow: hidden;
}
ul {
margin: 0;
padding: 0;
}
h2 {
display: flex;
align-items: center;
gap: 0.5rem;
}
header {
border-bottom: 1px solid var(--border-color);
div {
width: min-content;
}
}
header,
ul.changelists,
.footer {
max-width: var(--viewport-xl);
width: 100%;
margin: 0 auto;
padding: 1rem;
@media (--mobile) {
padding: 0.5rem;
}
}
ul {
list-style: none;
}
ul.changelists {
--avatar-size: 2.5rem;
padding-top: 0;
> li {
border-bottom: 1px solid var(--border-color);
display: flex;
padding: 0.5rem 0;
gap: 1rem;
@media (--mobile) {
display: block;
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
li:not(:last-child)::after {
content: '•';
padding-left: 0.5rem;
color: var(--text-muted);
}
}
}
.changelist {
flex: 1;
min-width: 0;
}
.actions {
--icon-color: currentColor;
text-align: right;
flex-shrink: 0;
}
&:last-child {
border: none;
}
}
}
.footer {
&:not(:first-child) {
border-top: 1px solid var(--border-color);
}
}
</style>

View File

@ -0,0 +1,50 @@
import { IncrementalRestoreStrategy, getGraphQLClient, infinityQuery } from "$lib/graphql"
import { resolveRevision } from "$lib/repo/utils"
import { parseRepoRevision } from "@sourcegraph/shared/src/util/url"
import type { PageLoad } from "./$types"
import { ChangelistsPage_ChangelistsQuery } from "./page.gql"
const PAGE_SIZE = 20
export const load: PageLoad = ({ parent, params }) => {
const client = getGraphQLClient()
const { repoName, revision = '' } = parseRepoRevision(params.repo)
const path = params.path ? decodeURIComponent(params.path) : ''
const resolvedRevision = resolveRevision(parent, revision)
const changelistsQuery = infinityQuery({
client,
query: ChangelistsPage_ChangelistsQuery,
variables: resolvedRevision.then(revision => ({
depotName: repoName,
revision,
first: PAGE_SIZE,
path,
afterCursor: null as string | null,
})),
map: result => {
const ancestors = result.data?.repository?.commit?.ancestors
return {
nextVariables:
ancestors?.pageInfo?.endCursor && ancestors?.pageInfo?.hasNextPage
? { afterCursor: ancestors.pageInfo.endCursor }
: undefined,
data: ancestors?.nodes,
error: result.error
}
},
merge: (previous, next) => (previous ?? []).concat(next ?? []),
createRestoreStrategy: api =>
new IncrementalRestoreStrategy(
api,
n => n.length,
n => ({ first: n.length })
),
})
return {
changelistsQuery,
path
}
}

View File

@ -0,0 +1,35 @@
fragment ChangelistsPage_GitCommitConnection on GitCommitConnection {
nodes {
canonicalURL
oid
abbreviatedOID
externalURLs {
serviceKind
url
}
...Changelist
}
pageInfo {
hasNextPage
endCursor
}
}
query ChangelistsPage_ChangelistsQuery(
$depotName: String!
$revision: String!
$first: Int
$path: String
$afterCursor: String
) {
repository(name: $depotName) {
id
commit(rev: $revision) {
id
ancestors(first: $first, afterCursor: $afterCursor, path: $path) {
...ChangelistsPage_GitCommitConnection
}
}
}
}

View File

@ -5,13 +5,12 @@
import { navigating } from '$app/stores'
import Commit from '$lib/Commit.svelte'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import RepositoryRevPicker from '$lib/repo/RepositoryRevPicker.svelte'
import { getHumanNameForCodeHost } from '$lib/repo/shared/codehost'
import Scroller, { type Capture as ScrollerCapture } from '$lib/Scroller.svelte'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import { Alert, Badge } from '$lib/wildcard'
import RepositoryRevPicker from '$lib/repo/RepositoryRevPicker.svelte'
import type { PageData, Snapshot } from './$types'
export let data: PageData

View File

@ -115,10 +115,11 @@ export const load: LayoutLoad = async ({ params, url, depends }) => {
),
// Depot pickers queries (changelists, @TODO: labels)
getDepotChangelists: () =>
getDepotChangelists: (searchTerm: string) =>
client
.query(DepotChangelists, {
depotName: repoName,
query: searchTerm,
revision: resolvedRepository.commit?.oid || ''
})
.then(