mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:11:48 +00:00
[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:
parent
eba2338d2f
commit
153cff847b
@ -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
|
||||
|
||||
@ -3,3 +3,6 @@ src/schema/*
|
||||
src/graphql-operations.ts
|
||||
GH2SG.bookmarklet.js
|
||||
**/vendor/*.js
|
||||
svelte.config.js
|
||||
vite.config.ts
|
||||
playwright.config.ts
|
||||
|
||||
1
.github/workflows/scip-typescript.yml
vendored
1
.github/workflows/scip-typescript.yml
vendored
@ -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
|
||||
|
||||
@ -43,3 +43,4 @@ code-intel-extensions.json
|
||||
pnpm-lock.yaml
|
||||
client/plugin-backstage/
|
||||
node_modules/
|
||||
client/web-sveltekit/.svelte-kit
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)}` +
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
24
client/branded/src/search-ui/results/progress/utils.ts
Normal file
24
client/branded/src/search-ui/results/progress/utils.ts
Normal 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)
|
||||
10
client/branded/src/search-ui/util/query.ts
Normal file
10
client/branded/src/search-ui/util/query.ts
Normal 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
|
||||
}
|
||||
@ -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'),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1201,7 +1201,7 @@ export const toCSSClassName = (token: DecoratedToken): string => {
|
||||
}
|
||||
}
|
||||
|
||||
interface Decoration {
|
||||
export interface Decoration {
|
||||
value: string
|
||||
key: number
|
||||
className: string
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { uniq } from 'lodash'
|
||||
import uniq from 'lodash/uniq'
|
||||
|
||||
import { Literal } from './token'
|
||||
|
||||
|
||||
5
client/web-sveltekit/.env
Normal file
5
client/web-sveltekit/.env
Normal 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=
|
||||
2
client/web-sveltekit/.env.dotcom
Normal file
2
client/web-sveltekit/.env.dotcom
Normal file
@ -0,0 +1,2 @@
|
||||
PUBLIC_SG_ENTERPRISE=true
|
||||
PUBLIC_DOTCOM=true
|
||||
2
client/web-sveltekit/.env.oss
Normal file
2
client/web-sveltekit/.env.oss
Normal file
@ -0,0 +1,2 @@
|
||||
PUBLIC_SG_ENTERPRISE=
|
||||
PUBLIC_DOTCOM=
|
||||
18
client/web-sveltekit/.eslintignore
Normal file
18
client/web-sveltekit/.eslintignore
Normal 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
|
||||
26
client/web-sveltekit/.eslintrc.cjs
Normal file
26
client/web-sveltekit/.eslintrc.cjs
Normal 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
9
client/web-sveltekit/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
!.env
|
||||
.env.*.local
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
client/web-sveltekit/.npmrc
Normal file
1
client/web-sveltekit/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
14
client/web-sveltekit/.prettierignore
Normal file
14
client/web-sveltekit/.prettierignore
Normal 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
|
||||
11
client/web-sveltekit/.stylelintrc.json
Normal file
11
client/web-sveltekit/.stylelintrc.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": ["../../.stylelintrc.json"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["./src/routes/styles.scss"],
|
||||
"rules": {
|
||||
"@sourcegraph/filenames-match-regex": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
client/web-sveltekit/Dockerfile
Normal file
14
client/web-sveltekit/Dockerfile
Normal 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"]
|
||||
82
client/web-sveltekit/README.md
Normal file
82
client/web-sveltekit/README.md
Normal 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.
|
||||
18
client/web-sveltekit/build-and-push.sh
Executable file
18
client/web-sveltekit/build-and-push.sh
Executable 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
|
||||
7
client/web-sveltekit/build-docker.sh
Executable file
7
client/web-sveltekit/build-docker.sh
Executable 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
|
||||
44
client/web-sveltekit/package.json
Normal file
44
client/web-sveltekit/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
client/web-sveltekit/playwright.config.ts
Normal file
10
client/web-sveltekit/playwright.config.ts
Normal 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
|
||||
6
client/web-sveltekit/prettier.config.cjs
Normal file
6
client/web-sveltekit/prettier.config.cjs
Normal 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' } }],
|
||||
}
|
||||
19
client/web-sveltekit/server.js
Normal file
19
client/web-sveltekit/server.js
Normal 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
13
client/web-sveltekit/src/app.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
12
client/web-sveltekit/src/app.html
Normal file
12
client/web-sveltekit/src/app.html
Normal 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
3
client/web-sveltekit/src/global.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
interface Window {
|
||||
context: { xhrHeaders: { [key: string]: string } }
|
||||
}
|
||||
101
client/web-sveltekit/src/lib/CodeMirrorBlob.svelte
Normal file
101
client/web-sveltekit/src/lib/CodeMirrorBlob.svelte
Normal 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>
|
||||
109
client/web-sveltekit/src/lib/Commit.svelte
Normal file
109
client/web-sveltekit/src/lib/Commit.svelte
Normal 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>
|
||||
49
client/web-sveltekit/src/lib/HeroPage.svelte
Normal file
49
client/web-sveltekit/src/lib/HeroPage.svelte
Normal 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>
|
||||
30
client/web-sveltekit/src/lib/Icon.svelte
Normal file
30
client/web-sveltekit/src/lib/Icon.svelte
Normal 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>
|
||||
44
client/web-sveltekit/src/lib/LoadingSpinner.svelte
Normal file
44
client/web-sveltekit/src/lib/LoadingSpinner.svelte
Normal 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>
|
||||
108
client/web-sveltekit/src/lib/Paginator.svelte
Normal file
108
client/web-sveltekit/src/lib/Paginator.svelte
Normal 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>
|
||||
56
client/web-sveltekit/src/lib/Popover.svelte
Normal file
56
client/web-sveltekit/src/lib/Popover.svelte
Normal 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>
|
||||
23
client/web-sveltekit/src/lib/TabPanel.svelte
Normal file
23
client/web-sveltekit/src/lib/TabPanel.svelte
Normal 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}
|
||||
94
client/web-sveltekit/src/lib/Tabs.svelte
Normal file
94
client/web-sveltekit/src/lib/Tabs.svelte
Normal 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>
|
||||
132
client/web-sveltekit/src/lib/Tooltip.svelte
Normal file
132
client/web-sveltekit/src/lib/Tooltip.svelte
Normal 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>
|
||||
60
client/web-sveltekit/src/lib/UserAvatar.svelte
Normal file
60
client/web-sveltekit/src/lib/UserAvatar.svelte
Normal 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>
|
||||
53
client/web-sveltekit/src/lib/app.ts
Normal file
53
client/web-sveltekit/src/lib/app.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
10
client/web-sveltekit/src/lib/branded.ts
Normal file
10
client/web-sveltekit/src/lib/branded.ts
Normal 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'
|
||||
@ -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
|
||||
}
|
||||
11
client/web-sveltekit/src/lib/common.ts
Normal file
11
client/web-sveltekit/src/lib/common.ts
Normal 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'
|
||||
53
client/web-sveltekit/src/lib/context.ts
Normal file
53
client/web-sveltekit/src/lib/context.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
23
client/web-sveltekit/src/lib/dom.ts
Normal file
23
client/web-sveltekit/src/lib/dom.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
1
client/web-sveltekit/src/lib/graphql/shared.ts
Normal file
1
client/web-sveltekit/src/lib/graphql/shared.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from '@sourcegraph/shared/src/graphql-operations'
|
||||
10
client/web-sveltekit/src/lib/http-client.ts
Normal file
10
client/web-sveltekit/src/lib/http-client.ts
Normal 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'
|
||||
3
client/web-sveltekit/src/lib/images.ts
Normal file
3
client/web-sveltekit/src/lib/images.ts
Normal 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'
|
||||
39
client/web-sveltekit/src/lib/intersection-observer.ts
Normal file
39
client/web-sveltekit/src/lib/intersection-observer.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
1
client/web-sveltekit/src/lib/loader/auth.ts
Normal file
1
client/web-sveltekit/src/lib/loader/auth.ts
Normal file
@ -0,0 +1 @@
|
||||
export { currentAuthStateQuery } from '@sourcegraph/shared/src/auth'
|
||||
123
client/web-sveltekit/src/lib/loader/blob.ts
Normal file
123
client/web-sveltekit/src/lib/loader/blob.ts
Normal 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)
|
||||
296
client/web-sveltekit/src/lib/loader/commits.ts
Normal file
296
client/web-sveltekit/src/lib/loader/commits.ts
Normal 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: [] }
|
||||
}
|
||||
243
client/web-sveltekit/src/lib/loader/repo.ts
Normal file
243
client/web-sveltekit/src/lib/loader/repo.ts
Normal 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}`
|
||||
)
|
||||
1
client/web-sveltekit/src/lib/loader/settings.ts
Normal file
1
client/web-sveltekit/src/lib/loader/settings.ts
Normal file
@ -0,0 +1 @@
|
||||
export { viewerSettingsQuery } from '@sourcegraph/shared/src/backend/settings'
|
||||
21
client/web-sveltekit/src/lib/logger.ts
Normal file
21
client/web-sveltekit/src/lib/logger.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
25
client/web-sveltekit/src/lib/relativeTime.ts
Normal file
25
client/web-sveltekit/src/lib/relativeTime.ts
Normal 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')
|
||||
}
|
||||
83
client/web-sveltekit/src/lib/repo/FileTree.svelte
Normal file
83
client/web-sveltekit/src/lib/repo/FileTree.svelte
Normal 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>
|
||||
63
client/web-sveltekit/src/lib/repo/GitReference.svelte
Normal file
63
client/web-sveltekit/src/lib/repo/GitReference.svelte
Normal 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>
|
||||
11
client/web-sveltekit/src/lib/repo/HeaderAction.svelte
Normal file
11
client/web-sveltekit/src/lib/repo/HeaderAction.svelte
Normal 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>
|
||||
43
client/web-sveltekit/src/lib/repo/actions.ts
Normal file
43
client/web-sveltekit/src/lib/repo/actions.ts
Normal 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))
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
20
client/web-sveltekit/src/lib/repo/utils.ts
Normal file
20
client/web-sveltekit/src/lib/repo/utils.ts
Normal 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
|
||||
)
|
||||
}
|
||||
221
client/web-sveltekit/src/lib/search/CodeMirrorQueryInput.svelte
Normal file
221
client/web-sveltekit/src/lib/search/CodeMirrorQueryInput.svelte
Normal 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>
|
||||
67
client/web-sveltekit/src/lib/search/QueryExampleChip.svelte
Normal file
67
client/web-sveltekit/src/lib/search/QueryExampleChip.svelte
Normal 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>
|
||||
238
client/web-sveltekit/src/lib/search/SearchBox.svelte
Normal file
238
client/web-sveltekit/src/lib/search/SearchBox.svelte
Normal 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>
|
||||
@ -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>
|
||||
136
client/web-sveltekit/src/lib/search/codemirror/theme.ts
vendored
Normal file
136
client/web-sveltekit/src/lib/search/codemirror/theme.ts
vendored
Normal 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',
|
||||
},
|
||||
})
|
||||
14
client/web-sveltekit/src/lib/search/queryExamples.ts
Normal file
14
client/web-sveltekit/src/lib/search/queryExamples.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
31
client/web-sveltekit/src/lib/search/results.ts
Normal file
31
client/web-sveltekit/src/lib/search/results.ts
Normal 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
|
||||
)
|
||||
}
|
||||
26
client/web-sveltekit/src/lib/search/sidebar.ts
Normal file
26
client/web-sveltekit/src/lib/search/sidebar.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
152
client/web-sveltekit/src/lib/search/state.ts
Normal file
152
client/web-sveltekit/src/lib/search/state.ts
Normal 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)
|
||||
}
|
||||
92
client/web-sveltekit/src/lib/search/symbolIcons.ts
Normal file
92
client/web-sveltekit/src/lib/search/symbolIcons.ts
Normal 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
|
||||
}
|
||||
}
|
||||
48
client/web-sveltekit/src/lib/search/utils.ts
Normal file
48
client/web-sveltekit/src/lib/search/utils.ts
Normal 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,
|
||||
})) ||
|
||||
[]
|
||||
: []
|
||||
}
|
||||
68
client/web-sveltekit/src/lib/shared.ts
Normal file
68
client/web-sveltekit/src/lib/shared.ts
Normal 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]]
|
||||
}
|
||||
65
client/web-sveltekit/src/lib/stores.ts
Normal file
65
client/web-sveltekit/src/lib/stores.ts
Normal 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))
|
||||
})
|
||||
67
client/web-sveltekit/src/lib/temporarySettings.ts
Normal file
67
client/web-sveltekit/src/lib/temporarySettings.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
39
client/web-sveltekit/src/lib/utils.ts
Normal file
39
client/web-sveltekit/src/lib/utils.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
47
client/web-sveltekit/src/lib/web.ts
Normal file
47
client/web-sveltekit/src/lib/web.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
28
client/web-sveltekit/src/lib/wildcard/Button.svelte
Normal file
28
client/web-sveltekit/src/lib/wildcard/Button.svelte
Normal 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>
|
||||
11
client/web-sveltekit/src/lib/wildcard/Button.ts
Normal file
11
client/web-sveltekit/src/lib/wildcard/Button.ts
Normal 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'
|
||||
15
client/web-sveltekit/src/lib/wildcard/ButtonGroup.svelte
Normal file
15
client/web-sveltekit/src/lib/wildcard/ButtonGroup.svelte
Normal 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>
|
||||
2
client/web-sveltekit/src/lib/wildcard/index.ts
Normal file
2
client/web-sveltekit/src/lib/wildcard/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as Button } from './Button.svelte'
|
||||
export { default as ButtonGroup } from './ButtonGroup.svelte'
|
||||
15
client/web-sveltekit/src/routes/(enterprise)/+layout.ts
Normal file
15
client/web-sveltekit/src/routes/(enterprise)/+layout.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
<h1>Contexts</h1>
|
||||
Placeholder content
|
||||
21
client/web-sveltekit/src/routes/+error.svelte
Normal file
21
client/web-sveltekit/src/routes/+error.svelte
Normal 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>
|
||||
83
client/web-sveltekit/src/routes/+layout.svelte
Normal file
83
client/web-sveltekit/src/routes/+layout.svelte
Normal 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>
|
||||
47
client/web-sveltekit/src/routes/+layout.ts
Normal file
47
client/web-sveltekit/src/routes/+layout.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
8
client/web-sveltekit/src/routes/+page.ts
Normal file
8
client/web-sveltekit/src/routes/+page.ts
Normal 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')
|
||||
}
|
||||
85
client/web-sveltekit/src/routes/Header.svelte
Normal file
85
client/web-sveltekit/src/routes/Header.svelte
Normal 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>
|
||||
57
client/web-sveltekit/src/routes/HeaderNavLink.svelte
Normal file
57
client/web-sveltekit/src/routes/HeaderNavLink.svelte
Normal 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" />
|
||||
|
||||
{:else if svgIconPath}
|
||||
<Icon svgPath={svgIconPath} aria-hidden="true" inline --color="var(--header-icon-color)" />
|
||||
|
||||
{/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>
|
||||
@ -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>
|
||||
29
client/web-sveltekit/src/routes/[...repo]/(code)/+layout.ts
Normal file
29
client/web-sveltekit/src/routes/[...repo]/(code)/+layout.ts
Normal 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
|
||||
)
|
||||
),
|
||||
})
|
||||
@ -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>
|
||||
12
client/web-sveltekit/src/routes/[...repo]/(code)/+page.ts
Normal file
12
client/web-sveltekit/src/routes/[...repo]/(code)/+page.ts
Normal 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) ?? [])
|
||||
),
|
||||
})
|
||||
@ -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
Loading…
Reference in New Issue
Block a user