Svelte: Implement and instantiate FilePopover (#62498)

Implements the file popover on the SvelteKit web app.

Co-authored-by: Camden Cheek <camden@ccheek.com>
This commit is contained in:
Jason Hawk Harris 2024-05-21 14:26:00 -05:00 committed by GitHub
parent 3f8eaca56f
commit 939e000cdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 377 additions and 17 deletions

View File

@ -0,0 +1,63 @@
query FileOrDirPopoverQuery($repoName: String!, $revision: String!, $filePath: String!) {
repository(name: $repoName) {
commit(rev: $revision) {
path(path: $filePath) {
...on GitBlob {
...FilePopoverFragment
}
...on GitTree {
...DirPopoverFragment
}
}
}
}
}
fragment FilePopoverFragment on GitBlob {
__typename
path
languages
byteSize
totalLines
history(first: 1) {
nodes {
commit {
...FilePopoverLastCommitFragment
}
}
}
...FileIcon_GitBlob
}
fragment DirPopoverFragment on GitTree {
__typename
path
# TODO(camdencheek): fix this to use a count, which currently does not exist in our API.
# This currently does not scale well with very large directories.
files {
name
}
directories {
name
}
history(first: 1) {
nodes {
commit {
...FilePopoverLastCommitFragment
}
}
}
}
fragment FilePopoverLastCommitFragment on GitCommit {
abbreviatedOID
oid
subject
canonicalURL
author {
date
person {
...Avatar_Person
}
}
}

View File

@ -0,0 +1,58 @@
<script lang="ts" context="module">
import { Story } from '@storybook/addon-svelte-csf'
import type { FilePopoverFragment, DirPopoverFragment, FilePopoverLastCommitFragment } from './FilePopover.gql'
import FilePopover from './FilePopover.svelte'
export const meta = {
component: FilePopover,
}
const lastCommit: FilePopoverLastCommitFragment = {
abbreviatedOID: '1234567',
oid: '1234567890123456789012345678901234567890',
subject: 'Test subject',
canonicalURL: 'https://sourcegraph.com/about',
author: {
date: '2021-01-01T00:00:00Z',
person: {
__typename: 'Person',
name: 'camdencheek',
avatarURL: 'https://github.com/camdencheek.png',
displayName: 'Camden Cheek',
},
},
}
const fileEntry: FilePopoverFragment = {
__typename: 'GitBlob',
name: 'results.go',
path: 'internal/search/results/results.go',
languages: ['Go'],
byteSize: 325,
totalLines: 12,
history: {
nodes: [
{
commit: lastCommit,
},
],
},
}
const dirEntry: DirPopoverFragment = {
__typename: 'GitTree',
path: 'internal/search/results',
files: [{ name: 'results.go' }, { name: 'results_test.go' }],
directories: [{ name: 'testdata' }],
history: {
nodes: [{ commit: lastCommit }],
},
}
</script>
<Story name="Default">
<FilePopover repoName={'github.com/sourcegraph/sourcegraph'} entry={fileEntry} />
<FilePopover repoName={'github.com/sourcegraph/sourcegraph'} entry={dirEntry} />
</Story>

View File

@ -0,0 +1,188 @@
<script lang="ts" context="module">
import { type DirPopoverFragment, type FilePopoverFragment, FileOrDirPopoverQuery } from './FilePopover.gql'
export interface FilePopoverInputs {
repoName: string
revision: string
filePath: string
}
export async function fetchPopoverData(args: FilePopoverInputs): Promise<DirPopoverFragment | FilePopoverFragment> {
const client = getGraphQLClient()
const result = await client.query(FileOrDirPopoverQuery, args)
if (result.error) {
throw new Error('could not fetch file or dir popover', result.error)
}
const fragment = result.data?.repository?.commit?.path
if (!fragment) {
throw new Error('entry does not exist')
}
return fragment
}
</script>
<script lang="ts">
import { mdiFolder } from '@mdi/js'
import Avatar from '$lib/Avatar.svelte'
import { pluralize } from '$lib/common'
import { getGraphQLClient } from '$lib/graphql'
import Icon from '$lib/Icon.svelte'
import { displayRepoName } from '$lib/shared'
import Timestamp from '$lib/Timestamp.svelte'
import { formatBytes } from '$lib/utils'
import Badge from '$lib/wildcard/Badge.svelte'
import FileIcon from '../FileIcon.svelte'
import NodeLine from './NodeLine.svelte'
export let repoName: string
export let entry: FilePopoverFragment | DirPopoverFragment
function splitPath(filePath: string): [string, string] {
let parts = filePath.split('/')
return [parts.slice(0, parts.length - 1).join('/'), parts[parts.length - 1]]
}
$: [dirName, baseName] = splitPath(entry.path)
$: lastCommit = entry.history.nodes[0].commit
</script>
<div class="root section muted">
<div class="repo-and-path section mono">
<small>
{displayRepoName(repoName).replaceAll('/', ' / ')}
·
{dirName ? `${dirName.replaceAll('/', ' / ')}` : '/'}
</small>
</div>
<div class="lang-and-file section">
{#if entry.__typename === 'GitBlob'}
<FileIcon file={entry} inline={false} --icon-size="1.5rem" />
<div class="file mono">
<div>{baseName}</div>
<small>
{entry.languages[0] ? `${entry.languages[0]} ·` : ''}
{entry.totalLines}
{pluralize('Line', entry.totalLines)} ·
{formatBytes(entry.byteSize)}
</small>
</div>
{:else if entry.__typename === 'GitTree'}
<Icon svgPath={mdiFolder} --icon-fill-color="var(--primary)" --icon-size="1.5rem" />
<div class="file mono">
<div class="title">{baseName}</div>
<small>
Subdirectories {entry.directories.length}
· Files {entry.files.length}
</small>
</div>
{/if}
</div>
<div class="last-changed section">Last Changed @</div>
<div class="commit">
<div class="node-line"><NodeLine /></div>
<div class="commit-info">
<Badge variant="link">
<a href={lastCommit.canonicalURL} target="_blank">
{lastCommit.abbreviatedOID}
</a>
</Badge>
<div class="body">{lastCommit.subject}</div>
<div class="author">
<Avatar avatar={lastCommit.author.person} --avatar-size="1.0rem" />
{lastCommit.author.person.displayName}
·
<Timestamp date={lastCommit.author.date} />
</div>
</div>
</div>
</div>
<style lang="scss">
.root {
width: 400px;
background: var(--body-bg);
.section {
padding: 0.5rem 1rem;
}
.repo-and-path {
border-bottom: 1px solid var(--border-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lang-and-file {
display: flex;
align-items: center;
gap: 1rem;
.file {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
div {
color: var(--text-body);
}
}
}
.last-changed {
background-color: var(--secondary-4);
border-bottom: 1px solid var(--border-color);
}
.commit {
display: flex;
align-items: stretch;
justify-content: flex-start;
.node-line {
flex: 0 0 40px;
}
.commit-info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.5rem 0.5rem 0.5rem 0;
.author {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.25rem;
font-size: var(--font-size-tiny);
}
}
}
}
.mono {
font-family: var(--monospace-font-family);
}
.title {
color: var(--text-title);
}
.muted {
color: var(--text-muted);
}
.body {
color: var(--text-body);
}
</style>

View File

@ -0,0 +1,29 @@
<div class="line">
<div class="connector" />
<div class="circle" />
<div class="connector" />
</div>
<style lang="scss">
$circle-size: 8px;
.line {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
.connector {
border-left: 1px solid var(--border-color);
flex-grow: 1;
}
.circle {
height: $circle-size;
width: $circle-size;
border-radius: 50%;
border: 2px solid var(--border-color);
flex-grow: 0;
}
}
</style>

View File

@ -96,9 +96,9 @@
}
$: if (!!lastCommitQuery) {
// Reset commit history when the query observable changes. Without
// this we are showing the commit history of the previously selected
// file/folder until the new commit history is loaded.
// Reset last commit when the query observable changes. Without
// this we are showing the last commit of the previously selected
// file/folder until the last commit is loaded.
lastCommit = null
}

View File

@ -5,12 +5,14 @@
import { goto } from '$app/navigation'
import Icon from '$lib/Icon.svelte'
import Popover from '$lib/Popover.svelte'
import { type FileTreeProvider, NODE_LIMIT, type TreeEntry } from '$lib/repo/api/tree'
import FileIcon from '$lib/repo/FileIcon.svelte'
import FilePopover, { fetchPopoverData } from '$lib/repo/filePopover/FilePopover.svelte'
import { getSidebarFileTreeStateForRepo } from '$lib/repo/stores'
import { replaceRevisionInURL } from '$lib/shared'
import TreeView, { setTreeContext } from '$lib/TreeView.svelte'
import { createForwardStore } from '$lib/utils'
import { createForwardStore, delay } from '$lib/utils'
import { Alert } from '$lib/wildcard'
export let repoName: string
@ -122,19 +124,27 @@
We handle navigation via the TreeView's select event, to preserve the focus state.
Using a link here allows us to benefit from data preloading.
-->
<a
tabindex={-1}
href={replaceRevisionInURL(entry.canonicalURL, revision)}
on:click|preventDefault={() => {}}
data-go-up={isRoot ? true : undefined}
>
{#if entry.isDirectory}
<Icon svgPath={getDirectoryIconPath(entry, expanded)} inline />
{:else}
<FileIcon inline file={entry.__typename === 'GitBlob' ? entry : null} />
{/if}
{isRoot ? '..' : entry.name}
</a>
<Popover let:registerTrigger placement="right-end" showOnHover>
<a
href={replaceRevisionInURL(entry.canonicalURL, revision)}
on:click|preventDefault={() => {}}
tabindex={-1}
data-go-up={isRoot ? true : undefined}
use:registerTrigger
>
{#if entry.isDirectory}
<Icon svgPath={getDirectoryIconPath(entry, expanded)} inline />
{:else}
<FileIcon inline file={entry.__typename === 'GitBlob' ? entry : null} />
{/if}
{isRoot ? '..' : entry.name}
</a>
<svelte:fragment slot="content">
{#await delay(fetchPopoverData({ repoName, revision, filePath: entry.path }), 300) then entry}
<FilePopover {repoName} {entry} />
{/await}
</svelte:fragment>
</Popover>
{/if}
</svelte:fragment>
<Alert slot="error" let:error variant="danger">

View File

@ -239,3 +239,15 @@ test('history panel', async ({ page, sg }) => {
await page.getByRole('tab', { name: 'History' }).click()
await expect(page.getByText('Test commit')).toBeHidden()
})
test('file popover', async ({ page }) => {
await page.goto(`/${repoName}`)
await page.locator('#sidebar-panel').getByRole('button').click()
await page.getByRole('link', { name: 'index.js' }).hover()
await expect(page.getByText('Last Changed')).toBeVisible()
await page.getByRole('link', { name: 'Sourcegraph', exact: true }).hover()
await expect(page.getByText('Last Changed')).toBeHidden()
})