Svelte: add repo header dropdown menu (#63257)

Adds a dropdown menu when clicking the repo name for common repo-level actions.
This commit is contained in:
Camden Cheek 2024-06-14 12:11:20 -06:00 committed by GitHub
parent fadcaa264f
commit 7228b4958d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 235 additions and 52 deletions

View File

@ -29,6 +29,7 @@ declare global {
const ILucideCircleHelp: typeof import('~icons/lucide/circle-help')['default']
const ILucideCircleX: typeof import('~icons/lucide/circle-x')['default']
const ILucideCode: typeof import('~icons/lucide/code')['default']
const ILucideCodesandbox: typeof import('~icons/lucide/codesandbox')['default']
const ILucideCopy: typeof import('~icons/lucide/copy')['default']
const ILucideCornerRightDown: typeof import('~icons/lucide/corner-right-down')['default']
const ILucideCornerRightUp: typeof import('~icons/lucide/corner-right-up')['default']
@ -55,6 +56,7 @@ declare global {
const ILucideFullscreen: typeof import('~icons/lucide/fullscreen')['default']
const ILucideGitBranch: typeof import('~icons/lucide/git-branch')['default']
const ILucideGitCommitVertical: typeof import('~icons/lucide/git-commit-vertical')['default']
const ILucideGitCompare: typeof import('~icons/lucide/git-compare')['default']
const ILucideGitCompareArrows: typeof import('~icons/lucide/git-compare-arrows')['default']
const ILucideGitFork: typeof import('~icons/lucide/git-fork')['default']
const ILucideGitMerge: typeof import('~icons/lucide/git-merge')['default']
@ -70,6 +72,7 @@ declare global {
const ILucidePanelLeftOpen: typeof import('~icons/lucide/panel-left-open')['default']
const ILucidePencil: typeof import('~icons/lucide/pencil')['default']
const ILucideRegex: typeof import('~icons/lucide/regex')['default']
const ILucideRepeat: typeof import('~icons/lucide/repeat')['default']
const ILucideSearch: typeof import('~icons/lucide/search')['default']
const ILucideSearchX: typeof import('~icons/lucide/search-x')['default']
const ILucideSettings: typeof import('~icons/lucide/settings')['default']

View File

@ -1,8 +1,8 @@
:root {
--dropdown-inner-border-radius: 0.1875rem;
--dropdown-padding-y: 0.5rem;
--dropdown-item-padding-y: 0.25rem;
--dropdown-item-padding-x: 0.5rem;
--dropdown-padding-y: 0.375rem;
--dropdown-item-padding-y: 0.375rem;
--dropdown-item-padding-x: 0.75rem;
--dropdown-item-padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
--dropdown-min-width: 10rem;
--dropdown-spacer: 0.125rem;
@ -25,7 +25,8 @@
--dropdown-link-active-bg: var(--primary);
--dropdown-link-active-color: var(--white);
--dropdown-link-disabled-color: var(--text-muted);
--dropdown-shadow: 0 4px 16px -6px rgba(36, 41, 54, 0.2);
--dropdown-shadow: 0 193px 54px 0 rgba(0, 0, 0, 0), 0 123px 49px 0 rgba(0, 0, 0, 0.01),
0 69px 42px 0 rgba(0, 0, 0, 0.04), 0 31px 31px 0 rgba(0, 0, 0, 0.07), 0 8px 17px 0 rgba(0, 0, 0, 0.08);
}
.theme-dark {
@ -39,5 +40,6 @@
--dropdown-link-active-bg: var(--primary);
--dropdown-link-active-color: var(--white);
--dropdown-link-disabled-color: var(--text-muted);
--dropdown-shadow: 0 4px 16px -6px rgba(11, 12, 15, 0.8);
--dropdown-shadow: 0 309px 87px 0 rgba(0, 0, 0, 0.01), 0 198px 79px 0 rgba(0, 0, 0, 0.07),
0 111px 67px 0 rgba(0, 0, 0, 0.24), 0 49px 49px 0 rgba(0, 0, 0, 0.41), 0 12px 27px 0 rgba(0, 0, 0, 0.47);
}

View File

@ -1,5 +1,6 @@
<script lang="ts" context="module">
import { createContextAccessors } from '$lib/utils/context'
type DropdownMenu = ReturnType<typeof createDropdownMenu>
interface DropdownMenuContext {
@ -48,14 +49,14 @@
div :global([role='menu']) {
isolation: isolate;
min-width: 12rem;
font-size: 0.875rem;
font-size: var(--font-size-small);
background-clip: padding-box;
background-color: var(--dropdown-bg);
border: 1px solid var(--dropdown-border-color);
border-radius: var(--popover-border-radius);
color: var(--body-color);
box-shadow: var(--dropdown-shadow);
padding: 0.25rem 0;
padding: var(--dropdown-padding-y) 0;
:global([role^='menuitem']) {
all: unset;

View File

@ -9,7 +9,7 @@
<style lang="scss">
div {
height: 0;
margin: 0.25rem 0;
margin: var(--dropdown-item-padding-y) 0;
overflow: hidden;
border-top: 1px solid var(--dropdown--separator-color, var(--border-color));
}

View File

@ -266,6 +266,27 @@ test.describe('file header', () => {
})
})
test.describe('repo menu', () => {
test('click go to root', async ({ page }) => {
const url = `/${repoName}/-/blob/src/large-file-1.js`
await page.goto(url)
await page.getByRole('heading', { name: 'sourcegraph/sourcegraph' }).click()
await page.getByRole('menuitem', { name: 'Go to repository root' }).click()
await page.waitForURL(`/${repoName}`)
})
test('keyboard shortcut go to root', async ({ page }) => {
const url = `/${repoName}/-/blob/src/large-file-1.js`
await page.goto(url)
// Focus _something_ on the page. Use both mac and linux shortcuts so this works
// both locally and in CI.
await page.getByRole('link', { name: 'Sourcegraph' }).press('Meta+Backspace')
await page.getByRole('link', { name: 'Sourcegraph' }).press('Control+Backspace')
await page.waitForURL(`/${repoName}`)
})
})
test.describe('scroll behavior', () => {
const url = `/${repoName}/-/blob/src/large-file-1.js`

View File

@ -2,11 +2,14 @@
import type { ComponentProps } from 'svelte'
import { writable } from 'svelte/store'
import { getButtonClassName } from '@sourcegraph/wildcard'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sizeToFit } from '$lib/dom'
import { registerHotkey } from '$lib/Hotkey'
import Icon from '$lib/Icon.svelte'
import GlobalHeaderPortal from '$lib/navigation/GlobalHeaderPortal.svelte'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import { createScopeSuggestions } from '$lib/search/codemirror/suggestions'
import SearchInput from '$lib/search/input/SearchInput.svelte'
import { queryStateStore } from '$lib/search/state'
@ -15,10 +18,10 @@
import { default as TabsHeader } from '$lib/TabsHeader.svelte'
import { TELEMETRY_RECORDER } from '$lib/telemetry'
import { DropdownMenu, MenuLink } from '$lib/wildcard'
import { getButtonClassName } from '$lib/wildcard/Button'
import type { LayoutData } from './$types'
import { setRepositoryPageContext, type RepositoryPageContext } from './context'
import RepoMenu from './RepoMenu.svelte'
interface MenuEntry {
/**
@ -50,10 +53,10 @@
{ path: '/-/stats/contributors', icon: ILucideUsers, label: 'Contributors', visibility: 'user' },
]
const menuEntries: MenuEntry[] = [
{ path: '/-/compare', icon: ILucideHistory, label: 'Compare', visibility: 'user' },
{ path: '/-/compare', icon: ILucideGitCompare, label: 'Compare', visibility: 'user' },
{ path: '/-/own', icon: ILucideUsers, label: 'Ownership', visibility: 'admin' },
{ path: '/-/embeddings', icon: ILucideSpline, label: 'Embeddings', visibility: 'admin' },
{ path: '/-/code-graph', icon: ILucideBrainCircuit, label: 'Code graph data', visibility: 'admin' },
{ path: '/-/code-graph', icon: ILucideCodesandbox, label: 'Code graph data', visibility: 'admin' },
{ path: '/-/batch-changes', icon: ISgBatchChanges, label: 'Batch changes', visibility: 'admin' },
{ path: '/-/settings', icon: ILucideSettings, label: 'Settings', visibility: 'admin' },
]
@ -97,7 +100,7 @@
}))
$: selectedTab = tabs.findIndex(tab => isActive(tab.href, $page.url))
$: ({ repoName, displayRepoName, revision, resolvedRevision } = data)
$: ({ repoName, revision } = data)
$: query = `repo:${repositoryInsertText({ repository: repoName })}${revision ? `@${revision}` : ''} `
$: queryState = queryStateStore({ query }, $settings)
function handleSearchSubmit(): void {
@ -105,6 +108,17 @@
metadata: { source: TELEMETRY_SEARCH_SOURCE_TYPE['repo'] },
})
}
registerHotkey({
keys: {
key: 'ctrl+backspace',
mac: 'cmd+backspace',
},
ignoreInputFields: false,
handler: () => {
goto(data.repoURL)
},
})
</script>
<GlobalHeaderPortal>
@ -126,10 +140,13 @@
},
}}
>
<a href={data.repoURL}>
<CodeHostIcon repository={repoName} codeHost={resolvedRevision?.repo?.externalRepository?.serviceType} />
<h1>{displayRepoName}</h1>
</a>
<RepoMenu
repoName={data.repoName}
displayRepoName={data.displayRepoName}
repoURL={data.repoURL}
externalURL={data.resolvedRevision?.repo?.externalURLs?.[0].url}
externalServiceKind={data.resolvedRevision?.repo?.externalURLs?.[0].serviceKind ?? undefined}
/>
<TabsHeader id="repoheader" {tabs} selected={selectedTab} />
@ -145,12 +162,12 @@
{#if entry.visibility === 'user' || (entry.visibility === 'admin' && data.user?.siteAdmin)}
{@const href = data.repoURL + entry.path}
<MenuLink {href}>
<span class="overflow-entry" class:active={isActive(href, $page.url)}>
<div class="overflow-entry">
{#if entry.icon}
<Icon icon={entry.icon} inline aria-hidden />
{/if}
<span>{entry.label}</span>
</span>
</div>
</MenuLink>
{/if}
{/each}
@ -176,40 +193,11 @@
overflow: hidden;
border-bottom: 1px solid var(--border-color);
background-color: var(--color-bg-1);
a {
all: unset;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1rem;
cursor: pointer;
&:hover {
background-color: var(--color-bg-2);
}
h1 {
display: contents;
font-size: 1rem;
white-space: nowrap;
color: var(--text-title);
font-weight: normal;
}
}
:global([data-dropdown-trigger]) {
height: 100%;
align-self: stretch;
padding: 0.5rem;
--icon-fill-color: var(--text-muted);
}
}
.overflow-entry {
width: 100%;
display: inline-block;
padding: 0 0.25rem;
border-radius: var(--border-radius);
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>

View File

@ -0,0 +1,123 @@
<script lang="ts">
import { openFuzzyFinder } from '$lib/fuzzyfinder/FuzzyFinderContainer.svelte'
import { reposHotkey } from '$lib/fuzzyfinder/keys'
import Icon from '$lib/Icon.svelte'
import KeyboardShortcut from '$lib/KeyboardShortcut.svelte'
import { getHumanNameForCodeHost } from '$lib/repo/shared/codehost'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import { getButtonClassName } from '$lib/wildcard/Button'
import DropdownMenu from '$lib/wildcard/menu/DropdownMenu.svelte'
import MenuButton from '$lib/wildcard/menu/MenuButton.svelte'
import MenuLink from '$lib/wildcard/menu/MenuLink.svelte'
import MenuSeparator from '$lib/wildcard/menu/MenuSeparator.svelte'
export let repoName: string
export let displayRepoName: string
export let repoURL: string
export let externalURL: string | undefined
export let externalServiceKind: string | undefined
</script>
<DropdownMenu triggerButtonClass={getButtonClassName({ variant: 'text' })}>
<svelte:fragment slot="trigger">
<div class="trigger">
<CodeHostIcon repository={repoName} codeHost={externalServiceKind} />
<h2>
{#each displayRepoName.split('/') as segment, i}
{#if i > 0}<span class="slash">/</span>{/if}{segment}
{/each}
</h2>
</div>
</svelte:fragment>
<MenuLink href={repoURL}>
<div class="menu-item">
<Icon icon={ILucideHome} inline />
<span>Go to repository root</span>
<KeyboardShortcut shortcut={{ key: 'ctrl+backspace', mac: 'cmd+backspace' }} />
</div>
</MenuLink>
<MenuButton on:click={() => openFuzzyFinder('repos')}>
<div class="menu-item">
<Icon icon={ILucideRepeat} inline />
<span>Switch repo</span>
<KeyboardShortcut shortcut={reposHotkey} />
</div>
</MenuButton>
<MenuLink href="{repoURL}/-/settings">
<div class="menu-item">
<Icon icon={ILucideSettings} inline />
<span>Settings</span>
</div>
</MenuLink>
{#if externalURL}
<MenuSeparator />
<MenuLink href={externalURL} target="_blank" rel="noreferrer noopener">
<div class="code-host-item">
<small>
{#if externalServiceKind}
Hosted on {getHumanNameForCodeHost(externalServiceKind)}
{:else}
View on code host
{/if}
</small>
<div>
<CodeHostIcon repository={repoName} codeHost={externalServiceKind} />
<span>{displayRepoName}</span>
</div>
</div>
</MenuLink>
{/if}
</DropdownMenu>
<style lang="scss">
.trigger {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
h2 {
font-size: var(--font-size-large);
font-weight: 500;
margin: 0;
.slash {
font-weight: 400;
color: var(--text-muted);
margin: 0.25em;
letter-spacing: -0.25px;
}
}
}
.menu-item {
display: flex;
gap: 0.5rem;
min-width: 20rem;
align-items: center;
color: var(--color-text);
--icon-color: currentColor;
:global(kbd) {
margin-left: auto;
}
}
.code-host-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
small {
color: var(--text-muted);
}
div {
display: flex;
gap: 0.5em;
align-items: center;
}
}
</style>

View File

@ -28,7 +28,10 @@ fragment ResolvedRepository on Repository {
defaultBranch {
abbrevName
}
externalURLs {
url
serviceKind
}
...RepoPage_ResolvedRevision
...BlobPage_ResolvedRevision
}

View File

@ -1,3 +1,4 @@
import { ExternalServiceKind } from '../../testing/graphql-type-mocks'
import { test, expect } from '../../testing/integration'
const repoName = 'github.com/sourcegraph/sourcegraph'
@ -93,3 +94,44 @@ test('not cloned', async ({ sg, page }) => {
// Shows queue message
await expect(page.getByText('queued for cloning')).toBeVisible()
})
test.describe('repo menu', () => {
test.beforeEach(async ({ sg, page }) => {
sg.mockOperations({
ResolveRepoRevision: ({ repoName }) => ({
repositoryRedirect: {
id: '1',
name: repoName,
commit: {
oid: '123456789',
},
externalURLs: [
{
serviceKind: ExternalServiceKind.GITHUB,
url: 'https://github.com/sourcegraph/sourcegraph',
},
],
},
}),
})
await page.goto(`/${repoName}`)
})
test('click switch repo', async ({ page }) => {
await page.getByRole('heading', { name: 'sourcegraph/sourcegraph' }).click()
await page.getByRole('menuitem', { name: 'Switch repo' }).click()
await expect(page.getByPlaceholder('Enter a fuzzy query')).toBeVisible()
})
test('settings url', async ({ page }) => {
await page.getByRole('heading', { name: 'sourcegraph/sourcegraph' }).click()
const url = await page.getByRole('menuitem', { name: 'Settings' }).getAttribute('href')
expect(url).toEqual(`/${repoName}/-/settings`)
})
test('github url', async ({ page }) => {
await page.getByRole('heading', { name: 'sourcegraph/sourcegraph' }).click()
const url = await page.getByRole('menuitem', { name: 'Hosted on GitHub' }).getAttribute('href')
expect(url).toEqual(`https://github.com/sourcegraph/sourcegraph`)
})
})