[experiment] Merge SvelteKit prototype into main (#47238)

This PR merges my prototype branch into main to make it easier to
iterate on the prototype and for others to contribute to it. It adds a
new web frontend under `client/web-sveltekit`.

I've been trying to make as little changes to the existing code base as
possible to make the initial merge simple. I made some small changes to
make it easier to reuse some functions.
Most importantly I'm trying to limit the "exposure" others might have to
the prototype by importing any dependencies from other `@sourcegraph/*`
packages into a few select TypeScript files (e.g. `src/lib/shared.ts`).
This way the risk of others having to work with Svelte is mitigated to
some degree.

The prototype implements the following pages (also see file structure
under `src/routes/`):

- Search home page
- Search result page
- Repo pages: root, file, tree, commits, branches, tags, contributors,
commit

It's configured to run as a client side application, without pre- or
server-side rendering.

You can run the development server via

```sh
cd client/web-sveltekit
pnpm run dev
```

The README file contains more information about running the development
server, linter and formatter.

Note: Some features (e.g. playwright) are not used yet but are committed
as part of the initial scaffolding.


Co-authored-by: William Bezuidenhout <william.bezuidenhout@sourcegraph.com>
This commit is contained in:
Felix Kling 2023-02-13 17:53:23 +01:00 committed by GitHub
parent eba2338d2f
commit 153cff847b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
148 changed files with 7979 additions and 494 deletions

View File

@ -19,6 +19,7 @@ client/storybook/node_modules
client/template-parser/node_modules
client/testing/node_modules
client/web/node_modules
client/web-sveltekit/node_modules
client/wildcard/node_modules
client/vscode/node_modules
dev/release/node_modules

View File

@ -3,3 +3,6 @@ src/schema/*
src/graphql-operations.ts
GH2SG.bookmarklet.js
**/vendor/*.js
svelte.config.js
vite.config.ts
playwright.config.ts

View File

@ -32,6 +32,7 @@ jobs:
- name: Install npm dependencies
run: pnpm install --frozen-lockfile
- run: pnpm generate
- run: pnpm --filter ./client/web-sveltekit run sync
- run: pnpm dlx @sourcegraph/scip-typescript index --pnpm-workspaces
- run: cp index.scip dump.lsif-typed
- name: Install src-cli

View File

@ -43,3 +43,4 @@ code-intel-extensions.json
pnpm-lock.yaml
client/plugin-backstage/
node_modules/
client/web-sveltekit/.svelte-kit

View File

@ -3,8 +3,8 @@ import React, { Fragment, useMemo } from 'react'
import classNames from 'classnames'
import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations'
import { decorate, toDecoration } from '@sourcegraph/shared/src/search/query/decoratedToken'
import { scanSearchQuery } from '@sourcegraph/shared/src/search/query/scanner'
import { decorateQuery } from '../util/query'
interface SyntaxHighlightedSearchQueryProps extends React.HTMLAttributes<HTMLSpanElement> {
query: string
@ -16,18 +16,14 @@ export const SyntaxHighlightedSearchQuery: React.FunctionComponent<
React.PropsWithChildren<SyntaxHighlightedSearchQueryProps>
> = ({ query, searchPatternType, ...otherProps }) => {
const tokens = useMemo(() => {
const tokens = searchPatternType ? scanSearchQuery(query, false, searchPatternType) : scanSearchQuery(query)
return tokens.type === 'success'
? tokens.term.flatMap(token =>
decorate(token).map(token => {
const { value, key, className } = toDecoration(query, token)
return (
<span className={className} key={key}>
{value}
</span>
)
})
)
const decorations = decorateQuery(query, searchPatternType)
return decorations
? decorations.map(({ value, key, className }) => (
<span className={className} key={key}>
{value}
</span>
))
: [<Fragment key="0">{query}</Fragment>]
}, [query, searchPatternType])

View File

@ -7,6 +7,7 @@ export * from './input/SearchBox'
export * from './input/toggles'
export * from './results/progress/StreamingProgress'
export * from './results/progress/StreamingProgressCount'
export * from './results/progress/utils'
export * from './results/sidebar/FilterLink'
export * from './results/sidebar/revisions'
export * from './results/sidebar/SearchSidebar'

View File

@ -9,6 +9,7 @@ import { Progress } from '@sourcegraph/shared/src/search/stream'
import { Icon, Tooltip } from '@sourcegraph/wildcard'
import { StreamingProgressProps } from './StreamingProgress'
import { limitHit } from './utils'
import styles from './StreamingProgressCount.module.scss'
@ -25,9 +26,6 @@ const abbreviateNumber = (number: number): string => {
return (number / 1e9).toFixed(1) + 'b'
}
export const limitHit = (progress: Progress): boolean =>
progress.skipped.some(skipped => skipped.reason.indexOf('-limit') > 0)
export const getProgressText = (progress: Progress): { visibleText: string; readText: string } => {
const contentWithoutTimeUnit =
`${abbreviateNumber(progress.matchCount)}` +

View File

@ -22,28 +22,10 @@ import {
import { SyntaxHighlightedSearchQuery } from '../../components/SyntaxHighlightedSearchQuery'
import { StreamingProgressProps } from './StreamingProgress'
import { limitHit } from './StreamingProgressCount'
import { sortBySeverity, limitHit } from './utils'
import styles from './StreamingProgressSkippedPopover.module.scss'
const severityToNumber = (severity: Skipped['severity']): number => {
switch (severity) {
case 'error':
return 1
case 'warn':
return 2
case 'info':
return 3
}
}
const sortBySeverity = (a: Skipped, b: Skipped): number => {
const aSev = severityToNumber(a.severity)
const bSev = severityToNumber(b.severity)
return aSev - bSev
}
const SkippedMessage: React.FunctionComponent<React.PropsWithChildren<{ skipped: Skipped; startOpen: boolean }>> = ({
skipped,
startOpen,
@ -136,7 +118,7 @@ export const StreamingProgressSkippedPopover: React.FunctionComponent<
})
}, [])
const sortedSkippedItems = progress.skipped.sort(sortBySeverity)
const sortedSkippedItems = sortBySeverity(progress.skipped)
return (
<>

View File

@ -0,0 +1,24 @@
import type { Progress, Skipped } from '@sourcegraph/shared/src/search/stream'
export const limitHit = (progress: Progress): boolean =>
progress.skipped.some(skipped => skipped.reason.indexOf('-limit') > 0)
const severityToNumber = (severity: Skipped['severity']): number => {
switch (severity) {
case 'error':
return 1
case 'warn':
return 2
case 'info':
return 3
}
}
const severityComparer = (a: Skipped, b: Skipped): number => {
const aSev = severityToNumber(a.severity)
const bSev = severityToNumber(b.severity)
return aSev - bSev
}
export const sortBySeverity = (skipped: Skipped[]): Skipped[] => skipped.slice().sort(severityComparer)

View File

@ -0,0 +1,10 @@
import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations'
import { decorate, type Decoration, toDecoration } from '@sourcegraph/shared/src/search/query/decoratedToken'
import { scanSearchQuery } from '@sourcegraph/shared/src/search/query/scanner'
export function decorateQuery(query: string, searchPatternType?: SearchPatternType): Decoration[] | null {
const tokens = searchPatternType ? scanSearchQuery(query, false, searchPatternType) : scanSearchQuery(query)
return tokens.type === 'success'
? tokens.term.flatMap(token => decorate(token).map(token => toDecoration(query, token)))
: null
}

View File

@ -7,6 +7,7 @@ const { generate } = require('@graphql-codegen/cli')
const ROOT_FOLDER = path.resolve(__dirname, '../../../')
const WEB_FOLDER = path.resolve(ROOT_FOLDER, './client/web')
const SVELTEKIT_FOLDER = path.resolve(ROOT_FOLDER, './client/web-sveltekit')
const BROWSER_FOLDER = path.resolve(ROOT_FOLDER, './client/browser')
const SHARED_FOLDER = path.resolve(ROOT_FOLDER, './client/shared')
const VSCODE_FOLDER = path.resolve(ROOT_FOLDER, './client/vscode')
@ -21,6 +22,8 @@ const WEB_DOCUMENTS_GLOB = [
`!${WEB_FOLDER}/src/end-to-end/**/*.*`,
]
const SVELTEKIT_DOCUMENTS_GLOB = [`${SVELTEKIT_FOLDER}/src/lib/**/*.ts`]
const BROWSER_DOCUMENTS_GLOB = [
`${BROWSER_FOLDER}/src/**/*.{ts,tsx}`,
`!${BROWSER_FOLDER}/src/end-to-end/**/*.*`,
@ -37,6 +40,7 @@ const GLOBS = {
SharedGraphQlOperations: SHARED_DOCUMENTS_GLOB,
VSCodeGraphQlOperations: VSCODE_DOCUMENTS_GLOB,
WebGraphQlOperations: WEB_DOCUMENTS_GLOB,
SvelteKitGraphQlOperations: SVELTEKIT_DOCUMENTS_GLOB,
}
const EXTRA_PLUGINS = {
@ -48,6 +52,7 @@ const ALL_DOCUMENTS_GLOB = [
...new Set([
...SHARED_DOCUMENTS_GLOB,
...WEB_DOCUMENTS_GLOB,
...SVELTEKIT_DOCUMENTS_GLOB,
...BROWSER_DOCUMENTS_GLOB,
...VSCODE_DOCUMENTS_GLOB,
...JETBRAINS_DOCUMENTS_GLOB,
@ -76,6 +81,10 @@ async function generateGraphQlOperations() {
interfaceNameForOperations: 'WebGraphQlOperations',
outputPath: path.join(WEB_FOLDER, './src/graphql-operations.ts'),
},
{
interfaceNameForOperations: 'SvelteKitGraphQlOperations',
outputPath: path.join(SVELTEKIT_FOLDER, './src/lib/graphql-operations.ts'),
},
{
interfaceNameForOperations: 'SharedGraphQlOperations',
outputPath: path.join(SHARED_FOLDER, './src/graphql-operations.ts'),

View File

@ -20,7 +20,7 @@ import { makeRepoURI } from '../util/url'
*/
type RequestVariables = Omit<HighlightedFileVariables, 'format'> & { format?: HighlightedFileVariables['format'] }
const IS_VSCE = typeof (window as any).acquireVsCodeApi === 'function'
const IS_VSCE = typeof window !== 'undefined' && typeof (window as any).acquireVsCodeApi === 'function'
const HIGHLIGHTED_FILE_QUERY = gql`
query HighlightedFile(

View File

@ -1201,7 +1201,7 @@ export const toCSSClassName = (token: DecoratedToken): string => {
}
}
interface Decoration {
export interface Decoration {
value: string
key: number
className: string

View File

@ -1,4 +1,4 @@
import { uniq } from 'lodash'
import uniq from 'lodash/uniq'
import { Literal } from './token'

View File

@ -0,0 +1,5 @@
# Whether to run the enterprise version (default). Unsetting the value
# corresponds to running the OSS version.
PUBLIC_SG_ENTERPRISE=true
PUBLIC_DOTCOM=
PUBLIC_ENABLE_EVENT_LOGGER=

View File

@ -0,0 +1,2 @@
PUBLIC_SG_ENTERPRISE=true
PUBLIC_DOTCOM=true

View File

@ -0,0 +1,2 @@
PUBLIC_SG_ENTERPRISE=
PUBLIC_DOTCOM=

View File

@ -0,0 +1,18 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.ts
svelte.config.js
playwright.config.ts
src/lib/graphql-operations.ts
/server.js
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -0,0 +1,26 @@
const baseConfig = require('../../.eslintrc')
module.exports = {
root: true,
extends: '../../.eslintrc.js',
parserOptions: {
...baseConfig.parserOptions,
project: [__dirname + '/tsconfig.json', __dirname + '/src/**/tsconfig.json'],
},
plugins: [...baseConfig.plugins, 'svelte3'],
overrides: [...baseConfig.overrides, { files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
...baseConfig.settings,
'svelte3/typescript': () => require('typescript'),
},
rules: {
...baseConfig.rules,
'import/extensions': [
'error',
'never',
{
svelte: 'always',
svg: 'always',
},
],
},
}

9
client/web-sveltekit/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
!.env
.env.*.local
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -0,0 +1,14 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
package.json
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -0,0 +1,11 @@
{
"extends": ["../../.stylelintrc.json"],
"overrides": [
{
"files": ["./src/routes/styles.scss"],
"rules": {
"@sourcegraph/filenames-match-regex": null
}
}
]
}

View File

@ -0,0 +1,14 @@
# This image creates a standalone web server for demoing the prototype
FROM node:16
ARG PROJECT_ROOT="."
WORKDIR /usr/src/app
RUN echo '{"type": "module"}' > package.json
RUN npm install express@~4.18 http-proxy-middleware@~2.0
COPY ${PROJECT_ROOT}/server.js ./
COPY ${PROJECT_ROOT}/build ./build
ENV SOURCEGRAPH_API_HOST=https://sourcegraph.com
EXPOSE 4173
CMD ["node", "server.js"]

View File

@ -0,0 +1,82 @@
# Sourcegraph SvelteKit
This folder contains the experimental [SvelteKit](https://kit.svelte.dev/)
implementation of the Sourcegraph app.
**NOTE:** This is a _very early_ prototype and it will change a lot.
## Developing
```bash
# Install dependencies
pnpm install
# Generate GraphQL types
pnpm run -w generate
# Run dev server
pnpm run dev
```
You can also build the OSS or dotcom version by running `pnpm run dev:oss` and
`pnpm run dev:dotcom` respectively, but they don't really differ in
functionality yet.
The dev server can be accessed on http://localhost:5173. API requests and
signin/signout are proxied to an actual Sourcegraph instance,
https://sourcegraph.com by default (can be overwritten via the
`SOURCEGRAPH_API_URL` environment variable.
### Using code from `@sourcegraph/*`
There are some things to consider when using code from other `@sourcegraph`
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.
- 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
exposure, a module of any `@sourcegraph/*` package should only be imported
once into this package and only into a TypeScript file.
The current convention is to import any modules from `@sourcegraph/common`
into `src/lib/common.ts`, etc.
### Tests
There are no tests yet. It would be great to try out Playwright but it looks
like this depends on getting the production build working first (see below).
### Formatting and linting
This package defines its own rules for formatting (which includes support for
Svelte components) and linting. The workspace rules linting and formatting
commands have not been updated yet to keep this experiment contained.
Run
```sh
pnpm run lint
pnpm run format
```
inside this directory.
There is also the `pnpm run check` command which uses `svelte-check` to validate
TypeScript, CSS, etc in Svelte components. This currently produces many errors
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).
## Production build
A production version of this app can be built with
```sh
pnpm run build
```
Currently SvelteKit is configured to create a client-side single page
application.

View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# This script builds the svelte docker image.
pnpm install
pnpm -w generate
pnpm build
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
set -eu
IMAGE="us-central1-docker.pkg.dev/sourcegraph-dogfood/svelte/web"
echo "--- docker build web-svelte $(pwd)"
docker build -f client/web-sveltekit/Dockerfile --build-arg PROJECT_ROOT=./client/web-sveltekit -t "$IMAGE" "$(pwd)" \
docker push $IMAGE

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Prepares and creates a standalone docker image for demoing the prototype
pnpm install
pnpm run -w generate
pnpm run build
docker build . -t fkling/web-sveltekit

View File

@ -0,0 +1,44 @@
{
"name": "@sourcegraph/web-sveltekit",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"dev:dotcom": "vite dev --mode=dotcom",
"dev:oss": "vite dev --mode=oss",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test",
"sync": "svelte-kit sync",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --plugin-search-dir . --write .",
"generate": "pnpm -w generate"
},
"devDependencies": {
"@playwright/test": "1.25.0",
"@sveltejs/adapter-auto": "1.0.0-next.91",
"@sveltejs/adapter-static": "^2.0.0",
"@sveltejs/kit": "^1.3.2",
"@types/cookie": "^0.5.1",
"@types/prismjs": "^1.26.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier-plugin-svelte": "^2.9.0",
"svelte": "^3.55.1",
"svelte-check": "^3.0.3",
"tslib": "2.1.0",
"vite": "^4.0.4"
},
"type": "module",
"dependencies": {
"@popperjs/core": "^2.11.6",
"@sourcegraph/branded": "workspace:*",
"@sourcegraph/common": "workspace:*",
"@sourcegraph/http-client": "workspace:*",
"@sourcegraph/shared": "workspace:*",
"@sourcegraph/web": "workspace:*",
"@sourcegraph/wildcard": "workspace:*",
"lodash-es": "^4.17.21",
"prismjs": "^1.29.0"
}
}

View File

@ -0,0 +1,10 @@
import type { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
},
}
export default config

View File

@ -0,0 +1,6 @@
const baseConfig = require('../../prettier.config.js')
module.exports = {
...baseConfig,
plugins: [...(baseConfig.plugins || []), 'prettier-plugin-svelte'],
overrides: [...(baseConfig.overrides || []), { files: '*.svelte', options: { parser: 'svelte' } }],
}

View File

@ -0,0 +1,19 @@
// Simple express server to serve a static production build of the prototype
import express from 'express'
import { createProxyMiddleware } from 'http-proxy-middleware'
const app = express()
const port = 4173
// Serve prototype files
app.use(express.static('./build', { fallthrough: true }))
// Proxy API, stream and other specific endpoints to Sourcegraph instance
app.use(
/^\/(sign-in|.assets|-|.api|search\/stream)/,
createProxyMiddleware({ target: process.env['SOURCEGRAPH_API_HOST'], changeOrigin: true, secure: false })
)
// Fallback route to make SPA work for any URL on cold load
app.all('*', (_req, res) => res.sendFile('index.html', { root: './build' }))
app.listen(port, () => {
console.log(`Listening on port ${port}`)
})

13
client/web-sveltekit/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
/// <reference types="@sveltejs/kit" />
import type { ErrorLike } from '$lib/common'
import type { ResolvedRevision, Repo } from '$lib/web'
// and what to do when importing types
declare namespace App {
interface PageData {
resolvedRevision?: (ResolvedRevision & Repo) | ErrorLike
}
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data>
<div>%sveltekit.body%</div>
</body>
</html>

3
client/web-sveltekit/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
interface Window {
context: { xhrHeaders: { [key: string]: string } }
}

View File

@ -0,0 +1,101 @@
<script lang="ts">
import '@sourcegraph/wildcard/src/global-styles/highlight.scss'
import { Compartment, EditorState, StateEffect, type Extension } from '@codemirror/state'
import { EditorView, lineNumbers } from '@codemirror/view'
import { browser } from '$app/environment'
import { syntaxHighlight } from '$lib/web'
import type { BlobFileFields } from '$lib/graphql-operations'
export let blob: BlobFileFields
export let highlights: string
export let wrapLines: boolean = false
let editor: EditorView
let container: HTMLDivElement | null = null
const shCompartment = new Compartment()
const miscSettingsCompartment = new Compartment()
function createEditor(container: HTMLDivElement): EditorView {
const extensions = [
lineNumbers(),
miscSettingsCompartment.of(configureMiscSettings({ wrapLines })),
shCompartment.of(configureSyntaxHighlighting(blob.content, highlights)),
EditorView.theme({
'&': {
width: '100%',
'min-height': 0,
color: 'var(--color-code)',
flex: 1,
},
'.cm-scroller': {
overflow: 'auto',
'font-family': 'var(--code-font-family)',
'font-size': 'var(--code-font-size)',
},
'.cm-gutters': {
'background-color': 'var(--code-bg)',
border: 'none',
color: 'var(--line-number-color)',
},
'.cm-line': {
'line-height': '1rem',
'padding-left': '1rem',
},
}),
]
const view = new EditorView({
state: EditorState.create({ doc: blob.content, extensions }),
parent: container,
})
return view
}
function configureSyntaxHighlighting(content: string, lsif: string): Extension {
return lsif ? syntaxHighlight.of({ content, lsif }) : []
}
function configureMiscSettings({ wrapLines }: { wrapLines: boolean }): Extension {
return [wrapLines ? EditorView.lineWrapping : []]
}
function updateExtensions(effects: StateEffect<unknown>[]) {
if (editor) {
editor.dispatch({ effects })
}
}
$: updateExtensions([shCompartment.reconfigure(configureSyntaxHighlighting(blob.content, highlights))])
$: updateExtensions([miscSettingsCompartment.reconfigure(configureMiscSettings({ wrapLines }))])
$: if (editor && editor?.state.sliceDoc() !== blob.content) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: blob.content },
})
}
$: if (container && !editor) {
editor = createEditor(container)
}
</script>
{#if browser}
<div bind:this={container} class="root test-editor" data-editor="codemirror6" />
{:else}
<div class="root">
<pre>{blob.content}</pre>
</div>
{/if}
<style lang="scss">
.root {
display: contents;
overflow: hidden;
}
pre {
margin: 0;
}
</style>

View File

@ -0,0 +1,109 @@
<script lang="ts">
import { mdiDotsHorizontal } from '@mdi/js'
import type { GitCommitFields } from '$lib/graphql-operations'
import { currentDate as now } from '$lib/stores'
import { getRelativeTime } from '$lib/relativeTime'
import UserAvatar from '$lib/UserAvatar.svelte'
import Icon from '$lib/Icon.svelte'
export let commit: GitCommitFields
export let alwaysExpanded: boolean = false
$: relativeTime = getRelativeTime(new Date(commit.committer ? commit.committer.date : commit.author.date), $now)
let expanded = alwaysExpanded
</script>
<div class="root">
<div class="avatar">
<UserAvatar user={commit.author.person} />
</div>
{#if commit.committer}
<div class="avatar">
<UserAvatar user={commit.committer.person} />
</div>
{/if}
<div class="info">
<span class="d-flex">
<a class="subject" href={commit.url}>{commit.subject}</a>
{#if !alwaysExpanded}
<button type="button" on:click={() => (expanded = !expanded)}>
<Icon svgPath={mdiDotsHorizontal} inline />
</button>
{/if}
</span>
<span>committed by <strong>{commit.author.person.name}</strong> about {relativeTime}</span>
{#if expanded}
<pre>{commit.body}</pre>
{/if}
</div>
{#if !alwaysExpanded}
<div class="buttons">
<a href={commit.url}>{commit.abbreviatedOID}</a>
</div>
{/if}
</div>
<style lang="scss">
.root {
display: flex;
}
.info {
display: flex;
flex-direction: column;
margin: 0 0.5rem;
flex: 1;
min-width: 0;
}
.subject {
font-weight: 600;
flex: 0 1 auto;
padding-right: 0.5rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--body-color);
min-width: 0;
}
.avatar {
flex: 0 0 auto;
display: flex;
width: 2.75rem;
height: 2.75rem;
margin-right: 0.5rem;
font-size: 1.5rem;
}
span {
color: var(--text-muted);
}
button {
color: var(--body-color);
border: 1px solid var(--secondary);
cursor: pointer;
}
pre {
margin-top: 0.5rem;
margin-bottom: 1.5rem;
font-size: 0.75rem;
overflow: visible;
max-width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}
.buttons {
align-self: center;
a {
display: inline-block;
padding: 0.125rem;
font-family: var(--code-font-family);
font-size: 0.75rem;
}
}
</style>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte'
export let title: string
export let svgIconPath: string = ''
</script>
<div class="root">
{#if svgIconPath}
<div class="icon-wrapper">
<Icon svgPath={svgIconPath} aria-hidden="true" inline --icon-inline-size="4rem" />
</div>
{/if}
<h1>{title}</h1>
<slot />
</div>
<style lang="scss">
.root {
flex: 1 1 auto;
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
align-items: center;
overflow-y: auto;
padding-top: 8rem;
}
h1 {
font-size: 2rem;
margin: 1rem;
line-height: 1.5;
font-weight: 400;
letter-spacing: normal;
}
.icon-wrapper {
border-radius: 100%;
width: 6rem;
height: 6rem;
min-height: 6rem;
background-color: var(--color-bg-2);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,30 @@
<!--
@component
Creates an SVG icon. You can overwrite the color by using the --color
style directive:
<Icon svgPath={...} --color="other color" />
Otherwise the current text color is used.
-->
<script lang="ts">
import type { SVGAttributes } from 'svelte/elements'
interface $$Props extends SVGAttributes<SVGElement> {
svgPath: string
inline?: boolean
}
export let svgPath: string
export let inline: boolean = false
</script>
<svg class:icon-inline={inline} height="24" width="24" viewBox="0 0 24 24" {...$$restProps}>
<path d={svgPath} />
</svg>
<style lang="scss">
svg {
color: var(--color, inherit);
fill: currentColor;
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
export let inline = false
export let center = true
</script>
<div class:center>
<div class="loading-spinner" class:center class:icon-inline={inline} aria-label="loading" aria-live="polite" />
</div>
<style lang="scss">
.center {
display: flex;
flex-direction: column;
align-items: center;
}
.loading-spinner {
:global(.theme-light) & {
--loading-spinner-outer-color: var(--gray-05);
--loading-spinner-inner-color: var(--gray-08);
}
:global(.theme-dark) & {
--loading-spinner-outer-color: var(--gray-07);
--loading-spinner-inner-color: var(--white);
}
margin: 0.125rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
animation: loading-spinner-spin 1s linear infinite;
border: 2px solid var(--loading-spinner-outer-color, rgba(0, 0, 0, 0.3));
border-top: 2px solid var(--loading-spinner-inner-color, rgba(0, 0, 0, 1));
}
@keyframes loading-spinner-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,108 @@
<script context="module" lang="ts">
enum Param {
before = '$before',
after = '$after',
last = '$last',
}
export function getPaginationParams(
searchParams: URLSearchParams,
pageSize: number
):
| { first: number; last: null; before: null; after: string | null }
| { first: null; last: number; before: string | null; after: null } {
if (searchParams.has('$before')) {
return { first: null, last: pageSize, before: searchParams.get(Param.before), after: null }
} else if (searchParams.has('$after')) {
return { first: pageSize, last: null, before: null, after: searchParams.get(Param.after) }
} else if (searchParams.has('$last')) {
return { first: null, last: pageSize, before: null, after: null }
} else {
return { first: pageSize, last: null, before: null, after: null }
}
}
</script>
<script lang="ts">
import Icon from './Icon.svelte'
import { mdiPageFirst, mdiPageLast, mdiChevronRight, mdiChevronLeft } from '@mdi/js'
import { page } from '$app/stores'
import { Button } from './wildcard'
export let pageInfo: {
hasPreviousPage: boolean
hasNextPage: boolean
startCursor: string | null
endCursor: string | null
}
export let disabled: boolean
function urlWithParameter(name: string, value: string | null): string {
const url = new URL($page.url)
url.searchParams.delete(Param.before)
url.searchParams.delete(Param.after)
url.searchParams.delete(Param.last)
if (value !== null) {
url.searchParams.set(name, value)
}
return url.toString()
}
function preventClickOnDisabledLink(event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest('a[aria-disabled="true"]')) {
event.preventDefault()
}
}
let firstPageURL = urlWithParameter('', null)
let lastPageURL = urlWithParameter(Param.last, '')
$: previousPageURL = urlWithParameter(Param.before, pageInfo.startCursor)
$: nextPageURL = urlWithParameter(Param.after, pageInfo.endCursor)
$: firstAndPreviousDisabled = disabled || !pageInfo.hasPreviousPage
$: nextAndLastDisabled = disabled || !pageInfo.hasNextPage
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- The event handler is used for event delegation -->
<div on:click={preventClickOnDisabledLink}>
<Button variant="secondary" outline>
<a slot="custom" let:className href={firstPageURL} class={className} aria-disabled={firstAndPreviousDisabled}>
<Icon svgPath={mdiPageFirst} inline />
</a>
</Button>
<Button variant="secondary" outline>
<a
slot="custom"
let:className
class={className}
href={previousPageURL}
aria-disabled={firstAndPreviousDisabled}
>
<Icon svgPath={mdiChevronLeft} inline />Previous
</a>
</Button>
<Button variant="secondary" outline>
<a slot="custom" let:className class={className} href={nextPageURL} aria-disabled={nextAndLastDisabled}>
Next <Icon svgPath={mdiChevronRight} inline />
</a>
</Button>
<Button variant="secondary" outline>
<a slot="custom" let:className class={className} href={lastPageURL} aria-disabled={nextAndLastDisabled}>
<Icon svgPath={mdiPageLast} inline />
</a>
</Button>
</div>
<style lang="scss">
a {
color: var(--body-color);
&:first-child {
margin-right: 1rem;
}
&:last-child {
margin-left: 1rem;
}
}
</style>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { createPopper, type Placement } from '@popperjs/core'
import { onClickOutside } from './dom'
export let placement: Placement = 'bottom'
let isOpen = false
let trigger: HTMLElement | null
let content: HTMLElement | null
function createInstance(target: HTMLElement, content: HTMLElement) {
return createPopper(target, content, {
placement,
})
}
function toggle(open?: boolean): void {
isOpen = open === undefined ? !isOpen : open
}
function clickOutside(event: { detail: HTMLElement }): void {
if (event.detail !== trigger && !trigger?.contains(event.detail)) {
isOpen = false
}
}
function registerTrigger(node: HTMLElement) {
trigger = node
}
$: if (isOpen && trigger && content) {
createInstance(trigger, content)
}
</script>
<slot {toggle} {registerTrigger} />
{#if isOpen}
<div class="content" bind:this={content} use:onClickOutside on:click-outside={clickOutside}>
<slot name="content" />
</div>
{/if}
<style lang="scss">
.content {
isolation: isolate;
z-index: 1000;
min-width: 10rem;
font-size: 0.875rem;
background-clip: padding-box;
background-color: var(--dropdown-bg);
border: 1px solid var(--dropdown-border-color);
border-radius: var(--popover-border-radius);
color: var(--body-color);
box-shadow: var(--dropdown-shadow);
}
</style>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { getContext } from 'svelte'
import { type TabsContext, KEY } from './Tabs.svelte'
import * as uuid from 'uuid'
export let title: string
const context = getContext<TabsContext>(KEY)
const id = uuid.v4()
const tabId = `${context.id}-tab-${id}`
context.register({
id: tabId,
title,
})
$: selectedId = context.selectedTab
$: selected = $selectedId === tabId
</script>
{#if selected}
<div id="{context.id}-panel-{id}" aria-labelledby={tabId} role="tabpanel" tabindex={selected ? 0 : -1}>
<slot />
</div>
{/if}

View File

@ -0,0 +1,94 @@
<script lang="ts" context="module">
export interface Tab {
id: string
title: string
}
export interface TabsContext {
id: string
selectedTab: Readable<string>
register(tab: Tab): void
}
export const KEY = {}
</script>
<script lang="ts">
import * as uuid from 'uuid'
import { setContext } from 'svelte'
import { derived, writable, type Readable, type Writable } from 'svelte/store'
/**
* The index of the tab that should be selected by default.
*/
export let initial: number = 0
const id = uuid.v4()
const tabs: Writable<Tab[]> = writable([])
const selectedTab = writable(initial)
setContext<TabsContext>(KEY, {
id,
selectedTab: derived([tabs, selectedTab], ([$tabs, $selectedTab]) => $tabs[$selectedTab]?.id ?? null),
register(tab: Tab) {
tabs.update(tabs => {
if (tabs.some(existingTab => existingTab.id === tab.id)) {
return tabs
}
return [...tabs, tab]
})
},
})
</script>
<div class="tabs">
<div class="tabs-header" role="tablist">
{#each $tabs as tab, index (tab.id)}
<button
id="{id}--tab--{index}"
aria-controls={tab.id}
aria-selected={$selectedTab === index}
tabindex={$selectedTab === index ? 0 : -1}
role="tab"
on:click={() => ($selectedTab = index)}>{tab.title}</button
>
{/each}
</div>
<slot />
</div>
<style lang="scss">
.tabs {
display: flex;
flex-direction: column;
align-items: center;
}
.tabs-header {
display: flex;
gap: 1rem;
}
button {
cursor: pointer;
border: none;
background: none;
align-items: center;
letter-spacing: normal;
margin: 0;
min-height: 2rem;
padding: 0 0.125rem;
color: var(--body-color);
text-transform: none;
display: inline-flex;
flex-direction: column;
justify-content: center;
border-bottom: 2px solid transparent;
&[aria-selected='true'] {
color: var(--body-color);
font-weight: 700;
border-bottom: 2px solid var(--brand-secondary);
}
}
</style>

View File

@ -0,0 +1,132 @@
<script lang="ts">
import { createPopper, type Placement, type Options } from '@popperjs/core'
import { afterUpdate } from 'svelte'
export let tooltip: string
export let placement: Placement = 'bottom'
let visible = false
let tooltipElement: HTMLElement
let container: HTMLElement
let target: Element | null
let instance: ReturnType<typeof createPopper>
function show() {
visible = true
}
function hide() {
visible = false
}
function updateInstance(options: Partial<Options>): void {
if (instance) {
instance.setOptions(options)
}
}
afterUpdate(() => {
instance?.update()
})
$: updateInstance({ placement })
$: target = container?.firstElementChild
$: if (tooltipElement && target && !instance) {
instance = createPopper(target, tooltipElement, {
placement,
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
})
}
</script>
<div
class="target"
bind:this={container}
on:mouseenter={show}
on:mouseleave={hide}
on:focusin={show}
on:focusout={hide}
>
<slot />
</div>
<div class="tooltip-content" class:visible role="tooltip" bind:this={tooltipElement}>
{tooltip}
<div class="arrow" data-popper-arrow />
</div>
<style lang="scss">
.target {
display: contents;
}
.tooltip-content {
--tooltip-font-size: 0.75rem; // 12px
--tooltip-line-height: 1.02rem; // 16.32px / 16px, per Figma
--tooltip-max-width: 256px;
--tooltip-color: var(--light-text);
--tooltip-border-radius: var(--border-radius);
--tooltip-padding-y: 0.25rem;
--tooltip-padding-x: 0.5rem;
--tooltip-margin: 0;
isolation: isolate;
font-size: var(--tooltip-font-size);
line-height: var(--tooltip-line-height);
max-width: var(--tooltip-max-width);
background-color: var(--tooltip-bg);
border-radius: var(--tooltip-border-radius);
color: var(--tooltip-color);
padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
user-select: text;
word-wrap: break-word;
border: none;
min-width: 0;
display: none;
&:global([data-popper-placement^='top']) > .arrow {
bottom: -4px;
}
&:global([data-popper-placement^='bottom']) > .arrow {
top: -4px;
}
&:global([data-popper-placement^='left']) > .arrow {
right: -4px;
}
&:global([data-popper-placement^='right']) > .arrow {
left: -4px;
}
&.visible {
display: block;
}
}
.arrow,
.arrow::before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
}
.arrow {
visibility: hidden;
&::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
}
</style>

View File

@ -0,0 +1,60 @@
<script lang="ts">
interface User {
avatarURL?: string | null
displayName?: string | null
username?: string | null
}
export let user: User
function getInitials(name: string): string {
const names = name.split(' ')
const initials = names.map(name => name.charAt(0).toLowerCase())
if (initials.length > 1) {
return `${initials[0]}${initials[initials.length - 1]}`
}
return initials[0]
}
$: name = user.displayName || user.username || ''
</script>
<div>
<span>{getInitials(name)}</span>
</div>
<style lang="scss">
div {
flex: 1;
isolation: isolate;
display: inline-flex;
border-radius: 50%;
text-transform: capitalize;
color: var(--color-bg-1);
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
position: relative;
background: linear-gradient(to bottom, var(--logo-purple), var(--logo-orange));
width: var(--avatar-size);
height: var(--avatar-size);
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: 50%;
background: linear-gradient(to right, var(--logo-purple), var(--logo-blue));
mask-image: linear-gradient(to bottom, #000000, transparent);
}
}
span {
z-index: 1;
color: var(--white);
}
</style>

View File

@ -0,0 +1,53 @@
/* eslint-disable jsdoc/check-indentation */
import { onDestroy } from 'svelte'
import { beforeNavigate } from '$app/navigation'
import { page } from '$app/stores'
const scrollCache: Map<string, number> = new Map()
/**
* Stores and restores scroll position. Needs to be called at component
* initialization time.
*
* `setter` is called when the component is instantiated. The value passed is
* the previously stored scroll position, if any. This value can then be used to
* set the target elements scroll position when it becomes available.
*
* `getter` is called when the current page is being navigated away from. It
* should to return the scroll position of the target element.
*
* Example:
*
* let scrollTop: number|undefined
* let element: HTMLElement|undefined
*
* preserveScrollPosition(
* position => (scrollTop = position ?? 0),
* () => resultContainer?.scrollTop
* )
* $: if (element) {
* element.scrollTop = scrollTop ?? 0
* }
* ...
* <div bind:this={element} />
*/
export function preserveScrollPosition(
setter: (position: number | undefined) => void,
getter: () => number | undefined
): void {
onDestroy(
page.subscribe($page => {
setter(scrollCache.get($page.url.toString()))
})
)
beforeNavigate(({ from }) => {
if (from) {
const position = getter()
if (position) {
scrollCache.set(from?.url.toString(), position)
}
}
})
}

View File

@ -0,0 +1,10 @@
export { formatRepositoryStarCount } from '@sourcegraph/branded/src/search-ui/util/stars'
export { limitHit, sortBySeverity } from '@sourcegraph/branded/src/search-ui/results/progress/utils'
export {
basicSyntaxColumns,
exampleQueryColumns,
} from '@sourcegraph/branded/src/search-ui/components/QueryExamples.constants'
export { createDefaultSuggestions, singleLine } from '@sourcegraph/branded/src/search-ui/input/codemirror'
export { parseInputAsQuery } from '@sourcegraph/branded/src/search-ui/input/codemirror/parsedQuery'
export { querySyntaxHighlighting } from '@sourcegraph/branded/src/search-ui/input/codemirror/syntax-highlighting'
export { decorateQuery } from '@sourcegraph/branded/src/search-ui/util/query'

View File

@ -0,0 +1,99 @@
import { RangeSetBuilder, type Extension } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, type DecorationSet } from '@codemirror/view'
import prism from 'prismjs'
import { SyntaxKind } from '$lib/shared'
import 'prismjs/components/prism-go'
/**
* Implements experimental client side syntax highlighting for Go files.
* The idea is that downloading only the raw text from the server results in
* smaller payload and avoids memory allocation for highlighting information,
* which can be quite significant for large files.
* Instead only the visible range is highlighted on demand. Here this is done
* with client side syntax highlighting.
* However it's broken if a token starts outside of the visible range, e.g.
* multiline strings.
*/
export function highlight(): Extension {
return ViewPlugin.define(
view => {
const decorationCache: Partial<Record<SyntaxKind, Decoration>> = {}
function decorate(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
const tokens = prism.tokenize(
view.state.sliceDoc(view.viewport.from, view.viewport.to),
prism.languages.go
)
let position = view.viewport.from
for (const token of tokens) {
const from = position
const to = from + token.length
position = to
if (typeof token !== 'string') {
const kind = getType(token.type)
if (kind) {
const decoration =
decorationCache[kind] ??
(decorationCache[kind] = Decoration.mark({ class: `hl-typed-${SyntaxKind[kind]}` }))
builder.add(from, to, decoration)
}
}
}
return builder.finish()
}
return {
decorations: decorate(view),
update(update) {
if (update.viewportChanged) {
this.decorations = decorate(update.view)
}
},
}
},
{
decorations(plugin) {
return plugin.decorations
},
}
)
}
function getType(type: string): SyntaxKind | null {
switch (type) {
case 'comment':
return SyntaxKind.Comment
case 'keyword':
return SyntaxKind.IdentifierKeyword
case 'builtin':
return SyntaxKind.IdentifierBuiltin
case 'class-name':
case 'function':
return SyntaxKind.IdentifierFunction
case 'boolean':
return SyntaxKind.BooleanLiteral
case 'number':
return SyntaxKind.NumericLiteral
case 'string':
return SyntaxKind.StringLiteral
case 'char':
return SyntaxKind.CharacterLiteral
case 'variable':
case 'symbol':
return SyntaxKind.Identifier
case 'constant':
return SyntaxKind.IdentifierConstant
case 'property':
return SyntaxKind.IdentifierAttribute
case 'punctuation':
return SyntaxKind.PunctuationDelimiter
case 'operator':
return SyntaxKind.IdentifierOperator
case 'regex':
case 'url':
return SyntaxKind.UnspecifiedSyntaxKind
}
return null
}

View File

@ -0,0 +1,11 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
export type { ErrorLike } from '@sourcegraph/common/src/errors/types'
export { isErrorLike } from '@sourcegraph/common/src/errors/utils'
export { createAggregateError, asError } from '@sourcegraph/common/src/errors/errors'
export { memoizeObservable, resetAllMemoizationCaches } from '@sourcegraph/common/src/util/rxjs/memoizeObservable'
export { encodeURIPathComponent } from '@sourcegraph/common/src/util/url'
export { pluralize, numberWithCommas } from '@sourcegraph/common/src/util/strings'
export { renderMarkdown } from '@sourcegraph/common/src/util/markdown/markdown'
export { highlightNodeMultiline } from '@sourcegraph/common/src/util/highlightNode'
export { logger } from '@sourcegraph/common/src/util/logger'

View File

@ -0,0 +1,53 @@
// Trimmed down platform context object to make dev and prod builds work. The
// real platform context module imports tracking/eventLogger.ts, which causes a
// couple of errors.
// TODO: Consolidate with actual platform context
//
import type { ApolloQueryResult, ObservableQuery } from '@apollo/client'
import { map, publishReplay, refCount, shareReplay } from 'rxjs/operators'
import { createAggregateError } from '$lib/common'
import type { ViewerSettingsResult, ViewerSettingsVariables } from '$lib/graphql/shared'
import { getDocumentNode, type GraphQLClient, fromObservableQuery } from '$lib/http-client'
import { viewerSettingsQuery } from '$lib/loader/settings'
import { type PlatformContext, type SettingsSubject, gqlToCascade } from '$lib/shared'
import { requestGraphQL } from '$lib/web'
export function createPlatformContext(client: GraphQLClient): Pick<PlatformContext, 'requestGraphQL' | 'settings'> {
const settingsQueryWatcher = watchViewerSettingsQuery(client)
return {
settings: fromObservableQuery(settingsQueryWatcher).pipe(
map(mapViewerSettingsResult),
shareReplay(1),
map(gqlToCascade),
publishReplay(1),
refCount()
),
requestGraphQL: ({ request, variables }) => requestGraphQL(request, variables),
}
}
function mapViewerSettingsResult({ data, errors }: ApolloQueryResult<ViewerSettingsResult>): {
subjects: SettingsSubject[]
} {
if (!data?.viewerSettings) {
throw createAggregateError(errors)
}
return data.viewerSettings
}
/**
* Creates Apollo query watcher for the viewer's settings. Watcher is used instead of the one-time query because we
* want to use cached response if it's available. Callers should use settingsRefreshes#next instead of calling
* this function, to ensure that the result is propagated consistently throughout the app instead of only being
* returned to the caller.
*/
function watchViewerSettingsQuery(
graphQLClient: GraphQLClient
): ObservableQuery<ViewerSettingsResult, ViewerSettingsVariables> {
return graphQLClient.watchQuery<ViewerSettingsResult, ViewerSettingsVariables>({
query: getDocumentNode(viewerSettingsQuery),
})
}

View File

@ -0,0 +1,23 @@
import type { ActionReturn } from 'svelte/action'
/**
* An action that dispatches a custom 'click-outside' event when the user clicks
* outside the attached element.
*/
export function onClickOutside(
node: HTMLElement
): ActionReturn<void, { 'on:click-outside': (event: CustomEvent<HTMLElement>) => void }> {
function handler(event: MouseEvent): void {
if (event.target && !node.contains(event.target as HTMLElement)) {
node.dispatchEvent(new CustomEvent('click-outside', { detail: event.target }))
}
}
window.addEventListener('mousedown', handler)
return {
destroy() {
window.removeEventListener('mousedown', handler)
},
}
}

View File

@ -0,0 +1 @@
export * from '@sourcegraph/shared/src/graphql-operations'

View File

@ -0,0 +1,10 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
export {
getDocumentNode,
type GraphQLClient,
gql,
dataOrThrowErrors,
type GraphQLResult,
} from '@sourcegraph/http-client/src/graphql/graphql'
export { fromObservableQuery } from '@sourcegraph/http-client/src/graphql/apollo/fromObservableQuery'

View File

@ -0,0 +1,3 @@
export { default as logoLight } from '$root/ui/assets/img/sourcegraph-logo-light.svg'
export { default as logoDark } from '$root/ui/assets/img/sourcegraph-logo-dark.svg'
export { default as mark } from '$root/ui/assets/img/sourcegraph-mark.svg'

View File

@ -0,0 +1,39 @@
import type { ActionReturn } from 'svelte/action'
const callback = (entries: IntersectionObserverEntry[]): void => {
for (const entry of entries) {
entry.target.dispatchEvent(new CustomEvent<boolean>('intersecting', { detail: entry.isIntersecting }))
}
}
function createObserver(root: HTMLElement | null): IntersectionObserver {
return new IntersectionObserver(callback, { root, rootMargin: '0px 0px 500px 0px' })
}
const globalObserver = createObserver(null)
export function observeIntersection(
node: HTMLElement
): ActionReturn<void, { 'on:intersecting': (e: CustomEvent<boolean>) => void }> {
let observer = globalObserver
let scrollAncestor: HTMLElement | null = node.parentElement
while (scrollAncestor) {
const overflow = getComputedStyle(scrollAncestor).overflowY
if (overflow === 'auto' || overflow === 'scroll') {
break
}
scrollAncestor = scrollAncestor.parentElement
}
if (scrollAncestor && scrollAncestor !== document.getRootNode()) {
observer = new IntersectionObserver(callback, { root: scrollAncestor, rootMargin: '0px 0px 500px 0px' })
}
observer.observe(node)
return {
destroy() {
observer.unobserve(node)
},
}
}

View File

@ -0,0 +1 @@
export { currentAuthStateQuery } from '@sourcegraph/shared/src/auth'

View File

@ -0,0 +1,123 @@
import type { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
export { fetchHighlightedFileLineRanges } from '@sourcegraph/shared/src/backend/file'
import { memoizeObservable } from '$lib/common'
import type {
BlobFileFields,
HighlightingFields,
BlobResult,
BlobVariables,
HighlightResult,
HighlightVariables,
} from '$lib/graphql-operations'
import { dataOrThrowErrors, gql } from '$lib/http-client'
import { makeRepoURI } from '$lib/shared'
import { requestGraphQL } from '$lib/web'
interface FetchBlobOptions {
repoName: string
revision: string
filePath: string
disableTimeout?: boolean
}
/**
* Makes sure that default values are applied consistently for the cache key and the `fetchBlob` function.
*/
const applyDefaultValuesToFetchBlobOptions = ({
disableTimeout = false,
...options
}: FetchBlobOptions): Required<FetchBlobOptions> => ({
...options,
disableTimeout,
})
function fetchBlobCacheKey(options: FetchBlobOptions): string {
const { disableTimeout } = applyDefaultValuesToFetchBlobOptions(options)
return `${makeRepoURI(options)}?disableTimeout=${disableTimeout}`
}
export const fetchHighlight = memoizeObservable(
(options: FetchBlobOptions): Observable<HighlightingFields['highlight'] | null> => {
const { repoName, revision, filePath, disableTimeout } = applyDefaultValuesToFetchBlobOptions(options)
return requestGraphQL<HighlightResult, HighlightVariables>(
gql`
query Highlight($repoName: String!, $revision: String!, $filePath: String!, $disableTimeout: Boolean!) {
repository(name: $repoName) {
commit(rev: $revision) {
file(path: $filePath) {
...HighlightingFields
}
}
}
}
fragment HighlightingFields on File2 {
__typename
highlight(disableTimeout: $disableTimeout, format: JSON_SCIP) {
aborted
lsif
}
}
`,
{ repoName, revision, filePath, disableTimeout }
).pipe(
map(dataOrThrowErrors),
map(data => {
if (!data.repository?.commit) {
throw new Error('Commit not found')
}
return data.repository.commit.file?.highlight ?? null
})
)
},
fetchBlobCacheKey
)
export const fetchBlobPlaintext = memoizeObservable((options: FetchBlobOptions): Observable<BlobFileFields | null> => {
const { repoName, revision, filePath } = applyDefaultValuesToFetchBlobOptions(options)
return requestGraphQL<BlobResult, BlobVariables>(
gql`
query Blob($repoName: String!, $revision: String!, $filePath: String!) {
repository(name: $repoName) {
commit(rev: $revision) {
file(path: $filePath) {
...BlobFileFields
}
}
}
}
fragment BlobFileFields on File2 {
__typename
content
richHTML
... on GitBlob {
lfs {
byteSize
}
externalURLs {
url
serviceKind
}
}
}
`,
{ repoName, revision, filePath }
).pipe(
map(dataOrThrowErrors),
map(data => {
if (!data.repository?.commit) {
throw new Error('Commit not found')
}
return data.repository.commit.file
})
)
}, fetchBlobCacheKey)

View File

@ -0,0 +1,296 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
import type { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import type { Repo, ResolvedRevision } from '@sourcegraph/web/src/repo/backend'
import { browser } from '$app/environment'
import { isErrorLike, type ErrorLike } from '$lib/common'
import type {
RepositoryGitCommitsResult,
RepositoryCommitResult,
Scalars,
RepositoryComparisonDiffResult,
RepositoryComparisonDiffVariables,
GitCommitFields,
} from '$lib/graphql-operations'
import { dataOrThrowErrors, gql, type GraphQLResult } from '$lib/http-client'
import { requestGraphQL } from '$lib/web'
// Unfortunately it doesn't seem possible to share fragements across package
// boundaries
const gitCommitFragment = gql`
fragment GitCommitFields on GitCommit {
id
oid
abbreviatedOID
message
subject
body
author {
...SignatureFields
}
committer {
...SignatureFields
}
parents {
oid
abbreviatedOID
url
}
url
canonicalURL
externalURLs {
...ExternalLinkFields
}
tree(path: "") {
canonicalURL
}
}
fragment SignatureFields on Signature {
person {
avatarURL
name
email
displayName
user {
id
username
url
displayName
}
}
date
}
fragment ExternalLinkFields on ExternalLink {
url
serviceKind
}
`
const diffStatFields = gql`
fragment DiffStatFields on DiffStat {
__typename
added
deleted
}
`
const fileDiffHunkFields = gql`
fragment FileDiffHunkFields on FileDiffHunk {
oldRange {
startLine
lines
}
oldNoNewlineAt
newRange {
startLine
lines
}
section
highlight(disableTimeout: false) {
aborted
lines {
kind
html
}
}
}
`
export const fileDiffFields = gql`
fragment FileDiffFields on FileDiff {
oldPath
oldFile {
__typename
binary
byteSize
}
newFile {
__typename
binary
byteSize
}
newPath
mostRelevantFile {
__typename
url
}
hunks {
...FileDiffHunkFields
}
stat {
added
deleted
}
internalID
}
${fileDiffHunkFields}
`
const REPOSITORY_GIT_COMMITS_PER_PAGE = 20
const REPOSITORY_GIT_COMMITS_QUERY = gql`
query RepositoryGitCommits($repo: ID!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
node(id: $repo) {
__typename
... on Repository {
commit(rev: $revspec) {
ancestors(first: $first, path: $filePath, afterCursor: $afterCursor) {
nodes {
...GitCommitFields
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
}
${gitCommitFragment}
`
const COMMIT_QUERY = gql`
query RepositoryCommit($repo: ID!, $revspec: String!) {
node(id: $repo) {
__typename
... on Repository {
commit(rev: $revspec) {
__typename # Necessary for error handling to check if commit exists
...GitCommitFields
}
}
}
}
${gitCommitFragment}
`
export function fetchRepoCommits(
repoId: string,
revision: string,
filePath: string | null,
first: number = REPOSITORY_GIT_COMMITS_PER_PAGE
): Observable<GraphQLResult<RepositoryGitCommitsResult>> {
return requestGraphQL(REPOSITORY_GIT_COMMITS_QUERY, {
repo: repoId,
revspec: revision,
filePath: filePath ?? null,
first,
afterCursor: null,
})
}
export function fetchRepoCommit(repoId: string, revision: string): Observable<GraphQLResult<RepositoryCommitResult>> {
return requestGraphQL(COMMIT_QUERY, { repo: repoId, revspec: revision })
}
export type RepositoryComparisonDiff = Extract<RepositoryComparisonDiffResult['node'], { __typename?: 'Repository' }>
export function queryRepositoryComparisonFileDiffs(args: {
repo: Scalars['ID']
base: string | null
head: string | null
first: number | null
after: string | null
paths: string[] | null
}): Observable<RepositoryComparisonDiff['comparison']['fileDiffs']> {
return requestGraphQL<RepositoryComparisonDiffResult, RepositoryComparisonDiffVariables>(
gql`
query RepositoryComparisonDiff(
$repo: ID!
$base: String
$head: String
$first: Int
$after: String
$paths: [String!]
) {
node(id: $repo) {
__typename
... on Repository {
comparison(base: $base, head: $head) {
fileDiffs(first: $first, after: $after, paths: $paths) {
nodes {
...FileDiffFields
}
totalCount
pageInfo {
endCursor
hasNextPage
}
diffStat {
...DiffStatFields
}
}
}
}
}
}
${fileDiffFields}
${diffStatFields}
`,
args
).pipe(
map(result => {
const data = dataOrThrowErrors(result)
const repo = data.node
if (repo === null) {
throw new Error('Repository not found')
}
if (repo.__typename !== 'Repository') {
throw new Error('Not a repository')
}
return repo.comparison.fileDiffs
})
)
}
const clientCache: Map<string, { nodes: GitCommitFields[] }> = new Map()
function getCacheKey(resolvedRevision: ResolvedRevision & Repo): string {
return [resolvedRevision.repo.id, resolvedRevision.commitID ?? ''].join('/')
}
export async function fetchCommits(
resolvedRevision: (ResolvedRevision & Repo) | ErrorLike,
force: boolean = false
): Promise<{ nodes: GitCommitFields[] }> {
if (!isErrorLike(resolvedRevision)) {
if (browser && !force) {
const fromCache = clientCache.get(getCacheKey(resolvedRevision))
if (fromCache) {
return fromCache
}
}
const commits = await fetchRepoCommits(resolvedRevision.repo.id, resolvedRevision.commitID ?? '', null)
.toPromise()
.then(result => {
const { node } = dataOrThrowErrors(result)
if (!node) {
return { nodes: [] }
}
if (node.__typename !== 'Repository') {
return { nodes: [] }
}
if (!node.commit?.ancestors) {
return { nodes: [] }
}
return node?.commit?.ancestors
})
if (browser) {
clientCache.set(getCacheKey(resolvedRevision), commits)
}
return commits
}
return { nodes: [] }
}

View File

@ -0,0 +1,243 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
import type { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { createAggregateError, memoizeObservable } from '$lib/common'
import {
GitRefType,
type GitRefConnectionFields,
type GitRefFields,
type RepositoryGitBranchesOverviewResult,
type RepositoryGitBranchesOverviewVariables,
type RepositoryGitRefsResult,
type RepositoryGitRefsVariables,
type Scalars,
} from '$lib/graphql-operations'
import { gql } from '$lib/http-client'
import { requestGraphQL } from '$lib/web'
export { resolveRepoRevision } from '@sourcegraph/web/src/repo/backend'
export { fetchTreeEntries } from '@sourcegraph/shared/src/backend/repo'
// Copies of non-reusable queries from the main app
// TODO: Refactor queries to make them reusable
export 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) {
... on Repository {
__typename
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 {
oid
abbreviatedOID
url
subject
author {
date
}
}
}
}
`
export const gitReferenceFragments = gql`
fragment GitRefFields on GitRef {
__typename
id
displayName
name
abbrevName
url
target {
commit {
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) {
__typename
... 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 const queryGitReferences = memoizeObservable(
(args: {
repo: Scalars['ID']
first?: number
query?: string
type: GitRefType
withBehindAhead?: boolean
}): Observable<GitRefConnectionFields> =>
requestGraphQL<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,
}).pipe(
map(({ data, errors }) => {
if (data?.node?.__typename !== 'Repository' || !data.node.gitRefs) {
throw createAggregateError(errors)
}
return data.node.gitRefs
})
),
args => `${args.repo}:${String(args.first)}:${String(args.query)}:${args.type}`
)
interface Data {
defaultBranch: GitRefFields | null
activeBranches: GitRefFields[]
hasMoreActiveBranches: boolean
}
export const queryGitBranchesOverview = memoizeObservable(
(args: { repo: Scalars['ID']; first: number }): Observable<Data> =>
requestGraphQL<RepositoryGitBranchesOverviewResult, RepositoryGitBranchesOverviewVariables>(
gql`
query RepositoryGitBranchesOverview($repo: ID!, $first: Int!, $withBehindAhead: Boolean!) {
node(id: $repo) {
...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 }
).pipe(
map(({ data, errors }) => {
if (!data?.node) {
throw createAggregateError(errors)
}
const repo = data.node
if (repo.__typename !== 'Repository') {
throw new Error('Not a GitRef')
}
if (!repo.gitRefs.nodes) {
throw createAggregateError(errors)
}
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,
}
})
),
args => `${args.repo}:${args.first}`
)

View File

@ -0,0 +1 @@
export { viewerSettingsQuery } from '@sourcegraph/shared/src/backend/settings'

View File

@ -0,0 +1,21 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
import { onMount } from 'svelte'
// Dev build breaks if this import is moved to `$lib/web` ¯\_(ツ)_/¯
import { eventLogger, type EventLogger } from '@sourcegraph/web/src/tracking/eventLogger'
import { PUBLIC_ENABLE_EVENT_LOGGER } from '$env/static/public'
/**
* Can only be called during component initialization. It logs a view event when
* the component is mounted (and event logging is enabled).
*/
export function logViewEvent(...args: Parameters<EventLogger['logViewEvent']>): void {
if (PUBLIC_ENABLE_EVENT_LOGGER) {
onMount(() => {
eventLogger.logViewEvent(...args)
})
}
}

View File

@ -0,0 +1,25 @@
// in miliseconds
const units = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
} satisfies Partial<Record<Intl.RelativeTimeFormatUnit, number>>
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
export function getRelativeTime(date1: Date, date2: Date = new Date()): string {
const elapsed = date1.getTime() - date2.getTime()
for (const unit in units) {
if (Math.abs(elapsed) > units[unit as keyof typeof units]) {
return rtf.format(
Math.round(elapsed / units[unit as keyof typeof units]),
unit as Intl.RelativeTimeFormatUnit
)
}
}
return rtf.format(Math.round(elapsed / units.second), 'second')
}

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { mdiFileDocumentOutline, mdiFolderOutline } from '@mdi/js'
import { isErrorLike, type ErrorLike } from '$lib/common'
import type { TreeFields } from '$lib/graphql/shared'
import Icon from '$lib/Icon.svelte'
export let treeOrError: TreeFields | ErrorLike | null
export let activeEntry: string
export let commitData: string | null = null
function scrollIntoView(node: HTMLElement, scroll: boolean) {
if (scroll) {
console.log(scroll, node)
node.scrollIntoView()
}
}
$: entries = !isErrorLike(treeOrError) && treeOrError ? treeOrError.entries : []
</script>
<slot name="title">
<h3>Files</h3>
</slot>
<ul>
{#each entries as entry}
<li class:active={entry.name === activeEntry} use:scrollIntoView={entry.name === 'activeEntry'}>
<a href={entry.url}>
<span>
<Icon svgPath={entry.isDirectory ? mdiFolderOutline : mdiFileDocumentOutline} inline />
</span>
<span class="name">{entry.name}</span>
</a>
{#if commitData}
<span class="ml-5">{commitData}</span>
{/if}
</li>
{/each}
</ul>
<style lang="scss">
ul {
flex: 1;
list-style: none;
padding: 0;
margin: 0;
overflow: auto;
min-height: 0;
}
li {
display: flex;
a {
flex: 1;
white-space: nowrap;
color: var(--body-color);
text-decoration: none;
padding: 0.25rem;
}
&:hover {
a {
background-color: var(--color-bg-2);
}
.name {
text-decoration: underline;
}
}
&.active a {
background-color: var(--color-bg-3);
}
}
span {
position: sticky;
left: 0;
background-color: inherit;
}
</style>

View File

@ -0,0 +1,63 @@
<script lang="ts">
import { getRelativeTime } from '$lib/relativeTime'
import { currentDate as now } from '$lib/stores'
import { numberWithCommas } from '$lib/common'
import type { GitRefFields } from '$lib/graphql-operations'
export let ref: GitRefFields
$: authorName = ref.target.commit?.author.person.displayName ?? ''
$: authorDate = ref.target.commit ? new Date(ref.target.commit.author.date) : null
$: behind = ref.target.commit?.behindAhead?.behind
$: ahead = ref.target.commit?.behindAhead?.ahead
$: relativeTime = authorDate ? getRelativeTime(authorDate, $now) : ''
</script>
<tr>
<td>
<a href={ref.url}><span class="ref px-1 py-0">{ref.displayName}</span></a>
</td>
<td colspan={behind || ahead ? 1 : 2}>
<a href={ref.url}><small>Updated {relativeTime} by {authorName}</small></a>
</td>
{#if ahead || behind}
<td class="diff">
<a href={ref.url}
><small>{numberWithCommas(behind ?? 0)} behind, {numberWithCommas(ahead ?? 0)} ahead</small></a
>
</td>
{/if}
</tr>
<style lang="scss">
td {
padding: 0.5rem;
border-bottom: 1px solid var(--border-color-2);
}
tr:last-child td {
border: none;
}
.ref {
background-color: var(--subtle-bg);
border-radius: var(--border-radius);
}
a {
display: block;
}
small {
color: var(--text-muted);
&:hover {
color: inherit;
}
}
.diff {
text-align: right;
}
</style>

View File

@ -0,0 +1,11 @@
<script lang="ts">
import { getContext, type ComponentType } from 'svelte'
import type { ActionStore } from './actions'
export let key: string
export let priority: number
export let component: ComponentType
const repoActions = getContext<ActionStore>('repo-actions')
repoActions.setAction({ key, priority, component })
</script>

View File

@ -0,0 +1,43 @@
import { onDestroy, type ComponentType } from 'svelte'
import { writable, derived, type Readable } from 'svelte/store'
interface Action {
key: string
priority: number
component: ComponentType
}
export interface ActionStore extends Readable<Action[]> {
setAction(action: Action): void
}
/**
* Creates a context via which repo subpages can add "actions" to the shared
* header.
*/
export function createActionStore(): ActionStore {
const actions = writable<Action[]>([])
const sortedActions = derived(actions, $actions => [...$actions].sort((a, b) => a.priority - b.priority))
// TODO: This should be reimplemented so that it's possible to add and
// remove actions even after the component was instantiated.
return {
subscribe: sortedActions.subscribe,
setAction(action: Action): void {
actions.update(actions => {
const existingAction = actions.find(a => a.key === action.key)
if (existingAction) {
if (existingAction.component === action.component) {
return actions
}
actions = actions.filter(a => a.key !== action.key)
}
return [...actions, action]
})
onDestroy(() => {
actions.update(actions => actions.filter(a => a.key !== action.key))
})
},
}
}

View File

@ -0,0 +1,20 @@
import type { ResolvedRevision } from '$lib/web'
export function navFromPath(path: string, repo: string, blobPage: boolean): [string, string][] {
const parts = path.split('/')
return parts
.slice(0, -1)
.map((part, index, all): [string, string] => [part, `/${repo}/-/tree/${all.slice(0, index + 1).join('/')}`])
.concat([[parts[parts.length - 1], `/${repo}/-/${blobPage ? 'blob' : 'tree'}/${path}`]])
}
export function getRevisionLabel(
urlRevision: string | undefined,
resolvedRevision: ResolvedRevision | null
): string | undefined {
return (
(urlRevision && urlRevision === resolvedRevision?.commitID
? resolvedRevision?.commitID.slice(0, 7)
: urlRevision?.slice(0, 7)) || resolvedRevision?.defaultBranch
)
}

View File

@ -0,0 +1,221 @@
<script lang="ts">
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { Compartment, EditorState, Prec } from '@codemirror/state'
import { EditorView, keymap, placeholder as placeholderExtension } from '@codemirror/view'
import { closeCompletion } from '@codemirror/autocomplete'
import { createEventDispatcher } from 'svelte'
import { browser } from '$app/environment'
import { goto } from '$app/navigation'
import type { SearchPatternType } from '$lib/graphql-operations'
import { createDefaultSuggestions, singleLine, parseInputAsQuery, querySyntaxHighlighting } from '$lib/branded'
import { fetchStreamSuggestions, QueryChangeSource, type QueryState } from '$lib/shared'
import { defaultTheme } from './codemirror/theme'
export let queryState: QueryState
export let patternType: SearchPatternType
export let interpretComments: boolean = false
export let placeholder: string = ''
export let autoFocus: boolean = false
export function focus() {
editor?.focus()
editor?.dispatch({ selection: { anchor: editor.state.doc.length } })
}
const dispatch = createEventDispatcher<{ change: QueryState; submit: void }>()
let container: HTMLDivElement | null = null
let editor: EditorView | null = null
let dynamicExtensions = new Compartment()
interface ExtensionConfig {
patternType: SearchPatternType
interpretComments: boolean
placeholder: string
}
function configureExtensions(config: ExtensionConfig) {
if (!browser) {
return []
}
const extensions = [
parseInputAsQuery({ patternType: config.patternType, interpretComments: config.interpretComments }),
createDefaultSuggestions({
fetchSuggestions: query => fetchStreamSuggestions(query),
globbing: false,
isSourcegraphDotCom: false,
navigate: url => goto(url.toString()),
applyOnEnter: true,
}),
]
if (config.placeholder) {
// Passing a DOM element instead of a string makes the CodeMirror
// extension set aria-hidden="true" on the placeholder, which is
// what we want.
const element = document.createElement('span')
element.append(document.createTextNode(placeholder))
extensions.push(placeholderExtension(element))
}
return extensions
}
function updateExtensions(config: ExtensionConfig) {
if (editor) {
editor.dispatch({ effects: dynamicExtensions.reconfigure(configureExtensions(config)) })
}
}
function createEditor(container: HTMLDivElement): EditorView {
const extensions = [
defaultTheme,
dynamicExtensions.of(configureExtensions({ interpretComments, patternType, placeholder })),
Prec.high(
keymap.of([
{
key: 'Enter',
run(view) {
closeCompletion(view)
dispatch('submit')
return true
},
},
])
),
singleLine,
EditorView.updateListener.of(update => {
const { state } = update
if (update.docChanged) {
dispatch('change', {
query: state.sliceDoc(),
changeSource: QueryChangeSource.userInput,
})
}
if (update.focusChanged && !update.view.hasFocus) {
closeCompletion(update.view)
}
}),
keymap.of(historyKeymap),
keymap.of(defaultKeymap),
history(),
// themeExtension.of(EditorView.darkTheme.of(isLightTheme === false)),
// queryDiagnostic(),
// The precedence of these extensions needs to be decreased
// explicitly, otherwise the diagnostic indicators will be
// hidden behind the highlight background color
Prec.low([
//tokenInfo(),
//highlightFocusedFilter,
// It baffels me but the syntax highlighting extension has
// to come after the highlight current filter extension,
// otherwise CodeMirror keeps steeling the focus.
// See https://github.com/sourcegraph/sourcegraph/issues/38677
querySyntaxHighlighting,
]),
]
const view = new EditorView({
state: EditorState.create({ doc: queryState.query, extensions }),
parent: container,
})
return view
}
$: if (editor && editor.state.sliceDoc() !== queryState.query) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: queryState.query },
})
}
$: updateExtensions({ placeholder, patternType, interpretComments })
$: if (container && !editor) {
editor = createEditor(container)
if (autoFocus) {
window.requestAnimationFrame(() => editor!.focus())
}
}
</script>
{#if browser}
<div bind:this={container} class="root test-query-input test-editor" data-editor="codemirror6" />
{:else}
<div class="root">
<input value={queryState.query} {placeholder} />
</div>
{/if}
<style lang="scss">
input {
border: 0;
font-family: var(--code-font-family);
font-size: var(--code-font-size);
width: 100%;
}
.root {
flex: 1;
box-sizing: border-box;
background-color: var(--color-bg-1);
min-width: 0;
:global(.cm-editor) {
// Codemirror shows a focus ring by default. Since we handle that
// differently, disable it here.
outline: none !important;
:global(.cm-scroller) {
// Codemirror shows a vertical scroll bar by default (when
// overflowing). This disables it.
overflow-x: hidden;
}
:global(.cm-content) {
caret-color: var(--search-query-text-color);
font-family: var(--code-font-family);
font-size: var(--code-font-size);
color: var(--search-query-text-color);
// Disable default padding
padding: 0;
&:global(.focus-visible) {
box-shadow: none;
}
}
}
:global(.cm-line) {
// Disable default padding
padding: 0;
}
:global(.cm-placeholder) {
// CodeMirror uses display: inline-block by default, but that causes
// Chrome to render a larger cursor if the placeholder holder spans
// multiple lines. Firefox doesn't have this problem (but
// setting display: inline doesn't seem to have a negative effect
// either)
display: inline;
// Once again, Chrome renders the placeholder differently than
// Firefox. CodeMirror sets 'word-break: break-word' (which is
// deprecated) and 'overflow-wrap: anywhere' but they don't seem to
// have an effect in Chrome (at least not in this instance).
// Setting 'word-break: break-all' explicitly makes appearances a
// bit better for example queries with long tokens.
word-break: break-all;
}
// .placeholder needs to explicilty have the same background color because it
// appears to be placed outside of .focusedFilter rather than within it.
:global(.placeholder),
:global(.focusedFilter) {
background-color: var(--gray-02);
:global(.theme-dark) & {
background-color: var(--gray-08);
}
}
}
</style>

View File

@ -0,0 +1,67 @@
<script context="module" lang="ts">
export interface QueryExample {
query: string
id?: string
slug?: string
helperText?: string
}
</script>
<script lang="ts">
import { getContext } from 'svelte'
import type { SearchPageContext } from './utils'
import SyntaxHighlightedQuery from '$lib/search/SyntaxHighlightedQuery.svelte'
export let queryExample: QueryExample
const searchContext = getContext<SearchPageContext>('search-context')
function handleClick() {
searchContext.setQuery(query => (query + ' ' + queryExample.query).trim())
}
</script>
<li class="d-flex align-items-center">
<button type="button" on:click={handleClick}>
<SyntaxHighlightedQuery query={queryExample.query} />
</button>
{#if queryExample.helperText}
<span class="text-muted ml-2"><small>{queryExample.helperText}</small></span>
{/if}
</li>
<style lang="scss">
button {
background-color: var(--code-bg);
box-shadow: var(--search-input-shadow);
border-radius: var(--border-radius);
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
max-width: 21rem;
text-align: left;
border: 1px solid transparent;
color: var(--body-color);
cursor: pointer;
&:hover {
border: 1px solid var(--border-color);
}
&:active {
position: relative;
top: 1px;
border: 1px solid var(--border-color);
box-shadow: none;
}
&:focus {
border: 1px solid transparent;
box-shadow: 0 0 0 0.125rem var(--primary-2);
outline: none;
}
&:active:focus {
border: 1px solid var(--border-color);
box-shadow: none;
}
}
</style>

View File

@ -0,0 +1,238 @@
<script lang="ts">
import { mdiClose, mdiCodeBrackets, mdiFormatLetterCase, mdiLightningBolt, mdiMagnify, mdiRegex } from '@mdi/js'
import { invalidate } from '$app/navigation'
import Icon from '$lib/Icon.svelte'
import Popover from '$lib/Popover.svelte'
import Tooltip from '$lib/Tooltip.svelte'
import { SearchPatternType } from '$lib/graphql-operations'
import CodeMirrorQueryInput from './CodeMirrorQueryInput.svelte'
import { SearchMode, submitSearch, type QueryStateStore } from './state'
export let queryState: QueryStateStore
export let autoFocus = false
export function focus() {
input?.focus()
}
let input: CodeMirrorQueryInput
$: regularExpressionEnabled = $queryState.patternType === SearchPatternType.regexp
$: structuralEnabled = $queryState.patternType === SearchPatternType.structural
$: smartEnabled = $queryState.searchMode === SearchMode.SmartSearch
function setOrUnsetPatternType(patternType: SearchPatternType): void {
queryState.setPatternType(currentPatternType =>
currentPatternType === patternType ? SearchPatternType.standard : patternType
)
}
async function handleSubmit(event: Event) {
event.preventDefault()
const currentQueryState = $queryState
await invalidate(`query:${$queryState.query}--${$queryState.caseSensitive}`)
submitSearch(currentQueryState)
}
</script>
<form class="search-box" action="/search" method="get" on:submit={handleSubmit}>
<input class="hidden" value={$queryState.query} name="q" />
<span class="context"
><span class="search-filter-keyword">context:</span><span>{$queryState.searchContext}</span></span
>
<span class="divider" />
<CodeMirrorQueryInput
bind:this={input}
{autoFocus}
placeholder="Search for code or files"
queryState={$queryState}
on:change={event => queryState.setQuery(event.detail.query)}
on:submit={handleSubmit}
patternType={$queryState.patternType}
/>
<Tooltip tooltip={`${$queryState.caseSensitive ? 'Disable' : 'Enable'} case sensitivity`}>
<button
class="toggle icon"
type="button"
class:active={$queryState.caseSensitive}
on:click={() => queryState.setCaseSensitive(caseSensitive => !caseSensitive)}
>
<Icon svgPath={mdiFormatLetterCase} inline />
</button>
</Tooltip>
<Tooltip tooltip={`${regularExpressionEnabled ? 'Disable' : 'Enable'} regular expression`}>
<button
class="toggle icon"
type="button"
class:active={regularExpressionEnabled}
on:click={() => setOrUnsetPatternType(SearchPatternType.regexp)}
>
<Icon svgPath={mdiRegex} inline />
</button>
</Tooltip>
<Tooltip tooltip={`${structuralEnabled ? 'Disable' : 'Enable'} structural search`}>
<button
class="toggle icon"
type="button"
class:active={structuralEnabled}
on:click={() => setOrUnsetPatternType(SearchPatternType.structural)}
>
<Icon svgPath={mdiCodeBrackets} inline />
</button>
</Tooltip>
<span class="divider" />
<Popover let:registerTrigger let:toggle>
<Tooltip tooltip="Smart search {smartEnabled ? 'enabled' : 'disabled'}">
<button
class="toggle icon"
type="button"
class:active={smartEnabled}
on:click={() => toggle()}
use:registerTrigger
>
<Icon svgPath={mdiLightningBolt} inline />
</button>
</Tooltip>
<div slot="content" class="popover-content">
{@const delayedClose = () => setTimeout(() => toggle(false), 100)}
<div class="d-flex align-items-center px-3 py-2">
<h4 class="m-0 mr-auto">SmartSearch</h4>
<button class="icon" type="button" on:click={() => toggle(false)}>
<Icon svgPath={mdiClose} inline />
</button>
</div>
<div>
<label class="d-flex align-items-start">
<input
type="radio"
name="mode"
value="smart"
checked={smartEnabled}
on:click={() => {
queryState.setMode(SearchMode.SmartSearch)
delayedClose()
}}
/>
<span class="d-flex flex-column ml-1">
<span>Enable</span>
<small class="text-muted"
>Suggest variations of your query to find more results that may relate.</small
>
</span>
</label>
<label class="d-flex align-items-start">
<input
type="radio"
name="mode"
value="precise"
checked={!smartEnabled}
on:click={() => {
queryState.setMode(SearchMode.Precise)
delayedClose()
}}
/>
<span class="d-flex flex-column ml-1">
<span>Disable</span>
<small class="text-muted">Only show results that previsely match your query.</small>
</span>
</label>
</div>
</div>
</Popover>
<button class="submit">
<Icon aria-label="search" svgPath={mdiMagnify} inline />
</button>
</form>
<style lang="scss">
form {
width: 100%;
display: flex;
align-items: center;
background-color: var(--color-bg-1);
padding-left: 0.5rem;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
border: 1px solid var(--border-color);
margin: 2px;
&:focus-within {
outline: 0;
box-shadow: var(--focus-box-shadow);
}
}
.hidden {
display: none;
}
.context {
font-family: var(--code-font-family);
font-size: 0.75rem;
}
button.toggle {
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
&.active {
background-color: var(--primary);
color: var(--light-text);
}
:global(svg) {
transform: scale(1.172);
}
}
button.submit {
margin-left: 1rem;
padding: 0.5rem 1rem;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
background-color: var(--primary);
border: none;
color: var(--light-text);
cursor: pointer;
&:hover {
background-color: var(--primary-3);
}
}
.divider {
width: 1px;
height: 1rem;
background-color: var(--border-color-2);
margin: 0 0.5rem;
}
button.icon {
padding: 0;
margin: 0;
border: 0;
background-color: transparent;
cursor: pointer;
}
.popover-content {
input {
margin-left: 0;
}
label {
max-width: 17rem;
display: flex;
cursor: pointer;
padding: 0.5rem 1rem;
border-top: 1px solid var(--border-color);
}
}
</style>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { decorateQuery } from '$lib/branded'
export let query: string
$: decorations = decorateQuery(query)
</script>
<span class="text-monospace search-query-link">
{#if decorations}
{#each decorations as { key, className, value } (key)}
<span class={className}>{value}</span>
{/each}
{:else}
{query}
{/if}
</span>

View File

@ -0,0 +1,136 @@
// Replication of style overwrites in CodeMirrorQueryInput.module.scss as
// CodeMirror theme
import { EditorView } from '@codemirror/view'
export const defaultTheme = EditorView.theme({
'.cm-focus': {
// Codemirror shows a focus ring by default. Since we handle that
// differently, disable it here.
outline: 'none',
},
'.cm-scroller': {
// Codemirror shows a vertical scroll bar by default (when
// overflowing). This disables it.
overflowX: 'hidden',
},
'.cm-content': {
caretColor: 'var(--search-query-text-color)',
fontFamily: 'var(--code-font-family)',
fontSize: 'var(--code-font-size)',
color: 'var(--search-query-text-color)',
// Disable default padding
padding: 0,
},
'.cm-content.focus-visible,.cm-content:focus-visible': {
boxShadow: 'none',
},
// @media (--xs-breakpoint-down) probably doesn't work here
'.cm-line': {
padding: 0,
},
'.cm-placeholder': {
// CodeMirror uses display: inline-block by default, but that causes
// Chrome to render a larger cursor if the placeholder holder spans
// multiple lines. Firefox doesn't have this problem (but
// setting display: inline doesn't seem to have a negative effect
// either)
display: 'inline',
// Once again, Chrome renders the placeholder differently than
// Firefox. CodeMirror sets 'word-break: break-word' (which is
// deprecated) and 'overflow-wrap: anywhere' but they don't seem to
// have an effect in Chrome (at least not in this instance).
// Setting 'word-break: break-all' explicitly makes appearances a
// bit better for example queries with long tokens.
wordBreak: 'break-all',
},
'.cm-tooltip': {
padding: '0.25rem',
color: 'var(--search-query-text-color)',
backgroundColor: 'var(--color-bg-1)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--box-shadow)',
maxWidth: '50vw',
},
'.cm-tooltip p:last-child': {
marginBottom: 0,
},
'.cm-toolip code': {
backgroundColor: 'rgba(220, 220, 220, 0.4)',
borderRadius: 'var(--border-radius)',
padding: '0 0.4em',
},
'.cm-tooltip-section': {
paddingBottom: '0.25rem',
borderTopColor: 'var(--border-color)',
},
'.cm-tooltip-section:last-child': {
paddingTop: '0.25rem',
paddingBottom: 0,
},
'.cm-tooltip-section:last-child:first-child': {
padding: 0,
},
'.cm-tooltip.cm-tooltip-autocomplete': {
/* Resets padding added above to .cm-tooltip */
padding: 0,
color: 'var(--search-query-text-color)',
backgroundColor: 'var(--color-bg-1)',
// Default is 50vw
maxWidth: '70vw',
marginTop: '0.25rem', // Position is controlled absolutely but needs to be shifted down a bit from the default
},
// Requires some additional classes to overwrite default settings
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontSize: 'var(--code-font-size)',
fontFamily: 'var(--code-font-family)',
maxHeight: '15rem',
},
'.cm-tooltip-autocomplete > ul > li': {
alignItems: 'center',
boxSizing: 'content-box',
padding: '0.25rem 0.375rem',
display: 'flex',
height: '1.25rem',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
color: 'var(--search-query-text-color)',
backgroundColor: 'var(--color-bg-2)',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected] .tabStyle': {
display: 'inline-block',
},
'.theme-dark .cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-bg-3)',
},
'.cm-tooltip-autocomplete svg': {
flexShrink: 0,
},
'.cm-tooltip-autocomplete svg path': {
fill: 'var(--search-query-text-color)',
},
'.cm-completionLabel': {
flexShrink: 0,
},
'.cm-completionDetail': {
paddingLeft: '0.25rem',
fontSize: '0.675rem',
color: 'var(--gray-06)',
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
fontStyle: 'initial',
textAlign: 'right',
},
'.cm-completionMatchedText': {
// Reset
textDecoration: 'none',
// Our style
color: 'var(--search-filter-keyword-color)',
fontWeight: 'bold',
},
})

View File

@ -0,0 +1,14 @@
import { basicSyntaxColumns, exampleQueryColumns } from '$lib/branded'
export function getQueryExamples(): { title: string; columns: typeof basicSyntaxColumns }[] {
return [
{
title: 'Code search basics',
columns: basicSyntaxColumns,
},
{
title: 'Search query examples',
columns: exampleQueryColumns,
},
]
}

View File

@ -0,0 +1,31 @@
import type { Observable } from 'rxjs'
import type { HighlightResponseFormat, HighlightLineRange } from '$lib/graphql-operations'
import { fetchHighlightedFileLineRanges } from '$lib/loader/blob'
import type { PlatformContext } from '$lib/shared'
interface Result {
repository: string
commit?: string
path: string
}
export function fetchFileRangeMatches(args: {
result: Result
format?: HighlightResponseFormat
ranges: HighlightLineRange[]
platformContext: Pick<PlatformContext, 'requestGraphQL'>
}): Observable<string[][]> {
return fetchHighlightedFileLineRanges(
{
repoName: args.result.repository,
commitID: args.result.commit || '',
filePath: args.result.path,
disableTimeout: false,
format: args.format,
ranges: args.ranges,
platformContext: args.platformContext,
},
false
)
}

View File

@ -0,0 +1,26 @@
import type { SidebarFilter } from './utils'
export const searchTypes: SidebarFilter[] = [
{
label: 'Search repos by org or name',
value: 'repo:',
kind: 'utility',
},
{
label: 'Find a symbol',
value: 'type:symbol',
kind: 'utility',
runImmediately: true,
},
{
label: 'Search diffs',
value: 'type:diff',
kind: 'utility',
runImmediately: true,
},
{
label: 'Search commit message',
value: 'type:commit',
kind: 'utility',
},
]

View File

@ -0,0 +1,152 @@
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 { defaultSearchModeFromSettings } from '$lib/web'
// Defined in @sourcegraph/shared/src/search/searchQueryState.tsx
export enum SearchMode {
Precise = 0,
SmartSearch = 1 << 0,
}
type Update<T> = T | ((value: T) => T)
interface Options {
caseSensitive: boolean
regularExpression: boolean
patternType: SearchPatternType
searchMode: SearchMode
query: string
searchContext: string
}
type QuerySettings = Pick<
SettingsCascade['final'],
'search.defaultCaseSensitive' | 'search.defaultPatternType' | 'search.defaultMode'
> | null
export type QueryOptions = Pick<Options, 'patternType' | 'caseSensitive' | 'searchMode' | 'searchContext'>
export class QueryState {
private defaultCaseSensitive = false
private defaultPatternType = SearchPatternType.standard
private defaultSearchMode = SearchMode.SmartSearch
private defaultQuery = ''
private defaultSearchContext = 'global'
private constructor(private options: Partial<Options>, private settings: QuerySettings) {}
public static init(options: Partial<Options>, settings: QuerySettings): QueryState {
return new QueryState(options, settings)
}
public get caseSensitive(): boolean {
return this.options.caseSensitive ?? this.settings?.['search.defaultCaseSensitive'] ?? this.defaultCaseSensitive
}
public get patternType(): SearchPatternType {
return (
this.options.patternType ??
(this.settings?.['search.defaultPatternType'] as SearchPatternType) ??
this.defaultPatternType
)
}
public get searchMode(): SearchMode {
return (
// {final: this.settings, subjects} is a workaround to make our
// settings representation work with defaultSearchModeFromSettings
this.options.searchMode ??
(this.settings ? defaultSearchModeFromSettings({ final: this.settings, subjects: [] }) : null) ??
this.defaultSearchMode
)
}
public get query(): string {
return this.options.query ?? this.defaultQuery
}
public get searchContext(): string {
return this.options.searchContext ?? this.defaultSearchContext
}
public setQuery(newQuery: Update<string>): QueryState {
const query = typeof newQuery === 'function' ? newQuery(this.query) : newQuery
return new QueryState({ ...this.options, query }, this.settings)
}
public setCaseSensitive(caseSensitive: Update<boolean>): QueryState {
return new QueryState(
{
...this.options,
caseSensitive: typeof caseSensitive === 'function' ? caseSensitive(this.caseSensitive) : caseSensitive,
},
this.settings
)
}
public setPatternType(patternType: Update<SearchPatternType>): QueryState {
return new QueryState(
{
...this.options,
patternType: typeof patternType === 'function' ? patternType(this.patternType) : patternType,
},
this.settings
)
}
public setMode(mode: SearchMode): QueryState {
return new QueryState({ ...this.options, searchMode: mode }, this.settings)
}
public setSettings(settings: QuerySettings): QueryState {
return new QueryState(this.options, settings)
}
}
export interface QueryStateStore extends Readable<QueryState> {
setQuery(update: Update<string>): void
setCaseSensitive(update: Update<boolean>): void
setPatternType(update: Update<SearchPatternType>): void
setSettings(settings: QuerySettings): void
setMode(mode: SearchMode): void
}
export function queryStateStore(initial: Partial<Options> = {}, settings: QuerySettings): QueryStateStore {
const { subscribe, update } = writable<QueryState>(QueryState.init(initial, settings))
return {
subscribe,
setQuery(newQuery) {
update(state => state.setQuery(newQuery))
},
setCaseSensitive(caseSensitive) {
update(state => state.setCaseSensitive(caseSensitive))
},
setPatternType(patternType) {
update(state => state.setPatternType(patternType))
},
setSettings(settings) {
update(state => state.setSettings(settings))
},
setMode(mode) {
update(state => state.setMode(mode))
},
}
}
export function submitSearch(
queryState: Pick<QueryState, 'searchMode' | 'query' | 'caseSensitive' | 'patternType' | 'searchContext'>
): void {
const searchQueryParameter = buildSearchURLQuery(
queryState.query,
queryState.patternType,
queryState.caseSensitive,
queryState.searchContext,
queryState.searchMode
)
// no-void conflicts with no-floating-promises
// eslint-disable-next-line no-void
void goto('/search?' + searchQueryParameter)
}

View File

@ -0,0 +1,92 @@
// TODO: Reuse code from main app
import {
mdiCodeArray,
mdiCodeBraces,
mdiCodeNotEqual,
mdiCodeString,
mdiCube,
mdiCubeOutline,
mdiDrawingBox,
mdiFileDocument,
mdiFunction,
mdiKey,
mdiLink,
mdiMatrix,
mdiNull,
mdiNumeric,
mdiPackage,
mdiPiBox,
mdiPillar,
mdiPound,
mdiShape,
mdiSitemap,
mdiTextBox,
mdiTimetable,
mdiWeb,
mdiWrench,
} from '@mdi/js'
import type { SymbolKind } from '$lib/graphql/shared'
/**
* Returns the icon path for a given symbol kind
*/
export const getSymbolIconPath = (kind: SymbolKind): string => {
switch (kind) {
case 'FILE':
return mdiFileDocument
case 'MODULE':
return mdiCodeBraces
case 'NAMESPACE':
return mdiWeb
case 'PACKAGE':
return mdiPackage
case 'CLASS':
return mdiSitemap
case 'METHOD':
return mdiCubeOutline
case 'PROPERTY':
return mdiWrench
case 'FIELD':
return mdiTextBox
case 'CONSTRUCTOR':
return mdiCubeOutline
case 'ENUM':
return mdiNumeric
case 'INTERFACE':
return mdiLink
case 'FUNCTION':
return mdiFunction
case 'VARIABLE':
return mdiCube
case 'CONSTANT':
return mdiPiBox
case 'STRING':
return mdiCodeString
case 'NUMBER':
return mdiPound
case 'BOOLEAN':
return mdiMatrix
case 'ARRAY':
return mdiCodeArray
case 'OBJECT':
return mdiDrawingBox
case 'KEY':
return mdiKey
case 'NULL':
return mdiNull
case 'ENUMMEMBER':
return mdiNumeric
case 'STRUCT':
return mdiPillar
case 'EVENT':
return mdiTimetable
case 'OPERATOR':
return mdiCodeNotEqual
case 'TYPEPARAMETER':
return mdiCube
case 'UNKNOWN':
default:
return mdiShape
}
}

View File

@ -0,0 +1,48 @@
import type { ContentMatch, MatchItem } from '$lib/shared'
export interface SidebarFilter {
value: string
label: string
count?: number
limitHit?: boolean
kind: 'file' | 'repo' | 'lang' | 'utility'
runImmediately?: boolean
}
/**
* A context object provided on pages with the main search input to interact
* with the main input.
*/
export interface SearchPageContext {
setQuery(query: string | ((query: string) => string)): void
}
export function resultToMatchItems(result: ContentMatch): MatchItem[] {
return result.type === 'content'
? result.chunkMatches?.map(match => ({
highlightRanges: match.ranges.map(range => ({
startLine: range.start.line,
startCharacter: range.start.column,
endLine: range.end.line,
endCharacter: range.end.column,
})),
content: match.content,
startLine: match.contentStart.line,
endLine: match.ranges[match.ranges.length - 1].end.line,
aggregableBadges: match.aggregableBadges,
})) ||
result.lineMatches?.map(match => ({
highlightRanges: match.offsetAndLengths.map(offsetAndLength => ({
startLine: match.lineNumber,
startCharacter: offsetAndLength[0],
endLine: match.lineNumber,
endCharacter: offsetAndLength[0] + offsetAndLength[1],
})),
content: match.line,
startLine: match.lineNumber,
endLine: match.lineNumber,
aggregableBadges: match.aggregableBadges,
})) ||
[]
: []
}

View File

@ -0,0 +1,68 @@
// We want to limit the number of imported modules as much as possible
export { parseRepoRevision, buildSearchURLQuery, makeRepoURI } from '@sourcegraph/shared/src/util/url'
export {
isCloneInProgressErrorLike,
isRepoSeeOtherErrorLike,
isRepoNotFoundErrorLike,
} from '@sourcegraph/shared/src/backend/errors'
export { SectionID as SearchSidebarSectionID } from '@sourcegraph/shared/src/settings/temporary/searchSidebar'
export { TemporarySettingsStorage } from '@sourcegraph/shared/src/settings/temporary/TemporarySettingsStorage'
export {
type ContentMatch,
type Skipped,
getFileMatchUrl,
getRepositoryUrl,
aggregateStreamingSearch,
LATEST_VERSION,
type AggregateStreamingSearchResults,
type StreamSearchOptions,
type SearchMatch,
getRepoMatchLabel,
getRepoMatchUrl,
type RepositoryMatch,
type SymbolMatch,
type Progress,
} from '@sourcegraph/shared/src/search/stream'
export type {
MatchItem,
MatchGroupMatch,
MatchGroup,
} from '@sourcegraph/shared/src/components/ranking/PerFileResultRanking'
export { ZoektRanking } from '@sourcegraph/shared/src/components/ranking/ZoektRanking'
export type { AuthenticatedUser } 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 } from '@sourcegraph/shared/src/search/query/query'
export { omitFilter } from '@sourcegraph/shared/src/search/query/transformer'
export { observeSystemIsLightTheme } from '@sourcegraph/shared/src/theme'
export type { PlatformContext } from '@sourcegraph/shared/src/platform/context'
export { type SettingsCascade, type SettingsSubject, gqlToCascade } 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'
export type { TemporarySettings } from '@sourcegraph/shared/src/settings/temporary/TemporarySettings'
export { SyntaxKind } from '@sourcegraph/shared/src/codeintel/scip'
// Copies of non-reusable code
// Currently defined in client/shared/src/components/RepoLink.tsx
/**
* Returns the friendly display form of the repository name (e.g., removing "github.com/").
*/
export function displayRepoName(repoName: string): string {
let parts = repoName.split('/')
if (parts.length >= 3 && parts[0].includes('.')) {
parts = parts.slice(1) // remove hostname from repo name (reduce visual noise)
}
return parts.join('/')
}
/**
* Splits the repository name into the dir and base components.
*/
export function splitPath(path: string): [string, string] {
const components = path.split('/')
return [components.slice(0, -1).join('/'), components[components.length - 1]]
}

View File

@ -0,0 +1,65 @@
import { getContext } from 'svelte'
import { readable, type Readable } from 'svelte/store'
import type { GraphQLClient } from '$lib/http-client'
import type { SettingsCascade, AuthenticatedUser, PlatformContext, TemporarySettingsStorage } from '$lib/shared'
import { getWebGraphQLClient } from '$lib/web'
export interface SourcegraphContext {
settings: Readable<SettingsCascade['final'] | null>
user: Readable<AuthenticatedUser | null>
platformContext: Readable<Pick<PlatformContext, 'requestGraphQL'>>
isLightTheme: Readable<boolean>
temporarySettingsStorage: Readable<TemporarySettingsStorage>
}
export const KEY = '__sourcegraph__'
export function getStores(): SourcegraphContext {
const { settings, user, platformContext, isLightTheme, temporarySettingsStorage } =
getContext<SourcegraphContext>(KEY)
return { settings, user, platformContext, isLightTheme, temporarySettingsStorage }
}
export const user = {
subscribe(subscriber: (user: AuthenticatedUser | null) => void) {
const { user } = getStores()
return user.subscribe(subscriber)
},
}
export const settings = {
subscribe(subscriber: (settings: SettingsCascade['final'] | null) => void) {
const { settings } = getStores()
return settings.subscribe(subscriber)
},
}
export const platformContext = {
subscribe(subscriber: (platformContext: Pick<PlatformContext, 'requestGraphQL'>) => void) {
const { platformContext } = getStores()
return platformContext.subscribe(subscriber)
},
}
export const isLightTheme = {
subscribe(subscriber: (isLightTheme: boolean) => void) {
const { isLightTheme } = getStores()
return isLightTheme.subscribe(subscriber)
},
}
/**
* A store that updates every second to return the current time.
*/
export const currentDate: Readable<Date> = readable(new Date(), set => {
const interval = setInterval(() => set(new Date()), 1000)
return () => clearInterval(interval)
})
// TODO: Standardize on getWebGraphQLCient or platformContext.requestGraphQL
export const graphqlClient = readable<GraphQLClient | null>(null, set => {
// no-void conflicts with no-floating-promises
// eslint-disable-next-line no-void
void getWebGraphQLClient().then(client => set(client))
})

View File

@ -0,0 +1,67 @@
import { writable, type Writable, derived, type Readable } from 'svelte/store'
import { getStores } from './stores'
import type { LoadingData } from './utils'
import { logger } from '$lib/common'
import { type TemporarySettings, TemporarySettingsStorage, migrateLocalStorageToTemporarySettings } from '$lib/shared'
const loggedOutUserStore = new TemporarySettingsStorage(null, false)
export function createTemporarySettingsStorage(storage = loggedOutUserStore): Writable<TemporarySettingsStorage> {
const { subscribe, set } = writable(storage)
function disposeAndSet(newStorage: TemporarySettingsStorage): void {
storage.dispose()
// On first run, migrate the settings from the local storage to the temporary storage.
migrateLocalStorageToTemporarySettings(newStorage).catch(logger.error)
set((storage = newStorage))
}
return {
set: disposeAndSet,
update(update): void {
disposeAndSet(update(storage))
},
subscribe,
}
}
type TemporarySettingsKey = keyof TemporarySettings
type TemporarySettingStatus<K extends TemporarySettingsKey> = LoadingData<TemporarySettings[K], unknown>
interface TemporarySettingStore<K extends TemporarySettingsKey> extends Readable<TemporarySettingStatus<K>> {
setValue(value: TemporarySettings[K]): void
}
/**
* Returns a store for the provided temporary setting.
*/
export function temporarySetting<K extends TemporarySettingsKey>(
key: K,
defaultValue?: TemporarySettings[K]
): TemporarySettingStore<K> {
let storage: TemporarySettingsStorage | null = null
const { subscribe } = derived<Readable<TemporarySettingsStorage>, TemporarySettingStatus<K>>(
getStores().temporarySettingsStorage,
($storage, set) => {
storage = $storage
const subscription = $storage.get(key, defaultValue).subscribe({
next: data => set({ loading: false, data, error: null }),
error: error => set({ loading: false, data: null, error }),
})
return () => subscription.unsubscribe()
},
{ loading: true }
)
// TODO: Do we need to sync a local copy like useTemporarySettings?
return {
subscribe,
setValue(data) {
storage?.set(key, data)
},
}
}

View File

@ -0,0 +1,39 @@
import type { Observable } from 'rxjs'
import { shareReplay } from 'rxjs/operators'
import { type Readable, writable } from 'svelte/store'
export type LoadingData<D, E> =
| { loading: true }
| { loading: false; data: D; error: null }
| { loading: false; data: null; error: E }
/**
* Converts a promise to a readable store which emits loading and error data.
* This is useful in loader functions to prevent SvelteKit from waiting for the
* promise to resolve before rendering the page.
*/
export function asStore<T, E = Error>(promise: Promise<T>): Readable<LoadingData<T, E>> {
const { subscribe, set } = writable<LoadingData<T, E>>({ loading: true })
promise.then(
result => set({ loading: false, data: result, error: null }),
error => set({ loading: false, data: null, error })
)
return {
subscribe,
}
}
/**
* Helper function to convert an Observable to a Svelte Readable. Useful when a
* real Readable is needed to satisfy an interface.
*/
export function readableObservable<T>(observable: Observable<T>): Readable<T> {
const sharedObservable = observable.pipe(shareReplay(1))
return {
subscribe(subscriber) {
const subscription = sharedObservable.subscribe(subscriber)
return () => subscription.unsubscribe()
},
}
}

View File

@ -0,0 +1,47 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
import type { Observable } from 'rxjs'
import { map, tap } from 'rxjs/operators'
import { requestGraphQL, getWebGraphQLClient, mutateGraphQL } from '@sourcegraph/web/src/backend/graphql'
import { resetAllMemoizationCaches } from './common'
import type { CheckMirrorRepositoryConnectionResult, Scalars } from './graphql-operations'
import { dataOrThrowErrors, gql } from './http-client'
export { requestGraphQL, getWebGraphQLClient, mutateGraphQL }
export { parseSearchURL } from '@sourcegraph/web/src/search/index'
export { replaceRevisionInURL } from '@sourcegraph/web/src/util/url'
export type { ResolvedRevision } from '@sourcegraph/web/src/repo/backend'
export { syntaxHighlight } from '@sourcegraph/web/src/repo/blob/codemirror/highlight'
export { defaultSearchModeFromSettings } from '@sourcegraph/web/src/util/settings'
// Copy of non-reusable code
// Importing from @sourcegraph/web/site-admin/backend.ts breaks the build because this
// module has (transitive) dependencies on @sourcegraph/wildcard which imports
// all Wildcard components
//
const CHECK_MIRROR_REPOSITORY_CONNECTION = gql`
mutation CheckMirrorRepositoryConnection($repository: ID, $name: String) {
checkMirrorRepositoryConnection(repository: $repository, name: $name) {
error
}
}
`
export function checkMirrorRepositoryConnection(
args:
| {
repository: Scalars['ID']
}
| {
name: string
}
): Observable<CheckMirrorRepositoryConnectionResult['checkMirrorRepositoryConnection']> {
return mutateGraphQL<CheckMirrorRepositoryConnectionResult>(CHECK_MIRROR_REPOSITORY_CONNECTION, args).pipe(
map(dataOrThrowErrors),
tap(() => resetAllMemoizationCaches()),
map(data => data.checkMirrorRepositoryConnection)
)
}

View File

@ -0,0 +1,28 @@
<script lang="ts">
// In addition to the props explicitly listed here, this component also
// accepts any HTMLButton attributes. Note that those will only be used when
// the default implementation is used.
import type { HTMLButtonAttributes } from 'svelte/elements'
interface $$Props extends HTMLButtonAttributes {
variant?: (typeof BUTTON_VARIANTS)[number]
size?: (typeof BUTTON_SIZES)[number]
display?: (typeof BUTTON_DISPLAY)[number]
outline?: boolean
}
import { type BUTTON_DISPLAY, type BUTTON_SIZES, type BUTTON_VARIANTS, getButtonClassName } from './Button'
export let variant: $$Props['variant'] = 'primary'
export let size: $$Props['size'] = undefined
export let display: $$Props['display'] = undefined
export let outline: $$Props['outline'] = undefined
$: brandedButtonClassname = getButtonClassName({ variant, outline, display, size })
</script>
<slot name="custom" className={brandedButtonClassname}>
<!-- $$restProps holds all the additional props that are passed to the component -->
<button class={brandedButtonClassname} {...$$restProps} on:click|preventDefault>
<slot />
</button>
</slot>

View File

@ -0,0 +1,11 @@
// We want to limit the number of imported modules as much as possible
/* eslint-disable no-restricted-imports */
export type {
BUTTON_GROUP_DIRECTION,
BUTTON_SIZES,
BUTTON_DISPLAY,
BUTTON_VARIANTS,
} from '@sourcegraph/wildcard/src/components/Button'
export { default as styles } from '@sourcegraph/wildcard/src/components/Button/Button.module.scss'
export { getButtonClassName } from '@sourcegraph/wildcard/src/components/Button/utils'

View File

@ -0,0 +1,15 @@
<script lang="ts">
import classNames from 'classnames'
import { type BUTTON_GROUP_DIRECTION, styles } from './Button'
export let direction: (typeof BUTTON_GROUP_DIRECTION)[number] = 'horizontal'
$: className = classNames(styles.btnGroup, direction === 'vertical' && styles.btnGroupVertical)
</script>
<slot name="custom" role="group" {className}>
<div role="group" class={className}>
<slot />
</div>
</slot>

View File

@ -0,0 +1,2 @@
export { default as Button } from './Button.svelte'
export { default as ButtonGroup } from './ButtonGroup.svelte'

View File

@ -0,0 +1,15 @@
import { error } from '@sveltejs/kit'
import type { LayoutLoad } from './$types'
import { PUBLIC_SG_ENTERPRISE } from '$env/static/public'
export const load: LayoutLoad = () => {
// Example for how we could prevent access to all enterprese specific routes.
// It's not quite the same as not having the routes at all and have the
// interpreted differently, like in the current web app.
if (!PUBLIC_SG_ENTERPRISE) {
// eslint-disable-next-line etc/throw-error, rxjs/throw-error
throw error(404, { message: 'enterprise feature' })
}
}

View File

@ -0,0 +1,2 @@
<h1>Contexts</h1>
Placeholder content

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { page } from '$app/stores'
</script>
<div>
{#if $page.error?.message === 'enterprise feature'}
<h1>Enterprise feature</h1>
<p>This is an enterprise-only feature. See ... for more information.</p>
{:else}
<h1>{$page.status}</h1>
<p>
{$page.error?.message}
</p>
{/if}
</div>
<style lang="scss">
div {
margin: 1rem;
}
</style>

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { onMount, setContext } from 'svelte'
import { readable, writable } from 'svelte/store'
import { browser } from '$app/environment'
import { KEY, type SourcegraphContext } from '$lib/stores'
import { isErrorLike } from '$lib/common'
import { observeSystemIsLightTheme, TemporarySettingsStorage } from '$lib/shared'
import { readableObservable } from '$lib/utils'
import { createTemporarySettingsStorage } from '$lib/temporarySettings'
import Header from './Header.svelte'
import './styles.scss'
import type { LayoutData } from './$types'
export let data: LayoutData
const user = writable(data.user ?? null)
const settings = writable(data.settings)
const platformContext = writable(data.platformContext)
const isLightTheme = browser ? readableObservable(observeSystemIsLightTheme(window).observable) : readable(true)
// It's OK to set the temporary storage during initialization time because
// sign-in/out currently performs a full page refresh
const temporarySettingsStorage = createTemporarySettingsStorage(
data.user ? new TemporarySettingsStorage(data.graphqlClient, true) : undefined
)
setContext<SourcegraphContext>(KEY, {
user,
settings,
platformContext,
isLightTheme,
temporarySettingsStorage,
})
onMount(() => {
// Settings can change over time. This ensures that the store is always
// up-to-date.
const settingsSubscription = data.platformContext?.settings.subscribe(newSettings => {
settings.set(isErrorLike(newSettings.final) ? null : newSettings.final)
})
return () => settingsSubscription?.unsubscribe()
})
$: $user = data.user ?? null
$: $settings = data.settings
$: $platformContext = data.platformContext
$: if (browser) {
document.documentElement.classList.toggle('theme-light', $isLightTheme)
document.documentElement.classList.toggle('theme-dark', !$isLightTheme)
}
</script>
<svelte:head>
<title>Sourcegraph</title>
<meta name="description" content="Code search" />
</svelte:head>
<div class="app">
<Header authenticatedUser={$user} />
<main>
<slot />
</main>
</div>
<style>
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
main {
flex: 1;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,47 @@
import { from } from 'rxjs'
import { map, switchMap, take } from 'rxjs/operators'
import type { LayoutLoad } from './$types'
import { browser } from '$app/environment'
import { isErrorLike } from '$lib/common'
import { createPlatformContext } from '$lib/context'
import type { CurrentAuthStateResult } from '$lib/graphql/shared'
import { getDocumentNode } from '$lib/http-client'
import { currentAuthStateQuery } from '$lib/loader/auth'
import { getWebGraphQLClient } from '$lib/web'
// Disable server side rendering for the whole app
export const ssr = false
export const prerender = false
if (browser) {
// Necessary to make authenticated GrqphQL requests work
// No idea why TS picks up Mocha.SuiteFunction for this
window.context = {
xhrHeaders: {
'X-Requested-With': 'Sourcegraph',
},
}
}
export const load: LayoutLoad = () => {
const graphqlClient = getWebGraphQLClient()
const platformContext = graphqlClient.then(createPlatformContext)
return {
platformContext,
graphqlClient,
user: graphqlClient
.then(client => client.query<CurrentAuthStateResult>({ query: getDocumentNode(currentAuthStateQuery) }))
.then(result => result.data.currentUser),
// Initial user settings
settings: from(platformContext)
.pipe(
switchMap(platformContext => platformContext.settings),
map(settingsOrError => (isErrorLike(settingsOrError.final) ? null : settingsOrError.final)),
take(1)
)
.toPromise(),
}
}

View File

@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = () => {
// eslint-disable-next-line etc/throw-error, rxjs/throw-error
throw redirect(300, '/search')
}

View File

@ -0,0 +1,85 @@
<script lang="ts">
import { mdiBookOutline, mdiChartBar, mdiMagnify } from '@mdi/js'
import { mark } from '$lib/images'
import UserAvatar from '$lib/UserAvatar.svelte'
import type { AuthenticatedUser } from '$lib/shared'
import HeaderNavLink from './HeaderNavLink.svelte'
export let authenticatedUser: AuthenticatedUser | null | undefined
</script>
<header>
<a href="/search">
<img src={mark} alt="Sourcegraph" width="25" height="25" />
</a>
<nav class="ml-2">
<ul>
<HeaderNavLink href="/search" svgIconPath={mdiMagnify}>Code search</HeaderNavLink>
</ul>
</nav>
<div class="user">
{#if authenticatedUser}
<UserAvatar user={authenticatedUser} />
<!--
Needs data-sveltekit-reload to force hitting the server and
proxying the request to the Sourcegraph instance.
-->
<a href="/-/sign-out" data-sveltekit-reload>Sign out</a>
{:else}
<a href="/sign-in" data-sveltekit-reload>Sign in</a>
{/if}
</div>
</header>
<style lang="scss">
header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color-2);
height: var(--navbar-height);
min-height: 40px;
padding: 0 1rem;
background-color: var(--color-bg-1);
}
img {
&:hover {
@keyframes spin {
50% {
transform: rotate(180deg) scale(1.2);
}
100% {
transform: rotate(180deg) scale(1);
}
}
@media (prefers-reduced-motion: no-preference) {
animation: spin 0.5s ease-in-out 1;
}
}
}
nav {
display: flex;
align-self: stretch;
flex: 1;
}
svg {
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
}
ul {
position: relative;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
list-style: none;
background-size: contain;
}
</style>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/stores'
import Icon from '$lib/Icon.svelte'
export let href: string
export let svgIconPath: string = ''
$: current = $page.url.pathname === href ? ('page' as const) : null
</script>
<li aria-current={current}>
<a {href}>
{#if $$slots.icon}
<slot name="icon" />
&nbsp;
{:else if svgIconPath}
<Icon svgPath={svgIconPath} aria-hidden="true" inline --color="var(--header-icon-color)" />
&nbsp;
{/if}
<span><slot /></span>
</a>
</li>
<style lang="scss">
li {
position: relative;
display: flex;
align-items: stretch;
border-bottom: 2px solid transparent;
margin: 0 0.5rem;
&:hover {
border-color: var(--border-color-2);
}
&[aria-current='page'] {
border-color: var(--brand-secondary);
}
}
a {
display: flex;
height: 100%;
align-items: center;
text-decoration: none;
&:hover {
text-decoration: none;
}
}
span,
a {
color: var(--body-color);
}
</style>

View File

@ -0,0 +1,94 @@
<script lang="ts">
import { page } from '$app/stores'
import FileTree from '$lib/repo/FileTree.svelte'
import Icon from '$lib/Icon.svelte'
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from '@mdi/js'
import type { PageData } from './$types'
export let data: PageData
function last<T>(arr: T[]): T {
return arr[arr.length - 1]
}
$: treeOrError = data.treeEntries
let showSidebar = true
</script>
<section>
<div class="sidebar" class:open={showSidebar}>
{#if showSidebar && !$treeOrError.loading && $treeOrError.data}
<FileTree
activeEntry={$page.params.path ? last($page.params.path.split('/')) : ''}
treeOrError={$treeOrError.data}
>
<h3 slot="title">
Files
<button on:click={() => (showSidebar = false)}
><Icon svgPath={mdiChevronDoubleLeft} inline /></button
>
</h3>
</FileTree>
{/if}
{#if !showSidebar}
<button class="open-sidebar" on:click={() => (showSidebar = true)}
><Icon svgPath={mdiChevronDoubleRight} inline /></button
>
{/if}
</div>
<div class="content">
<slot />
</div>
</section>
<style lang="scss">
section {
display: flex;
overflow: hidden;
margin: 1rem;
margin-bottom: 0;
flex: 1;
}
.sidebar {
&.open {
width: 200px;
}
overflow: hidden;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
margin: 0 1rem;
background-color: var(--code-bg);
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
}
button {
border: 0;
background-color: transparent;
padding: 0;
margin: 0;
cursor: pointer;
}
h3 {
display: flex;
justify-content: space-between;
align-items: center;
}
.open-sidebar {
position: absolute;
left: 0;
border: 1px solid var(--border-color);
}
</style>

View File

@ -0,0 +1,29 @@
import { dirname } from 'path'
import { catchError } from 'rxjs/operators'
import type { LayoutLoad } from './$types'
import { asError, isErrorLike, type ErrorLike } from '$lib/common'
import { fetchTreeEntries } from '$lib/loader/repo'
import { asStore } from '$lib/utils'
import { requestGraphQL } from '$lib/web'
export const load: LayoutLoad = ({ parent, params }) => ({
treeEntries: asStore(
parent().then(({ resolvedRevision, repoName, revision }) =>
!isErrorLike(resolvedRevision)
? fetchTreeEntries({
repoName,
commitID: resolvedRevision.commitID,
revision: revision ?? '',
filePath: params.path ? dirname(params.path) : '.',
first: 2500,
requestGraphQL: options => requestGraphQL(options.request, options.variables),
})
.pipe(catchError((error): [ErrorLike] => [asError(error)]))
.toPromise()
: null
)
),
})

View File

@ -0,0 +1,81 @@
<script lang="ts">
import { mdiFolderOutline, mdiFileDocumentOutline } from '@mdi/js'
import { isErrorLike } from '$lib/common'
import Commit from '$lib/Commit.svelte'
import Icon from '$lib/Icon.svelte'
import type { PageData } from './$types'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
export let data: PageData
$: treeOrError = data.treeEntries
$: commits = data.commits
</script>
<div class="content">
{#if !isErrorLike(data.resolvedRevision)}
<h3>Description</h3>
<p>
{data.resolvedRevision.repo.description}
</p>
{/if}
{#if !$treeOrError.loading && $treeOrError.data && !isErrorLike($treeOrError.data)}
<h3>Files and directories</h3>
<ul class="files">
{#each $treeOrError.data.entries as entry}
<li>
<a
data-sveltekit-preload-data={entry.isDirectory ? 'hover' : 'tap'}
data-sveltekit-preload-code="hover"
href={entry.url}
><Icon svgPath={entry.isDirectory ? mdiFolderOutline : mdiFileDocumentOutline} inline />
{entry.name}</a
>
</li>
{/each}
</ul>
{/if}
<h3 class="mt-3">Changes</h3>
<ul class="commits">
{#if $commits.loading}
<LoadingSpinner />
{:else if $commits.data}
{#each $commits.data as commit (commit.url)}
<li><Commit {commit} /></li>
{/each}
{/if}
</ul>
</div>
<style lang="scss">
.content {
padding: 1rem;
overflow: auto;
}
ul.commits {
padding: 0;
margin: 0;
list-style: none;
li {
border-bottom: 1px solid var(--border-color);
padding: 0.5rem 0;
&:last-child {
border: none;
}
}
}
ul.files {
padding: 0;
margin: 0;
list-style: none;
columns: 3;
}
</style>

View File

@ -0,0 +1,12 @@
import type { PageLoad } from './$types'
import { fetchCommits } from '$lib/loader/commits'
import { asStore } from '$lib/utils'
export const load: PageLoad = ({ parent }) => ({
commits: asStore(
parent()
.then(({ resolvedRevision }) => fetchCommits(resolvedRevision, true))
.then(result => result?.nodes.slice(0, 5) ?? [])
),
})

View File

@ -0,0 +1,58 @@
<script lang="ts">
import CodeMirrorBlob from '$lib/CodeMirrorBlob.svelte'
import type { PageData } from './$types'
import { page } from '$app/stores'
import WrapLinesAction, { lineWrap } from './WrapLinesAction.svelte'
import FormatAction from './FormatAction.svelte'
import type { BlobFileFields } from '$lib/graphql-operations'
import HeaderAction from '$lib/repo/HeaderAction.svelte'
export let data: PageData
$: blob = data.blob
$: highlights = data.highlights
$: loading = $blob.loading
let blobData: BlobFileFields
$: if (!$blob.loading && $blob.data) {
blobData = $blob.data
}
$: formatted = !!blobData?.richHTML
$: showRaw = $page.url.searchParams.get('view') === 'raw'
</script>
{#if !formatted || showRaw}
<HeaderAction key="wrap-lines" priority={0} component={WrapLinesAction} />
{/if}
{#if formatted}
<HeaderAction key="format" priority={-1} component={FormatAction} />
{/if}
<div class="content" class:loading>
{#if blobData}
{#if blobData.richHTML && !showRaw}
<div class="rich">
{@html blobData.richHTML}
</div>
{:else}
<CodeMirrorBlob
blob={blobData}
highlights={($highlights && !$highlights.loading && $highlights.data) || ''}
wrapLines={$lineWrap}
/>
{/if}
{/if}
</div>
<style lang="scss">
.content {
overflow: hidden;
display: flex;
}
.loading {
filter: blur(1px);
}
.rich {
padding: 1rem;
overflow: auto;
}
</style>

Some files were not shown because too many files have changed in this diff Show More