mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +00:00
V4 (#311)
Sourcebot V4 introduces authentication, performance improvements and code navigation. Checkout the [migration guide](https://docs.sourcebot.dev/self-hosting/upgrade/v3-to-v4-guide) for information on upgrading your instance to v4. ### Changed - [**Breaking Change**] Authentication is now required by default. Notes: - When setting up your instance, email / password login will be the default authentication provider. - The first user that logs into the instance is given the `owner` role. ([docs](https://docs.sourcebot.dev/docs/more/roles-and-permissions)). - Subsequent users can request to join the instance. The `owner` can approve / deny requests to join the instance via `Settings` > `Members` > `Pending Requests`. - If a user is approved to join the instance, they are given the `member` role. - Additional login providers, including email links and SSO, can be configured with additional environment variables. ([docs](https://docs.sourcebot.dev/self-hosting/configuration/authentication)). - Clicking on a search result now takes you to the `/browse` view. Files can still be previewed by clicking the "Preview" button or holding `Cmd` / `Ctrl` when clicking on a search result. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) ### Added - [Sourcebot EE] Added search-based code navigation, allowing you to jump between symbol definition and references when viewing source files. [Read the documentation](https://docs.sourcebot.dev/docs/search/code-navigation). [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) - Added collapsible filter panel. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) ### Fixed - Improved scroll performance for large numbers of search results. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315)
This commit is contained in:
parent
536bf8aa79
commit
60a3528394
169 changed files with 7135 additions and 1783 deletions
7
.cursor/rules/style.mdc
Normal file
7
.cursor/rules/style.mdc
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
- Always use 4 spaces for indentation
|
||||||
|
- Filenames should always be camelCase. Exception: if there are filenames in the same directory with a format other than camelCase, use that format to keep things consistent.
|
||||||
|
|
@ -18,10 +18,10 @@ SRC_TENANT_ENFORCEMENT_MODE=strict
|
||||||
AUTH_SECRET="00000000000000000000000000000000000000000000"
|
AUTH_SECRET="00000000000000000000000000000000000000000000"
|
||||||
AUTH_URL="http://localhost:3000"
|
AUTH_URL="http://localhost:3000"
|
||||||
# AUTH_CREDENTIALS_LOGIN_ENABLED=true
|
# AUTH_CREDENTIALS_LOGIN_ENABLED=true
|
||||||
# AUTH_GITHUB_CLIENT_ID=""
|
# AUTH_EE_GITHUB_CLIENT_ID=""
|
||||||
# AUTH_GITHUB_CLIENT_SECRET=""
|
# AUTH_EE_GITHUB_CLIENT_SECRET=""
|
||||||
# AUTH_GOOGLE_CLIENT_ID=""
|
# AUTH_EE_GOOGLE_CLIENT_ID=""
|
||||||
# AUTH_GOOGLE_CLIENT_SECRET=""
|
# AUTH_EE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot)
|
DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot)
|
||||||
# CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists)
|
# CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists)
|
||||||
|
|
|
||||||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
Sourcebot V4 introduces authentication, performance improvements and code navigation. Checkout the [migration guide](https://docs.sourcebot.dev/self-hosting/upgrade/v3-to-v4-guide) for information on upgrading your instance to v4.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- [**Breaking Change**] Authentication is now required by default. Notes:
|
||||||
|
- When setting up your instance, email / password login will be the default authentication provider.
|
||||||
|
- The first user that logs into the instance is given the `owner` role. ([docs](https://docs.sourcebot.dev/docs/more/roles-and-permissions)).
|
||||||
|
- Subsequent users can request to join the instance. The `owner` can approve / deny requests to join the instance via `Settings` > `Members` > `Pending Requests`.
|
||||||
|
- If a user is approved to join the instance, they are given the `member` role.
|
||||||
|
- Additional login providers, including email links and SSO, can be configured with additional environment variables. ([docs](https://docs.sourcebot.dev/self-hosting/configuration/authentication)).
|
||||||
|
- Clicking on a search result now takes you to the `/browse` view. Files can still be previewed by clicking the "Preview" button or holding `Cmd` / `Ctrl` when clicking on a search result. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- [Sourcebot EE] Added search-based code navigation, allowing you to jump between symbol definition and references when viewing source files. [Read the documentation](https://docs.sourcebot.dev/docs/search/code-navigation). [#315](https://github.com/sourcebot-dev/sourcebot/pull/315)
|
||||||
|
- Added collapsible filter panel. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315)
|
||||||
|
- Added Sourcebot API key management for external clients. [#311](https://github.com/sourcebot-dev/sourcebot/pull/311)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved scroll performance for large numbers of search results. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315)
|
||||||
|
|
||||||
## [3.2.1] - 2025-05-15
|
## [3.2.1] - 2025-05-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -93,8 +112,8 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou
|
||||||
### Added
|
### Added
|
||||||
- Added parallelized repo indexing and connection syncing via Redis & BullMQ. See the [architecture overview](https://docs.sourcebot.dev/self-hosting/overview#architecture).
|
- Added parallelized repo indexing and connection syncing via Redis & BullMQ. See the [architecture overview](https://docs.sourcebot.dev/self-hosting/overview#architecture).
|
||||||
- Added repo indexing progress indicators in the navbar.
|
- Added repo indexing progress indicators in the navbar.
|
||||||
- Added authentication support via OAuth or email/password. For instructions on enabling, see [this doc](https://docs.sourcebot.dev/self-hosting/more/authentication).
|
- Added authentication support via OAuth or email/password. For instructions on enabling, see [this doc](https://docs.sourcebot.dev/self-hosting/configuration/authentication).
|
||||||
- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/self-hosting/more/authentication)**:
|
- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/self-hosting/configuration/authentication)**:
|
||||||
- connection management: create and manage your JSON configs via a integrated web-editor.
|
- connection management: create and manage your JSON configs via a integrated web-editor.
|
||||||
- secrets: import personal access tokens (PAT) into Sourcebot (AES-256 encrypted). Reference secrets in your connection config by name.
|
- secrets: import personal access tokens (PAT) into Sourcebot (AES-256 encrypted). Reference secrets in your connection config by name.
|
||||||
- team & invite management: invite users to your instance to give them access. Configure team [roles & permissions](https://docs.sourcebot.dev/docs/more/roles-and-permissions).
|
- team & invite management: invite users to your instance to give them access. Configure team [roles & permissions](https://docs.sourcebot.dev/docs/more/roles-and-permissions).
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"docs/search/syntax-reference",
|
"docs/search/syntax-reference",
|
||||||
"docs/search/multi-branch-indexing",
|
"docs/search/multi-branch-indexing",
|
||||||
|
"docs/search/code-navigation",
|
||||||
"docs/search/search-contexts"
|
"docs/search/search-contexts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -63,6 +64,7 @@
|
||||||
{
|
{
|
||||||
"group": "More",
|
"group": "More",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"docs/more/api-keys",
|
||||||
"docs/more/roles-and-permissions",
|
"docs/more/roles-and-permissions",
|
||||||
"docs/more/mcp-server"
|
"docs/more/mcp-server"
|
||||||
]
|
]
|
||||||
|
|
@ -77,17 +79,16 @@
|
||||||
"group": "Getting Started",
|
"group": "Getting Started",
|
||||||
"pages": [
|
"pages": [
|
||||||
"self-hosting/overview",
|
"self-hosting/overview",
|
||||||
"self-hosting/configuration",
|
|
||||||
"self-hosting/license-key"
|
"self-hosting/license-key"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "More",
|
"group": "Configuration",
|
||||||
"pages": [
|
"pages": [
|
||||||
"self-hosting/more/authentication",
|
"self-hosting/configuration/environment-variables",
|
||||||
"self-hosting/more/tenancy",
|
"self-hosting/configuration/authentication",
|
||||||
"self-hosting/more/transactional-emails",
|
"self-hosting/configuration/transactional-emails",
|
||||||
"self-hosting/more/declarative-config"
|
"self-hosting/configuration/declarative-config"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -98,6 +99,7 @@
|
||||||
{
|
{
|
||||||
"group": "Upgrade",
|
"group": "Upgrade",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"self-hosting/upgrade/v3-to-v4-guide",
|
||||||
"self-hosting/upgrade/v2-to-v3-guide"
|
"self-hosting/upgrade/v2-to-v3-guide"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ Before you get started, make sure you have an OpenAPI account that you can creat
|
||||||
directory that you mount to Sourcebot
|
directory that you mount to Sourcebot
|
||||||

|

|
||||||
- `OPENAI_API_KEY`: Your OpenAI API key
|
- `OPENAI_API_KEY`: Your OpenAI API key
|
||||||
|
- `REVIEW_AGENT_API_KEY`: The Sourcebot API key that the review agent uses to hit the Sourcebot API to fetch code context
|
||||||
- `REVIEW_AGENT_AUTO_REVIEW_ENABLED` (default: `false`): If enabled, the review agent will automatically review any new or updated PR. If disabled, you must invoke it using the command defined by `REVIEW_AGENT_REVIEW_COMMAND`
|
- `REVIEW_AGENT_AUTO_REVIEW_ENABLED` (default: `false`): If enabled, the review agent will automatically review any new or updated PR. If disabled, you must invoke it using the command defined by `REVIEW_AGENT_REVIEW_COMMAND`
|
||||||
- `REVIEW_AGENT_REVIEW_COMMAND` (default: `review`): The command that invokes the review agent (ex. `/review`) when a user comments on the PR. Don't include the slash character in this value.
|
- `REVIEW_AGENT_REVIEW_COMMAND` (default: `review`): The command that invokes the review agent (ex. `/review`) when a user comments on the PR. Don't include the slash character in this value.
|
||||||
|
|
||||||
|
|
@ -76,6 +77,7 @@ Before you get started, make sure you have an OpenAPI account that you can creat
|
||||||
GITHUB_APP_ID: "my-github-app-id"
|
GITHUB_APP_ID: "my-github-app-id"
|
||||||
GITHUB_APP_WEBHOOK_SECRET: "my-github-app-webhook-secret"
|
GITHUB_APP_WEBHOOK_SECRET: "my-github-app-webhook-secret"
|
||||||
GITHUB_APP_PRIVATE_KEY_PATH: "/data/review-agent-key.pem"
|
GITHUB_APP_PRIVATE_KEY_PATH: "/data/review-agent-key.pem"
|
||||||
|
REVIEW_AGENT_API_KEY: "sourcebot-my-key"
|
||||||
OPENAI_API_KEY: "sk-proj-my-open-api-key"
|
OPENAI_API_KEY: "sk-proj-my-open-api-key"
|
||||||
```
|
```
|
||||||
</Step>
|
</Step>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ Next, provide the access token via the `token` property, either as an environmen
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Environment Variable">
|
<Tab title="Environment Variable">
|
||||||
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
|
<Note>Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI.</Note>
|
||||||
|
|
||||||
1. Add the `token` property to your connection config:
|
1. Add the `token` property to your connection config:
|
||||||
```json
|
```json
|
||||||
|
|
@ -107,7 +107,7 @@ Next, provide the access token via the `token` property, either as an environmen
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
<Tab title="Secret">
|
<Tab title="Secret">
|
||||||
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
|
<Note>Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled.</Note>
|
||||||
|
|
||||||
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Environment Variable">
|
<Tab title="Environment Variable">
|
||||||
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
|
<Note>Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI.</Note>
|
||||||
|
|
||||||
1. Add the `token` property to your connection config:
|
1. Add the `token` property to your connection config:
|
||||||
```json
|
```json
|
||||||
|
|
@ -136,7 +136,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
<Tab title="Secret">
|
<Tab title="Secret">
|
||||||
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
|
<Note>Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled.</Note>
|
||||||
|
|
||||||
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Environment Variable">
|
<Tab title="Environment Variable">
|
||||||
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
|
<Note>Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI.</Note>
|
||||||
|
|
||||||
1. Add the `token` property to your connection config:
|
1. Add the `token` property to your connection config:
|
||||||
```json
|
```json
|
||||||
|
|
@ -141,7 +141,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
<Tab title="Secret">
|
<Tab title="Secret">
|
||||||
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
|
<Note>Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled.</Note>
|
||||||
|
|
||||||
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ There are two ways to define connections:
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
<Accordion title="Declarative configuration file">
|
<Accordion title="Declarative configuration file">
|
||||||
This is only supported when self-hosting, and is the default mechanism to define connections. Connections are defined in a [JSON file](/self-hosting/more/declarative-config)
|
This is only supported when self-hosting, and is the default mechanism to define connections. Connections are defined in a [JSON file](/self-hosting/configuration/declarative-config)
|
||||||
and the path to the file is provided through the `CONFIG_PATH` environment variable
|
and the path to the file is provided through the `CONFIG_PATH` environment variable
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="UI connection management">
|
<Accordion title="UI connection management">
|
||||||
This is the only way to define connections when using Sourcebot Cloud, and can be configured when self-hosting by enabling [authentication](/self-hosting/more/authentications).
|
This is the only way to define connections when using Sourcebot Cloud, and can be configured when self-hosting by enabling [authentication](/self-hosting/configuration/authentications).
|
||||||
|
|
||||||
In this method, connections are defined and managed within the webapp:
|
In this method, connections are defined and managed within the webapp:
|
||||||
|
|
||||||
|
|
|
||||||
8
docs/docs/more/api-keys.mdx
Normal file
8
docs/docs/more/api-keys.mdx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
title: API Keys
|
||||||
|
---
|
||||||
|
|
||||||
|
An API Key is required when querying Sourcebot outside the context of the web app client (ex. MCP server, review agent). To create an API key, login to your Sourcebot instance and navigate to
|
||||||
|
**Settings -> API Keys**:
|
||||||
|
|
||||||
|

|
||||||
|
|
@ -4,7 +4,7 @@ sidebarTitle: Sourcebot MCP server
|
||||||
---
|
---
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
This feature is only available when [self-hosting](/self-hosting) with [authentication](/self-hosting/more/authentication) disabled.
|
This feature is only available when [self-hosting](/self-hosting)
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is an open standard for providing context to LLMs. The [@sourcebot/mcp](https://www.npmjs.com/package/@sourcebot/mcp) package is a MCP server that enables LLMs to interface with your Sourcebot instance, enabling MCP clients like Cursor, Vscode, and others to have context over your entire codebase.
|
The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is an open standard for providing context to LLMs. The [@sourcebot/mcp](https://www.npmjs.com/package/@sourcebot/mcp) package is a MCP server that enables LLMs to interface with your Sourcebot instance, enabling MCP clients like Cursor, Vscode, and others to have context over your entire codebase.
|
||||||
|
|
@ -176,6 +176,7 @@ Parameters:
|
||||||
| Name | Default | Description |
|
| Name | Default | Description |
|
||||||
|:-------------------------|:-----------------------|:--------------------------------------------------|
|
|:-------------------------|:-----------------------|:--------------------------------------------------|
|
||||||
| `SOURCEBOT_HOST` | http://localhost:3000 | URL of your Sourcebot instance. |
|
| `SOURCEBOT_HOST` | http://localhost:3000 | URL of your Sourcebot instance. |
|
||||||
|
| `SOURCEBOT_API_KEY` | - | Sourcebot API key. |
|
||||||
| `DEFAULT_MINIMUM_TOKENS` | 10000 | Minimum number of tokens to return in responses. |
|
| `DEFAULT_MINIMUM_TOKENS` | 10000 | Minimum number of tokens to return in responses. |
|
||||||
| `DEFAULT_MATCHES` | 10000 | Number of code matches to fetch per search. |
|
| `DEFAULT_MATCHES` | 10000 | Number of code matches to fetch per search. |
|
||||||
| `DEFAULT_CONTEXT_LINES` | 5 | Lines of context to include above/below matches. |
|
| `DEFAULT_CONTEXT_LINES` | 5 | Lines of context to include above/below matches. |
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ title: Roles and Permissions
|
||||||
|
|
||||||
<Note>Looking to sync permissions with your identify provider? We're working on it - [reach out](https://www.sourcebot.dev/contact) to us to learn more</Note>
|
<Note>Looking to sync permissions with your identify provider? We're working on it - [reach out](https://www.sourcebot.dev/contact) to us to learn more</Note>
|
||||||
|
|
||||||
If you're using Sourcebot Cloud, or are self-hosting with [authentication](/self-hosting/more/authentication) enabled, you may have multiple members in your organization. Each
|
Each member has a role which defines their permissions within an organization:
|
||||||
member has a role which defines their permissions:
|
|
||||||
|
|
||||||
| Role | Permission |
|
| Role | Permission |
|
||||||
| :--- | :--------- |
|
| :--- | :--------- |
|
||||||
|
|
|
||||||
44
docs/docs/search/code-navigation.mdx
Normal file
44
docs/docs/search/code-navigation.mdx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
title: Code navigation
|
||||||
|
sidebarTitle: Code navigation
|
||||||
|
---
|
||||||
|
|
||||||
|
import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx'
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
This feature is only available in [Sourcebot cloud](app.sourcebot.dev) or with an active Enterprise license when [self-hosting](/self-hosting). Please add your [license key](/self-hosting/license-key) to activate it.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
**Code navigation** allows you to jump between symbol definition and references when viewing source files in Sourcebot. This feature is enabled **automatically** when a valid license key is present and works with all popular programming languages.
|
||||||
|
|
||||||
|
|
||||||
|
<video src="https://framerusercontent.com/assets/B9ZxrlsUeO9NJyzkKyvVV2KSU4.mp4" className="w-full aspect-video" controls></video>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|:--------|:------------|
|
||||||
|
| **Hover popover** | Hovering over a symbol reveals the symbol's definition signature as a inline preview. |
|
||||||
|
| **Go to definition** | Clicking the "go to definition" button in the popover or clicking the symbol name navigates to the symbol's definition. |
|
||||||
|
| **Find references** | Clicking the "find all references" button in the popover lists all references in the explore panel. |
|
||||||
|
| **Explore panel** | Lists all references and definitions for the symbol selected in the popover. |
|
||||||
|
|
||||||
|
## How does it work?
|
||||||
|
|
||||||
|
Code navigation is **search-based**, meaning it uses the same code search engine and [query language](/docs/search/syntax-reference) to estimate a symbol's references and definitions. We refer to these estimations as "search heuristics". We have two search heuristics to enable the following operations:
|
||||||
|
|
||||||
|
### Find references
|
||||||
|
Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
\\b{symbolName}\\b rev:{git_revision} lang:{language} case:yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find definitions
|
||||||
|
Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sym:\\b{symbolName}\\b rev:{git_revision} lang:{language}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the `sym:` prefix is used to filter the search by symbol definitions. These are created at index time by [universal ctags](https://ctags.io/).
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
title: Search contexts
|
title: Search contexts
|
||||||
sidebarTitle: Search contexts (EE)
|
sidebarTitle: Search contexts
|
||||||
---
|
---
|
||||||
|
|
||||||
import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx'
|
import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx'
|
||||||
|
|
@ -16,7 +16,7 @@ A **search context** is a user-defined grouping of repositories that helps focus
|
||||||
- `( context:project1 or context:project2 ) logger\.debug` - search for debug log calls in project1 and project2
|
- `( context:project1 or context:project2 ) logger\.debug` - search for debug log calls in project1 and project2
|
||||||
|
|
||||||
|
|
||||||
Search contexts are defined in the `context` object inside of a [declarative config](/self-hosting/more/declarative-config). Repositories can be included / excluded from a search context by specifying the repo's URL in either the `include` array or `exclude` array. Glob patterns are supported.
|
Search contexts are defined in the `context` object inside of a [declarative config](/self-hosting/configuration/declarative-config). Repositories can be included / excluded from a search context by specifying the repo's URL in either the `include` array or `exclude` array. Glob patterns are supported.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ shared/
|
||||||
├─ ...
|
├─ ...
|
||||||
```
|
```
|
||||||
|
|
||||||
To make searching easier, we can create three search contexts in our [config.json](/self-hosting/more/declarative-config):
|
To make searching easier, we can create three search contexts in our [config.json](/self-hosting/configuration/declarative-config):
|
||||||
- `web`: For all frontend-related code
|
- `web`: For all frontend-related code
|
||||||
- `backend`: For backend services and shared APIs
|
- `backend`: For backend services and shared APIs
|
||||||
- `pipelines`: For all CI/CD configurations
|
- `pipelines`: For all CI/CD configurations
|
||||||
|
|
|
||||||
BIN
docs/images/api_key.png
Normal file
BIN
docs/images/api_key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
BIN
docs/images/join_request_email.png
Normal file
BIN
docs/images/join_request_email.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 118 KiB |
BIN
docs/images/login_basic.png
Normal file
BIN
docs/images/login_basic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/images/pending_approval.png
Normal file
BIN
docs/images/pending_approval.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
|
|
@ -1,59 +0,0 @@
|
||||||
---
|
|
||||||
title: Configuration
|
|
||||||
sidebarTitle: Configuration
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Sourcebot accepts a variety of environment variables to fine tune your deployment.
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| :------- | :------ | :---------- |
|
|
||||||
| `SOURCEBOT_LOG_LEVEL` | `info` | <p>The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.</p> |
|
|
||||||
| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | <p>Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.</p><p>If you'd like to use a non-default schema, you can provide it as a parameter in the database url </p> |
|
|
||||||
| `REDIS_URL` | `redis://localhost:6379` | <p>Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.</p> |
|
|
||||||
| `SOURCEBOT_ENCRYPTION_KEY` | - | <p>Used to encrypt connection secrets. Generated using `openssl rand -base64 24`. Automatically generated at startup if no value is provided.</p> |
|
|
||||||
| `AUTH_SECRET` | - | <p>Used to validate login session cookies. Generated using `openssl rand -base64 33`. Automatically generated at startup if no value is provided.</p> |
|
|
||||||
| `AUTH_URL` | - | <p>URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`. Required when `SOURCEBOT_AUTH_ENABLED` is `true`.</p> |
|
|
||||||
| `SOURCEBOT_TENANCY_MODE` | `single` | <p>The tenancy configuration for Sourcebot. Valid values are `single` or `multi`. See [this doc](/self-hosting/more/tenancy) for more info.</p> |
|
|
||||||
| `SOURCEBOT_AUTH_ENABLED` | `false` | <p>Enables/disables authentication in Sourcebot. If set to `false`, `SOURCEBOT_TENANCY_MODE` must be `single`. See [this doc](/self-hosting/more/authentication) for more info.</p> |
|
|
||||||
| `SOURCEBOT_TELEMETRY_DISABLED` | `false` | <p>Enables/disables telemetry collection in Sourcebot. See [this doc](/self-hosting/security/telemetry) for more info.</p> |
|
|
||||||
| `DATA_DIR` | `/data` | <p>The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)</p> |
|
|
||||||
| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` | <p>The root data directory in which all data written to disk by Sourcebot will be located.</p> |
|
|
||||||
| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` | <p>The data directory for the default Postgres database.</p> |
|
|
||||||
| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` | <p>The data directory for the default Redis instance.</p> |
|
|
||||||
|
|
||||||
|
|
||||||
## Additional Features
|
|
||||||
|
|
||||||
There are additional features that can be enabled and configured via environment variables.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card horizontal title="Authentication" icon="lock" href="/self-hosting/more/authentication" />
|
|
||||||
<Card horizontal title="Tenancy" icon="users" href="/self-hosting/more/tenancy" />
|
|
||||||
<Card horizontal title="Transactional Emails" icon="envelope" href="/self-hosting/more/transactional-emails" />
|
|
||||||
<Card horizontal title="Declarative Configs" icon="page" href="/self-hosting/more/declarative-config" />
|
|
||||||
</CardGroup>
|
|
||||||
|
|
||||||
## Health Check and Version Endpoints
|
|
||||||
|
|
||||||
Sourcebot includes a health check endpoint that indicates if the application is alive, returning `200 OK` if it is:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl http://localhost:3000/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
It also includes a version endpoint to check the current version of the application:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl http://localhost:3000/api/version
|
|
||||||
```
|
|
||||||
|
|
||||||
Sample response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "v3.0.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
118
docs/self-hosting/configuration/authentication.mdx
Normal file
118
docs/self-hosting/configuration/authentication.mdx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
---
|
||||||
|
title: Authentication
|
||||||
|
sidebarTitle: Authentication
|
||||||
|
---
|
||||||
|
|
||||||
|
<Warning>Make sure the `AUTH_URL` environment variable is [configured correctly](/self-hosting/configuration) when using Sourcebot behind a domain.</Warning>
|
||||||
|
|
||||||
|
Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported.
|
||||||
|
|
||||||
|
The first account that's registered on a Sourcebot deployment is made the owner. All other users who register must be [approved](/self-hosting/configuration/authentication#approving-new-members) by the owner.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
# Approving New Members
|
||||||
|
|
||||||
|
All account registrations after the first account must be approved by the owner. The owner can see all join requests by going into **Settings -> Members**.
|
||||||
|
|
||||||
|
If you have an [enterprise license](/self-hosting/license-key), you can enable [AUTH_EE_ENABLE_JIT_PROVISIONING](/self-hosting/configuration/authentication#enterprise-authentication-providers) to
|
||||||
|
have Sourcebot accounts automatically created and approved on registration.
|
||||||
|
|
||||||
|
You can setup emails to be sent when new join requests are created/approved by configurating [transactional emails](/self-hosting/configuration/transactional-emails)
|
||||||
|
# Authentication Providers
|
||||||
|
|
||||||
|
To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider.
|
||||||
|
|
||||||
|
## Core Authentication Providers
|
||||||
|
|
||||||
|
### Email / Password
|
||||||
|
---
|
||||||
|
Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`.
|
||||||
|
|
||||||
|
### Email codes
|
||||||
|
---
|
||||||
|
Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables:
|
||||||
|
|
||||||
|
- `AUTH_EMAIL_CODE_LOGIN_ENABLED`
|
||||||
|
- `SMTP_CONNECTION_URL`
|
||||||
|
- `EMAIL_FROM_ADDRESS`
|
||||||
|
|
||||||
|
|
||||||
|
See [transactional emails](/self-hosting/configuration/transactional-emails) for more details.
|
||||||
|
|
||||||
|
## Enterprise Authentication Providers
|
||||||
|
|
||||||
|
The following authentication providers require an [enterprise license](/self-hosting/license-key) to be enabled.
|
||||||
|
|
||||||
|
By default, a new user registering using these providers must have their join request accepted by the owner of the organization to join. To allow a user to join automatically when
|
||||||
|
they register for the first time, set the `AUTH_EE_ENABLE_JIT_PROVISIONING` environment variable to `true`.
|
||||||
|
|
||||||
|
### GitHub
|
||||||
|
---
|
||||||
|
|
||||||
|
[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
|
||||||
|
|
||||||
|
**Required environment variables:**
|
||||||
|
- `AUTH_EE_GITHUB_CLIENT_ID`
|
||||||
|
- `AUTH_EE_GITHUB_CLIENT_SECRET`
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com)
|
||||||
|
|
||||||
|
### GitLab
|
||||||
|
---
|
||||||
|
|
||||||
|
[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab)
|
||||||
|
|
||||||
|
**Required environment variables:**
|
||||||
|
- `AUTH_EE_GITLAB_CLIENT_ID`
|
||||||
|
- `AUTH_EE_GITLAB_CLIENT_SECRET`
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com)
|
||||||
|
|
||||||
|
### Google
|
||||||
|
---
|
||||||
|
|
||||||
|
[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google)
|
||||||
|
|
||||||
|
**Required environment variables:**
|
||||||
|
- `AUTH_EE_GOOGLE_CLIENT_ID`
|
||||||
|
- `AUTH_EE_GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
|
### Okta
|
||||||
|
---
|
||||||
|
|
||||||
|
[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta)
|
||||||
|
|
||||||
|
**Required environment variables:**
|
||||||
|
- `AUTH_EE_OKTA_CLIENT_ID`
|
||||||
|
- `AUTH_EE_OKTA_CLIENT_SECRET`
|
||||||
|
- `AUTH_EE_OKTA_ISSUER`
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
---
|
||||||
|
|
||||||
|
[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak)
|
||||||
|
|
||||||
|
**Required environment variables:**
|
||||||
|
- `AUTH_EE_KEYCLOAK_CLIENT_ID`
|
||||||
|
- `AUTH_EE_KEYCLOAK_CLIENT_SECRET`
|
||||||
|
- `AUTH_EE_KEYCLOAK_ISSUER`
|
||||||
|
|
||||||
|
### Microsoft Entra ID
|
||||||
|
|
||||||
|
[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id)
|
||||||
|
|
||||||
|
**Required environment variables:**
|
||||||
|
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID`
|
||||||
|
- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET`
|
||||||
|
- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers).
|
||||||
|
- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
|
||||||
|
|
@ -5,10 +5,6 @@ sidebarTitle: Declarative config
|
||||||
|
|
||||||
import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx'
|
import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx'
|
||||||
|
|
||||||
<Warning>
|
|
||||||
Declaratively defining `connections` is not available when [multi-tenancy](/self-hosting/more/tenancy) is enabled.
|
|
||||||
</Warning>
|
|
||||||
|
|
||||||
Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview).
|
Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview).
|
||||||
|
|
||||||
|
|
||||||
64
docs/self-hosting/configuration/environment-variables.mdx
Normal file
64
docs/self-hosting/configuration/environment-variables.mdx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
title: Environment Variables
|
||||||
|
sidebarTitle: Environment Variables
|
||||||
|
---
|
||||||
|
|
||||||
|
<Note>This page provides a detailed reference of all environment variables supported by Sourcebot. If you're just looking to get up and running, we recommend starting with the [getting started](/self-hosting/overview) guide instead.</Note>
|
||||||
|
|
||||||
|
### Core Environment Variables
|
||||||
|
The following environment variables allow you to configure your Sourcebot deployment.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| :------- | :------ | :---------- |
|
||||||
|
| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` | <p>Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/self-hosting/configuration/authentication) for more info</p> |
|
||||||
|
| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` | <p>Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/self-hosting/configuration/authentication) for more info </p> |
|
||||||
|
| `AUTH_SECRET` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 33` | <p>Used to validate login session cookies</p> |
|
||||||
|
| `AUTH_URL` | - | <p>URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`.</p> |
|
||||||
|
| `CONFIG_PATH` | `-` | <p>The container relative path to the declerative configuration file. See [this doc](/self-hosting/configuration/declarative-config) for more info.</p> |
|
||||||
|
| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` | <p>The root data directory in which all data written to disk by Sourcebot will be located.</p> |
|
||||||
|
| `DATA_DIR` | `/data` | <p>The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)</p> |
|
||||||
|
| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` | <p>The data directory for the default Postgres database.</p> |
|
||||||
|
| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` | <p>Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.</p><p>If you'd like to use a non-default schema, you can provide it as a parameter in the database url </p> |
|
||||||
|
| `EMAIL_FROM_ADDRESS` | `-` | <p>The email address that transactional emails will be sent from. See [this doc](/self-hosting/configuration/transactional-emails) for more info.</p> |
|
||||||
|
| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` | <p>The data directory for the default Redis instance.</p> |
|
||||||
|
| `REDIS_URL` | `redis://localhost:6379` | <p>Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.</p> |
|
||||||
|
| `SHARD_MAX_MATCH_COUNT` | `10000` | <p>The maximum shard count per query</p> |
|
||||||
|
| `SMTP_CONNECTION_URL` | `-` | <p>The url to the SMTP service used for sending transactional emails. See [this doc](/self-hosting/configuration/transactional-emails) for more info.</p> |
|
||||||
|
| `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` | <p>Used to encrypt connection secrets and generate API keys.</p> |
|
||||||
|
| `SOURCEBOT_LOG_LEVEL` | `info` | <p>The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.</p> |
|
||||||
|
| `SOURCEBOT_TELEMETRY_DISABLED` | `false` | <p>Enables/disables telemetry collection in Sourcebot. See [this doc](/self-hosting/security/telemetry) for more info.</p> |
|
||||||
|
| `TOTAL_MAX_MATCH_COUNT` | `100000` | <p>The maximum number of matches per query</p> |
|
||||||
|
| `ZOEKT_MAX_WALL_TIME_MS` | `10000` | <p>The maximum real world duration (in milliseconds) per zoekt query</p> |
|
||||||
|
|
||||||
|
### Enterprise Environment Variables
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| :------- | :------ | :---------- |
|
||||||
|
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
|
||||||
|
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` | <p>The client secret for GitHub Enterprise SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GITLAB_BASE_URL` | `https://gitlab.com` | <p>The base URL for GitLab Enterprise SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GITLAB_CLIENT_ID` | `-` | <p>The client ID for GitLab Enterprise SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GITLAB_CLIENT_SECRET` | `-` | <p>The client secret for GitLab Enterprise SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GOOGLE_CLIENT_ID` | `-` | <p>The client ID for Google SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_GOOGLE_CLIENT_SECRET` | `-` | <p>The client secret for Google SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_KEYCLOAK_CLIENT_ID` | `-` | <p>The client ID for Keycloak SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_KEYCLOAK_CLIENT_SECRET` | `-` | <p>The client secret for Keycloak SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_KEYCLOAK_ISSUER` | `-` | <p>The issuer URL for Keycloak SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_OKTA_CLIENT_ID` | `-` | <p>The client ID for Okta SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_OKTA_CLIENT_SECRET` | `-` | <p>The client secret for Okta SSO authentication.</p> |
|
||||||
|
| `AUTH_EE_OKTA_ISSUER` | `-` | <p>The issuer URL for Okta SSO authentication.</p> |
|
||||||
|
|
||||||
|
|
||||||
|
### Review Agent Environment Variables
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| :------- | :------ | :---------- |
|
||||||
|
| `GITHUB_APP_ID` | `-` | <p>The GitHub App ID used for review agent authentication.</p> |
|
||||||
|
| `GITHUB_APP_PRIVATE_KEY_PATH` | `-` | <p>The container relative path to the private key file for the GitHub App used by the review agent.</p> |
|
||||||
|
| `GITHUB_APP_WEBHOOK_SECRET` | `-` | <p>The webhook secret for the GitHub App used by the review agent.</p> |
|
||||||
|
| `OPENAI_API_KEY` | `-` | <p>The OpenAI API key used by the review agent.</p> |
|
||||||
|
| `REVIEW_AGENT_API_KEY` | `-` | <p>The Sourcebot API key used by the review agent.</p> |
|
||||||
|
| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` | <p>Enables/disables automatic code reviews by the review agent.</p> |
|
||||||
|
| `REVIEW_AGENT_LOGGING_ENABLED` | `true` | <p>Enables/disables logging for the review agent. Logs are saved in `DATA_CACHE_DIR/review-agent`</p> |
|
||||||
|
| `REVIEW_AGENT_REVIEW_COMMAND` | `review` | <p>The command used to trigger a code review by the review agent.</p> |
|
||||||
|
|
||||||
|
|
@ -4,7 +4,7 @@ sidebarTitle: Multi tenancy
|
||||||
---
|
---
|
||||||
|
|
||||||
<Warning>If you're switching from single-tenant mode, delete the Sourcebot cache (the `.sourcebot` folder) before starting.</Warning>
|
<Warning>If you're switching from single-tenant mode, delete the Sourcebot cache (the `.sourcebot` folder) before starting.</Warning>
|
||||||
<Warning>[Authentication](/self-hosting/more/authentication) must be enabled to enable multi tenancy mode</Warning>
|
<Warning>[Authentication](/self-hosting/configuration/authentication) must be enabled to enable multi tenancy mode</Warning>
|
||||||
Multi tenancy allows your Sourcebot deployment to have **multiple organizations**, each with their own set of members and repos. To enable multi tenancy mode, define an environment variable
|
Multi tenancy allows your Sourcebot deployment to have **multiple organizations**, each with their own set of members and repos. To enable multi tenancy mode, define an environment variable
|
||||||
named `SOURCEBOT_TENANCY_MODE` and set its value to `multi`. When multi tenancy mode is enabled:
|
named `SOURCEBOT_TENANCY_MODE` and set its value to `multi`. When multi tenancy mode is enabled:
|
||||||
|
|
||||||
|
|
@ -6,9 +6,10 @@ sidebarTitle: Transactional email
|
||||||
To enable transactional emails in your deployment, set the following environment variables. We recommend using [Resend](https://resend.com/), but you can use any provider. Setting this enables you to:
|
To enable transactional emails in your deployment, set the following environment variables. We recommend using [Resend](https://resend.com/), but you can use any provider. Setting this enables you to:
|
||||||
|
|
||||||
- Send emails when new members are invited
|
- Send emails when new members are invited
|
||||||
|
- Send emails when organization join requests are created/accepted
|
||||||
- Log into the Sourcebot deployment using [email codes](self-hosting/more/authentication#email-codes)
|
- Log into the Sourcebot deployment using [email codes](self-hosting/more/authentication#email-codes)
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| :------- | :---------- |
|
| :------- | :---------- |
|
||||||
| `SMTP_CONNECTION_URL` | SMTP server connection. |
|
| `SMTP_CONNECTION_URL` | SMTP server connection (`smtp://[user[:password]@]host[:port]`)|
|
||||||
| `EMAIL_FROM_ADDRESS` | The sender's email address |
|
| `EMAIL_FROM_ADDRESS` | The sender's email address |
|
||||||
|
|
@ -19,4 +19,4 @@ docker run \
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
If you have any questions regarding licensing, please [contact us](mailto:team@sourcebot.dev).
|
If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact).
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
---
|
|
||||||
title: Authentication
|
|
||||||
sidebarTitle: Authentication
|
|
||||||
---
|
|
||||||
|
|
||||||
<Note>SSO is currently not supported. If you'd like SSO, please reach out using our [contact form](https://www.sourcebot.dev/contact)</Note>
|
|
||||||
<Warning>If you're switching from non-auth, delete the Sourcebot cache (the `.sourcebot` folder) before starting.</Warning>
|
|
||||||
|
|
||||||
Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported. To enable authentication, set the `SOURCEBOT_AUTH_ENABLED` environment variable to `true`.
|
|
||||||
When authentication is enabled:
|
|
||||||
|
|
||||||
- [Connection managment](/docs/connections/overview) happens through the UI
|
|
||||||
- Members must be invited to an organization to gain access
|
|
||||||
- If you're in single-tenant mode, the first user to register will be made the owner of the default organization. Check out the [roles page](/docs/more/roles-and-permissions) for more info on the different roles and permissions
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
# Authentication Providers
|
|
||||||
|
|
||||||
<Warning>Make sure the `AUTH_URL` environment variable is [configured correctly](/self-hosting/configuration) when using Sourcebot in a deployed environment.</Warning>
|
|
||||||
|
|
||||||
To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider.
|
|
||||||
|
|
||||||
|
|
||||||
## Email / Password
|
|
||||||
---
|
|
||||||
Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`.
|
|
||||||
|
|
||||||
## Email codes
|
|
||||||
---
|
|
||||||
Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables:
|
|
||||||
|
|
||||||
- `SMTP_CONNECTION_URL`
|
|
||||||
- `EMAIL_FROM_ADDRESS`
|
|
||||||
|
|
||||||
|
|
||||||
See [transactional emails](/self-hosting/more/transactional-emails) for more details.
|
|
||||||
|
|
||||||
## GitHub
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github)
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_GITHUB_CLIENT_ID`
|
|
||||||
- `AUTH_GITHUB_CLIENT_SECRET`
|
|
||||||
|
|
||||||
## Google
|
|
||||||
---
|
|
||||||
|
|
||||||
[Auth.js Google Provider Docs](https://next-auth.js.org/providers/google)
|
|
||||||
|
|
||||||
**Required environment variables:**
|
|
||||||
- `AUTH_GOOGLE_CLIENT_ID`
|
|
||||||
- `AUTH_GOOGLE_CLIENT_SECRET`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Troubleshooting
|
|
||||||
|
|
||||||
- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers).
|
|
||||||
- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
|
|
||||||
|
|
@ -47,6 +47,7 @@ Sourcebot is open source and can be self-hosted using our official [Docker image
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step title="Launch your instance">
|
<Step title="Launch your instance">
|
||||||
|
<Warning>If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/self-hosting/configuration/environment-variables) environment variable</Warning>
|
||||||
Sourcebot is packaged as a [single Docker image](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). In the same directory as `config.json`, run the following command to start your instance:
|
Sourcebot is packaged as a [single Docker image](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). In the same directory as `config.json`, run the following command to start your instance:
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
|
|
@ -71,7 +72,16 @@ Sourcebot is open source and can be self-hosted using our official [Docker image
|
||||||
- reads `config.json` and starts syncing.
|
- reads `config.json` and starts syncing.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Warning>Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev).</Warning>
|
<Note>Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev).</Note>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step title="Create the owner account">
|
||||||
|
Sourcebot has built-in authentication which gates your instance. The first account which is registered on a fresh Sourcebot deployment is made owner.
|
||||||
|
|
||||||
|
Registration is performed using basic credentials which are stored encrypted within your deployment. To setup more authentication providers
|
||||||
|
check out the [auth docs](/self-hosting/configuration/authentication)
|
||||||
|
|
||||||
|
<img width="600" height="500" style={{ borderRadius: '0.5rem' }} src="/images/login_basic.png" />
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step title="Link your code">
|
<Step title="Link your code">
|
||||||
|
|
|
||||||
61
docs/self-hosting/upgrade/v3-to-v4-guide.mdx
Normal file
61
docs/self-hosting/upgrade/v3-to-v4-guide.mdx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
title: V3 to V4 Guide
|
||||||
|
sidebarTitle: V3 to V4 guide
|
||||||
|
---
|
||||||
|
|
||||||
|
This guide will walk you through upgrading your Sourcebot deployment from v3 to v4.
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
Please note that the following features are no longer supported in v4:
|
||||||
|
- Multi-tenancy mode
|
||||||
|
- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/self-hosting/license-key)
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
### If your deployment doesn't have authentication enabled
|
||||||
|
<Steps>
|
||||||
|
<Step title="Spin down deployment">
|
||||||
|
</Step>
|
||||||
|
<Step title="Set AUTH_URL environment variable if needed">
|
||||||
|
If your Sourcebot instance is deployed behind a domain (ex. `https://sourcebot.yourcompany.com`) you **must** set the `AUTH_URL` environment variable to your deployment domain.
|
||||||
|
</Step>
|
||||||
|
<Step title="Spin up v4 deployment and create owner account">
|
||||||
|
When you visit your new deployment you'll be presented with a sign-in page. Sourcebot now requires authentication, and all users must register and sign-in to the deployment.
|
||||||
|
|
||||||
|
The first account that's registered will be made the owner. By default, you can register using basic credentials which will be stored encrypted within the postgres DB connected to Sourcebot. Check out
|
||||||
|
the [auth docs](/self-hosting/configuration/authentication) to setup additional auth providers.
|
||||||
|
|
||||||
|
<img width="600" height="500" style={{ borderRadius: '0.5rem' }} src="/images/login_basic.png" />
|
||||||
|
</Step>
|
||||||
|
<Step title="(Optional) Configure transactional emails">
|
||||||
|
Emails can be sent on organization join request/approval by configuring [transactional emails](/self-hosting/configuration/transactional-emails)
|
||||||
|
|
||||||
|
<img width="500" height="600" style={{ borderRadius: '0.5rem' }} src="/images/join_request_email.png" />
|
||||||
|
</Step>
|
||||||
|
<Step title="Approve additional users onto your deployment">
|
||||||
|
After the first account is created, all new account registrations must be approved by the owner. When new users register onto the deployment they'll be presented with the following request approval page:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The owner can view and approve join requests by navigating to **Settings -> Members**
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
<Step title="You're done!">
|
||||||
|
Congrats, you've successfully migrated to v4! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support)
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
### If your deployment has authentication enabled
|
||||||
|
|
||||||
|
The only change that's required if your deployment has authentication enabled is to unset the `SOURCEBOT_AUTH_ENABLED` environment variable. New user registrations will now submit a request to join the organization which can be approved by the owner by
|
||||||
|
navigating to **Settings -> Members**. Emails can be sent on organization join request/approval by configuring [transactional emails](/self-hosting/configuration/transactional-emails)
|
||||||
|
|
||||||
|
### If your deployment uses multi-tenancy mode
|
||||||
|
|
||||||
|
Unfortunately, multi-tenancy mode is no longer officially supported in v4. To upgrade to v4, you'll need to unset the `SOURCEBOT_TENANCY_MODE` environment variable and wipe your Sourcebot cache. You can then follow the [instructions above](/self-hosting/upgrade/v3-to-v4-guide#if-your-deployment-doesnt-have-authentication-enabled)
|
||||||
|
to finish upgrading to v4 in single-tenant mode.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- If you're hitting issues with signing into your Sourcebot instance, make sure you're setting `AUTH_URL` correctly to your deployment domain (ex. `https://sourcebot.yourcompany.com`)
|
||||||
|
|
||||||
|
|
||||||
|
Having troubles migrating from v3 to v4? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) and we'll try our best to help
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Environment Variable">
|
<Tab title="Environment Variable">
|
||||||
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
|
<Note>Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI.</Note>
|
||||||
|
|
||||||
1. Add the `token` and `user` (username associated with the app password you created) properties to your connection config:
|
1. Add the `token` and `user` (username associated with the app password you created) properties to your connection config:
|
||||||
```json
|
```json
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
<Tab title="Secret">
|
<Tab title="Secret">
|
||||||
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
|
<Note>Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled.</Note>
|
||||||
|
|
||||||
1. Navigate to **Secrets** in settings and create a new secret with your access token:
|
1. Navigate to **Secrets** in settings and create a new secret with your access token:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Environment Variable">
|
<Tab title="Environment Variable">
|
||||||
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
|
<Note>Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI.</Note>
|
||||||
|
|
||||||
1. Add the `token` property to your connection config:
|
1. Add the `token` property to your connection config:
|
||||||
```json
|
```json
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
<Tab title="Secret">
|
<Tab title="Secret">
|
||||||
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
|
<Note>Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled.</Note>
|
||||||
|
|
||||||
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
1. Navigate to **Secrets** in settings and create a new secret with your PAT:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"enablePublicAccess": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
@ -172,6 +177,11 @@
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"enablePublicAccess": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,5 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||||
maxRepoGarbageCollectionJobConcurrency: 8,
|
maxRepoGarbageCollectionJobConcurrency: 8,
|
||||||
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
||||||
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
|
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
|
||||||
|
enablePublicAccess: false,
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,28 @@ export function encrypt(text: string): { iv: string; encryptedData: string } {
|
||||||
return { iv: iv.toString('hex'), encryptedData: encrypted };
|
return { iv: iv.toString('hex'), encryptedData: encrypted };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashSecret(text: string): string {
|
||||||
|
if (!SOURCEBOT_ENCRYPTION_KEY) {
|
||||||
|
throw new Error('Encryption key is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.createHmac('sha256', SOURCEBOT_ENCRYPTION_KEY).update(text).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateApiKey(): { key: string; hash: string } {
|
||||||
|
if (!SOURCEBOT_ENCRYPTION_KEY) {
|
||||||
|
throw new Error('Encryption key is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hash = hashSecret(secret);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `sourcebot-${secret}`,
|
||||||
|
hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function decrypt(iv: string, encryptedText: string): string {
|
export function decrypt(iv: string, encryptedText: string): string {
|
||||||
if (!SOURCEBOT_ENCRYPTION_KEY) {
|
if (!SOURCEBOT_ENCRYPTION_KEY) {
|
||||||
throw new Error('Encryption key is not set');
|
throw new Error('Encryption key is not set');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Org" ADD COLUMN "metadata" JSONB;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "pendingApproval" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AccountRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"requestedById" TEXT NOT NULL,
|
||||||
|
"orgId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AccountRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountRequest_requestedById_key" ON "AccountRequest"("requestedById");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AccountRequest_requestedById_orgId_key" ON "AccountRequest"("requestedById", "orgId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountRequest" ADD CONSTRAINT "AccountRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AccountRequest" ADD CONSTRAINT "AccountRequest_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "OrgRole" ADD VALUE 'GUEST';
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiKey" (
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"orgId" INTEGER NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("hash")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiKey_hash_key" ON "ApiKey"("hash");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -136,6 +136,20 @@ model Invite {
|
||||||
@@unique([recipientEmail, orgId])
|
@@unique([recipientEmail, orgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AccountRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
|
||||||
|
requestedById String @unique
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
orgId Int
|
||||||
|
|
||||||
|
@@unique([requestedById, orgId])
|
||||||
|
}
|
||||||
|
|
||||||
model Org {
|
model Org {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
|
|
@ -146,8 +160,10 @@ model Org {
|
||||||
connections Connection[]
|
connections Connection[]
|
||||||
repos Repo[]
|
repos Repo[]
|
||||||
secrets Secret[]
|
secrets Secret[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
isOnboarded Boolean @default(false)
|
isOnboarded Boolean @default(false)
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
|
||||||
|
|
||||||
stripeCustomerId String?
|
stripeCustomerId String?
|
||||||
stripeSubscriptionStatus StripeSubscriptionStatus?
|
stripeSubscriptionStatus StripeSubscriptionStatus?
|
||||||
|
|
@ -156,12 +172,15 @@ model Org {
|
||||||
/// List of pending invites to this organization
|
/// List of pending invites to this organization
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
||||||
|
accountRequests AccountRequest[]
|
||||||
|
|
||||||
searchContexts SearchContext[]
|
searchContexts SearchContext[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrgRole {
|
enum OrgRole {
|
||||||
OWNER
|
OWNER
|
||||||
MEMBER
|
MEMBER
|
||||||
|
GUEST
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserToOrg {
|
model UserToOrg {
|
||||||
|
|
@ -193,20 +212,39 @@ model Secret {
|
||||||
@@id([orgId, key])
|
@@id([orgId, key])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
name String
|
||||||
|
hash String @id @unique
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
orgId Int
|
||||||
|
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
createdById String
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// @see : https://authjs.dev/concepts/database-models#user
|
// @see : https://authjs.dev/concepts/database-models#user
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
email String? @unique
|
email String? @unique
|
||||||
hashedPassword String?
|
hashedPassword String?
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
image String?
|
image String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
orgs UserToOrg[]
|
orgs UserToOrg[]
|
||||||
|
pendingApproval Boolean @default(true)
|
||||||
|
accountRequest AccountRequest?
|
||||||
|
|
||||||
/// List of pending invites that the user has created
|
/// List of pending invites that the user has created
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Added API key support. [#311](https://github.com/sourcebot-dev/sourcebot/pull/311)
|
||||||
|
|
||||||
# [1.0.1] - 2025-05-15
|
# [1.0.1] - 2025-05-15
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ export const search = async (request: SearchRequest): Promise<SearchResponse | S
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Org-Domain': '~'
|
'X-Org-Domain': '~',
|
||||||
|
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request)
|
body: JSON.stringify(request)
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
@ -26,7 +27,8 @@ export const listRepos = async (): Promise<ListRepositoriesResponse | ServiceErr
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Org-Domain': '~'
|
'X-Org-Domain': '~',
|
||||||
|
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
|
||||||
},
|
},
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
||||||
|
|
@ -42,7 +44,8 @@ export const getFileSource = async (request: FileSourceRequest): Promise<FileSou
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Org-Domain': '~'
|
'X-Org-Domain': '~',
|
||||||
|
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request)
|
body: JSON.stringify(request)
|
||||||
}).then(response => response.json());
|
}).then(response => response.json());
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
SOURCEBOT_HOST: z.string().url().default(SOURCEBOT_DEMO_HOST),
|
SOURCEBOT_HOST: z.string().url().default(SOURCEBOT_DEMO_HOST),
|
||||||
|
|
||||||
|
SOURCEBOT_API_KEY: z.string().optional(),
|
||||||
|
|
||||||
// The minimum number of tokens to return
|
// The minimum number of tokens to return
|
||||||
DEFAULT_MINIMUM_TOKENS: numberSchema.default(10000),
|
DEFAULT_MINIMUM_TOKENS: numberSchema.default(10000),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ server.tool(
|
||||||
"search_code",
|
"search_code",
|
||||||
`Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search.
|
`Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search.
|
||||||
Results are returned as an array of matching files, with the file's URL, repository, and language.
|
Results are returned as an array of matching files, with the file's URL, repository, and language.
|
||||||
|
If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.
|
||||||
If the \`includeCodeSnippets\` property is true, code snippets containing the matches will be included in the response. Only set this to true if the request requires code snippets (e.g., show me examples where library X is used).
|
If the \`includeCodeSnippets\` property is true, code snippets containing the matches will be included in the response. Only set this to true if the request requires code snippets (e.g., show me examples where library X is used).
|
||||||
When referencing a file in your response, **ALWAYS** include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out.
|
When referencing a file in your response, **ALWAYS** include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out.
|
||||||
**ONLY USE** the \`filterByRepoIds\` property if the request requires searching a specific repo(s). Otherwise, leave it empty.`,
|
**ONLY USE** the \`filterByRepoIds\` property if the request requires searching a specific repo(s). Otherwise, leave it empty.`,
|
||||||
|
|
@ -151,7 +152,7 @@ server.tool(
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"list_repos",
|
"list_repos",
|
||||||
"Lists all repositories in the organization.",
|
"Lists all repositories in the organization. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.",
|
||||||
async () => {
|
async () => {
|
||||||
const response = await listRepos();
|
const response = await listRepos();
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
|
|
@ -178,7 +179,7 @@ server.tool(
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"get_file_source",
|
"get_file_source",
|
||||||
"Fetches the source code for a given file.",
|
"Fetches the source code for a given file. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.",
|
||||||
{
|
{
|
||||||
fileName: z.string().describe("The file to fetch the source code for."),
|
fileName: z.string().describe("The file to fetch the source code for."),
|
||||||
repoId: z.string().describe("The repository to fetch the source code for. This is the Sourcebot compatible repository ID."),
|
repoId: z.string().describe("The repository to fetch the source code for. This is the Sourcebot compatible repository ID."),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,11 @@ const schema = {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"enablePublicAccess": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
@ -171,6 +176,11 @@ const schema = {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
"description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"enablePublicAccess": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.",
|
||||||
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ export interface Settings {
|
||||||
* The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.
|
* The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.
|
||||||
*/
|
*/
|
||||||
repoIndexTimeoutMs?: number;
|
repoIndexTimeoutMs?: number;
|
||||||
|
/**
|
||||||
|
* [Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.
|
||||||
|
*/
|
||||||
|
enablePublicAccess?: boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Search context
|
* Search context
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"next/core-web-vitals"
|
"next/core-web-vitals",
|
||||||
|
"plugin:@tanstack/query/recommended"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-hooks/exhaustive-deps": "warn",
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/language-data": "^6.5.1",
|
||||||
"@codemirror/legacy-modes": "^6.4.2",
|
"@codemirror/legacy-modes": "^6.4.2",
|
||||||
"@codemirror/search": "^6.5.6",
|
"@codemirror/search": "^6.5.6",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
|
|
@ -44,6 +45,7 @@
|
||||||
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
|
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.5",
|
"@radix-ui/react-alert-dialog": "^1.1.5",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-hover-card": "^1.1.6",
|
"@radix-ui/react-hover-card": "^1.1.6",
|
||||||
|
|
@ -55,6 +57,7 @@
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-switch": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@radix-ui/react-toggle": "^1.1.0",
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
|
|
@ -79,6 +82,7 @@
|
||||||
"@tanstack/react-query": "^5.53.3",
|
"@tanstack/react-query": "^5.53.3",
|
||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"@uiw/codemirror-themes": "^4.23.6",
|
"@uiw/codemirror-themes": "^4.23.6",
|
||||||
"@uiw/react-codemirror": "^4.23.0",
|
"@uiw/react-codemirror": "^4.23.0",
|
||||||
"@viz-js/lang-dot": "^1.0.4",
|
"@viz-js/lang-dot": "^1.0.4",
|
||||||
|
|
@ -142,6 +146,7 @@
|
||||||
"zod-to-json-schema": "^3.24.5"
|
"zod-to-json-schema": "^3.24.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.74.7",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
12
packages/web/src/app/[domain]/browse/README.md
Normal file
12
packages/web/src/app/[domain]/browse/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# File browser
|
||||||
|
|
||||||
|
This directory contains Sourcebot's file browser implementation. URL paths are used to determine what file the user wants to view. The following template is used:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/browse/<repo-name>[@<optional-revision-name>]/-/(blob|tree)/<path_to_file>
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, to view `packages/backend/src/env.ts` in Sourcebot, we would use the following path:
|
||||||
|
```sh
|
||||||
|
/browse/github.com/sourcebot-dev/sourcebot@HEAD/-/blob/packages/backend/src/env.ts
|
||||||
|
```
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
|
||||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
|
||||||
import { search } from "@codemirror/search";
|
|
||||||
import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { EditorContextMenu } from "../../components/editorContextMenu";
|
|
||||||
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
|
||||||
|
|
||||||
interface CodePreviewProps {
|
|
||||||
path: string;
|
|
||||||
repoName: string;
|
|
||||||
revisionName: string;
|
|
||||||
source: string;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodePreview = ({
|
|
||||||
source,
|
|
||||||
language,
|
|
||||||
path,
|
|
||||||
repoName,
|
|
||||||
revisionName,
|
|
||||||
}: CodePreviewProps) => {
|
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
|
||||||
const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view);
|
|
||||||
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
|
|
||||||
const keymapExtension = useKeymapExtension(editorRef.current?.view);
|
|
||||||
const [isEditorCreated, setIsEditorCreated] = useState(false);
|
|
||||||
|
|
||||||
const highlightRangeQuery = useNonEmptyQueryParam('highlightRange');
|
|
||||||
const highlightRange = useMemo(() => {
|
|
||||||
if (!highlightRangeQuery) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rangeRegex = /^\d+:\d+,\d+:\d+$/;
|
|
||||||
if (!rangeRegex.test(highlightRangeQuery)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [start, end] = highlightRangeQuery.split(',').map((range) => {
|
|
||||||
return range.split(':').map((val) => parseInt(val, 10));
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
start: {
|
|
||||||
line: start[0],
|
|
||||||
character: start[1],
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
line: end[0],
|
|
||||||
character: end[1],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [highlightRangeQuery]);
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
const highlightDecoration = Decoration.mark({
|
|
||||||
class: "cm-searchMatch-selected",
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
syntaxHighlighting,
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
keymapExtension,
|
|
||||||
search({
|
|
||||||
top: true,
|
|
||||||
}),
|
|
||||||
EditorView.updateListener.of((update: ViewUpdate) => {
|
|
||||||
if (update.selectionSet) {
|
|
||||||
setCurrentSelection(update.state.selection.main);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
StateField.define<DecorationSet>({
|
|
||||||
create(state) {
|
|
||||||
if (!highlightRange) {
|
|
||||||
return Decoration.none;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { start, end } = highlightRange;
|
|
||||||
const from = state.doc.line(start.line).from + start.character - 1;
|
|
||||||
const to = state.doc.line(end.line).from + end.character - 1;
|
|
||||||
|
|
||||||
return Decoration.set([
|
|
||||||
highlightDecoration.range(from, to),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
update(deco, tr) {
|
|
||||||
return deco.map(tr.changes);
|
|
||||||
},
|
|
||||||
provide: (field) => EditorView.decorations.from(field),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, [keymapExtension, syntaxHighlighting, highlightRange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!highlightRange || !editorRef.current || !editorRef.current.state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = editorRef.current.state.doc;
|
|
||||||
const { start, end } = highlightRange;
|
|
||||||
const from = doc.line(start.line).from + start.character - 1;
|
|
||||||
const to = doc.line(end.line).from + end.character - 1;
|
|
||||||
const selection = EditorSelection.range(from, to);
|
|
||||||
|
|
||||||
editorRef.current.view?.dispatch({
|
|
||||||
effects: [
|
|
||||||
EditorView.scrollIntoView(selection, { y: "center" }),
|
|
||||||
]
|
|
||||||
});
|
|
||||||
// @note: we need to include `isEditorCreated` in the dependency array since
|
|
||||||
// a race-condition can happen if the `highlightRange` is resolved before the
|
|
||||||
// editor is created.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [highlightRange, isEditorCreated]);
|
|
||||||
|
|
||||||
const theme = useCodeMirrorTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full overflow-auto flex-1">
|
|
||||||
<CodeMirror
|
|
||||||
className="relative"
|
|
||||||
ref={editorRef}
|
|
||||||
onCreateEditor={() => {
|
|
||||||
setIsEditorCreated(true);
|
|
||||||
}}
|
|
||||||
value={source}
|
|
||||||
extensions={extensions}
|
|
||||||
readOnly={true}
|
|
||||||
theme={theme}
|
|
||||||
>
|
|
||||||
{editorRef.current && editorRef.current.view && currentSelection && (
|
|
||||||
<EditorContextMenu
|
|
||||||
view={editorRef.current.view}
|
|
||||||
selection={currentSelection}
|
|
||||||
repoName={repoName}
|
|
||||||
path={path}
|
|
||||||
revisionName={revisionName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CodeMirror>
|
|
||||||
</ScrollArea>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ResizablePanel } from "@/components/ui/resizable";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
|
||||||
|
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
|
||||||
|
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
|
||||||
|
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
|
||||||
|
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
|
||||||
|
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
||||||
|
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
||||||
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
|
import { search } from "@codemirror/search";
|
||||||
|
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { EditorContextMenu } from "../../../components/editorContextMenu";
|
||||||
|
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
|
||||||
|
import { useBrowseState } from "../../hooks/useBrowseState";
|
||||||
|
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
|
||||||
|
interface CodePreviewPanelProps {
|
||||||
|
path: string;
|
||||||
|
repoName: string;
|
||||||
|
revisionName: string;
|
||||||
|
source: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodePreviewPanel = ({
|
||||||
|
source,
|
||||||
|
language,
|
||||||
|
path,
|
||||||
|
repoName,
|
||||||
|
revisionName,
|
||||||
|
}: CodePreviewPanelProps) => {
|
||||||
|
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
|
||||||
|
const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view);
|
||||||
|
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
|
||||||
|
const keymapExtension = useKeymapExtension(editorRef?.view);
|
||||||
|
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
||||||
|
const { updateBrowseState } = useBrowseState();
|
||||||
|
const { navigateToPath } = useBrowseNavigation();
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
|
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
|
||||||
|
const highlightRange = useMemo((): BrowseHighlightRange | undefined => {
|
||||||
|
if (!highlightRangeQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight ranges can be formatted in two ways:
|
||||||
|
// 1. start_line,end_line (no column specified)
|
||||||
|
// 2. start_line:start_column,end_line:end_column (column specified)
|
||||||
|
const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/;
|
||||||
|
if (!rangeRegex.test(highlightRangeQuery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start, end] = highlightRangeQuery.split(',').map((range) => {
|
||||||
|
if (range.includes(':')) {
|
||||||
|
return range.split(':').map((val) => parseInt(val, 10));
|
||||||
|
}
|
||||||
|
// For line-only format, use column 1 for start and last column for end
|
||||||
|
const line = parseInt(range, 10);
|
||||||
|
return [line];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (start.length === 1 || end.length === 1) {
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
lineNumber: start[0],
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
lineNumber: end[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
lineNumber: start[0],
|
||||||
|
column: start[1],
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
lineNumber: end[0],
|
||||||
|
column: end[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [highlightRangeQuery]);
|
||||||
|
|
||||||
|
const extensions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
languageExtension,
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
keymapExtension,
|
||||||
|
search({
|
||||||
|
top: true,
|
||||||
|
}),
|
||||||
|
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||||
|
if (update.selectionSet) {
|
||||||
|
setCurrentSelection(update.state.selection.main);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
highlightRange ? rangeHighlightingExtension(highlightRange) : [],
|
||||||
|
hasCodeNavEntitlement ? symbolHoverTargetsExtension : [],
|
||||||
|
];
|
||||||
|
}, [
|
||||||
|
keymapExtension,
|
||||||
|
languageExtension,
|
||||||
|
highlightRange,
|
||||||
|
hasCodeNavEntitlement,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Scroll the highlighted range into view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightRange || !editorRef || !editorRef.state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = editorRef.state.doc;
|
||||||
|
const { start, end } = highlightRange;
|
||||||
|
const selection = EditorSelection.range(
|
||||||
|
doc.line(start.lineNumber).from,
|
||||||
|
doc.line(end.lineNumber).from,
|
||||||
|
);
|
||||||
|
|
||||||
|
editorRef.view?.dispatch({
|
||||||
|
effects: [
|
||||||
|
EditorView.scrollIntoView(selection, { y: "center" }),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}, [editorRef, highlightRange]);
|
||||||
|
|
||||||
|
const onFindReferences = useCallback((symbolName: string) => {
|
||||||
|
captureEvent('wa_browse_find_references_pressed', {});
|
||||||
|
|
||||||
|
updateBrowseState({
|
||||||
|
selectedSymbolInfo: {
|
||||||
|
repoName,
|
||||||
|
symbolName,
|
||||||
|
revisionName,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
isBottomPanelCollapsed: false,
|
||||||
|
activeExploreMenuTab: "references",
|
||||||
|
})
|
||||||
|
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);
|
||||||
|
|
||||||
|
|
||||||
|
// If we resolve multiple matches, instead of navigating to the first match, we should
|
||||||
|
// instead popup the bottom sheet with the list of matches.
|
||||||
|
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
|
||||||
|
captureEvent('wa_browse_goto_definition_pressed', {});
|
||||||
|
|
||||||
|
if (symbolDefinitions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (symbolDefinitions.length === 1) {
|
||||||
|
const symbolDefinition = symbolDefinitions[0];
|
||||||
|
const { fileName, repoName } = symbolDefinition;
|
||||||
|
|
||||||
|
navigateToPath({
|
||||||
|
repoName,
|
||||||
|
revisionName,
|
||||||
|
path: fileName,
|
||||||
|
pathType: 'blob',
|
||||||
|
highlightRange: symbolDefinition.range,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
updateBrowseState({
|
||||||
|
selectedSymbolInfo: {
|
||||||
|
symbolName,
|
||||||
|
repoName,
|
||||||
|
revisionName,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
activeExploreMenuTab: "definitions",
|
||||||
|
isBottomPanelCollapsed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
|
||||||
|
|
||||||
|
const theme = useCodeMirrorTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanel
|
||||||
|
order={1}
|
||||||
|
id={"code-preview-panel"}
|
||||||
|
>
|
||||||
|
<ScrollArea className="h-full overflow-auto flex-1">
|
||||||
|
<CodeMirror
|
||||||
|
className="relative"
|
||||||
|
ref={setEditorRef}
|
||||||
|
value={source}
|
||||||
|
extensions={extensions}
|
||||||
|
readOnly={true}
|
||||||
|
theme={theme}
|
||||||
|
>
|
||||||
|
{editorRef && editorRef.view && currentSelection && (
|
||||||
|
<EditorContextMenu
|
||||||
|
view={editorRef.view}
|
||||||
|
selection={currentSelection}
|
||||||
|
repoName={repoName}
|
||||||
|
path={path}
|
||||||
|
revisionName={revisionName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editorRef && hasCodeNavEntitlement && (
|
||||||
|
<SymbolHoverPopup
|
||||||
|
editorRef={editorRef}
|
||||||
|
revisionName={revisionName}
|
||||||
|
language={language}
|
||||||
|
onFindReferences={onFindReferences}
|
||||||
|
onGotoDefinition={onGotoDefinition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CodeMirror>
|
||||||
|
|
||||||
|
</ScrollArea>
|
||||||
|
</ResizablePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { StateField, Range } from "@codemirror/state";
|
||||||
|
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||||
|
import { BrowseHighlightRange } from "../../hooks/useBrowseNavigation";
|
||||||
|
|
||||||
|
const markDecoration = Decoration.mark({
|
||||||
|
class: "searchMatch-selected",
|
||||||
|
});
|
||||||
|
|
||||||
|
const lineDecoration = Decoration.line({
|
||||||
|
attributes: { class: "lineHighlight" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rangeHighlightingExtension = (range: BrowseHighlightRange) => StateField.define<DecorationSet>({
|
||||||
|
create(state) {
|
||||||
|
const { start, end } = range;
|
||||||
|
|
||||||
|
if ('column' in start && 'column' in end) {
|
||||||
|
const from = state.doc.line(start.lineNumber).from + start.column - 1;
|
||||||
|
const to = state.doc.line(end.lineNumber).from + end.column - 1;
|
||||||
|
|
||||||
|
return Decoration.set([
|
||||||
|
markDecoration.range(from, to),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
for (let line = start.lineNumber; line <= end.lineNumber; line++) {
|
||||||
|
decorations.push(lineDecoration.range(state.doc.line(line).from));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update(deco, tr) {
|
||||||
|
return deco.map(tr.changes);
|
||||||
|
},
|
||||||
|
provide: (field) => EditorView.decorations.from(field),
|
||||||
|
});
|
||||||
|
|
@ -2,15 +2,15 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
||||||
import { TopBar } from "@/app/[domain]/components/topBar";
|
import { TopBar } from "@/app/[domain]/components/topBar";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { getFileSource } from '@/features/search/fileSourceApi';
|
import { getFileSource } from '@/features/search/fileSourceApi';
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
|
||||||
import { base64Decode } from "@/lib/utils";
|
import { base64Decode } from "@/lib/utils";
|
||||||
import { CodePreview } from "./codePreview";
|
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { LuFileX2, LuBookX } from "react-icons/lu";
|
import { LuFileX2, LuBookX } from "react-icons/lu";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { getRepoInfoByName } from "@/actions";
|
import { getRepoInfoByName } from "@/actions";
|
||||||
|
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
interface BrowsePageProps {
|
interface BrowsePageProps {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -50,7 +50,18 @@ export default async function BrowsePage({
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const repoInfo = await getRepoInfoByName(repoName, params.domain);
|
const repoInfo = await getRepoInfoByName(repoName, params.domain);
|
||||||
if (isServiceError(repoInfo) && repoInfo.errorCode !== ErrorCode.NOT_FOUND) {
|
if (isServiceError(repoInfo)) {
|
||||||
|
if (repoInfo.errorCode === ErrorCode.NOT_FOUND) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="m-auto flex flex-col items-center gap-2">
|
||||||
|
<LuBookX className="h-12 w-12 text-secondary-foreground" />
|
||||||
|
<span className="font-medium text-secondary-foreground">Repository not found</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new ServiceErrorException(repoInfo);
|
throw new ServiceErrorException(repoInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,70 +74,11 @@ export default async function BrowsePage({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen">
|
|
||||||
<div className='sticky top-0 left-0 right-0 z-10'>
|
|
||||||
<TopBar
|
|
||||||
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
|
|
||||||
domain={params.domain}
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
{!isServiceError(repoInfo) && (
|
|
||||||
<>
|
|
||||||
<div className="bg-accent py-1 px-2 flex flex-row">
|
|
||||||
<FileHeader
|
|
||||||
fileName={path}
|
|
||||||
repo={{
|
|
||||||
name: repoInfo.name,
|
|
||||||
displayName: repoInfo.displayName,
|
|
||||||
webUrl: repoInfo.webUrl,
|
|
||||||
codeHostType: repoInfo.codeHostType,
|
|
||||||
}}
|
|
||||||
branchDisplayName={revisionName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isServiceError(repoInfo) ? (
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div className="m-auto flex flex-col items-center gap-2">
|
|
||||||
<LuBookX className="h-12 w-12 text-secondary-foreground" />
|
|
||||||
<span className="font-medium text-secondary-foreground">Repository not found</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CodePreviewWrapper
|
|
||||||
path={path}
|
|
||||||
repoName={repoInfo.name}
|
|
||||||
revisionName={revisionName ?? 'HEAD'}
|
|
||||||
domain={params.domain}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodePreviewWrapper {
|
|
||||||
path: string,
|
|
||||||
repoName: string,
|
|
||||||
revisionName: string,
|
|
||||||
domain: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodePreviewWrapper = async ({
|
|
||||||
path,
|
|
||||||
repoName,
|
|
||||||
revisionName,
|
|
||||||
domain,
|
|
||||||
}: CodePreviewWrapper) => {
|
|
||||||
// @todo: this will depend on `pathType`.
|
|
||||||
const fileSourceResponse = await getFileSource({
|
const fileSourceResponse = await getFileSource({
|
||||||
fileName: path,
|
fileName: path,
|
||||||
repository: repoName,
|
repository: repoName,
|
||||||
branch: revisionName,
|
branch: revisionName ?? 'HEAD',
|
||||||
}, domain);
|
}, params.domain);
|
||||||
|
|
||||||
if (isServiceError(fileSourceResponse)) {
|
if (isServiceError(fileSourceResponse)) {
|
||||||
if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) {
|
if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) {
|
||||||
|
|
@ -143,13 +95,57 @@ const CodePreviewWrapper = async ({
|
||||||
throw new ServiceErrorException(fileSourceResponse);
|
throw new ServiceErrorException(fileSourceResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeHostInfo = getCodeHostInfoForRepo({
|
||||||
|
codeHostType: repoInfo.codeHostType,
|
||||||
|
name: repoInfo.name,
|
||||||
|
displayName: repoInfo.displayName,
|
||||||
|
webUrl: repoInfo.webUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodePreview
|
<>
|
||||||
source={base64Decode(fileSourceResponse.source)}
|
<div className='sticky top-0 left-0 right-0 z-10'>
|
||||||
language={fileSourceResponse.language}
|
<TopBar
|
||||||
repoName={repoName}
|
defaultSearchQuery={`repo:${repoName}${revisionName ? ` rev:${revisionName}` : ''} `}
|
||||||
path={path}
|
domain={params.domain}
|
||||||
revisionName={revisionName}
|
/>
|
||||||
/>
|
<Separator />
|
||||||
|
<div className="bg-accent py-1 px-2 flex flex-row items-center">
|
||||||
|
<FileHeader
|
||||||
|
fileName={path}
|
||||||
|
repo={{
|
||||||
|
name: repoInfo.name,
|
||||||
|
displayName: repoInfo.displayName,
|
||||||
|
webUrl: repoInfo.webUrl,
|
||||||
|
codeHostType: repoInfo.codeHostType,
|
||||||
|
}}
|
||||||
|
branchDisplayName={revisionName}
|
||||||
|
/>
|
||||||
|
{(fileSourceResponse.webUrl && codeHostInfo) && (
|
||||||
|
<a
|
||||||
|
href={fileSourceResponse.webUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex flex-row items-center gap-2 px-2 py-0.5 rounded-md flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={codeHostInfo.icon}
|
||||||
|
alt={codeHostInfo.codeHostName}
|
||||||
|
className={cn('w-4 h-4 flex-shrink-0', codeHostInfo.iconClassName)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">Open in {codeHostInfo.codeHostName}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
<CodePreviewPanel
|
||||||
|
source={base64Decode(fileSourceResponse.source)}
|
||||||
|
language={fileSourceResponse.language}
|
||||||
|
repoName={repoInfo.name}
|
||||||
|
path={path}
|
||||||
|
revisionName={revisionName ?? 'HEAD'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
73
packages/web/src/app/[domain]/browse/browseStateProvider.tsx
Normal file
73
packages/web/src/app/[domain]/browse/browseStateProvider.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
|
import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel";
|
||||||
|
|
||||||
|
export interface BrowseState {
|
||||||
|
selectedSymbolInfo?: {
|
||||||
|
symbolName: string;
|
||||||
|
repoName: string;
|
||||||
|
revisionName: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
isBottomPanelCollapsed: boolean;
|
||||||
|
activeExploreMenuTab: "references" | "definitions";
|
||||||
|
bottomPanelSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: BrowseState = {
|
||||||
|
selectedSymbolInfo: undefined,
|
||||||
|
isBottomPanelCollapsed: true,
|
||||||
|
activeExploreMenuTab: "references",
|
||||||
|
bottomPanelSize: BOTTOM_PANEL_MIN_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState";
|
||||||
|
|
||||||
|
export const BrowseStateContext = createContext<{
|
||||||
|
state: BrowseState;
|
||||||
|
updateBrowseState: (state: Partial<BrowseState>) => void;
|
||||||
|
}>({
|
||||||
|
state: defaultState,
|
||||||
|
updateBrowseState: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [state, setState] = useState<BrowseState>(defaultState);
|
||||||
|
const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM);
|
||||||
|
|
||||||
|
const onUpdateState = useCallback((state: Partial<BrowseState>) => {
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
...state,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydratedBrowseState) {
|
||||||
|
try {
|
||||||
|
const parsedState = JSON.parse(hydratedBrowseState) as Partial<BrowseState>;
|
||||||
|
onUpdateState(parsedState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing hydratedBrowseState", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the query param
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete(SET_BROWSE_STATE_QUERY_PARAM);
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
}, [hydratedBrowseState, onUpdateState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowseStateContext.Provider
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
updateBrowseState: onUpdateState,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BrowseStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
packages/web/src/app/[domain]/browse/components/bottomPanel.tsx
Normal file
136
packages/web/src/app/[domain]/browse/components/bottomPanel.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ResizablePanel } from "@/components/ui/resizable";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
import { FaChevronDown } from "react-icons/fa";
|
||||||
|
import { VscReferences, VscSymbolMisc } from "react-icons/vsc";
|
||||||
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
import { useBrowseState } from "../hooks/useBrowseState";
|
||||||
|
import { ExploreMenu } from "@/ee/features/codeNav/components/exploreMenu";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export const BOTTOM_PANEL_MIN_SIZE = 35;
|
||||||
|
export const BOTTOM_PANEL_MAX_SIZE = 65;
|
||||||
|
const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/search/code-navigation";
|
||||||
|
|
||||||
|
export const BottomPanel = () => {
|
||||||
|
const panelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
||||||
|
const domain = useDomain();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize },
|
||||||
|
updateBrowseState,
|
||||||
|
} = useBrowseState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBottomPanelCollapsed) {
|
||||||
|
panelRef.current?.collapse();
|
||||||
|
} else {
|
||||||
|
panelRef.current?.expand();
|
||||||
|
}
|
||||||
|
}, [isBottomPanelCollapsed]);
|
||||||
|
|
||||||
|
useHotkeys("shift+mod+e", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed });
|
||||||
|
}, {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
description: "Open Explore Panel",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex flex-row justify-between">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
updateBrowseState({
|
||||||
|
isBottomPanelCollapsed: !isBottomPanelCollapsed,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VscReferences className="w-4 h-4" />
|
||||||
|
Explore
|
||||||
|
<KeyboardShortcutHint shortcut="⇧ ⌘ E" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isBottomPanelCollapsed && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
updateBrowseState({ isBottomPanelCollapsed: true })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaChevronDown className="w-4 h-4" />
|
||||||
|
Hide
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={BOTTOM_PANEL_MIN_SIZE}
|
||||||
|
maxSize={BOTTOM_PANEL_MAX_SIZE}
|
||||||
|
collapsible={true}
|
||||||
|
ref={panelRef}
|
||||||
|
defaultSize={isBottomPanelCollapsed ? 0 : bottomPanelSize}
|
||||||
|
onCollapse={() => updateBrowseState({ isBottomPanelCollapsed: true })}
|
||||||
|
onExpand={() => updateBrowseState({ isBottomPanelCollapsed: false })}
|
||||||
|
onResize={(size) => {
|
||||||
|
if (!isBottomPanelCollapsed) {
|
||||||
|
updateBrowseState({ bottomPanelSize: size });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
order={2}
|
||||||
|
id={"bottom-panel"}
|
||||||
|
>
|
||||||
|
{!hasCodeNavEntitlement ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<VscSymbolMisc className="w-6 h-6" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Code navigation is not enabled for <span className="text-blue-500 hover:underline cursor-pointer" onClick={() => router.push(`/${domain}/settings/license`)}>your plan</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={CODE_NAV_DOCS_URL}
|
||||||
|
target="_blank"
|
||||||
|
className="text-sm text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : !selectedSymbolInfo ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||||
|
<VscSymbolMisc className="w-6 h-6" />
|
||||||
|
<p className="text-sm">No symbol selected</p>
|
||||||
|
<Link
|
||||||
|
href={CODE_NAV_DOCS_URL}
|
||||||
|
target="_blank"
|
||||||
|
className="text-sm text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ExploreMenu
|
||||||
|
selectedSymbolInfo={selectedSymbolInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
|
||||||
|
|
||||||
|
export type BrowseHighlightRange = {
|
||||||
|
start: { lineNumber: number; column: number; };
|
||||||
|
end: { lineNumber: number; column: number; };
|
||||||
|
} | {
|
||||||
|
start: { lineNumber: number; };
|
||||||
|
end: { lineNumber: number; };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange';
|
||||||
|
|
||||||
|
interface NavigateToPathOptions {
|
||||||
|
repoName: string;
|
||||||
|
revisionName?: string;
|
||||||
|
path: string;
|
||||||
|
pathType: 'blob' | 'tree';
|
||||||
|
highlightRange?: BrowseHighlightRange;
|
||||||
|
setBrowseState?: Partial<BrowseState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBrowseNavigation = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
|
const navigateToPath = useCallback(({
|
||||||
|
repoName,
|
||||||
|
revisionName = 'HEAD',
|
||||||
|
path,
|
||||||
|
pathType,
|
||||||
|
highlightRange,
|
||||||
|
setBrowseState,
|
||||||
|
}: NavigateToPathOptions) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (highlightRange) {
|
||||||
|
const { start, end } = highlightRange;
|
||||||
|
|
||||||
|
if ('column' in start && 'column' in end) {
|
||||||
|
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`);
|
||||||
|
} else {
|
||||||
|
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setBrowseState) {
|
||||||
|
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${path}?${params.toString()}`);
|
||||||
|
}, [domain, router]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToPath,
|
||||||
|
};
|
||||||
|
};
|
||||||
12
packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts
Normal file
12
packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { BrowseStateContext } from "../browseStateProvider";
|
||||||
|
|
||||||
|
export const useBrowseState = () => {
|
||||||
|
const context = useContext(BrowseStateContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useBrowseState must be used within a BrowseStateProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
26
packages/web/src/app/[domain]/browse/layout.tsx
Normal file
26
packages/web/src/app/[domain]/browse/layout.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { BottomPanel } from "./components/bottomPanel";
|
||||||
|
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||||
|
import { BrowseStateProvider } from "./browseStateProvider";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({
|
||||||
|
children,
|
||||||
|
}: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<BrowseStateProvider>
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="vertical"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<AnimatedResizableHandle />
|
||||||
|
<BottomPanel />
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</BrowseStateProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ export const SecretCombobox = ({
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
const { data: secrets, isPending, isError, refetch } = useQuery({
|
const { data: secrets, isPending, isError, refetch } = useQuery({
|
||||||
queryKey: ["secrets"],
|
queryKey: ["secrets", domain],
|
||||||
queryFn: () => unwrapServiceError(getSecrets(domain)),
|
queryFn: () => unwrapServiceError(getSecrets(domain)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Link2Icon } from "@radix-ui/react-icons";
|
||||||
import { EditorView, SelectionRange } from "@uiw/react-codemirror";
|
import { EditorView, SelectionRange } from "@uiw/react-codemirror";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
|
|
@ -107,7 +108,7 @@ export const EditorContextMenu = ({
|
||||||
|
|
||||||
const basePath = `${window.location.origin}/${domain}/browse`;
|
const basePath = `${window.location.origin}/${domain}/browse`;
|
||||||
const url = createPathWithQueryParams(`${basePath}/${repoName}@${revisionName}/-/blob/${path}`,
|
const url = createPathWithQueryParams(`${basePath}/${repoName}@${revisionName}/-/blob/${path}`,
|
||||||
['highlightRange', `${from?.line}:${from?.column},${to?.line}:${to?.column}`],
|
[HIGHLIGHT_RANGE_QUERY_PARAM, `${from?.line}:${from?.column},${to?.line}:${to?.column}`],
|
||||||
);
|
);
|
||||||
|
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
||||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation";
|
||||||
|
|
||||||
interface FileHeaderProps {
|
interface FileHeaderProps {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|
@ -35,6 +37,8 @@ export const FileHeader = ({
|
||||||
webUrl: repo.webUrl,
|
webUrl: repo.webUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { navigateToPath } = useBrowseNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
|
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
|
||||||
{info?.icon ? (
|
{info?.icon ? (
|
||||||
|
|
@ -58,17 +62,11 @@ export const FileHeader = ({
|
||||||
<p
|
<p
|
||||||
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-[3px] flex items-center gap-0.5"
|
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-[3px] flex items-center gap-0.5"
|
||||||
title={branchDisplayTitle}
|
title={branchDisplayTitle}
|
||||||
|
style={{
|
||||||
|
marginBottom: "0.1rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* hack since to make the @ symbol look more centered with the text */}
|
<span className="mr-0.5">@</span>
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.60rem",
|
|
||||||
lineHeight: "1rem",
|
|
||||||
marginBottom: "0.1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
@
|
|
||||||
</span>
|
|
||||||
{`${branchDisplayName}`}
|
{`${branchDisplayName}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -76,7 +74,17 @@ export const FileHeader = ({
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex items-center overflow-hidden mt-0.5"
|
className="flex-1 flex items-center overflow-hidden mt-0.5"
|
||||||
>
|
>
|
||||||
<span className="inline-block w-full truncate-start font-mono text-sm">
|
<span
|
||||||
|
className="inline-block w-full truncate-start font-mono text-sm cursor-pointer hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
navigateToPath({
|
||||||
|
repoName: repo.name,
|
||||||
|
path: fileName,
|
||||||
|
pathType: 'blob',
|
||||||
|
revisionName: branchDisplayName,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
{!fileNameHighlightRange ?
|
{!fileNameHighlightRange ?
|
||||||
fileName
|
fileName
|
||||||
: (
|
: (
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface KeyboardShortcutHintProps {
|
|
||||||
shortcut: string
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center" aria-label={label || `Keyboard shortcut: ${shortcut}`}>
|
|
||||||
<kbd className="px-2 py-1 text-xs font-semibold border rounded-md">
|
|
||||||
{shortcut}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { Parser } from '@lezer/common'
|
||||||
|
import { LanguageDescription, StreamLanguage } from '@codemirror/language'
|
||||||
|
import { Highlighter, highlightTree } from '@lezer/highlight'
|
||||||
|
import { languages as builtinLanguages } from '@codemirror/language-data'
|
||||||
|
import { memo, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useCodeMirrorHighlighter } from '@/hooks/useCodeMirrorHighlighter'
|
||||||
|
import tailwind from '@/tailwind'
|
||||||
|
import { measure } from '@/lib/utils'
|
||||||
|
import { SourceRange } from '@/features/search/types'
|
||||||
|
|
||||||
|
// Define a plain text language
|
||||||
|
const plainTextLanguage = StreamLanguage.define({
|
||||||
|
token(stream) {
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LightweightCodeHighlighter {
|
||||||
|
language: string;
|
||||||
|
children: string;
|
||||||
|
/* 1-based highlight ranges */
|
||||||
|
highlightRanges?: SourceRange[];
|
||||||
|
lineNumbers?: boolean;
|
||||||
|
/* 1-based line number offset */
|
||||||
|
lineNumbersOffset?: number;
|
||||||
|
renderWhitespace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight code highlighter that uses the Lezer parser to highlight code.
|
||||||
|
* This is helpful in scenarios where we need to highlight a ton of code snippets
|
||||||
|
* (e.g., code nav, search results, etc)., but can't use the full-blown CodeMirror
|
||||||
|
* editor because of perf issues.
|
||||||
|
*
|
||||||
|
* Inspired by: https://github.com/craftzdog/react-codemirror-runmode
|
||||||
|
*/
|
||||||
|
export const LightweightCodeHighlighter = memo<LightweightCodeHighlighter>((props: LightweightCodeHighlighter) => {
|
||||||
|
const {
|
||||||
|
language,
|
||||||
|
children: code,
|
||||||
|
highlightRanges,
|
||||||
|
lineNumbers = false,
|
||||||
|
lineNumbersOffset = 1,
|
||||||
|
renderWhitespace = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const unhighlightedLines = useMemo(() => {
|
||||||
|
return code.trimEnd().split('\n');
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
|
||||||
|
const [highlightedLines, setHighlightedLines] = useState<React.ReactNode[] | null>(null);
|
||||||
|
|
||||||
|
const highlightStyle = useCodeMirrorHighlighter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
measure(() => Promise.all(
|
||||||
|
unhighlightedLines
|
||||||
|
.map(async (line, index) => {
|
||||||
|
const lineNumber = index + lineNumbersOffset;
|
||||||
|
|
||||||
|
// @todo: we will need to handle the case where a range spans multiple lines.
|
||||||
|
const ranges = highlightRanges?.filter(range => {
|
||||||
|
return range.start.lineNumber === lineNumber || range.end.lineNumber === lineNumber;
|
||||||
|
}).map(range => ({
|
||||||
|
from: range.start.column - 1,
|
||||||
|
to: range.end.column - 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const snippets = await highlightCode(
|
||||||
|
language,
|
||||||
|
line,
|
||||||
|
highlightStyle,
|
||||||
|
ranges,
|
||||||
|
(text: string, style: string | null, from: number) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={from}
|
||||||
|
className={`${style || ''}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return <span key={index}>{snippets}</span>
|
||||||
|
})
|
||||||
|
).then(highlightedLines => {
|
||||||
|
setHighlightedLines(highlightedLines);
|
||||||
|
}), 'highlightCode', /* outputLog = */ false);
|
||||||
|
}, [
|
||||||
|
language,
|
||||||
|
code,
|
||||||
|
highlightRanges,
|
||||||
|
highlightStyle,
|
||||||
|
unhighlightedLines,
|
||||||
|
lineNumbersOffset
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lineCount = (highlightedLines ?? unhighlightedLines).length + lineNumbersOffset;
|
||||||
|
const lineNumberDigits = String(lineCount).length;
|
||||||
|
const lineNumberWidth = `${lineNumberDigits + 2}ch`; // +2 for padding
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: tailwind.theme.fontFamily.editor,
|
||||||
|
fontSize: tailwind.theme.fontSize.editor,
|
||||||
|
whiteSpace: renderWhitespace ? 'pre-wrap' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(highlightedLines ?? unhighlightedLines).map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex"
|
||||||
|
>
|
||||||
|
{lineNumbers && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: lineNumberWidth,
|
||||||
|
minWidth: lineNumberWidth,
|
||||||
|
display: 'inline-block',
|
||||||
|
textAlign: 'left',
|
||||||
|
paddingLeft: '5px',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontFamily: tailwind.theme.fontFamily.editor,
|
||||||
|
color: tailwind.theme.colors.editor.gutterForeground,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + lineNumbersOffset}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="cm-line"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: '6px',
|
||||||
|
paddingRight: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
LightweightCodeHighlighter.displayName = 'LightweightCodeHighlighter';
|
||||||
|
|
||||||
|
async function getCodeParser(
|
||||||
|
languageName: string,
|
||||||
|
): Promise<Parser> {
|
||||||
|
if (languageName) {
|
||||||
|
const parser = await (async () => {
|
||||||
|
const found = LanguageDescription.matchLanguageName(
|
||||||
|
builtinLanguages,
|
||||||
|
languageName,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found.support) {
|
||||||
|
await found.load();
|
||||||
|
}
|
||||||
|
return found.support ? found.support.language.parser : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (parser) {
|
||||||
|
return parser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plainTextLanguage.parser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function highlightCode<Output>(
|
||||||
|
languageName: string,
|
||||||
|
input: string,
|
||||||
|
highlighter: Highlighter,
|
||||||
|
highlightRanges: { from: number, to: number }[] = [],
|
||||||
|
callback: (
|
||||||
|
text: string,
|
||||||
|
style: string | null,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
) => Output,
|
||||||
|
): Promise<Output[]> {
|
||||||
|
const parser = await getCodeParser(languageName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a range to a series of highlighted subranges.
|
||||||
|
*/
|
||||||
|
const convertRangeToHighlightedSubranges = (
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
classes: string | null,
|
||||||
|
cb: (from: number, to: number, classes: string | null) => void,
|
||||||
|
) => {
|
||||||
|
type HighlightRange = {
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
isHighlighted: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightClasses = classes ? `${classes} searchMatch-selected` : 'searchMatch-selected';
|
||||||
|
|
||||||
|
let currentRange: HighlightRange | null = null;
|
||||||
|
for (let i = from; i < to; i++) {
|
||||||
|
const isHighlighted = isIndexHighlighted(i, highlightRanges);
|
||||||
|
|
||||||
|
if (currentRange) {
|
||||||
|
if (currentRange.isHighlighted === isHighlighted) {
|
||||||
|
currentRange.to = i + 1;
|
||||||
|
} else {
|
||||||
|
cb(
|
||||||
|
currentRange.from,
|
||||||
|
currentRange.to,
|
||||||
|
currentRange.isHighlighted ? highlightClasses : classes,
|
||||||
|
)
|
||||||
|
|
||||||
|
currentRange = { from: i, to: i + 1, isHighlighted };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentRange = { from: i, to: i + 1, isHighlighted };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRange) {
|
||||||
|
cb(
|
||||||
|
currentRange.from,
|
||||||
|
currentRange.to,
|
||||||
|
currentRange.isHighlighted ? highlightClasses : classes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = parser.parse(input)
|
||||||
|
const output: Array<Output> = [];
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
highlightTree(tree, highlighter, (from, to, classes) => {
|
||||||
|
// `highlightTree` only calls this callback when at least one style/class
|
||||||
|
// is applied to the text (i.e., `classes` is not empty). This means that
|
||||||
|
// any unstyled regions will be skipped (e.g., whitespace, `=`. `;`. etc).
|
||||||
|
// This check ensures that we process these unstyled regions as well.
|
||||||
|
// @see: https://discuss.codemirror.net/t/static-highlighting-using-cm-v6/3420/2
|
||||||
|
if (from > pos) {
|
||||||
|
convertRangeToHighlightedSubranges(pos, from, null, (from, to, classes) => {
|
||||||
|
output.push(callback(input.slice(from, to), classes, from, to));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
convertRangeToHighlightedSubranges(from, to, classes, (from, to, classes) => {
|
||||||
|
output.push(callback(input.slice(from, to), classes, from, to));
|
||||||
|
})
|
||||||
|
|
||||||
|
pos = to;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process any remaining unstyled regions.
|
||||||
|
if (pos != tree.length) {
|
||||||
|
convertRangeToHighlightedSubranges(pos, tree.length, null, (from, to, classes) => {
|
||||||
|
output.push(callback(input.slice(from, to), classes, from, to));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIndexHighlighted = (index: number, ranges: { from: number, to: number }[]) => {
|
||||||
|
return ranges.some(range => index >= range.from && index < range.to);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ import { TrialNavIndicator } from "./trialNavIndicator";
|
||||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import WhatsNewIndicator from "./whatsNewIndicator";
|
||||||
|
|
||||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||||
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||||
|
|
@ -26,9 +28,11 @@ export const NavigationMenu = async ({
|
||||||
domain,
|
domain,
|
||||||
}: NavigationMenuProps) => {
|
}: NavigationMenuProps) => {
|
||||||
const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null;
|
const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null;
|
||||||
|
const session = await auth();
|
||||||
|
const isAuthenticated = session?.user !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-screen h-fit bg-background">
|
<div className="flex flex-col w-full h-fit bg-background">
|
||||||
<div className="flex flex-row justify-between items-center py-1.5 px-3">
|
<div className="flex flex-row justify-between items-center py-1.5 px-3">
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -59,13 +63,6 @@ export const NavigationMenu = async ({
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
|
||||||
<Link href={`/${domain}/agents`} legacyBehavior passHref>
|
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
|
||||||
Agents
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href={`/${domain}/repos`} legacyBehavior passHref>
|
<Link href={`/${domain}/repos`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
|
@ -73,23 +70,32 @@ export const NavigationMenu = async ({
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
{env.SOURCEBOT_AUTH_ENABLED === 'true' && (
|
{isAuthenticated && (
|
||||||
<NavigationMenuItem>
|
<>
|
||||||
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
{env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && (
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuItem>
|
||||||
Connections
|
<Link href={`/${domain}/agents`} legacyBehavior passHref>
|
||||||
</NavigationMenuLink>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
</Link>
|
Agents
|
||||||
</NavigationMenuItem>
|
</NavigationMenuLink>
|
||||||
)}
|
</Link>
|
||||||
{env.SOURCEBOT_AUTH_ENABLED === 'true' && (
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
)}
|
||||||
<Link href={`/${domain}/settings`} legacyBehavior passHref>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
||||||
Settings
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
</NavigationMenuLink>
|
Connections
|
||||||
</Link>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href={`/${domain}/settings`} legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Settings
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenuBase>
|
</NavigationMenuBase>
|
||||||
|
|
@ -100,6 +106,7 @@ export const NavigationMenu = async ({
|
||||||
<WarningNavIndicator />
|
<WarningNavIndicator />
|
||||||
<ErrorNavIndicator />
|
<ErrorNavIndicator />
|
||||||
<TrialNavIndicator subscription={subscription} />
|
<TrialNavIndicator subscription={subscription} />
|
||||||
|
<WhatsNewIndicator />
|
||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
@ -128,7 +135,7 @@ export const NavigationMenu = async ({
|
||||||
<GitHubLogoIcon className="w-4 h-4" />
|
<GitHubLogoIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<SettingsDropdown displaySettingsOption={env.SOURCEBOT_AUTH_ENABLED === 'true'} />
|
<SettingsDropdown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
|
||||||
60
packages/web/src/app/[domain]/components/pendingApproval.tsx
Normal file
60
packages/web/src/app/[domain]/components/pendingApproval.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { HelpCircle } from "lucide-react"
|
||||||
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { ResubmitAccountRequestButton } from "./resubmitAccountRequestButton"
|
||||||
|
|
||||||
|
interface PendingApprovalCardProps {
|
||||||
|
domain: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) => {
|
||||||
|
const session = await auth()
|
||||||
|
const userId = session?.user?.id
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen py-24 bg-background text-foreground relative">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md mx-auto">
|
||||||
|
<Card className="shadow-xl">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-16 w-auto mx-auto mb-2"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-2xl font-bold text-center">Pending Approval</CardTitle>
|
||||||
|
<CardDescription className="text-center mt-2">
|
||||||
|
Your request to join the organization is being reviewed
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col items-center space-y-2 mt-4">
|
||||||
|
<ResubmitAccountRequestButton domain={domain} userId={userId} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="inline-flex items-center space-x-3 p-3 bg-muted/50 rounded-md">
|
||||||
|
<HelpCircle className="h-5 w-5 text-primary" />
|
||||||
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
|
<p>Need help or have questions?</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/sourcebot-dev/sourcebot/discussions/categories/support"
|
||||||
|
className="text-primary hover:text-primary/80 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Submit a support request
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -18,12 +18,10 @@ import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
|
|
||||||
interface RepositorySnapshotProps {
|
interface RepositorySnapshotProps {
|
||||||
authEnabled: boolean;
|
|
||||||
repos: RepositoryQuery[];
|
repos: RepositoryQuery[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepositorySnapshot({
|
export function RepositorySnapshot({
|
||||||
authEnabled,
|
|
||||||
repos: initialRepos,
|
repos: initialRepos,
|
||||||
}: RepositorySnapshotProps) {
|
}: RepositorySnapshotProps) {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
@ -59,10 +57,10 @@ export function RepositorySnapshot({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ... otherwise, show the empty state.
|
// ... otherwise, show the empty state.
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<EmptyRepoState domain={domain} authEnabled={authEnabled} />
|
<EmptyRepoState />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +81,7 @@ export function RepositorySnapshot({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled: boolean }) {
|
function EmptyRepoState() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<span className="text-sm">No repositories found</span>
|
<span className="text-sm">No repositories found</span>
|
||||||
|
|
@ -91,23 +89,13 @@ function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled:
|
||||||
<div className="w-full max-w-lg">
|
<div className="w-full max-w-lg">
|
||||||
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{authEnabled ? (
|
<>
|
||||||
<>
|
Create a{" "}
|
||||||
Create a{" "}
|
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||||
<Link href={`/${domain}/connections`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
connection
|
||||||
connection
|
</Link>{" "}
|
||||||
</Link>{" "}
|
to start indexing repositories
|
||||||
to start indexing repositories
|
</>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Create a {" "}
|
|
||||||
<Link href={`https://docs.sourcebot.dev/self-hosting`} className="text-blue-500 hover:underline inline-flex items-center gap-1" target="_blank">
|
|
||||||
configuration file
|
|
||||||
</Link>{" "}
|
|
||||||
to start indexing repositories
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Clock } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
import { createAccountRequest } from "@/actions"
|
||||||
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ResubmitButtonProps {
|
||||||
|
domain: string
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
const result = await createAccountRequest(userId, domain)
|
||||||
|
if (!isServiceError(result)) {
|
||||||
|
if (result.existingRequest) {
|
||||||
|
toast({
|
||||||
|
title: "Request Already Submitted",
|
||||||
|
description: "Your request to join the organization has already been submitted. Please wait for it to be approved.",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Request Resubmitted",
|
||||||
|
description: "Your request to join the organization has been resubmitted.",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Failed to Resubmit",
|
||||||
|
description: `There was an error resubmitting your request. Reason: ${result.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="domain" value={domain} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{isSubmitting ? "Submitting..." : "Resubmit Request"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useClickListener } from "@/hooks/useClickListener";
|
import { useClickListener } from "@/hooks/useClickListener";
|
||||||
import { useTailwind } from "@/hooks/useTailwind";
|
|
||||||
import { SearchQueryParams } from "@/lib/types";
|
import { SearchQueryParams } from "@/lib/types";
|
||||||
import { cn, createPathWithQueryParams } from "@/lib/utils";
|
import { cn, createPathWithQueryParams } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -43,7 +42,8 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { KeyboardShortcutHint } from "../keyboardShortcutHint";
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
import tailwind from "@/tailwind";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -95,7 +95,6 @@ export const SearchBar = ({
|
||||||
}: SearchBarProps) => {
|
}: SearchBarProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const tailwind = useTailwind();
|
|
||||||
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
const suggestionBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
|
|
@ -161,7 +160,7 @@ export const SearchBar = ({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}, [tailwind]);
|
}, []);
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
@ -267,7 +266,18 @@ export const SearchBar = ({
|
||||||
indentWithTab={false}
|
indentWithTab={false}
|
||||||
autoFocus={autoFocus ?? false}
|
autoFocus={autoFocus ?? false}
|
||||||
/>
|
/>
|
||||||
<KeyboardShortcutHint shortcut="/" />
|
<Tooltip
|
||||||
|
delayDuration={100}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<KeyboardShortcutHint shortcut="/" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="flex flex-row items-center gap-2">
|
||||||
|
Focus search bar
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<SearchSuggestionsBox
|
<SearchSuggestionsBox
|
||||||
ref={suggestionBoxRef}
|
ref={suggestionBoxRef}
|
||||||
query={query}
|
query={query}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { IconType } from "react-icons/lib";
|
||||||
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
|
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { KeyboardShortcutHint } from "../keyboardShortcutHint";
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider";
|
import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider";
|
||||||
import { useRefineModeSuggestions } from "./useRefineModeSuggestions";
|
import { useRefineModeSuggestions } from "./useRefineModeSuggestions";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export const useSuggestionsData = ({
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
|
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
|
||||||
queryKey: ["repoSuggestions"],
|
queryKey: ["repoSuggestions", domain],
|
||||||
queryFn: () => getRepos(domain),
|
queryFn: () => getRepos(domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
return data.repos
|
return data.repos
|
||||||
|
|
@ -50,7 +50,7 @@ export const useSuggestionsData = ({
|
||||||
const isLoadingRepos = useMemo(() => suggestionMode === "repo" && _isLoadingRepos, [_isLoadingRepos, suggestionMode]);
|
const isLoadingRepos = useMemo(() => suggestionMode === "repo" && _isLoadingRepos, [_isLoadingRepos, suggestionMode]);
|
||||||
|
|
||||||
const { data: fileSuggestions, isLoading: _isLoadingFiles } = useQuery({
|
const { data: fileSuggestions, isLoading: _isLoadingFiles } = useQuery({
|
||||||
queryKey: ["fileSuggestions", suggestionQuery],
|
queryKey: ["fileSuggestions", suggestionQuery, domain],
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: `file:${suggestionQuery}`,
|
query: `file:${suggestionQuery}`,
|
||||||
matches: 15,
|
matches: 15,
|
||||||
|
|
@ -70,7 +70,7 @@ export const useSuggestionsData = ({
|
||||||
const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFiles, [_isLoadingFiles, suggestionMode]);
|
const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFiles, [_isLoadingFiles, suggestionMode]);
|
||||||
|
|
||||||
const { data: symbolSuggestions, isLoading: _isLoadingSymbols } = useQuery({
|
const { data: symbolSuggestions, isLoading: _isLoadingSymbols } = useQuery({
|
||||||
queryKey: ["symbolSuggestions", suggestionQuery],
|
queryKey: ["symbolSuggestions", suggestionQuery, domain],
|
||||||
queryFn: () => search({
|
queryFn: () => search({
|
||||||
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`,
|
||||||
matches: 15,
|
matches: 15,
|
||||||
|
|
@ -100,7 +100,7 @@ export const useSuggestionsData = ({
|
||||||
const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]);
|
const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]);
|
||||||
|
|
||||||
const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({
|
const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({
|
||||||
queryKey: ["searchContexts"],
|
queryKey: ["searchContexts", domain],
|
||||||
queryFn: () => getSearchContexts(domain),
|
queryFn: () => getSearchContexts(domain),
|
||||||
select: (data): Suggestion[] => {
|
select: (data): Suggestion[] => {
|
||||||
if (isServiceError(data)) {
|
if (isServiceError(data)) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import {
|
import {
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
Laptop,
|
Laptop,
|
||||||
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -37,12 +38,10 @@ import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface SettingsDropdownProps {
|
interface SettingsDropdownProps {
|
||||||
menuButtonClassName?: string;
|
menuButtonClassName?: string;
|
||||||
displaySettingsOption: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsDropdown = ({
|
export const SettingsDropdown = ({
|
||||||
menuButtonClassName,
|
menuButtonClassName,
|
||||||
displaySettingsOption,
|
|
||||||
}: SettingsDropdownProps) => {
|
}: SettingsDropdownProps) => {
|
||||||
|
|
||||||
const { theme: _theme, setTheme } = useTheme();
|
const { theme: _theme, setTheme } = useTheme();
|
||||||
|
|
@ -82,7 +81,7 @@ export const SettingsDropdown = ({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-64">
|
<DropdownMenuContent className="w-64">
|
||||||
{session?.user && (
|
{session?.user ? (
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<div className="flex flex-row items-center gap-1 p-2">
|
<div className="flex flex-row items-center gap-1 p-2">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
|
|
@ -107,9 +106,18 @@ export const SettingsDropdown = ({
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Log out</span>
|
<span>Log out</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
|
<span>Sign in</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
|
|
@ -150,7 +158,7 @@ export const SettingsDropdown = ({
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
{displaySettingsOption && (
|
{session?.user && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href={`/${domain}/settings`}>
|
<a href={`/${domain}/settings`}>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ export const TopBar = ({
|
||||||
</div>
|
</div>
|
||||||
<SettingsDropdown
|
<SettingsDropdown
|
||||||
menuButtonClassName="w-8 h-8"
|
menuButtonClassName="w-8 h-8"
|
||||||
displaySettingsOption={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
179
packages/web/src/app/[domain]/components/whatsNewIndicator.tsx
Normal file
179
packages/web/src/app/[domain]/components/whatsNewIndicator.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { HelpCircle, Mail, MailOpen } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { NewsItem } from "@/lib/types"
|
||||||
|
import { newsData } from "@/lib/newsData"
|
||||||
|
|
||||||
|
interface WhatsNewProps {
|
||||||
|
newsItems?: NewsItem[]
|
||||||
|
autoMarkAsRead?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIE_NAME = "whats-new-read-items"
|
||||||
|
|
||||||
|
const getReadItems = (): string[] => {
|
||||||
|
if (typeof document === "undefined") return []
|
||||||
|
|
||||||
|
const cookies = document.cookie.split(';').map(cookie => cookie.trim())
|
||||||
|
const targetCookie = cookies.find(cookie => cookie.startsWith(`${COOKIE_NAME}=`))
|
||||||
|
|
||||||
|
if (!targetCookie) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cookieValue = targetCookie.substring(`${COOKIE_NAME}=`.length)
|
||||||
|
return JSON.parse(decodeURIComponent(cookieValue))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse whats-new cookie:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setReadItems = (readItems: string[]) => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expires = new Date()
|
||||||
|
expires.setFullYear(expires.getFullYear() + 1)
|
||||||
|
const cookieValue = encodeURIComponent(JSON.stringify(readItems))
|
||||||
|
|
||||||
|
document.cookie = `${COOKIE_NAME}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to set whats-new cookie:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WhatsNewIndicator({ newsItems = newsData, autoMarkAsRead = true }: WhatsNewProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [readItems, setReadItemsState] = useState<string[]>([])
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const items = getReadItems()
|
||||||
|
setReadItemsState(items)
|
||||||
|
setIsInitialized(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized) {
|
||||||
|
setReadItems(readItems)
|
||||||
|
}
|
||||||
|
}, [readItems, isInitialized])
|
||||||
|
|
||||||
|
const newsItemsWithReadState = newsItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
read: readItems.includes(item.unique_id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unreadCount = newsItemsWithReadState.filter((item) => !item.read).length
|
||||||
|
|
||||||
|
const markAsRead = (itemId: string) => {
|
||||||
|
setReadItemsState((prev) => {
|
||||||
|
if (!prev.includes(itemId)) {
|
||||||
|
return [...prev, itemId]
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllAsRead = () => {
|
||||||
|
const allIds = newsItems.map((item) => item.unique_id)
|
||||||
|
setReadItemsState(allIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewsItemClick = (item: NewsItem) => {
|
||||||
|
window.open(item.url, "_blank", "noopener,noreferrer")
|
||||||
|
|
||||||
|
if (autoMarkAsRead && !item.read) {
|
||||||
|
markAsRead(item.unique_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative h-9 w-9 rounded-full hover:bg-muted"
|
||||||
|
aria-label={`What's new${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
{isInitialized && unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -top-1 -right-1 h-5 w-5 p-0 text-[10px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
<span className="sr-only">{unreadCount} unread updates</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="end" sideOffset={8}>
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm">{"What's New"}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{unreadCount > 0 ? `${unreadCount} unread update${unreadCount === 1 ? "" : "s"}` : "All caught up!"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={markAllAsRead} className="text-xs h-7">
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[32rem] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent">
|
||||||
|
{newsItemsWithReadState.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">No recent updates</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 p-2">
|
||||||
|
{newsItemsWithReadState.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.unique_id}
|
||||||
|
className={`relative rounded-md transition-colors ${item.read ? "opacity-60" : ""} ${
|
||||||
|
index !== newsItemsWithReadState.length - 1 ? "border-b border-border/50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!item.read && <div className="absolute left-2 top-3 h-2 w-2 bg-blue-500 rounded-full"></div>}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNewsItemClick(item)}
|
||||||
|
className="w-full text-left p-3 pl-6 rounded-md hover:bg-muted transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4
|
||||||
|
className={`font-medium text-sm leading-tight group-hover:text-primary ${
|
||||||
|
item.read ? "text-muted-foreground" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.header}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.sub_header}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{item.read ? (
|
||||||
|
<MailOpen className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-3 w-3 text-muted-foreground group-hover:text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -180,7 +180,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="mt-4 max-h-96 overflow-scroll">
|
<ScrollArea className="mt-4 h-96 pr-4">
|
||||||
{isReposPending ? (
|
{isReposPending ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { isServiceError } from "@/lib/utils"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { OrgRole } from "@sourcebot/db"
|
import { OrgRole } from "@sourcebot/db"
|
||||||
import { CodeHostType } from "@/lib/utils"
|
import { CodeHostType } from "@/lib/utils"
|
||||||
|
import { env } from "@/env.mjs"
|
||||||
|
|
||||||
interface ConnectionManagementPageProps {
|
interface ConnectionManagementPageProps {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -45,6 +46,7 @@ export default async function ConnectionManagementPage({ params, searchParams }:
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = membership.role === OrgRole.OWNER;
|
const isOwner = membership.role === OrgRole.OWNER;
|
||||||
|
const isDisabled = !isOwner || env.CONFIG_PATH !== undefined;
|
||||||
const currentTab = searchParams.tab || "overview";
|
const currentTab = searchParams.tab || "overview";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -92,14 +94,14 @@ export default async function ConnectionManagementPage({ params, searchParams }:
|
||||||
value="settings"
|
value="settings"
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<DisplayNameSetting connectionId={connection.id} name={connection.name} disabled={!isOwner} />
|
<DisplayNameSetting connectionId={connection.id} name={connection.name} disabled={isDisabled} />
|
||||||
<ConfigSetting
|
<ConfigSetting
|
||||||
connectionId={connection.id}
|
connectionId={connection.id}
|
||||||
type={connection.connectionType as CodeHostType}
|
type={connection.connectionType as CodeHostType}
|
||||||
config={JSON.stringify(connection.config, null, 2)}
|
config={JSON.stringify(connection.config, null, 2)}
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<DeleteConnectionSetting connectionId={connection.id} disabled={!isOwner} />
|
<DeleteConnectionSetting connectionId={connection.id} disabled={isDisabled} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,28 @@ import { OrgRole } from "@sourcebot/db"
|
||||||
interface NewConnectionCardProps {
|
interface NewConnectionCardProps {
|
||||||
className?: string
|
className?: string
|
||||||
role: OrgRole
|
role: OrgRole
|
||||||
|
configPathProvided: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) => {
|
export const NewConnectionCard = ({ className, role, configPathProvided }: NewConnectionCardProps) => {
|
||||||
const isOwner = role === OrgRole.OWNER
|
const isOwner = role === OrgRole.OWNER
|
||||||
|
const isDisabled = !isOwner || configPathProvided
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col border rounded-lg p-4 h-fit relative",
|
"flex flex-col border rounded-lg p-4 h-fit relative",
|
||||||
!isOwner && "bg-muted/10 border-muted cursor-not-allowed",
|
isDisabled && "bg-muted/10 border-muted cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isOwner && (
|
{isDisabled && (
|
||||||
<div className="absolute right-3 top-3">
|
<div className="absolute right-3 top-3">
|
||||||
<LockIcon className="w-4 h-4 text-muted-foreground" />
|
<LockIcon className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BlocksIcon className={cn("mx-auto w-7 h-7", !isOwner && "text-muted-foreground")} />
|
<BlocksIcon className={cn("mx-auto w-7 h-7", isDisabled && "text-muted-foreground")} />
|
||||||
<h2 className={cn("mx-auto mt-4 font-medium text-lg", !isOwner && "text-muted-foreground")}>
|
<h2 className={cn("mx-auto mt-4 font-medium text-lg", isDisabled && "text-muted-foreground")}>
|
||||||
Connect to a Code Host
|
Connect to a Code Host
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto text-center text-sm text-muted-foreground font-light">
|
<p className="mx-auto text-center text-sm text-muted-foreground font-light">
|
||||||
|
|
@ -41,42 +43,44 @@ export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) =
|
||||||
type="github"
|
type="github"
|
||||||
title="GitHub"
|
title="GitHub"
|
||||||
subtitle="Cloud or Enterprise supported."
|
subtitle="Cloud or Enterprise supported."
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
type="gitlab"
|
type="gitlab"
|
||||||
title="GitLab"
|
title="GitLab"
|
||||||
subtitle="Cloud and Self-Hosted supported."
|
subtitle="Cloud and Self-Hosted supported."
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
type="bitbucket-cloud"
|
type="bitbucket-cloud"
|
||||||
title="Bitbucket Cloud"
|
title="Bitbucket Cloud"
|
||||||
subtitle="Fetch repos from Bitbucket Cloud."
|
subtitle="Fetch repos from Bitbucket Cloud."
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
type="bitbucket-server"
|
type="bitbucket-server"
|
||||||
title="Bitbucket Data Center"
|
title="Bitbucket Data Center"
|
||||||
subtitle="Fetch repos from a Bitbucket DC instance."
|
subtitle="Fetch repos from a Bitbucket DC instance."
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
type="gitea"
|
type="gitea"
|
||||||
title="Gitea"
|
title="Gitea"
|
||||||
subtitle="Cloud and Self-Hosted supported."
|
subtitle="Cloud and Self-Hosted supported."
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
type="gerrit"
|
type="gerrit"
|
||||||
title="Gerrit"
|
title="Gerrit"
|
||||||
subtitle="Cloud and Self-Hosted supported."
|
subtitle="Cloud and Self-Hosted supported."
|
||||||
disabled={!isOwner}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isOwner && (
|
{isDisabled && (
|
||||||
<p className="mt-4 text-xs text-center text-muted-foreground">
|
<p className="mt-4 text-xs text-center text-muted-foreground">
|
||||||
Only organization owners can manage connections.
|
{configPathProvided
|
||||||
|
? "Connections are managed through the configuration file."
|
||||||
|
: "Only organization owners can manage connections."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getConnections, getOrgMembership } from "@/actions";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { notFound, ServiceErrorException } from "@/lib/serviceError";
|
import { notFound, ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { OrgRole } from "@sourcebot/db";
|
import { OrgRole } from "@sourcebot/db";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
|
export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) {
|
||||||
const connections = await getConnections(domain);
|
const connections = await getConnections(domain);
|
||||||
|
|
@ -30,6 +31,7 @@ export default async function ConnectionsPage({ params: { domain } }: { params:
|
||||||
<NewConnectionCard
|
<NewConnectionCard
|
||||||
className="md:w-1/4"
|
className="md:w-1/4"
|
||||||
role={membership.role}
|
role={membership.role}
|
||||||
|
configPathProvided={env.CONFIG_PATH !== undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/co
|
||||||
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
|
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
|
||||||
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
|
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
|
||||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||||
|
import { PendingApprovalCard } from "./components/pendingApproval";
|
||||||
|
import { hasEntitlement } from "@/features/entitlements/server";
|
||||||
|
import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
|
|
@ -30,7 +33,8 @@ export default async function Layout({
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.SOURCEBOT_AUTH_ENABLED === 'true') {
|
const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain);
|
||||||
|
if (!publicAccessEnabled) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
|
|
@ -42,11 +46,25 @@ export default async function Layout({
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
userId: session.user.id
|
userId: session.user.id
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
return notFound();
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: session.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Organization join requests are only supported in single-tenant mode
|
||||||
|
if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) {
|
||||||
|
return <PendingApprovalCard domain={domain} />
|
||||||
|
} else {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { Footer } from "@/app/components/footer";
|
||||||
import { SourcebotLogo } from "../components/sourcebotLogo";
|
import { SourcebotLogo } from "../components/sourcebotLogo";
|
||||||
import { RepositorySnapshot } from "./components/repositorySnapshot";
|
import { RepositorySnapshot } from "./components/repositorySnapshot";
|
||||||
import { SyntaxReferenceGuideHint } from "./components/syntaxReferenceGuideHint";
|
import { SyntaxReferenceGuideHint } from "./components/syntaxReferenceGuideHint";
|
||||||
import { env } from '@/env.mjs';
|
|
||||||
import { getRepos } from "@/actions";
|
import { getRepos } from "@/actions";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -39,7 +38,6 @@ export default async function Home({ params: { domain } }: { params: { domain: s
|
||||||
/>
|
/>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<RepositorySnapshot
|
<RepositorySnapshot
|
||||||
authEnabled={env.SOURCEBOT_AUTH_ENABLED === 'true'}
|
|
||||||
repos={isServiceError(repos) ? [] : repos}
|
repos={isServiceError(repos) ? [] : repos}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,28 @@
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { PlusCircle } from "lucide-react"
|
import { PlusCircle } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ConnectionList } from "../connections/components/connectionList"
|
import { ConnectionList } from "../connections/components/connectionList"
|
||||||
import { useDomain } from "@/hooks/useDomain"
|
import { useDomain } from "@/hooks/useDomain"
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
|
||||||
export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButtonVisible: boolean }) {
|
export function AddRepoButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const domain = useDomain()
|
const domain = useDomain()
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{session?.user && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
|
|
@ -40,7 +44,7 @@ export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButto
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<ConnectionList className="w-full" isDisabled={!isAddNewRepoButtonVisible} />
|
<ConnectionList className="w-full" isDisabled={false} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex justify-between items-center border-t p-4 px-6">
|
<DialogFooter className="flex justify-between items-center border-t p-4 px-6">
|
||||||
<Button asChild variant="default" className="bg-primary hover:bg-primary/90">
|
<Button asChild variant="default" className="bg-primary hover:bg-primary/90">
|
||||||
|
|
@ -54,4 +58,7 @@ export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButto
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -93,13 +93,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef<RepositoryColumnInfo>[] => [
|
export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center w-[400px]">
|
<div className="flex items-center w-[400px]">
|
||||||
<span>Repository</span>
|
<span>Repository</span>
|
||||||
{isAddNewRepoButtonVisible && <AddRepoButton isAddNewRepoButtonVisible={isAddNewRepoButtonVisible} />}
|
<AddRepoButton />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
|
@ -182,7 +182,7 @@ export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): Col
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter ? "secondary" : "ghost"}
|
variant={currentFilter ? "secondary" : "ghost"}
|
||||||
className="font-medium"
|
className="px-0 font-medium"
|
||||||
>
|
>
|
||||||
Status
|
Status
|
||||||
<ListFilter className={cn(
|
<ListFilter className={cn(
|
||||||
|
|
@ -227,7 +227,7 @@ export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): Col
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="font-medium"
|
className="px-0 font-medium"
|
||||||
>
|
>
|
||||||
Last Indexed
|
Last Indexed
|
||||||
<ArrowUpDown className="ml-2 h-3.5 w-3.5 text-muted-foreground" />
|
<ArrowUpDown className="ml-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ import { RepositoryTable } from "./repositoryTable";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { PageNotFound } from "../components/pageNotFound";
|
import { PageNotFound } from "../components/pageNotFound";
|
||||||
import { Header } from "../components/header";
|
import { Header } from "../components/header";
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
|
|
||||||
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
|
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
|
||||||
const org = await getOrgFromDomain(domain);
|
const org = await getOrgFromDomain(domain);
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return <PageNotFound />
|
return <PageNotFound />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -17,9 +16,7 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
|
||||||
</Header>
|
</Header>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<RepositoryTable
|
<RepositoryTable />
|
||||||
isAddNewRepoButtonVisible={env.SOURCEBOT_AUTH_ENABLED === 'true'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,7 @@ import { useMemo } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
export const RepositoryTable = () => {
|
||||||
isAddNewRepoButtonVisible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTableProps) => {
|
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
||||||
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
|
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
|
||||||
|
|
@ -52,7 +48,7 @@ export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTablePr
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
if (reposLoading) {
|
if (reposLoading) {
|
||||||
return columns(domain, isAddNewRepoButtonVisible).map((column) => {
|
return columns(domain).map((column) => {
|
||||||
if ('accessorKey' in column && column.accessorKey === "name") {
|
if ('accessorKey' in column && column.accessorKey === "name") {
|
||||||
return {
|
return {
|
||||||
...column,
|
...column,
|
||||||
|
|
@ -76,8 +72,8 @@ export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTablePr
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return columns(domain, isAddNewRepoButtonVisible);
|
return columns(domain);
|
||||||
}, [reposLoading, domain, isAddNewRepoButtonVisible]);
|
}, [reposLoading, domain]);
|
||||||
|
|
||||||
|
|
||||||
if (reposError) {
|
if (reposError) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { SearchResultChunk } from "@/features/search/types";
|
import { SearchResultChunk } from "@/features/search/types";
|
||||||
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
||||||
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
|
||||||
import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension";
|
import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension";
|
||||||
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension";
|
||||||
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension";
|
||||||
import { search } from "@codemirror/search";
|
import { search } from "@codemirror/search";
|
||||||
|
|
@ -14,9 +14,14 @@ import { EditorView } from "@codemirror/view";
|
||||||
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
import { Cross1Icon, FileIcon } from "@radix-ui/react-icons";
|
||||||
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
import { Scrollbar } from "@radix-ui/react-scroll-area";
|
||||||
import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror';
|
import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror';
|
||||||
import clsx from "clsx";
|
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
||||||
|
import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup";
|
||||||
|
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
|
||||||
|
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
|
||||||
|
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
|
||||||
export interface CodePreviewFile {
|
export interface CodePreviewFile {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -28,10 +33,10 @@ export interface CodePreviewFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodePreviewProps {
|
interface CodePreviewProps {
|
||||||
file?: CodePreviewFile;
|
file: CodePreviewFile;
|
||||||
repoName?: string;
|
repoName: string;
|
||||||
selectedMatchIndex: number;
|
selectedMatchIndex: number;
|
||||||
onSelectedMatchIndexChange: (index: number) => void;
|
onSelectedMatchIndexChange: Dispatch<SetStateAction<number>>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,19 +48,23 @@ export const CodePreview = ({
|
||||||
onClose,
|
onClose,
|
||||||
}: CodePreviewProps) => {
|
}: CodePreviewProps) => {
|
||||||
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
|
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
|
||||||
|
const { navigateToPath } = useBrowseNavigation();
|
||||||
|
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
||||||
|
|
||||||
const [gutterWidth, setGutterWidth] = useState(0);
|
const [gutterWidth, setGutterWidth] = useState(0);
|
||||||
const theme = useCodeMirrorTheme();
|
const theme = useCodeMirrorTheme();
|
||||||
|
|
||||||
const keymapExtension = useKeymapExtension(editorRef?.view);
|
const keymapExtension = useKeymapExtension(editorRef?.view);
|
||||||
const syntaxHighlighting = useSyntaxHighlightingExtension(file?.language ?? '', editorRef?.view);
|
const languageExtension = useCodeMirrorLanguageExtension(file?.language ?? '', editorRef?.view);
|
||||||
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
|
const [currentSelection, setCurrentSelection] = useState<SelectionRange>();
|
||||||
|
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
keymapExtension,
|
keymapExtension,
|
||||||
gutterWidthExtension,
|
gutterWidthExtension,
|
||||||
syntaxHighlighting,
|
languageExtension,
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
searchResultHighlightExtension(),
|
searchResultHighlightExtension(),
|
||||||
search({
|
search({
|
||||||
|
|
@ -74,12 +83,13 @@ export const CodePreview = ({
|
||||||
if (update.selectionSet || update.docChanged) {
|
if (update.selectionSet || update.docChanged) {
|
||||||
setCurrentSelection(update.state.selection.main);
|
setCurrentSelection(update.state.selection.main);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
hasCodeNavEntitlement ? symbolHoverTargetsExtension : [],
|
||||||
];
|
];
|
||||||
}, [keymapExtension, syntaxHighlighting]);
|
}, [hasCodeNavEntitlement, keymapExtension, languageExtension]);
|
||||||
|
|
||||||
const ranges = useMemo(() => {
|
const ranges = useMemo(() => {
|
||||||
if (!file || !file.matches.length) {
|
if (!file.matches.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +99,7 @@ export const CodePreview = ({
|
||||||
}, [file]);
|
}, [file]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!file || !editorRef?.view) {
|
if (!editorRef?.view) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,12 +107,71 @@ export const CodePreview = ({
|
||||||
}, [ranges, selectedMatchIndex, file, editorRef]);
|
}, [ranges, selectedMatchIndex, file, editorRef]);
|
||||||
|
|
||||||
const onUpClicked = useCallback(() => {
|
const onUpClicked = useCallback(() => {
|
||||||
onSelectedMatchIndexChange(selectedMatchIndex - 1);
|
onSelectedMatchIndexChange((prev) => prev - 1);
|
||||||
}, [onSelectedMatchIndexChange, selectedMatchIndex]);
|
}, [onSelectedMatchIndexChange]);
|
||||||
|
|
||||||
const onDownClicked = useCallback(() => {
|
const onDownClicked = useCallback(() => {
|
||||||
onSelectedMatchIndexChange(selectedMatchIndex + 1);
|
onSelectedMatchIndexChange((prev) => prev + 1);
|
||||||
}, [onSelectedMatchIndexChange, selectedMatchIndex]);
|
}, [onSelectedMatchIndexChange]);
|
||||||
|
|
||||||
|
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
|
||||||
|
captureEvent('wa_preview_panel_goto_definition_pressed', {});
|
||||||
|
|
||||||
|
if (symbolDefinitions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (symbolDefinitions.length === 1) {
|
||||||
|
const symbolDefinition = symbolDefinitions[0];
|
||||||
|
const { fileName, repoName } = symbolDefinition;
|
||||||
|
|
||||||
|
navigateToPath({
|
||||||
|
repoName,
|
||||||
|
revisionName: file.revision,
|
||||||
|
path: fileName,
|
||||||
|
pathType: 'blob',
|
||||||
|
highlightRange: symbolDefinition.range,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
navigateToPath({
|
||||||
|
repoName,
|
||||||
|
revisionName: file.revision,
|
||||||
|
path: file.filepath,
|
||||||
|
pathType: 'blob',
|
||||||
|
setBrowseState: {
|
||||||
|
selectedSymbolInfo: {
|
||||||
|
symbolName,
|
||||||
|
repoName,
|
||||||
|
revisionName: file.revision,
|
||||||
|
language: file.language,
|
||||||
|
},
|
||||||
|
activeExploreMenuTab: "definitions",
|
||||||
|
isBottomPanelCollapsed: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
|
||||||
|
|
||||||
|
const onFindReferences = useCallback((symbolName: string) => {
|
||||||
|
captureEvent('wa_preview_panel_find_references_pressed', {});
|
||||||
|
|
||||||
|
navigateToPath({
|
||||||
|
repoName,
|
||||||
|
revisionName: file.revision,
|
||||||
|
path: file.filepath,
|
||||||
|
pathType: 'blob',
|
||||||
|
setBrowseState: {
|
||||||
|
selectedSymbolInfo: {
|
||||||
|
repoName,
|
||||||
|
symbolName,
|
||||||
|
revisionName: file.revision,
|
||||||
|
language: file.language,
|
||||||
|
},
|
||||||
|
activeExploreMenuTab: "references",
|
||||||
|
isBottomPanelCollapsed: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|
@ -121,23 +190,24 @@ export const CodePreview = ({
|
||||||
{/* File path */}
|
{/* File path */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={clsx("block truncate-start text-sm font-mono", {
|
className="block truncate-start text-sm font-mono cursor-pointer hover:underline"
|
||||||
"cursor-pointer text-blue-500 hover:underline": file?.link
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (file?.link) {
|
navigateToPath({
|
||||||
window.open(file.link, "_blank");
|
repoName,
|
||||||
}
|
path: file.filepath,
|
||||||
|
pathType: 'blob',
|
||||||
|
revisionName: file.revision,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
title={file?.filepath}
|
title={file.filepath}
|
||||||
>
|
>
|
||||||
{file?.filepath}
|
{file.filepath}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-1 items-center pl-2">
|
<div className="flex flex-row gap-1 items-center pl-2">
|
||||||
{/* Match selector */}
|
{/* Match selector */}
|
||||||
{file && file.matches.length > 0 && (
|
{file.matches.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm">{`${selectedMatchIndex + 1} of ${ranges.length}`}</p>
|
<p className="text-sm">{`${selectedMatchIndex + 1} of ${ranges.length}`}</p>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -154,7 +224,7 @@ export const CodePreview = ({
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
onClick={onDownClicked}
|
onClick={onDownClicked}
|
||||||
disabled={file ? selectedMatchIndex === ranges.length - 1 : true}
|
disabled={selectedMatchIndex === ranges.length - 1}
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -196,6 +266,16 @@ export const CodePreview = ({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{editorRef && hasCodeNavEntitlement && (
|
||||||
|
<SymbolHoverPopup
|
||||||
|
editorRef={editorRef}
|
||||||
|
language={file.language}
|
||||||
|
revisionName={file.revision}
|
||||||
|
onFindReferences={onFindReferences}
|
||||||
|
onGotoDefinition={onGotoDefinition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CodeMirror>
|
</CodeMirror>
|
||||||
<Scrollbar orientation="vertical" />
|
<Scrollbar orientation="vertical" />
|
||||||
<Scrollbar orientation="horizontal" />
|
<Scrollbar orientation="horizontal" />
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,79 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { fetchFileSource } from "@/app/api/(client)/client";
|
|
||||||
import { base64Decode } from "@/lib/utils";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CodePreview, CodePreviewFile } from "./codePreview";
|
import { CodePreview } from "./codePreview";
|
||||||
import { SearchResultFile } from "@/features/search/types";
|
import { SearchResultFile } from "@/features/search/types";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
|
import { SetStateAction, Dispatch, useMemo } from "react";
|
||||||
|
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||||
|
import { base64Decode } from "@/lib/utils";
|
||||||
|
import { unwrapServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
interface CodePreviewPanelProps {
|
interface CodePreviewPanelProps {
|
||||||
fileMatch?: SearchResultFile;
|
previewedFile: SearchResultFile;
|
||||||
onClose: () => void;
|
|
||||||
selectedMatchIndex: number;
|
selectedMatchIndex: number;
|
||||||
onSelectedMatchIndexChange: (index: number) => void;
|
onClose: () => void;
|
||||||
|
onSelectedMatchIndexChange: Dispatch<SetStateAction<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodePreviewPanel = ({
|
export const CodePreviewPanel = ({
|
||||||
fileMatch,
|
previewedFile,
|
||||||
onClose,
|
|
||||||
selectedMatchIndex,
|
selectedMatchIndex,
|
||||||
|
onClose,
|
||||||
onSelectedMatchIndexChange,
|
onSelectedMatchIndexChange,
|
||||||
}: CodePreviewPanelProps) => {
|
}: CodePreviewPanelProps) => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
||||||
const { data: file, isLoading } = useQuery({
|
// If there are multiple branches pointing to the same revision of this file, it doesn't
|
||||||
queryKey: ["source", fileMatch?.fileName, fileMatch?.repository, fileMatch?.branches],
|
// matter which branch we use here, so use the first one.
|
||||||
queryFn: async (): Promise<CodePreviewFile | undefined> => {
|
const branch = useMemo(() => {
|
||||||
if (!fileMatch) {
|
return previewedFile.branches && previewedFile.branches.length > 0 ? previewedFile.branches[0] : undefined;
|
||||||
return undefined;
|
}, [previewedFile]);
|
||||||
}
|
|
||||||
|
|
||||||
// If there are multiple branches pointing to the same revision of this file, it doesn't
|
const { data: file, isLoading, isPending, isError } = useQuery({
|
||||||
// matter which branch we use here, so use the first one.
|
queryKey: ["source", previewedFile, branch, domain],
|
||||||
const branch = fileMatch.branches && fileMatch.branches.length > 0 ? fileMatch.branches[0] : undefined;
|
queryFn: () => unwrapServiceError(
|
||||||
|
getFileSource({
|
||||||
return fetchFileSource({
|
fileName: previewedFile.fileName.text,
|
||||||
fileName: fileMatch.fileName.text,
|
repository: previewedFile.repository,
|
||||||
repository: fileMatch.repository,
|
|
||||||
branch,
|
branch,
|
||||||
}, domain)
|
}, domain)
|
||||||
.then(({ source }) => {
|
),
|
||||||
const decodedSource = base64Decode(source);
|
select: (data) => {
|
||||||
|
const decodedSource = base64Decode(data.source);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: decodedSource,
|
content: decodedSource,
|
||||||
filepath: fileMatch.fileName.text,
|
filepath: previewedFile.fileName.text,
|
||||||
matches: fileMatch.chunks,
|
matches: previewedFile.chunks,
|
||||||
link: fileMatch.webUrl,
|
link: previewedFile.webUrl,
|
||||||
language: fileMatch.language,
|
language: previewedFile.language,
|
||||||
revision: branch ?? "HEAD",
|
revision: branch ?? "HEAD",
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
},
|
|
||||||
enabled: fileMatch !== undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || isPending) {
|
||||||
return <div className="flex flex-col items-center justify-center h-full">
|
return <div className="flex flex-col items-center justify-center h-full">
|
||||||
<SymbolIcon className="h-6 w-6 animate-spin" />
|
<SymbolIcon className="h-6 w-6 animate-spin" />
|
||||||
<p className="font-semibold text-center">Loading...</p>
|
<p className="font-semibold text-center">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<p>Failed to load file source</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodePreview
|
<CodePreview
|
||||||
file={file}
|
file={file}
|
||||||
repoName={fileMatch?.repository}
|
repoName={previewedFile.repository}
|
||||||
onClose={onClose}
|
|
||||||
selectedMatchIndex={selectedMatchIndex}
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
onSelectedMatchIndexChange={onSelectedMatchIndexChange}
|
||||||
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ export type Entry = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
count: number;
|
count: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
Icon?: React.ReactNode;
|
Icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,6 +24,7 @@ export const Entry = ({
|
||||||
displayName,
|
displayName,
|
||||||
count,
|
count,
|
||||||
Icon,
|
Icon,
|
||||||
|
isDisabled,
|
||||||
},
|
},
|
||||||
onClicked,
|
onClicked,
|
||||||
}: EntryProps) => {
|
}: EntryProps) => {
|
||||||
|
|
@ -36,6 +39,7 @@ export const Entry = ({
|
||||||
{
|
{
|
||||||
"hover:bg-gray-200 dark:hover:bg-gray-700": !isSelected,
|
"hover:bg-gray-200 dark:hover:bg-gray-700": !isSelected,
|
||||||
"bg-blue-200 dark:bg-blue-400": isSelected,
|
"bg-blue-200 dark:bg-blue-400": isSelected,
|
||||||
|
"opacity-50": isDisabled,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
onClick={() => onClicked()}
|
onClick={() => onClicked()}
|
||||||
|
|
|
||||||
|
|
@ -6,39 +6,49 @@ import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
|
||||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Entry } from "./entry";
|
import { Entry } from "./entry";
|
||||||
import { Filter } from "./filter";
|
import { Filter } from "./filter";
|
||||||
|
import { LANGUAGES_QUERY_PARAM, REPOS_QUERY_PARAM, useFilteredMatches } from "./useFilterMatches";
|
||||||
|
import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery";
|
||||||
|
|
||||||
interface FilePanelProps {
|
interface FilePanelProps {
|
||||||
matches: SearchResultFile[];
|
matches: SearchResultFile[];
|
||||||
onFilterChanged: (filteredMatches: SearchResultFile[]) => void,
|
|
||||||
repoInfo: Record<number, RepositoryInfo>;
|
repoInfo: Record<number, RepositoryInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANGUAGES_QUERY_PARAM = "langs";
|
/**
|
||||||
const REPOS_QUERY_PARAM = "repos";
|
* FilterPanel Component
|
||||||
|
*
|
||||||
|
* A bidirectional filtering component that allows users to filter search results by repository and language.
|
||||||
|
* The filtering is bidirectional, meaning:
|
||||||
|
* 1. When repositories are selected, the language filter will only show languages that exist in those repositories
|
||||||
|
* 2. When languages are selected, the repository filter will only show repositories that contain those languages
|
||||||
|
*
|
||||||
|
* This prevents users from selecting filter combinations that would yield no results. For example:
|
||||||
|
* - If Repository A only contains Python and JavaScript files, selecting it will only enable these languages
|
||||||
|
* - If Language Python is selected, only repositories containing Python files will be enabled
|
||||||
|
*
|
||||||
|
* @param matches - Array of search result files to filter
|
||||||
|
* @param repoInfo - Information about repositories including their display names and icons
|
||||||
|
*/
|
||||||
export const FilterPanel = ({
|
export const FilterPanel = ({
|
||||||
matches,
|
matches,
|
||||||
onFilterChanged,
|
|
||||||
repoInfo,
|
repoInfo,
|
||||||
}: FilePanelProps) => {
|
}: FilePanelProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// Helper to parse query params into sets
|
const { getSelectedFromQuery } = useGetSelectedFromQuery();
|
||||||
const getSelectedFromQuery = useCallback((param: string) => {
|
const matchesFilteredByRepository = useFilteredMatches(matches, 'repository');
|
||||||
const value = searchParams.get(param);
|
const matchesFilteredByLanguage = useFilteredMatches(matches, 'language');
|
||||||
return value ? new Set(value.split(',')) : new Set();
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const repos = useMemo(() => {
|
const repos = useMemo(() => {
|
||||||
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
|
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
|
||||||
return aggregateMatches(
|
return aggregateMatches(
|
||||||
"repository",
|
"repository",
|
||||||
matches,
|
matches,
|
||||||
({ key, match }) => {
|
/* createEntry = */ ({ key: repository, match }) => {
|
||||||
const repo: RepositoryInfo | undefined = repoInfo[match.repositoryId];
|
const repo: RepositoryInfo | undefined = repoInfo[match.repositoryId];
|
||||||
|
|
||||||
const info = repo ? getCodeHostInfoForRepo({
|
const info = repo ? getCodeHostInfoForRepo({
|
||||||
|
|
@ -58,63 +68,72 @@ export const FilterPanel = ({
|
||||||
<LaptopIcon className="w-4 h-4 flex-shrink-0" />
|
<LaptopIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSelected = selectedRepos.has(repository);
|
||||||
|
|
||||||
|
// If the matches filtered by language don't contain this repository, then this entry is disabled
|
||||||
|
const isDisabled = !matchesFilteredByLanguage.some((match) => match.repository === repository);
|
||||||
|
const isHidden = isDisabled && !isSelected;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key: repository,
|
||||||
displayName: info?.displayName ?? key,
|
displayName: info?.displayName ?? repository,
|
||||||
count: 0,
|
count: 0,
|
||||||
isSelected: selectedRepos.has(key),
|
isSelected,
|
||||||
|
isDisabled,
|
||||||
|
isHidden,
|
||||||
Icon,
|
Icon,
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
/* shouldCount = */ ({ match }) => {
|
||||||
|
return matchesFilteredByLanguage.some((value) => value.language === match.language)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}, [getSelectedFromQuery, matches, repoInfo]);
|
}, [getSelectedFromQuery, matches, repoInfo, matchesFilteredByLanguage]);
|
||||||
|
|
||||||
const languages = useMemo(() => {
|
const languages = useMemo(() => {
|
||||||
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
||||||
return aggregateMatches(
|
return aggregateMatches(
|
||||||
"language",
|
"language",
|
||||||
matches,
|
matches,
|
||||||
({ key }) => {
|
/* createEntry = */ ({ key: language }) => {
|
||||||
const Icon = (
|
const Icon = (
|
||||||
<FileIcon language={key} />
|
<FileIcon language={language} />
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isSelected = selectedLanguages.has(language);
|
||||||
|
|
||||||
|
// If the matches filtered by repository don't contain this language, then this entry is disabled
|
||||||
|
const isDisabled = !matchesFilteredByRepository.some((match) => match.language === language);
|
||||||
|
const isHidden = isDisabled && !isSelected;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key: language,
|
||||||
displayName: key,
|
displayName: language,
|
||||||
count: 0,
|
count: 0,
|
||||||
isSelected: selectedLanguages.has(key),
|
isSelected,
|
||||||
|
isDisabled,
|
||||||
|
isHidden,
|
||||||
Icon: Icon,
|
Icon: Icon,
|
||||||
} satisfies Entry;
|
} satisfies Entry;
|
||||||
|
},
|
||||||
|
/* shouldCount = */ ({ match }) => {
|
||||||
|
return matchesFilteredByRepository.some((value) => value.repository === match.repository)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [getSelectedFromQuery, matches]);
|
}, [getSelectedFromQuery, matches, matchesFilteredByRepository]);
|
||||||
|
|
||||||
// Calls `onFilterChanged` with the filtered list of matches
|
const visibleRepos = useMemo(() => Object.values(repos).filter((entry) => !entry.isHidden), [repos]);
|
||||||
// whenever the filter state changes.
|
const visibleLanguages = useMemo(() => Object.values(languages).filter((entry) => !entry.isHidden), [languages]);
|
||||||
useEffect(() => {
|
|
||||||
const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected));
|
|
||||||
const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected));
|
|
||||||
|
|
||||||
const filteredMatches = matches.filter((match) =>
|
const numRepos = useMemo(() => visibleRepos.length > 100 ? '100+' : visibleRepos.length, [visibleRepos]);
|
||||||
(
|
const numLanguages = useMemo(() => visibleLanguages.length > 100 ? '100+' : visibleLanguages.length, [visibleLanguages]);
|
||||||
(selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) &&
|
|
||||||
(selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
onFilterChanged(filteredMatches);
|
|
||||||
|
|
||||||
}, [matches, repos, languages, onFilterChanged, searchParams, router]);
|
|
||||||
|
|
||||||
const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]);
|
|
||||||
const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 flex flex-col gap-3 h-full">
|
<div className="p-3 flex flex-col gap-3 h-full">
|
||||||
<Filter
|
<Filter
|
||||||
title="Filter By Repository"
|
title="Filter By Repository"
|
||||||
searchPlaceholder={`Filter ${numRepos} repositories`}
|
searchPlaceholder={`Filter ${numRepos} repositories`}
|
||||||
entries={Object.values(repos)}
|
entries={visibleRepos}
|
||||||
onEntryClicked={(key) => {
|
onEntryClicked={(key) => {
|
||||||
const newRepos = { ...repos };
|
const newRepos = { ...repos };
|
||||||
newRepos[key].isSelected = !newRepos[key].isSelected;
|
newRepos[key].isSelected = !newRepos[key].isSelected;
|
||||||
|
|
@ -136,7 +155,7 @@ export const FilterPanel = ({
|
||||||
<Filter
|
<Filter
|
||||||
title="Filter By Language"
|
title="Filter By Language"
|
||||||
searchPlaceholder={`Filter ${numLanguages} languages`}
|
searchPlaceholder={`Filter ${numLanguages} languages`}
|
||||||
entries={Object.values(languages)}
|
entries={visibleLanguages}
|
||||||
onEntryClicked={(key) => {
|
onEntryClicked={(key) => {
|
||||||
const newLanguages = { ...languages };
|
const newLanguages = { ...languages };
|
||||||
newLanguages[key].isSelected = !newLanguages[key].isSelected;
|
newLanguages[key].isSelected = !newLanguages[key].isSelected;
|
||||||
|
|
@ -175,7 +194,8 @@ export const FilterPanel = ({
|
||||||
const aggregateMatches = (
|
const aggregateMatches = (
|
||||||
propName: 'repository' | 'language',
|
propName: 'repository' | 'language',
|
||||||
matches: SearchResultFile[],
|
matches: SearchResultFile[],
|
||||||
createEntry: (props: { key: string, match: SearchResultFile }) => Entry
|
createEntry: (props: { key: string, match: SearchResultFile }) => Entry,
|
||||||
|
shouldCount: (props: { key: string, match: SearchResultFile }) => boolean,
|
||||||
) => {
|
) => {
|
||||||
return matches
|
return matches
|
||||||
.map((match) => ({ key: match[propName], match }))
|
.map((match) => ({ key: match[propName], match }))
|
||||||
|
|
@ -184,7 +204,11 @@ const aggregateMatches = (
|
||||||
if (!aggregation[key]) {
|
if (!aggregation[key]) {
|
||||||
aggregation[key] = createEntry({ key, match });
|
aggregation[key] = createEntry({ key, match });
|
||||||
}
|
}
|
||||||
aggregation[key].count += 1;
|
|
||||||
|
if (!aggregation[key].isDisabled && shouldCount({ key, match })) {
|
||||||
|
aggregation[key].count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
return aggregation;
|
return aggregation;
|
||||||
}, {} as Record<string, Entry>)
|
}, {} as Record<string, Entry>)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SearchResultFile } from "@/features/search/types";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery";
|
||||||
|
|
||||||
|
export const LANGUAGES_QUERY_PARAM = "langs";
|
||||||
|
export const REPOS_QUERY_PARAM = "repos";
|
||||||
|
|
||||||
|
|
||||||
|
export const useFilteredMatches = (
|
||||||
|
matches: SearchResultFile[],
|
||||||
|
filterBy: 'repository' | 'language' | 'all' = 'all'
|
||||||
|
) => {
|
||||||
|
const { getSelectedFromQuery } = useGetSelectedFromQuery();
|
||||||
|
|
||||||
|
const filteredMatches = useMemo(() => {
|
||||||
|
const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM);
|
||||||
|
const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM);
|
||||||
|
|
||||||
|
const isInRepoSet = (repo: string) => selectedRepos.size === 0 || selectedRepos.has(repo);
|
||||||
|
const isInLanguageSet = (language: string) => selectedLanguages.size === 0 || selectedLanguages.has(language);
|
||||||
|
|
||||||
|
switch (filterBy) {
|
||||||
|
case 'repository':
|
||||||
|
return matches.filter((match) => isInRepoSet(match.repository));
|
||||||
|
case 'language':
|
||||||
|
return matches.filter((match) => isInLanguageSet(match.language));
|
||||||
|
case 'all':
|
||||||
|
return matches.filter((match) => isInRepoSet(match.repository) && isInLanguageSet(match.language));
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [filterBy, getSelectedFromQuery, matches]);
|
||||||
|
|
||||||
|
return filteredMatches;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
// Helper to parse query params into sets
|
||||||
|
export const useGetSelectedFromQuery = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const getSelectedFromQuery = useCallback((param: string): Set<string> => {
|
||||||
|
const value = searchParams.get(param);
|
||||||
|
return value ? new Set(value.split(',')) : new Set();
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSelectedFromQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage";
|
|
||||||
import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension";
|
|
||||||
import { SearchResultRange } from "@/features/search/types";
|
|
||||||
import { EditorState, StateField, Transaction } from "@codemirror/state";
|
|
||||||
import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view";
|
|
||||||
import { useMemo, useRef } from "react";
|
|
||||||
import { LightweightCodeMirror, CodeMirrorRef } from "./lightweightCodeMirror";
|
|
||||||
import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme";
|
|
||||||
|
|
||||||
const markDecoration = Decoration.mark({
|
|
||||||
class: "cm-searchMatch-selected"
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CodePreviewProps {
|
|
||||||
content: string,
|
|
||||||
language: string,
|
|
||||||
ranges: SearchResultRange[],
|
|
||||||
lineOffset: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodePreview = ({
|
|
||||||
content,
|
|
||||||
language,
|
|
||||||
ranges,
|
|
||||||
lineOffset,
|
|
||||||
}: CodePreviewProps) => {
|
|
||||||
const editorRef = useRef<CodeMirrorRef>(null);
|
|
||||||
const theme = useCodeMirrorTheme();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
|
||||||
const codemirrorExtension = getCodemirrorLanguage(language);
|
|
||||||
return [
|
|
||||||
EditorView.editable.of(false),
|
|
||||||
theme,
|
|
||||||
lineNumbers(),
|
|
||||||
lineOffsetExtension(lineOffset),
|
|
||||||
codemirrorExtension ? codemirrorExtension : [],
|
|
||||||
StateField.define<DecorationSet>({
|
|
||||||
create(editorState: EditorState) {
|
|
||||||
const document = editorState.doc;
|
|
||||||
|
|
||||||
const decorations = ranges
|
|
||||||
.sort((a, b) => {
|
|
||||||
return a.start.byteOffset - b.start.byteOffset;
|
|
||||||
})
|
|
||||||
.filter(({ start, end }) => {
|
|
||||||
const startLine = start.lineNumber - lineOffset;
|
|
||||||
const endLine = end.lineNumber - lineOffset;
|
|
||||||
|
|
||||||
if (
|
|
||||||
startLine < 1 ||
|
|
||||||
endLine < 1 ||
|
|
||||||
startLine > document.lines ||
|
|
||||||
endLine > document.lines
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map(({ start, end }) => {
|
|
||||||
const startLine = start.lineNumber - lineOffset;
|
|
||||||
const endLine = end.lineNumber - lineOffset;
|
|
||||||
|
|
||||||
const from = document.line(startLine).from + start.column - 1;
|
|
||||||
const to = document.line(endLine).from + end.column - 1;
|
|
||||||
return markDecoration.range(from, to);
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.from - b.from);
|
|
||||||
|
|
||||||
return Decoration.set(decorations);
|
|
||||||
},
|
|
||||||
update(highlights: DecorationSet, _transaction: Transaction) {
|
|
||||||
return highlights;
|
|
||||||
},
|
|
||||||
provide: (field) => EditorView.decorations.from(field),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}, [language, lineOffset, ranges, theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LightweightCodeMirror
|
|
||||||
ref={editorRef}
|
|
||||||
value={content}
|
|
||||||
extensions={extensions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +1,34 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { CodePreview } from "./codePreview";
|
|
||||||
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
||||||
import { base64Decode } from "@/lib/utils";
|
import { base64Decode } from "@/lib/utils";
|
||||||
|
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||||
|
|
||||||
|
|
||||||
interface FileMatchProps {
|
interface FileMatchProps {
|
||||||
match: SearchResultChunk;
|
match: SearchResultChunk;
|
||||||
file: SearchResultFile;
|
file: SearchResultFile;
|
||||||
onOpen: () => void;
|
onOpen: (startLineNumber: number, endLineNumber: number, isCtrlKeyPressed: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileMatch = ({
|
export const FileMatch = ({
|
||||||
match,
|
match,
|
||||||
file,
|
file,
|
||||||
onOpen,
|
onOpen: _onOpen,
|
||||||
}: FileMatchProps) => {
|
}: FileMatchProps) => {
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
return base64Decode(match.content);
|
return base64Decode(match.content);
|
||||||
}, [match.content]);
|
}, [match.content]);
|
||||||
|
|
||||||
|
const onOpen = useCallback((isCtrlKeyPressed: boolean) => {
|
||||||
|
const startLineNumber = match.contentStart.lineNumber;
|
||||||
|
const endLineNumber = content.trimEnd().split('\n').length + startLineNumber - 1;
|
||||||
|
|
||||||
|
_onOpen(startLineNumber, endLineNumber, isCtrlKeyPressed);
|
||||||
|
}, [content, match.contentStart.lineNumber, _onOpen]);
|
||||||
|
|
||||||
// If it's just the title, don't show a code preview
|
// If it's just the title, don't show a code preview
|
||||||
if (match.matchRanges.length === 0) {
|
if (match.matchRanges.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -29,21 +37,28 @@ export const FileMatch = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="cursor-pointer focus:ring-inset focus:ring-4 bg-white dark:bg-[#282c34]"
|
className="cursor-pointer focus:ring-inset focus:ring-4 bg-background hover:bg-editor-lineHighlight"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Enter") {
|
if (e.key !== "Enter") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onOpen();
|
|
||||||
|
onOpen(e.metaKey || e.ctrlKey);
|
||||||
}}
|
}}
|
||||||
onClick={onOpen}
|
onClick={(e) => {
|
||||||
|
onOpen(e.metaKey || e.ctrlKey);
|
||||||
|
}}
|
||||||
|
title="open file: click, open file preview: cmd/ctrl + click"
|
||||||
>
|
>
|
||||||
<CodePreview
|
<LightweightCodeHighlighter
|
||||||
content={content}
|
|
||||||
language={file.language}
|
language={file.language}
|
||||||
ranges={match.matchRanges}
|
highlightRanges={match.matchRanges}
|
||||||
lineOffset={match.contentStart.lineNumber - 1}
|
lineNumbers={true}
|
||||||
/>
|
lineNumbersOffset={match.contentStart.lineNumber}
|
||||||
|
renderWhitespace={true}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</LightweightCodeHighlighter>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3,16 +3,17 @@
|
||||||
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
import { FileHeader } from "@/app/[domain]/components/fileHeader";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { FileMatch } from "./fileMatch";
|
import { FileMatch } from "./fileMatch";
|
||||||
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
|
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
||||||
|
|
||||||
export const MAX_MATCHES_TO_PREVIEW = 3;
|
export const MAX_MATCHES_TO_PREVIEW = 3;
|
||||||
|
|
||||||
interface FileMatchContainerProps {
|
interface FileMatchContainerProps {
|
||||||
file: SearchResultFile;
|
file: SearchResultFile;
|
||||||
onOpenFile: () => void;
|
onOpenFilePreview: (matchIndex?: number) => void;
|
||||||
onMatchIndexChanged: (matchIndex: number) => void;
|
|
||||||
showAllMatches: boolean;
|
showAllMatches: boolean;
|
||||||
onShowAllMatchesButtonClicked: () => void;
|
onShowAllMatchesButtonClicked: () => void;
|
||||||
isBranchFilteringEnabled: boolean;
|
isBranchFilteringEnabled: boolean;
|
||||||
|
|
@ -22,18 +23,17 @@ interface FileMatchContainerProps {
|
||||||
|
|
||||||
export const FileMatchContainer = ({
|
export const FileMatchContainer = ({
|
||||||
file,
|
file,
|
||||||
onOpenFile,
|
onOpenFilePreview,
|
||||||
onMatchIndexChanged,
|
|
||||||
showAllMatches,
|
showAllMatches,
|
||||||
onShowAllMatchesButtonClicked,
|
onShowAllMatchesButtonClicked,
|
||||||
isBranchFilteringEnabled,
|
isBranchFilteringEnabled,
|
||||||
repoInfo,
|
repoInfo,
|
||||||
yOffset,
|
yOffset,
|
||||||
}: FileMatchContainerProps) => {
|
}: FileMatchContainerProps) => {
|
||||||
|
|
||||||
const matchCount = useMemo(() => {
|
const matchCount = useMemo(() => {
|
||||||
return file.chunks.length;
|
return file.chunks.length;
|
||||||
}, [file]);
|
}, [file]);
|
||||||
|
const { navigateToPath } = useBrowseNavigation();
|
||||||
|
|
||||||
const matches = useMemo(() => {
|
const matches = useMemo(() => {
|
||||||
const sortedMatches = file.chunks.sort((a, b) => {
|
const sortedMatches = file.chunks.sort((a, b) => {
|
||||||
|
|
@ -63,14 +63,6 @@ export const FileMatchContainer = ({
|
||||||
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
return matchCount > MAX_MATCHES_TO_PREVIEW;
|
||||||
}, [matchCount]);
|
}, [matchCount]);
|
||||||
|
|
||||||
const onOpenMatch = useCallback((index: number) => {
|
|
||||||
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
|
||||||
return acc + match.matchRanges.length;
|
|
||||||
}, 0);
|
|
||||||
onOpenFile();
|
|
||||||
onMatchIndexChanged(matchIndex);
|
|
||||||
}, [matches, onMatchIndexChanged, onOpenFile]);
|
|
||||||
|
|
||||||
const branches = useMemo(() => {
|
const branches = useMemo(() => {
|
||||||
if (!file.branches) {
|
if (!file.branches) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -91,18 +83,14 @@ export const FileMatchContainer = ({
|
||||||
return repoInfo[file.repositoryId];
|
return repoInfo[file.repositoryId];
|
||||||
}, [repoInfo, file.repositoryId]);
|
}, [repoInfo, file.repositoryId]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div
|
<div
|
||||||
className="bg-accent primary-foreground px-2 py-0.5 flex flex-row items-center justify-between cursor-pointer sticky top-0 z-10"
|
className="bg-accent primary-foreground px-2 py-0.5 flex flex-row items-center justify-between sticky top-0 z-10"
|
||||||
style={{
|
style={{
|
||||||
top: `-${yOffset}px`,
|
top: `-${yOffset}px`,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
|
||||||
onOpenFile();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FileHeader
|
<FileHeader
|
||||||
repo={{
|
repo={{
|
||||||
|
|
@ -116,6 +104,15 @@ export const FileMatchContainer = ({
|
||||||
branchDisplayName={branchDisplayName}
|
branchDisplayName={branchDisplayName}
|
||||||
branchDisplayTitle={branches.join(", ")}
|
branchDisplayTitle={branches.join(", ")}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-blue-500 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenFilePreview();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matches */}
|
{/* Matches */}
|
||||||
|
|
@ -126,8 +123,28 @@ export const FileMatchContainer = ({
|
||||||
<FileMatch
|
<FileMatch
|
||||||
match={match}
|
match={match}
|
||||||
file={file}
|
file={file}
|
||||||
onOpen={() => {
|
onOpen={(startLineNumber, endLineNumber, isCtrlKeyPressed) => {
|
||||||
onOpenMatch(index);
|
if (isCtrlKeyPressed) {
|
||||||
|
const matchIndex = matches.slice(0, index).reduce((acc, match) => {
|
||||||
|
return acc + match.matchRanges.length;
|
||||||
|
}, 0);
|
||||||
|
onOpenFilePreview(matchIndex);
|
||||||
|
} else {
|
||||||
|
navigateToPath({
|
||||||
|
repoName: file.repository,
|
||||||
|
revisionName: file.branches?.[0] ?? 'HEAD',
|
||||||
|
path: file.fileName.text,
|
||||||
|
pathType: 'blob',
|
||||||
|
highlightRange: {
|
||||||
|
start: {
|
||||||
|
lineNumber: startLineNumber,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
lineNumber: endLineNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
|
{(index !== matches.length - 1 || isMoreContentButtonVisible) && (
|
||||||
|
|
@ -140,7 +157,7 @@ export const FileMatchContainer = ({
|
||||||
{isMoreContentButtonVisible && (
|
{isMoreContentButtonVisible && (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="px-4 bg-accent p-0.5"
|
className="px-4 bg-accent p-0.5 group focus:outline-none"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "Enter") {
|
if (e.key !== "Enter") {
|
||||||
return;
|
return;
|
||||||
|
|
@ -150,7 +167,7 @@ export const FileMatchContainer = ({
|
||||||
onClick={onShowAllMatchesButtonClicked}
|
onClick={onShowAllMatchesButtonClicked}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className="text-blue-500 cursor-pointer text-sm flex flex-row items-center gap-2"
|
className="text-blue-500 w-fit cursor-pointer text-sm flex flex-row items-center gap-2 group-focus:ring-2 group-focus:ring-blue-500 rounded-sm"
|
||||||
>
|
>
|
||||||
{showAllMatches ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
|
{showAllMatches ? <DoubleArrowUpIcon className="w-3 h-3" /> : <DoubleArrowDownIcon className="w-3 h-3" />}
|
||||||
{showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
|
{showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
|
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
|
||||||
import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer";
|
import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useDebounce, usePrevious } from "@uidotdev/usehooks";
|
||||||
|
|
||||||
interface SearchResultsPanelProps {
|
interface SearchResultsPanelProps {
|
||||||
fileMatches: SearchResultFile[];
|
fileMatches: SearchResultFile[];
|
||||||
onOpenFileMatch: (fileMatch: SearchResultFile) => void;
|
onOpenFilePreview: (fileMatch: SearchResultFile, matchIndex?: number) => void;
|
||||||
onMatchIndexChanged: (matchIndex: number) => void;
|
|
||||||
isLoadMoreButtonVisible: boolean;
|
isLoadMoreButtonVisible: boolean;
|
||||||
onLoadMoreButtonClicked: () => void;
|
onLoadMoreButtonClicked: () => void;
|
||||||
isBranchFilteringEnabled: boolean;
|
isBranchFilteringEnabled: boolean;
|
||||||
|
|
@ -19,18 +19,33 @@ const ESTIMATED_LINE_HEIGHT_PX = 20;
|
||||||
const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10;
|
const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10;
|
||||||
const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30;
|
const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30;
|
||||||
|
|
||||||
|
type ScrollHistoryState = {
|
||||||
|
scrollOffset?: number;
|
||||||
|
measurementsCache?: VirtualItem[];
|
||||||
|
showAllMatchesStates?: boolean[];
|
||||||
|
}
|
||||||
|
|
||||||
export const SearchResultsPanel = ({
|
export const SearchResultsPanel = ({
|
||||||
fileMatches,
|
fileMatches,
|
||||||
onOpenFileMatch,
|
onOpenFilePreview,
|
||||||
onMatchIndexChanged,
|
|
||||||
isLoadMoreButtonVisible,
|
isLoadMoreButtonVisible,
|
||||||
onLoadMoreButtonClicked,
|
onLoadMoreButtonClicked,
|
||||||
isBranchFilteringEnabled,
|
isBranchFilteringEnabled,
|
||||||
repoInfo,
|
repoInfo,
|
||||||
}: SearchResultsPanelProps) => {
|
}: SearchResultsPanelProps) => {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false));
|
|
||||||
const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1);
|
// Restore the scroll offset, measurements cache, and other state from the history
|
||||||
|
// state. This enables us to restore the scroll offset when the user navigates back
|
||||||
|
// to the page.
|
||||||
|
// @see: https://github.com/TanStack/virtual/issues/378#issuecomment-2173670081
|
||||||
|
const {
|
||||||
|
scrollOffset: restoreOffset,
|
||||||
|
measurementsCache: restoreMeasurementsCache,
|
||||||
|
showAllMatchesStates: restoreShowAllMatchesStates,
|
||||||
|
} = history.state as ScrollHistoryState;
|
||||||
|
|
||||||
|
const [showAllMatchesStates, setShowAllMatchesStates] = useState(restoreShowAllMatchesStates || Array(fileMatches.length).fill(false));
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: fileMatches.length,
|
count: fileMatches.length,
|
||||||
|
|
@ -51,60 +66,55 @@ export const SearchResultsPanel = ({
|
||||||
|
|
||||||
return estimatedSize;
|
return estimatedSize;
|
||||||
},
|
},
|
||||||
measureElement: (element, _entry, instance) => {
|
initialOffset: restoreOffset,
|
||||||
// @note : Stutters were appearing when scrolling upwards. The workaround is
|
initialMeasurementsCache: restoreMeasurementsCache,
|
||||||
// to use the cached height of the element when scrolling up.
|
|
||||||
// @see : https://github.com/TanStack/virtual/issues/659
|
|
||||||
const isCacheDirty = element.hasAttribute("data-cache-dirty");
|
|
||||||
element.removeAttribute("data-cache-dirty");
|
|
||||||
const direction = instance.scrollDirection;
|
|
||||||
if (direction === "forward" || direction === null || isCacheDirty) {
|
|
||||||
return element.scrollHeight;
|
|
||||||
} else {
|
|
||||||
const indexKey = Number(element.getAttribute("data-index"));
|
|
||||||
// Unfortunately, the cache is a private property, so we need to
|
|
||||||
// hush the TS compiler.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
const cacheMeasurement = instance.itemSizeCache.get(indexKey);
|
|
||||||
return cacheMeasurement;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onShowAllMatchesButtonClicked = useCallback((index: number) => {
|
// When the number of file matches changes, we need to reset our scroll state.
|
||||||
const states = [...showAllMatchesStates];
|
const prevFileMatches = usePrevious(fileMatches);
|
||||||
states[index] = !states[index];
|
useEffect(() => {
|
||||||
setShowAllMatchesStates(states);
|
if (!prevFileMatches) {
|
||||||
setLastShowAllMatchesButtonClickIndex(index);
|
|
||||||
}, [showAllMatchesStates]);
|
|
||||||
|
|
||||||
// After the "show N more/less matches" button is clicked, the FileMatchContainer's
|
|
||||||
// size can change considerably. In cases where N > 3 or 4 cells when collapsing,
|
|
||||||
// a visual artifact can appear where there is a large gap between the now collapsed
|
|
||||||
// container and the next container. This is because the container's height was not
|
|
||||||
// re-calculated. To get arround this, we force a re-measure of the element AFTER
|
|
||||||
// it was re-rendered (hence the useLayoutEffect).
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (lastShowAllMatchesButtonClickIndex < 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = virtualizer.elementsCache.get(lastShowAllMatchesButtonClickIndex);
|
if (prevFileMatches.length !== fileMatches.length) {
|
||||||
element?.setAttribute('data-cache-dirty', 'true');
|
setShowAllMatchesStates(Array(fileMatches.length).fill(false));
|
||||||
virtualizer.measureElement(element);
|
virtualizer.scrollToIndex(0);
|
||||||
|
}
|
||||||
|
}, [fileMatches.length, prevFileMatches, virtualizer]);
|
||||||
|
|
||||||
setLastShowAllMatchesButtonClickIndex(-1);
|
// Save the scroll state to the history stack.
|
||||||
}, [lastShowAllMatchesButtonClickIndex, virtualizer]);
|
const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 100);
|
||||||
|
|
||||||
// Reset some state when the file matches change.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowAllMatchesStates(Array(fileMatches.length).fill(false));
|
history.replaceState(
|
||||||
virtualizer.scrollToIndex(0);
|
{
|
||||||
}, [fileMatches, virtualizer]);
|
scrollOffset: debouncedScrollOffset ?? undefined,
|
||||||
|
measurementsCache: virtualizer.measurementsCache,
|
||||||
|
showAllMatchesStates,
|
||||||
|
} satisfies ScrollHistoryState,
|
||||||
|
'',
|
||||||
|
window.location.href
|
||||||
|
);
|
||||||
|
}, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesStates]);
|
||||||
|
|
||||||
|
const onShowAllMatchesButtonClicked = useCallback((index: number) => {
|
||||||
|
const states = [...showAllMatchesStates];
|
||||||
|
const wasShown = states[index];
|
||||||
|
states[index] = !wasShown;
|
||||||
|
setShowAllMatchesStates(states);
|
||||||
|
|
||||||
|
// When collapsing, scroll to the top of the file match container. This ensures
|
||||||
|
// that the focused "show fewer matches" button is visible.
|
||||||
|
if (wasShown) {
|
||||||
|
virtualizer.scrollToIndex(index, {
|
||||||
|
align: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showAllMatchesStates, virtualizer]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -140,11 +150,8 @@ export const SearchResultsPanel = ({
|
||||||
>
|
>
|
||||||
<FileMatchContainer
|
<FileMatchContainer
|
||||||
file={file}
|
file={file}
|
||||||
onOpenFile={() => {
|
onOpenFilePreview={(matchIndex) => {
|
||||||
onOpenFileMatch(file);
|
onOpenFilePreview(file, matchIndex);
|
||||||
}}
|
|
||||||
onMatchIndexChanged={(matchIndex) => {
|
|
||||||
onMatchIndexChanged(matchIndex);
|
|
||||||
}}
|
}}
|
||||||
showAllMatches={showAllMatchesStates[virtualRow.index]}
|
showAllMatches={showAllMatchesStates[virtualRow.index]}
|
||||||
onShowAllMatchesButtonClicked={() => {
|
onShowAllMatchesButtonClicked={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { EditorState, Extension, StateEffect } from "@codemirror/state";
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
||||||
|
|
||||||
interface CodeMirrorProps {
|
|
||||||
value?: string;
|
|
||||||
extensions?: Extension[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodeMirrorRef {
|
|
||||||
editor: HTMLDivElement | null;
|
|
||||||
state?: EditorState;
|
|
||||||
view?: EditorView;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This component provides a lightweight CodeMirror component that has been optimized to
|
|
||||||
* render quickly in the search results panel. Why not use react-codemirror? For whatever reason,
|
|
||||||
* react-codemirror issues many StateEffects when first rendering, causing a stuttery scroll
|
|
||||||
* experience as new cells load. This component is a workaround for that issue and provides
|
|
||||||
* a minimal react wrapper around CodeMirror that avoids this issue.
|
|
||||||
*/
|
|
||||||
const LightweightCodeMirror = forwardRef<CodeMirrorRef, CodeMirrorProps>(({
|
|
||||||
value,
|
|
||||||
extensions,
|
|
||||||
className,
|
|
||||||
}, ref) => {
|
|
||||||
const editor = useRef<HTMLDivElement | null>(null);
|
|
||||||
const viewRef = useRef<EditorView>();
|
|
||||||
const stateRef = useRef<EditorState>();
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
editor: editor.current,
|
|
||||||
state: stateRef.current,
|
|
||||||
view: viewRef.current,
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = EditorState.create({
|
|
||||||
extensions: [], /* extensions are explicitly left out here */
|
|
||||||
doc: value,
|
|
||||||
});
|
|
||||||
stateRef.current = state;
|
|
||||||
|
|
||||||
const view = new EditorView({
|
|
||||||
state,
|
|
||||||
parent: editor.current,
|
|
||||||
});
|
|
||||||
viewRef.current = view;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
view.destroy();
|
|
||||||
viewRef.current = undefined;
|
|
||||||
stateRef.current = undefined;
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewRef.current) {
|
|
||||||
viewRef.current.dispatch({ effects: StateEffect.reconfigure.of(extensions ?? []) });
|
|
||||||
}
|
|
||||||
}, [extensions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
ref={editor}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
LightweightCodeMirror.displayName = "LightweightCodeMirror";
|
|
||||||
|
|
||||||
export { LightweightCodeMirror };
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
|
|
@ -15,7 +14,6 @@ import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
|
||||||
import { search } from "../../api/(client)/client";
|
import { search } from "../../api/(client)/client";
|
||||||
import { TopBar } from "../components/topBar";
|
import { TopBar } from "../components/topBar";
|
||||||
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
||||||
|
|
@ -24,8 +22,17 @@ import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
|
import { RepositoryInfo, SearchResultFile } from "@/features/search/types";
|
||||||
|
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||||
|
import { useFilteredMatches } from "./components/filterPanel/useFilterMatches";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
import { FilterIcon } from "lucide-react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
|
||||||
const DEFAULT_MATCH_COUNT = 10000;
|
const DEFAULT_MAX_MATCH_COUNT = 10000;
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
// We need a suspense boundary here since we are accessing query params
|
// We need a suspense boundary here since we are accessing query params
|
||||||
|
|
@ -41,18 +48,20 @@ export default function SearchPage() {
|
||||||
const SearchPageInternal = () => {
|
const SearchPageInternal = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||||
const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`);
|
|
||||||
const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches;
|
|
||||||
const { setSearchHistory } = useSearchHistory();
|
const { setSearchHistory } = useSearchHistory();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Encodes the number of matches to return in the search response.
|
||||||
|
const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`);
|
||||||
|
const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount;
|
||||||
|
|
||||||
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
|
const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({
|
||||||
queryKey: ["search", searchQuery, matches],
|
queryKey: ["search", searchQuery, maxMatchCount],
|
||||||
queryFn: () => measure(() => unwrapServiceError(search({
|
queryFn: () => measure(() => unwrapServiceError(search({
|
||||||
query: searchQuery,
|
query: searchQuery,
|
||||||
matches,
|
matches: maxMatchCount,
|
||||||
contextLines: 3,
|
contextLines: 3,
|
||||||
whole: false,
|
whole: false,
|
||||||
}, domain)), "client.search"),
|
}, domain)), "client.search"),
|
||||||
|
|
@ -63,6 +72,7 @@ const SearchPageInternal = () => {
|
||||||
enabled: searchQuery.length > 0,
|
enabled: searchQuery.length > 0,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -122,7 +132,7 @@ const SearchPageInternal = () => {
|
||||||
});
|
});
|
||||||
}, [captureEvent, searchQuery, searchResponse]);
|
}, [captureEvent, searchQuery, searchResponse]);
|
||||||
|
|
||||||
const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo } = useMemo(() => {
|
const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo, matchCount } = useMemo(() => {
|
||||||
if (!searchResponse) {
|
if (!searchResponse) {
|
||||||
return {
|
return {
|
||||||
fileMatches: [],
|
fileMatches: [],
|
||||||
|
|
@ -130,6 +140,7 @@ const SearchPageInternal = () => {
|
||||||
totalMatchCount: 0,
|
totalMatchCount: 0,
|
||||||
isBranchFilteringEnabled: false,
|
isBranchFilteringEnabled: false,
|
||||||
repositoryInfo: {},
|
repositoryInfo: {},
|
||||||
|
matchCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,32 +153,21 @@ const SearchPageInternal = () => {
|
||||||
acc[repo.id] = repo;
|
acc[repo.id] = repo;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<number, RepositoryInfo>),
|
}, {} as Record<number, RepositoryInfo>),
|
||||||
|
matchCount: searchResponse.stats.matchCount,
|
||||||
}
|
}
|
||||||
}, [searchResponse]);
|
}, [searchResponse]);
|
||||||
|
|
||||||
const isMoreResultsButtonVisible = useMemo(() => {
|
const isMoreResultsButtonVisible = useMemo(() => {
|
||||||
return totalMatchCount > matches;
|
return totalMatchCount > maxMatchCount;
|
||||||
}, [totalMatchCount, matches]);
|
}, [totalMatchCount, maxMatchCount]);
|
||||||
|
|
||||||
const numMatches = useMemo(() => {
|
|
||||||
// Accumualtes the number of matches across all files
|
|
||||||
return fileMatches.reduce(
|
|
||||||
(acc, file) =>
|
|
||||||
acc + file.chunks.reduce(
|
|
||||||
(acc, chunk) => acc + chunk.matchRanges.length,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}, [fileMatches]);
|
|
||||||
|
|
||||||
const onLoadMoreResults = useCallback(() => {
|
const onLoadMoreResults = useCallback(() => {
|
||||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||||
[SearchQueryParams.query, searchQuery],
|
[SearchQueryParams.query, searchQuery],
|
||||||
[SearchQueryParams.matches, `${matches * 2}`],
|
[SearchQueryParams.matches, `${maxMatchCount * 2}`],
|
||||||
)
|
)
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}, [matches, router, searchQuery, domain]);
|
}, [maxMatchCount, router, searchQuery, domain]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-clip">
|
<div className="flex flex-col h-screen overflow-clip">
|
||||||
|
|
@ -193,7 +193,7 @@ const SearchPageInternal = () => {
|
||||||
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
||||||
repoInfo={repositoryInfo}
|
repoInfo={repositoryInfo}
|
||||||
searchDurationMs={searchDurationMs}
|
searchDurationMs={searchDurationMs}
|
||||||
numMatches={numMatches}
|
numMatches={matchCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -219,22 +219,24 @@ const PanelGroup = ({
|
||||||
searchDurationMs,
|
searchDurationMs,
|
||||||
numMatches,
|
numMatches,
|
||||||
}: PanelGroupProps) => {
|
}: PanelGroupProps) => {
|
||||||
|
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
|
||||||
|
const filteredFileMatches = useFilteredMatches(fileMatches);
|
||||||
|
const filterPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
||||||
const [selectedFile, setSelectedFile] = useState<SearchResultFile | undefined>(undefined);
|
|
||||||
const [filteredFileMatches, setFilteredFileMatches] = useState<SearchResultFile[]>(fileMatches);
|
|
||||||
|
|
||||||
const codePreviewPanelRef = useRef<ImperativePanelHandle>(null);
|
const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false);
|
||||||
useEffect(() => {
|
|
||||||
if (selectedFile) {
|
useHotkeys("mod+b", () => {
|
||||||
codePreviewPanelRef.current?.expand();
|
if (isFilterPanelCollapsed) {
|
||||||
|
filterPanelRef.current?.expand();
|
||||||
} else {
|
} else {
|
||||||
codePreviewPanelRef.current?.collapse();
|
filterPanelRef.current?.collapse();
|
||||||
}
|
}
|
||||||
}, [selectedFile]);
|
}, {
|
||||||
|
enableOnFormTags: true,
|
||||||
const onFilterChanged = useCallback((matches: SearchResultFile[]) => {
|
enableOnContentEditable: true,
|
||||||
setFilteredFileMatches(matches);
|
description: "Toggle filter panel",
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
|
|
@ -243,22 +245,47 @@ const PanelGroup = ({
|
||||||
>
|
>
|
||||||
{/* ~~ Filter panel ~~ */}
|
{/* ~~ Filter panel ~~ */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
|
ref={filterPanelRef}
|
||||||
minSize={20}
|
minSize={20}
|
||||||
maxSize={30}
|
maxSize={30}
|
||||||
defaultSize={20}
|
defaultSize={isFilterPanelCollapsed ? 0 : 20}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
id={'filter-panel'}
|
id={'filter-panel'}
|
||||||
order={1}
|
order={1}
|
||||||
|
onCollapse={() => setIsFilterPanelCollapsed(true)}
|
||||||
|
onExpand={() => setIsFilterPanelCollapsed(false)}
|
||||||
>
|
>
|
||||||
<FilterPanel
|
<FilterPanel
|
||||||
matches={fileMatches}
|
matches={fileMatches}
|
||||||
onFilterChanged={onFilterChanged}
|
|
||||||
repoInfo={repoInfo}
|
repoInfo={repoInfo}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle
|
{isFilterPanelCollapsed && (
|
||||||
className="w-[1px] bg-accent transition-colors delay-50 data-[resize-handle-state=drag]:bg-accent-foreground data-[resize-handle-state=hover]:bg-accent-foreground"
|
<div className="flex flex-col items-center h-full p-2">
|
||||||
/>
|
<Tooltip
|
||||||
|
delayDuration={100}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
filterPanelRef.current?.expand();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="flex flex-row items-center gap-2">
|
||||||
|
<KeyboardShortcutHint shortcut="⌘ B" />
|
||||||
|
<Separator orientation="vertical" className="h-4" />
|
||||||
|
<span>Open filter panel</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AnimatedResizableHandle />
|
||||||
|
|
||||||
{/* ~~ Search results ~~ */}
|
{/* ~~ Search results ~~ */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
|
|
@ -287,11 +314,9 @@ const PanelGroup = ({
|
||||||
{filteredFileMatches.length > 0 ? (
|
{filteredFileMatches.length > 0 ? (
|
||||||
<SearchResultsPanel
|
<SearchResultsPanel
|
||||||
fileMatches={filteredFileMatches}
|
fileMatches={filteredFileMatches}
|
||||||
onOpenFileMatch={(fileMatch) => {
|
onOpenFilePreview={(fileMatch, matchIndex) => {
|
||||||
setSelectedFile(fileMatch);
|
setSelectedMatchIndex(matchIndex ?? 0);
|
||||||
}}
|
setPreviewedFile(fileMatch);
|
||||||
onMatchIndexChanged={(matchIndex) => {
|
|
||||||
setSelectedMatchIndex(matchIndex);
|
|
||||||
}}
|
}}
|
||||||
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
||||||
onLoadMoreButtonClicked={onLoadMoreResults}
|
onLoadMoreButtonClicked={onLoadMoreResults}
|
||||||
|
|
@ -304,25 +329,27 @@ const PanelGroup = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle
|
|
||||||
className="mt-7 w-[1px] bg-accent transition-colors delay-50 data-[resize-handle-state=drag]:bg-accent-foreground data-[resize-handle-state=hover]:bg-accent-foreground"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ~~ Code preview ~~ */}
|
{previewedFile && (
|
||||||
<ResizablePanel
|
<>
|
||||||
ref={codePreviewPanelRef}
|
<AnimatedResizableHandle />
|
||||||
minSize={10}
|
{/* ~~ Code preview ~~ */}
|
||||||
collapsible={true}
|
<ResizablePanel
|
||||||
id={'code-preview-panel'}
|
minSize={10}
|
||||||
order={3}
|
collapsible={true}
|
||||||
>
|
id={'code-preview-panel'}
|
||||||
<CodePreviewPanel
|
order={3}
|
||||||
fileMatch={selectedFile}
|
onCollapse={() => setPreviewedFile(undefined)}
|
||||||
onClose={() => setSelectedFile(undefined)}
|
>
|
||||||
selectedMatchIndex={selectedMatchIndex}
|
<CodePreviewPanel
|
||||||
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
previewedFile={previewedFile}
|
||||||
/>
|
onClose={() => setPreviewedFile(undefined)}
|
||||||
</ResizablePanel>
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
|
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard";
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
interface GeneralSettingsPageProps {
|
interface GeneralSettingsPageProps {
|
||||||
params: {
|
params: {
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|
|
||||||
160
packages/web/src/app/[domain]/settings/apiKeys/columns.tsx
Normal file
160
packages/web/src/app/[domain]/settings/apiKeys/columns.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { ArrowUpDown, Key, Trash2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { deleteApiKey } from "@/actions"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
|
||||||
|
export type ApiKeyColumnInfo = {
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
lastUsedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component for the actions cell to properly use React hooks
|
||||||
|
function ApiKeyActions({ apiKey }: { apiKey: ApiKeyColumnInfo }) {
|
||||||
|
const params = useParams<{ domain: string }>()
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
await deleteApiKey(apiKey.name, params.domain)
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete API key", error)
|
||||||
|
toast({
|
||||||
|
title: "Failed to Delete API Key",
|
||||||
|
description: `There was an error deleting the API key: ${error}`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete API Key</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the API key <span className="font-semibold text-foreground">{apiKey.name}</span>? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isPending ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns = (): ColumnDef<ApiKeyColumnInfo>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: () => <div className="flex items-center w-[300px]">Name</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const name = row.original.name
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<Key className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<div className="w-[200px]">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
className="px-0 font-medium"
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
<ArrowUpDown className="ml-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.createdAt) {
|
||||||
|
return <div className="py-2">—</div>
|
||||||
|
}
|
||||||
|
const date = new Date(row.original.createdAt)
|
||||||
|
return (
|
||||||
|
<div className="py-2 text-muted-foreground">
|
||||||
|
{date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "lastUsedAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<div className="w-[200px]">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
className="px-0 font-medium"
|
||||||
|
>
|
||||||
|
Last Used
|
||||||
|
<ArrowUpDown className="ml-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.lastUsedAt) {
|
||||||
|
return <div className="py-2 text-muted-foreground">Never</div>
|
||||||
|
}
|
||||||
|
const date = new Date(row.original.lastUsedAt)
|
||||||
|
return (
|
||||||
|
<div className="py-2 text-muted-foreground">
|
||||||
|
{date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => <ApiKeyActions apiKey={row.original} />
|
||||||
|
}
|
||||||
|
]
|
||||||
269
packages/web/src/app/[domain]/settings/apiKeys/page.tsx
Normal file
269
packages/web/src/app/[domain]/settings/apiKeys/page.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createApiKey, getUserApiKeys } from "@/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { columns, ApiKeyColumnInfo } from "./columns";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
|
const [apiKeys, setApiKeys] = useState<{ name: string; createdAt: Date; lastUsedAt: Date | null }[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [isCreatingKey, setIsCreatingKey] = useState(false);
|
||||||
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadApiKeys = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const keys = await getUserApiKeys(domain);
|
||||||
|
if (isServiceError(keys)) {
|
||||||
|
setError("Failed to load API keys");
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load API keys",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApiKeys(keys);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setError("Failed to load API keys");
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load API keys",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [domain, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApiKeys();
|
||||||
|
}, [loadApiKeys]);
|
||||||
|
|
||||||
|
const handleCreateApiKey = async () => {
|
||||||
|
if (!newKeyName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "API key name cannot be empty",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingKey(true);
|
||||||
|
try {
|
||||||
|
const result = await createApiKey(newKeyName.trim(), domain);
|
||||||
|
if (isServiceError(result)) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to create API key: ${result.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
captureEvent('wa_api_key_creation_fail', {});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewlyCreatedKey(result.key);
|
||||||
|
await loadApiKeys();
|
||||||
|
captureEvent('wa_api_key_created', {});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Failed to create API key: ${error}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
captureEvent('wa_api_key_creation_fail', {});
|
||||||
|
} finally {
|
||||||
|
setIsCreatingKey(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyApiKey = () => {
|
||||||
|
if (!newlyCreatedKey) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(newlyCreatedKey)
|
||||||
|
.then(() => {
|
||||||
|
setCopySuccess(true);
|
||||||
|
setTimeout(() => setCopySuccess(false), 2000);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to copy API key to clipboard",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
setNewKeyName("");
|
||||||
|
setNewlyCreatedKey(null);
|
||||||
|
setCopySuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableData = useMemo(() => {
|
||||||
|
if (isLoading) return Array(4).fill(null).map(() => ({
|
||||||
|
name: "",
|
||||||
|
createdAt: "",
|
||||||
|
lastUsedAt: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!apiKeys) return [];
|
||||||
|
|
||||||
|
return apiKeys.map((key): ApiKeyColumnInfo => ({
|
||||||
|
name: key.name,
|
||||||
|
createdAt: key.createdAt.toISOString(),
|
||||||
|
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
|
||||||
|
})).sort((a, b) => {
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
});
|
||||||
|
}, [apiKeys, isLoading]);
|
||||||
|
|
||||||
|
const tableColumns = useMemo(() => {
|
||||||
|
if (isLoading) {
|
||||||
|
return columns().map((column) => {
|
||||||
|
if ('accessorKey' in column && column.accessorKey === "name") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
cell: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-md" /> {/* Icon skeleton */}
|
||||||
|
<Skeleton className="h-4 w-48" /> {/* Name skeleton */}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
cell: () => <Skeleton className="h-4 w-24" />,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns();
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>Error loading API keys</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">API Keys</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-lg">
|
||||||
|
Create and manage API keys for programmatic access to Sourcebot. All API keys are scoped to the user who created them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setNewlyCreatedKey(null);
|
||||||
|
setNewKeyName("");
|
||||||
|
setIsCreateDialogOpen(true);
|
||||||
|
}}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create API Key
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{newlyCreatedKey ? 'Your New API Key' : 'Create API Key'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{newlyCreatedKey ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 p-3 border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 rounded-md text-yellow-700 dark:text-yellow-400">
|
||||||
|
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
This is the only time you'll see this API key. Make sure to copy it now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="bg-muted p-2 rounded-md text-sm flex-1 break-all font-mono">
|
||||||
|
{newlyCreatedKey}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCopyApiKey}
|
||||||
|
>
|
||||||
|
{copySuccess ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4">
|
||||||
|
<Input
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="Enter a name for your API key"
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-between">
|
||||||
|
{newlyCreatedKey ? (
|
||||||
|
<Button onClick={handleCloseDialog}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleCloseDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateApiKey}
|
||||||
|
disabled={isCreatingKey || !newKeyName.trim()}
|
||||||
|
>
|
||||||
|
{isCreatingKey && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={tableColumns}
|
||||||
|
data={tableData}
|
||||||
|
searchKey="name"
|
||||||
|
searchPlaceholder="Search API keys..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue