mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:11:49 +00:00
svelte: Towards a better data fetching and GraphQL authoring experience (#59383)
This PR refactors almost all of the prototypes GraphQL queries to take advantage of GraphQLs compossibility via fragments. The goal is to provide a more structured approach to authoring and executing GraphQL queries, with the following advantages:
- Data dependency co-location makes it easier to maintain/extend individual components.
- Data fetching happens in specific, predictable places (layout and page loaders).
On a high level it works like this:
- Components declare their data dependencies in `<Component>.gql` files next to them. Thanks to GraphQL code generation they can import the corresponding TypeScript types via `import type { SomeFragment} from './<Component>.gql'`.
- Higher level components compose the fragments of their children.
- At the page/layout level `page.gql`/`layout.gql` files define the queries, composed from the data dependencies of the page/layout.
- The page/layout data loaders can import queries directly from the corresponding `.gql` file.
Authoring the `.gql` files should be relatively easily if the graphql language server is setup. The changes in the `.graphlrc` file make all fragments globally available which means that every fragment needs to be unique and we don't need to use unofficial `#import` directives inside `.gql` files.
There are a couple of things to consider though:
- Caching: If different pages/layouts fetch the same data with different queries, we won't leverage caching without additional setup. That's something I still need to look into. That also means that sometimes we might want to use a shared queries instead of composition/co-location, if caching is more important.
- Shared layout data: Some data fetched in layouts is accessed by sub-layouts/sub-pages, but the layout doesn't know which sub-layout/sub-page is loaded, making query composition more difficult. So it far it seems that data shared this way is rather limited/constrained. So my current approach is to have components/pages define fragments following a specific naming convention, and have the loaders that provide this data compose them. Examples for this is `SearchInput_AuthorizedUser` and `RepoPage_ResolvedRevision`.
This is not ideal because it has to be remembered to embed this fragment in the right place, but it's not worse than the current situation (where we often don't know where the query providing some data is defined).
- On demand data fetching: Not all data is necessary for rendering a page, some data is only fetched when in response to some user interaction. The layout/page loader should still be the place that executes the query, but instead of doing it on page load it passes a function to the page to fetch the data on demand. This way we can maintain data co-location and fetching in loaders. For an example see the `fetchCommitHistory` function.
NOTE: I expect there to be changes to this approach as we uncover more data loading requirements.
This commit is contained in:
parent
a5984c7d6e
commit
031bb871fb
@ -18,6 +18,7 @@ yarn.lock
|
||||
|
||||
# Generated files
|
||||
*.gql.d.ts
|
||||
*.gql.ts
|
||||
src/lib/graphql-operations.ts
|
||||
src/lib/graphql-types.ts
|
||||
static/mockServiceWorker.js
|
||||
|
||||
1
client/web-sveltekit/.gitignore
vendored
1
client/web-sveltekit/.gitignore
vendored
@ -10,5 +10,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Generated TypeScript type definitions and GraphQL documents
|
||||
*.gql.d.ts
|
||||
*.gql.ts
|
||||
src/lib/graphql-types.ts
|
||||
src/lib/graphql-operations.ts
|
||||
|
||||
4
client/web-sveltekit/.graphqlrc
Normal file
4
client/web-sveltekit/.graphqlrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "../../cmd/frontend/graphqlbackend/*.graphql",
|
||||
"documents": "src/**/*.gql"
|
||||
}
|
||||
@ -14,6 +14,7 @@ package-lock.json
|
||||
yarn.lock
|
||||
/static/mockServiceWorker.js
|
||||
*.gql.d.ts
|
||||
*.gql.ts
|
||||
graphql-operations.ts
|
||||
graphql-types.ts
|
||||
|
||||
|
||||
@ -43,10 +43,10 @@ BUILD_DEPS = [
|
||||
":node_modules/@graphql-codegen/typescript",
|
||||
":node_modules/@graphql-codegen/typescript-operations",
|
||||
":node_modules/@graphql-codegen/near-operation-file-preset",
|
||||
":node_modules/@graphql-codegen/typed-document-node",
|
||||
":node_modules/@graphql-tools/utils",
|
||||
":node_modules/@melt-ui/svelte",
|
||||
":node_modules/@popperjs/core",
|
||||
":node_modules/@rollup/plugin-graphql",
|
||||
":node_modules/@sourcegraph/branded",
|
||||
":node_modules/@sourcegraph/common",
|
||||
":node_modules/@sourcegraph/http-client",
|
||||
|
||||
@ -10,8 +10,6 @@ implementation of the Sourcegraph app.
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
# Generate GraphQL types
|
||||
pnpm run -w generate
|
||||
# Run dev server
|
||||
pnpm run dev
|
||||
```
|
||||
@ -31,10 +29,9 @@ packages:
|
||||
|
||||
- Since we use the [barrel](https://basarat.gitbook.io/typescript/main-1/barrel)
|
||||
style of organizing our modules, many (unused) dependencies are imported into
|
||||
the app. This isn't really available, and at best will only increase the
|
||||
initial loading time. But some modules, especially those that access browser
|
||||
specific features during module initialization, can even cause the dev build
|
||||
to fail.
|
||||
the app. This isn't ideal and at best will only increase the initial loading
|
||||
time. Some modules, especially those that access browser specific features
|
||||
during module initialization, can even cause the dev build to fail.
|
||||
- Reusing code is great, but also potentially exposes someone who modifies the
|
||||
reused code to this package and therefore Svelte (if the reused code changes
|
||||
in an incompatible way, this package needs to be updated too). To limit the
|
||||
@ -69,6 +66,39 @@ because it also validates imported modules from other packages, and we are not
|
||||
explicitly marking type-only imports with `type` in other parts of the code
|
||||
base (which is required by this package).
|
||||
|
||||
### Data loading with GraphQL
|
||||
|
||||
This project makes use of query composition, i.e. components define their own
|
||||
data dependencies via fragments, which get composed by their callers and are
|
||||
eventually being used in a query in a loader.
|
||||
|
||||
This goal of this approach is to make data dependencies co-located and easier
|
||||
to change, as well to make the flow of data clearer. Data fetching should only
|
||||
happen in data loaders, not components.
|
||||
|
||||
There are a couple of issues to consider with this approach and sometimes we'll
|
||||
have to make exceptions:
|
||||
|
||||
- Caching: If every loader composes its own query it's possible that two
|
||||
queries fetch the same data, in which case we miss out on caching. If caching
|
||||
the data is more important than data co-location it might be preferable to
|
||||
define a reusable query function. Example: File list for currently opened
|
||||
folder (sidebar + folder page)
|
||||
- Shared data from layout loaders: While it's very convenient that pages have
|
||||
access to any data from the ancestor layout loaders, that doesn't work well
|
||||
with data dependency co-location. The layout loaders don't know which
|
||||
sub-layout or sub-page is loaded and what data it needs.
|
||||
Fortunately we don't have a lot of data (yet) that is used this way. The
|
||||
prime example for this right now is information about the authenticated user.
|
||||
The current approach is to name data-dependencies on the current user as
|
||||
`<ComponentName>_AuthenticatedUser` and use that fragment in the
|
||||
`AuthenticatedUser` fragment in `src/routes/layout.gql`.
|
||||
This approach might change as we uncover more use cases.
|
||||
- On demand data loading: Not all data is fetched/needed immediately for
|
||||
rendering page. Data for e.g. typeaheads is fetched on demand. Ideally the
|
||||
related queries are still composed by the data loader, which passes a
|
||||
function for fetching the data to the page.
|
||||
|
||||
## Production build
|
||||
|
||||
A production version of this app can be built with
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
const { visit } = require('graphql')
|
||||
|
||||
/**
|
||||
* Custom version of the `typed-document-node` plugin that only generates the type definitions
|
||||
* for the documents without generating a parsed version. This is all we need because the
|
||||
* `@rollup/plugin-graphql` plugin already parses the documents for us.
|
||||
*
|
||||
* @param {import('graphql').GraphQLSchema} _schema
|
||||
* @param {import('@graphql-codegen/plugin-helpers').Types.DocumentFile[]} documents
|
||||
* @param {{operationResultSuffix?: string}} config
|
||||
*/
|
||||
const plugin = (_schema, documents, config) => {
|
||||
const { operationResultSuffix = '' } = config
|
||||
|
||||
/** @type {{name: string}[]} */
|
||||
const allOperations = []
|
||||
|
||||
for (const item of documents) {
|
||||
if (item.document) {
|
||||
visit(item.document, {
|
||||
OperationDefinition: {
|
||||
enter: node => {
|
||||
if (node.name && node.name.value) {
|
||||
allOperations.push({
|
||||
name: node.name.value,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const documentNodes = allOperations.map(
|
||||
({ name }) =>
|
||||
`export declare const ${name}: TypedDocumentNode<${name}${operationResultSuffix}, ${name}Variables>`
|
||||
)
|
||||
if (documentNodes.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return {
|
||||
prepend: ["import type { TypedDocumentNode } from '@graphql-typed-document-node/core'\n"],
|
||||
content: documentNodes.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { plugin }
|
||||
@ -20,12 +20,12 @@
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@graphql-codegen/cli": "^5.0.0",
|
||||
"@graphql-codegen/near-operation-file-preset": "^3.0.0",
|
||||
"@graphql-codegen/typed-document-node": "^5.0.1",
|
||||
"@graphql-codegen/typescript": "^4.0.1",
|
||||
"@graphql-codegen/typescript-operations": "^4.0.1",
|
||||
"@graphql-tools/utils": "^10.0.11",
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@playwright/test": "1.25.0",
|
||||
"@rollup/plugin-graphql": "^2.0.4",
|
||||
"@storybook/addon-essentials": "^7.2.0",
|
||||
"@storybook/addon-interactions": "^7.2.0",
|
||||
"@storybook/addon-links": "^7.2.0",
|
||||
|
||||
16
client/web-sveltekit/src/lib/Avatar.gql
Normal file
16
client/web-sveltekit/src/lib/Avatar.gql
Normal file
@ -0,0 +1,16 @@
|
||||
fragment Avatar_User on User {
|
||||
avatarURL
|
||||
displayName
|
||||
username
|
||||
}
|
||||
|
||||
fragment Avatar_Person on Person {
|
||||
displayName
|
||||
avatarURL
|
||||
name
|
||||
}
|
||||
|
||||
fragment Avatar_Team on Team {
|
||||
avatarURL
|
||||
displayName
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
<script lang="ts" context="module">
|
||||
import { Story } from '@storybook/addon-svelte-csf'
|
||||
import UserAvatar from './UserAvatar.svelte'
|
||||
import Avatar from './Avatar.svelte'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
export const meta = {
|
||||
component: UserAvatar,
|
||||
component: Avatar,
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
|
||||
<Story name="Default">
|
||||
<h2>With <code>avatarURL</code></h2>
|
||||
<UserAvatar user={{ avatarURL }} />
|
||||
<Avatar avatar={{ avatarURL, displayName: null }} />
|
||||
<h2>With <code>username</code> "{username}"</h2>
|
||||
<UserAvatar user={{ username }} />
|
||||
<Avatar avatar={{ username, avatarURL: null, displayName: null }} />
|
||||
<h2>With <code>displayName</code> "{displayName}"</h2>
|
||||
<UserAvatar user={{ displayName }} />
|
||||
<Avatar avatar={{ displayName, avatarURL: null, username }} />
|
||||
</Story>
|
||||
@ -1,11 +1,9 @@
|
||||
<script lang="ts">
|
||||
interface User {
|
||||
avatarURL?: string | null
|
||||
displayName?: string | null
|
||||
username?: string | null
|
||||
}
|
||||
import type { Avatar_User, Avatar_Team, Avatar_Person } from './Avatar.gql'
|
||||
|
||||
export let user: User
|
||||
type Avatar = Avatar_User | Avatar_Team | Avatar_Person
|
||||
|
||||
export let avatar: Avatar
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const names = name.split(' ')
|
||||
@ -16,11 +14,25 @@
|
||||
return initials[0]
|
||||
}
|
||||
|
||||
$: name = user.displayName || user.username || ''
|
||||
function getName(avatar: Avatar): string {
|
||||
switch (avatar.__typename) {
|
||||
case 'User':
|
||||
return avatar.displayName || avatar.username || ''
|
||||
case 'Person':
|
||||
return avatar.displayName || avatar.name || ''
|
||||
case 'Team':
|
||||
return avatar.displayName || ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
$: name = getName(avatar)
|
||||
$: avatarURL = avatar.avatarURL
|
||||
</script>
|
||||
|
||||
{#if user.avatarURL}
|
||||
<img src={user.avatarURL} role="presentation" aria-hidden="true" alt="Avatar of {name}" />
|
||||
{#if avatarURL}
|
||||
<img src={avatarURL} role="presentation" aria-hidden="true" alt="Avatar of {name}" />
|
||||
{:else}
|
||||
<div>
|
||||
<span>{getInitials(name)}</span>
|
||||
@ -1,12 +1,31 @@
|
||||
<script lang="ts" context="module">
|
||||
import type { BlobFileFields } from '$lib/repo/api/blob'
|
||||
import { HovercardView } from '$lib/repo/HovercardView'
|
||||
|
||||
export interface BlobInfo extends BlobFileFields {
|
||||
commitID: string
|
||||
filePath: string
|
||||
export interface BlobInfo {
|
||||
/**
|
||||
* Name of the repository this file belongs to.
|
||||
*/
|
||||
repoName: string
|
||||
/**
|
||||
* The commit OID of the currently viewed commit.
|
||||
*/
|
||||
commitID: string
|
||||
/**
|
||||
* Human readable version of the current commit (e.g. branch name).
|
||||
*/
|
||||
revision: string
|
||||
/**
|
||||
* The path of the file relative to the repository root.
|
||||
*/
|
||||
filePath: string
|
||||
/**
|
||||
* The content of the file.
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* The language of the file.
|
||||
*/
|
||||
languages: string[]
|
||||
}
|
||||
|
||||
const extensionsCompartment = new Compartment()
|
||||
|
||||
@ -8,18 +8,15 @@ fragment Commit on GitCommit {
|
||||
date
|
||||
person {
|
||||
name
|
||||
displayName
|
||||
email
|
||||
avatarURL
|
||||
...Avatar_Person
|
||||
}
|
||||
}
|
||||
committer {
|
||||
date
|
||||
person {
|
||||
name
|
||||
displayName
|
||||
email
|
||||
avatarURL
|
||||
...Avatar_Person
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
import type { Commit } from './Commit.gql'
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import UserAvatar from '$lib/UserAvatar.svelte'
|
||||
import Avatar from '$lib/Avatar.svelte'
|
||||
import Timestamp from '$lib/Timestamp.svelte'
|
||||
import Tooltip from '$lib/Tooltip.svelte'
|
||||
|
||||
@ -31,13 +31,13 @@
|
||||
<div class="root">
|
||||
<div class="avatar">
|
||||
<Tooltip tooltip={authorAvatarTooltip}>
|
||||
<UserAvatar user={author} />
|
||||
<Avatar avatar={author} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{#if committer && committer.name !== author.name}
|
||||
<div class="avatar">
|
||||
<Tooltip tooltip="{committer.name} (committer)">
|
||||
<UserAvatar user={committer} />
|
||||
<Avatar avatar={committer} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
@ -50,7 +50,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span>committed by <strong>{commit.author.person.name}</strong> <Timestamp date={commitDate} /></span>
|
||||
<span>committed by <strong>{author.name}</strong> <Timestamp date={commitDate} /></span>
|
||||
{#if expanded}
|
||||
<pre>{commit.body}</pre>
|
||||
{/if}
|
||||
|
||||
@ -16,6 +16,7 @@ export { highlightNodeMultiline, highlightNode } from '@sourcegraph/common/src/u
|
||||
export { logger } from '@sourcegraph/common/src/util/logger'
|
||||
export { isSafari } from '@sourcegraph/common/src/util/browserDetection'
|
||||
export { isExternalLink, type LineOrPositionOrRange } from '@sourcegraph/common/src/util/url'
|
||||
export { parseJSONCOrError } from '@sourcegraph/common/src/util/jsonc'
|
||||
|
||||
let highlightingLoaded = false
|
||||
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import { gql, query } from '$lib/graphql'
|
||||
import type { EvaluatedFeatureFlagsResult } from '$lib/graphql-operations'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
value: boolean
|
||||
}
|
||||
|
||||
const FEATUREFLAGS_QUERY = gql`
|
||||
query EvaluatedFeatureFlags {
|
||||
evaluatedFeatureFlags {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
`
|
||||
export async function fetchEvaluatedFeatureFlags(): Promise<FeatureFlag[]> {
|
||||
return (
|
||||
await query<EvaluatedFeatureFlagsResult>(FEATUREFLAGS_QUERY, undefined, {
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
).evaluatedFeatureFlags
|
||||
}
|
||||
@ -1,2 +1 @@
|
||||
export * from './stores'
|
||||
export * from './api'
|
||||
|
||||
@ -3,7 +3,7 @@ import { derived, readable, type Readable } from 'svelte/store'
|
||||
import { getStores } from '$lib/stores'
|
||||
import type { FeatureFlagName } from '$lib/web'
|
||||
|
||||
import type { FeatureFlag } from './api'
|
||||
import type { FeatureFlag } from '../../routes/layout.gql'
|
||||
|
||||
const MINUTE = 60000
|
||||
const FEATURE_FLAG_CACHE_TTL = MINUTE * 10
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export * from '@sourcegraph/shared/src/graphql-operations'
|
||||
3
client/web-sveltekit/src/lib/repo/BottomPanel.gql
Normal file
3
client/web-sveltekit/src/lib/repo/BottomPanel.gql
Normal file
@ -0,0 +1,3 @@
|
||||
fragment BottomPanel_HistoryConnection on GitCommitConnection {
|
||||
...HistoryPanel_HistoryConnection
|
||||
}
|
||||
@ -11,14 +11,14 @@
|
||||
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import type { HistoryResult } from '$lib/graphql-operations'
|
||||
import { fetchRepoCommits } from '$lib/repo/api/commits'
|
||||
import TabPanel from '$lib/TabPanel.svelte'
|
||||
import Tabs from '$lib/Tabs.svelte'
|
||||
|
||||
import HistoryPanel, { type Capture as HistoryPanelCapture } from './HistoryPanel.svelte'
|
||||
import type { BottomPanel_HistoryConnection } from './BottomPanel.gql'
|
||||
|
||||
export let history: Promise<HistoryResult>
|
||||
export let history: Promise<BottomPanel_HistoryConnection | null>
|
||||
export let fetchCommitHistory: (afterCursor: string | null) => Promise<BottomPanel_HistoryConnection | null>
|
||||
|
||||
export function capture(): Capture {
|
||||
return {
|
||||
@ -51,18 +51,6 @@
|
||||
selectedTab = event.detail
|
||||
}
|
||||
|
||||
async function fetchMoreHistory(pageInfo: HistoryResult['pageInfo']) {
|
||||
if (!$page.data.resolvedRevision) {
|
||||
throw new Error('Unable to resolve repo revision')
|
||||
}
|
||||
return fetchRepoCommits({
|
||||
repoID: $page.data.resolvedRevision.repo.id,
|
||||
revision: $page.data.resolvedRevision.commitID,
|
||||
filePath: $page.params.path,
|
||||
pageInfo,
|
||||
})
|
||||
}
|
||||
|
||||
let selectedTab: number | null = null
|
||||
let historyPanel: HistoryPanel
|
||||
|
||||
@ -76,7 +64,7 @@
|
||||
<Tabs selected={selectedTab} toggable on:select={selectTab}>
|
||||
<TabPanel title="History">
|
||||
{#key $page.params.path}
|
||||
<HistoryPanel bind:this={historyPanel} {history} fetchMoreHandler={fetchMoreHistory} />
|
||||
<HistoryPanel bind:this={historyPanel} {history} fetchMoreHandler={fetchCommitHistory} />
|
||||
{/key}
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
#import './FileDiffHunks.gql'
|
||||
|
||||
fragment FileDiff_Diff on FileDiff {
|
||||
newPath
|
||||
oldPath
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import type { FileDiffHunks_Hunk } from './FileDiffHunks.gql'
|
||||
|
||||
export let hunks: FileDiffHunks_Hunk[]
|
||||
export let hunks: readonly FileDiffHunks_Hunk[]
|
||||
</script>
|
||||
|
||||
{#if hunks.length === 0}
|
||||
@ -7,8 +7,8 @@
|
||||
import Timestamp from '$lib/Timestamp.svelte'
|
||||
import type { TreeEntryFields } from './api/tree'
|
||||
|
||||
export let entries: TreeEntryFields[]
|
||||
export let commitInfo: TreeEntryWithCommitInfo[]
|
||||
export let entries: readonly TreeEntryFields[]
|
||||
export let commitInfo: readonly TreeEntryWithCommitInfo[]
|
||||
export let revision: string
|
||||
|
||||
$: commitInfoByPath = new Map(commitInfo.map(entry => [entry.canonicalURL, entry]))
|
||||
|
||||
20
client/web-sveltekit/src/lib/repo/GitReference.gql
Normal file
20
client/web-sveltekit/src/lib/repo/GitReference.gql
Normal file
@ -0,0 +1,20 @@
|
||||
fragment GitReference_Ref on GitRef {
|
||||
id
|
||||
url
|
||||
displayName
|
||||
target {
|
||||
commit {
|
||||
id
|
||||
author {
|
||||
date
|
||||
person {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
behindAhead(revspec: $revspec) @include(if: $withBehindAhead) {
|
||||
ahead
|
||||
behind
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Timestamp from '$lib/Timestamp.svelte'
|
||||
import { numberWithCommas } from '$lib/common'
|
||||
import type { GitRefFields } from '$lib/graphql-operations'
|
||||
import type { GitReference_Ref } from './GitReference.gql'
|
||||
|
||||
export let ref: GitRefFields
|
||||
export let ref: GitReference_Ref
|
||||
|
||||
$: authorName = ref.target.commit?.author.person.displayName ?? ''
|
||||
$: authorDate = ref.target.commit ? new Date(ref.target.commit.author.date) : null
|
||||
|
||||
19
client/web-sveltekit/src/lib/repo/HistoryPanel.gql
Normal file
19
client/web-sveltekit/src/lib/repo/HistoryPanel.gql
Normal file
@ -0,0 +1,19 @@
|
||||
fragment HistoryPanel_HistoryConnection on GitCommitConnection {
|
||||
nodes {
|
||||
id
|
||||
abbreviatedOID
|
||||
subject
|
||||
author {
|
||||
date
|
||||
person {
|
||||
displayName
|
||||
...Avatar_Person
|
||||
}
|
||||
}
|
||||
canonicalURL
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
<script lang="ts" context="module">
|
||||
import { createHistoryResults } from '$testdata'
|
||||
import { createHistoryResults } from '$testing/testdata'
|
||||
import { Story } from '@storybook/addon-svelte-csf'
|
||||
import HistoryPanel from './HistoryPanel.svelte'
|
||||
export const meta = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface Capture {
|
||||
history: HistoryResult | null
|
||||
history: HistoryPanel_HistoryConnection | null
|
||||
scroller?: ScrollerCapture
|
||||
}
|
||||
</script>
|
||||
@ -11,17 +11,17 @@
|
||||
|
||||
import { page } from '$app/stores'
|
||||
import { scrollIntoView } from '$lib/actions'
|
||||
import type { HistoryResult } from '$lib/graphql-operations'
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
|
||||
import { createHistoryPanelStore } from '$lib/repo/stores'
|
||||
import Scroller, { type Capture as ScrollerCapture } from '$lib/Scroller.svelte'
|
||||
import Tooltip from '$lib/Tooltip.svelte'
|
||||
import UserAvatar from '$lib/UserAvatar.svelte'
|
||||
import Avatar from '$lib/Avatar.svelte'
|
||||
import Timestamp from '$lib/Timestamp.svelte'
|
||||
import type { HistoryPanel_HistoryConnection } from './HistoryPanel.gql'
|
||||
|
||||
export let history: Promise<HistoryResult>
|
||||
export let fetchMoreHandler: (pageInfo: HistoryResult['pageInfo']) => Promise<HistoryResult>
|
||||
export let history: Promise<HistoryPanel_HistoryConnection | null>
|
||||
export let fetchMoreHandler: (afterCursor: string | null) => Promise<HistoryPanel_HistoryConnection | null>
|
||||
|
||||
export function capture(): Capture {
|
||||
return {
|
||||
@ -67,6 +67,7 @@
|
||||
|
||||
$: selectedRev = $page.url?.searchParams.get('rev')
|
||||
$: clearURL = getClearURL()
|
||||
$: canShowInlineDiff = $page.route.id?.includes('/blob/')
|
||||
|
||||
let scroller: Scroller
|
||||
</script>
|
||||
@ -77,11 +78,11 @@
|
||||
{@const selected = commit.abbreviatedOID === selectedRev}
|
||||
<tr class:selected use:scrollIntoView={selected}>
|
||||
<td>
|
||||
<UserAvatar user={commit.author.person} />
|
||||
<Avatar avatar={commit.author.person} />
|
||||
{commit.author.person.displayName}
|
||||
</td>
|
||||
<td class="subject">
|
||||
{#if $page.params?.path}
|
||||
{#if canShowInlineDiff}
|
||||
<a href="?rev={commit.abbreviatedOID}">{commit.subject}</a>
|
||||
{:else}
|
||||
{commit.subject}
|
||||
|
||||
@ -2,17 +2,13 @@
|
||||
import { mdiLink } from '@mdi/js'
|
||||
|
||||
import { page } from '$app/stores'
|
||||
import { isErrorLike, type ErrorLike } from '$lib/common'
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import Tooltip from '$lib/Tooltip.svelte'
|
||||
import { replaceRevisionInURL } from '$lib/web'
|
||||
import type { ResolvedRevision } from '$lib/repo/api/repo'
|
||||
|
||||
export let resolvedRevision: ResolvedRevision | ErrorLike
|
||||
export let commitID: string
|
||||
|
||||
$: href = !isErrorLike(resolvedRevision)
|
||||
? replaceRevisionInURL($page.url.toString(), resolvedRevision.commitID)
|
||||
: ''
|
||||
$: href = commitID ? replaceRevisionInURL($page.url.toString(), commitID) : ''
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
|
||||
4
client/web-sveltekit/src/lib/repo/Readme.gql
Normal file
4
client/web-sveltekit/src/lib/repo/Readme.gql
Normal file
@ -0,0 +1,4 @@
|
||||
fragment Readme_Blob on GitBlob {
|
||||
richHTML
|
||||
content
|
||||
}
|
||||
25
client/web-sveltekit/src/lib/repo/Readme.svelte
Normal file
25
client/web-sveltekit/src/lib/repo/Readme.svelte
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { Readme_Blob } from './Readme.gql'
|
||||
|
||||
export let file: Readme_Blob
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if file.richHTML}
|
||||
{@html file.richHTML}
|
||||
{:else if file.content}
|
||||
<pre>{file.content}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
:global(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,104 +0,0 @@
|
||||
import { query, gql, type NodeFromResult } from '$lib/graphql'
|
||||
import type { BlobResult, BlobVariables, HighlightResult, HighlightVariables, Scalars } from '$lib/graphql-operations'
|
||||
|
||||
interface FetchBlobOptions {
|
||||
repoID: Scalars['ID']['input']
|
||||
commitID: string
|
||||
filePath: string
|
||||
disableTimeout?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure that default values are applied consistently for the cache key and the `fetchBlob` function.
|
||||
*/
|
||||
function applyDefaultValuesToFetchBlobOptions({
|
||||
disableTimeout = false,
|
||||
...options
|
||||
}: FetchBlobOptions): Required<FetchBlobOptions> {
|
||||
return {
|
||||
...options,
|
||||
disableTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
type Highlight = NonNullable<
|
||||
NonNullable<NodeFromResult<HighlightResult['node'], 'Repository'>['commit']>['blob']
|
||||
>['highlight']
|
||||
|
||||
export async function fetchHighlight(options: FetchBlobOptions): Promise<Highlight | null> {
|
||||
const { repoID, commitID, filePath, disableTimeout } = applyDefaultValuesToFetchBlobOptions(options)
|
||||
|
||||
const data = await query<HighlightResult, HighlightVariables>(
|
||||
gql`
|
||||
query Highlight($repoID: ID!, $commitID: String!, $filePath: String!, $disableTimeout: Boolean!) {
|
||||
node(id: $repoID) {
|
||||
__typename
|
||||
id
|
||||
... on Repository {
|
||||
commit(rev: $commitID) {
|
||||
id
|
||||
blob(path: $filePath) {
|
||||
canonicalURL
|
||||
highlight(disableTimeout: $disableTimeout, format: JSON_SCIP) {
|
||||
aborted
|
||||
lsif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ repoID, commitID, filePath, disableTimeout }
|
||||
)
|
||||
|
||||
if (data.node?.__typename !== 'Repository' || !data.node?.commit) {
|
||||
throw new Error('Commit not found')
|
||||
}
|
||||
|
||||
return data.node.commit.blob?.highlight ?? null
|
||||
}
|
||||
|
||||
export type BlobFileFields = NonNullable<
|
||||
NonNullable<NodeFromResult<BlobResult['node'], 'Repository'>['commit']>['blob']
|
||||
>
|
||||
|
||||
export async function fetchBlobPlaintext(options: FetchBlobOptions): Promise<BlobFileFields | null> {
|
||||
const { repoID, commitID, filePath } = applyDefaultValuesToFetchBlobOptions(options)
|
||||
|
||||
const data = await query<BlobResult, BlobVariables>(
|
||||
gql`
|
||||
query Blob($repoID: ID!, $commitID: String!, $filePath: String!) {
|
||||
node(id: $repoID) {
|
||||
__typename
|
||||
id
|
||||
... on Repository {
|
||||
commit(rev: $commitID) {
|
||||
id
|
||||
blob(path: $filePath) {
|
||||
canonicalURL
|
||||
content
|
||||
richHTML
|
||||
lfs {
|
||||
byteSize
|
||||
}
|
||||
externalURLs {
|
||||
url
|
||||
serviceKind
|
||||
}
|
||||
languages
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ repoID, commitID, filePath }
|
||||
)
|
||||
|
||||
if (data.node?.__typename !== 'Repository' || !data.node?.commit?.blob) {
|
||||
throw new Error('Commit or file not found')
|
||||
}
|
||||
|
||||
return data.node.commit.blob
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
import { query, gql } from '$lib/graphql'
|
||||
import type {
|
||||
RepositoryCommitResult,
|
||||
Scalars,
|
||||
HistoryResult,
|
||||
GitHistoryResult,
|
||||
GitHistoryVariables,
|
||||
CommitDiffResult,
|
||||
CommitDiffVariables,
|
||||
FileDiffFields,
|
||||
RepositoryCommitVariables,
|
||||
} from '$lib/graphql-operations'
|
||||
|
||||
// Unfortunately it doesn't seem possible to share fragements across package
|
||||
// boundaries
|
||||
const gitCommitFragment = gql`
|
||||
fragment GitCommitFields on GitCommit {
|
||||
id
|
||||
oid
|
||||
abbreviatedOID
|
||||
subject
|
||||
body
|
||||
author {
|
||||
...SignatureFields
|
||||
}
|
||||
committer {
|
||||
...SignatureFields
|
||||
}
|
||||
parents {
|
||||
oid
|
||||
abbreviatedOID
|
||||
canonicalURL
|
||||
}
|
||||
canonicalURL
|
||||
externalURLs {
|
||||
...ExternalLinkFields
|
||||
}
|
||||
}
|
||||
|
||||
fragment SignatureFields on Signature {
|
||||
person {
|
||||
avatarURL
|
||||
name
|
||||
displayName
|
||||
user {
|
||||
id
|
||||
username
|
||||
url
|
||||
displayName
|
||||
}
|
||||
}
|
||||
date
|
||||
}
|
||||
|
||||
fragment ExternalLinkFields on ExternalLink {
|
||||
url
|
||||
serviceKind
|
||||
}
|
||||
`
|
||||
|
||||
const fileDiffHunkFields = gql`
|
||||
fragment FileDiffHunkFields on FileDiffHunk {
|
||||
oldNoNewlineAt
|
||||
oldRange {
|
||||
startLine
|
||||
lines
|
||||
}
|
||||
newRange {
|
||||
startLine
|
||||
lines
|
||||
}
|
||||
section
|
||||
highlight(disableTimeout: false) {
|
||||
aborted
|
||||
lines {
|
||||
kind
|
||||
html
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const fileDiffFields = gql`
|
||||
fragment FileDiffFields on FileDiff {
|
||||
oldPath
|
||||
oldFile {
|
||||
__typename
|
||||
canonicalURL
|
||||
binary
|
||||
byteSize
|
||||
}
|
||||
newPath
|
||||
newFile {
|
||||
__typename
|
||||
canonicalURL
|
||||
binary
|
||||
byteSize
|
||||
}
|
||||
mostRelevantFile {
|
||||
__typename
|
||||
canonicalURL
|
||||
url
|
||||
}
|
||||
hunks {
|
||||
...FileDiffHunkFields
|
||||
}
|
||||
stat {
|
||||
added
|
||||
deleted
|
||||
}
|
||||
internalID
|
||||
}
|
||||
|
||||
${fileDiffHunkFields}
|
||||
`
|
||||
|
||||
const HISTORY_COMMITS_PER_PAGE = 20
|
||||
|
||||
const HISTORY_QUERY = gql`
|
||||
query GitHistory($repo: ID!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
|
||||
node(id: $repo) {
|
||||
__typename
|
||||
id
|
||||
... on Repository {
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
ancestors(first: $first, path: $filePath, afterCursor: $afterCursor) {
|
||||
...HistoryResult
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment HistoryResult on GitCommitConnection {
|
||||
nodes {
|
||||
...GitCommitFields
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
|
||||
${gitCommitFragment}
|
||||
`
|
||||
|
||||
const COMMIT_QUERY = gql`
|
||||
query RepositoryCommit($repo: ID!, $revspec: String!) {
|
||||
node(id: $repo) {
|
||||
__typename
|
||||
id
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
__typename # Necessary for error handling to check if commit exists
|
||||
id
|
||||
...GitCommitFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${gitCommitFragment}
|
||||
`
|
||||
|
||||
interface FetchRepoCommitsArgs {
|
||||
repoID: Scalars['ID']['input']
|
||||
revision: string
|
||||
filePath?: string
|
||||
first?: number
|
||||
pageInfo?: HistoryResult['pageInfo']
|
||||
}
|
||||
|
||||
export async function fetchRepoCommits({
|
||||
repoID,
|
||||
revision,
|
||||
filePath,
|
||||
first = HISTORY_COMMITS_PER_PAGE,
|
||||
pageInfo,
|
||||
}: FetchRepoCommitsArgs): Promise<HistoryResult> {
|
||||
const emptyResult: HistoryResult = { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }
|
||||
|
||||
const data = await query<GitHistoryResult, GitHistoryVariables>(HISTORY_QUERY, {
|
||||
repo: repoID,
|
||||
revspec: revision,
|
||||
filePath: filePath ?? null,
|
||||
first,
|
||||
afterCursor: pageInfo?.endCursor ?? null,
|
||||
})
|
||||
if (data.node?.__typename === 'Repository') {
|
||||
return data.node.commit?.ancestors ?? emptyResult
|
||||
}
|
||||
return emptyResult
|
||||
}
|
||||
|
||||
export async function fetchRepoCommit(repoId: string, revision: string): Promise<RepositoryCommitResult> {
|
||||
return query<RepositoryCommitResult, RepositoryCommitVariables>(COMMIT_QUERY, {
|
||||
repo: repoId,
|
||||
revspec: revision,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchDiff(
|
||||
repoID: Scalars['ID']['input'],
|
||||
revspec: string,
|
||||
paths: string[] = []
|
||||
): Promise<FileDiffFields[]> {
|
||||
const data = await query<CommitDiffResult, CommitDiffVariables>(
|
||||
gql`
|
||||
query CommitDiff($repoID: ID!, $revspec: String!, $paths: [String!], $first: Int) {
|
||||
node(id: $repoID) {
|
||||
... on Repository {
|
||||
__typename
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
diff {
|
||||
fileDiffs(paths: $paths, first: $first) {
|
||||
nodes {
|
||||
...FileDiffFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${fileDiffFields}
|
||||
`,
|
||||
{ repoID, revspec, paths, first: paths.length }
|
||||
)
|
||||
if (data.node?.__typename !== 'Repository') {
|
||||
return []
|
||||
}
|
||||
return data.node.commit?.diff.fileDiffs.nodes ?? []
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
import { gql, query } from '$lib/graphql'
|
||||
import type {
|
||||
PagedRepositoryContributorConnectionFields,
|
||||
PagedRepositoryContributorsResult,
|
||||
PagedRepositoryContributorsVariables,
|
||||
} from '$lib/graphql-operations'
|
||||
|
||||
const CONTRIBUTORS_QUERY = gql`
|
||||
query PagedRepositoryContributors(
|
||||
$repo: ID!
|
||||
$first: Int
|
||||
$last: Int
|
||||
$after: String
|
||||
$before: String
|
||||
$revisionRange: String
|
||||
$afterDate: String
|
||||
$path: String
|
||||
) {
|
||||
node(id: $repo) {
|
||||
__typename
|
||||
id
|
||||
... on Repository {
|
||||
contributors(
|
||||
first: $first
|
||||
last: $last
|
||||
before: $before
|
||||
after: $after
|
||||
revisionRange: $revisionRange
|
||||
afterDate: $afterDate
|
||||
path: $path
|
||||
) {
|
||||
...PagedRepositoryContributorConnectionFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment PagedRepositoryContributorConnectionFields on RepositoryContributorConnection {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
startCursor
|
||||
}
|
||||
nodes {
|
||||
...PagedRepositoryContributorNodeFields
|
||||
}
|
||||
}
|
||||
|
||||
fragment PagedRepositoryContributorNodeFields on RepositoryContributor {
|
||||
__typename
|
||||
person {
|
||||
name
|
||||
displayName
|
||||
email
|
||||
avatarURL
|
||||
user {
|
||||
username
|
||||
url
|
||||
displayName
|
||||
avatarURL
|
||||
}
|
||||
}
|
||||
count
|
||||
commits(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
oid
|
||||
abbreviatedOID
|
||||
canonicalURL
|
||||
subject
|
||||
author {
|
||||
date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const emptyPage: Extract<PagedRepositoryContributorsResult['node'], { __typename: 'Repository' }>['contributors'] = {
|
||||
totalCount: 0,
|
||||
nodes: [] as any[],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
endCursor: null,
|
||||
startCursor: null,
|
||||
},
|
||||
}
|
||||
|
||||
export async function fetchContributors(
|
||||
options: PagedRepositoryContributorsVariables
|
||||
): Promise<PagedRepositoryContributorConnectionFields> {
|
||||
const data = await query<PagedRepositoryContributorsResult, PagedRepositoryContributorsVariables>(
|
||||
CONTRIBUTORS_QUERY,
|
||||
options
|
||||
)
|
||||
|
||||
if (data?.node?.__typename === 'Repository') {
|
||||
return data.node.contributors
|
||||
}
|
||||
|
||||
return emptyPage
|
||||
}
|
||||
@ -1,154 +0,0 @@
|
||||
import { gql, query } from '$lib/graphql'
|
||||
import {
|
||||
GitRefType,
|
||||
type GitRefConnectionFields,
|
||||
type GitRefFields,
|
||||
type RepositoryGitBranchesOverviewResult,
|
||||
type RepositoryGitBranchesOverviewVariables,
|
||||
type RepositoryGitRefsResult,
|
||||
type RepositoryGitRefsVariables,
|
||||
type Scalars,
|
||||
} from '$lib/graphql-operations'
|
||||
|
||||
export const gitReferenceFragments = gql`
|
||||
fragment GitRefFields on GitRef {
|
||||
__typename
|
||||
id
|
||||
displayName
|
||||
name
|
||||
abbrevName
|
||||
url
|
||||
target {
|
||||
commit {
|
||||
id
|
||||
author {
|
||||
...SignatureFieldsForReferences
|
||||
}
|
||||
committer {
|
||||
...SignatureFieldsForReferences
|
||||
}
|
||||
behindAhead(revspec: "HEAD") @include(if: $withBehindAhead) {
|
||||
behind
|
||||
ahead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment SignatureFieldsForReferences on Signature {
|
||||
__typename
|
||||
person {
|
||||
displayName
|
||||
user {
|
||||
username
|
||||
}
|
||||
}
|
||||
date
|
||||
}
|
||||
`
|
||||
|
||||
export const REPOSITORY_GIT_REFS = gql`
|
||||
query RepositoryGitRefs($repo: ID!, $first: Int, $query: String, $type: GitRefType!, $withBehindAhead: Boolean!) {
|
||||
node(id: $repo) {
|
||||
id
|
||||
... on Repository {
|
||||
gitRefs(first: $first, query: $query, type: $type, orderBy: AUTHORED_OR_COMMITTED_AT) {
|
||||
__typename
|
||||
...GitRefConnectionFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment GitRefConnectionFields on GitRefConnection {
|
||||
nodes {
|
||||
__typename
|
||||
...GitRefFields
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
|
||||
${gitReferenceFragments}
|
||||
`
|
||||
|
||||
export async function queryGitReferences(args: {
|
||||
repo: Scalars['ID']['input']
|
||||
first?: number
|
||||
query?: string
|
||||
type: GitRefType
|
||||
withBehindAhead?: boolean
|
||||
}): Promise<GitRefConnectionFields> {
|
||||
const data = await query<RepositoryGitRefsResult, RepositoryGitRefsVariables>(REPOSITORY_GIT_REFS, {
|
||||
query: args.query ?? null,
|
||||
first: args.first ?? null,
|
||||
repo: args.repo,
|
||||
type: args.type,
|
||||
withBehindAhead:
|
||||
args.withBehindAhead !== undefined ? args.withBehindAhead : args.type === GitRefType.GIT_BRANCH,
|
||||
})
|
||||
|
||||
if (data?.node?.__typename !== 'Repository' || !data.node.gitRefs) {
|
||||
throw new Error('Unable to fetch git information')
|
||||
}
|
||||
return data.node.gitRefs
|
||||
}
|
||||
|
||||
export interface GitBranchesOverview {
|
||||
defaultBranch: GitRefFields | null
|
||||
activeBranches: GitRefFields[]
|
||||
hasMoreActiveBranches: boolean
|
||||
}
|
||||
|
||||
export async function queryGitBranchesOverview(args: {
|
||||
repo: Scalars['ID']['input']
|
||||
first: number
|
||||
}): Promise<GitBranchesOverview> {
|
||||
const data = await query<RepositoryGitBranchesOverviewResult, RepositoryGitBranchesOverviewVariables>(
|
||||
gql`
|
||||
query RepositoryGitBranchesOverview($repo: ID!, $first: Int!, $withBehindAhead: Boolean!) {
|
||||
node(id: $repo) {
|
||||
id
|
||||
...RepositoryGitBranchesOverviewRepository
|
||||
}
|
||||
}
|
||||
|
||||
fragment RepositoryGitBranchesOverviewRepository on Repository {
|
||||
__typename
|
||||
defaultBranch {
|
||||
...GitRefFields
|
||||
}
|
||||
gitRefs(first: $first, type: GIT_BRANCH, orderBy: AUTHORED_OR_COMMITTED_AT) {
|
||||
nodes {
|
||||
...GitRefFields
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
${gitReferenceFragments}
|
||||
`,
|
||||
{ ...args, withBehindAhead: true }
|
||||
)
|
||||
if (!data?.node) {
|
||||
throw new Error('Unable to fetch repository information')
|
||||
}
|
||||
const repo = data.node
|
||||
if (repo.__typename !== 'Repository') {
|
||||
throw new Error('Not a GitRef')
|
||||
}
|
||||
if (!repo.gitRefs.nodes) {
|
||||
throw new Error('Unable to fetch repository information')
|
||||
}
|
||||
return {
|
||||
defaultBranch: repo.defaultBranch,
|
||||
activeBranches: repo.gitRefs.nodes.filter(
|
||||
// Filter out default branch from activeBranches.
|
||||
({ id }) => !repo.defaultBranch || repo.defaultBranch.id !== id
|
||||
),
|
||||
hasMoreActiveBranches: repo.gitRefs.pageInfo.hasNextPage,
|
||||
}
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
import { query, gql, fromCache } from '$lib/graphql'
|
||||
import type { ResolveRepoRevisonResult, ResolveRepoRevisonVariables } from '$lib/graphql-operations'
|
||||
import {
|
||||
CloneInProgressError,
|
||||
RepoNotFoundError,
|
||||
RepoSeeOtherError,
|
||||
RevisionNotFoundError,
|
||||
type RepoSpec,
|
||||
type RevisionSpec,
|
||||
type ResolvedRevisionSpec,
|
||||
} from '$lib/shared'
|
||||
|
||||
const resolveRevisionQuery = gql`
|
||||
query ResolveRepoRevison($repoName: String!, $revision: String!) {
|
||||
repositoryRedirect(name: $repoName) {
|
||||
__typename
|
||||
... on Repository {
|
||||
id
|
||||
name
|
||||
url
|
||||
sourceType
|
||||
externalURLs {
|
||||
url
|
||||
serviceKind
|
||||
}
|
||||
externalRepository {
|
||||
serviceType
|
||||
serviceID
|
||||
}
|
||||
description
|
||||
viewerCanAdminister
|
||||
defaultBranch {
|
||||
displayName
|
||||
abbrevName
|
||||
}
|
||||
isFork
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
mirrorInfo {
|
||||
cloneInProgress
|
||||
cloneProgress
|
||||
cloned
|
||||
}
|
||||
commit(rev: $revision) {
|
||||
id
|
||||
oid
|
||||
tree(path: ".") {
|
||||
canonicalURL
|
||||
url
|
||||
}
|
||||
}
|
||||
changelist(cid: $revision) {
|
||||
cid
|
||||
canonicalURL
|
||||
commit {
|
||||
id
|
||||
__typename
|
||||
oid
|
||||
tree(path: ".") {
|
||||
canonicalURL
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultBranch {
|
||||
id
|
||||
abbrevName
|
||||
}
|
||||
}
|
||||
... on Redirect {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export interface ResolvedRevision extends ResolvedRevisionSpec {
|
||||
defaultBranch: string
|
||||
repo: Extract<ResolveRepoRevisonResult['repositoryRedirect'], { __typename: 'Repository' }>
|
||||
}
|
||||
|
||||
/**
|
||||
* When `revision` is undefined, the default branch is resolved.
|
||||
* @returns Promise that emits the commit ID. Errors with a `CloneInProgressError` if the repo is still being cloned.
|
||||
*/
|
||||
export async function resolveRepoRevision({
|
||||
repoName,
|
||||
revision = '',
|
||||
}: RepoSpec & Partial<RevisionSpec>): Promise<ResolvedRevision> {
|
||||
let data = await fromCache<ResolveRepoRevisonResult, ResolveRepoRevisonVariables>(resolveRevisionQuery, {
|
||||
repoName,
|
||||
revision,
|
||||
})
|
||||
if (
|
||||
!data ||
|
||||
(data.repositoryRedirect?.__typename === 'Repository' && data.repositoryRedirect.commit?.oid !== revision)
|
||||
) {
|
||||
// We always refetch data when 'revision' is a "symbolic" revision (e.g. a tag or branch name)
|
||||
data = await query<ResolveRepoRevisonResult, ResolveRepoRevisonVariables>(
|
||||
resolveRevisionQuery,
|
||||
{
|
||||
repoName,
|
||||
revision,
|
||||
},
|
||||
{ fetchPolicy: 'network-only' }
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.repositoryRedirect) {
|
||||
throw new RepoNotFoundError(repoName)
|
||||
}
|
||||
if (data.repositoryRedirect.__typename === 'Redirect') {
|
||||
throw new RepoSeeOtherError(data.repositoryRedirect.url)
|
||||
}
|
||||
if (data.repositoryRedirect.mirrorInfo.cloneInProgress) {
|
||||
throw new CloneInProgressError(repoName, data.repositoryRedirect.mirrorInfo.cloneProgress || undefined)
|
||||
}
|
||||
if (!data.repositoryRedirect.mirrorInfo.cloned) {
|
||||
throw new CloneInProgressError(repoName, 'queued for cloning')
|
||||
}
|
||||
|
||||
// The "revision" we queried for could be a commit or a changelist.
|
||||
const commit = data.repositoryRedirect.commit || data.repositoryRedirect.changelist?.commit
|
||||
if (!commit) {
|
||||
throw new RevisionNotFoundError(revision)
|
||||
}
|
||||
|
||||
const defaultBranch = data.repositoryRedirect.defaultBranch?.abbrevName || 'HEAD'
|
||||
|
||||
if (!commit.tree) {
|
||||
throw new RevisionNotFoundError(defaultBranch)
|
||||
}
|
||||
|
||||
return {
|
||||
repo: data.repositoryRedirect,
|
||||
commitID: commit.oid,
|
||||
defaultBranch,
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ import {
|
||||
type DocumentInfo,
|
||||
} from '$lib/web'
|
||||
|
||||
import type { BlobFileFields } from './api/blob'
|
||||
import type { BlobPage_Blob } from '../../routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/page.gql'
|
||||
|
||||
/**
|
||||
* The minimum number of milliseconds that must elapse before we handle a "Go to
|
||||
@ -140,12 +140,12 @@ export function openImplementations(
|
||||
}
|
||||
|
||||
interface CombinedBlobData {
|
||||
blob: BlobFileFields | null
|
||||
blob: BlobPage_Blob | null
|
||||
highlights: string | undefined
|
||||
}
|
||||
|
||||
interface BlobDataHandler {
|
||||
set(blob: Promise<BlobFileFields | null>, highlight: Promise<string | undefined>): void
|
||||
set(blob: Promise<BlobPage_Blob | null>, highlight: Promise<string | undefined>): void
|
||||
combinedBlobData: Readable<CombinedBlobData>
|
||||
loading: Readable<boolean>
|
||||
}
|
||||
@ -160,7 +160,7 @@ export function createBlobDataHandler(): BlobDataHandler {
|
||||
let subscription: Subscription | undefined
|
||||
|
||||
return {
|
||||
set(blob: Promise<BlobFileFields | null>, highlight: Promise<string | undefined>): void {
|
||||
set(blob: Promise<BlobPage_Blob | null>, highlight: Promise<string | undefined>): void {
|
||||
subscription?.unsubscribe()
|
||||
loading.set(true)
|
||||
subscription = from(blob)
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { expect, describe, it, vi, beforeAll, afterAll } from 'vitest'
|
||||
|
||||
import type { HistoryResult } from '$lib/graphql-operations'
|
||||
import { createHistoryResults } from '$testdata'
|
||||
import { createHistoryResults } from '$testing/testdata'
|
||||
|
||||
import { createHistoryPanelStore } from './stores'
|
||||
|
||||
describe('createHistoryPanelStore', () => {
|
||||
const historyResults: HistoryResult[] = createHistoryResults(2, 2)
|
||||
const historyResults = createHistoryResults(2, 2)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { memoize } from 'lodash'
|
||||
import { writable, type Readable, type Writable } from 'svelte/store'
|
||||
|
||||
import type { HistoryResult } from '$lib/graphql-operations'
|
||||
import { createEmptySingleSelectTreeState, type TreeState } from '$lib/TreeView'
|
||||
|
||||
import type { HistoryPanel_HistoryConnection } from './HistoryPanel.gql'
|
||||
|
||||
export const sidebarOpen = writable(true)
|
||||
|
||||
/**
|
||||
@ -17,35 +18,39 @@ export const getSidebarFileTreeStateForRepo = memoize(
|
||||
|
||||
interface HistoryPanelStoreValue {
|
||||
loading: boolean
|
||||
history?: HistoryResult
|
||||
history?: HistoryPanel_HistoryConnection
|
||||
error?: Error
|
||||
}
|
||||
|
||||
interface HistoryPanelStore extends Readable<HistoryPanelStoreValue> {
|
||||
capture(): HistoryResult | null
|
||||
restore(result: HistoryResult | null): void
|
||||
loadMore(fetch: (pageInfo: HistoryResult['pageInfo']) => Promise<HistoryResult>): void
|
||||
capture(): HistoryPanel_HistoryConnection | null
|
||||
restore(result: HistoryPanel_HistoryConnection | null): void
|
||||
loadMore(fetch: (afterCursor: string | null) => Promise<HistoryPanel_HistoryConnection | null>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a store for properly handling history panel state. Having this logic in a separate
|
||||
* store makes it easier to handle promises.
|
||||
*/
|
||||
export function createHistoryPanelStore(initialHistory: Promise<HistoryResult>): HistoryPanelStore {
|
||||
export function createHistoryPanelStore(
|
||||
initialHistory: Promise<HistoryPanel_HistoryConnection | null>
|
||||
): HistoryPanelStore {
|
||||
let loading = true
|
||||
let history: HistoryResult | null = null
|
||||
let currentPromise: Promise<HistoryResult> | null = initialHistory
|
||||
let history: HistoryPanel_HistoryConnection | null = null
|
||||
let currentPromise: Promise<HistoryPanel_HistoryConnection | null> | null = initialHistory
|
||||
|
||||
const { subscribe, set, update } = writable<HistoryPanelStoreValue>({ loading })
|
||||
|
||||
function processPromise(promise: Promise<HistoryResult>) {
|
||||
function processPromise(promise: Promise<HistoryPanel_HistoryConnection | null>): void {
|
||||
currentPromise = promise
|
||||
loading = true
|
||||
update(state => ({ ...state, loading, error: undefined }))
|
||||
promise.then(result => {
|
||||
if (promise === currentPromise) {
|
||||
// Don't update data when promise is "stale"
|
||||
history = { pageInfo: result.pageInfo, nodes: [...(history?.nodes ?? []), ...result.nodes] }
|
||||
history = result
|
||||
? { pageInfo: result.pageInfo, nodes: [...(history?.nodes ?? []), ...result.nodes] }
|
||||
: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }
|
||||
loading = false
|
||||
set({ history, loading })
|
||||
}
|
||||
@ -70,7 +75,7 @@ export function createHistoryPanelStore(initialHistory: Promise<HistoryResult>):
|
||||
if (loading || !history || !history.pageInfo.hasNextPage) {
|
||||
return
|
||||
}
|
||||
processPromise(fetch(history.pageInfo))
|
||||
processPromise(fetch(history.pageInfo.endCursor))
|
||||
},
|
||||
capture() {
|
||||
return history
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { resolvePath } from '@sveltejs/kit'
|
||||
|
||||
import type { ResolvedRevision } from '$lib/repo/api/repo'
|
||||
import type { ResolvedRevision } from '../../routes/[...repo=reporev]/+layout'
|
||||
|
||||
const TREE_ROUTE_ID = '/[...repo=reporev]/(validrev)/(code)/-/tree/[...path]'
|
||||
|
||||
|
||||
14
client/web-sveltekit/src/lib/search/input/SearchInput.gql
Normal file
14
client/web-sveltekit/src/lib/search/input/SearchInput.gql
Normal file
@ -0,0 +1,14 @@
|
||||
# Needed for search context suggestions for the current user
|
||||
fragment SearchInput_AuthenticatedUser on User {
|
||||
id
|
||||
organizations {
|
||||
nodes {
|
||||
__typename
|
||||
id
|
||||
name
|
||||
displayName
|
||||
url
|
||||
settingsURL
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { writable, type Readable } from 'svelte/store'
|
||||
|
||||
import { goto } from '$app/navigation'
|
||||
import { SearchPatternType } from '$lib/graphql-operations'
|
||||
import { buildSearchURLQuery, type SettingsCascade } from '$lib/shared'
|
||||
import { buildSearchURLQuery, type Settings } from '$lib/shared'
|
||||
import { defaultSearchModeFromSettings } from '$lib/web'
|
||||
|
||||
// Defined in @sourcegraph/shared/src/search/searchQueryState.tsx
|
||||
@ -23,7 +23,7 @@ interface Options {
|
||||
}
|
||||
|
||||
type QuerySettings = Pick<
|
||||
SettingsCascade['final'],
|
||||
Settings,
|
||||
'search.defaultCaseSensitive' | 'search.defaultPatternType' | 'search.defaultMode'
|
||||
> | null
|
||||
export type QueryOptions = Pick<Options, 'patternType' | 'caseSensitive' | 'searchMode' | 'searchContext'>
|
||||
|
||||
@ -7,9 +7,6 @@ export {
|
||||
parseQueryAndHash,
|
||||
buildSearchURLQuery,
|
||||
makeRepoURI,
|
||||
type RevisionSpec,
|
||||
type ResolvedRevisionSpec,
|
||||
type RepoSpec,
|
||||
} from '@sourcegraph/shared/src/util/url'
|
||||
export {
|
||||
isCloneInProgressErrorLike,
|
||||
@ -58,18 +55,11 @@ export {
|
||||
rankByLine,
|
||||
truncateGroups,
|
||||
} from '@sourcegraph/shared/src/components/ranking/PerFileResultRanking'
|
||||
export { type AuthenticatedUser, currentAuthStateQuery } from '@sourcegraph/shared/src/auth'
|
||||
export { filterExists } from '@sourcegraph/shared/src/search/query/validate'
|
||||
export { FilterType } from '@sourcegraph/shared/src/search/query/filters'
|
||||
export { getGlobalSearchContextFilter, findFilter, FilterKind } from '@sourcegraph/shared/src/search/query/query'
|
||||
export { omitFilter, appendFilter, updateFilter } from '@sourcegraph/shared/src/search/query/transformer'
|
||||
export {
|
||||
type SettingsCascade,
|
||||
type SettingsSubject,
|
||||
type SettingsCascadeOrError,
|
||||
SettingsProvider,
|
||||
gqlToCascade,
|
||||
} from '@sourcegraph/shared/src/settings/settings'
|
||||
export { type Settings, SettingsProvider } from '@sourcegraph/shared/src/settings/settings'
|
||||
export { fetchStreamSuggestions } from '@sourcegraph/shared/src/search/suggestions'
|
||||
export { QueryChangeSource, type QueryState } from '@sourcegraph/shared/src/search/helpers'
|
||||
export { migrateLocalStorageToTemporarySettings } from '@sourcegraph/shared/src/settings/temporary/migrateLocalStorageToTemporarySettings'
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { getContext } from 'svelte'
|
||||
import { readable, writable, type Readable, type Writable } from 'svelte/store'
|
||||
|
||||
import type { SettingsCascade, AuthenticatedUser, TemporarySettingsStorage } from '$lib/shared'
|
||||
import type { Settings, TemporarySettingsStorage } from '$lib/shared'
|
||||
|
||||
import type { AuthenticatedUser, FeatureFlag } from '../routes/layout.gql'
|
||||
|
||||
import type { FeatureFlag } from './featureflags'
|
||||
import type { GraphQLClient } from './graphql'
|
||||
|
||||
export { isLightTheme } from './theme'
|
||||
|
||||
export interface SourcegraphContext {
|
||||
settings: Readable<SettingsCascade['final'] | null>
|
||||
settings: Readable<Settings | null>
|
||||
user: Readable<AuthenticatedUser | null>
|
||||
temporarySettingsStorage: Readable<TemporarySettingsStorage>
|
||||
featureFlags: Readable<FeatureFlag[]>
|
||||
@ -31,7 +32,7 @@ export const user = {
|
||||
}
|
||||
|
||||
export const settings = {
|
||||
subscribe(subscriber: (settings: SettingsCascade['final'] | null) => void) {
|
||||
subscribe(subscriber: (settings: Settings | null) => void) {
|
||||
const { settings } = getStores()
|
||||
return settings.subscribe(subscriber)
|
||||
},
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { viewerSettingsQuery } from '@sourcegraph/shared/src/backend/settings'
|
||||
|
||||
import { gql, query } from '$lib/graphql'
|
||||
import type { ViewerSettingsResult } from '$lib/graphql/shared'
|
||||
import { gqlToCascade } from '$lib/shared'
|
||||
import type { SettingsCascadeOrError } from '$lib/shared'
|
||||
|
||||
export { viewerSettingsQuery }
|
||||
|
||||
export async function fetchUserSettings(): Promise<SettingsCascadeOrError> {
|
||||
const data = await query<ViewerSettingsResult>(gql(viewerSettingsQuery))
|
||||
if (!data?.viewerSettings) {
|
||||
throw new Error('Unable to fetch user settings')
|
||||
}
|
||||
|
||||
return gqlToCascade(data.viewerSettings)
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { query, gql } from '$lib/graphql'
|
||||
import type { CurrentAuthStateResult } from '$lib/graphql/shared'
|
||||
import { currentAuthStateQuery, type AuthenticatedUser } from '$lib/shared'
|
||||
|
||||
export async function fetchAuthenticatedUser(): Promise<AuthenticatedUser | null> {
|
||||
const result = await query<CurrentAuthStateResult>(gql(currentAuthStateQuery))
|
||||
return result.currentUser
|
||||
}
|
||||
@ -16,13 +16,13 @@
|
||||
import { beforeNavigate } from '$app/navigation'
|
||||
|
||||
import type { LayoutData, Snapshot } from './$types'
|
||||
import { createFeatureFlagStore, fetchEvaluatedFeatureFlags } from '$lib/featureflags'
|
||||
import { createFeatureFlagStore } from '$lib/featureflags'
|
||||
import InfoBanner from './InfoBanner.svelte'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
const user = writable(data.user ?? null)
|
||||
const settings = writable(isErrorLike(data.settings) ? null : data.settings.final)
|
||||
const settings = writable(isErrorLike(data.settings) ? null : data.settings)
|
||||
// It's OK to set the temporary storage during initialization time because
|
||||
// sign-in/out currently performs a full page refresh
|
||||
const temporarySettingsStorage = createTemporarySettingsStorage(
|
||||
@ -33,13 +33,13 @@
|
||||
user,
|
||||
settings,
|
||||
temporarySettingsStorage,
|
||||
featureFlags: createFeatureFlagStore(data.featureFlags, fetchEvaluatedFeatureFlags),
|
||||
featureFlags: createFeatureFlagStore(data.featureFlags, data.fetchEvaluatedFeatureFlags),
|
||||
client: readable(data.graphqlClient),
|
||||
})
|
||||
|
||||
// Update stores when data changes
|
||||
$: $user = data.user ?? null
|
||||
$: $settings = isErrorLike(data.settings) ? null : data.settings.final
|
||||
$: $settings = isErrorLike(data.settings) ? null : data.settings
|
||||
|
||||
// Set initial, user configured theme
|
||||
// TODO: This should be send be server in the HTML so that we don't flash the wrong theme
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
|
||||
import { browser } from '$app/environment'
|
||||
import { fetchEvaluatedFeatureFlags } from '$lib/featureflags'
|
||||
import { isErrorLike, parseJSONCOrError } from '$lib/common'
|
||||
import { getGraphQLClient } from '$lib/graphql'
|
||||
import { fetchUserSettings } from '$lib/user/api/settings'
|
||||
import { fetchAuthenticatedUser } from '$lib/user/api/user'
|
||||
import type { Settings } from '$lib/shared'
|
||||
|
||||
import type { LayoutLoad } from './$types'
|
||||
import { Init, EvaluatedFeatureFlagsQuery } from './layout.gql'
|
||||
|
||||
// Disable server side rendering for the whole app
|
||||
export const ssr = false
|
||||
@ -21,10 +23,24 @@ if (browser) {
|
||||
}
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = () => ({
|
||||
graphqlClient: getGraphQLClient(),
|
||||
user: fetchAuthenticatedUser(),
|
||||
// Initial user settings
|
||||
settings: fetchUserSettings(),
|
||||
featureFlags: fetchEvaluatedFeatureFlags(),
|
||||
})
|
||||
export const load: LayoutLoad = async () => {
|
||||
const graphqlClient = await getGraphQLClient()
|
||||
const result = await graphqlClient.query({ query: Init, fetchPolicy: 'no-cache' })
|
||||
|
||||
const settings = parseJSONCOrError<Settings>(result.data.viewerSettings.final)
|
||||
if (isErrorLike(settings)) {
|
||||
throw error(500, `Failed to parse user settings: ${settings.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
graphqlClient,
|
||||
user: result.data.currentUser,
|
||||
// Initial user settings
|
||||
settings,
|
||||
featureFlags: result.data.evaluatedFeatureFlags,
|
||||
fetchEvaluatedFeatureFlags: async () => {
|
||||
const result = await graphqlClient.query({ query: EvaluatedFeatureFlagsQuery, fetchPolicy: 'no-cache' })
|
||||
return result.data.evaluatedFeatureFlags
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
3
client/web-sveltekit/src/routes/Header.gql
Normal file
3
client/web-sveltekit/src/routes/Header.gql
Normal file
@ -0,0 +1,3 @@
|
||||
fragment Header_User on User {
|
||||
...UserMenu_User
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
import { mdiBookOutline, mdiChartBar, mdiMagnify } from '@mdi/js'
|
||||
|
||||
import { mark, svelteLogoEnabled } from '$lib/images'
|
||||
import type { AuthenticatedUser } from '$lib/shared'
|
||||
|
||||
import HeaderNavLink from './HeaderNavLink.svelte'
|
||||
import { Button } from '$lib/wildcard'
|
||||
@ -12,8 +11,9 @@
|
||||
import CodyIcon from '$lib/icons/Cody.svelte'
|
||||
import CodeMonitoringIcon from '$lib/icons/CodeMonitoring.svelte'
|
||||
import BatchChangesIcon from '$lib/icons/BatchChanges.svelte'
|
||||
import type { Header_User } from './Header.gql'
|
||||
|
||||
export let authenticatedUser: AuthenticatedUser | null | undefined
|
||||
export let authenticatedUser: Header_User | null | undefined
|
||||
|
||||
$: reactURL = (function (url) {
|
||||
const urlCopy = new URL(url)
|
||||
@ -58,7 +58,7 @@
|
||||
</Tooltip>
|
||||
<div>
|
||||
{#if authenticatedUser}
|
||||
<UserMenu {authenticatedUser} />
|
||||
<UserMenu user={authenticatedUser} />
|
||||
{:else}
|
||||
<Button variant="secondary" outline>
|
||||
<svelte:fragment slot="custom" let:buttonClass>
|
||||
|
||||
14
client/web-sveltekit/src/routes/UserMenu.gql
Normal file
14
client/web-sveltekit/src/routes/UserMenu.gql
Normal file
@ -0,0 +1,14 @@
|
||||
fragment UserMenu_User on User {
|
||||
settingsURL
|
||||
username
|
||||
siteAdmin
|
||||
organizations {
|
||||
nodes {
|
||||
name
|
||||
displayName
|
||||
url
|
||||
settingsURL
|
||||
}
|
||||
}
|
||||
...Avatar_User
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import UserAvatar from '$lib/UserAvatar.svelte'
|
||||
import type { AuthenticatedUser } from '$lib/shared'
|
||||
import Avatar from '$lib/Avatar.svelte'
|
||||
import type { UserMenu_User } from './UserMenu.gql'
|
||||
import { humanTheme } from '$lib/theme'
|
||||
import { DropdownMenu, MenuLink, MenuRadioGroup, MenuSeparator, Submenu } from '$lib/wildcard'
|
||||
import { getButtonClassName } from '$lib/wildcard/Button'
|
||||
@ -10,10 +10,10 @@
|
||||
|
||||
const MAX_VISIBLE_ORGS = 5
|
||||
|
||||
export let authenticatedUser: AuthenticatedUser
|
||||
export let user: UserMenu_User
|
||||
|
||||
const open = writable(false)
|
||||
$: organizations = authenticatedUser.organizations.nodes
|
||||
$: organizations = user.organizations.nodes
|
||||
</script>
|
||||
|
||||
<DropdownMenu
|
||||
@ -22,13 +22,13 @@
|
||||
aria-label="{$open ? 'Close' : 'Open'} user profile menu"
|
||||
>
|
||||
<svelte:fragment slot="trigger">
|
||||
<UserAvatar user={authenticatedUser} />
|
||||
<Avatar avatar={user} />
|
||||
<Icon svgPath={$open ? mdiChevronUp : mdiChevronDown} aria-hidden={true} inline />
|
||||
</svelte:fragment>
|
||||
<h6>Signed in as <strong>@{authenticatedUser.username}</strong></h6>
|
||||
<h6>Signed in as <strong>@{user.username}</strong></h6>
|
||||
<MenuSeparator />
|
||||
<MenuLink href={authenticatedUser.settingsURL} data-sveltekit-reload>Settings</MenuLink>
|
||||
<MenuLink href="/users/{authenticatedUser.username}/searches" data-sveltekit-reload>Saved searches</MenuLink>
|
||||
<MenuLink href={user.settingsURL} data-sveltekit-reload>Settings</MenuLink>
|
||||
<MenuLink href="/users/{user.username}/searches" data-sveltekit-reload>Saved searches</MenuLink>
|
||||
<MenuLink href="/teams" data-sveltekit-reload>Teams</MenuLink>
|
||||
<MenuSeparator />
|
||||
<Submenu>
|
||||
@ -44,11 +44,11 @@
|
||||
</MenuLink>
|
||||
{/each}
|
||||
{#if organizations.length > MAX_VISIBLE_ORGS}
|
||||
<MenuLink href={authenticatedUser.settingsURL}>Show all organizations</MenuLink>
|
||||
<MenuLink href={user.settingsURL}>Show all organizations</MenuLink>
|
||||
{/if}
|
||||
{/if}
|
||||
<MenuSeparator />
|
||||
{#if authenticatedUser.siteAdmin}
|
||||
{#if user.siteAdmin}
|
||||
<MenuLink href="/site-admin" data-sveltekit-reload>Site admin</MenuLink>
|
||||
{/if}
|
||||
<MenuLink href="/help" target="_blank" rel="noopener">
|
||||
|
||||
@ -69,6 +69,15 @@
|
||||
})
|
||||
}
|
||||
|
||||
function fetchCommitHistory(afterCursor: string | null) {
|
||||
return data.fetchCommitHistory({
|
||||
repo: data.resolvedRevision.repo.id,
|
||||
revspec: data.resolvedRevision.commitID,
|
||||
filePath: $page.params.path ?? '',
|
||||
afterCursor,
|
||||
})
|
||||
}
|
||||
|
||||
$: ({ revision, parentPath, resolvedRevision } = data)
|
||||
$: commitID = resolvedRevision.commitID
|
||||
$: repoID = resolvedRevision.repo.id
|
||||
@ -107,7 +116,7 @@
|
||||
{/if}
|
||||
<div class="main">
|
||||
<slot />
|
||||
<BottomPanel bind:this={bottomPanel} history={data.deferred.codeCommits} />
|
||||
<BottomPanel bind:this={bottomPanel} history={data.deferred.commitHistory} {fetchCommitHistory} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { dirname } from 'path'
|
||||
|
||||
import { browser } from '$app/environment'
|
||||
import { fetchRepoCommits } from '$lib/repo/api/commits'
|
||||
import type { Scalars } from '$lib/graphql-types'
|
||||
import { fetchSidebarFileTree } from '$lib/repo/api/tree'
|
||||
|
||||
import type { LayoutLoad } from './$types'
|
||||
import { GitHistoryQuery, type GitHistory_HistoryConnection } from './layout.gql'
|
||||
|
||||
const HISTORY_COMMITS_PER_PAGE = 20
|
||||
|
||||
// Signifies the path of the repository root
|
||||
const REPO_ROOT = '.'
|
||||
@ -24,18 +27,52 @@ if (browser) {
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchCommitHistoryArgs {
|
||||
repo: Scalars['ID']['input']
|
||||
revspec: string
|
||||
filePath: string
|
||||
afterCursor: string | null
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = async ({ parent, params }) => {
|
||||
const { resolvedRevision, repoName } = await parent()
|
||||
const { resolvedRevision, repoName, graphqlClient } = await parent()
|
||||
const parentPath = getRootPath(repoName, params.path ? dirname(params.path) : REPO_ROOT)
|
||||
|
||||
function fetchCommitHistory({
|
||||
repo,
|
||||
revspec,
|
||||
filePath,
|
||||
afterCursor,
|
||||
}: FetchCommitHistoryArgs): Promise<GitHistory_HistoryConnection | null> {
|
||||
return graphqlClient
|
||||
.query({
|
||||
query: GitHistoryQuery,
|
||||
variables: {
|
||||
repo,
|
||||
revspec,
|
||||
filePath,
|
||||
first: HISTORY_COMMITS_PER_PAGE,
|
||||
afterCursor,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected repository')
|
||||
}
|
||||
return result.data.node.commit?.ancestors ?? null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
parentPath,
|
||||
fetchCommitHistory,
|
||||
deferred: {
|
||||
// Fetches the most recent commits for current blob, tree or repo root
|
||||
codeCommits: fetchRepoCommits({
|
||||
repoID: resolvedRevision.repo.id,
|
||||
revision: resolvedRevision.commitID,
|
||||
filePath: params.path,
|
||||
commitHistory: fetchCommitHistory({
|
||||
repo: resolvedRevision.repo.id,
|
||||
revspec: resolvedRevision.commitID,
|
||||
filePath: params.path ?? '',
|
||||
afterCursor: null,
|
||||
}),
|
||||
fileTree: fetchSidebarFileTree({
|
||||
repoID: resolvedRevision.repo.id,
|
||||
|
||||
@ -5,16 +5,14 @@
|
||||
import SidebarToggleButton from '$lib/repo/SidebarToggleButton.svelte'
|
||||
import { sidebarOpen } from '$lib/repo/stores'
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
import Readme from '$lib/repo/Readme.svelte'
|
||||
import type { RepoPage_Readme } from './page.gql'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const {
|
||||
value: readme,
|
||||
set: setReadme,
|
||||
pending: readmePending,
|
||||
} = createPromiseStore<PageData['deferred']['readme']>()
|
||||
const { value: readme, set: setReadme, pending: readmePending } = createPromiseStore<RepoPage_Readme | null>()
|
||||
$: setReadme(data.deferred.readme)
|
||||
</script>
|
||||
|
||||
@ -31,10 +29,8 @@
|
||||
{/if}
|
||||
</h3>
|
||||
<div class="content">
|
||||
{#if $readme?.richHTML}
|
||||
{@html $readme.richHTML}
|
||||
{:else if $readme?.content}
|
||||
<pre>{$readme.content}</pre>
|
||||
{#if $readme}
|
||||
<Readme file={$readme} />
|
||||
{:else if !$readmePending}
|
||||
{data.resolvedRevision.repo.description}
|
||||
{/if}
|
||||
@ -72,13 +68,5 @@
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
|
||||
:global(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { fetchBlobPlaintext } from '$lib/repo/api/blob'
|
||||
import { findReadme } from '$lib/repo/tree'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { RepoPageReadmeQuery } from './page.gql'
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { resolvedRevision, deferred } = await parent()
|
||||
const { resolvedRevision, deferred, graphqlClient } = await parent()
|
||||
|
||||
return {
|
||||
deferred: {
|
||||
@ -14,19 +14,21 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
if (!readme) {
|
||||
return null
|
||||
}
|
||||
return fetchBlobPlaintext({
|
||||
repoID: resolvedRevision.repo.id,
|
||||
commitID: resolvedRevision.commitID,
|
||||
filePath: readme.path,
|
||||
}).then(result =>
|
||||
result
|
||||
? {
|
||||
name: readme.name,
|
||||
content: result.content,
|
||||
richHTML: result.richHTML,
|
||||
}
|
||||
: null
|
||||
)
|
||||
return graphqlClient
|
||||
.query({
|
||||
query: RepoPageReadmeQuery,
|
||||
variables: {
|
||||
repoID: resolvedRevision.repo.id,
|
||||
revspec: resolvedRevision.commitID,
|
||||
path: readme.path,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node.commit?.blob ?? null
|
||||
})
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
import FileHeader from '$lib/repo/FileHeader.svelte'
|
||||
import Permalink from '$lib/repo/Permalink.svelte'
|
||||
|
||||
import FileDiff from '../../../../-/commit/[...revspec]/FileDiff.svelte'
|
||||
import FileDiff from '$lib/repo/FileDiff.svelte'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
import FormatAction from './FormatAction.svelte'
|
||||
@ -26,7 +26,8 @@
|
||||
|
||||
const {
|
||||
revision,
|
||||
resolvedRevision: { commitID, repo },
|
||||
resolvedRevision: { commitID },
|
||||
repoName,
|
||||
filePath,
|
||||
settings,
|
||||
graphqlClient,
|
||||
@ -67,7 +68,7 @@
|
||||
{#if formatted}
|
||||
<FormatAction />
|
||||
{/if}
|
||||
<Permalink resolvedRevision={data.resolvedRevision} />
|
||||
<Permalink commitID={data.resolvedRevision.commitID} />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</FileHeader>
|
||||
@ -94,7 +95,7 @@
|
||||
...blobData,
|
||||
revision: revision ?? '',
|
||||
commitID,
|
||||
repoName: repo.name,
|
||||
repoName: repoName,
|
||||
filePath,
|
||||
}}
|
||||
highlights={$combinedBlobData.highlights || ''}
|
||||
|
||||
@ -1,31 +1,62 @@
|
||||
import { fetchHighlight, fetchBlobPlaintext } from '$lib/repo/api/blob'
|
||||
import { fetchDiff } from '$lib/repo/api/commits'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { BlobDiffQuery, BlobPageQuery, BlobSyntaxHighlightQuery } from './page.gql'
|
||||
|
||||
export const load: PageLoad = async ({ parent, params, url }) => {
|
||||
const revisionToCompare = url.searchParams.get('rev')
|
||||
const { resolvedRevision } = await parent()
|
||||
const { resolvedRevision, graphqlClient } = await parent()
|
||||
|
||||
return {
|
||||
filePath: params.path,
|
||||
deferred: {
|
||||
blob: fetchBlobPlaintext({
|
||||
filePath: params.path,
|
||||
repoID: resolvedRevision.repo.id,
|
||||
commitID: resolvedRevision.commitID,
|
||||
}),
|
||||
highlights: fetchHighlight({
|
||||
filePath: params.path,
|
||||
repoID: resolvedRevision.repo.id,
|
||||
commitID: resolvedRevision.commitID,
|
||||
}).then(highlight => highlight?.lsif),
|
||||
blob: graphqlClient
|
||||
.query({
|
||||
query: BlobPageQuery,
|
||||
variables: {
|
||||
repoID: resolvedRevision.repo.id,
|
||||
revspec: resolvedRevision.commitID,
|
||||
path: params.path,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository' || !result.data.node.commit?.blob) {
|
||||
throw new Error('Commit or file not found')
|
||||
}
|
||||
return result.data.node.commit.blob
|
||||
}),
|
||||
highlights: graphqlClient
|
||||
.query({
|
||||
query: BlobSyntaxHighlightQuery,
|
||||
variables: {
|
||||
repoID: resolvedRevision.repo.id,
|
||||
revspec: resolvedRevision.commitID,
|
||||
path: params.path,
|
||||
disableTimeout: false,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node.commit?.blob?.highlight.lsif
|
||||
}),
|
||||
compare: revisionToCompare
|
||||
? {
|
||||
revisionToCompare,
|
||||
diff: fetchDiff(resolvedRevision.repo.id, revisionToCompare, [params.path]).then(
|
||||
nodes => nodes[0]
|
||||
),
|
||||
diff: graphqlClient
|
||||
.query({
|
||||
query: BlobDiffQuery,
|
||||
variables: {
|
||||
repoID: resolvedRevision.repo.id,
|
||||
revspec: revisionToCompare,
|
||||
paths: [params.path],
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node.commit?.diff.fileDiffs.nodes[0]
|
||||
}),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
query BlobDiffQuery($repoID: ID!, $revspec: String!, $paths: [String!]) {
|
||||
node(id: $repoID) {
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
diff {
|
||||
fileDiffs(paths: $paths) {
|
||||
nodes {
|
||||
...FileDiff_Diff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query BlobPageQuery($repoID: ID!, $revspec: String!, $path: String!) {
|
||||
node(id: $repoID) {
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
oid
|
||||
blob(path: $path) {
|
||||
...BlobPage_Blob
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment BlobPage_Blob on GitBlob {
|
||||
canonicalURL
|
||||
content
|
||||
richHTML
|
||||
languages
|
||||
}
|
||||
|
||||
query BlobSyntaxHighlightQuery($repoID: ID!, $revspec: String!, $path: String!, $disableTimeout: Boolean!) {
|
||||
node(id: $repoID) {
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
blob(path: $path) {
|
||||
canonicalURL
|
||||
highlight(disableTimeout: $disableTimeout) {
|
||||
aborted
|
||||
lsif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,22 +2,20 @@
|
||||
import { mdiFileDocumentOutline, mdiFolderOutline } from '@mdi/js'
|
||||
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
|
||||
import FileHeader from '$lib/repo/FileHeader.svelte'
|
||||
import Permalink from '$lib/repo/Permalink.svelte'
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
import type { TreeWithCommitInfo } from './page.gql'
|
||||
|
||||
import FileDiff from '../../../../-/commit/[...revspec]/FileDiff.svelte'
|
||||
import type { TreePage_TreeWithCommitInfo, TreePage_Readme } from './page.gql'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
import FileTable from '$lib/repo/FileTable.svelte'
|
||||
import Readme from '$lib/repo/Readme.svelte'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { value: tree, set: setTree } = createPromiseStore<PageData['deferred']['treeEntries']>()
|
||||
const { value: commitInfo, set: setCommitInfo } = createPromiseStore<Promise<TreeWithCommitInfo | null>>()
|
||||
const { value: readme, set: setReadme } = createPromiseStore<PageData['deferred']['readme']>()
|
||||
const { value: commitInfo, set: setCommitInfo } = createPromiseStore<Promise<TreePage_TreeWithCommitInfo | null>>()
|
||||
const { value: readme, set: setReadme } = createPromiseStore<Promise<TreePage_Readme | null>>()
|
||||
|
||||
$: setTree(data.deferred.treeEntries)
|
||||
$: setCommitInfo(data.deferred.commitInfo)
|
||||
@ -33,22 +31,12 @@
|
||||
<FileHeader>
|
||||
<Icon slot="icon" svgPath={mdiFolderOutline} />
|
||||
<svelte:fragment slot="actions">
|
||||
<Permalink resolvedRevision={data.resolvedRevision} />
|
||||
<Permalink commitID={data.resolvedRevision.commitID} />
|
||||
</svelte:fragment>
|
||||
</FileHeader>
|
||||
|
||||
<div class="content">
|
||||
{#if data.deferred.compare}
|
||||
{#await data.deferred.compare.diff}
|
||||
<LoadingSpinner />
|
||||
{:then nodes}
|
||||
{#each nodes as fileDiff}
|
||||
<FileDiff {fileDiff} expanded={false} />
|
||||
{/each}
|
||||
{/await}
|
||||
{:else}
|
||||
<FileTable revision={data.revision ?? ''} {entries} commitInfo={entriesWithCommitInfo} />
|
||||
{/if}
|
||||
<FileTable revision={data.revision ?? ''} {entries} commitInfo={entriesWithCommitInfo} />
|
||||
{#if $readme}
|
||||
<h4 class="header">
|
||||
<Icon svgPath={mdiFileDocumentOutline} />
|
||||
@ -56,11 +44,7 @@
|
||||
{$readme.name}
|
||||
</h4>
|
||||
<div class="readme">
|
||||
{#if $readme.richHTML}
|
||||
{@html $readme.richHTML}
|
||||
{:else if $readme.content}
|
||||
<pre>{$readme.content}</pre>
|
||||
{/if}
|
||||
<Readme file={$readme} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -83,9 +67,5 @@
|
||||
.readme {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { fetchBlobPlaintext } from '$lib/repo/api/blob'
|
||||
import { fetchDiff } from '$lib/repo/api/commits'
|
||||
import { fetchTreeEntries } from '$lib/repo/api/tree'
|
||||
import { findReadme } from '$lib/repo/tree'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { TreeEntriesCommitInfo } from './page.gql'
|
||||
import { TreePageCommitInfoQuery, TreePageReadmeQuery } from './page.gql'
|
||||
|
||||
export const load: PageLoad = async ({ params, parent, url }) => {
|
||||
const revisionToCompare = url.searchParams.get('rev')
|
||||
export const load: PageLoad = async ({ params, parent }) => {
|
||||
const { resolvedRevision, graphqlClient } = await parent()
|
||||
|
||||
const treeEntries = fetchTreeEntries({
|
||||
@ -26,7 +23,7 @@ export const load: PageLoad = async ({ params, parent, url }) => {
|
||||
treeEntries,
|
||||
commitInfo: graphqlClient
|
||||
.query({
|
||||
query: TreeEntriesCommitInfo,
|
||||
query: TreePageCommitInfoQuery,
|
||||
variables: {
|
||||
repoID: resolvedRevision.repo.id,
|
||||
commitID: resolvedRevision.commitID,
|
||||
@ -48,21 +45,22 @@ export const load: PageLoad = async ({ params, parent, url }) => {
|
||||
if (!readme) {
|
||||
return null
|
||||
}
|
||||
return fetchBlobPlaintext({
|
||||
repoID: resolvedRevision.repo.id,
|
||||
commitID: resolvedRevision.commitID,
|
||||
filePath: readme.path,
|
||||
}).then(result => ({
|
||||
name: readme.name,
|
||||
...result,
|
||||
}))
|
||||
return graphqlClient
|
||||
.query({
|
||||
query: TreePageReadmeQuery,
|
||||
variables: {
|
||||
repoID: resolvedRevision.repo.id,
|
||||
revspec: resolvedRevision.commitID,
|
||||
path: readme.path,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node.commit?.blob ?? null
|
||||
})
|
||||
}),
|
||||
compare: revisionToCompare
|
||||
? {
|
||||
revisionToCompare,
|
||||
diff: fetchDiff(resolvedRevision.repo.id, revisionToCompare, [params.path]),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
#import "$lib/repo/FileTable.gql"
|
||||
|
||||
fragment TreeWithCommitInfo on GitTree {
|
||||
fragment TreePage_TreeWithCommitInfo on GitTree {
|
||||
canonicalURL
|
||||
entries(first: $first) {
|
||||
...TreeEntryWithCommitInfo
|
||||
}
|
||||
}
|
||||
|
||||
query TreeEntriesCommitInfo($repoID: ID!, $commitID: String!, $filePath: String!, $first: Int) {
|
||||
query TreePageCommitInfoQuery($repoID: ID!, $commitID: String!, $filePath: String!, $first: Int) {
|
||||
node(id: $repoID) {
|
||||
__typename
|
||||
id
|
||||
@ -15,9 +13,29 @@ query TreeEntriesCommitInfo($repoID: ID!, $commitID: String!, $filePath: String!
|
||||
commit(rev: $commitID) {
|
||||
id
|
||||
tree(path: $filePath) {
|
||||
...TreeWithCommitInfo
|
||||
...TreePage_TreeWithCommitInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query TreePageReadmeQuery($repoID: ID!, $revspec: String!, $path: String!) {
|
||||
node(id: $repoID) {
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
blob(path: $path) {
|
||||
canonicalURL # key field
|
||||
...RepoPage_Readme
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment TreePage_Readme on GitBlob {
|
||||
name
|
||||
...Readme_Blob
|
||||
}
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
query GitHistoryQuery($repo: ID!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
|
||||
node(id: $repo) {
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
ancestors(first: $first, path: $filePath, afterCursor: $afterCursor) {
|
||||
...GitHistory_HistoryConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment GitHistory_HistoryConnection on GitCommitConnection {
|
||||
...BottomPanel_HistoryConnection
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
fragment RepoPage_ResolvedRevision on Repository {
|
||||
description
|
||||
}
|
||||
|
||||
query RepoPageReadmeQuery($repoID: ID!, $revspec: String!, $path: String!) {
|
||||
node(id: $repoID) {
|
||||
... on Repository {
|
||||
id
|
||||
commit(rev: $revspec) {
|
||||
id
|
||||
blob(path: $path) {
|
||||
canonicalURL # key field
|
||||
...RepoPage_Readme
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment RepoPage_Readme on GitBlob {
|
||||
name
|
||||
...Readme_Blob
|
||||
}
|
||||
@ -2,15 +2,16 @@
|
||||
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
|
||||
import GitReference from '$lib/repo/GitReference.svelte'
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
import type { GitBranchesOverview } from './page.gql'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { pending, value: branches, set } = createPromiseStore<PageData['deferred']['branches']>()
|
||||
$: set(data.deferred.branches)
|
||||
const { pending, value: branches, set } = createPromiseStore<GitBranchesOverview>()
|
||||
$: set(data.deferred.overview)
|
||||
$: defaultBranch = $branches?.defaultBranch
|
||||
$: activeBranches = $branches?.activeBranches
|
||||
$: activeBranches = $branches?.branches.nodes.filter(branch => branch.id !== defaultBranch?.id)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
import { queryGitBranchesOverview } from '$lib/repo/api/refs'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { GitBranchesOverviewQuery } from './page.gql'
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { resolvedRevision } = await parent()
|
||||
const { resolvedRevision, graphqlClient } = await parent()
|
||||
return {
|
||||
deferred: {
|
||||
branches: queryGitBranchesOverview({ repo: resolvedRevision.repo.id, first: 10 }),
|
||||
overview: graphqlClient
|
||||
.query({
|
||||
query: GitBranchesOverviewQuery,
|
||||
variables: {
|
||||
first: 20,
|
||||
repo: resolvedRevision.repo.id,
|
||||
withBehindAhead: true,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,15 +2,16 @@
|
||||
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
|
||||
import GitReference from '$lib/repo/GitReference.svelte'
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
import type { GitBranchesConnection } from './page.gql'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { pending, value: branches, set } = createPromiseStore<PageData['deferred']['branches']>()
|
||||
const { pending, value: connection, set } = createPromiseStore<GitBranchesConnection>()
|
||||
$: set(data.deferred.branches)
|
||||
$: nodes = $branches?.nodes
|
||||
$: total = $branches?.totalCount
|
||||
$: nodes = $connection?.nodes
|
||||
$: totalCount = $connection?.totalCount
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -29,8 +30,8 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if total !== null}
|
||||
<small class="text-muted">{total} branches total</small>
|
||||
{#if totalCount !== null}
|
||||
<small class="text-muted">{totalCount} branches total</small>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
import { GitRefType } from '$lib/graphql-operations'
|
||||
import { queryGitReferences } from '$lib/repo/api/refs'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { GitBranchesQuery } from './page.gql'
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { resolvedRevision } = await parent()
|
||||
const { resolvedRevision, graphqlClient } = await parent()
|
||||
return {
|
||||
deferred: {
|
||||
branches: queryGitReferences({
|
||||
repo: resolvedRevision.repo.id,
|
||||
type: GitRefType.GIT_BRANCH,
|
||||
first: 20,
|
||||
}),
|
||||
branches: graphqlClient
|
||||
.query({
|
||||
query: GitBranchesQuery,
|
||||
variables: {
|
||||
repo: resolvedRevision.repo.id,
|
||||
first: 20,
|
||||
withBehindAhead: true,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node.branches
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
query GitBranchesQuery($repo: ID!, $first: Int!, $withBehindAhead: Boolean!, $revspec: String = "") {
|
||||
node(id: $repo) {
|
||||
id
|
||||
... on Repository {
|
||||
branches(first: $first, orderBy: AUTHORED_OR_COMMITTED_AT) {
|
||||
...GitBranchesConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment GitBranchesConnection on GitRefConnection {
|
||||
nodes {
|
||||
id
|
||||
...GitReference_Ref
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
query GitBranchesOverviewQuery($repo: ID!, $first: Int!, $withBehindAhead: Boolean!, $revspec: String = "") {
|
||||
node(id: $repo) {
|
||||
id
|
||||
...GitBranchesOverview
|
||||
}
|
||||
}
|
||||
|
||||
fragment GitBranchesOverview on Repository {
|
||||
defaultBranch {
|
||||
...GitReference_Ref
|
||||
}
|
||||
branches(first: $first, orderBy: AUTHORED_OR_COMMITTED_AT) {
|
||||
nodes {
|
||||
id
|
||||
...GitReference_Ref
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
import FileDiff from './FileDiff.svelte'
|
||||
import FileDiff from '$lib/repo/FileDiff.svelte'
|
||||
|
||||
type Deferred = PageData['deferred']
|
||||
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
#import '$lib/Commit.gql'
|
||||
#import './FileDiff.gql'
|
||||
|
||||
query CommitQuery($repo: ID!, $revspec: String!) {
|
||||
node(id: $repo) {
|
||||
... on Repository {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
#import "$lib/Commit.gql"
|
||||
|
||||
fragment Commits on GitCommitConnection {
|
||||
nodes {
|
||||
...Commit
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
|
||||
import Paginator from '$lib/Paginator.svelte'
|
||||
import Timestamp from '$lib/Timestamp.svelte'
|
||||
import UserAvatar from '$lib/UserAvatar.svelte'
|
||||
import Avatar from '$lib/Avatar.svelte'
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
import { Button, ButtonGroup } from '$lib/wildcard'
|
||||
import type { ContributorConnection } from './page.gql'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
|
||||
@ -19,15 +20,15 @@
|
||||
['All time', ''],
|
||||
]
|
||||
|
||||
const { pending, latestValue: contributors, set } = createPromiseStore<PageData['deferred']['contributors']>()
|
||||
const { pending, latestValue: contributorConnection, set } = createPromiseStore<ContributorConnection | null>()
|
||||
$: set(data.deferred.contributors)
|
||||
|
||||
// We want to show stale contributors data when the user navigates to
|
||||
// the next or previous page for the current time period. When the user
|
||||
// changes the time period we want to show a loading indicator instead.
|
||||
let currentContributors = $contributors
|
||||
$: if (!$pending && $contributors) {
|
||||
currentContributors = $contributors
|
||||
let currentContributorConnection = $contributorConnection
|
||||
$: if (!$pending && $contributorConnection) {
|
||||
currentContributorConnection = $contributorConnection
|
||||
}
|
||||
|
||||
$: timePeriod = data.after
|
||||
@ -38,7 +39,7 @@
|
||||
const newURL = new URL($page.url)
|
||||
newURL.search = timePeriod ? `after=${timePeriod}` : ''
|
||||
// Don't show stale contributors when switching the time period
|
||||
currentContributors = null
|
||||
currentContributorConnection = null
|
||||
await goto(newURL)
|
||||
}
|
||||
</script>
|
||||
@ -67,19 +68,19 @@
|
||||
{/each}
|
||||
</ButtonGroup>
|
||||
</form>
|
||||
{#if !currentContributors && $pending}
|
||||
{#if !currentContributorConnection && $pending}
|
||||
<div class="mt-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if currentContributors}
|
||||
{@const nodes = currentContributors.nodes}
|
||||
{:else if currentContributorConnection}
|
||||
{@const nodes = currentContributorConnection.nodes}
|
||||
<table class="mt-3">
|
||||
<tbody>
|
||||
{#each nodes as contributor}
|
||||
{@const commit = contributor.commits.nodes[0]}
|
||||
<tr>
|
||||
<td
|
||||
><span><UserAvatar user={contributor.person} /></span> <span
|
||||
><span><Avatar avatar={contributor.person} /></span> <span
|
||||
>{contributor.person.displayName}</span
|
||||
></td
|
||||
>
|
||||
@ -93,9 +94,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex flex-column align-items-center">
|
||||
<Paginator disabled={$pending} pageInfo={currentContributors.pageInfo} />
|
||||
<Paginator disabled={$pending} pageInfo={currentContributorConnection.pageInfo} />
|
||||
<p class="mt-1 text-muted">
|
||||
<small>Total contributors: {currentContributors.totalCount}</small>
|
||||
<small>Total contributors: {currentContributorConnection.totalCount}</small>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -1,25 +1,35 @@
|
||||
import { getPaginationParams } from '$lib/Paginator'
|
||||
import { fetchContributors } from '$lib/repo/api/contributors'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { PagedRepositoryContributors } from './page.gql'
|
||||
|
||||
const pageSize = 20
|
||||
|
||||
export const load: PageLoad = async ({ url, parent }) => {
|
||||
const afterDate = url.searchParams.get('after') ?? ''
|
||||
const { first, last, before, after } = getPaginationParams(url.searchParams, pageSize)
|
||||
const { resolvedRevision } = await parent()
|
||||
const { resolvedRevision, graphqlClient } = await parent()
|
||||
|
||||
const contributors = fetchContributors({
|
||||
afterDate,
|
||||
repo: resolvedRevision.repo.id,
|
||||
revisionRange: '',
|
||||
path: '',
|
||||
first,
|
||||
last,
|
||||
after,
|
||||
before,
|
||||
})
|
||||
const contributors = graphqlClient
|
||||
.query({
|
||||
query: PagedRepositoryContributors,
|
||||
variables: {
|
||||
afterDate,
|
||||
repo: resolvedRevision.repo.id,
|
||||
revisionRange: '',
|
||||
path: '',
|
||||
first,
|
||||
last,
|
||||
after,
|
||||
before,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
return null
|
||||
}
|
||||
return result.data.node.contributors
|
||||
})
|
||||
return {
|
||||
after: afterDate,
|
||||
deferred: {
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
fragment ContributorConnection on RepositoryContributorConnection {
|
||||
nodes {
|
||||
count
|
||||
person {
|
||||
...Avatar_Person
|
||||
}
|
||||
commits(first: 1) {
|
||||
# Fetch information about the last commit the user made
|
||||
nodes {
|
||||
id
|
||||
author {
|
||||
date
|
||||
}
|
||||
canonicalURL
|
||||
subject
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
startCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
|
||||
query PagedRepositoryContributors(
|
||||
$repo: ID!
|
||||
$first: Int
|
||||
$last: Int
|
||||
$after: String
|
||||
$before: String
|
||||
$revisionRange: String
|
||||
$afterDate: String
|
||||
$path: String
|
||||
) {
|
||||
node(id: $repo) {
|
||||
__typename
|
||||
... on Repository {
|
||||
id
|
||||
contributors(
|
||||
first: $first
|
||||
last: $last
|
||||
before: $before
|
||||
after: $after
|
||||
revisionRange: $revisionRange
|
||||
afterDate: $afterDate
|
||||
path: $path
|
||||
) {
|
||||
...ContributorConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,16 +2,17 @@
|
||||
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
|
||||
import GitReference from '$lib/repo/GitReference.svelte'
|
||||
import { createPromiseStore } from '$lib/utils'
|
||||
import type { GitTagsConnection } from './page.gql'
|
||||
|
||||
import type { PageData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
const { pending, value: tags, set } = createPromiseStore<PageData['deferred']['tags']>()
|
||||
const { pending, value: connection, set } = createPromiseStore<GitTagsConnection>()
|
||||
$: set(data.deferred.tags)
|
||||
|
||||
$: nodes = $tags?.nodes
|
||||
$: total = $tags?.totalCount
|
||||
$: nodes = $connection?.nodes
|
||||
$: total = $connection?.totalCount
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
import { GitRefType } from '$lib/graphql-operations'
|
||||
import { queryGitReferences } from '$lib/repo/api/refs'
|
||||
|
||||
import type { PageLoad } from './$types'
|
||||
import { GitTagsQuery } from './page.gql'
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
const { resolvedRevision } = await parent()
|
||||
const { resolvedRevision, graphqlClient } = await parent()
|
||||
|
||||
return {
|
||||
deferred: {
|
||||
tags: queryGitReferences({
|
||||
repo: resolvedRevision.repo.id,
|
||||
type: GitRefType.GIT_TAG,
|
||||
first: 20,
|
||||
}),
|
||||
tags: graphqlClient
|
||||
.query({
|
||||
query: GitTagsQuery,
|
||||
variables: {
|
||||
repo: resolvedRevision.repo.id,
|
||||
first: 20,
|
||||
withBehindAhead: false,
|
||||
},
|
||||
})
|
||||
.then(result => {
|
||||
if (result.data.node?.__typename !== 'Repository') {
|
||||
throw new Error('Expected Repository')
|
||||
}
|
||||
return result.data.node.gitRefs
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
query GitTagsQuery($repo: ID!, $first: Int!, $withBehindAhead: Boolean!, $revspec: String = "") {
|
||||
node(id: $repo) {
|
||||
id
|
||||
... on Repository {
|
||||
gitRefs(first: $first, type: GIT_TAG, orderBy: AUTHORED_OR_COMMITTED_AT) {
|
||||
...GitTagsConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment GitTagsConnection on GitRefConnection {
|
||||
nodes {
|
||||
id
|
||||
...GitReference_Ref
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
@ -1,12 +1,30 @@
|
||||
import { redirect, error, type Redirect } from '@sveltejs/kit'
|
||||
|
||||
import { asError, loadMarkdownSyntaxHighlighting, type ErrorLike } from '$lib/common'
|
||||
import { resolveRepoRevision, type ResolvedRevision } from '$lib/repo/api/repo'
|
||||
import { displayRepoName, isRepoSeeOtherErrorLike, isRevisionNotFoundErrorLike, parseRepoRevision } from '$lib/shared'
|
||||
import type { GraphQLClient } from '$lib/graphql'
|
||||
import {
|
||||
CloneInProgressError,
|
||||
RepoNotFoundError,
|
||||
RepoSeeOtherError,
|
||||
RevisionNotFoundError,
|
||||
displayRepoName,
|
||||
isRepoSeeOtherErrorLike,
|
||||
isRevisionNotFoundErrorLike,
|
||||
parseRepoRevision,
|
||||
} from '$lib/shared'
|
||||
|
||||
import type { LayoutLoad } from './$types'
|
||||
import { ResolveRepoRevison, ResolvedRepository } from './layout.gql'
|
||||
|
||||
export interface ResolvedRevision {
|
||||
repo: ResolvedRepository
|
||||
commitID: string
|
||||
defaultBranch: string
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = async ({ parent, params, url, depends }) => {
|
||||
const { graphqlClient: client } = await parent()
|
||||
|
||||
export const load: LayoutLoad = async ({ params, url, depends }) => {
|
||||
// This allows other places to reload all repo related data by calling
|
||||
// invalidate('repo:root')
|
||||
depends('repo:root')
|
||||
@ -20,7 +38,7 @@ export const load: LayoutLoad = async ({ params, url, depends }) => {
|
||||
let resolvedRevisionOrError: ResolvedRevision | ErrorLike
|
||||
|
||||
try {
|
||||
resolvedRevisionOrError = await resolveRepoRevision({ repoName, revision })
|
||||
resolvedRevisionOrError = await resolveRepoRevision({ client, repoName, revision })
|
||||
} catch (repoError: unknown) {
|
||||
const redirect = isRepoSeeOtherErrorLike(repoError)
|
||||
|
||||
@ -28,7 +46,7 @@ export const load: LayoutLoad = async ({ params, url, depends }) => {
|
||||
throw redirectToExternalHost(redirect, url)
|
||||
}
|
||||
|
||||
// TODO: use differen error codes for different types of errors
|
||||
// TODO: use differenr error codes for different types of errors
|
||||
// Let revision errors be handled by the nested layout so that we can
|
||||
// still render the main repo navigation and header
|
||||
if (!isRevisionNotFoundErrorLike(repoError)) {
|
||||
@ -55,3 +73,72 @@ function redirectToExternalHost(externalRedirectURL: string, currentURL: URL): R
|
||||
redirectURL.protocol = externalHostURL.protocol
|
||||
return redirect(303, redirectURL.toString())
|
||||
}
|
||||
|
||||
async function resolveRepoRevision({
|
||||
client,
|
||||
repoName,
|
||||
revision = '',
|
||||
}: {
|
||||
client: GraphQLClient
|
||||
repoName: string
|
||||
revision?: string
|
||||
}): Promise<ResolvedRevision> {
|
||||
let data = client.readQuery({
|
||||
query: ResolveRepoRevison,
|
||||
variables: {
|
||||
repoName,
|
||||
revision,
|
||||
},
|
||||
})
|
||||
if (
|
||||
!data ||
|
||||
(data.repositoryRedirect?.__typename === 'Repository' && data.repositoryRedirect.commit?.oid !== revision)
|
||||
) {
|
||||
// We always refetch data when 'revision' is a "symbolic" revision (e.g. a tag or branch name)
|
||||
data = await client
|
||||
.query({
|
||||
query: ResolveRepoRevison,
|
||||
variables: {
|
||||
repoName,
|
||||
revision,
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
.then(result => result.data)
|
||||
}
|
||||
|
||||
if (!data?.repositoryRedirect) {
|
||||
throw new RepoNotFoundError(repoName)
|
||||
}
|
||||
|
||||
if (data.repositoryRedirect.__typename === 'Redirect') {
|
||||
throw new RepoSeeOtherError(data.repositoryRedirect.url)
|
||||
}
|
||||
if (data.repositoryRedirect.mirrorInfo.cloneInProgress) {
|
||||
throw new CloneInProgressError(repoName, data.repositoryRedirect.mirrorInfo.cloneProgress || undefined)
|
||||
}
|
||||
if (!data.repositoryRedirect.mirrorInfo.cloned) {
|
||||
throw new CloneInProgressError(repoName, 'queued for cloning')
|
||||
}
|
||||
|
||||
// The "revision" we queried for could be a commit or a changelist.
|
||||
const commit = data.repositoryRedirect.commit || data.repositoryRedirect.changelist?.commit
|
||||
if (!commit) {
|
||||
throw new RevisionNotFoundError(revision)
|
||||
}
|
||||
|
||||
const defaultBranch = data.repositoryRedirect.defaultBranch?.abbrevName || 'HEAD'
|
||||
|
||||
/*
|
||||
* TODO: What exactly is this check for?
|
||||
if (!commit.tree) {
|
||||
throw new RevisionNotFoundError(defaultBranch)
|
||||
}
|
||||
*/
|
||||
|
||||
return {
|
||||
repo: data.repositoryRedirect,
|
||||
commitID: commit.oid,
|
||||
defaultBranch,
|
||||
}
|
||||
}
|
||||
|
||||
33
client/web-sveltekit/src/routes/[...repo=reporev]/layout.gql
Normal file
33
client/web-sveltekit/src/routes/[...repo=reporev]/layout.gql
Normal file
@ -0,0 +1,33 @@
|
||||
query ResolveRepoRevison($repoName: String!, $revision: String!) {
|
||||
repositoryRedirect(name: $repoName) {
|
||||
__typename
|
||||
... on Repository {
|
||||
...ResolvedRepository
|
||||
}
|
||||
... on Redirect {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment ResolvedRepository on Repository {
|
||||
id
|
||||
commit(rev: $revision) {
|
||||
oid
|
||||
}
|
||||
changelist(cid: $revision) {
|
||||
commit {
|
||||
oid
|
||||
}
|
||||
}
|
||||
mirrorInfo {
|
||||
cloneProgress
|
||||
cloneInProgress
|
||||
cloned
|
||||
}
|
||||
defaultBranch {
|
||||
abbrevName
|
||||
}
|
||||
|
||||
...RepoPage_ResolvedRevision
|
||||
}
|
||||
29
client/web-sveltekit/src/routes/layout.gql
Normal file
29
client/web-sveltekit/src/routes/layout.gql
Normal file
@ -0,0 +1,29 @@
|
||||
query Init {
|
||||
currentUser {
|
||||
...AuthenticatedUser
|
||||
}
|
||||
|
||||
viewerSettings {
|
||||
final
|
||||
}
|
||||
|
||||
evaluatedFeatureFlags {
|
||||
...FeatureFlag
|
||||
}
|
||||
}
|
||||
|
||||
query EvaluatedFeatureFlagsQuery {
|
||||
evaluatedFeatureFlags {
|
||||
...FeatureFlag
|
||||
}
|
||||
}
|
||||
|
||||
fragment FeatureFlag on EvaluatedFeatureFlag {
|
||||
name
|
||||
value
|
||||
}
|
||||
|
||||
fragment AuthenticatedUser on User {
|
||||
...Header_User
|
||||
...SearchInput_AuthenticatedUser
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
import SearchResult from './SearchResult.svelte'
|
||||
import { getSearchResultsContext } from './searchResultsContext'
|
||||
import { getOwnerDisplayName, getOwnerMatchURL, buildSearchURLQueryForOwner } from '$lib/search/results'
|
||||
import UserAvatar from '$lib/UserAvatar.svelte'
|
||||
import Avatar from '$lib/Avatar.svelte'
|
||||
import type { PersonMatch } from '$lib/shared'
|
||||
|
||||
export let result: PersonMatch
|
||||
@ -20,7 +20,10 @@
|
||||
</script>
|
||||
|
||||
<SearchResult>
|
||||
<UserAvatar slot="icon" user={{ ...result.user, displayName }} />
|
||||
<Avatar
|
||||
slot="icon"
|
||||
avatar={{ displayName, username: result.user?.username ?? '', avatarURL: result.user?.avatarURL ?? null }}
|
||||
/>
|
||||
<div slot="title">
|
||||
|
||||
{#if ownerURL}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Meta, Story, Template } from '@storybook/addon-svelte-csf'
|
||||
import { Story, Template } from '@storybook/addon-svelte-csf'
|
||||
import SearchResults from './SearchResults.svelte'
|
||||
import {
|
||||
createCommitMatch,
|
||||
@ -9,7 +9,7 @@
|
||||
createPersonMatch,
|
||||
createSymbolMatch,
|
||||
createTeamMatch,
|
||||
} from '$testdata'
|
||||
} from '$testing/testdata'
|
||||
import FileContentSearchResult from './FileContentSearchResult.svelte'
|
||||
import { SvelteComponent, setContext } from 'svelte'
|
||||
import { KEY, type SourcegraphContext } from '$lib/stores'
|
||||
@ -27,6 +27,21 @@
|
||||
import { setSearchResultsContext } from './searchResultsContext'
|
||||
import { createTestGraphqlClient } from '$testing/graphql'
|
||||
|
||||
export const meta = {
|
||||
title: 'search/SearchResults',
|
||||
component: SearchResults,
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: {
|
||||
highlightedFile: graphql.query<HighlightedFileResult, HighlightedFileVariables>(
|
||||
'HighlightedFile',
|
||||
(req, res, ctx) => res(ctx.data(createHighlightedFileResult(req.variables.ranges)))
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setContext<SourcegraphContext>(KEY, {
|
||||
user: readable(null),
|
||||
settings: readable({}),
|
||||
@ -61,22 +76,9 @@
|
||||
function randomizeData(i: number) {
|
||||
data[i] = results[i][2]()
|
||||
}
|
||||
|
||||
$: parameters = {
|
||||
msw: {
|
||||
handlers: {
|
||||
highlightedFile: graphql.query<HighlightedFileResult, HighlightedFileVariables>(
|
||||
'HighlightedFile',
|
||||
(req, res, ctx) => res(ctx.data(createHighlightedFileResult(req.variables.ranges)))
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<Meta title="search/SearchResults" component={SearchResults} {parameters} />
|
||||
|
||||
<Template>
|
||||
<Story name="Default">
|
||||
{#each results as [title, component], i}
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
@ -84,9 +86,7 @@
|
||||
</div>
|
||||
<svelte:component this={component} result={data[i]} />
|
||||
{/each}
|
||||
</Template>
|
||||
|
||||
<Story name="Default" />
|
||||
</Story>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
|
||||
@ -3,7 +3,7 @@ import signale from 'signale'
|
||||
import { writable, type Readable, type Writable } from 'svelte/store'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import type { SettingsCascade } from '$lib/shared'
|
||||
import type { Settings } from '$lib/shared'
|
||||
import { KEY, type SourcegraphContext } from '$lib/stores'
|
||||
import type { FeatureFlagName } from '$lib/web'
|
||||
|
||||
@ -116,7 +116,7 @@ export function unmockFeatureFlags(): void {
|
||||
* calling `unmockUserSettings` in between then subsequent calls will update the underlying settings
|
||||
* store, updating all subscribers.
|
||||
*/
|
||||
export function mockUserSettings(settings: Partial<SettingsCascade['final']>): void {
|
||||
export function mockUserSettings(settings: Partial<Settings>): void {
|
||||
if (mockedSourcgraphContext.settings === unmocked) {
|
||||
mockedSourcgraphContext.settings = writable(settings)
|
||||
} else {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { range } from 'lodash'
|
||||
|
||||
import { SymbolKind, type GitCommitFields, type HistoryResult, type SignatureFields } from '$lib/graphql-operations'
|
||||
import type { HighlightedFileVariables, HighlightedFileResult } from '$lib/graphql-operations'
|
||||
import type { Commit } from '$lib/Commit.gql'
|
||||
import { type HighlightedFileVariables, type HighlightedFileResult, SymbolKind } from '$lib/graphql-operations'
|
||||
import type { HistoryPanel_HistoryConnection } from '$lib/repo/HistoryPanel.gql'
|
||||
import type { CommitMatch, ContentMatch, PersonMatch, TeamMatch, PathMatch, SymbolMatch } from '$lib/shared'
|
||||
|
||||
/**
|
||||
@ -13,7 +14,7 @@ export function seed(seed?: number): number {
|
||||
return faker.seed(seed)
|
||||
}
|
||||
|
||||
export function createSignature(): SignatureFields {
|
||||
export function createSignature() {
|
||||
const firstName = faker.person.firstName()
|
||||
const lastName = faker.person.lastName()
|
||||
const displayName = faker.internet.displayName({ firstName, lastName })
|
||||
@ -29,41 +30,28 @@ export function createSignature(): SignatureFields {
|
||||
url: faker.internet.url(),
|
||||
username: faker.internet.userName({ firstName, lastName }),
|
||||
},
|
||||
email: faker.internet.email(),
|
||||
},
|
||||
date: faker.date.past().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createGitCommit(initial?: Partial<GitCommitFields>): GitCommitFields {
|
||||
export function createGitCommit(initial?: Partial<Commit>): Commit {
|
||||
const oid = faker.git.commitSha()
|
||||
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
oid,
|
||||
abbreviatedOID: oid.slice(0, 7),
|
||||
subject: faker.git.commitMessage(),
|
||||
body: faker.lorem.paragraph(),
|
||||
author: createSignature(),
|
||||
committer: faker.helpers.maybe(createSignature) ?? null,
|
||||
parents: faker.helpers.multiple(
|
||||
() => {
|
||||
const oid = faker.git.commitSha()
|
||||
return {
|
||||
oid,
|
||||
abbreviatedOID: oid.slice(0, 7),
|
||||
url: faker.internet.url(),
|
||||
canonicalURL: faker.internet.url(),
|
||||
}
|
||||
},
|
||||
{ count: { min: 1, max: 2 } }
|
||||
),
|
||||
canonicalURL: faker.internet.url(),
|
||||
externalURLs: [],
|
||||
...initial,
|
||||
}
|
||||
}
|
||||
|
||||
export function createHistoryResults(count: number, pageSize: number): HistoryResult[] {
|
||||
export function createHistoryResults(count: number, pageSize: number): HistoryPanel_HistoryConnection[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
nodes: faker.helpers.uniqueArray(createGitCommit, pageSize),
|
||||
pageInfo: {
|
||||
@ -52,9 +52,8 @@ const config = {
|
||||
alias: {
|
||||
// Makes it easier to refer to files outside packages (such as images)
|
||||
$root: '../../',
|
||||
// Used inside tests for easy access to helpers
|
||||
$testdata: 'src/testdata.ts',
|
||||
$mocks: 'src/testing/mocks.ts',
|
||||
// Used inside tests for easy access to helpers
|
||||
$testing: 'src/testing',
|
||||
// Map node-module to browser version
|
||||
path: '../../node_modules/path-browserify',
|
||||
|
||||
@ -2,7 +2,6 @@ import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { generate, type CodegenConfig } from '@graphql-codegen/cli'
|
||||
import graphql from '@rollup/plugin-graphql'
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import { defineConfig, mergeConfig, type Plugin, type UserConfig } from 'vite'
|
||||
import inspect from 'vite-plugin-inspect'
|
||||
@ -17,13 +16,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
// caused duplicate code generation issues.
|
||||
function generateGraphQLTypes(): Plugin {
|
||||
const codgegenConfig: CodegenConfig = {
|
||||
hooks: {
|
||||
// This hook removes the 'import type * as Types from ...' import from generated files if it's not used.
|
||||
// The near-operation-file preset generates this import for every file, even if it's not used. This
|
||||
// generally is not a problem, but the issue is reported by `pnpm check`.
|
||||
// See https://github.com/dotansimha/graphql-code-generator/issues/4900
|
||||
beforeOneFileWrite(_path, content) {
|
||||
if (/^import type \* as Types from/m.test(content) && !/Types(\[|\.)/.test(content)) {
|
||||
return content.replace(/^import type \* as Types from .+$/m, '').trimStart()
|
||||
}
|
||||
return content
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
// Legacy graphql-operations.ts file that is still used by some components.
|
||||
'./src/lib/graphql-operations.ts': {
|
||||
documents: ['src/{lib,routes}/**/*.ts', '!src/lib/graphql-{operations,types}.ts'],
|
||||
documents: ['src/{lib,routes}/**/*.ts', '!src/lib/graphql-{operations,types}.ts', '!src/**/*.gql.ts'],
|
||||
config: {
|
||||
onlyOperationTypes: true,
|
||||
enumValues: '$lib/graphql-types',
|
||||
//interfaceNameForOperations: 'SvelteKitGraphQlOperations',
|
||||
},
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
},
|
||||
@ -31,16 +42,17 @@ function generateGraphQLTypes(): Plugin {
|
||||
plugins: ['typescript'],
|
||||
},
|
||||
'src/': {
|
||||
documents: ['src/**/*.gql', '!src/**/*.gql.d.ts'],
|
||||
documents: ['src/**/*.gql', '!src/**/*.gql.ts'],
|
||||
preset: 'near-operation-file',
|
||||
presetConfig: {
|
||||
baseTypesPath: 'lib/graphql-types',
|
||||
extension: '.gql.d.ts',
|
||||
extension: '.gql.ts',
|
||||
},
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
documentVariableSuffix: '', // The default is 'Document'
|
||||
},
|
||||
plugins: ['typescript-operations', `${__dirname}/dev/typed-document-node.cjs`],
|
||||
plugins: ['typescript-operations', 'typed-document-node'],
|
||||
},
|
||||
},
|
||||
schema: '../../cmd/frontend/graphqlbackend/*.graphql',
|
||||
@ -77,7 +89,7 @@ function generateGraphQLTypes(): Plugin {
|
||||
// Cheap custom function to check whether we should run codegen for the provided path
|
||||
function shouldRunCodegen(path: string): boolean {
|
||||
// Do not run codegen for generated files
|
||||
if (/(graphql-(operations|types)|\.gql\.d)\.ts$/.test(path)) {
|
||||
if (/(graphql-(operations|types)|\.gql)\.ts$/.test(path)) {
|
||||
return false
|
||||
}
|
||||
if (/\.(ts|gql)$/.test(path)) {
|
||||
@ -119,11 +131,9 @@ export default defineConfig(({ mode }) => {
|
||||
let config: UserConfig = {
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
// Generates typescript types for gql-tags and .graphql files
|
||||
// Generates typescript types for gql-tags and .gql files
|
||||
generateGraphQLTypes(),
|
||||
inspect(),
|
||||
// Parses .graphql files and imports them as AST
|
||||
graphql(),
|
||||
],
|
||||
define:
|
||||
mode === 'test'
|
||||
@ -176,6 +186,12 @@ export default defineConfig(({ mode }) => {
|
||||
find: /^react-icons\/(.+)$/,
|
||||
replacement: 'react-icons/$1/index.js',
|
||||
},
|
||||
// We generate corresponding .gql.ts files for .gql files.
|
||||
// This alias allows us to import .gql files and have them resolved to the generated .gql.ts files.
|
||||
{
|
||||
find: /^(.*)\.gql$/,
|
||||
replacement: '$1.gql.ts',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@ -881,7 +881,7 @@ interface Caches {
|
||||
|
||||
export interface SuggestionsSourceConfig {
|
||||
graphqlQuery: <T, V extends Record<string, any>>(query: string, variables: V) => Promise<T>
|
||||
authenticatedUser?: AuthenticatedUser | null
|
||||
authenticatedUser: Pick<AuthenticatedUser, 'id' | 'organizations'> | null
|
||||
isSourcegraphDotCom?: boolean
|
||||
valueType: 'regex' | 'glob'
|
||||
}
|
||||
|
||||
@ -440,7 +440,8 @@
|
||||
"peerDependencies": {
|
||||
"@graphql-codegen/typescript": "*",
|
||||
"@graphql-codegen/typescript-operations": "*",
|
||||
"@graphql-codegen/near-operation-file-preset": "*"
|
||||
"@graphql-codegen/near-operation-file-preset": "*",
|
||||
"@graphql-codegen/typed-document-node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ overrides:
|
||||
cssnano: 4.1.10
|
||||
tslib: 2.1.0
|
||||
|
||||
packageExtensionsChecksum: 7b118421a6bf16cdf7b3ddb064dd0cee
|
||||
packageExtensionsChecksum: dd95e2509ba76d0489f870c0fbed3202
|
||||
|
||||
importers:
|
||||
|
||||
@ -1423,10 +1423,13 @@ importers:
|
||||
version: 8.0.2
|
||||
'@graphql-codegen/cli':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(@graphql-codegen/near-operation-file-preset@3.0.0)(@graphql-codegen/typescript-operations@4.0.1)(@graphql-codegen/typescript@4.0.1)(@types/node@20.8.0)(graphql@15.4.0)(typescript@5.2.2)
|
||||
version: 5.0.0(@graphql-codegen/near-operation-file-preset@3.0.0)(@graphql-codegen/typed-document-node@5.0.1)(@graphql-codegen/typescript-operations@4.0.1)(@graphql-codegen/typescript@4.0.1)(@types/node@20.8.0)(graphql@15.4.0)(typescript@5.2.2)
|
||||
'@graphql-codegen/near-operation-file-preset':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(graphql@15.4.0)
|
||||
'@graphql-codegen/typed-document-node':
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(graphql@15.4.0)
|
||||
'@graphql-codegen/typescript':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(graphql@15.4.0)
|
||||
@ -1442,9 +1445,6 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: 1.25.0
|
||||
version: 1.25.0
|
||||
'@rollup/plugin-graphql':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4(graphql@15.4.0)
|
||||
'@storybook/addon-essentials':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0(@types/react-dom@18.0.2)(@types/react@18.0.8)(react-dom@18.1.0)(react@18.1.0)
|
||||
@ -3893,11 +3893,12 @@ packages:
|
||||
- utf-8-validate
|
||||
dev: true
|
||||
|
||||
/@graphql-codegen/cli@5.0.0(@graphql-codegen/near-operation-file-preset@3.0.0)(@graphql-codegen/typescript-operations@4.0.1)(@graphql-codegen/typescript@4.0.1)(@types/node@20.8.0)(graphql@15.4.0)(typescript@5.2.2):
|
||||
/@graphql-codegen/cli@5.0.0(@graphql-codegen/near-operation-file-preset@3.0.0)(@graphql-codegen/typed-document-node@5.0.1)(@graphql-codegen/typescript-operations@4.0.1)(@graphql-codegen/typescript@4.0.1)(@types/node@20.8.0)(graphql@15.4.0)(typescript@5.2.2):
|
||||
resolution: {integrity: sha512-A7J7+be/a6e+/ul2KI5sfJlpoqeqwX8EzktaKCeduyVKgOLA6W5t+NUGf6QumBDXU8PEOqXk3o3F+RAwCWOiqA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@graphql-codegen/near-operation-file-preset': '*'
|
||||
'@graphql-codegen/typed-document-node': '*'
|
||||
'@graphql-codegen/typescript': '*'
|
||||
'@graphql-codegen/typescript-operations': '*'
|
||||
'@parcel/watcher': ^2.1.0
|
||||
@ -3912,6 +3913,7 @@ packages:
|
||||
'@graphql-codegen/core': 4.0.0(graphql@15.4.0)
|
||||
'@graphql-codegen/near-operation-file-preset': 3.0.0(graphql@15.4.0)
|
||||
'@graphql-codegen/plugin-helpers': 5.0.1(graphql@15.4.0)
|
||||
'@graphql-codegen/typed-document-node': 5.0.1(graphql@15.4.0)
|
||||
'@graphql-codegen/typescript': 4.0.1(graphql@15.4.0)
|
||||
'@graphql-codegen/typescript-operations': 4.0.1(graphql@15.4.0)
|
||||
'@graphql-tools/apollo-engine-loader': 8.0.0(graphql@15.4.0)
|
||||
@ -4061,6 +4063,22 @@ packages:
|
||||
tslib: 2.1.0
|
||||
dev: true
|
||||
|
||||
/@graphql-codegen/typed-document-node@5.0.1(graphql@15.4.0):
|
||||
resolution: {integrity: sha512-VFkhCuJnkgtbbgzoCAwTdJe2G1H6sd3LfCrDqWUrQe53y2ukfSb5Ov1PhAIkCBStKCMQBUY9YgGz9GKR40qQ8g==}
|
||||
peerDependencies:
|
||||
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
dependencies:
|
||||
'@graphql-codegen/plugin-helpers': 5.0.1(graphql@15.4.0)
|
||||
'@graphql-codegen/visitor-plugin-common': 4.0.1(graphql@15.4.0)
|
||||
auto-bind: 4.0.0
|
||||
change-case-all: 1.0.15
|
||||
graphql: 15.4.0
|
||||
tslib: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/@graphql-codegen/typescript-apollo-client-helpers@2.2.6(graphql@15.4.0):
|
||||
resolution: {integrity: sha512-WEWtjg2D/Clmep7fflKmt6o70rZj/Mqf4ywIO5jF/PI91OHpKhLFM2aWm1ythkqALwQ6wJIFlAjdYqz/EOVYdQ==}
|
||||
peerDependencies:
|
||||
@ -7498,21 +7516,6 @@ packages:
|
||||
resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==}
|
||||
dev: true
|
||||
|
||||
/@rollup/plugin-graphql@2.0.4(graphql@15.4.0):
|
||||
resolution: {integrity: sha512-TfaqbbK71VHodCDCoRbPnv2+Tsnlvad2OsGEviURHFl+ZBUyf5wfXgXc9RqZ+xKxSl87Z3YbPhD0z6eWYjuByw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
graphql: '>=0.9.0'
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.0.2
|
||||
graphql: 15.4.0
|
||||
graphql-tag: 2.12.6(graphql@15.4.0)
|
||||
dev: true
|
||||
|
||||
/@rollup/pluginutils@5.0.2:
|
||||
resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user