diff --git a/.buildkite/hooks/pre-command b/.buildkite/hooks/pre-command index fe78bf037b67..7296439302e9 100755 --- a/.buildkite/hooks/pre-command +++ b/.buildkite/hooks/pre-command @@ -1,13 +1,20 @@ #!/usr/bin/env bash set -eu -pushd $(dirname "${BASH_SOURCE[0]}")/../.. +pushd "$(dirname "${BASH_SOURCE[0]}")"/../.. + +ORPHAN_ASDF=() +mapfile ORPHAN_ASDF < <(find "$HOME/.asdf/installs/" -maxdepth 2 -empty) + +for dir in "${ORPHAN_ASDF[@]}"; do + echo "Removing orphaned .asdf directory: ${dir}" + rm -rf "${dir}" +done TOOL_VERSION_FILES=() mapfile -d $'\0' TOOL_VERSION_FILES < <(fd .tool-versions --hidden --absolute-path --print0) -for file in "${TOOL_VERSION_FILES[@]}" -do +for file in "${TOOL_VERSION_FILES[@]}"; do echo "Installing asdf dependencies as defined in ${file}:" parent=$(dirname "${file}") pushd "${parent}" diff --git a/.buildkite/pipeline.async.yml b/.buildkite/pipeline.async.yml new file mode 100644 index 000000000000..1f0914fd91e6 --- /dev/null +++ b/.buildkite/pipeline.async.yml @@ -0,0 +1,12 @@ +env: + ENTERPRISE: "1" + MINIFY: "1" + FORCE_COLOR: "3" + +steps: +- command: + - COVERAGE_INSTRUMENT=true NODE_OPTIONS="--max_old_space_size=4096" dev/ci/yarn-run.sh build-storybook + - yarn run cover-storybook + - yarn nyc report -r json + - bash <(curl -s https://codecov.io/bash) -c -F typescript -F storybook + label: ':storybook::codecov: Storybook coverage' diff --git a/.buildkite/pipeline.codeintel.yml b/.buildkite/pipeline.codeintel.yml new file mode 100644 index 000000000000..3a0c2feb020d --- /dev/null +++ b/.buildkite/pipeline.codeintel.yml @@ -0,0 +1,9 @@ +env: + VAGRANT_RUN_ENV: "CI" +steps: +- label: ':docker::brain: Code Intel' + command: + - .buildkite/vagrant-run.sh sourcegraph-code-intel-test + artifact_paths: ./*.log + agents: + queue: 'baremetal' diff --git a/.buildkite/pipeline.e2e.yml b/.buildkite/pipeline.e2e.yml index 5f928574eb69..ba0f26611d5f 100644 --- a/.buildkite/pipeline.e2e.yml +++ b/.buildkite/pipeline.e2e.yml @@ -1,31 +1,9 @@ env: - ENTERPRISE: "1" - DOCKER_BUILDKIT: "1" - ENTERPRISE: "1" - FORCE_COLOR: "3" - GO111MODULE: "on" - IMAGE: us.gcr.io/sourcegraph-dev/server:$TAG - TEST_USER_PASSWORD: "SuperSecurePassword" - + VAGRANT_RUN_ENV: 'CI' steps: -- artifact_paths: ./puppeteer/*.png;./web/e2e.mp4;./web/ffmpeg.log - command: - - pushd enterprise - - ./cmd/server/pre-build.sh - - ./cmd/server/build.sh - - popd - - | - if [ "$PUSH_CANDIDATE_IMAGE" == "true" ]; then - yes | gcloud auth configure-docker - docker push "$IMAGE" - fi - - ./dev/ci/e2e.sh - timeout_in_minutes: 20 - label: ':docker::arrow_right::chromium:' - -- wait - -- command: docker image rm -f "$IMAGE" - timeout_in_minutes: 10 - label: ':sparkles:' - soft_fail: true + - label: ':chromium: Sourcegraph E2E' + artifact_paths: ./*.png;./*.mp4;./ffmpeg.log + command: + - .buildkite/vagrant-run.sh sourcegraph-e2e + agents: + queue: 'baremetal' diff --git a/.buildkite/pipeline.qa.yml b/.buildkite/pipeline.qa.yml new file mode 100644 index 000000000000..8f1d7e9ad7f5 --- /dev/null +++ b/.buildkite/pipeline.qa.yml @@ -0,0 +1,24 @@ +env: + VAGRANT_RUN_ENV: "CI" +steps: +- label: ':docker::chromium: Sourcegraph QA' + command: + - .buildkite/vagrant-run.sh sourcegraph-qa-test + artifact_paths: ./*.png;./*.mp4;./*.log + agents: + queue: 'baremetal' + +- label: ':docker::arrow_double_up: Sourcegraph Upgrade' + command: + - .buildkite/vagrant-run.sh sourcegraph-upgrade + artifact_paths: ./*.png;./*.mp4;./*.log + agents: + queue: 'baremetal' + +- label: ":k8s: Sourcegraph Cluster (deploy-sourcegraph) QA" + commands: + - dev/ci/test/cluster/cluster-test.sh + artifact_paths: ./*.png;./*.mp4;./*.log + concurrency: 1 + concurrency_group: "cluster-test" + timeout_in_minutes: 30 diff --git a/.buildkite/updater/is-tip-of-main.sh b/.buildkite/updater/is-tip-of-main.sh new file mode 100755 index 000000000000..8ff843aaeb25 --- /dev/null +++ b/.buildkite/updater/is-tip-of-main.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +set -euxo pipefail + +COMMIT="${BUILDKITE_COMMIT}" + +API_SLUG="repos/sourcegraph/sourcegraph/commits" +function get_branch_tip() { + local ref="$1" + + # https://docs.github.com/en/rest/reference/repos#list-commits + gh api "${API_SLUG}?sha=${ref}&per_page=1" --jq '.[].sha' +} + +REF="main" +tip_of_main="$(get_branch_tip ${REF})" + +[[ "$tip_of_main" == "$COMMIT" ]] diff --git a/.buildkite/updater/pipeline.update-trigger.yaml b/.buildkite/updater/pipeline.update-trigger.yaml new file mode 100644 index 000000000000..f0ff74b24951 --- /dev/null +++ b/.buildkite/updater/pipeline.update-trigger.yaml @@ -0,0 +1,15 @@ +steps: + - trigger: 'ds-updater-test-image-updater' + label: ':k8s: :arrows_counterclockwise: :construction: Trigger update pipeline for deploy-sourcegraph-updater-test' + branches: 'main' + async: true + build: + env: + TARGET_COMMIT: '${BUILDKITE_COMMIT}' + - trigger: 'deploy-sourcegraph-dogfood-k8s-2-image-updater-pipeline' + label: ':k8s: :arrows_counterclockwise: :dog: Trigger update pipeline for deploy-sourcegraph-dogfood-k8s-2' + branches: 'main' + async: true + build: + env: + TARGET_COMMIT: '${BUILDKITE_COMMIT}' diff --git a/.buildkite/updater/trigger-if-tip-of-main.sh b/.buildkite/updater/trigger-if-tip-of-main.sh new file mode 100755 index 000000000000..66ba58301e7e --- /dev/null +++ b/.buildkite/updater/trigger-if-tip-of-main.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +set -euo pipefail + +if ! .buildkite/updater/is-tip-of-main.sh; then + echo "๐Ÿšจ This commit is not the tip of main (either it's behind or an unrelated commit). Skipping deployment triggers..." + exit 0 # This is not a failure condition. +fi + +buildkite-agent pipeline upload '.buildkite/updater/pipeline.update-trigger.yaml' diff --git a/.buildkite/vagrant-run.sh b/.buildkite/vagrant-run.sh new file mode 100755 index 000000000000..44937ef99c33 --- /dev/null +++ b/.buildkite/vagrant-run.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +set -euxo pipefail + +box="$1" +exit_code=0 + +pushd "dev/ci/test" + +cleanup() { + echo "--- vagrant status" + vagrant status --debug-timestamp "$box" + + echo "--- vagrant destroy" + vagrant destroy -f "$box" +} + +# remove log prefix that vagrant inserts so buildkite can interpret control +# characters from output. For example +# +# sourcegraph-e2e: --- yarn run test-e2e +# +# becomes +# +# --- yarn run test-e2e +remove_log_prefix() { + # We don't use ^ due to control characters. + sed -E "s/ ${box}: (---|\+\+\+|\^\^\^) /\1 /g" +} + +plugins=(vagrant-google vagrant-env vagrant-scp) +for i in "${plugins[@]}"; do + if ! vagrant plugin list --no-tty | grep "$i"; then + vagrant plugin install "$i" + fi +done + +trap cleanup EXIT + +(vagrant up "$box" --provider=google | remove_log_prefix) || exit_code=$? + +vagrant scp "${box}:/sourcegraph/puppeteer/*.png" ../../../ +vagrant scp "${box}:/sourcegraph/*.mp4" ../../../ +vagrant scp "${box}:/sourcegraph/*.log" ../../../ + +if [ "$exit_code" != 0 ]; then + exit $exit_code +fi diff --git a/.dockerignore b/.dockerignore index 4af2c40639b6..e5cfd89a37c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -67,8 +67,6 @@ yarn-error.log eb-bundle.zip -/cmd/frontend/internal/app/assets/assets_vfsdata.go - /release/ /conf/private @@ -97,12 +95,7 @@ cmd/indexer/debug # Web node_modules/ -/web +/client/web # Extensions -/packages/sourcegraph-extension-api/dist - -# Precise code intel -./cmd/precise-code-intel/node_modules -./cmd/precise-code-intel/out -./cmd/precise-code-intel/test-data +/extension-api/dist diff --git a/.editorconfig b/.editorconfig index efbe4b38192e..69a805b0f80c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,3 @@ - root = true [*] @@ -12,8 +11,20 @@ indent_size = 4 [*.go] indent_style = tab -[{*.js,*.json,*.yml,*.md,.babelrc,.stylelintrc}] +[{*.js,*.jsx,*.json,*.yml,*.yaml,*.md,.babelrc,.stylelintrc}] indent_size = 2 [*.md] trim_trailing_whitespace = false + +[{*.sh, *.bash}] +indent_style = space +indent_size = 2 +switch_case_indent = true + +[**/node_modules/**] +ignore = true + +# shfmt shouldn't format third-party script codecov.sh +[dev/ci/codecov.sh] +ignore = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000000..aa26087a92c9 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +out/ +src/schema/* +src/graphql-operations.ts +GH2SG.bookmarklet.js diff --git a/.eslintrc.js b/.eslintrc.js index 93b44aac106a..2c5742ddae3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,7 @@ const config = { ecmaFeatures: { jsx: true, }, + EXPERIMENTAL_useSourceOfProjectReferenceRedirect: true, project: __dirname + '/tsconfig.json', }, settings: { @@ -30,9 +31,12 @@ const config = { }, ], }, + plugins: ['@sourcegraph/sourcegraph', 'monorepo'], rules: { // Rules that are specific to this repo // All other rules should go into https://github.com/sourcegraph/eslint-config + 'monorepo/no-relative-import': 'error', + '@sourcegraph/sourcegraph/check-help-links': 'error', 'no-restricted-imports': [ 'error', { @@ -41,9 +45,17 @@ const config = { 'marked', 'rxjs/ajax', { - name: 'rxjs/fetch', - message: - 'rxjs fromFetch is broken. Until https://github.com/ReactiveX/rxjs/pull/5306 is merged, please use shared/src/graphql/fromFetch.ts', + name: 'rxjs', + importNames: ['animationFrameScheduler'], + message: 'Code using animationFrameScheduler breaks in Firefox when using Sentry.', + }, + ], + patterns: [ + { + group: ['**/enterprise/*'], + message: `The OSS product may not pull in any code from the enterprise codebase, to stay a 100% open-source program. + +See https://about.sourcegraph.com/community/faq#is-all-of-sourcegraph-open-source for more information.`, }, ], }, @@ -60,6 +72,7 @@ const config = { ], }, ], + 'react/jsx-no-target-blank': ['error', { allowReferrer: true }], }, overrides: [ { diff --git a/.gitattributes b/.gitattributes index 33e17e1135c2..1dc1a6f63060 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ -cmd/frontend/graphqlbackend/schema.go linguist-generated=true cmd/repo-updater/repos/testdata/** linguist-generated=true **/__fixtures__/** linguist-generated=true **/bindata.go linguist-generated=true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8a978acf9d04..7cc5bb26eec4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,284 +5,10 @@ # Order is important; the last matching pattern takes the most # precedence. -# Top-level catch-alls (these are weaker confidence and might need to be reassigned at some point) -*.js @sourcegraph/web -*.ts @sourcegraph/web -*.tsx @sourcegraph/web -/enterprise/cmd/frontend @beyang @slimsag -/enterprise/cmd/server @beyang @slimsag -/enterprise/dev @beyang -/cmd/frontend/shared @beyang @slimsag -/cmd/frontend/backend @beyang @slimsag -/cmd/frontend/internal/app/assets @slimsag -/cmd/frontend/internal/app/templates @slimsag -/cmd/frontend/internal/app/canonicalurl @beyang -/cmd/frontend/internal/app/*.go @slimsag -/cmd/frontend/internal/app/assetsutil @slimsag -/cmd/frontend/internal/app/ui @slimsag -/cmd/frontend/internal/app/returnto @beyang -/cmd/frontend/internal/app/pkg @beyang @slimsag -/cmd/frontend/internal/app/router @slimsag -/cmd/frontend/internal/app/errorutil @beyang @slimsag -/cmd/frontend/internal/goroutine @slimsag -/cmd/frontend/internal/inventory @beyang @slimsag -/cmd/frontend/internal/cli/middleware @beyang @slimsag -/cmd/frontend/internal/cli @slimsag @beyang -/cmd/frontend/internal/pkg/siteid @beyang -/cmd/frontend/internal/pkg/suspiciousnames @beyang -/cmd/frontend/internal/pkg/markdown @slimsag -/cmd/frontend/internal/pkg/handlerutil @slimsag @beyang -/cmd/frontend/internal/httpapi @slimsag -/cmd/frontend/types @slimsag -/cmd/frontend/hooks @beyang @slimsag -/cmd/frontend/internal/ @beyang @slimsag -/internal/randstring/ @beyang -/internal/pubsub/ @beyang -/internal/repotrackutil/ @beyang -/internal/atomicvalue/ @beyang -/internal/testutil/ @beyang -/internal/debugserver/ @beyang -/internal/vfsutil/ @slimsag -/internal/gituri/ @beyang -/internal/comby @rvantonder -/internal/db/ @keegancsmith -/internal/processrestart @slimsag @keegancsmith -/internal/honey @keegancsmith -/internal/ratelimit @beyang -/internal/registry @sourcegraph/web -/internal/slack @slimsag -/internal/prefixsuffixsaver @beyang -/internal/gosrc @beyang -/internal/txemail @slimsag -/internal/src-cli @efritz -/internal/linkheader @efritz -/renovate.json @felixfbecker -/.stylelintrc.json @felixfbecker -/.stylelintignore @felixfbecker -/graphql-schema-linter.config.js @felixfbecker -/.prettierignore @felixfbecker -/.github @beyang -/.github/workflows/lsif.yml @efritz -/.gitmodules @beyang -/.gitattributes @beyang -/.yarnrc @felixfbecker -.eslintrc.js @felixfbecker -/internal/httputil @beyang -/internal/diskcache @beyang -/internal/sysreq @beyang -/internal/errcode @beyang -/internal/routevar @beyang -/internal/env @beyang -/internal/pathmatch @beyang -/internal/version @beyang -/internal/httptestutil @beyang -/internal/mutablelimiter @beyang -/internal/buildkite @ggilmore -/internal/httpcli @sourcegraph/core-services -/packages @beyang -/cmd/frontend @beyang -/dev @beyang -/dev/release-ping.sh @sourcegraph/distribution -/dev/grafana.sh @sourcegraph/distribution -/dev/grafana @sourcegraph/distribution -/dev/prometheus.sh @sourcegraph/distribution -/dev/prometheus @sourcegraph/distribution -/dev/zoekt @sourcegraph/core-services -/dev/src-expose @keegancsmith -/dev/drop-test-databases.sh @efritz -/dev/squash_migrations.sh @efritz -/.buildkite @beyang -/.storybook @felixfbecker -/CONTRIBUTING.md @beyang @nicksnyder @slimsag -/SECURITY.md @beyang @nicksnyder -/.dockerignore @beyang -/.mailmap @beyang -/tsconfig.json @sourcegraph/web -/.mocharc.json @sourcegraph/web -.eslintrc.* @sourcegraph/web -/enterprise/cmd @beyang -/enterprise/internal @beyang -/enterprise @beyang -/doc.go @beyang -/.gitignore @beyang -/prettier.config.js @sourcegraph/web -/.editorconfig @sourcegraph/web -/jest.config.js @sourcegraph/web -/cmd @beyang @slimsag -/internal @beyang @slimsag - -# Regression testing -/web/src/regression @beyang - -# Web -/shared @sourcegraph/web -/web @sourcegraph/web -/ui @sourcegraph/web -/client @sourcegraph/web -/enterprise/ui @sourcegraph/web -/cmd/frontend/internal/app/jscontext @sourcegraph/web @slimsag -/packages/@sourcegraph @sourcegraph/web -/web/src/site-admin/externalServices.tsx @beyang -/shared/src/components/activation/ @beyang - -# Tracking -/cmd/frontend/internal/app/pkg/updatecheck/ @dadlerj -/web/src/tracking/ @dadlerj -**/tracking @dadlerj -/cmd/frontend/internal/usagestats @dadlerj -/cmd/frontend/internal/pkg/usagestatsdeprecated @dadlerj -/internal/eventlogger @dadlerj - -# Campaigns -/cmd/frontend/graphqlbackend/campaigns.go @sourcegraph/campaigns-core -/enterprise/internal/campaigns @sourcegraph/campaigns-core -/internal/campaigns @sourcegraph/campaigns-core -/web/**/campaigns/** @sourcegraph/campaigns-web @mrnugget @sourcegraph/web - -# Auth -/cmd/frontend/auth/ @beyang @unknwon -/cmd/frontend/internal/auth/ @beyang @unknwon -/cmd/frontend/internal/session/ @beyang @unknwon -/cmd/frontend/external/session/session.go @beyang @unknwon -/enterprise/cmd/frontend/auth @beyang @unknwon -/enterprise/dev/auth-provider @beyang @unknwon -/cmd/frontend/graphqlbackend/*session* @beyang @unknwon -/cmd/frontend/graphqlbackend/*auth* @beyang @unknwon -/cmd/frontend/graphqlbackend/access_token.go @beyang @unknwon -/internal/actor/ @beyang @unknwon - -# Core Services -*git*/* @sourcegraph/core-services -/cmd/frontend/authz/ @sourcegraph/core-services -/cmd/frontend/db/ @sourcegraph/core-services -/cmd/frontend/globals/ @sourcegraph/core-services @slimsag -/cmd/frontend/graphqlbackend/ @sourcegraph/core-services @slimsag -/cmd/frontend/internal/bg/ @sourcegraph/core-services @slimsag -/cmd/github-proxy/ @sourcegraph/core-services -/cmd/gitserver/ @sourcegraph/core-services -/cmd/repo-updater/ @sourcegraph/core-services -/enterprise/cmd/frontend/authz/ @sourcegraph/core-services -/enterprise/cmd/frontend/db/ @sourcegraph/core-services -/enterprise/cmd/frontend/internal/authz/ @sourcegraph/core-services -/enterprise/cmd/frontend/internal/graphqlbackend/ @sourcegraph/core-services @slimsag -/enterprise/cmd/repo-updater/ @sourcegraph/core-services -/internal/api/ @sourcegraph/core-services -/internal/extsvc/ @sourcegraph/core-services -/internal/gitserver/ @sourcegraph/core-services -/internal/jsonc/ @sourcegraph/core-services @tsenart @slimsag -/internal/repoupdater/ @sourcegraph/core-services -/internal/trace/ @sourcegraph/core-services -/internal/tracer/ @sourcegraph/core-services -/internal/vcs/ @sourcegraph/core-services -/migrations/ @sourcegraph/core-services -/schema/ @sourcegraph/core-services - -# Search and code mod -*/search/**/* @sourcegraph/core-services -/cmd/frontend/internal/pkg/search @sourcegraph/core-services -/cmd/query-runner/ @sourcegraph/core-services -/cmd/replacer/ @sourcegraph/core-services @rvantonder -/cmd/searcher/ @sourcegraph/core-services -/cmd/symbols/ @sourcegraph/core-services -/internal/search/ @sourcegraph/core-services -/internal/symbols/ @sourcegraph/core-services - -# Symbols -/cmd/frontend/graphqlbackend/*symbols* @sourcegraph/code-intel -/enterprise/cmd/frontend/internal/symbols @sourcegraph/code-intel -/cmd/symbols/.ctags.d/ @sourcegraph/code-intel -/cmd/symbols/internal/pkg/ctags/ @sourcegraph/code-intel -/shared/src/languages* @sourcegraph/code-intel - -# Saved searches -/web/src/SavedQuery.tsx @attfarhan -/web/src/SavedQueries.tsx @attfarhan -/web/src/SavedQueryCreateForm.tsx @attfarhan -/web/src/SavedQueryUpdateForm.tsx @attfarhan -/web/src/SavedQueryForm.tsx @attfarhan -/web/src/SavedQueryRow.tsx @attfarhan -/cmd/frontend/types/saved_searches.go @attfarhan - -# Deployment and distribution -Dockerfile @sourcegraph/distribution -/observability @sourcegraph/distribution -/docker-images @sourcegraph/distribution -/enterprise/docs/deployment.md @sourcegraph/distribution -**/build.sh @sourcegraph/distribution -/cmd/frontend/envvar @sourcegraph/distribution -/cmd/server @sourcegraph/distribution -/internal/conf @slimsag -/internal/db/confdb @slimsag -/internal/db/globalstatedb @slimsag -/enterprise/docs @sourcegraph/distribution - -# Licensing and billing -/enterprise/cmd/frontend/internal/dotcom @sourcegraph/distribution -/enterprise/cmd/frontend/internal/licensing @sourcegraph/distribution - -# Documentation and homepage -/README.md @sqs -/doc/ @sourcegraph/distribution @ryan-blunden -/doc/dev/ @nicksnyder - -# Browser extensions -/browser/ @sourcegraph/web - -# Extension API -/packages/sourcegraph-extension-api/ @sourcegraph/web -/packages/@sourcegraph/extension-api-types @sourcegraph/web -/cmd/frontend/registry @sourcegraph/web -/enterprise/cmd/frontend/internal/registry @sourcegraph/web - -# Backend shared packages -/internal/endpoint/ @keegancsmith @slimsag -/internal/rcache/ @keegancsmith -/internal/redispool/ @keegancsmith -/internal/store/ @keegancsmith -/internal/metrics @keegancsmith @slimsag - -# Code discussions -**/*discussion* @slimsag -/web/src/discussions @slimsag -/web/src/repo/blob/discussions @slimsag -/cmd/frontend/types/discussions.go @slimsag -/cmd/frontend/internal/pkg/discussions @slimsag -/cmd/frontend/graphqlbackend/discussion* @slimsag -/cmd/frontend/db/discussion* @slimsag - -# LSIF -/cmd/precise-code-intel/ @sourcegraph/code-intel -/internal/lsif @sourcegraph/code-intel -/enterprise/internal/codeintel @sourcegraph/code-intel -/cmd/frontend/graphqlbackend/codeintel.go @sourcegraph/code-intel - -# Development -/dev/repogen @sourcegraph/core-services -/.vscode @felixfbecker -/.graphqlconfig @felixfbecker - -# Misc and special overrides -/LICENSE* @sqs @beyang @slimsag -/enterprise/internal/license @beyang -/cmd/frontend/external/session @beyang -/cmd/frontend/external @beyang -/babel.config.js @felixfbecker -/cmd/loadtest @beyang -/internal/hubspot/ @dadlerj -/internal/highlight/ @slimsag - -# Changes to the GraphQL API should be approved by both the team owning the backend and the consumers -/cmd/frontend/graphqlbackend/schema.graphql @sourcegraph/web @sourcegraph/core-services - -# These are configured through Renovate config. -# See ../renovate.json and https://github.com/sourcegraph/renovate-config/blob/master/renovate.json -# This is so that automerged PRs do not trigger email notification spam. -**/package.json -**/yarn.lock - -/go.sum @sourcegraph/core-services -/go.mod @sourcegraph/core-services -/CHANGELOG.md - -/.tool-versions @sourcegraph/distribution -/.nvmrc @sourcegraph/web +# We prefer to use Codenotify (https://github.com/sourcegraph/codenotify) instead of CODEOWNERS. +# More context is in this blog post: https://about.sourcegraph.com/blog/a-different-way-to-think-about-code-ownership/ +# If you are tempted to add an entry to CODEOWNERS, please try using Codenotify first for some amount of time. +# If Codenotify does not satisfy your needs, then you can open a PR to propose adding a new entry to CODEOWNERS and Nick will review. +# The PR description should describe why using Codenotify was insufficient. Thanks! +CODEOWNERS @nicksnyder +.github/CODEOWNERS @nicksnyder diff --git a/.github/ISSUE_TEMPLATE/ab-test.md b/.github/ISSUE_TEMPLATE/ab-test.md new file mode 100644 index 000000000000..c97df481c264 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ab-test.md @@ -0,0 +1,32 @@ +--- +name: A/B test tracking issue +about: Run an A/B test on Sourcegraph.com +title: 'A/B test: ' +labels: 'AB-test' +assignees: '' + +--- + +### Motivation + + + +### Test + + + +### Metric and experimental design + +**Metric:** +**Smaller significant change:** +**Significance threshold:** 5% +**Duration/size:** How long (on how many users) do we need to run this A/B test for it to be significant? Include a link to a singificance calculator. + + +#### Descriptive analytics + + + +### Flag + + diff --git a/.github/ISSUE_TEMPLATE/customer_feedback.md b/.github/ISSUE_TEMPLATE/customer_feedback.md new file mode 100644 index 000000000000..11ead4a2267f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/customer_feedback.md @@ -0,0 +1,26 @@ +--- +name: 'Customer #feedback' +about: 'Customer #feedback via an internal Sourcegraph teammate' +title: '' +labels: 'feedback' +assignees: '' + +--- + + + +#### Feedback + + + +#### Customer + + diff --git a/.github/ISSUE_TEMPLATE/design_debt.md b/.github/ISSUE_TEMPLATE/design_debt.md new file mode 100644 index 000000000000..b3986d3b55d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/design_debt.md @@ -0,0 +1,30 @@ +--- +name: Design debt +about: Describes the problem that needs attention because of design debt +title: '' +labels: 'design-debt' +assignees: '' +--- + + +#### Details + +#### Type of debt + + +#### Estimated amount of work + + +#### Areas affected + +#### Impact + +#### Urgency diff --git a/.github/ISSUE_TEMPLATE/docs-issue.md b/.github/ISSUE_TEMPLATE/docs-issue.md new file mode 100644 index 000000000000..afd997c40115 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs-issue.md @@ -0,0 +1,16 @@ +--- +name: Docs issue +about: Report broken links, typos, or other doc improvements +title: '' +labels: docs +assignees: kghopson + +--- +#### Describe the issue + + +#### Where is the issue located? + + +#### Suggested resolution + diff --git a/.github/ISSUE_TEMPLATE/flaky_test.md b/.github/ISSUE_TEMPLATE/flaky_test.md new file mode 100644 index 000000000000..2971bcf379e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/flaky_test.md @@ -0,0 +1,18 @@ +--- +name: Flaky Test +about: Capture information about a flaky test that has been disabled. +title: 'Flake: $TEST_NAME disabled' +labels: + - 'testing' + - 'flake' +assignees: '' + +--- + +- **Name of test:** +- **Example failure:** +- **PR**: diff --git a/.github/ISSUE_TEMPLATE/request_patch_release.md b/.github/ISSUE_TEMPLATE/request_patch_release.md new file mode 100644 index 000000000000..e944a0e91f7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/request_patch_release.md @@ -0,0 +1,50 @@ +--- +name: Request patch release +about: Sourcegraph teams, use this issue to request the Distribution team perform a patch release or include your changes in a patch release.. +title: '' +labels: 'team/distribution,patch-release-request' +assignees: '' + +--- + +@sourcegraph/distribution I am requesting the following commits be included in a patch release. They are already merged into `main`: + +The intent of the questions below is to ensure we keep Sourcegraph high quality and [only create patch releases based on a strict criteria.](https://about.sourcegraph.com/handbook/engineering/releases#when-are-patch-releases-performed) If you can answer yes to many or most of these questions, we will be happy to create the patch release. + +- + +I have read [when and why we perform patch releases](https://about.sourcegraph.com/handbook/engineering/releases#when-are-patch-releases-performed) and answer the questions as follows: + +> Are users/customers actively asking us for these changes and cannot wait until the next full release? + + + +> Are the changes extremely minimal, well-tested, and low risk such that not testing as we do in a full release is OK? + + + +> Is there some functionality completely broken that warrants redacting the prior release of Sourcegraph and advising users wait for the patch release? + + + +> This will interrupt our regular planned work and release cycle, taking one full working day of our time, and will take up all of our site admin's valuable time by asking them to upgrade or producing noise for them if they don't need to upgrade. +> +> Do you believe the changes are important enough to warrant this? + + + +> Patch releases are a signal we can do something better to improve the quality of Sourcegraph. Have you already scheduled a call (or created a google doc) to perform a [retrospective](https://about.sourcegraph.com/retrospectives) and identify ways we can improve? + + + +--- + +**For the [release captain](https://about.sourcegraph.com/handbook/engineering/releases#release-captain)** - after reviewing and approving this request: + +- If there is [already an upcoming patch release](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+label%3Arelease-tracking+), add the listed commits alongside a link to this issue +- If there is no upcoming patch release, create a new one: + - Update [`dev/release/release-config.jsonc`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/dev/release/release-config.jsonc) with the patch release in `upcomingRelease` and `releaseDate` (and open a PR to `main` to update it) + - `yarn release tracking:issues` + - Add the listed commits alongside a link to this issue to the generated [release tracking issue](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+label%3Arelease-tracking+) + +Comment and close this issue once the relevant commit(s) have been cherry-picked into the release branch. diff --git a/.github/ISSUE_TEMPLATE/security.md b/.github/ISSUE_TEMPLATE/security.md deleted file mode 100644 index 0816a0c4eed9..000000000000 --- a/.github/ISSUE_TEMPLATE/security.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Security vulnerability -about: Do NOT file security issues here. See hackerone.com/sourcegraph instead. -title: '' -labels: '' -assignees: '' - ---- - -STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! -STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! -STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! -STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! -STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! STOP !! - -If you are reporting a security vulnerability, please visit https://hackerone.com/sourcegraph and -follow the instructions there for responsible disclosure. - -Do **NOT** file the issue in this issue tracker. diff --git a/.github/ISSUE_TEMPLATE/tracking_issue.md b/.github/ISSUE_TEMPLATE/tracking_issue.md new file mode 100644 index 000000000000..53a5c013e9f4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tracking_issue.md @@ -0,0 +1,37 @@ +--- +name: Tracking issue +about: An issue to capture planned and on-going work of a team's milestone. +title: 'WIP: $TEAM $MILESTONE Tracking issue' +labels: tracking +assignees: '' + +--- + +### Plan + + + +### Availability + +If you have planned unavailability this iteration (e.g., vacation), you can note that here. + +### Tracked issues + + + + +#### Legend + +- ๐Ÿ‘ฉ Customer issue +- ๐Ÿ› Bug +- ๐Ÿงถ Technical debt +- ๐ŸŽฉ Quality of life +- ๐Ÿ› ๏ธ [Roadmap](https://docs.google.com/document/d/1cBsE9801DcBF9chZyMnxRdolqM_1c2pPyGQz15QAvYI/edit#heading=h.5nwl5fv52ess) +- ๐Ÿ•ต๏ธ [Spike](https://en.wikipedia.org/wiki/Spike_(software_development)) +- ๐Ÿ”’ Security issue +- :shipit: Pull Request diff --git a/.github/ISSUE_TEMPLATE/wildcard_proposal.md b/.github/ISSUE_TEMPLATE/wildcard_proposal.md new file mode 100644 index 000000000000..c6347d1091c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/wildcard_proposal.md @@ -0,0 +1,21 @@ +--- +name: Propose a new Wildcard component +about: Propose a new component for the Wildcard component library +title: '' +labels: 'team/frontend-platform' +assignees: '' + +--- + +#### Component description + + +#### Have designs already been created? If so, please link them here. + + +#### Additional context + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3ff2134a6ae5..6606c1b3c753 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,4 @@ + diff --git a/.github/PULL_REQUEST_TEMPLATE/developer_insights.md b/.github/PULL_REQUEST_TEMPLATE/developer_insights.md new file mode 100644 index 000000000000..4f412cc1153d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/developer_insights.md @@ -0,0 +1,28 @@ + +## Overview +Insert description here... + + +## Screenshots +| BEFORE | AFTER | +:-------------------------:|:-------------------------: + Insert image here | Insert image here + + + +## Progress +- [ ] Implementation progress milestones + - [ ] Milestone X + - [ ] Task 1 + - [ ] Task 2 + - [ ] Milestone Y + - [ ] Milestone Z + - [ ] Milestone ... +- [ ] Documentation text/screenshots updated (if relevant) +- [ ] Changelog updated (if user-facing) +- [ ] Approved by a frontend engineer (if touching frontend code) +- [ ] Approved by a backend engineer (if touching backend code) +- [ ] Approved by a designer (if it touches the UI) + + +Closes #{{ISSUE_NUMBER}} diff --git a/.github/teams.yml b/.github/teams.yml new file mode 100644 index 000000000000..9624b7daba38 --- /dev/null +++ b/.github/teams.yml @@ -0,0 +1,6 @@ +team/frontend-platform: + - '@alicjasuska' + - '@umpox' + - '@valerybugakov' + - '@5h1ru' + - '@pdubroy' diff --git a/.github/workflows/CODENOTIFY b/.github/workflows/CODENOTIFY new file mode 100644 index 000000000000..af686c166f2a --- /dev/null +++ b/.github/workflows/CODENOTIFY @@ -0,0 +1,9 @@ +# See https://github.com/sourcegraph/codenotify for documentation. + +codenotify.yml @nicksnyder + +licenses-check.yml @bobheadxi +licenses-update.yml @bobheadxi +renovate-downstream.yml @bobheadxi +renovate-downstream.json @bobheadxi +resources-report.yml @bobheadxi diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 000000000000..2e8fb69136b0 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,34 @@ +name: automerge + +on: + pull_request: + types: + - labeled + - unlabeled + - synchronize + - opened + - edited + - ready_for_review + - reopened + - unlocked + pull_request_review: + types: + - submitted + check_suite: + types: + - completed + status: {} + +jobs: + automerge: + runs-on: ubuntu-latest + steps: + - name: Automerge + uses: pascalgn/automerge-action@v0.12.0 + env: + MERGE_LABELS: automerge + MERGE_METHOD: squash + MERGE_RETRIES: 1 + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + with: + args: "--trace" diff --git a/.github/workflows/batches-notify.yml b/.github/workflows/batches-notify.yml new file mode 100644 index 000000000000..700a3c7f791a --- /dev/null +++ b/.github/workflows/batches-notify.yml @@ -0,0 +1,16 @@ +name: notify-batchers-team +on: + issues: + types: [opened] + +jobs: + create_comment: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-ecosystem/action-create-comment@v1 + if: (contains(github.event.issue.body, 'batch') || contains(github.event.issue.body, 'campaign')) && !contains(github.event.issue.labels.*.name, 'team/batchers') + with: + github_token: ${{ secrets.github_token }} + body: | + Hey, @sourcegraph/batchers (@eseliger @mrnugget @LawnGnome @malomarrec @chrispine @courier-new) - we have been mentioned. Let's take a look. diff --git a/.github/workflows/codenotify.yml b/.github/workflows/codenotify.yml new file mode 100644 index 000000000000..d21afdabcd40 --- /dev/null +++ b/.github/workflows/codenotify.yml @@ -0,0 +1,16 @@ +name: codenotify +on: + pull_request: + types: [opened, synchronize, ready_for_review] + +jobs: + codenotify: + runs-on: ubuntu-latest + name: codenotify + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: sourcegraph/codenotify@v0.4 + env: + GITHUB_TOKEN: ${{ secrets.CODENOTIFY_GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000000..5f2c81698dc8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,34 @@ +name: "Code Scanning - Action" + +on: + push: + branches: [main] + +jobs: + CodeQL-Build: + + strategy: + fail-fast: false + matrix: + languages: [ 'go', 'javascript'] + + # CodeQL runs on ubuntu-latest, windows-latest, and macos-latest + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.languages }} + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below). + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/container-scanning.yml b/.github/workflows/container-scanning.yml new file mode 100644 index 000000000000..c3341b8517f5 --- /dev/null +++ b/.github/workflows/container-scanning.yml @@ -0,0 +1,27 @@ +name: Scan latest container +on: + schedule: + - cron: '5 5 * * *' + +jobs: + scan: + name: container-scan + runs-on: ubuntu-latest + + steps: + - name: Scan image + uses: anchore/scan-action@v2 + with: + image: "sourcegraph/server:insiders" + acs-report-enable: true + act-report-severity-cutoff: "Medium" + fail-build: false + - name: Upload SARIF report + uses: github/codeql-action/upload-sarif@v1 + with: + sarif_file: results.sarif + - name: Upload artifact + uses: actions/upload-artifact@v2.2.2 + with: + name: AnchoreReports + path: ./anchore-reports/ diff --git a/.github/workflows/label-move.yml b/.github/workflows/label-move.yml new file mode 100644 index 000000000000..977da00013de --- /dev/null +++ b/.github/workflows/label-move.yml @@ -0,0 +1,15 @@ +name: Move labeled or milestoned issue to a specific project colum +on: + issues: + types: [labeled] +jobs: + Move_Labeled_Issue_On_Project_Board: + runs-on: ubuntu-latest + steps: + - uses: konradpabjan/move-labeled-or-milestoned-issue@v2.0 + with: + action-token: "${{ secrets.LABELER_GITHUB_TOKEN }}" + project-url: "https://github.com/orgs/sourcegraph/projects/145" + column-name: "To Triage ๐Ÿ“ฅ" + label-name: "team/extensibility" + columns-to-ignore: "*" diff --git a/.github/workflows/label-notify.yml b/.github/workflows/label-notify.yml new file mode 100644 index 000000000000..1d2312003ff7 --- /dev/null +++ b/.github/workflows/label-notify.yml @@ -0,0 +1,21 @@ +name: "Notify users based on issue labels" + +on: + issues: + types: [labeled] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - uses: jenschelkopf/issue-label-notification-action@f7d2363e5efa18b8aeea671ca8093e183ae8f218 # 1.3 + with: + recipients: | + team/extensibility=@joelkw @muratsu + team/frontend-platform=@alicjasuska @umpox @valerybugakov @5h1ru @pdubroy + team/cloud=@tsenart + team/search=@lguychard + team/code-intelligence=@macraig + team/code-insights=@joelkw @felixfbecker + team/distribution=@davejrt @ggilmore @daxmc99 @dan-mckean + team/security=@dan-mckean diff --git a/.github/workflows/licenses-check.yml b/.github/workflows/licenses-check.yml new file mode 100644 index 000000000000..082a8357dad7 --- /dev/null +++ b/.github/workflows/licenses-check.yml @@ -0,0 +1,24 @@ +name: Licenses Check +on: [ pull_request ] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-ruby@v1 + with: { ruby-version: '2.6' } + - uses: actions/setup-go@v2 + with: { go-version: '1.16' } + + # set up correct version of node + - id: nvmrc + run: echo ::set-output name=NODE_VERSION::$(cat .nvmrc) + - uses: actions/setup-node@v2 + with: { node-version: '${{ steps.nvmrc.outputs.NODE_VERSION }}' } + + - name: Install license_finder + run: gem install license_finder:6.9.0 # sync with licenses-update.yml + + - name: Check dependencies + run: LICENSE_CHECK=true ./dev/licenses.sh diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml new file mode 100644 index 000000000000..c554d6d94278 --- /dev/null +++ b/.github/workflows/licenses-update.yml @@ -0,0 +1,49 @@ +name: Licenses Update +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * MON' + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-ruby@v1 + with: { ruby-version: '2.6' } + - uses: actions/setup-go@v2 + with: { go-version: '1.16' } + + # set up correct version of node + - id: nvmrc + run: echo ::set-output name=NODE_VERSION::$(cat .nvmrc) + - uses: actions/setup-node@v2 + with: { node-version: '${{ steps.nvmrc.outputs.NODE_VERSION }}' } + + - name: Install license_finder + run: gem install license_finder:6.9.0 # sync with licenses-check.yml + + - name: Generate report + run: ./dev/licenses.sh + + - name: Preview report diff + run: git --no-pager diff + + - name: Open pull request + uses: peter-evans/create-pull-request@v3 + with: + labels: automerge + base: main + branch: chore/licenses-update + title: 'chore: update third-party licenses' + commit-message: 'chore: update third-party licenses' + body: | + This is an automated pull request generated by [this run](https://github.com/sourcegraph/sourcegraph/actions/runs/${{ github.run_id }}). + Learn more about our GitHub Actions for managing licenses [here](https://docs.sourcegraph.com/dev/background-information/continuous_integration#third-party-licenses). + + You're safe to merge this pull request when the required checks are passing. + # token must be a personal access token for pull request to trigger other actions + # token must have `repo` scope + # currently @sourcegraph-bot cross-repo-github-actions in 1password + # configure in https://github.com/sourcegraph/sourcegraph/settings/secrets/GH_REPO_TOKEN + token: ${{ secrets.GH_REPO_TOKEN }} diff --git a/.github/workflows/lsif.yml b/.github/workflows/lsif.yml index b9bca67e83fa..590de103c3c7 100644 --- a/.github/workflows/lsif.yml +++ b/.github/workflows/lsif.yml @@ -2,87 +2,259 @@ name: LSIF on: - push jobs: - lsif-go: + lsif-go-root: + if: github.repository == 'sourcegraph/sourcegraph' runs-on: ubuntu-latest + container: sourcegraph/lsif-go steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Generate LSIF data - uses: sourcegraph/lsif-go-action@master - - name: Upload LSIF data - uses: sourcegraph/lsif-upload-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} + run: lsif-go + - name: Upload LSIF data to .com + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ - lsif-web: + lsif-go-lib: + if: github.repository == 'sourcegraph/sourcegraph' runs-on: ubuntu-latest - container: node:13.8.0-alpine3.10 + container: sourcegraph/lsif-go steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + - name: Generate LSIF data + working-directory: lib/ + run: lsif-go + - name: Upload LSIF data to .com + working-directory: lib/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: lib/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ + + lsif-tsc-web: + if: github.repository == 'sourcegraph/sourcegraph' + runs-on: ubuntu-latest + container: sourcegraph/lsif-node + steps: + - uses: actions/checkout@v2 + - name: Install build dependencies + run: apk --no-cache add python g++ make git + - name: Install dependencies + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Generate LSIF data + working-directory: client/web/ + run: lsif-tsc -p . + - name: Upload LSIF data to .com + working-directory: client/web/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/web/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ + + lsif-tsc-shared: + if: github.repository == 'sourcegraph/sourcegraph' + runs-on: ubuntu-latest + container: sourcegraph/lsif-node + steps: + - uses: actions/checkout@v2 - name: Install build dependencies run: apk --no-cache add python g++ make git - name: Install dependencies - run: yarn + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate - name: Generate LSIF data - uses: sourcegraph/lsif-node-action@master - with: - project_root: web - - name: Upload LSIF data - uses: sourcegraph/lsif-upload-action@master - with: - root: web - github_token: ${{ secrets.GITHUB_TOKEN }} + working-directory: client/shared/ + run: lsif-tsc -p . + - name: Upload LSIF data to .com + working-directory: client/shared/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/shared/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ - lsif-precise-code-intel: + lsif-tsc-branded: + if: github.repository == 'sourcegraph/sourcegraph' runs-on: ubuntu-latest - container: node:13.8.0-alpine3.10 + container: sourcegraph/lsif-node steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + - name: Install build dependencies + run: apk --no-cache add python g++ make git + - name: Install dependencies + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Generate LSIF data + working-directory: client/branded/ + run: lsif-tsc -p . + - name: Upload LSIF data to .com + working-directory: client/branded/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/branded/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ + + lsif-tsc-browser: + if: github.repository == 'sourcegraph/sourcegraph' + runs-on: ubuntu-latest + container: sourcegraph/lsif-node + steps: + - uses: actions/checkout@v2 + - name: Install build dependencies + run: apk --no-cache add python g++ make git + - name: Install dependencies + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Generate LSIF data + working-directory: client/browser/ + run: lsif-tsc -p . + - name: Upload LSIF data to .com + working-directory: client/browser/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/browser/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ + + lsif-tsc-wildcard: + if: github.repository == 'sourcegraph/sourcegraph' + runs-on: ubuntu-latest + container: sourcegraph/lsif-node + steps: + - uses: actions/checkout@v2 + - name: Install build dependencies + run: apk --no-cache add python g++ make git + - name: Install dependencies + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Generate LSIF data + working-directory: client/wildcard/ + run: lsif-tsc -p . + - name: Upload LSIF data to .com + working-directory: client/wildcard/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/wildcard/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ + + lsif-eslint-web: + if: github.repository == 'sourcegraph/sourcegraph' + runs-on: ubuntu-latest + container: sourcegraph/lsif-node + steps: + - uses: actions/checkout@v2 + - name: Install build dependencies + run: apk --no-cache add python g++ make git + - name: Install dependencies + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Build TypeScript + run: yarn run --ignore-engines build-ts + - name: Generate LSIF data + working-directory: client/web/ + run: yarn eslint -f lsif -o dump.lsif + - name: Upload LSIF data to .com + working-directory: client/web/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/web/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ + + lsif-eslint-shared: + if: github.repository == 'sourcegraph/sourcegraph' + runs-on: ubuntu-latest + container: sourcegraph/lsif-node + steps: + - uses: actions/checkout@v2 + - name: Install build dependencies + run: apk --no-cache add python g++ make git - name: Install dependencies - run: yarn --cwd cmd/precise-code-intel + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Build TypeScript + run: yarn run --ignore-engines build-ts - name: Generate LSIF data - uses: sourcegraph/lsif-node-action@master - with: - project_root: cmd/precise-code-intel - - name: Upload LSIF data - uses: sourcegraph/lsif-upload-action@master - with: - root: cmd/precise-code-intel - github_token: ${{ secrets.GITHUB_TOKEN }} + working-directory: client/shared/ + run: yarn eslint -f lsif -o dump.lsif + - name: Upload LSIF data to .com + working-directory: client/shared/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/shared/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ - lsif-shared: + lsif-eslint-browser: + if: github.repository == 'sourcegraph/sourcegraph' runs-on: ubuntu-latest - container: node:13.8.0-alpine3.10 + container: sourcegraph/lsif-node steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install build dependencies run: apk --no-cache add python g++ make git - name: Install dependencies - run: yarn + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Build TypeScript + run: yarn run --ignore-engines build-ts - name: Generate LSIF data - uses: sourcegraph/lsif-node-action@master - with: - project_root: shared - - name: Upload LSIF data - uses: sourcegraph/lsif-upload-action@master - with: - root: shared - github_token: ${{ secrets.GITHUB_TOKEN }} + working-directory: client/browser/ + run: yarn eslint -f lsif -o dump.lsif + - name: Upload LSIF data to .com + working-directory: client/browser/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/browser/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ - lsif-browser: + lsif-eslint-wildcard: + if: github.repository == 'sourcegraph/sourcegraph' runs-on: ubuntu-latest - container: node:13.8.0-alpine3.10 + container: sourcegraph/lsif-node steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install build dependencies run: apk --no-cache add python g++ make git - name: Install dependencies - run: yarn + run: yarn --ignore-engines --ignore-scripts + - name: Generate + run: ./node_modules/.bin/gulp generate + - name: Build TypeScript + run: yarn run --ignore-engines build-ts - name: Generate LSIF data - uses: sourcegraph/lsif-node-action@master - with: - project_root: browser - - name: Upload LSIF data - uses: sourcegraph/lsif-upload-action@master - with: - root: browser - github_token: ${{ secrets.GITHUB_TOKEN }} + working-directory: client/wildcard/ + run: yarn eslint -f lsif -o dump.lsif + - name: Upload LSIF data to .com + working-directory: client/wildcard/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + - name: Upload LSIF data to dogfood + working-directory: client/wildcard/ + run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} + env: + SRC_ENDPOINT: https://k8s.sgdev.org/ diff --git a/.github/workflows/progress.yml b/.github/workflows/progress.yml new file mode 100644 index 000000000000..018e6660a6ae --- /dev/null +++ b/.github/workflows/progress.yml @@ -0,0 +1,37 @@ +name: Progress bot +on: + workflow_dispatch: + inputs: + since: + description: 'Time period to report' + required: false + default: '24h' + dry: + description: 'Only output message that would be sent to Slack' + required: false + default: 'false' + channel: + description: 'Slack channel to send message to' + required: false + default: 'progress' + schedule: + - cron: "0 0 * * *" # Every day 00:00 UTC (4pm PST) +jobs: + report-to-slack: + runs-on: ubuntu-latest + name: Report the last 24h of CHANGELOG to the progress channel + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 1000 + - name: Set up GCP key + run: | + echo ${{ secrets.PROGRESS_BOT_GCP_ACCOUNT_KEY }} | base64 -d > progress-bot-credentials.json + - name: Report to Slack + uses: docker://sourcegraph/progress-bot:latest + env: + SINCE: ${{ github.event.inputs.since || '24h' }} + DRY: ${{ github.event.inputs.dry || 'false' }} + CHANNEL: ${{ github.event.inputs.channel || 'progress' }} + GOOGLE_APPLICATION_CREDENTIALS: progress-bot-credentials.json + SLACK_API_TOKEN: ${{ secrets.PROGRESS_BOT_SLACK_API_TOKEN }} diff --git a/.github/workflows/renovate-downstream.json b/.github/workflows/renovate-downstream.json new file mode 100644 index 000000000000..471cf2bee6d2 --- /dev/null +++ b/.github/workflows/renovate-downstream.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json.schemastore.org/renovate", + "onboarding": false, + "requireConfig": true, + "gitAuthor": "Renovate Bot ", + "repositories": [ + "sourcegraph/deploy-sourcegraph" + ], + "prBodyNotes": [ + "{{#if groupName}}Source: {{replace 'Sourcegraph Docker insiders images' 'https://github.com/sourcegraph/sourcegraph/pull/PULL_REQUEST' groupName}}{{/if}}" + ], + "prFooter": "This PR was generated by the ['Renovate downstream' workflow](https://github.com/sourcegraph/sourcegraph/actions?query=workflow%3A%22Renovate+downstream%22)", + "force": { + "enabled": true + }, + "logLevel": "debug", + "printConfig": true +} diff --git a/.github/workflows/renovate-downstream.yml b/.github/workflows/renovate-downstream.yml new file mode 100644 index 000000000000..1d80ef0376cd --- /dev/null +++ b/.github/workflows/renovate-downstream.yml @@ -0,0 +1,43 @@ +name: Renovate downstream +on: + status: + workflow_dispatch: + +jobs: + # This job should trigger rennovate to run on the repositories defined in renovate-downstream.json + renovate: + runs-on: ubuntu-latest + # Run on commit status success on branch main, ignoring bot events + if: ${{ contains(github.event.branches.*.name, 'main') }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Dump event + continue-on-error: true + env: + EVENT_CONTEXT: ${{ toJson(github) }} + run: | + echo "$EVENT_CONTEXT" + + # retrieve first pull request attached to this commit + - name: Get pull request + id: pull_request + run: | + NUMBER=$(curl \ + -H "Authorization: token ${{ secrets.BUILDKITE_GITHUBDOTCOM_TOKEN }}" \ + -H "Accept: application/vnd.github.groot-preview+json" \ + https://api.github.com/repos/sourcegraph/sourcegraph/commits/${{ github.sha }}/pulls | jq --raw-output '.[0].number' | cat) + echo "::set-output name=number::$NUMBER" + - name: Update renovate config with PR number + run: | + sed -i -e 's/PULL_REQUEST/${{ steps.pull_request.outputs.number }}/g' .github/workflows/renovate-downstream.json + cat .github/workflows/renovate-downstream.json + + - name: Renovate + uses: renovatebot/github-action@v24.16.3 + with: + configurationFile: .github/workflows/renovate-downstream.json + # token must be a personal access token for cross-repo access - currently @sourcegraph-bot cross-repo-github-actions in 1password + # configure in https://github.com/sourcegraph/sourcegraph/settings/secrets/RENOVATE_TOKEN + token: ${{ secrets.RENOVATE_TOKEN }} diff --git a/.github/workflows/resources-report.yml b/.github/workflows/resources-report.yml new file mode 100644 index 000000000000..87a4bf71877a --- /dev/null +++ b/.github/workflows/resources-report.yml @@ -0,0 +1,26 @@ + +name: Resources Report +on: + schedule: + - cron: '0 9 * * *' + repository_dispatch: + types: [ resources-report ] + +jobs: + resources-report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: set up gcp key + run: | + echo ${{ secrets.RR_GCP_ACCOUNT_KEY }} | base64 -d > resources-report-credentials.json + - name: report resources + uses: ./internal/cmd/resources-report/. + with: + args: --aws --gcp --gcp.allowlist "cost-category:build" + env: + GOOGLE_APPLICATION_CREDENTIALS: resources-report-credentials.json + AWS_ACCESS_KEY_ID: ${{ secrets.RR_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.RR_AWS_SECRET_ACCESS_KEY }} + SLACK_WEBHOOK: ${{ secrets.RR_SLACK_WEBHOOK }} + SHEET_ID: ${{ secrets.RR_SHEET_ID }} diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml new file mode 100644 index 000000000000..c730949af1a4 --- /dev/null +++ b/.github/workflows/reviewdog.yml @@ -0,0 +1,18 @@ +name: reviewdog +on: [pull_request] +jobs: + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + - name: golangci-lint + uses: docker://reviewdog/action-golangci-lint:latest + env: + GITHUB_ACTION: reviewdog + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + filter_mode: file + reporter: github-pr-check + level: info diff --git a/.github/workflows/team-labeler.yml b/.github/workflows/team-labeler.yml new file mode 100644 index 000000000000..b663f12392d3 --- /dev/null +++ b/.github/workflows/team-labeler.yml @@ -0,0 +1,9 @@ +name: team-label +on: pull_request +jobs: + team-labeler: + runs-on: ubuntu-latest + steps: + - uses: JulienKode/team-labeler-action@v0.1.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/tracking-issue.yml b/.github/workflows/tracking-issue.yml index 9d710027df1b..a7c1b6b0e3dc 100644 --- a/.github/workflows/tracking-issue.yml +++ b/.github/workflows/tracking-issue.yml @@ -1,7 +1,7 @@ name: Tracking Issue Syncer on: schedule: - - cron: '*/15 * * * *' + - cron: '*/15 * * * *' issues: types: - opened @@ -15,28 +15,20 @@ on: - unlabeled - milestoned - demilestoned + pull_request: + types: + - opened + - edited + - closed + - reopened + - assigned + - unassigned + - labeled + - unlabeled jobs: - code-intelligence: - runs-on: ubuntu-latest - steps: - - uses: docker://sourcegraph/tracking-issue:latest - with: - args: -milestone 3.15 -labels team/code-intelligence -update - env: - GITHUB_TOKEN: ${{ secrets.TRACKING_ISSUE_SYNCER_TOKEN }} - core-services: - runs-on: ubuntu-latest - steps: - - uses: docker://sourcegraph/tracking-issue:latest - with: - args: -milestone 3.15 -labels team/core-services -update - env: - GITHUB_TOKEN: ${{ secrets.TRACKING_ISSUE_SYNCER_TOKEN }} - web: + sync-tracking-issues: runs-on: ubuntu-latest steps: - uses: docker://sourcegraph/tracking-issue:latest - with: - args: -milestone 3.15 -labels team/web -update env: GITHUB_TOKEN: ${{ secrets.TRACKING_ISSUE_SYNCER_TOKEN }} diff --git a/.gitignore b/.gitignore index b9e0a507c3a0..dea41e0abe99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /vagrant_ansible_inventory_default -/.vagrant +.vagrant # Vim *.swp @@ -56,18 +56,18 @@ yarn-error.log /ui/assets/sourcebox.css /ui/assets/test.js /ui/assets/test.css +/ui/assets/webpack.manifest.json /ui/assets/vs /ui/assets/*.html /ui/assets/*.map +/ui/assets/*.txt /ui/assets/extension /ui/.tmp +/ui/assets/scripts/ *.json.actual eb-bundle.zip -/cmd/frontend/internal/app/assets/assets_vfsdata.go -/cmd/frontend/internal/app/templates/data_vfsdata.go - /release/ /conf/private @@ -91,25 +91,27 @@ cmd/src/debug cmd/gitserver/debug cmd/indexer/debug -/dev/e2e/log.html /.gtm/ -# Web -node_modules/ +# Client +node_modules package-lock.json .nyc_output/ coverage/ out/ -shared/src/graphql/schema.ts -web/src/schema/* +client/shared/src/graphql/schema.ts +client/web/src/schema/* puppeteer/ package-lock.json /dist sourcegraph-webapp-*.tgz *.tsbuildinfo +graphql-operations.ts +*.module.scss.d.ts +dll-bundle # Extensions -/packages/sourcegraph-extension-api/dist +/client/extension-api/dist # Symbols service: PCRE extension to sqlite3 libsqlite3-pcre.dylib @@ -125,3 +127,20 @@ comment.txt .eslintcache storybook-static/ + +# Certificates +*.pem +*.crt + +# tilt files +/dev/tilt/generated-cluster +/dev/tilt/tilt-watch-targets + +# sonarqube +.scannerwork + +# sg command specific things, see ./dev/sg +sg.config.overwrite.yaml +# sg Google Cloud API OAuth token +.sg.token.json + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000000..4f618ace7d17 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,61 @@ +# See explanation of linters at https://golangci-lint.run/usage/linters/ +linters: + disable-all: true + enable: + - bodyclose + - depguard + - gocritic + - goimports + - gosimple + - govet + - ineffassign + - nolintlint + - staticcheck + - typecheck + - unconvert + - unused + +linters-settings: + depguard: + list-type: blacklist + include-go-root: true + packages-with-error-message: + - errors: 'Use github.com/cockroachdb/errors instead' + - github.com/pkg/errors: 'Use github.com/cockroachdb/errors instead' + - ioutil: 'The ioutil package has been deprecated' + gocritic: + disabled-checks: + - appendAssign # Too many false positives + - assignOp # Maybe worth adding, but likely not worth the noise + - commentFormatting # No strong benefit + - deprecatedComment # Unnecessary + - exitAfterDefer # Only occurs in auxiliary tools + - ifElseChain # Noisy for not much gain + - singleCaseSwitch # Noisy for not much gain + govet: + disable: + - composites + forbidigo: + forbid: + # Use errors.New instead + - 'fmt\.Errorf' + +issues: + exclude-rules: + # Exclude bodyclose lint from tests because leaking connections in tests + # is a non-issue, and checking that adds unnecessary noise + - path: _test\.go + linters: + - bodyclose + +run: + timeout: 5m + + skip-dirs: + - client + - ui + - vendor + - node_modules + + skip-files: + - schema/schema.go # Auto-generated with depguard failures diff --git a/.graphqlconfig b/.graphqlconfig deleted file mode 100644 index 3673fcb5a200..000000000000 --- a/.graphqlconfig +++ /dev/null @@ -1,3 +0,0 @@ -{ - "schemaPath": "./cmd/frontend/graphqlbackend/schema.graphql" -} diff --git a/.graphqlrc.yml b/.graphqlrc.yml new file mode 100644 index 000000000000..58b4553811d9 --- /dev/null +++ b/.graphqlrc.yml @@ -0,0 +1,2 @@ +schema: + - ./cmd/frontend/graphqlbackend/*.graphql diff --git a/.mailmap b/.mailmap index dbace57d5f90..53357b49714e 100644 --- a/.mailmap +++ b/.mailmap @@ -59,3 +59,4 @@ Unknown ๆ— ้—ป Renovate Bot renovate[bot] Matt King Matthew King Nico Tonozzi Dominic Tonozzi +Camden Cheek Camden Cheek diff --git a/.mocharc.js b/.mocharc.js index 370f63084090..f035c841cf10 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -1,5 +1,5 @@ module.exports = { - require: ['ts-node/register', 'abort-controller/polyfill', __dirname + '/shared/dev/fetch'], + require: ['ts-node/register', 'abort-controller/polyfill', __dirname + '/client/shared/dev/fetch'], extension: ['js', 'ts'], // 1 minute test timeout. This must be greater than the default Puppeteer // command timeout of 30s in order to get the stack trace to point to the diff --git a/.nvmrc b/.nvmrc index 6665a53d3b54..c91434ab584a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -13.12.0 +14.15.4 diff --git a/.percy.yml b/.percy.yml new file mode 100644 index 000000000000..ea6ad8f01039 --- /dev/null +++ b/.percy.yml @@ -0,0 +1,9 @@ +version: 1 +snapshot: + widths: + - 1920 # Full-width browser window + percy-css: | + .percy-hide, + .monaco-editor .cursor { + visibility: hidden !important; + } diff --git a/.prettierignore b/.prettierignore index 04561b2559be..4a8ab7ddfc56 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,10 +1,11 @@ .bin/ *.bundle.* client/phabricator/scripts/loader.js -browser/build +client/browser/build **/package.json **/coverage -cmd/frontend/db/schema.md +internal/database/schema.md +internal/database/schema.*.md cmd/xlang-python/python-langserver/ package-lock.json package.json @@ -13,14 +14,16 @@ vendor/ .nyc_output/ out/ dist/ -shared/src/graphqlschema.ts -web/src/schema/ +client/shared/src/graphqlschema.ts +client/web/src/schema/ ts-node-* testdata .github/* doc/ **/.cache -shared/dev/**/*.js **/__snapshots__ -**/*.html -client/contrib/GH2SG.bookmarklet.js +**/__fixtures__ +GH2SG.bookmarklet.js +docker-images/grafana/config/provisioning/dashboards/sourcegraph/ +storybook-static/ +browser/code-intel-extensions/ diff --git a/.storybook/addons.ts b/.storybook/addons.ts deleted file mode 100644 index 41a31c20706a..000000000000 --- a/.storybook/addons.ts +++ /dev/null @@ -1,3 +0,0 @@ -import '@storybook/addon-actions/register' -import '@storybook/addon-knobs/register' -import '@storybook/addon-options/register' diff --git a/.storybook/config.ts b/.storybook/config.ts deleted file mode 100644 index 81202329006c..000000000000 --- a/.storybook/config.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { configureActions } from '@storybook/addon-actions' -// @ts-ignore -import { withConsole } from '@storybook/addon-console' -import { withInfo } from '@storybook/addon-info' -import { withKnobs } from '@storybook/addon-knobs' -import { addDecorator, addParameters, configure } from '@storybook/react' -import { themes } from '@storybook/theming' - -import './styles' - -async function main(): Promise { - // Webpack provides require.context. TODO: If this is run in Jest in the future, we'll need to - // use babel-plugin-require-context-hook. - const requireContexts = [ - require.context('../shared', true, /\.story\.tsx?$/), - require.context('../browser', true, /\.story\.tsx?$/), - require.context('../web', true, /\.story\.tsx?$/), - ] - for (const requireContext of requireContexts) { - for (const storyModule of requireContext.keys()) { - requireContext(storyModule) - } - } - - // Configure storybooks. - configure(() => { - addDecorator((storyFn, context) => withKnobs(storyFn, context)) - addDecorator((storyFn, context) => withConsole()(storyFn)(context)) - addParameters({ theme: themes.dark }) - addDecorator(withInfo({ header: false, propTables: false })) - - configureActions({ - depth: 100, - limit: 20, - }) - }, module) -} -main().catch(err => console.error(err)) diff --git a/.storybook/styles.ts b/.storybook/styles.ts deleted file mode 100644 index 6adbb9b5b0b4..000000000000 --- a/.storybook/styles.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Set commonly used CSS variables that many components assume exist. This is better than importing -// all of (e.g.) SourcegraphWebApp.scss, because those styles are only applied for the web app and -// would be misleading to use for browser extension and shared component storybooks. -if (document && document.documentElement) { - // It's not necessary to define all CSS variables here or to use the precise values from our CSS. - // These are just used for storybooks. - const CSS_VARS = { - '--secondary': '#777777', - '--text-muted': '#bbbbbb', - '--primary': '#1c7ed6', - } - for (const name of Object.keys(CSS_VARS)) { - document.documentElement.style.setProperty(name, CSS_VARS[name]) - } -} diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts deleted file mode 100644 index a7748c920254..000000000000 --- a/.storybook/webpack.config.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as path from 'path' -import * as webpack from 'webpack' - -export default ({ config }: { config: webpack.Configuration }) => { - if (!config.module || !config.resolve?.extensions) { - throw new Error('unexpected config') - } - - config.module.rules.push({ - test: /\.tsx?$/, - loader: require.resolve('babel-loader'), - options: { - configFile: path.resolve(__dirname, '..', 'babel.config.js'), - }, - }) - config.resolve.extensions.push('.ts', '.tsx') - - // Put our style rules at the beginning so they're processed by the time it - // gets to storybook's style rules. - config.module.rules.unshift({ - test: /\.(css|sass|scss)$/, - use: [ - 'style-loader', - 'css-loader', - { - loader: 'sass-loader', - options: { - sassOptions: { - includePaths: [path.resolve(__dirname, '..', 'node_modules')], - }, - }, - }, - ], - // Make sure Storybook styles get handled by the Storybook config - exclude: /node_modules\/@storybook\//, - }) - - return config -} diff --git a/.stylelintignore b/.stylelintignore index 70d51ce34369..6d63984458c3 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -2,4 +2,5 @@ **/*.jsx **/*.ts **/*.tsx +**/*.svg node_modules/ diff --git a/.tool-versions b/.tool-versions index 878b927a4599..b06a519a042c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,8 @@ -golang 1.14 +golang 1.16.5 yarn 1.22.4 fd 7.4.0 +shfmt 3.2.0 +shellcheck 0.7.1 +kubectl 1.17.3 +github-cli 1.8.0 +nodejs 14.15.4 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f06b18fb911e..09cf3b450c44 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,13 +4,16 @@ "recommendations": [ "EditorConfig.editorconfig", "esbenp.prettier-vscode", - "prisma.vscode-graphql", - "ms-vscode.Go", + "graphql.vscode-graphql", + "golang.go", "exiasr.hadolint", "bierner.markdown-mermaid", - "zignd.html-css-class-completion", + "ecmel.vscode-html-css", "orta.vscode-jest", "dbaeumer.vscode-eslint", + "foxundermoon.shell-format", + "timonwong.shellcheck", + "felixfbecker.css-stacking-contexts", ], "unwantedRecommendations": ["ms-vscode.vscode-typescript-tslint-plugin", "eg2.tslint"], } diff --git a/.vscode/launch.json b/.vscode/launch.json index 5dd9bd4f92d1..499c01b0188c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "request": "launch", "name": "Browser extension E2E Tests", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", - "args": ["--no-timeouts", "--colors", "${workspaceFolder}/browser/src/e2e/github.test"], + "args": ["--no-timeouts", "--colors", "${workspaceFolder}/client/browser/src/e2e/github.test"], "internalConsoleOptions": "openOnSessionStart", "skipFiles": ["/**"], "env": { @@ -36,6 +36,15 @@ ], "smartStep": false, }, + { + "type": "node", + "request": "launch", + "name": "TS-Morph", + "program": "${workspaceFolder}/dev/ts-morph/out/main.js", + "sourceMaps": true, + "skipFiles": ["/**"], + "console": "internalConsole", + }, { "type": "node", "request": "launch", @@ -44,7 +53,8 @@ "program": "${workspaceFolder}/node_modules/gulp/bin/gulp.js", "args": ["webpack"], "env": { - "TS_NODE_COMPILER_OPTIONS": '{"module":"commonjs"}', + // prettier-ignore + "TS_NODE_COMPILER_OPTIONS": "{\"module\":\"commonjs\"}", }, "internalConsoleOptions": "openOnSessionStart", }, diff --git a/.vscode/settings.json b/.vscode/settings.json index 81bf38912293..10662472f18d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,11 +4,14 @@ "**/bower_components": true, "dist": true, "ui/assets": true, - "browser/build": true, + "client/browser/build": true, "**/coverage": true, "**/out": true, "**/__fixtures__/**": true, "**/.cache": true, + "**/.nyc_output": true, + "doc/_resources/assets": true, + "**/.eslintcache": true, }, "files.associations": { "**/dev/critical-config.json": "jsonc", @@ -26,7 +29,10 @@ }, ], "editor.formatOnSave": true, - "go.docsTool": "gogetdoc", + "go.useLanguageServer": true, + "gopls": { + "local": "github.com/sourcegraph/sourcegraph", + }, "jest.pathToJest": "yarn -s test", "jest.showCoverageOnLoad": false, "jest.autoEnable": false, // until we confirm people like it @@ -44,28 +50,21 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": true, }, + "eslint.codeActionsOnSave.mode": "problems", "eslint.options": { "cache": true }, "eslint.workingDirectories": [ - { - "directory": "dev/release", - "changeProcessCWD": true, - }, - { - "directory": "web", - "changeProcessCWD": true, - }, - { - "directory": "browser", - "changeProcessCWD": true, - }, - { - "directory": "shared", - "changeProcessCWD": true, - }, - { - "directory": "cmd/precise-code-intel", - "changeProcessCWD": true, - }, + "./dev/release", + "./client/web", + "./client/browser", + "./client/shared", + "./client/branded", + "./client/wildcard", + "./client/storybook", + "./client/extension-api-types", + "./client/extension-api", ], "go.lintTool": "golangci-lint", + "shellformat.flag": "-i 2 -ci", + "vscode-graphql.useSchemaFileDefinitions": true, + "jest.jestCommandLine": "yarn -s test", } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 8dc85609dfcf..1e7605ccf541 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -14,11 +14,28 @@ "problemMatcher": "$tsc-watch", "isBackground": true, "command": ["node_modules/.bin/tsc"], - "args": ["--build", ".", "--watch", "--incremental"], + "args": ["--build", "tsconfig.all.json", "--watch", "--incremental"], "runOptions": { "runOn": "folderOpen", }, }, + { + "label": "Watch web app", + "detail": "Watch files and build the JavaScript bundle (no development server).", + "type": "npm", + "script": "watch-web", + "problemMatcher": [], + "isBackground": true, + }, + { + "label": "Watch code generation", + "detail": "Watch files and generate types when files are changed", + "type": "npm", + "script": "watch-generate", + "group": "build", + "problemMatcher": [], + "isBackground": true, + }, { "label": "stylelint", "command": "yarn", @@ -46,7 +63,8 @@ "problemMatcher": [], }, { - "label": "eslint all", + "label": "ESLint all", + "detail": "Run ESLint once on all TypeScript projects in parallel to get problems in the problems panel", "dependsOn": [ "eslint:web", "eslint:shared", @@ -62,28 +80,21 @@ "label": "eslint:shared", "type": "npm", "script": "eslint", - "path": "shared/", + "path": "client/shared/", "problemMatcher": ["$eslint-stylish"], }, { "label": "eslint:browser", "type": "npm", "script": "eslint", - "path": "browser/", + "path": "client/browser/", "problemMatcher": ["$eslint-stylish"], }, { "label": "eslint:web", "type": "npm", "script": "eslint", - "path": "web/", - "problemMatcher": ["$eslint-stylish"], - }, - { - "label": "eslint:precise-code-intel", - "type": "npm", - "script": "eslint", - "path": "cmd/precise-code-intel/", + "path": "client/web/", "problemMatcher": ["$eslint-stylish"], }, { @@ -97,7 +108,7 @@ "label": "eslint:extension-api", "type": "npm", "script": "eslint", - "path": "packages/sourcegraph-extension-api/", + "path": "client/extension-api/", "problemMatcher": ["$eslint-stylish"], }, ], diff --git a/.yarnrc b/.yarnrc index 45291c133828..823894ed4d75 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1 +1,4 @@ registry "https://registry.npmjs.org/" + +# Do not warn when adding packages until we need to split packages into different workspaces +--ignore-workspace-root-check true diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0df8a6f98e..bf939034d565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ @@ -9,14 +9,960 @@ All notable changes to Sourcegraph are documented in this file. + + ## Unreleased ### Added -- Users and site administrators can now view a log of their actions/events in the user settings. +- Backend Code Insights GraphQL queries now support arguments `includeRepoRegex` and `excludeRepoRegex` to filter on repository names. [#23256](https://github.com/sourcegraph/sourcegraph/pull/23256) +- Code Insights background queries now process in a priority order backwards through time. This will allow insights to populate concurrently. [#23101](https://github.com/sourcegraph/sourcegraph/pull/23101) +- Operator documentation has been added to the Search Reference sidebar section. [#23116](https://github.com/sourcegraph/sourcegraph/pull/23116) + +### Changed + +- Code Insights backend has moved from the `repo-updater` service to the `worker` service. [#23050](https://github.com/sourcegraph/sourcegraph/pull/23050) +- Code Insights feature flag `DISABLE_CODE_INSIGHTS` environment variable has moved from the `repo-updater` service to the `worker` service. Any users of this flag will need to update their `worker` service configuration to continue using it. [#23050](https://github.com/sourcegraph/sourcegraph/pull/23050) + +### Fixed + +- The search reference will now show matching entries when using the filter input. [#23224](https://github.com/sourcegraph/sourcegraph/pull/23224) +- Graceful termination periods have been added to database deployments. [#3358](https://github.com/sourcegraph/deploy-sourcegraph/pull/3358) & [#477](https://github.com/sourcegraph/deploy-sourcegraph-docker/pull/477) + +### Removed + +- + +## 3.30.1 + +**โš ๏ธ Users are advised to wait on upgrading to any 3.30 release until [#23288](https://github.com/sourcegraph/sourcegraph/issues/23288) is resolved** + +### Fixed + +- An issue where the UI would occasionally display `lsifStore.Ranges: ERROR: relation \"lsif_documentation_mappings\" does not exist (SQLSTATE 42P01)` [#23115](https://github.com/sourcegraph/sourcegraph/pull/23115) +- Fixed a vulnerability in our Postgres Alpine image related to libgcrypt [#23174](https://github.com/sourcegraph/sourcegraph/pull/23174) +- When syncing in streaming mode, repo-updater will now ensure a repo's transaction is committed before notifying gitserver to update that repo. [#23169](https://github.com/sourcegraph/sourcegraph/pull/23169) +- When encountering spurious errors during streaming syncing (like temporary 500s from codehosts), repo-updater will no longer delete all associated repos that weren't seen. Deletion will happen only if there were no errors or if the error was one of "Unauthorized", "Forbidden" or "Account Suspended". [#23171](https://github.com/sourcegraph/sourcegraph/pull/23171) +- External HTTP requests are now automatically retried when appropriate. [#23131](https://github.com/sourcegraph/sourcegraph/pull/23131) + +## 3.30.0 + +**โš ๏ธ Users are advised to wait on upgrading to any 3.30 release until [#23288](https://github.com/sourcegraph/sourcegraph/issues/23288) is resolved** + +### Added + +- Added support for `select:file.directory` in search queries, which returns unique directory paths for results that satisfy the query. [#22449](https://github.com/sourcegraph/sourcegraph/pull/22449) +- An `sg_service` Postgres role has been introduced, as well as an `sg_repo_access_policy` policy on the `repo` table that restricts access to that role. The role that owns the `repo` table will continue to get unrestricted access. [#22303](https://github.com/sourcegraph/sourcegraph/pull/22303) +- Every service that connects to the database (i.e. Postgres) now has a "Database connections" monitoring section in its Grafana dashboard. [#22570](https://github.com/sourcegraph/sourcegraph/pull/22570) +- A new bulk operation to close many changesets at once has been added to Batch Changes. [#22547](https://github.com/sourcegraph/sourcegraph/pull/22547) +- Backend Code Insights will aggregate viewable repositories based on the authenticated user. [#22471](https://github.com/sourcegraph/sourcegraph/pull/22471) +- Added support for highlighting .frugal files as Thrift syntax. +- Added `file:contains.content(regexp)` predicate, which filters only to files that contain matches of the given pattern. [#22666](https://github.com/sourcegraph/sourcegraph/pull/22666) +- Repository syncing is now done in streaming mode by default. Customers with many repositories should notice code host updates much faster, with repo-updater consuming less memory. Using the previous batch mode can be done by setting the `ENABLE_STREAMING_REPOS_SYNCER` environment variable to `false` in `repo-updater`. That environment variable will be deleted in the next release. [#22756](https://github.com/sourcegraph/sourcegraph/pull/22756) +- Enabled the ability to query Batch Changes changesets, changesets stats, and file diff stats for an individual repository via the Sourcegraph GraphQL API. [#22744](https://github.com/sourcegraph/sourcegraph/pull/22744/) +- Added "Groovy" to the initial `lang:` filter suggestions in the search bar. [#22755](https://github.com/sourcegraph/sourcegraph/pull/22755) +- The `lang:` filter suggestions now show all supported, matching languages as the user types a language name. [#22765](https://github.com/sourcegraph/sourcegraph/pull/22765) +- Code Insights can now be grouped into dashboards. [#22215](https://github.com/sourcegraph/sourcegraph/issues/22215) +- Batch Changes changesets can now be [published from the Sourcegraph UI](https://docs.sourcegraph.com/batch_changes/how-tos/publishing_changesets#within-the-ui). [#18277](https://github.com/sourcegraph/sourcegraph/issues/18277) +- The repository page now has a new button to view batch change changesets created in that specific repository, with a badge indicating how many changesets are currently open. [#22804](https://github.com/sourcegraph/sourcegraph/pull/22804) +- Experimental: Search-based code insights can run over all repositories on the instance. To enable, use the feature flag `"experimentalFeatures": { "codeInsightsAllRepos": true }` and tick the checkbox in the insight creation/edit UI. [#22759](https://github.com/sourcegraph/sourcegraph/issues/22759) +- Search References is a new search sidebar section to simplify learning about the available search filters directly where they are used. [#21539](https://github.com/sourcegraph/sourcegraph/issues/21539) + +### Changed + +- Backend Code Insights only fills historical data frames that have changed to reduce the number of searches required. [#22298](https://github.com/sourcegraph/sourcegraph/pull/22298) +- Backend Code Insights displays data points for a fixed 6 months period in 2 week intervals, and will carry observations forward that are missing. [#22298](https://github.com/sourcegraph/sourcegraph/pull/22298) +- Backend Code Insights now aggregate over 26 weeks instead of 6 months. [#22527](https://github.com/sourcegraph/sourcegraph/pull/22527) +- Search queries now disallow specifying `rev:` without `repo:`. Note that to search across potentially multiple revisions, a query like `repo:.* rev:` remains valid. [#22705](https://github.com/sourcegraph/sourcegraph/pull/22705) +- The extensions status bar on diff pages has been redesigned and now shows information for both the base and head commits. [#22123](https://github.com/sourcegraph/sourcegraph/pull/22123/files) +- The `applyBatchChange` and `createBatchChange` mutations now accept an optional `publicationStates` argument to set the publication state of specific changesets within the batch change. [#22485](https://github.com/sourcegraph/sourcegraph/pull/22485) and [#22854](https://github.com/sourcegraph/sourcegraph/pull/22854) +- Search queries now return up to 80 suggested filters. Previously we returned up to 24. [#22863](https://github.com/sourcegraph/sourcegraph/pull/22863) +- GitHub code host connections can now include `repositoryQuery` entries that match more than 1000 repositories from the GitHub search API without requiring the previously documented work-around of splitting the query up with `created:` qualifiers, which is now done automatically. [#2562](https://github.com/sourcegraph/sourcegraph/issues/2562) + +### Fixed + +- The Batch Changes user and site credential encryption migrators added in Sourcegraph 3.28 could report zero progress when encryption was disabled, even though they had nothing to do. This has been fixed, and progress will now be correctly reported. [#22277](https://github.com/sourcegraph/sourcegraph/issues/22277) +- Listing Github Entreprise org repos now returns internal repos as well. [#22339](https://github.com/sourcegraph/sourcegraph/pull/22339) +- Jaeger works in Docker-compose deployments again. [#22691](https://github.com/sourcegraph/sourcegraph/pull/22691) +- A bug where the pattern `)` makes the browser unresponsive. [#22738](https://github.com/sourcegraph/sourcegraph/pull/22738) +- An issue where using `select:repo` in conjunction with `and` patterns did not yield expected repo results. [#22743](https://github.com/sourcegraph/sourcegraph/pull/22743) +- The `isLocked` and `isDisabled` fields of GitHub repositories are now fetched correctly from the GraphQL API of GitHub Enterprise instances. Users that rely on the `repos` config in GitHub code host connections should update so that locked and disabled repositories defined in that list are actually skipped. [#22788](https://github.com/sourcegraph/sourcegraph/pull/22788) +- Homepage no longer fails to load if there are invalid entries in user's search history. [#22857](https://github.com/sourcegraph/sourcegraph/pull/22857) +- An issue where regexp query highlighting in the search bar would render incorrectly on Firefox. [#23043](https://github.com/sourcegraph/sourcegraph/pull/23043) +- Code intelligence uploads and indexes are restricted to only site-admins. It was read-only for any user. [#22890](https://github.com/sourcegraph/sourcegraph/pull/22890) +- Daily usage statistics are restricted to only site-admins. It was read-only for any user. [#23026](https://github.com/sourcegraph/sourcegraph/pull/23026) +- Ephemeral storage requests now match their cache size requests for Kubernetes deployments. [#2953](https://github.com/sourcegraph/deploy-sourcegraph/pull/2953) + +### Removed + +- The experimental paginated search feature (the `stable:` keyword) has been removed, to be replaced with streaming search. [#22428](https://github.com/sourcegraph/sourcegraph/pull/22428) +- The experimental extensions view page has been removed. [#22565](https://github.com/sourcegraph/sourcegraph/pull/22565) +- A search query diagnostic that previously warned the user when quotes are interpreted literally has been removed. The literal meaning has been Sourcegraph's default search behavior for some time now. [#22892](https://github.com/sourcegraph/sourcegraph/pull/22892) +- The old batch repository syncer was removed and can no longer be activated by setting `ENABLE_STREAMING_REPOS_SYNCER=false`. [#22949](https://github.com/sourcegraph/sourcegraph/pull/22949) +- Non-root overlays were removed for `deploy-sourcegraph` in favor of using `non-privileged`. [#3404](https://github.com/sourcegraph/deploy-sourcegraph/pull/3404) + +### API docs (experimental) + +API docs is a new experimental feature of Sourcegraph ([learn more](https://docs.sourcegraph.com/code_intelligence/apidocs)). It is enabled by default in Sourcegraph 3.30.0. + +- API docs is enabled by default in Sourcegraph 3.30.0. It can be disabled by adding `"apiDocs": false` to the `experimentalFeatures` section of user settings. +- The API docs landing page now indicates what API docs are and provide more info. +- The API docs landing page now represents the code in the repository root, instead of an empty page. +- Pages now correctly indicate it is an experimental feature, and include a feedback widget. +- Subpages linked via the sidebar are now rendered much better, and have an expandable section. +- Symbols in documentation now have distinct icons for e.g. functions/vars/consts/etc. +- Symbols are now sorted in exported-first, alphabetical order. +- Repositories without LSIF documentation data now show a friendly error page indicating what languages are supported, how to set it up, etc. +- API docs can now distinguish between different types of symbols, tests, examples, benchmarks, etc. and whether symbols are public/private - to support filtering in the future. +- Only public/exported symbols are included by default for now. +- URL paths for Go packages are now friendlier, e.g. `/-/docs/cmd/frontend/auth` instead of `/-/docs/cmd-frontend-auth`. +- URLs are now formatted by the language indexer, in a way that makes sense for the language, e.g. `#Mocks.CreateUserAndSave` instead of `#ypeMocksCreateUserAndSave` for a Go method `CreateUserAndSave` on type `Mocks`. +- Go blank identifier assignments `var _ = ...` are no longer incorrectly included. +- Go symbols defined within functions, e.g. a `var` inside a `func` scope are no longer incorrectly included. +- `Functions`, `Variables`, and other top-level sections are no longer rendered empty if there are none in that section. +- A new test suite for LSIF indexers implementing the Sourcegraph documentation extension to LSIF [is available](https://github.com/sourcegraph/lsif-static-doc). +- We now emit the LSIF data needed to in the future support "Jump to API docs" from code views, "View code" from API docs, usage examples in API docs, and search indexing. +- Various UI style issues, color contrast issues, etc. have been fixed. +- Major improvements to the GraphQL APIs for API documentation. + +## 3.29.0 + +### Added + +- Code Insights queries can now run concurrently up to a limit set by the `insights.query.worker.concurrency` site config. [#21219](https://github.com/sourcegraph/sourcegraph/pull/21219) +- Code Insights workers now support a rate limit for query execution and historical data frame analysis using the `insights.query.worker.rateLimit` and `insights.historical.worker.rateLimit` site configurations. [#21533](https://github.com/sourcegraph/sourcegraph/pull/21533) +- The GraphQL `Site` `SettingsSubject` type now has an `allowSiteSettingsEdits` field to allow clients to determine whether the instance uses the `GLOBAL_SETTINGS_FILE` environment variable. [#21827](https://github.com/sourcegraph/sourcegraph/pull/21827) +- The Code Insights creation UI now remembers previously filled-in field values when returning to the form after having navigated away. [#21744](https://github.com/sourcegraph/sourcegraph/pull/21744) +- The Code Insights creation UI now shows autosuggestions for the repository field. [#21699](https://github.com/sourcegraph/sourcegraph/pull/21699) +- A new bulk operation to retry many changesets at once has been added to Batch Changes. [#21173](https://github.com/sourcegraph/sourcegraph/pull/21173) +- A `security_event_logs` database table has been added in support of upcoming security-related efforts. [#21949](https://github.com/sourcegraph/sourcegraph/pull/21949) +- Added featured Sourcegraph extensions query to the GraphQL API, as well as a section in the extension registry to display featured extensions. [#21665](https://github.com/sourcegraph/sourcegraph/pull/21665) +- The search page now has a `create insight` button to create search-based insight based on your search query [#21943](https://github.com/sourcegraph/sourcegraph/pull/21943) +- Added support for Terraform syntax highlighting. [#22040](https://github.com/sourcegraph/sourcegraph/pull/22040) +- A new bulk operation to merge many changesets at once has been added to Batch Changes. [#21959](https://github.com/sourcegraph/sourcegraph/pull/21959) +- Pings include aggregated usage for the Code Insights creation UI, organization visible insight count per insight type, and insight step size in days. [#21671](https://github.com/sourcegraph/sourcegraph/pull/21671) +- Search-based insight creation UI now supports `count:` filter in data series query input. [#22049](https://github.com/sourcegraph/sourcegraph/pull/22049) +- Code Insights background workers will now index commits in a new table `commit_index` for future optimization efforts. [#21994](https://github.com/sourcegraph/sourcegraph/pull/21994) +- The creation UI for search-based insights now supports the `count:` filter in the data series query input. [#22049](https://github.com/sourcegraph/sourcegraph/pull/22049) +- A new service, `worker`, has been introduced to run background jobs that were previously run in the frontend. See the [deployment documentation](https://docs.sourcegraph.com/admin/workers) for additional details. [#21768](https://github.com/sourcegraph/sourcegraph/pull/21768) + +### Changed + +- SSH public keys generated to access code hosts with batch changes now include a comment indicating they originated from Sourcegraph. [#20523](https://github.com/sourcegraph/sourcegraph/issues/20523) +- The copy query button is now permanently enabled and `experimentalFeatures.copyQueryButton` setting has been deprecated. [#21364](https://github.com/sourcegraph/sourcegraph/pull/21364) +- Search streaming is now permanently enabled and `experimentalFeatures.searchStreaming` setting has been deprecated. [#21522](https://github.com/sourcegraph/sourcegraph/pull/21522) +- Pings removes the collection of aggregate search filter usage counts and adds a smaller set of aggregate usage counts for query operators, predicates, and pattern counts. [#21320](https://github.com/sourcegraph/sourcegraph/pull/21320) +- Sourcegraph will now refuse to start if there are unfinished [out-of-band-migrations](https://docs.sourcegraph.com/admin/migrations) that are deprecated in the current version. See the [upgrade documentation](https://docs.sourcegraph.com/admin/updates) for changes to the upgrade process. [#20967](https://github.com/sourcegraph/sourcegraph/pull/20967) +- Code Insight pages now have new URLs [#21856](https://github.com/sourcegraph/sourcegraph/pull/21856) +- We are proud to bring you [an entirely new visual design for the Sourcegraph UI](https://about.sourcegraph.com/blog/introducing-sourcegraphs-new-ui/). We think youโ€™ll find this new design improves your experience and sets the stage for some incredible features to come. Some of the highlights include: + + - **Refined search results:** The redesigned search bar provides more space for expressive queries, and the new results sidebar helps to discover search syntax without referencing documentation. + - **Improved focus on code:** Weโ€™ve reduced non-essential UI elements to provide greater focus on the code itself, and positioned the most important items so theyโ€™re unobtrusive and located exactly where they are needed. + - **Improved layouts:** Weโ€™ve improved pages like diff views to make them easier to use and to help find information quickly. + - **New navigation:** A new global navigation provides immediate discoverability and access to current and future functionality. + - **Promoting extensibility:** We've brought the extension registry back to the main navigation and improved its design and navigation. + + With bulk of the redesign complete, future releases will include more improvements and refinements. + +### Fixed + +- Stricter validation of structural search queries. The `type:` parameter is not supported for structural searches and returns an appropriate alert. [#21487](https://github.com/sourcegraph/sourcegraph/pull/21487) +- Batch changeset specs that are not attached to changesets will no longer prematurely expire before the batch specs that they are associated with. [#21678](https://github.com/sourcegraph/sourcegraph/pull/21678) +- The Y-axis of Code Insights line charts no longer start at a negative value. [#22018](https://github.com/sourcegraph/sourcegraph/pull/22018) +- Correctly handle field aliases in the query (like `r:` versus `repo:`) when used with `contains` predicates. [#22105](https://github.com/sourcegraph/sourcegraph/pull/22105) +- Running a code insight over a timeframe when the repository didn't yet exist doesn't break the entire insight anymore. [#21288](https://github.com/sourcegraph/sourcegraph/pull/21288) + +### Removed + +- The deprecated GraphQL `icon` field on CommitSearchResult and Repository was removed. [#21310](https://github.com/sourcegraph/sourcegraph/pull/21310) +- The undocumented `index` filter was removed from search type-ahead suggestions. [#18806](https://github.com/sourcegraph/sourcegraph/issues/18806) +- Code host connection tokens aren't used for creating changesets anymore when the user is site admin and no credential has been specified. [#16814](https://github.com/sourcegraph/sourcegraph/issues/16814) + +## 3.28.0 + +### Added + +- Added `select:commit.diff.added` and `select:commit.diff.removed` for `type:diff` search queries. These selectors return commit diffs only if a pattern matches in `added` (respespectively, `removed`) lines. [#20328](https://github.com/sourcegraph/sourcegraph/pull/20328) +- Additional language autocompletions for the `lang:` filter in the search bar. [#20535](https://github.com/sourcegraph/sourcegraph/pull/20535) +- Steps in batch specs can now have an `if:` attribute to enable conditional execution of different steps. [#20701](https://github.com/sourcegraph/sourcegraph/pull/20701) +- Extensions can now log messages through `sourcegraph.app.log` to aid debugging user issues. [#20474](https://github.com/sourcegraph/sourcegraph/pull/20474) +- Bulk comments on many changesets are now available in Batch Changes. [#20361](https://github.com/sourcegraph/sourcegraph/pull/20361) +- Batch specs are now viewable when previewing changesets. [#19534](https://github.com/sourcegraph/sourcegraph/issues/19534) +- Added a new UI for creating code insights. [#20212](https://github.com/sourcegraph/sourcegraph/issues/20212) + +### Changed + +- User and site credentials used in Batch Changes are now encrypted in the database if encryption is enabled with the `encryption.keys` config. [#19570](https://github.com/sourcegraph/sourcegraph/issues/19570) +- All Sourcegraph images within [deploy-sourcegraph](https://github.com/sourcegraph/deploy-sourcegraph) now specify the registry. Thanks! @k24dizzle [#2901](https://github.com/sourcegraph/deploy-sourcegraph/pull/2901). +- Default reviewers are now added to Bitbucket Server PRs opened by Batch Changes. [#20551](https://github.com/sourcegraph/sourcegraph/pull/20551) +- The default memory requirements for the `redis-*` containers have been raised by 1GB (to a new total of 7GB). This change allows Redis to properly run its key-eviction routines (when under memory pressure) without getting killed by the host machine. This affects both the docker-compose and Kubernetes deployments. [sourcegraph/deploy-sourcegraph-docker#373](https://github.com/sourcegraph/deploy-sourcegraph-docker/pull/373) and [sourcegraph/deploy-sourcegraph#2898](https://github.com/sourcegraph/deploy-sourcegraph/pull/2898) +- Only site admins can now list users on an instance. [#20619](https://github.com/sourcegraph/sourcegraph/pull/20619) +- Repository permissions can now be enabled for site admins via the `authz.enforceForSiteAdmins` setting. [#20674](https://github.com/sourcegraph/sourcegraph/pull/20674) +- Site admins can no longer view user added code host configuration. [#20851](https://github.com/sourcegraph/sourcegraph/pull/20851) +- Site admins cannot add access tokens for any user by default. [#20988](https://github.com/sourcegraph/sourcegraph/pull/20988) +- Our namespaced overlays now only scrape container metrics within that namespace. [#2969](https://github.com/sourcegraph/deploy-sourcegraph/pull/2969) +- The extension registry main page has a new visual design that better conveys the most useful information about extensions, and individual extension pages have better information architecture. [#20822](https://github.com/sourcegraph/sourcegraph/pull/20822) + +### Fixed + +- Search returned inconsistent result counts when a `count:` limit was not specified. +- Indexed search failed when the `master` branch needed indexing but was not the default. [#20260](https://github.com/sourcegraph/sourcegraph/pull/20260) +- `repo:contains(...)` built-in did not respect parameters that affect repo filtering (e.g., `repogroup`, `fork`). It now respects these. [#20339](https://github.com/sourcegraph/sourcegraph/pull/20339) +- An issue where duplicate results would render for certain `or`-expressions. [#20480](https://github.com/sourcegraph/sourcegraph/pull/20480) +- Issue where the search query bar suggests that some `lang` values are not valid. [#20534](https://github.com/sourcegraph/sourcegraph/pull/20534) +- Pull request event webhooks received from GitHub with unexpected actions no longer cause panics. [#20571](https://github.com/sourcegraph/sourcegraph/pull/20571) +- Repository search patterns like `^repo/(prefix-suffix|prefix)$` now correctly match both `repo/prefix-suffix` and `repo/prefix`. [#20389](https://github.com/sourcegraph/sourcegraph/issues/20389) +- Ephemeral storage requests and limits now match the default cache size to avoid Symbols pods being evicted. The symbols pod now requires 10GB of ephemeral space as a minimum to scheduled. [#2369](https://github.com/sourcegraph/deploy-sourcegraph/pull/2369) +- Minor query syntax highlighting bug for `repo:contains` predicate. [#21038](https://github.com/sourcegraph/sourcegraph/pull/21038) +- An issue causing diff and commit results with file filters to return invalid results. [#21039](https://github.com/sourcegraph/sourcegraph/pull/21039) +- All databases now have the Kubernetes Quality of Service class of 'Guaranteed' which should reduce the chance of them + being evicted during NodePressure events. [#2900](https://github.com/sourcegraph/deploy-sourcegraph/pull/2900) +- An issue causing diff views to display without syntax highlighting [#21160](https://github.com/sourcegraph/sourcegraph/pull/21160) + +### Removed + +- The deprecated `SetRepositoryEnabled` mutation was removed. [#21044](https://github.com/sourcegraph/sourcegraph/pull/21044) + +## 3.27.5 + +### Fixed + +- Fix scp style VCS url parsing. [#20799](https://github.com/sourcegraph/sourcegraph/pull/20799) + +## 3.27.4 + +### Fixed + +- Fixed an issue related to Gitolite repos with `@` being prepended with a `?`. [#20297](https://github.com/sourcegraph/sourcegraph/pull/20297) +- Add missing return from handler when DisableAutoGitUpdates is true. [#20451](https://github.com/sourcegraph/sourcegraph/pull/20451) + +## 3.27.3 + +### Fixed + +- Pushing batch changes to Bitbucket Server code hosts over SSH was broken in 3.27.0, and has been fixed. [#20324](https://github.com/sourcegraph/sourcegraph/issues/20324) + +## 3.27.2 + +### Fixed + +- Fixed an issue with our release tooling that was preventing all images from being tagged with the correct version. + All sourcegraph images have the proper release version now. + +## 3.27.1 + +### Fixed + +- Indexed search failed when the `master` branch needed indexing but was not the default. [#20260](https://github.com/sourcegraph/sourcegraph/pull/20260) +- Fixed a regression that caused "other" code hosts urls to not be built correctly which prevents code to be cloned / updated in 3.27.0. This change will provoke some cloning errors on repositories that are already sync'ed, until the next code host sync. [#20258](https://github.com/sourcegraph/sourcegraph/pull/20258) + +## 3.27.0 + +### Added + +- `count:` now supports "all" as value. Queries with `count:all` will return up to 999999 results. [#19756](https://github.com/sourcegraph/sourcegraph/pull/19756) +- Credentials for Batch Changes are now validated when adding them. [#19602](https://github.com/sourcegraph/sourcegraph/pull/19602) +- Batch Changes now ignore repositories that contain a `.batchignore` file. [#19877](https://github.com/sourcegraph/sourcegraph/pull/19877) and [src-cli#509](https://github.com/sourcegraph/src-cli/pull/509) +- Side-by-side diff for commit visualization. [#19553](https://github.com/sourcegraph/sourcegraph/pull/19553) +- The site configuration now supports defining batch change rollout windows, which can be used to slow or disable pushing changesets at particular times of day or days of the week. [#19796](https://github.com/sourcegraph/sourcegraph/pull/19796), [#19797](https://github.com/sourcegraph/sourcegraph/pull/19797), and [#19951](https://github.com/sourcegraph/sourcegraph/pull/19951). +- Search functionality via built-in `contains` predicate: `repo:contains(...)`, `repo:contains.file(...)`, `repo:contains.content(...)`, repo:contains.commit.after(...)`. [#18584](https://github.com/sourcegraph/sourcegraph/issues/18584) +- Database encryption, external service config & user auth data can now be encrypted in the database using the `encryption.keys` config. See [the docs](https://docs.sourcegraph.com/admin/encryption) for more info. +- Repositories that gitserver fails to clone or fetch are now gradually moved to the back of the background update queue instead of remaining at the front. [#20204](https://github.com/sourcegraph/sourcegraph/pull/20204) +- The new `disableAutoCodeHostSyncs` setting allows site admins to disable any periodic background syncing of configured code host connections. That includes syncing of repository metadata (i.e. not git updates, use `disableAutoGitUpdates` for that), permissions and batch changes changesets, but may include other data we'd sync from the code host API in the future. + +### Changed + +- Bumped the minimum supported version of Postgres from `9.6` to `12`. The upgrade procedure is mostly automated for existing deployments, but may require action if using the single-container deployment or an external database. See the [upgrade documentation](https://docs.sourcegraph.com/admin/updates) for your deployment type for detailed instructions. +- Changesets in batch changes will now be marked as archived instead of being detached when a new batch spec that doesn't include the changesets is applied. Once they're archived users can manually detach them in the UI. [#19527](https://github.com/sourcegraph/sourcegraph/pull/19527) +- The default replica count on `sourcegraph-frontend` and `precise-code-intel-worker` for Kubernetes has changed from `1` -> `2`. +- Changes to code monitor trigger search queries [#19680](https://github.com/sourcegraph/sourcegraph/pull/19680) + - A `repo:` filter is now required. This is due to an existing limitations where only 50 repositories can be searched at a time, so using a `repo:` filter makes sure the right code is being searched. Any existing code monitor without `repo:` in the trigger query will continue to work (with the limitation that not all repositories will be searched) but will require a `repo:` filter to be added when making any changes to it. + - A `patternType` filter is no longer required. `patternType:literal` will be added to a code monitor query if not specified. + - Added a new checklist UI to make it more intuitive to create code monitor trigger queries. +- Deprecated the GraphQL `icon` field on `GenericSearchResultInterface`. It will be removed in a future release. [#20028](https://github.com/sourcegraph/sourcegraph/pull/20028/files) +- Creating changesets through Batch Changes as a site-admin without configured Batch Changes credentials has been deprecated. Please configure user or global credentials before Sourcegraph 3.29 to not experience any interruptions in changeset creation. [#20143](https://github.com/sourcegraph/sourcegraph/pull/20143) +- Deprecated the GraphQL `limitHit` field on `LineMatch`. It will be removed in a future release. [#20164](https://github.com/sourcegraph/sourcegraph/pull/20164) + +### Fixed + +- A regression caused by search onboarding tour logic to never focus input in the search bar on the homepage. Input now focuses on the homepage if the search tour isn't in effect. [#19678](https://github.com/sourcegraph/sourcegraph/pull/19678) +- New changes of a Perforce depot will now be reflected in `master` branch after the initial clone. [#19718](https://github.com/sourcegraph/sourcegraph/pull/19718) +- Gitolite and Other type code host connection configuration can be correctly displayed. [#19976](https://github.com/sourcegraph/sourcegraph/pull/19976) +- Fixed a regression that caused user and code host limits to be ignored. [#20089](https://github.com/sourcegraph/sourcegraph/pull/20089) +- A regression where incorrect query highlighting happens for certain quoted values. [#20110](https://github.com/sourcegraph/sourcegraph/pull/20110) +- We now respect the `disableAutoGitUpdates` setting when cloning or fetching repos on demand and during cleanup tasks that may re-clone old repos. [#20194](https://github.com/sourcegraph/sourcegraph/pull/20194) + +## 3.26.3 + +### Fixed + +- Setting `gitMaxCodehostRequestsPerSecond` to `0` now actually blocks all Git operations happening on the gitserver. [#19716](https://github.com/sourcegraph/sourcegraph/pull/19716) + +## 3.26.2 + +### Fixed + +- Our indexed search logic now correctly handles de-duplication of search results across multiple replicas. [#19743](https://github.com/sourcegraph/sourcegraph/pull/19743) + +## 3.26.1 + +### Added + +- Experimental: Sync permissions of Perforce depots through the Sourcegraph UI. To enable, use the feature flag `"experimentalFeatures": { "perforce": "enabled" }`. For more information, see [how to enable permissions for your Perforce depots](https://docs.sourcegraph.com/admin/repo/perforce). [#16705](https://github.com/sourcegraph/sourcegraph/issues/16705) +- Added support for user email headers in the HTTP auth proxy. See [HTTP Auth Proxy docs](https://docs.sourcegraph.com/admin/auth#http-authentication-proxies) for more information. +- Ignore locked and disabled GitHub Enterprise repositories. [#19500](https://github.com/sourcegraph/sourcegraph/pull/19500) +- Remote code host git operations (such as `clone` or `ls-remote`) can now be rate limited beyond concurrency (which was already possible with `gitMaxConcurrentClones`). Set `gitMaxCodehostRequestsPerSecond` in site config to control the maximum rate of these operations per git-server instance. [#19504](https://github.com/sourcegraph/sourcegraph/pull/19504) + +### Changed + +- + +### Fixed + +- Commit search returning duplicate commits. [#19460](https://github.com/sourcegraph/sourcegraph/pull/19460) +- Clicking the Code Monitoring tab tries to take users to a non-existent repo. [#19525](https://github.com/sourcegraph/sourcegraph/pull/19525) +- Diff and commit search not highlighting search terms correctly for some files. [#19543](https://github.com/sourcegraph/sourcegraph/pull/19543), [#19639](https://github.com/sourcegraph/sourcegraph/pull/19639) +- File actions weren't appearing on large window sizes in Firefox and Safari. [#19380](https://github.com/sourcegraph/sourcegraph/pull/19380) + +### Removed + +- + +## 3.26.0 + +### Added + +- Searches are streamed into Sourcegraph by default. [#19300](https://github.com/sourcegraph/sourcegraph/pull/19300) + - This gives a faster time to first result. + - Several heuristics around result limits have been improved. You should see more consistent result counts now. + - Can be disabled with the setting `experimentalFeatures.streamingSearch`. +- Opsgenie API keys can now be added via an environment variable. [#18662](https://github.com/sourcegraph/sourcegraph/pull/18662) +- It's now possible to control where code insights are displayed through the boolean settings `insights.displayLocation.homepage`, `insights.displayLocation.insightsPage` and `insights.displayLocation.directory`. [#18979](https://github.com/sourcegraph/sourcegraph/pull/18979) +- Users can now create changesets in batch changes on repositories that are cloned using SSH. [#16888](https://github.com/sourcegraph/sourcegraph/issues/16888) +- Syntax highlighting for Elixir, Elm, REG, Julia, Move, Nix, Puppet, VimL, Coq. [#19282](https://github.com/sourcegraph/sourcegraph/pull/19282) +- `BUILD.in` files are now highlighted as Bazel/Starlark build files. Thanks to @jjwon0 [#19282](https://github.com/sourcegraph/sourcegraph/pull/19282) +- `*.pyst` and `*.pyst-include` are now highlighted as Python files. Thanks to @jjwon0 [#19282](https://github.com/sourcegraph/sourcegraph/pull/19282) +- The code monitoring feature flag is now enabled by default. [#19295](https://github.com/sourcegraph/sourcegraph/pull/19295) +- New query field `select` enables returning only results of the desired type. See [documentation](https://docs.sourcegraph.com/code_search/reference/language#select) for details. [#19236](https://github.com/sourcegraph/sourcegraph/pull/19236) +- Syntax highlighting for Elixer, Elm, REG, Julia, Move, Nix, Puppet, VimL thanks to @rvantonder +- `BUILD.in` files are now highlighted as Bazel/Starlark build files. Thanks to @jjwon0 +- `*.pyst` and `*.pyst-include` are now highlighted as Python files. Thanks to @jjwon0 +- Added a `search.defaultCaseSensitive` setting to configure whether query patterns should be treated case sensitivitely by default. + +### Changed + +- Campaigns have been renamed to Batch Changes! See [#18771](https://github.com/sourcegraph/sourcegraph/issues/18771) for a detailed log on what has been renamed. + - A new [Sourcegraph CLI](https://docs.sourcegraph.com/cli) version will use `src batch [preview|apply]` commands, while keeping the old ones working to be used with older Sourcegraph versions. + - Old URLs in the application and in the documentation will redirect. + - GraphQL API entities with "campaign" in their name have been deprecated and have new Batch Changes counterparts: + - Deprecated GraphQL entities: `CampaignState`, `Campaign`, `CampaignSpec`, `CampaignConnection`, `CampaignsCodeHostConnection`, `CampaignsCodeHost`, `CampaignsCredential`, `CampaignDescription` + - Deprecated GraphQL mutations: `createCampaign`, `applyCampaign`, `moveCampaign`, `closeCampaign`, `deleteCampaign`, `createCampaignSpec`, `createCampaignsCredential`, `deleteCampaignsCredential` + - Deprecated GraphQL queries: `Org.campaigns`, `User.campaigns`, `User.campaignsCodeHosts`, `camapigns`, `campaign` + - Site settings with `campaigns` in their name have been replaced with equivalent `batchChanges` settings. +- A repository's `remote.origin.url` is not stored on gitserver disk anymore. Note: if you use the experimental feature `customGitFetch` your setting may need to be updated to specify the remote URL. [#18535](https://github.com/sourcegraph/sourcegraph/pull/18535) +- Repositories and files containing spaces will now render with escaped spaces in the query bar rather than being + quoted. [#18642](https://github.com/sourcegraph/sourcegraph/pull/18642) +- Sourcegraph is now built with Go 1.16. [#18447](https://github.com/sourcegraph/sourcegraph/pull/18447) +- Cursor hover information in the search query bar will now display after 150ms (previously 0ms). [#18916](https://github.com/sourcegraph/sourcegraph/pull/18916) +- The `repo.cloned` column is deprecated in favour of `gitserver_repos.clone_status`. It will be removed in a subsequent release. +- Precision class indicators have been improved for code intelligence results in both the hover overlay as well as the definition and references locations panel. [#18843](https://github.com/sourcegraph/sourcegraph/pull/18843) +- Pings now contain added, aggregated campaigns usage data: aggregate counts of unique monthly users and Weekly campaign and changesets counts for campaign cohorts created in the last 12 months. [#18604](https://github.com/sourcegraph/sourcegraph/pull/18604) + +### Fixed + +- Auto complete suggestions for repositories and files containing spaces will now be automatically escaped when accepting the suggestion. [#18635](https://github.com/sourcegraph/sourcegraph/issues/18635) +- An issue causing repository results containing spaces to not be clickable in some cases. [#18668](https://github.com/sourcegraph/sourcegraph/pull/18668) +- Closing a batch change now correctly closes the entailed changesets, when requested by the user. [#18957](https://github.com/sourcegraph/sourcegraph/pull/18957) +- TypesScript highlighting bug. [#15930](https://github.com/sourcegraph/sourcegraph/issues/15930) +- The number of shards is now reported accurately in Site Admin > Repository Status > Settings > Indexing. [#19265](https://github.com/sourcegraph/sourcegraph/pull/19265) + +### Removed + +- Removed the deprecated GraphQL fields `SearchResults.repositoriesSearched` and `SearchResults.indexedRepositoriesSearched`. +- Removed the deprecated search field `max` +- Removed the `experimentalFeatures.showBadgeAttachments` setting + +## 3.25.2 + +### Fixed + +- A security vulnerability with in the authentication workflow has been fixed. [#18686](https://github.com/sourcegraph/sourcegraph/pull/18686) + +## 3.25.1 + +### Added + +- Experimental: Sync Perforce depots directly through the Sourcegraph UI. To enable, use the feature flag `"experimentalFeatures": { "perforce": "enabled" }`. For more information, see [how to add your Perforce depots](https://docs.sourcegraph.com/admin/repo/perforce). [#16703](https://github.com/sourcegraph/sourcegraph/issues/16703) + +## 3.25.0 + +**IMPORTANT** Sourcegraph now uses Go 1.15. This may break AWS RDS database connections with older x509 certificates. Please follow the Amazon [docs](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL-certificate-rotation.html) to rotate your certificate. + +### Added + +- New site config option `"log": { "sentry": { "backendDSN": "" } }` to use a separate Sentry project for backend errors. [#17363](https://github.com/sourcegraph/sourcegraph/pull/17363) +- Structural search now supports searching indexed branches other than default. [#17726](https://github.com/sourcegraph/sourcegraph/pull/17726) +- Structural search now supports searching unindexed revisions. [#17967](https://github.com/sourcegraph/sourcegraph/pull/17967) +- New site config option `"allowSignup"` for SAML authentication to determine if automatically create new users is allowed. [#17989](https://github.com/sourcegraph/sourcegraph/pull/17989) +- Experimental: The webapp can now stream search results to the client, improving search performance. To enable it, add `{ "experimentalFeatures": { "searchStreaming": true } }` in user settings. [#16097](https://github.com/sourcegraph/sourcegraph/pull/16097) +- New product research sign-up page. This can be accessed by all users in their user settings. [#17945](https://github.com/sourcegraph/sourcegraph/pull/17945) +- New site config option `productResearchPage.enabled` to disable access to the product research sign-up page. [#17945](https://github.com/sourcegraph/sourcegraph/pull/17945) +- Pings now contain Sourcegraph extension activation statistics. [#16421](https://github.com/sourcegraph/sourcegraph/pull/16421) +- Pings now contain aggregate Sourcegraph extension activation statistics: the number of users and number of activations per (public) extension per week, and the number of total extension users per week and average extensions activated per user. [#16421](https://github.com/sourcegraph/sourcegraph/pull/16421) +- Pings now contain aggregate code insights usage data: total insight views, interactions, edits, creations, removals, and counts of unique users that view and create insights. [#16421](https://github.com/sourcegraph/sourcegraph/pull/17805) +- When previewing a campaign spec, changesets can be filtered by current state or the action(s) to be performed. [#16960](https://github.com/sourcegraph/sourcegraph/issues/16960) + +### Changed + +- Alert solutions links included in [monitoring alerts](https://docs.sourcegraph.com/admin/observability/alerting) now link to the relevant documentation version. [#17828](https://github.com/sourcegraph/sourcegraph/pull/17828) +- Secrets (such as access tokens and passwords) will now appear as REDACTED when editing external service config, and in graphql API responses. [#17261](https://github.com/sourcegraph/sourcegraph/issues/17261) +- Sourcegraph is now built with Go 1.15 + - Go `1.15` introduced changes to SSL/TLS connection validation which requires certificates to include a `SAN`. This field was not included in older certificates and clients relied on the `CN` field. You might see an error like `x509: certificate relies on legacy Common Name field`. We recommend that customers using Sourcegraph with an external database and connecting to it using SSL/TLS check whether the certificate is up to date. + - RDS Customers please reference [AWS' documentation on updating the SSL/TLS certificate](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL-certificate-rotation.html). +- Search results on `.rs` files now recommend `lang:rust` instead of `lang:renderscript` as a filter. [#18316](https://github.com/sourcegraph/sourcegraph/pull/18316) +- Campaigns users creating Personal Access Tokens on GitHub are now asked to request the `user:email` scope in addition to the [previous scopes](https://docs.sourcegraph.com/@3.24/admin/external_service/github#github-api-token-and-access). This will be used in a future Sourcegraph release to display more fine-grained information on the progress of pull requests. [#17555](https://github.com/sourcegraph/sourcegraph/issues/17555) + +### Fixed + +- Fixes an issue that prevented the hard deletion of a user if they had saved searches. [#17461](https://github.com/sourcegraph/sourcegraph/pull/17461) +- Fixes an issue that caused some missing results for `type:commit` when a pattern was used instead of the `message` field. [#17490](https://github.com/sourcegraph/sourcegraph/pull/17490#issuecomment-764004758) +- Fixes an issue where cAdvisor-based alerts would not fire correctly for services with multiple replicas. [#17600](https://github.com/sourcegraph/sourcegraph/pull/17600) +- Significantly improved performance of structural search on monorepo deployments [#17846](https://github.com/sourcegraph/sourcegraph/pull/17846) +- Fixes an issue where upgrades on Kubernetes may fail due to null environment variable lists in deployment manifests [#1781](https://github.com/sourcegraph/deploy-sourcegraph/pull/1781) +- Fixes an issue where counts on search filters were inaccurate. [#18158](https://github.com/sourcegraph/sourcegraph/pull/18158) +- Fixes services with emptyDir volumes being evicted from nodes. [#1852](https://github.com/sourcegraph/deploy-sourcegraph/pull/1852) + +### Removed + +- Removed the `search.migrateParser` setting. As of 3.20 and onward, a new parser processes search queries by default. Previously, `search.migrateParser` was available to enable the legacy parser. Enabling/disabling this setting now no longer has any effect. [#17344](https://github.com/sourcegraph/sourcegraph/pull/17344) + +## 3.24.1 + +### Fixed + +- Fixes an issue that SAML is not able to proceed with the error `Expected Enveloped and C14N transforms`. [#13032](https://github.com/sourcegraph/sourcegraph/issues/13032) + +## 3.24.0 + +### Added + +- Panels in the [Sourcegraph monitoring dashboards](https://docs.sourcegraph.com/admin/observability/metrics#grafana) now: + - include links to relevant alerts documentation and the new [monitoring dashboards reference](https://docs.sourcegraph.com/admin/observability/dashboards). [#16939](https://github.com/sourcegraph/sourcegraph/pull/16939) + - include alert events and version changes annotations that can be enabled from the top of each service dashboard. [#17198](https://github.com/sourcegraph/sourcegraph/pull/17198) +- Suggested filters in the search results page can now be scrolled. [#17097](https://github.com/sourcegraph/sourcegraph/pull/17097) +- Structural search queries can now be used in saved searches by adding `patternType:structural`. [#17265](https://github.com/sourcegraph/sourcegraph/pull/17265) + +### Changed + +- Dashboard links included in [monitoring alerts](https://docs.sourcegraph.com/admin/observability/alerting) now: + - link directly to the relevant Grafana panel, instead of just the service dashboard. [#17014](https://github.com/sourcegraph/sourcegraph/pull/17014) + - link to a time frame relevant to the alert, instead of just the past few hours. [#17034](https://github.com/sourcegraph/sourcegraph/pull/17034) +- Added `serviceKind` field of the `ExternalServiceKind` type to `Repository.externalURLs` GraphQL API, `serviceType` field is deprecated and will be removed in the future releases. [#14979](https://github.com/sourcegraph/sourcegraph/issues/14979) +- Deprecated the GraphQL fields `SearchResults.repositoriesSearched` and `SearchResults.indexedRepositoriesSearched`. +- The minimum Kubernetes version required to use the [Kubernetes deployment option](https://docs.sourcegraph.com/admin/install/kubernetes) is now [v1.15 (released June 2019)](https://kubernetes.io/blog/2019/06/19/kubernetes-1-15-release-announcement/). + +### Fixed + +- Imported changesets acquired an extra button to download the "generated diff", which did nothing, since imported changesets don't have a generated diff. This button has been removed. [#16778](https://github.com/sourcegraph/sourcegraph/issues/16778) +- Quoted global filter values (case, patterntype) are now properly extracted and set in URL parameters. [#16186](https://github.com/sourcegraph/sourcegraph/issues/16186) +- The endpoint for "Open in Sourcegraph" functionality in editor extensions now uses code host connection information to resolve the repository, which makes it more correct and respect the `repositoryPathPattern` setting. [#16846](https://github.com/sourcegraph/sourcegraph/pull/16846) +- Fixed an issue that prevented search expressions of the form `repo:foo (rev:a or rev:b)` from evaluating all revisions [#16873](https://github.com/sourcegraph/sourcegraph/pull/16873) +- Updated language detection library. Includes language detection for `lang:starlark`. [#16900](https://github.com/sourcegraph/sourcegraph/pull/16900) +- Fixed retrieving status for indexed tags and deduplicated main branches in the indexing settings page. [#13787](https://github.com/sourcegraph/sourcegraph/issues/13787) +- Specifying a ref that doesn't exist would show an alert, but still return results [#15576](https://github.com/sourcegraph/sourcegraph/issues/15576) +- Fixed search highlighting the wrong line. [#10468](https://github.com/sourcegraph/sourcegraph/issues/10468) +- Fixed an issue where searches of the form `foo type:file` returned results of type `path` too. [#17076](https://github.com/sourcegraph/sourcegraph/issues/17076) +- Fixed queries like `(type:commit or type:diff)` so that if the query matches both the commit message and the diff, both are returned as results. [#16899](https://github.com/sourcegraph/sourcegraph/issues/16899) +- Fixed container monitoring and provisioning dashboard panels not displaying metrics in certain deployment types and environments. If you continue to have issues with these panels not displaying any metrics after upgrading, please [open an issue](https://github.com/sourcegraph/sourcegraph/issues/new). +- Fixed a nonexistent field in site configuration being marked as "required" when configuring PagerDuty alert notifications. [#17277](https://github.com/sourcegraph/sourcegraph/pull/17277) +- Fixed cases of incorrect highlighting for symbol definitions in the definitions panel. [#17258](https://github.com/sourcegraph/sourcegraph/pull/17258) +- Fixed a Cross-Site Scripting vulnerability where quick links created on the homepage were not sanitized and allowed arbitrary JavaScript execution. [#17099](https://github.com/sourcegraph/sourcegraph/pull/17099) + +### Removed + +- Interactive mode has now been removed. [#16868](https://github.com/sourcegraph/sourcegraph/pull/16868). + +## 3.23.0 + +### Added + +- Password reset link expiration can be customized via `auth.passwordResetLinkExpiry` in the site config. [#13999](https://github.com/sourcegraph/sourcegraph/issues/13999) +- Campaign steps may now include environment variables from outside of the campaign spec using [array syntax](http://docs.sourcegraph.com/campaigns/references/campaign_spec_yaml_reference#environment-array). [#15822](https://github.com/sourcegraph/sourcegraph/issues/15822) +- The total size of all Git repositories and the lines of code for indexed branches are displayed in the site admin overview. [#15125](https://github.com/sourcegraph/sourcegraph/issues/15125) +- Extensions can now add decorations to files on the sidebar tree view and tree page through the experimental `FileDecoration` API. [#15833](https://github.com/sourcegraph/sourcegraph/pull/15833) +- Extensions can now easily query the Sourcegraph GraphQL API through a dedicated API method. [#15566](https://github.com/sourcegraph/sourcegraph/pull/15566) +- Individual changesets can now be downloaded as a diff. [#16098](https://github.com/sourcegraph/sourcegraph/issues/16098) +- The campaigns preview page is much more detailed now, especially when updating existing campaigns. [#16240](https://github.com/sourcegraph/sourcegraph/pull/16240) +- When a newer version of a campaign spec is uploaded, a message is now displayed when viewing the campaign or an outdated campaign spec. [#14532](https://github.com/sourcegraph/sourcegraph/issues/14532) +- Changesets in a campaign can now be searched by title and repository name. [#15781](https://github.com/sourcegraph/sourcegraph/issues/15781) +- Experimental: [`transformChanges` in campaign specs](https://docs.sourcegraph.com/campaigns/references/campaign_spec_yaml_reference#transformchanges) is now available as a feature preview to allow users to create multiple changesets in a single repository. [#16235](https://github.com/sourcegraph/sourcegraph/pull/16235) +- The `gitUpdateInterval` site setting was added to allow custom git update intervals based on repository names. [#16765](https://github.com/sourcegraph/sourcegraph/pull/16765) +- Various additions to syntax highlighting and hover tooltips in the search query bar (e.g., regular expressions). Can be disabled with `{ "experimentalFeatures": { "enableSmartQuery": false } }` in case of unlikely adverse effects. [#16742](https://github.com/sourcegraph/sourcegraph/pull/16742) +- Search queries may now scope subexpressions across repositories and files, and also allow greater freedom for combining search filters. See the updated documentation on [search subexpressions](https://docs.sourcegraph.com/code_search/tutorials/search_subexpressions) to learn more. [#16866](https://github.com/sourcegraph/sourcegraph/pull/16866) + +### Changed + +- Search indexer tuned to wait longer before assuming a deadlock has occurred. Previously if the indexserver had many cores (40+) and indexed a monorepo it could give up. [#16110](https://github.com/sourcegraph/sourcegraph/pull/16110) +- The total size of all Git repositories and the lines of code for indexed branches will be sent back in pings as part of critical telemetry. [#16188](https://github.com/sourcegraph/sourcegraph/pull/16188) +- The `gitserver` container now has a dependency on Postgres. This does not require any additional configuration unless access to Postgres requires a sidecar proxy / firewall rules. [#16121](https://github.com/sourcegraph/sourcegraph/pull/16121) +- Licensing is now enforced for campaigns: creating a campaign with more than five changesets requires a valid license. Please [contact Sourcegraph with any licensing questions](https://about.sourcegraph.com/contact/sales/). [#15715](https://github.com/sourcegraph/sourcegraph/issues/15715) + +### Fixed + +- Syntax highlighting on files with mixed extension case (e.g. `.CPP` vs `.cpp`) now works as expected. [#11327](https://github.com/sourcegraph/sourcegraph/issues/11327) +- After applying a campaign, some GitLab MRs might have had outdated state shown in the UI until the next sync with the code host. [#16100](https://github.com/sourcegraph/sourcegraph/pull/16100) +- The web app no longer sends stale text document content to extensions. [#14965](https://github.com/sourcegraph/sourcegraph/issues/14965) +- The blob viewer now supports multiple decorations per line as intended. [#15063](https://github.com/sourcegraph/sourcegraph/issues/15063) +- Repositories with plus signs in their name can now be navigated to as expected. [#15079](https://github.com/sourcegraph/sourcegraph/issues/15079) + +### Removed + +- + +## 3.22.1 + +### Changed + +- Reduced memory and CPU required for updating the code intelligence commit graph [#16517](https://github.com/sourcegraph/sourcegraph/pull/16517) + +## 3.22.0 + +### Added + +- GraphQL and TOML syntax highlighting is now back (special thanks to @rvantonder) [#13935](https://github.com/sourcegraph/sourcegraph/issues/13935) +- Zig and DreamMaker syntax highlighting. +- Campaigns now support publishing GitHub draft PRs and GitLab WIP MRs. [#7998](https://github.com/sourcegraph/sourcegraph/issues/7998) +- `indexed-searcher`'s watchdog can be configured and has additional instrumentation. This is useful when diagnosing [zoekt-webserver is restarting due to watchdog](https://docs.sourcegraph.com/admin/observability/troubleshooting#scenario-zoekt-webserver-is-restarting-due-to-watchdog). [#15148](https://github.com/sourcegraph/sourcegraph/pull/15148) +- Pings now contain Redis & Postgres server versions. [14405](https://github.com/sourcegraph/sourcegraph/14405) +- Aggregated usage data of the search onboarding tour is now included in pings. The data tracked are: total number of views of the onboarding tour, total number of views of each step in the onboarding tour, total number of tours closed. [#15113](https://github.com/sourcegraph/sourcegraph/pull/15113) +- Users can now specify credentials for code hosts to enable campaigns for non site-admin users. [#15506](https://github.com/sourcegraph/sourcegraph/pull/15506) +- A `campaigns.restrictToAdmins` site configuration option has been added to prevent non site-admin users from using campaigns. [#15785](https://github.com/sourcegraph/sourcegraph/pull/15785) +- Number of page views on campaign apply page, page views on campaign details page after create/update, closed campaigns, created campaign specs and changesets specs and the sum of changeset diff stats will be sent back in pings. [#15279](https://github.com/sourcegraph/sourcegraph/pull/15279) +- Users can now explicitly set their primary email address. [#15683](https://github.com/sourcegraph/sourcegraph/pull/15683) +- "[Why code search is still needed for monorepos](https://docs.sourcegraph.com/adopt/code_search_in_monorepos)" doc page + +### Changed + +- Improved contrast / visibility in comment syntax highlighting. [#14546](https://github.com/sourcegraph/sourcegraph/issues/14546) +- Campaigns are no longer in beta. [#14900](https://github.com/sourcegraph/sourcegraph/pull/14900) +- Campaigns now have a fancy new icon. [#14740](https://github.com/sourcegraph/sourcegraph/pull/14740) +- Search queries with an unbalanced closing paren `)` are now invalid, since this likely indicates an error. Previously, patterns with dangling `)` were valid in some cases. Note that patterns with dangling `)` can still be searched, but should be quoted via `content:"foo)"`. [#15042](https://github.com/sourcegraph/sourcegraph/pull/15042) +- Extension providers can now return AsyncIterables, enabling dynamic provider results without dependencies. [#15042](https://github.com/sourcegraph/sourcegraph/issues/15061) +- Deprecated the `"email.smtp": { "disableTLS" }` site config option, this field has been replaced by `"email.smtp": { "noVerifyTLS" }`. [#15682](https://github.com/sourcegraph/sourcegraph/pull/15682) + +### Fixed + +- The `file:` added to the search field when navigating to a tree or file view will now behave correctly when the file path contains spaces. [#12296](https://github.com/sourcegraph/sourcegraph/issues/12296) +- OAuth login now respects site configuration `experimentalFeatures: { "tls.external": {...} }` for custom certificates and skipping TLS verify. [#14144](https://github.com/sourcegraph/sourcegraph/issues/14144) +- If the `HEAD` file in a cloned repo is absent or truncated, background cleanup activities will use a best-effort default to remedy the situation. [#14962](https://github.com/sourcegraph/sourcegraph/pull/14962) +- Search input will always show suggestions. Previously we only showed suggestions for letters and some special characters. [#14982](https://github.com/sourcegraph/sourcegraph/pull/14982) +- Fixed an issue where `not` keywords were not recognized inside expression groups, and treated incorrectly as patterns. [#15139](https://github.com/sourcegraph/sourcegraph/pull/15139) +- Fixed an issue where hover pop-ups would not show on the first character of a valid hover range in search queries. [#15410](https://github.com/sourcegraph/sourcegraph/pull/15410) +- Fixed an issue where submodules configured with a relative URL resulted in non-functional hyperlinks in the file tree UI. [#15286](https://github.com/sourcegraph/sourcegraph/issues/15286) +- Pushing commits to public GitLab repositories with campaigns now works, since we use the configured token even if the repository is public. [#15536](https://github.com/sourcegraph/sourcegraph/pull/15536) +- `.kts` is now highlighted properly as Kotlin code, fixed various other issues in Kotlin syntax highlighting. +- Fixed an issue where the value of `content:` was treated literally when the regular expression toggle is active. [#15639](https://github.com/sourcegraph/sourcegraph/pull/15639) +- Fixed an issue where non-site admins were prohibited from updating some of their other personal metadata when `auth.enableUsernameChanges` was `false`. [#15663](https://github.com/sourcegraph/sourcegraph/issues/15663) +- Fixed the `url` fields of repositories and trees in GraphQL returning URLs that were not %-encoded (e.g. when the repository name contained spaces). [#15667](https://github.com/sourcegraph/sourcegraph/issues/15667) +- Fixed "Find references" showing errors in the references panel in place of the syntax-highlighted code for repositories with spaces in their name. [#15618](https://github.com/sourcegraph/sourcegraph/issues/15618) +- Fixed an issue where specifying the `repohasfile` filter did not return results as expected unless `repo` was specified. [#15894](https://github.com/sourcegraph/sourcegraph/pull/15894) +- Fixed an issue causing user input in the search query field to be erased in some cases. [#15921](https://github.com/sourcegraph/sourcegraph/issues/15921). + +### Removed + +- + +## 3.21.2 + +:warning: WARNING :warning: For users of single-image Sourcegraph instance, please delete the secret key file `/var/lib/sourcegraph/token` inside the container before attempting to upgrade to 3.21.x. + +### Fixed + +- Fix externalURLs alert logic [#14980](https://github.com/sourcegraph/sourcegraph/pull/14980) + +## 3.21.1 + +:warning: WARNING :warning: For users of single-image Sourcegraph instance, please delete the secret key file `/var/lib/sourcegraph/token` inside the container before attempting to upgrade to 3.21.x. + +### Fixed + +- Fix alerting for native integration condition [#14775](https://github.com/sourcegraph/sourcegraph/pull/14775) +- Fix query with large repo count hanging [#14944](https://github.com/sourcegraph/sourcegraph/pull/14944) +- Fix server upgrade where codeintel database does not exist [#14953](https://github.com/sourcegraph/sourcegraph/pull/14953) +- CVE-2019-18218 in postgres docker image [#14954](https://github.com/sourcegraph/sourcegraph/pull/14954) +- Fix an issue where .git/HEAD in invalid [#14962](https://github.com/sourcegraph/sourcegraph/pull/14962) +- Repository syncing will not happen more frequently than the repoListUpdateInterval config value [#14901](https://github.com/sourcegraph/sourcegraph/pull/14901) [#14983](https://github.com/sourcegraph/sourcegraph/pull/14983) + +## 3.21.0 + +:warning: WARNING :warning: For users of single-image Sourcegraph instance, please delete the secret key file `/var/lib/sourcegraph/token` inside the container before attempting to upgrade to 3.21.x. + +### Added + +- The new GraphQL API query field `namespaceByName(name: String!)` makes it easier to look up the user or organization with the given name. Previously callers needed to try looking up the user and organization separately. +- Changesets created by campaigns will now include a link back to the campaign in their body text. [#14033](https://github.com/sourcegraph/sourcegraph/issues/14033) +- Users can now preview commits that are going to be created in their repositories in the campaign preview UI. [#14181](https://github.com/sourcegraph/sourcegraph/pull/14181) +- If emails are configured, the user will be sent an email when important account information is changed. This currently encompasses changing/resetting the password, adding/removing emails, and adding/removing access tokens. [#14320](https://github.com/sourcegraph/sourcegraph/pull/14320) +- A subset of changesets can now be published by setting the `published` flag in campaign specs [to an array](https://docs.sourcegraph.com/@main/campaigns/campaign_spec_yaml_reference#publishing-only-specific-changesets), which allows only specific changesets within a campaign to be published based on the repository name. [#13476](https://github.com/sourcegraph/sourcegraph/pull/13476) +- Homepage panels are now enabled by default. [#14287](https://github.com/sourcegraph/sourcegraph/issues/14287) +- The most recent ping data is now available to site admins via the Site-admin > Pings page. [#13956](https://github.com/sourcegraph/sourcegraph/issues/13956) +- Homepage panel engagement metrics will be sent back in pings. [#14589](https://github.com/sourcegraph/sourcegraph/pull/14589) +- Homepage now has a footer with links to different extensibility features. [#14638](https://github.com/sourcegraph/sourcegraph/issues/14638) +- Added an onboarding tour of Sourcegraph for new users. It can be enabled in user settings with `experimentalFeatures.showOnboardingTour` [#14636](https://github.com/sourcegraph/sourcegraph/pull/14636) +- Added an onboarding tour of Sourcegraph for new users. [#14636](https://github.com/sourcegraph/sourcegraph/pull/14636) +- Repository GraphQL queries now support an `after` parameter that permits cursor-based pagination. [#13715](https://github.com/sourcegraph/sourcegraph/issues/13715) +- Searches in the Recent Searches panel and other places are now syntax highlighted. [#14443](https://github.com/sourcegraph/sourcegraph/issues/14443) + +### Changed + +- Interactive search mode is now disabled by default because the new plain text search input is smarter. To reenable it, add `{ "experimentalFeatures": { "splitSearchModes": true } }` in user settings. +- The extension registry has been redesigned to make it easier to find non-default Sourcegraph extensions. +- Tokens and similar sensitive information included in the userinfo portion of remote repository URLs will no longer be visible on the Mirroring settings page. [#14153](https://github.com/sourcegraph/sourcegraph/pull/14153) +- The sign in and sign up forms have been redesigned with better input validation. +- Kubernetes admins mounting [configuration files](https://docs.sourcegraph.com/admin/config/advanced_config_file#kubernetes-configmap) are encouraged to change how the ConfigMap is mounted. See the new documentation. Previously our documentation suggested using subPath. However, this lead to Kubernetes not automatically updating the files on configuration change. [#14297](https://github.com/sourcegraph/sourcegraph/pull/14297) +- The precise code intel bundle manager will now expire any converted LSIF data that is older than `PRECISE_CODE_INTEL_MAX_DATA_AGE` (30 days by default) that is also not visible from the tip of the default branch. +- `SRC_LOG_LEVEL=warn` is now the default in Docker Compose and Kubernetes deployments, reducing the amount of uninformative log spam. [#14458](https://github.com/sourcegraph/sourcegraph/pull/14458) +- Permissions data that were stored in deprecated binary format are abandoned. Downgrade from 3.21 to 3.20 is OK, but to 3.19 or prior versions might experience missing/incomplete state of permissions for a short period of time. [#13740](https://github.com/sourcegraph/sourcegraph/issues/13740) +- The query builder page is now disabled by default. To reenable it, add `{ "experimentalFeatures": { "showQueryBuilder": true } }` in user settings. +- The GraphQL `updateUser` mutation now returns the updated user (instead of an empty response). + +### Fixed + +- Git clone URLs now validate their format correctly. [#14313](https://github.com/sourcegraph/sourcegraph/pull/14313) +- Usernames set in Slack `observability.alerts` now apply correctly. [#14079](https://github.com/sourcegraph/sourcegraph/pull/14079) +- Path segments in breadcrumbs get truncated correctly again on small screen sizes instead of inflating the header bar. [#14097](https://github.com/sourcegraph/sourcegraph/pull/14097) +- GitLab pipelines are now parsed correctly and show their current status in campaign changesets. [#14129](https://github.com/sourcegraph/sourcegraph/pull/14129) +- Fixed an issue where specifying any repogroups would effectively search all repositories for all repogroups. [#14190](https://github.com/sourcegraph/sourcegraph/pull/14190) +- Changesets that were previously closed after being detached from a campaign are now reopened when being reattached. [#14099](https://github.com/sourcegraph/sourcegraph/pull/14099) +- Previously large files that match the site configuration [search.largeFiles](https://docs.sourcegraph.com/admin/config/site_config#search-largeFiles) would not be indexed if they contained a large number of unique trigrams. We now index those files as well. Note: files matching the glob still need to be valid utf-8. [#12443](https://github.com/sourcegraph/sourcegraph/issues/12443) +- Git tags without a `creatordate` value will no longer break tag search within a repository. [#5453](https://github.com/sourcegraph/sourcegraph/issues/5453) +- Campaigns pages now work properly on small viewports. [#14292](https://github.com/sourcegraph/sourcegraph/pull/14292) +- Fix an issue with viewing repositories that have spaces in the repository name [#2867](https://github.com/sourcegraph/sourcegraph/issues/2867) + +### Removed + +- Syntax highlighting for GraphQL, INI, TOML, and Perforce files has been removed [due to incompatible/absent licenses](https://github.com/sourcegraph/sourcegraph/issues/13933). We plan to [add it back in the future](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+is%3Aopen+add+syntax+highlighting+for+develop+a+). +- Search scope pages (`/search/scope/:id`) were removed. +- User-defined search scopes are no longer shown below the search bar on the homepage. Use the [`quicklinks`](https://docs.sourcegraph.com/user/personalization/quick_links) setting instead to display links there. +- The explore page (`/explore`) was removed. +- The sign out page was removed. +- The unused GraphQL types `DiffSearchResult` and `DeploymentConfiguration` were removed. +- The deprecated GraphQL mutation `updateAllMirrorRepositories`. +- The deprecated GraphQL field `Site.noRepositoriesEnabled`. +- Total counts of users by product area have been removed from pings. +- Aggregate daily, weekly, and monthly latencies (in ms) of code intelligence events (e.g., hover tooltips) have been removed from pings. + +## 3.20.1 + +### Fixed + +- gomod: rollback go-diff to v0.5.3 (v0.6.0 causes panic in certain cases) [#13973](https://github.com/sourcegraph/sourcegraph/pull/13973). +- Fixed an issue causing the scoped query in the search field to be erased when viewing files. [#13954](https://github.com/sourcegraph/sourcegraph/pull/13954). + +## 3.20.0 + +### Added + +- Site admins can now force a specific user to re-authenticate on their next request or visit. [#13647](https://github.com/sourcegraph/sourcegraph/pull/13647) +- Sourcegraph now watches its [configuration files](https://docs.sourcegraph.com/admin/config/advanced_config_file) (when using external files) and automatically applies the changes to Sourcegraph's configuration when they change. For example, this allows Sourcegraph to detect when a Kubernetes ConfigMap changes. [#13646](https://github.com/sourcegraph/sourcegraph/pull/13646) +- To define repository groups (`search.repositoryGroups` in global, org, or user settings), you can now specify regular expressions in addition to single repository names. [#13730](https://github.com/sourcegraph/sourcegraph/pull/13730) +- The new site configuration property `search.limits` configures the maximum search timeout and the maximum number of repositories to search for various types of searches. [#13448](https://github.com/sourcegraph/sourcegraph/pull/13448) +- Files and directories can now be excluded from search by adding the file `.sourcegraph/ignore` to the root directory of a repository. Each line in the _ignore_ file is interpreted as a globbing pattern. [#13690](https://github.com/sourcegraph/sourcegraph/pull/13690) +- Structural search syntax now allows regular expressions in patterns. Also, `...` can now be used in place of `:[_]`. See the [documentation](https://docs.sourcegraph.com/@main/code_search/reference/structural) for example syntax. [#13809](https://github.com/sourcegraph/sourcegraph/pull/13809) +- The total size of all Git repositories and the lines of code for indexed branches will be sent back in pings. [#13764](https://github.com/sourcegraph/sourcegraph/pull/13764) +- Experimental: A new homepage UI for Sourcegraph Server shows the user their recent searches, repositories, files, and saved searches. It can be enabled with `experimentalFeatures.showEnterpriseHomePanels`. [#13407](https://github.com/sourcegraph/sourcegraph/issues/13407) + +### Changed + +- Campaigns are enabled by default for all users. Site admins may view and create campaigns; everyone else may only view campaigns. The new site configuration property `campaigns.enabled` can be used to disable campaigns for all users. The properties `campaigns.readAccess`, `automation.readAccess.enabled`, and `"experimentalFeatures": { "automation": "enabled" }}` are deprecated and no longer have any effect. +- Diff and commit searches are limited to 10,000 repositories (if `before:` or `after:` filters are used), or 50 repositories (if no time filters are used). You can configure this limit in the site configuration property `search.limits`. [#13386](https://github.com/sourcegraph/sourcegraph/pull/13386) +- The site configuration `maxReposToSearch` has been deprecated in favor of the property `maxRepos` on `search.limits`. [#13439](https://github.com/sourcegraph/sourcegraph/pull/13439) +- Search queries are now processed by a new parser that will always be enabled going forward. There should be no material difference in behavior. In case of adverse effects, the previous parser can be reenabled by setting `"search.migrateParser": false` in settings. [#13435](https://github.com/sourcegraph/sourcegraph/pull/13435) +- It is now possible to search for file content that excludes a term using the `NOT` operator. [#12412](https://github.com/sourcegraph/sourcegraph/pull/12412) +- `NOT` is available as an alternative syntax of `-` on supported keywords `repo`, `file`, `content`, `lang`, and `repohasfile`. [#12412](https://github.com/sourcegraph/sourcegraph/pull/12412) +- Negated content search is now also supported for unindexed repositories. Previously it was only supported for indexed repositories [#13359](https://github.com/sourcegraph/sourcegraph/pull/13359). +- The experimental feature flag `andOrQuery` is deprecated. [#13435](https://github.com/sourcegraph/sourcegraph/pull/13435) +- After a user's password changes, they will be signed out on all devices and must sign in again. [#13647](https://github.com/sourcegraph/sourcegraph/pull/13647) +- `rev:` is available as alternative syntax of `@` for searching revisions instead of the default branch [#13133](https://github.com/sourcegraph/sourcegraph/pull/13133) +- Campaign URLs have changed to use the campaign name instead of an opaque ID. The old URLs no longer work. [#13368](https://github.com/sourcegraph/sourcegraph/pull/13368) +- A new `external_service_repos` join table was added. The migration required to make this change may take a few minutes. + +### Fixed + +- User satisfaction/NPS surveys will now correctly provide a range from 0โ€“10, rather than 0โ€“9. [#13163](https://github.com/sourcegraph/sourcegraph/pull/13163) +- Fixed a bug where we returned repositories with invalid revisions in the search results. Now, if a user specifies an invalid revision, we show an alert. [#13271](https://github.com/sourcegraph/sourcegraph/pull/13271) +- Previously it wasn't possible to search for certain patterns containing `:` because they would not be considered valid filters. We made these checks less strict. [#10920](https://github.com/sourcegraph/sourcegraph/pull/10920) +- When a user signs out of their account, all of their sessions will be invalidated, not just the session where they signed out. [#13647](https://github.com/sourcegraph/sourcegraph/pull/13647) +- URL information will no longer be leaked by the HTTP referer header. This prevents the user's password reset code from being leaked. [#13804](https://github.com/sourcegraph/sourcegraph/pull/13804) +- GitLab OAuth2 user authentication now respects `tls.external` site setting. [#13814](https://github.com/sourcegraph/sourcegraph/pull/13814) + +### Removed + +- The smartSearchField feature is now always enabled. The `experimentalFeatures.smartSearchField` settings option has been removed. + +## 3.19.2 + +### Fixed + +- search: always limit commit and diff to less than 10,000 repos [a97f81b0f7](https://github.com/sourcegraph/sourcegraph/commit/a97f81b0f79535253bd7eae6c30d5c91d48da5ca) +- search: configurable limits on commit/diff search [1c22d8ce1](https://github.com/sourcegraph/sourcegraph/commit/1c22d8ce13c149b3fa3a7a26f8cb96adc89fc556) +- search: add site configuration for maxTimeout [d8d61b43c0f](https://github.com/sourcegraph/sourcegraph/commit/d8d61b43c0f0d229d46236f2f128ca0f93455172) + +## 3.19.1 + +### Fixed + +- migrations: revert migration causing deadlocks in some deployments [#13194](https://github.com/sourcegraph/sourcegraph/pull/13194) + +## 3.19.0 + +### Added + +- Emails can be now be sent to SMTP servers with self-signed certificates, using `email.smtp.disableTLS`. [#12243](https://github.com/sourcegraph/sourcegraph/pull/12243) +- Saved search emails now include a link to the user's saved searches page. [#11651](https://github.com/sourcegraph/sourcegraph/pull/11651) +- Campaigns can now be synced using GitLab webhooks. [#12139](https://github.com/sourcegraph/sourcegraph/pull/12139) +- Configured `observability.alerts` can now be tested using a GraphQL endpoint, `triggerObservabilityTestAlert`. [#12532](https://github.com/sourcegraph/sourcegraph/pull/12532) +- The Sourcegraph CLI can now serve local repositories for Sourcegraph to clone. This was previously in a command called `src-expose`. See [serving local repositories](https://docs.sourcegraph.com/admin/external_service/src_serve_git) in our documentation to find out more. [#12363](https://github.com/sourcegraph/sourcegraph/issues/12363) +- The count of retained, churned, resurrected, new and deleted users will be sent back in pings. [#12136](https://github.com/sourcegraph/sourcegraph/pull/12136) +- Saved search usage will be sent back in pings. [#12956](https://github.com/sourcegraph/sourcegraph/pull/12956) +- Any request with `?trace=1` as a URL query parameter will enable Jaeger tracing (if Jaeger is enabled). [#12291](https://github.com/sourcegraph/sourcegraph/pull/12291) +- Password reset emails will now be automatically sent to users created by a site admin if email sending is configured and password reset is enabled. Previously, site admins needed to manually send the user this password reset link. [#12803](https://github.com/sourcegraph/sourcegraph/pull/12803) +- Syntax highlighting for `and` and `or` search operators. [#12694](https://github.com/sourcegraph/sourcegraph/pull/12694) +- It is now possible to search for file content that excludes a term using the `NOT` operator. Negating pattern syntax requires setting `"search.migrateParser": true` in settings and is currently only supported for literal and regexp queries on indexed repositories. [#12412](https://github.com/sourcegraph/sourcegraph/pull/12412) +- `NOT` is available as an alternative syntax of `-` on supported keywords `repo`, `file`, `content`, `lang`, and `repohasfile`. `NOT` requires setting `"search.migrateParser": true` option in settings. [#12520](https://github.com/sourcegraph/sourcegraph/pull/12520) + +### Changed + +- Repository permissions are now always checked and updated asynchronously ([background permissions syncing](https://docs.sourcegraph.com/admin/repo/permissions#background-permissions-syncing)) instead of blocking each operation. The site config option `permissions.backgroundSync` (which enabled this behavior in previous versions) is now a no-op and is deprecated. +- [Background permissions syncing](https://docs.sourcegraph.com/admin/repo/permissions#background-permissions-syncing) (`permissions.backgroundSync`) has become the only option for mirroring repository permissions from code hosts. All relevant site configurations are deprecated. + +### Fixed + +- Fixed site admins are getting errors when visiting user settings page in OSS version. [#12313](https://github.com/sourcegraph/sourcegraph/pull/12313) +- `github-proxy` now respects the environment variables `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` (or the lowercase versions thereof). Other services already respect these variables, but this was missed. If you need a proxy to access github.com set the environment variable for the github-proxy container. [#12377](https://github.com/sourcegraph/sourcegraph/issues/12377) +- `sourcegraph-frontend` now respects the `tls.external` experimental setting as well as the proxy environment variables. In proxy environments this allows Sourcegraph to fetch extensions. [#12633](https://github.com/sourcegraph/sourcegraph/issues/12633) +- Fixed a bug that would sometimes cause trailing parentheses to be removed from search queries upon page load. [#12960](https://github.com/sourcegraph/sourcegraph/issues/12690) +- Indexed search will no longer stall if a specific index job stalls. Additionally at scale many corner cases causing indexing to stall have been fixed. [#12502](https://github.com/sourcegraph/sourcegraph/pull/12502) +- Indexed search will quickly recover from rebalancing / roll outs. When a indexed search shard goes down, its repositories are re-indexed by other shards. This takes a while and during a rollout leads to effectively re-indexing all repositories. We now avoid indexing the redistributed repositories once a shard comes back online. [#12474](https://github.com/sourcegraph/sourcegraph/pull/12474) +- Indexed search has many improvements to observability. More detailed Jaeger traces, detailed logging during startup and more prometheus metrics. +- The site admin repository needs-index page is significantly faster. Previously on large instances it would usually timeout. Now it should load within a second. [#12513](https://github.com/sourcegraph/sourcegraph/pull/12513) +- User password reset page now respects the value of site config `auth.minPasswordLength`. [#12971](https://github.com/sourcegraph/sourcegraph/pull/12971) +- Fixed an issue where duplicate search results would show for queries with `or`-expressions. [#12531](https://github.com/sourcegraph/sourcegraph/pull/12531) +- Faster indexed search queries over a large number of repositories. Searching 100k+ repositories is now ~400ms faster and uses much less memory. [#12546](https://github.com/sourcegraph/sourcegraph/pull/12546) + +### Removed + +- Deprecated site settings `lightstepAccessToken` and `lightstepProject` have been removed. We now only support sending traces to Jaeger. Configure Jaeger with `observability.tracing` site setting. +- Removed `CloneInProgress` option from GraphQL Repositories API. [#12560](https://github.com/sourcegraph/sourcegraph/pull/12560) + +## 3.18.0 + +### Added + +- To search across multiple revisions of the same repository, list multiple branch names (or other revspecs) separated by `:` in your query, as in `repo:myrepo@branch1:branch2:branch2`. To search all branches, use `repo:myrepo@*refs/heads/`. Previously this was only supported for diff and commit searches and only available via the experimental site setting `searchMultipleRevisionsPerRepository`. +- The "Add repositories" page (/site-admin/external-services/new) now displays a dismissable notification explaining how and why we access code host data. [#11789](https://github.com/sourcegraph/sourcegraph/pull/11789). +- New `observability.alerts` features: + - Notifications now provide more details about relevant alerts. + - Support for email and OpsGenie notifications has been added. Note that to receive email alerts, `email.address` and `email.smtp` must be configured. + - Some notifiers now have new options: + - PagerDuty notifiers: `severity` and `apiUrl` + - Webhook notifiers: `bearerToken` + - A new `disableSendResolved` option disables notifications for when alerts resolve themselves. +- Recently firing critical alerts can now be displayed to admins via site alerts, use the flag `{ "alerts.hideObservabilitySiteAlerts": false }` to enable these alerts in user configuration. +- Specific alerts can now be silenced using `observability.silenceAlerts`. [#12087](https://github.com/sourcegraph/sourcegraph/pull/12087) +- Revisions listed in `experimentalFeatures.versionContext` will be indexed for faster searching. This is the first support towards indexing non-default branches. [#6728](https://github.com/sourcegraph/sourcegraph/issues/6728) +- Revisions listed in `experimentalFeatures.versionContext` or `experimentalFeatures.search.index.branches` will be indexed for faster searching. This is the first support towards indexing non-default branches. [#6728](https://github.com/sourcegraph/sourcegraph/issues/6728) +- Campaigns are now supported on GitLab. +- Campaigns now support GitLab and allow users to create, update and track merge requests on GitLab instances. +- Added a new section on the search homepage on Sourcegraph.com. It is currently feature flagged behind `experimentalFeatures.showRepogroupHomepage` in settings. +- Added new repository group pages. + +### Changed + +- Some monitoring alerts now have more useful descriptions. [#11542](https://github.com/sourcegraph/sourcegraph/pull/11542) +- Searching `fork:true` or `archived:true` has the same behaviour as searching `fork:yes` or `archived:yes` respectively. Previously it incorrectly had the same behaviour as `fork:only` and `archived:only` respectively. [#11740](https://github.com/sourcegraph/sourcegraph/pull/11740) +- Configuration for `observability.alerts` has changed and notifications are now provided by Prometheus Alertmanager. [#11832](https://github.com/sourcegraph/sourcegraph/pull/11832) + - Removed: `observability.alerts.id`. + - Removed: Slack notifiers no longer accept `mentionUsers`, `mentionGroups`, `mentionChannel`, and `token` options. + +### Fixed + +- The single-container `sourcegraph/server` image now correctly reports its version. +- An issue where repositories would not clone and index in some edge cases where the clones were deleted or not successful on gitserver. [#11602](https://github.com/sourcegraph/sourcegraph/pull/11602) +- An issue where repositories previously deleted on gitserver would not immediately reclone on system startup. [#11684](https://github.com/sourcegraph/sourcegraph/issues/11684) +- An issue where the sourcegraph/server Jaeger config was invalid. [#11661](https://github.com/sourcegraph/sourcegraph/pull/11661) +- An issue where valid search queries were improperly hinted as being invalid in the search field. [#11688](https://github.com/sourcegraph/sourcegraph/pull/11688) +- Reduce frontend memory spikes by limiting the number of goroutines launched by our GraphQL resolvers. [#11736](https://github.com/sourcegraph/sourcegraph/pull/11736) +- Fixed a bug affecting Sourcegraph icon display in our Phabricator native integration [#11825](https://github.com/sourcegraph/sourcegraph/pull/11825). +- Improve performance of site-admin repositories status page. [#11932](https://github.com/sourcegraph/sourcegraph/pull/11932) +- An issue where search autocomplete for files didn't add the right path. [#12241](https://github.com/sourcegraph/sourcegraph/pull/12241) + +### Removed + +- Backwards compatibility for "critical configuration" (a type of configuration that was deprecated in December 2019) was removed. All critical configuration now belongs in site configuration. +- Experimental feature setting `{ "experimentalFeatures": { "searchMultipleRevisionsPerRepository": true } }` will be removed in 3.19. It is now always on. Please remove references to it. +- Removed "Cloning" tab in site-admin Repository Status page. [#12043](https://github.com/sourcegraph/sourcegraph/pull/12043) +- The `blacklist` configuration option for Gitolite that was deprecated in 3.17 has been removed in 3.19. Use `exclude.pattern` instead. [#12345](https://github.com/sourcegraph/sourcegraph/pull/12345) + +## 3.17.3 + +### Fixed + +- git: Command retrying made a copy that was never used [#11807](https://github.com/sourcegraph/sourcegraph/pull/11807) +- frontend: Allow opt out of EnsureRevision when making a comparison query [#11811](https://github.com/sourcegraph/sourcegraph/pull/11811) +- Fix Phabricator icon class [#11825](https://github.com/sourcegraph/sourcegraph/pull/11825) + +## 3.17.2 + +### Fixed + +- An issue where repositories previously deleted on gitserver would not immediately reclone on system startup. [#11684](https://github.com/sourcegraph/sourcegraph/issues/11684) + +## 3.17.1 + +### Added + +- Improved search indexing metrics + +### Changed + +- Some monitoring alerts now have more useful descriptions. [#11542](https://github.com/sourcegraph/sourcegraph/pull/11542) + +### Fixed + +- The single-container `sourcegraph/server` image now correctly reports its version. +- An issue where repositories would not clone and index in some edge cases where the clones were deleted or not successful on gitserver. [#11602](https://github.com/sourcegraph/sourcegraph/pull/11602) +- An issue where the sourcegraph/server Jaeger config was invalid. [#11661](https://github.com/sourcegraph/sourcegraph/pull/11661) + +## 3.17.0 + +### Added + +- The search results page now shows a small UI notification if either repository forks or archives are excluded, when `fork` or `archived` options are not explicitly set. [#10624](https://github.com/sourcegraph/sourcegraph/pull/10624) +- Prometheus metric `src_gitserver_repos_removed_disk_pressure` which is incremented everytime we remove a repository due to disk pressure. [#10900](https://github.com/sourcegraph/sourcegraph/pull/10900) +- `gitolite.exclude` setting in [Gitolite external service config](https://docs.sourcegraph.com/admin/external_service/gitolite#configuration) now supports a regular expression via the `pattern` field. This is consistent with how we exclude in other external services. Additionally this is a replacement for the deprecated `blacklist` configuration. [#11403](https://github.com/sourcegraph/sourcegraph/pull/11403) +- Notifications about Sourcegraph being out of date will now be shown to site admins and users (depending on how out-of-date it is). +- Alerts are now configured using `observability.alerts` in the site configuration, instead of via the Grafana web UI. This does not yet support all Grafana notification channel types, and is not yet supported on `sourcegraph/server` ([#11473](https://github.com/sourcegraph/sourcegraph/issues/11473)). For more details, please refer to the [Sourcegraph alerting guide](https://docs.sourcegraph.com/admin/observability/alerting). +- Experimental basic support for detecting if your Sourcegraph instance is over or under-provisioned has been added through a set of dashboards and warning-level alerts based on container utilization. +- Query [operators](https://docs.sourcegraph.com/code_search/reference/queries#boolean-operators) `and` and `or` are now enabled by default in all search modes for searching file content. [#11521](https://github.com/sourcegraph/sourcegraph/pull/11521) + +### Changed + +- Repository search within a version context will link to the revision in the version context. [#10860](https://github.com/sourcegraph/sourcegraph/pull/10860) +- Background permissions syncing becomes the default method to sync permissions from code hosts. Please [read our documentation for things to keep in mind before upgrading](https://docs.sourcegraph.com/admin/repo/permissions#background-permissions-syncing). [#10972](https://github.com/sourcegraph/sourcegraph/pull/10972) +- The styling of the hover overlay was overhauled to never have badges or the close button overlap content while also always indicating whether the overlay is currently pinned. The styling on code hosts was also improved. [#10956](https://github.com/sourcegraph/sourcegraph/pull/10956) +- Previously, it was required to quote most patterns in structural search. This is no longer a restriction and single and double quotes in structural search patterns are interpreted literally. Note: you may still use `content:"structural-pattern"` if the pattern without quotes conflicts with other syntax. [#11481](https://github.com/sourcegraph/sourcegraph/pull/11481) + +### Fixed + +- Dynamic repo search filters on branches which contain special characters are correctly escaped now. [#10810](https://github.com/sourcegraph/sourcegraph/pull/10810) +- Forks and archived repositories at a specific commit are searched without the need to specify "fork:yes" or "archived:yes" in the query. [#10864](https://github.com/sourcegraph/sourcegraph/pull/10864) +- The git history for binary files is now correctly shown. [#11034](https://github.com/sourcegraph/sourcegraph/pull/11034) +- Links to AWS Code Commit repositories have been fixed after the URL schema has been changed. [#11019](https://github.com/sourcegraph/sourcegraph/pull/11019) +- A link to view all repositories will now always appear on the Explore page. [#11113](https://github.com/sourcegraph/sourcegraph/pull/11113) +- The Site-admin > Pings page no longer incorrectly indicates that pings are disabled when they aren't. [#11229](https://github.com/sourcegraph/sourcegraph/pull/11229) +- Match counts are now accurately reported for indexed search. [#11242](https://github.com/sourcegraph/sourcegraph/pull/11242) +- When background permissions syncing is enabled, it is now possible to only enforce permissions for repositories from selected code hosts (instead of enforcing permissions for repositories from all code hosts). [#11336](https://github.com/sourcegraph/sourcegraph/pull/11336) +- When more than 200+ repository revisions in a search are unindexed (very rare), the remaining repositories are reported as missing instead of Sourcegraph issuing e.g. several thousand unindexed search requests which causes system slowness and ultimately times out - ensuring searches are still fast even if there are indexing issues on a deployment of Sourcegraph. This does not apply if `index:no` is present in the query. + +### Removed + +- Automatic syncing of Campaign webhooks for Bitbucket Server. [#10962](https://github.com/sourcegraph/sourcegraph/pull/10962) +- The `blacklist` configuration option for Gitolite is DEPRECATED and will be removed in 3.19. Use `exclude.pattern` instead. + +## 3.16.2 + +### Fixed + +- Search: fix indexed search match count [#7fc96](https://github.com/sourcegraph/sourcegraph/commit/7fc96d319f49f55da46a7649ccf261aa7e8327c3) +- Sort detected languages properly [#e7750](https://github.com/sourcegraph/sourcegraph/commit/e77507d060a40355e7b86fb093d21a7149ea03ac) + +## 3.16.1 + +### Fixed + +- Fix repo not found error for patches [#11021](https://github.com/sourcegraph/sourcegraph/pull/11021). +- Show expired license screen [#10951](https://github.com/sourcegraph/sourcegraph/pull/10951). +- Sourcegraph is now built with Go 1.14.3, fixing issues running Sourcegraph onUbuntu 19 and 20. [#10447](https://github.com/sourcegraph/sourcegraph/issues/10447) + +## 3.16.0 + +### Added + +- Autocompletion for `repogroup` filters in search queries. [#10141](https://github.com/sourcegraph/sourcegraph/pull/10286) +- If the experimental feature flag `codeInsights` is enabled, extensions can contribute content to directory pages through the experimental `ViewProvider` API. [#10236](https://github.com/sourcegraph/sourcegraph/pull/10236) + - Directory pages are then represented as an experimental `DirectoryViewer` in the `visibleViewComponents` of the extension API. **Note: This may break extensions that were assuming `visibleViewComponents` were always `CodeEditor`s and did not check the `type` property.** Extensions checking the `type` property will continue to work. [#10236](https://github.com/sourcegraph/sourcegraph/pull/10236) +- [Major syntax highlighting improvements](https://github.com/sourcegraph/syntect_server/pull/29), including: + - 228 commits / 1 year of improvements to the syntax highlighter library Sourcegraph uses ([syntect](https://github.com/trishume/syntect)). + - 432 commits / 1 year of improvements to the base syntax definitions for ~36 languages Sourcegraph uses ([sublimehq/Packages](https://github.com/sublimehq/Packages)). + - 30 new file extensions/names now detected. + - Likely fixes other major instability and language support issues. #9557 + - Added [Smarty](#2885), [Ethereum / Solidity / Vyper)](#2440), [Cuda](#5907), [COBOL](#10154), [vb.NET](#4901), and [ASP.NET](#4262) syntax highlighting. + - Fixed OCaml syntax highlighting #3545 + - Bazel/Starlark support improved (.star, BUILD, and many more extensions now properly highlighted). #8123 +- New permissions page in both user and repository settings when background permissions syncing is enabled (`"permissions.backgroundSync": {"enabled": true}`). [#10473](https://github.com/sourcegraph/sourcegraph/pull/10473) [#10655](https://github.com/sourcegraph/sourcegraph/pull/10655) +- A new dropdown for choosing version contexts appears on the left of the query input when version contexts are specified in `experimentalFeatures.versionContext` in site configuration. Version contexts allow you to scope your search to specific sets of repos at revisions. +- Campaign changeset usage counts including changesets created, added and merged will be sent back in pings. [#10591](https://github.com/sourcegraph/sourcegraph/pull/10591) +- Diff views now feature syntax highlighting and can be properly copy-pasted. [#10437](https://github.com/sourcegraph/sourcegraph/pull/10437) +- Admins can now download an anonymized usage statistics ZIP archive in the **Site admin > Usage stats**. Opting to share this archive with the Sourcegraph team helps us make the product even better. [#10475](https://github.com/sourcegraph/sourcegraph/pull/10475) +- Extension API: There is now a field `versionContext` and subscribable `versionContextChanges` in `Workspace` to allow extensions to respect the instance's version context. +- The smart search field, providing syntax highlighting, hover tooltips, and validation on filters in search queries, is now activated by default. It can be disabled by setting `{ "experimentalFeatures": { "smartSearchField": false } }` in global settings. + +### Changed + +- The `userID` and `orgID` fields in the SavedSearch type in the GraphQL API have been replaced with a `namespace` field. To get the ID of the user or org that owns the saved search, use `namespace.id`. [#5327](https://github.com/sourcegraph/sourcegraph/pull/5327) +- Tree pages now redirect to blob pages if the path is not a tree and vice versa. [#10193](https://github.com/sourcegraph/sourcegraph/pull/10193) +- Files and directories that are not found now return a 404 status code. [#10193](https://github.com/sourcegraph/sourcegraph/pull/10193) +- The site admin flag `disableNonCriticalTelemetry` now allows Sourcegraph admins to disable most anonymous telemetry. Visit https://docs.sourcegraph.com/admin/pings to learn more. [#10402](https://github.com/sourcegraph/sourcegraph/pull/10402) + +### Fixed + +- In the OSS version of Sourcegraph, authorization providers are properly initialized and GraphQL APIs are no longer blocked. [#3487](https://github.com/sourcegraph/sourcegraph/issues/3487) +- Previously, GitLab repository paths containing certain characters could not be excluded (slashes and periods in parts of the paths). These characters are now allowed, so the repository paths can be excluded. [#10096](https://github.com/sourcegraph/sourcegraph/issues/10096) +- Symbols for indexed commits in languages Haskell, JSONNet, Kotlin, Scala, Swift, Thrift, and TypeScript will show up again. Previously our symbol indexer would not know how to extract symbols for those languages even though our unindexed symbol service did. [#10357](https://github.com/sourcegraph/sourcegraph/issues/10357) +- When periodically re-cloning a repository it will still be available. [#10663](https://github.com/sourcegraph/sourcegraph/pull/10663) + +### Removed + +- The deprecated feature discussions has been removed. [#9649](https://github.com/sourcegraph/sourcegraph/issues/9649) + +## 3.15.2 + +### Fixed + +- Fix repo not found error for patches [#11021](https://github.com/sourcegraph/sourcegraph/pull/11021). +- Show expired license screen [#10951](https://github.com/sourcegraph/sourcegraph/pull/10951). + +## 3.15.1 + +### Fixed + +- A potential security vulnerability with in the authentication workflow has been fixed. [#10167](https://github.com/sourcegraph/sourcegraph/pull/10167) +- An issue where `sourcegraph/postgres-11.4:3.15.0` was incorrectly an older version of the image incompatible with non-root Kubernetes deployments. `sourcegraph/postgres-11.4:3.15.1` now matches the same image version found in Sourcegraph 3.14.3 (`20-04-07_56b20163`). +- An issue that caused the search result type tabs to be overlapped in Safari. [#10191](https://github.com/sourcegraph/sourcegraph/pull/10191) + +## 3.15.0 + +### Added + +- Users and site administrators can now view a log of their actions/events in the user settings. [#9141](https://github.com/sourcegraph/sourcegraph/pull/9141) - With the new `visibility:` filter search results can now be filtered based on a repository's visibility (possible filter values: `any`, `public` or `private`). [#8344](https://github.com/sourcegraph/sourcegraph/issues/8344) -- observability: Dashboard panels now show an orange/red background color when the defined warning/critical alert threshold has been met, making it even easier to see on a dashboard what is in a bad state. -- observability: Distributed tracing is a powerful tool for investigating performance issues. The following changes have been made with the goal of making it easier to use distributed tracing with Sourcegraph: +- [`sourcegraph/git-extras`](https://sourcegraph.com/extensions/sourcegraph/git-extras) is now enabled by default on new instances [#3501](https://github.com/sourcegraph/sourcegraph/issues/3501) +- The Sourcegraph Docker image will now copy `/etc/sourcegraph/gitconfig` to `$HOME/.gitconfig`. This is a convenience similiar to what we provide for [repositories that need HTTP(S) or SSH authentication](https://docs.sourcegraph.com/admin/repo/auth). [#658](https://github.com/sourcegraph/sourcegraph/issues/658) +- Permissions background syncing is now supported for GitHub via site configuration `"permissions.backgroundSync": {"enabled": true}`. [#8890](https://github.com/sourcegraph/sourcegraph/issues/8890) +- Search: Adding `stable:true` to a query ensures a deterministic search result order. This is an experimental parameter. It applies only to file contents, and is limited to at max 5,000 results (consider using [the paginated search API](https://docs.sourcegraph.com/api/graphql/search#sourcegraph-3-9-experimental-paginated-search) if you need more than that.). [#9681](https://github.com/sourcegraph/sourcegraph/pull/9681). +- After completing the Sourcegraph user feedback survey, a button may appear for tweeting this feedback at [@sourcegraph](https://twitter.com/sourcegraph). [#9728](https://github.com/sourcegraph/sourcegraph/pull/9728) +- `git fetch` and `git clone` now inherit the parent process environment variables. This allows site admins to set `HTTPS_PROXY` or [git http configurations](https://git-scm.com/docs/git-config/2.26.0#Documentation/git-config.txt-httpproxy) via environment variables. For cluster environments site admins should set this on the gitserver container. [#250](https://github.com/sourcegraph/sourcegraph/issues/250) +- Experimental: Search for file contents using `and`- and `or`-expressions in queries. Enabled via the global settings value `{"experimentalFeatures": {"andOrQuery": "enabled"}}`. [#8567](https://github.com/sourcegraph/sourcegraph/issues/8567) +- Always include forks or archived repositories in searches via the global/org/user settings with `"search.includeForks": true` or `"search.includeArchived": true` respectively. [#9927](https://github.com/sourcegraph/sourcegraph/issues/9927) +- observability (debugging): It is now possible to log all Search and GraphQL requests slower than N milliseconds, using the new site configuration options `observability.logSlowGraphQLRequests` and `observability.logSlowSearches`. +- observability (monitoring): **More metrics monitored and alerted on, more legible dashboards** + - Dashboard panels now show an orange/red background color when the defined warning/critical alert threshold has been met, making it even easier to see on a dashboard what is in a bad state. + - Symbols: failing `symbols` -> `frontend-internal` requests are now monitored. [#9732](https://github.com/sourcegraph/sourcegraph/issues/9732) + - Frontend dasbhoard: Search error types are now broken into distinct panels for improved visibility/legibility. + - **IMPORTANT**: If you have previously configured alerting on any of these panels or on "hard search errors", you will need to reconfigure it after upgrading. + - Frontend dasbhoard: Search error and latency are now broken down by type: Browser requests, search-based code intel requests, and API requests. +- observability (debugging): **Distributed tracing is a powerful tool for investigating performance issues.** The following changes have been made with the goal of making it easier to use distributed tracing with Sourcegraph: - The site configuration field `"observability.tracing": { "sampling": "..." }` allows a site admin to control which requests generate tracing data. - `"all"` will trace all requests. @@ -30,6 +976,7 @@ All notable changes to Sourcegraph are documented in this file. } ``` + - Jaeger is now included in the Sourcegraph deployment configuration by default if you are using Kubernetes, Docker Compose, or the pure Docker cluster deployment model. (It is not yet included in the single Docker container distribution.) It will be included as part of upgrading to 3.15 in these deployment models, unless disabled. - The site configuration field, `useJaeger`, is deprecated in favor of `observability.tracing`. - Support for configuring Lightstep as a distributed tracer is deprecated and will be removed in a subsequent release. Instances that use Lightstep with Sourcegraph are encouraged to migrate to Jaeger (directions for running Jaeger alongside Sourcegraph are included in the installation instructions). @@ -44,16 +991,58 @@ All notable changes to Sourcegraph are documented in this file. - `Campaign.changesetPlans` has been renamed to `campaign.changesetPlan`. - `createCampaignPlanFromPatches` mutation has been renamed to `createPatchSetFromPatches`. - Removed the scoped search field on tree pages. When browsing code, the global search query will now get scoped to the current tree or file. [#9225](https://github.com/sourcegraph/sourcegraph/pull/9225) +- Instances without a license key that exceed the published user limit will now display a notice to all users. ### Fixed - `.*` in the filter pattern were ignored and led to missing search results. [#9152](https://github.com/sourcegraph/sourcegraph/pull/9152) -- monitoring: the Syntect Server dashboard's "Worker timeouts" can no longer appear to go negative. [#9523](https://github.com/sourcegraph/sourcegraph/issues/9523) -- monitoring: the Syntect Server dashboard's "Worker timeouts" no longer incorrectly shows multiple values. [#9524](https://github.com/sourcegraph/sourcegraph/issues/9524) -- monitoring: the Syntect Server dashboard's panels are no longer compacted, for improved visibility. [#9525](https://github.com/sourcegraph/sourcegraph/issues/9525) +- The Phabricator integration no longer makes duplicate requests to Phabricator's API on diff views. [#8849](https://github.com/sourcegraph/sourcegraph/issues/8849) +- Changesets on repositories that aren't available on the instance anymore are now hidden instead of failing. [#9656](https://github.com/sourcegraph/sourcegraph/pull/9656) +- observability (monitoring): + - **Dashboard and alerting bug fixes** + - Syntect Server dashboard: "Worker timeouts" can no longer appear to go negative. [#9523](https://github.com/sourcegraph/sourcegraph/issues/9523) + - Symbols dashboard: "Store fetch queue size" can no longer appear to go negative. [#9731](https://github.com/sourcegraph/sourcegraph/issues/9731) + - Syntect Server dashboard: "Worker timeouts" no longer incorrectly shows multiple values. [#9524](https://github.com/sourcegraph/sourcegraph/issues/9524) + - Searcher dashboard: "Search errors on unindexed repositories" no longer includes cancelled search requests (which are expected). + - Fixed an issue where NaN could leak into the `alert_count` metric. [#9832](https://github.com/sourcegraph/sourcegraph/issues/9832) + - Gitserver: "resolve_revision_duration_slow" alert is no longer flaky / non-deterministic. [#9751](https://github.com/sourcegraph/sourcegraph/issues/9751) + - Git Server dashboard: there is now a panel to show concurrent command executions to match the defined alerts. [#9354](https://github.com/sourcegraph/sourcegraph/issues/9354) + - Git Server dashboard: adjusted the critical disk space alert to 15% so it can now fire. [#9351](https://github.com/sourcegraph/sourcegraph/issues/9351) + - **Dashboard visiblity and legibility improvements** + - all: "frontend internal errors" are now broken down just by route, which makes reading the graph easier. [#9668](https://github.com/sourcegraph/sourcegraph/issues/9668) + - Frontend dashboard: panels no longer show misleading duplicate labels. [#9660](https://github.com/sourcegraph/sourcegraph/issues/9660) + - Syntect Server dashboard: panels are no longer compacted, for improved visibility. [#9525](https://github.com/sourcegraph/sourcegraph/issues/9525) + - Frontend dashboard: panels are no longer compacted, for improved visibility. [#9356](https://github.com/sourcegraph/sourcegraph/issues/9356) + - Searcher dashboard: "Search errors on unindexed repositories" is now broken down by code instead of instance for improved readability. [#9670](https://github.com/sourcegraph/sourcegraph/issues/9670) + - Symbols dashboard: metrics are now aggregated instead of per-instance, for improved visibility. [#9730](https://github.com/sourcegraph/sourcegraph/issues/9730) + - Firing alerts are now correctly sorted at the top of dashboards by default. [#9766](https://github.com/sourcegraph/sourcegraph/issues/9766) + - Panels at the bottom of the home dashboard no longer appear clipped / cut off. [#9768](https://github.com/sourcegraph/sourcegraph/issues/9768) + - Git Server dashboard: disk usage now shown in percentages to match the alerts that can fire. [#9352](https://github.com/sourcegraph/sourcegraph/issues/9352) + - Git Server dashboard: the 'echo command duration test' panel now properly displays units in seconds. [#7628](https://github.com/sourcegraph/sourcegraph/issues/7628) + - Dashboard panels showing firing alerts no longer over-count firing alerts due to the number of service replicas. [#9353](https://github.com/sourcegraph/sourcegraph/issues/9353) ### Removed +- The experimental feature discussions is marked as deprecated. GraphQL and configuration fields related to it will be removed in 3.16. [#9649](https://github.com/sourcegraph/sourcegraph/issues/9649) + +## 3.14.4 + +### Fixed + +- A potential security vulnerability with in the authentication workflow has been fixed. [#10167](https://github.com/sourcegraph/sourcegraph/pull/10167) + +## 3.14.3 + +### Fixed + +- phabricator: Duplicate requests to phabricator API from sourcegraph extensions. [#8849](https://github.com/sourcegraph/sourcegraph/issues/8849) + +## 3.14.2 + +### Fixed + +- campaigns: Ignore changesets where repo does not exist anymore. [#9656](https://github.com/sourcegraph/sourcegraph/pull/9656) + ## 3.14.1 ### Added @@ -268,7 +1257,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ### Added -- Bitbucket Server repositories with the label `archived` can be excluded from search with `archived:no` [syntax](https://docs.sourcegraph.com/user/search/queries). [#5494](https://github.com/sourcegraph/sourcegraph/issues/5494) +- Bitbucket Server repositories with the label `archived` can be excluded from search with `archived:no` [syntax](https://docs.sourcegraph.com/code_search/reference/queries). [#5494](https://github.com/sourcegraph/sourcegraph/issues/5494) - Add button to download file in code view. [#5478](https://github.com/sourcegraph/sourcegraph/issues/5478) - The new `allowOrgs` site config setting in GitHub `auth.providers` enables admins to restrict GitHub logins to members of specific GitHub organizations. [#4195](https://github.com/sourcegraph/sourcegraph/issues/4195) - Support case field in repository search. [#7671](https://github.com/sourcegraph/sourcegraph/issues/7671) @@ -320,7 +1309,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ## 3.11.0 -**Important:** If you use `SITE_CONFIG_FILE` or `CRITICAL_CONFIG_FILE`, please be sure to follow the steps in: [migration notes for Sourcegraph v3.11+](doc/admin/migration/3_11.md) after upgrading. +**Important:** If you use `SITE_CONFIG_FILE` or `CRITICAL_CONFIG_FILE`, please be sure to follow the steps in: [migration notes for Sourcegraph v3.11+](https://docs.sourcegraph.com/admin/migration/3_11.md) after upgrading. ### Added @@ -331,7 +1320,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke - Logging for GraphQL API requests not issued by Sourcegraph is now much more verbose, allowing for easier debugging of problematic queries and where they originate from. [#5706](https://github.com/sourcegraph/sourcegraph/issues/5706) - A new campaign type finds and removes leaked NPM credentials. [#6893](https://github.com/sourcegraph/sourcegraph/pull/6893) - Campaigns can now be retried to create failed changesets due to ephemeral errors (e.g. network problems when creating a pull request on GitHub). [#6718](https://github.com/sourcegraph/sourcegraph/issues/6718) -- The initial release of [structural code search](https://docs.sourcegraph.com/user/search/structural). +- The initial release of [structural code search](https://docs.sourcegraph.com/code_search/reference/structural). ### Changed @@ -356,7 +1345,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ### Removed -- The management console has been removed. All critical configuration previously stored in the management console will be automatically migrated to your site configuration. For more information about this change, or if you use `SITE_CONFIG_FILE` / `CRITICAL_CONFIG_FILE`, please see the [migration notes for Sourcegraph v3.11+](doc/admin/migration/3_11.md). +- The management console has been removed. All critical configuration previously stored in the management console will be automatically migrated to your site configuration. For more information about this change, or if you use `SITE_CONFIG_FILE` / `CRITICAL_CONFIG_FILE`, please see the [migration notes for Sourcegraph v3.11+](https://docs.sourcegraph.com/admin/migration/3_11.md). ## 3.10.4 @@ -529,12 +1518,12 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ### Added -- A [migration guide for Sourcegraph v3.7+](doc/admin/migration/3_7.md). +- A [migration guide for Sourcegraph v3.7+](https://docs.sourcegraph.com/admin/migration/3_7.md). ### Fixed - Fixed an issue where some repositories with very long symbol names would fail to index after v3.7. -- We now retain one prior search index version after an upgrade, meaning upgrading AND downgrading from v3.6.2 <-> v3.7.2 is now 100% seamless and involves no downtime or negated search performance while repositories reindex. Please refer to the [v3.7+ migration guide](doc/admin/migration/3_7.md) for details. +- We now retain one prior search index version after an upgrade, meaning upgrading AND downgrading from v3.6.2 <-> v3.7.2 is now 100% seamless and involves no downtime or negated search performance while repositories reindex. Please refer to the [v3.7+ migration guide](https://docs.sourcegraph.com/admin/migration/3_7.md) for details. ## 3.7.1 @@ -587,7 +1576,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ### Added - The `github.exclude` setting in [GitHub external service config](https://docs.sourcegraph.com/admin/external_service/github#configuration) additionally allows you to specify regular expressions with `{"pattern": "regex"}`. -- A new [`quicklinks` setting](https://docs.sourcegraph.com/user/quick_links) allows adding links to be displayed on the homepage and search page for all users (or users in an organization). +- A new [`quicklinks` setting](https://docs.sourcegraph.com/user/personalization/quick_links) allows adding links to be displayed on the homepage and search page for all users (or users in an organization). - Compatibility with the [Sourcegraph for Bitbucket Server](https://github.com/sourcegraph/bitbucket-server-plugin) plugin. - Support for [Bitbucket Cloud](https://bitbucket.org) as an external service. @@ -632,7 +1621,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ### Added -- A new [`quicklinks` setting](https://docs.sourcegraph.com/user/quick_links) allows adding links to be displayed on the homepage and search page for all users (or users in an organization). +- A new [`quicklinks` setting](https://docs.sourcegraph.com/user/personalization/quick_links) allows adding links to be displayed on the homepage and search page for all users (or users in an organization). - Site admins can prevent the icon in the top-left corner of the screen from spinning on hovers by setting `"branding": { "disableSymbolSpin": true }` in their site configuration. ### Fixed @@ -966,7 +1955,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke - Added Docker-specific help text when running the Sourcegraph docker image in an environment with an sufficient open file descriptor limit. - Added syntax highlighting for Kotlin and Dart. -- Added a management console environment variable to disable HTTPS, see [the docs](doc/admin/management_console.md#can-i-disable-https-on-the-management-console) for more information. +- Added a management console environment variable to disable HTTPS, see [the docs](https://docs.sourcegraph.com/admin/management_console.md#can-i-disable-https-on-the-management-console) for more information. - Added `auth.disableUsernameChanges` to critical configuration to prevent users from changing their usernames. - Site admins can query a user by email address or username from the GraphQL API. - Added a search query builder to the main search page. Click "Use search query builder" to open the query builder, which is a form with separate inputs for commonly used search keywords. @@ -978,7 +1967,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ### Fixed -- Fixed an issue where the management console would improperly regenerate the TLS cert/key unless `CUSTOM_TLS=true` was set. See the documentation for [how to use your own TLS certificate with the management console](doc/admin/management_console.md#how-can-i-use-my-own-tls-certificates-with-the-management-console). +- Fixed an issue where the management console would improperly regenerate the TLS cert/key unless `CUSTOM_TLS=true` was set. See the documentation for [how to use your own TLS certificate with the management console](https://docs.sourcegraph.com/admin/management_console.md#how-can-i-use-my-own-tls-certificates-with-the-management-console). ## 3.0.1 @@ -998,7 +1987,7 @@ This is `3.12.8` release with internal infrastructure fixes to publish the docke ## 3.0.0 -See the changelog entries for 3.0.0 beta releases and our [3.0](doc/admin/migration/3_0.md) upgrade guide if you are upgrading from 2.x. +See the changelog entries for 3.0.0 beta releases and our [3.0](https://docs.sourcegraph.com/admin/migration/3_0.md) upgrade guide if you are upgrading from 2.x. ## 3.0.0-beta.4 @@ -1201,7 +2190,7 @@ See the changelog entries for 3.0.0 beta releases and our [3.0](doc/admin/migrat ### Removed -- The deprecated environment variables `SRC_SESSION_STORE_REDIS` and `REDIS_MASTER_ENDPOINT` are no longer used to configure alternative redis endpoints. For more information, see "[Using external databases with Sourcegraph](https://docs.sourcegraph.com/admin/external_database)". +- The deprecated environment variables `SRC_SESSION_STORE_REDIS` and `REDIS_MASTER_ENDPOINT` are no longer used to configure alternative redis endpoints. For more information, see "[using external services with Sourcegraph](https://docs.sourcegraph.com/admin/external_services)". ## 2.11.1 @@ -1586,7 +2575,7 @@ See the changelog entries for 3.0.0 beta releases and our [3.0](doc/admin/migrat - Code intelligence indexes are now built for all repositories in the background, regardless of whether or not they are visited directly by a user. - Language servers are now automatically enabled when visiting a repository. For example, visiting a Go repository will now automatically download and run the relevant Docker container for Go code intelligence. - This change only affects when Sourcegraph is deployed using the `sourcegraph/server` Docker image (not using Kubernetes). - - You will need to use the new `docker run` command at https://docs.sourcegraph.com/#quickstart in order for this feature to be enabled. Otherwise, you will receive errors in the log about `/var/run/docker.sock` and things will work just as they did before. See https://docs.sourcegraph.com/extensions/language_servers for more information. + - You will need to use the new `docker run` command at https://docs.sourcegraph.com/#quick-install in order for this feature to be enabled. Otherwise, you will receive errors in the log about `/var/run/docker.sock` and things will work just as they did before. See https://docs.sourcegraph.com/extensions/language_servers for more information. - The site admin Analytics page will now display the number of "Code Intelligence" actions each user has made, including hovers, jump to definitions, and find references, on the Sourcegraph webapp or in a code host integration or extension. - An experimental cross repository jump to definition which consults the OSS index on Sourcegraph.com. This is disabled by default; use `"experimentalFeatures": { "jumpToDefOSSIndex": "enabled" }` in your site configuration to enable it. - Users can now view Git branches, tags, and commits, and compare Git branches and revisions on Sourcegraph. (The code host icon in the header takes you to the commit on the code host.) diff --git a/CODENOTIFY b/CODENOTIFY new file mode 100644 index 000000000000..9700c8b3a764 --- /dev/null +++ b/CODENOTIFY @@ -0,0 +1,3 @@ +# See https://github.com/sourcegraph/codenotify for documentation. + +**/CODEOWNERS @nicksnyder diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce5c32827f20..11c55a39da2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,12 @@ In short, we are open to nearly all contributions! We love feedback in all forms, issues, comments, PRs, etc! +## Contributing to `enterprise/` code + +Anyone is free to contribute changes to any file in this repository, including `enterprise/` code. Once you open a pull request, the CLA bot will require you sign our contributor CLA before we can accept the change. + +## Contributing large changes, new features, etc. + Unless you feel confident your change will be accepted (trivial bug fixes, code cleanup, etc) you should first create an issue or a [Sourcegraph RFC](https://about.sourcegraph.com/handbook/communication/rfcs#external-contributors) (preferred for bigger changes) to discuss your change with us. This lets us all discuss the design and proposed implementation of your change, which helps ensure your time is well spent and that your contribution will be accepted. > Exception: If you contribute functionality that already exists as a [paid Sourcegraph feature](https://about.sourcegraph.com/pricing/), we are unlikely to accept it. Consult us beforehand for a definitive answer. (We'll add more details about the process here, and they'll be similar to [GitLab's stewardship principles](https://about.gitlab.com/stewardship/#contributing-an-existing-ee-feature-to-ce).) @@ -11,4 +17,4 @@ Unless you feel confident your change will be accepted (trivial bug fixes, code ## Code of Conduct All interactions with the Sourcegraph open source project are governed by the -[Sourcegraph Code of Conduct](https://about.sourcegraph.com/community/code_of_conduct). +[Sourcegraph Community Code of Conduct](https://handbook.sourcegraph.com/company-info-and-process/community/code_of_conduct/). diff --git a/LICENSE b/LICENSE index d01c1615a068..ddea14606cb8 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1 @@ -LICENSE.apache (Apache License) applies to all files in this repository, except for those in the enterprise/ and web/src/enterprise/ directories, which are covered by LICENSE.enterprise. +LICENSE.apache (Apache License) applies to all files in this repository, except for those in or under any directory named "enterprise," which are covered by LICENSE.enterprise. diff --git a/LICENSE.apache b/LICENSE.apache index a0ffe05e58ce..26c804f3aedc 100644 --- a/LICENSE.apache +++ b/LICENSE.apache @@ -1,9 +1,9 @@ -############################################################################## -## ## -## NOTE: The following license applies to all files in this repository, ## -## except those in the enterprise/ and web/src/enterprise/ directories. ## -## ## -############################################################################## +##################################################################################### +## ## +## NOTE: The following license applies to all files in this repository, ## +## except those in the enterprise/ and client/web/src/enterprise/ directories. ## +## ## +##################################################################################### diff --git a/README.md b/README.md index 39b14c10ded2..35ae281808b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Sourcegraph +# Sourcegraph [![build](https://badge.buildkite.com/00bbe6fa9986c78b8e8591cffeb0b0f2e8c4bb610d7e339ff6.svg?branch=master)](https://buildkite.com/sourcegraph/sourcegraph) [![apache license](https://img.shields.io/badge/license-Apache-blue.svg)](LICENSE) @@ -9,69 +9,56 @@ **Features** -- Fast global code search with a hybrid backend that combines a trigram index with in-memory streaming -- Code intelligence for many languages via the [Language Server Protocol](https://langserver.org/) -- Enhances GitHub, GitLab, Phabricator, and other code hosts and code review tools via the [Sourcegraph browser extension](https://docs.sourcegraph.com/integration/browser_extension) -- Integration with third-party developer tools via the [Sourcegraph extension API](https://docs.sourcegraph.com/extensions) +- Fast global code search with a hybrid backend that combines a trigram index with in-memory streaming. +- Code intelligence for many languages via the [Language Server Index Format](https://lsif.dev/). +- Enhances GitHub, GitLab, Phabricator, and other code hosts and code review tools via the [Sourcegraph browser extension](https://docs.sourcegraph.com/integration/browser_extension). +- Integration with third-party developer tools via the [Sourcegraph extension API](https://docs.sourcegraph.com/extensions). ## Try it yourself - Try out the public instance on any open-source repository at [sourcegraph.com](https://sourcegraph.com/github.com/golang/go/-/blob/src/net/http/httptest/httptest.go#L41:6&tab=references). - Install the free and open-source [browser extension](https://chrome.google.com/webstore/detail/sourcegraph/dgjhfomjieaadpoljlnidmbgkdffpack?hl=en). -- Spin up your own instance with the [quickstart installation guide](https://docs.sourcegraph.com/#quickstart). +- Spin up your own instance with the [quickstart installation guide](https://docs.sourcegraph.com/#getting-started) - File feature requests and bug reports in [our issue tracker](https://github.com/sourcegraph/sourcegraph/issues). - Visit [about.sourcegraph.com](https://about.sourcegraph.com) for more information about product features. -## Development - -### Prerequisites - -- Git -- Go (1.13 or later) -- Docker -- PostgreSQL (v11 or higher) -- Node.js (version 8 or 10) -- Redis -- Yarn -- Nginx - -For a detailed guide to installing prerequisites, see [these -instructions](doc/dev/local_development.md#step-1-install-dependencies). - -### Installation +## Installation -> Prebuilt Docker images are the fastest way to use Sourcegraph Enterprise. See the [quickstart installation guide](https://docs.sourcegraph.com/#quickstart). +> **Prebuilt Docker images are the fastest way to use Sourcegraph Enterprise. See the [quickstart installation guide](https://docs.sourcegraph.com/#getting-started).** To use Sourcegraph OSS: -1. [Ensure Docker is running](doc/dev/local_development.md#step-3-macos-start-docker) -1. [Initialize the PostgreSQL database](doc/dev/local_development.md#step-2-initialize-your-database) -1. [Configure the HTTPS reverse proxy](doc/dev/local_development.md#step-5-configure-https-reverse-proxy) -1. Start the development server - - ``` - ./dev/start.sh - ``` +1. [Initialize the PostgreSQL database](doc/dev/getting-started/quickstart_2_initialize_database.md) +1. [Ensure Docker is running](doc/dev/getting-started/quickstart_3_start_docker.md) +1. [Configure the HTTPS reverse proxy](doc/dev/getting-started/quickstart_5_configure_https_reverse_proxy.md) +1. [Start the development server](doc/dev/getting-started/quickstart_6_start_server.md) + ```sh + ./dev/start.sh + ``` Sourcegraph should now be running at https://sourcegraph.test:3443. -For detailed instructions and troubleshooting, see the [local development documentation](./doc/dev/local_development.md). +For detailed instructions and troubleshooting, see the [local development documentation](./doc/dev/index.md). + +## Development + +Refer to the [Developing Sourcegraph guide](doc/dev/index.md) to get started. ### Documentation The `doc` directory has additional documentation for developing and understanding Sourcegraph: - [Project FAQ](./doc/admin/faq.md) -- [Architecture](./doc/dev/architecture/index.md): high-level architecture -- [Database setup](./doc/dev/postgresql.md): database setup and best practices +- [Architecture](./doc/dev/background-information/architecture/index.md): high-level architecture +- [Database setup](./doc/dev/background-information/postgresql.md): database best practices - [General style guide](https://about.sourcegraph.com/handbook/communication/style_guide) -- [Go style guide](https://about.sourcegraph.com/handbook/engineering/go_style_guide) -- [Documentation style guide](https://about.sourcegraph.com/handbook/documentation) -- [GraphQL API](./doc/dev/graphql_api.md): useful tips when modifying the GraphQL API +- [Go style guide](https://about.sourcegraph.com/handbook/engineering/languages/go) +- [Documentation style guide](https://about.sourcegraph.com/handbook/engineering/product_documentation) +- [GraphQL API](./doc/api/graphql/index.md): useful tips when modifying the GraphQL API - [Contributing](./CONTRIBUTING.md) -### License +## License -Sourcegraph OSS is available freely under the [Apache 2 license](LICENSE.apache). Sourcegraph OSS comprises all files in this repository except those in the `enterprise/` and `web/src/enterprise` directories. +Sourcegraph OSS is available freely under the [Apache 2 license](LICENSE.apache). Sourcegraph OSS comprises all files in this repository except those in the `enterprise/` and `client/web/src/enterprise` directories. -All files in the `enterprise/` and `web/src/enterprise/` directories are subject to the [Sourcegraph Enterprise license](LICENSE.enterprise). +All files in the `enterprise/` and `client/web/src/enterprise/` directories are subject to the [Sourcegraph Enterprise license](LICENSE.enterprise). diff --git a/babel.config.js b/babel.config.js index 28568fd4334c..b7b0f5137574 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,12 +1,26 @@ // @ts-check +const logger = require('gulplog') +const semver = require('semver') +const path = require('path') /** @type {import('@babel/core').ConfigFunction} */ module.exports = api => { const isTest = api.env('test') api.cache.forever() + /** + * Whether to instrument files with istanbul for code coverage. + * This is needed for e2e test coverage. + */ + const instrument = Boolean(process.env.COVERAGE_INSTRUMENT && JSON.parse(process.env.COVERAGE_INSTRUMENT)) + if (instrument) { + logger.info('Instrumenting code for coverage tracking') + } + return { presets: [ + // Can't put this in plugins because it needs to run as the last plugin. + ...(instrument ? [{ plugins: [['babel-plugin-istanbul', { cwd: path.resolve(__dirname) }]] }] : []), [ '@babel/preset-env', { @@ -14,18 +28,29 @@ module.exports = api => { modules: isTest ? 'commonjs' : false, bugfixes: true, useBuiltIns: 'entry', - corejs: 3, + include: [ + // Polyfill URL because Chrome and Firefox are not spec-compliant + // Hostnames of URIs with custom schemes (e.g. git) are not parsed out + 'web.url', + // URLSearchParams.prototype.keys() is not iterable in Firefox + 'web.url-search-params', + // Commonly needed by extensions (used by vscode-jsonrpc) + 'web.immediate', + // Always define Symbol.observable before libraries are loaded, ensuring interopability between different libraries. + 'esnext.symbol.observable', + // Webpack v4 chokes on optional chaining and nullish coalescing syntax, fix will be released with webpack v5. + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator', + ], + // See https://github.com/zloirock/core-js#babelpreset-env + corejs: semver.minVersion(require('./package.json').dependencies['core-js']), }, ], '@babel/preset-typescript', '@babel/preset-react', ], - plugins: [ - 'babel-plugin-lodash', - // Required to support typeorm decorators in ./cmd/precise-code-intel - ['@babel/plugin-proposal-decorators', { legacy: true }], - // Node 12 (released 2019 Apr 23) supports these natively, but there seem to be issues when used with TypeScript. - ['@babel/plugin-proposal-class-properties', { loose: true }], - ], + plugins: [['@babel/plugin-transform-typescript', { isTSX: true }], 'babel-plugin-lodash'], + // Required for d3-array v1.2 (dependency of recharts). See https://github.com/babel/babel/issues/11038 + ignore: [new RegExp('d3-array/src/cumsum.js')], } } diff --git a/browser/.editorconfig b/browser/.editorconfig deleted file mode 100644 index f2bec05df855..000000000000 --- a/browser/.editorconfig +++ /dev/null @@ -1,14 +0,0 @@ - -[*] -insert_final_newline = true -charset = utf-8 -indent_size = 4 -indent_style = space -end_of_line = lf -trim_trailing_whitespace = true - -[*.md] -trim_trailing_whitespace = false - -[{*.json,*.js,*.yml}] -indent_size = 2 diff --git a/browser/.eslintignore b/browser/.eslintignore deleted file mode 100644 index a55332ef4b6c..000000000000 --- a/browser/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -/build/ -coverage/ -out/ diff --git a/browser/.eslintrc.js b/browser/.eslintrc.js deleted file mode 100644 index 1ddd180efbd9..000000000000 --- a/browser/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -const baseConfig = require('../.eslintrc') -module.exports = { - extends: '../.eslintrc.js', - parserOptions: { - ...baseConfig.parserOptions, - project: [__dirname + '/tsconfig.json', __dirname + '/src/e2e/tsconfig.json'], - }, - overrides: baseConfig.overrides, -} diff --git a/browser/.github/PULL_REQUEST_TEMPLATE.md b/browser/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 0fc126dc15ba..000000000000 --- a/browser/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,19 +0,0 @@ -This PR changes: - -. - -Testing plan: - - - -I have tested on: - - - -- [ ] Chrome -- [ ] Firefox -- [ ] Safari -- [ ] Phabricator Bundle diff --git a/browser/.gitignore b/browser/.gitignore deleted file mode 100644 index 942520dbbe8f..000000000000 --- a/browser/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -node_modules -npm-debug.log -yarn-error.log -.DS_Store - -# Ignore build/ except for the Phabricator package.json -/build/* -!/build/phabricator/ -/build/phabricator/* -!/build/phabricator/package.json -!/build/phabricator/package-lock.json - -.checksum -.extension - -*.zip -*.crx -*.pem -*.xpi -update.xml -npm-debug.log.* -/.gtm/ diff --git a/browser/LICENSE b/browser/LICENSE deleted file mode 100644 index 2e8d210b6167..000000000000 --- a/browser/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2018 Sourcegraph - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/browser/README.md b/browser/README.md deleted file mode 100644 index 61e94c134773..000000000000 --- a/browser/README.md +++ /dev/null @@ -1,152 +0,0 @@ -# Sourcegraph browser extension - -[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) -![license](https://img.shields.io/badge/license-MIT-blue.svg) - -[![chrome version](https://img.shields.io/chrome-web-store/v/dgjhfomjieaadpoljlnidmbgkdffpack.svg?logo=Google%20Chrome&logoColor=white)](https://chrome.google.com/webstore/detail/sourcegraph/dgjhfomjieaadpoljlnidmbgkdffpack) -[![chrome users](https://img.shields.io/chrome-web-store/users/dgjhfomjieaadpoljlnidmbgkdffpack.svg)](https://chrome.google.com/webstore/detail/sourcegraph/dgjhfomjieaadpoljlnidmbgkdffpack) -[![chrome rating](https://img.shields.io/chrome-web-store/rating/dgjhfomjieaadpoljlnidmbgkdffpack.svg)](https://chrome.google.com/webstore/detail/sourcegraph/dgjhfomjieaadpoljlnidmbgkdffpack)\ - -## Overview - -The Sourcegraph browser extension adds tooltips to code on GitHub, Phabricator, and Bitbucket. -The tooltips include features like: - -- symbol type information & documentation -- go to definition & find references (currently for Go, Java, TypeScript, JavaScript, Python) -- find references - -#### ๐Ÿš€ Install: [**Sourcegraph for Chrome**](https://chrome.google.com/webstore/detail/sourcegraph/dgjhfomjieaadpoljlnidmbgkdffpack) - -#### ๐Ÿš€ Install: [**Sourcegraph for Firefox**](https://docs.sourcegraph.com/integration/browser_extension) - -It works as follows: - -- when visiting e.g. https://github.com/..., the extension injects a content script (inject.bundle.js) -- there is a background script running to access certain chrome APIs, like storage (background.bundle.js) -- a "code view" contains rendered (syntax highlighted) code (in an HTML table); the extension adds event listeners to the code view which control the tooltip -- when the user mouses over a code table cell, the extension modifies the DOM node: - - text nodes are wrapped in (so hover/click events have appropriate specificity) - - element nodes may be recursively split into multiple element nodes (e.g. a &Router{namedRoutes: contains multiple code tokens, and event targets need more granular ranges) - - We assume syntax highlighting takes care of the base case of wrapping a discrete language symbol - - tooltip data is fetched from the Sourcegraph API -- when an event occurs, we modify a central state store about what kind of tooltip to display -- code subscribes to the central store updates, and creates/adds/removes/hides an absolutely positioned element (the tooltip) - -## Project layout - -- `src/extension/` - - Entrypoint for browser extension builds. (Includes bundled assets, background scripts, options) -- `src/browser` - - [A wrapper around the browser APIs.](./src/browser/README.md) -- `src/libs/` - - Isolated pieces of the browser extension. This contains code that is specific to code hosts and separate "mini applications" included in the browser extension such as the `src` omnibar cli. -- `src/libs/phabricator/` - - Entrypoint for Phabricator extension. This is used by the browser extension and [sourcegraph/phabricator-extension](https://github.com/sourcegraph/phabricator-extension). -- `src/shared/` - - Code shared by the extension and the libraries. Ideally, nothing in here should reach into any other directory. -- `src/config/` - - Polyfills and configuration/plumbing code that is bundled via webpack. The configuration code adds properties to `window` that make it easier to tell what environment the script is running in. This is useful because the code can be run in the content script, background, options page, or in the actual page when injected by Phabricator and each environment will have different ways to do different things. -- `src/e2e/` - - E2e test suite. -- `scripts/` - - Development scripts. -- `webpack` - - Build configs. -- `build` - - Generated directory containing the output from webpack and the generated bundles for each browser. - -## Requirements - -- `node` -- `yarn` -- `make` - -## Development - -For each browser run: - -```bash -yarn run dev -``` - -To only build for a single browser (which makes builds faster in local development), set the env var `TARGETS=chrome` or `TARGETS=firefox`. - -Now, follow the steps below for the browser you intend to work with. - -### Chrome - -- Browse to [chrome://extensions](chrome://extensions). -- If you already have the Sourcegraph extension installed, disable it by unchecking the "Enabled" box. -- Click on [Load unpacked extensions](https://developer.chrome.com/extensions/getstarted#unpacked), and select the `build/chrome` folder. -- Browse to any public repository on GitHub to confirm it is working. -- After making changes it is necessary to refresh the extension. This is done by going to [chrome://extensions](chrome://extensions) and clicking "Reload". - -![Add dist folder](readme-load-extension-asset.png) - -#### Updating the bundle - -Click reload for Sourcegraph at `chrome://extensions` - -### Firefox (hot reloading) - -In a separate terminal session run: - -```bash -yarn global add web-ext -yarn run dev:firefox -``` - -A Firefox window will be spun up with the extension already installed. - -#### Updating the bundle - -Save a file and wait for webpack to finish rebuilding. - -#### Caveats - -The window that is spun up is completely separate from any existing sessions you have on Firefox. -You'll have to sign into everything at the beginning of each development session(each time you run `yarn run dev:firefox`). -You should ensure you're signed into any Sourcegraph instance you point the extension at as well as GitHub. - -### Firefox (manual) - -- Go to `about:debugging` -- Select "Enable add-on debugging" -- Click "Load Temporary Add-on" and select "firefox-bundle.xpi" -- [More information](https://developer.mozilla.org/en-US/docs/Tools/about:debugging#Add-ons) - -#### Updating the bundle - -Click reload for Sourcegraph at `about:debugging` - -## Testing - -- Unit tests: `yarn test` -- E2E tests: `yarn test-e2e` - -### e2e tests - -The test suite in e2e/github.test.ts runs on the release branch `bext/release` in both Chrome and Firefox against a Sourcegraph Docker instance. - -The test suite in e2e/phabricator.test.ts tests the Phabricator native integration. -It assumes an existing Sourcegraph and Phabricator instance that has the Phabricator extension installed. -There are automated scripts to set up the Phabricator instance, see https://docs.sourcegraph.com/dev/phabricator_gitolite. -It currently does not run in CI and is intended to be run manually for release testing. - -e2e/bitbucket.test.ts tests the browser extension on a Bitbucket Server instance. - -e2e/gitlab.test.ts tests the browser extension on gitlab.com (or a private Gitlab instance). - -## Deploy - -Deployment the Chrome web store happen automatically in CI when the `bext/release` branch is updated. -Releases are also uploaded to the [GitHub releases -page](https://github.com/sourcegraph/browser-extensions/releases) and tagged in -git. - -To release the latest commit on master, ensure your master is up-to-date and run - -```sh -git push origin master:bext/release -``` diff --git a/browser/babel.config.js b/browser/babel.config.js deleted file mode 100644 index 8095fa6fb11d..000000000000 --- a/browser/babel.config.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check - -/** @type {import('@babel/core').TransformOptions} */ -const config = { - extends: '../babel.config.js', -} - -module.exports = config diff --git a/browser/config/tsconfig.webpack.json b/browser/config/tsconfig.webpack.json deleted file mode 100644 index 4423c2c5d4aa..000000000000 --- a/browser/config/tsconfig.webpack.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "exclude": ["**/*.test.ts", "./node_modules", "./build"], -} diff --git a/browser/config/webpack/base.config.ts b/browser/config/webpack/base.config.ts deleted file mode 100644 index 7302b58939c9..000000000000 --- a/browser/config/webpack/base.config.ts +++ /dev/null @@ -1,84 +0,0 @@ -import MiniCssExtractPlugin from 'mini-css-extract-plugin' -import OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin' -import * as path from 'path' -import * as webpack from 'webpack' - -const buildEntry = (...files: string[]): string[] => files.map(file => path.join(__dirname, file)) - -const contentEntry = '../../src/config/content.entry.js' -const backgroundEntry = '../../src/config/background.entry.js' -const optionsEntry = '../../src/config/options.entry.js' -const pageEntry = '../../src/config/page.entry.js' -const extEntry = '../../src/config/extension.entry.js' - -const config: webpack.Configuration = { - entry: { - background: buildEntry(extEntry, backgroundEntry, '../../src/extension/scripts/background.ts'), - options: buildEntry(extEntry, optionsEntry, '../../src/extension/scripts/options.tsx'), - inject: buildEntry(extEntry, contentEntry, '../../src/extension/scripts/inject.ts'), - phabricator: buildEntry(pageEntry, '../../src/libs/phabricator/extension.ts'), - integration: buildEntry(pageEntry, '../../src/integration/integration.ts'), - - style: path.join(__dirname, '../../src/app.scss'), - 'options-style': path.join(__dirname, '../../src/options.scss'), - }, - output: { - path: path.join(__dirname, '../../build/dist/js'), - filename: '[name].bundle.js', - chunkFilename: '[id].chunk.js', - }, - devtool: 'inline-cheap-module-source-map', - - plugins: [ - new MiniCssExtractPlugin({ filename: '../css/[name].bundle.css' }), - new OptimizeCssAssetsPlugin(), - // Code splitting doesn't make sense/work in the browser extension, but we still want to use dynamic import() - new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), - ], - resolve: { - extensions: ['.ts', '.tsx', '.js'], - }, - module: { - rules: [ - { - test: /\.[jt]sx?$/, - use: [ - { - loader: 'babel-loader', - options: { - cacheDirectory: true, - configFile: path.join(__dirname, '..', '..', 'babel.config.js'), - }, - }, - ], - }, - { - // SCSS rule for our own styles and Bootstrap - test: /\.(css|sass|scss)$/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: 'css-loader', - }, - { - loader: 'postcss-loader', - options: { - config: { - path: path.join(__dirname, '../..'), - }, - }, - }, - { - loader: 'sass-loader', - options: { - sassOptions: { - includePaths: [path.resolve(__dirname, '../../../node_modules')], - }, - }, - }, - ], - }, - ], - }, -} -export default config diff --git a/browser/config/webpack/dev.config.ts b/browser/config/webpack/dev.config.ts deleted file mode 100644 index 585bc5ea5228..000000000000 --- a/browser/config/webpack/dev.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as path from 'path' -import * as webpack from 'webpack' -import baseConfig from './base.config' -import { generateBundleUID } from './utils' - -const { plugins, entry, ...base } = baseConfig - -const entries = entry as webpack.Entry - -const entriesWithAutoReload = { - ...entries, - background: [path.join(__dirname, '../../src/extension/scripts/auto-reloading.ts'), ...entries.background], -} - -const config: webpack.Configuration = { - ...base, - entry: process.env.AUTO_RELOAD === 'false' ? entries : entriesWithAutoReload, - mode: 'development', - plugins: (plugins || []).concat( - ...[ - new webpack.DefinePlugin({ - 'process.env': { - NODE_ENV: JSON.stringify('development'), - BUNDLE_UID: JSON.stringify(generateBundleUID()), - USE_EXTENSIONS: JSON.stringify(process.env.USE_EXTENSIONS), - }, - }), - ] - ), -} -export default config diff --git a/browser/config/webpack/prod.config.ts b/browser/config/webpack/prod.config.ts deleted file mode 100644 index b560397e16d9..000000000000 --- a/browser/config/webpack/prod.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import TerserPlugin from 'terser-webpack-plugin' -import * as webpack from 'webpack' -import baseConfig from './base.config' -import { generateBundleUID } from './utils' - -const { plugins, ...base } = baseConfig - -const config: webpack.Configuration = { - ...base, - mode: 'production', - optimization: { - minimize: true, - minimizer: [ - new TerserPlugin({ - sourceMap: true, - terserOptions: { - output: { - // Without this, Uglify will change \u0000 to \0 (NULL byte), - // which causes Chrome to complain that the bundle is not UTF8 - ascii_only: true, - beautify: false, - }, - }, - }), - ], - }, - plugins: (plugins || []).concat( - ...[ - new webpack.DefinePlugin({ - 'process.env': { - NODE_ENV: JSON.stringify('production'), - BUNDLE_UID: JSON.stringify(generateBundleUID()), - USE_EXTENSIONS: JSON.stringify(process.env.USE_EXTENSIONS), - }, - }), - new webpack.ProvidePlugin({ - $: 'jquery', - jQuery: 'jquery', - '$.fn.pjax': 'jquery-pjax', - }), - ] - ), -} -export default config diff --git a/browser/config/webpack/utils.ts b/browser/config/webpack/utils.ts deleted file mode 100644 index 949ba5c1cf95..000000000000 --- a/browser/config/webpack/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import extensionInfo from '../../src/extension/manifest.spec.json' - -/** - * Generates a unique bundle ID that is used to prevent the Phabricator extension - * from returning cached contents after upgrading. - * - * @returns The current extension version from extension.info.json. - */ -export function generateBundleUID(): string { - if (!extensionInfo?.version) { - throw new Error('Could not resolve extension version from manifest.') - } - return extensionInfo.version -} diff --git a/browser/gulpfile.js b/browser/gulpfile.js deleted file mode 100644 index cdaa51bd84d6..000000000000 --- a/browser/gulpfile.js +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-check - -const { spawn } = require('child_process') -const gulp = require('gulp') -const path = require('path') - -function build() { - return spawn('yarn', ['-s', 'run', 'build'], { - stdio: 'inherit', - shell: true, - env: { ...process.env, NODE_OPTIONS: '--max_old_space_size=8192' }, - }) -} - -function watch() { - return spawn('yarn', ['-s', 'run', 'dev'], { - stdio: 'inherit', - shell: true, - env: { ...process.env, NODE_OPTIONS: '--max_old_space_size=8192' }, - }) -} - -module.exports = { build, watch } diff --git a/browser/jest.config.js b/browser/jest.config.js deleted file mode 100644 index ce940198be52..000000000000 --- a/browser/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-check - -/** @type {jest.InitialOptions} */ -const config = require('../jest.config.base') - -/** @type {jest.InitialOptions} */ -module.exports = { ...config, displayName: 'browser', rootDir: __dirname } diff --git a/browser/node_modules/.bin b/browser/node_modules/.bin deleted file mode 120000 index 764a62fef7e1..000000000000 --- a/browser/node_modules/.bin +++ /dev/null @@ -1 +0,0 @@ -../../node_modules/.bin \ No newline at end of file diff --git a/browser/package.json b/browser/package.json deleted file mode 100644 index 074302117b9d..000000000000 --- a/browser/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "private": true, - "version": "1.0.0", - "engines": { - "yarn": ">1.10.0" - }, - "scripts": { - "dev": "NODE_ENV=development NODE_OPTIONS=--max_old_space_size=4096 TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\"}\" node -r ts-node/register scripts/dev", - "dev:no-reload": "AUTO_RELOAD=false yarn run dev", - "dev:firefox": "if type web-ext 2>/dev/null; then web-ext run --source-dir ./build/firefox; else echo 'web-ext not found. Install it with: yarn global add web-ext'; exit 1; fi", - "build": "NODE_ENV=production NODE_OPTIONS=--max_old_space_size=4096 TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\"}\" node -r ts-node/register scripts/build", - "release": "yarn release:chrome", - "release:chrome": "webstore upload --auto-publish --source build/bundles/chrome-bundle.zip --extension-id dgjhfomjieaadpoljlnidmbgkdffpack --client-id $GOOGLE_CLIENT_ID --client-secret $GOOGLE_CLIENT_SECRET --refresh-token $GOOGLE_REFRESH_TOKEN", - "release:ff": "./scripts/release-ff.sh", - "release:npm": "TS_NODE_COMPILER_OPTIONS=\"{\\\"module\\\":\\\"commonjs\\\"}\" ts-node ./scripts/publish-npm.ts", - "lint": "yarn run eslint && yarn run stylelint", - "eslint": "eslint --cache '**/*.[jt]s?(x)'", - "stylelint": "stylelint 'src/**/*.scss'", - "clean": "rm -rf build/ dist/ *.zip *.xpi .checksum", - "test": "jest --testPathIgnorePatterns e2e", - "test-e2e": "mocha './src/e2e/**/*.test.ts'", - "bundlesize": "GITHUB_TOKEN= bundlesize" - }, - "browserslist": [ - "last 3 Chrome versions", - "last 3 Firefox versions" - ], - "bundlesize": [ - { - "path": "./build/dist/js/background.bundle.js" - }, - { - "path": "./build/dist/js/inject.bundle.js" - }, - { - "path": "./build/dist/js/integration.bundle.js" - }, - { - "path": "./build/dist/js/phabricator.bundle.js" - }, - { - "path": "./build/dist/css/style.bundle.css" - } - ] -} diff --git a/browser/postcss.config.js b/browser/postcss.config.js deleted file mode 100644 index 5eb20c761c16..000000000000 --- a/browser/postcss.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - plugins: [require('autoprefixer')], -} diff --git a/browser/scripts/auto-reloading.ts b/browser/scripts/auto-reloading.ts deleted file mode 100644 index 111b6eb25878..000000000000 --- a/browser/scripts/auto-reloading.ts +++ /dev/null @@ -1,32 +0,0 @@ -import signale from 'signale' -import io from 'socket.io' - -/** - * Returns a trigger function that notifies the extension to reload itself. - */ -export const initializeServer = (): (() => void) => { - const logger = new signale.Signale({ scope: 'Auto reloading' }) - logger.config({ displayTimestamp: true }) - - // Since this port is hard-coded, it must match background.ts - const socketIOServer = io.listen(8890) - logger.await('Ready for a browser extension to connect') - socketIOServer.on('connect', () => { - logger.info('Browser extension connected') - }) - socketIOServer.on('disconnect', () => { - logger.info('Browser extension disconnected') - }) - - return () => { - if (Object.keys(socketIOServer.clients().connected).length === 0) { - logger.warn('No browser extension has connected yet, so no reload was triggered') - logger.warn("- Make sure it's enabled") - logger.warn("- Make sure it's in developer mode (unpacked extension)") - logger.warn('- Try manually reloading it ๐Ÿ”„') - } else { - logger.info('Triggering a reload of browser extensions') - socketIOServer.emit('file.change', {}) - } - } -} diff --git a/browser/scripts/build.ts b/browser/scripts/build.ts deleted file mode 100644 index f400e9b16f8c..000000000000 --- a/browser/scripts/build.ts +++ /dev/null @@ -1,28 +0,0 @@ -import signale from 'signale' -import webpack from 'webpack' -import config from '../config/webpack/prod.config' -import * as tasks from './tasks' - -const buildChrome = tasks.buildChrome('prod') -const buildFirefox = tasks.buildFirefox('prod') - -tasks.copyAssets() - -const compiler = webpack(config) - -signale.await('Webpack compilation') - -compiler.run((err, stats) => { - console.log(stats.toString(tasks.WEBPACK_STATS_OPTIONS)) - - if (stats.hasErrors()) { - signale.error('Webpack compilation error') - process.exit(1) - } - signale.success('Webpack compilation done') - - buildChrome() - buildFirefox() - tasks.copyIntegrationAssets() - signale.success('Build done') -}) diff --git a/browser/scripts/dev.ts b/browser/scripts/dev.ts deleted file mode 100644 index 1e7ee7bd5533..000000000000 --- a/browser/scripts/dev.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { noop } from 'lodash' -import signale from 'signale' -import webpack from 'webpack' -import config from '../config/webpack/dev.config' -import * as autoReloading from './auto-reloading' -import * as tasks from './tasks' - -signale.config({ displayTimestamp: true }) - -const triggerReload = process.env.AUTO_RELOAD === 'false' ? noop : autoReloading.initializeServer() - -const buildChrome = tasks.buildChrome('dev') -const buildFirefox = tasks.buildFirefox('dev') - -tasks.copyAssets() - -const compiler = webpack(config) - -signale.info('Running webpack') - -compiler.hooks.watchRun.tap('Notify', () => signale.await('Compiling...')) - -compiler.watch( - { - aggregateTimeout: 300, - }, - (err, stats) => { - signale.complete(stats.toString(tasks.WEBPACK_STATS_OPTIONS)) - - if (err || stats.hasErrors()) { - signale.error('Webpack compilation error') - return - } - signale.success('Webpack compilation done') - - buildChrome() - buildFirefox() - tasks.copyIntegrationAssets() - triggerReload() - } -) diff --git a/browser/scripts/release-ff.sh b/browser/scripts/release-ff.sh deleted file mode 100755 index c680e7f433ff..000000000000 --- a/browser/scripts/release-ff.sh +++ /dev/null @@ -1,24 +0,0 @@ -set -e - -# Setup -yarn global add web-ext -yarn build -rm -rf build/web-ext -mkdir -p build/web-ext - -# Sign the bundle -web-ext sign -s build/firefox -a build/web-ext --api-key $FIREFOX_AMO_ISSUER --api-secret $FIREFOX_AMO_SECRET - -# Upload to gcp and make it public -for filename in $(ls build/web-ext); do - gsutil cp build/web-ext/$filename gs://sourcegraph-for-firefox/$filename - gsutil cp build/web-ext/$filename gs://sourcegraph-for-firefox/latest.xpi - gsutil -m acl set -R -a public-read gs://sourcegraph-for-firefox/$filename - gsutil -m acl set -R -a public-read gs://sourcegraph-for-firefox/latest.xpi -done - -export TS_NODE_COMPILER_OPTIONS="{\"module\":\"commonjs\"}" - -gsutil ls gs://sourcegraph-for-firefox | xargs yarn ts-node scripts/build-updates-manifest.ts -gsutil cp src/extension/updates.manifest.json gs://sourcegraph-for-firefox/updates.json -gsutil -m acl set -R -a public-read gs://sourcegraph-for-firefox/updates.json diff --git a/browser/scripts/tasks.ts b/browser/scripts/tasks.ts deleted file mode 100644 index 11417da6407e..000000000000 --- a/browser/scripts/tasks.ts +++ /dev/null @@ -1,156 +0,0 @@ -/* eslint no-sync: warn */ -import fs from 'fs' -import { omit } from 'lodash' -import path from 'path' -import shelljs from 'shelljs' -import signale from 'signale' -import utcVersion from 'utc-version' -import { Stats } from 'webpack' -import extensionInfo from '../src/extension/manifest.spec.json' -import schema from '../src/extension/schema.json' - -/** - * If true, add to the permissions in the manifest. - * This is needed for e2e tests because it is not possible to accept the permission prompt with puppeteer. - */ -const EXTENSION_PERMISSIONS_ALL_URLS = Boolean( - process.env.EXTENSION_PERMISSIONS_ALL_URLS && JSON.parse(process.env.EXTENSION_PERMISSIONS_ALL_URLS) -) - -export type BuildEnv = 'dev' | 'prod' - -type Browser = 'firefox' | 'chrome' - -const BUILDS_DIR = 'build' - -export const WEBPACK_STATS_OPTIONS: Stats.ToStringOptions = { - all: false, - timings: true, - errors: true, - warnings: true, - colors: true, -} - -function ensurePaths(): void { - shelljs.mkdir('-p', 'build/dist') - shelljs.mkdir('-p', 'build/bundles') - shelljs.mkdir('-p', 'build/chrome') - shelljs.mkdir('-p', 'build/firefox') -} - -export function copyAssets(): void { - signale.await('Copy assets') - const dir = 'build/dist' - shelljs.rm('-rf', dir) - shelljs.mkdir('-p', dir) - shelljs.cp('-R', 'src/extension/assets/*', dir) - shelljs.cp('-R', 'src/extension/views/*', dir) - signale.success('Assets copied') -} - -function copyExtensionAssets(toDir: string): void { - shelljs.mkdir('-p', `${toDir}/js`, `${toDir}/css`, `${toDir}/img`) - shelljs.cp('build/dist/js/background.bundle.js', `${toDir}/js`) - shelljs.cp('build/dist/js/inject.bundle.js', `${toDir}/js`) - shelljs.cp('build/dist/js/options.bundle.js', `${toDir}/js`) - shelljs.cp('build/dist/css/style.bundle.css', `${toDir}/css`) - shelljs.cp('build/dist/css/options-style.bundle.css', `${toDir}/css`) - shelljs.cp('build/dist/css/options-style.bundle.css', `${toDir}/css`) - shelljs.cp('-R', 'build/dist/img/*', `${toDir}/img`) - shelljs.cp('build/dist/background.html', toDir) - shelljs.cp('build/dist/options.html', toDir) -} - -export function copyIntegrationAssets(): void { - shelljs.mkdir('-p', 'build/integration/scripts') - shelljs.mkdir('-p', 'build/integration/css') - shelljs.cp('build/dist/js/phabricator.bundle.js', 'build/integration/scripts') - shelljs.cp('build/dist/js/integration.bundle.js', 'build/integration/scripts') - shelljs.cp('build/dist/js/extensionHostWorker.bundle.js', 'build/integration/scripts') - shelljs.cp('build/dist/css/style.bundle.css', 'build/integration/css') - shelljs.cp('src/phabricator/extensionHostFrame.html', 'build/integration') - // Copy to the ui/assets directory so that these files can be served by the webapp. - shelljs.mkdir('-p', '../ui/assets/extension') - shelljs.cp('-r', 'build/integration/*', '../ui/assets/extension') -} - -const BROWSER_TITLES = { - firefox: 'Firefox', - chrome: 'Chrome', -} - -const BROWSER_BUNDLE_ZIPS = { - firefox: 'firefox-bundle.xpi', - chrome: 'chrome-bundle.zip', -} - -const BROWSER_BLACKLIST = { - chrome: ['applications'] as const, - firefox: ['key'] as const, -} - -function writeSchema(env: BuildEnv, browser: Browser, writeDir: string): void { - fs.writeFileSync(`${writeDir}/schema.json`, JSON.stringify(schema, null, 4)) -} - -const version = utcVersion() - -function writeManifest(env: BuildEnv, browser: Browser, writeDir: string): void { - const manifest = { - ...omit(extensionInfo, ['dev', 'prod', ...BROWSER_BLACKLIST[browser]]), - ...omit(extensionInfo[env], BROWSER_BLACKLIST[browser]), - } - - if (EXTENSION_PERMISSIONS_ALL_URLS) { - manifest.permissions!.push('') - signale.info('Adding to permissions because of env var setting') - } - - if (browser === 'firefox') { - manifest.permissions!.push('') - delete manifest.storage - } - - delete manifest.$schema - - if (env === 'prod') { - manifest.version = version - } - - fs.writeFileSync(`${writeDir}/manifest.json`, JSON.stringify(manifest, null, 4)) -} - -function buildForBrowser(browser: Browser): (env: BuildEnv) => () => void { - ensurePaths() - return env => { - const title = BROWSER_TITLES[browser] - - const buildDir = path.resolve(process.cwd(), `${BUILDS_DIR}/${browser}`) - - writeManifest(env, browser, buildDir) - writeSchema(env, browser, buildDir) - - return () => { - // Allow only building for specific browser targets. - // Useful in local dev for faster builds. - if (process.env.TARGETS && !process.env.TARGETS.includes(browser)) { - return - } - - signale.await(`Building the ${title} ${env} bundle`) - - copyExtensionAssets(buildDir) - - const zipDest = path.resolve(process.cwd(), `${BUILDS_DIR}/bundles/${BROWSER_BUNDLE_ZIPS[browser]}`) - if (zipDest) { - shelljs.mkdir('-p', `./${BUILDS_DIR}/bundles`) - shelljs.exec(`cd ${buildDir} && zip -q -r ${zipDest} *`) - } - - signale.success(`Done building the ${title} ${env} bundle`) - } - } -} - -export const buildFirefox = buildForBrowser('firefox') -export const buildChrome = buildForBrowser('chrome') diff --git a/browser/src/app.scss b/browser/src/app.scss deleted file mode 100644 index 0ad3e06bd88e..000000000000 --- a/browser/src/app.scss +++ /dev/null @@ -1,51 +0,0 @@ -@import '../../shared/src/global-styles/colors'; - -// Bootstrap configuration before Bootstrap is imported -$border-radius: 2px; -$border-radius-sm: 1px; -$border-radius-lg: 4px; -$font-size-base: 0.875rem; -$line-height-base: (20/14); - -// Media breakpoints -$media-sm: 576px; -$media-md: 768px; -$media-lg: 992px; -$media-xl: 1200px; - -$theme-colors-light: ( - 'secondary': $secondary-light, -); - -:root { - --border-color: rgba(0, 0, 0, 0.125); -} - -.sg-icon .icon { - @extend .icon-inline; - display: flex; -} - -.selection-highlight, -.selection-highlight span, -.selection-highlight-sticky, -.selection-highlight-sticky span { - background-color: rgba(255, 192, 120, 0.5); -} - -@import 'bootstrap/scss/functions'; -@import 'bootstrap/scss/variables'; -@import 'bootstrap/scss/mixins'; -@import 'bootstrap/scss/utilities/text'; -@import 'bootstrap/scss/utilities/screenreaders'; -@import '../../shared/src/global-styles/icons'; -@import './highlight'; -@import './shared/components/CodeViewToolbar.scss'; -@import './libs/bitbucket/style.scss'; -@import './libs/gitlab/style.scss'; -@import './libs/github/style.scss'; -@import './libs/phabricator/style.scss'; -@import './libs/code_intelligence/HoverOverlay.scss'; -@import './libs/code_intelligence/external_links'; -@import './libs/code_intelligence/native_tooltips'; -@import './shared'; diff --git a/browser/src/browser/runtime.ts b/browser/src/browser/runtime.ts deleted file mode 100644 index 05329f15ceda..000000000000 --- a/browser/src/browser/runtime.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { isBackground, isInPage } from '../context' -import { BackgroundMessageHandlers } from './types' - -const messageSender = (type: T): BackgroundMessageHandlers[T] => ( - payload?: any -) => { - if (isBackground) { - throw new Error('Tried to call background page function from background page itself') - } - if (isInPage) { - throw new Error('Tried to call background page function from in-page integration') - } - return browser.runtime.sendMessage({ type, payload }) -} - -/** - * Functions that can be invoked from content scripts that will be executed in the background page. - */ -export const background: BackgroundMessageHandlers = { - createBlobURL: messageSender('createBlobURL'), - openOptionsPage: messageSender('openOptionsPage'), - requestGraphQL: messageSender('requestGraphQL'), -} diff --git a/browser/src/browser/types.ts b/browser/src/browser/types.ts deleted file mode 100644 index faffb8b5d38b..000000000000 --- a/browser/src/browser/types.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { GraphQLResult } from '../../../shared/src/graphql/graphql' -import * as GQL from '../../../shared/src/graphql/schema' -import { ExtensionHoverAlertType } from '../libs/code_intelligence/hover_alerts' - -export interface PhabricatorMapping { - callsign: string - path: string -} - -/** - * The feature flags available. - */ -export interface FeatureFlags { - /** - * Allow error reporting. - * - * @todo Since this is not really a feature flag, just unnest it into settings (and potentially get rid of the feature flags abstraction completely) - */ - allowErrorReporting: boolean - - /** - * Support link previews from extensions in content views (such as GitHub issues). - */ - experimentalLinkPreviews: boolean - - /** - * Support completion in text fields (such as on GitHub issues). - */ - experimentalTextFieldCompletion: boolean -} - -export const featureFlagDefaults: FeatureFlags = { - allowErrorReporting: false, - experimentalLinkPreviews: false, - experimentalTextFieldCompletion: false, -} - -interface SourcegraphURL { - sourcegraphURL: string -} - -export interface SyncStorageItems extends SourcegraphURL { - sourcegraphAnonymousUid: string - /** - * Temporarily disable the browser extension features. - */ - disableExtension: boolean - /** - * Storage for feature flags. - */ - featureFlags: Partial - /** - * Overrides settings from Sourcegraph. - */ - clientSettings: string - dismissedHoverAlerts: { - [alertType in ExtensionHoverAlertType]?: boolean - } -} - -export interface LocalStorageItems { - sideloadedExtensionURL: string | null -} - -export interface ManagedStorageItems extends SourcegraphURL { - phabricatorMappings: PhabricatorMapping[] -} - -/** - * Functions in the background page that can be invoked from content scripts. - */ -export interface BackgroundMessageHandlers { - openOptionsPage(): Promise - createBlobURL(bundleUrl: string): Promise - requestGraphQL(options: { - request: string - variables: {} - }): Promise> -} diff --git a/browser/src/context.ts b/browser/src/context.ts deleted file mode 100644 index 2ee6c6d3b434..000000000000 --- a/browser/src/context.ts +++ /dev/null @@ -1,43 +0,0 @@ -enum AppEnv { - Extension, - Page, -} - -enum ScriptEnv { - Content, - Background, - Options, -} - -interface AppContext { - appEnv: AppEnv - scriptEnv: ScriptEnv -} - -function getContext(): AppContext { - const appEnv = window.SG_ENV === 'EXTENSION' ? AppEnv.Extension : AppEnv.Page - - let scriptEnv: ScriptEnv = ScriptEnv.Content - if (appEnv === AppEnv.Extension) { - if (window.location.pathname.includes('options.html')) { - scriptEnv = ScriptEnv.Options - } else if (globalThis.browser && browser.runtime.getBackgroundPage) { - scriptEnv = ScriptEnv.Background - } - } - - return { - appEnv, - scriptEnv, - } -} - -const ctx = getContext() - -export const isBackground = ctx.scriptEnv === ScriptEnv.Background -export const isOptions = ctx.scriptEnv === ScriptEnv.Options - -export const isExtension = ctx.appEnv === AppEnv.Extension -export const isInPage = !isExtension - -export const isPhabricator = Boolean(document.querySelector('.phabricator-wordmark')) diff --git a/browser/src/e2e/bitbucket.test.ts b/browser/src/e2e/bitbucket.test.ts deleted file mode 100644 index 35598551b3aa..000000000000 --- a/browser/src/e2e/bitbucket.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import expect from 'expect' -import { saveScreenshotsUponFailures } from '../../../shared/src/e2e/screenshotReporter' -import { createDriverForTest, Driver } from '../../../shared/src/e2e/driver' -import { retry } from '../../../shared/src/e2e/e2e-test-utils' -import { ExternalServiceKind } from '../../../shared/src/graphql/schema' -import { testSingleFilePage } from './shared' -import { getConfig } from '../../../shared/src/e2e/config' - -// By default, these tests run against a local Bitbucket instance and a local Sourcegraph instance. -// You can run them against other instances by setting the below env vars in addition to SOURCEGRAPH_BASE_URL. - -const BITBUCKET_BASE_URL = process.env.BITBUCKET_BASE_URL || 'http://localhost:7990' -const BITBUCKET_USERNAME = process.env.BITBUCKET_USERNAME || 'test' -const BITBUCKET_PASSWORD = process.env.BITBUCKET_PASSWORD || 'test' -const TEST_NATIVE_INTEGRATION = Boolean(process.env.TEST_NATIVE_INTEGRATION) -const REPO_PATH_PREFIX = new URL(BITBUCKET_BASE_URL).hostname - -const BITBUCKET_INTEGRATION_JAR_URL = 'https://storage.googleapis.com/sourcegraph-for-bitbucket-server/latest.jar' - -const { sourcegraphBaseUrl } = getConfig('sourcegraphBaseUrl') - -/** - * Logs into Bitbucket. - */ -async function bitbucketLogin({ page }: Driver): Promise { - await page.goto(BITBUCKET_BASE_URL) - if (new URL(page.url()).pathname.endsWith('/login')) { - await page.type('#j_username', BITBUCKET_USERNAME) - await page.type('#j_password', BITBUCKET_PASSWORD) - await Promise.all([page.click('#submit'), page.waitForNavigation()]) - } -} - -/** - * Adds sourcegraph/jsonrpc2 to this Bitbucket instance. - */ -async function importBitbucketRepo(driver: Driver): Promise { - // Import repo (idempotent) - await driver.page.goto(BITBUCKET_BASE_URL + '/plugins/servlet/import-repository/SOURCEGRAPH') - await driver.page.waitForSelector('button[data-source="GIT"]') - await driver.page.click('button[data-source="GIT"]') - await driver.page.waitForSelector('input[name="url"]') - await driver.page.type('.source-form.git-specific input[name="url"]', 'https://github.com/sourcegraph/jsonrpc2') - // Need to focus the next input field to trigger validation and have the submit button be enabled - await driver.page.focus('.source-form.git-specific input[name="username"]') - await driver.page.click('.next-step [name="connect"]') - await retry(async () => { - const browsePage = '/projects/SOURCEGRAPH/repos/jsonrpc2/browse' - await driver.page.goto(BITBUCKET_BASE_URL + browsePage) - // Retry until not redirected to the "import in progress" page anymore - expect(new URL(driver.page.url()).pathname).toBe(new URL(browsePage, BITBUCKET_BASE_URL).pathname) - // Ensure this is not a 404 page - expect(await driver.page.$('.filebrowser-content')).toBeTruthy() - }) -} - -/** - * Configures the Sourcegraph for Bitbucket Server integration on the Bitbucket instance. - */ -async function configureSourcegraphIntegration(driver: Driver): Promise { - await driver.ensureHasCORSOrigin({ corsOriginURL: new URL(BITBUCKET_BASE_URL).origin }) - await bitbucketLogin(driver) - await driver.page.goto(BITBUCKET_BASE_URL + '/plugins/servlet/upm?source=side_nav_manage_addons') - await driver.page.waitForSelector('#upm-manage-plugins-user-installed') - const sourcegraphPluginSelector = '.upm-plugin[data-key="com.sourcegraph.plugins.sourcegraph-bitbucket"]' - if (await driver.page.$(sourcegraphPluginSelector)) { - // Enable if needed - if (await driver.page.$(`${sourcegraphPluginSelector}.disabled`)) { - await driver.page.click(`${sourcegraphPluginSelector} [data-action="ENABLE"]`) - await driver.page.waitForSelector(`${sourcegraphPluginSelector} [data-action="DISABLE"]`) - } - } else { - // Install - await driver.page.click('#upm-upload') - await driver.page.waitForSelector('#upm-upload-url') - await driver.page.type('#upm-upload-url', BITBUCKET_INTEGRATION_JAR_URL) - await driver.page.click('#upm-upload-dialog button.confirm') - await driver.page.waitForSelector(sourcegraphPluginSelector) - } - await driver.page.reload() - await driver.page.waitForSelector('#sourcegraph-admin-link') - await driver.page.click('#sourcegraph-admin-link') - await driver.page.waitForSelector('form#admin') - // The Sourcegraph URL input field is disabled until the Sourcegraph URL has been fetched. - await retry(async () => { - expect( - await driver.page.evaluate(() => document.querySelector('form#admin input#url')!.disabled) - ).toBe(false) - }) - await driver.replaceText({ selector: 'form#admin input#url', newText: sourcegraphBaseUrl }) - await driver.page.click('form#admin input#submit') - await driver.page.waitForSelector('.aui-message-success') -} - -/** - * Runs initial setup for the Bitbucket instance. - */ -async function init(driver: Driver): Promise { - await driver.ensureLoggedIn({ username: 'test', password: 'test', email: 'test@test.com' }) - if (TEST_NATIVE_INTEGRATION) { - await configureSourcegraphIntegration(driver) - } else { - await bitbucketLogin(driver) - await driver.setExtensionSourcegraphUrl() - } - await importBitbucketRepo(driver) - await driver.ensureHasExternalService({ - kind: ExternalServiceKind.BITBUCKETSERVER, - displayName: `Bitbucket ${BITBUCKET_BASE_URL}`, - config: JSON.stringify({ - url: BITBUCKET_BASE_URL, - username: BITBUCKET_USERNAME, - password: BITBUCKET_PASSWORD, - repos: ['SOURCEGRAPH/jsonrpc2'], - }), - ensureRepos: [REPO_PATH_PREFIX + '/SOURCEGRAPH/jsonrpc2'], - }) - await driver.ensureHasCORSOrigin({ corsOriginURL: BITBUCKET_BASE_URL }) -} - -describe('Sourcegraph browser extension on Bitbucket Server', () => { - let driver: Driver - - before(async function () { - this.timeout(4 * 60 * 1000) - driver = await createDriverForTest({ loadExtension: !TEST_NATIVE_INTEGRATION, sourcegraphBaseUrl }) - await init(driver) - }) - - after(async () => { - await driver.close() - }) - - // Take a screenshot when a test fails. - saveScreenshotsUponFailures(() => driver.page) - - testSingleFilePage({ - getDriver: () => driver, - url: `${BITBUCKET_BASE_URL}/projects/SOURCEGRAPH/repos/jsonrpc2/browse/call_opt.go?until=4fb7cd90793ee6ab445f466b900e6bffb9b63d78&untilPath=call_opt.go`, - repoName: `${REPO_PATH_PREFIX}/SOURCEGRAPH/jsonrpc2`, - sourcegraphBaseUrl, - lineSelector: '.line', - }) -}) diff --git a/browser/src/e2e/ghe.test.ts b/browser/src/e2e/ghe.test.ts deleted file mode 100644 index da1bdf990310..000000000000 --- a/browser/src/e2e/ghe.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { saveScreenshotsUponFailures } from '../../../shared/src/e2e/screenshotReporter' -import { createDriverForTest, Driver } from '../../../shared/src/e2e/driver' -import { ExternalServiceKind } from '../../../shared/src/graphql/schema' -import { testSingleFilePage } from './shared' -import { getConfig } from '../../../shared/src/e2e/config' - -const GHE_BASE_URL = process.env.GHE_BASE_URL || 'https://ghe.sgdev.org' -const GHE_USERNAME = process.env.GHE_USERNAME -if (!GHE_USERNAME) { - throw new Error('GHE_USERNAME environment variable must be set') -} -const GHE_PASSWORD = process.env.GHE_PASSWORD -if (!GHE_PASSWORD) { - throw new Error('GHE_PASSWORD environment variable must be set') -} -const GHE_TOKEN = process.env.GHE_TOKEN -if (!GHE_TOKEN) { - throw new Error('GHE_TOKEN environment variable must be set') -} - -const REPO_PREFIX = new URL(GHE_BASE_URL).hostname - -const { sourcegraphBaseUrl } = getConfig('sourcegraphBaseUrl') - -/** - * Logs into GitHub Enterprise enterprise. - */ -async function gheLogin({ page }: Driver): Promise { - await page.goto(GHE_BASE_URL) - if (new URL(page.url()).pathname.endsWith('/login')) { - await page.type('#login_field', GHE_USERNAME!) - await page.type('#password', GHE_PASSWORD!) - await Promise.all([page.click('input[name=commit]'), page.waitForNavigation()]) - } -} - -/** - * Runs initial setup for the GitHub Enterprise instance. - * - */ -async function init(driver: Driver): Promise { - await driver.ensureLoggedIn({ username: 'test', password: 'test', email: 'test@test.com' }) - await gheLogin(driver) - await driver.setExtensionSourcegraphUrl() - await driver.ensureHasExternalService({ - kind: ExternalServiceKind.GITHUB, - displayName: 'GitHub Enterprise (e2e)', - config: JSON.stringify({ - url: GHE_BASE_URL, - token: GHE_TOKEN, - repos: ['sourcegraph/jsonrpc2'], - }), - ensureRepos: [`${REPO_PREFIX}/sourcegraph/jsonrpc2`], - }) - // GHE doesn't allow cloning public repos through the UI (only GitHub.com has the GitHub importer). - // These tests expect that sourcegraph/jsonrpc2 is cloned on the GHE instance. - await driver.page.goto(`${GHE_BASE_URL}/sourcegraph/jsonrpc2`) - if (await driver.page.evaluate(() => document.querySelector('#not-found-search') !== null)) { - throw new Error('You must clone sourcegraph/jsonrpc2 to your GHE instance to run these tests') - } -} - -describe('Sourcegraph browser extension on GitHub Enterprise', () => { - let driver: Driver - - before(async function () { - this.timeout(4 * 60 * 1000) - driver = await createDriverForTest({ loadExtension: true, sourcegraphBaseUrl }) - await init(driver) - }) - - after(async () => { - await driver.close() - }) - - // Take a screenshot when a test fails. - saveScreenshotsUponFailures(() => driver.page) - - testSingleFilePage({ - getDriver: () => driver, - url: `${GHE_BASE_URL}/sourcegraph/jsonrpc2/blob/4fb7cd90793ee6ab445f466b900e6bffb9b63d78/call_opt.go`, - repoName: `${REPO_PREFIX}/sourcegraph/jsonrpc2`, - sourcegraphBaseUrl, - // Not using '.js-file-line' because it breaks the reliance on :nth-child() in testSingleFilePage() - lineSelector: '.js-file-line-container tr', - goToDefinitionURL: `${GHE_BASE_URL}/sourcegraph/jsonrpc2/blob/4fb7cd90793ee6ab445f466b900e6bffb9b63d78/call_opt.go#L5:6`, - }) -}) diff --git a/browser/src/e2e/github.test.ts b/browser/src/e2e/github.test.ts deleted file mode 100644 index 7e3dcbffebaa..000000000000 --- a/browser/src/e2e/github.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { startCase } from 'lodash' -import assert from 'assert' -import { saveScreenshotsUponFailures } from '../../../shared/src/e2e/screenshotReporter' -import { Driver, createDriverForTest } from '../../../shared/src/e2e/driver' -import { testSingleFilePage } from './shared' -import { retry } from '../../../shared/src/e2e/e2e-test-utils' -import { getConfig } from '../../../shared/src/e2e/config' - -describe('Sourcegraph browser extension on github.com', function () { - this.slow(8000) - - const { browser, sourcegraphBaseUrl } = getConfig('browser', 'sourcegraphBaseUrl') - - let driver: Driver - - before('Open browser', async function () { - this.timeout(90 * 1000) - driver = await createDriverForTest({ loadExtension: true, browser, sourcegraphBaseUrl }) - if (sourcegraphBaseUrl !== 'https://sourcegraph.com') { - await driver.setExtensionSourcegraphUrl() - } - }) - - // Take a screenshot when a test fails - saveScreenshotsUponFailures(() => driver.page) - - after('Close browser', async () => { - if (driver) { - await driver.close() - } - }) - - testSingleFilePage({ - getDriver: () => driver, - url: 'https://github.com/sourcegraph/jsonrpc2/blob/4fb7cd90793ee6ab445f466b900e6bffb9b63d78/call_opt.go', - repoName: 'github.com/sourcegraph/jsonrpc2', - sourcegraphBaseUrl, - // Not using '.js-file-line' because it breaks the reliance on :nth-child() in testSingleFilePage() - lineSelector: '.js-file-line-container tr', - goToDefinitionURL: - 'https://github.com/sourcegraph/jsonrpc2/blob/4fb7cd90793ee6ab445f466b900e6bffb9b63d78/call_opt.go#L5:6', - }) - - const tokens = { - // https://github.com/gorilla/mux/pull/117/files#diff-9ef8a22c4ce5141c30a501c542fb1adeL244 - base: { - token: 'varsN', - lineId: 'diff-9ef8a22c4ce5141c30a501c542fb1adeL244', - goToDefinitionURL: - 'https://github.com/gorilla/mux/blob/f15e0c49460fd49eebe2bcc8486b05d1bef68d3a/regexp.go#L139:2', - }, - // https://github.com/gorilla/mux/pull/117/files#diff-9ef8a22c4ce5141c30a501c542fb1adeR247 - head: { - token: 'host', - lineId: 'diff-9ef8a22c4ce5141c30a501c542fb1adeR247', - goToDefinitionURL: - 'https://github.com/gorilla/mux/blob/e73f183699f8ab7d54609771e1fa0ab7ffddc21b/regexp.go#L233:2', - }, - } - - describe('Pull request pages', () => { - for (const diffType of ['unified', 'split']) { - describe(`${startCase(diffType)} view`, () => { - for (const side of ['base', 'head'] as const) { - const { token, lineId, goToDefinitionURL } = tokens[side] - it(`provides hover tooltips on token "${token}" in the ${side} part`, async () => { - await driver.page.goto(`https://github.com/gorilla/mux/pull/117/files?diff=${diffType}`) - // The browser extension takes a bit to initialize and register all event listeners. - // Waiting here saves one retry cycle below in the common case. - // If it's not enough, the retry will catch it. - await driver.page.waitFor(1500) - const tokenElement = await retry(async () => { - const lineNumberElement = await driver.page.waitForSelector(`#${lineId}`, { - timeout: 10000, - }) - const row = (await driver.page.evaluateHandle( - (element: Element) => element.closest('tr'), - lineNumberElement - ))!.asElement()! - assert(row, 'Expected row to exist') - const tokenElement = ( - await driver.page.evaluateHandle( - (row: Element, token: string) => - Array.from(row.querySelectorAll('span')).find( - element => element.textContent === token - ), - row, - token - ) - ).asElement() - assert(tokenElement, 'Expected token element to exist') - return tokenElement! - }) - // Retry is here to wait for listeners to be registered - await retry(async () => { - await tokenElement.hover() - await driver.page.waitForSelector('.e2e-tooltip-go-to-definition', { timeout: 5000 }) - }) - - // Check go-to-definition jumps to the right place - await retry(async () => { - const href = await driver.page.evaluate( - () => document.querySelector('.e2e-tooltip-go-to-definition')?.href - ) - assert.strictEqual(href, goToDefinitionURL) - }) - await Promise.all([ - driver.page.waitForNavigation(), - driver.page.click('.e2e-tooltip-go-to-definition'), - ]) - assert.strictEqual(await driver.page.evaluate(() => location.href), goToDefinitionURL) - }) - } - }) - } - }) -}) diff --git a/browser/src/e2e/gitlab.test.ts b/browser/src/e2e/gitlab.test.ts deleted file mode 100644 index bf0d0cae32e7..000000000000 --- a/browser/src/e2e/gitlab.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { saveScreenshotsUponFailures } from '../../../shared/src/e2e/screenshotReporter' -import { createDriverForTest, Driver } from '../../../shared/src/e2e/driver' -import { ExternalServiceKind } from '../../../shared/src/graphql/schema' -import { testSingleFilePage } from './shared' -import { getConfig } from '../../../shared/src/e2e/config' - -// By default, these tests run against gitlab.com and a local Sourcegraph instance. -// You can run them against other instances by setting the below env vars in addition to SOURCEGRAPH_BASE_URL. - -const GITLAB_BASE_URL = process.env.GITLAB_BASE_URL || 'https://gitlab.com' -const GITLAB_TOKEN = process.env.GITLAB_TOKEN -const REPO_PATH_PREFIX = new URL(GITLAB_BASE_URL).hostname - -const { sourcegraphBaseUrl } = getConfig('sourcegraphBaseUrl') - -/** - * Runs initial setup for the Gitlab instance. - */ -async function init(driver: Driver): Promise { - await driver.ensureLoggedIn({ username: 'test', password: 'test', email: 'test@test.com' }) - await driver.setExtensionSourcegraphUrl() - await driver.ensureHasExternalService({ - kind: ExternalServiceKind.GITLAB, - displayName: 'Gitlab', - config: JSON.stringify({ - url: GITLAB_BASE_URL, - token: GITLAB_TOKEN, - projectQuery: ['groups/sourcegraph/projects?search=jsonrpc2'], - }), - ensureRepos: [REPO_PATH_PREFIX + '/sourcegraphs/jsonrpc2'], - }) - await driver.ensureHasCORSOrigin({ corsOriginURL: GITLAB_BASE_URL }) -} - -describe('Sourcegraph browser extension on Gitlab Server', () => { - let driver: Driver - - before(async function () { - this.timeout(4 * 60 * 1000) - driver = await createDriverForTest({ loadExtension: true, sourcegraphBaseUrl }) - await init(driver) - }) - - after(async () => { - await driver.close() - }) - - // Take a screenshot when a test fails. - saveScreenshotsUponFailures(() => driver.page) - - testSingleFilePage({ - getDriver: () => driver, - url: `${GITLAB_BASE_URL}/sourcegraph/jsonrpc2/blob/4fb7cd90793ee6ab445f466b900e6bffb9b63d78/call_opt.go`, - repoName: `${REPO_PATH_PREFIX}/sourcegraph/jsonrpc2`, - sourcegraphBaseUrl, - lineSelector: '.line', - }) -}) diff --git a/browser/src/e2e/shared.ts b/browser/src/e2e/shared.ts deleted file mode 100644 index 3f59b2de5fbe..000000000000 --- a/browser/src/e2e/shared.ts +++ /dev/null @@ -1,72 +0,0 @@ -import expect from 'expect' -import { Driver } from '../../../shared/src/e2e/driver' - -/** - * Defines e2e tests for a single-file page of a code host. - */ -export function testSingleFilePage({ - getDriver, - url, - sourcegraphBaseUrl, - repoName, - lineSelector, - goToDefinitionURL, -}: { - /** Called to get the driver */ - getDriver: () => Driver - - /** The URL to sourcegraph/jsonrpc2 call_opt.go at commit 4fb7cd90793ee6ab445f466b900e6bffb9b63d78 on the code host */ - url: string - - /** The base URL of the sourcegraph instance */ - sourcegraphBaseUrl: string - - /** The repo name of sourcgraph/jsonrpc2 on the Sourcegraph instance */ - repoName: string - - /** The CSS selector for a line in the code view */ - lineSelector: string - /** The expected URL for the "Go to Definition" button */ - goToDefinitionURL?: string -}): void { - describe('File views', () => { - it('adds "View on Sourcegraph" buttons to files', async () => { - await getDriver().page.goto(url) - await getDriver().page.waitForSelector('.code-view-toolbar .open-on-sourcegraph', { timeout: 10000 }) - expect(await getDriver().page.$$('.code-view-toolbar .open-on-sourcegraph')).toHaveLength(1) - await Promise.all([ - getDriver().page.waitForNavigation(), - getDriver().page.click('.code-view-toolbar .open-on-sourcegraph'), - ]) - expect(getDriver().page.url()).toBe( - `${sourcegraphBaseUrl}/${repoName}@4fb7cd90793ee6ab445f466b900e6bffb9b63d78/-/blob/call_opt.go` - ) - }) - - it('shows hover tooltips when hovering a token', async () => { - await getDriver().page.goto(url) - await getDriver().page.waitForSelector('.code-view-toolbar .open-on-sourcegraph') - - // Pause to give codeintellify time to register listeners for - // tokenization (only necessary in CI, not sure why). - await getDriver().page.waitFor(1000) - - // Trigger tokenization of the line. - const lineNumber = 16 - const line = await getDriver().page.waitForSelector(`${lineSelector}:nth-child(${lineNumber})`, { - timeout: 10000, - }) - const [token] = await line.$x('//span[text()="CallOption"]') - await token.hover() - await getDriver().page.waitForSelector('.e2e-tooltip-go-to-definition') - await Promise.all([ - getDriver().page.waitForNavigation(), - getDriver().page.click('.e2e-tooltip-go-to-definition'), - ]) - expect(await getDriver().page.evaluate(() => location.href)).toBe( - goToDefinitionURL || - `${sourcegraphBaseUrl}/${repoName}@4fb7cd90793ee6ab445f466b900e6bffb9b63d78/-/blob/call_opt.go#L5:6` - ) - }) - }) -} diff --git a/browser/src/e2e/tsconfig.json b/browser/src/e2e/tsconfig.json deleted file mode 100644 index d0f675b104ae..000000000000 --- a/browser/src/e2e/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "references": [{ "path": "../.." }, { "path": "../../../shared/src/e2e" }], - "compilerOptions": { - "types": ["mocha", "node"], - "module": "commonjs", - "rootDir": ".", - "outDir": "../../out/src/e2e", - }, - "include": ["**/*"], - "exclude": [], -} diff --git a/browser/src/extension/assets/img/icon-128.png b/browser/src/extension/assets/img/icon-128.png deleted file mode 100644 index 849cacf4d8d0..000000000000 Binary files a/browser/src/extension/assets/img/icon-128.png and /dev/null differ diff --git a/browser/src/extension/assets/img/icon-16.png b/browser/src/extension/assets/img/icon-16.png deleted file mode 100644 index bfe57f2b471a..000000000000 Binary files a/browser/src/extension/assets/img/icon-16.png and /dev/null differ diff --git a/browser/src/extension/assets/img/icon-48.png b/browser/src/extension/assets/img/icon-48.png deleted file mode 100644 index f42edc548040..000000000000 Binary files a/browser/src/extension/assets/img/icon-48.png and /dev/null differ diff --git a/browser/src/extension/envAssertion.ts b/browser/src/extension/envAssertion.ts deleted file mode 100644 index b83d8673ff3b..000000000000 --- a/browser/src/extension/envAssertion.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function assertEnv(env: typeof window['EXTENSION_ENV']): void { - if (window.EXTENSION_ENV !== env) { - throw new Error( - 'Detected transitive import of an entrypoint! ' + - window.EXTENSION_ENV + - ' attempted to import a file that is only intended to be imported by ' + - env + - '.' - ) - } -} diff --git a/browser/src/extension/polyfills.ts b/browser/src/extension/polyfills.ts deleted file mode 100644 index e9b94874f914..000000000000 --- a/browser/src/extension/polyfills.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Polyfills for all scripts running in the browser extension - -// Include same polyfills as the webapp and native integrations -import '../../../shared/src/polyfills' - -// Polyfill global browser API for Chrome -// The API is much nicer to use because it supports Promises -// The polyfill is also very simple. -import browser from 'webextension-polyfill' -Object.assign(self, { browser }) diff --git a/browser/src/extension/scripts/auto-reloading.ts b/browser/src/extension/scripts/auto-reloading.ts deleted file mode 100644 index f1c95781c244..000000000000 --- a/browser/src/extension/scripts/auto-reloading.ts +++ /dev/null @@ -1,17 +0,0 @@ -import '../polyfills' - -import io from 'socket.io-client' - -/** - * Reloads the extension when notified from the development server. Only enabled - * during development when `process.env.AUTO_RELOAD !== 'false'. - */ -async function main(): Promise { - const self = await browser.management.getSelf() - if (self.installType === 'development') { - // Since the port is hard-coded, it must match scripts/dev.ts - io.connect('http://localhost:8890').on('file.change', () => browser.runtime.reload()) - } -} - -main().catch(console.error.bind(console)) diff --git a/browser/src/extension/scripts/background.ts b/browser/src/extension/scripts/background.ts deleted file mode 100644 index 6b7898ac40d4..000000000000 --- a/browser/src/extension/scripts/background.ts +++ /dev/null @@ -1,266 +0,0 @@ -// We want to polyfill first. -import '../polyfills' - -import { Endpoint } from '@sourcegraph/comlink' -import { without } from 'lodash' -import { noop, Observable, Subscription } from 'rxjs' -import { bufferCount, filter, groupBy, map, mergeMap, switchMap, take, concatMap } from 'rxjs/operators' -import addDomainPermissionToggle from 'webext-domain-permission-toggle' -import { createExtensionHostWorker } from '../../../../shared/src/api/extension/worker' -import { GraphQLResult, requestGraphQL as requestGraphQLCommon } from '../../../../shared/src/graphql/graphql' -import * as GQL from '../../../../shared/src/graphql/schema' -import { BackgroundMessageHandlers } from '../../browser/types' -import { initializeOmniboxInterface } from '../../libs/cli' -import { initSentry } from '../../libs/sentry' -import { createBlobURLForBundle } from '../../platform/worker' -import { getHeaders } from '../../shared/backend/headers' -import { fromBrowserEvent } from '../../shared/util/browser' -import { observeSourcegraphURL } from '../../shared/util/context' -import { assertEnv } from '../envAssertion' -import { observeStorageKey, storage } from '../../browser/storage' -import { isDefined } from '../../../../shared/src/util/types' - -const IS_EXTENSION = true - -assertEnv('BACKGROUND') - -initSentry('background') - -let customServerOrigins: string[] = [] - -const contentScripts = browser.runtime.getManifest().content_scripts - -// jsContentScriptOrigins are the required URLs inside of the manifest. When checking for permissions to inject -// the content script on optional pages (inside browser.tabs.onUpdated) we need to skip manual injection of the -// script since the browser extension will automatically inject it. -const jsContentScriptOrigins: string[] = [] -if (contentScripts) { - for (const contentScript of contentScripts) { - if (!contentScript || !contentScript.js || !contentScript.matches) { - continue - } - jsContentScriptOrigins.push(...contentScript.matches) - } -} - -const configureOmnibox = (serverUrl: string): void => { - browser.omnibox.setDefaultSuggestion({ - description: `Search code on ${serverUrl}`, - }) -} - -const requestGraphQL = ({ - request, - variables, -}: { - request: string - variables: {} -}): Observable> => - observeSourcegraphURL(IS_EXTENSION).pipe( - take(1), - switchMap(sourcegraphURL => - requestGraphQLCommon({ - request, - variables, - baseUrl: sourcegraphURL, - headers: getHeaders(), - credentials: 'include', - }) - ) - ) - -initializeOmniboxInterface(requestGraphQL) - -async function main(): Promise { - const subscriptions = new Subscription() - - // Mirror the managed sourcegraphURL to sync storage - subscriptions.add( - observeStorageKey('managed', 'sourcegraphURL') - .pipe( - filter(isDefined), - concatMap(sourcegraphURL => storage.sync.set({ sourcegraphURL })) - ) - .subscribe() - ) - // Configure the omnibox when the sourcegraphURL changes. - subscriptions.add( - observeSourcegraphURL(IS_EXTENSION).subscribe(sourcegraphURL => { - configureOmnibox(sourcegraphURL) - }) - ) - - const permissions = await browser.permissions.getAll() - if (!permissions.origins) { - customServerOrigins = [] - return - } - customServerOrigins = without(permissions.origins, ...jsContentScriptOrigins) - - // Not supported in Firefox - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded#Browser_compatibility - if (browser.permissions.onAdded) { - browser.permissions.onAdded.addListener(permissions => { - if (!permissions.origins) { - return - } - const origins = without(permissions.origins, ...jsContentScriptOrigins) - customServerOrigins.push(...origins) - }) - } - if (browser.permissions.onRemoved) { - browser.permissions.onRemoved.addListener(permissions => { - if (!permissions.origins) { - return - } - customServerOrigins = without(customServerOrigins, ...permissions.origins) - const urlsToRemove: string[] = [] - for (const url of permissions.origins) { - urlsToRemove.push(url.replace('/*', '')) - } - }) - } - - // Inject content script whenever a new tab was opened with a URL that we have permissions for - browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { - if ( - changeInfo.status === 'complete' && - customServerOrigins.some( - origin => origin === '' || (!!tab.url && tab.url.startsWith(origin.replace('/*', ''))) - ) - ) { - await browser.tabs.executeScript(tabId, { file: 'js/inject.bundle.js', runAt: 'document_end' }) - } - }) - - const handlers: BackgroundMessageHandlers = { - async openOptionsPage(): Promise { - await browser.runtime.openOptionsPage() - }, - - async createBlobURL(bundleUrl: string): Promise { - return createBlobURLForBundle(bundleUrl) - }, - - async requestGraphQL({ - request, - variables, - }: { - request: string - variables: {} - }): Promise> { - return requestGraphQL({ request, variables }).toPromise() - }, - } - - // Handle calls from other scripts - browser.runtime.onMessage.addListener(async message => { - const method = message.type as keyof typeof handlers - if (!handlers[method]) { - throw new Error(`Invalid RPC call for "${method}"`) - } - return handlers[method](message.payload) - }) - - await browser.runtime.setUninstallURL('https://about.sourcegraph.com/uninstall/') - - browser.browserAction.onClicked.addListener(noop) - browser.browserAction.setBadgeText({ text: '' }) - browser.browserAction.setPopup({ popup: 'options.html?popup=true' }) - - // Add "Enable Sourcegraph on this domain" context menu item - addDomainPermissionToggle() - - const ENDPOINT_KIND_REGEX = /^(proxy|expose)-/ - - const portKind = (port: browser.runtime.Port): string | undefined => { - const match = port.name.match(ENDPOINT_KIND_REGEX) - return match?.[1] - } - - /** - * A stream of EndpointPair created from Port objects emitted by browser.runtime.onConnect. - * - * On initialization, the content script creates a pair of browser.runtime.Port objects - * using browser.runtime.connect(). The two ports are named 'proxy-{uuid}' and 'expose-{uuid}', - * and wrapped using {@link endpointFromPort} to behave like comlink endpoints on the content script side. - * - * This listens to events on browser.runtime.onConnect, pairs emitted ports using their naming pattern, - * and emits pairs. Each pair of ports represents a connection with an instance of the content script. - */ - const endpointPairs: Observable> = fromBrowserEvent( - browser.runtime.onConnect - ).pipe( - map(([port]) => port), - groupBy( - port => (port.name || 'other').replace(ENDPOINT_KIND_REGEX, ''), - port => port, - group => group.pipe(bufferCount(2)) - ), - filter(group => group.key !== 'other'), - mergeMap(group => - group.pipe( - bufferCount(2), - map(ports => { - const proxyPort = ports.find(port => portKind(port) === 'proxy') - if (!proxyPort) { - throw new Error('No proxy port') - } - const exposePort = ports.find(port => portKind(port) === 'expose') - if (!exposePort) { - throw new Error('No expose port') - } - return { - proxy: proxyPort, - expose: exposePort, - } - }) - ) - ) - ) - - // Extension Host Connection - // When an Port pair is emitted, create an extension host worker. - // Messages from the ports are forwarded to the endpoints returned by {@link createExtensionHostWorker}, and vice-versa. - // The lifetime of the extension host worker is tied to that of the content script instance: - // when a port disconnects, the worker is terminated. This means there should always be exactly one - // extension host worker per active instance of the content script. - subscriptions.add( - endpointPairs.subscribe( - ({ proxy, expose }) => { - console.log('Extension host client connected') - // It's necessary to wrap endpoints because browser.runtime.Port objects do not support transferring MessagePorts. - // See https://github.com/GoogleChromeLabs/comlink/blob/master/messagechanneladapter.md - const { worker, clientEndpoints } = createExtensionHostWorker({ wrapEndpoints: true }) - const connectPortAndEndpoint = ( - port: browser.runtime.Port, - endpoint: Endpoint & Pick - ): void => { - endpoint.start() - port.onMessage.addListener(message => { - endpoint.postMessage(message) - }) - endpoint.addEventListener('message', ({ data }) => { - port.postMessage(data) - }) - } - // Connect proxy client endpoint - connectPortAndEndpoint(proxy, clientEndpoints.proxy) - // Connect expose client endpoint - connectPortAndEndpoint(expose, clientEndpoints.expose) - // Kill worker when either port disconnects - proxy.onDisconnect.addListener(() => worker.terminate()) - expose.onDisconnect.addListener(() => worker.terminate()) - }, - err => { - console.error('Error handling extension host client connection', err) - } - ) - ) - - console.log('Sourcegraph background page initialized') -} - -// Browsers log this unhandled Promise automatically (and with a better stack trace through console.error) -// eslint-disable-next-line @typescript-eslint/no-floating-promises -main() diff --git a/browser/src/extension/scripts/inject.ts b/browser/src/extension/scripts/inject.ts deleted file mode 100644 index 36559e5b7b74..000000000000 --- a/browser/src/extension/scripts/inject.ts +++ /dev/null @@ -1,107 +0,0 @@ -import '../polyfills' - -import { fromEvent, Subscription } from 'rxjs' -import { first } from 'rxjs/operators' -import { setLinkComponent, AnchorLink } from '../../../../shared/src/components/Link' -import { storage } from '../../browser/storage' -import { determineCodeHost } from '../../libs/code_intelligence' -import { injectCodeIntelligence } from '../../libs/code_intelligence/inject' -import { initSentry } from '../../libs/sentry' -import { - checkIsSourcegraph, - EXTENSION_MARKER_ID, - injectExtensionMarker, - NATIVE_INTEGRATION_ACTIVATED, - signalBrowserExtensionInstalled, -} from '../../libs/sourcegraph/inject' -import { DEFAULT_SOURCEGRAPH_URL, getAssetsURL } from '../../shared/util/context' -import { featureFlags } from '../../shared/util/featureFlags' -import { assertEnv } from '../envAssertion' - -const subscriptions = new Subscription() -window.addEventListener('unload', () => subscriptions.unsubscribe(), { once: true }) - -assertEnv('CONTENT') - -const codeHost = determineCodeHost() -initSentry('content', codeHost?.type) - -setLinkComponent(AnchorLink) - -const IS_EXTENSION = true - -/** - * Main entry point into browser extension. - */ -async function main(): Promise { - console.log('Sourcegraph browser extension is running') - - // Make sure DOM is fully loaded - if (document.readyState !== 'complete' && document.readyState !== 'interactive') { - await new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, { once: true })) - } - - // Allow users to set this via the console. - ;(window as any).sourcegraphFeatureFlags = featureFlags - - // Check if a native integration is already running on the page, - // and abort execution if it's the case. - // If the native integration was activated before the content script, we can - // synchronously check for the presence of the extension marker. - if (document.getElementById(EXTENSION_MARKER_ID) !== null) { - console.log('Sourcegraph native integration is already running') - return - } - // If the extension marker isn't present, inject it and listen for a custom event sent by the native - // integration to signal its activation. - injectExtensionMarker() - const nativeIntegrationActivationEventReceived = fromEvent(document, NATIVE_INTEGRATION_ACTIVATED) - .pipe(first()) - .toPromise() - - const items = await storage.sync.get() - const sourcegraphURL = items.sourcegraphURL || DEFAULT_SOURCEGRAPH_URL - - const isSourcegraphServer = checkIsSourcegraph(sourcegraphURL) - if (isSourcegraphServer) { - signalBrowserExtensionInstalled() - return - } - - // Add style sheet and wait for it to load to avoid rendering unstyled elements (which causes an - // annoying flash/jitter when the stylesheet loads shortly thereafter). - const styleSheet = (() => { - let styleSheet = document.getElementById('ext-style-sheet') as HTMLLinkElement | null - // If does not exist, create - if (!styleSheet) { - styleSheet = document.createElement('link') - styleSheet.id = 'ext-style-sheet' - styleSheet.rel = 'stylesheet' - styleSheet.type = 'text/css' - styleSheet.href = browser.extension.getURL('css/style.bundle.css') - } - return styleSheet - })() - // If not loaded yet, wait for it to load - if (!styleSheet.sheet) { - await new Promise(resolve => { - styleSheet.addEventListener('load', resolve, { once: true }) - // If not appended yet, append to - if (!styleSheet.parentNode) { - document.head.appendChild(styleSheet) - } - }) - } - - subscriptions.add( - injectCodeIntelligence({ sourcegraphURL, assetsURL: getAssetsURL(DEFAULT_SOURCEGRAPH_URL) }, IS_EXTENSION) - ) - - // Clean up susbscription if the native integration gets activated - // later in the lifetime of the content script. - await nativeIntegrationActivationEventReceived - console.log('Native integration activation event received') - subscriptions.unsubscribe() -} - -main().catch(console.error.bind(console)) diff --git a/browser/src/extension/scripts/options.tsx b/browser/src/extension/scripts/options.tsx deleted file mode 100644 index 2b4b1df622ac..000000000000 --- a/browser/src/extension/scripts/options.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// We want to polyfill first. -import '../polyfills' - -import * as React from 'react' -import { render } from 'react-dom' -import { from, noop, Observable, Subscription } from 'rxjs' -import { GraphQLResult } from '../../../../shared/src/graphql/graphql' -import * as GQL from '../../../../shared/src/graphql/schema' -import { background } from '../../browser/runtime' -import { observeStorageKey, storage } from '../../browser/storage' -import { featureFlagDefaults, FeatureFlags } from '../../browser/types' -import { OptionsContainer, OptionsContainerProps } from '../../libs/options/OptionsContainer' -import { OptionsMenuProps } from '../../libs/options/OptionsMenu' -import { initSentry } from '../../libs/sentry' -import { fetchSite } from '../../shared/backend/server' -import { featureFlags } from '../../shared/util/featureFlags' -import { assertEnv } from '../envAssertion' -import { observeSourcegraphURL } from '../../shared/util/context' - -assertEnv('OPTIONS') - -initSentry('options') - -const IS_EXTENSION = true - -type State = Pick< - FeatureFlags, - 'allowErrorReporting' | 'experimentalLinkPreviews' | 'experimentalTextFieldCompletion' -> & { sourcegraphURL: string | null; isActivated: boolean } - -const keyIsFeatureFlag = (key: string): key is keyof FeatureFlags => - !!Object.keys(featureFlagDefaults).find(k => key === k) - -const toggleFeatureFlag = (key: string): void => { - if (keyIsFeatureFlag(key)) { - featureFlags.toggle(key).then(noop).catch(noop) - } -} - -const fetchCurrentTabStatus = async (): Promise => { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }) - if (tabs.length > 1) { - throw new Error('Querying for the currently active tab returned more than one result') - } - const { url } = tabs[0] - if (!url) { - throw new Error('Currently active tab has no URL') - } - const { host, protocol } = new URL(url) - const hasPermissions = await browser.permissions.contains({ - origins: [`${protocol}//${host}/*`], - }) - return { host, protocol, hasPermissions } -} - -// Make GraphQL requests from background page -function requestGraphQL(options: { - request: string - variables: {} -}): Observable> { - return from(background.requestGraphQL(options)) -} - -const ensureValidSite = (): Observable => fetchSite(requestGraphQL) - -class Options extends React.Component<{}, State> { - public state: State = { - sourcegraphURL: null, - isActivated: true, - allowErrorReporting: false, - experimentalLinkPreviews: false, - experimentalTextFieldCompletion: false, - } - - private subscriptions = new Subscription() - - public componentDidMount(): void { - this.subscriptions.add( - observeStorageKey('sync', 'featureFlags').subscribe(featureFlags => { - const { allowErrorReporting, experimentalLinkPreviews, experimentalTextFieldCompletion } = { - ...featureFlagDefaults, - ...featureFlags, - } - this.setState({ - allowErrorReporting, - experimentalLinkPreviews, - experimentalTextFieldCompletion, - }) - }) - ) - - this.subscriptions.add( - observeSourcegraphURL(IS_EXTENSION).subscribe(sourcegraphURL => { - this.setState({ sourcegraphURL }) - }) - ) - - this.subscriptions.add( - observeStorageKey('sync', 'disableExtension').subscribe(disableExtension => { - this.setState({ - isActivated: !disableExtension, - }) - }) - ) - } - - public componentWillUnmount(): void { - this.subscriptions.unsubscribe() - } - - public render(): React.ReactNode { - if (this.state.sourcegraphURL === null) { - return null - } - - const props: OptionsContainerProps = { - sourcegraphURL: this.state.sourcegraphURL, - isActivated: this.state.isActivated, - - ensureValidSite, - fetchCurrentTabStatus, - hasPermissions: url => - browser.permissions.contains({ - origins: [`${url}/*`], - }), - requestPermissions: url => - browser.permissions.request({ - origins: [`${url}/*`], - }), - - setSourcegraphURL: (sourcegraphURL: string) => storage.sync.set({ sourcegraphURL }), - toggleExtensionDisabled: (isActivated: boolean) => storage.sync.set({ disableExtension: !isActivated }), - toggleFeatureFlag, - featureFlags: [ - { key: 'allowErrorReporting', value: this.state.allowErrorReporting }, - { key: 'experimentalLinkPreviews', value: this.state.experimentalLinkPreviews }, - { key: 'experimentalTextFieldCompletion', value: this.state.experimentalTextFieldCompletion }, - ], - } - - return - } -} - -const inject = (): void => { - const injectDOM = document.createElement('div') - injectDOM.className = 'sourcegraph-options-menu options' - document.body.appendChild(injectDOM) - // For shared CSS that would otherwise be dark by default - document.body.classList.add('theme-light') - - render(, injectDOM) -} - -document.addEventListener('DOMContentLoaded', inject) diff --git a/browser/src/extension/views/options.html b/browser/src/extension/views/options.html deleted file mode 100644 index 63904302e965..000000000000 --- a/browser/src/extension/views/options.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - Sourcegraph extension - - - - - - diff --git a/browser/src/globals.d.ts b/browser/src/globals.d.ts deleted file mode 100644 index b5c3bba67ee0..000000000000 --- a/browser/src/globals.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** Set by the browser extension page and extension entry scripts. */ -declare var SG_ENV: 'EXTENSION' | 'PAGE' | undefined - -/** Set by the browser extension content, background and option page entry scripts. */ -declare var EXTENSION_ENV: 'CONTENT' | 'BACKGROUND' | 'OPTIONS' | null | undefined - -/** Set by native integrations. */ -declare var SOURCEGRAPH_URL: string | undefined - -/** Set by native integrations. */ -declare var SOURCEGRAPH_INTEGRATION: - | 'phabricator-integration' - | 'bitbucket-integration' - | 'gitlab-integration' - | undefined - -/** - * Set by Gitlab native integration to load the assets from the Gitlab instance - * instead of the Sourcegraph instance. - */ -declare var SOURCEGRAPH_ASSETS_URL: string | undefined - -/** Global object with metadata available on Gitlab pages. */ -declare var gon: { - gitlab_url: string -} - -/** Set from the Phabricator native integration. **/ -declare var PHABRICATOR_CALLSIGN_MAPPINGS: - | { - callsign: string - path: string - }[] - | undefined - -/** Set from the Phabricator native integration. **/ -declare var SOURCEGRAPH_PHABRICATOR_EXTENSION: boolean | undefined - -/** Set from the Phabricator native integration. **/ -declare var SOURCEGRAPH_BUNDLE_URL: string | undefined - -/** - * Set by shared/dev/jest-environment.js - */ -declare var jsdom: import('jsdom').JSDOM - -/** - * For Web Worker entrypoints using Webpack's worker-loader. - * - * See https://github.com/webpack-contrib/worker-loader#integrating-with-typescript. - */ -declare module 'worker-loader?*' { - class WebpackWorker extends Worker { - constructor() - } - export default WebpackWorker -} diff --git a/browser/src/integration/integration.ts b/browser/src/integration/integration.ts deleted file mode 100644 index 6eda1ef71801..000000000000 --- a/browser/src/integration/integration.ts +++ /dev/null @@ -1,40 +0,0 @@ -import '../../../shared/src/polyfills' - -import { setLinkComponent, AnchorLink } from '../../../shared/src/components/Link' -import { injectCodeIntelligence } from '../libs/code_intelligence/inject' -import { EXTENSION_MARKER_ID, injectExtensionMarker, NATIVE_INTEGRATION_ACTIVATED } from '../libs/sourcegraph/inject' -import { getAssetsURL } from '../shared/util/context' - -const IS_EXTENSION = false - -setLinkComponent(AnchorLink) - -function init(): void { - console.log('Sourcegraph native integration is running') - const sourcegraphURL = window.SOURCEGRAPH_URL - if (!sourcegraphURL) { - throw new Error('window.SOURCEGRAPH_URL is undefined') - } - - const assetsURL = getAssetsURL(sourcegraphURL) - - if (document.getElementById(EXTENSION_MARKER_ID) !== null) { - // If the extension marker already exists, it means the browser extension is currently executing. - // Dispatch a custom event to signal that browser extension resources should be cleaned up. - document.dispatchEvent(new CustomEvent<{}>(NATIVE_INTEGRATION_ACTIVATED)) - } else { - injectExtensionMarker() - } - const link = document.createElement('link') - link.setAttribute('rel', 'stylesheet') - link.setAttribute('type', 'text/css') - link.setAttribute('href', new URL('css/style.bundle.css', assetsURL).href) - link.id = 'sourcegraph-styles' - document.getElementsByTagName('head')[0].appendChild(link) - window.localStorage.setItem('SOURCEGRAPH_URL', sourcegraphURL) - window.SOURCEGRAPH_URL = sourcegraphURL - // TODO handle subscription - injectCodeIntelligence({ sourcegraphURL, assetsURL }, IS_EXTENSION) -} - -init() diff --git a/browser/src/libs/bitbucket/api.ts b/browser/src/libs/bitbucket/api.ts deleted file mode 100644 index 697e6f8be219..000000000000 --- a/browser/src/libs/bitbucket/api.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { first } from 'lodash' -import { Observable } from 'rxjs' -import { filter, map } from 'rxjs/operators' -import { memoizeObservable } from '../../../../shared/src/util/memoizeObservable' -import { isDefined } from '../../../../shared/src/util/types' -import { DiffResolvedRevSpec } from '../../shared/repo' -import { BitbucketRepoInfo } from './scrape' -import { checkOk } from '../../../../shared/src/backend/fetch' -import { fromFetch } from '../../../../shared/src/graphql/fromFetch' - -// -// PR API /rest/api/1.0/projects/SG/repos/go-langserver/pull-requests/1 - -/** - * Builds the URL to the Bitbucket Server REST API endpoint for the given project/repo/path. - * - * `path` should have a leading slash. - * `project` and `repoSlug` should have neither a leading nor a traling slash. - */ -const buildURL = (project: string, repoSlug: string, path: string): string => - // If possible, use the global `AJS.contextPath()` to reliably construct an absolute URL. - // This is possible in the native integration only - browser extension content scripts cannot - // access the page's global scope. - `${window.AJS ? window.AJS.contextPath() : window.location.origin}/rest/api/1.0/projects/${encodeURIComponent( - project - )}/repos/${repoSlug}${path}` - -const get = (url: string): Observable => fromFetch(url, undefined, response => checkOk(response).json()) - -interface Repo { - project: { key: string } - name: string - public: boolean -} - -interface Ref { - /** - * The branch name. - */ - displayId: string - /** - * The commit ID. - */ - latestCommit: string - - repository: Repo -} - -interface PRResponse { - fromRef: Ref - toRef: Ref -} - -/** - * Get the base commit ID for a merge request. - */ -export const getCommitsForPR: ( - info: BitbucketRepoInfo & { prID: number } -) => Observable = memoizeObservable( - ({ project, repoSlug, prID }) => - get(buildURL(project, repoSlug, `/pull-requests/${prID}`)).pipe( - map(({ fromRef, toRef }) => ({ baseCommitID: toRef.latestCommit, headCommitID: fromRef.latestCommit })) - ), - ({ prID }) => prID.toString() -) - -interface GetBaseCommitInput extends BitbucketRepoInfo { - commitID: string -} - -interface Commit { - id: string -} - -interface CommitResponse { - parents: Commit[] -} - -// Commit API /rest/api/1.0/projects/SG/repos/go-langserver/commits/b8a948dc75cc9d0c01ece01d0ba9d1eeace573aa -export const getBaseCommit: (info: GetBaseCommitInput) => Observable = memoizeObservable( - ({ project, repoSlug, commitID }) => - get(buildURL(project, repoSlug, `/commits/${commitID}`)).pipe( - map(({ parents }) => first(parents)), - filter(isDefined), - map(({ id }) => id) - ), - ({ project, repoSlug, commitID }) => `${project}:${repoSlug}:${commitID}` -) diff --git a/browser/src/libs/bitbucket/code_intelligence.test.ts b/browser/src/libs/bitbucket/code_intelligence.test.ts deleted file mode 100644 index b88614d486d9..000000000000 --- a/browser/src/libs/bitbucket/code_intelligence.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { testCodeHostMountGetters, testToolbarMountGetter } from '../code_intelligence/code_intelligence_test_utils' -import { bitbucketServerCodeHost, getToolbarMount } from './code_intelligence' - -describe('bitbucketServerCodeHost', () => { - testCodeHostMountGetters(bitbucketServerCodeHost, { - getCommandPaletteMount: `${__dirname}/__fixtures__/browse.html`, - getViewContextOnSourcegraphMount: `${__dirname}/__fixtures__/browse.html`, - }) - describe('getToolbarMount()', () => { - testToolbarMountGetter(`${__dirname}/__fixtures__/code-views/pull-request/split/modified.html`, getToolbarMount) - }) -}) diff --git a/browser/src/libs/bitbucket/code_intelligence.tsx b/browser/src/libs/bitbucket/code_intelligence.tsx deleted file mode 100644 index 71bf970539e5..000000000000 --- a/browser/src/libs/bitbucket/code_intelligence.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { AdjustmentDirection, PositionAdjuster } from '@sourcegraph/codeintellify' -import { of } from 'rxjs' -import { Omit } from 'utility-types' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { FileSpec, RepoSpec, ResolvedRevSpec, RevSpec } from '../../../../shared/src/util/url' -import { querySelectorOrSelf } from '../../shared/util/dom' -import { CodeHost, MountGetter } from '../code_intelligence' -import { CodeView, DOMFunctions } from '../code_intelligence/code_views' -import { ViewResolver } from '../code_intelligence/views' -import { getContext } from './context' -import { diffDOMFunctions, singleFileDOMFunctions } from './dom_functions' -import { - resolveCommitViewFileInfo, - resolveCompareFileInfo, - resolveFileInfoForSingleFileSourceView, - resolvePullRequestFileInfo, - resolveSingleFileDiffFileInfo, -} from './file_info' -import { isCommitsView, isCompareView, isPullRequestView, isSingleFileView } from './scrape' -import { NotificationType } from '../../../../shared/src/api/client/services/notifications' - -/** - * Gets or creates the toolbar mount for allcode views. - */ -export const getToolbarMount = (codeView: HTMLElement): HTMLElement => { - const existingMount = codeView.querySelector('.sg-toolbar-mount') - if (existingMount) { - return existingMount - } - - const fileActions = codeView.querySelector('.file-toolbar .secondary') - if (!fileActions) { - throw new Error('Unable to find mount location') - } - - const mount = document.createElement('div') - mount.classList.add('btn-group') - mount.classList.add('sg-toolbar-mount') - mount.classList.add('sg-toolbar-mount-bitbucket-server') - - fileActions.insertAdjacentElement('afterbegin', mount) - - return mount -} - -/** - * Sometimes tabs are converted to spaces so we need to adjust. Luckily, there - * is an attribute `cm-text` that contains the real text. - */ -const createPositionAdjuster = (dom: DOMFunctions) => ( - requestGraphQL: PlatformContext['requestGraphQL'] -): PositionAdjuster => ({ direction, codeView, position }) => { - const codeElement = dom.getCodeElementFromLineNumber(codeView, position.line, position.part) - if (!codeElement) { - throw new Error('(adjustPosition) could not find code element for line provided') - } - - let delta = 0 - for (const modifiedTextElem of codeElement.querySelectorAll('[cm-text]')) { - const actualText = modifiedTextElem.getAttribute('cm-text') || '' - const adjustedText = modifiedTextElem.textContent || '' - - delta += actualText.length - adjustedText.length - } - - const modifier = direction === AdjustmentDirection.ActualToCodeView ? -1 : 1 - - const newPos = { - line: position.line, - character: position.character + modifier * delta, - } - - return of(newPos) -} - -const toolbarButtonProps = { - className: 'aui-button', -} - -/** - * A code view spec for single file code view in the "source" view (not diff). - */ -const singleFileSourceCodeView: Omit = { - getToolbarMount, - dom: singleFileDOMFunctions, - resolveFileInfo: resolveFileInfoForSingleFileSourceView, - getPositionAdjuster: createPositionAdjuster(singleFileDOMFunctions), - toolbarButtonProps, -} - -const baseDiffCodeView: Omit = { - getToolbarMount, - dom: diffDOMFunctions, - getPositionAdjuster: createPositionAdjuster(diffDOMFunctions), - toolbarButtonProps, -} -/** - * A code view spec for a single file "diff to previous" view - */ -const singleFileDiffCodeView: Omit = { - ...baseDiffCodeView, - resolveFileInfo: resolveSingleFileDiffFileInfo, -} - -/** - * A code view spec for pull requests - */ -const pullRequestDiffCodeView: Omit = { - ...baseDiffCodeView, - resolveFileInfo: resolvePullRequestFileInfo, -} - -/** - * A code view spec for compare pages - */ -const compareDiffCodeView: Omit = { - ...baseDiffCodeView, - resolveFileInfo: resolveCompareFileInfo, -} - -/** - * A code view spec for commit pages - */ -const commitDiffCodeView: Omit = { - ...baseDiffCodeView, - resolveFileInfo: resolveCommitViewFileInfo, -} - -const codeViewResolver: ViewResolver = { - selector: '.file-content', - resolveView: element => { - const contentView = element.querySelector('.content-view') - if (!contentView) { - return null - } - if (isCompareView()) { - return { element, ...compareDiffCodeView } - } - if (isCommitsView(window.location)) { - return { element, ...commitDiffCodeView } - } - if (isSingleFileView(element)) { - const isDiff = contentView.classList.contains('diff-view') - return isDiff ? { element, ...singleFileDiffCodeView } : { element, ...singleFileSourceCodeView } - } - if (isPullRequestView(window.location)) { - return { element, ...pullRequestDiffCodeView } - } - console.error('Unknown code view', element) - return null - }, -} - -const getCommandPaletteMount: MountGetter = (container: HTMLElement): HTMLElement | null => { - const headerElement = querySelectorOrSelf(container, '.aui-header-primary .aui-nav') - if (!headerElement) { - return null - } - const classes = ['command-palette-button', 'command-palette-button--bitbucket-server'] - const create = (): HTMLElement => { - const mount = document.createElement('li') - mount.className = classes.join(' ') - headerElement.insertAdjacentElement('beforeend', mount) - return mount - } - const preexisting = headerElement.querySelector(classes.map(c => `.${c}`).join('')) - return preexisting || create() -} - -function getViewContextOnSourcegraphMount(container: HTMLElement): HTMLElement | null { - const branchSelectorButtons = querySelectorOrSelf(container, '.branch-selector-toolbar .aui-buttons') - if (!branchSelectorButtons) { - return null - } - const preexisting = branchSelectorButtons.querySelector('#open-on-sourcegraph') - if (preexisting) { - return preexisting - } - const mount = document.createElement('span') - mount.id = 'open-on-sourcegraph' - mount.className = 'open-on-sourcegraph--bitbucket-server' - branchSelectorButtons.insertAdjacentElement('beforeend', mount) - return mount -} - -export const checkIsBitbucket = (): boolean => - !!document.querySelector('.bitbucket-header-logo') || - !!document.querySelector('.aui-header-logo.aui-header-logo-bitbucket') - -const iconClassName = 'aui-icon' - -const notificationClassNames = { - [NotificationType.Log]: 'aui-message aui-message-info', - [NotificationType.Success]: 'aui-message aui-message-success', - [NotificationType.Info]: 'aui-message aui-message-info', - [NotificationType.Warning]: 'aui-message aui-message-warning', - [NotificationType.Error]: 'aui-message aui-message-error', -} - -export const bitbucketServerCodeHost: CodeHost = { - type: 'bitbucket-server', - name: 'Bitbucket Server', - check: checkIsBitbucket, - codeViewResolvers: [codeViewResolver], - getCommandPaletteMount, - notificationClassNames, - commandPaletteClassProps: { - buttonClassName: - 'command-list-popover-button--bitbucket-server aui-alignment-target aui-alignment-abutted aui-alignment-abutted-left aui-alignment-element-attached-top aui-alignment-element-attached-left aui-alignment-target-attached-bottom aui-alignment-target-attached-left', - buttonElement: 'a', - buttonOpenClassName: 'aui-dropdown2-active active aui-alignment-enabled', - showCaret: false, - popoverClassName: - 'command-palette-popover--bitbucket-server aui-dropdown2 aui-style-default aui-layer aui-dropdown2-in-header aui-alignment-element aui-alignment-side-bottom aui-alignment-snap-left aui-alignment-enabled aui-alignment-abutted aui-alignment-abutted-left aui-alignment-element-attached-top aui-alignment-element-attached-left aui-alignment-target-attached-bottom aui-alignment-target-attached-left', - popoverInnerClassName: 'aui-dropdown2-section', - formClassName: 'aui', - inputClassName: 'text', - resultsContainerClassName: 'results', - listClassName: 'results-list', - listItemClassName: 'result', - selectedListItemClassName: 'focused', - noResultsClassName: 'no-results', - iconClassName, - }, - codeViewToolbarClassProps: { - className: 'code-view-toolbar--bitbucket aui-buttons', - actionItemClass: 'aui-button action-item--bitbucket-server', - // actionItemPressedClass is not needed because Bitbucket applies styling to aria-pressed="true" - actionItemIconClass: 'aui-icon', - listItemClass: 'action-nav-item--bitbucket', - }, - hoverOverlayClassProps: { - className: 'aui-dialog', - actionItemClassName: 'aui-button hover-action-item--bitbucket-server', - closeButtonClassName: 'aui-button', - infoAlertClassName: notificationClassNames[NotificationType.Info], - errorAlertClassName: notificationClassNames[NotificationType.Error], - iconClassName, - }, - getViewContextOnSourcegraphMount, - getContext, - viewOnSourcegraphButtonClassProps: { - className: 'aui-button', - iconClassName, - }, - codeViewsRequireTokenization: false, -} diff --git a/browser/src/libs/bitbucket/context.tsx b/browser/src/libs/bitbucket/context.tsx deleted file mode 100644 index 2cb1c07afb9e..000000000000 --- a/browser/src/libs/bitbucket/context.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { RawRepoSpec, RevSpec } from '../../../../shared/src/util/url' -import { CodeHostContext } from '../code_intelligence/code_intelligence' - -// example pathname: /projects/TEST/repos/some-repo/browse/src/extension.ts -const PATH_REGEX = /\/projects\/([^/]+)\/repos\/([^/]+)\// - -function getRawRepoSpecFromLocation(location: Pick): RawRepoSpec { - const { hostname, pathname } = location - const match = pathname.match(PATH_REGEX) - if (!match) { - throw new Error(`location pathname does not match path regex: ${pathname}`) - } - const [, projectName, repoName] = match - return { - rawRepoName: `${hostname}/${projectName}/${repoName}`, - } -} - -interface RevisionRefInfo { - latestCommit?: string -} - -function getRevSpecFromRevisionSelector(): RevSpec { - const branchNameElement = document.querySelector('#repository-layout-revision-selector .name[data-revision-ref]') - if (!branchNameElement) { - throw new Error('branchNameElement not found') - } - const revisionRefStr = branchNameElement.getAttribute('data-revision-ref') - let revisionRefInfo: RevisionRefInfo | null = null - if (revisionRefStr) { - try { - revisionRefInfo = JSON.parse(revisionRefStr) - } catch (err) { - throw new Error(`Could not parse revisionRefStr: ${revisionRefStr}`) - } - } - if (revisionRefInfo?.latestCommit) { - return { - rev: revisionRefInfo.latestCommit, - } - } - throw new Error(`revisionRefInfo is empty or has no latestCommit (revisionRefStr: ${String(revisionRefStr)})`) -} - -export function getContext(): CodeHostContext { - const repoSpec = getRawRepoSpecFromLocation(window.location) - let revSpec: Partial = {} - try { - revSpec = getRevSpecFromRevisionSelector() - } catch (err) { - // RevSpec is optional in CodeHostContext - } - return { - ...repoSpec, - ...revSpec, - privateRepository: window.location.hostname !== 'bitbucket.org', - } -} diff --git a/browser/src/libs/bitbucket/dom_functions.test.ts b/browser/src/libs/bitbucket/dom_functions.test.ts deleted file mode 100644 index b81f4a8e7239..000000000000 --- a/browser/src/libs/bitbucket/dom_functions.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { startCase } from 'lodash' -import { testDOMFunctions } from '../code_intelligence/code_intelligence_test_utils' -import { diffDOMFunctions, singleFileDOMFunctions } from './dom_functions' - -describe('Bitbucket DOM functions', () => { - describe('diffDOMFunctions', () => { - for (const view of ['split', 'unified']) { - describe(`${startCase(view)} view`, () => { - testDOMFunctions(diffDOMFunctions, { - htmlFixturePath: `${__dirname}/__fixtures__/code-views/pull-request/${view}/modified.html`, - lineCases: [ - { diffPart: 'head', lineNumber: 54 }, // not changed - { diffPart: 'head', lineNumber: 60 }, // added - { diffPart: 'base', lineNumber: 102 }, // removed - ], - }) - }) - } - }) - - describe('singleFileDOMFunctions', () => { - const htmlFixturePath = `${__dirname}/__fixtures__/code-views/single-file-source.html` - testDOMFunctions(singleFileDOMFunctions, { - htmlFixturePath, - lineCases: [{ lineNumber: 1 }, { lineNumber: 18 }], - }) - }) -}) diff --git a/browser/src/libs/bitbucket/dom_functions.ts b/browser/src/libs/bitbucket/dom_functions.ts deleted file mode 100644 index c467d91ce467..000000000000 --- a/browser/src/libs/bitbucket/dom_functions.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { DiffPart } from '@sourcegraph/codeintellify' -import { DOMFunctions } from '../code_intelligence/code_views' - -const getSingleFileLineElementFromLineNumber = (codeView: HTMLElement, line: number): HTMLElement => { - const lineNumElem = codeView.querySelector(`[data-line-number="${line}"]`) - if (!lineNumElem) { - throw new Error(`Line ${line} not found in code view`) - } - - const lineElem = lineNumElem.closest('.line') - if (!lineElem) { - throw new Error('Could not find line elem for line element') - } - - return lineElem -} - -export const singleFileDOMFunctions: DOMFunctions = { - getCodeElementFromTarget: target => { - const container = target.closest('.CodeMirror-line') - - return container ? container.querySelector('span[role="presentation"]') : null - }, - getLineNumberFromCodeElement: codeElement => { - const line = codeElement.closest('.line') - if (!line) { - throw new Error('Could not find line containing code element') - } - - const lineNumElem = line.querySelector('.line-locator') - if (!lineNumElem) { - throw new Error('Could not find the line number in a line container') - } - - const lineNum = parseInt(lineNumElem.dataset.lineNumber || '', 10) - if (isNaN(lineNum)) { - throw new Error('data-line-number not set on line number element') - } - - return lineNum - }, - getLineElementFromLineNumber: getSingleFileLineElementFromLineNumber, - getCodeElementFromLineNumber: (codeView, line) => - getSingleFileLineElementFromLineNumber(codeView, line).querySelector( - '.CodeMirror-line span[role="presentation"]' - ), -} - -const getDiffLineElementFromLineNumber = (codeView: HTMLElement, line: number, part?: DiffPart): HTMLElement => { - for (const lineNumElem of codeView.getElementsByClassName(`line-number-${part === 'head' ? 'to' : 'from'}`)) { - const lineNum = parseInt((lineNumElem.textContent || '').trim(), 10) - if (!isNaN(lineNum) && lineNum === line) { - const lineElem = lineNumElem.closest('.line') - if (!lineElem) { - throw new Error('Could not find lineElem from lineNumElem') - } - - return lineElem - } - } - - throw new Error(`Could not locate line number element for line ${line}, part: ${String(part)}`) -} - -export const diffDOMFunctions: DOMFunctions = { - getCodeElementFromTarget: singleFileDOMFunctions.getCodeElementFromTarget, - getLineNumberFromCodeElement: codeElement => { - const line = codeElement.closest('.line') - if (!line) { - throw new Error('Could not find line containing code element') - } - - const lineNumTo = line.querySelector('.line-number-to') - if (lineNumTo) { - const lineNum = parseInt((lineNumTo.textContent || '').trim(), 10) - if (!isNaN(lineNum)) { - return lineNum - } - } - - const lineNumFrom = line.querySelector('.line-number-from') - if (lineNumFrom) { - const lineNum = parseInt((lineNumFrom.textContent || '').trim(), 10) - if (!isNaN(lineNum)) { - return lineNum - } - } - - throw new Error('Could not find line number element for code element') - }, - getLineElementFromLineNumber: getDiffLineElementFromLineNumber, - getCodeElementFromLineNumber: (codeView, line, part) => - getDiffLineElementFromLineNumber(codeView, line, part).querySelector( - '.CodeMirror-line span[role="presentation"]' - ), - getDiffCodePart: codeElement => { - if (!document.querySelector('.side-by-side-diff')) { - return codeElement.closest('.line')!.classList.contains('removed') ? 'base' : 'head' - } - - const diffSide = codeElement.closest('.diff-editor')! - - return diffSide.previousElementSibling && - // If the sibling to the left is the diff divider, it's in the HEAD. - diffSide.previousElementSibling.classList.contains('segment-connector-column') - ? 'head' - : 'base' - }, - isFirstCharacterDiffIndicator: () => false, -} diff --git a/browser/src/libs/bitbucket/file_info.ts b/browser/src/libs/bitbucket/file_info.ts deleted file mode 100644 index 6ea50812db42..000000000000 --- a/browser/src/libs/bitbucket/file_info.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Observable, of } from 'rxjs' -import { map } from 'rxjs/operators' -import { FileInfo } from '../code_intelligence' -import { getBaseCommit, getCommitsForPR } from './api' -import { - getCommitInfoFromComparePage, - getFileInfoFromCommitDiffCodeView, - getFileInfoFromSingleFileDiffCodeView, - getFileInfoFromSingleFileSourceCodeView, - getFileInfoWithoutCommitIDsFromMultiFileDiffCodeView, - getPRIDFromPathName, -} from './scrape' - -/** - * Resolves file information for a page with a single file in source (not diff) view. - */ -export const resolveFileInfoForSingleFileSourceView = (codeView: HTMLElement): Observable => { - const fileInfo = getFileInfoFromSingleFileSourceCodeView(codeView) - return of(fileInfo) -} - -/** - * Gets the file info for a PR diff code view. - */ -export const resolvePullRequestFileInfo = (codeView: HTMLElement): Observable => { - const fileInfo = getFileInfoWithoutCommitIDsFromMultiFileDiffCodeView(codeView) - const prID = getPRIDFromPathName() - return getCommitsForPR({ ...fileInfo, prID }).pipe( - map(({ headCommitID, baseCommitID }) => ({ ...fileInfo, commitID: headCommitID, baseCommitID })) - ) -} - -/** - * Gets the file info for a single-file "diff to previous" code view. - */ -export const resolveSingleFileDiffFileInfo = (codeView: HTMLElement): Observable => { - const fileInfo = getFileInfoFromSingleFileDiffCodeView(codeView) - return getBaseCommit(fileInfo).pipe(map(baseCommitID => ({ baseCommitID, ...fileInfo }))) -} - -export const resolveCommitViewFileInfo = (codeView: HTMLElement): Observable => - of(getFileInfoFromCommitDiffCodeView(codeView)) - -/** - * Resolves the file info on a compare page. - */ -export const resolveCompareFileInfo = (codeView: HTMLElement): Observable => - of(codeView).pipe( - map(codeView => { - const { baseCommitID, headCommitID } = getCommitInfoFromComparePage() - return { - ...getFileInfoWithoutCommitIDsFromMultiFileDiffCodeView(codeView), - commitID: headCommitID, - baseCommitID, - } - }) - ) diff --git a/browser/src/libs/bitbucket/scrape.ts b/browser/src/libs/bitbucket/scrape.ts deleted file mode 100644 index ccead77c6962..000000000000 --- a/browser/src/libs/bitbucket/scrape.ts +++ /dev/null @@ -1,337 +0,0 @@ -import * as path from 'path' -import { createAggregateError } from '../../../../shared/src/util/errors' -import { DiffResolvedRevSpec } from '../../shared/repo' -import { FileInfo } from '../code_intelligence' - -export interface BitbucketRepoInfo { - repoSlug: string - project: string -} - -const LINK_SELECTORS = ['a.raw-view-link', 'a.source-view-link', 'a.mode-source'] - -const bitbucketToSourcegraphRepoName = ({ repoSlug, project }: BitbucketRepoInfo): string => - [window.location.hostname, project, repoSlug].join('/') - -/** - * Attempts to parse the file info from a link element contained in the given - * single-file code view (both source and "diff to previous" views). - * Depending on the configuration of the page, this can be a link to the raw file, - * or to the original source view, so a few different selectors are tried. - * - * The href of these links contains: - * - project name - * - repo name - * - file path - * - rev (through the query parameter `at`) - */ -const getFileInfoFromLinkInSingleFileView = ( - codeView: HTMLElement -): Pick & BitbucketRepoInfo => { - const errors: Error[] = [] - for (const selector of LINK_SELECTORS) { - try { - const linkElement = codeView.querySelector(selector) - if (!linkElement) { - throw new Error(`Could not find selector ${selector} in code view`) - } - const url = new URL(linkElement.href) - const path = url.pathname - - // Looks like /projects//repos//(browse|raw)/?at= - const pathMatch = path.match(/\/projects\/(.*?)\/repos\/(.*?)\/(?:browse|raw)\/(.*)$/) - if (!pathMatch) { - throw new Error(`Path of link matching selector ${selector} did not match path regex: ${path}`) - } - - const [, project, repoSlug, filePath] = pathMatch - - // Looks like 'refs/heads/' - const at = url.searchParams.get('at') - if (!at) { - throw new Error( - `href of link matching selector ${selector} did not have 'at' search param: ${url.href}` - ) - } - - const atMatch = at.match(/refs\/heads\/(.*?)$/) - - const rev = atMatch ? atMatch[1] : at - - return { - rawRepoName: bitbucketToSourcegraphRepoName({ repoSlug, project }), - filePath: decodeURIComponent(filePath), - rev, - project, - repoSlug, - } - } catch (err) { - errors.push(err) - continue - } - } - throw createAggregateError(errors) -} - -/** - * Attempts to retrieve the commitid from a link to the commit, - * found on single file views (both source and "diff to previous" views) and commit pages. - */ -export const getCommitIDFromLink = (selector = 'a.commitid'): string => { - const commitLink = document.querySelector(selector) - if (!commitLink) { - throw new Error('No element found matching a.commitid') - } - const commitID = commitLink.dataset.commitid - if (!commitID) { - throw new Error('Element matching a.commitid has no data-commitid') - } - return commitID -} - -const getCommitIDFromRevisionSelector = (): string => { - const revisionSelectorSpan = document.querySelector('span[data-revision-ref]') - if (!revisionSelectorSpan) { - throw new Error('Could not find span[data-revision-ref] element') - } - try { - const { latestCommit }: { latestCommit: string } = JSON.parse(revisionSelectorSpan.dataset.revisionRef!) - return latestCommit - } catch (err) { - throw new Error('Could not parse JSON from revision selector') - } -} - -/** - * Gets the file info on a single-file source code view - */ -export const getFileInfoFromSingleFileSourceCodeView = ( - codeViewElement: HTMLElement -): BitbucketRepoInfo & Pick => { - const { rawRepoName, filePath, rev, project, repoSlug } = getFileInfoFromLinkInSingleFileView(codeViewElement) - const commitID = getCommitIDFromRevisionSelector() - return { - rawRepoName, - filePath, - rev, - commitID, - project, - repoSlug, - } -} - -/** The type of the change of a file in a diff */ -type ChangeType = 'MOVE' | 'RENAME' | 'MODIFY' | 'DELETE' | 'COPY' | 'ADD' - -/** - * Returns true if the active page is a compare view. - */ -export const isCompareView = (): boolean => !!document.querySelector('#branch-compare') - -/** - * Returns true if the active page is a commit view. - */ -export const isCommitsView = ({ pathname }: Pick): boolean => - /\/projects\/[^/]+\/repos\/[^/]+\/commits\/\w+$/.test(pathname) - -/** - * Returns true if the active page is a pull request view. - */ -export const isPullRequestView = ({ pathname }: Pick): boolean => - /\/projects\/[^/]+\/repos\/[^/]+\/pull-requests\/\d+/.test(pathname) - -/** - * Returns true if the given code view is a single file source or "diff to previous" view. - * These views have a toggle to toggle between "source" and "diff to previous". - */ -export const isSingleFileView = (codeViewElement: HTMLElement): boolean => - !!codeViewElement.querySelector('.mode-toggle') - -/** - * Gets the change type indicator badge from the given diff code view. - * Returns `null` if there is no badge on the page (this is expected on single-file diff pages if the file was _modified_). - */ -const getChangeTypeElement = ({ codeViewElement }: { codeViewElement: HTMLElement }): HTMLElement | null => - codeViewElement.querySelector('.change-type-lozenge') - -/** - * Reads the change type from the change type indicator badge. - */ -const getChangeType = ({ changeTypeElement }: { changeTypeElement: HTMLElement | null }): ChangeType => { - if (!changeTypeElement) { - return 'MODIFY' - } - const className = Array.from(changeTypeElement.classList).find(c => /^change-type-[A-Z]+/.test(c)) - if (!className) { - throw new Error('Could not detect change type from change type element') - } - return className.replace(/^change-type-/, '') as ChangeType -} - -/** - * Gets the base file path for a diff code view ("diff to previous" or PR) by inspecting the change type badge. - * Returns `undefined` if there is no base file path (if the file was _added_). - * Returns `filePath` if the file was _modified_. - * - * @param filePath The head file path - */ -const getBaseFilePathForDiffCodeView = ({ - filePath, - changeType, - changeTypeElement, -}: { - changeTypeElement: HTMLElement | null - changeType: ChangeType - filePath: string -}): string | undefined => { - if (changeType === 'ADD') { - // This file didn't exist in the base - return undefined - } - if (changeType === 'MODIFY' || changeType === 'DELETE') { - // File path is the same - return filePath - } - if (changeType === 'MOVE' || changeType === 'RENAME' || changeType === 'COPY') { - if (!changeTypeElement) { - throw new Error(`Change type is ${changeType} but no change type indicator found`) - } - // Need to read previous file path from change type indicator - // Contains HTML content, example: - // .github/stale.yml →
test-dir/stale.yml - const tooltip = changeTypeElement.getAttribute('original-title') - if (!tooltip) { - throw new Error('Moved change type badge did not have original-title attribute') - } - const span = document.createElement('span') - span.innerHTML = tooltip - const tooltipText = span.textContent! - if (changeType === 'MOVE' || changeType === 'COPY') { - const from = tooltipText.split('โ†’')[0].trim() - if (!from) { - throw new Error(`Unexpected move change type badge content "${tooltipText}"`) - } - return from - } - if (changeType === 'RENAME') { - const renameRegexp = /Renamed from '(.+)'/ - const match = tooltipText.match(renameRegexp) - if (!match) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/no-base-to-string - `Rename change type badge content did not match ${renameRegexp.toString()}: "${tooltipText}"` - ) - } - return path.join(path.dirname(filePath), match[1]) - } - } - throw new Error(`Unexpected change type "${changeType as string}"`) -} - -/** - * Returns most file info from a single file "diff to previous" code view (excluding `baseCommitID`). - * The base commit ID needs to be resolved through the API. - */ -export const getFileInfoFromSingleFileDiffCodeView = ( - codeViewElement: HTMLElement -): BitbucketRepoInfo & Pick => { - const { rawRepoName, project, repoSlug, filePath } = getFileInfoFromLinkInSingleFileView(codeViewElement) - const commitID = getCommitIDFromLink() - const changeTypeElement = getChangeTypeElement({ codeViewElement }) - const changeType = getChangeType({ changeTypeElement }) - const baseFilePath = getBaseFilePathForDiffCodeView({ changeTypeElement, changeType, filePath }) - const baseRawRepoName = changeType !== 'ADD' ? rawRepoName : undefined - return { - rawRepoName, - baseRawRepoName, - filePath, - baseFilePath, - commitID, - project, - repoSlug, - } -} - -/** - * Gets most of the file info from the DOM of a multi-file diff code view (PR, compare or commit page). - * - * The returned file info does not have the commit ID and base commit ID. - * Those need to be fetched from the Bitbucket API for PRs, - * or taken from links on the page for compare and commit pages {@link getCommitInfoFromComparePage}. - */ -export const getFileInfoWithoutCommitIDsFromMultiFileDiffCodeView = ( - codeViewElement: HTMLElement -): BitbucketRepoInfo & Pick => { - // Get the file path from the breadcrumbs - const breadcrumbsElement = codeViewElement.querySelector('.breadcrumbs') - if (!breadcrumbsElement) { - throw new Error('Could not find diff code view breadcrumbs element through selector .breadcrumbs') - } - const filePath = breadcrumbsElement.textContent - if (!filePath) { - throw Error('Unexpected empty file path in breadcrumbs') - } - - // Get project and repo from the URL - const pathMatch = location.pathname.match(/\/projects\/(.*?)\/repos\/(.*?)\//) - if (!pathMatch) { - throw new Error('Location did not match regexp') - } - const [, project, repoSlug] = pathMatch - const rawRepoName = bitbucketToSourcegraphRepoName({ project, repoSlug }) - - // Get base file path from the change type indicator - const changeTypeElement = getChangeTypeElement({ codeViewElement }) - const changeType = getChangeType({ changeTypeElement }) - const baseFilePath = getBaseFilePathForDiffCodeView({ changeTypeElement, changeType, filePath }) - const baseRawRepoName = changeType !== 'ADD' ? rawRepoName : undefined // if the file was added, there is no base - - return { - rawRepoName, - baseRawRepoName, - filePath, - baseFilePath, - project, - repoSlug, - } -} - -export const getFileInfoFromCommitDiffCodeView = ( - codeViewElement: HTMLElement -): BitbucketRepoInfo & - Pick< - FileInfo, - 'rawRepoName' | 'baseRawRepoName' | 'filePath' | 'baseFilePath' | 'rev' | 'commitID' | 'baseCommitID' - > => { - const commitID = getCommitIDFromLink('.commit-badge-oneline .commitid') - const baseCommitID = getCommitIDFromLink('.commit-parents .commitid') - - return { - ...getFileInfoWithoutCommitIDsFromMultiFileDiffCodeView(codeViewElement), - commitID, - baseCommitID, - } -} - -export function getPRIDFromPathName(): number { - const prIDMatch = window.location.pathname.match(/pull-requests\/(\d*?)\/(diff|overview|commits)/) - if (!prIDMatch) { - throw new Error(`Could not parse PR ID from pathname: ${window.location.pathname}`) - } - return parseInt(prIDMatch[1], 10) -} - -/** - * Gets the head and base commit ID from the comparison pickers on the compare page. - */ -export function getCommitInfoFromComparePage(): DiffResolvedRevSpec { - const headCommitElement = document.querySelector('#branch-compare .source-selector a.commitid[data-commitid]') - const baseCommitElement = document.querySelector('#branch-compare .target-selector a.commitid[data-commitid]') - if (!headCommitElement || !baseCommitElement) { - throw new Error('Could not resolve Bitbucket compare diff spec') - } - return { - headCommitID: headCommitElement.getAttribute('data-commitid')!, - baseCommitID: baseCommitElement.getAttribute('data-commitid')!, - } -} diff --git a/browser/src/libs/bitbucket/style.scss b/browser/src/libs/bitbucket/style.scss deleted file mode 100644 index bb4d8872a825..000000000000 --- a/browser/src/libs/bitbucket/style.scss +++ /dev/null @@ -1,103 +0,0 @@ -// Command palette -.command-palette-button--bitbucket-server { - z-index: 3000; - font-size: 13px; - svg { - // The icon we use is taller than the other items' font size, so make it a bit shorter. - height: 13px; - } -} - -.command-palette-popover--bitbucket-server { - display: block !important; - max-width: unset !important; - - header { - padding: 8px; - } - input { - max-width: unset !important; - } - .no-results { - padding: 10px; - } -} - -// Open on Sourcegraph button -.open-on-sourcegraph--bitbucket-server { - margin-left: 2px; // same as other buttons in the row -} - -.code-view-toolbar--bitbucket { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - margin-top: -5px !important; -} - -.action-nav-item--bitbucket { - margin-left: 5px; - margin-top: 5px; -} - -// Use flexbox instead of float, so we can handle wrapping action items -.file-toolbar { - display: flex; - > .primary { - flex: 1 0 auto; - order: 1; - - display: flex; - align-items: center; - } - > .secondary { - // Overrides - float: none; - white-space: normal; - line-height: initial; - - flex: 1 1 auto; - order: 2; - - display: flex; - align-items: center; - justify-content: flex-end; - } -} - -// Hover overlay buttons -.hover-action-item--bitbucket-server { - margin: 0 !important; - border-radius: 0 !important; - border-bottom: none !important; - border-top: none !important; - border-right: none !important; - &:first-child { - border-left: none !important; - } -} - -// Bitbucket's style is copied here because adding the aui-dropdown2-trigger class -// to the command palette causes exceptions in Atlassian's JS. -.command-list-popover-button--bitbucket-server { - padding-right: 24px !important; - - &::after { - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - -webkit-text-stroke-width: 0; - font-family: 'Adgs Icons'; - font-weight: normal; - font-style: normal; - content: '\f15b'; - font-size: 16px; - height: 16px; - line-height: 1; - margin-top: -8px; - position: absolute; - right: 4px; - top: 50%; - text-indent: 0; - width: 16px; - } -} diff --git a/browser/src/libs/cli/index.ts b/browser/src/libs/cli/index.ts deleted file mode 100644 index e570b649c699..000000000000 --- a/browser/src/libs/cli/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PlatformContext } from '../../../../shared/src/platform/context' -import { SearchCommand } from './search' - -export function initializeOmniboxInterface(requestGraphQL: PlatformContext['requestGraphQL']): void { - const searchCommand = new SearchCommand(requestGraphQL) - browser.omnibox.onInputChanged.addListener(async (query, suggest) => { - try { - const suggestions = await searchCommand.getSuggestions(query) - suggest(suggestions) - } catch (err) { - console.error('error getting suggestions', err) - } - }) - - browser.omnibox.onInputEntered.addListener(async (query, disposition) => { - await searchCommand.action(query, disposition) - }) -} diff --git a/browser/src/libs/cli/search.ts b/browser/src/libs/cli/search.ts deleted file mode 100644 index 27f1b48970d1..000000000000 --- a/browser/src/libs/cli/search.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { take } from 'rxjs/operators' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { buildSearchURLQuery } from '../../../../shared/src/util/url' -import { createSuggestionFetcher } from '../../shared/backend/search' -import { observeSourcegraphURL } from '../../shared/util/context' -import { SearchPatternType } from '../../../../shared/src/graphql/schema' - -const isURL = /^https?:\/\// - -export class SearchCommand { - public description = 'Enter a search query' - - private suggestionFetcher = createSuggestionFetcher(20, this.requestGraphQL) - - private prev: { query: string; suggestions: browser.omnibox.SuggestResult[] } = { query: '', suggestions: [] } - - constructor(private requestGraphQL: PlatformContext['requestGraphQL']) {} - - public getSuggestions = (query: string): Promise => - new Promise(resolve => { - if (this.prev.query === query) { - resolve(this.prev.suggestions) - return - } - - this.suggestionFetcher({ - query, - handler: async suggestions => { - const sourcegraphURL = await observeSourcegraphURL(true) // isExtension=true, this feature is only supported in the browser extension - .pipe(take(1)) - .toPromise() - const built = suggestions.map(({ title, url, urlLabel }) => ({ - content: `${sourcegraphURL}${url}`, - description: `${title} - ${urlLabel}`, - })) - - this.prev = { - query, - suggestions: built, - } - - resolve(built) - }, - }) - }) - - public action = async (query: string, disposition?: string): Promise => { - const sourcegraphURL = await observeSourcegraphURL(true) // isExtension=true, this feature is only supported in the browser extension - .pipe(take(1)) - .toPromise() - const props = { - url: isURL.test(query) - ? query - : `${sourcegraphURL}/search?${buildSearchURLQuery( - query, - SearchPatternType.literal, - false - )}&utm_source=omnibox`, - } - - switch (disposition) { - case 'newForegroundTab': - await browser.tabs.create(props) - break - case 'newBackgroundTab': - await browser.tabs.create({ ...props, active: false }) - break - case 'currentTab': - default: - await browser.tabs.update(props) - break - } - } -} diff --git a/browser/src/libs/code_intelligence/HoverOverlay.scss b/browser/src/libs/code_intelligence/HoverOverlay.scss deleted file mode 100644 index 5629e61c2d60..000000000000 --- a/browser/src/libs/code_intelligence/HoverOverlay.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import '../../shared/global-styles/variables.scss'; -@import '../../../../shared/src/hover/HoverOverlay.scss'; - -.hover-overlay { - z-index: $default-z-index; -} diff --git a/browser/src/libs/code_intelligence/__snapshots__/code_intelligence.test.tsx.snap b/browser/src/libs/code_intelligence/__snapshots__/code_intelligence.test.tsx.snap deleted file mode 100644 index 75ff53d4b4aa..000000000000 --- a/browser/src/libs/code_intelligence/__snapshots__/code_intelligence.test.tsx.snap +++ /dev/null @@ -1,254 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`code_intelligence handleCodeHost() Decorations decorates a diff code view 1`] = ` -
-
- - - test decoration head line 1 - - -
- - -
- - - test decoration base line 2 - - -
- - -
- - - test decoration head line 2 - - -
- - -
- -
- - -
- - - test decoration base line 5 - - -
- - -
-`; - -exports[`code_intelligence handleCodeHost() Decorations decorates a diff code view 2`] = ` -
-
- - - test decoration head line 1 - - -
- - -
- -
- - -
- - - test decoration head line 2 - - -
- - -
- -
- - -
- - - test decoration base line 5 - - -
- - -
-`; - -exports[`code_intelligence handleCodeHost() Decorations decorates a diff code view 3`] = ` -
-
- - - test decoration head line 1 - - -
- - -
- -
- - -
- - - test decoration head line 2 changed - - -
- - -
- -
- - -
- - - test decoration base line 5 - - -
- - -
-`; diff --git a/browser/src/libs/code_intelligence/__snapshots__/external_links.test.tsx.snap b/browser/src/libs/code_intelligence/__snapshots__/external_links.test.tsx.snap deleted file mode 100644 index ca7e0a77059a..000000000000 --- a/browser/src/libs/code_intelligence/__snapshots__/external_links.test.tsx.snap +++ /dev/null @@ -1,234 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` minimalUI = false renders a sign in button when authentication failed and showSignInButton = true 1`] = ` - - - - - - - - - - Sign in to Sourcegraph - -`; - -exports[` minimalUI = true renders a sign in button when authentication failed and showSignInButton = true 1`] = ` - - - - - - - - - - Sign in to Sourcegraph - -`; - -exports[` renders a button with an error label if the repo exists check failed with an unknown error 1`] = ` - - - - - - - - - - Error - -`; - -exports[` renders a link to the repository on the Sourcegraph instance 1`] = ` - - - - - - - - - - -`; - -exports[` renders a link with the rev when provided 1`] = ` - - - - - - - - - - -`; - -exports[` renders configure sourcegraph button when pointing at sourcegraph.com and the repo does not exist 1`] = ` - - - - - - - - - - Configure Sourcegraph - -`; - -exports[` renders nothing in minimal UI mode 1`] = `null`; - -exports[` still renders a button to a private instance if repo does not exist 1`] = ` - - - - - - - - - - -`; diff --git a/browser/src/libs/code_intelligence/code_intelligence.test.tsx b/browser/src/libs/code_intelligence/code_intelligence.test.tsx deleted file mode 100644 index ea85115c829a..000000000000 --- a/browser/src/libs/code_intelligence/code_intelligence.test.tsx +++ /dev/null @@ -1,901 +0,0 @@ -import { DiffPart } from '@sourcegraph/codeintellify' -import { Range } from '@sourcegraph/extension-api-classes' -import { uniqueId, noop } from 'lodash' -import renderer from 'react-test-renderer' -import { BehaviorSubject, from, NEVER, of, Subject, Subscription, throwError } from 'rxjs' -import { filter, skip, switchMap, take, first } from 'rxjs/operators' -import { TestScheduler } from 'rxjs/testing' -import * as sinon from 'sinon' -import { Services } from '../../../../shared/src/api/client/services' -import { integrationTestContext } from '../../../../shared/src/api/integration-test/testHelpers' -import { PrivateRepoPublicSourcegraphComError } from '../../../../shared/src/backend/errors' -import { Controller } from '../../../../shared/src/extensions/controller' -import { SuccessGraphQLResult } from '../../../../shared/src/graphql/graphql' -import { IQuery } from '../../../../shared/src/graphql/schema' -import { NOOP_TELEMETRY_SERVICE } from '../../../../shared/src/telemetry/telemetryService' -import { resetAllMemoizationCaches } from '../../../../shared/src/util/memoizeObservable' -import { isDefined, subTypeOf } from '../../../../shared/src/util/types' -import { DEFAULT_SOURCEGRAPH_URL } from '../../shared/util/context' -import { MutationRecordLike } from '../../shared/util/dom' -import { - CodeIntelligenceProps, - createGlobalDebugMount, - createOverlayMount, - FileInfo, - handleCodeHost, - observeHoverOverlayMountLocation, - HandleCodeHostOptions, -} from './code_intelligence' -import { toCodeViewResolver } from './code_views' -import { DEFAULT_GRAPHQL_RESPONSES, mockRequestGraphQL } from './test_helpers' -import { TextDocumentDecoration } from '@sourcegraph/extension-api-types' -import { NotificationType } from '../../../../shared/src/api/client/services/notifications' -import { toPrettyBlobURL } from '../../../../shared/src/util/url' - -const RENDER = sinon.spy() - -const notificationClassNames = { - [NotificationType.Log]: 'log', - [NotificationType.Success]: 'success', - [NotificationType.Info]: 'info', - [NotificationType.Warning]: 'warning', - [NotificationType.Error]: 'error', -} - -const elementRenderedAtMount = (mount: Element): renderer.ReactTestRendererJSON | undefined => { - const call = RENDER.args.find(call => call[1] === mount) - return call?.[0] -} - -const scheduler = (): TestScheduler => new TestScheduler((a, b) => expect(a).toEqual(b)) - -const createTestElement = (): HTMLElement => { - const el = document.createElement('div') - el.className = `test test-${uniqueId()}` - document.body.appendChild(el) - return el -} - -jest.mock('uuid', () => ({ - v4: () => 'uuid', -})) - -const createMockController = (services: Services): Controller => ({ - services, - notifications: NEVER, - executeCommand: () => Promise.resolve(), - unsubscribe: noop, -}) - -const createMockPlatformContext = ( - partialMocks?: Partial -): CodeIntelligenceProps['platformContext'] => ({ - forceUpdateTooltip: noop, - urlToFile: toPrettyBlobURL, - requestGraphQL: mockRequestGraphQL(), - sideloadedExtensionURL: new Subject(), - settings: NEVER, - refreshSettings: () => Promise.resolve(), - ...partialMocks, -}) - -const commonArgs = () => - subTypeOf>()({ - mutations: of([{ addedNodes: [document.body], removedNodes: [] }]), - showGlobalDebug: false, - platformContext: createMockPlatformContext(), - sourcegraphURL: DEFAULT_SOURCEGRAPH_URL, - telemetryService: NOOP_TELEMETRY_SERVICE, - render: RENDER, - userSignedIn: true, - minimalUI: false, - }) - -describe('code_intelligence', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - - describe('createOverlayMount()', () => { - it('should create the overlay mount', () => { - createOverlayMount('some-code-host', document.body) - const mount = document.body.querySelector('.hover-overlay-mount') - expect(mount).toBeDefined() - expect(mount!.className).toBe('hover-overlay-mount hover-overlay-mount__some-code-host theme-light') - }) - }) - - describe('createGlobalDebugMount()', () => { - it('should create the debug menu mount', () => { - createGlobalDebugMount() - const mount = document.body.querySelector('.global-debug') - expect(mount).toBeDefined() - }) - }) - - describe('handleCodeHost()', () => { - let subscriptions = new Subscription() - - afterEach(() => { - RENDER.resetHistory() - resetAllMemoizationCaches() - subscriptions.unsubscribe() - subscriptions = new Subscription() - }) - - test('renders the hover overlay mount', async () => { - const { services } = await integrationTestContext() - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - codeViewResolvers: [], - notificationClassNames, - }, - extensionsController: createMockController(services), - }) - ) - const overlayMount = document.body.querySelector('.hover-overlay-mount') - expect(overlayMount).toBeDefined() - expect(overlayMount!.className).toBe('hover-overlay-mount hover-overlay-mount__github theme-light') - const renderedOverlay = elementRenderedAtMount(overlayMount!) - expect(renderedOverlay).not.toBeUndefined() - }) - - test('renders the command palette if codeHost.getCommandPaletteMount is defined', async () => { - const { services } = await integrationTestContext() - const commandPaletteMount = createTestElement() - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - getCommandPaletteMount: () => commandPaletteMount, - codeViewResolvers: [], - notificationClassNames, - }, - extensionsController: createMockController(services), - }) - ) - const renderedCommandPalette = elementRenderedAtMount(commandPaletteMount) - expect(renderedCommandPalette).not.toBeUndefined() - }) - - test('creates a .global-debug element and renders the debug menu if showGlobalDebug is true', async () => { - const { services } = await integrationTestContext() - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - codeViewResolvers: [], - notificationClassNames, - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - }) - ) - const globalDebugMount = document.body.querySelector('.global-debug') - expect(globalDebugMount).toBeDefined() - const renderedDebugElement = elementRenderedAtMount(globalDebugMount!) - expect(renderedDebugElement).toBeDefined() - }) - - test('detects code views based on selectors', async () => { - const { services } = await integrationTestContext(undefined, { roots: [], editors: [] }) - const codeView = createTestElement() - codeView.id = 'code' - const toolbarMount = document.createElement('div') - codeView.appendChild(toolbarMount) - const fileInfo: FileInfo = { - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - } - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom: { - getCodeElementFromTarget: sinon.spy(), - getCodeElementFromLineNumber: sinon.spy(), - getLineElementFromLineNumber: sinon.spy(), - getLineNumberFromCodeElement: sinon.spy(), - }, - resolveFileInfo: codeView => of(fileInfo), - getToolbarMount: () => toolbarMount, - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - platformContext: createMockPlatformContext({ - // Simulate an instance with repositoryPathPattern - requestGraphQL: mockRequestGraphQL({ - ...DEFAULT_GRAPHQL_RESPONSES, - ResolveRepo: variables => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - of({ - data: { - repository: { - name: `github/${variables.rawRepoName as string}`, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), - }), - }), - }) - ) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect([...services.editor.editors.values()]).toEqual([ - { - editorId: 'editor#0', - isActive: true, - // The repo name exposed to extensions is affected by repositoryPathPattern - resource: 'git://github/foo?1#/bar.ts', - selections: [], - type: 'CodeEditor', - }, - ]) - expect(codeView.classList.contains('sg-mounted')).toBe(true) - const toolbar = elementRenderedAtMount(toolbarMount) - expect(toolbar).not.toBeUndefined() - }) - - describe('Decorations', () => { - it('decorates a code view', async () => { - const { extensionAPI, services } = await integrationTestContext(undefined, { - roots: [], - editors: [], - }) - const codeView = createTestElement() - codeView.id = 'code' - const fileInfo: FileInfo = { - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - } - // For this test, we pretend bar.ts only has one line of code - const line = document.createElement('div') - codeView.appendChild(line) - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom: { - getCodeElementFromTarget: () => line, - getCodeElementFromLineNumber: () => line, - getLineElementFromLineNumber: () => line, - getLineNumberFromCodeElement: () => 1, - }, - resolveFileInfo: codeView => of(fileInfo), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - }) - ) - const activeEditor = await from(extensionAPI.app.activeWindowChanges) - .pipe( - filter(isDefined), - switchMap(window => window.activeViewComponentChanges), - filter(isDefined), - take(1) - ) - .toPromise() - const decorationType = extensionAPI.app.createDecorationType() - const decorated = (): Promise => - services.textDocumentDecoration - .getDecorations({ uri: 'git://foo?1#/bar.ts' }) - .pipe( - filter(decorations => Boolean(decorations && decorations.length > 0)), - take(1) - ) - .toPromise() - - // Set decorations and verify that a decoration attachment has been added - activeEditor.setDecorations(decorationType, [ - { - range: new Range(0, 0, 0, 0), - after: { - contentText: 'test decoration', - }, - }, - ]) - await decorated() - expect(line.querySelectorAll('.line-decoration-attachment')).toHaveLength(1) - expect(line.querySelector('.line-decoration-attachment')!.textContent).toEqual('test decoration') - - // Decorate the code view again, and verify that previous decorations - // are cleaned up and replaced by the new decorations. - activeEditor.setDecorations(decorationType, [ - { - range: new Range(0, 0, 0, 0), - after: { - contentText: 'test decoration 2', - }, - }, - ]) - await services.textDocumentDecoration - .getDecorations({ uri: 'git://foo?1#/bar.ts' }) - .pipe( - filter( - decorations => - !!decorations && - !!decorations[0].after && - decorations[0].after.contentText === 'test decoration 2' - ), - take(1) - ) - .toPromise() - expect(line.querySelectorAll('.line-decoration-attachment').length).toBe(1) - expect(line.querySelector('.line-decoration-attachment')!.textContent).toEqual('test decoration 2') - }) - - it('decorates a diff code view', async () => { - const { extensionAPI, services } = await integrationTestContext(undefined, { - roots: [], - editors: [], - }) - const codeView = createTestElement() - codeView.id = 'code' - const fileInfo: FileInfo = { - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '2', - baseRawRepoName: 'foo', - baseFilePath: '/bar.ts', - baseCommitID: '1', - } - codeView.innerHTML = - '
\n' + - '
\n' + - '
\n' + - '
\n' + - '
\n' - const dom = { - getCodeElementFromTarget: (target: HTMLElement) => target.closest('.code-element') as HTMLElement, - getCodeElementFromLineNumber: (codeView: HTMLElement, line: number, part?: DiffPart) => - codeView.querySelector(`[line="${line}"][part="${String(part)}"] > .code-element`), - getLineElementFromLineNumber: (codeView: HTMLElement, line: number, part?: DiffPart) => - codeView.querySelector(`[line="${line}"][part="${String(part)}"]`), - getLineNumberFromCodeElement: (codeElement: HTMLElement) => - parseInt(codeElement.parentElement!.getAttribute('line')!, 10), - } - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom, - resolveFileInfo: () => of(fileInfo), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - platformContext: createMockPlatformContext({}), - }) - ) - await from(extensionAPI.app.activeWindowChanges) - .pipe( - filter(isDefined), - switchMap(window => window.activeViewComponentChanges), - filter(isDefined), - take(2) - ) - .toPromise() - const decorationType = extensionAPI.app.createDecorationType() - const decorated = (commit: string): Promise => - services.textDocumentDecoration - .getDecorations({ uri: `git://foo?${commit}#/bar.ts` }) - .pipe(skip(1), take(1)) - .toPromise() - - // Set decorations and verify that a decoration attachment has been added - const editors = extensionAPI.app.activeWindow!.visibleViewComponents - expect(editors).toHaveLength(2) - - const baseEditor = editors.find(e => e.document.uri === 'git://foo?1#/bar.ts')! - const baseDecorations = [ - { - range: new Range(0, 0, 0, 0), - isWholeLine: true, - backgroundColor: 'red', - after: { - contentText: 'test decoration base line 1', - }, - }, - { - range: new Range(1, 0, 1, 0), - isWholeLine: true, - backgroundColor: 'red', - after: { - contentText: 'test decoration base line 2', - }, - }, - { - range: new Range(4, 0, 4, 0), - isWholeLine: true, - backgroundColor: 'red', - after: { - contentText: 'test decoration base line 5', - }, - }, - ] - baseEditor.setDecorations(decorationType, baseDecorations) - - const headEditor = editors.find(e => e.document.uri === 'git://foo?2#/bar.ts')! - const headDecorations = [ - { - range: new Range(0, 0, 0, 0), - isWholeLine: true, - after: { - contentText: 'test decoration head line 1', - }, - }, - { - range: new Range(1, 0, 1, 0), - isWholeLine: true, - backgroundColor: 'blue', - after: { - contentText: 'test decoration head line 2', - }, - }, - { - range: new Range(6, 0, 6, 0), - isWholeLine: true, - after: { - contentText: 'test decoration not visible', - }, - }, - ] - headEditor.setDecorations(decorationType, headDecorations) - - await Promise.all([decorated('1'), decorated('2')]) - - expect(codeView).toMatchSnapshot() - - // Decorate the code view again, and verify that previous decorations - // are cleaned up and replaced by the new decorations. - // Remove decoration in first and second line - baseEditor.setDecorations(decorationType, baseDecorations.slice(2)) - await decorated('1') - expect(codeView).toMatchSnapshot() - - // Change decoration in first line - headEditor.setDecorations(decorationType, [ - headDecorations[0], - { - ...headDecorations[1], - after: { - ...headDecorations[1].after, - contentText: 'test decoration head line 2 changed', - }, - }, - headDecorations[2], - ]) - await decorated('2') - expect(codeView).toMatchSnapshot() - }) - }) - - test('removes code views and models', async () => { - const { services } = await integrationTestContext(undefined, { - roots: [], - editors: [], - }) - const codeView1 = createTestElement() - codeView1.className = 'code' - const codeView2 = createTestElement() - codeView2.className = 'code' - const fileInfo: FileInfo = { - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - } - const mutations = new BehaviorSubject([ - { addedNodes: [document.body], removedNodes: [] }, - ]) - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - mutations, - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - codeViewResolvers: [ - toCodeViewResolver('.code', { - dom: { - getCodeElementFromTarget: sinon.spy(), - getCodeElementFromLineNumber: sinon.spy(), - getLineElementFromLineNumber: sinon.spy(), - getLineNumberFromCodeElement: sinon.spy(), - }, - resolveFileInfo: codeView => of(fileInfo), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - platformContext: createMockPlatformContext(), - }) - ) - await from(services.editor.editorUpdates).pipe(skip(1), take(1)).toPromise() - expect([...services.editor.editors.values()]).toEqual([ - { - editorId: 'editor#0', - isActive: true, - resource: 'git://foo?1#/bar.ts', - selections: [], - type: 'CodeEditor', - }, - { - editorId: 'editor#1', - isActive: true, - resource: 'git://foo?1#/bar.ts', - selections: [], - type: 'CodeEditor', - }, - ]) - expect(services.model.hasModel('git://foo?1#/bar.ts')).toBe(true) - // Simulate codeView1 removal - mutations.next([{ addedNodes: [], removedNodes: [codeView1] }]) - // One editor should have been removed, model should still exist - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect([...services.editor.editors.values()]).toEqual([ - { - editorId: 'editor#1', - isActive: true, - resource: 'git://foo?1#/bar.ts', - selections: [], - type: 'CodeEditor', - }, - ]) - expect(services.model.hasModel('git://foo?1#/bar.ts')).toBe(true) - // Simulate codeView2 removal - mutations.next([{ addedNodes: [], removedNodes: [codeView2] }]) - // Second editor and model should have been removed - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect([...services.editor.editors.values()]).toEqual([]) - expect(services.model.hasModel('git://foo?1#/bar.ts')).toBe(false) - }) - - test('Hoverifies a view if the code host has no nativeTooltipResolvers', async () => { - const { services } = await integrationTestContext(undefined, { roots: [], editors: [] }) - const codeView = createTestElement() - codeView.id = 'code' - const codeElement = document.createElement('span') - codeElement.innerText = 'alert(1)' - codeView.appendChild(codeElement) - const dom = { - getCodeElementFromTarget: sinon.spy(() => codeElement), - getCodeElementFromLineNumber: sinon.spy(() => codeElement), - getLineElementFromLineNumber: sinon.spy(() => codeElement), - getLineNumberFromCodeElement: sinon.spy(() => 1), - } - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom, - resolveFileInfo: codeView => - of({ - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - }), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - }) - ) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect(services.editor.editors.size).toEqual(1) - codeView.dispatchEvent(new MouseEvent('mouseover')) - sinon.assert.called(dom.getCodeElementFromTarget) - }) - - test('Does not hoverify a view if the code host has nativeTooltipResolvers and they are enabled from settings', async () => { - const { services } = await integrationTestContext(undefined, { roots: [], editors: [] }) - const codeView = createTestElement() - codeView.id = 'code' - const codeElement = document.createElement('span') - codeElement.innerText = 'alert(1)' - codeView.appendChild(codeElement) - const dom = { - getCodeElementFromTarget: sinon.spy(() => codeElement), - getCodeElementFromLineNumber: sinon.spy(() => codeElement), - getLineElementFromLineNumber: sinon.spy(() => codeElement), - getLineNumberFromCodeElement: sinon.spy(() => 1), - } - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - nativeTooltipResolvers: [{ selector: '.native', resolveView: element => ({ element }) }], - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom, - resolveFileInfo: codeView => - of({ - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - }), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - platformContext: { - ...createMockPlatformContext(), - settings: of({ - subjects: [], - final: { - extensions: {}, - 'codeHost.useNativeTooltips': true, - }, - }), - }, - }) - ) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - - expect(services.editor.editors.size).toEqual(1) - codeView.dispatchEvent(new MouseEvent('mouseover')) - sinon.assert.notCalled(dom.getCodeElementFromTarget) - }) - - test('Hides native tooltips if they are disabled from settings', async () => { - const { services } = await integrationTestContext(undefined, { roots: [], editors: [] }) - const codeView = createTestElement() - codeView.id = 'code' - const codeElement = document.createElement('span') - codeElement.innerText = 'alert(1)' - codeView.appendChild(codeElement) - const nativeTooltip = createTestElement() - nativeTooltip.classList.add('native') - const dom = { - getCodeElementFromTarget: sinon.spy(() => codeElement), - getCodeElementFromLineNumber: sinon.spy(() => codeElement), - getLineElementFromLineNumber: sinon.spy(() => codeElement), - getLineNumberFromCodeElement: sinon.spy(() => 1), - } - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - nativeTooltipResolvers: [{ selector: '.native', resolveView: element => ({ element }) }], - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom, - resolveFileInfo: codeView => - of({ - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - }), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - platformContext: { - ...createMockPlatformContext(), - settings: of({ - subjects: [], - final: { - extensions: {}, - 'codeHost.useNativeTooltips': false, - }, - }), - }, - }) - ) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect(services.editor.editors.size).toEqual(1) - codeView.dispatchEvent(new MouseEvent('mouseover')) - sinon.assert.called(dom.getCodeElementFromTarget) - expect(nativeTooltip.classList.contains('native-tooltip--hidden')).toBe(true) - }) - - test('gracefully handles viewing private repos on a public Sourcegraph instance', async () => { - const { services } = await integrationTestContext(undefined, { roots: [], editors: [] }) - const codeView = createTestElement() - codeView.id = 'code' - const fileInfo: FileInfo = { - rawRepoName: 'github.com/foo', - filePath: '/bar.ts', - commitID: '1', - } - subscriptions.add( - handleCodeHost({ - ...commonArgs(), - codeHost: { - type: 'github', - name: 'GitHub', - check: () => true, - notificationClassNames, - codeViewResolvers: [ - toCodeViewResolver('#code', { - dom: { - getCodeElementFromTarget: sinon.spy(), - getCodeElementFromLineNumber: sinon.spy(), - getLineElementFromLineNumber: sinon.spy(), - getLineNumberFromCodeElement: sinon.spy(), - }, - resolveFileInfo: () => of(fileInfo), - }), - ], - }, - extensionsController: createMockController(services), - showGlobalDebug: true, - platformContext: createMockPlatformContext({ - // Simulate an instance where all repo-specific graphQL requests error with - // PrivateRepoPublicSourcegraph - requestGraphQL: mockRequestGraphQL({ - ...DEFAULT_GRAPHQL_RESPONSES, - BlobContent: () => throwError(new PrivateRepoPublicSourcegraphComError('BlobContent')), - ResolveRepo: () => throwError(new PrivateRepoPublicSourcegraphComError('ResolveRepo')), - ResolveRev: () => throwError(new PrivateRepoPublicSourcegraphComError('ResolveRev')), - }), - }), - }) - ) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect([...services.editor.editors.values()]).toEqual([ - { - editorId: 'editor#0', - isActive: true, - // Repo name exposed in URIs is the raw repo name - resource: 'git://github.com/foo?1#/bar.ts', - selections: [], - type: 'CodeEditor', - }, - ]) - }) - }) - - describe('observeHoverOverlayMountLocation()', () => { - test('emits document.body if the getMountLocationSelector() returns null', () => { - scheduler().run(({ cold, expectObservable }) => { - expectObservable( - observeHoverOverlayMountLocation( - () => null, - cold('a', { - a: [ - { - addedNodes: [document.body], - removedNodes: [], - }, - ], - }) - ) - ).toBe('a', { - a: document.body, - }) - }) - }) - - test('emits a custom mount location if a node matching the selector is in addedNodes()', () => { - const el = createTestElement() - scheduler().run(({ cold, expectObservable }) => { - expectObservable( - observeHoverOverlayMountLocation( - () => '.test', - cold('-b', { - b: [ - { - addedNodes: [el], - removedNodes: [], - }, - ], - }) - ) - ).toBe('ab', { - a: document.body, - b: el, - }) - }) - }) - - test('emits a custom mount location if a node matching the selector is nested in an addedNode', () => { - const el = createTestElement() - const nested = document.createElement('div') - nested.classList.add('nested') - el.appendChild(nested) - scheduler().run(({ cold, expectObservable }) => { - expectObservable( - observeHoverOverlayMountLocation( - () => '.nested', - cold('-b', { - b: [ - { - addedNodes: [el], - removedNodes: [], - }, - ], - }) - ) - ).toBe('ab', { - a: document.body, - b: nested, - }) - }) - }) - - test('emits document.body if a node matching the selector is removed', () => { - const el = createTestElement() - scheduler().run(({ cold, expectObservable }) => { - expectObservable( - observeHoverOverlayMountLocation( - () => '.test', - cold('-bc', { - b: [ - { - addedNodes: [el], - removedNodes: [], - }, - ], - c: [ - { - addedNodes: [], - removedNodes: [el], - }, - ], - }) - ) - ).toBe('abc', { - a: document.body, - b: el, - c: document.body, - }) - }) - }) - }) -}) diff --git a/browser/src/libs/code_intelligence/code_intelligence.tsx b/browser/src/libs/code_intelligence/code_intelligence.tsx deleted file mode 100644 index 217a86e2c8bc..000000000000 --- a/browser/src/libs/code_intelligence/code_intelligence.tsx +++ /dev/null @@ -1,1097 +0,0 @@ -import { - ContextResolver, - createHoverifier, - findPositionsFromEvents, - Hoverifier, - HoverState, -} from '@sourcegraph/codeintellify' -import { TextDocumentDecoration } from '@sourcegraph/extension-api-types' -import * as H from 'history' -import * as React from 'react' -import { render as reactDOMRender } from 'react-dom' -import { - animationFrameScheduler, - combineLatest, - EMPTY, - from, - Observable, - of, - Subject, - Subscription, - Unsubscribable, - concat, - BehaviorSubject, -} from 'rxjs' -import { - catchError, - concatAll, - concatMap, - filter, - finalize, - map, - mergeMap, - observeOn, - switchMap, - withLatestFrom, - tap, - startWith, - distinctUntilChanged, - retryWhen, -} from 'rxjs/operators' -import { ActionItemAction } from '../../../../shared/src/actions/ActionItem' -import { DecorationMapByLine } from '../../../../shared/src/api/client/services/decoration' -import { CodeEditorData, CodeEditorWithPartialModel } from '../../../../shared/src/api/client/services/editorService' -import { PRIVATE_REPO_PUBLIC_SOURCEGRAPH_COM_ERROR_NAME } from '../../../../shared/src/backend/errors' -import { - CommandListClassProps, - CommandListPopoverButtonClassProps, -} from '../../../../shared/src/commandPalette/CommandList' -import { CompletionWidgetClassProps } from '../../../../shared/src/components/completion/CompletionWidget' -import { asObservable } from '../../../../shared/src/util/rxjs/asObservable' -import { ApplyLinkPreviewOptions } from '../../../../shared/src/components/linkPreviews/linkPreviews' -import { Controller } from '../../../../shared/src/extensions/controller' -import { registerHighlightContributions } from '../../../../shared/src/highlight/contributions' -import { getHoverActions, registerHoverContributions } from '../../../../shared/src/hover/actions' -import { - HoverAlert, - HoverContext, - HoverData, - HoverOverlay, - HoverOverlayClassProps, -} from '../../../../shared/src/hover/HoverOverlay' -import { getModeFromPath } from '../../../../shared/src/languages' -import { URLToFileContext } from '../../../../shared/src/platform/context' -import { TelemetryProps } from '../../../../shared/src/telemetry/telemetryService' -import { isDefined, isInstanceOf, propertyIsDefined } from '../../../../shared/src/util/types' -import { - FileSpec, - UIPositionSpec, - RawRepoSpec, - RepoSpec, - ResolvedRevSpec, - RevSpec, - toRootURI, - toURIWithPath, - ViewStateSpec, -} from '../../../../shared/src/util/url' -import { isInPage } from '../../context' -import { createLSPFromExtensions, toTextDocumentIdentifier } from '../../shared/backend/lsp' -import { CodeViewToolbar, CodeViewToolbarClassProps } from '../../shared/components/CodeViewToolbar' -import { resolveRev, retryWhenCloneInProgressError } from '../../shared/repo/backend' -import { EventLogger } from '../../shared/tracking/eventLogger' -import { MutationRecordLike, querySelectorOrSelf } from '../../shared/util/dom' -import { featureFlags } from '../../shared/util/featureFlags' -import { bitbucketServerCodeHost } from '../bitbucket/code_intelligence' -import { githubCodeHost } from '../github/code_intelligence' -import { gitlabCodeHost } from '../gitlab/code_intelligence' -import { phabricatorCodeHost } from '../phabricator/code_intelligence' -import { CodeView, fetchFileContents, trackCodeViews } from './code_views' -import { ContentView, handleContentViews } from './content_views' -import { applyDecorations, initializeExtensions, renderCommandPalette, renderGlobalDebug } from './extensions' -import { ViewOnSourcegraphButtonClassProps, ViewOnSourcegraphButton } from './external_links' -import { ExtensionHoverAlertType, getActiveHoverAlerts, onHoverAlertDismissed } from './hover_alerts' -import { - handleNativeTooltips, - NativeTooltip, - nativeTooltipsEnabledFromSettings, - registerNativeTooltipContributions, -} from './native_tooltips' -import { handleTextFields, TextField } from './text_fields' -import { resolveRepoNames } from './util/file_info' -import { ViewResolver } from './views' -import { observeStorageKey } from '../../browser/storage' -import { SourcegraphIntegrationURLs, BrowserPlatformContext } from '../../platform/context' -import { IS_LIGHT_THEME } from './consts' -import { NotificationType } from 'sourcegraph' -import { failedWithHTTPStatus } from '../../../../shared/src/backend/fetch' -import { asError } from '../../../../shared/src/util/errors' - -registerHighlightContributions() - -export interface OverlayPosition { - top: number - left: number -} - -/** - * A function that gets the mount location for elements being mounted to the DOM. - * - * - If the mount doesn't belong into the container, it must return `null`. - * - If the mount already exists in the container, it must return the existing mount. - * - If the mount does not exist yet in the container, it must create and return it. - * - * Caveats: - * - The passed element might be the mount itself - * - The passed element might be an element _within_ the mount - */ -export type MountGetter = (container: HTMLElement) => HTMLElement | null - -/** - * The context the code host is in on the current page. - */ -export type CodeHostContext = RawRepoSpec & Partial & { privateRepository: boolean } - -type CodeHostType = 'github' | 'phabricator' | 'bitbucket-server' | 'gitlab' - -/** Information for adding code intelligence to code views on arbitrary code hosts. */ -export interface CodeHost extends ApplyLinkPreviewOptions { - /** - * The type of the code host. This will be added as a className to the overlay mount. - * Use {@link CodeHost#name} if you need a human-readable name for the code host to display in the UI. - */ - type: CodeHostType - - /** - * A human-readable name for the code host, to be displayed in the UI. - */ - name: string - - /** - * Basic contextual information for the current code host. - */ - getContext?: () => CodeHostContext - - /** - * Mount getter for the repository "View on Sourcegraph" button. - * - * If undefined, the "View on Sourcegraph" button won't be rendered on the code host. - */ - getViewContextOnSourcegraphMount?: MountGetter - - /** - * Optional class name for the contextual link to Sourcegraph. - */ - viewOnSourcegraphButtonClassProps?: ViewOnSourcegraphButtonClassProps - - /** - * Checks to see if the current context the code is running in is within - * the given code host. - */ - check: () => boolean - - /** - * CSS classes for ActionItem buttons in the hover overlay to customize styling - */ - hoverOverlayClassProps?: HoverOverlayClassProps - - /** - * Resolve {@link CodeView}s from the DOM. - */ - codeViewResolvers: ViewResolver[] - - /** - * Resolve {@link ContentView}s from the DOM. - */ - contentViewResolvers?: ViewResolver[] - - /** - * Resolve {@link TextField}s from the DOM. - */ - textFieldResolvers?: ViewResolver[] - - /** - * Resolves {@link NativeTooltip}s from the DOM. - */ - nativeTooltipResolvers?: ViewResolver[] - - /** - * Adjust the position of the hover overlay. Useful for fixed headers or other - * elements that throw off the position of the tooltip within the relative - * element. - */ - adjustOverlayPosition?: (position: OverlayPosition) => OverlayPosition - - // Extensions related input - - /** - * Mount getter for the command palette button for extensions. - * - * If undefined, the command palette button won't be rendered on the code host. - */ - getCommandPaletteMount?: MountGetter - - /** - * Returns a selector used to determine the mount location of the hover overlay in the DOM. - * - * If undefined, or when null is returned, the hover overlay container will be mounted to . - */ - getHoverOverlayMountLocation?: () => string | null - - /** - * Construct the URL to the specified file. - * - * @param sourcegraphURL The URL of the Sourcegraph instance. - * @param target The target to build a URL for. - * @param context Context information about this invocation. - */ - urlToFile?: ( - sourcegraphURL: string, - target: RepoSpec & RawRepoSpec & RevSpec & FileSpec & Partial & Partial, - context: URLToFileContext - ) => string - - notificationClassNames: Record - - /** - * CSS classes for the command palette to customize styling - */ - commandPaletteClassProps?: CommandListPopoverButtonClassProps & CommandListClassProps - - /** - * CSS classes for the code view toolbar to customize styling - */ - codeViewToolbarClassProps?: CodeViewToolbarClassProps - - /** - * CSS classes for the completion widget to customize styling - */ - completionWidgetClassProps?: CompletionWidgetClassProps - - /** - * Whether or not code views need to be tokenized. Defaults to false. - */ - codeViewsRequireTokenization?: boolean -} - -export interface FileInfo { - /** - * The path for the repo the file belongs to. If a `baseRepoName` is provided, this value - * is treated as the head repo name. - */ - rawRepoName: string - /** - * The path for the file path for a given `codeView`. If a `baseFilePath` is provided, this value - * is treated as the head file path. - */ - filePath: string - /** - * The commit that the code view is at. If a `baseCommitID` is provided, this value is treated - * as the head commit ID. - */ - commitID: string - /** - * The revision the code view is at. If a `baseRev` is provided, this value is treated as the head rev. - */ - rev?: string - /** - * The repo name for the BASE side of a diff. This is useful for Phabricator - * staging areas since they are separate repos. - */ - baseRawRepoName?: string - /** - * The base file path. - */ - baseFilePath?: string - /** - * Commit ID for the BASE side of the diff. - */ - baseCommitID?: string - /** - * Revision for the BASE side of the diff. - */ - baseRev?: string -} - -export interface FileInfoWithRepoNames extends FileInfo, RepoSpec { - baseRepoName?: string -} - -export interface CodeIntelligenceProps extends TelemetryProps { - platformContext: Pick< - BrowserPlatformContext, - | 'forceUpdateTooltip' - | 'urlToFile' - | 'sideloadedExtensionURL' - | 'requestGraphQL' - | 'settings' - | 'refreshSettings' - > - codeHost: CodeHost - extensionsController: Controller - showGlobalDebug?: boolean -} - -export const createOverlayMount = (codeHostName: string, container: HTMLElement): HTMLElement => { - const mount = document.createElement('div') - mount.classList.add('hover-overlay-mount', `hover-overlay-mount__${codeHostName}`, 'theme-light') - container.appendChild(mount) - return mount -} - -export const createGlobalDebugMount = (): HTMLElement => { - const mount = document.createElement('div') - mount.className = 'global-debug' - document.body.appendChild(mount) - return mount -} - -/** - * Prepares the page for code intelligence. It creates the hoverifier, injects - * and mounts the hover overlay and then returns the hoverifier. - */ -function initCodeIntelligence({ - mutations, - codeHost, - platformContext, - extensionsController, - render, - telemetryService, - hoverAlerts, -}: Pick & { - render: typeof reactDOMRender - hoverAlerts: Observable>[] - mutations: Observable -}): { - hoverifier: Hoverifier< - RepoSpec & RevSpec & FileSpec & ResolvedRevSpec, - HoverData, - ActionItemAction - > - subscription: Unsubscribable -} { - const subscription = new Subscription() - - const { getHover } = createLSPFromExtensions(extensionsController) - - /** Emits when the close button was clicked */ - const closeButtonClicks = new Subject() - const nextCloseButtonClick = closeButtonClicks.next.bind(closeButtonClicks) - - /** Emits whenever the ref callback for the hover element is called */ - const hoverOverlayElements = new Subject() - const nextOverlayElement = hoverOverlayElements.next.bind(hoverOverlayElements) - - const relativeElement = document.body - - const containerComponentUpdates = new Subject() - - subscription.add( - registerHoverContributions({ extensionsController, platformContext, history: H.createBrowserHistory() }) - ) - - // Code views come and go, but there is always a single hoverifier on the page - const hoverifier = createHoverifier< - RepoSpec & RevSpec & FileSpec & ResolvedRevSpec, - HoverData, - ActionItemAction - >({ - closeButtonClicks, - hoverOverlayElements, - hoverOverlayRerenders: containerComponentUpdates.pipe( - withLatestFrom(hoverOverlayElements), - map(([, hoverOverlayElement]) => ({ hoverOverlayElement, relativeElement })), - filter(propertyIsDefined('hoverOverlayElement')) - ), - getHover: ({ line, character, part, ...rest }) => - combineLatest([ - getHover({ ...rest, position: { line, character } }), - getActiveHoverAlerts(hoverAlerts), - ]).pipe( - map(([hoverMerged, alerts]): HoverData | null => - hoverMerged ? { ...hoverMerged, alerts } : null - ) - ), - getActions: context => getHoverActions({ extensionsController, platformContext }, context), - pinningEnabled: true, - tokenize: codeHost.codeViewsRequireTokenization, - }) - - class HoverOverlayContainer extends React.Component< - {}, - HoverState, ActionItemAction> - > { - private subscription = new Subscription() - constructor(props: {}) { - super(props) - this.state = hoverifier.hoverState - this.subscription.add( - hoverifier.hoverStateUpdates.subscribe(update => { - this.setState(update) - }) - ) - } - public componentDidMount(): void { - containerComponentUpdates.next() - } - public componentWillUnmount(): void { - this.subscription.unsubscribe() - } - public componentDidUpdate(): void { - containerComponentUpdates.next() - } - public render(): JSX.Element | null { - const hoverOverlayProps = this.getHoverOverlayProps() - return hoverOverlayProps ? ( - - ) : null - } - private getHoverOverlayProps(): HoverState< - HoverContext, - HoverData, - ActionItemAction - >['hoverOverlayProps'] { - if (!this.state.hoverOverlayProps) { - return undefined - } - let { overlayPosition, ...rest } = this.state.hoverOverlayProps - // TODO: is adjustOverlayPosition needed or could it be solved with a better relativeElement? - if (overlayPosition && codeHost.adjustOverlayPosition) { - overlayPosition = codeHost.adjustOverlayPosition(overlayPosition) - } - return { ...rest, overlayPosition } - } - } - - const { getHoverOverlayMountLocation } = codeHost - if (!getHoverOverlayMountLocation) { - // This renders to document.body, which we can assume is never removed, - // so we don't need to subscribe to mutations. - const overlayMount = createOverlayMount(codeHost.type, document.body) - render(, overlayMount) - } else { - let previousMount: HTMLElement | null = null - subscription.add( - observeHoverOverlayMountLocation(getHoverOverlayMountLocation, mutations).subscribe(mountLocation => { - // Remove the previous mount if it exists, - // to avoid displaying duplicate hovers. - if (previousMount) { - previousMount.remove() - } - const mount = createOverlayMount(codeHost.type, mountLocation) - previousMount = mount - render(, mount) - }) - ) - } - - return { hoverifier, subscription } -} - -/** - * Returns an Observable that emits the element where - * the hover overlay mount should be appended, taking account - * mutations and {@link CodeHost#getHoverOverlayMountLocation}. - * - * The caller is responsible for removing the previous mount if it exists. - * - * This is useful to mount the hover overlay to a different container than document.body, - * so that it is affected by the visibility changes of that container. - * - * Related issue: https://gitlab.com/gitlab-org/gitlab/issues/193433 - * - * Example use case on GitLab: - * 1. User visits https://gitlab.com/gitlab-org/gitaly/-/merge_requests/1575. div.tab-pane.diffs doesn't exist yet (it'll be lazy-loaded) - * -> Mount the hover overlay is to document.body. - * 2. User visits the 'Changes' tab - * -> Unmount from document.body, mount to div.tab-pane.diffs - * 3. User visits the 'Overview' tab again - * -> div.tab-pane.diffs is hidden, and as a result so is the hover overlay. - * 4. User navigates away from the merge request (soft-reload), div.tab-pane.diffs is removed - * -> Mount to document.body again - */ -export function observeHoverOverlayMountLocation( - getMountLocationSelector: NonNullable, - mutations: Observable -): Observable { - return mutations.pipe( - concatAll(), - map(({ addedNodes, removedNodes }): HTMLElement | null => { - // If no selector can be used to determine the mount location - // return document.body as the mount location. - const selector = getMountLocationSelector() - if (selector === null) { - return document.body - } - // If any of the added nodes match the selector, return it - // as the new mount location. - for (const addedNode of addedNodes) { - if (!(addedNode instanceof HTMLElement)) { - continue - } - const mountLocation = querySelectorOrSelf(addedNode, selector) - if (mountLocation) { - return mountLocation - } - } - // If any of the removed nodes match the selector, - // return document.body as the new mount location. - for (const removedNode of removedNodes) { - if (!(removedNode instanceof HTMLElement)) { - continue - } - if (querySelectorOrSelf(removedNode, selector)) { - return document.body - } - } - // Neither added nodes nor removed nodes match the selector, - // don't return a new mount location. - return null - }), - filter(isDefined), - startWith(document.body), - distinctUntilChanged() - ) -} - -export interface HandleCodeHostOptions extends CodeIntelligenceProps { - mutations: Observable - sourcegraphURL: string - render: typeof reactDOMRender - minimalUI: boolean -} - -export function handleCodeHost({ - mutations, - codeHost, - extensionsController, - platformContext, - showGlobalDebug, - sourcegraphURL, - telemetryService, - render, - minimalUI, -}: HandleCodeHostOptions): Subscription { - const history = H.createBrowserHistory() - const subscriptions = new Subscription() - const { requestGraphQL } = platformContext - - const openOptionsMenu = (): Promise => browser.runtime.sendMessage({ type: 'openOptionsPage' }) - - const addedElements = mutations.pipe( - concatAll(), - concatMap(mutation => mutation.addedNodes), - filter(isInstanceOf(HTMLElement)) - ) - - const nativeTooltipsEnabled = codeHost.nativeTooltipResolvers - ? nativeTooltipsEnabledFromSettings(platformContext.settings) - : of(false) - - const hoverAlerts: Observable>[] = [] - - if (codeHost.nativeTooltipResolvers) { - const { subscription, nativeTooltipsAlert } = handleNativeTooltips(mutations, nativeTooltipsEnabled, codeHost) - subscriptions.add(subscription) - hoverAlerts.push(nativeTooltipsAlert) - subscriptions.add(registerNativeTooltipContributions(extensionsController)) - } - - const { hoverifier, subscription } = initCodeIntelligence({ - codeHost, - extensionsController, - platformContext, - telemetryService, - render, - hoverAlerts, - mutations, - }) - subscriptions.add(hoverifier) - subscriptions.add(subscription) - - // Inject UI components - // Render command palette - if (codeHost.getCommandPaletteMount && !minimalUI) { - subscriptions.add( - addedElements.pipe(map(codeHost.getCommandPaletteMount), filter(isDefined)).subscribe( - renderCommandPalette({ - extensionsController, - history, - platformContext, - telemetryService, - render, - ...codeHost.commandPaletteClassProps, - notificationClassNames: codeHost.notificationClassNames, - }) - ) - ) - } - - // Render extension debug menu - // This renders to document.body, which we can assume is never removed, - // so we don't need to subscribe to mutations. - if (showGlobalDebug) { - const mount = createGlobalDebugMount() - renderGlobalDebug({ extensionsController, platformContext, history, sourcegraphURL, render })(mount) - } - - const signInCloses = new Subject() - const nextSignInClose = signInCloses.next.bind(signInCloses) - - // Try to fetch settings and refresh them when a sign in tab was closed - subscriptions.add( - concat([null], signInCloses) - .pipe( - switchMap(() => - from(platformContext.refreshSettings()).pipe( - catchError(error => { - console.error('Refreshing settings failed', error) - return [] - }) - ) - ) - ) - .subscribe() - ) - - /** The number of code views that were detected on the page (not necessarily initialized) */ - const codeViewCount = new BehaviorSubject(0) - - // Render view on Sourcegraph button - if (codeHost.getViewContextOnSourcegraphMount && codeHost.getContext) { - const { getContext, viewOnSourcegraphButtonClassProps } = codeHost - - /** Whether or not the repo exists on the configured Sourcegraph instance. */ - const repoExistsOrErrors = signInCloses.pipe( - startWith(null), - switchMap(() => { - const { rawRepoName, rev } = getContext() - return resolveRev({ repoName: rawRepoName, rev, requestGraphQL }).pipe( - retryWhenCloneInProgressError(), - map(rev => !!rev), - catchError(error => [asError(error)]), - startWith(undefined) - ) - }) - ) - - subscriptions.add( - combineLatest([ - repoExistsOrErrors, - addedElements.pipe(map(codeHost.getViewContextOnSourcegraphMount), filter(isDefined)), - // Only show sign in button when there is no other code view on the page that is displaying it - codeViewCount.pipe( - map(count => count === 0), - distinctUntilChanged() - ), - ]).subscribe(([repoExistsOrError, mount, showSignInButton]) => { - render( - , - mount - ) - }) - ) - } - - /** A stream of added or removed code views with the resolved file info */ - const codeViews = mutations.pipe( - trackCodeViews(codeHost), - // Limit number of code views for perf reasons. - filter(() => codeViewCount.value < 50), - tap(codeViewEvent => { - codeViewCount.next(codeViewCount.value + 1) - codeViewEvent.subscriptions.add(() => codeViewCount.next(codeViewCount.value - 1)) - }), - mergeMap(codeViewEvent => - asObservable(() => - codeViewEvent.resolveFileInfo(codeViewEvent.element, platformContext.requestGraphQL) - ).pipe( - mergeMap(fileInfo => resolveRepoNames(fileInfo, platformContext.requestGraphQL)), - mergeMap(fileInfo => - fetchFileContents(fileInfo, platformContext.requestGraphQL).pipe( - map(fileInfoWithContents => ({ - fileInfo: fileInfoWithContents, - ...codeViewEvent, - })) - ) - ), - catchError(err => { - // Ignore PrivateRepoPublicSourcegraph errors (don't initialize those code views) - if (err.name === PRIVATE_REPO_PUBLIC_SOURCEGRAPH_COM_ERROR_NAME) { - return EMPTY - } - throw err - }), - tap({ - error: error => { - if (codeViewEvent.getToolbarMount) { - const mount = codeViewEvent.getToolbarMount(codeViewEvent.element) - render( - , - mount - ) - } - }, - }), - // Retry auth errors after the user closed a sign-in tab - retryWhen(errors => - errors.pipe( - // Don't swallow non-auth errors - tap(error => { - if (!failedWithHTTPStatus(error, 401)) { - throw error - } - }), - switchMap(() => signInCloses) - ) - ), - catchError(error => { - // Log errors but don't break the handling of other code views - console.error('Could not resolve file info for code view', error) - return [] - }) - ) - ), - observeOn(animationFrameScheduler) - ) - - /** Map from workspace URI to number of editors referencing it */ - const rootRefCounts = new Map() - - /** - * Adds root referenced by a code editor to the worskpace. - * - * Will only cause `workspace.roots` to emit if no root with - * the given `uri` existed. - */ - const addRootRef = (uri: string, inputRevision: string | undefined): void => { - rootRefCounts.set(uri, (rootRefCounts.get(uri) || 0) + 1) - if (rootRefCounts.get(uri) === 1) { - extensionsController.services.workspace.roots.next([ - ...extensionsController.services.workspace.roots.value, - { uri, inputRevision }, - ]) - } - } - - /** - * Deletes a reference to a workspace root from a code editor. - * - * Will only cause `workspace.roots` to emit if the root - * with the given `uri` has no more references. - */ - const deleteRootRef = (uri: string): void => { - const currentRefCount = rootRefCounts.get(uri) - if (!currentRefCount) { - throw new Error(`No preexisting root refs for uri ${uri}`) - } - const updatedRefCount = currentRefCount - 1 - if (updatedRefCount === 0) { - extensionsController.services.workspace.roots.next( - extensionsController.services.workspace.roots.value.filter(root => root.uri !== uri) - ) - } else { - rootRefCounts.set(uri, updatedRefCount) - } - } - - subscriptions.add( - codeViews.subscribe(codeViewEvent => { - console.log('Code view added') - codeViewEvent.subscriptions.add(() => console.log('Code view removed')) - - const { element, fileInfo, getPositionAdjuster, getToolbarMount, toolbarButtonProps } = codeViewEvent - const uri = toURIWithPath(fileInfo) - const languageId = getModeFromPath(fileInfo.filePath) - const model = { uri, languageId, text: fileInfo.content } - // Only add the model if it doesn't exist - // (there may be several code views on the page pointing to the same model) - if (!extensionsController.services.model.hasModel(uri)) { - extensionsController.services.model.addModel(model) - } - const editorData: CodeEditorData = { - type: 'CodeEditor' as const, - resource: uri, - selections: codeViewEvent.getSelections ? codeViewEvent.getSelections(codeViewEvent.element) : [], - isActive: true, - } - const editorId = extensionsController.services.editor.addEditor(editorData) - const scope: CodeEditorWithPartialModel = { - ...editorData, - ...editorId, - model, - } - const rootURI = toRootURI(fileInfo) - addRootRef(rootURI, fileInfo.rev) - codeViewEvent.subscriptions.add(() => { - deleteRootRef(rootURI) - extensionsController.services.editor.removeEditor(editorId) - }) - - if (codeViewEvent.observeSelections) { - codeViewEvent.subscriptions.add( - // This nested subscription is necessary, it is managed correctly through `codeViewEvent.subscriptions` - // eslint-disable-next-line rxjs/no-nested-subscribe - codeViewEvent.observeSelections(codeViewEvent.element).subscribe(selections => { - extensionsController.services.editor.setSelections(editorId, selections) - }) - ) - } - - // When codeView is a diff (and not an added file), add BASE too. - if (fileInfo.baseContent && fileInfo.baseRepoName && fileInfo.baseCommitID && fileInfo.baseFilePath) { - const uri = toURIWithPath({ - repoName: fileInfo.baseRepoName, - commitID: fileInfo.baseCommitID, - filePath: fileInfo.baseFilePath, - }) - // Only add the model if it doesn't exist - // (there may be several code views on the page pointing to the same model) - if (!extensionsController.services.model.hasModel(uri)) { - extensionsController.services.model.addModel({ - uri, - languageId: getModeFromPath(fileInfo.baseFilePath), - text: fileInfo.baseContent, - }) - } - const editor = extensionsController.services.editor.addEditor({ - type: 'CodeEditor' as const, - resource: uri, - // There is no notion of a selection on diff views yet, so this is empty. - selections: [], - isActive: true, - }) - const baseRootURI = toRootURI({ - repoName: fileInfo.baseRepoName, - commitID: fileInfo.baseCommitID, - }) - addRootRef(baseRootURI, fileInfo.baseRev) - codeViewEvent.subscriptions.add(() => { - deleteRootRef(baseRootURI) - extensionsController.services.editor.removeEditor(editor) - }) - } - - const domFunctions = { - ...codeViewEvent.dom, - // If any parent element has the sourcegraph-extension-element - // class then that element does not have any code. We - // must check for "any parent element" because extensions - // create their DOM changes before the blob is tokenized - // into multiple elements. - getCodeElementFromTarget: (target: HTMLElement): HTMLElement | null => - target.closest('.sourcegraph-extension-element') !== null - ? null - : codeViewEvent.dom.getCodeElementFromTarget(target), - } - - // Apply decorations coming from extensions - if (!minimalUI) { - let decorationsByLine: DecorationMapByLine = new Map() - const update = (decorations?: TextDocumentDecoration[] | null): void => { - try { - decorationsByLine = applyDecorations( - domFunctions, - element, - decorations || [], - decorationsByLine, - fileInfo.baseCommitID ? 'head' : undefined - ) - } catch (err) { - console.error('Could not apply head decorations to code view', codeViewEvent.element, err) - } - } - codeViewEvent.subscriptions.add( - extensionsController.services.textDocumentDecoration - .getDecorations(toTextDocumentIdentifier(fileInfo)) - // Make sure extensions get cleaned up un unsubscription - .pipe(finalize(update)) - // The nested subscribe cannot be replaced with a switchMap() - // We manage the subscription correctly. - // eslint-disable-next-line rxjs/no-nested-subscribe - .subscribe(update) - ) - } - if (fileInfo.baseCommitID && fileInfo.baseFilePath) { - let decorationsByLine: DecorationMapByLine = new Map() - const update = (decorations?: TextDocumentDecoration[] | null): void => { - try { - decorationsByLine = applyDecorations( - domFunctions, - element, - decorations || [], - decorationsByLine, - 'base' - ) - } catch (err) { - console.error('Could not apply base decorations to code view', codeViewEvent.element, err) - } - } - codeViewEvent.subscriptions.add( - extensionsController.services.textDocumentDecoration - .getDecorations( - toTextDocumentIdentifier({ - repoName: fileInfo.baseRepoName || fileInfo.repoName, // not sure if all code hosts set baseRepoName - commitID: fileInfo.baseCommitID, - filePath: fileInfo.baseFilePath, - }) - ) - // Make sure decorations get cleaned up on unsubscription - .pipe(finalize(update)) - // The nested subscribe cannot be replaced with a switchMap() - // We manage the subscription correctly. - // eslint-disable-next-line rxjs/no-nested-subscribe - .subscribe(update) - ) - } - - // Add hover code intelligence - const resolveContext: ContextResolver = ({ part }) => ({ - repoName: part === 'base' ? fileInfo.baseRepoName || fileInfo.repoName : fileInfo.repoName, - commitID: part === 'base' ? fileInfo.baseCommitID! : fileInfo.commitID, - filePath: part === 'base' ? fileInfo.baseFilePath || fileInfo.filePath : fileInfo.filePath, - rev: part === 'base' ? fileInfo.baseRev || fileInfo.baseCommitID! : fileInfo.rev || fileInfo.commitID, - }) - const adjustPosition = getPositionAdjuster?.(platformContext.requestGraphQL) - let hoverSubscription = new Subscription() - codeViewEvent.subscriptions.add( - // eslint-disable-next-line rxjs/no-nested-subscribe - nativeTooltipsEnabled.subscribe(useNativeTooltips => { - hoverSubscription.unsubscribe() - if (!useNativeTooltips) { - hoverSubscription = hoverifier.hoverify({ - dom: domFunctions, - positionEvents: of(element).pipe( - findPositionsFromEvents({ - domFunctions, - tokenize: codeHost.codeViewsRequireTokenization !== false, - }) - ), - resolveContext, - adjustPosition, - scrollBoundaries: codeViewEvent.getScrollBoundaries - ? codeViewEvent.getScrollBoundaries(codeViewEvent.element) - : [], - }) - } - }) - ) - codeViewEvent.subscriptions.add(hoverSubscription) - - element.classList.add('sg-mounted') - - // Render toolbar - if (getToolbarMount && !minimalUI) { - const mount = getToolbarMount(element) - render( - , - mount - ) - } - }) - ) - - // Show link previews on content views (feature-flagged). - subscriptions.add( - handleContentViews( - from(featureFlags.isEnabled('experimentalLinkPreviews')).pipe( - switchMap(enabled => (enabled ? mutations : [])) - ), - { extensionsController }, - codeHost - ) - ) - - // Show completions in text fields (feature-flagged). - subscriptions.add( - handleTextFields( - from(featureFlags.isEnabled('experimentalTextFieldCompletion')).pipe( - switchMap(enabled => (enabled ? mutations : [])) - ), - { extensionsController }, - codeHost - ) - ) - - return subscriptions -} - -const SHOW_DEBUG = (): boolean => localStorage.getItem('debug') !== null - -const CODE_HOSTS: CodeHost[] = [bitbucketServerCodeHost, githubCodeHost, gitlabCodeHost, phabricatorCodeHost] -export const determineCodeHost = (): CodeHost | undefined => CODE_HOSTS.find(codeHost => codeHost.check()) - -export function injectCodeIntelligenceToCodeHost( - mutations: Observable, - codeHost: CodeHost, - { sourcegraphURL, assetsURL }: SourcegraphIntegrationURLs, - isExtension: boolean, - showGlobalDebug = SHOW_DEBUG() -): Subscription { - const subscriptions = new Subscription() - const { platformContext, extensionsController } = initializeExtensions( - codeHost, - { sourcegraphURL, assetsURL }, - isExtension - ) - const { requestGraphQL } = platformContext - const telemetryService = new EventLogger(isExtension, requestGraphQL) - subscriptions.add(extensionsController) - - let codeHostSubscription: Subscription - // In the browser extension, observe whether the `disableExtension` storage flag is set. - // In the native integration, this flag does not exist. - const extensionDisabled = isExtension ? observeStorageKey('sync', 'disableExtension') : of(false) - - // RFC 68: hide some UI features in the GitLab native integration. - // This can be overridden using the `sourcegraphMinimalUI` local storage flag. - const minimalUIStorageFlag = localStorage.getItem('sourcegraphMinimalUI') - const minimalUI = - minimalUIStorageFlag !== null ? minimalUIStorageFlag === 'true' : codeHost.type === 'gitlab' && !isExtension - subscriptions.add( - extensionDisabled.subscribe(disableExtension => { - if (disableExtension) { - // We don't need to unsubscribe if the extension starts with disabled state. - if (codeHostSubscription) { - codeHostSubscription.unsubscribe() - } - console.log('Browser extension is disabled') - } else { - codeHostSubscription = handleCodeHost({ - mutations, - codeHost, - extensionsController, - platformContext, - showGlobalDebug, - sourcegraphURL, - telemetryService, - render: reactDOMRender, - minimalUI, - }) - subscriptions.add(codeHostSubscription) - console.log(`${isExtension ? 'Browser extension' : 'Native integration'} is enabled`) - } - }) - ) - return subscriptions -} diff --git a/browser/src/libs/code_intelligence/code_intelligence_test_utils.ts b/browser/src/libs/code_intelligence/code_intelligence_test_utils.ts deleted file mode 100644 index cab1ae5e1efb..000000000000 --- a/browser/src/libs/code_intelligence/code_intelligence_test_utils.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { DiffPart } from '@sourcegraph/codeintellify' -import assert from 'assert' -import { readFile } from 'mz/fs' -import Simmer, { Options as SimmerOptions } from 'simmerjs' -import { SetIntersection } from 'utility-types' -import { CodeHost, MountGetter } from './code_intelligence' -import { CodeView, DOMFunctions } from './code_views' - -const mountGetterKeys = ['getCommandPaletteMount', 'getViewContextOnSourcegraphMount'] as const -type MountGetterKey = typeof mountGetterKeys[number] - -/** - * @param containerHtmlFixturePaths Paths to full-document fixtures keyed by the mount getter function name - */ -export function testCodeHostMountGetters( - codeHost: C, - containerHtmlFixturePaths: string | Record, string> -): void { - for (const mountGetterKey of mountGetterKeys) { - const getMount = codeHost[mountGetterKey] - if (!getMount) { - continue - } - describe(mountGetterKey, () => { - const fixturePath = - typeof containerHtmlFixturePaths === 'string' - ? containerHtmlFixturePaths - : containerHtmlFixturePaths[mountGetterKey as keyof typeof containerHtmlFixturePaths] - testMountGetter(fixturePath, getMount, true, true) - }) - } -} - -export function testToolbarMountGetter( - codeViewHtmlFixturePath: string, - getToolbarMount: NonNullable -): void { - testMountGetter(codeViewHtmlFixturePath, getToolbarMount, false, false) -} - -export async function getFixtureBody({ - isFullDocument, - htmlFixturePath, -}: { - isFullDocument: boolean - htmlFixturePath: string -}): Promise { - const content = await readFile(htmlFixturePath, 'utf-8') - // Do not append to global document to test that the mount getter only looks at the container - if (isFullDocument) { - // Create Document - const fixtureDocument = document.implementation.createHTMLDocument() - fixtureDocument.write(content) - return fixtureDocument.documentElement - } - // Create DocumentFragment - const template = document.createElement('template') - template.innerHTML = content - if (template.content.children.length !== 1) { - throw new Error( - `Fixture must have exactly one element, has ${template.content.children.length}: ${htmlFixturePath}` - ) - } - if (!(template.content.firstElementChild instanceof HTMLElement)) { - throw new Error(`Fixture must be HTML: ${htmlFixturePath}`) - } - return template.content.firstElementChild -} - -/** - * @param isFullDocument Whether the fixture HTML is a document fragment or a full document (does it contain ``?) - * @param mayReturnNull Whether the mount getter might be called with containers where the mount does not belong. - * It will be tested to return `null` in that case. - */ -export function testMountGetter( - htmlFixturePath: string, - getMount: MountGetter, - isFullDocument: boolean, - mayReturnNull: boolean -): void { - it('creates and returns a new mount in the container', async () => { - const container = await getFixtureBody({ isFullDocument, htmlFixturePath }) - const outerHtmlBefore = container.outerHTML - const mount = getMount(container) - expect(mount).toBeInstanceOf(HTMLElement) - expect(container.contains(mount)).toBe(true) - if (container.outerHTML === outerHtmlBefore) { - // Don't use expect().not.toBe() because the output is gigantic - assert.fail('Expected container outerHTML to have changed') - } - }) - it('is idempotent', async () => { - const container = await getFixtureBody({ isFullDocument, htmlFixturePath }) - const first = getMount(container) - const outerHTMLAfterFirstCall = container.outerHTML - const second = getMount(container) - expect(first).toBe(second) - expect(container.outerHTML).toBe(outerHTMLAfterFirstCall) - }) - if (mayReturnNull) { - it('returns null if the mount does not belong into the container', () => { - const container = document.createElement('div') - container.innerHTML = '
Hello
World
' - const mount = getMount(container) - expect(mount).toBe(null) - }) - } else { - it('throws an Error if given an unexpected code view', () => { - const container = document.createElement('div') - container.innerHTML = '
Hello
World
' - try { - getMount(container) - assert.fail('Expected function to throw an Error') - } catch (err) { - // "Cannot read property foo of null" does not count! - expect(err).not.toBeInstanceOf(TypeError) - } - }) - } -} - -interface Line { - lineNumber: number - /** - * The part of the diff, if the code view is a diff code view. - */ - diffPart?: DiffPart - - /** - * Whether the first character of the line is a diff indicator - */ - firstCharacterIsDiffIndicator?: boolean -} - -export interface DOMFunctionsTest { - htmlFixturePath: string - - /** - * Descriptors for lines in the diff that will be tested - */ - lineCases: Line[] - - url?: string // TODO DOM functions should not rely on global state like the URL -} - -export function testDOMFunctions( - domFunctions: DOMFunctions, - { htmlFixturePath, lineCases: codeElements, url }: DOMFunctionsTest -): void { - let codeViewElement: HTMLElement - beforeEach(async () => { - if (url) { - jsdom.reconfigure({ url }) - } - codeViewElement = await getFixtureBody({ htmlFixturePath, isFullDocument: false }) - }) - for (const { diffPart, lineNumber, firstCharacterIsDiffIndicator } of codeElements) { - describe( - `line number ${lineNumber}` + (diffPart !== undefined ? ` in ${String(diffPart)} diff part` : ''), - () => { - const simmerOptions: SimmerOptions = { - depth: 20, - specificityThreshold: 500, - selectorMaxLength: 1000, - } - - describe('getLineElementFromLineNumber()', () => { - it('should return the right line element given the line number', () => { - const codeElement = domFunctions.getLineElementFromLineNumber( - codeViewElement, - lineNumber, - diffPart - ) - expect(codeElement).toBeDefined() - expect(codeElement).not.toBeNull() - // Generate CSS selector for element - const simmer = new Simmer(codeViewElement, simmerOptions) - const selector = simmer(codeElement!) - expect(selector).toBeTruthy() - expect({ selector, content: codeElement!.textContent!.trim() }).toMatchSnapshot() - }) - }) - - describe('getCodeElementFromLineNumber()', () => { - it('should return the right code element given the line number', () => { - const codeElement = domFunctions.getCodeElementFromLineNumber( - codeViewElement, - lineNumber, - diffPart - ) - expect(codeElement).toBeDefined() - expect(codeElement).not.toBeNull() - // Generate CSS selector for element - const simmer = new Simmer(codeViewElement, simmerOptions) - const selector = simmer(codeElement!) - expect(selector).toBeTruthy() - expect({ selector, content: codeElement!.textContent!.trim() }).toMatchSnapshot() - }) - }) - - let codeElement: HTMLElement - const setCodeElement = (): void => { - codeElement = domFunctions.getCodeElementFromLineNumber(codeViewElement, lineNumber, diffPart)! - if (!codeElement) { - throw new Error('Test depends on test for getCodeElementFromLineNumber() passing') - } - } - // These tests depend on getCodeElementFromLineNumber() working as expected - describe('getLineNumberFromCodeElement()', () => { - beforeEach(setCodeElement) - it('should return the right line number given the code element', () => { - const returnedLineNumber = domFunctions.getLineNumberFromCodeElement(codeElement) - expect(returnedLineNumber).toBe(lineNumber) - }) - }) - if (domFunctions.getDiffCodePart) { - describe('getDiffCodePart()', () => { - beforeEach(setCodeElement) - it(`should return "${String(diffPart)}" when given the code element`, () => { - expect(domFunctions.getDiffCodePart!(codeElement)).toBe(diffPart) - }) - }) - } - describe('isFirstCharacterDiffIndicator()', () => { - beforeEach(setCodeElement) - it('should return correctly whether the first character is a diff indicator', () => { - // Default is false - const is = Boolean( - domFunctions.isFirstCharacterDiffIndicator && - domFunctions.isFirstCharacterDiffIndicator(codeElement) - ) - expect(is).toBe(Boolean(firstCharacterIsDiffIndicator)) - if (is) { - // Check that the first character is truly a diff indicator - const diffIndicators = new Set(['+', '-', ' ']) - expect(is).toBe(diffIndicators.has(codeElement.textContent![0])) - } - }) - }) - } - ) - } -} diff --git a/browser/src/libs/code_intelligence/code_views.test.ts b/browser/src/libs/code_intelligence/code_views.test.ts deleted file mode 100644 index b34259413d79..000000000000 --- a/browser/src/libs/code_intelligence/code_views.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { of } from 'rxjs' -import { toArray } from 'rxjs/operators' -import * as sinon from 'sinon' -import { Omit } from 'utility-types' -import { FileInfo } from './code_intelligence' -import { CodeView, toCodeViewResolver, trackCodeViews } from './code_views' - -describe('code_views', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - describe('trackCodeViews()', () => { - const fileInfo: FileInfo = { - rawRepoName: 'foo', - filePath: '/bar.ts', - commitID: '1', - } - const codeViewSpec: Omit = { - dom: { - getCodeElementFromTarget: () => null, - getCodeElementFromLineNumber: () => null, - getLineElementFromLineNumber: () => null, - getLineNumberFromCodeElement: () => 1, - }, - resolveFileInfo: () => of(fileInfo), - } - it('should detect added code views from specs', async () => { - const element = document.createElement('div') - element.className = 'test-code-view' - document.body.append(element) - const selector = '.test-code-view' - const detected = await of([{ addedNodes: [document.body], removedNodes: [] }]) - .pipe( - trackCodeViews({ - codeViewResolvers: [toCodeViewResolver(selector, codeViewSpec)], - }), - toArray() - ) - .toPromise() - expect(detected.map(({ subscriptions, ...rest }) => rest)).toEqual([{ ...codeViewSpec, element }]) - }) - it('should detect added code views from resolver', async () => { - const element = document.createElement('div') - element.className = 'test-code-view' - document.body.append(element) - const selector = '.test-code-view' - const resolveView = sinon.spy((element: HTMLElement) => ({ element, ...codeViewSpec })) - const detected = await of([{ addedNodes: [document.body], removedNodes: [] }]) - .pipe( - trackCodeViews({ - codeViewResolvers: [{ selector, resolveView }], - }), - toArray() - ) - .toPromise() - expect(detected.map(({ subscriptions, ...rest }) => rest)).toEqual([{ ...codeViewSpec, element }]) - sinon.assert.calledOnce(resolveView) - sinon.assert.calledWith(resolveView, element) - }) - }) -}) diff --git a/browser/src/libs/code_intelligence/code_views.ts b/browser/src/libs/code_intelligence/code_views.ts deleted file mode 100644 index 0e70284ca88a..000000000000 --- a/browser/src/libs/code_intelligence/code_views.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { DiffPart, DOMFunctions as CodeIntellifyDOMFuncions, PositionAdjuster } from '@sourcegraph/codeintellify' -import { Selection } from '@sourcegraph/extension-api-types' -import { Observable, of, zip, OperatorFunction } from 'rxjs' -import { catchError, map, switchMap } from 'rxjs/operators' -import { Omit } from 'utility-types' -import { PRIVATE_REPO_PUBLIC_SOURCEGRAPH_COM_ERROR_NAME } from '../../../../shared/src/backend/errors' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { isErrorLike } from '../../../../shared/src/util/errors' -import { FileSpec, RepoSpec, ResolvedRevSpec, RevSpec } from '../../../../shared/src/util/url' -import { ButtonProps } from '../../shared/components/CodeViewToolbar' -import { fetchBlobContentLines } from '../../shared/repo/backend' -import { CodeHost, FileInfo, FileInfoWithRepoNames } from './code_intelligence' -import { ensureRevisionsAreCloned } from './util/file_info' -import { trackViews, ViewResolver, ViewWithSubscriptions } from './views' -import { MutationRecordLike } from '../../shared/util/dom' - -export interface DOMFunctions extends CodeIntellifyDOMFuncions { - /** - * Gets the element for the entire line. This element is used for whole-line - * background decorations. It should span the entire width of the line - * independent on how long the code on that line is. This may be a parent - * element of the code element, but keep in mind that even in split diff - * views it must only contain the line the given diff part. - */ - getLineElementFromLineNumber: (codeView: HTMLElement, line: number, part?: DiffPart) => HTMLElement | null -} - -/** - * Defines a code view that is present on a page. - * Exposes operations for manipulating it, and CSS classes to be applied to injected UI elements. - */ -export interface CodeView { - /** - * The code view element on the page. - */ - element: HTMLElement - /** The DOMFunctions for the code view. */ - dom: DOMFunctions - /** - * Finds or creates a DOM element where we should inject the - * `CodeViewToolbar`. This function is responsible for ensuring duplicate - * mounts aren't created. - */ - getToolbarMount?: (codeView: HTMLElement) => HTMLElement - /** - * Resolves the file info for a given code view. It returns an observable - * because some code hosts need to resolve this asynchronously. The - * observable should only emit once. - */ - resolveFileInfo: ( - codeView: HTMLElement, - requestGraphQL: PlatformContext['requestGraphQL'] - ) => Observable | FileInfo - /** - * In some situations, we need to be able to adjust the position going into - * and coming out of codeintellify. For example, Phabricator converts tabs - * to spaces in it's DOM. - */ - getPositionAdjuster?: ( - requestGraphQL: PlatformContext['requestGraphQL'] - ) => PositionAdjuster - /** Props for styling the buttons in the `CodeViewToolbar`. */ - toolbarButtonProps?: ButtonProps - /** - * Gets the current selections for a code view. - */ - getSelections?: (codeViewElement: HTMLElement) => Selection[] - /** - * Returns a stream of selections changes for a code view. - */ - observeSelections?: (codeViewElement: HTMLElement) => Observable - - /** - * Returns the scrollBoundaries of the code view, used by codeintellify. - * This is called once per code view, when calling Hoverifier.hoverify(). - */ - getScrollBoundaries?: (codeViewElement: HTMLElement) => HTMLElement[] -} - -/** - * Builds a CodeViewResolver from a static CodeView and a selector. - */ -export const toCodeViewResolver = (selector: string, spec: Omit): ViewResolver => ({ - selector, - resolveView: element => ({ ...spec, element }), -}) - -/** - * Find all the code views on a page using both the code view specs and the code view spec - * resolvers, calling down to {@link trackViews}. - */ -export const trackCodeViews = ({ - codeViewResolvers, -}: Pick): OperatorFunction> => - trackViews(codeViewResolvers) - -export interface FileInfoWithContents extends FileInfoWithRepoNames { - content?: string - baseContent?: string - headHasFileContents?: boolean - baseHasFileContents?: boolean -} - -export const fetchFileContents = ( - info: FileInfoWithRepoNames, - requestGraphQL: PlatformContext['requestGraphQL'] -): Observable => - ensureRevisionsAreCloned(info, requestGraphQL).pipe( - switchMap(info => { - const fetchingBaseFile = info.baseCommitID - ? fetchBlobContentLines({ - repoName: info.repoName, - filePath: info.baseFilePath || info.filePath, - commitID: info.baseCommitID, - requestGraphQL, - }) - : of(null) - - const fetchingHeadFile = fetchBlobContentLines({ - repoName: info.repoName, - filePath: info.filePath, - commitID: info.commitID, - requestGraphQL, - }) - return zip(fetchingBaseFile, fetchingHeadFile).pipe( - map( - ([baseFileContent, headFileContent]): FileInfoWithContents => ({ - ...info, - baseContent: baseFileContent ? baseFileContent.join('\n') : undefined, - content: headFileContent.join('\n'), - headHasFileContents: headFileContent.length > 0, - baseHasFileContents: baseFileContent ? baseFileContent.length > 0 : undefined, - }) - ), - catchError(() => [info]) - ) - }), - catchError(err => { - if (isErrorLike(err) && err.name === PRIVATE_REPO_PUBLIC_SOURCEGRAPH_COM_ERROR_NAME) { - return [info] - } - throw err - }) - ) diff --git a/browser/src/libs/code_intelligence/consts.ts b/browser/src/libs/code_intelligence/consts.ts deleted file mode 100644 index c87166cd7ad2..000000000000 --- a/browser/src/libs/code_intelligence/consts.ts +++ /dev/null @@ -1 +0,0 @@ -export const IS_LIGHT_THEME = true // assume all code hosts have a light theme (correct for now) diff --git a/browser/src/libs/code_intelligence/content_views.test.ts b/browser/src/libs/code_intelligence/content_views.test.ts deleted file mode 100644 index 71bf541343c5..000000000000 --- a/browser/src/libs/code_intelligence/content_views.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { MarkupKind } from '@sourcegraph/extension-api-classes' -import { uniqueId } from 'lodash' -import { concat, Observable, of, Subject, Subscription } from 'rxjs' -import { first } from 'rxjs/operators' -import { LinkPreviewMerged } from '../../../../shared/src/api/client/services/linkPreview' -import { createBarrier } from '../../../../shared/src/api/integration-test/testHelpers' -import { MutationRecordLike } from '../../shared/util/dom' -import { handleContentViews } from './content_views' - -describe('content_views', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - - describe('handleContentViews()', () => { - let subscriptions = new Subscription() - - afterEach(() => { - subscriptions.unsubscribe() - subscriptions = new Subscription() - }) - - const createTestElement = (): HTMLElement => { - const el = document.createElement('div') - el.className = `test test-${uniqueId()}` - document.body.appendChild(el) - return el - } - - test('detects addition, mutation, and removal of content views (and annotates them)', async () => { - const element = createTestElement() - element.id = 'content-view' - element.innerHTML = '0 foo 1 bar 2 qux 3' - - const wait = new Subject() - const unsubscribed = new Subject() - const mutations = new Subject() - - subscriptions.add( - handleContentViews( - mutations, - { - extensionsController: { - services: { - linkPreviews: { - provideLinkPreview: url => { - wait.next() - if (url.includes('bar')) { - return of(null) - } - return concat( - of({ - content: [ - { - kind: MarkupKind.Markdown, - value: `**${url.slice(url.lastIndexOf('#') + 1)}** x`, - }, - ], - hover: [ - { - kind: MarkupKind.PlainText, - value: url.slice(url.lastIndexOf('#') + 1), - }, - ], - }), - // Support checking that the provider's observable was unsubscribed. - new Observable(() => () => { - unsubscribed.next() - }) - ) - }, - }, - }, - }, - }, - { - contentViewResolvers: [{ selector: 'div', resolveView: () => ({ element }) }], - setElementTooltip: (e, text) => - text !== null ? e.setAttribute('data-tooltip', text) : e.removeAttribute('data-tooltip'), - } - ) - ) - - // Add content view. - mutations.next([{ addedNodes: [document.body], removedNodes: [] }]) - await wait.pipe(first()).toPromise() - expect(element.innerHTML).toBe( - '0 foofoo x 1 bar 2 quxqux x 3' - ) - - // Mutate content view. - element.innerHTML = '4 zip 5' - await Promise.all([unsubscribed.pipe(first()).toPromise(), wait.pipe(first()).toPromise()]) - expect(element.innerHTML).toBe( - '4 zipzip x 5' - ) - - // Remove content view. - mutations.next([{ addedNodes: [], removedNodes: [element] }]) - await unsubscribed.pipe(first()).toPromise() - }) - - test('handles multiple emissions', async () => { - const element = createTestElement() - element.id = 'content-view' - element.innerHTML = '0 foo 1 bar 2' - const originalInnerHTML = element.innerHTML - const fooLinkPreviewValues = new Subject() - const { wait, done } = createBarrier() - subscriptions.add( - handleContentViews( - of([{ addedNodes: [document.body], removedNodes: [] }]), - { - extensionsController: { - services: { - linkPreviews: { - provideLinkPreview: url => { - done() - return url.includes('bar') ? of(null) : fooLinkPreviewValues - }, - }, - }, - }, - }, - { - contentViewResolvers: [{ selector: 'div', resolveView: () => ({ element }) }], - setElementTooltip: (e, text) => - text !== null ? e.setAttribute('data-tooltip', text) : e.removeAttribute('data-tooltip'), - } - ) - ) - - await wait - expect(element.innerHTML).toBe(originalInnerHTML) - - fooLinkPreviewValues.next({ - content: [ - { - kind: MarkupKind.Markdown, - value: '**foo**', - }, - ], - hover: [ - { - kind: MarkupKind.PlainText, - value: 'foo', - }, - ], - }) - expect(element.innerHTML).toBe( - '0 foofoo 1 bar 2' - ) - - fooLinkPreviewValues.next({ - content: [ - { - kind: MarkupKind.Markdown, - value: '**foo2**', - }, - ], - hover: [ - { - kind: MarkupKind.PlainText, - value: 'foo2', - }, - ], - }) - expect(element.innerHTML).toBe( - '0 foofoo2 1 bar 2' - ) - - fooLinkPreviewValues.next({ - content: [], - hover: [ - { - kind: MarkupKind.PlainText, - value: 'foo2', - }, - ], - }) - expect(element.innerHTML).toBe('0 foo 1 bar 2') - - fooLinkPreviewValues.next({ - content: [], - hover: [], - }) - expect(element.innerHTML).toBe('0 foo 1 bar 2') - }) - }) -}) diff --git a/browser/src/libs/code_intelligence/content_views.ts b/browser/src/libs/code_intelligence/content_views.ts deleted file mode 100644 index 40586b599058..000000000000 --- a/browser/src/libs/code_intelligence/content_views.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { animationFrameScheduler, merge, Observable, of, Subject, Subscription, Unsubscribable } from 'rxjs' -import { distinctUntilChanged, map, mapTo, mergeMap, observeOn, tap, throttleTime } from 'rxjs/operators' -import { LinkPreviewProviderRegistry } from '../../../../shared/src/api/client/services/linkPreview' -import { applyLinkPreview } from '../../../../shared/src/components/linkPreviews/linkPreviews' -import { ExtensionsControllerProps } from '../../../../shared/src/extensions/controller' -import { MutationRecordLike, observeMutations } from '../../shared/util/dom' -import { CodeHost } from './code_intelligence' -import { trackViews } from './views' - -/** - * Defines a content view that is present on a page and exposes operations for manipulating it. - */ -export interface ContentView { - /** The content view HTML element. */ - element: HTMLElement -} - -/** - * Handles added and removed content views according to the {@link CodeHost} configuration. - */ -export function handleContentViews( - mutations: Observable, - { - extensionsController, - }: - | ExtensionsControllerProps - | { - extensionsController: { - services: { linkPreviews: Pick } - } - }, - { - contentViewResolvers, - linkPreviewContentClass, - setElementTooltip, - }: Pick -): Unsubscribable { - /** A stream of added or removed content views. */ - const contentViews = mutations.pipe( - trackViews(contentViewResolvers || []), - observeOn(animationFrameScheduler) - ) - - /** Pause DOM MutationObserver while we are making changes to avoid duplicating work. */ - const pauseMutationObserver = new Subject() - - /** - * Map from content view element to linkPreview subscriptions - * - * These subscriptions are maintained separately from `contentViewEvent.subscription`, - * as they need to be unsubscribed when a content view is updated. - */ - const linkPreviewSubscriptions = new Map() - - return contentViews - .pipe( - mergeMap(contentViewEvent => - merge( - of(contentViewEvent).pipe( - tap(() => { - console.log('Content view added', { contentViewEvent }) - linkPreviewSubscriptions.set(contentViewEvent.element, new Subscription()) - contentViewEvent.subscriptions.add(() => { - console.log('Content view removed', { contentViewEvent }) - - // Clean up current link preview subscriptions when the content view is removed - const subscriptions = linkPreviewSubscriptions.get(contentViewEvent.element) - if (!subscriptions) { - throw new Error('No linkPreview subscriptions') - } - subscriptions.unsubscribe() - }) - }) - ), - - /** - * Observe updates to the element. Only emit on mutations that actually - * change the innerHTML so that our own {@link applyLinkPreview} updates - * don't trigger needless work. It is not sufficient to suppress observing - * these changes using {@link MutationObserver#disconnect} because that does - * not actually seem to suppress mutation notifications in tests when using - * jsdom. - */ - observeMutations(contentViewEvent.element, { childList: true }, pauseMutationObserver).pipe( - observeOn(animationFrameScheduler), - map(() => contentViewEvent.element.innerHTML), - distinctUntilChanged(), - tap(() => console.log('Content view updated', { contentViewEvent })), - mapTo(contentViewEvent), - throttleTime(2000, undefined, { leading: true, trailing: true }) // reduce the harm from an infinite loop bug - ) - ) - ), - tap(({ element }) => { - // Reset link preview subscriptions - let subscriptions = linkPreviewSubscriptions.get(element) - if (!subscriptions) { - throw new Error('No linkPreview subscriptions') - } - subscriptions.unsubscribe() - subscriptions = new Subscription() - linkPreviewSubscriptions.set(element, subscriptions) - - // Add link preview content. - for (const link of element.querySelectorAll('a[href]')) { - subscriptions.add( - extensionsController.services.linkPreviews - .provideLinkPreview(link.href) - // The nested subscribe cannot be replaced with a switchMap() - // because we are managing a stateful Map. The subscription is - // managed correctly. - // - // eslint-disable-next-line rxjs/no-nested-subscribe - .subscribe(linkPreview => { - try { - pauseMutationObserver.next(true) // ignore DOM mutations we make - applyLinkPreview({ setElementTooltip, linkPreviewContentClass }, link, linkPreview) - } finally { - pauseMutationObserver.next(false) // stop ignoring DOM mutations - } - }) - ) - } - }) - ) - .subscribe() -} diff --git a/browser/src/libs/code_intelligence/extensions.test.tsx b/browser/src/libs/code_intelligence/extensions.test.tsx deleted file mode 100644 index 6efd8da173c7..000000000000 --- a/browser/src/libs/code_intelligence/extensions.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { DEFAULT_SOURCEGRAPH_URL, getAssetsURL } from '../../shared/util/context' -import { initializeExtensions } from './extensions' - -describe('Extensions controller', () => { - it('Blocks GraphQL requests from extensions if they risk leaking private information to the public sourcegraph.com instance', () => { - window.SOURCEGRAPH_URL = DEFAULT_SOURCEGRAPH_URL - const { extensionsController } = initializeExtensions( - { - urlToFile: () => '', - getContext: () => ({ rawRepoName: 'foo', privateRepository: true }), - }, - { - sourcegraphURL: DEFAULT_SOURCEGRAPH_URL, - assetsURL: getAssetsURL(DEFAULT_SOURCEGRAPH_URL), - }, - false - ) - return expect( - extensionsController.executeCommand({ - command: 'queryGraphQL', - arguments: [ - ` - query ResolveRepo($repoName: String!) { - repository(name: $repoName) { - url - } - } - `, - { repoName: 'foo' }, - ], - }) - ).rejects.toMatchObject({ - message: - 'A ResolveRepo GraphQL request to the public Sourcegraph.com was blocked because the current repository is private.', - }) - }) -}) diff --git a/browser/src/libs/code_intelligence/extensions.tsx b/browser/src/libs/code_intelligence/extensions.tsx deleted file mode 100644 index ab94ca340852..000000000000 --- a/browser/src/libs/code_intelligence/extensions.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { TextDocumentDecoration } from '@sourcegraph/extension-api-types' -import * as React from 'react' -import { render } from 'react-dom' -import { ContributableMenu } from '../../../../shared/src/api/protocol' -import { - CommandListPopoverButton, - CommandListPopoverButtonProps, -} from '../../../../shared/src/commandPalette/CommandList' -import { Notifications } from '../../../../shared/src/notifications/Notifications' -import classNames from 'classnames' -import { DiffPart } from '@sourcegraph/codeintellify' -import * as H from 'history' -import { isEqual } from 'lodash' -import { - decorationAttachmentStyleForTheme, - DecorationMapByLine, - decorationStyleForTheme, - groupDecorationsByLine, -} from '../../../../shared/src/api/client/services/decoration' -import { - createController as createExtensionsController, - ExtensionsControllerProps, -} from '../../../../shared/src/extensions/controller' -import { PlatformContextProps } from '../../../../shared/src/platform/context' -import { TelemetryProps } from '../../../../shared/src/telemetry/telemetryService' -import { createPlatformContext, SourcegraphIntegrationURLs, BrowserPlatformContext } from '../../platform/context' -import { GlobalDebug } from '../../shared/components/GlobalDebug' -import { ShortcutProvider } from '../../shared/components/ShortcutProvider' -import { CodeHost } from './code_intelligence' -import { DOMFunctions } from './code_views' -import { IS_LIGHT_THEME } from './consts' -import { NotificationClassNameProps } from '../../../../shared/src/notifications/NotificationItem' - -/** - * Initializes extensions for a page. It creates the {@link PlatformContext} and extensions controller. - * - */ -export function initializeExtensions( - { urlToFile, getContext }: Pick, - urls: SourcegraphIntegrationURLs, - isExtension: boolean -): { platformContext: BrowserPlatformContext } & ExtensionsControllerProps { - const platformContext = createPlatformContext({ urlToFile, getContext }, urls, isExtension) - const extensionsController = createExtensionsController(platformContext) - return { platformContext, extensionsController } -} - -interface InjectProps - extends PlatformContextProps<'forceUpdateTooltip' | 'settings' | 'sideloadedExtensionURL'>, - ExtensionsControllerProps { - history: H.History - render: typeof render -} - -export const renderCommandPalette = ({ - extensionsController, - history, - render, - ...props -}: TelemetryProps & - InjectProps & - Pick & - NotificationClassNameProps) => (mount: HTMLElement): void => { - render( - - - - , - mount - ) -} - -export const renderGlobalDebug = ({ - extensionsController, - platformContext, - history, - render, - sourcegraphURL, -}: InjectProps & { sourcegraphURL: string; showGlobalDebug?: boolean }) => (mount: HTMLElement): void => { - render( - , - mount - ) -} - -const cleanupDecorationsForCodeElement = (codeElement: HTMLElement, part: DiffPart | undefined): void => { - codeElement.style.backgroundColor = '' - const previousAttachments = codeElement.querySelectorAll(`.line-decoration-attachment[data-part=${String(part)}]`) - for (const attachment of previousAttachments) { - attachment.remove() - } -} - -const cleanupDecorationsForLineElement = (lineElement: HTMLElement): void => { - lineElement.style.backgroundColor = '' -} - -/** - * Applies a decoration to a code view. This doesn't work with diff views yet. - * - * @returns New decorations, grouped by line number - */ -export const applyDecorations = ( - dom: DOMFunctions, - codeView: HTMLElement, - decorations: TextDocumentDecoration[], - previousDecorations: DecorationMapByLine, - part?: DiffPart -): DecorationMapByLine => { - const decorationsByLine = groupDecorationsByLine(decorations) - // Clean up lines that now don't have decorations anymore - for (const lineNumber of previousDecorations.keys()) { - if (!decorationsByLine.has(lineNumber)) { - const codeElement = dom.getCodeElementFromLineNumber(codeView, lineNumber, part) - if (codeElement) { - cleanupDecorationsForCodeElement(codeElement, part) - } - const lineElement = dom.getLineElementFromLineNumber(codeView, lineNumber, part) - if (lineElement) { - cleanupDecorationsForLineElement(lineElement) - } - } - } - for (const [lineNumber, decorationsForLine] of decorationsByLine) { - const previousDecorationsForLine = previousDecorations.get(lineNumber) - if (isEqual(decorationsForLine, previousDecorationsForLine)) { - // No change in this line - continue - } - - const codeElement = dom.getCodeElementFromLineNumber(codeView, lineNumber, part) - if (!codeElement) { - if (part === undefined) { - throw new Error(`Unable to find code element for line ${lineNumber}`) - } - // In diffs it's normal that many lines are not visible - continue - } - const lineElement = dom.getLineElementFromLineNumber(codeView, lineNumber, part) - if (!lineElement) { - if (part === undefined) { - throw new Error(`Could not find line element for line ${lineNumber}`) - } - // In diffs it's normal that many lines are not visible - continue - } - - // Clean up previous decorations - // Sometimes these can be there even if we cleaned them up if - // the code host snapshotted the DOM before removal of the code view - // (happens on GitHub when switching tabs on a PR) - cleanupDecorationsForCodeElement(codeElement, part) - cleanupDecorationsForLineElement(lineElement) - - for (const decoration of decorationsForLine) { - const style = decorationStyleForTheme(decoration, IS_LIGHT_THEME) - if (style.backgroundColor) { - let backgroundElement: HTMLElement - if (decoration.isWholeLine) { - backgroundElement = lineElement - } else { - backgroundElement = codeElement - } - backgroundElement.style.backgroundColor = style.backgroundColor - } - - if (decoration.after) { - const style = decorationAttachmentStyleForTheme(decoration.after, IS_LIGHT_THEME) - - const linkTo = (url: string) => (e: HTMLElement): HTMLElement => { - const link = document.createElement('a') - link.setAttribute('href', url) - - // External URLs should open in a new tab, whereas relative URLs - // should not. - link.setAttribute('target', /^https?:\/\//.test(url) ? '_blank' : '') - - // Avoid leaking referrer URLs (which contain repository and path names, etc.) to external sites. - link.setAttribute('rel', 'noreferrer noopener') - - link.style.color = style.color || '' - link.appendChild(e) - return link - } - - const after = document.createElement('span') - after.style.color = style.color || '' - after.style.backgroundColor = style.backgroundColor || '' - after.textContent = decoration.after.contentText || null - if (decoration.after.hoverMessage) { - after.title = decoration.after.hoverMessage - } - - const annotation = decoration.after.linkURL ? linkTo(decoration.after.linkURL)(after) : after - annotation.dataset.part = String(part) - annotation.className = 'sourcegraph-extension-element line-decoration-attachment' - codeElement.appendChild(annotation) - } - } - } - return decorationsByLine -} diff --git a/browser/src/libs/code_intelligence/external_links.test.tsx b/browser/src/libs/code_intelligence/external_links.test.tsx deleted file mode 100644 index c38f9c2f7fd5..000000000000 --- a/browser/src/libs/code_intelligence/external_links.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { ViewOnSourcegraphButton } from './external_links' -import { HTTPStatusError } from '../../../../shared/src/backend/fetch' -import * as React from 'react' -import renderer, { ReactTestRenderer } from 'react-test-renderer' -import { noop } from 'lodash' - -describe('', () => { - it('renders a link to the repository on the Sourcegraph instance', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ rawRepoName: 'test', privateRepository: false })} - className="test" - repoExistsOrError={true} - minimalUI={false} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) - - it('renders nothing in minimal UI mode', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ rawRepoName: 'test', privateRepository: false })} - className="test" - repoExistsOrError={true} - minimalUI={true} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) - - it('renders a link with the rev when provided', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ - rawRepoName: 'test', - rev: 'test', - privateRepository: false, - })} - className="test" - repoExistsOrError={true} - minimalUI={false} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) - - for (const minimalUI of [true, false]) { - describe(`minimalUI = ${String(minimalUI)}`, () => { - it('renders a sign in button when authentication failed and showSignInButton = true', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ - rawRepoName: 'test', - rev: 'test', - privateRepository: false, - })} - showSignInButton={true} - className="test" - repoExistsOrError={new HTTPStatusError(new Response('', { status: 401 }))} - minimalUI={minimalUI} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) - }) - } - - it('renders a button with an error label if the repo exists check failed with an unknown error', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ - rawRepoName: 'test', - rev: 'test', - privateRepository: false, - })} - showSignInButton={true} - className="test" - repoExistsOrError={new Error('Something unknown happened!')} - minimalUI={false} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) - - it('renders configure sourcegraph button when pointing at sourcegraph.com and the repo does not exist', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ - rawRepoName: 'test', - rev: 'test', - privateRepository: false, - })} - className="test" - repoExistsOrError={false} - onConfigureSourcegraphClick={noop} - minimalUI={false} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) - - it('still renders a button to a private instance if repo does not exist', () => { - let root: ReactTestRenderer - renderer.act(() => { - root = renderer.create( - ({ - rawRepoName: 'test', - rev: 'test', - privateRepository: false, - })} - className="test" - repoExistsOrError={false} - minimalUI={false} - /> - ) - }) - expect(root!).toMatchSnapshot() - }) -}) diff --git a/browser/src/libs/code_intelligence/external_links.tsx b/browser/src/libs/code_intelligence/external_links.tsx deleted file mode 100644 index e70e21685b47..000000000000 --- a/browser/src/libs/code_intelligence/external_links.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import classNames from 'classnames' -import React from 'react' -import { SourcegraphIconButton } from '../../shared/components/Button' -import { DEFAULT_SOURCEGRAPH_URL } from '../../shared/util/context' -import { CodeHostContext } from './code_intelligence' -import { ErrorLike, isErrorLike } from '../../../../shared/src/util/errors' -import { failedWithHTTPStatus } from '../../../../shared/src/backend/fetch' -import { SignInButton } from './SignInButton' - -export interface ViewOnSourcegraphButtonClassProps { - className?: string - iconClassName?: string -} - -interface ViewOnSourcegraphButtonProps extends ViewOnSourcegraphButtonClassProps { - getContext: () => CodeHostContext - sourcegraphURL: string - minimalUI: boolean - repoExistsOrError?: boolean | ErrorLike - showSignInButton?: boolean - onConfigureSourcegraphClick?: () => void - - /** - * A callback for when the user finished a sign in flow. - * This does not guarantee the sign in was successful. - */ - onSignInClose?: () => void -} - -export const ViewOnSourcegraphButton: React.FunctionComponent = ({ - repoExistsOrError, - sourcegraphURL, - getContext, - minimalUI, - onConfigureSourcegraphClick, - showSignInButton, - onSignInClose, - className, - iconClassName, -}) => { - className = classNames('open-on-sourcegraph', className) - - if (repoExistsOrError === undefined) { - return null - } - if (isErrorLike(repoExistsOrError)) { - if (failedWithHTTPStatus(repoExistsOrError, 401)) { - if (showSignInButton) { - return ( - - ) - } - // Sign in button may already be shown elsewhere on the page - return null - } - return ( - - ) - } - // In minimal UI mode, only show the button as a CTA to sign in - if (minimalUI) { - return null - } - - // If repo doesn't exist and the instance is sourcegraph.com, prompt - // user to configure Sourcegraph. - if (!repoExistsOrError && sourcegraphURL === DEFAULT_SOURCEGRAPH_URL && onConfigureSourcegraphClick) { - return ( - - ) - } - - const { rawRepoName, rev } = getContext() - const url = new URL(`/${rawRepoName}${rev ? `@${rev}` : ''}`, sourcegraphURL).href - return ( - - ) -} diff --git a/browser/src/libs/code_intelligence/hover_alerts.tsx b/browser/src/libs/code_intelligence/hover_alerts.tsx deleted file mode 100644 index 5bc8d2cf189b..000000000000 --- a/browser/src/libs/code_intelligence/hover_alerts.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Observable, of } from 'rxjs' -import { catchError, map, startWith, switchMap } from 'rxjs/operators' -import { HoverAlert } from '../../../../shared/src/hover/HoverOverlay' -import { combineLatestOrDefault } from '../../../../shared/src/util/rxjs/combineLatestOrDefault' -import { observeStorageKey, storage } from '../../browser/storage' -import { SyncStorageItems } from '../../browser/types' -import { isInPage } from '../../context' - -export type ExtensionHoverAlertType = 'nativeTooltips' - -/** - * Returns an Observable of all hover alerts that have not yet - * been dismissed by the user. - */ -export function getActiveHoverAlerts( - allAlerts: Observable>[] -): Observable[] | undefined> { - if (isInPage) { - return of(undefined) - } - return observeStorageKey('sync', 'dismissedHoverAlerts').pipe( - switchMap(dismissedAlerts => - combineLatestOrDefault(allAlerts).pipe( - map(alerts => (dismissedAlerts ? alerts.filter(({ type }) => !dismissedAlerts[type]) : alerts)) - ) - ), - catchError(err => { - console.error('Error getting hover alerts', err) - return [undefined] - }), - startWith([]) - ) -} -/** - * Marks a hover alert as dismissed in sync storage. - */ -export async function onHoverAlertDismissed(alertType: ExtensionHoverAlertType): Promise { - try { - const partialStorageItems: Pick = { - dismissedHoverAlerts: {}, - ...(await storage.sync.get('dismissedHoverAlerts')), - } - partialStorageItems.dismissedHoverAlerts[alertType] = true - await storage.sync.set(partialStorageItems) - } catch (err) { - console.error('Error dismissing alert', err) - } -} diff --git a/browser/src/libs/code_intelligence/index.ts b/browser/src/libs/code_intelligence/index.ts deleted file mode 100644 index 52adc153f3ff..000000000000 --- a/browser/src/libs/code_intelligence/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './code_intelligence' diff --git a/browser/src/libs/code_intelligence/inject.ts b/browser/src/libs/code_intelligence/inject.ts deleted file mode 100644 index 0720d7347d9c..000000000000 --- a/browser/src/libs/code_intelligence/inject.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Observable, Subscription } from 'rxjs' -import { startWith } from 'rxjs/operators' -import { MutationRecordLike, observeMutations } from '../../shared/util/dom' -import { determineCodeHost, injectCodeIntelligenceToCodeHost } from './code_intelligence' -import { SourcegraphIntegrationURLs } from '../../platform/context' - -/** - * Checks if the current page is a known code host. If it is, - * injects features for the lifetime of the script in reaction to DOM mutations. - * - * @param isExtension `true` when executing in the browser extension. - */ -export function injectCodeIntelligence(urls: SourcegraphIntegrationURLs, isExtension: boolean): Subscription { - const subscriptions = new Subscription() - const codeHost = determineCodeHost() - if (codeHost) { - console.log('Detected code host:', codeHost.type) - const mutations: Observable = observeMutations(document.body, { - childList: true, - subtree: true, - }).pipe(startWith([{ addedNodes: [document.body], removedNodes: [] }])) - subscriptions.add(injectCodeIntelligenceToCodeHost(mutations, codeHost, urls, isExtension)) - } - return subscriptions -} diff --git a/browser/src/libs/code_intelligence/native_tooltips.tsx b/browser/src/libs/code_intelligence/native_tooltips.tsx deleted file mode 100644 index ca94fa239918..000000000000 --- a/browser/src/libs/code_intelligence/native_tooltips.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { isEqual } from 'lodash' -import * as React from 'react' -import { from, Observable, Unsubscribable } from 'rxjs' -import { distinctUntilChanged, filter, first, map, mapTo, publishReplay, refCount } from 'rxjs/operators' -import { parseTemplate } from '../../../../shared/src/api/client/context/expr/evaluator' -import { Services } from '../../../../shared/src/api/client/services' -import { HoverAlert } from '../../../../shared/src/hover/HoverOverlay' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { Settings } from '../../../../shared/src/settings/settings' -import { ErrorLike, isErrorLike } from '../../../../shared/src/util/errors' -import { isDefined, isNot } from '../../../../shared/src/util/types' -import { MutationRecordLike } from '../../shared/util/dom' -import { CodeHost } from './code_intelligence' -import { ExtensionHoverAlertType } from './hover_alerts' -import { trackViews } from './views' - -const NATIVE_TOOLTIP_HIDDEN = 'native-tooltip--hidden' - -/** - * Defines a native tooltip that is present on a page and exposes operations for manipulating it. - */ -export interface NativeTooltip { - /** The native tooltip HTML element. */ - element: HTMLElement -} - -export function handleNativeTooltips( - mutations: Observable, - nativeTooltipsEnabled: Observable, - { nativeTooltipResolvers, name }: Pick -): { nativeTooltipsAlert: Observable>; subscription: Unsubscribable } { - const nativeTooltips = mutations.pipe(trackViews(nativeTooltipResolvers || [])) - const nativeTooltipsAlert = mutations.pipe( - first(), - mapTo({ - type: 'nativeTooltips' as const, - content: ( - <> - Sourcegraph has hidden {name || 'the code host'}'s native hover tooltips. You can toggle this at any - time: to enable the native tooltips run โ€œCode host: prefer non-Sourcegraph hover tooltipsโ€ from the - command palette or set "codeHost.useNativeTooltips": true in your user settings. - - ), - }), - publishReplay(1), - refCount() - ) - return { - nativeTooltipsAlert, - subscription: nativeTooltips.subscribe(({ element, subscriptions }) => { - subscriptions.add( - // This subscription is correctly handled through the view's `subscriptions` - // eslint-disable-next-line rxjs/no-nested-subscribe - nativeTooltipsEnabled.subscribe(enabled => { - element.classList.toggle(NATIVE_TOOLTIP_HIDDEN, !enabled) - }) - ) - }), - } -} - -export function nativeTooltipsEnabledFromSettings(settings: PlatformContext['settings']): Observable { - return from(settings).pipe( - map(({ final }) => final), - filter(isDefined), - filter(isNot(isErrorLike)), - map(s => !!s['codeHost.useNativeTooltips']), - distinctUntilChanged((a, b) => isEqual(a, b)), - publishReplay(1), - refCount() - ) -} - -export function registerNativeTooltipContributions(extensionsController: { - services: Pick -}): Unsubscribable { - return extensionsController.services.contribution.registerContributions({ - contributions: { - actions: [ - { - id: 'codeHost.toggleUseNativeTooltips', - command: 'updateConfiguration', - category: parseTemplate('Code host'), - commandArguments: [ - parseTemplate('codeHost.useNativeTooltips'), - /* eslint-disable-next-line no-template-curly-in-string */ - parseTemplate('${!config.codeHost.useNativeTooltips}'), - null, - parseTemplate('json'), - ], - title: parseTemplate( - /* eslint-disable-next-line no-template-curly-in-string */ - 'Prefer ${config.codeHost.useNativeTooltips && "Sourcegraph" || "non-Sourcegraph"} hover tooltips' - ), - }, - ], - menus: { - commandPalette: [ - { - action: 'codeHost.toggleUseNativeTooltips', - }, - ], - }, - }, - }) -} diff --git a/browser/src/libs/code_intelligence/test_helpers.tsx b/browser/src/libs/code_intelligence/test_helpers.tsx deleted file mode 100644 index 0096e9776142..000000000000 --- a/browser/src/libs/code_intelligence/test_helpers.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Observable, of, throwError } from 'rxjs' -import { SuccessGraphQLResult } from '../../../../shared/src/graphql/graphql' -import { IMutation, IQuery } from '../../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../../shared/src/platform/context' - -export interface GraphQLResponseMap { - [requestName: string]: ( - variables: { [k: string]: any }, - mightContainPrivateInfo?: boolean - ) => Observable> -} - -export const DEFAULT_GRAPHQL_RESPONSES: GraphQLResponseMap = { - SiteProductVersion: () => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - of({ - data: { - site: { - productVersion: 'dev', - buildVersion: 'dev', - hasCodeIntelligence: true, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), - CurrentUSer: () => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - of({ - data: { - currentUser: { - id: 'u1', - displayName: 'Alice', - username: 'alice', - avatarURL: null, - url: 'https://example.com/alice', - settingsURL: 'https://example.com/alice/settings', - emails: [{ email: 'alice@example.com' }], - siteAdmin: false, - }, - }, - } as SuccessGraphQLResult), - - ResolveRev: () => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - of({ - data: { - repository: { - mirrorInfo: { - cloned: true, - }, - commit: { - oid: 'foo', - }, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), - BlobContent: () => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - of({ - data: { - repository: { - commit: { - file: { - content: 'Hello World', - }, - }, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), - ResolveRepo: variables => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - of({ - data: { - repository: { - name: variables.rawRepoName, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), -} - -/** - * @param responseMap a {@link GraphQLResponseMap} of request names (eg. `ResolveRev`) to response builders. - * - * @returns a mock implementation of {@link PlatformContext#requestGraphQL} - */ -export const mockRequestGraphQL = ( - responseMap: GraphQLResponseMap = DEFAULT_GRAPHQL_RESPONSES -): PlatformContext['requestGraphQL'] => ({ - request, - variables, - mightContainPrivateInfo, -}: { - request: string - variables: {} - mightContainPrivateInfo?: boolean -}) => { - const nameMatch = request.match(/^\s*(?:query|mutation)\s+(\w+)/) - const requestName = nameMatch?.[1] - if (!requestName || !responseMap[requestName]) { - return throwError(new Error(`No mock for GraphQL request ${String(requestName)}`)) - } - return responseMap[requestName](variables, mightContainPrivateInfo) as Observable> -} diff --git a/browser/src/libs/code_intelligence/text_fields.test.tsx b/browser/src/libs/code_intelligence/text_fields.test.tsx deleted file mode 100644 index f5bd2e7219d0..000000000000 --- a/browser/src/libs/code_intelligence/text_fields.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { uniqueId, noop } from 'lodash' -import { from, NEVER, Subject, Subscription } from 'rxjs' -import { first } from 'rxjs/operators' -import { Services } from '../../../../shared/src/api/client/services' -import { CodeEditor } from '../../../../shared/src/api/client/services/editorService' -import { integrationTestContext } from '../../../../shared/src/api/integration-test/testHelpers' -import { Controller } from '../../../../shared/src/extensions/controller' -import { MutationRecordLike } from '../../shared/util/dom' -import { handleTextFields } from './text_fields' - -jest.mock('uuid', () => ({ - v4: () => 'uuid', -})) - -const createMockController = (services: Services): Controller => ({ - services, - notifications: NEVER, - executeCommand: () => Promise.resolve(), - unsubscribe: noop, -}) - -describe('text_fields', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - - describe('handleTextFields()', () => { - let subscriptions = new Subscription() - - afterEach(() => { - subscriptions.unsubscribe() - subscriptions = new Subscription() - }) - - const createTestElement = (): HTMLTextAreaElement => { - const el = document.createElement('textarea') - el.className = `test test-${uniqueId()}` - document.body.appendChild(el) - return el - } - - test('detects addition and removal of text fields', async () => { - const { services } = await integrationTestContext(undefined, { roots: [], editors: [] }) - const textFieldElement = createTestElement() - textFieldElement.id = 'text-field' - textFieldElement.value = 'abc' - textFieldElement.setSelectionRange(2, 3) - - const mutations = new Subject() - - subscriptions.add( - handleTextFields( - mutations, - { extensionsController: createMockController(services) }, - { - textFieldResolvers: [ - { selector: 'textarea', resolveView: () => ({ element: textFieldElement }) }, - ], - } - ) - ) - - // Add text field. - mutations.next([{ addedNodes: [document.body], removedNodes: [] }]) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect([...services.editor.editors.values()]).toEqual([ - { - editorId: 'editor#0', - isActive: true, - resource: 'comment://0', - selections: [ - { - anchor: { line: 0, character: 2 }, - active: { line: 0, character: 3 }, - start: { line: 0, character: 2 }, - end: { line: 0, character: 3 }, - isReversed: false, - }, - ], - type: 'CodeEditor', - }, - ] as CodeEditor[]) - - // Remove text field. - textFieldElement.remove() - mutations.next([{ addedNodes: [], removedNodes: [textFieldElement] }]) - await from(services.editor.editorUpdates).pipe(first()).toPromise() - expect(services.editor.editors.size).toEqual(0) - }) - }) -}) diff --git a/browser/src/libs/code_intelligence/text_fields.tsx b/browser/src/libs/code_intelligence/text_fields.tsx deleted file mode 100644 index ca7cc413b310..000000000000 --- a/browser/src/libs/code_intelligence/text_fields.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React from 'react' -import { render } from 'react-dom' -import { animationFrameScheduler, fromEvent, Observable, Subscription, Unsubscribable } from 'rxjs' -import { observeOn } from 'rxjs/operators' -import { COMMENT_URI_SCHEME } from '../../../../shared/src/api/client/types/textDocument' -import { EditorCompletionWidget } from '../../../../shared/src/components/completion/EditorCompletionWidget' -import { EditorTextFieldUtils } from '../../../../shared/src/components/editorTextField/EditorTextField' -import { ExtensionsControllerProps } from '../../../../shared/src/extensions/controller' -import { MutationRecordLike } from '../../shared/util/dom' -import { CodeHost } from './code_intelligence' -import { trackViews } from './views' - -/** - * Defines a text field that is present on a page and exposes operations for manipulating it. - */ -export interface TextField { - /** The text field HTML element. */ - element: HTMLTextAreaElement -} - -/** - * Handles added and removed text fields according to the {@link CodeHost} configuration. - */ -export function handleTextFields( - mutations: Observable, - { extensionsController }: ExtensionsControllerProps, - { - textFieldResolvers, - completionWidgetClassProps, - }: Pick -): Unsubscribable { - /** A stream of added or removed text fields. */ - const textFields = mutations.pipe(trackViews(textFieldResolvers || []), observeOn(animationFrameScheduler)) - - // Don't use lodash.uniqueId because that makes it harder to hard-code expected URI values in - // test code (because the URIs would change depending on test execution order). - let seq = 0 - const nextModelUri = (): string => `${COMMENT_URI_SCHEME}://${seq++}` - - return textFields.subscribe(textFieldEvent => { - console.log('Text field added', { textFieldEvent }) - textFieldEvent.subscriptions.add(() => console.log('Text field removed', { textFieldEvent })) - // Start 2-way syncing the text field with an editor and model. - textFieldEvent.subscriptions.add( - synchronizeTextField({ extensionsController }, { completionWidgetClassProps }, nextModelUri, textFieldEvent) - ) - }) -} - -/** - * Start 2-way syncing a text field with an editor and model. - */ -function synchronizeTextField( - { extensionsController }: ExtensionsControllerProps, - { completionWidgetClassProps }: Pick, - nextModelUri: () => string, - { element }: TextField -): Unsubscribable { - const { - services: { editor: editorService, model: modelService }, - } = extensionsController - - const subscriptions = new Subscription() - - // Create the editor backing this text field. - const modelUri = nextModelUri() - const { text, selections } = EditorTextFieldUtils.getEditorDataFromElement(element) - modelService.addModel({ uri: modelUri, languageId: 'plaintext', text }) - const editor = editorService.addEditor({ - type: 'CodeEditor', - resource: modelUri, - selections, - isActive: true, - }) - subscriptions.add(() => editorService.removeEditor(editor)) - - // Keep the text field in sync with the editor and model. - subscriptions.add( - fromEvent(element, 'input') - .pipe(observeOn(animationFrameScheduler)) - .subscribe(() => { - EditorTextFieldUtils.updateModelFromElement(modelService, modelUri, element) - EditorTextFieldUtils.updateEditorSelectionFromElement(editorService, editor, element) - }) - ) - subscriptions.add( - fromEvent(element, 'keydown') - .pipe(observeOn(animationFrameScheduler)) - .subscribe(() => { - EditorTextFieldUtils.updateEditorSelectionFromElement(editorService, editor, element) - }) - ) - subscriptions.add( - EditorTextFieldUtils.updateElementOnEditorOrModelChanges( - editorService, - modelService, - editor, - text => { - element.value = text - }, - { current: element } - ) - ) - - // Show completions in the text field. - const completionWidgetMount = document.createElement('div') - completionWidgetMount.classList.add('sg-text-field-editor-completion-widget') - element.insertAdjacentElement('beforebegin', completionWidgetMount) - render( - , - completionWidgetMount - ) - subscriptions.add(() => completionWidgetMount.remove()) - - return subscriptions -} diff --git a/browser/src/libs/code_intelligence/util/file_info.ts b/browser/src/libs/code_intelligence/util/file_info.ts deleted file mode 100644 index 5383d184247c..000000000000 --- a/browser/src/libs/code_intelligence/util/file_info.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Observable, of, zip } from 'rxjs' -import { catchError, map } from 'rxjs/operators' - -import { PRIVATE_REPO_PUBLIC_SOURCEGRAPH_COM_ERROR_NAME } from '../../../../../shared/src/backend/errors' -import { PlatformContext } from '../../../../../shared/src/platform/context' -import { resolveRepo, resolveRev, retryWhenCloneInProgressError } from '../../../shared/repo/backend' -import { FileInfo, FileInfoWithRepoNames } from '../code_intelligence' - -export const ensureRevisionsAreCloned = ( - { repoName, commitID, baseCommitID, ...rest }: FileInfoWithRepoNames, - requestGraphQL: PlatformContext['requestGraphQL'] -): Observable => { - // Although we get the commit SHA's from elsewhere, we still need to - // use `resolveRev` otherwise we can't guarantee Sourcegraph has the - // revision cloned. - - // Head - const resolvingHeadRev = resolveRev({ repoName, rev: commitID, requestGraphQL }).pipe( - retryWhenCloneInProgressError() - ) - - const requests = [resolvingHeadRev] - - // If theres a base, resolve it as well. - if (baseCommitID) { - const resolvingBaseRev = resolveRev({ repoName, rev: baseCommitID, requestGraphQL }).pipe( - retryWhenCloneInProgressError() - ) - requests.push(resolvingBaseRev) - } - - return zip(...requests).pipe(map(() => ({ repoName, commitID, baseCommitID, ...rest }))) -} - -/** - * Resolve a `FileInfo`'s raw repo names to their Sourcegraph - * repo names as affected by `repositoryPathPattern`. - */ -export const resolveRepoNames = ( - { rawRepoName, baseRawRepoName, ...rest }: FileInfo, - requestGraphQL: PlatformContext['requestGraphQL'] -): Observable => { - const resolvingHeadRepoName = resolveRepo({ rawRepoName, requestGraphQL }).pipe(retryWhenCloneInProgressError()) - const resolvingBaseRepoName = baseRawRepoName - ? resolveRepo({ rawRepoName: baseRawRepoName, requestGraphQL }).pipe(retryWhenCloneInProgressError()) - : of(undefined) - - return zip(resolvingHeadRepoName, resolvingBaseRepoName).pipe( - map(([repoName, baseRepoName]) => ({ repoName, baseRepoName, rawRepoName, baseRawRepoName, ...rest })), - - // ERPRIVATEREPOPUBLICSOURCEGRAPHCOM likely means that the user is viewing private code - // without having pointed his browser extension to a self-hosted Sourcegraph instance that - // has access to that code. In that case, it's impossible to resolve the repo names, - // so we keep the repo names inferred from the code host's DOM. - catchError(err => { - if (err.name === PRIVATE_REPO_PUBLIC_SOURCEGRAPH_COM_ERROR_NAME) { - return [{ rawRepoName, baseRawRepoName, repoName: rawRepoName, baseRepoName: baseRawRepoName, ...rest }] - } - throw err - }) - ) -} diff --git a/browser/src/libs/code_intelligence/util/selections.ts b/browser/src/libs/code_intelligence/util/selections.ts deleted file mode 100644 index 68f87e652f40..000000000000 --- a/browser/src/libs/code_intelligence/util/selections.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Selection } from '@sourcegraph/extension-api-types' -import { isEqual } from 'lodash' -import { fromEvent, Observable } from 'rxjs' -import { distinctUntilChanged, map } from 'rxjs/operators' -import { lprToSelectionsZeroIndexed, parseHash } from '../../../../../shared/src/util/url' - -export function getSelectionsFromHash(): Selection[] { - return lprToSelectionsZeroIndexed(parseHash(window.location.hash)) -} - -export function observeSelectionsFromHash(): Observable { - return fromEvent(window, 'hashchange').pipe(map(getSelectionsFromHash), distinctUntilChanged(isEqual)) -} diff --git a/browser/src/libs/code_intelligence/views.test.ts b/browser/src/libs/code_intelligence/views.test.ts deleted file mode 100644 index 5eee671f7a6a..000000000000 --- a/browser/src/libs/code_intelligence/views.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { from, Observable, of, Subject, Subscription } from 'rxjs' -import { bufferCount, map, switchMap, toArray } from 'rxjs/operators' -import * as sinon from 'sinon' -import { createBarrier } from '../../../../shared/src/api/integration-test/testHelpers' -import { MutationRecordLike } from '../../shared/util/dom' -import { trackViews, ViewResolver } from './views' - -const FIXTURE_HTML = ` -
-
-
-
-
-` - -describe('trackViews()', () => { - let subscriptions = new Subscription() - - beforeEach(() => { - document.body.innerHTML = FIXTURE_HTML - }) - - afterAll(() => { - subscriptions.unsubscribe() - subscriptions = new Subscription() - document.body.innerHTML = '' - }) - - test('detects all views on the page', async () => { - const mutations: Observable = of([{ addedNodes: [document.body], removedNodes: [] }]) - const views = await mutations - .pipe(trackViews([{ selector: '.view', resolveView: element => ({ element }) }]), toArray()) - .toPromise() - expect(views.map(({ element }) => element.id)).toEqual(['1', '2', '3']) - }) - - test('detects a view if it is the added element itself', async () => { - const mutations: Observable = of([ - { addedNodes: [document.getElementById('1')!], removedNodes: [] }, - ]) - expect( - await mutations - .pipe( - trackViews([{ selector: '.view', resolveView: element => ({ element }) }]), - map(({ element }) => element.id), - toArray() - ) - .toPromise() - ).toEqual(['1']) - }) - - test('detects a view if it is the added element itself', async () => { - const mutations: Observable = of([ - { addedNodes: [document.getElementById('1')!], removedNodes: [] }, - ]) - expect( - await mutations - .pipe( - trackViews([{ selector: '.view', resolveView: element => ({ element }) }]), - map(({ element }) => element.id), - toArray() - ) - .toPromise() - ).toEqual(['1']) - }) - - test('emits the element returned by the resolver', async () => { - const mutations: Observable = of([{ addedNodes: [document.body], removedNodes: [] }]) - const selectorTarget = document.createElement('div') - selectorTarget.className = 'selector-target' - document.getElementById('1')!.append(selectorTarget) - expect( - await mutations - .pipe( - trackViews([ - { - selector: '.selector-target', - resolveView: element => ({ element: element.parentElement! }), - }, - ]), - map(({ element }) => element.id), - toArray() - ) - .toPromise() - ).toEqual(['1']) - }) - - test("doesn't emit duplicate views", async () => { - const mutations: Observable = of([{ addedNodes: [document.body], removedNodes: [] }]) - expect( - await mutations - .pipe( - trackViews([ - { selector: '.view', resolveView: () => ({ element: document.getElementById('1')! }) }, - ]), - map(({ element }) => element.id), - toArray() - ) - .toPromise() - ).toEqual(['1']) - }) - - test('detects views added later', async () => { - const selector = '.test-code-view' - const subscriber = sinon.spy() - const mutations = new Subject() - const { wait, done } = createBarrier() - subscriptions.add( - mutations - .pipe( - trackViews([ - { - selector, - resolveView: element => ({ element }), - }, - ]) - ) - .subscribe(codeView => { - done() - subscriber(codeView) - }) - ) - sinon.assert.notCalled(subscriber) - mutations.next([{ addedNodes: [document.body], removedNodes: [] }]) - - // Add code view to DOM - const element = document.createElement('div') - element.className = 'test-code-view' - document.body.append(element) - mutations.next([{ addedNodes: [element], removedNodes: [] }]) - await wait - sinon.assert.calledOnce(subscriber) - expect(subscriber.args[0].map(({ subscriptions, ...rest }) => rest)).toEqual([{ element }]) - }) - - test('detects nested views added later', async () => { - const selector = '.test-code-view' - const subscriber = sinon.spy() - const mutations = new Subject() - const { wait, done } = createBarrier() - subscriptions.add( - mutations - .pipe( - trackViews([ - { - selector, - resolveView: element => ({ element }), - }, - ]) - ) - .subscribe(codeView => { - done() - subscriber(codeView) - }) - ) - sinon.assert.notCalled(subscriber) - mutations.next([{ addedNodes: [document.body], removedNodes: [] }]) - - // Add code view to DOM - const element = document.createElement('div') - element.className = 'test-code-view' - const container = document.getElementById('parent')! - container.append(element) - mutations.next([{ addedNodes: [container], removedNodes: [] }]) - await wait - sinon.assert.calledOnce(subscriber) - expect(subscriber.args[0].map(({ subscriptions, ...rest }) => rest)).toEqual([{ element }]) - }) - - test('removes views', async () => { - const mutations = from([ - [{ addedNodes: [document.body], removedNodes: [] }], - [{ addedNodes: [], removedNodes: [document.getElementById('1')!] }], - [{ addedNodes: [], removedNodes: [document.getElementById('3')!] }], - ]) - await mutations - .pipe( - trackViews([{ selector: '.view', resolveView: element => ({ element }) }]), - bufferCount(3), - switchMap(async ([v1, v2, v3]) => { - const v2Removed = sinon.spy() - v2.subscriptions.add(v2Removed) - const v1Removed = new Promise(resolve => v1.subscriptions.add(resolve)) - const v3Removed = new Promise(resolve => v3.subscriptions.add(resolve)) - await Promise.all([v1Removed, v3Removed]) - sinon.assert.notCalled(v2Removed) - }) - ) - .toPromise() - }) - - test('removes all nested views', async () => { - const mutations = from([ - [{ addedNodes: [document.body], removedNodes: [] }], - [{ addedNodes: [], removedNodes: [document.getElementById('parent')!] }], - ]) - await mutations - .pipe( - trackViews([{ selector: '.view', resolveView: element => ({ element }) }]), - bufferCount(3), - switchMap(views => - Promise.all(views.map(view => new Promise(resolve => view.subscriptions.add(resolve)))) - ) - ) - .toPromise() - }) - - test('removes a view without depending on its resolver', async () => { - const selector = '.test-code-view' - const subscriber = sinon.spy() - const mutations = new Subject() - const { wait, done } = createBarrier() - - // Track views using a resolver that looks at the element's parent tree - // to determine whether it should resolve or return `null`. - const resolver: ViewResolver<{ element: HTMLElement }> = { - selector, - resolveView: element => element.closest('.view') && { element }, - } - subscriptions.add( - mutations.pipe(trackViews([resolver])).subscribe(codeView => { - done() - subscriber(codeView) - }) - ) - sinon.assert.notCalled(subscriber) - mutations.next([{ addedNodes: [document.body], removedNodes: [] }]) - - // Add code view to DOM - const testElement = document.createElement('div') - testElement.className = 'test-code-view' - const container = document.getElementById('1')! - container.append(testElement) - mutations.next([{ addedNodes: [document.body], removedNodes: [] }]) - await wait - sinon.assert.calledOnce(subscriber) - const view = subscriber.args[0][0] as { element: HTMLElement; subscriptions: Subscription } - expect(view.element).toEqual(testElement) - - // Remove code view from the DOM. Verify it cannot be resolved anymore. - testElement.remove() - expect(resolver.resolveView(testElement)).toBe(null) - - // Verify that the code view still gets removed. - const unsubscribed = new Promise(resolve => view.subscriptions.add(resolve)) - mutations.next([{ addedNodes: [], removedNodes: [testElement] }]) - await unsubscribed - }) -}) diff --git a/browser/src/libs/code_intelligence/views.ts b/browser/src/libs/code_intelligence/views.ts deleted file mode 100644 index f1ed63c8e10b..000000000000 --- a/browser/src/libs/code_intelligence/views.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { asyncScheduler, defer, from, Subscription, OperatorFunction } from 'rxjs' -import { concatAll, filter, mergeMap, observeOn, tap } from 'rxjs/operators' -import { isDefined, isInstanceOf } from '../../../../shared/src/util/types' -import { MutationRecordLike, querySelectorAllOrSelf } from '../../shared/util/dom' - -interface View { - element: HTMLElement -} - -export type ViewWithSubscriptions = V & { - /** - * Maintains subscriptions to resources that should be freed when the view is removed. - */ - subscriptions: Subscription -} - -/** - * Finds and resolves elements matched by a MutationObserver to views. - * - * @template V The type of view, such as a code view. - */ -export interface ViewResolver { - /** - * The element selector (used with {@link Window#querySelectorAll}) that matches candidate - * elements to be passed to {@link ViewResolver#resolveView}. - */ - selector: string - - /** - * Resolve an element matched by {@link ViewResolver#selector} to a view, or `null` if it's not - * a a valid view upon further examination. - */ - resolveView: (element: HTMLElement) => V | null -} - -/** - * Find all the views (e.g., code views) on a page using view resolvers (defined in - * {@link CodeHost}). - * - * Emits every view that gets added as a {@link ViewWithSubscriptions}, - * and frees a view's resources when it gets removed from the page. - * - * At any given time, there can be any number of views on a page. - * - * @template V The type of view, such as a code view. - */ -export function trackViews( - viewResolvers: ViewResolver[] -): OperatorFunction> { - return mutations => - defer(() => { - const viewStates = new Map>() - return mutations.pipe( - observeOn(asyncScheduler), - concatAll(), - // Inspect removed nodes for known views - tap(({ removedNodes }) => { - for (const node of removedNodes) { - if (!(node instanceof HTMLElement)) { - continue - } - const view = viewStates.get(node) - if (view) { - view.subscriptions.unsubscribe() - viewStates.delete(node) - continue - } - for (const [viewElement, view] of viewStates.entries()) { - if (node.contains(viewElement)) { - view.subscriptions.unsubscribe() - viewStates.delete(viewElement) - } - } - } - }), - mergeMap(mutation => - // Find all new code views within the added nodes - // (MutationObservers don't emit all descendant nodes of an addded node recursively) - from(mutation.addedNodes).pipe( - filter(isInstanceOf(HTMLElement)), - mergeMap(addedElement => - from(viewResolvers).pipe( - mergeMap(({ selector, resolveView }) => - [...querySelectorAllOrSelf(addedElement, selector)].map( - (element): ViewWithSubscriptions | null => { - const view = resolveView(element) - return ( - view && { - ...view, - subscriptions: new Subscription(), - } - ) - } - ) - ), - filter(isDefined), - filter(view => !viewStates.has(view.element)), - tap(view => { - viewStates.set(view.element, view) - }) - ) - ) - ) - ) - ) - }) -} diff --git a/browser/src/libs/github/code_intelligence.test.ts b/browser/src/libs/github/code_intelligence.test.ts deleted file mode 100644 index e1f40b6a7896..000000000000 --- a/browser/src/libs/github/code_intelligence.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { existsSync, readdirSync } from 'fs' -import { startCase } from 'lodash' -import { testCodeHostMountGetters, testToolbarMountGetter } from '../code_intelligence/code_intelligence_test_utils' -import { CodeView } from '../code_intelligence/code_views' -import { createFileActionsToolbarMount, createFileLineContainerToolbarMount, githubCodeHost } from './code_intelligence' -import { readFile } from 'mz/fs' - -const testCodeHost = (fixturePath: string): void => { - if (existsSync(fixturePath)) { - describe('githubCodeHost', () => { - testCodeHostMountGetters(githubCodeHost, fixturePath) - }) - } -} - -const testMountGetter = ( - mountGetter: NonNullable, - mountGetterName: string, - codeViewFixturePath: string -): void => { - if (existsSync(codeViewFixturePath)) { - describe(mountGetterName, () => { - testToolbarMountGetter(codeViewFixturePath, mountGetter) - }) - } -} - -describe('github/code_intelligence', () => { - for (const version of ['github.com', 'ghe-2.14.11']) { - describe(version, () => { - for (const page of readdirSync(`${__dirname}/__fixtures__/${version}`)) { - describe(`${startCase(page)} page`, () => { - for (const extension of ['vanilla', 'refined-github']) { - describe(startCase(extension), () => { - // no split/unified view on blobs, and pull-request-discussion is always unified - if (page === 'blob' || page === 'pull-request-discussion') { - const directory = `${__dirname}/__fixtures__/${version}/${page}/${extension}` - testCodeHost(`${directory}/page.html`) - if (page !== 'pull-request-discussion') { - testMountGetter( - createFileLineContainerToolbarMount, - 'createSingleFileToolbarMount()', - `${directory}/code-view.html` - ) - } - } else { - for (const view of ['split', 'unified']) { - describe(`${startCase(view)} view`, () => { - const directory = `${__dirname}/__fixtures__/${version}/${page}/${extension}/${view}` - testCodeHost(`${directory}/page.html`) - describe('createFileActionsToolbarMount()', () => { - testMountGetter( - createFileActionsToolbarMount, - 'createFileActionsToolbarMount()', - `${directory}/code-view.html` - ) - }) - }) - } - } - }) - } - }) - } - }) - } - - describe('githubCodeHost.urlToFile()', () => { - const urlToFile = githubCodeHost.urlToFile! - const sourcegraphURL = 'https://sourcegraph.my.org' - - describe('on blob page', () => { - beforeAll(() => { - jsdom.reconfigure({ - url: - 'https://github.com/sourcegraph/sourcegraph/blob/master/browser/src/libs/code_intelligence/code_intelligence.tsx', - }) - }) - it('returns an URL to the Sourcegraph instance if the location has a viewState', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'github.com/sourcegraph/sourcegraph', - rev: 'master', - filePath: 'browser/src/libs/code_intelligence/code_intelligence.tsx', - position: { - line: 5, - character: 12, - }, - viewState: 'references', - }, - { part: undefined } - ) - ).toBe( - 'https://sourcegraph.my.org/sourcegraph/sourcegraph@master/-/blob/browser/src/libs/code_intelligence/code_intelligence.tsx#L5:12&tab=references' - ) - }) - - it('returns an absolute URL if the location is not on the same code host', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'ghe.sgdev.org/sourcegraph/sourcegraph', - rev: 'master', - filePath: 'browser/src/libs/code_intelligence/code_intelligence.tsx', - position: { - line: 5, - character: 12, - }, - }, - { part: undefined } - ) - ).toBe( - 'https://sourcegraph.my.org/sourcegraph/sourcegraph@master/-/blob/browser/src/libs/code_intelligence/code_intelligence.tsx#L5:12' - ) - }) - it('returns an URL to a blob on the same code host if possible', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'github.com/sourcegraph/sourcegraph', - rev: 'master', - filePath: 'browser/src/libs/code_intelligence/code_intelligence.tsx', - position: { - line: 5, - character: 12, - }, - }, - { part: undefined } - ) - ).toBe( - 'https://github.com/sourcegraph/sourcegraph/blob/master/browser/src/libs/code_intelligence/code_intelligence.tsx#L5:12' - ) - }) - }) - describe('on pull request page', () => { - beforeAll(async () => { - jsdom.reconfigure({ url: 'https://github.com/sourcegraph/sourcegraph/pull/3257/files' }) - document.documentElement.innerHTML = await readFile( - __dirname + '/__fixtures__/github.com/pull-request/vanilla/unified/page.html', - 'utf-8' - ) - }) - it('returns a URL to the same PR if possible', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'github.com/sourcegraph/sourcegraph', - rev: 'core/gitserver-tracing', - filePath: 'cmd/gitserver/server/server.go', - position: { - line: 1335, - character: 6, - }, - }, - { part: 'head' } - ) - ).toBe( - 'https://github.com/sourcegraph/sourcegraph/pull/3257/files#diff-93ceb95cf0be7b7b17815c5638fc4c5cR1335' - ) - }) - }) - }) -}) diff --git a/browser/src/libs/github/code_intelligence.ts b/browser/src/libs/github/code_intelligence.ts deleted file mode 100644 index 9fe90ea0b50b..000000000000 --- a/browser/src/libs/github/code_intelligence.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { AdjustmentDirection, PositionAdjuster } from '@sourcegraph/codeintellify' -import { trimStart } from 'lodash' -import { map } from 'rxjs/operators' -import { Omit } from 'utility-types' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { FileSpec, RepoSpec, ResolvedRevSpec, RevSpec } from '../../../../shared/src/util/url' -import { fetchBlobContentLines } from '../../shared/repo/backend' -import { querySelectorOrSelf } from '../../shared/util/dom' -import { toAbsoluteBlobURL } from '../../shared/util/url' -import { CodeHost, MountGetter } from '../code_intelligence' -import { CodeView, toCodeViewResolver } from '../code_intelligence/code_views' -import { NativeTooltip } from '../code_intelligence/native_tooltips' -import { getSelectionsFromHash, observeSelectionsFromHash } from '../code_intelligence/util/selections' -import { ViewResolver } from '../code_intelligence/views' -import { markdownBodyViewResolver } from './content_views' -import { diffDomFunctions, searchCodeSnippetDOMFunctions, singleFileDOMFunctions } from './dom_functions' -import { getCommandPaletteMount } from './extensions' -import { resolveDiffFileInfo, resolveFileInfo, resolveSnippetFileInfo } from './file_info' -import { commentTextFieldResolver } from './text_fields' -import { setElementTooltip } from './tooltip' -import { getFileContainers, parseURL } from './util' -import { NotificationType } from '../../../../shared/src/api/client/services/notifications' - -/** - * Creates the mount element for the CodeViewToolbar on code views containing - * a `.file-actions` element, for instance: - * - A diff code view on a PR's files page, or a commit page - * - An older GHE single file code view (newer GitHub.com code views use createFileLineContainerToolbarMount) - */ -export function createFileActionsToolbarMount(codeView: HTMLElement): HTMLElement { - const className = 'github-file-actions-toolbar-mount' - const existingMount = codeView.querySelector('.' + className) as HTMLElement - if (existingMount) { - return existingMount - } - - const mountEl = document.createElement('div') - mountEl.className = className - - const fileActions = codeView.querySelector('.file-actions') - if (!fileActions) { - throw new Error('Could not find GitHub file actions with selector .file-actions') - } - - // Add a class to the .file-actions element, so that we can reliably match it in - // stylesheets without bleeding CSS to other code hosts (GitLab also uses .file-actions elements). - fileActions.classList.add('sg-github-file-actions') - - // Old GitHub Enterprise PR views have a "โ˜‘ show comments" text that we want to insert *after* - const showCommentsElement = codeView.querySelector('.show-file-notes') - if (showCommentsElement) { - showCommentsElement.insertAdjacentElement('afterend', mountEl) - } else { - fileActions.prepend(mountEl) - } - - return mountEl -} - -const toolbarButtonProps = { - className: 'btn btn-sm tooltipped tooltipped-s', -} - -const diffCodeView: Omit = { - dom: diffDomFunctions, - getToolbarMount: createFileActionsToolbarMount, - resolveFileInfo: resolveDiffFileInfo, - toolbarButtonProps, - getScrollBoundaries: codeView => { - const fileHeader = codeView.querySelector('.file-header') - if (!fileHeader) { - throw new Error('Could not find .file-header element in GitHub PR code view') - } - return [fileHeader] - }, -} - -const diffConversationCodeView: Omit = { - ...diffCodeView, - getToolbarMount: undefined, -} - -const singleFileCodeView: Omit = { - dom: singleFileDOMFunctions, - getToolbarMount: createFileActionsToolbarMount, - resolveFileInfo, - toolbarButtonProps, - getSelections: getSelectionsFromHash, - observeSelections: observeSelectionsFromHash, -} - -/** - * Some code snippets get leading white space trimmed. This adjusts based on - * this. See an example here https://github.com/sourcegraph/browser-extensions/issues/188. - */ -const getSnippetPositionAdjuster = ( - requestGraphQL: PlatformContext['requestGraphQL'] -): PositionAdjuster => ({ direction, codeView, position }) => - fetchBlobContentLines({ ...position, requestGraphQL }).pipe( - map(lines => { - const codeElement = singleFileDOMFunctions.getCodeElementFromLineNumber( - codeView, - position.line, - position.part - ) - if (!codeElement) { - throw new Error('(adjustPosition) could not find code element for line provided') - } - - const actualLine = lines[position.line - 1] - const documentLine = codeElement.textContent || '' - - const actualLeadingWhiteSpace = actualLine.length - trimStart(actualLine).length - const documentLeadingWhiteSpace = documentLine.length - trimStart(documentLine).length - - const modifier = direction === AdjustmentDirection.ActualToCodeView ? -1 : 1 - const delta = Math.abs(actualLeadingWhiteSpace - documentLeadingWhiteSpace) * modifier - - return { - line: position.line, - character: position.character + delta, - } - }) - ) - -const searchResultCodeViewResolver = toCodeViewResolver('.code-list-item', { - dom: searchCodeSnippetDOMFunctions, - getPositionAdjuster: getSnippetPositionAdjuster, - resolveFileInfo: resolveSnippetFileInfo, - toolbarButtonProps, -}) - -const snippetCodeView: Omit = { - dom: singleFileDOMFunctions, - resolveFileInfo: resolveSnippetFileInfo, - getPositionAdjuster: getSnippetPositionAdjuster, -} - -export const createFileLineContainerToolbarMount: NonNullable = ( - codeViewElement: HTMLElement -): HTMLElement => { - const className = 'sourcegraph-github-file-code-view-toolbar-mount' - const existingMount = codeViewElement.querySelector(`.${className}`) as HTMLElement - if (existingMount) { - return existingMount - } - const mountEl = document.createElement('div') - mountEl.style.display = 'inline-flex' - mountEl.style.verticalAlign = 'middle' - mountEl.style.alignItems = 'center' - mountEl.className = className - const rawURLLink = codeViewElement.querySelector('#raw-url') - const buttonGroup = rawURLLink?.closest('.BtnGroup') - if (!buttonGroup?.parentNode) { - throw new Error('File actions not found') - } - buttonGroup.parentNode.insertBefore(mountEl, buttonGroup) - return mountEl -} - -/** - * Matches the modern single-file code view, or snippets embedded in comments. - * - */ -export const fileLineContainerResolver: ViewResolver = { - selector: '.js-file-line-container', - resolveView: (fileLineContainer: HTMLElement): CodeView | null => { - const embeddedBlobWrapper = fileLineContainer.closest('.blob-wrapper-embedded') - if (embeddedBlobWrapper) { - // This is a snippet embedded in a comment. - // Resolve to `.blob-wrapper-embedded`'s parent element, - // the smallest element that contains both the code and - // the HTML anchor allowing to resolve the file info. - const element = embeddedBlobWrapper.parentElement! - return { - element, - ...snippetCodeView, - } - } - const { pageType } = parseURL() - if (pageType !== 'blob') { - // this is not a single-file code view - return null - } - const repositoryContent = fileLineContainer.closest('.repository-content') - if (!repositoryContent) { - throw new Error('Could not find repository content element') - } - return { - element: repositoryContent as HTMLElement, - ...singleFileCodeView, - getToolbarMount: createFileLineContainerToolbarMount, - } - }, -} - -const genericCodeViewResolver: ViewResolver = { - selector: '.file', - resolveView: (elem: HTMLElement): CodeView | null => { - if (elem.querySelector('article.markdown-body')) { - // This code view is rendered markdown, we shouldn't add code intelligence - return null - } - - // This is a suggested change on a GitHub PR - if (elem.closest('.js-suggested-changes-blob')) { - return null - } - - const { pageType } = parseURL() - const isSingleCodeFile = - pageType === 'blob' && - document.getElementsByClassName('file').length === 1 && - document.getElementsByClassName('diff-view').length === 0 - - if (isSingleCodeFile) { - return { element: elem, ...singleFileCodeView } - } - - if (elem.closest('.discussion-item-body') || elem.classList.contains('js-comment-container')) { - // This code view is embedded on a PR conversation page. - return { element: elem, ...diffConversationCodeView } - } - - return { element: elem, ...diffCodeView } - }, -} - -/** - * Returns true if the current page is GitHub Enterprise. - */ -export function checkIsGitHubEnterprise(): boolean { - const ogSiteName = document.head.querySelector('meta[property="og:site_name"]') - return ( - !!ogSiteName && - // GitHub Enterprise v2.14.11 has "GitHub" as og:site_name - (ogSiteName.content === 'GitHub Enterprise' || ogSiteName.content === 'GitHub') && - document.body.classList.contains('enterprise') - ) -} - -/** - * Returns true if the current page is github.com. - */ -export const checkIsGitHubDotCom = (): boolean => /^https?:\/\/(www.)?github.com/.test(window.location.href) - -/** - * Returns true if the current page is either github.com or GitHub Enterprise. - */ -export const checkIsGitHub = (): boolean => checkIsGitHubDotCom() || checkIsGitHubEnterprise() - -const OPEN_ON_SOURCEGRAPH_ID = 'open-on-sourcegraph' - -export const createOpenOnSourcegraphIfNotExists: MountGetter = (container: HTMLElement): HTMLElement | null => { - const pageheadActions = querySelectorOrSelf(container, '.pagehead-actions') - // If ran on page that isn't under a repository namespace. - if (!pageheadActions || pageheadActions.children.length === 0) { - return null - } - // Check for existing - let mount = pageheadActions.querySelector('#' + OPEN_ON_SOURCEGRAPH_ID) - if (mount) { - return mount - } - // Create new - mount = document.createElement('li') - mount.id = OPEN_ON_SOURCEGRAPH_ID - pageheadActions.insertAdjacentElement('afterbegin', mount) - return mount -} - -const nativeTooltipResolver: ViewResolver = { - selector: '.js-tagsearch-popover', - resolveView: element => ({ element }), -} - -const iconClassName = 'action-item__icon--github v-align-text-bottom' - -export const githubCodeHost: CodeHost = { - type: 'github', - name: checkIsGitHubEnterprise() ? 'GitHub Enterprise' : 'GitHub', - codeViewResolvers: [genericCodeViewResolver, fileLineContainerResolver, searchResultCodeViewResolver], - contentViewResolvers: [markdownBodyViewResolver], - textFieldResolvers: [commentTextFieldResolver], - nativeTooltipResolvers: [nativeTooltipResolver], - getContext: () => { - const repoHeaderHasPrivateMarker = !!document.querySelector('.repohead .private') - const parsedURL = parseURL() - return { - ...parsedURL, - rev: parsedURL.pageType === 'blob' || parsedURL.pageType === 'tree' ? resolveFileInfo().rev : undefined, - privateRepository: window.location.hostname !== 'github.com' || repoHeaderHasPrivateMarker, - } - }, - getViewContextOnSourcegraphMount: createOpenOnSourcegraphIfNotExists, - viewOnSourcegraphButtonClassProps: { - className: 'btn btn-sm tooltipped tooltipped-s', - iconClassName, - }, - check: checkIsGitHub, - getCommandPaletteMount, - notificationClassNames: { - [NotificationType.Log]: 'flash', - [NotificationType.Success]: 'flash flash-success', - [NotificationType.Info]: 'flash', - [NotificationType.Warning]: 'flash flash-warn', - [NotificationType.Error]: 'flash flash-error', - }, - commandPaletteClassProps: { - buttonClassName: 'Header-link', - popoverClassName: 'Box', - formClassName: 'p-1', - inputClassName: 'form-control input-sm header-search-input jump-to-field', - listClassName: 'p-0 m-0 js-navigation-container jump-to-suggestions-results-container', - selectedListItemClassName: 'navigation-focus', - listItemClassName: - 'd-flex flex-justify-start flex-items-center p-0 f5 navigation-item js-navigation-item js-jump-to-scoped-search', - actionItemClassName: - 'command-palette-action-item--github no-underline d-flex flex-auto flex-items-center jump-to-suggestions-path p-2', - noResultsClassName: 'd-flex flex-auto flex-items-center jump-to-suggestions-path p-2', - iconClassName, - }, - codeViewToolbarClassProps: { - className: 'code-view-toolbar--github', - listItemClass: 'code-view-toolbar__item--github BtnGroup', - actionItemClass: 'btn btn-sm tooltipped tooltipped-s BtnGroup-item action-item--github', - actionItemPressedClass: 'selected', - actionItemIconClass: 'action-item__icon--github v-align-text-bottom', - }, - completionWidgetClassProps: { - widgetClassName: 'suggester-container', - widgetContainerClassName: 'suggester', - listClassName: 'suggestions', - selectedListItemClassName: 'navigation-focus', - listItemClassName: 'text-normal', - }, - hoverOverlayClassProps: { - className: 'Box', - actionItemClassName: 'btn btn-secondary', - actionItemPressedClassName: 'active', - closeButtonClassName: 'btn', - infoAlertClassName: 'flash flash-full', - errorAlertClassName: 'flash flash-full flash-error', - iconClassName, - }, - setElementTooltip, - linkPreviewContentClass: 'text-small text-gray p-1 mx-1 border rounded-1 bg-gray text-gray-dark', - urlToFile: (sourcegraphURL, target, context) => { - if (target.viewState) { - // A view state means that a panel must be shown, and panels are currently only supported on - // Sourcegraph (not code hosts). - return toAbsoluteBlobURL(sourcegraphURL, target) - } - - // Make sure the location is also on this github instance, return an absolute URL otherwise. - const sameCodeHost = target.rawRepoName.startsWith(window.location.hostname) - if (!sameCodeHost) { - return toAbsoluteBlobURL(sourcegraphURL, target) - } - - const rev = target.rev || 'HEAD' - // If we're provided options, we can make the j2d URL more specific. - const { rawRepoName } = parseURL() - - // Stay on same page in PR if possible. - // TODO to be entirely correct, this would need to compare the rev of the code view with the target rev. - const isSameRepo = rawRepoName === target.rawRepoName - if (isSameRepo && context.part !== undefined) { - const containers = getFileContainers() - for (const container of containers) { - const header = container.querySelector( - '.file-header[data-path][data-anchor]' - ) - if (!header) { - // E.g. suggestion snippet - continue - } - const anchorPath = header.dataset.path - if (anchorPath === target.filePath) { - const anchorUrl = header.dataset.anchor - const url = new URL(window.location.href) - url.hash = anchorUrl - if (target.position) { - // GitHub uses L for the left side, R for both right side and the unchanged/white parts - url.hash += `${context.part === 'base' ? 'L' : 'R'}${target.position.line}` - } - // Only use URL if it is visible - // TODO: Expand hidden lines to reveal - if (!document.querySelector(url.hash)) { - break - } - return url.href - } - } - } - - // Go to blob URL - const fragment = target.position - ? `#L${target.position.line}${target.position.character ? ':' + target.position.character : ''}` - : '' - return `https://${target.rawRepoName}/blob/${rev}/${target.filePath}${fragment}` - }, - codeViewsRequireTokenization: true, -} diff --git a/browser/src/libs/github/content_views.ts b/browser/src/libs/github/content_views.ts deleted file mode 100644 index b90fc60298f6..000000000000 --- a/browser/src/libs/github/content_views.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ContentView } from '../code_intelligence/content_views' -import { ViewResolver } from '../code_intelligence/views' - -/** - * Matches all GitHub Markdown body content, including comment bodies, issue/PR descriptions, review - * comments, and rendered Markdown files. - */ -export const markdownBodyViewResolver: ViewResolver = { - selector: '.markdown-body', - resolveView: element => ({ element }), -} diff --git a/browser/src/libs/github/dom_functions.test.ts b/browser/src/libs/github/dom_functions.test.ts deleted file mode 100644 index 7493b0bf50aa..000000000000 --- a/browser/src/libs/github/dom_functions.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { startCase } from 'lodash' -import { Omit } from 'utility-types' -import { DOMFunctionsTest, getFixtureBody, testDOMFunctions } from '../code_intelligence/code_intelligence_test_utils' -import { diffDomFunctions, isDomSplitDiff, singleFileDOMFunctions } from './dom_functions' - -type GitHubVersion = 'github.com' | 'ghe-2.14.11' - -describe('GitHub DOM functions', () => { - describe('diffDomFunctions', () => { - type GitHubDiffPage = 'pull-request' | 'pull-request-discussion' | 'commit' - - interface GitHubCodeViewFixture extends Omit {} - - const diffFixtures: Record> = { - 'ghe-2.14.11': { - commit: { - url: 'https://ghe.sgdev.org/beyang/mux/commit/1fddf523893b7475951631ed0f7e09edd9ce50d0', - lineCases: [ - { diffPart: 'head', lineNumber: 80, firstCharacterIsDiffIndicator: true }, // not changed - { diffPart: 'head', lineNumber: 82, firstCharacterIsDiffIndicator: true }, // added - { diffPart: 'base', lineNumber: 82, firstCharacterIsDiffIndicator: true }, // removed - ], - }, - 'pull-request': { - url: 'http://ghe.sgdev.org/beyang/mux/pull/1', - lineCases: [ - { diffPart: 'head', lineNumber: 63, firstCharacterIsDiffIndicator: true }, // not changed - { diffPart: 'head', lineNumber: 64, firstCharacterIsDiffIndicator: true }, // added - ], - }, - 'pull-request-discussion': { - url: 'http://ghe.sgdev.org/beyang/mux/pull/1', - lineCases: [ - { diffPart: 'head', lineNumber: 64, firstCharacterIsDiffIndicator: true }, // added - ], - }, - }, - 'github.com': { - commit: { - url: 'https://github.com/sourcegraph/sourcegraph/commit/d3d0fe7fad2c909e3a2e4de2259dc6604983a092', - lineCases: [ - { diffPart: 'head', lineNumber: 41 }, // not changed - { diffPart: 'base', lineNumber: 42 }, // removeed - { diffPart: 'head', lineNumber: 42 }, // added - ], - }, - 'pull-request': { - url: 'https://github.com/sourcegraph/sourcegraph/pull/3272/files', - lineCases: [ - { diffPart: 'head', lineNumber: 570 }, // not changed - { diffPart: 'base', lineNumber: 572 }, // removed - { diffPart: 'head', lineNumber: 572 }, // added - ], - }, - 'pull-request-discussion': { - url: 'https://github.com/sourcegraph/sourcegraph/pull/3221', - lineCases: [ - { diffPart: 'head', lineNumber: 13 }, // added - ], - }, - }, - } - for (const [version, pages] of Object.entries(diffFixtures)) { - describe(version, () => { - for (const [page, { lineCases, url }] of Object.entries(pages)) { - describe(`${startCase(page)} page`, () => { - for (const extension of ['vanilla', 'refined-github']) { - describe(startCase(extension), () => { - if (page === 'pull-request-discussion') { - const htmlFixturePath = `${__dirname}/__fixtures__/${version}/${page}/${extension}/code-view.html` - testDOMFunctions(diffDomFunctions, { - url, - htmlFixturePath, - lineCases, - }) - } else { - for (const view of ['split', 'unified']) { - const htmlFixturePath = `${__dirname}/__fixtures__/${version}/${page}/${extension}/${view}/code-view.html` - describe(`${startCase(view)} view`, () => { - testDOMFunctions(diffDomFunctions, { - url, - htmlFixturePath, - lineCases, - }) - }) - } - } - }) - } - }) - } - }) - } - }) - - describe('singleFileDOMFunctions', () => { - for (const version of ['github.com', 'ghe-2.14.11']) { - describe(version, () => { - for (const extension of ['vanilla', 'refined-github']) { - describe(startCase(extension), () => { - const htmlFixturePath = `${__dirname}/__fixtures__/${version}/blob/${extension}/code-view.html` - testDOMFunctions(singleFileDOMFunctions, { - htmlFixturePath, - lineCases: [{ lineNumber: 1 }, { lineNumber: 2 }], - }) - }) - } - }) - } - }) - - describe('isDomSplitDiff()', () => { - for (const version of ['github.com', 'ghe-2.14.11']) { - describe(`Version ${version}`, () => { - const views = [ - { - view: 'pull-request', - url: 'https://github.com/sourcegraph/sourcegraph/pull/2672/files', - }, - { - view: 'commit', - url: - 'https://github.com/sourcegraph/sourcegraph/commit/2c74f329fd03008fa0b446cd5e53234715dae3dc', - }, - { - view: 'pull-request-discussion', - url: 'https://github.com/sourcegraph/sourcegraph/pull/2672/', - }, - ] - for (const { view, url } of views) { - describe(`${startCase(view)} page`, () => { - beforeEach(() => { - // TODO ideally DOM functions would not look at global state like the URL. - jsdom.reconfigure({ url }) - }) - for (const extension of ['vanilla', 'refined-github']) { - describe(startCase(extension), () => { - if (view === 'pull-request-discussion') { - it('should return false', async () => { - const codeViewElement = await getFixtureBody({ - htmlFixturePath: `${__dirname}/__fixtures__/${version}/${view}/${extension}/code-view.html`, - isFullDocument: false, - }) - expect(isDomSplitDiff(codeViewElement)).toBe(false) - }) - } else { - it('should return true for split view', async () => { - const codeViewElement = await getFixtureBody({ - htmlFixturePath: `${__dirname}/__fixtures__/${version}/${view}/${extension}/split/code-view.html`, - isFullDocument: false, - }) - expect(isDomSplitDiff(codeViewElement)).toBe(true) - }) - it('should return false for unified view', async () => { - const codeViewElement = await getFixtureBody({ - htmlFixturePath: `${__dirname}/__fixtures__/${version}/${view}/${extension}/unified/code-view.html`, - isFullDocument: false, - }) - expect(isDomSplitDiff(codeViewElement)).toBe(false) - }) - } - }) - } - }) - } - }) - } - }) -}) diff --git a/browser/src/libs/github/dom_functions.ts b/browser/src/libs/github/dom_functions.ts deleted file mode 100644 index a259fbba093c..000000000000 --- a/browser/src/libs/github/dom_functions.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { DiffPart } from '@sourcegraph/codeintellify' -import { DOMFunctions } from '../code_intelligence/code_views' -import { isDiffPageType, parseURL } from './util' - -const getDiffCodePart = (codeElement: HTMLElement): DiffPart => { - const td = codeElement.closest('td')! - - if (td.classList.contains('blob-code-addition')) { - return 'head' - } - - if (td.classList.contains('blob-code-deletion')) { - return 'base' - } - // If we can't determine the diff part the code element's parent `` - // (which may be because it is unchanged, or because the .blob-code(addition|deletion) classes - // aren't present), call `isSplitDomDiff()`, which will look at the parent - // code view to determine whether this is a split or unified diff view. - if (isDomSplitDiff(codeElement)) { - // If there are more cells on the right, this is the base, otherwise the head - return td.nextElementSibling ? 'base' : 'head' - } - - return 'head' -} - -/** - * Returns the 0-based index of the cell that holds the line number for a given part, - * depending on whether the diff is in unified or split view. - * Prefers head. - */ -const getLineNumberElementIndex = (part: DiffPart, isSplitDiff: boolean): number => { - if (part === 'base') { - // base line number is always the first child - return 0 - } - return isSplitDiff ? 2 : 1 -} - -/** - * Gets the line number for a given code element on unified diff, split diff and blob views - */ -const getLineNumberFromCodeElement = (codeElement: HTMLElement): number => { - // In diff views, the code element is the `` inside the cell - // On blob views, the code element is the `` itself, so `closest()` will simply return it - // Walk all previous sibling cells until we find one with the line number - let cell = codeElement.closest('td')!.previousElementSibling as HTMLTableCellElement - while (cell) { - if (cell.dataset.lineNumber) { - return parseInt(cell.dataset.lineNumber, 10) - } - cell = cell.previousElementSibling as HTMLTableCellElement - } - throw new Error('Could not find a line number in any cell') -} - -/** - * Gets the `` element for a target that contains the code - */ -const getCodeCellFromTarget = (target: HTMLElement): HTMLTableCellElement | null => { - const cell = target.closest('td.blob-code') - // Handle rows with the [ โ†• ] button that expands collapsed unchanged lines - if (!cell || cell.parentElement?.classList.contains('js-expandable-line')) { - return null - } - return cell -} - -/** - * Returns the `` containing the code (which may contain a `.blob-code-inner`) - */ -const getDiffCodeCellFromLineNumber = ( - codeView: HTMLElement, - line: number, - part?: DiffPart -): HTMLTableCellElement | null => { - if (codeView.querySelector('.js-diff-load-container')) { - // Diff is collapsed - return null - } - const isSplitDiff = isDomSplitDiff(codeView) - const nthChild = getLineNumberElementIndex(part!, isSplitDiff) + 1 // nth-child() is 1-indexed - const lineNumberCell = codeView.querySelector( - `td:nth-child(${nthChild})[data-line-number="${line}"]` - ) - if (!lineNumberCell) { - return null - } - // In unified diff, the not-changed lines shall only be returned for the head. - // Without this check they would be returned for both head and base. - if ( - !isSplitDiff && - part === 'base' && - !lineNumberCell.classList.contains('blob-num-addition') && - !lineNumberCell.classList.contains('blob-num-deletion') - ) { - return null - } - let codeCell: HTMLTableCellElement - if (isSplitDiff) { - // In split diff view, the code cell is next to the line number cell - codeCell = lineNumberCell.nextElementSibling as HTMLTableCellElement - } else { - // In unified diff view, the code cell is the last cell - const row = lineNumberCell.parentElement as HTMLTableRowElement - codeCell = row.lastElementChild as HTMLTableCellElement - } - return codeCell -} - -/** - * Returns the `` element inside a cell. - * The code element on diff pages is the `` element inside the cell, - * because the cell also contains a button to add a comment - */ -const getBlobCodeInner = (codeCell: HTMLTableCellElement): HTMLElement => - codeCell.classList.contains('blob-code-inner') - ? codeCell // ``'s in sections of the table that were expanded are not commentable so the `.blob-code-inner` element is the `` - : (codeCell.querySelector('.blob-code-inner') as HTMLElement) - -/** - * Implementations of the DOM functions for GitHub diff code views - */ -export const diffDomFunctions: DOMFunctions = { - getCodeElementFromTarget: target => { - const codeCell = getCodeCellFromTarget(target) - return codeCell && getBlobCodeInner(codeCell) - }, - getLineElementFromLineNumber: getDiffCodeCellFromLineNumber, - getCodeElementFromLineNumber: (codeView, line, part) => { - const codeCell = getDiffCodeCellFromLineNumber(codeView, line, part) - return codeCell && getBlobCodeInner(codeCell) - }, - getLineNumberFromCodeElement, - getDiffCodePart, - isFirstCharacterDiffIndicator: codeElement => { - // Old versions of GitHub write a +, -, or space character directly into - // the HTML text of the diff: - // - // + fmt.Println... - // ^ - // - // New versions of GitHub do not, and Refined GitHub used to strip these - // characters. - // - // Since a +, -, or space character in the first column could be either - // - // - a diff indicator on an old version of GitHub, or - // - simply part of the code being diffed on either a new version of - // GitHub or Refined GitHub, - // - // we check for the presence of other diff indicators that we know are - // mutually exclusive with the first character diff indicator. - - // Some versions of GitHub have blob-code-marker-* classes instead of the first character diff indicator. - const blobCodeInner = codeElement.closest('.blob-code-inner') - const hasBlobCodeMarker = - blobCodeInner && - ['deletion', 'context', 'addition'].some(name => - blobCodeInner.classList.contains('blob-code-marker-' + name) - ) - - // Some versions of GitHub have data-code-marker attributes instead of the first character diff indicator. - const tr = codeElement.closest('tr') - const hasDataCodeMarkerUnified = tr?.querySelector('td[data-code-marker]') - const hasDataCodeMarkerSplit = blobCodeInner?.hasAttribute('data-code-marker') - const hasDataCodeMarker = hasDataCodeMarkerUnified || hasDataCodeMarkerSplit - - // Refined GitHub used to strip the first character diff indicator. - const hasRefinedGitHub = codeElement.closest('.refined-github-diff-signs') - - // When no other diff indicator is found, we assume the first character - // is a diff indicator. - return !hasBlobCodeMarker && !hasDataCodeMarker && !hasRefinedGitHub - }, -} - -const getSingleFileCodeElementFromLineNumber = (codeView: HTMLElement, line: number): HTMLElement | null => { - const lineNumberCell = codeView.querySelector(`td[data-line-number="${line}"]`) - // In blob views, the `` is the code element - return lineNumberCell && (lineNumberCell.nextElementSibling as HTMLTableCellElement) -} - -/** - * Implementations of the DOM functions for GitHub blob code views - */ -export const singleFileDOMFunctions: DOMFunctions = { - getCodeElementFromTarget: getCodeCellFromTarget, - getCodeElementFromLineNumber: getSingleFileCodeElementFromLineNumber, - getLineElementFromLineNumber: getSingleFileCodeElementFromLineNumber, - getLineNumberFromCodeElement, -} - -const getSearchCodeSnippetLineNumberCellFromLineNumber = (codeView: HTMLElement, line: number): HTMLElement | null => { - const lineNumberCells = codeView.querySelectorAll('td.blob-num') - let lineNumberCell: HTMLTableCellElement | null = null - for (const cell of lineNumberCells) { - const a = cell.querySelector('a')! - if (a.href.endsWith(`#L${line}`)) { - lineNumberCell = cell as HTMLTableCellElement - break - } - } - return lineNumberCell -} - -const getSearchCodeSnippetCodeElementFromLineNumber = (codeView: HTMLElement, line: number): HTMLElement | null => { - const lineNumberCell = getSearchCodeSnippetLineNumberCellFromLineNumber(codeView, line) - // In search snippet views, the `` is the code element - return lineNumberCell && (lineNumberCell.nextElementSibling as HTMLTableCellElement) -} - -export const searchCodeSnippetDOMFunctions: DOMFunctions = { - getCodeElementFromTarget: getCodeCellFromTarget, - getCodeElementFromLineNumber: getSearchCodeSnippetCodeElementFromLineNumber, - getLineElementFromLineNumber: getSearchCodeSnippetCodeElementFromLineNumber, - getLineNumberFromCodeElement: (codeElement: HTMLElement): number => { - const cell = codeElement.closest('td')!.previousElementSibling as HTMLTableCellElement - return parseInt(cell.firstElementChild!.textContent!, 10) - }, -} - -/** - * Returns if the current view shows diffs with split (vs. unified) view. - * - * @param element, either an element contained in a code view or the code view itself - */ -export function isDomSplitDiff(element: HTMLElement): boolean { - const { pageType } = parseURL() - if (!isDiffPageType(pageType)) { - return false - } - const codeView = element.classList.contains('file') ? element : element.closest('.file') - if (!codeView) { - throw new Error('Could not resolve code view element') - } - if (codeView.classList.contains('js-comment-container')) { - // Commented snippet in PR discussion - return false - } - const codeViewTable = codeView.querySelector('table') - if (!codeViewTable) { - throw new Error('Could not find code view table') - } - return codeViewTable.classList.contains('js-file-diff-split') || codeViewTable.classList.contains('file-diff-split') -} diff --git a/browser/src/libs/github/extensions.tsx b/browser/src/libs/github/extensions.tsx deleted file mode 100644 index e5124dc880e6..000000000000 --- a/browser/src/libs/github/extensions.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { querySelectorOrSelf } from '../../shared/util/dom' -import { MountGetter } from '../code_intelligence' - -export const getCommandPaletteMount: MountGetter = (container: HTMLElement): HTMLElement | null => { - const className = 'command-palette-button' - // This selector matches both GitHub Enterprise and github.com - const existing = container.querySelector(`.Header .${className}`) - if (existing) { - return existing - } - // Legacy header (GitHub Enterprise) - const gheHeaderElement = querySelectorOrSelf(container, '.HeaderMenu > :last-child') - if (gheHeaderElement) { - const mount = document.createElement('div') - mount.classList.add(className) - gheHeaderElement.insertAdjacentElement('afterbegin', mount) - return mount - } - // github.com doesn't use HeaderMenu to wrap the right-hand-side menu anymore, - // it has a flatter DOM structure - // Instead of finding the parent to insert into, find the sibling to insert next to - let rightNeighbor = querySelectorOrSelf(container, '.Header-item:nth-last-child(2)') - if (rightNeighbor) { - // Caveat: there is no noticiations icon if web notifications are disabled, - // but the empty header item is still there - if (rightNeighbor.previousElementSibling!.children.length !== 0) { - rightNeighbor = rightNeighbor.previousElementSibling! - } - const mount = document.createElement('div') - mount.classList.add('Header-item', 'mr-0', 'mr-lg-3', className) - rightNeighbor.insertAdjacentElement('beforebegin', mount) - return mount - } - return null -} diff --git a/browser/src/libs/github/file_info.test.ts b/browser/src/libs/github/file_info.test.ts deleted file mode 100644 index d7c8890582ad..000000000000 --- a/browser/src/libs/github/file_info.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readFile } from 'mz/fs' -import { getFilePath } from './util' - -const tests = [ - ['github.com/blob/vanilla/page.html', 'shared/src/api/extension/types/url.ts'], - ['github.com/blob/refined-github/page.html', 'shared/src/api/extension/types/url.ts'], - ['ghe-2.14.11/blob/vanilla/page.html', 'bench_test.go'], - ['ghe-2.14.11/blob/refined-github/page.html', 'bench_test.go'], -] - -describe('github/file_info', () => { - describe('getFilePath()', () => { - for (const [fixture, expectedFilePath] of tests) { - it(`finds the file path in ${fixture}`, async () => { - document.body.innerHTML = await readFile(`${__dirname}/__fixtures__/${fixture}`, 'utf-8') - expect(getFilePath()).toBe(expectedFilePath) - }) - } - }) -}) diff --git a/browser/src/libs/github/file_info.ts b/browser/src/libs/github/file_info.ts deleted file mode 100644 index 57ff1415aaad..000000000000 --- a/browser/src/libs/github/file_info.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { FileInfo } from '../code_intelligence' -import { getCommitIDFromPermalink } from './scrape' -import { getDiffFileName, getDiffResolvedRev, getFilePath, parseURL } from './util' - -export const resolveDiffFileInfo = (codeView: HTMLElement): FileInfo => { - const { rawRepoName } = parseURL() - const { headFilePath, baseFilePath } = getDiffFileName(codeView) - if (!headFilePath) { - throw new Error('cannot determine file path') - } - const diffResolvedRev = getDiffResolvedRev(codeView) - if (!diffResolvedRev) { - throw new Error('cannot determine delta info') - } - const { headCommitID, baseCommitID } = diffResolvedRev - return { - rawRepoName, - filePath: headFilePath, - commitID: headCommitID, - rev: headCommitID, - baseRawRepoName: rawRepoName, - baseFilePath, - baseCommitID, - baseRev: baseCommitID, - } -} - -export const resolveFileInfo = (): FileInfo => { - const parsedURL = parseURL() - if (parsedURL.pageType !== 'blob' && parsedURL.pageType !== 'tree') { - throw new Error(`Current URL does not match a blob or tree url: ${window.location.href}`) - } - const { revAndFilePath, rawRepoName } = parsedURL - - const filePath = getFilePath() - const filePathWithLeadingSlash = filePath.startsWith('/') ? filePath : `/${filePath}` - if (!revAndFilePath.endsWith(filePathWithLeadingSlash)) { - throw new Error( - `The file path ${filePathWithLeadingSlash} should always be a suffix of revAndFilePath ${revAndFilePath}, but isn't in this case.` - ) - } - return { - rawRepoName, - filePath, - commitID: getCommitIDFromPermalink(), - rev: revAndFilePath.slice(0, -filePathWithLeadingSlash.length), - } -} - -const COMMIT_HASH_REGEX = /\/([0-9a-f]{40})$/i - -export const resolveSnippetFileInfo = (codeView: HTMLElement): FileInfo => { - // A snippet code view contains a link to the snippet's commit. - // We use it to find the 40-character commit id. - const commitLinkElement = codeView.querySelector('a.commit-tease-sha') as HTMLAnchorElement - if (!commitLinkElement) { - throw new Error('Could not find commit link in snippet code view') - } - const commitIDMatch = commitLinkElement.href.match(COMMIT_HASH_REGEX) - if (!commitIDMatch?.[1]) { - throw new Error(`Could not parse commitID from snippet commit link href: ${commitLinkElement.href}`) - } - const commitID = commitIDMatch[1] - - // We then use the permalink to determine the repo name and parse the filePath. - const selector = 'a:not(.commit-tease-sha)' - const anchors = codeView.querySelectorAll(selector) - const snippetPermalinkURL = new URL((anchors[0] as HTMLAnchorElement).href) - const parsedURL = parseURL(snippetPermalinkURL) - if (parsedURL.pageType !== 'blob') { - throw new Error(`Snippet URL does not match a blob url: ${snippetPermalinkURL.href}`) - } - const { revAndFilePath, rawRepoName } = parsedURL - if (!revAndFilePath.startsWith(commitID)) { - throw new Error( - `Could not parse filePath: revAndFilePath ${revAndFilePath} does not start with commitID ${commitID}` - ) - } - const filePath = revAndFilePath.slice(commitID.length + 1) - return { - rawRepoName, - filePath, - commitID, - rev: commitID, - } -} diff --git a/browser/src/libs/github/scrape.ts b/browser/src/libs/github/scrape.ts deleted file mode 100644 index db465810afd6..000000000000 --- a/browser/src/libs/github/scrape.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { commitIDFromPermalink } from '../../shared/util/dom' - -/** - * Get the commit ID from the permalink element on the page. - */ -export function getCommitIDFromPermalink(): string { - return commitIDFromPermalink({ - selector: '.js-permalink-shortcut', - hrefRegex: /^\/.*?\/.*?\/(?:blob|tree)\/([0-9a-f]{40})/, - }) -} diff --git a/browser/src/libs/github/style.scss b/browser/src/libs/github/style.scss deleted file mode 100644 index 4643a545187b..000000000000 --- a/browser/src/libs/github/style.scss +++ /dev/null @@ -1,63 +0,0 @@ -.command-palette-action-item--github { - // Reset GitHub's 44px min-height - min-height: initial; -} - -.action-item--github { - // Match GitHub's button height even if button only contains icon - // (no text that would push the height) - // stylelint-disable-next-line declaration-property-unit-whitelist - height: 28px; -} - -.action-item__icon--github { - height: 16px; -} - -.code-view-toolbar--github { - margin-right: 4px; - margin-bottom: -4px; - text-align: right; -} - -.code-view-toolbar__item--github { - // The space provides enough margin - margin-left: 0 !important; - margin-bottom: 4px; -} - -// Blob view -// Make sure only our code view toolbar shrinks and wraps, -// not GitHub's UI groups -.repository-content { - .Box-header { - > .text-mono { - // only let Sourcegraph toolbar shrink - flex-shrink: 0 !important; - } - > div:nth-child(2) { - > div:not(.sourcegraph-github-file-code-view-toolbar-mount) { - // only let Sourcegraph toolbar shrink - flex-shrink: 0; - display: flex; - align-items: center; - } - } - } -} - -// Diff views -.diff-view { - .file-header { - .file-info { - flex: 0 2 auto !important; - } - .file-actions { - flex: 1 1 50%; - margin-left: 1rem; - display: flex; - align-items: center; - justify-content: flex-end; - } - } -} diff --git a/browser/src/libs/github/text_fields.ts b/browser/src/libs/github/text_fields.ts deleted file mode 100644 index 85644ec93bf7..000000000000 --- a/browser/src/libs/github/text_fields.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TextField } from '../code_intelligence/text_fields' -import { ViewResolver } from '../code_intelligence/views' - -export const commentTextFieldResolver: ViewResolver = { - selector: '.comment-form-textarea', - resolveView: element => { - if (!(element instanceof HTMLTextAreaElement)) { - return null - } - return { element } - }, -} diff --git a/browser/src/libs/github/util.test.ts b/browser/src/libs/github/util.test.ts deleted file mode 100644 index ec22fd84021c..000000000000 --- a/browser/src/libs/github/util.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { startCase } from 'lodash' -import { parseURL, getDiffFileName } from './util' -import { getFixtureBody } from '../code_intelligence/code_intelligence_test_utils' - -describe('util', () => { - describe('parseURL()', () => { - const testcases: { - name: string - url: string - }[] = [ - { - name: 'tree page', - url: 'https://github.com/sourcegraph/sourcegraph/tree/master/client', - }, - { - name: 'blob page', - url: 'https://github.com/sourcegraph/sourcegraph/blob/3.3/shared/src/hover/HoverOverlay.tsx', - }, - { - name: 'commit page', - url: 'https://github.com/sourcegraph/sourcegraph/commit/fb054666c12b40f180c794db4829cbfd1a5fabae', - }, - { - name: 'pull request page', - url: 'https://github.com/sourcegraph/sourcegraph/pull/3849', - }, - { - name: 'compare page', - url: - 'https://github.com/sourcegraph/sourcegraph-basic-code-intel/compare/new-extension-api-usage...fuzzy-locations', - }, - { - name: 'selections - single line', - url: 'https://github.com/sourcegraph/sourcegraph/blob/master/jest.config.base.js#L5', - }, - { - name: 'selections - range', - url: 'https://github.com/sourcegraph/sourcegraph/blob/master/jest.config.base.js#L5-L12', - }, - { - name: 'snippet permalink', - url: - 'https://github.com/sourcegraph/sourcegraph/blob/6a91ccec97a46bfb511b7ff58d790554a7d075c8/client/browser/src/shared/repo/backend.tsx#L128-L151', - }, - { - name: 'pull request list', - url: 'https://github.com/sourcegraph/sourcegraph/pulls', - }, - { - name: 'wiki page', - url: 'https://github.com/sourcegraph/sourcegraph/pulls', - }, - { - name: 'branch name with forward slashes', - url: 'http://ghe.sgdev.org/beyang/mux/blob/jr/branch/mux.go', - }, - ] - for (const { name, url } of testcases) { - test(name, () => { - expect(parseURL(new URL(url))).toMatchSnapshot() - }) - } - }) -}) - -type GithubVersion = 'github.com' | 'ghe-2.14.11' -type GithubDiffPage = 'commit' | 'pull-request' | 'pull-request-discussion' - -describe('getDiffFileName()', () => { - const tests: Record> = { - 'github.com': { - commit: 'doc/dev/incidents.md', - 'pull-request-discussion': 'web/src/regression/util/TestResourceManager.ts', - 'pull-request': 'packages/sourcegraph-extension-api/src/sourcegraph.d.ts', - }, - 'ghe-2.14.11': { - commit: 'mux.go', - 'pull-request': 'mux.go', - 'pull-request-discussion': 'mux.go', - }, - } - const testGetDeltaFilename = ({ - expectedFilePath, - htmlFixturePath, - }: { - expectedFilePath: string - htmlFixturePath: string - }) => { - test('extracts the filename', async () => { - const container = await getFixtureBody({ - htmlFixturePath, - isFullDocument: false, - }) - // TODO add examples with renamed files and check that getDeltaFilename() doesn't return - // identical headFilePath & baseFilePath - expect(getDiffFileName(container)).toStrictEqual({ - baseFilePath: expectedFilePath, - headFilePath: expectedFilePath, - }) - }) - } - for (const gitHubVersion of ['github.com', 'ghe-2.14.11'] as const) { - describe(gitHubVersion, () => { - for (const [gitHubDiffPage, expectedFilePath] of Object.entries(tests[gitHubVersion])) { - describe(`${startCase(gitHubDiffPage)} page`, () => { - for (const gitHubFlavor of ['vanilla', 'refined-github']) { - describe(startCase(gitHubFlavor), () => { - if (gitHubDiffPage === 'pull-request-discussion') { - testGetDeltaFilename({ - expectedFilePath, - htmlFixturePath: `${__dirname}/__fixtures__/${gitHubVersion}/${gitHubDiffPage}/${gitHubFlavor}/code-view.html`, - }) - } else { - for (const view of ['split', 'unified']) { - describe(`${view}`, () => { - testGetDeltaFilename({ - expectedFilePath, - htmlFixturePath: `${__dirname}/__fixtures__/${gitHubVersion}/${gitHubDiffPage}/${gitHubFlavor}/${view}/code-view.html`, - }) - }) - } - } - }) - } - }) - } - }) - } -}) diff --git a/browser/src/libs/github/util.tsx b/browser/src/libs/github/util.tsx deleted file mode 100644 index 44d323a2fdbd..000000000000 --- a/browser/src/libs/github/util.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { RawRepoSpec } from '../../../../shared/src/util/url' -import { DiffResolvedRevSpec } from '../../shared/repo' - -/** - * getFileContainers returns the elements on the page which should be marked - * up with tooltips & links: - * - * 1. blob view: a single file - * 2. commit view: one or more file diffs - * 3. PR conversation view: snippets with inline comments - * 4. PR unified/split view: one or more file diffs - */ -export function getFileContainers(): HTMLCollectionOf { - return document.getElementsByClassName('file') as HTMLCollectionOf -} - -/** - * Returns the path of the file container. It assumes - * the file container is for a diff (i.e. a commit or pull request view). - */ -export function getDiffFileName(container: HTMLElement): { headFilePath: string; baseFilePath?: string } { - const fileInfoElement = container.querySelector('.file-info') - if (fileInfoElement) { - if (fileInfoElement.tagName === 'A') { - // for PR conversation snippets on GHE, where the .file-info element - // is the link containing the file paths. - return getPathNamesFromElement(fileInfoElement) - } - // On commit code views, or code views on a PR's files tab, - // find the link contained in the .file-info element. - const link = fileInfoElement.querySelector('a') - if (link) { - return getPathNamesFromElement(link) - } - } - // If no file info element is present, the code view is probably a PR conversation snippet - // on github.com, where a link containing the file path can be found in the .file-header element. - const fileHeaderLink = container.querySelector('.file-header a') - if (fileHeaderLink) { - return getPathNamesFromElement(fileHeaderLink) - } - throw new Error('Could not determine diff file name') -} - -function getPathNamesFromElement(element: HTMLElement): { headFilePath: string; baseFilePath: string | undefined } { - const elements = element.title.split(' โ†’ ') - if (elements.length > 1) { - return { headFilePath: elements[1], baseFilePath: elements[0] } - } - return { headFilePath: elements[0], baseFilePath: elements[0] } -} - -/** - * getDiffResolvedRev returns the base and head revision SHA, or null for non-diff views. - */ -export function getDiffResolvedRev(codeView: HTMLElement): DiffResolvedRevSpec | null { - const { pageType } = parseURL() - if (!isDiffPageType(pageType)) { - return null - } - - let baseCommitID = '' - let headCommitID = '' - const fetchContainers = document.getElementsByClassName( - 'js-socket-channel js-updatable-content js-pull-refresh-on-pjax' - ) - const isCommentedSnippet = codeView.classList.contains('js-comment-container') - if (pageType === 'pull') { - if (fetchContainers && fetchContainers.length === 1) { - for (const el of fetchContainers) { - // for conversation view of pull request - const url = el.getAttribute('data-url') - if (!url) { - continue - } - const parsed = new URL(url, window.location.href) - baseCommitID = parsed.searchParams.get('base_commit_oid') || '' - headCommitID = parsed.searchParams.get('end_commit_oid') || '' - } - } else if (isCommentedSnippet) { - const resolvedDiffSpec = getResolvedDiffFromCommentedSnippet(codeView) - if (resolvedDiffSpec) { - return resolvedDiffSpec - } - } else { - // Last-ditch: look for inline comment form input which has base/head on it. - const baseInput = document.querySelector('input[name="comparison_start_oid"]') - if (baseInput) { - baseCommitID = (baseInput as HTMLInputElement).value - } - const headInput = document.querySelector('input[name="comparison_end_oid"]') - if (headInput) { - headCommitID = (headInput as HTMLInputElement).value - } - } - } else if (pageType === 'commit') { - // Refined GitHub adds a `.patch-diff-links` element - const shaContainers = document.querySelectorAll('.sha-block:not(.patch-diff-links)') - if (shaContainers && shaContainers.length === 2) { - const baseShaEl = shaContainers[0].querySelector('a') - if (baseShaEl) { - // e.g "https://github.com/gorilla/mux/commit/0b13a922203ebdbfd236c818efcd5ed46097d690" - baseCommitID = baseShaEl.href.split('/').slice(-1)[0] - } - const headShaEl = shaContainers[1].querySelector('span.sha') as HTMLElement - if (headShaEl) { - headCommitID = headShaEl.innerHTML - } - } - } else if (pageType === 'compare') { - const resolvedDiffSpec = getResolvedDiffForCompare() - if (resolvedDiffSpec) { - return resolvedDiffSpec - } - } - - if (baseCommitID === '' || headCommitID === '') { - return getDiffResolvedRevFromPageSource(document.documentElement.innerHTML, pageType === 'pull') - } - return { baseCommitID, headCommitID } -} - -// ".../files/(BASE..)?HEAD#diff-DIFF" -// https://github.com/sourcegraph/codeintellify/pull/77/files/e8ffee0c59e951d29bcc7cff7d58caff1c5c97c2..ce472adbfc6ac8ccf1bf7afbe71f18505ca994ec#diff-8a128e9e8a5a8bb9767f5f5392391217 -// https://github.com/lguychard/sourcegraph-configurable-references/pull/1/files/fa32ce95d666d73cf4cb3e13b547993374eb158d#diff-45327f86d4438556066de133327f4ca2 -const COMMENTED_SNIPPET_DIFF_REGEX = /\/files\/((\w+)\.\.)?(\w+)#diff-\w+$/ - -function getResolvedDiffFromCommentedSnippet(codeView: HTMLElement): DiffResolvedRevSpec | null { - // For commented snippets, try to get the HEAD commit ID from the file header, - // as it will always be the most accurate (for example in the case of outdated snippets). - const linkToFile: HTMLLinkElement | null = codeView.querySelector('.file-header a') - if (!linkToFile) { - return null - } - const match = linkToFile.href.match(COMMENTED_SNIPPET_DIFF_REGEX) - if (!match) { - return null - } - const headCommitID = match[3] - // The file header may not contain the base commit ID, so we get it from the page source. - const resolvedRevFromPageSource = getDiffResolvedRevFromPageSource(document.documentElement.innerHTML, true) - return headCommitID && resolvedRevFromPageSource - ? { - ...resolvedRevFromPageSource, - headCommitID, - } - : null -} - -function getResolvedDiffForCompare(): DiffResolvedRevSpec | undefined { - const branchElements = document.querySelectorAll('.commitish-suggester .select-menu-button span') - if (branchElements && branchElements.length === 2) { - return { baseCommitID: branchElements[0].innerText, headCommitID: branchElements[1].innerText } - } - return undefined -} - -function getDiffResolvedRevFromPageSource(pageSource: string, isPullRequest: boolean): DiffResolvedRevSpec | null { - if (!isPullRequest) { - return null - } - const baseShaComment = ' -
...
@@ -732,14 +732,21 @@ func TestInteractiveTerminal(t *testing.T) {
...
@@ -732,14 +732,21 @@ func TestInteractiveTerminal(t *testing.T) {
srv := httptest.NewServer(build.Session.Mux()) -
srv := httptest.NewServer(build.Session.Mux()) -
defer srv.Close() -
defer srv.Close() -
-
-
u := url.URL{Scheme: "ws", Host: srv.Listener.Addr().String(), Path: build.Session.Endpoint + "/exec"} -
u := url.URL{ -
conn, resp, err := websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": []string{build.Session.Token}}) -
Scheme: "ws", -
Host: srv.Listener.Addr().String(), -
Path: build.Session.Endpoint + "/exec", -
} -
headers := http.Header{ -
"Authorization": []string{build.Session.Token}, -
} -
conn, resp, err := websocket.DefaultDialer.Dial(u.String(), headers) -
assert.NoError(t, err) -
assert.NoError(t, err) -
assert.Equal(t, c.expectedStatusCode, resp.StatusCode) -
assert.Equal(t, c.expectedStatusCode, resp.StatusCode) -
-
-
defer func() { -
defer func() { -
if conn != nil { -
if conn != nil { -
defer conn.Close() -
conn.Close() -
} -
} -
}() -
}() -
-
-
...
...
diff --git a/browser/src/libs/gitlab/__fixtures__/code-views/merge-request/unified.html b/browser/src/libs/gitlab/__fixtures__/code-views/merge-request/unified.html deleted file mode 100644 index 09ca25feeb68..000000000000 --- a/browser/src/libs/gitlab/__fixtures__/code-views/merge-request/unified.html +++ /dev/null @@ -1,28 +0,0 @@ - -
...
...
@@ -732,14 +732,21 @@ func TestInteractiveTerminal(t *testing.T) {
srv := httptest.NewServer(build.Session.Mux()) -
defer srv.Close() -
-
u := url.URL{Scheme: "ws", Host: srv.Listener.Addr().String(), Path: build.Session.Endpoint + "/exec"} -
conn, resp, err := websocket.DefaultDialer.Dial(u.String(), http.Header{"Authorization": []string{build.Session.Token}}) -
u := url.URL{ -
Scheme: "ws", -
Host: srv.Listener.Addr().String(), -
Path: build.Session.Endpoint + "/exec", -
} -
headers := http.Header{ -
"Authorization": []string{build.Session.Token}, -
} -
conn, resp, err := websocket.DefaultDialer.Dial(u.String(), headers) -
assert.NoError(t, err) -
assert.Equal(t, c.expectedStatusCode, resp.StatusCode) -
-
defer func() { -
if conn != nil { -
defer conn.Close() -
conn.Close() -
} -
}() -
-
...
...
diff --git a/browser/src/libs/gitlab/__snapshots__/dom_functions.test.ts.snap b/browser/src/libs/gitlab/__snapshots__/dom_functions.test.ts.snap deleted file mode 100644 index 01c80ba27a4d..000000000000 --- a/browser/src/libs/gitlab/__snapshots__/dom_functions.test.ts.snap +++ /dev/null @@ -1,113 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Bitbucket DOM functions diffDOMFunctions Split view line number 733 in head diff part getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "defer srv.Close()", - "selector": "TR.line_holder.parallel:nth-child(3) > TD.line_content.parallel.right-side > SPAN.line", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Split view line number 733 in head diff part getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "defer srv.Close()", - "selector": "TR.line_holder.parallel:nth-child(3) > TD.line_content.parallel.right-side", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Split view line number 735 in base diff part getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "u := url.URL{Scheme: \\"ws\\", Host: srv.Listener.Addr().String(), Path: build.Session.Endpoint + \\"/exec\\"}", - "selector": "TD[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_735_735'].line_content.parallel.left-side.old > SPAN.line", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Split view line number 735 in base diff part getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "u := url.URL{Scheme: \\"ws\\", Host: srv.Listener.Addr().String(), Path: build.Session.Endpoint + \\"/exec\\"}", - "selector": "TR.line_holder.parallel:nth-child(5) > TD.line_content.parallel.left-side.old", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Split view line number 740 in head diff part getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "headers := http.Header{", - "selector": "TD[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_737_740'].line_content.parallel.right-side.new > SPAN.line", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Split view line number 740 in head diff part getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "headers := http.Header{", - "selector": "TR.line_holder.parallel:nth-child(10) > TD.line_content.parallel.right-side.new", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Unified view line number 733 in head diff part getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "defer srv.Close()", - "selector": "TR[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_733_733'].line_holder:nth-child(3) > TD.line_content > SPAN.line", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Unified view line number 733 in head diff part getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "defer srv.Close()", - "selector": "TR[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_733_733'].line_holder:nth-child(3) > TD.line_content", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Unified view line number 735 in base diff part getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "u := url.URL{Scheme: \\"ws\\", Host: srv.Listener.Addr().String(), Path: build.Session.Endpoint + \\"/exec\\"}", - "selector": "TR[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_735_735'].line_holder.old:nth-child(5) > TD.line_content.old > SPAN.line", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Unified view line number 735 in base diff part getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "u := url.URL{Scheme: \\"ws\\", Host: srv.Listener.Addr().String(), Path: build.Session.Endpoint + \\"/exec\\"}", - "selector": "TR[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_735_735'].line_holder.old:nth-child(5) > TD.line_content.old", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Unified view line number 740 in head diff part getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "headers := http.Header{", - "selector": "TR[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_737_740'].line_holder.new:nth-child(12) > TD.line_content.new > SPAN.line", -} -`; - -exports[`Bitbucket DOM functions diffDOMFunctions Unified view line number 740 in head diff part getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "headers := http.Header{", - "selector": "TR[id='ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf_737_740'].line_holder.new:nth-child(12) > TD.line_content.new", -} -`; - -exports[`Bitbucket DOM functions singleFileDOMFunctions line number 1 getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "package shell", - "selector": "SPAN.line:nth-child(1)", -} -`; - -exports[`Bitbucket DOM functions singleFileDOMFunctions line number 1 getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "package shell", - "selector": "SPAN.line:nth-child(1)", -} -`; - -exports[`Bitbucket DOM functions singleFileDOMFunctions line number 22 getCodeElementFromLineNumber() should return the right code element given the line number 1`] = ` -Object { - "content": "type executor struct {", - "selector": "SPAN.line:nth-child(22)", -} -`; - -exports[`Bitbucket DOM functions singleFileDOMFunctions line number 22 getLineElementFromLineNumber() should return the right line element given the line number 1`] = ` -Object { - "content": "type executor struct {", - "selector": "SPAN.line:nth-child(22)", -} -`; diff --git a/browser/src/libs/gitlab/api.ts b/browser/src/libs/gitlab/api.ts deleted file mode 100644 index 41e3440d07bf..000000000000 --- a/browser/src/libs/gitlab/api.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { first, identity } from 'lodash' -import { Observable, zip, of } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' - -import { memoizeObservable } from '../../../../shared/src/util/memoizeObservable' -import { GitLabInfo } from './scrape' -import { checkOk } from '../../../../shared/src/backend/fetch' -import { FileInfo } from '../code_intelligence' -import { Omit } from 'utility-types' -import { fromFetch } from '../../../../shared/src/graphql/fromFetch' - -/** - * Significant revisions for a merge request. - */ -interface DiffRefs { - base_sha: string - head_sha: string - start_sha: string -} - -/** - * Response from the GitLab API for fetching a merge request. Note that there - * is more information returned but we are not using it. - */ -interface MergeRequestResponse { - diff_refs: DiffRefs - source_project_id: string -} - -/** - * Response from the GitLab API for fetching a specific version(diff) of a merge - * request. Note that there is more information returned but we are not using it. - */ -interface DiffVersionsResponse { - base_commit_sha: string -} - -const buildURL = (owner: string, projectName: string, path: string): string => - `${window.location.origin}/api/v4/projects/${encodeURIComponent(owner)}%2f${projectName}${path}` - -const get = (url: string): Observable => fromFetch(url, undefined, response => checkOk(response).json()) - -const getRepoNameFromProjectID = memoizeObservable( - (projectId: string): Observable => - get<{ web_url: string }>(`${window.location.origin}/api/v4/projects/${projectId}`).pipe( - map(({ web_url }) => { - const { hostname, pathname } = new URL(web_url) - return `${hostname}${pathname}` - }) - ), - identity -) - -/** - * Fetches the base commit ID of the merge request at the given diffID. - * If there is no diffID, emits `undefined`. - */ -const getBaseCommitIDFromDiffID = memoizeObservable( - ({ - owner, - projectName, - mergeRequestID, - diffID, - }: Pick & { mergeRequestID: string; diffID?: string }): Observable< - string | undefined - > => - diffID - ? get( - buildURL(owner, projectName, `/merge_requests/${mergeRequestID}/versions/${diffID}`) - ).pipe(map(({ base_commit_sha }) => base_commit_sha)) - : of(undefined), - ({ owner, projectName, mergeRequestID, diffID }) => `${owner}:${projectName}:${mergeRequestID}:${String(diffID)}` -) - -/** - * Fetches the fields of FileInfo common to all code views from the GitLab API. - */ -export const getMergeRequestDetailsFromAPI = memoizeObservable( - ({ - owner, - projectName, - mergeRequestID, - rawRepoName, - diffID, - }: Pick & { - mergeRequestID: string - diffID?: string - }): Observable> => - zip( - get(buildURL(owner, projectName, `/merge_requests/${mergeRequestID}`)), - getBaseCommitIDFromDiffID({ owner, projectName, mergeRequestID, diffID }) - ).pipe( - switchMap(([{ diff_refs, source_project_id }, baseCommitIDFromDiffID]) => - getRepoNameFromProjectID(source_project_id).pipe( - map( - (baseRawRepoName): Omit => ({ - baseCommitID: baseCommitIDFromDiffID || diff_refs.base_sha, - commitID: diff_refs.head_sha, - rawRepoName, - baseRawRepoName, - }) - ) - ) - ) - ), - ({ owner, projectName, mergeRequestID, rawRepoName, diffID }) => - `${owner}:${projectName}:${mergeRequestID}:${rawRepoName}:${String(diffID)}` -) - -interface CommitResponse { - parent_ids: string[] -} - -/** - * Get the base commit ID for a commit. - */ -export const getBaseCommitIDForCommit: ({ - owner, - projectName, - commitID, -}: Pick & { commitID: string }) => Observable = memoizeObservable( - ({ owner, projectName, commitID }) => - get(buildURL(owner, projectName, `/repository/commits/${commitID}`)).pipe( - map(({ parent_ids }) => first(parent_ids)!) // ! because it'll always have a parent if we are looking at the commit page. - ), - ({ owner, projectName, commitID }) => `${owner}:${projectName}:${commitID}` -) diff --git a/browser/src/libs/gitlab/code_intelligence.test.ts b/browser/src/libs/gitlab/code_intelligence.test.ts deleted file mode 100644 index 1debf3a82dc6..000000000000 --- a/browser/src/libs/gitlab/code_intelligence.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - testCodeHostMountGetters as testMountGetters, - testToolbarMountGetter, -} from '../code_intelligence/code_intelligence_test_utils' -import { getToolbarMount, gitlabCodeHost } from './code_intelligence' -import { readFile } from 'mz/fs' - -describe('gitlab/code_intelligence', () => { - describe('gitlabCodeHost', () => { - testMountGetters(gitlabCodeHost, `${__dirname}/__fixtures__/repository.html`) - }) - describe('getToolbarMount()', () => { - testToolbarMountGetter(`${__dirname}/__fixtures__/code-views/merge-request/unified.html`, getToolbarMount) - }) - - describe('urlToFile()', () => { - const { urlToFile } = gitlabCodeHost - const sourcegraphURL = 'https://sourcegraph.my.org' - - beforeAll(async () => { - document.documentElement.innerHTML = await readFile(__dirname + '/__fixtures__/merge-request.html', 'utf-8') - jsdom.reconfigure({ url: 'https://gitlab.com/sourcegraph/jsonrpc2/merge_requests/1/diffs' }) - globalThis.gon = { gitlab_url: 'https://gitlab.com' } - }) - it('returns an URL to the Sourcegraph instance if the location has a viewState', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'gitlab.com/sourcegraph/sourcegraph', - rev: 'master', - filePath: 'browser/src/libs/code_intelligence/code_intelligence.tsx', - position: { - line: 5, - character: 12, - }, - viewState: 'references', - }, - { part: undefined } - ) - ).toBe( - 'https://sourcegraph.my.org/sourcegraph/sourcegraph@master/-/blob/browser/src/libs/code_intelligence/code_intelligence.tsx#L5:12&tab=references' - ) - }) - - it('returns an absolute URL if the location is not on the same code host', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'gitlab.sgdev.org/sourcegraph/sourcegraph', - rev: 'master', - filePath: 'browser/src/libs/code_intelligence/code_intelligence.tsx', - position: { - line: 5, - character: 12, - }, - }, - { part: undefined } - ) - ).toBe( - 'https://sourcegraph.my.org/sourcegraph/sourcegraph@master/-/blob/browser/src/libs/code_intelligence/code_intelligence.tsx#L5:12' - ) - }) - it('returns an URL to a blob on the same code host if possible', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/sourcegraph', - rawRepoName: 'gitlab.com/sourcegraph/sourcegraph', - rev: 'master', - filePath: 'browser/src/libs/code_intelligence/code_intelligence.tsx', - position: { - line: 5, - character: 12, - }, - }, - { part: undefined } - ) - ).toBe( - 'https://gitlab.com/sourcegraph/sourcegraph/blob/master/browser/src/libs/code_intelligence/code_intelligence.tsx#L5' - ) - }) - it('returns an URL to the file on the same merge request if possible', () => { - expect( - urlToFile( - sourcegraphURL, - { - repoName: 'sourcegraph/jsonrpc2', - rawRepoName: 'gitlab.com/sourcegraph/jsonrpc2', - rev: 'changes', - filePath: 'call_opt.go', - position: { - line: 5, - character: 12, - }, - }, - { part: 'head' } - ) - ).toBe( - 'https://gitlab.com/sourcegraph/jsonrpc2/merge_requests/1/diffs#9e1d3828a925c1eca74b74c20b58a9138f886d29_3_5' - ) - }) - }) -}) diff --git a/browser/src/libs/gitlab/code_intelligence.ts b/browser/src/libs/gitlab/code_intelligence.ts deleted file mode 100644 index 29cb5d94c389..000000000000 --- a/browser/src/libs/gitlab/code_intelligence.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Omit } from 'utility-types' -import { CodeHost } from '../code_intelligence' -import { CodeView } from '../code_intelligence/code_views' -import { getSelectionsFromHash, observeSelectionsFromHash } from '../code_intelligence/util/selections' -import { ViewResolver } from '../code_intelligence/views' -import { diffDOMFunctions, singleFileDOMFunctions } from './dom_functions' -import { getCommandPaletteMount } from './extensions' -import { resolveCommitFileInfo, resolveDiffFileInfo, resolveFileInfo } from './file_info' -import { getPageInfo, GitLabPageKind, getFilePathsFromCodeView } from './scrape' -import { toAbsoluteBlobURL } from '../../shared/util/url' -import { subTypeOf } from '../../../../shared/src/util/types' -import { NotificationType } from '../../../../shared/src/api/client/services/notifications' - -const toolbarButtonProps = { - className: 'btn btn-default btn-sm', -} - -export function checkIsGitlab(): boolean { - return !!document.head.querySelector('meta[content="GitLab"]') -} - -const adjustOverlayPosition: CodeHost['adjustOverlayPosition'] = ({ top, left }) => { - const header = document.querySelector('header') - if (header) { - top += header.getBoundingClientRect().height - } - // When running GitLab from source, we also need to take into account - // the debug header shown at the top of the page. - const debugHeader = document.querySelector('#js-peek.development') - if (debugHeader) { - top += debugHeader.getBoundingClientRect().height - } - return { - top, - left, - } -} - -export const getToolbarMount = (codeView: HTMLElement): HTMLElement => { - const existingMount: HTMLElement | null = codeView.querySelector('.sg-toolbar-mount-gitlab') - if (existingMount) { - return existingMount - } - - const fileActions = codeView.querySelector('.file-actions') - if (!fileActions) { - throw new Error('Unable to find mount location') - } - - const mount = document.createElement('div') - mount.classList.add('btn-group') - mount.classList.add('sg-toolbar-mount') - mount.classList.add('sg-toolbar-mount-gitlab') - - fileActions.insertAdjacentElement('afterbegin', mount) - - return mount -} - -const singleFileCodeView: Omit = { - dom: singleFileDOMFunctions, - getToolbarMount, - resolveFileInfo, - toolbarButtonProps, - getSelections: getSelectionsFromHash, - observeSelections: observeSelectionsFromHash, -} - -const getFileTitle = (codeView: HTMLElement): HTMLElement[] => { - const fileTitle = codeView.querySelector('.js-file-title') - if (!fileTitle) { - throw new Error('Could not find .file-title element') - } - return [fileTitle] -} - -const mergeRequestCodeView: Omit = { - dom: diffDOMFunctions, - getToolbarMount, - resolveFileInfo: resolveDiffFileInfo, - toolbarButtonProps, - getScrollBoundaries: getFileTitle, -} - -const commitCodeView: Omit = { - dom: diffDOMFunctions, - getToolbarMount, - resolveFileInfo: resolveCommitFileInfo, - toolbarButtonProps, - getScrollBoundaries: getFileTitle, -} - -const resolveView: ViewResolver['resolveView'] = (element: HTMLElement): CodeView | null => { - if (element.classList.contains('discussion-wrapper')) { - // This is a commented snippet in a merge request discussion timeline - // (a snippet where somebody added a review comment on a piece of code in the MR), - // we don't support adding code intelligence on those. - return null - } - const { pageKind } = getPageInfo() - - if (pageKind === GitLabPageKind.Other) { - return null - } - - if (pageKind === GitLabPageKind.File) { - return { element, ...singleFileCodeView } - } - - if (pageKind === GitLabPageKind.MergeRequest) { - if (!element.querySelector('.file-actions')) { - // If the code view has no file actions, we cannot resolve its head commit ID. - // This can be the case for code views representing added git submodules. - return null - } - return { element, ...mergeRequestCodeView } - } - - return { element, ...commitCodeView } -} - -const codeViewResolver: ViewResolver = { - selector: '.file-holder', - resolveView, -} - -const notificationClassNames = { - [NotificationType.Log]: 'alert alert-secondary', - [NotificationType.Success]: 'alert alert-success', - [NotificationType.Info]: 'alert alert-info', - [NotificationType.Warning]: 'alert alert-warning', - [NotificationType.Error]: 'alert alert-danger', -} - -export const gitlabCodeHost = subTypeOf()({ - type: 'gitlab', - name: 'GitLab', - check: checkIsGitlab, - codeViewResolvers: [codeViewResolver], - adjustOverlayPosition, - getCommandPaletteMount, - getContext: () => ({ - ...getPageInfo(), - privateRepository: window.location.hostname !== 'gitlab.com', - }), - urlToFile: (sourcegraphURL, target, context): string => { - // A view state means that a panel must be shown, and panels are currently only supported on - // Sourcegraph (not code hosts). - // Make sure the location is also on this Gitlab instance, return an absolute URL otherwise. - if (target.viewState || !target.rawRepoName.startsWith(window.location.hostname)) { - return toAbsoluteBlobURL(sourcegraphURL, target) - } - - // Stay on same page in MR if possible. - // TODO to be entirely correct, this would need to compare the rev of the code view with the target rev. - const currentPage = getPageInfo() - if (currentPage.rawRepoName === target.rawRepoName && context.part !== undefined) { - const codeViews = document.querySelectorAll(codeViewResolver.selector) - for (const codeView of codeViews) { - const { filePath, baseFilePath } = getFilePathsFromCodeView(codeView) - if (filePath !== target.filePath && baseFilePath !== target.filePath) { - continue - } - if (!target.position) { - const url = new URL(window.location.href) - url.hash = codeView.id - return url.href - } - const partSelector = context.part !== null ? { head: '.new_line', base: '.old_line' }[context.part] : '' - const link = codeView.querySelector( - `${partSelector} a[data-linenumber="${target.position.line}"]` - ) - if (!link) { - break - } - return new URL(link.href).href - } - } - - // Go to specific URL on this Gitlab instance. - const url = new URL(`https://${target.rawRepoName}/blob/${target.rev}/${target.filePath}`) - if (target.position) { - const { line } = target.position - url.hash = `#L${line}` - } - return url.href - }, - notificationClassNames, - commandPaletteClassProps: { - popoverClassName: 'dropdown-menu command-list-popover--gitlab', - formClassName: 'dropdown-input', - inputClassName: 'dropdown-input-field', - resultsContainerClassName: 'dropdown-content', - selectedActionItemClassName: 'is-focused', - noResultsClassName: 'px-3', - iconClassName: 's16 align-bottom', - }, - codeViewToolbarClassProps: { - className: 'code-view-toolbar--gitlab', - actionItemClass: 'btn btn-sm btn-secondary action-item--gitlab', - actionItemPressedClass: 'active', - }, - hoverOverlayClassProps: { - className: 'card', - actionItemClassName: 'btn btn-secondary action-item--gitlab', - actionItemPressedClassName: 'active', - closeButtonClassName: 'btn', - infoAlertClassName: notificationClassNames[NotificationType.Info], - errorAlertClassName: notificationClassNames[NotificationType.Error], - }, - codeViewsRequireTokenization: true, - getHoverOverlayMountLocation: (): string | null => { - const { pageKind } = getPageInfo() - // On merge request pages only, mount the hover overlay to the diffs tab container. - if (pageKind === GitLabPageKind.MergeRequest) { - return 'div.tab-pane.diffs' - } - return null - }, -}) diff --git a/browser/src/libs/gitlab/dom_functions.test.ts b/browser/src/libs/gitlab/dom_functions.test.ts deleted file mode 100644 index 3336533bd28f..000000000000 --- a/browser/src/libs/gitlab/dom_functions.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { startCase } from 'lodash' -import { testDOMFunctions } from '../code_intelligence/code_intelligence_test_utils' -import { diffDOMFunctions, singleFileDOMFunctions } from './dom_functions' - -describe('Bitbucket DOM functions', () => { - describe('diffDOMFunctions', () => { - for (const view of ['split', 'unified']) { - describe(`${startCase(view)} view`, () => { - // https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1058/diffs?view=parallel#diff-content-ca8e0332ce17b2ee630a2ee2c0b56d47a462dadf - testDOMFunctions(diffDOMFunctions, { - htmlFixturePath: `${__dirname}/__fixtures__/code-views/merge-request/${view}.html`, - lineCases: [ - { diffPart: 'head', lineNumber: 733 }, // not changed - { diffPart: 'head', lineNumber: 740 }, // added - { diffPart: 'base', lineNumber: 735 }, // removed - ], - }) - }) - } - }) - - describe('singleFileDOMFunctions', () => { - const htmlFixturePath = `${__dirname}/__fixtures__/code-views/blob.html` - // https://gitlab.com/gitlab-org/gitlab-runner/blob/0362425dc5026417338ac6a823c53fe65b10c4a7/executors/shell/executor_shell.go - testDOMFunctions(singleFileDOMFunctions, { - htmlFixturePath, - lineCases: [{ lineNumber: 1 }, { lineNumber: 22 }], - }) - }) -}) diff --git a/browser/src/libs/gitlab/dom_functions.ts b/browser/src/libs/gitlab/dom_functions.ts deleted file mode 100644 index 56cbc9062443..000000000000 --- a/browser/src/libs/gitlab/dom_functions.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { DiffPart } from '@sourcegraph/codeintellify' -import { DOMFunctions } from '../code_intelligence/code_views' - -const getSingleFileCodeElementFromLineNumber = ( - codeView: HTMLElement, - line: number, - part?: DiffPart -): HTMLElement | null => codeView.querySelector(`#LC${line}`) -export const singleFileDOMFunctions: DOMFunctions = { - getCodeElementFromTarget: target => target.closest('span.line') as HTMLElement | null, - getLineNumberFromCodeElement: codeElement => { - const line = codeElement.id.replace(/^LC/, '') - return parseInt(line, 10) - }, - getCodeElementFromLineNumber: getSingleFileCodeElementFromLineNumber, - getLineElementFromLineNumber: getSingleFileCodeElementFromLineNumber, -} - -const getDiffCodePart: DOMFunctions['getDiffCodePart'] = codeElement => { - let selector = 'old' - - const row = codeElement.closest('td')! - - // Split diff - if (row.classList.contains('parallel')) { - selector = 'left-side' - } - - return row.classList.contains(selector) ? 'base' : 'head' -} - -const getDiffCodeElementFromLineNumber = (codeView: HTMLElement, line: number, part?: DiffPart): HTMLElement | null => { - const lineNumberElement = codeView.querySelector( - `.${part === 'base' ? 'old_line' : 'new_line'} [data-linenumber="${line}"]` - ) - if (!lineNumberElement) { - return null - } - - const row = lineNumberElement.closest('tr') - if (!row) { - return null - } - - let selector = 'span.line' - - // Split diff - if (row.classList.contains('parallel')) { - selector = `.${part === 'base' ? 'left-side' : 'right-side'} ${selector}` - } - - return row.querySelector(selector) -} - -export const diffDOMFunctions: DOMFunctions = { - getCodeElementFromTarget: singleFileDOMFunctions.getCodeElementFromTarget, - getLineNumberFromCodeElement: codeElement => { - const part = getDiffCodePart(codeElement) - - let cell: HTMLElement | null = codeElement.closest('td') - while ( - cell && - !cell.matches(`.diff-line-num.${part === 'base' ? 'old_line' : 'new_line'}`) && - cell.previousElementSibling - ) { - cell = cell.previousElementSibling as HTMLElement | null - } - - if (cell) { - const a = cell.querySelector('a')! - return parseInt(a.dataset.linenumber || '', 10) - } - - throw new Error('Unable to determine line number for diff code element') - }, - getCodeElementFromLineNumber: getDiffCodeElementFromLineNumber, - getLineElementFromLineNumber: (codeView, line, part) => { - const codeElement = getDiffCodeElementFromLineNumber(codeView, line, part) - return codeElement && (codeElement.parentElement as HTMLTableCellElement) - }, - getDiffCodePart, -} diff --git a/browser/src/libs/gitlab/extensions.ts b/browser/src/libs/gitlab/extensions.ts deleted file mode 100644 index 9851ba6a6647..000000000000 --- a/browser/src/libs/gitlab/extensions.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { querySelectorOrSelf } from '../../shared/util/dom' -import { MountGetter } from '../code_intelligence' - -export const getCommandPaletteMount: MountGetter = (container: HTMLElement): HTMLElement | null => { - const headerElem = querySelectorOrSelf(container, '.navbar-collapse') - if (!headerElem) { - return null - } - const commandListClass = 'command-palette-button' - const createCommandList = (): HTMLElement => { - const mount = document.createElement('div') - mount.className = commandListClass - headerElem.insertAdjacentElement('afterbegin', mount) - return mount - } - return headerElem.querySelector('.' + commandListClass) || createCommandList() -} diff --git a/browser/src/libs/gitlab/file_info.ts b/browser/src/libs/gitlab/file_info.ts deleted file mode 100644 index 873a7927b717..000000000000 --- a/browser/src/libs/gitlab/file_info.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Observable, from } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' - -import { FileInfo } from '../code_intelligence' - -import { getBaseCommitIDForCommit, getMergeRequestDetailsFromAPI } from './api' -import { - getCommitIDFromPermalink, - getCommitPageInfo, - getFilePageInfo, - getFilePathsFromCodeView, - getPageInfo, - getMergeRequestID, - getDiffID, -} from './scrape' -import { asObservable } from '../../../../shared/src/util/rxjs/asObservable' - -/** - * Resolves file information for a page with a single file, not including diffs with only one file. - */ -export const resolveFileInfo = (): FileInfo => { - const { rawRepoName, filePath, rev } = getFilePageInfo() - if (!filePath) { - throw new Error( - `Unable to determine the file path of the current file because the current URL (window.location ${window.location.href}) does not have a file path.` - ) - } - const commitID = getCommitIDFromPermalink() - return { rawRepoName, filePath, commitID, rev } -} - -/** - * Gets `FileInfo` for a diff file. - */ -export const resolveDiffFileInfo = (codeView: HTMLElement): Observable => - from( - getMergeRequestDetailsFromAPI({ - ...getPageInfo(), - mergeRequestID: getMergeRequestID(), - diffID: getDiffID(), - }) - ).pipe(map((info): FileInfo => ({ ...info, ...getFilePathsFromCodeView(codeView) }))) - -/** - * Resolves file information for commit pages. - */ -export const resolveCommitFileInfo = (codeView: HTMLElement): Observable => - asObservable(getCommitPageInfo).pipe( - // Resolve base commit ID. - switchMap(({ owner, projectName, commitID, rawRepoName }) => - getBaseCommitIDForCommit({ owner, projectName, commitID }).pipe( - map(baseCommitID => ({ commitID, baseCommitID, rawRepoName })) - ) - ), - map( - ({ commitID, baseCommitID, rawRepoName }): FileInfo => { - const { filePath, baseFilePath } = getFilePathsFromCodeView(codeView) - return { baseCommitID, baseFilePath, commitID, filePath, rawRepoName } - } - ) - ) diff --git a/browser/src/libs/gitlab/scrape.ts b/browser/src/libs/gitlab/scrape.ts deleted file mode 100644 index 5d017a734023..000000000000 --- a/browser/src/libs/gitlab/scrape.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { last, take } from 'lodash' - -import { FileSpec, RawRepoSpec, RevSpec } from '../../../../shared/src/util/url' -import { commitIDFromPermalink } from '../../shared/util/dom' -import { FileInfo } from '../code_intelligence' -import { isExtension } from '../../context' - -export enum GitLabPageKind { - File, - Commit, - MergeRequest, - Other, -} - -/** - * General information that can be found on any GitLab page that we care about. (i.e. has code) - */ -export interface GitLabInfo extends RawRepoSpec { - pageKind: GitLabPageKind - - owner: string - projectName: string -} - -/** - * Information about single file pages. - */ -interface GitLabFileInfo extends RawRepoSpec, FileSpec, RevSpec {} - -export const getPageKindFromPathName = (owner: string, projectName: string, pathname: string): GitLabPageKind => { - const pageKindMatch = pathname.match(new RegExp(`^/${owner}/${projectName}(/-)?/(commit|merge_requests|blob)/`)) - if (!pageKindMatch) { - return GitLabPageKind.Other - } - switch (pageKindMatch[2]) { - case 'commit': - return GitLabPageKind.Commit - case 'merge_requests': - return GitLabPageKind.MergeRequest - case 'blob': - return GitLabPageKind.File - default: - return GitLabPageKind.Other - } -} - -/** - * Gets information about the page. - */ -export function getPageInfo(): GitLabInfo { - const projectLink = document.querySelector('.context-header a') - if (!projectLink) { - throw new Error('Unable to determine project name') - } - - const projectFullName = new URL(projectLink.href).pathname.slice(1) - - const parts = projectFullName.split('/') - - const owner = take(parts, parts.length - 1).join('/') - const projectName = last(parts)! - - const pageKind = getPageKindFromPathName(owner, projectName, window.location.pathname) - const hostname = isExtension ? window.location.hostname : new URL(gon.gitlab_url).hostname - - return { - owner, - projectName, - rawRepoName: [hostname, owner, projectName].join('/'), - pageKind, - } -} - -/** - * Gets information about a file view page. - */ -export function getFilePageInfo(): GitLabFileInfo { - const { rawRepoName } = getPageInfo() - const matches = window.location.pathname.match(/\/blob\/(.*?)\/(.*)/) - if (!matches) { - throw new Error('Unable to determine revision or file path') - } - - const rev = decodeURIComponent(matches[1]) - const filePath = decodeURIComponent(matches[2]) - return { - rawRepoName, - filePath, - rev, - } -} - -/** - * Finds the merge request ID from the URL. - */ -export const getMergeRequestID = (): string => { - const matches = window.location.pathname.match(/merge_requests\/(.*?)\/diffs/) - if (!matches) { - throw new Error('Unable to determine merge request ID') - } - return matches[1] -} - -/** - * Finds the diff ID, if any, from the URL. - * The diff ID represents a specific revision in a merge request. - */ -export const getDiffID = (): string | undefined => { - const params = new URLSearchParams(window.location.search) - return params.get('diff_id') ?? undefined -} - -/** - * Finds the file paths from the code view. If the name has changed, it'll return the base and head file paths. - */ -export function getFilePathsFromCodeView(codeView: HTMLElement): Pick { - const filePathElements = codeView.querySelectorAll('.file-title-name') - if (filePathElements.length === 0) { - throw new Error('Unable to get file paths from code view: no .file-title.name') - } - - const getFilePathFromElem = (elem: HTMLElement): string => { - const filePath = elem.dataset.originalTitle || elem.dataset.title || elem.title - if (!filePath) { - throw new Error('Unable to get file paths from code view: no file title') - } - - return filePath - } - - const filePathDidChange = filePathElements.length > 1 - const filePath = getFilePathFromElem(filePathElements.item(filePathDidChange ? 1 : 0)) - - return { - filePath, - baseFilePath: filePathDidChange ? getFilePathFromElem(filePathElements.item(0)) : filePath, - } -} - -interface GitLabCommitPageInfo extends RawRepoSpec, Pick { - commitID: FileInfo['commitID'] -} - -/** - * Get the commit from the URL. - */ -export function getCommitPageInfo(): GitLabCommitPageInfo { - const { rawRepoName, owner, projectName } = getPageInfo() - - return { - rawRepoName, - owner, - projectName, - commitID: last(window.location.pathname.split('/'))!, - } -} - -/** - * Get the commit ID from the permalink element on the page. - */ -export function getCommitIDFromPermalink(): string { - return commitIDFromPermalink({ - selector: '.js-data-file-blob-permalink-url', - hrefRegex: new RegExp('^/.*?/.*?/blob/([0-9a-f]{40})/'), - }) -} diff --git a/browser/src/libs/gitlab/style.scss b/browser/src/libs/gitlab/style.scss deleted file mode 100644 index fcfcc7fa0dc4..000000000000 --- a/browser/src/libs/gitlab/style.scss +++ /dev/null @@ -1,122 +0,0 @@ -.command-list-popover--gitlab { - // The navbar has z-index 1000 - z-index: 1001 !important; -} - -.hover-overlay-mount__gitlab { - .hover-overlay { - code { - background: none; - color: unset; - } - } - - // stylelint-disable - - // highlight.js styles - - .hljs { - display: block; - overflow-x: auto; - padding: 0.5em; - color: #333333; - } - - .hljs-comment, - .hljs-quote { - color: #999988; - } - - .hljs-keyword, - .hljs-selector-tag, - .hljs-subst { - color: inherit; - font-weight: 600; - } - - .hljs-number, - .hljs-literal, - .hljs-variable, - .hljs-template-variable, - .hljs-tag .hljs-attr { - color: #008080; - } - - .hljs-string, - .hljs-doctag { - color: #dd1144; - } - - .hljs-title, - .hljs-section, - .hljs-selector-id { - color: #333333; - } - - .hljs-subst { - font-weight: normal; - } - - .hljs-type, - .hljs-class .hljs-title { - color: #445588; - } - - .hljs-tag, - .hljs-name, - .hljs-attribute { - color: #000080; - font-weight: normal; - } - - .hljs-regexp, - .hljs-link { - color: #009926; - } - - .hljs-symbol, - .hljs-bullet { - color: #990073; - } - - .hljs-built_in, - .hljs-builtin-name { - color: #0086b3; - } - - .hljs-meta { - color: #999999; - font-weight: bold; - } - - .hljs-deletion { - background: #ffdddd; - } - - .hljs-addition { - background: #ddffdd; - } - - .hljs-emphasis { - font-style: italic; - } - - .hljs-strong { - font-weight: bold; - } -} - -.code-view-toolbar--gitlab { - display: flex; - - // Same style as for Gitlab's svg icons - img { - vertical-align: baseline; - } - .btn img { - height: 15px; - width: 15px; - position: relative; - top: 2px; - } -} diff --git a/browser/src/libs/options/OptionsContainer.scss b/browser/src/libs/options/OptionsContainer.scss deleted file mode 100644 index 98f2d4f49ea3..000000000000 --- a/browser/src/libs/options/OptionsContainer.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import './OptionsMenu.scss'; -@import './ServerURLForm.scss'; diff --git a/browser/src/libs/options/OptionsContainer.test.tsx b/browser/src/libs/options/OptionsContainer.test.tsx deleted file mode 100644 index 3fec00c38c05..000000000000 --- a/browser/src/libs/options/OptionsContainer.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as React from 'react' -import { render, RenderResult } from '@testing-library/react' -import { noop, Observable, of } from 'rxjs' -import { switchMap } from 'rxjs/operators' -import { TestScheduler } from 'rxjs/testing' -import { OptionsContainer, OptionsContainerProps } from './OptionsContainer' - -describe('OptionsContainer', () => { - const stubs: Pick< - OptionsContainerProps, - | 'isActivated' - | 'fetchCurrentTabStatus' - | 'ensureValidSite' - | 'toggleExtensionDisabled' - | 'toggleFeatureFlag' - | 'featureFlags' - | 'hasPermissions' - | 'requestPermissions' - > = { - isActivated: true, - hasPermissions: () => Promise.resolve(true), - requestPermissions: noop, - fetchCurrentTabStatus: () => Promise.resolve(undefined), - ensureValidSite: (url: string) => new Observable(), - toggleExtensionDisabled: (isActivated: boolean) => Promise.resolve(undefined), - toggleFeatureFlag: noop, - featureFlags: [], - } - - test('checks the connection status when it mounts', () => { - const scheduler = new TestScheduler((a, b) => expect(a).toEqual(b)) - - scheduler.run(({ cold, expectObservable }) => { - const values = { a: 'https://test.com' } - - const siteFetches = cold('a', values).pipe( - switchMap( - url => - new Observable(observer => { - const ensureValidSite = (url: string): Observable => { - observer.next(url) - - return of(undefined) - } - - render( - Promise.resolve()} - /> - ) - }) - ) - ) - - expectObservable(siteFetches).toBe('a', values) - }) - }) - - test('checks the connection status when it the url updates', () => { - const scheduler = new TestScheduler((a, b) => expect(a).toEqual(b)) - - const buildRenderer = (): ((ui: React.ReactElement) => void) => { - let rerender: RenderResult['rerender'] | undefined - - return ui => { - if (rerender) { - rerender(ui) - } else { - const renderedRes = render(ui) - - rerender = renderedRes.rerender - } - } - } - - const renderOrRerender = buildRenderer() - - scheduler.run(({ cold, expectObservable }) => { - const values = { a: 'https://test.com', b: 'https://test1.com' } - - const siteFetches = cold('ab', values).pipe( - switchMap( - url => - new Observable(observer => { - const ensureValidSite = (url: string): Observable => { - observer.next(url) - - return of(undefined) - } - - renderOrRerender( - Promise.resolve()} - /> - ) - }) - ) - ) - - expectObservable(siteFetches).toBe('ab', values) - }) - }) - - test('handles when an error is thrown checking the site connection', () => { - const ensureValidSite = (): never => { - throw new Error('no site, woops') - } - - try { - render( - Promise.resolve()} - /> - ) - } catch (err) { - throw new Error("shouldn't be hit") - } - }) -}) diff --git a/browser/src/libs/options/OptionsContainer.tsx b/browser/src/libs/options/OptionsContainer.tsx deleted file mode 100644 index 591661019db4..000000000000 --- a/browser/src/libs/options/OptionsContainer.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* eslint rxjs/no-async-subscribe: warn */ -/* eslint @typescript-eslint/no-misused-promises: warn */ -import * as React from 'react' -import { Observable, of, Subject, Subscription } from 'rxjs' -import { catchError, distinctUntilChanged, filter, map, share, switchMap, concatMap } from 'rxjs/operators' -import { AUTH_REQUIRED_ERROR_NAME } from '../../../../shared/src/backend/errors' -import { ErrorLike, isErrorLike } from '../../../../shared/src/util/errors' -import { getExtensionVersion } from '../../shared/util/context' -import { OptionsMenu, OptionsMenuProps } from './OptionsMenu' -import { ConnectionErrors } from './ServerURLForm' - -export interface OptionsContainerProps { - sourcegraphURL: string - isActivated: boolean - ensureValidSite: (url: string) => Observable - fetchCurrentTabStatus: () => Promise - hasPermissions: (url: string) => Promise - requestPermissions: (url: string) => void - setSourcegraphURL: (url: string) => Promise - toggleExtensionDisabled: (isActivated: boolean) => Promise - toggleFeatureFlag: (key: string) => void - featureFlags: { key: string; value: boolean }[] -} - -interface OptionsContainerState - extends Pick< - OptionsMenuProps, - | 'status' - | 'sourcegraphURL' - | 'connectionError' - | 'isSettingsOpen' - | 'isActivated' - | 'urlHasPermissions' - | 'currentTabStatus' - > {} - -export class OptionsContainer extends React.Component { - private version = getExtensionVersion() - - private urlUpdates = new Subject() - - private activationClicks = new Subject() - - private subscriptions = new Subscription() - - constructor(props: OptionsContainerProps) { - super(props) - - this.state = { - status: 'connecting', - sourcegraphURL: props.sourcegraphURL, - isActivated: props.isActivated, - urlHasPermissions: false, - connectionError: undefined, - isSettingsOpen: false, - } - - const fetchingSite: Observable = this.urlUpdates.pipe( - distinctUntilChanged(), - map(url => url.replace(/\/$/, '')), - filter(maybeURL => { - let validURL = false - try { - validURL = !!new URL(maybeURL) - } catch (e) { - validURL = false - } - - return validURL - }), - switchMap(url => { - this.setState({ status: 'connecting', connectionError: undefined }) - return this.props.ensureValidSite(url).pipe( - map(() => url), - catchError(err => of(err)) - ) - }), - catchError(err => of(err)), - share() - ) - - this.subscriptions.add( - fetchingSite.subscribe(async res => { - let url = '' - - if (isErrorLike(res)) { - this.setState({ - status: 'error', - connectionError: - res.name === AUTH_REQUIRED_ERROR_NAME - ? ConnectionErrors.AuthError - : ConnectionErrors.UnableToConnect, - }) - url = this.state.sourcegraphURL - } else { - this.setState({ status: 'connected' }) - url = res - } - - const urlHasPermissions = await props.hasPermissions(url) - this.setState({ urlHasPermissions }) - - await props.setSourcegraphURL(url) - }) - ) - - props - .fetchCurrentTabStatus() - .then(currentTabStatus => this.setState(state => ({ ...state, currentTabStatus }))) - .catch(err => { - console.error('Error fetching current tab status', err) - }) - } - - public componentDidMount(): void { - this.urlUpdates.next(this.state.sourcegraphURL) - this.subscriptions.add( - this.activationClicks - .pipe(concatMap(isActivated => this.props.toggleExtensionDisabled(isActivated))) - .subscribe() - ) - } - - public componentDidUpdate(): void { - this.urlUpdates.next(this.props.sourcegraphURL) - } - - public componentWillUnmount(): void { - this.subscriptions.unsubscribe() - } - - public render(): React.ReactNode { - return ( - - ) - } - - private handleURLChange = (value: string): void => { - this.setState({ sourcegraphURL: value }) - } - - private handleURLSubmit = async (): Promise => { - await this.props.setSourcegraphURL(this.state.sourcegraphURL) - } - - private handleSettingsClick = (): void => { - this.setState(state => ({ - isSettingsOpen: !state.isSettingsOpen, - })) - } - - private handleToggleActivationClick = (value: boolean): void => this.activationClicks.next(value) -} diff --git a/browser/src/libs/options/OptionsHeader.scss b/browser/src/libs/options/OptionsHeader.scss deleted file mode 100644 index 27516581e451..000000000000 --- a/browser/src/libs/options/OptionsHeader.scss +++ /dev/null @@ -1,45 +0,0 @@ -@import '../../../../shared/src/components/Toggle'; - -.options-header { - display: flex; - align-items: stretch; - justify-content: space-between; - - &__logo { - flex: 1; - max-width: 65%; - width: 300px; - } - - &__version { - flex: 1; - display: flex; - align-items: center; - - font-weight: 500; - color: $color-light-bg-5; - margin-left: 30px; - } - - &__right { - flex: 1; - display: flex; - justify-content: flex-end; - align-items: center; - - font-weight: 600; - color: $color-light-bg-5; - } - - &__settings { - background-color: transparent; - color: inherit; - - padding: 0.5rem; - - &:hover, - &:focus { - color: $gray-23; - } - } -} diff --git a/browser/src/libs/options/OptionsHeader.story.tsx b/browser/src/libs/options/OptionsHeader.story.tsx deleted file mode 100644 index 37fa0c60a2e0..000000000000 --- a/browser/src/libs/options/OptionsHeader.story.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react' - -import { storiesOf } from '@storybook/react' - -import '../../app.scss' - -import { action } from '@storybook/addon-actions' -import { OptionsHeader } from './OptionsHeader' - -storiesOf('Options - OptionsHeader', module).add('Default', () => ( -
- -
-)) diff --git a/browser/src/libs/options/OptionsHeader.tsx b/browser/src/libs/options/OptionsHeader.tsx deleted file mode 100644 index 310bf2e29c43..000000000000 --- a/browser/src/libs/options/OptionsHeader.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import SettingsOutlineIcon from 'mdi-react/SettingsOutlineIcon' -import { Toggle } from '../../../../shared/src/components/Toggle' -import * as React from 'react' - -export interface OptionsHeaderProps { - className?: string - version: string - assetsDir?: string - isActivated: boolean - onSettingsClick: (event: React.MouseEvent) => void - onToggleActivationClick: (value: boolean) => void -} - -export const OptionsHeader: React.FunctionComponent = ({ - className, - version, - assetsDir, - isActivated, - onSettingsClick, - onToggleActivationClick, -}: OptionsHeaderProps) => ( -
-
- -
v{version}
-
-
- - -
-
-) diff --git a/browser/src/libs/options/OptionsMenu.scss b/browser/src/libs/options/OptionsMenu.scss deleted file mode 100644 index 7d20de91b57c..000000000000 --- a/browser/src/libs/options/OptionsMenu.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import './OptionsHeader.scss'; - -.options-menu { - width: 26rem; - - &--full { - margin: 8rem auto; - box-shadow: 0 0 12px 0 rgba(0, 0, 0, 0.15); - } - - &__section { - padding: 1rem; - border-top: 1px solid $gray-02; - &:first-child { - border-top: none; - } - } -} diff --git a/browser/src/libs/options/OptionsMenu.story.tsx b/browser/src/libs/options/OptionsMenu.story.tsx deleted file mode 100644 index 9c0a00f958d1..000000000000 --- a/browser/src/libs/options/OptionsMenu.story.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react' - -import { action } from '@storybook/addon-actions' -import { storiesOf } from '@storybook/react' - -import '../../app.scss' - -import { OptionsMenu } from './OptionsMenu' - -storiesOf('Options - OptionsMenu', module) - .add('Default', () => ( -
- undefined} - urlHasPermissions={true} - /> -
- )) - .add('Settings open', () => ( -
- undefined} - urlHasPermissions={true} - /> -
- )) diff --git a/browser/src/libs/options/OptionsMenu.test.tsx b/browser/src/libs/options/OptionsMenu.test.tsx deleted file mode 100644 index 07b53d3d8d91..000000000000 --- a/browser/src/libs/options/OptionsMenu.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { noop } from 'lodash' -import React from 'react' -import renderer from 'react-test-renderer' -import { cleanup, fireEvent, render } from '@testing-library/react' -import sinon from 'sinon' - -import { DEFAULT_SOURCEGRAPH_URL } from '../../shared/util/context' -import { OptionsMenu, OptionsMenuProps } from './OptionsMenu' - -jest.mock('mdi-react/SettingsOutlineIcon', () => 'SettingsOutlineIcon') - -describe('OptionsMenu', () => { - afterAll(cleanup) - - const stubs: OptionsMenuProps = { - status: 'connected', - version: '0.0.0', - urlHasPermissions: true, - sourcegraphURL: DEFAULT_SOURCEGRAPH_URL, - requestPermissions: noop, - onURLChange: noop, - onURLSubmit: noop, - isActivated: true, - toggleFeatureFlag: noop, - onToggleActivationClick: noop, - onSettingsClick: noop, - } - - test('renders a default state', () => { - expect(renderer.create()).toMatchSnapshot() - }) - - test('renders the current tab permissions alert', () => { - expect( - renderer.create( - - ) - ).toMatchSnapshot() - }) - - test("doesn't render the permissions alert on chrome://extensions", () => { - expect( - renderer.create( - - ) - ).toMatchSnapshot() - }) - - test("doesn't render the permissions alert on chrome://newtab", () => { - expect( - renderer.create( - - ) - ).toMatchSnapshot() - }) - - test("doesn't render the permissions alert on about://addons", () => { - expect( - renderer.create( - - ) - ).toMatchSnapshot() - }) - - test('fires requestPermissions', () => { - const requestPermissions = sinon.spy() - const { container } = render( - - ) - const requestLink = container.querySelector('.request-permissions__test')! - fireEvent.click(requestLink) - expect(requestPermissions.calledOnce).toBe(true) - }) - - test('renders the feature flags', () => { - expect( - renderer.create( - - ) - ).toMatchSnapshot() - }) - - test('triggers the toggleFeatureFlag handler', () => { - const toggleFeatureFlag = sinon.spy() - const { container } = render( - - ) - const fooCheckbox = container.querySelector('#foo')! - fireEvent.click(fooCheckbox) - expect(toggleFeatureFlag.calledOnce).toBe(true) - }) -}) diff --git a/browser/src/libs/options/OptionsMenu.tsx b/browser/src/libs/options/OptionsMenu.tsx deleted file mode 100644 index 08f44c4a82a3..000000000000 --- a/browser/src/libs/options/OptionsMenu.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { lowerCase, upperFirst } from 'lodash' -import * as React from 'react' - -import { OptionsHeader, OptionsHeaderProps } from './OptionsHeader' -import { ServerURLForm, ServerURLFormProps } from './ServerURLForm' - -interface ConfigurableFeatureFlag { - key: string - value: boolean -} - -export interface OptionsMenuProps - extends OptionsHeaderProps, - Pick> { - sourcegraphURL: ServerURLFormProps['value'] - onURLChange: ServerURLFormProps['onChange'] - onURLSubmit: ServerURLFormProps['onSubmit'] - - isSettingsOpen?: boolean - isActivated: boolean - toggleFeatureFlag: (key: string) => void - featureFlags?: ConfigurableFeatureFlag[] - currentTabStatus?: { - host: string - protocol: string - hasPermissions: boolean - } -} - -const buildFeatureFlagToggleHandler = (key: string, handler: OptionsMenuProps['toggleFeatureFlag']) => () => - handler(key) - -const isFullPage = (): boolean => !new URLSearchParams(window.location.search).get('popup') - -const buildRequestPermissionsHandler = ( - { protocol, host }: NonNullable, - requestPermissions: OptionsMenuProps['requestPermissions'] -) => (event: React.MouseEvent) => { - event.preventDefault() - requestPermissions(`${protocol}//${host}`) -} - -/** - * A list of protocols where we should *not* show the permissions notification. - */ -const PERMISSIONS_PROTOCOL_BLACKLIST = ['chrome:', 'about:'] - -export const OptionsMenu: React.FunctionComponent = ({ - sourcegraphURL, - onURLChange, - onURLSubmit, - isSettingsOpen, - isActivated, - toggleFeatureFlag, - featureFlags, - status, - requestPermissions, - currentTabStatus, - ...props -}) => ( -
- - - {status === 'connected' && - currentTabStatus && - !currentTabStatus.hasPermissions && - !PERMISSIONS_PROTOCOL_BLACKLIST.includes(currentTabStatus.protocol) && ( -
-
- Sourcegraph is not enabled on {currentTabStatus.host}.{' '} - - Grant permissions - {' '} - to enable Sourcegraph. -
-
- )} - {isSettingsOpen && featureFlags && ( -
- -
- {featureFlags.map(({ key, value }) => ( -
- -
- ))} -
-
- )} -
-) diff --git a/browser/src/libs/options/ServerURLForm.scss b/browser/src/libs/options/ServerURLForm.scss deleted file mode 100644 index 21b17954004f..000000000000 --- a/browser/src/libs/options/ServerURLForm.scss +++ /dev/null @@ -1,9 +0,0 @@ -.server-url-form { - &__status-indicator { - display: inline-block; - height: 0.5rem; - width: 0.5rem; - margin-bottom: 0.1em; - border-radius: 50%; - } -} diff --git a/browser/src/libs/options/ServerURLForm.story.tsx b/browser/src/libs/options/ServerURLForm.story.tsx deleted file mode 100644 index 23268e72caed..000000000000 --- a/browser/src/libs/options/ServerURLForm.story.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import * as React from 'react' - -import { action } from '@storybook/addon-actions' -import { storiesOf } from '@storybook/react' - -import '../../app.scss' - -import { interval, Subscription } from 'rxjs' -import { ConnectionErrors, ServerURLForm, ServerURLFormProps } from './ServerURLForm' - -class Container extends React.Component<{}, { value: string; status: ServerURLFormProps['status'] }> { - public state = { value: 'https://sourcegraph.com', status: 'connected' as ServerURLFormProps['status'] } - - public render(): React.ReactNode { - return ( -
- undefined} - urlHasPermissions={true} - /> -
- ) - } - - private onChange = (value: string): void => { - this.setState({ value }) - - action('URL Changed')(value) - } - - private onSubmit = (): void => { - action('Form submitted')(this.state.value) - } -} - -class CyclingStatus extends React.Component<{}, { step: number }> { - public state = { step: 0 } - private subscription = new Subscription() - - private onChange = action('Input onChange fired') - private onSubmit = action('Form onSubmit fired') - - public componentDidMount(): void { - this.subscription.add( - interval(1000).subscribe(() => { - this.setState(({ step }) => ({ step: (step + 1) % 4 })) - }) - ) - } - - public componentWillUnmount(): void { - this.subscription.unsubscribe() - } - - public render(): React.ReactNode { - let status: ServerURLFormProps['status'] = 'connected' - let error: ServerURLFormProps['connectionError'] - let isUpdating: boolean | undefined - - if (this.state.step === 1) { - status = 'connecting' - } else if (this.state.step === 2) { - status = 'error' - error = ConnectionErrors.AuthError - } else if (this.state.step === 3) { - isUpdating = true - } - - return ( -
- undefined} - urlHasPermissions={true} - /> -
- ) - } -} - -storiesOf('Options - ServerURLForm', module) - .add('Interactive', () => ) - .add('Cycling Status', () => ) - .add('Error Status', () => ( -
- undefined} - urlHasPermissions={true} - /> -
- )) diff --git a/browser/src/libs/options/ServerURLForm.test.tsx b/browser/src/libs/options/ServerURLForm.test.tsx deleted file mode 100644 index 3c9d2ad92847..000000000000 --- a/browser/src/libs/options/ServerURLForm.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from 'react' -import { cleanup, fireEvent, render } from '@testing-library/react' -import { EMPTY, merge, noop, of, Subject } from 'rxjs' -import { switchMap, tap } from 'rxjs/operators' -import { TestScheduler } from 'rxjs/testing' -import sinon from 'sinon' - -import { ServerURLForm, ServerURLFormProps } from './ServerURLForm' - -describe('ServerURLForm', () => { - afterAll(cleanup) - - test('fires the onChange prop handler', () => { - const onChange = sinon.spy() - const onSubmit = sinon.spy() - - const { container } = render( - - ) - - const urlInput = container.querySelector('input')! - - fireEvent.change(urlInput, { target: { value: 'https://different.com' } }) - - expect(onChange.calledOnce).toBe(true) - expect(onChange.calledWith('https://different.com')).toBe(true) - - expect(onSubmit.notCalled).toBe(true) - }) - - test('updates the input value when the url changes', () => { - const props: ServerURLFormProps = { - value: 'https://sourcegraph.com', - status: 'connected', - onChange: noop, - onSubmit: noop, - urlHasPermissions: false, - requestPermissions: noop, - } - - const { container, rerender } = render() - - const urlInput = container.querySelector('input')! - - rerender() - - const newValue = urlInput.value - - expect(newValue).toEqual('https://different.com') - }) - - test('fires the onSubmit prop handler when the form is submitted', () => { - const onSubmit = sinon.spy() - - const { container } = render( - - ) - - const form = container.querySelector('form')! - - fireEvent.submit(form) - - expect(onSubmit.calledOnce).toBe(true) - }) - - test('fires the onSubmit prop handler after 5s on inactivity after a change', () => { - const scheduler = new TestScheduler((a, b) => expect(a).toEqual(b)) - - scheduler.run(({ cold, expectObservable }) => { - const submits = new Subject() - const nextSubmit = (): void => submits.next() - - const { container } = render( - - ) - - const form = container.querySelector('input')! - - const urls: { [key: string]: string } = { - a: 'https://different.com', - } - - const submitObs = cold('a', urls).pipe( - switchMap(url => { - const emit = of(undefined).pipe( - tap(() => { - fireEvent.change(form, { target: { value: url } }) - }), - switchMap(() => EMPTY) - ) - - return merge(submits, emit) - }) - ) - - expectObservable(submitObs).toBe('5s a', { a: undefined }) - }) - }) - - test("doesn't submit after 5 seconds if the form was submitted manually", () => { - const scheduler = new TestScheduler((a, b) => expect(a).toEqual(b)) - - scheduler.run(({ cold, expectObservable }) => { - const changes = new Subject() - const nextChange = (): void => changes.next() - - const submits = new Subject() - const nextSubmit = (): void => submits.next() - - const props: ServerURLFormProps = { - value: 'https://sourcegraph.com', - status: 'connected', - onChange: nextChange, - onSubmit: nextSubmit, - urlHasPermissions: false, - requestPermissions: noop, - } - - const { container } = render() - const form = container.querySelector('input')! - - changes.subscribe(url => { - fireEvent.submit(form) - }) - - const urls: { [key: string]: string } = { - a: 'https://different.com', - } - - const submitObs = cold('a', urls).pipe( - switchMap(url => { - const emit = of(undefined).pipe( - tap(() => { - fireEvent.change(form, { target: { value: url } }) - }), - switchMap(() => EMPTY) - ) - - return merge(submits, emit) - }) - ) - - expectObservable(submitObs).toBe('a', { a: undefined }) - }) - }) -}) diff --git a/browser/src/libs/options/ServerURLForm.tsx b/browser/src/libs/options/ServerURLForm.tsx deleted file mode 100644 index 27b495577bbb..000000000000 --- a/browser/src/libs/options/ServerURLForm.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { upperFirst } from 'lodash' -import * as React from 'react' -import { merge, Subject, Subscription } from 'rxjs' -import { debounceTime, takeUntil } from 'rxjs/operators' - -export enum ConnectionErrors { - AuthError, - UnableToConnect, -} - -const statusClassNames = { - connecting: 'warning', - connected: 'success', - error: 'danger', -} - -/** - * This is the [Word-Joiner](https://en.wikipedia.org/wiki/Word_joiner) character. - * We are using this as a   that has no width to maintain line height when the - * url is being updated (therefore no text is in the status indicator). - */ -const zeroWidthNbsp = '\u2060' - -export interface ServerURLFormProps { - className?: string - status: keyof typeof statusClassNames - connectionError?: ConnectionErrors - - value: string - onChange: (value: string) => void - onSubmit: () => void - urlHasPermissions: boolean - requestPermissions: (url: string) => void - - /** - * Overrides `this.props.status` and `this.state.isUpdating` in order to - * display the `isUpdating` UI state. This is only intended for use in storybooks. - */ - overrideUpdatingState?: boolean -} - -interface State { - isUpdating: boolean -} - -export class ServerURLForm extends React.Component { - public state: State = { isUpdating: false } - - private inputElement = React.createRef() - - private componentUpdates = new Subject() - private changes = new Subject() - private submits = new Subject() - - private subscriptions = new Subscription() - - constructor(props: ServerURLFormProps) { - super(props) - - this.subscriptions.add( - this.changes.subscribe(value => { - this.props.onChange(value) - this.setState({ isUpdating: true }) - }) - ) - - const submitAfterInactivity = this.changes.pipe(debounceTime(5000), takeUntil(this.submits)) - - this.subscriptions.add( - merge(this.submits, submitAfterInactivity).subscribe(() => { - this.props.onSubmit() - this.setState({ isUpdating: false }) - }) - ) - } - - public componentDidUpdate(): void { - this.componentUpdates.next(this.state) - } - - public componentWillUnmount(): void { - this.subscriptions.unsubscribe() - } - - public render(): React.ReactNode { - return ( - // eslint-disable-next-line react/forbid-elements -
- -
-
- - - {' '} - - {this.isUpdating ? zeroWidthNbsp : upperFirst(this.props.status)} - - - -
- -
- {!this.state.isUpdating && this.props.connectionError === ConnectionErrors.AuthError && ( -
- Authentication to Sourcegraph failed.{' '} - - Sign in to your instance - {' '} - to continue. -
- )} - {!this.state.isUpdating && this.props.connectionError === ConnectionErrors.UnableToConnect && ( -
-

- Unable to connect to{' '} - - {this.props.value} - - . Ensure the URL is correct and you are{' '} - - signed in - - . -

- {!this.props.urlHasPermissions && ( -

- You may need to{' '} - - grant the Sourcegraph browser extension additional permissions - {' '} - for this URL. -

- )} -

- Site admins: ensure that{' '} - - all users can create access tokens - - . -

-
- )} -
- ) - } - - private handleChange = ({ target: { value } }: React.ChangeEvent): void => { - this.changes.next(value) - } - - private handleSubmit = (event: React.FormEvent): void => { - event.preventDefault() - - this.submits.next() - } - - private requestServerURLPermissions = (): void => this.props.requestPermissions(this.props.value) - - private get isUpdating(): boolean { - if (typeof this.props.overrideUpdatingState !== 'undefined') { - console.warn( - ' - You are using the `overrideUpdatingState` prop which is ' + - 'only intended for use with storybooks. Keeping this state in multiple places can ' + - 'lead to race conditions and will be hard to maintain.' - ) - - return this.props.overrideUpdatingState - } - - return this.state.isUpdating - } -} diff --git a/browser/src/libs/options/__snapshots__/OptionsMenu.test.tsx.snap b/browser/src/libs/options/__snapshots__/OptionsMenu.test.tsx.snap deleted file mode 100644 index 01cf1f41fcc7..000000000000 --- a/browser/src/libs/options/__snapshots__/OptionsMenu.test.tsx.snap +++ /dev/null @@ -1,625 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`OptionsMenu doesn't render the permissions alert on about://addons 1`] = ` -
-
-
- -
- v - 0.0.0 -
-
-
- - -
-
-
- -
-
- - - - - - Connected - - - -
- -
-
-
-`; - -exports[`OptionsMenu doesn't render the permissions alert on chrome://extensions 1`] = ` -
-
-
- -
- v - 0.0.0 -
-
-
- - -
-
-
- -
-
- - - - - - Connected - - - -
- -
-
-
-`; - -exports[`OptionsMenu doesn't render the permissions alert on chrome://newtab 1`] = ` -
-
-
- -
- v - 0.0.0 -
-
-
- - -
-
-
- -
-
- - - - - - Connected - - - -
- -
-
-
-`; - -exports[`OptionsMenu renders a default state 1`] = ` -
-
-
- -
- v - 0.0.0 -
-
-
- - -
-
-
- -
-
- - - - - - Connected - - - -
- -
-
-
-`; - -exports[`OptionsMenu renders the current tab permissions alert 1`] = ` -
-
-
- -
- v - 0.0.0 -
-
-
- - -
-
-
- -
-
- - - - - - Connected - - - -
- -
-
-
-
- Sourcegraph is not enabled on - - gitlab.com - - . - - - Grant permissions - - - to enable Sourcegraph. -
-
-
-`; - -exports[`OptionsMenu renders the feature flags 1`] = ` -
-
-
- -
- v - 0.0.0 -
-
-
- - -
-
-
- -
-
- - - - - - Connected - - - -
- -
-
-
- -
-
- -
-
- -
-
-
-
-`; diff --git a/browser/src/libs/phabricator/backend.tsx b/browser/src/libs/phabricator/backend.tsx deleted file mode 100644 index 51f64dce394c..000000000000 --- a/browser/src/libs/phabricator/backend.tsx +++ /dev/null @@ -1,589 +0,0 @@ -import { from, Observable, of, throwError } from 'rxjs' -import { map, mapTo, switchMap, catchError } from 'rxjs/operators' -import { dataOrThrowErrors, gql } from '../../../../shared/src/graphql/graphql' -import * as GQL from '../../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { memoizeObservable } from '../../../../shared/src/util/memoizeObservable' -import { storage } from '../../browser/storage' -import { isExtension } from '../../context' -import { resolveRepo } from '../../shared/repo/backend' -import { normalizeRepoName } from './util' -import { REPO_NOT_FOUND_ERROR_NAME } from '../../../../shared/src/backend/errors' -import { RepoSpec, FileSpec, ResolvedRevSpec } from '../../../../shared/src/util/url' -import { RevisionSpec, DiffSpec, BaseDiffSpec } from '.' -import { checkOk } from '../../../../shared/src/backend/fetch' -import { fromFetch } from '../../../../shared/src/graphql/fromFetch' - -interface PhabEntity { - id: string // e.g. "48" - type: string // e.g. "RHURI" - phid: string // e.g. "PHID-RHURI-..." -} - -interface ConduitURI extends PhabEntity { - fields: { - uri: { - raw: string // e.g. https://secure.phabricator.com/source/phabricator.git", - display: string // e.g. https://secure.phabricator.com/source/phabricator.git", - effective: string // e.g. https://secure.phabricator.com/source/phabricator.git", - normalized: string // e.g. secure.phabricator.com/source/phabricator", - disabled: boolean - } - } -} - -interface ConduitRepo extends PhabEntity { - fields: { - name: string - vcs: string // e.g. 'git' - callsign: string - shortName: string - status: 'active' | 'inactive' - } - attachments: { - uris: { - uris: ConduitURI[] - } - } -} - -export interface ConduitReposResponse { - data: ConduitRepo[] -} - -interface ConduitRef { - ref: string - type: 'base' | 'diff' - commit: string // a SHA - remote: { - uri: string - } -} - -interface ConduitDiffChange { - oldPath: string - currentPath: string -} - -interface ConduitDiffDetails { - branch: string - sourceControlBaseRevision: string // the merge base commit - description: string // e.g. 'rNZAP9bee3bc2cd3068dd97dfa87068c4431c5d6093ef' - changes: ConduitDiffChange[] - dateCreated: string - authorName: string - authorEmail: string - properties: { - 'arc.staging': { - status: string - refs: ConduitRef[] - } - 'local:commits': string[] - } -} - -interface ConduitDiffDetailsResponse { - [id: string]: ConduitDiffDetails -} - -/** - * Creates the `FormData` used to pass parameters along with Conduit API requests, - * including the CSRF token. - */ -function createConduitRequestForm(): FormData { - const searchForm = document.querySelector('.phabricator-search-menu form') - if (!searchForm) { - throw new Error('cannot create conduit request form') - } - const form = new FormData() - form.set('__csrf__', searchForm.querySelector('input[name=__csrf__]')!.value) - form.set('__form__', searchForm.querySelector('input[name=__form__]')!.value) - return form -} - -/** - * Native installation of the Phabricator extension does not allow for us to fetch the style.bundle from a script element. - * To get around this we fetch the bundled CSS contents and append it to the DOM. - */ -export async function getPhabricatorCSS(sourcegraphURL: string): Promise { - const bundleUID = process.env.BUNDLE_UID! - const resp = await fetch(sourcegraphURL + `/.assets/extension/css/style.bundle.css?v=${bundleUID}`, { - method: 'GET', - credentials: 'include', - headers: new Headers({ Accept: 'text/html' }), - }) - return resp.text() -} - -type ConduitResponse = - | { error_code: null; error_info: null; result: T } - | { error_code: string; error_info: string; result: null } - -export type QueryConduitHelper = (endpoint: string, params: {}) => Observable - -/** - * Generic helper to query the Phabricator Conduit API. - */ -export function queryConduitHelper(endpoint: string, params: {}): Observable { - const form = createConduitRequestForm() - for (const [key, value] of Object.entries(params)) { - form.set(`params[${key}]`, JSON.stringify(value)) - } - return fromFetch( - window.location.origin + endpoint, - { - method: 'POST', - body: form, - credentials: 'include', - headers: { - Accept: 'application/json', - }, - }, - response => checkOk(response).json() - ).pipe( - map((response: ConduitResponse) => { - if (response.error_code !== null) { - throw new Error(`error ${response.error_code}: ${response.error_info}`) - } - return response.result - }) - ) -} - -/** - * Queries the Phabricator Conduit API for the {@link ConduitDiffDetails} matching the given - * revision and diff IDs. {@link ConduitDiffDetails} notably contain the staging details for the diff, - * including the base and head commit IDs on the staging repository. - */ -function getDiffDetailsFromConduit( - { diffID, revisionID }: RevisionSpec & DiffSpec, - queryConduit = queryConduitHelper -): Observable { - return queryConduit('/api/differential.querydiffs', { - ids: [diffID], - revisionIDs: [revisionID], - }).pipe(map(diffDetails => diffDetails[String(diffID)])) -} - -function getRawDiffFromConduit(diffID: number, queryConduit = queryConduitHelper): Observable { - return queryConduit('/api/differential.getrawdiff', { diffID }) -} - -interface ConduitDifferentialQueryResponse { - [index: string]: { - repositoryPHID: string | null - } -} - -/** - * Queries the Phabricator Conduit API for the PHID (Phabricator's opaque unique ID) - * of the repository matching the given revisionID. - */ -function getRepoPHIDForRevisionID(revisionID: number, queryConduit = queryConduitHelper): Observable { - return queryConduit('/api/differential.query', { ids: [revisionID] }).pipe( - map(result => { - const phid = result['0'].repositoryPHID - if (!phid) { - // This happens for revisions that were created without an associated repository - throw new Error(`no repositoryPHID for revision ${revisionID}`) - } - return phid - }) - ) -} - -interface CreatePhabricatorRepoOptions extends Pick { - callsign: string - repoName: string - phabricatorURL: string -} - -const createPhabricatorRepo = memoizeObservable( - ({ requestGraphQL, ...variables }: CreatePhabricatorRepoOptions): Observable => - requestGraphQL({ - request: gql` - mutation addPhabricatorRepo($callsign: String!, $repoName: String!, $phabricatorURL: String!) { - addPhabricatorRepo(callsign: $callsign, uri: $repoName, url: $phabricatorURL) { - alwaysNil - } - } - `, - variables, - mightContainPrivateInfo: true, - }).pipe(mapTo(undefined)), - ({ callsign }) => callsign -) - -interface PhabricatorRepoDetails { - callsign: string - rawRepoName: string -} - -/** - * Queries the Phabricator Conduit API for a repository matching the given callsign, - * and emits the {@link PhabricatorRepoDetails} if found. - */ -export function getRepoDetailsFromCallsign( - callsign: string, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit: QueryConduitHelper -): Observable { - return queryConduit('/api/diffusion.repository.search', { - constraints: { callsigns: [callsign] }, - attachments: { uris: true }, - }).pipe( - switchMap(({ data }) => { - const repo = data[0] - if (!repo) { - throw new Error(`could not locate repo with callsign ${callsign}`) - } - if (!repo.attachments || !repo.attachments.uris) { - throw new Error(`could not locate git uri for repo with callsign ${callsign}`) - } - return convertConduitRepoToRepoDetails(repo) - }), - switchMap((details: PhabricatorRepoDetails | null) => { - if (!details) { - return throwError(new Error('could not parse repo details')) - } - return createPhabricatorRepo({ - callsign, - repoName: details.rawRepoName, - phabricatorURL: window.location.origin, - requestGraphQL, - }).pipe(mapTo(details)) - }) - ) -} - -/** - * Queries the Phabricator Conduit API sourcegraph.configuration endpoint. - * - * The Phabricator extension updates the window object automatically, but in the - * case it fails we query the conduit API. - */ -export function getSourcegraphURLFromConduit(): Promise { - return queryConduitHelper<{ url: string }>('/api/sourcegraph.configuration', {}) - .pipe(map(({ url }) => url)) - .toPromise() -} - -function getRepoDetailsFromRepoPHID( - phid: string, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit = queryConduitHelper -): Observable { - return queryConduit('/api/diffusion.repository.search', { - constraints: { - phids: [phid], - }, - attachments: { - uris: true, - }, - }).pipe( - switchMap(({ data }) => { - const repo = data[0] - if (!repo) { - throw new Error(`could not locate repo with phid ${phid}`) - } - if (!repo.attachments || !repo.attachments.uris) { - throw new Error(`could not locate git uri for repo with phid ${phid}`) - } - return from(convertConduitRepoToRepoDetails(repo)).pipe( - switchMap((details: PhabricatorRepoDetails | null) => { - if (!details) { - return throwError(new Error('could not parse repo details')) - } - if (!repo.fields || !repo.fields.callsign) { - return throwError(new Error('callsign not found')) - } - return createPhabricatorRepo({ - callsign: repo.fields.callsign, - repoName: details.rawRepoName, - phabricatorURL: window.location.origin, - requestGraphQL, - }).pipe(mapTo(details)) - }) - ) - }) - ) -} - -/** - * Queries the Phabricator Conduit API for a repository matching the given revisionID, - * and emits the {@link PhabricatorRepoDetails} for that repository if found. - */ -export function getRepoDetailsFromRevisionID( - revisionID: number, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit = queryConduitHelper -): Observable { - return getRepoPHIDForRevisionID(revisionID, queryConduit).pipe( - switchMap(repositoryPHID => getRepoDetailsFromRepoPHID(repositoryPHID, requestGraphQL, queryConduit)) - ) -} - -async function convertConduitRepoToRepoDetails(repo: ConduitRepo): Promise { - if (isExtension) { - const items = await storage.managed.get() - if (items.phabricatorMappings) { - for (const mapping of items.phabricatorMappings) { - if (mapping.callsign === repo.fields.callsign) { - return { - callsign: repo.fields.callsign, - rawRepoName: mapping.path, - } - } - } - } - return convertToDetails(repo) - } - // The path to a phabricator repository on a Sourcegraph instance may differ than it's URI / name from the - // phabricator conduit API. Since we do not currently send the PHID with the Phabricator repository this a - // backwards work around configuration setting to ensure mappings are correct. This logic currently exists - // in the browser extension options menu. - type Mappings = { callsign: string; path: string }[] - const mappingsString = window.localStorage.getItem('PHABRICATOR_CALLSIGN_MAPPINGS') - const callsignMappings = mappingsString - ? (JSON.parse(mappingsString) as Mappings) - : window.PHABRICATOR_CALLSIGN_MAPPINGS || [] - const details = convertToDetails(repo) - if (callsignMappings) { - for (const mapping of callsignMappings) { - if (mapping.callsign === repo.fields.callsign) { - return { - callsign: repo.fields.callsign, - rawRepoName: mapping.path, - } - } - } - } - return details -} - -function convertToDetails(repo: ConduitRepo): PhabricatorRepoDetails | null { - const enabledURIs = repo.attachments.uris.uris - // Filter out disabled URIs - .filter(({ fields }) => !fields.uri.disabled) - .map(({ fields }) => ({ - isExternalURI: !fields.uri.normalized.replace('\\', '').startsWith(window.location.host + '/'), - rawURI: fields.uri.raw, - })) - if (enabledURIs.length === 0) { - return null - } - // Use the external URI if there is one, otherwise use the first enabled URI. - const { rawURI } = enabledURIs.find(({ isExternalURI }) => isExternalURI) ?? enabledURIs[0] - const rawRepoName = normalizeRepoName(rawURI) - return { callsign: repo.fields.callsign, rawRepoName } -} - -interface ResolveStagingOptions extends Pick, RepoSpec, DiffSpec { - baseRev: string - patch?: string - date?: string - authorName?: string - authorEmail?: string - description?: string -} - -/** - * Returns the commit ID of the one-off commit created on the Sourcegraph instance for the given - * repo/diffID/patch, creating that commit if needed. - */ -const resolveStagingRev = ({ requestGraphQL, ...variables }: ResolveStagingOptions): Observable => - requestGraphQL({ - request: gql` - mutation ResolveStagingRev( - $repoName: String! - $diffID: ID! - $baseRev: String! - $patch: String - $date: String - $authorName: String - $authorEmail: String - $description: String - ) { - resolvePhabricatorDiff( - repoName: $repoName - diffID: $diffID - baseRev: $baseRev - patch: $patch - date: $date - authorName: $authorName - authorEmail: $authorEmail - description: $description - ) { - oid - } - } - `, - variables, - mightContainPrivateInfo: true, - }).pipe( - map(dataOrThrowErrors), - map(({ resolvePhabricatorDiff }) => { - if (!resolvePhabricatorDiff) { - throw new Error('Empty resolvePhabricatorDiff') - } - const { oid } = resolvePhabricatorDiff - if (!oid) { - throw new Error('Could not resolve staging rev: empty oid') - } - return { commitID: oid } - }) - ) - -function hasThisFileChanged(filePath: string, changes: ConduitDiffChange[]): boolean { - for (const change of changes) { - if (change.currentPath === filePath) { - return true - } - } - return false -} - -interface ResolveDiffOpt extends RepoSpec, FileSpec, RevisionSpec, DiffSpec, BaseDiffSpec { - isBase: boolean - useDiffForBase: boolean // indicates whether the base should use the diff commit - useBaseForDiff: boolean // indicates whether the diff should use the base commit -} - -interface PropsWithDiffDetails extends ResolveDiffOpt { - diffDetails: ConduitDiffDetails -} - -function getPropsWithDiffDetails( - props: ResolveDiffOpt, - queryConduit: QueryConduitHelper -): Observable { - return getDiffDetailsFromConduit(props, queryConduit).pipe( - switchMap(diffDetails => { - if (props.isBase || !props.baseDiffID || hasThisFileChanged(props.filePath, diffDetails.changes)) { - // no need to update props - return of({ - ...props, - diffDetails, - }) - } - return getDiffDetailsFromConduit(props, queryConduit).pipe( - map( - (diffDetails): PropsWithDiffDetails => ({ - ...props, - diffDetails, - diffID: props.baseDiffID!, - useBaseForDiff: true, - }) - ) - ) - }) - ) -} - -function getStagingDetails( - propsWithInfo: PropsWithDiffDetails -): { repoName: string; ref: ConduitRef; unconfigured: boolean } | undefined { - const stagingInfo = propsWithInfo.diffDetails.properties['arc.staging'] - if (!stagingInfo) { - return undefined - } - let key: string - if (propsWithInfo.isBase) { - const type = propsWithInfo.useDiffForBase ? 'diff' : 'base' - key = `refs/tags/phabricator/${type}/${propsWithInfo.diffID}` - } else { - const type = propsWithInfo.useBaseForDiff ? 'base' : 'diff' - key = `refs/tags/phabricator/${type}/${propsWithInfo.diffID}` - } - for (const ref of propsWithInfo.diffDetails.properties['arc.staging'].refs) { - if (ref.ref === key) { - const remote = ref.remote.uri - if (remote) { - return { - repoName: normalizeRepoName(remote), - ref, - unconfigured: stagingInfo.status === 'repository.unconfigured', - } - } - } - } - return undefined -} - -interface ResolvedDiff extends ResolvedRevSpec { - /** - * The name of the staging repository, if it is synced to the Sourcegraph instance. - */ - stagingRepoName?: string -} - -/** - * Emits the {@link ResolvedDiff} for the base or head commit of a Phabricator diff. - * - If possible, the base commit from the source control repository will be used. - * - If a staging repository is configured and is synced to the Sourcegraph instance, - * the commit ID on the staging repository will be returned, and the {@link ResolvedDiff} - * will include the `stagingRepoName`. - * - If a staging repository is configured but it isn't synced to the Sourcegraph instance, - * a one-off staging commit will be created from the raw diff on the Sourcegraph instance, - * and its commit ID will be returned ({@see resolveStagingRev}). - * - If no staging repository is configured, and the commit doesn't exist on the Sourcegraph instance - * (for example in the case of a revision created through the Phabricator UI from a raw diff), a one-off - * staging commit will be created from the raw diff on the Sourcegraph instance, and its commit ID will - * be returned ({@see resolveStagingRev}). - * - */ -export function resolveDiffRev( - props: ResolveDiffOpt, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit: QueryConduitHelper -): Observable { - return getPropsWithDiffDetails(props, queryConduit).pipe( - switchMap(({ diffDetails, ...props }) => { - const stagingDetails = getStagingDetails({ diffDetails, ...props }) - const conduitProps = { - repoName: props.repoName, - diffID: props.diffID, - baseRev: diffDetails.sourceControlBaseRevision, - date: diffDetails.dateCreated, - authorName: diffDetails.authorName, - authorEmail: diffDetails.authorEmail, - description: diffDetails.description, - } - - // When resolving the base, use the commit ID from the diff details. - if (props.isBase && !props.useDiffForBase) { - return of({ - commitID: diffDetails.sourceControlBaseRevision, - }) - } - if (!stagingDetails || stagingDetails.unconfigured) { - // If there are no staging details, get the patch from the conduit API, - // create a one-off commit on the Sourcegraph instance from the patch, - // and resolve to the commit ID returned by the Sourcegraph instance. - return getRawDiffFromConduit(props.diffID, queryConduit).pipe( - switchMap(patch => resolveStagingRev({ ...conduitProps, patch, requestGraphQL })) - ) - } - - // If staging details are configured, first check if the repo is present on the Sourcegraph instance. - return resolveRepo({ rawRepoName: stagingDetails.repoName, requestGraphQL }).pipe( - // If the repo is present on the Sourcegraph instance, - // use the commitID and repo name from the staging details. - mapTo({ - commitID: stagingDetails.ref.commit, - stagingRepoName: stagingDetails.repoName, - }), - // Otherwise, create a one-off commit containing the patch on the Sourcegraph instance, - // and resolve to the commit ID returned by the Sourcegraph instance. - catchError(error => { - if (error.name !== REPO_NOT_FOUND_ERROR_NAME) { - throw error - } - return getRawDiffFromConduit(props.diffID, queryConduit).pipe( - switchMap(patch => resolveStagingRev({ ...conduitProps, patch, requestGraphQL })) - ) - }) - ) - }) - ) -} diff --git a/browser/src/libs/phabricator/code_intelligence.test.ts b/browser/src/libs/phabricator/code_intelligence.test.ts deleted file mode 100644 index 2125a5bae9ab..000000000000 --- a/browser/src/libs/phabricator/code_intelligence.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { testToolbarMountGetter } from '../code_intelligence/code_intelligence_test_utils' -import { commitCodeView, diffCodeView } from './code_intelligence' - -describe('phabricator/code_intelligence', () => { - describe('diffCodeView', () => { - describe('getToolbarMount()', () => { - for (const view of ['split', 'unified']) { - testToolbarMountGetter( - `${__dirname}/__fixtures__/code-views/2017.09-r1/differential/${view}.html`, - diffCodeView.getToolbarMount - ) - } - }) - }) - describe('commitCodeView', () => { - describe('getToolbarMount()', () => { - for (const view of ['split', 'unified']) { - testToolbarMountGetter( - `${__dirname}/__fixtures__/code-views/2017.09-r1/commit/${view}.html`, - commitCodeView.getToolbarMount - ) - } - }) - }) - // TODO sourceCodeView, currently not possible because code view element does not contain toolbar -}) diff --git a/browser/src/libs/phabricator/code_intelligence.ts b/browser/src/libs/phabricator/code_intelligence.ts deleted file mode 100644 index 375fece47ddb..000000000000 --- a/browser/src/libs/phabricator/code_intelligence.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { AdjustmentDirection, PositionAdjuster } from '@sourcegraph/codeintellify' -import { Position } from '@sourcegraph/extension-api-types' -import { map } from 'rxjs/operators' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { FileSpec, RepoSpec, ResolvedRevSpec, RevSpec } from '../../../../shared/src/util/url' -import { fetchBlobContentLines } from '../../shared/repo/backend' -import { CodeHost } from '../code_intelligence' -import { CodeView, toCodeViewResolver } from '../code_intelligence/code_views' -import { ViewResolver } from '../code_intelligence/views' -import { convertSpacesToTabs, spacesToTabsAdjustment } from '.' -import { diffDomFunctions, diffusionDOMFns } from './dom_functions' -import { resolveDiffFileInfo, resolveDiffusionFileInfo, resolveRevisionFileInfo } from './file_info' -import { NotificationType } from '../../../../shared/src/api/client/services/notifications' - -/** - * Gets the actual text content we care about and returns the number of characters we have stripped - * so that we can adjust accordingly. - */ -const getTextContent = (element: HTMLElement): { textContent: string; adjust: number } => { - let textContent = element.textContent || '' - let adjust = 0 - - // For some reason, phabricator adds an invisible element to the beginning of lines containing the diff indicator - // followed by a space (ex: '+ '). We need to adjust the position accordingly. - if (element.firstElementChild && element.firstElementChild.classList.contains('aural-only')) { - const pre = element.firstElementChild.textContent || '' - // Codeintellify handles ignoring one character for diff indicators so we'll allow it to adjust for that. - adjust = pre.replace(/^(\+|-)/, '').length - - // Get rid of the characters we have adjusted for. - textContent = textContent.substr(pre.length - adjust) - } - - // Phabricator adds a no-width-space to the beginning of the line in some cases. - // We need to strip that and account for it here. - if (textContent.charCodeAt(0) === 8203) { - textContent = textContent.substr(1) - adjust++ - } - - return { textContent, adjust } -} - -const adjustCharacter = (position: Position, adjustment: number): Position => ({ - line: position.line, - character: position.character + adjustment, -}) - -const getPositionAdjuster = ( - requestGraphQL: PlatformContext['requestGraphQL'] -): PositionAdjuster => ({ direction, codeView, position }) => - fetchBlobContentLines({ ...position, requestGraphQL }).pipe( - map(lines => { - const codeElement = diffDomFunctions.getCodeElementFromLineNumber(codeView, position.line, position.part) - if (!codeElement) { - throw new Error('(adjustPosition) could not find code element for line provided') - } - - const textContentInfo = getTextContent(codeElement) - - const documentLineContent = textContentInfo.textContent - const actualLineContent = lines[position.line - 1] - - // See if we should adjust for whitespace changes. - const convertSpaces = convertSpacesToTabs(actualLineContent, documentLineContent) - - // Whether the adjustment should add or subtract from the given position. - const modifier = direction === AdjustmentDirection.CodeViewToActual ? -1 : 1 - - return convertSpaces - ? adjustCharacter( - position, - (spacesToTabsAdjustment(documentLineContent) + textContentInfo.adjust) * modifier - ) - : adjustCharacter(position, textContentInfo.adjust * modifier) - }) - ) - -const toolbarButtonProps = { - className: 'button grey button-grey has-icon has-text phui-button-default msl', -} -export const commitCodeView = { - dom: diffDomFunctions, - resolveFileInfo: resolveRevisionFileInfo, - getPositionAdjuster, - getToolbarMount: (codeView: HTMLElement): HTMLElement => { - let mount = codeView.querySelector('.sourcegraph-phabricator-code-view-toolbar-mount') - if (mount) { - return mount - } - const actions = codeView.querySelector('.differential-changeset-buttons') - if (!actions) { - throw new Error('Unable to find action links for revision') - } - - mount = document.createElement('div') - mount.style.display = 'inline-block' - mount.classList.add('sourcegraph-phabricator-code-view-toolbar-mount') - - actions.insertAdjacentElement('afterbegin', mount) - - return mount - }, - toolbarButtonProps, -} - -export const diffCodeView = { - dom: diffDomFunctions, - resolveFileInfo: resolveDiffFileInfo, - getPositionAdjuster, - getToolbarMount: (codeView: HTMLElement): HTMLElement => { - const className = 'sourcegraph-phabricator-code-view-toolbar-mount' - const existingMount = codeView.querySelector('.' + className) - if (existingMount) { - return existingMount - } - const mountLocation = codeView.querySelector('.differential-changeset-buttons') - if (!mountLocation) { - throw new Error('Unable to find action links for changeset') - } - const mount = document.createElement('div') - mount.style.display = 'inline-block' - mount.classList.add(className) - mountLocation.prepend(mount, ' ') - return mount - }, - toolbarButtonProps, - isDiff: true, -} - -const differentialChangesetCodeViewResolver: ViewResolver = { - selector: '.differential-changeset', - resolveView: (element: HTMLElement): CodeView => { - if (window.location.pathname.match(/^\/r/)) { - return { element, ...commitCodeView } - } - return { element, ...diffCodeView } - }, -} - -// TODO this code view does not include the toolbar, -// which makes it not possible to test getToolbarMount() -// Fix after https://github.com/sourcegraph/sourcegraph/issues/3271 -const diffusionSourceCodeViewResolver = toCodeViewResolver('.diffusion-source', { - dom: diffusionDOMFns, - resolveFileInfo: resolveDiffusionFileInfo, - getToolbarMount: () => { - const actions = document.querySelector('.phui-two-column-content .phui-header-action-links') - if (!actions) { - throw new Error('unable to find file actions') - } - - const mount = document.createElement('div') - mount.style.display = 'inline-block' - mount.classList.add('sourcegraph-phabricator-code-view-toolbar-mount') - - actions.insertAdjacentElement('afterbegin', mount) - - return mount - }, - toolbarButtonProps, -}) - -// Matches Diffusion single file code views on recent Phabricator versions. -const phabSourceCodeViewResolver = toCodeViewResolver('.phabricator-source-code-container', { - dom: diffusionDOMFns, - resolveFileInfo: resolveDiffusionFileInfo, -}) - -export const checkIsPhabricator = (): boolean => !!document.querySelector('.phabricator-wordmark') - -export const phabricatorCodeHost: CodeHost = { - codeViewResolvers: [ - differentialChangesetCodeViewResolver, - diffusionSourceCodeViewResolver, - phabSourceCodeViewResolver, - ], - type: 'phabricator', - name: 'Phabricator', - check: checkIsPhabricator, - - // TODO: handle parsing selected line number from Phabricator href, - // and find a way to listen to changes (Phabricator does not emit popstate events). - codeViewToolbarClassProps: { - actionItemClass: 'button grey action-item--phabricator', - actionItemIconClass: 'action-item__icon--phabricator', - }, - notificationClassNames: { - [NotificationType.Log]: 'phui-info-view phui-info-severity-plain', - [NotificationType.Success]: 'phui-info-view phui-info-severity-success', - [NotificationType.Info]: 'phui-info-view phui-info-severity-notice', - [NotificationType.Warning]: 'phui-info-view phui-info-severity-warning', - [NotificationType.Error]: 'phui-info-view phui-info-severity-error', - }, - hoverOverlayClassProps: { - className: 'aphront-dialog-view hover-overlay--phabricator', - actionItemClassName: 'button grey hover-overlay-action-item--phabricator', - closeButtonClassName: 'button grey hover-overlay__close-button--phabricator', - infoAlertClassName: 'phui-info-view phui-info-severity-notice', - errorAlertClassName: 'phui-info-view phui-info-severity-error', - }, - codeViewsRequireTokenization: true, -} diff --git a/browser/src/libs/phabricator/dom_functions.test.ts b/browser/src/libs/phabricator/dom_functions.test.ts deleted file mode 100644 index 03d80cee0aca..000000000000 --- a/browser/src/libs/phabricator/dom_functions.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { startCase } from 'lodash' -import { DOMFunctionsTest, testDOMFunctions } from '../code_intelligence/code_intelligence_test_utils' -import { diffDomFunctions, diffusionDOMFns } from './dom_functions' - -type PhabricatorPage = 'commit' | 'differential' - -type PhabricatorVersion = '2017.09-r1' | '2019.21.0-r25' - -interface PhabricatorCodeViewFixture extends Pick {} - -describe('Phabricator DOM functions', () => { - describe('diffDOMFunctions', () => { - const DIFF_FIXTURES: Record< - PhabricatorVersion, - Partial PhabricatorCodeViewFixture>> - > = { - '2017.09-r1': { - commit: () => ({ - lineCases: [ - { diffPart: 'head', lineNumber: 3 }, // not changed - { diffPart: 'head', lineNumber: 7 }, // added - { diffPart: 'base', lineNumber: 10 }, // removed - ], - }), - differential: () => ({ - lineCases: [ - { diffPart: 'head', lineNumber: 9 }, // not changed - { diffPart: 'head', lineNumber: 10 }, // added - // TODO test case for removed line - ], - }), - }, - '2019.21.0-r25': { - differential: view => ({ - lineCases: [ - { diffPart: 'head', lineNumber: 29 }, // not changed - { diffPart: 'head', lineNumber: 64, firstCharacterIsDiffIndicator: view === 'unified' }, // added - { diffPart: 'base', lineNumber: 34, firstCharacterIsDiffIndicator: view === 'unified' }, // removed - ], - }), - }, - } - for (const [version, testCases] of Object.entries(DIFF_FIXTURES)) { - for (const [page, testCase] of Object.entries(testCases)) { - if (!testCase) { - continue - } - describe(`${startCase(page)} Page`, () => { - for (const view of ['split', 'unified'] as const) { - const htmlFixturePath = `${__dirname}/__fixtures__/code-views/${version}/${page}/${view}.html` - describe(`${startCase(view)} view, version ${version}`, () => { - // https://phabricator.sgdev.org/D3#diff-7ddfb3e0 - testDOMFunctions(diffDomFunctions, { - htmlFixturePath, - ...testCase(view), - }) - }) - } - }) - } - } - }) - - describe('diffusionDOMFns', () => { - const htmlFixturePath = `${__dirname}/__fixtures__/code-views/2017.09-r1/diffusion.html` - // https://phabricator.sgdev.org/source/test/browse/master/main.go;48600480cce9f832f7daacab256fbfdeb7002603 - testDOMFunctions(diffusionDOMFns, { - htmlFixturePath, - lineCases: [{ lineNumber: 1 }, { lineNumber: 10 }], - }) - }) -}) diff --git a/browser/src/libs/phabricator/dom_functions.ts b/browser/src/libs/phabricator/dom_functions.ts deleted file mode 100644 index e7b55d2257c8..000000000000 --- a/browser/src/libs/phabricator/dom_functions.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { DiffPart } from '@sourcegraph/codeintellify' -import { DOMFunctions } from '../code_intelligence/code_views' - -/** - * Returns `true` if the element is a line number cell in a Phabricator diff code views. - * - * Supports both `` line number cells, where the line number is the `textContent` (old Phabricator versions) - * and `` line number cells with a `data-n` attribtue (recent Phabricator versions). - */ -const isLineNumberCell = (element: HTMLElement): boolean => - Boolean((element.tagName === 'TH' && element.textContent) || element.dataset.n) - -const getLineNumber = (lineNumberCell: HTMLElement): number => - parseInt((lineNumberCell.tagName === 'TH' ? lineNumberCell.textContent : lineNumberCell.dataset.n) || '', 10) - -/** - * Returns the closest line number cell to a code element in a Phabricator diff. - * If no line number cell can be found, an error is thrown. - * - * Supports both `` line number cells, where the line number is the `textContent` (old Phabricator versions) - * and `` line number cells with a `data-n` attribtue (recent Phabricator versions). - */ -const getLineNumberCellFromCodeElement = (codeElement: HTMLElement): HTMLElement | null => { - let elem: HTMLElement | null = codeElement - while (elem) { - if (isLineNumberCell(elem)) { - return elem - } - elem = elem.previousElementSibling as HTMLElement | null - } - return null -} - -const getDiffLineNumberElementFromLineNumber = ( - codeView: HTMLElement, - line: number, - part?: DiffPart -): HTMLElement | null => { - const lineNumberSelector = codeView.querySelector('td[data-n]') - ? 'td[data-n]' - : `th:nth-of-type(${part === 'base' ? 1 : 2})` - for (const lineNumberCell of codeView.querySelectorAll(lineNumberSelector)) { - if (getLineNumber(lineNumberCell) === line) { - if (part === 'head' && lineNumberCell.previousElementSibling === null) { - // this is the line number for the base element - continue - } - return lineNumberCell - } - } - return null -} - -const getDiffCodeElementFromLineNumber = (codeView: HTMLElement, line: number, part?: DiffPart): HTMLElement | null => { - const lineNumberCell = getDiffLineNumberElementFromLineNumber(codeView, line, part) - let codeElement: HTMLElement | null = lineNumberCell - while ( - codeElement && - // On unified diffs, some or td.n elements can have an empty text content or no data-n attribute - // (for added lines that did not exist in the base, for instance), - // in which case isLineNumberCell returns false. - (codeElement.tagName !== 'TD' || - codeElement.classList.contains('n') || - isLineNumberCell(codeElement) || - codeElement.classList.contains('copy')) - ) { - codeElement = codeElement.nextElementSibling as HTMLElement | null - } - return codeElement -} - -/** - * Implementations of the DOM functions for diff code views on Phabricator - */ -export const diffDomFunctions: DOMFunctions = { - getCodeElementFromTarget: target => { - if (target.tagName === 'TH' || target.classList.contains('copy')) { - return null - } - - const td = target.closest('td') - if (!td) { - return null - } - if (td.classList.contains('show-more') || td.classList.contains('show-context')) { - // This element represents a collapsed part of the diff, it's not a code element. - return null - } - if (!getLineNumberCellFromCodeElement(td)) { - // The element has no associated line number cell: this can be the case when hovering - // 'empty' lines in the base part of a split diff that has added lines. - return null - } - return td - }, - getCodeElementFromLineNumber: getDiffCodeElementFromLineNumber, - getLineElementFromLineNumber: getDiffCodeElementFromLineNumber, - getLineNumberFromCodeElement: codeElement => { - const lineNumberCell = getLineNumberCellFromCodeElement(codeElement) - if (!lineNumberCell) { - throw new Error('Could not find line number cell from code element') - } - return getLineNumber(lineNumberCell) - }, - getDiffCodePart: codeElement => { - // Changed lines have handy class names. - if (codeElement.classList.contains('old')) { - return 'base' - } - if (codeElement.classList.contains('new')) { - return 'head' - } - - const lineNumberCell = getLineNumberCellFromCodeElement(codeElement) - if (!lineNumberCell) { - throw new Error('Could not find line number cell from code element') - } - - // In unified diffs, both 's have a class telling us which side of the diff the line belongs to. - if (lineNumberCell.classList.contains('left')) { - return 'base' - } - if (lineNumberCell.classList.contains('right')) { - return 'head' - } - - // If the lineNumberCell is the first element in the line, the codeElement - // belongs to the base part of the diff. - return lineNumberCell.previousElementSibling ? 'head' : 'base' - }, - isFirstCharacterDiffIndicator: (codeElement: HTMLElement) => { - const firstChild = codeElement.firstElementChild as HTMLElement - if (firstChild?.classList.contains('aural-only')) { - return true - } - - return false - }, -} - -const getDiffusionCodeElementFromLineNumber = ( - codeView: HTMLElement, - line: number, - part?: DiffPart -): HTMLElement | null => { - const row = codeView.querySelector(`tr:nth-of-type(${line})`) - if (!row) { - throw new Error(`unable to find row ${line} from code view`) - } - return row.querySelector('td') -} - -export const diffusionDOMFns: DOMFunctions = { - getCodeElementFromTarget: target => target.closest('td'), - getCodeElementFromLineNumber: getDiffusionCodeElementFromLineNumber, - getLineElementFromLineNumber: getDiffusionCodeElementFromLineNumber, - getLineNumberFromCodeElement: codeElement => { - let lineCell = codeElement as HTMLElement | null - while ( - lineCell !== null && - lineCell.tagName !== 'TH' && - !lineCell.classList.contains('phabricator-source-line') - ) { - lineCell = lineCell.previousElementSibling as HTMLElement | null - } - if (!lineCell) { - throw new Error('could not find line number cell from code element') - } - - const lineAnchor = lineCell.querySelector('a') - if (!lineAnchor) { - throw new Error('could not find line number anchor from code element') - } - // In recent Phabricator versions, the line number is stored in the `data-n` - // attribute, and the textContent is empty. - if (lineAnchor.dataset.n !== undefined) { - const lineNumber = parseInt(lineAnchor.dataset.n, 10) - if (isNaN(lineNumber)) { - throw new Error(`Could not parse lineNumber from data-n attribute: ${lineAnchor.dataset.n}`) - } - return lineNumber - } - const lineNumber = parseInt(lineAnchor.textContent || '', 10) - if (isNaN(lineNumber)) { - throw new Error(`Could not parse lineNumber from lineAnchor.textContent: ${String(lineAnchor.textContent)}`) - } - return lineNumber - }, - isFirstCharacterDiffIndicator: () => false, -} diff --git a/browser/src/libs/phabricator/extension.ts b/browser/src/libs/phabricator/extension.ts deleted file mode 100644 index af1c023d133a..000000000000 --- a/browser/src/libs/phabricator/extension.ts +++ /dev/null @@ -1,60 +0,0 @@ -import '../../../../shared/src/polyfills' - -import { setLinkComponent, AnchorLink } from '../../../../shared/src/components/Link' -import { injectCodeIntelligence } from '../code_intelligence/inject' -import { injectExtensionMarker } from '../sourcegraph/inject' -import { getPhabricatorCSS, getSourcegraphURLFromConduit } from './backend' -import { metaClickOverride } from './util' -import { getAssetsURL } from '../../shared/util/context' - -// Just for informational purposes (see getPlatformContext()) -window.SOURCEGRAPH_PHABRICATOR_EXTENSION = true - -const IS_EXTENSION = false - -setLinkComponent(AnchorLink) - -async function init(): Promise { - /** - * This is the main entry point for the phabricator in-page JavaScript plugin. - */ - if (window.localStorage && window.localStorage.getItem('SOURCEGRAPH_DISABLED') === 'true') { - const value = window.localStorage.getItem('SOURCEGRAPH_DISABLED') - console.log( - `Sourcegraph on Phabricator is disabled because window.localStorage.getItem('SOURCEGRAPH_DISABLED') is set to ${String( - value - )}.` - ) - return - } - - const sourcegraphURL = - window.localStorage.getItem('SOURCEGRAPH_URL') || - window.SOURCEGRAPH_URL || - (await getSourcegraphURLFromConduit()) - const assetsURL = getAssetsURL(sourcegraphURL) - - // Backwards compat: Support Legacy Phabricator extension. Check that the Phabricator integration - // passed the bundle url. Legacy Phabricator extensions inject CSS via the loader.js script - // so we do not need to do this here. - if (!window.SOURCEGRAPH_BUNDLE_URL && !window.localStorage.getItem('SOURCEGRAPH_BUNDLE_URL')) { - injectExtensionMarker() - injectCodeIntelligence({ sourcegraphURL, assetsURL }, IS_EXTENSION) - metaClickOverride() - return - } - - window.SOURCEGRAPH_URL = sourcegraphURL - const css = await getPhabricatorCSS(sourcegraphURL) - const style = document.createElement('style') - style.setAttribute('type', 'text/css') - style.id = 'sourcegraph-styles' - style.textContent = css - document.head.appendChild(style) - window.localStorage.setItem('SOURCEGRAPH_URL', sourcegraphURL) - metaClickOverride() - injectExtensionMarker() - injectCodeIntelligence({ sourcegraphURL, assetsURL }, IS_EXTENSION) -} - -init().catch(err => console.error('Error initializing Phabricator integration', err)) diff --git a/browser/src/libs/phabricator/file_info.test.ts b/browser/src/libs/phabricator/file_info.test.ts deleted file mode 100644 index cbf727a27cf4..000000000000 --- a/browser/src/libs/phabricator/file_info.test.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { readFile } from 'mz/fs' -import { Observable, throwError, of } from 'rxjs' -import { resolveDiffusionFileInfo, resolveRevisionFileInfo, resolveDiffFileInfo } from './file_info' -import { GraphQLResponseMap, mockRequestGraphQL } from '../code_intelligence/test_helpers' -import { QueryConduitHelper } from './backend' -import { SuccessGraphQLResult } from '../../../../shared/src/graphql/graphql' -import { IMutation, IQuery } from '../../../../shared/src/graphql/schema' -import { resetAllMemoizationCaches } from '../../../../shared/src/util/memoizeObservable' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { FileInfo } from '../code_intelligence' - -interface ConduitResponseMap { - [endpoint: string]: (params: any) => Observable -} - -const DEFAULT_CONDUIT_RESPONSES: ConduitResponseMap = { - '/api/diffusion.repository.search': () => - of({ - data: [ - { - fields: { - callsign: 'MUX', - }, - attachments: { - uris: { - uris: [ - { - fields: { - uri: { - raw: 'https://github.com/gorilla/mux', - normalized: 'https://github.com/gorilla/mux', - }, - }, - }, - ], - }, - }, - }, - ], - }), - '/api/differential.query': () => - of({ - 0: { - repositoryPHID: '1', - }, - }), - '/api/differential.querydiffs': (params: { ids: string[]; revisionIDs: string[] }) => - of({ - [params.ids[0]]: { - id: params.ids[0], - revisionID: params.revisionIDs[0], - dateCreated: '1566329300', - dateModified: '1566329305', - sourceControlBaseRevision: 'base-revision', - branch: 'test', - description: ' - test', - changes: [ - { - currentPath: 'helpers/add.go', - }, - { - currentPath: '.arcconfig', - }, - ], - properties: { - 'arc.staging': { - status: 'pushed', - refs: [ - { - ref: `refs/tags/phabricator/base/${params.ids[0]}`, - type: 'base', - commit: `base-${params.ids[0]}`, - remote: { uri: 'https://github.com/lguychard/testing.git' }, - }, - { - ref: `refs/tags/phabricator/diff/${params.ids[0]}`, - type: 'diff', - commit: `diff-${params.ids[0]}`, - remote: { uri: 'https://github.com/lguychard/testing.git' }, - }, - ], - }, - }, - authorName: 'Loรฏc Guychard', - authorEmail: 'loic@sourcegraph.com', - }, - }), - '/api/differential.getrawdiff': () => of('diff'), -} - -const DEFAULT_GRAPHQL_RESPONSES: GraphQLResponseMap = { - addPhabricatorRepo: () => - of({ - data: {}, - errors: undefined, - } as SuccessGraphQLResult), - ResolveRepo: () => - of({ - data: { - repository: null, - }, - errors: undefined, - } as SuccessGraphQLResult), - ResolveStagingRev: () => - of({ - data: { resolvePhabricatorDiff: { oid: 'staging-rev' } }, - errors: undefined, - } as SuccessGraphQLResult), -} - -function mockQueryConduit(responseMap?: ConduitResponseMap): QueryConduitHelper { - return (endpoint, params) => { - const mock = responseMap?.[endpoint] || DEFAULT_CONDUIT_RESPONSES[endpoint] - if (!mock) { - return throwError(new Error(`No mock for endpoint ${endpoint}`)) - } - return mock(params) - } -} - -type Resolver = ( - codeView: HTMLElement, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit: QueryConduitHelper -) => Observable - -interface Fixture { - htmlFixture: string - url: string - codeViewSelector: string - graphQLResponseMap?: GraphQLResponseMap - conduitResponseMap?: ConduitResponseMap -} - -const resolveFileInfoFromFixture = async ( - { url, htmlFixture, codeViewSelector, graphQLResponseMap, conduitResponseMap }: Fixture, - resolver: Resolver -): Promise => { - const fixtureContent = await readFile(`${__dirname}/__fixtures__/pages/${htmlFixture}`, 'utf-8') - document.body.innerHTML = fixtureContent - jsdom.reconfigure({ url }) - const codeView = document.querySelector(codeViewSelector) - if (!codeView) { - throw new Error(`Code view matching selector ${codeViewSelector} not found`) - } - return resolver( - codeView as HTMLElement, - mockRequestGraphQL({ - ...DEFAULT_GRAPHQL_RESPONSES, - ...(graphQLResponseMap || {}), - }), - mockQueryConduit(conduitResponseMap) - ).toPromise() -} - -describe('Phabricator file info', () => { - beforeEach(() => { - resetAllMemoizationCaches() - }) - - describe('resolveRevisionFileInfo()', () => { - test('Commit view', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'commit-view.html', - url: 'https://phabricator.sgdev.org/rMUXeab9c4f3d22d907d728aa0f5918934357866249e', - codeViewSelector: '.differential-changeset', - }, - resolveRevisionFileInfo - ) - ).toEqual({ - baseCommitID: '50fbc3e7fbfcdb4fb850686588071e5f0bdd4a0a', - commitID: 'eab9c4f3d22d907d728aa0f5918934357866249e', - filePath: 'mux.go', - rawRepoName: 'github.com/gorilla/mux', - }) - }) - }) - - describe('resolveDiffusionFileInfo()', () => { - test('Resolves file info for a Diffusion code view', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'diffusion.html', - url: 'https://phabricator.sgdev.org/source/gorilla/browse/master/mux.go', - codeViewSelector: '.diffusion-source', - }, - resolveDiffusionFileInfo - ) - ).toEqual({ - commitID: 'e67b3c02c7195c052acff13261f0c9fd1ba53011', - filePath: 'mux.go', - rawRepoName: 'github.com/gorilla/mux', - }) - }) - - test('Ignores disabled URIs', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'diffusion.html', - url: 'https://phabricator.sgdev.org/source/gorilla/browse/master/mux.go', - codeViewSelector: '.diffusion-source', - conduitResponseMap: { - '/api/diffusion.repository.search': () => - of({ - data: [ - { - fields: { - callsign: 'MUX', - }, - attachments: { - uris: { - uris: [ - { - fields: { - uri: { - raw: 'ssh://git@a.b/gorilla/mux', - normalized: 'a.b/gorilla/mux', - disabled: true, - }, - }, - }, - { - fields: { - uri: { - raw: 'ssh://git@c.d/gorilla/mux', - normalized: 'c.d/gorilla/mux', - disabled: false, - }, - }, - }, - ], - }, - }, - }, - ], - }), - }, - }, - resolveDiffusionFileInfo - ) - ).toEqual({ - commitID: 'e67b3c02c7195c052acff13261f0c9fd1ba53011', - filePath: 'mux.go', - rawRepoName: 'c.d/gorilla/mux', - }) - }) - - test('Repository hosted on phabricator instance', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'diffusion.html', - url: 'https://phabricator.sgdev.org/source/gorilla/browse/master/mux.go', - codeViewSelector: '.diffusion-source', - conduitResponseMap: { - '/api/diffusion.repository.search': () => - of({ - data: [ - { - fields: { - callsign: 'MUX', - }, - attachments: { - uris: { - uris: [ - { - fields: { - uri: { - raw: 'https://phabricator.sgdev.org/gorilla/mux', - normalized: 'phabricator.sgdev.org/gorilla/mux', - disabled: false, - }, - }, - }, - ], - }, - }, - }, - ], - }), - }, - }, - resolveDiffusionFileInfo - ) - ).toEqual({ - commitID: 'e67b3c02c7195c052acff13261f0c9fd1ba53011', - filePath: 'mux.go', - rawRepoName: 'phabricator.sgdev.org/gorilla/mux', - }) - }) - }) - - describe('resolveDiffFileInfo()', () => { - test('Differential revision - no staging repo', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'differential-revision.html', - url: 'https://phabricator.sgdev.org/D7', - codeViewSelector: '.differential-changeset', - conduitResponseMap: { - // Returns diff details without staging details - '/api/differential.querydiffs': params => - of({ - [params.ids[0]]: { - id: params.ids[0], - revisionID: params.revisionIDs[0], - dateCreated: '1566329300', - dateModified: '1566329305', - sourceControlBaseRevision: 'base-revision', - branch: 'test', - description: ' - test', - changes: [], - properties: {}, - authorName: 'Loรฏc Guychard', - authorEmail: 'loic@sourcegraph.com', - }, - }), - }, - }, - resolveDiffFileInfo - ) - ).toEqual({ - baseCommitID: 'base-revision', - baseFilePath: 'helpers/add.go', - baseRawRepoName: 'github.com/gorilla/mux', - commitID: 'staging-rev', - filePath: 'helpers/add.go', - rawRepoName: 'github.com/gorilla/mux', - }) - }) - test('Differential revision - staging repo not synced', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'differential-revision.html', - url: 'https://phabricator.sgdev.org/D7', - codeViewSelector: '.differential-changeset', - }, - resolveDiffFileInfo - ) - ).toEqual({ - baseCommitID: 'base-revision', - baseFilePath: 'helpers/add.go', - baseRawRepoName: 'github.com/gorilla/mux', - commitID: 'staging-rev', - filePath: 'helpers/add.go', - rawRepoName: 'github.com/gorilla/mux', - }) - }) - test('Differential revision - staging repo synced', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'differential-revision.html', - url: 'https://phabricator.sgdev.org/D7', - codeViewSelector: '.differential-changeset', - graphQLResponseMap: { - // Echoes the raw repo name, to represent the fact that the repository - // exists on the Sourcegraph instance. - ResolveRepo: (variables: any) => - of({ - data: { - repository: { - name: variables.rawRepoName, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), - }, - }, - resolveDiffFileInfo - ) - ).toEqual({ - baseCommitID: 'base-revision', - baseFilePath: 'helpers/add.go', - baseRawRepoName: 'github.com/gorilla/mux', - commitID: 'diff-13', - filePath: 'helpers/add.go', - rawRepoName: 'github.com/lguychard/testing', - }) - }) - test('Differential revision - comparing diffs - staging repo not synced', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'differential-diff-comparison.html', - url: 'https://phabricator.sgdev.org/D1?vs=2&id=3&whitespace=ignore-most#toc', - codeViewSelector: '.differential-changeset', - graphQLResponseMap: { - ResolveStagingRev: (variables: any) => - of({ - data: { - resolvePhabricatorDiff: { oid: `staging-rev-${variables.patch as string}` }, - }, - errors: undefined, - } as SuccessGraphQLResult), - }, - conduitResponseMap: { - '/api/differential.getrawdiff': params => - of(`raw-diff-for-diffid-${params.diffID as string}`), - }, - }, - resolveDiffFileInfo - ) - ).toEqual({ - baseCommitID: 'staging-rev-raw-diff-for-diffid-2', - baseFilePath: '.arcconfig', - baseRawRepoName: 'github.com/gorilla/mux', - commitID: 'staging-rev-raw-diff-for-diffid-3', - filePath: '.arcconfig', - rawRepoName: 'github.com/gorilla/mux', - }) - }) - test('Differential revision - comparing diffs - staging repo synced', async () => { - expect( - await resolveFileInfoFromFixture( - { - htmlFixture: 'differential-diff-comparison.html', - url: 'https://phabricator.sgdev.org/D1?vs=2&id=3&whitespace=ignore-most#toc', - codeViewSelector: '.differential-changeset', - graphQLResponseMap: { - // Echoes the raw repo name, to represent the fact that the repository - // exists on the Sourcegraph instance. - ResolveRepo: (variables: any) => - of({ - data: { - repository: { - name: variables.rawRepoName, - }, - }, - errors: undefined, - } as SuccessGraphQLResult), - }, - }, - resolveDiffFileInfo - ) - ).toEqual({ - baseCommitID: 'diff-2', - baseFilePath: '.arcconfig', - baseRawRepoName: 'github.com/lguychard/testing', - commitID: 'diff-3', - filePath: '.arcconfig', - rawRepoName: 'github.com/lguychard/testing', - }) - }) - }) -}) diff --git a/browser/src/libs/phabricator/file_info.ts b/browser/src/libs/phabricator/file_info.ts deleted file mode 100644 index 6b72b0cc64c5..000000000000 --- a/browser/src/libs/phabricator/file_info.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Observable, zip } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { FileInfo } from '../code_intelligence' -import { PhabricatorMode } from '.' -import { queryConduitHelper, resolveDiffRev } from './backend' -import { getFilepathFromFileForDiff, getFilePathFromFileForRevision } from './scrape' -import { getPhabricatorState } from './util' - -export const resolveRevisionFileInfo = ( - codeView: HTMLElement, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit = queryConduitHelper -): Observable => - getPhabricatorState(window.location, requestGraphQL, queryConduit).pipe( - map( - (state): FileInfo => { - if (state.mode !== PhabricatorMode.Revision) { - throw new Error( - `Unexpected Phabricator state for resolveRevisionFileInfo, PhabricatorMode: ${state.mode}` - ) - } - const { rawRepoName, headCommitID, baseCommitID } = state - return { - rawRepoName, - commitID: headCommitID, - baseCommitID, - filePath: getFilePathFromFileForRevision(codeView), - } - } - ) - ) - -export const resolveDiffFileInfo = ( - codeView: HTMLElement, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit = queryConduitHelper -): Observable => - getPhabricatorState(window.location, requestGraphQL, queryConduit).pipe( - switchMap(state => { - if (state.mode !== PhabricatorMode.Differential) { - throw new Error(`Unexpected PhabricatorState for resolveDiffFileInfo, PhabricatorMode: ${state.mode}`) - } - const { filePath, baseFilePath } = getFilepathFromFileForDiff(codeView) - const resolveBaseCommitID = resolveDiffRev( - { - repoName: state.baseRawRepoName, - revisionID: state.revisionID, - diffID: state.baseDiffID || state.diffID, - baseDiffID: state.baseDiffID, - useDiffForBase: Boolean(state.baseDiffID), // if ?vs and base is not `on` i.e. the initial commit) - useBaseForDiff: false, - filePath: baseFilePath || filePath, - isBase: true, - }, - requestGraphQL, - - queryConduit - ).pipe( - map( - ({ commitID, stagingRepoName }): Pick => ({ - baseCommitID: commitID, - baseRawRepoName: stagingRepoName || state.baseRawRepoName, - }) - ) - ) - const resolveHeadCommitID = resolveDiffRev( - { - repoName: state.headRawRepoName, - revisionID: state.revisionID, - diffID: state.diffID, - baseDiffID: state.baseDiffID, - useDiffForBase: false, - useBaseForDiff: false, - filePath, - isBase: false, - }, - requestGraphQL, - - queryConduit - ).pipe( - map( - ({ commitID, stagingRepoName }): Pick => ({ - commitID, - rawRepoName: stagingRepoName || state.headRawRepoName, - }) - ) - ) - return zip(resolveBaseCommitID, resolveHeadCommitID).pipe( - map( - ([baseInfo, headInfo]): FileInfo => ({ - ...baseInfo, - ...headInfo, - baseFilePath: baseFilePath || filePath, - filePath, - }) - ) - ) - }) - ) - -export const resolveDiffusionFileInfo = ( - codeView: HTMLElement, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit = queryConduitHelper -): Observable => - getPhabricatorState(window.location, requestGraphQL, queryConduit).pipe( - map( - (state): FileInfo => { - if (state.mode !== PhabricatorMode.Diffusion) { - throw new Error( - `Unexpected PhabricatorState for resolveDiffusionFileInfo, PhabricatorMode: ${state.mode}` - ) - } - const { filePath, commitID, rawRepoName } = state - return { - filePath, - commitID, - rawRepoName, - } - } - ) - ) diff --git a/browser/src/libs/phabricator/index.tsx b/browser/src/libs/phabricator/index.tsx deleted file mode 100644 index 919207c422e9..000000000000 --- a/browser/src/libs/phabricator/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { FileSpec, RawRepoSpec, ResolvedRevSpec } from '../../../../shared/src/util/url' - -export enum PhabricatorMode { - Diffusion = 1, - Differential, - Revision, - Change, -} - -export interface DiffusionState extends RawRepoSpec, ResolvedRevSpec, FileSpec { - mode: PhabricatorMode.Diffusion -} - -export interface RevisionSpec { - /** - * The ID of the revision in Differential. - * A revision is a set of changes up for review in Differential. - * See https://secure.phabricator.com/book/phabricator/article/differential/#how-review-works - */ - revisionID: number -} - -export interface DiffSpec { - /** - * The ID of the 'head' diff that is being viewed in the Differential UI. - * A Differential revision is made up of one or more 'Diffs' (patches). - */ - diffID: number -} - -export interface BaseDiffSpec { - /** - * The ID of the 'base' diff. This is only defined when comparing - * two states of a revision in the differential UI. - * - */ - baseDiffID?: number -} - -export interface DifferentialState extends RevisionSpec, DiffSpec, BaseDiffSpec { - mode: PhabricatorMode.Differential - baseRawRepoName: string - headRawRepoName: string -} - -export interface RevisionState extends RawRepoSpec { - mode: PhabricatorMode.Revision - baseCommitID: string - headCommitID: string -} - -/** - * Refers to a URL like http://phabricator.aws.sgdev.org/source/nzap/change/master/checked_message_bench_test.go, - * which a user gets to by clicking "Show Last Change" on a differential page. - */ -export interface ChangeState extends RawRepoSpec, FileSpec, ResolvedRevSpec { - mode: PhabricatorMode.Change -} - -export function convertSpacesToTabs(realLineContent: string, domContent: string): boolean { - return !!realLineContent && !!domContent && realLineContent.startsWith('\t') && !domContent.startsWith('\t') -} - -export function spacesToTabsAdjustment(text: string): number { - let suffix = text - let adjustment = 0 - - while (suffix.length >= 2 && suffix.startsWith(' ')) { - ++adjustment - suffix = suffix.substr(2) - } - return adjustment -} diff --git a/browser/src/libs/phabricator/scrape.ts b/browser/src/libs/phabricator/scrape.ts deleted file mode 100644 index 7fbb44712514..000000000000 --- a/browser/src/libs/phabricator/scrape.ts +++ /dev/null @@ -1,28 +0,0 @@ -export function getFilepathFromFileForDiff(fileContainer: HTMLElement): { filePath: string; baseFilePath?: string } { - const filePath = fileContainer.children[3].textContent as string - const metas = fileContainer.querySelectorAll('.differential-meta-notice') - let baseFilePath: string | undefined - const movedFilePrefix = 'This file was moved from ' - for (const meta of metas) { - let metaText = meta.textContent! - if (metaText.startsWith(movedFilePrefix)) { - metaText = metaText.substr(0, metaText.length - 1) // remove trailing '.' - baseFilePath = metaText.split(movedFilePrefix)[1] - break - } - } - return { filePath, baseFilePath } -} - -export function getFilePathFromFileForRevision(codeView: HTMLElement): string { - const filePathContainer = document.querySelector('.differential-file-icon-header') - if (!filePathContainer) { - throw new Error('Unable to find file path container for revision code view') - } - - if (!filePathContainer.textContent) { - throw new Error('`textContent` is undefined for revision file path container') - } - - return filePathContainer.textContent -} diff --git a/browser/src/libs/phabricator/style.scss b/browser/src/libs/phabricator/style.scss deleted file mode 100644 index 47ead7be8e02..000000000000 --- a/browser/src/libs/phabricator/style.scss +++ /dev/null @@ -1,32 +0,0 @@ -.hover-overlay--phabricator { - margin: 0; -} - -.hover-overlay__close-button--phabricator { - // Fight Phabricator selector specificity - background: transparent !important; - border: none !important; -} - -// Mimics Phabricator's font icon style -.action-item__icon--phabricator { - height: 14px; - width: 14px; - vertical-align: middle; - margin-top: -3px; -} - -.action-item--phabricator.action-item--pressed { - // Phabricator doesn't seem to have a style for pressed, so we invent one - box-shadow: inset 0 0.15em 0.3em #cad2e2; -} - -.hover-overlay-action-item--phabricator { - border-radius: 0 !important; - border-bottom: none !important; - border-top: none !important; - border-right: none !important; - &:first-child { - border-left: none !important; - } -} diff --git a/browser/src/libs/phabricator/util.tsx b/browser/src/libs/phabricator/util.tsx deleted file mode 100644 index 8e549b3e38e1..000000000000 --- a/browser/src/libs/phabricator/util.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { PlatformContext } from '../../../../shared/src/platform/context' -import { ChangeState, DifferentialState, DiffusionState, PhabricatorMode, RevisionState } from '.' -import { getRepoDetailsFromCallsign, getRepoDetailsFromRevisionID, QueryConduitHelper } from './backend' -import { map } from 'rxjs/operators' -import { Observable, throwError } from 'rxjs' - -const TAG_PATTERN = /r([0-9A-z]+)([0-9a-f]{40})/ -function matchPageTag(): RegExpExecArray | null { - const el = document.getElementsByClassName('phui-tag-core').item(0) - if (!el) { - throw new Error('Could not find Phabricator page tag') - } - return TAG_PATTERN.exec(el.children[0].getAttribute('href') as string) -} - -function getCallsignFromPageTag(): string { - const match = matchPageTag() - if (!match) { - throw new Error('Could not determine callsign from page tag') - } - return match[1] -} - -function getCommitIDFromPageTag(): string { - const match = matchPageTag() - if (!match) { - throw new Error('Could not determine commitID from page tag') - } - return match[2] -} - -const DIFF_PATTERN = /Diff ([0-9]+)/ -function getDiffIdFromDifferentialPage(): number { - const diffsContainer = document.getElementById('differential-review-stage') - if (!diffsContainer) { - throw new Error('no element with id differential-review-stage found on page.') - } - const wrappingDiffBox = diffsContainer.parentElement - if (!wrappingDiffBox) { - throw new Error('parent container of diff container not found.') - } - const diffTitle = wrappingDiffBox.children[0].getElementsByClassName('phui-header-header').item(0) - if (!diffTitle || !diffTitle.textContent) { - throw new Error('Could not find diffTitle element, or it had no text content') - } - const matches = DIFF_PATTERN.exec(diffTitle.textContent) - if (!matches) { - throw new Error(`diffTitle element does not match pattern. Content: '${diffTitle.textContent}'`) - } - return parseInt(matches[1], 10) -} - -// https://phabricator.sgdev.org/source/gorilla/browse/master/mux.go -const PHAB_DIFFUSION_REGEX = /^\/?(source|diffusion)\/([A-Za-z0-9\-_]+)\/browse\/([\w-]+\/)?([^;$]+)(;[0-9a-f]{40})?(?:\$[0-9]+)?/i -// https://phabricator.sgdev.org/D2 -const PHAB_DIFFERENTIAL_REGEX = /^\/?(D[0-9]+)(?:\?(?:(?:id=([0-9]+))|(vs=(?:[0-9]+|on)&id=[0-9]+)))?/i -// https://phabricator.sgdev.org/rMUXfb619131e25d82897c9de11789aa479941cfd415 -const PHAB_REVISION_REGEX = /^\/?r([0-9A-z]+)([0-9a-f]{40})/i -// https://phabricator.sgdev.org/source/gorilla/change/master/mux.go -const PHAB_CHANGE_REGEX = /^\/?(source|diffusion)\/([A-Za-z0-9]+)\/change\/([\w-]+)\/([^;]+)(;[0-9a-f]{40})?/i -const PHAB_CHANGESET_REGEX = /^\/?\/differential\/changeset.*/i -const COMPARISON_REGEX = /^vs=((?:[0-9]+|on))&id=([0-9]+)/i - -function getBaseCommitIDFromRevisionPage(): string { - const keyElements = document.getElementsByClassName('phui-property-list-key') - for (const keyElement of Array.from(keyElements)) { - if (keyElement.textContent === 'Parents ') { - const parentUrl = ((keyElement.nextSibling as HTMLElement).children[0].children[0] as HTMLLinkElement).href - const url = new URL(parentUrl) - const revisionMatch = PHAB_REVISION_REGEX.exec(url.pathname) - if (revisionMatch) { - return revisionMatch[2] - } - } - } - throw new Error('Could not determine base commit ID from revision page') -} - -export function getPhabricatorState( - loc: Location, - requestGraphQL: PlatformContext['requestGraphQL'], - queryConduit: QueryConduitHelper -): Observable { - try { - const stateUrl = loc.href.replace(loc.origin, '') - const diffusionMatch = PHAB_DIFFUSION_REGEX.exec(stateUrl) - if (diffusionMatch) { - const filePath = diffusionMatch[4] - if (!filePath) { - throw new Error(`Could not determine file path from diffusionMatch, stateUrl: ${stateUrl}`) - } - const callsign = getCallsignFromPageTag() - return getRepoDetailsFromCallsign(callsign, requestGraphQL, queryConduit).pipe( - map( - ({ rawRepoName }): DiffusionState => ({ - mode: PhabricatorMode.Diffusion, - rawRepoName, - filePath, - commitID: getCommitIDFromPageTag(), - }) - ) - ) - } - const differentialMatch = PHAB_DIFFERENTIAL_REGEX.exec(stateUrl) - if (differentialMatch) { - const differentialID = differentialMatch[1] - const comparison = differentialMatch[3] - const revisionID = parseInt(differentialID.split('D')[1], 10) - let diffID = differentialMatch[2] ? parseInt(differentialMatch[2], 10) : undefined - if (!diffID) { - diffID = getDiffIdFromDifferentialPage() - } - - let baseDiffID: number | undefined - if (comparison) { - // urls that looks like this: http://phabricator.aws.sgdev.org/D3?vs=on&id=8&whitespace=ignore-most#toc - const comparisonMatch = COMPARISON_REGEX.exec(comparison) - const comparisonBase = comparisonMatch?.[1] - if (comparisonBase && comparisonBase !== 'on') { - baseDiffID = parseInt(comparisonBase, 10) - console.log(`comparison diffID ${diffID} baseDiffID ${baseDiffID}`) - } - } - - return getRepoDetailsFromRevisionID(revisionID, requestGraphQL, queryConduit).pipe( - map( - ({ rawRepoName }): DifferentialState => ({ - baseRawRepoName: rawRepoName, - headRawRepoName: rawRepoName, - revisionID, - diffID: diffID!, - baseDiffID, - mode: PhabricatorMode.Differential, - }) - ) - ) - } - - const revisionMatch = PHAB_REVISION_REGEX.exec(stateUrl) - if (revisionMatch) { - const callsign = revisionMatch[1] - const headCommitID = revisionMatch[2] - const baseCommitID = getBaseCommitIDFromRevisionPage() - return getRepoDetailsFromCallsign(callsign, requestGraphQL, queryConduit).pipe( - map( - ({ rawRepoName }): RevisionState => ({ - mode: PhabricatorMode.Revision, - rawRepoName, - baseCommitID, - headCommitID, - }) - ) - ) - } - - const changeMatch = PHAB_CHANGE_REGEX.exec(stateUrl) - if (changeMatch) { - const filePath = changeMatch[8] - const callsign = getCallsignFromPageTag() - const commitID = getCommitIDFromPageTag() - return getRepoDetailsFromCallsign(callsign, requestGraphQL, queryConduit).pipe( - map( - ({ rawRepoName }): ChangeState => ({ - mode: PhabricatorMode.Change, - filePath, - rawRepoName, - commitID, - }) - ) - ) - } - - const changesetMatch = PHAB_CHANGESET_REGEX.exec(stateUrl) - if (changesetMatch) { - const crumbs = document.querySelector('.phui-crumbs-view') - if (!crumbs) { - throw new Error('failed parsing changeset dom') - } - - const [, differentialHref, diffHref] = crumbs.querySelectorAll('a') - - const differentialMatch = differentialHref.getAttribute('href')!.match(/D(\d+)/) - if (!differentialMatch) { - throw new Error('failed parsing differentialID') - } - const revisionID = parseInt(differentialMatch[1], 10) - - const diffMatch = diffHref.getAttribute('href')!.match(/\/differential\/diff\/(\d+)/) - if (!diffMatch) { - throw new Error('failed parsing diffID') - } - const diffID = parseInt(diffMatch[1], 10) - return getRepoDetailsFromRevisionID(revisionID, requestGraphQL, queryConduit).pipe( - map( - ({ rawRepoName }): DifferentialState => ({ - baseRawRepoName: rawRepoName, - headRawRepoName: rawRepoName, - revisionID, - diffID, - mode: PhabricatorMode.Differential, - }) - ) - ) - } - - throw new Error(`Could not determine Phabricator state from stateUrl ${stateUrl}`) - } catch (err) { - return throwError(err) - } -} - -/** - * This hacks javelin Stratcom to ignore command + click actions on sg-clickable tokens. - * Without this, two windows open when a user command + clicks on a token. - * - * TODO could this be eliminated with shadow DOM? - */ -export function metaClickOverride(): void { - const JX = (window as any).JX - if (JX.Stratcom._dispatchProxyPreMeta) { - return - } - JX.Stratcom._dispatchProxyPreMeta = JX.Stratcom._dispatchProxy - JX.Stratcom._dispatchProxy = (proxyEvent: { - __auto__type: string - __auto__rawEvent: KeyboardEvent - __auto__target: HTMLElement - }) => { - if ( - proxyEvent.__auto__type === 'click' && - proxyEvent.__auto__rawEvent.metaKey && - proxyEvent.__auto__target.classList.contains('sg-clickable') - ) { - return - } - return JX.Stratcom._dispatchProxyPreMeta(proxyEvent) - } -} - -export function normalizeRepoName(origin: string): string { - let repoName = origin - repoName = repoName.replace('\\', '') - if (origin.startsWith('git@')) { - repoName = origin.substr('git@'.length) - repoName = repoName.replace(':', '/') - } else if (origin.startsWith('git://')) { - repoName = origin.substr('git://'.length) - } else if (origin.startsWith('https://')) { - repoName = origin.substr('https://'.length) - } else if (origin.includes('@')) { - // Assume the origin looks like `username@host:repo/path` - const split = origin.split('@') - repoName = split[1] - repoName = repoName.replace(':', '/') - } - return repoName.replace(/.git$/, '') -} diff --git a/browser/src/libs/sentry/index.ts b/browser/src/libs/sentry/index.ts deleted file mode 100644 index c443c2837191..000000000000 --- a/browser/src/libs/sentry/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint rxjs/no-ignored-subscription: warn */ -import * as Sentry from '@sentry/browser' -import { once } from 'lodash' -import { observeStorageKey } from '../../browser/storage' -import { featureFlagDefaults } from '../../browser/types' -import { isInPage } from '../../context' -import { DEFAULT_SOURCEGRAPH_URL, getExtensionVersion, observeSourcegraphURL } from '../../shared/util/context' - -const IS_EXTENSION = true - -const isExtensionStackTrace = (stacktrace: Sentry.Stacktrace, extensionID: string): boolean => - !!(stacktrace.frames && stacktrace.frames.some(({ filename }) => !!filename?.includes(extensionID))) - -const callSentryInit = once((extensionID: string) => { - Sentry.init({ - dsn: 'https://32613b2b6a5b4da2aa50660a60297d79@sentry.io/1334031', - beforeSend: event => { - // Filter out events if we can tell from the stack trace that - // they didn't originate from extension code. - let keep = true - if (event.exception && event.exception.values) { - keep = event.exception.values.some( - ({ stacktrace }) => !!(stacktrace && isExtensionStackTrace(stacktrace, extensionID)) - ) - } else if (event.stacktrace) { - keep = isExtensionStackTrace(event.stacktrace, extensionID) - } - return keep ? event : null - }, - }) -}) - -/** Initialize Sentry for error reporting. */ -export function initSentry(script: 'content' | 'options' | 'background', codeHost?: string): void { - if (process.env.NODE_ENV !== 'production') { - return - } - - observeStorageKey('sync', 'featureFlags').subscribe((flags = featureFlagDefaults) => { - const allowed = flags.allowErrorReporting - - // Don't initialize if user hasn't allowed us to report errors or in Phabricator. - if (!allowed || isInPage) { - const client = Sentry.getCurrentHub().getClient() - if (client) { - client.getOptions().enabled = false - } - return - } - - callSentryInit(browser.runtime.id) - - Sentry.configureScope(scope => { - scope.setTag('script', script) - scope.setTag('extension_version', getExtensionVersion()) - if (codeHost) { - scope.setTag('code_host', codeHost) - } - }) - }) - - observeSourcegraphURL(IS_EXTENSION).subscribe(url => { - Sentry.configureScope(scope => { - scope.setTag('using_dot_com', url === DEFAULT_SOURCEGRAPH_URL ? 'true' : 'false') - }) - }) -} diff --git a/browser/src/libs/sourcegraph/inject.tsx b/browser/src/libs/sourcegraph/inject.tsx deleted file mode 100644 index 96cc01ed8da4..000000000000 --- a/browser/src/libs/sourcegraph/inject.tsx +++ /dev/null @@ -1,46 +0,0 @@ -export const EXTENSION_MARKER_ID = 'sourcegraph-app-background' - -/** - * A custom native integration <-> browser extension event used to free - * browser extension subscriptions when the native integration gets activated - * on the page, so as to avoid conflicts such as duplicate UI elements. - */ -export const NATIVE_INTEGRATION_ACTIVATED = 'sourcegraph:native-integration-activated' - -/** - * Injects a `#sourcegraph-app-background` hidden element. - * - * This element is checked for in the webapp to know if the browser extension - * is installed, and in the browser extension to determine whether a native integration - * is already running on the page. - * - * Not idempotent. - */ -export function injectExtensionMarker(): void { - const extensionMarker = document.createElement('div') - extensionMarker.id = EXTENSION_MARKER_ID - extensionMarker.style.display = 'none' - document.body.appendChild(extensionMarker) -} - -/** - * Dispatches a custom event to signal to Sourcegraph web app - * that the browser extension is installed. - */ -export function signalBrowserExtensionInstalled(): void { - if (document.readyState === 'complete' || document.readyState === 'interactive') { - dispatchSourcegraphEvents() - } else { - window.addEventListener('load', dispatchSourcegraphEvents, { once: true }) - } -} - -function dispatchSourcegraphEvents(): void { - // Send custom webapp <-> extension registration event in case webapp listener is attached first. - document.dispatchEvent(new CustomEvent<{}>('sourcegraph:browser-extension-registration')) -} - -export const checkIsSourcegraph = (sourcegraphServerUrl: string): boolean => - window.location.origin === sourcegraphServerUrl || - /^https?:\/\/(www.)?sourcegraph.com/.test(location.href) || - !!document.getElementById('sourcegraph-chrome-webstore-item') diff --git a/browser/src/options.scss b/browser/src/options.scss deleted file mode 100644 index 40e7b848fddd..000000000000 --- a/browser/src/options.scss +++ /dev/null @@ -1,26 +0,0 @@ -// Options page CSS entry point - -@import '../../shared/src/global-styles/colors.scss'; -@import '../../shared/src/global-styles/icons.scss'; -@import 'bootstrap/scss/functions'; -@import 'bootstrap/scss/variables'; -@import 'bootstrap/scss/mixins'; -@import 'bootstrap/scss/reboot'; -@import 'bootstrap/scss/type'; -@import 'bootstrap/scss/utilities'; -@import 'bootstrap/scss/grid'; -@import 'bootstrap/scss/forms'; -@import 'bootstrap/scss/input-group'; -@import 'bootstrap/scss/custom-forms'; -@import 'bootstrap/scss/buttons'; -@import 'bootstrap/scss/button-group'; -@import 'bootstrap/scss/alert'; -@import './libs/options/OptionsContainer'; - -:root { - // Use a smaller base size than the default 16px, - // because an options menu should feel more like a - // natural extension of the browser's UI than a webpage - // This also affects all border-radiuses etc (everything that uses rem). - font-size: 14px; -} diff --git a/browser/src/platform/context.test.tsx b/browser/src/platform/context.test.tsx deleted file mode 100644 index cee69cd1b032..000000000000 --- a/browser/src/platform/context.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { gql } from '../../../shared/src/graphql/graphql' -import { DEFAULT_SOURCEGRAPH_URL, getAssetsURL } from '../shared/util/context' -import { createPlatformContext } from './context' - -describe('Platform Context', () => { - describe('requestGraphQL()', () => { - it('throws if the request risks leaking private information to the public sourcegraph.com', () => { - window.SOURCEGRAPH_URL = DEFAULT_SOURCEGRAPH_URL - const { requestGraphQL } = createPlatformContext( - { - urlToFile: () => '', - getContext: () => ({ rawRepoName: 'foo', privateRepository: true }), - }, - { - sourcegraphURL: DEFAULT_SOURCEGRAPH_URL, - assetsURL: getAssetsURL(DEFAULT_SOURCEGRAPH_URL), - }, - false - ) - return expect( - requestGraphQL({ - request: gql` - query ResolveRepo($repoName: String!) { - repository(name: $repoName) { - url - } - } - `, - variables: { repoName: 'foo' }, - mightContainPrivateInfo: true, - }).toPromise() - ).rejects.toMatchObject({ - message: - 'A ResolveRepo GraphQL request to the public Sourcegraph.com was blocked because the current repository is private.', - }) - }) - }) -}) diff --git a/browser/src/platform/context.ts b/browser/src/platform/context.ts deleted file mode 100644 index 7ac36625c9bf..000000000000 --- a/browser/src/platform/context.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { combineLatest, Observable, ReplaySubject } from 'rxjs' -import { map, switchMap, take } from 'rxjs/operators' -import { PrivateRepoPublicSourcegraphComError } from '../../../shared/src/backend/errors' -import { GraphQLResult } from '../../../shared/src/graphql/graphql' -import * as GQL from '../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../shared/src/platform/context' -import { mutateSettings, updateSettings } from '../../../shared/src/settings/edit' -import { EMPTY_SETTINGS_CASCADE, gqlToCascade } from '../../../shared/src/settings/settings' -import { LocalStorageSubject } from '../../../shared/src/util/LocalStorageSubject' -import { toPrettyBlobURL } from '../../../shared/src/util/url' -import { ExtensionStorageSubject } from '../browser/ExtensionStorageSubject' -import { background } from '../browser/runtime' -import { isInPage } from '../context' -import { CodeHost } from '../libs/code_intelligence' -import { DEFAULT_SOURCEGRAPH_URL, observeSourcegraphURL } from '../shared/util/context' -import { createExtensionHost } from './extensionHost' -import { editClientSettings, fetchViewerSettings, mergeCascades, storageSettingsCascade } from './settings' -import { requestGraphQLHelper } from '../shared/backend/requestGraphQL' -import { failedWithHTTPStatus } from '../../../shared/src/backend/fetch' - -export interface SourcegraphIntegrationURLs { - /** - * The URL of the configured Sourcegraph instance. Used for extensions, find-references, ... - */ - sourcegraphURL: string - - /** - * The base URL where assets will be fetched from (CSS, extension host - * worker bundle, ...) - * - * This is the sourcegraph URL in most cases, but may be different for - * native code hosts that self-host the integration bundle. - */ - assetsURL: string -} - -/** - * The PlatformContext provided in the browser extension and native integrations. - */ -export interface BrowserPlatformContext extends PlatformContext { - /** - * Refetches the settings cascade from the Sourcegraph instance. - */ - refreshSettings(): Promise -} - -/** - * Creates the {@link PlatformContext} for the browser extension. - */ -export function createPlatformContext( - { urlToFile, getContext }: Pick, - { sourcegraphURL, assetsURL }: SourcegraphIntegrationURLs, - isExtension: boolean -): BrowserPlatformContext { - const updatedViewerSettings = new ReplaySubject>(1) - const requestGraphQL: PlatformContext['requestGraphQL'] = ({ - request, - variables, - mightContainPrivateInfo, - }: { - request: string - variables: {} - mightContainPrivateInfo: boolean - }): Observable> => - observeSourcegraphURL(isExtension).pipe( - take(1), - switchMap(sourcegraphURL => { - if (mightContainPrivateInfo && sourcegraphURL === DEFAULT_SOURCEGRAPH_URL) { - // If we can't determine the code host context, assume the current repository is private. - const privateRepository = getContext ? getContext().privateRepository : true - if (privateRepository) { - const nameMatch = request.match(/^\s*(?:query|mutation)\s+(\w+)/) - throw new PrivateRepoPublicSourcegraphComError(nameMatch ? nameMatch[1] : '') - } - } - return requestGraphQLHelper(isExtension, sourcegraphURL)({ request, variables }) - }) - ) - - const context: BrowserPlatformContext = { - /** - * The active settings cascade. - * - * - For unauthenticated users, this is the GraphQL settings plus client settings (which are stored locally - * in the browser extension. - * - For authenticated users, this is just the GraphQL settings (client settings are ignored to simplify - * the UX). - */ - settings: combineLatest([updatedViewerSettings, storageSettingsCascade]).pipe( - map(([gqlCascade, storageCascade]) => - gqlCascade - ? mergeCascades( - gqlToCascade(gqlCascade), - gqlCascade.subjects.some(subject => subject.__typename === 'User') - ? EMPTY_SETTINGS_CASCADE - : storageCascade - ) - : EMPTY_SETTINGS_CASCADE - ) - ), - refreshSettings: async () => { - try { - const settings = await fetchViewerSettings(requestGraphQL).toPromise() - updatedViewerSettings.next(settings) - } catch (error) { - if (failedWithHTTPStatus(error, 401)) { - // User is not signed in - console.warn( - `Could not fetch Sourcegraph settings from ${sourcegraphURL} because user is not signed into Sourcegraph` - ) - updatedViewerSettings.next({ final: '{}', subjects: [] }) - } else { - throw error - } - } - }, - updateSettings: async (subject, edit) => { - if (subject === 'Client') { - // Support storing settings on the client (in the browser extension) so that unauthenticated - // Sourcegraph viewers can update settings. - await updateSettings(context, subject, edit, () => editClientSettings(edit)) - return - } - - try { - await updateSettings(context, subject, edit, mutateSettings) - } catch (error) { - if ('message' in error && error.message.includes('version mismatch')) { - // The user probably edited the settings in another tab, so - // try once more. - await context.refreshSettings() - await updateSettings(context, subject, edit, mutateSettings) - } else { - throw error - } - } - // TODO: We shouldn't need to make another HTTP request to get the latest state - await context.refreshSettings() - }, - requestGraphQL, - forceUpdateTooltip: () => { - // TODO(sqs): implement tooltips on the browser extension - }, - createExtensionHost: () => createExtensionHost({ assetsURL }), - getScriptURLForExtension: async bundleURL => { - if (isInPage) { - return bundleURL - } - // We need to import the extension's JavaScript file (in importScripts in the Web Worker) from a blob: - // URI, not its original http:/https: URL, because Chrome extensions are not allowed to be published - // with a CSP that allowlists https://* in script-src (see - // https://developer.chrome.com/extensions/contentSecurityPolicy#relaxing-remote-script). (Firefox - // add-ons have an even stricter restriction.) - const blobURL = await background.createBlobURL(bundleURL) - return blobURL - }, - urlToFile: ({ rawRepoName, ...target }, context) => { - // We don't always resolve the rawRepoName, e.g. if there are multiple definitions. - // Construct URL to file on code host, if possible. - if (rawRepoName && urlToFile) { - return urlToFile(sourcegraphURL, { rawRepoName, ...target }, context) - } - // Otherwise fall back to linking to Sourcegraph (with an absolute URL). - return `${sourcegraphURL}${toPrettyBlobURL(target)}` - }, - sourcegraphURL, - clientApplication: 'other', - sideloadedExtensionURL: isInPage - ? new LocalStorageSubject('sideloadedExtensionURL', null) - : new ExtensionStorageSubject('sideloadedExtensionURL', null), - } - return context -} diff --git a/browser/src/platform/extensionHost.ts b/browser/src/platform/extensionHost.ts deleted file mode 100644 index 4c94e476fbc7..000000000000 --- a/browser/src/platform/extensionHost.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as MessageChannelAdapter from '@sourcegraph/comlink/messagechanneladapter' -import { Observable } from 'rxjs' -import * as uuid from 'uuid' -import { EndpointPair } from '../../../shared/src/platform/context' -import { isInPage } from '../context' -import { SourcegraphIntegrationURLs } from './context' - -function createInPageExtensionHost({ - assetsURL, -}: Pick): Observable { - return new Observable(subscriber => { - // Create an iframe pointing to extensionHostFrame.html, - // which will load the extension host worker, and forward it - // the client endpoints. - const frame: HTMLIFrameElement = document.createElement('iframe') - frame.setAttribute('src', new URL('extensionHostFrame.html', assetsURL).href) - frame.setAttribute('style', 'display: none;') - document.body.append(frame) - const clientAPIChannel = new MessageChannel() - const extensionHostAPIChannel = new MessageChannel() - const workerEndpoints: EndpointPair = { - proxy: clientAPIChannel.port2, - expose: extensionHostAPIChannel.port2, - } - const clientEndpoints = { - proxy: extensionHostAPIChannel.port1, - expose: clientAPIChannel.port1, - } - // Subscribe to the load event on the frame - frame.addEventListener( - 'load', - () => { - frame.contentWindow!.postMessage( - { - type: 'workerInit', - payload: { - endpoints: clientEndpoints, - wrapEndpoints: false, - }, - }, - new URL(assetsURL).origin, - Object.values(clientEndpoints) - ) - subscriber.next(workerEndpoints) - }, - { - once: true, - } - ) - return () => { - clientEndpoints.proxy.close() - clientEndpoints.expose.close() - frame.remove() - } - }) -} - -/** - * Returns an observable of a communication channel to an extension host. - * - * When executing in-page (for example as a Phabricator plugin), this simply - * creates an extension host worker and emits the returned EndpointPair. - * - * When executing in the browser extension, we create pair of browser.runtime.Port objects, - * named 'expose-{uuid}' and 'proxy-{uuid}', and return the ports wrapped using ${@link endpointFromPort}. - * - * The background script will listen to newly created ports, create an extension host - * worker per pair of ports, and forward messages between the port objects and - * the extension host worker's endpoints. - */ -export function createExtensionHost(urls: Pick): Observable { - if (isInPage) { - return createInPageExtensionHost(urls) - } - const id = uuid.v4() - return new Observable(subscriber => { - const proxyPort = browser.runtime.connect({ name: `proxy-${id}` }) - const exposePort = browser.runtime.connect({ name: `expose-${id}` }) - subscriber.next({ - proxy: endpointFromPort(proxyPort), - expose: endpointFromPort(exposePort), - }) - return () => { - proxyPort.disconnect() - exposePort.disconnect() - } - }) -} - -/** - * Partially wraps a browser.runtime.Port and returns a MessagePort created using - * comlink's {@link MessageChannelAdapter}, so that the Port can be used - * as a comlink Endpoint to transport messages between the content script and the extension host. - * - * It is necessary to wrap the port using MessageChannelAdapter because browser.runtime.Port objects do not support - * transferring MessagePort objects (see https://github.com/GoogleChromeLabs/comlink/blob/master/messagechanneladapter.md). - * - */ -function endpointFromPort(port: browser.runtime.Port): MessagePort { - const messageListeners = new Map<(event: MessageEvent) => any, (message: unknown) => void>() - return MessageChannelAdapter.wrap({ - send(data: string): void { - port.postMessage(data) - }, - addEventListener(event: 'message', messageListener: (event: MessageEvent) => any): void { - if (event !== 'message') { - return - } - const portListener = (data: unknown): void => { - // This callback is called *very* often (e.g., ~900 times per keystroke in a - // monitored textarea). Avoid creating unneeded objects here because GC - // significantly hurts perf. See - // https://github.com/sourcegraph/sourcegraph/issues/3433#issuecomment-483561297 and - // watch that issue for a (possibly better) fix. - // - // HACK: Use a simple object here instead of `new MessageEvent('message', { data })` - // to reduce the amount of garbage created. There are no callers that depend on - // other MessageEvent properties; they would be set to their default values anyway, - // so losing the properties is not a big problem. - messageListener.call(this, { data } as MessageEvent) - } - messageListeners.set(messageListener, portListener) - port.onMessage.addListener(portListener) - }, - removeEventListener(event: 'message', messageListener: (event: MessageEvent) => any): void { - if (event !== 'message') { - return - } - const portListener = messageListeners.get(messageListener) - if (!portListener) { - return - } - port.onMessage.removeListener(portListener) - }, - }) -} diff --git a/browser/src/platform/settings.ts b/browser/src/platform/settings.ts deleted file mode 100644 index 95075d4a1688..000000000000 --- a/browser/src/platform/settings.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { applyEdits, parse as parseJSONC } from '@sqs/jsonc-parser' -import { setProperty } from '@sqs/jsonc-parser/lib/edit' -import { from, Observable } from 'rxjs' -import { map } from 'rxjs/operators' -import { SettingsEdit } from '../../../shared/src/api/client/services/settings' -import { dataOrThrowErrors, gql } from '../../../shared/src/graphql/graphql' -import * as GQL from '../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../shared/src/platform/context' -import { - mergeSettings, - SettingsCascade, - SettingsCascadeOrError, - SettingsSubject, -} from '../../../shared/src/settings/settings' -import { isErrorLike } from '../../../shared/src/util/errors' -import { LocalStorageSubject } from '../../../shared/src/util/LocalStorageSubject' -import { observeStorageKey, storage } from '../browser/storage' -import { isInPage } from '../context' - -const inPageClientSettingsKey = 'sourcegraphClientSettings' - -const createStorageSettingsCascade: () => Observable = () => { - const storageSubject = isInPage - ? new LocalStorageSubject(inPageClientSettingsKey, '{}') - : observeStorageKey('sync', 'clientSettings') - - const subject: SettingsSubject = { - __typename: 'Client', - id: 'Client', - displayName: 'Client', - viewerCanAdminister: true, - } - - return storageSubject.pipe( - map(clientSettingsString => parseJSONC(clientSettingsString || '')), - map(clientSettings => ({ - subjects: [ - { - subject, - settings: clientSettings, - lastID: null, - }, - ], - final: clientSettings || {}, - })) - ) -} - -/** - * The settings cascade consisting solely of client settings. - */ -export const storageSettingsCascade = createStorageSettingsCascade() - -/** - * Merge two settings cascades (used to merge viewer settings and client settings). - */ -export function mergeCascades( - cascadeOrError: SettingsCascadeOrError, - cascade: SettingsCascade -): SettingsCascadeOrError { - return { - subjects: - cascadeOrError.subjects === null - ? cascade.subjects - : isErrorLike(cascadeOrError.subjects) - ? cascadeOrError.subjects - : [...cascadeOrError.subjects, ...cascade.subjects], - final: - cascadeOrError.final === null - ? cascade.final - : isErrorLike(cascadeOrError.final) - ? cascadeOrError.final - : mergeSettings([cascadeOrError.final, cascade.final]), - } -} - -// This is a fragment on the DEPRECATED GraphQL API type ConfigurationCascade (not SettingsCascade) for backcompat. -const configurationCascadeFragment = gql` - fragment ConfigurationCascadeFields on ConfigurationCascade { - subjects { - __typename - ... on Org { - id - name - displayName - } - ... on User { - id - username - displayName - } - ... on Site { - id - siteID - } - latestSettings { - id - contents - } - settingsURL - viewerCanAdminister - } - merged { - contents - messages - } - } -` - -/** - * Fetches the settings cascade for the viewer. - * - * TODO(sqs): This uses the DEPRECATED GraphQL Query.viewerConfiguration and ConfigurationCascade for backcompat. - */ -export function fetchViewerSettings( - requestGraphQL: PlatformContext['requestGraphQL'] -): Observable> { - return from( - requestGraphQL({ - request: gql` - query ViewerConfiguration { - viewerConfiguration { - ...ConfigurationCascadeFields - } - } - ${configurationCascadeFragment} - `, - variables: {}, - mightContainPrivateInfo: false, - }) - ).pipe( - map(dataOrThrowErrors), - map(({ viewerConfiguration }) => { - if (!viewerConfiguration) { - throw new Error('fetchViewerSettings: empty viewerConfiguration') - } - - for (const subject of viewerConfiguration.subjects) { - // User/org/global settings cannot be edited from the - // browser extension (only client settings can). - subject.viewerCanAdminister = false - } - - return { - subjects: viewerConfiguration.subjects, - final: viewerConfiguration.merged.contents, - } - }) - ) -} - -/** - * Applies an edit and persists the result to client settings. - */ -export async function editClientSettings(edit: SettingsEdit | string): Promise { - const getNext = (prev: string): string => - typeof edit === 'string' - ? edit - : applyEdits( - prev, - // TODO(chris): remove `.slice()` (which guards against mutation) once - // https://github.com/Microsoft/node-jsonc-parser/pull/12 is merged in. - setProperty(prev, edit.path.slice(), edit.value, { - tabSize: 2, - insertSpaces: true, - eol: '\n', - }) - ) - if (isInPage) { - const prev = localStorage.getItem(inPageClientSettingsKey) || '' - const next = getNext(prev) - - localStorage.setItem(inPageClientSettingsKey, next) - - return Promise.resolve() - } - - const { clientSettings: prev = '{}' } = await storage.sync.get() - const next = getNext(prev) - - await storage.sync.set({ clientSettings: next }) -} diff --git a/browser/src/platform/worker.ts b/browser/src/platform/worker.ts deleted file mode 100644 index 881aece26091..000000000000 --- a/browser/src/platform/worker.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DEFAULT_SOURCEGRAPH_URL } from '../shared/util/context' -import { checkOk } from '../../../shared/src/backend/fetch' - -export async function createBlobURLForBundle(bundleURL: string): Promise { - const { origin, hostname } = new URL(bundleURL) - // Include credentials when fetching extensions from the private registry - const includeCredentials = origin !== DEFAULT_SOURCEGRAPH_URL && hostname !== 'localhost' - const response = await fetch(bundleURL, { - credentials: includeCredentials ? 'include' : 'omit', - }) - checkOk(response) - const blob = await response.blob() - return window.URL.createObjectURL(blob) -} diff --git a/browser/src/shared.scss b/browser/src/shared.scss deleted file mode 100644 index d1a0b2ca26b6..000000000000 --- a/browser/src/shared.scss +++ /dev/null @@ -1,84 +0,0 @@ -// CSS for components shared with the webapp - -// Set this to override the icon background color in badge attachments. -// The web's light-theme background color is slightly off-white, but the -// box that renders the tooltip on GitHub is pure white. -$body-bg-color-light: #ffffff; - -@import '../../shared/src/actions/ActionItem'; -@import '../../shared/src/actions/ActionsNavItems'; -@import '../../shared/src/commandPalette/CommandList'; -@import '../../shared/src/components/completion/CompletionWidget.scss'; -@import '../../shared/src/components/Toggle'; -@import '../../shared/src/extensions/ExtensionStatus'; -@import '../../shared/src/notifications/NotificationItem'; -@import '../../shared/src/notifications/Notifications'; -@import '../../shared/src/components/BadgeAttachment'; - -$body-color-light: #2b3750; -$body-color-dark: #f2f4f8; - -:root { - --body-bg: #ffffff; - --text-muted: #{$color-light-text-2}; - --link-color: #566e9f; - --link-hover-color: #1d2535; - --dropdown-bg: #{$color-light-bg-1}; - --dropdown-border-color: #{$color-light-border}; -} - -.command-palette-button { - align-self: center; - - > .command-list-popover-button { - user-select: none; - position: relative; - } -} - -.command-list { - @import 'bootstrap/scss/list-group'; - @import 'bootstrap/scss/forms'; - @import 'bootstrap/scss/input-group'; - - a { - cursor: pointer; - } -} - -.command-list-popover { - z-index: 1100; // high enough to prevent most things from obscuring it - border: 1px solid var(--dropdown-border-color); - border-radius: 3px; -} - -.sourcegraph-extensions-global { - position: fixed; - bottom: 0; - right: 0; -} - -.global-debug { - position: fixed; - right: 0; - bottom: 0; - z-index: 10000; - background-color: var(--body-bg); -} - -.extension-status { - // TODO make the CSS classes configurable - background: var(--body-bg); -} - -.toggle--off::-webkit-slider-runnable-track { - color: #eeeeee; -} -.toggle--off::-webkit-slider-thumb { - background-color: #000000; - opacity: 0.2; -} - -.line-decoration-attachment { - margin-left: 0.25rem; -} diff --git a/browser/src/shared/backend/diffs.tsx b/browser/src/shared/backend/diffs.tsx deleted file mode 100644 index 40b083cc3e28..000000000000 --- a/browser/src/shared/backend/diffs.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Observable } from 'rxjs' -import { map } from 'rxjs/operators' -import { RepoNotFoundError } from '../../../../shared/src/backend/errors' -import { dataOrThrowErrors, gql } from '../../../../shared/src/graphql/graphql' -import * as GQL from '../../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { memoizeObservable } from '../../../../shared/src/util/memoizeObservable' - -export const queryRepositoryComparisonFileDiffs = memoizeObservable( - ({ - requestGraphQL, - ...args - }: { - repo: string - base: string | null - head: string | null - first?: number - } & Pick): Observable => - requestGraphQL({ - request: gql` - query RepositoryComparisonDiff($repo: String!, $base: String, $head: String, $first: Int) { - repository(name: $repo) { - comparison(base: $base, head: $head) { - fileDiffs(first: $first) { - nodes { - ...FileDiffFields - } - totalCount - } - } - } - } - - fragment FileDiffFields on FileDiff { - oldPath - newPath - internalID - } - `, - variables: { repo: args.repo, base: args.base, head: args.head, first: args.first }, - mightContainPrivateInfo: true, - }).pipe( - map(dataOrThrowErrors), - map(({ repository }) => { - if (!repository) { - throw new RepoNotFoundError(args.repo) - } - if (!repository.comparison || !repository.comparison.fileDiffs) { - throw new Error('empty fileDiffs') - } - return repository.comparison.fileDiffs - }) - ), - ({ repo, base, head, first }) => `${repo}:${String(base)}:${String(head)}:${String(first)}` -) diff --git a/browser/src/shared/backend/lsp.tsx b/browser/src/shared/backend/lsp.tsx deleted file mode 100644 index 9fc7894564ad..000000000000 --- a/browser/src/shared/backend/lsp.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Location } from '@sourcegraph/extension-api-types' -import { from, Observable } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' -import { fromHoverMerged, HoverMerged } from '../../../../shared/src/api/client/types/hover' -import { TextDocumentIdentifier } from '../../../../shared/src/api/client/types/textDocument' -import { TextDocumentPositionParams } from '../../../../shared/src/api/protocol' -import { Controller } from '../../../../shared/src/extensions/controller' -import { AbsoluteRepoFilePosition, FileSpec, RepoSpec, ResolvedRevSpec } from '../../../../shared/src/util/url' - -interface SimpleProviderFns { - getHover: (pos: AbsoluteRepoFilePosition) => Observable - fetchDefinition: (pos: AbsoluteRepoFilePosition) => Observable -} - -export const toTextDocumentIdentifier = (pos: RepoSpec & ResolvedRevSpec & FileSpec): TextDocumentIdentifier => ({ - uri: `git://${pos.repoName}?${pos.commitID}#${pos.filePath}`, -}) - -const toTextDocumentPositionParams = (pos: AbsoluteRepoFilePosition): TextDocumentPositionParams => ({ - textDocument: toTextDocumentIdentifier(pos), - position: { - character: pos.position.character - 1, - line: pos.position.line - 1, - }, -}) - -export const createLSPFromExtensions = (extensionsController: Controller): SimpleProviderFns => ({ - // Use from() to suppress rxjs type incompatibilities between different minor versions of rxjs in - // node_modules/. - getHover: pos => - from(extensionsController.services.textDocumentHover.getHover(toTextDocumentPositionParams(pos))).pipe( - map(hover => (hover === null ? fromHoverMerged([]) : hover)) - ), - fetchDefinition: pos => - from(extensionsController.services.textDocumentDefinition.getLocations(toTextDocumentPositionParams(pos))).pipe( - switchMap(locations => locations) - ) as Observable, -}) diff --git a/browser/src/shared/backend/requestGraphQL.ts b/browser/src/shared/backend/requestGraphQL.ts deleted file mode 100644 index 04e4e0009880..000000000000 --- a/browser/src/shared/backend/requestGraphQL.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { from } from 'rxjs' -import { requestGraphQL } from '../../../../shared/src/graphql/graphql' -import { IQuery, IMutation } from '../../../../shared/src/graphql/schema' -import { background } from '../../browser/runtime' - -/** - * Returns a platform-appropriate implementation of the function used to make requests to our GraphQL API. - * - * In the browser extension, the returned function will make all requests from the background page. - * - * In the native integration, the returned function will rely on the `requestGraphQL` implementation from `/shared`. - */ -export const requestGraphQLHelper = (isExtension: boolean, baseUrl: string) => ({ - request, - variables, -}: { - request: string - variables: {} -}) => - isExtension - ? from( - background.requestGraphQL({ request, variables }) - ) - : requestGraphQL({ - request, - variables, - baseUrl, - credentials: 'include', - }) diff --git a/browser/src/shared/backend/server.ts b/browser/src/shared/backend/server.ts deleted file mode 100644 index b73f6b23c8ca..000000000000 --- a/browser/src/shared/backend/server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Observable } from 'rxjs' -import { catchError, map } from 'rxjs/operators' -import { dataOrThrowErrors, gql } from '../../../../shared/src/graphql/graphql' -import * as GQL from '../../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../../shared/src/platform/context' - -export const fetchSite = (requestGraphQL: PlatformContext['requestGraphQL']): Observable => - requestGraphQL({ - request: gql` - query SiteProductVersion { - site { - productVersion - buildVersion - hasCodeIntelligence - } - } - `, - variables: {}, - mightContainPrivateInfo: false, - }).pipe( - map(dataOrThrowErrors), - map( - ({ site }) => site, - catchError((err, caught) => caught) - ) - ) diff --git a/browser/src/shared/components/Button.tsx b/browser/src/shared/components/Button.tsx deleted file mode 100644 index e31ec982aed8..000000000000 --- a/browser/src/shared/components/Button.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from 'react' -import { SourcegraphIcon } from './Icons' - -interface Props { - url?: string - - /** The HTML hover tooltip title */ - title?: string - - className?: string - iconClassName?: string - ariaLabel?: string - onClick?: (e: React.MouseEvent) => void - target?: string - label?: string -} - -export const SourcegraphIconButton: React.FunctionComponent = (props: Props) => ( - - {props.label} - -) diff --git a/browser/src/shared/components/CodeViewToolbar.tsx b/browser/src/shared/components/CodeViewToolbar.tsx deleted file mode 100644 index 9bd85c9f2c51..000000000000 --- a/browser/src/shared/components/CodeViewToolbar.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import classNames from 'classnames' -import H from 'history' -import * as React from 'react' -import { ActionNavItemsClassProps, ActionsNavItems } from '../../../../shared/src/actions/ActionsNavItems' -import { ContributionScope } from '../../../../shared/src/api/client/context/context' -import { ContributableMenu } from '../../../../shared/src/api/protocol' -import { ExtensionsControllerProps } from '../../../../shared/src/extensions/controller' -import { PlatformContextProps } from '../../../../shared/src/platform/context' -import { TelemetryProps } from '../../../../shared/src/telemetry/telemetryService' -import { FileInfoWithContents } from '../../libs/code_intelligence/code_views' -import { OpenDiffOnSourcegraph } from './OpenDiffOnSourcegraph' -import { OpenOnSourcegraph } from './OpenOnSourcegraph' -import { SignInButton } from '../../libs/code_intelligence/SignInButton' -import { ErrorLike, isErrorLike } from '../../../../shared/src/util/errors' -import { failedWithHTTPStatus } from '../../../../shared/src/backend/fetch' - -export interface ButtonProps { - className?: string -} - -export interface CodeViewToolbarClassProps extends ActionNavItemsClassProps { - /** - * Class name for the `
    ` element wrapping all toolbar items - */ - className?: string - - /** - * The scope of this toolbar (e.g., the view component that it is associated with). - */ - scope?: ContributionScope -} - -export interface CodeViewToolbarProps - extends PlatformContextProps<'forceUpdateTooltip' | 'settings' | 'requestGraphQL'>, - ExtensionsControllerProps, - TelemetryProps, - CodeViewToolbarClassProps { - sourcegraphURL: string - - /** - * Information about the file or diff the toolbar is displayed on. - */ - fileInfoOrError: FileInfoWithContents | ErrorLike - - buttonProps?: ButtonProps - onSignInClose: () => void - location: H.Location -} - -export const CodeViewToolbar: React.FunctionComponent = props => ( -
      - {' '} - {isErrorLike(props.fileInfoOrError) ? ( - failedWithHTTPStatus(props.fileInfoOrError, 401) ? ( - - ) : null - ) : ( - <> - {props.fileInfoOrError.baseCommitID && props.fileInfoOrError.baseHasFileContents && ( -
    • - -
    • - )}{' '} - {// Only show the "View file" button if we were able to fetch the file contents - // from the Sourcegraph instance - !props.fileInfoOrError.baseCommitID && - (props.fileInfoOrError.content !== undefined || - props.fileInfoOrError.baseContent !== undefined) && ( -
    • - -
    • - )} - - )} -
    -) diff --git a/browser/src/shared/components/GlobalDebug.tsx b/browser/src/shared/components/GlobalDebug.tsx deleted file mode 100644 index dff5e4310710..000000000000 --- a/browser/src/shared/components/GlobalDebug.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as H from 'history' -import * as React from 'react' -import { Controller as ClientController } from '../../../../shared/src/extensions/controller' -import { ExtensionStatusPopover } from '../../../../shared/src/extensions/ExtensionStatus' -import { PlatformContextProps } from '../../../../shared/src/platform/context' -import { ShortcutProvider } from './ShortcutProvider' - -interface Props extends PlatformContextProps<'sideloadedExtensionURL'> { - location: H.Location - extensionsController: ClientController - sourcegraphURL: string -} - -const makeExtensionLink = (sourcegraphURL: string): React.FunctionComponent<{ id: string }> => props => { - const extensionURL = new URL(sourcegraphURL) - extensionURL.pathname = `extensions/${props.id}` - return {props.id} -} - -/** - * A global debug toolbar shown in the bottom right of the window. - */ -export const GlobalDebug: React.FunctionComponent = props => ( -
    -
    -
    - - - -
    -
    -
    -) diff --git a/browser/src/shared/components/OpenOnSourcegraph.tsx b/browser/src/shared/components/OpenOnSourcegraph.tsx deleted file mode 100644 index f4e7adff2681..000000000000 --- a/browser/src/shared/components/OpenOnSourcegraph.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react' -import { OpenInSourcegraphProps } from '../repo' -import { getPlatformName } from '../util/context' -import { SourcegraphIconButton } from './Button' -import classNames from 'classnames' - -interface Props { - openProps: OpenInSourcegraphProps - className?: string - iconClassName?: string - ariaLabel?: string - onClick?: (e: any) => void -} - -export class OpenOnSourcegraph extends React.Component { - public render(): JSX.Element { - const url = this.getOpenInSourcegraphUrl(this.props.openProps) - return ( - - ) - } - - private getOpenInSourcegraphUrl(props: OpenInSourcegraphProps): string { - const baseUrl = props.sourcegraphURL - // Build URL for Web - let url = `${baseUrl}/${props.repoName}` - if (props.commit) { - return `${url}/-/compare/${props.commit.baseRev}...${props.commit.headRev}?utm_source=${getPlatformName()}` - } - if (props.rev) { - url = `${url}@${props.rev}` - } - if (props.filePath) { - url = `${url}/-/blob/${props.filePath}` - } - if (props.query) { - if (props.query.diff) { - url = `${url}?diff=${props.query.diff.rev}&utm_source=${getPlatformName()}` - } else if (props.query.search) { - url = `${url}?q=${props.query.search}&utm_source=${getPlatformName()}` - } - } - if (props.coords) { - url = `${url}#L${props.coords.line}:${props.coords.char}` - } - if (props.fragment) { - url = `${url}$${props.fragment}` - } - return url - } -} diff --git a/browser/src/shared/global-styles/variables.scss b/browser/src/shared/global-styles/variables.scss deleted file mode 100644 index 081096925eab..000000000000 --- a/browser/src/shared/global-styles/variables.scss +++ /dev/null @@ -1 +0,0 @@ -$default-z-index: 2000; diff --git a/browser/src/shared/repo/backend.tsx b/browser/src/shared/repo/backend.tsx deleted file mode 100644 index 20bed9621370..000000000000 --- a/browser/src/shared/repo/backend.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { from, Observable } from 'rxjs' -import { catchError, delay, filter, map, retryWhen } from 'rxjs/operators' -import { - CloneInProgressError, - CLONE_IN_PROGRESS_ERROR_NAME, - RepoNotFoundError, - RevNotFoundError, -} from '../../../../shared/src/backend/errors' -import { dataOrThrowErrors, gql } from '../../../../shared/src/graphql/graphql' -import * as GQL from '../../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { isErrorLike, createAggregateError } from '../../../../shared/src/util/errors' -import { memoizeObservable } from '../../../../shared/src/util/memoizeObservable' -import { FileSpec, makeRepoURI, RawRepoSpec, RepoSpec, ResolvedRevSpec, RevSpec } from '../../../../shared/src/util/url' - -/** - * @returns Observable that emits if the repo exists on the instance. - * Emits the repo name on the Sourcegraph instance as affected by `repositoryPathPattern`. - * Errors with a `RepoNotFoundError` if the repo is not found - */ -export const resolveRepo = memoizeObservable( - ({ rawRepoName, requestGraphQL }: RawRepoSpec & Pick): Observable => - requestGraphQL({ - request: gql` - query ResolveRepo($rawRepoName: String!) { - repository(name: $rawRepoName) { - name - } - } - `, - variables: { rawRepoName }, - // This request may leak private repository names - mightContainPrivateInfo: true, - }).pipe( - map(dataOrThrowErrors), - map( - ({ repository }) => { - if (!repository || !repository.name) { - throw new RepoNotFoundError(rawRepoName) - } - return repository.name - }, - catchError((err, caught) => caught) - ) - ), - ({ rawRepoName }) => rawRepoName -) - -/** - * @returns Observable that emits the commit ID. Errors with a `CloneInProgressError` if the repo is still being cloned. - */ -export const resolveRev = memoizeObservable( - ({ - requestGraphQL, - ...ctx - }: RepoSpec & Partial & Pick): Observable => - from( - requestGraphQL({ - request: gql` - query ResolveRev($repoName: String!, $rev: String!) { - repository(name: $repoName) { - mirrorInfo { - cloned - } - commit(rev: $rev) { - oid - } - } - } - `, - variables: { ...ctx, rev: ctx.rev || '' }, - mightContainPrivateInfo: true, - }) - ).pipe( - map(dataOrThrowErrors), - map(({ repository }) => { - if (!repository) { - throw new RepoNotFoundError(ctx.repoName) - } - if (!repository.mirrorInfo.cloned) { - throw new CloneInProgressError(ctx.repoName) - } - if (!repository.commit) { - throw new RevNotFoundError(ctx.rev) - } - return repository.commit.oid - }) - ), - makeRepoURI -) - -export function retryWhenCloneInProgressError(): (v: Observable) => Observable { - return (maybeErrors: Observable) => - maybeErrors.pipe( - retryWhen(errors => - errors.pipe( - filter(err => { - if (isErrorLike(err) && err.name === CLONE_IN_PROGRESS_ERROR_NAME) { - return true - } - - // Don't swallow other errors. - throw err - }), - delay(1000) - ) - ) - ) -} - -/** - * Fetches the lines of a given file at a given commit from the Sourcegraph API. - * Will return an empty array if the repo, commit or file does not exist or an error happened (TODO change this!). - * - * Only emits once. - */ -export const fetchBlobContentLines = memoizeObservable( - ({ - requestGraphQL, - ...ctx - }: RepoSpec & ResolvedRevSpec & FileSpec & Pick): Observable => - from( - requestGraphQL({ - request: gql` - query BlobContent($repoName: String!, $commitID: String!, $filePath: String!) { - repository(name: $repoName) { - commit(rev: $commitID) { - file(path: $filePath) { - content - } - } - } - } - `, - variables: ctx, - mightContainPrivateInfo: true, - }) - ).pipe( - map(({ data, errors }) => { - if (!data) { - throw new Error('Invalid response') - } - if (errors) { - if (errors.length === 1) { - const err = errors[0] - const isFileContent = err.path.join('.') === 'repository.commit.file.content' - const isDNE = err.message.includes('does not exist') - - // The error is the file DNE. Just ignore it and pass an empty array - // to represent this. - if (isFileContent && isDNE) { - return [] - } - } - throw createAggregateError(errors) - } - const { repository } = data - if (!repository || !repository.commit || !repository.commit.file || !repository.commit.file.content) { - return [] - } - return repository.commit.file.content.split('\n') - }) - ), - makeRepoURI -) diff --git a/browser/src/shared/repo/index.tsx b/browser/src/shared/repo/index.tsx deleted file mode 100644 index 4adf76c5359e..000000000000 --- a/browser/src/shared/repo/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -export interface DiffResolvedRevSpec { - baseCommitID: string - headCommitID: string -} - -export interface OpenInSourcegraphProps { - sourcegraphURL: string - repoName: string - rev: string - filePath?: string - commit?: { - baseRev: string - headRev: string - } - coords?: { - line: number - char: number - } - fragment?: 'references' - query?: { - search?: string - diff?: { - rev: string - } - } - withModifierKey?: boolean -} - -export interface OpenDiffInSourcegraphProps - extends Pick> { - commit: { - baseRev: string - headRev: string - } -} diff --git a/browser/src/shared/tracking/eventLogger.tsx b/browser/src/shared/tracking/eventLogger.tsx deleted file mode 100644 index ab1d46333da2..000000000000 --- a/browser/src/shared/tracking/eventLogger.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { noop } from 'lodash' -import { Observable, ReplaySubject } from 'rxjs' -import { take } from 'rxjs/operators' -import * as uuid from 'uuid' -import * as GQL from '../../../../shared/src/graphql/schema' -import { PlatformContext } from '../../../../shared/src/platform/context' -import { TelemetryService } from '../../../../shared/src/telemetry/telemetryService' -import { storage } from '../../browser/storage' -import { isInPage } from '../../context' -import { logUserEvent, logEvent } from '../backend/userEvents' -import { observeSourcegraphURL } from '../util/context' - -const uidKey = 'sourcegraphAnonymousUid' - -export class EventLogger implements TelemetryService { - private uid: string | null = null - - /** - * Buffered Observable for the latest Sourcegraph URL - */ - private sourcegraphURLs: Observable - - constructor(isExtension: boolean, private requestGraphQL: PlatformContext['requestGraphQL']) { - const replaySubject = new ReplaySubject(1) - this.sourcegraphURLs = replaySubject.asObservable() - // TODO pass this Observable as a parameter - // eslint-disable-next-line rxjs/no-ignored-subscription - observeSourcegraphURL(isExtension).subscribe(replaySubject) - // Fetch user ID on initial load. - this.getAnonUserID().catch(noop) - } - - /** - * Generate a new anonymous user ID if one has not yet been set and stored. - */ - private generateAnonUserID = (): string => uuid.v4() - - /** - * Get the anonymous identifier for this user (allows site admins on a private Sourcegraph - * instance to see a count of unique users on a daily, weekly, and monthly basis). - * - * Not used at all for public/Sourcegraph.com usage. - */ - private async getAnonUserID(): Promise { - if (this.uid) { - return this.uid - } - - if (isInPage) { - let id = localStorage.getItem(uidKey) - if (id === null) { - id = this.generateAnonUserID() - localStorage.setItem(uidKey, id) - } - this.uid = id - return this.uid - } - - let { sourcegraphAnonymousUid } = await storage.sync.get() - if (!sourcegraphAnonymousUid) { - sourcegraphAnonymousUid = this.generateAnonUserID() - await storage.sync.set({ sourcegraphAnonymousUid }) - } - this.uid = sourcegraphAnonymousUid - return sourcegraphAnonymousUid - } - - /** - * Log a user action on the associated self-hosted Sourcegraph instance (allows site admins on a private - * Sourcegraph instance to see a count of unique users on a daily, weekly, and monthly basis). - * - * This is never sent to Sourcegraph.com (i.e., when using the integration with open source code). - */ - public async logCodeIntelligenceEvent( - event: string, - userEvent: GQL.UserEvent, - eventProperties?: any - ): Promise { - const anonUserId = await this.getAnonUserID() - const sourcegraphURL = await this.sourcegraphURLs.pipe(take(1)).toPromise() - logUserEvent(userEvent, anonUserId, sourcegraphURL, this.requestGraphQL) - logEvent( - { name: event, userCookieID: anonUserId, url: sourcegraphURL, argument: eventProperties }, - this.requestGraphQL - ) - } - - /** - * Implements {@link TelemetryService}. - * - * @todo Handle arbitrary action IDs. - * - * @param eventName The ID of the action executed. - */ - public async log(eventName: string, eventProperties?: any): Promise { - switch (eventName) { - case 'goToDefinition': - case 'goToDefinition.preloaded': - case 'hover': - await this.logCodeIntelligenceEvent(eventName, GQL.UserEvent.CODEINTELINTEGRATION, eventProperties) - break - case 'findReferences': - await this.logCodeIntelligenceEvent(eventName, GQL.UserEvent.CODEINTELINTEGRATIONREFS, eventProperties) - break - } - } -} diff --git a/browser/src/shared/util/browser.ts b/browser/src/shared/util/browser.ts deleted file mode 100644 index d74347127d38..000000000000 --- a/browser/src/shared/util/browser.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Observable } from 'rxjs' - -/** - * Returns an Observable for a WebExtension API event listener. - * The handler will always return `void`. - */ -export const fromBrowserEvent = void>( - emitter: browser.CallbackEventEmitter -): Observable> => - // Do not use fromEventPattern() because of https://github.com/ReactiveX/rxjs/issues/4736 - new Observable(subscriber => { - const handler: any = (...args: any) => subscriber.next(args) - try { - emitter.addListener(handler) - } catch (err) { - subscriber.error(err) - return undefined - } - return () => emitter.removeListener(handler) - }) diff --git a/browser/src/shared/util/context.tsx b/browser/src/shared/util/context.tsx deleted file mode 100644 index 8d584957385b..000000000000 --- a/browser/src/shared/util/context.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Observable, of } from 'rxjs' -import { map } from 'rxjs/operators' -import { observeStorageKey } from '../../browser/storage' - -export const DEFAULT_SOURCEGRAPH_URL = 'https://sourcegraph.com' - -export function observeSourcegraphURL(isExtension: boolean): Observable { - if (isExtension) { - return observeStorageKey('sync', 'sourcegraphURL').pipe( - map(sourcegraphURL => sourcegraphURL || DEFAULT_SOURCEGRAPH_URL) - ) - } - return of(window.SOURCEGRAPH_URL || window.localStorage.getItem('SOURCEGRAPH_URL') || DEFAULT_SOURCEGRAPH_URL) -} - -/** - * Returns the base URL where assets will be fetched from - * (CSS, extension host worker, bundle...). - * - * The returned URL is guaranteed to have a trailing slash. - * - * If `window.SOURCEGRAPH_ASSETS_URL` is defined by a code host - * self-hosting the integration bundle, it will be returned. - * Otherwise, the given `sourcegraphURL` will be used. - */ -export function getAssetsURL(sourcegraphURL: string): string { - const assetsURL = window.SOURCEGRAPH_ASSETS_URL || new URL('/.assets/extension/', sourcegraphURL).href - return assetsURL.endsWith('/') ? assetsURL : assetsURL + '/' -} - -type PlatformName = NonNullable | 'firefox-extension' | 'chrome-extension' - -export function getPlatformName(): PlatformName { - if (window.SOURCEGRAPH_PHABRICATOR_EXTENSION) { - return 'phabricator-integration' - } - if (window.SOURCEGRAPH_INTEGRATION) { - return window.SOURCEGRAPH_INTEGRATION - } - return isFirefoxExtension() ? 'firefox-extension' : 'chrome-extension' -} - -export function getExtensionVersion(): string { - if (globalThis.browser) { - const manifest = browser.runtime.getManifest() - return manifest.version - } - - return 'NO_VERSION' -} - -function isFirefoxExtension(): boolean { - return window.navigator.userAgent.includes('Firefox') -} diff --git a/browser/src/shared/util/dom.tsx b/browser/src/shared/util/dom.tsx deleted file mode 100644 index 40966f7fbd12..000000000000 --- a/browser/src/shared/util/dom.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Observable, Subject, Subscription } from 'rxjs' - -/** - * commitIDFromPermalink finds the permalink element on the page and extracts - * the 40 character commit ID from it. This will throw if the link doesn't exist - * or doesn't match the provided regex. - */ -export function commitIDFromPermalink({ selector, hrefRegex }: { selector: string; hrefRegex: RegExp }): string { - const permalinkElement = document.querySelector(selector) - if (!permalinkElement) { - throw new Error( - `Unable to determine the commit ID (40 character hash) you're on because the permalink shortcut element (query selector ${selector}) which contains the commit ID does not exist in the DOM.` - ) - } - const href = permalinkElement.getAttribute('href') - if (!href) { - throw new Error( - `Unable to determine the commit ID (40 character hash) you're on because the permalink shortcut element (query selector ${selector}) which contains the commit ID does not have an href attribute.` - ) - } - const commitIDMatch = hrefRegex.exec(href) - if (!commitIDMatch || !commitIDMatch[1]) { - throw new Error( - `Unable to determine the commit ID (40 character hash) you're on because the permalink shortcut element's (query selector ${selector}) href is ${href}, which doesn't match the regex /${hrefRegex.source}/.` - ) - } - return commitIDMatch[1] -} - -/** - * Compatible with MutationRecord, but synthesizable. - */ -export interface MutationRecordLike { - addedNodes: ArrayLike & Iterable - removedNodes: ArrayLike & Iterable -} - -/** - * An Observable wrapper around `MutationObserver`. - * - * Instructs the user agent to observe a given `target` (a node) and report any mutations based on - * the criteria given by `options` (an object). - * - * The `options` argument allows for setting mutation observation options via object members. These - * are the object members that can be used: - * - `childList` Set to true if mutations to target's children are to be observed. - * - `attributes` Set to true if mutations to target's attributes are to be observed. Can be omitted - * if attributeOldValue or attributeFilter is specified. - * - `characterData` Set to true if mutations to target's data are to be observed. Can be omitted if - * characterDataOldValue is specified. - * - `subtree` Set to true if mutations to not just target, but also target's descendants are to be - * observed. - * - `attributeOldValue` Set to true if attributes is true or omitted and target's attribute value - * before the mutation needs to be recorded. - * - `characterDataOldValue` Set to true if characterData is set to true or omitted and target's - * data before the mutation needs to be recorded. - * - `attributeFilter` Set to a list of attribute local names (without namespace) if not all - * attribute mutations need to be observed and attributes is true or omitted. - * - * @param paused Allows pausing (via {@link MutationObserver#disconnect}) and resuming (via - * {@link MutationObserver#observe}) of the mutation observer. This is useful if the caller is - * itself mutating the DOM and doesn't want to receive events for its own mutations. - */ -export const observeMutations = ( - target: Node, - options?: MutationObserverInit, - paused?: Subject -): Observable => - new Observable(subscriber => { - const subscriptions = new Subscription() - const mutationObserver = new MutationObserver(mutations => subscriber.next(mutations)) - mutationObserver.observe(target, options) - subscriptions.add(() => mutationObserver.disconnect()) - if (paused) { - subscriptions.add( - paused.subscribe(paused => { - if (paused) { - mutationObserver.disconnect() - } else { - mutationObserver.observe(target, options) - } - }) - ) - } - return () => subscriptions.unsubscribe() - }) - -/** - * Like `element.querySelectorAll()`, but will return (only) the element itself if it matches the selector. - */ -export function querySelectorAllOrSelf( - element: Element, - selectors: K -): ArrayLike & Iterable -export function querySelectorAllOrSelf( - element: Element, - selectors: K -): ArrayLike & Iterable -export function querySelectorAllOrSelf( - element: Element, - selectors: string -): ArrayLike & Iterable -export function querySelectorAllOrSelf(element: Element, selectors: string): ArrayLike & Iterable { - return element.matches(selectors) ? [element] : element.querySelectorAll(selectors) -} - -/** - * Like `element.querySelector()`, but will return the element itself if it matches the selector. - */ -export function querySelectorOrSelf( - element: Element, - selectors: K -): HTMLElementTagNameMap[K] | null -export function querySelectorOrSelf( - element: Element, - selectors: K -): SVGElementTagNameMap[K] | null -export function querySelectorOrSelf(element: Element, selectors: string): E | null -export function querySelectorOrSelf(element: Element, selectors: string): Element | null { - return element.matches(selectors) ? element : element.querySelector(selectors) -} diff --git a/browser/src/shared/util/featureFlags.ts b/browser/src/shared/util/featureFlags.ts deleted file mode 100644 index 340d6c5f1068..000000000000 --- a/browser/src/shared/util/featureFlags.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { storage } from '../../browser/storage' -import { featureFlagDefaults, FeatureFlags } from '../../browser/types' -import { isInPage } from '../../context' - -interface FeatureFlagsStorage { - /** - * Checks to see if the feature flag is set enabled. - */ - isEnabled(key: K): Promise - /** - * Enable a feature flag. - */ - enable(key: K): Promise - /** - * Disable a feature flag. - */ - disable(key: K): Promise - /** - * Set a feature flag. - */ - set(key: K, enabled: boolean): Promise - /** Toggle a feature flag. */ - toggle(key: K): Promise -} - -interface FeatureFlagUtilities { - get(key: keyof FeatureFlags): Promise - set(key: keyof FeatureFlags, enabled: boolean): Promise -} - -const createFeatureFlagStorage = ({ get, set }: FeatureFlagUtilities): FeatureFlagsStorage => ({ - set, - enable: key => set(key, true), - disable: key => set(key, false), - async isEnabled(key: K): Promise { - const value = await get(key) - return typeof value === 'boolean' ? value : featureFlagDefaults[key] - }, - async toggle(key: K): Promise { - const val = await get(key) - await set(key, !val) - return !val - }, -}) - -async function bextGet(key: K): Promise { - const { featureFlags = {} } = await storage.sync.get() - return featureFlags[key] -} - -async function bextSet(key: K, val: FeatureFlags[K]): Promise { - const { featureFlags } = await storage.sync.get('featureFlags') - await storage.sync.set({ featureFlags: { ...featureFlags, [key]: val } }) -} - -const browserExtensionFeatureFlags = createFeatureFlagStorage({ - get: bextGet, - set: bextSet, -}) - -const inPageFeatureFlags = createFeatureFlagStorage({ - // eslint-disable-next-line @typescript-eslint/require-await - get: async key => { - const value = localStorage.getItem(key) - return value === null ? undefined : value === 'true' - }, - // eslint-disable-next-line @typescript-eslint/require-await - set: async (key, val) => { - localStorage.setItem(key, String(val)) - }, -}) - -export const featureFlags: FeatureFlagsStorage = isInPage ? inPageFeatureFlags : browserExtensionFeatureFlags diff --git a/browser/src/shared/util/url.test.ts b/browser/src/shared/util/url.test.ts deleted file mode 100644 index f38e7fc9ded9..000000000000 --- a/browser/src/shared/util/url.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DEFAULT_SOURCEGRAPH_URL } from './context' -import { toAbsoluteBlobURL } from './url' - -describe('toAbsoluteBlobURL', () => { - const ctx = { - repoName: 'github.com/gorilla/mux', - rev: '', - commitID: '24fca303ac6da784b9e8269f724ddeb0b2eea5e7', - filePath: 'mux.go', - } - - test('default sourcegraph URL, default context', () => { - expect(toAbsoluteBlobURL(DEFAULT_SOURCEGRAPH_URL, ctx)).toBe( - 'https://sourcegraph.com/github.com/gorilla/mux/-/blob/mux.go' - ) - }) - - test('default sourcegraph URL, specified rev', () => { - expect(toAbsoluteBlobURL(DEFAULT_SOURCEGRAPH_URL, { ...ctx, rev: 'branch' })).toBe( - 'https://sourcegraph.com/github.com/gorilla/mux@branch/-/blob/mux.go' - ) - }) - - test('default sourcegraph URL, with position', () => { - expect(toAbsoluteBlobURL(DEFAULT_SOURCEGRAPH_URL, { ...ctx, position: { line: 1, character: 1 } })).toBe( - 'https://sourcegraph.com/github.com/gorilla/mux/-/blob/mux.go#L1:1' - ) - }) -}) diff --git a/browser/src/shared/util/url.ts b/browser/src/shared/util/url.ts deleted file mode 100644 index 2764645eb8d5..000000000000 --- a/browser/src/shared/util/url.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - FileSpec, - UIPositionSpec, - RepoSpec, - RevSpec, - toPrettyBlobURL, - ViewStateSpec, -} from '../../../../shared/src/util/url' - -/** - * Returns an absolute URL to the blob (file) on the Sourcegraph instance. - */ -export function toAbsoluteBlobURL( - sourcegraphURL: string, - ctx: RepoSpec & RevSpec & FileSpec & Partial & Partial -): string { - // toPrettyBlobURL() always returns an URL starting with a forward slash, - // no need to add one here - return `${sourcegraphURL.replace(/\/$/, '')}${toPrettyBlobURL(ctx)}` -} diff --git a/browser/src/types/webextension-polyfill/index.d.ts b/browser/src/types/webextension-polyfill/index.d.ts deleted file mode 100644 index f153b0ac3ed2..000000000000 --- a/browser/src/types/webextension-polyfill/index.d.ts +++ /dev/null @@ -1,2018 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// license, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. -declare module 'webextension-polyfill' { - export = browser -} - -declare namespace browser { - /** - * An object that allows adding, removing and inspecting listeners. - * Event listeners may return a value. - */ - interface CallbackEventEmitter any> { - addListener(callback: F): void - removeListener(callback: F): void - hasListener(callback: F): boolean - } - - /** - * Simpler version for events with a single parameter that always return void. - */ - type EventEmitter = CallbackEventEmitter<(event: T) => void> -} - -declare namespace browser.alarms { - interface Alarm { - name: string - scheduledTime: number - periodInMinutes?: number - } - - interface When { - when?: number - periodInMinutes?: number - } - interface DelayInMinutes { - delayInMinutes?: number - periodInMinutes?: number - } - function create(name?: string, alarmInfo?: When | DelayInMinutes): void - function get(name?: string): Promise - function getAll(): Promise - function clear(name?: string): Promise - function clearAll(): Promise - - const onAlarm: EventEmitter -} - -declare namespace browser.bookmarks { - type BookmarkTreeNodeUnmodifiable = 'managed' - type BookmarkTreeNodeType = 'bookmark' | 'folder' | 'separator' - interface BookmarkTreeNode { - id: string - parentId?: string - index?: number - url?: string - title: string - dateAdded?: number - dateGroupModified?: number - unmodifiable?: BookmarkTreeNodeUnmodifiable - children?: BookmarkTreeNode[] - type?: BookmarkTreeNodeType - } - - interface CreateDetails { - parentId?: string - index?: number - title?: string - type?: BookmarkTreeNodeType - url?: string - } - - function create(bookmark: CreateDetails): Promise - function get(idOrIdList: string | string[]): Promise - function getChildren(id: string): Promise - function getRecent(numberOfItems: number): Promise - function getSubTree(id: string): Promise<[BookmarkTreeNode]> - function getTree(): Promise<[BookmarkTreeNode]> - - type Destination = - | { - parentId: string - index?: number - } - | { - index: number - parentId?: string - } - function move(id: string, destination: Destination): Promise - function remove(id: string): Promise - function removeTree(id: string): Promise - function search( - query: - | string - | { - query?: string - url?: string - title?: string - } - ): Promise - function update(id: string, changes: { title: string; url: string }): Promise - - const onCreated: CallbackEventEmitter<(id: string, bookmark: BookmarkTreeNode) => void> - const onRemoved: CallbackEventEmitter<( - id: string, - removeInfo: { - parentId: string - index: number - node: BookmarkTreeNode - } - ) => void> - const onChanged: CallbackEventEmitter<( - id: string, - changeInfo: { - title: string - url?: string - } - ) => void> - const onMoved: CallbackEventEmitter<( - id: string, - moveInfo: { - parentId: string - index: number - oldParentId: string - oldIndex: number - } - ) => void> -} - -declare namespace browser.browserAction { - type ColorArray = [number, number, number, number] - type ImageDataType = ImageData - - function setTitle(details: { title: string | null; tabId?: number }): void - function getTitle(details: { tabId?: number }): Promise - - interface IconViaPath { - path: string | { [size: number]: string } - tabId?: number - } - - interface IconViaImageData { - imageData: ImageDataType | { [size: number]: ImageDataType } - tabId?: number - } - - interface IconReset { - imageData?: {} | null - path?: {} | null - tabId?: number - } - - function setIcon(details: IconViaPath | IconViaImageData | IconReset): Promise - function setPopup(details: { popup: string | null; tabId?: number }): void - function getPopup(details: { tabId?: number }): Promise - function openPopup(): Promise - function setBadgeText(details: { text: string | null; tabId?: number }): void - function getBadgeText(details: { tabId?: number }): Promise - function setBadgeBackgroundColor(details: { color: string | ColorArray | null; tabId?: number }): void - function getBadgeBackgroundColor(details: { tabId?: number }): Promise - - interface SetBadgeTextColorDetails { - /** - * The color, specified as one of: - * - a string: any CSS color value, for example "red", "#FF0000", or "rgb(255,0,0)". If the string is not a valid color, the returned promise will be rejected and the text color won't be altered. - * - a `browserAction.ColorArray` object. - * - `null`. If a tabId is specified, it removes the tab-specific badge text color so that the tab inherits the global badge text color. Otherwise it reverts the global badge text color to the default value. - */ - color: string | ColorArray | null - } - function setBadgeTextColor(details: SetBadgeTextColorDetails & { tabId?: number }): void - // a union type would allow specifying both, which is not allowed. - // eslint-disable-next-line @typescript-eslint/unified-signatures - function setBadgeTextColor(details: SetBadgeTextColorDetails & { windowId?: number }): void - - function getBadgeTextColor(details: { tabId?: string }): Promise - // a union type would allow specifying both, which is not allowed. - // eslint-disable-next-line @typescript-eslint/unified-signatures - function getBadgeTextColor(details: { windowId?: string }): Promise - - function enable(tabId?: number): void - function disable(tabId?: number): void - - const onClicked: EventEmitter -} - -declare namespace browser.browsingData { - interface DataTypeSet { - cache?: boolean - cookies?: boolean - downloads?: boolean - fileSystems?: boolean - formData?: boolean - history?: boolean - indexedDB?: boolean - localStorage?: boolean - passwords?: boolean - pluginData?: boolean - serverBoundCertificates?: boolean - serviceWorkers?: boolean - } - - interface DataRemovalOptions { - since?: number - originTypes?: { unprotectedWeb: boolean } - } - - function remove(removalOptions: DataRemovalOptions, dataTypes: DataTypeSet): Promise - function removeCache(removalOptions?: DataRemovalOptions): Promise - function removeCookies(removalOptions: DataRemovalOptions): Promise - function removeDownloads(removalOptions: DataRemovalOptions): Promise - function removeFormData(removalOptions: DataRemovalOptions): Promise - function removeHistory(removalOptions: DataRemovalOptions): Promise - function removePasswords(removalOptions: DataRemovalOptions): Promise - function removePluginData(removalOptions: DataRemovalOptions): Promise - function settings(): Promise<{ - options: DataRemovalOptions - dataToRemove: DataTypeSet - dataRemovalPermitted: DataTypeSet - }> -} - -declare namespace browser.commands { - interface Command { - name?: string - description?: string - shortcut?: string - } - - function getAll(): Promise - - const onCommand: EventEmitter -} - -declare namespace browser.menus { - type ContextType = - | 'all' - | 'audio' - | 'bookmarks' - | 'browser_action' - | 'editable' - | 'frame' - | 'image' - // | "launcher" unsupported - | 'link' - | 'page' - | 'page_action' - | 'password' - | 'selection' - | 'tab' - | 'tools_menu' - | 'video' - - type ItemType = 'normal' | 'checkbox' | 'radio' | 'separator' - - interface OnClickData { - bookmarkId?: string - checked?: boolean - editable: boolean - frameId?: number - frameUrl?: string - linkText?: string - linkUrl?: string - mediaType?: string - menuItemId: number | string - modifiers: string[] - pageUrl?: string - parentMenuItemId?: number | string - selectionText?: string - srcUrl?: string - targetElementId?: number - wasChecked?: boolean - } - - const ACTION_MENU_TOP_LEVEL_LIMIT: number - - function create( - createProperties: { - checked?: boolean - command?: '_execute_browser_action' | '_execute_page_action' | '_execute_sidebar_action' - contexts?: ContextType[] - documentUrlPatterns?: string[] - enabled?: boolean - icons?: object - id?: string - onclick?: (info: OnClickData, tab: tabs.Tab) => void - parentId?: number | string - targetUrlPatterns?: string[] - title?: string - type?: ItemType - visible?: boolean - }, - callback?: () => void - ): number | string - - function getTargetElement(targetElementId: number): object | null - - function refresh(): Promise - - function remove(menuItemId: number | string): Promise - - function removeAll(): Promise - - function update( - id: number | string, - updateProperties: { - checked?: boolean - command?: '_execute_browser_action' | '_execute_page_action' | '_execute_sidebar_action' - contexts?: ContextType[] - documentUrlPatterns?: string[] - enabled?: boolean - onclick?: (info: OnClickData, tab: tabs.Tab) => void - parentId?: number | string - targetUrlPatterns?: string[] - title?: string - type?: ItemType - visible?: boolean - } - ): Promise - - const onClicked: CallbackEventEmitter<(info: OnClickData, tab: tabs.Tab) => void> - - const onHidden: CallbackEventEmitter<() => void> - - const onShown: CallbackEventEmitter<(info: OnClickData, tab: tabs.Tab) => void> -} - -declare namespace browser.contextualIdentities { - type IdentityColor = 'blue' | 'turquoise' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'purple' - type IdentityIcon = 'fingerprint' | 'briefcase' | 'dollar' | 'cart' | 'circle' - - interface ContextualIdentity { - cookieStoreId: string - color: IdentityColor - icon: IdentityIcon - name: string - } - - function create(details: { name: string; color: IdentityColor; icon: IdentityIcon }): Promise - function get(cookieStoreId: string): Promise - function query(details: { name?: string }): Promise - function update( - cookieStoreId: string, - details: { - name: string - color: IdentityColor - icon: IdentityIcon - } - ): Promise - function remove(cookieStoreId: string): Promise -} - -declare namespace browser.cookies { - interface Cookie { - name: string - value: string - domain: string - hostOnly: boolean - path: string - secure: boolean - httpOnly: boolean - session: boolean - expirationDate?: number - storeId: string - } - - interface CookieStore { - id: string - tabIds: number[] - } - - type OnChangedCause = 'evicted' | 'expired' | 'explicit' | 'expired_overwrite' | 'overwrite' - - function get(details: { url: string; name: string; storeId?: string }): Promise - function getAll(details: { - url?: string - name?: string - domain?: string - path?: string - secure?: boolean - session?: boolean - storeId?: string - }): Promise - function set(details: { - url: string - name?: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - expirationDate?: number - storeId?: string - }): Promise - function remove(details: { url: string; name: string; storeId?: string }): Promise - function getAllCookieStores(): Promise - - const onChanged: EventEmitter<{ - removed: boolean - cookie: Cookie - cause: OnChangedCause - }> -} - -declare namespace browser.contentScripts { - interface RegisteredContentScriptOptions { - allFrames?: boolean - css?: ({ file: string } | { code: string })[] - excludeGlobs?: string[] - excludeMatches?: string[] - includeGlobs?: string[] - js?: ({ file: string } | { code: string })[] - matchAboutBlank?: boolean - matches: string[] - runAt?: 'document_start' | 'document_end' | 'document_idle' - } - - interface RegisteredContentScript { - unregister: () => void - } - - function register(contentScriptOptions: RegisteredContentScriptOptions): Promise -} - -declare namespace browser.devtools.inspectedWindow { - const tabId: number - - function eval( - expression: string - ): Promise<[any, { isException: boolean; value: string } | { isError: boolean; code: string }]> - - function reload(reloadOptions?: { ignoreCache?: boolean; userAgent?: string; injectedScript?: string }): void -} - -declare namespace browser.devtools.network { - const onNavigated: EventEmitter -} - -declare namespace browser.devtools.panels { - interface ExtensionPanel { - onShown: EventEmitter - onHidden: EventEmitter - } - - function create(title: string, iconPath: string, pagePath: string): Promise -} - -declare namespace browser.downloads { - type FilenameConflictAction = 'uniquify' | 'overwrite' | 'prompt' - - type InterruptReason = - | 'FILE_FAILED' - | 'FILE_ACCESS_DENIED' - | 'FILE_NO_SPACE' - | 'FILE_NAME_TOO_LONG' - | 'FILE_TOO_LARGE' - | 'FILE_VIRUS_INFECTED' - | 'FILE_TRANSIENT_ERROR' - | 'FILE_BLOCKED' - | 'FILE_SECURITY_CHECK_FAILED' - | 'FILE_TOO_SHORT' - | 'NETWORK_FAILED' - | 'NETWORK_TIMEOUT' - | 'NETWORK_DISCONNECTED' - | 'NETWORK_SERVER_DOWN' - | 'NETWORK_INVALID_REQUEST' - | 'SERVER_FAILED' - | 'SERVER_NO_RANGE' - | 'SERVER_BAD_CONTENT' - | 'SERVER_UNAUTHORIZED' - | 'SERVER_CERT_PROBLEM' - | 'SERVER_FORBIDDEN' - | 'USER_CANCELED' - | 'USER_SHUTDOWN' - | 'CRASH' - - type DangerType = 'file' | 'url' | 'content' | 'uncommon' | 'host' | 'unwanted' | 'safe' | 'accepted' - - type State = 'in_progress' | 'interrupted' | 'complete' - - interface DownloadItem { - id: number - url: string - referrer: string - filename: string - incognito: boolean - danger: string - mime: string - startTime: string - endTime?: string - estimatedEndTime?: string - state: string - paused: boolean - canResume: boolean - error?: string - bytesReceived: number - totalBytes: number - fileSize: number - exists: boolean - byExtensionId?: string - byExtensionName?: string - } - - interface Delta { - current?: T - previous?: T - } - - type StringDelta = Delta - type DoubleDelta = Delta - type BooleanDelta = Delta - type DownloadTime = Date | string | number - - interface DownloadQuery { - query?: string[] - startedBefore?: DownloadTime - startedAfter?: DownloadTime - endedBefore?: DownloadTime - endedAfter?: DownloadTime - totalBytesGreater?: number - totalBytesLess?: number - filenameRegex?: string - urlRegex?: string - limit?: number - orderBy?: string - id?: number - url?: string - filename?: string - danger?: DangerType - mime?: string - startTime?: string - endTime?: string - state?: State - paused?: boolean - error?: InterruptReason - bytesReceived?: number - totalBytes?: number - fileSize?: number - exists?: boolean - } - - function download(options: { - url: string - filename?: string - conflictAction?: string - saveAs?: boolean - method?: string - headers?: { [key: string]: string } - body?: string - }): Promise - function search(query: DownloadQuery): Promise - function pause(downloadId: number): Promise - function resume(downloadId: number): Promise - function cancel(downloadId: number): Promise - // unsupported: function getFileIcon(downloadId: number, options?: { size?: number }): - // Promise; - function open(downloadId: number): Promise - function show(downloadId: number): Promise - function showDefaultFolder(): void - function erase(query: DownloadQuery): Promise - function removeFile(downloadId: number): Promise - // unsupported: function acceptDanger(downloadId: number): Promise; - // unsupported: function drag(downloadId: number): Promise; - // unsupported: function setShelfEnabled(enabled: boolean): void; - - const onCreated: EventEmitter - const onErased: EventEmitter - const onChanged: EventEmitter<{ - id: number - url?: StringDelta - filename?: StringDelta - danger?: StringDelta - mime?: StringDelta - startTime?: StringDelta - endTime?: StringDelta - state?: StringDelta - canResume?: BooleanDelta - paused?: BooleanDelta - error?: StringDelta - totalBytes?: DoubleDelta - fileSize?: DoubleDelta - exists?: BooleanDelta - }> -} - -declare namespace browser.events { - interface UrlFilter { - hostContains?: string - hostEquals?: string - hostPrefix?: string - hostSuffix?: string - pathContains?: string - pathEquals?: string - pathPrefix?: string - pathSuffix?: string - queryContains?: string - queryEquals?: string - queryPrefix?: string - querySuffix?: string - urlContains?: string - urlEquals?: string - urlMatches?: string - originAndPathMatches?: string - urlPrefix?: string - urlSuffix?: string - schemes?: string[] - ports?: (number | number[])[] - } -} - -declare namespace browser.extension { - type ViewType = 'tab' | 'notification' | 'popup' - - const lastError: string | null - const inIncognitoContext: boolean - - function getURL(path: string): string - function getViews(fetchProperties?: { type?: ViewType; windowId?: number }): Window[] - function getBackgroundPage(): Window - function isAllowedIncognitoAccess(): Promise - function isAllowedFileSchemeAccess(): Promise - // unsupported: events as they are deprecated -} - -declare namespace browser.extensionTypes { - type ImageFormat = 'jpeg' | 'png' - interface ImageDetails { - format: ImageFormat - quality: number - } - type RunAt = 'document_start' | 'document_end' | 'document_idle' - interface InjectDetails { - allFrames?: boolean - code?: string - file?: string - frameId?: number - matchAboutBlank?: boolean - runAt?: RunAt - } - type InjectDetailsCSS = InjectDetails & { cssOrigin?: 'user' | 'author' } -} - -declare namespace browser.history { - type TransitionType = - | 'link' - | 'typed' - | 'auto_bookmark' - | 'auto_subframe' - | 'manual_subframe' - | 'generated' - | 'auto_toplevel' - | 'form_submit' - | 'reload' - | 'keyword' - | 'keyword_generated' - - interface HistoryItem { - id: string - url?: string - title?: string - lastVisitTime?: number - visitCount?: number - typedCount?: number - } - - interface VisitItem { - id: string - visitId: string - VisitTime?: number - refferingVisitId: string - transition: TransitionType - } - - function search(query: { - text: string - startTime?: number | string | Date - endTime?: number | string | Date - maxResults?: number - }): Promise - - function getVisits(details: { url: string }): Promise - - function addUrl(details: { - url: string - title?: string - transition?: TransitionType - visitTime?: number | string | Date - }): Promise - - function deleteUrl(details: { url: string }): Promise - - function deleteRange(range: { startTime: number | string | Date; endTime: number | string | Date }): Promise - - function deleteAll(): Promise - - const onVisited: EventEmitter - - // TODO: Ensure that urls is not `urls: [string]` instead - const onVisitRemoved: EventEmitter<{ allHistory: boolean; urls: string[] }> -} - -declare namespace browser.i18n { - type LanguageCode = string - - function getAcceptLanguages(): Promise - - function getMessage(messageName: string, substitutions?: string | string[]): string - - function getUILanguage(): LanguageCode - - function detectLanguage( - text: string - ): Promise<{ - isReliable: boolean - languages: { language: LanguageCode; percentage: number }[] - }> -} - -declare namespace browser.identity { - function getRedirectURL(): string - function launchWebAuthFlow(details: { url: string; interactive: boolean }): Promise -} - -declare namespace browser.idle { - type IdleState = 'active' | 'idle' /* unsupported: | "locked" */ - - function queryState(detectionIntervalInSeconds: number): Promise - function setDetectionInterval(intervalInSeconds: number): void - - const onStateChanged: EventEmitter -} - -declare namespace browser.management { - interface ExtensionInfo { - description: string - // unsupported: disabledReason: string, - enabled: boolean - homepageUrl: string - hostPermissions: string[] - icons: { size: number; url: string }[] - id: string - installType: 'admin' | 'development' | 'normal' | 'sideload' | 'other' - mayDisable: boolean - name: string - // unsupported: offlineEnabled: boolean, - optionsUrl: string - permissions: string[] - shortName: string - // unsupported: type: string, - updateUrl: string - version: string - // unsupported: versionName: string, - } - - function getSelf(): Promise - function uninstallSelf(options: { showConfirmDialog: boolean; dialogMessage: string }): Promise -} - -declare namespace browser.notifications { - type TemplateType = 'basic' /* | "image" | "list" | "progress" */ - - interface NotificationOptions { - type: TemplateType - message: string - title: string - iconUrl?: string - } - - function create(id: string | null, options: NotificationOptions): Promise - function create(options: NotificationOptions): Promise - - function clear(id: string): Promise - - function getAll(): Promise<{ [key: string]: NotificationOptions }> - - const onClosed: EventEmitter - - const onClicked: EventEmitter -} - -declare namespace browser.omnibox { - type OnInputEnteredDisposition = 'currentTab' | 'newForegroundTab' | 'newBackgroundTab' - interface SuggestResult { - content: string - description: string - } - - function setDefaultSuggestion(suggestion: { description: string }): void - - const onInputStarted: EventEmitter - const onInputChanged: CallbackEventEmitter<(text: string, suggest: (arg: SuggestResult[]) => void) => void> - const onInputEntered: CallbackEventEmitter<(text: string, disposition: OnInputEnteredDisposition) => void> - const onInputCancelled: EventEmitter -} - -declare namespace browser.pageAction { - type ImageDataType = ImageData - - function show(tabId: number): void - - function hide(tabId: number): void - - function setTitle(details: { tabId: number; title: string }): void - - function getTitle(details: { tabId: number }): Promise - - function setIcon(details: { tabId: number; path?: string | object; imageData?: ImageDataType }): Promise - - function setPopup(details: { tabId: number; popup: string }): void - - function getPopup(details: { tabId: number }): Promise - - const onClicked: EventEmitter -} - -declare namespace browser.permissions { - type Permission = - | 'activeTab' - | 'alarms' - | 'background' - | 'bookmarks' - | 'browsingData' - | 'browserSettings' - | 'clipboardRead' - | 'clipboardWrite' - | 'contextMenus' - | 'contextualIdentities' - | 'cookies' - | 'downloads' - | 'downloads.open' - | 'find' - | 'geolocation' - | 'history' - | 'identity' - | 'idle' - | 'management' - | 'menus' - | 'nativeMessaging' - | 'notifications' - | 'pkcs11' - | 'privacy' - | 'proxy' - | 'sessions' - | 'storage' - | 'tabs' - | 'theme' - | 'topSites' - | 'unlimitedStorage' - | 'webNavigation' - | 'webRequest' - | 'webRequestBlocking' - - interface Permissions { - origins?: string[] - permissions?: Permission[] - } - - function contains(permissions: Permissions): Promise - - function getAll(): Promise - - function remove(permissions: Permissions): Promise - - function request(permissions: Permissions): Promise - - /** - * Not supported yet in Firefox: - * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded#Browser_compatibility - */ - const onAdded: EventEmitter | undefined - - /** - * Not supported yet in Firefox: - * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/onAdded#Browser_compatibility - */ - const onRemoved: EventEmitter | undefined -} - -declare namespace browser.runtime { - const lastError: string | null - const id: string - - interface Port { - /** - * The port's name, defined in the runtime.connect() or tabs.connect() call that created it. - * If this port is connected to a native application, its name is the name of the native application. - */ - name: string - disconnect(): void - error: Error | null - onDisconnect: EventEmitter - onMessage: EventEmitter - postMessage(message: any): void - } - interface PortWithSender extends Port { - /** - * Contains information about the sender of the message. - * This property will only be present on ports passed to onConnect/onConnectExternal listeners. - */ - sender: MessageSender - } - - interface MessageSender { - tab?: tabs.Tab - frameId?: number - id?: string - url?: string - tlsChannelId?: string - } - - type PlatformOs = 'mac' | 'win' | 'android' | 'cros' | 'linux' | 'openbsd' - type PlatformArch = 'arm' | 'x86-32' | 'x86-64' - type PlatformNaclArch = 'arm' | 'x86-32' | 'x86-64' - - interface PlatformInfo { - os: PlatformOs - arch: PlatformArch - } - - // type RequestUpdateCheckStatus = "throttled" | "no_update" | "update_available"; - type OnInstalledReason = 'install' | 'update' | 'chrome_update' | 'shared_module_update' - type OnRestartRequiredReason = 'app_update' | 'os_update' | 'periodic' - - interface FirefoxSpecificProperties { - id?: string - strict_min_version?: string - strict_max_version?: string - update_url?: string - } - - type IconPath = { [urlName: string]: string } | string - - interface Manifest { - // Required - manifest_version: 2 - name: string - version: string - /** Required in Microsoft Edge */ - author?: string - - // Optional - - // ManifestBase - description?: string - homepage_url?: string - short_name?: string - - // WebExtensionManifest - background?: { - page: string - script: string[] - persistent?: boolean - } - content_scripts?: { - matches: string[] - exclude_matches?: string[] - include_globs?: string[] - exclude_globs?: string[] - css?: string[] - js?: string[] - all_frames?: boolean - match_about_blank?: boolean - run_at?: 'document_start' | 'document_end' | 'document_idle' - }[] - content_security_policy?: string - developer?: { - name?: string - url?: string - } - icons?: { - [imgSize: string]: string - } - incognito?: 'spanning' | 'split' | 'not_allowed' - optional_permissions?: permissions.Permission[] - options_ui?: { - page: string - browser_style?: boolean - chrome_style?: boolean - open_in_tab?: boolean - } - permissions?: permissions.Permission[] - web_accessible_resources?: string[] - - // WebExtensionLangpackManifest - languages: { - [langCode: string]: { - chrome_resources: { - [resName: string]: string | { [urlName: string]: string } - } - version: string - } - } - langpack_id?: string - sources?: { - [srcName: string]: { - base_path: string - paths?: string[] - } - } - - // Extracted from components - browser_action?: { - default_title?: string - default_icon?: IconPath - theme_icons?: { - light: string - dark: string - size: number - }[] - default_popup?: string - browser_style?: boolean - default_area?: 'navbar' | 'menupanel' | 'tabstrip' | 'personaltoolbar' - } - commands?: { - [keyName: string]: { - suggested_key?: { - default?: string - mac?: string - linux?: string - windows?: string - chromeos?: string - android?: string - ios?: string - } - description?: string - } - } - default_locale?: i18n.LanguageCode - devtools_page?: string - omnibox?: { - keyword: string - } - page_action?: { - default_title?: string - default_icon?: IconPath - default_popup?: string - browser_style?: boolean - show_matches?: string[] - hide_matches?: string[] - } - sidebar_action?: { - default_panel: string - default_title?: string - default_icon?: IconPath - browser_style?: boolean - } - - // Firefox specific - applications?: { - gecko?: FirefoxSpecificProperties - } - browser_specific_settings?: { - gecko?: FirefoxSpecificProperties - } - experiment_apis?: any - protocol_handlers?: { - name: string - protocol: string - uriTemplate: string - } - - // Opera specific - minimum_opera_version?: string - - // Chrome specific - action?: any - automation?: any - background_page?: any - chrome_settings_overrides?: { - homepage?: string - search_provider?: { - name: string - search_url: string - keyword?: string - favicon_url?: string - suggest_url?: string - instant_url?: string - is_default?: string - image_url?: string - search_url_post_params?: string - instant_url_post_params?: string - image_url_post_params?: string - alternate_urls?: string[] - prepopulated_id?: number - } - } - chrome_ui_overrides?: { - bookmarks_ui?: { - remove_bookmark_shortcut?: true - remove_button?: true - } - } - chrome_url_overrides?: { - newtab?: string - bookmarks?: string - history?: string - } - content_capabilities?: any - converted_from_user_script?: any - current_locale?: any - declarative_net_request?: any - event_rules?: any[] - export?: { - whitelist?: string[] - } - externally_connectable?: { - ids?: string[] - matches?: string[] - accepts_tls_channel_id?: boolean - } - file_browser_handlers?: { - id: string - default_title: string - file_filters: string[] - }[] - file_system_provider_capabilities?: { - source: 'file' | 'device' | 'network' - configurable?: boolean - multiple_mounts?: boolean - watchable?: boolean - } - import?: { - id: string - minimum_version?: string - }[] - input_components?: any - key?: string - minimum_chrome_version?: string - nacl_modules?: { - path: string - mime_type: string - }[] - oauth2?: any - offline_enabled?: boolean - options_page?: string - platforms?: any - requirements?: any - sandbox?: { - pages: string[] - content_security_policy?: string - }[] - signature?: any - spellcheck?: any - storage?: { - managed_schema: string - } - system_indicator?: any - tts_engine?: { - voice: { - voice_name: string - lang?: string - gender?: 'male' | 'female' - event_types: ('start' | 'word' | 'sentence' | 'marker' | 'end' | 'error')[] - }[] - } - update_url?: string - version_name?: string - } - - /** - * Only defined in the background page - */ - const getBackgroundPage: (() => Promise) | undefined - - function openOptionsPage(): Promise - function getManifest(): Manifest - - function getURL(path: string): string - function setUninstallURL(url: string): Promise - function reload(): void - // Will not exist: https://bugzilla.mozilla.org/show_bug.cgi?id=1314922 - // function RequestUpdateCheck(): Promise; - - interface ConnectInfo { - name?: string - includeTlsChannelId?: boolean - } - /** - * @param connectInfo Details of the connection - */ - function connect(connectInfo?: ConnectInfo): Port - /** - * @param extensionId The ID of the extension to connect to. If the target has set an ID explicitly using the applications key in manifest.json, then extensionId should have that value. Otherwise it should have the ID that was generated for the target. - * @param connectInfo Details of the connection - */ - function connect(extensionId?: string, connectInfo?: ConnectInfo): Port - - function connectNative(application: string): Port - - function sendMessage( - message: any, - options?: { includeTlsChannelId?: boolean; toProxyScript?: boolean } - ): Promise - function sendMessage( - extensionId: string, - message: any, - options?: { includeTlsChannelId?: boolean; toProxyScript?: boolean } - ): Promise - - function sendNativeMessage(application: string, message: object): Promise - function getPlatformInfo(): Promise - function getBrowserInfo(): Promise<{ - name: string - vendor: string - version: string - buildID: string - }> - // Unsupported: https://bugzilla.mozilla.org/show_bug.cgi?id=1339407 - // function getPackageDirectoryEntry(): Promise; - - const onStartup: EventEmitter - const onInstalled: EventEmitter<{ - reason: OnInstalledReason - previousVersion?: string - id?: string - }> - // Unsupported - // const onSuspend: Listener; - // const onSuspendCanceled: Listener; - // const onBrowserUpdateAvailable: Listener; - // const onRestartRequired: Listener; - const onUpdateAvailable: EventEmitter<{ version: string }> - const onConnect: EventEmitter - - const onConnectExternal: EventEmitter - - type OnMessageHandler = (message: any, sender: MessageSender) => void | Promise - - const onMessage: CallbackEventEmitter - - const onMessageExternal: CallbackEventEmitter -} - -declare namespace browser.sessions { - interface Filter { - maxResults?: number - } - - interface Session { - lastModified: number - tab: tabs.Tab - window: windows.Window - } - - const MAX_SESSION_RESULTS: number - - function getRecentlyClosed(filter?: Filter): Promise - - function restore(sessionId: string): Promise - - function setTabValue(tabId: number, key: string, value: string | object): Promise - - function getTabValue(tabId: number, key: string): Promise - - function removeTabValue(tabId: number, key: string): Promise - - function setWindowValue(windowId: number, key: string, value: string | object): Promise - - function getWindowValue(windowId: number, key: string): Promise - - function removeWindowValue(windowId: number, key: string): Promise - - const onChanged: CallbackEventEmitter<() => void> -} - -declare namespace browser.sidebarAction { - type ImageDataType = ImageData - - function setPanel(details: { panel: string; tabId?: number }): void - - function getPanel(details: { tabId?: number }): Promise - - function setTitle(details: { title: string; tabId?: number }): void - - function getTitle(details: { tabId?: number }): Promise - - interface IconViaPath { - path: string | { [index: number]: string } - tabId?: number - } - - interface IconViaImageData { - imageData: ImageDataType | { [index: number]: ImageDataType } - tabId?: number - } - - function setIcon(details: IconViaPath | IconViaImageData): Promise - - function open(): Promise - - function close(): Promise -} - -declare namespace browser.storage { - /** - * Example for type-safe usage: - * - * ```ts - * interface MyStorageItems { - * foo: number - * } - * - * (browser.storage.sync as browser.storage.StorageArea).get('foo') - * ``` - */ - interface StorageArea { - get(): Promise> - get(keys: K[] | K): Promise<{ [k in K]?: T[k] }> - - /** - * Stores one or more items in the storage area, or update existing items. - * - * When you store or update a value using this API, the storage.onChanged event will fire. - * - * @param items An object containing one or more key/value pairs to be stored in storage. If an item already exists, its value will be updated. - * Values may be primitive types (such as numbers, booleans, and strings) or Array types. - * It's generally not possible to store other types, such as Function, Date, RegExp, Set, Map, ArrayBuffer and so on. Some of these unsupported types will restore as an empty object, and some cause set() to throw an error. The exact behavior here is browser-specific. - * If a value is `undefined`, it will not be changed. - * If a value is `null`, it will be set to `null`. - */ - set(items: Partial): Promise - remove(keys: keyof T | (keyof T)[]): Promise - clear(): Promise - - // unsupported: getBytesInUse: (keys: string|string[]|null) => Promise, - } - - interface StorageChange { - oldValue?: T - newValue?: T - } - - const sync: StorageArea - const local: StorageArea - const managed: StorageArea - - type ChangeDict = { [K in keyof T]?: StorageChange } - type AreaName = 'sync' | 'local' | 'managed' - - const onChanged: CallbackEventEmitter<(changes: ChangeDict, areaName: AreaName) => void> -} - -declare namespace browser.tabs { - type MutedInfoReason = 'capture' | 'extension' | 'user' - interface MutedInfo { - muted: boolean - extensionId?: string - reason: MutedInfoReason - } - // TODO: Specify PageSettings properly. - type PageSettings = object - interface Tab { - active: boolean - audible?: boolean - autoDiscardable?: boolean - cookieStoreId?: string - discarded?: boolean - favIconUrl?: string - height?: number - hidden: boolean - highlighted: boolean - id?: number - incognito: boolean - index: number - isArticle: boolean - isInReaderMode: boolean - lastAccessed: number - mutedInfo?: MutedInfo - openerTabId?: number - pinned: boolean - selected: boolean - sessionId?: string - status?: string - title?: string - url?: string - width?: number - windowId: number - } - - type TabStatus = 'loading' | 'complete' - type WindowType = 'normal' | 'popup' | 'panel' | 'devtools' - type ZoomSettingsMode = 'automatic' | 'disabled' | 'manual' - type ZoomSettingsScope = 'per-origin' | 'per-tab' - interface ZoomSettings { - defaultZoomFactor?: number - mode?: ZoomSettingsMode - scope?: ZoomSettingsScope - } - - const TAB_ID_NONE: number - - function connect(tabId: number, connectInfo?: { name?: string; frameId?: number }): runtime.Port - function create(createProperties: { - active?: boolean - cookieStoreId?: string - index?: number - openerTabId?: number - pinned?: boolean - // deprecated: selected: boolean, - url?: string - windowId?: number - }): Promise - function captureTab(tabId?: number, options?: extensionTypes.ImageDetails): Promise - function captureVisibleTab(windowId?: number, options?: extensionTypes.ImageDetails): Promise - function detectLanguage(tabId?: number): Promise - function duplicate(tabId: number): Promise - function executeScript(tabId: number | undefined, details: extensionTypes.InjectDetails): Promise - function get(tabId: number): Promise - // deprecated: function getAllInWindow(): x; - function getCurrent(): Promise - // deprecated: function getSelected(windowId?: number): Promise; - function getZoom(tabId?: number): Promise - function getZoomSettings(tabId?: number): Promise - function hide(tabIds: number | number[]): Promise - // unsupported: function highlight(highlightInfo: { - // windowId?: number, - // tabs: number[]|number, - // }): Promise; - function insertCSS(tabId: number | undefined, details: extensionTypes.InjectDetailsCSS): Promise - function removeCSS(tabId: number | undefined, details: extensionTypes.InjectDetails): Promise - function move( - tabIds: number | number[], - moveProperties: { - windowId?: number - index: number - } - ): Promise - function print(): Promise - function printPreview(): Promise - function query(queryInfo: { - active?: boolean - audible?: boolean - // unsupported: autoDiscardable?: boolean, - cookieStoreId?: string - currentWindow?: boolean - discarded?: boolean - hidden?: boolean - highlighted?: boolean - index?: number - muted?: boolean - lastFocusedWindow?: boolean - pinned?: boolean - status?: TabStatus - title?: string - url?: string | string[] - windowId?: number - windowType?: WindowType - }): Promise - function reload(tabId?: number, reloadProperties?: { bypassCache?: boolean }): Promise - function remove(tabIds: number | number[]): Promise - function saveAsPDF( - pageSettings: PageSettings - ): Promise<'saved' | 'replaced' | 'canceled' | 'not_saved' | 'not_replaced'> - function sendMessage( - tabId: number, - message: T, - options?: { frameId?: number } - ): Promise - // deprecated: function sendRequest(): x; - function setZoom(tabId: number | undefined, zoomFactor: number): Promise - function setZoomSettings(tabId: number | undefined, zoomSettings: ZoomSettings): Promise - function show(tabIds: number | number[]): Promise - function toggleReaderMode(tabId?: number): Promise - - interface UpdateProperties { - active?: boolean - // unsupported: autoDiscardable?: boolean, - // unsupported: highlighted?: boolean, - // unsupported: hidden?: boolean; - loadReplace?: boolean - muted?: boolean - openerTabId?: number - pinned?: boolean - // deprecated: selected?: boolean, - url?: string - } - function update(tabId: number | undefined, updateProperties: UpdateProperties): Promise - function update(updateProperties: UpdateProperties): Promise - - const onActivated: EventEmitter<{ tabId: number; windowId: number }> - const onAttached: CallbackEventEmitter<( - tabId: number, - attachInfo: { - newWindowId: number - newPosition: number - } - ) => void> - const onCreated: EventEmitter - const onDetached: CallbackEventEmitter<( - tabId: number, - detachInfo: { - oldWindowId: number - oldPosition: number - } - ) => void> - const onHighlighted: EventEmitter<{ windowId: number; tabIds: number[] }> - const onMoved: CallbackEventEmitter<( - tabId: number, - moveInfo: { - windowId: number - fromIndex: number - toIndex: number - } - ) => void> - const onRemoved: CallbackEventEmitter<( - tabId: number, - removeInfo: { - windowId: number - isWindowClosing: boolean - } - ) => void> - const onReplaced: CallbackEventEmitter<(addedTabId: number, removedTabId: number) => void> - const onUpdated: CallbackEventEmitter<( - tabId: number, - changeInfo: { - audible?: boolean - discarded?: boolean - favIconUrl?: string - mutedInfo?: MutedInfo - pinned?: boolean - status?: string - title?: string - url?: string - }, - tab: Tab - ) => void> - const onZoomChanged: EventEmitter<{ - tabId: number - oldZoomFactor: number - newZoomFactor: number - zoomSettings: ZoomSettings - }> -} - -declare namespace browser.topSites { - interface MostVisitedURL { - title: string - url: string - } - function get(): Promise -} - -declare namespace browser.webNavigation { - type TransitionType = 'link' | 'auto_subframe' | 'form_submit' | 'reload' - // unsupported: | "typed" | "auto_bookmark" | "manual_subframe" - // | "generated" | "start_page" | "keyword" - // | "keyword_generated"; - - type TransitionQualifier = 'client_redirect' | 'server_redirect' | 'forward_back' - // unsupported: "from_address_bar"; - - function getFrame(details: { - tabId: number - processId: number - frameId: number - }): Promise<{ errorOccured: boolean; url: string; parentFrameId: number }> - - function getAllFrames(details: { - tabId: number - }): Promise< - { - errorOccured: boolean - processId: number - frameId: number - parentFrameId: number - url: string - }[] - > - - interface NavListener { - addListener: ( - callback: (arg: T) => void, - filter?: { - url: events.UrlFilter[] - } - ) => void - removeListener: (callback: (arg: T) => void) => void - hasListener: (callback: (arg: T) => void) => boolean - } - - type DefaultNavListener = NavListener<{ - tabId: number - url: string - processId: number - frameId: number - timeStamp: number - }> - - type TransitionNavListener = NavListener<{ - tabId: number - url: string - processId: number - frameId: number - timeStamp: number - transitionType: TransitionType - transitionQualifiers: TransitionQualifier[] - }> - - const onBeforeNavigate: NavListener<{ - tabId: number - url: string - processId: number - frameId: number - parentFrameId: number - timeStamp: number - }> - - const onCommitted: TransitionNavListener - - const onCreatedNavigationTarget: NavListener<{ - sourceFrameId: number - // Unsupported: sourceProcessId: number, - sourceTabId: number - tabId: number - timeStamp: number - url: string - windowId: number - }> - - const onDOMContentLoaded: DefaultNavListener - - const onCompleted: DefaultNavListener - - const onErrorOccurred: DefaultNavListener // error field unsupported - - const onReferenceFragmentUpdated: TransitionNavListener - - const onHistoryStateUpdated: TransitionNavListener -} - -declare namespace browser.webRequest { - type ResourceType = - | 'main_frame' - | 'sub_frame' - | 'stylesheet' - | 'script' - | 'image' - | 'object' - | 'xmlhttprequest' - | 'xbl' - | 'xslt' - | 'ping' - | 'beacon' - | 'xml_dtd' - | 'font' - | 'media' - | 'websocket' - | 'csp_report' - | 'imageset' - | 'web_manifest' - | 'other' - - interface RequestFilter { - urls: string[] - types?: ResourceType[] - tabId?: number - windowId?: number - } - - interface StreamFilter { - onstart: (event: any) => void - ondata: (event: { data: ArrayBuffer }) => void - onstop: (event: any) => void - onerror: (event: any) => void - - close(): void - disconnect(): void - resume(): void - suspend(): void - write(data: Uint8Array | ArrayBuffer): void - - error: string - status: - | 'uninitialized' - | 'transferringdata' - | 'finishedtransferringdata' - | 'suspended' - | 'closed' - | 'disconnected' - | 'failed' - } - - type HttpHeaders = ( - | { name: string; binaryValue: number[]; value?: string } - | { name: string; value: string; binaryValue?: number[] } - )[] - - interface BlockingResponse { - cancel?: boolean - redirectUrl?: string - requestHeaders?: HttpHeaders - responseHeaders?: HttpHeaders - authCredentials?: { username: string; password: string } - } - - interface UploadData { - bytes?: ArrayBuffer - file?: string - } - - const MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES: number - - function handlerBehaviorChanged(): Promise - - // TODO: Enforce the return result of the addListener call in the contract - // Use an intersection type for all the default properties - interface ReqListener { - addListener: ( - callback: (arg: T) => void, - filter: RequestFilter, - extraInfoSpec?: U[] - ) => BlockingResponse | Promise - removeListener: (callback: (arg: T) => void) => void - hasListener: (callback: (arg: T) => void) => boolean - } - - const onBeforeRequest: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - requestBody?: { - error?: string - formData?: { [key: string]: string[] } - raw?: UploadData[] - } - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - }, - 'blocking' | 'requestBody' - > - - const onBeforeSendHeaders: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - requestHeaders?: HttpHeaders - }, - 'blocking' | 'requestHeaders' - > - - const onSendHeaders: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - requestHeaders?: HttpHeaders - }, - 'requestHeaders' - > - - const onHeadersReceived: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - statusLine: string - responseHeaders?: HttpHeaders - statusCode: number - }, - 'blocking' | 'responseHeaders' - > - - const onAuthRequired: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - scheme: string - realm?: string - challenger: { host: string; port: number } - isProxy: boolean - responseHeaders?: HttpHeaders - statusLine: string - statusCode: number - }, - 'blocking' | 'responseHeaders' - > - - const onResponseStarted: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - ip?: string - fromCache: boolean - statusLine: string - responseHeaders?: HttpHeaders - statusCode: number - }, - 'responseHeaders' - > - - const onBeforeRedirect: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - ip?: string - fromCache: boolean - statusCode: number - redirectUrl: string - statusLine: string - responseHeaders?: HttpHeaders - }, - 'responseHeaders' - > - - const onCompleted: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - ip?: string - fromCache: boolean - statusCode: number - statusLine: string - responseHeaders?: HttpHeaders - }, - 'responseHeaders' - > - - const onErrorOccurred: ReqListener< - { - requestId: string - url: string - method: string - frameId: number - parentFrameId: number - tabId: number - type: ResourceType - timeStamp: number - originUrl: string - ip?: string - fromCache: boolean - error: string - }, - void - > - - function filterResponseData(requestId: string): StreamFilter -} - -declare namespace browser.windows { - type WindowType = 'normal' | 'popup' | 'panel' | 'devtools' - - type WindowState = 'normal' | 'minimized' | 'maximized' | 'fullscreen' | 'docked' - - interface Window { - id?: number - focused: boolean - top?: number - left?: number - width?: number - height?: number - tabs?: tabs.Tab[] - incognito: boolean - type?: WindowType - state?: WindowState - alwaysOnTop: boolean - sessionId?: string - } - - type CreateType = 'normal' | 'popup' | 'panel' | 'detached_panel' - - const WINDOW_ID_NONE: number - - const WINDOW_ID_CURRENT: number - - function get( - windowId: number, - getInfo?: { - populate?: boolean - windowTypes?: WindowType[] - } - ): Promise - - function getCurrent(getInfo?: { populate?: boolean; windowTypes?: WindowType[] }): Promise - - function getLastFocused(getInfo?: { populate?: boolean; windowTypes?: WindowType[] }): Promise - - function getAll(getInfo?: { populate?: boolean; windowTypes?: WindowType[] }): Promise - - // TODO: url and tabId should be exclusive - function create(createData?: { - allowScriptsToClose?: boolean - url?: string | string[] - tabId?: number - left?: number - top?: number - width?: number - height?: number - // unsupported: focused?: boolean, - incognito?: boolean - titlePreface?: string - type?: CreateType - state?: WindowState - }): Promise - - function update( - windowId: number, - updateInfo: { - left?: number - top?: number - width?: number - height?: number - focused?: boolean - drawAttention?: boolean - state?: WindowState - } - ): Promise - - function remove(windowId: number): Promise - - const onCreated: EventEmitter - - const onRemoved: EventEmitter - - const onFocusChanged: EventEmitter -} - -declare namespace browser.theme { - interface Theme { - images: ThemeImages - colors: ThemeColors - properties?: ThemeProperties - } - - interface ThemeImages { - headerURL: string - theme_frame?: string - additional_backgrounds?: string[] - } - - interface ThemeColors { - accentcolor: string - textcolor: string - frame?: [number, number, number] - tab_text?: [number, number, number] - toolbar?: string - toolbar_text?: string - toolbar_field?: string - toolbar_field_text?: string - } - - interface ThemeProperties { - additional_backgrounds_alignment: Alignment[] - additional_backgrounds_tiling: Tiling[] - } - - type Alignment = - | 'bottom' - | 'center' - | 'left' - | 'right' - | 'top' - | 'center bottom' - | 'center center' - | 'center top' - | 'left bottom' - | 'left center' - | 'left top' - | 'right bottom' - | 'right center' - | 'right top' - - type Tiling = 'no-repeat' | 'repeat' | 'repeat-x' | 'repeat-y' - - function getCurrent(windowId?: number): Promise - function update(theme: Theme): Promise - function update(windowId: number, theme: Theme): Promise - function reset(windowId?: number): Promise -} diff --git a/browser/tsconfig.json b/browser/tsconfig.json deleted file mode 100644 index 7a1fc6657126..000000000000 --- a/browser/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "extends": "../tsconfig.json", - "references": [{ "path": "../shared" }, { "path": "../schema" }], - "compilerOptions": { - "module": "commonjs", - "baseUrl": ".", - "paths": { - "*": ["src/types/*", "../shared/src/types/*", "*"], - }, - "jsx": "react", - "resolveJsonModule": true, - "rootDir": ".", - "outDir": "out", - }, - "include": ["./**/*", "./src/**/*.json", "src/types/**/*.d.ts"], - "exclude": [ - "out", - "node_modules", - "../node_modules", - "./build/**/*", - "coverage", - "stories", // TODO fix type errors and include - "src/e2e", - ], -} diff --git a/browser/yarn.lock b/browser/yarn.lock deleted file mode 100644 index fb57ccd13afb..000000000000 --- a/browser/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - diff --git a/client/README.md b/client/README.md new file mode 100644 index 000000000000..5b55f0d04f01 --- /dev/null +++ b/client/README.md @@ -0,0 +1,30 @@ +# Frontend packages + +## List + +- **web**: The web application deployed to http://sourcegraph.com/ +- **browser**: The Sourcegraph browser extension adds tooltips to code on different code hosts. +- **eslint-plugin-sourcegraph**: Not published package with custom ESLint rules for Sourcegraph. Isn't intended for reuse by other repositories in the Sourcegraph org. +- **extension-api**: The Sourcegraph extension API types for the _Sourcegraph extensions_. Published as `sourcegraph`. +- **extension-api-types**: The Sourcegraph extension API types for _client applications_ that embed Sourcegraph extensions and need to communicate with them. Published as `@sourcegraph/extension-api-types`. +- **sandboxes**: All demos-mvp (minimum viable product) for the Sourcegraph web application. +- **shared**: Contains common TypeScript/React/SCSS client code shared between the browser extension and the web app. Everything in this package is code-host agnostic. +- **branded**: Contains React components and implements the visual design language we use across our web app and e.g. in the options menu of the browser extension. Over time, components from `shared` and `branded` packages should be moved into the `wildcard` package. +- **wildcard**: Package that encapsulates storybook configuration and contains our Wildcard design system components. If we're using a component in two or more different areas (e.g. `web-app` and `browser-extension`) then it should live in the `wildcard` package. Otherwise the components should be better colocated with the code where they're actually used. +- **storybook**: Storybook configuration. + +## Further migration plan + +1. Fix circular dependency in TS project-references graph **wildcard** package should not rely on **web** and probably **shared**, **branded** too. Ideally it should be an independent self-contained package. + +2. Decide on package naming and update existing package names. Especially it should be done for a **shared** package because we have multiple `shared` folders inside of other packages. It's hard to understand from where dependency is coming from and it's not possible to refactor import paths using find-and-replace. + +3. Investigate if we can painlessly switch to `npm` workspaces. + +4. Content of packages **shared** and **branded** should be moved to **wildcard** and refactored using the latest FE rules and conventions. Having different packages clearly communicates the migration plan. Developers first should look for components in the **wildcard** package and then fall-back to **legacy** packages if **wildcard** doesn't have the solution to their problem yet. + +5. **shared** contains utility functions, types, polyfills, etc which is not a part of the Wildcard component library. These modules should be moved into **utils** package and other new packages: e.g. **api** for GraphQL client and type generators, etc. + +6. Packages should use package name (e.g. `@sourcegraph/wildcard`) for imports instead of the relative paths (e.g. `../../../../wildcard/src/components/Markdown`) to avoid long relative-paths and make dependency graph between packages clear. (Typescript will warn if packages have circular dependencies). It's easy to refactor such isolated packages, extract functionality into new ones, or even into new repositories. + +7. **build** or **config** package should be added later to encapsulate all the configurations reused between packages which will allow removing `jest.config`, `babel.config` from the root of the repo. diff --git a/client/branded/.eslintignore b/client/branded/.eslintignore new file mode 100644 index 000000000000..23e8b1825177 --- /dev/null +++ b/client/branded/.eslintignore @@ -0,0 +1,3 @@ +out/ +src/graphql/schema.ts +src/graphql-operations.ts diff --git a/client/branded/.eslintrc.js b/client/branded/.eslintrc.js new file mode 100644 index 000000000000..ff4edf4ad9a6 --- /dev/null +++ b/client/branded/.eslintrc.js @@ -0,0 +1,25 @@ +const baseConfig = require('../../.eslintrc.js') +module.exports = { + extends: '../../.eslintrc.js', + parserOptions: { + ...baseConfig.parserOptions, + project: [__dirname + '/tsconfig.json'], + }, + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + ...baseConfig.rules['no-restricted-imports'][1].paths, + { + name: 'react-router-dom', + importNames: ['Link'], + message: + "Use the shared/src/shared/components/Link component instead of react-router-dom's Link. Reason: Shared branded code runs on platforms that don't use react-router (such as in the browser extension).", + }, + ], + }, + ], + }, + overrides: baseConfig.overrides, +} diff --git a/shared/.stylelintrc.json b/client/branded/.stylelintrc.json similarity index 100% rename from shared/.stylelintrc.json rename to client/branded/.stylelintrc.json diff --git a/client/branded/README.md b/client/branded/README.md new file mode 100644 index 000000000000..d1b914aa2dbf --- /dev/null +++ b/client/branded/README.md @@ -0,0 +1,4 @@ +This folder contains client code that is **branded**, i.e. it implements the visual design language we use across our web app and e.g. in the options menu of the browser extension. +Code in here can use Bootstrap and must not adapt styles of the code host (for more details, see [Styling UI in the handbook](https://about.sourcegraph.com/handbook/engineering/web/styling)). + +Any code that is code host agnostic should go into [`../shared`](../shared) instead. diff --git a/cmd/precise-code-intel/babel.config.js b/client/branded/babel.config.js similarity index 100% rename from cmd/precise-code-intel/babel.config.js rename to client/branded/babel.config.js diff --git a/client/branded/jest.config.js b/client/branded/jest.config.js new file mode 100644 index 000000000000..e8fd07f6a6f4 --- /dev/null +++ b/client/branded/jest.config.js @@ -0,0 +1,11 @@ +// @ts-check + +/** @type {jest.InitialOptions} */ +const config = require('../../jest.config.base') + +/** @type {jest.InitialOptions} */ +module.exports = { + ...config, + displayName: 'branded', + rootDir: __dirname, +} diff --git a/client/branded/package.json b/client/branded/package.json new file mode 100644 index 000000000000..9d297165fdc6 --- /dev/null +++ b/client/branded/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "name": "@sourcegraph/branded", + "version": "1.0.0", + "license": "Apache-2.0", + "scripts": { + "storybook": "STORIES_GLOB=client/branded/src/**/*.story.tsx yarn workspace @sourcegraph/storybook run start", + "eslint": "eslint --cache '**/*.[jt]s?(x)'", + "stylelint": "stylelint 'src/**/*.scss' --quiet", + "test": "jest" + } +} diff --git a/client/branded/src/components/BrandedStory.tsx b/client/branded/src/components/BrandedStory.tsx new file mode 100644 index 000000000000..127857afd5a5 --- /dev/null +++ b/client/branded/src/components/BrandedStory.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { MemoryRouter, MemoryRouterProps } from 'react-router' + +import { ThemeProps } from '@sourcegraph/shared/src/theme' +import { usePrependStyles } from '@sourcegraph/storybook/src/hooks/usePrependStyles' +import { useTheme } from '@sourcegraph/storybook/src/hooks/useTheme' + +import brandedStyles from '../global-styles/index.scss' + +import { Tooltip } from './tooltip/Tooltip' + +export interface BrandedProps extends MemoryRouterProps { + children: React.FunctionComponent + styles?: string +} + +/** + * Wrapper component for branded Storybook stories that provides light theme and react-router props. + * Takes a render function as children that gets called with the props. + */ +export const BrandedStory: React.FunctionComponent = ({ + children: Children, + styles = brandedStyles, + ...memoryRouterProps +}) => { + const isLightTheme = useTheme() + usePrependStyles('branded-story-styles', styles) + + return ( + + + + + ) +} diff --git a/client/branded/src/components/CodeSnippet.tsx b/client/branded/src/components/CodeSnippet.tsx new file mode 100644 index 000000000000..12ea67fa188e --- /dev/null +++ b/client/branded/src/components/CodeSnippet.tsx @@ -0,0 +1,22 @@ +import classNames from 'classnames' +import React, { useMemo } from 'react' + +import { highlightCodeSafe } from '@sourcegraph/shared/src/util/markdown' + +interface CodeSnippetProps { + /** The code to be displayed. */ + code: string + /** Hint to the language, used for syntax-highlighting the code-snippet. */ + language: string + + className?: string +} + +export const CodeSnippet: React.FunctionComponent = ({ code, language, className }) => { + const highlightedInput = useMemo(() => ({ __html: highlightCodeSafe(code, language) }), [code, language]) + return ( +
    +            
    +        
    + ) +} diff --git a/web/src/components/Form.tsx b/client/branded/src/components/Form.tsx similarity index 100% rename from web/src/components/Form.tsx rename to client/branded/src/components/Form.tsx diff --git a/client/branded/src/components/LoaderInput.scss b/client/branded/src/components/LoaderInput.scss new file mode 100644 index 000000000000..0812097384bd --- /dev/null +++ b/client/branded/src/components/LoaderInput.scss @@ -0,0 +1,11 @@ +.loader-input { + &__container { + position: relative; + } + + &__spinner { + position: absolute; + right: calc(0.5rem - 1px); + top: calc(0.5rem - 1px); + } +} diff --git a/client/branded/src/components/LoaderInput.story.tsx b/client/branded/src/components/LoaderInput.story.tsx new file mode 100644 index 000000000000..1674158023b8 --- /dev/null +++ b/client/branded/src/components/LoaderInput.story.tsx @@ -0,0 +1,24 @@ +import { boolean } from '@storybook/addon-knobs' +import { storiesOf } from '@storybook/react' +import React from 'react' + +import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss' + +import { BrandedStory } from './BrandedStory' +import { LoaderInput } from './LoaderInput' + +const { add } = storiesOf('branded/LoaderInput', module).addDecorator(story => ( +
    + {story()} +
    +)) + +add('Interactive', () => ( + + {() => ( + + + + )} + +)) diff --git a/client/branded/src/components/LoaderInput.test.tsx b/client/branded/src/components/LoaderInput.test.tsx new file mode 100644 index 000000000000..e1d1af7ff107 --- /dev/null +++ b/client/branded/src/components/LoaderInput.test.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import renderer from 'react-test-renderer' + +import { LoaderInput } from './LoaderInput' + +jest.mock('@sourcegraph/react-loading-spinner', () => ({ LoadingSpinner: 'LoadingSpinner' })) + +describe('LoaderInput', () => { + it('should render a loading spinner when loading prop is true', () => { + expect( + renderer + .create( + + + + ) + .toJSON() + ).toMatchSnapshot() + }) + + it('should not render a loading spinner when loading prop is false', () => { + expect( + renderer + .create( + + + + ) + .toJSON() + ).toMatchSnapshot() + }) +}) diff --git a/client/branded/src/components/LoaderInput.tsx b/client/branded/src/components/LoaderInput.tsx new file mode 100644 index 000000000000..927d4735ba94 --- /dev/null +++ b/client/branded/src/components/LoaderInput.tsx @@ -0,0 +1,19 @@ +import classNames from 'classnames' +import React from 'react' + +import { LoadingSpinner } from '@sourcegraph/react-loading-spinner' + +/** Takes loading prop, input component as child */ + +interface Props { + loading: boolean + children: React.ReactNode + className?: string +} + +export const LoaderInput: React.FunctionComponent = ({ loading, children, className }) => ( +
    + {children} + {loading && } +
    +) diff --git a/client/branded/src/components/SourcegraphLogo.tsx b/client/branded/src/components/SourcegraphLogo.tsx new file mode 100644 index 000000000000..091164a0e367 --- /dev/null +++ b/client/branded/src/components/SourcegraphLogo.tsx @@ -0,0 +1,62 @@ +import React from 'react' + +export const SourcegraphLogo: React.FunctionComponent> = props => ( + + + + + + + + + + + + + + + + +) diff --git a/client/branded/src/components/Toggle.scss b/client/branded/src/components/Toggle.scss new file mode 100644 index 000000000000..08ee34464987 --- /dev/null +++ b/client/branded/src/components/Toggle.scss @@ -0,0 +1,94 @@ +$toggle-width: 2rem; +$box-shadow-spacing: 0 0 0 1px var(--body-bg); + +.toggle { + --toggle-bar-bg: var(--text-muted); + --toggle-bar-bg-on: var(--primary); + --toggle-knob-bg: var(--text-muted); + --toggle-knob-bg-on: var(--primary); + --toggle-bar-opacity: 0.2; + --toggle-bar-focus-opacity: 0.5; + --toggle-knob-disabled-opacity: 0.2; + --toggle-bar-focus-box-shadow: #{$box-shadow-spacing}, 0 0 0 0.1875rem var(--primary); + + .theme-redesign & { + --toggle-bar-bg: var(--icon-color); + --toggle-bar-bg-on: var(--primary); + --toggle-knob-bg: var(--body-bg); + --toggle-knob-bg-on: var(--body-bg); + --toggle-bar-opacity: 1; + --toggle-bar-focus-opacity: 1; + --toggle-knob-disabled-opacity: 1; + --toggle-bar-focus-box-shadow: #{$box-shadow-spacing}, 0 0 0 0.1875rem var(--primary-2); + } + + background: none; + border: none; + display: inline-block; + outline: none !important; + padding: 0; + position: relative; + width: $toggle-width; + + &:focus-visible { + // Move focus style to the rounded bar + box-shadow: none; + } + + &:focus-visible &__bar { + box-shadow: var(--toggle-bar-focus-box-shadow); + } + + &__bar { + border-radius: 1rem; + top: 2px; + left: 0; + height: 1rem; + width: 100%; + position: absolute; + + opacity: var(--toggle-bar-opacity); + background-color: var(--toggle-bar-bg); + + transition: all 0.3s; + transition-property: opacity; + + &--on { + background-color: var(--toggle-bar-bg-on); + } + } + + &__knob { + background-color: var(--toggle-knob-bg); + + border-radius: 0.375rem; + display: block; + + height: 0.75rem; + width: 0.75rem; + margin-top: 0.25rem; + left: 0.125rem; + + position: relative; + + &--on { + background-color: var(--toggle-knob-bg-on); + transform: translate3d(1rem, 0, 0); + } + } + + &:hover:enabled &__bar { + opacity: var(--toggle-bar-focus-opacity); + } + + .theme-redesign &:disabled { + --toggle-knob-bg: var(--icon-color); + --toggle-knob-bg-on: var(--icon-color); + --toggle-bar-bg: var(--input-disabled-bg); + --toggle-bar-bg-on: var(--input-disabled-bg); + } + + &:disabled &__knob { + opacity: var(--toggle-knob-disabled-opacity); + } +} diff --git a/client/branded/src/components/Toggle.story.tsx b/client/branded/src/components/Toggle.story.tsx new file mode 100644 index 000000000000..af42a7b6242f --- /dev/null +++ b/client/branded/src/components/Toggle.story.tsx @@ -0,0 +1,53 @@ +import { action } from '@storybook/addon-actions' +import { storiesOf } from '@storybook/react' +import React, { useState } from 'react' + +import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss' + +import { Toggle } from './Toggle' + +const onToggle = action('onToggle') + +const { add } = storiesOf('branded/Toggle', module).addDecorator(story => ( + <> +
    {story()}
    + + +)) + +const ToggleExample: typeof Toggle = ({ value, disabled, onToggle }) => ( +
    + +
    + + This is helper text as needed +
    +
    +) + +add( + 'Interactive', + () => { + const [value, setValue] = useState(false) + + const onToggle = (value: boolean) => setValue(value) + + return + }, + { + chromatic: { + disable: true, + }, + } +) + +add('Variants', () => ( + <> + + + + + +)) diff --git a/client/branded/src/components/Toggle.test.tsx b/client/branded/src/components/Toggle.test.tsx new file mode 100644 index 000000000000..428ef3d23c79 --- /dev/null +++ b/client/branded/src/components/Toggle.test.tsx @@ -0,0 +1,31 @@ +import { mount } from 'enzyme' +import React from 'react' +import sinon from 'sinon' + +import { Toggle } from './Toggle' + +describe('Toggle', () => { + test('value is false', () => { + expect(mount()).toMatchSnapshot() + }) + + test('value is true', () => { + expect(mount()).toMatchSnapshot() + }) + + test('disabled', () => { + const onToggle = sinon.spy(() => undefined) + const component = mount() + + component.find('.toggle').simulate('click') + sinon.assert.notCalled(onToggle) + expect(component).toMatchSnapshot() + }) + + test('className', () => expect(mount()).toMatchSnapshot()) + + test('aria', () => + expect( + mount() + ).toMatchSnapshot()) +}) diff --git a/client/branded/src/components/Toggle.tsx b/client/branded/src/components/Toggle.tsx new file mode 100644 index 000000000000..4b0a134d9cb1 --- /dev/null +++ b/client/branded/src/components/Toggle.tsx @@ -0,0 +1,87 @@ +import classnames from 'classnames' +import * as React from 'react' + +interface Props { + /** The initial value. */ + value?: boolean + + /** The DOM ID of the element. */ + id?: string + + /** + * Called when the user changes the input's value. + */ + onToggle?: (value: boolean) => void + + onClick?: (event: React.MouseEvent) => void + + /** The title attribute (tooltip). */ + title?: string + + 'aria-label'?: string + 'aria-labelledby'?: string + 'aria-describedby'?: string + + disabled?: boolean + tabIndex?: number + className?: string + + /** Data attribute for testing */ + dataTest?: string +} + +/** A toggle switch input component. */ +export const Toggle: React.FunctionComponent = ({ + disabled, + className, + id, + title, + value, + tabIndex, + onToggle, + onClick, + dataTest, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, +}) => { + function onButtonClick(event: React.MouseEvent): void { + event.stopPropagation() + if (!disabled && onToggle) { + onToggle(!value) + } + if (!disabled && onClick) { + onClick(event) + } + } + + return ( + + ) +} diff --git a/client/branded/src/components/ToggleBig.scss b/client/branded/src/components/ToggleBig.scss new file mode 100644 index 000000000000..85a63c7ada27 --- /dev/null +++ b/client/branded/src/components/ToggleBig.scss @@ -0,0 +1,111 @@ +$toggle-big-width: 6.625rem; +$toggle-big-height: 2rem; + +.toggle-big { + background: none; + border: none; + display: inline-block; + outline: none !important; + padding: 0; + position: relative; + width: $toggle-big-width; + + &:disabled { + cursor: not-allowed; + } + + &__container { + display: flex; + flex-direction: row; + align-items: center; + } + + &__bar, + &__bar-shadow { + border-radius: 1rem; + height: $toggle-big-height; + width: 100%; + top: 2px; + left: 0; + position: absolute; + } + + &__bar { + opacity: 0.2; + background-color: var(--text-muted); + transition: all 0.3s; + transition-property: opacity; + + &--on { + background-color: var(--primary); + } + } + + &:hover:enabled &__bar { + opacity: 0.5; + } + + &:focus-visible &__bar { + opacity: 0.5; + background-color: var(--text-muted); + + &--on { + background-color: var(--primary); + } + } + + &__bar-shadow { + background-color: transparent; + } + + &:focus-visible &__bar-shadow { + opacity: 0.2; + box-shadow: 0 0 0 0.1875rem var(--text-muted); + + &--on { + box-shadow: 0 0 0 0.1875rem var(--primary); + } + } + + &__knob { + background-color: var(--text-muted); + border-radius: 0.75rem; + height: 1.5rem; + width: 1.5rem; + margin-top: 0.375rem; + left: 0.25rem; + position: relative; + + &--on { + background-color: var(--primary); + transform: translate3d(4.625rem, 0, 0); + } + } + + &:disabled &__knob { + opacity: 0.2; + } + + &__icon { + fill: $secondary-light; + + &--on { + fill: $white; + } + } + + &__text { + color: var(--body-color); + position: relative; + margin-top: 0.375rem; + right: -0.6875rem; + + &--on { + right: 0.4375rem; + } + } + + &:disabled &__text { + opacity: 0.2; + } +} diff --git a/client/branded/src/components/ToggleBig.story.tsx b/client/branded/src/components/ToggleBig.story.tsx new file mode 100644 index 000000000000..574f66952528 --- /dev/null +++ b/client/branded/src/components/ToggleBig.story.tsx @@ -0,0 +1,44 @@ +import { action } from '@storybook/addon-actions' +import { storiesOf } from '@storybook/react' +import React, { useState } from 'react' + +import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss' + +import { ToggleBig } from './ToggleBig' + +const onToggle = action('onToggle') + +const { add } = storiesOf('branded/ToggleBig', module).addDecorator(story => ( + <> +
    {story()}
    + + +)) + +add( + 'Interactive', + () => { + const [value, setValue] = useState(false) + + const onToggle = (value: boolean) => setValue(value) + + return ( +
    + Value is {String(value)} +
    + ) + }, + { + chromatic: { + disable: true, + }, + } +) + +add('On', () => ) + +add('Off', () => ) + +add('Disabled & on', () => ) + +add('Disabled & off', () => ) diff --git a/client/branded/src/components/ToggleBig.test.tsx b/client/branded/src/components/ToggleBig.test.tsx new file mode 100644 index 000000000000..4d1b8ca5c85e --- /dev/null +++ b/client/branded/src/components/ToggleBig.test.tsx @@ -0,0 +1,26 @@ +import { mount } from 'enzyme' +import React from 'react' +import sinon from 'sinon' + +import { ToggleBig } from './ToggleBig' + +describe('ToggleBig', () => { + test('value is false', () => { + expect(mount()).toMatchSnapshot() + }) + + test('value is true', () => { + expect(mount()).toMatchSnapshot() + }) + + test('disabled', () => { + const onToggle = sinon.spy(() => undefined) + const component = mount() + + component.find('.toggle-big').simulate('click') + sinon.assert.notCalled(onToggle) + expect(component).toMatchSnapshot() + }) + + test('className', () => expect(mount()).toMatchSnapshot()) +}) diff --git a/client/branded/src/components/ToggleBig.tsx b/client/branded/src/components/ToggleBig.tsx new file mode 100644 index 000000000000..18b1ae1b0f8e --- /dev/null +++ b/client/branded/src/components/ToggleBig.tsx @@ -0,0 +1,107 @@ +import classnames from 'classnames' +import Check from 'mdi-react/CheckIcon' +import * as React from 'react' + +interface Props { + /** The initial value. */ + value?: boolean + + /** The DOM ID of the element. */ + id?: string + + /** + * Called when the user changes the input's value. + */ + onToggle?: (value: boolean) => void + + /** + * Called when the user hovers over the toggle. + */ + onHover?: (value: boolean) => void + + /** + * Called when the user focuses on the toggle. + */ + onFocus?: (value: boolean) => void + + /** The title attribute (tooltip). */ + title?: string + + disabled?: boolean + tabIndex?: number + className?: string + + /** Data attribute for testing */ + dataTest?: string +} + +/** A big toggle switch input component. */ +export const ToggleBig: React.FunctionComponent = ({ + disabled, + className, + id, + title, + value, + tabIndex, + onToggle, + onHover, + onFocus, + dataTest, +}) => { + function onClick(): void { + if (!disabled && onToggle) { + onToggle(!value) + } + } + + function onMouseOver(): void { + if (onHover) { + onHover(!value) + } + } + + function onToggleFocus(): void { + if (onFocus) { + onFocus(!value) + } + } + + return ( + + ) +} diff --git a/client/branded/src/components/__snapshots__/LoaderInput.test.tsx.snap b/client/branded/src/components/__snapshots__/LoaderInput.test.tsx.snap new file mode 100644 index 000000000000..00d0782db499 --- /dev/null +++ b/client/branded/src/components/__snapshots__/LoaderInput.test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoaderInput should not render a loading spinner when loading prop is false 1`] = ` +
    + +
    +`; + +exports[`LoaderInput should render a loading spinner when loading prop is true 1`] = ` +
    + + +
    +`; diff --git a/client/branded/src/components/__snapshots__/Toggle.test.tsx.snap b/client/branded/src/components/__snapshots__/Toggle.test.tsx.snap new file mode 100644 index 000000000000..cd0ca416771b --- /dev/null +++ b/client/branded/src/components/__snapshots__/Toggle.test.tsx.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Toggle aria 1`] = ` + + + +`; + +exports[`Toggle className 1`] = ` + + + +`; + +exports[`Toggle disabled 1`] = ` + + + +`; + +exports[`Toggle value is false 1`] = ` + + + +`; + +exports[`Toggle value is true 1`] = ` + + + +`; diff --git a/client/branded/src/components/__snapshots__/ToggleBig.test.tsx.snap b/client/branded/src/components/__snapshots__/ToggleBig.test.tsx.snap new file mode 100644 index 000000000000..0293f52a652c --- /dev/null +++ b/client/branded/src/components/__snapshots__/ToggleBig.test.tsx.snap @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ToggleBig className 1`] = ` + + + +`; + +exports[`ToggleBig disabled 1`] = ` + + + +`; + +exports[`ToggleBig value is false 1`] = ` + + + +`; + +exports[`ToggleBig value is true 1`] = ` + + + +`; diff --git a/client/branded/src/components/panel/Panel.fixtures.ts b/client/branded/src/components/panel/Panel.fixtures.ts new file mode 100644 index 000000000000..9dfcb73625dc --- /dev/null +++ b/client/branded/src/components/panel/Panel.fixtures.ts @@ -0,0 +1,86 @@ +import { noop } from 'lodash' +import { EMPTY, NEVER, of } from 'rxjs' + +import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' +import { PanelViewData } from '@sourcegraph/shared/src/api/extension/extensionHostApi' +import { pretendProxySubscribable, pretendRemote } from '@sourcegraph/shared/src/api/util' +import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { extensionsController } from '@sourcegraph/shared/src/util/searchTestHelpers' + +export const panels: PanelViewData[] = [ + { + id: 'panel_1', + title: 'Panel 1', + content: 'Panel 1', + priority: 3, + component: null, + }, + { + id: 'panel_2', + title: 'Panel 2', + content: 'Panel 2', + priority: 2, + component: null, + }, + { + id: 'panel_3', + title: 'Panel 3', + content: 'Panel 3', + priority: 1, + component: null, + }, +] + +export const panelActions = [ + { + id: 'a', + actionItem: { + label: 'Action A', + description: 'This is Action A', + }, + command: 'open', + commandArguments: ['https://example.com'], + }, + { + id: 'b', + actionItem: { + label: 'Action B', + description: 'This is Action B', + }, + command: 'updateConfiguration', + commandArguments: [], + }, +] + +export const panelMenus = { + 'panel/toolbar': [ + { + action: 'a', + }, + { + action: 'b', + }, + ], +} + +export const panelProps = { + repoName: 'git://github.com/foo/bar', + fetchHighlightedFileLineRanges: () => of([]), + isLightTheme: true, + versionContext: undefined, + platformContext: {} as any, + settingsCascade: { subjects: null, final: null }, + telemetryService: NOOP_TELEMETRY_SERVICE, + extensionsController: { + ...extensionsController, + extHostAPI: Promise.resolve( + pretendRemote({ + getContributions: () => pretendProxySubscribable(NEVER), + registerContributions: () => pretendProxySubscribable(EMPTY).subscribe(noop as any), + haveInitialExtensionsLoaded: () => pretendProxySubscribable(of(true)), + getPanelViews: () => pretendProxySubscribable(of(panels)), + getActiveCodeEditorPosition: () => pretendProxySubscribable(NEVER), + }) + ), + }, +} diff --git a/client/branded/src/components/panel/Panel.module.scss b/client/branded/src/components/panel/Panel.module.scss new file mode 100644 index 000000000000..c9d42c1830d4 --- /dev/null +++ b/client/branded/src/components/panel/Panel.module.scss @@ -0,0 +1,38 @@ +.resizable-panel { + isolation: isolate; + min-height: 6rem; + max-height: calc(100% - 3rem); + width: 100%; +} + +.panel { + flex: 1 1 50%; + min-height: 0; + + overflow-x: auto; + + display: flex; + flex-direction: column; + position: relative; + + background-color: var(--color-bg-1); + border-top: 2px solid var(--border-color-2); + width: 100%; +} + +.header { + padding: 0 1rem; + background-color: var(--color-bg-1); +} + +.dismiss-button { + color: var(--icon-color); +} + +.tabs { + padding: 0.25rem 1rem; +} + +.tabs-content { + flex: 1; +} diff --git a/client/branded/src/components/panel/Panel.story.tsx b/client/branded/src/components/panel/Panel.story.tsx new file mode 100644 index 000000000000..316fff6b2d51 --- /dev/null +++ b/client/branded/src/components/panel/Panel.story.tsx @@ -0,0 +1,46 @@ +import { storiesOf } from '@storybook/react' +import { noop } from 'lodash' +import React from 'react' +import { EMPTY, of } from 'rxjs' + +import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' +import { pretendProxySubscribable, pretendRemote } from '@sourcegraph/shared/src/api/util' +import { extensionsController } from '@sourcegraph/shared/src/util/searchTestHelpers' +import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss' + +import { BrandedStory } from '../BrandedStory' + +import { Panel } from './Panel' +import { panels, panelProps, panelActions, panelMenus } from './Panel.fixtures' + +const { add } = storiesOf('branded/Panel', module) + .addDecorator(story => ( + + {() =>
    {story()}
    } +
    + )) + .addParameters({ + chromatic: { + viewports: [320, 576, 978, 1440], + }, + }) + +add('Simple', () => ) + +add('With actions', () => ( + ({ + getContributions: () => pretendProxySubscribable(of({ actions: panelActions, menus: panelMenus })), + registerContributions: () => pretendProxySubscribable(EMPTY).subscribe(noop as any), + haveInitialExtensionsLoaded: () => pretendProxySubscribable(of(true)), + getPanelViews: () => pretendProxySubscribable(of(panels)), + getActiveCodeEditorPosition: () => pretendProxySubscribable(of(null)), + }) + ), + }} + /> +)) diff --git a/client/branded/src/components/panel/Panel.test.tsx b/client/branded/src/components/panel/Panel.test.tsx new file mode 100644 index 000000000000..06b6662f6d92 --- /dev/null +++ b/client/branded/src/components/panel/Panel.test.tsx @@ -0,0 +1,30 @@ +import { cleanup, fireEvent } from '@testing-library/react' +import React from 'react' + +import { renderWithRouter } from '@sourcegraph/shared/src/testing/render-with-router' + +import { Panel } from './Panel' +import { panels, panelProps } from './Panel.fixtures' + +describe('Panel', () => { + const location = { + pathname: `/${panelProps.repoName}`, + search: '?L4:7', + hash: `#tab=${panels[0].id}`, + } + const route = `${location.pathname}${location.search}${location.hash}` + + afterEach(cleanup) + + it('preserves `location.pathname` and `location.hash` on tab change', async () => { + const renderResult = renderWithRouter(, { route }) + + const panelToSelect = panels[2] + const panelButton = await renderResult.findByRole('tab', { name: panelToSelect.title }) + fireEvent.click(panelButton) + + expect(renderResult.history.location.pathname).toEqual(location.pathname) + expect(renderResult.history.location.search).toEqual(location.search) + expect(renderResult.history.location.hash).toEqual(`#tab=${panelToSelect.id}`) + }) +}) diff --git a/client/branded/src/components/panel/Panel.tsx b/client/branded/src/components/panel/Panel.tsx new file mode 100644 index 000000000000..339e7f637b82 --- /dev/null +++ b/client/branded/src/components/panel/Panel.tsx @@ -0,0 +1,306 @@ +import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@reach/tabs' +import classNames from 'classnames' +import CloseIcon from 'mdi-react/CloseIcon' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useHistory, useLocation } from 'react-router' +import { BehaviorSubject, from, Observable } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' + +import { MaybeLoadingResult } from '@sourcegraph/codeintellify' +import { Location } from '@sourcegraph/extension-api-types' +import { ActionsNavItems } from '@sourcegraph/shared/src/actions/ActionsNavItems' +import { wrapRemoteObservable } from '@sourcegraph/shared/src/api/client/api/common' +import { PanelViewData } from '@sourcegraph/shared/src/api/extension/extensionHostApi' +import { haveInitialExtensionsLoaded } from '@sourcegraph/shared/src/api/features' +import { ContributableMenu } from '@sourcegraph/shared/src/api/protocol' +import { ActivationProps } from '@sourcegraph/shared/src/components/activation/Activation' +import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' +import { Resizable } from '@sourcegraph/shared/src/components/Resizable' +import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context' +import { VersionContextProps } from '@sourcegraph/shared/src/search/util' +import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' +import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' +import { ThemeProps } from '@sourcegraph/shared/src/theme' +import { combineLatestOrDefault } from '@sourcegraph/shared/src/util/rxjs/combineLatestOrDefault' +import { isDefined } from '@sourcegraph/shared/src/util/types' +import { useObservable } from '@sourcegraph/shared/src/util/useObservable' + +import styles from './Panel.module.scss' +import { registerPanelToolbarContributions } from './views/contributions' +import { EmptyPanelView } from './views/EmptyPanelView' +import { ExtensionsLoadingPanelView } from './views/ExtensionsLoadingView' +import { PanelView } from './views/PanelView' + +interface Props + extends ExtensionsControllerProps, + PlatformContextProps, + SettingsCascadeProps, + ActivationProps, + TelemetryProps, + ThemeProps, + VersionContextProps { + repoName?: string + fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable +} + +export interface PanelViewWithComponent extends PanelViewData { + /** + * The location provider whose results to render in the panel view. + */ + locationProvider?: Observable> + + /** + * The React element to render in the panel view. + */ + reactElement?: React.ReactFragment +} + +/** + * A tab and corresponding content to display in the panel. + */ +interface PanelItem { + id: string + + label: React.ReactFragment + /** + * Controls the relative order of panel items. The items are laid out from highest priority (at the beginning) + * to lowest priority (at the end). The default is 0. + */ + priority: number + + /** The content element to display when the tab is active. */ + element: JSX.Element + + /** + * Whether this panel contains a list of locations (from a location provider). This value is + * exposed to contributions as `panel.activeView.hasLocations`. It is true if there is a + * location provider (even if the result set is empty). + */ + hasLocations?: boolean +} + +export type BuiltinPanelView = Omit + +const builtinPanelViewProviders = new BehaviorSubject< + Map }> +>(new Map()) + +/** + * React hook to add panel views from other components (panel views are typically + * contributed by Sourcegraph extensions) + */ +export function useBuiltinPanelViews( + builtinPanels: { id: string; provider: Observable }[] +): void { + useEffect(() => { + for (const builtinPanel of builtinPanels) { + builtinPanelViewProviders.value.set(builtinPanel.id, builtinPanel) + } + builtinPanelViewProviders.next(new Map([...builtinPanelViewProviders.value])) + + return () => { + for (const builtinPanel of builtinPanels) { + builtinPanelViewProviders.value.delete(builtinPanel.id) + } + builtinPanelViewProviders.next(new Map([...builtinPanelViewProviders.value])) + } + }, [builtinPanels]) +} + +/** + * The panel, which is a tabbed component with contextual information. Components rendering the panel should + * generally use ResizablePanel, not Panel. + * + * Other components can contribute panel items to the panel with the `useBuildinPanelViews` hook. + */ +export const Panel = React.memo(props => { + // Ensures that we don't show a misleading empty state when extensions haven't loaded yet. + const areExtensionsReady = useObservable( + useMemo(() => haveInitialExtensionsLoaded(props.extensionsController.extHostAPI), [props.extensionsController]) + ) + + const [tabIndex, setTabIndex] = useState(0) + const location = useLocation() + const { hash, pathname, search } = location + const history = useHistory() + const handlePanelClose = useCallback(() => history.replace(pathname), [history, pathname]) + const [currentTabLabel, currentTabID] = hash.split('=') + + const builtinPanels: PanelViewWithComponent[] | undefined = useObservable( + useMemo( + () => + builtinPanelViewProviders.pipe( + switchMap(providers => + combineLatestOrDefault( + [...providers].map(([id, { provider }]) => + provider.pipe(map(view => (view ? { ...view, id, component: null } : null))) + ) + ) + ), + map(views => views.filter(isDefined)) + ), + [] + ) + ) + + const extensionPanels: PanelViewWithComponent[] | undefined = useObservable( + useMemo( + () => + from(props.extensionsController.extHostAPI).pipe( + switchMap(extensionHostAPI => + wrapRemoteObservable(extensionHostAPI.getPanelViews()).pipe( + map(panelViews => ({ panelViews, extensionHostAPI })) + ) + ), + map(({ panelViews, extensionHostAPI }) => + panelViews.map((panelView: PanelViewWithComponent) => { + const locationProviderID = panelView.component?.locationProvider + if (locationProviderID) { + const panelViewWithProvider: PanelViewWithComponent = { + ...panelView, + locationProvider: wrapRemoteObservable( + extensionHostAPI.getActiveCodeEditorPosition() + ).pipe( + switchMap(parameters => { + if (!parameters) { + return [{ isLoading: false, result: [] }] + } + + return wrapRemoteObservable( + extensionHostAPI.getLocations(locationProviderID, parameters) + ) + }) + ), + } + return panelViewWithProvider + } + + return panelView + }) + ) + ), + [props.extensionsController] + ) + ) + + const panelViews = useMemo(() => [...(builtinPanels || []), ...(extensionPanels || [])], [ + builtinPanels, + extensionPanels, + ]) + + const items = useMemo( + () => + panelViews + ? panelViews + .map( + (panelView): PanelItem => ({ + label: panelView.title, + id: panelView.id, + priority: panelView.priority, + element: , + hasLocations: !!panelView.locationProvider, + }) + ) + .sort((a, b) => b.priority - a.priority) + : [], + [location, panelViews, props] + ) + + useEffect(() => { + const subscription = registerPanelToolbarContributions(props.extensionsController.extHostAPI) + return () => subscription.unsubscribe() + }, [props.extensionsController]) + + const handleActiveTab = useCallback( + (index: number): void => { + history.replace(`${pathname}${search}${currentTabLabel}=${items[index].id}`) + }, + [currentTabLabel, history, items, pathname, search] + ) + + useEffect(() => { + setTabIndex(items.findIndex(({ id }) => id === currentTabID)) + }, [items, hash, currentTabID]) + + if (!areExtensionsReady) { + return + } + + if (!items) { + return + } + + const activeTab: PanelItem | undefined = items[tabIndex] + + return ( + +
    + +
    + {items.map(({ label, id }) => ( + + {label} + + ))} +
    +
    +
    + + {activeTab && ( + + )} + + +
    +
    + + {activeTab ? ( + items.map(({ id, element }) => ( + + {id === activeTab.id ? element : null} + + )) + ) : ( + + )} + +
    + ) +}) + +/** A wrapper around Panel that makes it resizable. */ +export const ResizablePanel: React.FunctionComponent = props => ( + } + /> +) diff --git a/client/branded/src/components/panel/views/EmptyPanelView.module.scss b/client/branded/src/components/panel/views/EmptyPanelView.module.scss new file mode 100644 index 000000000000..85d742a7ba71 --- /dev/null +++ b/client/branded/src/components/panel/views/EmptyPanelView.module.scss @@ -0,0 +1,9 @@ +.empty-panel { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + opacity: 0.6; + padding-top: 0.5rem; +} diff --git a/client/branded/src/components/panel/views/EmptyPanelView.tsx b/client/branded/src/components/panel/views/EmptyPanelView.tsx new file mode 100644 index 000000000000..c81b9969ec6c --- /dev/null +++ b/client/branded/src/components/panel/views/EmptyPanelView.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames' +import CancelIcon from 'mdi-react/CancelIcon' +import React from 'react' + +import styles from './EmptyPanelView.module.scss' + +interface EmptyPanelViewProps { + className?: string +} + +export const EmptyPanelView: React.FunctionComponent = props => { + const { className, children } = props + + return ( +
    + {children || ( + <> + Nothing to show here + + )} +
    + ) +} diff --git a/client/branded/src/components/panel/views/ExtensionsLoadingView.tsx b/client/branded/src/components/panel/views/ExtensionsLoadingView.tsx new file mode 100644 index 000000000000..982adf30ba24 --- /dev/null +++ b/client/branded/src/components/panel/views/ExtensionsLoadingView.tsx @@ -0,0 +1,22 @@ +import PuzzleIcon from 'mdi-react/PuzzleIcon' +import React from 'react' + +import { LoadingSpinner } from '@sourcegraph/react-loading-spinner' + +import { EmptyPanelView } from './EmptyPanelView' + +interface ExtensionsLoadingPanelViewProps { + className?: string +} + +export const ExtensionsLoadingPanelView: React.FunctionComponent = props => { + const { className } = props + + return ( + + + Loading Sourcegraph extensions + + + ) +} diff --git a/shared/src/panel/views/FileLocations.scss b/client/branded/src/components/panel/views/FileLocations.scss similarity index 100% rename from shared/src/panel/views/FileLocations.scss rename to client/branded/src/components/panel/views/FileLocations.scss diff --git a/client/branded/src/components/panel/views/FileLocations.tsx b/client/branded/src/components/panel/views/FileLocations.tsx new file mode 100644 index 000000000000..e8d3a7591b13 --- /dev/null +++ b/client/branded/src/components/panel/views/FileLocations.tsx @@ -0,0 +1,210 @@ +import * as H from 'history' +import { upperFirst } from 'lodash' +import MapSearchIcon from 'mdi-react/MapSearchIcon' +import * as React from 'react' +import { Observable, Subject, Subscription } from 'rxjs' +import { catchError, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators' +import { Badged } from 'sourcegraph' + +import { Location } from '@sourcegraph/extension-api-types' +import { LoadingSpinner } from '@sourcegraph/react-loading-spinner' +import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' +import { FileMatch } from '@sourcegraph/shared/src/components/FileMatch' +import { VirtualList } from '@sourcegraph/shared/src/components/VirtualList' +import { ContentMatch } from '@sourcegraph/shared/src/search/stream' +import { VersionContextProps } from '@sourcegraph/shared/src/search/util' +import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' +import { asError, ErrorLike, isErrorLike } from '@sourcegraph/shared/src/util/errors' +import { isDefined, property } from '@sourcegraph/shared/src/util/types' +import { parseRepoURI } from '@sourcegraph/shared/src/util/url' + +export const FileLocationsError: React.FunctionComponent<{ error: ErrorLike }> = ({ error }) => ( +
    + Error getting locations: {upperFirst(error.message)} +
    +) + +export const FileLocationsNotFound: React.FunctionComponent = () => ( +
    + No locations found +
    +) + +export const FileLocationsNoGroupSelected: React.FunctionComponent = () => ( +
    + No locations found in the current repository +
    +) + +interface Props extends SettingsCascadeProps, VersionContextProps { + location: H.Location + /** + * The observable that emits the locations. + */ + locations: Observable + + /** The icon to use for each location. */ + icon: React.ComponentType<{ className?: string }> + + /** Called when a location is selected. */ + onSelect?: () => void + + className?: string + + isLightTheme: boolean + + fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable + + /** Whether or not there are other groups in the parent container with results. */ + parentContainerIsEmpty: boolean +} + +const LOADING = 'loading' as const + +interface State { + /** + * Locations (inside files identified by LSP-style git:// URIs) to display, loading, or an error if they failed + * to load. + */ + locationsOrError: typeof LOADING | Location[] | null | ErrorLike + + itemsToShow: number +} + +interface OrderedURI { + uri: string + repo: string +} + +/** + * Displays a flat list of file excerpts. For a tree view, use FileLocationsTree. + */ +export class FileLocations extends React.PureComponent { + public state: State = { + locationsOrError: LOADING, + itemsToShow: 3, + } + + private componentUpdates = new Subject() + private subscriptions = new Subscription() + + public componentDidMount(): void { + const locationsChanges = this.componentUpdates.pipe( + map(({ locations }) => locations), + distinctUntilChanged() + ) + + this.subscriptions.add( + locationsChanges + .pipe( + switchMap(query => query.pipe(catchError(error => [asError(error) as ErrorLike]))), + startWith(LOADING), + map(result => ({ locationsOrError: result })) + ) + .subscribe( + stateUpdate => this.setState(stateUpdate), + error => console.error(error) + ) + ) + + this.componentUpdates.next(this.props) + } + + public componentDidUpdate(): void { + this.componentUpdates.next(this.props) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): JSX.Element | null { + if (isErrorLike(this.state.locationsOrError)) { + return + } + if (this.state.locationsOrError === LOADING) { + return + } + if (this.state.locationsOrError === null || this.state.locationsOrError.length === 0) { + return this.props.parentContainerIsEmpty ? : + } + + // Locations by fully qualified URI, like git://github.com/gorilla/mux?revision#mux.go + const locationsByURI = new Map() + + // URIs with >0 locations, in order (to avoid jitter as more results stream in). + const orderedURIs: { uri: string; repo: string }[] = [] + + if (this.state.locationsOrError) { + for (const location of this.state.locationsOrError) { + if (!locationsByURI.has(location.uri)) { + locationsByURI.set(location.uri, []) + + const { repoName } = parseRepoURI(location.uri) + orderedURIs.push({ uri: location.uri, repo: repoName }) + } + locationsByURI.get(location.uri)!.push(location) + } + } + + return ( +
    + }> + itemsToShow={this.state.itemsToShow} + onShowMoreItems={this.onShowMoreItems} + items={orderedURIs} + renderItem={this.renderFileMatch} + itemProps={{ locationsByURI }} + itemKey={this.itemKey} + /> +
    + ) + } + + private onShowMoreItems = (): void => { + this.setState(state => ({ itemsToShow: state.itemsToShow + 3 })) + } + + private onSelect = (): void => { + if (this.props.onSelect) { + this.props.onSelect() + } + } + + private itemKey = (item: OrderedURI): string => item.uri + + private renderFileMatch = ( + { uri }: OrderedURI, + { locationsByURI }: { locationsByURI: Map } + ): JSX.Element => ( + + ) +} + +function referencesToContentMatch(uri: string, references: Badged[]): ContentMatch { + const parsedUri = parseRepoURI(uri) + return { + type: 'content', + name: parsedUri.filePath || '', + version: (parsedUri.commitID || parsedUri.revision)!, + repository: parsedUri.repoName, + lineMatches: references.filter(property('range', isDefined)).map(reference => ({ + line: '', + lineNumber: reference.range.start.line, + offsetAndLengths: [ + [reference.range.start.character, reference.range.end.character - reference.range.start.character], + ], + aggregableBadges: reference.aggregableBadges, + })), + } +} diff --git a/client/branded/src/components/panel/views/HierarchicalLocationsView.module.scss b/client/branded/src/components/panel/views/HierarchicalLocationsView.module.scss new file mode 100644 index 000000000000..5a92d6d6ba9a --- /dev/null +++ b/client/branded/src/components/panel/views/HierarchicalLocationsView.module.scss @@ -0,0 +1,35 @@ +.references-container { + height: 100%; + display: flex; + overflow-x: hidden; +} + +.resizable-group { + min-width: 6rem; + padding-top: 0.5rem; + overflow-y: auto; +} + +.resizable-handle { + width: 0; + margin-right: 0.5rem; + padding-left: 0.5rem; + transition: opacity 150ms; + border-right: 1px solid var(--border-color-2); + + &:hover { + opacity: 1; + } +} + +.group-list { + flex: 1; + border-right: none; + overflow-x: hidden; +} + +.file-locations { + flex: 1; + width: 100%; + margin-right: -1rem; +} diff --git a/client/branded/src/components/panel/views/HierarchicalLocationsView.story.tsx b/client/branded/src/components/panel/views/HierarchicalLocationsView.story.tsx new file mode 100644 index 000000000000..2055d18f3153 --- /dev/null +++ b/client/branded/src/components/panel/views/HierarchicalLocationsView.story.tsx @@ -0,0 +1,116 @@ +import { storiesOf } from '@storybook/react' +import * as H from 'history' +import React from 'react' +import { of } from 'rxjs' + +import { Location } from '@sourcegraph/extension-api-types' +import { extensionsController } from '@sourcegraph/shared/src/util/searchTestHelpers' +import webStyles from '@sourcegraph/web/src/SourcegraphWebApp.scss' + +import { BrandedStory } from '../../BrandedStory' + +import { HierarchicalLocationsView, HierarchicalLocationsViewProps } from './HierarchicalLocationsView' + +const { add } = storiesOf('branded/HierarchicalLocationsView', module).addDecorator(story => ( + {() =>
    {story()}
    }
    +)) + +const LOCATIONS: Location[] = [ + { + uri: 'git://github.com/foo/bar#file1.txt', + range: { + start: { + line: 1, + character: 0, + }, + end: { + line: 1, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/foo/bar#file2.txt', + range: { + start: { + line: 2, + character: 0, + }, + end: { + line: 2, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/baz/qux#file3.txt', + range: { + start: { + line: 3, + character: 0, + }, + end: { + line: 3, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/baz/qux#file4.txt', + range: { + start: { + line: 4, + character: 0, + }, + end: { + line: 4, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/baz/qux#file4.txt', + range: { + start: { + line: 5, + character: 0, + }, + end: { + line: 5, + character: 10, + }, + }, + }, +] + +const PROPS: HierarchicalLocationsViewProps = { + extensionsController, + settingsCascade: { subjects: null, final: null }, + location: H.createLocation('/'), + locations: of({ isLoading: false, result: LOCATIONS }), + defaultGroup: 'git://github.com/foo/bar', + isLightTheme: true, + fetchHighlightedFileLineRanges: () => of([['line1\n', 'line2\n', 'line3\n', 'line4']]), + versionContext: undefined, +} + +add('Single repo', () => ( + uri.includes('github.com/foo/bar')) })} + /> +)) + +add('Grouped by repo', () => ) + +add('Grouped by repo and file', () => ( + +)) diff --git a/client/branded/src/components/panel/views/HierarchicalLocationsView.test.tsx b/client/branded/src/components/panel/views/HierarchicalLocationsView.test.tsx new file mode 100644 index 000000000000..344fadf6e1af --- /dev/null +++ b/client/branded/src/components/panel/views/HierarchicalLocationsView.test.tsx @@ -0,0 +1,190 @@ +// react-visibility-sensor, used in CodeExcerpt depends on ReactDOM.findDOMNode, +// which is not supported when using react-test-renderer + jest. +// This mock makes it so that simply becomes a
    in the rendered output. +jest.mock('react-visibility-sensor', () => 'VisibilitySensor') + +import * as H from 'history' +import { noop } from 'lodash' +import React from 'react' +import renderer from 'react-test-renderer' +import { concat, EMPTY, NEVER, of } from 'rxjs' +import * as sinon from 'sinon' + +import { MaybeLoadingResult } from '@sourcegraph/codeintellify' +import { Location } from '@sourcegraph/extension-api-types' +import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' +import { pretendProxySubscribable, pretendRemote } from '@sourcegraph/shared/src/api/util' +import { Controller } from '@sourcegraph/shared/src/extensions/controller' +import { SettingsCascadeOrError } from '@sourcegraph/shared/src/settings/settings' + +import { HierarchicalLocationsView, HierarchicalLocationsViewProps } from './HierarchicalLocationsView' + +describe('', () => { + const getProps = () => { + const registerContributions = sinon.spy(() => + pretendProxySubscribable(EMPTY).subscribe(noop as any) + ) + + const extensionsController: Pick = { + extHostAPI: Promise.resolve( + pretendRemote({ + updateContext: () => Promise.resolve(), + registerContributions, + }) + ), + } + const settingsCascade: SettingsCascadeOrError = { + subjects: null, + final: null, + } + const location: H.Location = { + hash: '#L36:18&tab=references', + pathname: '/github.com/sourcegraph/sourcegraph/-/blob/browser/src/libs/phabricator/index.tsx', + search: '', + state: {}, + } + + const props: HierarchicalLocationsViewProps = { + extensionsController, + settingsCascade, + location, + locations: NEVER, + defaultGroup: 'git://github.com/foo/bar', + isLightTheme: true, + fetchHighlightedFileLineRanges: sinon.spy(), + versionContext: undefined, + } + return { props, registerContributions } + } + + test('shows a spinner before any locations emissions', () => { + const { props } = getProps() + expect(renderer.create().toJSON()).toMatchSnapshot() + }) + + test('shows a spinner if locations emits empty and is not complete', () => { + const { props } = getProps() + expect( + renderer + .create( + + ) + .toJSON() + ).toMatchSnapshot() + }) + + const SAMPLE_LOCATION: Location = { + uri: 'git://github.com/foo/bar', + range: { + start: { + line: 1, + character: 0, + }, + end: { + line: 1, + character: 10, + }, + }, + } + + test('displays a single location when complete', () => { + const locations = of>({ isLoading: false, result: [SAMPLE_LOCATION] }) + const props = { + ...getProps().props, + locations, + } + expect(renderer.create().toJSON()).toMatchSnapshot() + }) + + test('displays partial locations before complete', () => { + const props = { + ...getProps().props, + locations: concat(of({ isLoading: false, result: [SAMPLE_LOCATION] }), NEVER), + } + expect(renderer.create().toJSON()).toMatchSnapshot() + }) + + test('displays multiple locations grouped by file', () => { + const locations: Location[] = [ + { + uri: 'git://github.com/foo/bar#file1.txt', + range: { + start: { + line: 1, + character: 0, + }, + end: { + line: 1, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/foo/bar#file2.txt', + range: { + start: { + line: 2, + character: 0, + }, + end: { + line: 2, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/foo/bar#file1.txt', + range: { + start: { + line: 3, + character: 0, + }, + end: { + line: 3, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/foo/bar#file2.txt', + range: { + start: { + line: 4, + character: 0, + }, + end: { + line: 4, + character: 10, + }, + }, + }, + { + uri: 'git://github.com/foo/bar#file2.txt', + range: { + start: { + line: 5, + character: 0, + }, + end: { + line: 5, + character: 10, + }, + }, + }, + ] + const props: HierarchicalLocationsViewProps = { + ...getProps().props, + settingsCascade: { + subjects: null, + final: { + 'panel.locations.groupByFile': true, + }, + }, + locations: of({ isLoading: false, result: locations }), + } + expect(renderer.create().toJSON()).toMatchSnapshot() + }) +}) diff --git a/client/branded/src/components/panel/views/HierarchicalLocationsView.tsx b/client/branded/src/components/panel/views/HierarchicalLocationsView.tsx new file mode 100644 index 000000000000..5dd32bd58829 --- /dev/null +++ b/client/branded/src/components/panel/views/HierarchicalLocationsView.tsx @@ -0,0 +1,310 @@ +import classNames from 'classnames' +import * as H from 'history' +import FileDocumentIcon from 'mdi-react/FileDocumentIcon' +import * as React from 'react' +import { Observable, of, Subject, Subscription } from 'rxjs' +import { catchError, distinctUntilChanged, endWith, map, startWith, switchMap, tap } from 'rxjs/operators' + +import { MaybeLoadingResult } from '@sourcegraph/codeintellify' +import { Location } from '@sourcegraph/extension-api-types' +import { LoadingSpinner } from '@sourcegraph/react-loading-spinner' +import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' +import { Resizable } from '@sourcegraph/shared/src/components/Resizable' +import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { VersionContextProps } from '@sourcegraph/shared/src/search/util' +import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' +import { asError, ErrorLike, isErrorLike } from '@sourcegraph/shared/src/util/errors' +import { parseRepoURI } from '@sourcegraph/shared/src/util/url' + +import { FileLocations, FileLocationsError, FileLocationsNotFound } from './FileLocations' +import styles from './HierarchicalLocationsView.module.scss' +import { HierarchicalLocationsViewButton } from './HierarchicalLocationsViewButton' +import { groupLocations } from './locations' + +/** The maximum number of results we'll receive from a provider before we truncate and display a banner. */ +const MAXIMUM_LOCATION_RESULTS = 500 + +export interface HierarchicalLocationsViewProps + extends SettingsCascadeProps, + VersionContextProps, + ExtensionsControllerProps<'extHostAPI'> { + location: H.Location + /** + * The observable that emits the locations. + */ + locations: Observable> + + /** + * In the grouping (i.e., by repository and, optionally, then by file), this is the URI of the first group. + * Usually this is set to the URI to the root of the repository that is currently being viewed to ensure that + * it is listed first. + */ + defaultGroup: string + + /** Called when an item in the tree is selected. */ + onSelectTree?: () => void + + /** Called when a location is selected. */ + onSelectLocation?: () => void + + className?: string + + isLightTheme: boolean + + fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable +} + +interface State { + /** + * Locations (inside files identified by LSP-style git:// URIs) to display, + * loading, or an error if they failed to load. + * + * Locations may be truncated if the result set is too large. + */ + locationsOrError: MaybeLoadingResult<{ locations: Location[]; isTruncated: boolean } | ErrorLike> + + selectedGroups?: string[] +} + +interface LocationGroup { + name: string + defaultSize: number + key: (location: Location) => string | undefined +} + +/** + * Displays a multi-column view to drill down (by repository, file, etc.) to a list of locations in files. + */ +export class HierarchicalLocationsView extends React.PureComponent { + public state: State = { locationsOrError: { isLoading: true, result: { locations: [], isTruncated: false } } } + + private componentUpdates = new Subject() + private subscriptions = new Subscription() + + public componentDidMount(): void { + const locationProvidersChanges = this.componentUpdates.pipe( + map(({ locations }) => locations), + distinctUntilChanged() + ) + + this.subscriptions.add( + locationProvidersChanges + .pipe( + switchMap(locationProviderResults => + locationProviderResults.pipe( + // Truncate the result set if it is too large, + // to avoid crashing the UI. A banner will be displayed to the user + // when this is the case. + map(({ isLoading, result: locations }) => { + const isTruncated = locations.length > MAXIMUM_LOCATION_RESULTS + return { + isLoading, + result: { + locations: isTruncated + ? locations.slice(0, MAXIMUM_LOCATION_RESULTS) + : locations, + isTruncated, + }, + } + }), + catchError((error): [State['locationsOrError']] => [ + { isLoading: false, result: asError(error) }, + ]), + startWith({ + result: { locations: [], isTruncated: false }, + isLoading: true, + }), + tap(({ result }) => { + const hasResults = !isErrorLike(result) && result.locations.length > 0 + this.props.extensionsController.extHostAPI + .then(extensionHostAPI => + extensionHostAPI.updateContext({ + 'panel.locations.hasResults': hasResults, + }) + ) + .catch(() => { + // noop + }) + }), + endWith({ isLoading: false }) + ) + ) + ) + .subscribe(locationsOrError => + this.setState(previous => ({ + locationsOrError: { + ...previous.locationsOrError, + ...locationsOrError, + }, + })) + ) + ) + + this.componentUpdates.next(this.props) + } + + public componentDidUpdate(): void { + this.componentUpdates.next(this.props) + } + + public componentWillUnmount(): void { + this.subscriptions.unsubscribe() + } + + public render(): JSX.Element | null { + if (isErrorLike(this.state.locationsOrError.result)) { + return + } + if (this.state.locationsOrError.isLoading && this.state.locationsOrError.result.locations.length === 0) { + return + } + if (this.state.locationsOrError.result.locations.length === 0) { + return + } + + const GROUPS: LocationGroup[] = [ + { + name: 'repo', + defaultSize: 175, + key: location => parseRepoURI(location.uri).repoName, + }, + ] + const groupByFile = + this.props.settingsCascade.final && + !isErrorLike(this.props.settingsCascade.final) && + this.props.settingsCascade.final['panel.locations.groupByFile'] + + if (groupByFile) { + GROUPS.push({ + name: 'file', + defaultSize: 200, + key: location => parseRepoURI(location.uri).filePath, + }) + } + + const { groups, selectedGroups, visibleLocations } = groupLocations( + this.state.locationsOrError.result.locations, + this.state.selectedGroups || null, + GROUPS.map(({ key }) => key), + { uri: this.props.defaultGroup } + ) + + const groupsToDisplay = GROUPS.map(({ name, key, defaultSize }, index) => { + const group = { name, key, defaultSize } + if (!groups[index]) { + // No groups exist at this level. Don't display anything. + return null + } + if (groups[index].length > 1) { + // Always display when there is more than 1 group. + return group + } + if (groups[index].length === 1) { + if (selectedGroups[index] !== groups[index][0].key) { + // When the only group is not the currently selected group, show it. This occurs when the + // references list changes after the user made an initial selection. The group must be shown so + // that the user can update their selection to the only available group; otherwise they would + // be stuck viewing the (zero) results from the previously selected group that no longer + // exists. + return group + } + if (key({ uri: this.props.defaultGroup }) !== selectedGroups[index]) { + // When the only group is other than the default group, show it. This is important because it + // often indicates that the match comes from another repository. If it isn't shown, the user + // would likely assume the match is from the current repository. + return group + } + } + if (groupByFile && name === 'file') { + // Always display the file groups when group-by-file is enabled. + return group + } + return null + }) + + return ( +
    + {this.state.locationsOrError.result.isTruncated && ( +
    + + Large result set - only showing the first {MAXIMUM_LOCATION_RESULTS}{' '} + results. + +
    + )} +
    +
    + {selectedGroups && + groupsToDisplay.map( + (group, index) => + group && ( + + {groups[index].map((group, innerIndex) => ( + + this.onSelectTree( + event, + selectedGroups, + index, + group.key + ) + } + /> + ))} + {this.state.locationsOrError.isLoading && ( + + )} +
    + } + /> + ) + )} +
    + +
    +
    + ) + } + + private onSelectTree = ( + event: React.MouseEvent, + selectedGroups: string[], + index: number, + group: string + ): void => { + event.preventDefault() + this.setState({ selectedGroups: selectedGroups.slice(0, index).concat(group) }) + if (this.props.onSelectTree) { + this.props.onSelectTree() + } + } +} diff --git a/client/branded/src/components/panel/views/HierarchicalLocationsViewButton.module.scss b/client/branded/src/components/panel/views/HierarchicalLocationsViewButton.module.scss new file mode 100644 index 000000000000..7cc09b1f0241 --- /dev/null +++ b/client/branded/src/components/panel/views/HierarchicalLocationsViewButton.module.scss @@ -0,0 +1,49 @@ +.location-button { + display: flex; + flex: none; + justify-content: space-between; + align-items: center; + border-radius: var(--border-radius); + font-size: 0.75rem; + line-height: (16/12); + border: none; + outline: none; + + &:focus-visible { + // Show item outline over the active item background instead of cutting it. + z-index: 2; + } + + &:global(.active) { + background-color: var(--primary); + margin-top: 0; + + &:hover { + color: var(--light-text); + } + + .location-badge { + color: var(--light-text); + } + } +} + +.location-name { + margin-right: 0.5rem; + display: flex; + align-items: center; + + min-width: 0; + + &-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; + } +} + +.location-count { + opacity: 1; + color: var(--primary); +} diff --git a/client/branded/src/components/panel/views/HierarchicalLocationsViewButton.tsx b/client/branded/src/components/panel/views/HierarchicalLocationsViewButton.tsx new file mode 100644 index 000000000000..315de8c50d66 --- /dev/null +++ b/client/branded/src/components/panel/views/HierarchicalLocationsViewButton.tsx @@ -0,0 +1,37 @@ +import classNames from 'classnames' +import React from 'react' + +import { RepoLink } from '@sourcegraph/shared/src/components/RepoLink' + +import styles from './HierarchicalLocationsViewButton.module.scss' + +interface HierarchicalLocationsViewButtonProps { + groupKey: string + groupCount: number + isActive: boolean + onClick: (event: React.MouseEvent) => void +} + +export const HierarchicalLocationsViewButton: React.FunctionComponent = props => { + const { groupKey, groupCount, isActive, onClick } = props + + return ( + + ) +} diff --git a/client/branded/src/components/panel/views/PanelView.module.scss b/client/branded/src/components/panel/views/PanelView.module.scss new file mode 100644 index 000000000000..9760a44ccf88 --- /dev/null +++ b/client/branded/src/components/panel/views/PanelView.module.scss @@ -0,0 +1,4 @@ +.panel-view { + flex: 1; + overflow: auto; +} diff --git a/client/branded/src/components/panel/views/PanelView.tsx b/client/branded/src/components/panel/views/PanelView.tsx new file mode 100644 index 000000000000..18e656cd5bf1 --- /dev/null +++ b/client/branded/src/components/panel/views/PanelView.tsx @@ -0,0 +1,53 @@ +import * as H from 'history' +import React from 'react' +import { Observable } from 'rxjs' + +import { FetchFileParameters } from '@sourcegraph/shared/src/components/CodeExcerpt' +import { Markdown } from '@sourcegraph/shared/src/components/Markdown' +import { ExtensionsControllerProps } from '@sourcegraph/shared/src/extensions/controller' +import { VersionContextProps } from '@sourcegraph/shared/src/search/util' +import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings' +import { renderMarkdown } from '@sourcegraph/shared/src/util/markdown' + +import { PanelViewWithComponent } from '../Panel' + +import { EmptyPanelView } from './EmptyPanelView' +import { HierarchicalLocationsView } from './HierarchicalLocationsView' +import styles from './PanelView.module.scss' + +interface Props extends ExtensionsControllerProps, SettingsCascadeProps, VersionContextProps { + panelView: PanelViewWithComponent + repoName?: string + location: H.Location + isLightTheme: boolean + fetchHighlightedFileLineRanges: (parameters: FetchFileParameters, force?: boolean) => Observable +} + +/** + * A panel view contributed by an extension using {@link sourcegraph.app.createPanelView}. + */ +export const PanelView = React.memo(props => ( +
    + {props.panelView.content && ( +
    + +
    + )} + {props.panelView.reactElement} + {props.panelView.locationProvider && props.repoName && ( + + )} + {!props.panelView.content && !props.panelView.reactElement && !props.panelView.locationProvider && ( + + )} +
    +)) diff --git a/client/branded/src/components/panel/views/__snapshots__/HierarchicalLocationsView.test.tsx.snap b/client/branded/src/components/panel/views/__snapshots__/HierarchicalLocationsView.test.tsx.snap new file mode 100644 index 000000000000..6f724cc78729 --- /dev/null +++ b/client/branded/src/components/panel/views/__snapshots__/HierarchicalLocationsView.test.tsx.snap @@ -0,0 +1,601 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` displays a single location when complete 1`] = ` +
    +
    +
    +
    +
    + +
    +
    + + + +
    +
    + + + + + +
    + + foo/bar + + โ€บ + + + + + + +
    + +
    + + 1 match + +
    + +
    + +
    +
    +
    +
    +`; + +exports[` displays multiple locations grouped by file 1`] = ` +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +`; + +exports[` displays partial locations before complete 1`] = ` +
    +
    +
    +
    +
    + +
    +
    + + + +
    +
    + + + + + +
    + + foo/bar + + โ€บ + + + + + + +
    + +
    + + 1 match + +
    + +
    + +
    +
    +
    +
    +`; + +exports[` shows a spinner before any locations emissions 1`] = ` +
    +`; + +exports[` shows a spinner if locations emits empty and is not complete 1`] = ` +
    +`; diff --git a/client/branded/src/components/panel/views/contributions.ts b/client/branded/src/components/panel/views/contributions.ts new file mode 100644 index 000000000000..66d91a34b5b8 --- /dev/null +++ b/client/branded/src/components/panel/views/contributions.ts @@ -0,0 +1,43 @@ +import { Remote } from 'comlink' +import { Unsubscribable } from 'rxjs' + +import { FlatExtensionHostAPI } from '@sourcegraph/shared/src/api/contract' +import { syncRemoteSubscription } from '@sourcegraph/shared/src/api/util' + +export function registerPanelToolbarContributions( + extensionHostAPI: Promise> +): Unsubscribable { + return syncRemoteSubscription( + extensionHostAPI.then(extensionHostAPI => + extensionHostAPI.registerContributions({ + actions: [ + { + id: 'panel.locations.groupByFile', + title: 'Group by file', + category: 'Locations (panel)', + command: 'updateConfiguration', + commandArguments: [ + ['panel.locations.groupByFile'], + // eslint-disable-next-line no-template-curly-in-string + '${!config.panel.locations.groupByFile}', + null, + 'json', + ], + actionItem: { + // eslint-disable-next-line no-template-curly-in-string + label: '${config.panel.locations.groupByFile && "Ungroup" || "Group"} by file', + }, + }, + ], + menus: { + 'panel/toolbar': [ + { + action: 'panel.locations.groupByFile', + when: 'panel.locations.hasResults && panel.activeView.hasLocations', + }, + ], + }, + }) + ) + ) +} diff --git a/shared/src/panel/views/locations.test.ts b/client/branded/src/components/panel/views/locations.test.ts similarity index 98% rename from shared/src/panel/views/locations.test.ts rename to client/branded/src/components/panel/views/locations.test.ts index 6c0fdfc9baf3..cbbf0a5047ba 100644 --- a/shared/src/panel/views/locations.test.ts +++ b/client/branded/src/components/panel/views/locations.test.ts @@ -1,4 +1,5 @@ import { Location } from '@sourcegraph/extension-api-types' + import { GroupedLocations, groupLocations } from './locations' type TestLocation = string @@ -8,8 +9,8 @@ type TestGroup = string const LOCATIONS: TestLocation[] = ['a/a/0', 'b/a/0', 'a/a/1', 'a/b/0'] const GROUP_KEYS: ((location: TestLocation) => TestGroup | undefined)[] = [ - loc => loc.split('/')[0], - loc => loc.split('/')[1], + location => location.split('/')[0], + location => location.split('/')[1], ] describe('groupLocations', () => { diff --git a/shared/src/panel/views/locations.ts b/client/branded/src/components/panel/views/locations.ts similarity index 77% rename from shared/src/panel/views/locations.ts rename to client/branded/src/components/panel/views/locations.ts index 737dc42c7259..3ee11632b18f 100644 --- a/shared/src/panel/views/locations.ts +++ b/client/branded/src/components/panel/views/locations.ts @@ -1,6 +1,7 @@ -import { Location } from '@sourcegraph/extension-api-types' import { isEqual, uniqWith } from 'lodash' +import { Location } from '@sourcegraph/extension-api-types' + /** * Grouped locations returned by {@link groupLocations}. * @@ -54,32 +55,32 @@ export function groupLocations( } const visibleLocations: L[] = [] - for (const loc of locations) { - for (const [i, groupKey] of groupKeys.entries()) { - const group = groupKey(loc) + for (const location of locations) { + for (const [index, groupKey] of groupKeys.entries()) { + const group = groupKey(location) if (group === undefined) { break } - if (!groups[i]) { - groups[i] = [] + if (!groups[index]) { + groups[index] = [] } - const groupEntry = groups[i].find(g => g.key === group) + const groupEntry = groups[index].find(groupEntry => groupEntry.key === group) if (groupEntry) { groupEntry.count++ } else { - groups[i].push({ key: group, count: 1 }) + groups[index].push({ key: group, count: 1 }) } - if (selectedGroups[i] === undefined) { - selectedGroups[i] = group + if (selectedGroups[index] === undefined) { + selectedGroups[index] = group } - if (selectedGroups[i] !== group) { + if (selectedGroups[index] !== group) { // This location won't be visible and won't contribute to any more groups, so stop processing it. break } // If this location is the rightmost selected group, it is visible. - if (i === groupKeys.length - 1) { - visibleLocations.push(loc) + if (index === groupKeys.length - 1) { + visibleLocations.push(location) } } } diff --git a/client/branded/src/components/tooltip/Tooltip.story.tsx b/client/branded/src/components/tooltip/Tooltip.story.tsx new file mode 100644 index 000000000000..0ca4d8398cd0 --- /dev/null +++ b/client/branded/src/components/tooltip/Tooltip.story.tsx @@ -0,0 +1,56 @@ +import { storiesOf } from '@storybook/react' +import React, { useCallback } from 'react' + +import { BrandedStory } from '../BrandedStory' + +import { Tooltip } from './Tooltip' + +const { add } = storiesOf('branded/Tooltip', module).addDecorator(story => ( + {() =>
    {story()}
    }
    +)) + +add( + 'Hover', + () => ( + <> + +

    + You can hover me or{' '} + me. +

    + + ), + { + chromatic: { + disable: true, + }, + } +) + +const PinnedTooltip: React.FunctionComponent = () => { + const clickElement = useCallback((element: HTMLElement | null) => { + if (element) { + element.click() + } + }, []) + return ( + <> + + + Example + +

    + + (A pinned tooltip is shown when the target element is rendered, without any user interaction + needed.) + +

    + + ) +} +add('Pinned', () => , { + chromatic: { + // Chromatic pauses CSS animations by default and resets them to their initial state + pauseAnimationAtEnd: true, + }, +}) diff --git a/web/src/components/tooltip/Tooltip.tsx b/client/branded/src/components/tooltip/Tooltip.tsx similarity index 80% rename from web/src/components/tooltip/Tooltip.tsx rename to client/branded/src/components/tooltip/Tooltip.tsx index 7fb8198c6f7a..c16c6bd03a51 100644 --- a/web/src/components/tooltip/Tooltip.tsx +++ b/client/branded/src/components/tooltip/Tooltip.tsx @@ -1,5 +1,5 @@ -import * as React from 'react' import * as Popper from 'popper.js' +import * as React from 'react' import { Tooltip as BootstrapTooltip } from 'reactstrap' interface Props {} @@ -10,6 +10,7 @@ interface State { lastEventTarget?: HTMLElement content?: string placement?: string + delay?: number } /** @@ -32,8 +33,8 @@ export class Tooltip extends React.PureComponent { public static forceUpdate(): void { const instance = Tooltip.INSTANCE if (instance) { - instance.setState(prevState => { - const subject = prevState.lastEventTarget && instance.getSubject(prevState.lastEventTarget) + instance.setState(previousState => { + const subject = previousState.lastEventTarget && instance.getSubject(previousState.lastEventTarget) return { subject, content: subject ? instance.getContent(subject) : undefined, @@ -75,7 +76,11 @@ export class Tooltip extends React.PureComponent { flip: { enabled: false, }, + preventOverflow: { + boundariesElement: 'window', + }, }} + delay={this.state.delay} > {this.state.content} @@ -98,30 +103,31 @@ export class Tooltip extends React.PureComponent { const eventTarget = event.target as HTMLElement const subject = this.getSubject(eventTarget) - this.setState(prevState => ({ + this.setState(previousState => ({ subject, - subjectSeq: prevState.subject === subject ? prevState.subjectSeq : prevState.subjectSeq + 1, + subjectSeq: previousState.subject === subject ? previousState.subjectSeq : previousState.subjectSeq + 1, content: subject ? this.getContent(subject) : undefined, placement: subject ? this.getPlacement(subject) : undefined, + delay: subject ? this.getDelay(subject) : 0, })) } /** * Find the nearest ancestor element to e that contains a tooltip. */ - private getSubject = (e: HTMLElement | null): HTMLElement | undefined => { - while (e) { - if (e === document.body) { + private getSubject = (element: HTMLElement | null): HTMLElement | undefined => { + while (element) { + if (element === document.body) { break } - if (e.hasAttribute(Tooltip.SUBJECT_ATTRIBUTE)) { + if (element.hasAttribute(Tooltip.SUBJECT_ATTRIBUTE)) { // If e is not actually attached to the DOM, then abort. - if (!document.body.contains(e)) { + if (!document.body.contains(element)) { return undefined } - return e + return element } - e = e.parentElement + element = element.parentElement } return undefined } @@ -139,6 +145,14 @@ export class Tooltip extends React.PureComponent { } return subject.getAttribute('data-placement') || undefined } + + private getDelay = (subject: HTMLElement): number | undefined => { + if (!document.body.contains(subject)) { + return undefined + } + const dataDelay = subject.getAttribute('data-delay') + return dataDelay ? parseInt(dataDelay, 10) : undefined + } } /** @@ -151,7 +165,7 @@ export class Tooltip extends React.PureComponent { */ export function setElementTooltip(element: HTMLElement, tooltip: string | null): void { if (tooltip) { - element.setAttribute('data-tooltip', tooltip) + element.dataset.tooltip = tooltip } else { element.removeAttribute('data-tooltip') } diff --git a/client/branded/src/global-styles/GlobalStylesStory/AlertsStory.tsx b/client/branded/src/global-styles/GlobalStylesStory/AlertsStory.tsx new file mode 100644 index 000000000000..5af6090261a7 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/AlertsStory.tsx @@ -0,0 +1,41 @@ +import { action } from '@storybook/addon-actions' +import { StoryFn } from '@storybook/addons' +import classNames from 'classnames' +import { flow } from 'lodash' +import React, { ReactElement } from 'react' + +import { SEMANTIC_COLORS } from './constants' +import { preventDefault } from './utils' + +export const AlertsStory: StoryFn = () => ( + <> +

    Alerts

    +

    + Provide contextual feedback messages for typical user actions with the handful of available and flexible + alert messages. +

    + {SEMANTIC_COLORS.map(semantic => ( +
    +

    A shiny {semantic} alert - check it out!

    + It can also contain{' '} + + links like this + + . +
    + ))} +
    +
    +

    A shiny info alert with a button - check it out!

    + It can also contain text without links. +
    + +
    + +) diff --git a/client/branded/src/global-styles/GlobalStylesStory/BadgeVariants/BadgeVariants.module.scss b/client/branded/src/global-styles/GlobalStylesStory/BadgeVariants/BadgeVariants.module.scss new file mode 100644 index 000000000000..b658018ef4de --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/BadgeVariants/BadgeVariants.module.scss @@ -0,0 +1,8 @@ +.grid { + display: grid; + grid-template-columns: repeat(3, max-content); + grid-auto-rows: max-content; + grid-gap: 1rem; + margin-bottom: 1rem; + font-size: 1rem; +} diff --git a/client/branded/src/global-styles/GlobalStylesStory/BadgeVariants/BadgeVariants.tsx b/client/branded/src/global-styles/GlobalStylesStory/BadgeVariants/BadgeVariants.tsx new file mode 100644 index 000000000000..ff29ebff655a --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/BadgeVariants/BadgeVariants.tsx @@ -0,0 +1,40 @@ +import classNames from 'classnames' +import { startCase } from 'lodash' +import React from 'react' +import 'storybook-addon-designs' + +import { SEMANTIC_COLORS } from '../constants' + +import styles from './BadgeVariants.module.scss' + +interface BadgeProps { + variant?: string + small?: boolean +} + +const Badge: React.FunctionComponent = ({ variant, small }) => { + const className = classNames('badge', small && 'badge-sm', variant && `badge-${variant}`) + return ( + <> + {startCase(variant || 'Default')} + Uppercase + + Link + + + ) +} + +interface BadgeVariantProps { + variants?: readonly (typeof SEMANTIC_COLORS[number] | 'outline-secondary')[] + small?: boolean +} + +export const BadgeVariants: React.FunctionComponent = ({ variants, small }) => ( +
    + + {variants?.map(variant => ( + + ))} +
    +) diff --git a/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/ButtonVariants.module.scss b/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/ButtonVariants.module.scss new file mode 100644 index 000000000000..379089d7bb1a --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/ButtonVariants.module.scss @@ -0,0 +1,7 @@ +.grid { + display: grid; + grid-template-columns: repeat(3, max-content); + grid-auto-rows: max-content; + grid-gap: 1rem; + margin-bottom: 1rem; +} diff --git a/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/ButtonVariants.tsx b/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/ButtonVariants.tsx new file mode 100644 index 000000000000..3ced2e1655e2 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/ButtonVariants.tsx @@ -0,0 +1,60 @@ +import { action } from '@storybook/addon-actions' +import classNames from 'classnames' +import { flow, startCase } from 'lodash' +import React from 'react' +import 'storybook-addon-designs' + +import { SEMANTIC_COLORS } from '../constants' +import { preventDefault } from '../utils' + +import styles from './ButtonVariants.module.scss' + +interface ButtonVariantsProps { + variantType?: 'btn' | 'btn-outline' + variants: readonly typeof SEMANTIC_COLORS[number][] + small?: boolean + icon?: React.ComponentType<{ className?: string }> +} + +export const ButtonVariants: React.FunctionComponent = ({ + variantType = 'btn', + variants, + small, + icon: Icon, +}) => ( +
    + {variants.map(variant => { + const className = classNames('btn', `${variantType}-${variant}`, small && 'btn-sm') + return ( + + + + + + ) + })} +
    +) diff --git a/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/index.ts b/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/index.ts new file mode 100644 index 000000000000..9048ae145024 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/ButtonVariants/index.ts @@ -0,0 +1 @@ +export * from './ButtonVariants' diff --git a/client/branded/src/global-styles/GlobalStylesStory/CardsStory.tsx b/client/branded/src/global-styles/GlobalStylesStory/CardsStory.tsx new file mode 100644 index 000000000000..3d87ceabab99 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/CardsStory.tsx @@ -0,0 +1,43 @@ +import { StoryFn } from '@storybook/addons' +import React, { ReactElement } from 'react' + +export const CardsStory: StoryFn = () => ( + <> +

    Cards

    +

    + A card is a flexible and extensible content container. It includes options for headers and footers, a wide + variety of content, contextual background colors, and powerful display options.{' '} + Bootstrap documentation +

    + +

    Examples

    + +
    +
    This is some text within a card body.
    +
    + + {/* eslint-disable-next-line react/forbid-dom-props */} +
    +
    +

    Card title

    +

    + Some quick example text to build on the card title and make up the bulk of the card's content. +

    + +
    +
    + +
    +
    Featured
    +
    +

    Special title treatment

    +

    With supporting text below as a natural lead-in to additional content.

    + + Go somewhere + +
    +
    + +) diff --git a/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/ColorVariants.module.scss b/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/ColorVariants.module.scss new file mode 100644 index 000000000000..014fbb777fd9 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/ColorVariants.module.scss @@ -0,0 +1,7 @@ +.grid { + display: grid; + grid-template-columns: repeat(4, max-content); + grid-auto-rows: max-content; + grid-gap: 1rem; + margin-bottom: 1rem; +} diff --git a/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/ColorVariants.tsx b/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/ColorVariants.tsx new file mode 100644 index 000000000000..8c8a2acc6b2d --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/ColorVariants.tsx @@ -0,0 +1,20 @@ +/* eslint-disable react/forbid-dom-props */ +import React from 'react' + +import { getSemanticColorVariables } from '../utils' + +import styles from './ColorVariants.module.scss' + +export const ColorVariants: React.FunctionComponent = () => ( +
    + {getSemanticColorVariables().map(variant => ( +
    +
    + {variant} +
    + ))} +
    +) diff --git a/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/index.ts b/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/index.ts new file mode 100644 index 000000000000..c9a140501e39 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/ColorVariants/index.ts @@ -0,0 +1 @@ +export * from './ColorVariants' diff --git a/client/branded/src/global-styles/GlobalStylesStory/FormFieldVariants/FormFieldVariants.module.scss b/client/branded/src/global-styles/GlobalStylesStory/FormFieldVariants/FormFieldVariants.module.scss new file mode 100644 index 000000000000..014fbb777fd9 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/FormFieldVariants/FormFieldVariants.module.scss @@ -0,0 +1,7 @@ +.grid { + display: grid; + grid-template-columns: repeat(4, max-content); + grid-auto-rows: max-content; + grid-gap: 1rem; + margin-bottom: 1rem; +} diff --git a/client/branded/src/global-styles/GlobalStylesStory/FormFieldVariants/FormFieldVariants.tsx b/client/branded/src/global-styles/GlobalStylesStory/FormFieldVariants/FormFieldVariants.tsx new file mode 100644 index 000000000000..c81d4e2b0ce4 --- /dev/null +++ b/client/branded/src/global-styles/GlobalStylesStory/FormFieldVariants/FormFieldVariants.tsx @@ -0,0 +1,105 @@ +import classNames from 'classnames' +import React from 'react' +import 'storybook-addon-designs' + +import styles from './FormFieldVariants.module.scss' + +type FieldVariants = 'standard' | 'invalid' | 'valid' | 'disabled' + +interface WithVariantsProps { + field: React.ComponentType<{ + className?: string + disabled?: boolean + message?: JSX.Element + variant: FieldVariants + }> +} + +const FieldMessage: React.FunctionComponent<{ className?: string }> = ({ className }) => ( + Helper text +) + +const WithVariants: React.FunctionComponent = ({ field: Field }) => ( + <> + } /> + } /> + } /> + } /> + +) + +export const FormFieldVariants: React.FunctionComponent = () => ( +
    + ( +
    + + {message} +
    + )} + /> + ( +
    + + {message} +
    + )} + /> + ( +
    +