mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:51:43 +00:00
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:
parent
fadcaa264f
commit
7228b4958d
3
client/web-sveltekit/src/auto-imports.d.ts
vendored
3
client/web-sveltekit/src/auto-imports.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -28,7 +28,10 @@ fragment ResolvedRepository on Repository {
|
||||
defaultBranch {
|
||||
abbrevName
|
||||
}
|
||||
|
||||
externalURLs {
|
||||
url
|
||||
serviceKind
|
||||
}
|
||||
...RepoPage_ResolvedRevision
|
||||
...BlobPage_ResolvedRevision
|
||||
}
|
||||
|
||||
@ -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`)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user