mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
chore(worker,web): Repo indexing stability improvements + perf improvements to web (#563)
This commit is contained in:
parent
5b09757e92
commit
4ebe4e0475
111 changed files with 2922 additions and 5572 deletions
|
|
@ -80,9 +80,6 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection
|
||||||
# Controls the number of concurrent indexing jobs that can run at once
|
# Controls the number of concurrent indexing jobs that can run at once
|
||||||
# INDEX_CONCURRENCY_MULTIPLE=
|
# INDEX_CONCURRENCY_MULTIPLE=
|
||||||
|
|
||||||
# Controls the polling interval for the web app
|
|
||||||
# NEXT_PUBLIC_POLLING_INTERVAL_MS=
|
|
||||||
|
|
||||||
# Controls the version of the web app
|
# Controls the version of the web app
|
||||||
# NEXT_PUBLIC_SOURCEBOT_VERSION=
|
# NEXT_PUBLIC_SOURCEBOT_VERSION=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed "dubious ownership" errors when cloning / fetching repos. [#553](https://github.com/sourcebot-dev/sourcebot/pull/553)
|
- Fixed "dubious ownership" errors when cloning / fetching repos. [#553](https://github.com/sourcebot-dev/sourcebot/pull/553)
|
||||||
|
- Fixed issue with Ask Sourcebot tutorial re-appearing after restarting the browser. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Remove spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552)
|
|
||||||
- Improved search performance for unbounded search queries. [#555](https://github.com/sourcebot-dev/sourcebot/pull/555)
|
- Improved search performance for unbounded search queries. [#555](https://github.com/sourcebot-dev/sourcebot/pull/555)
|
||||||
|
- Improved homepage performance by removing client side polling. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
|
- Changed navbar indexing indicator to only report progress for first time indexing jobs. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
|
- Improved repo indexing job stability and robustness. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552)
|
||||||
|
- Removed connections management page. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Added support for passing db connection url as seperate `DATABASE_HOST`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`, and `DATABASE_ARGS` env vars. [#545](https://github.com/sourcebot-dev/sourcebot/pull/545)
|
- Added support for passing db connection url as seperate `DATABASE_HOST`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`, and `DATABASE_ARGS` env vars. [#545](https://github.com/sourcebot-dev/sourcebot/pull/545)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build",
|
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build",
|
||||||
"test": "yarn workspaces foreach -A run test",
|
"test": "yarn workspaces foreach -A run test",
|
||||||
"dev": "yarn dev:prisma:migrate:dev && npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web watch:mcp watch:schemas",
|
"dev": "concurrently --kill-others --names \"zoekt,worker,web,mcp,schemas\" 'yarn dev:zoekt' 'yarn dev:backend' 'yarn dev:web' 'yarn watch:mcp' 'yarn watch:schemas'",
|
||||||
"with-env": "cross-env PATH=\"$PWD/bin:$PATH\" dotenv -e .env.development -c --",
|
"with-env": "cross-env PATH=\"$PWD/bin:$PATH\" dotenv -e .env.development -c --",
|
||||||
"dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc",
|
"dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc",
|
||||||
"dev:backend": "yarn with-env yarn workspace @sourcebot/backend dev:watch",
|
"dev:backend": "yarn with-env yarn workspace @sourcebot/backend dev:watch",
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
"build:deps": "yarn workspaces foreach -R --from '{@sourcebot/schemas,@sourcebot/error,@sourcebot/crypto,@sourcebot/db,@sourcebot/shared}' run build"
|
"build:deps": "yarn workspaces foreach -R --from '{@sourcebot/schemas,@sourcebot/error,@sourcebot/crypto,@sourcebot/db,@sourcebot/shared}' run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0"
|
||||||
"npm-run-all": "^4.1.5"
|
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.7.0",
|
"packageManager": "yarn@4.7.0",
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"git-url-parse": "^16.1.0",
|
"git-url-parse": "^16.1.0",
|
||||||
"gitea-js": "^1.22.0",
|
"gitea-js": "^1.22.0",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
|
"groupmq": "^1.0.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
|
|
|
||||||
|
|
@ -364,12 +364,12 @@ export class ConnectionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public async dispose() {
|
||||||
if (this.interval) {
|
if (this.interval) {
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
}
|
}
|
||||||
this.worker.close();
|
await this.worker.close();
|
||||||
this.queue.close();
|
await this.queue.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { env } from "./env.js";
|
||||||
import { Settings } from "./types.js";
|
import { Settings } from "./types.js";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default settings.
|
* Default settings.
|
||||||
|
|
@ -23,3 +25,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||||
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
||||||
'github',
|
'github',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');
|
||||||
|
export const INDEX_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'index');
|
||||||
|
|
@ -101,12 +101,12 @@ export class RepoPermissionSyncer {
|
||||||
}, 1000 * 5);
|
}, 1000 * 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public async dispose() {
|
||||||
if (this.interval) {
|
if (this.interval) {
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
}
|
}
|
||||||
this.worker.close();
|
await this.worker.close();
|
||||||
this.queue.close();
|
await this.queue.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async schedulePermissionSync(repos: Repo[]) {
|
private async schedulePermissionSync(repos: Repo[]) {
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,12 @@ export class UserPermissionSyncer {
|
||||||
}, 1000 * 5);
|
}, 1000 * 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public async dispose() {
|
||||||
if (this.interval) {
|
if (this.interval) {
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
}
|
}
|
||||||
this.worker.close();
|
await this.worker.close();
|
||||||
this.queue.close();
|
await this.queue.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async schedulePermissionSync(users: User[]) {
|
private async schedulePermissionSync(users: User[]) {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export const env = createEnv({
|
||||||
LOGTAIL_TOKEN: z.string().optional(),
|
LOGTAIL_TOKEN: z.string().optional(),
|
||||||
LOGTAIL_HOST: z.string().url().optional(),
|
LOGTAIL_HOST: z.string().url().optional(),
|
||||||
SOURCEBOT_LOG_LEVEL: z.enum(["info", "debug", "warn", "error"]).default("info"),
|
SOURCEBOT_LOG_LEVEL: z.enum(["info", "debug", "warn", "error"]).default("info"),
|
||||||
|
DEBUG_ENABLE_GROUPMQ_LOGGING: booleanSchema.default('false'),
|
||||||
|
|
||||||
DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/postgres"),
|
DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/postgres"),
|
||||||
CONFIG_PATH: z.string().optional(),
|
CONFIG_PATH: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,67 @@
|
||||||
import { CheckRepoActions, GitConfigScope, simpleGit, SimpleGitProgressEvent } from 'simple-git';
|
import { CheckRepoActions, GitConfigScope, simpleGit, SimpleGitProgressEvent } from 'simple-git';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
type onProgressFn = (event: SimpleGitProgressEvent) => void;
|
type onProgressFn = (event: SimpleGitProgressEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a simple-git client that has it's working directory
|
||||||
|
* set to the given path.
|
||||||
|
*/
|
||||||
|
const createGitClientForPath = (path: string, onProgress?: onProgressFn, signal?: AbortSignal) => {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
throw new Error(`Path ${path} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = resolve(dirname(path));
|
||||||
|
|
||||||
|
const git = simpleGit({
|
||||||
|
progress: onProgress,
|
||||||
|
abort: signal,
|
||||||
|
})
|
||||||
|
.env({
|
||||||
|
...process.env,
|
||||||
|
/**
|
||||||
|
* @note on some inside-baseball on why this is necessary: The specific
|
||||||
|
* issue we saw was that a `git clone` would fail without throwing, and
|
||||||
|
* then a subsequent `git config` command would run, but since the clone
|
||||||
|
* failed, it wouldn't be running in a git directory. Git would then walk
|
||||||
|
* up the directory tree until it either found a git directory (in the case
|
||||||
|
* of the development env) or it would hit a GIT_DISCOVERY_ACROSS_FILESYSTEM
|
||||||
|
* error when trying to cross a filesystem boundary (in the prod case).
|
||||||
|
* GIT_CEILING_DIRECTORIES ensures that this walk will be limited to the
|
||||||
|
* parent directory.
|
||||||
|
*/
|
||||||
|
GIT_CEILING_DIRECTORIES: parentPath,
|
||||||
|
})
|
||||||
|
.cwd({
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
return git;
|
||||||
|
}
|
||||||
|
|
||||||
export const cloneRepository = async (
|
export const cloneRepository = async (
|
||||||
{
|
{
|
||||||
cloneUrl,
|
cloneUrl,
|
||||||
authHeader,
|
authHeader,
|
||||||
path,
|
path,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
cloneUrl: string,
|
cloneUrl: string,
|
||||||
authHeader?: string,
|
authHeader?: string,
|
||||||
path: string,
|
path: string,
|
||||||
onProgress?: onProgressFn
|
onProgress?: onProgressFn
|
||||||
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await mkdir(path, { recursive: true });
|
await mkdir(path, { recursive: true });
|
||||||
|
|
||||||
const git = simpleGit({
|
const git = createGitClientForPath(path, onProgress, signal);
|
||||||
progress: onProgress,
|
|
||||||
}).cwd({
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const cloneArgs = [
|
const cloneArgs = [
|
||||||
"--bare",
|
"--bare",
|
||||||
|
|
@ -33,7 +70,11 @@ export const cloneRepository = async (
|
||||||
|
|
||||||
await git.clone(cloneUrl, path, cloneArgs);
|
await git.clone(cloneUrl, path, cloneArgs);
|
||||||
|
|
||||||
await unsetGitConfig(path, ["remote.origin.url"]);
|
await unsetGitConfig({
|
||||||
|
path,
|
||||||
|
keys: ["remote.origin.url"],
|
||||||
|
signal,
|
||||||
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const baseLog = `Failed to clone repository: ${path}`;
|
const baseLog = `Failed to clone repository: ${path}`;
|
||||||
|
|
||||||
|
|
@ -54,20 +95,17 @@ export const fetchRepository = async (
|
||||||
authHeader,
|
authHeader,
|
||||||
path,
|
path,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
cloneUrl: string,
|
cloneUrl: string,
|
||||||
authHeader?: string,
|
authHeader?: string,
|
||||||
path: string,
|
path: string,
|
||||||
onProgress?: onProgressFn
|
onProgress?: onProgressFn,
|
||||||
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
|
const git = createGitClientForPath(path, onProgress, signal);
|
||||||
try {
|
try {
|
||||||
const git = simpleGit({
|
|
||||||
progress: onProgress,
|
|
||||||
}).cwd({
|
|
||||||
path: path,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
await git.addConfig("http.extraHeader", authHeader);
|
await git.addConfig("http.extraHeader", authHeader);
|
||||||
}
|
}
|
||||||
|
|
@ -90,12 +128,6 @@ export const fetchRepository = async (
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
const git = simpleGit({
|
|
||||||
progress: onProgress,
|
|
||||||
}).cwd({
|
|
||||||
path: path,
|
|
||||||
})
|
|
||||||
|
|
||||||
await git.raw(["config", "--unset", "http.extraHeader", authHeader]);
|
await git.raw(["config", "--unset", "http.extraHeader", authHeader]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,10 +139,19 @@ export const fetchRepository = async (
|
||||||
* that do not exist yet. It will _not_ remove any existing keys that are not
|
* that do not exist yet. It will _not_ remove any existing keys that are not
|
||||||
* present in gitConfig.
|
* present in gitConfig.
|
||||||
*/
|
*/
|
||||||
export const upsertGitConfig = async (path: string, gitConfig: Record<string, string>, onProgress?: onProgressFn) => {
|
export const upsertGitConfig = async (
|
||||||
const git = simpleGit({
|
{
|
||||||
progress: onProgress,
|
path,
|
||||||
}).cwd(path);
|
gitConfig,
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
path: string,
|
||||||
|
gitConfig: Record<string, string>,
|
||||||
|
onProgress?: onProgressFn,
|
||||||
|
signal?: AbortSignal
|
||||||
|
}) => {
|
||||||
|
const git = createGitClientForPath(path, onProgress, signal);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const [key, value] of Object.entries(gitConfig)) {
|
for (const [key, value] of Object.entries(gitConfig)) {
|
||||||
|
|
@ -129,10 +170,19 @@ export const upsertGitConfig = async (path: string, gitConfig: Record<string, st
|
||||||
* Unsets the specified keys in the git config for the repo at the given path.
|
* Unsets the specified keys in the git config for the repo at the given path.
|
||||||
* If a key is not set, this is a no-op.
|
* If a key is not set, this is a no-op.
|
||||||
*/
|
*/
|
||||||
export const unsetGitConfig = async (path: string, keys: string[], onProgress?: onProgressFn) => {
|
export const unsetGitConfig = async (
|
||||||
const git = simpleGit({
|
{
|
||||||
progress: onProgress,
|
path,
|
||||||
}).cwd(path);
|
keys,
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
path: string,
|
||||||
|
keys: string[],
|
||||||
|
onProgress?: onProgressFn,
|
||||||
|
signal?: AbortSignal
|
||||||
|
}) => {
|
||||||
|
const git = createGitClientForPath(path, onProgress, signal);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configList = await git.listConfig();
|
const configList = await git.listConfig();
|
||||||
|
|
@ -155,10 +205,20 @@ export const unsetGitConfig = async (path: string, keys: string[], onProgress?:
|
||||||
/**
|
/**
|
||||||
* Returns true if `path` is the _root_ of a git repository.
|
* Returns true if `path` is the _root_ of a git repository.
|
||||||
*/
|
*/
|
||||||
export const isPathAValidGitRepoRoot = async (path: string, onProgress?: onProgressFn) => {
|
export const isPathAValidGitRepoRoot = async ({
|
||||||
const git = simpleGit({
|
path,
|
||||||
progress: onProgress,
|
onProgress,
|
||||||
}).cwd(path);
|
signal,
|
||||||
|
}: {
|
||||||
|
path: string,
|
||||||
|
onProgress?: onProgressFn,
|
||||||
|
signal?: AbortSignal
|
||||||
|
}) => {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = createGitClientForPath(path, onProgress, signal);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
return git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
||||||
|
|
@ -184,7 +244,7 @@ export const isUrlAValidGitRepo = async (url: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getOriginUrl = async (path: string) => {
|
export const getOriginUrl = async (path: string) => {
|
||||||
const git = simpleGit().cwd(path);
|
const git = createGitClientForPath(path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const remotes = await git.getConfig('remote.origin.url', GitConfigScope.local);
|
const remotes = await git.getConfig('remote.origin.url', GitConfigScope.local);
|
||||||
|
|
@ -199,18 +259,13 @@ export const getOriginUrl = async (path: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getBranches = async (path: string) => {
|
export const getBranches = async (path: string) => {
|
||||||
const git = simpleGit();
|
const git = createGitClientForPath(path);
|
||||||
const branches = await git.cwd({
|
const branches = await git.branch();
|
||||||
path,
|
|
||||||
}).branch();
|
|
||||||
|
|
||||||
return branches.all;
|
return branches.all;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTags = async (path: string) => {
|
export const getTags = async (path: string) => {
|
||||||
const git = simpleGit();
|
const git = createGitClientForPath(path);
|
||||||
const tags = await git.cwd({
|
const tags = await git.tags();
|
||||||
path,
|
|
||||||
}).tags();
|
|
||||||
return tags.all;
|
return tags.all;
|
||||||
}
|
}
|
||||||
|
|
@ -6,15 +6,13 @@ import { hasEntitlement, loadConfig } from '@sourcebot/shared';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import path from 'path';
|
|
||||||
import { ConnectionManager } from './connectionManager.js';
|
import { ConnectionManager } from './connectionManager.js';
|
||||||
import { DEFAULT_SETTINGS } from './constants.js';
|
import { DEFAULT_SETTINGS, INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js';
|
||||||
import { env } from "./env.js";
|
|
||||||
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
||||||
import { PromClient } from './promClient.js';
|
|
||||||
import { RepoManager } from './repoManager.js';
|
|
||||||
import { AppContext } from "./types.js";
|
|
||||||
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { RepoIndexManager } from "./repoIndexManager.js";
|
||||||
|
import { PromClient } from './promClient.js';
|
||||||
|
|
||||||
|
|
||||||
const logger = createLogger('backend-entrypoint');
|
const logger = createLogger('backend-entrypoint');
|
||||||
|
|
@ -33,9 +31,8 @@ const getSettings = async (configPath?: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const cacheDir = env.DATA_CACHE_DIR;
|
const reposPath = REPOS_CACHE_DIR;
|
||||||
const reposPath = path.join(cacheDir, 'repos');
|
const indexPath = INDEX_CACHE_DIR;
|
||||||
const indexPath = path.join(cacheDir, 'index');
|
|
||||||
|
|
||||||
if (!existsSync(reposPath)) {
|
if (!existsSync(reposPath)) {
|
||||||
await mkdir(reposPath, { recursive: true });
|
await mkdir(reposPath, { recursive: true });
|
||||||
|
|
@ -44,12 +41,6 @@ if (!existsSync(indexPath)) {
|
||||||
await mkdir(indexPath, { recursive: true });
|
await mkdir(indexPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const context: AppContext = {
|
|
||||||
indexPath,
|
|
||||||
reposPath,
|
|
||||||
cachePath: cacheDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const redis = new Redis(env.REDIS_URL, {
|
const redis = new Redis(env.REDIS_URL, {
|
||||||
|
|
@ -68,14 +59,12 @@ const promClient = new PromClient();
|
||||||
const settings = await getSettings(env.CONFIG_PATH);
|
const settings = await getSettings(env.CONFIG_PATH);
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager(prisma, settings, redis);
|
const connectionManager = new ConnectionManager(prisma, settings, redis);
|
||||||
const repoManager = new RepoManager(prisma, settings, redis, promClient, context);
|
|
||||||
const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
|
const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
|
||||||
const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
|
const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
|
||||||
|
const repoIndexManager = new RepoIndexManager(prisma, settings, redis);
|
||||||
await repoManager.validateIndexedReposHaveShards();
|
|
||||||
|
|
||||||
connectionManager.startScheduler();
|
connectionManager.startScheduler();
|
||||||
repoManager.startScheduler();
|
repoIndexManager.startScheduler();
|
||||||
|
|
||||||
if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) {
|
if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) {
|
||||||
logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.');
|
logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.');
|
||||||
|
|
@ -87,12 +76,27 @@ else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement(
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanup = async (signal: string) => {
|
const cleanup = async (signal: string) => {
|
||||||
logger.info(`Recieved ${signal}, cleaning up...`);
|
logger.info(`Received ${signal}, cleaning up...`);
|
||||||
|
|
||||||
connectionManager.dispose();
|
const shutdownTimeout = 30000; // 30 seconds
|
||||||
repoManager.dispose();
|
|
||||||
repoPermissionSyncer.dispose();
|
try {
|
||||||
userPermissionSyncer.dispose();
|
await Promise.race([
|
||||||
|
Promise.all([
|
||||||
|
repoIndexManager.dispose(),
|
||||||
|
connectionManager.dispose(),
|
||||||
|
repoPermissionSyncer.dispose(),
|
||||||
|
userPermissionSyncer.dispose(),
|
||||||
|
promClient.dispose(),
|
||||||
|
]),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Shutdown timeout')), shutdownTimeout)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
logger.info('All workers shut down gracefully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Shutdown timeout or error, forcing exit:', error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await redis.quit();
|
await redis.quit();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
|
import { Server } from 'http';
|
||||||
import client, { Registry, Counter, Gauge } from 'prom-client';
|
import client, { Registry, Counter, Gauge } from 'prom-client';
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
|
||||||
|
|
@ -7,6 +8,8 @@ const logger = createLogger('prometheus-client');
|
||||||
export class PromClient {
|
export class PromClient {
|
||||||
private registry: Registry;
|
private registry: Registry;
|
||||||
private app: express.Application;
|
private app: express.Application;
|
||||||
|
private server: Server;
|
||||||
|
|
||||||
public activeRepoIndexingJobs: Gauge<string>;
|
public activeRepoIndexingJobs: Gauge<string>;
|
||||||
public pendingRepoIndexingJobs: Gauge<string>;
|
public pendingRepoIndexingJobs: Gauge<string>;
|
||||||
public repoIndexingReattemptsTotal: Counter<string>;
|
public repoIndexingReattemptsTotal: Counter<string>;
|
||||||
|
|
@ -98,12 +101,17 @@ export class PromClient {
|
||||||
res.end(metrics);
|
res.end(metrics);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.listen(this.PORT, () => {
|
this.server = this.app.listen(this.PORT, () => {
|
||||||
logger.info(`Prometheus metrics server is running on port ${this.PORT}`);
|
logger.info(`Prometheus metrics server is running on port ${this.PORT}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getRegistry(): Registry {
|
async dispose() {
|
||||||
return this.registry;
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +497,9 @@ export const compileGenericGitHostConfig_file = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all(repoPaths.map(async (repoPath) => {
|
await Promise.all(repoPaths.map(async (repoPath) => {
|
||||||
const isGitRepo = await isPathAValidGitRepoRoot(repoPath);
|
const isGitRepo = await isPathAValidGitRepoRoot({
|
||||||
|
path: repoPath,
|
||||||
|
});
|
||||||
if (!isGitRepo) {
|
if (!isGitRepo) {
|
||||||
logger.warn(`Skipping ${repoPath} - not a git repository.`);
|
logger.warn(`Skipping ${repoPath} - not a git repository.`);
|
||||||
notFound.repos.push(repoPath);
|
notFound.repos.push(repoPath);
|
||||||
|
|
|
||||||
456
packages/backend/src/repoIndexManager.ts
Normal file
456
packages/backend/src/repoIndexManager.ts
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
import * as Sentry from '@sentry/node';
|
||||||
|
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
||||||
|
import { createLogger, Logger } from "@sourcebot/logger";
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { readdir, rm } from 'fs/promises';
|
||||||
|
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { INDEX_CACHE_DIR } from './constants.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { cloneRepository, fetchRepository, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js';
|
||||||
|
import { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js";
|
||||||
|
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
|
||||||
|
import { indexGitRepository } from './zoekt.js';
|
||||||
|
|
||||||
|
const LOG_TAG = 'repo-index-manager';
|
||||||
|
const logger = createLogger(LOG_TAG);
|
||||||
|
const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId}`);
|
||||||
|
|
||||||
|
type JobPayload = {
|
||||||
|
type: 'INDEX' | 'CLEANUP';
|
||||||
|
jobId: string;
|
||||||
|
repoId: number;
|
||||||
|
repoName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOB_TIMEOUT_MS = 1000 * 60 * 60 * 6; // 6 hour indexing timeout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the lifecycle of repository data on disk, including git working copies
|
||||||
|
* and search index shards. Handles both indexing operations (cloning/fetching repos
|
||||||
|
* and building search indexes) and cleanup operations (removing orphaned repos and
|
||||||
|
* their associated data).
|
||||||
|
*
|
||||||
|
* Uses a job queue system to process indexing and cleanup tasks asynchronously,
|
||||||
|
* with configurable concurrency limits and retry logic. Automatically schedules
|
||||||
|
* re-indexing of repos based on configured intervals and manages garbage collection
|
||||||
|
* of repos that are no longer connected to any source.
|
||||||
|
*/
|
||||||
|
export class RepoIndexManager {
|
||||||
|
private interval?: NodeJS.Timeout;
|
||||||
|
private queue: Queue<JobPayload>;
|
||||||
|
private worker: Worker<JobPayload>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: PrismaClient,
|
||||||
|
private settings: Settings,
|
||||||
|
redis: Redis,
|
||||||
|
) {
|
||||||
|
this.queue = new Queue<JobPayload>({
|
||||||
|
redis,
|
||||||
|
namespace: 'repo-index-queue',
|
||||||
|
jobTimeoutMs: JOB_TIMEOUT_MS,
|
||||||
|
maxAttempts: 3,
|
||||||
|
logger: env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker = new Worker<JobPayload>({
|
||||||
|
queue: this.queue,
|
||||||
|
maxStalledCount: 1,
|
||||||
|
handler: this.runJob.bind(this),
|
||||||
|
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
||||||
|
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
||||||
|
logger: true,
|
||||||
|
}: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||||
|
this.worker.on('failed', this.onJobFailed.bind(this));
|
||||||
|
this.worker.on('stalled', this.onJobStalled.bind(this));
|
||||||
|
this.worker.on('error', this.onWorkerError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startScheduler() {
|
||||||
|
logger.debug('Starting scheduler');
|
||||||
|
this.interval = setInterval(async () => {
|
||||||
|
await this.scheduleIndexJobs();
|
||||||
|
await this.scheduleCleanupJobs();
|
||||||
|
}, 1000 * 5);
|
||||||
|
|
||||||
|
this.worker.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scheduleIndexJobs() {
|
||||||
|
const thresholdDate = new Date(Date.now() - this.settings.reindexIntervalMs);
|
||||||
|
const reposToIndex = await this.db.repo.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ indexedAt: null },
|
||||||
|
{ indexedAt: { lt: thresholdDate } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
jobs: {
|
||||||
|
some: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
type: RepoIndexingJobType.INDEX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
// Don't schedule if there are active jobs that were created within the threshold date.
|
||||||
|
// This handles the case where a job is stuck in a pending state and will never be scheduled.
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
RepoIndexingJobStatus.PENDING,
|
||||||
|
RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: {
|
||||||
|
gt: thresholdDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Don't schedule if there are recent failed jobs (within the threshold date).
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ status: RepoIndexingJobStatus.FAILED },
|
||||||
|
{ completedAt: { gt: thresholdDate } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reposToIndex.length > 0) {
|
||||||
|
await this.createJobs(reposToIndex, RepoIndexingJobType.INDEX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scheduleCleanupJobs() {
|
||||||
|
const thresholdDate = new Date(Date.now() - this.settings.repoGarbageCollectionGracePeriodMs);
|
||||||
|
|
||||||
|
const reposToCleanup = await this.db.repo.findMany({
|
||||||
|
where: {
|
||||||
|
connections: {
|
||||||
|
none: {}
|
||||||
|
},
|
||||||
|
OR: [
|
||||||
|
{ indexedAt: null },
|
||||||
|
{ indexedAt: { lt: thresholdDate } },
|
||||||
|
],
|
||||||
|
// Don't schedule if there are active jobs that were created within the threshold date.
|
||||||
|
NOT: {
|
||||||
|
jobs: {
|
||||||
|
some: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
type: RepoIndexingJobType.CLEANUP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
RepoIndexingJobStatus.PENDING,
|
||||||
|
RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: {
|
||||||
|
gt: thresholdDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reposToCleanup.length > 0) {
|
||||||
|
await this.createJobs(reposToCleanup, RepoIndexingJobType.CLEANUP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createJobs(repos: Repo[], type: RepoIndexingJobType) {
|
||||||
|
// @note: we don't perform this in a transaction because
|
||||||
|
// we want to avoid the situation where a job is created and run
|
||||||
|
// prior to the transaction being committed.
|
||||||
|
const jobs = await this.db.repoIndexingJob.createManyAndReturn({
|
||||||
|
data: repos.map(repo => ({
|
||||||
|
type,
|
||||||
|
repoId: repo.id,
|
||||||
|
})),
|
||||||
|
include: {
|
||||||
|
repo: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
await this.queue.add({
|
||||||
|
groupId: `repo:${job.repoId}`,
|
||||||
|
data: {
|
||||||
|
jobId: job.id,
|
||||||
|
type,
|
||||||
|
repoName: job.repo.name,
|
||||||
|
repoId: job.repo.id,
|
||||||
|
},
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runJob(job: ReservedJob<JobPayload>) {
|
||||||
|
const id = job.data.jobId;
|
||||||
|
const logger = createJobLogger(id);
|
||||||
|
logger.info(`Running ${job.data.type} job ${id} for repo ${job.data.repoName} (id: ${job.data.repoId}) (attempt ${job.attempts + 1} / ${job.maxAttempts})`);
|
||||||
|
|
||||||
|
|
||||||
|
const { repo, type: jobType } = await this.db.repoIndexingJob.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
repo: {
|
||||||
|
include: {
|
||||||
|
connections: {
|
||||||
|
include: {
|
||||||
|
connection: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signalHandler = () => {
|
||||||
|
logger.info(`Received shutdown signal, aborting...`);
|
||||||
|
abortController.abort(); // This cancels all operations
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', signalHandler);
|
||||||
|
process.on('SIGINT', signalHandler);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (jobType === RepoIndexingJobType.INDEX) {
|
||||||
|
await this.indexRepository(repo, logger, abortController.signal);
|
||||||
|
} else if (jobType === RepoIndexingJobType.CLEANUP) {
|
||||||
|
await this.cleanupRepository(repo, logger);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.off('SIGTERM', signalHandler);
|
||||||
|
process.off('SIGINT', signalHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async indexRepository(repo: RepoWithConnections, logger: Logger, signal: AbortSignal) {
|
||||||
|
const { path: repoPath, isReadOnly } = getRepoPath(repo);
|
||||||
|
|
||||||
|
const metadata = repoMetadataSchema.parse(repo.metadata);
|
||||||
|
|
||||||
|
const credentials = await getAuthCredentialsForRepo(repo, this.db);
|
||||||
|
const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
|
||||||
|
const authHeader = credentials?.authHeader ?? undefined;
|
||||||
|
|
||||||
|
// If the repo path exists but it is not a valid git repository root, this indicates
|
||||||
|
// that the repository is in a bad state. To fix, we remove the directory and perform
|
||||||
|
// a fresh clone.
|
||||||
|
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot( { path: repoPath } ))) {
|
||||||
|
const isValidGitRepo = await isPathAValidGitRepoRoot({
|
||||||
|
path: repoPath,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValidGitRepo && !isReadOnly) {
|
||||||
|
logger.warn(`${repoPath} is not a valid git repository root. Deleting directory and performing fresh clone.`);
|
||||||
|
await rm(repoPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(repoPath) && !isReadOnly) {
|
||||||
|
// @NOTE: in #483, we changed the cloning method s.t., we _no longer_
|
||||||
|
// write the clone URL (which could contain a auth token) to the
|
||||||
|
// `remote.origin.url` entry. For the upgrade scenario, we want
|
||||||
|
// to unset this key since it is no longer needed, hence this line.
|
||||||
|
// This will no-op if the key is already unset.
|
||||||
|
// @see: https://github.com/sourcebot-dev/sourcebot/pull/483
|
||||||
|
await unsetGitConfig({
|
||||||
|
path: repoPath,
|
||||||
|
keys: ["remote.origin.url"],
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Fetching ${repo.name} (id: ${repo.id})...`);
|
||||||
|
const { durationMs } = await measure(() => fetchRepository({
|
||||||
|
cloneUrl: cloneUrlMaybeWithToken,
|
||||||
|
authHeader,
|
||||||
|
path: repoPath,
|
||||||
|
onProgress: ({ method, stage, progress }) => {
|
||||||
|
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.name} (id: ${repo.id})`)
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
}));
|
||||||
|
const fetchDuration_s = durationMs / 1000;
|
||||||
|
|
||||||
|
process.stdout.write('\n');
|
||||||
|
logger.info(`Fetched ${repo.name} (id: ${repo.id}) in ${fetchDuration_s}s`);
|
||||||
|
|
||||||
|
} else if (!isReadOnly) {
|
||||||
|
logger.info(`Cloning ${repo.name} (id: ${repo.id})...`);
|
||||||
|
|
||||||
|
const { durationMs } = await measure(() => cloneRepository({
|
||||||
|
cloneUrl: cloneUrlMaybeWithToken,
|
||||||
|
authHeader,
|
||||||
|
path: repoPath,
|
||||||
|
onProgress: ({ method, stage, progress }) => {
|
||||||
|
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.name} (id: ${repo.id})`)
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
}));
|
||||||
|
const cloneDuration_s = durationMs / 1000;
|
||||||
|
|
||||||
|
process.stdout.write('\n');
|
||||||
|
logger.info(`Cloned ${repo.name} (id: ${repo.id}) in ${cloneDuration_s}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regardless of clone or fetch, always upsert the git config for the repo.
|
||||||
|
// This ensures that the git config is always up to date for whatever we
|
||||||
|
// have in the DB.
|
||||||
|
if (metadata.gitConfig && !isReadOnly) {
|
||||||
|
await upsertGitConfig({
|
||||||
|
path: repoPath,
|
||||||
|
gitConfig: metadata.gitConfig,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Indexing ${repo.name} (id: ${repo.id})...`);
|
||||||
|
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, signal));
|
||||||
|
const indexDuration_s = durationMs / 1000;
|
||||||
|
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupRepository(repo: Repo, logger: Logger) {
|
||||||
|
const { path: repoPath, isReadOnly } = getRepoPath(repo);
|
||||||
|
if (existsSync(repoPath) && !isReadOnly) {
|
||||||
|
logger.info(`Deleting repo directory ${repoPath}`);
|
||||||
|
await rm(repoPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
||||||
|
const files = (await readdir(INDEX_CACHE_DIR)).filter(file => file.startsWith(shardPrefix));
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = `${INDEX_CACHE_DIR}/${file}`;
|
||||||
|
logger.info(`Deleting shard file ${filePath}`);
|
||||||
|
await rm(filePath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onJobCompleted = async (job: Job<JobPayload>) =>
|
||||||
|
groupmqLifecycleExceptionWrapper('onJobCompleted', logger, async () => {
|
||||||
|
const logger = createJobLogger(job.data.jobId);
|
||||||
|
const jobData = await this.db.repoIndexingJob.update({
|
||||||
|
where: { id: job.data.jobId },
|
||||||
|
data: {
|
||||||
|
status: RepoIndexingJobStatus.COMPLETED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (jobData.type === RepoIndexingJobType.INDEX) {
|
||||||
|
const repo = await this.db.repo.update({
|
||||||
|
where: { id: jobData.repoId },
|
||||||
|
data: {
|
||||||
|
indexedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Completed index job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id})`);
|
||||||
|
}
|
||||||
|
else if (jobData.type === RepoIndexingJobType.CLEANUP) {
|
||||||
|
const repo = await this.db.repo.delete({
|
||||||
|
where: { id: jobData.repoId },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Completed cleanup job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private onJobFailed = async (job: Job<JobPayload>) =>
|
||||||
|
groupmqLifecycleExceptionWrapper('onJobFailed', logger, async () => {
|
||||||
|
const logger = createJobLogger(job.data.jobId);
|
||||||
|
|
||||||
|
const attempt = job.attemptsMade + 1;
|
||||||
|
const wasLastAttempt = attempt >= job.opts.attempts;
|
||||||
|
|
||||||
|
if (wasLastAttempt) {
|
||||||
|
const { repo } = await this.db.repoIndexingJob.update({
|
||||||
|
where: { id: job.data.jobId },
|
||||||
|
data: {
|
||||||
|
status: RepoIndexingJobStatus.FAILED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
errorMessage: job.failedReason,
|
||||||
|
},
|
||||||
|
select: { repo: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error(`Failed job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id}). Attempt ${attempt} / ${job.opts.attempts}. Failing job.`);
|
||||||
|
} else {
|
||||||
|
const repo = await this.db.repo.findUniqueOrThrow({
|
||||||
|
where: { id: job.data.repoId },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.warn(`Failed job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id}). Attempt ${attempt} / ${job.opts.attempts}. Retrying.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private onJobStalled = async (jobId: string) =>
|
||||||
|
groupmqLifecycleExceptionWrapper('onJobStalled', logger, async () => {
|
||||||
|
const logger = createJobLogger(jobId);
|
||||||
|
const { repo } = await this.db.repoIndexingJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: {
|
||||||
|
status: RepoIndexingJobStatus.FAILED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
errorMessage: 'Job stalled',
|
||||||
|
},
|
||||||
|
select: { repo: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error(`Job ${jobId} stalled for repo ${repo.name} (id: ${repo.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
private async onWorkerError(error: Error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
logger.error(`Index syncer worker error.`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async dispose() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
await this.worker.close();
|
||||||
|
await this.queue.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,566 +0,0 @@
|
||||||
import * as Sentry from "@sentry/node";
|
|
||||||
import { PrismaClient, Repo, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { Job, Queue, Worker } from 'bullmq';
|
|
||||||
import { existsSync, promises, readdirSync } from 'fs';
|
|
||||||
import { Redis } from 'ioredis';
|
|
||||||
import { env } from './env.js';
|
|
||||||
import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js";
|
|
||||||
import { PromClient } from './promClient.js';
|
|
||||||
import { AppContext, RepoWithConnections, Settings, repoMetadataSchema } from "./types.js";
|
|
||||||
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, measure } from "./utils.js";
|
|
||||||
import { indexGitRepository } from "./zoekt.js";
|
|
||||||
|
|
||||||
const REPO_INDEXING_QUEUE = 'repoIndexingQueue';
|
|
||||||
const REPO_GC_QUEUE = 'repoGarbageCollectionQueue';
|
|
||||||
|
|
||||||
type RepoIndexingPayload = {
|
|
||||||
repo: RepoWithConnections,
|
|
||||||
}
|
|
||||||
|
|
||||||
type RepoGarbageCollectionPayload = {
|
|
||||||
repo: Repo,
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = createLogger('repo-manager');
|
|
||||||
|
|
||||||
export class RepoManager {
|
|
||||||
private indexWorker: Worker;
|
|
||||||
private indexQueue: Queue<RepoIndexingPayload>;
|
|
||||||
private gcWorker: Worker;
|
|
||||||
private gcQueue: Queue<RepoGarbageCollectionPayload>;
|
|
||||||
private interval?: NodeJS.Timeout;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private db: PrismaClient,
|
|
||||||
private settings: Settings,
|
|
||||||
redis: Redis,
|
|
||||||
private promClient: PromClient,
|
|
||||||
private ctx: AppContext,
|
|
||||||
) {
|
|
||||||
// Repo indexing
|
|
||||||
this.indexQueue = new Queue<RepoIndexingPayload>(REPO_INDEXING_QUEUE, {
|
|
||||||
connection: redis,
|
|
||||||
});
|
|
||||||
this.indexWorker = new Worker(REPO_INDEXING_QUEUE, this.runIndexJob.bind(this), {
|
|
||||||
connection: redis,
|
|
||||||
concurrency: this.settings.maxRepoIndexingJobConcurrency,
|
|
||||||
});
|
|
||||||
this.indexWorker.on('completed', this.onIndexJobCompleted.bind(this));
|
|
||||||
this.indexWorker.on('failed', this.onIndexJobFailed.bind(this));
|
|
||||||
|
|
||||||
// Garbage collection
|
|
||||||
this.gcQueue = new Queue<RepoGarbageCollectionPayload>(REPO_GC_QUEUE, {
|
|
||||||
connection: redis,
|
|
||||||
});
|
|
||||||
this.gcWorker = new Worker(REPO_GC_QUEUE, this.runGarbageCollectionJob.bind(this), {
|
|
||||||
connection: redis,
|
|
||||||
concurrency: this.settings.maxRepoGarbageCollectionJobConcurrency,
|
|
||||||
});
|
|
||||||
this.gcWorker.on('completed', this.onGarbageCollectionJobCompleted.bind(this));
|
|
||||||
this.gcWorker.on('failed', this.onGarbageCollectionJobFailed.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
public startScheduler() {
|
|
||||||
logger.debug('Starting scheduler');
|
|
||||||
this.interval = setInterval(async () => {
|
|
||||||
await this.fetchAndScheduleRepoIndexing();
|
|
||||||
await this.fetchAndScheduleRepoGarbageCollection();
|
|
||||||
await this.fetchAndScheduleRepoTimeouts();
|
|
||||||
}, this.settings.reindexRepoPollingIntervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////
|
|
||||||
// Repo indexing
|
|
||||||
///////////////////////////
|
|
||||||
|
|
||||||
private async scheduleRepoIndexingBulk(repos: RepoWithConnections[]) {
|
|
||||||
await this.db.$transaction(async (tx) => {
|
|
||||||
await tx.repo.updateMany({
|
|
||||||
where: { id: { in: repos.map(repo => repo.id) } },
|
|
||||||
data: { repoIndexingStatus: RepoIndexingStatus.IN_INDEX_QUEUE }
|
|
||||||
});
|
|
||||||
|
|
||||||
const reposByOrg = repos.reduce<Record<number, RepoWithConnections[]>>((acc, repo) => {
|
|
||||||
if (!acc[repo.orgId]) {
|
|
||||||
acc[repo.orgId] = [];
|
|
||||||
}
|
|
||||||
acc[repo.orgId].push(repo);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
for (const orgId in reposByOrg) {
|
|
||||||
const orgRepos = reposByOrg[orgId];
|
|
||||||
// Set priority based on number of repos (more repos = lower priority)
|
|
||||||
// This helps prevent large orgs from overwhelming the indexQueue
|
|
||||||
const priority = Math.min(Math.ceil(orgRepos.length / 10), 2097152);
|
|
||||||
|
|
||||||
await this.indexQueue.addBulk(orgRepos.map(repo => ({
|
|
||||||
name: 'repoIndexJob',
|
|
||||||
data: { repo },
|
|
||||||
opts: {
|
|
||||||
priority: priority,
|
|
||||||
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
|
|
||||||
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
|
|
||||||
},
|
|
||||||
})));
|
|
||||||
|
|
||||||
// Increment pending jobs counter for each repo added
|
|
||||||
orgRepos.forEach(repo => {
|
|
||||||
this.promClient.pendingRepoIndexingJobs.inc({ repo: repo.id.toString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Added ${orgRepos.length} jobs to indexQueue for org ${orgId} with priority ${priority}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}).catch((err: unknown) => {
|
|
||||||
logger.error(`Failed to add jobs to indexQueue for repos ${repos.map(repo => repo.id).join(', ')}: ${err}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private async fetchAndScheduleRepoIndexing() {
|
|
||||||
const thresholdDate = new Date(Date.now() - this.settings.reindexIntervalMs);
|
|
||||||
const repos = await this.db.repo.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
// "NEW" is really a misnomer here - it just means that the repo needs to be indexed
|
|
||||||
// immediately. In most cases, this will be because the repo was just created and
|
|
||||||
// is indeed "new". However, it could also be that a "retry" was requested on a failed
|
|
||||||
// index. So, we don't want to block on the indexedAt timestamp here.
|
|
||||||
{
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
|
||||||
},
|
|
||||||
// When the repo has already been indexed, we only want to reindex if the reindexing
|
|
||||||
// interval has elapsed (or if the date isn't set for some reason).
|
|
||||||
{
|
|
||||||
AND: [
|
|
||||||
{ repoIndexingStatus: RepoIndexingStatus.INDEXED },
|
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ indexedAt: null },
|
|
||||||
{ indexedAt: { lt: thresholdDate } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
connections: {
|
|
||||||
include: {
|
|
||||||
connection: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (repos.length > 0) {
|
|
||||||
await this.scheduleRepoIndexingBulk(repos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) {
|
|
||||||
const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx);
|
|
||||||
|
|
||||||
const metadata = repoMetadataSchema.parse(repo.metadata);
|
|
||||||
|
|
||||||
// If the repo was already in the indexing state, this job was likely killed and picked up again. As a result,
|
|
||||||
// to ensure the repo state is valid, we delete the repo if it exists so we get a fresh clone
|
|
||||||
if (repoAlreadyInIndexingState && existsSync(repoPath) && !isReadOnly) {
|
|
||||||
logger.info(`Deleting repo directory ${repoPath} during sync because it was already in the indexing state`);
|
|
||||||
await promises.rm(repoPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = await getAuthCredentialsForRepo(repo, this.db);
|
|
||||||
const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
|
|
||||||
const authHeader = credentials?.authHeader ?? undefined;
|
|
||||||
|
|
||||||
if (existsSync(repoPath) && !isReadOnly) {
|
|
||||||
// @NOTE: in #483, we changed the cloning method s.t., we _no longer_
|
|
||||||
// write the clone URL (which could contain a auth token) to the
|
|
||||||
// `remote.origin.url` entry. For the upgrade scenario, we want
|
|
||||||
// to unset this key since it is no longer needed, hence this line.
|
|
||||||
// This will no-op if the key is already unset.
|
|
||||||
// @see: https://github.com/sourcebot-dev/sourcebot/pull/483
|
|
||||||
await unsetGitConfig(repoPath, ["remote.origin.url"]);
|
|
||||||
|
|
||||||
logger.info(`Fetching ${repo.displayName}...`);
|
|
||||||
const { durationMs } = await measure(() => fetchRepository({
|
|
||||||
cloneUrl: cloneUrlMaybeWithToken,
|
|
||||||
authHeader,
|
|
||||||
path: repoPath,
|
|
||||||
onProgress: ({ method, stage, progress }) => {
|
|
||||||
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const fetchDuration_s = durationMs / 1000;
|
|
||||||
|
|
||||||
process.stdout.write('\n');
|
|
||||||
logger.info(`Fetched ${repo.displayName} in ${fetchDuration_s}s`);
|
|
||||||
|
|
||||||
} else if (!isReadOnly) {
|
|
||||||
logger.info(`Cloning ${repo.displayName}...`);
|
|
||||||
|
|
||||||
const { durationMs } = await measure(() => cloneRepository({
|
|
||||||
cloneUrl: cloneUrlMaybeWithToken,
|
|
||||||
authHeader,
|
|
||||||
path: repoPath,
|
|
||||||
onProgress: ({ method, stage, progress }) => {
|
|
||||||
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const cloneDuration_s = durationMs / 1000;
|
|
||||||
|
|
||||||
process.stdout.write('\n');
|
|
||||||
logger.info(`Cloned ${repo.displayName} in ${cloneDuration_s}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regardless of clone or fetch, always upsert the git config for the repo.
|
|
||||||
// This ensures that the git config is always up to date for whatever we
|
|
||||||
// have in the DB.
|
|
||||||
if (metadata.gitConfig && !isReadOnly) {
|
|
||||||
await upsertGitConfig(repoPath, metadata.gitConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Indexing ${repo.displayName}...`);
|
|
||||||
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, this.ctx));
|
|
||||||
const indexDuration_s = durationMs / 1000;
|
|
||||||
logger.info(`Indexed ${repo.displayName} in ${indexDuration_s}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runIndexJob(job: Job<RepoIndexingPayload>) {
|
|
||||||
logger.info(`Running index job (id: ${job.id}) for repo ${job.data.repo.displayName}`);
|
|
||||||
const repo = job.data.repo as RepoWithConnections;
|
|
||||||
|
|
||||||
// We have to use the existing repo object to get the repoIndexingStatus because the repo object
|
|
||||||
// inside the job is unchanged from when it was added to the queue.
|
|
||||||
const existingRepo = await this.db.repo.findUnique({
|
|
||||||
where: {
|
|
||||||
id: repo.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!existingRepo) {
|
|
||||||
logger.error(`Repo ${repo.id} not found`);
|
|
||||||
const e = new Error(`Repo ${repo.id} not found`);
|
|
||||||
Sentry.captureException(e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
const repoAlreadyInIndexingState = existingRepo.repoIndexingStatus === RepoIndexingStatus.INDEXING;
|
|
||||||
|
|
||||||
|
|
||||||
await this.db.repo.update({
|
|
||||||
where: {
|
|
||||||
id: repo.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.INDEXING,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.promClient.activeRepoIndexingJobs.inc();
|
|
||||||
this.promClient.pendingRepoIndexingJobs.dec({ repo: repo.id.toString() });
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 3;
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
try {
|
|
||||||
await this.syncGitRepository(repo, repoAlreadyInIndexingState);
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
|
|
||||||
attempts++;
|
|
||||||
this.promClient.repoIndexingReattemptsTotal.inc();
|
|
||||||
if (attempts === maxAttempts) {
|
|
||||||
logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}) after ${maxAttempts} attempts. Error: ${error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sleepDuration = (env.REPO_SYNC_RETRY_BASE_SLEEP_SECONDS * 1000) * Math.pow(2, attempts - 1);
|
|
||||||
logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}), attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, sleepDuration));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onIndexJobCompleted(job: Job<RepoIndexingPayload>) {
|
|
||||||
logger.info(`Repo index job for repo ${job.data.repo.displayName} (id: ${job.data.repo.id}, jobId: ${job.id}) completed`);
|
|
||||||
this.promClient.activeRepoIndexingJobs.dec();
|
|
||||||
this.promClient.repoIndexingSuccessTotal.inc();
|
|
||||||
|
|
||||||
await this.db.repo.update({
|
|
||||||
where: {
|
|
||||||
id: job.data.repo.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
indexedAt: new Date(),
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.INDEXED,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onIndexJobFailed(job: Job<RepoIndexingPayload> | undefined, err: unknown) {
|
|
||||||
logger.info(`Repo index job for repo ${job?.data.repo.displayName} (id: ${job?.data.repo.id}, jobId: ${job?.id}) failed with error: ${err}`);
|
|
||||||
Sentry.captureException(err, {
|
|
||||||
tags: {
|
|
||||||
repoId: job?.data.repo.id,
|
|
||||||
jobId: job?.id,
|
|
||||||
queue: REPO_INDEXING_QUEUE,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (job) {
|
|
||||||
this.promClient.activeRepoIndexingJobs.dec();
|
|
||||||
this.promClient.repoIndexingFailTotal.inc();
|
|
||||||
|
|
||||||
await this.db.repo.update({
|
|
||||||
where: {
|
|
||||||
id: job.data.repo.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.FAILED,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////
|
|
||||||
// Repo garbage collection
|
|
||||||
///////////////////////////
|
|
||||||
|
|
||||||
private async scheduleRepoGarbageCollectionBulk(repos: Repo[]) {
|
|
||||||
await this.db.$transaction(async (tx) => {
|
|
||||||
await tx.repo.updateMany({
|
|
||||||
where: { id: { in: repos.map(repo => repo.id) } },
|
|
||||||
data: { repoIndexingStatus: RepoIndexingStatus.IN_GC_QUEUE }
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.gcQueue.addBulk(repos.map(repo => ({
|
|
||||||
name: 'repoGarbageCollectionJob',
|
|
||||||
data: { repo },
|
|
||||||
opts: {
|
|
||||||
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
|
|
||||||
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
|
|
||||||
logger.info(`Added ${repos.length} jobs to gcQueue`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchAndScheduleRepoGarbageCollection() {
|
|
||||||
////////////////////////////////////
|
|
||||||
// Get repos with no connections
|
|
||||||
////////////////////////////////////
|
|
||||||
|
|
||||||
|
|
||||||
const thresholdDate = new Date(Date.now() - this.settings.repoGarbageCollectionGracePeriodMs);
|
|
||||||
const reposWithNoConnections = await this.db.repo.findMany({
|
|
||||||
where: {
|
|
||||||
repoIndexingStatus: {
|
|
||||||
in: [
|
|
||||||
RepoIndexingStatus.INDEXED, // we don't include NEW repos here because they'll be picked up by the index queue (potential race condition)
|
|
||||||
RepoIndexingStatus.FAILED,
|
|
||||||
]
|
|
||||||
},
|
|
||||||
connections: {
|
|
||||||
none: {}
|
|
||||||
},
|
|
||||||
OR: [
|
|
||||||
{ indexedAt: null },
|
|
||||||
{ indexedAt: { lt: thresholdDate } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (reposWithNoConnections.length > 0) {
|
|
||||||
logger.info(`Garbage collecting ${reposWithNoConnections.length} repos with no connections: ${reposWithNoConnections.map(repo => repo.id).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////
|
|
||||||
// Get inactive org repos
|
|
||||||
////////////////////////////////////
|
|
||||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
const inactiveOrgRepos = await this.db.repo.findMany({
|
|
||||||
where: {
|
|
||||||
org: {
|
|
||||||
stripeSubscriptionStatus: StripeSubscriptionStatus.INACTIVE,
|
|
||||||
stripeLastUpdatedAt: {
|
|
||||||
lt: sevenDaysAgo
|
|
||||||
}
|
|
||||||
},
|
|
||||||
OR: [
|
|
||||||
{ indexedAt: null },
|
|
||||||
{ indexedAt: { lt: thresholdDate } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (inactiveOrgRepos.length > 0) {
|
|
||||||
logger.info(`Garbage collecting ${inactiveOrgRepos.length} inactive org repos: ${inactiveOrgRepos.map(repo => repo.id).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reposToDelete = [...reposWithNoConnections, ...inactiveOrgRepos];
|
|
||||||
if (reposToDelete.length > 0) {
|
|
||||||
await this.scheduleRepoGarbageCollectionBulk(reposToDelete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runGarbageCollectionJob(job: Job<RepoGarbageCollectionPayload>) {
|
|
||||||
logger.info(`Running garbage collection job (id: ${job.id}) for repo ${job.data.repo.displayName} (id: ${job.data.repo.id})`);
|
|
||||||
this.promClient.activeRepoGarbageCollectionJobs.inc();
|
|
||||||
|
|
||||||
const repo = job.data.repo as Repo;
|
|
||||||
await this.db.repo.update({
|
|
||||||
where: {
|
|
||||||
id: repo.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.GARBAGE_COLLECTING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// delete cloned repo
|
|
||||||
const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx);
|
|
||||||
if (existsSync(repoPath) && !isReadOnly) {
|
|
||||||
logger.info(`Deleting repo directory ${repoPath}`);
|
|
||||||
await promises.rm(repoPath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete shards
|
|
||||||
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
|
||||||
const files = readdirSync(this.ctx.indexPath).filter(file => file.startsWith(shardPrefix));
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = `${this.ctx.indexPath}/${file}`;
|
|
||||||
logger.info(`Deleting shard file ${filePath}`);
|
|
||||||
await promises.rm(filePath, { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onGarbageCollectionJobCompleted(job: Job<RepoGarbageCollectionPayload>) {
|
|
||||||
logger.info(`Garbage collection job ${job.id} completed`);
|
|
||||||
this.promClient.activeRepoGarbageCollectionJobs.dec();
|
|
||||||
this.promClient.repoGarbageCollectionSuccessTotal.inc();
|
|
||||||
|
|
||||||
await this.db.repo.delete({
|
|
||||||
where: {
|
|
||||||
id: job.data.repo.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onGarbageCollectionJobFailed(job: Job<RepoGarbageCollectionPayload> | undefined, err: unknown) {
|
|
||||||
logger.info(`Garbage collection job failed (id: ${job?.id ?? 'unknown'}) with error: ${err}`);
|
|
||||||
Sentry.captureException(err, {
|
|
||||||
tags: {
|
|
||||||
repoId: job?.data.repo.id,
|
|
||||||
jobId: job?.id,
|
|
||||||
queue: REPO_GC_QUEUE,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (job) {
|
|
||||||
this.promClient.activeRepoGarbageCollectionJobs.dec();
|
|
||||||
this.promClient.repoGarbageCollectionFailTotal.inc();
|
|
||||||
|
|
||||||
await this.db.repo.update({
|
|
||||||
where: {
|
|
||||||
id: job.data.repo.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.GARBAGE_COLLECTION_FAILED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////
|
|
||||||
// Repo index validation
|
|
||||||
///////////////////////////
|
|
||||||
|
|
||||||
public async validateIndexedReposHaveShards() {
|
|
||||||
logger.info('Validating indexed repos have shards...');
|
|
||||||
|
|
||||||
const indexedRepos = await this.db.repo.findMany({
|
|
||||||
where: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.INDEXED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info(`Found ${indexedRepos.length} repos in the DB marked as INDEXED`);
|
|
||||||
|
|
||||||
if (indexedRepos.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = readdirSync(this.ctx.indexPath);
|
|
||||||
const reposToReindex: number[] = [];
|
|
||||||
for (const repo of indexedRepos) {
|
|
||||||
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
|
||||||
|
|
||||||
// TODO: this doesn't take into account if a repo has multiple shards and only some of them are missing. To support that, this logic
|
|
||||||
// would need to know how many total shards are expected for this repo
|
|
||||||
let hasShards = false;
|
|
||||||
try {
|
|
||||||
hasShards = files.some(file => file.startsWith(shardPrefix));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to read index directory ${this.ctx.indexPath}: ${error}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasShards) {
|
|
||||||
logger.info(`Repo ${repo.displayName} (id: ${repo.id}) is marked as INDEXED but has no shards on disk. Marking for reindexing.`);
|
|
||||||
reposToReindex.push(repo.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reposToReindex.length > 0) {
|
|
||||||
await this.db.repo.updateMany({
|
|
||||||
where: {
|
|
||||||
id: { in: reposToReindex }
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.NEW
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.info(`Marked ${reposToReindex.length} repos for reindexing due to missing shards`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Done validating indexed repos have shards');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchAndScheduleRepoTimeouts() {
|
|
||||||
const repos = await this.db.repo.findMany({
|
|
||||||
where: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.INDEXING,
|
|
||||||
updatedAt: {
|
|
||||||
lt: new Date(Date.now() - this.settings.repoIndexTimeoutMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (repos.length > 0) {
|
|
||||||
logger.info(`Scheduling ${repos.length} repo timeouts`);
|
|
||||||
await this.scheduleRepoTimeoutsBulk(repos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async scheduleRepoTimeoutsBulk(repos: Repo[]) {
|
|
||||||
await this.db.$transaction(async (tx) => {
|
|
||||||
await tx.repo.updateMany({
|
|
||||||
where: { id: { in: repos.map(repo => repo.id) } },
|
|
||||||
data: { repoIndexingStatus: RepoIndexingStatus.FAILED }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async dispose() {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
this.indexWorker.close();
|
|
||||||
this.indexQueue.close();
|
|
||||||
this.gcQueue.close();
|
|
||||||
this.gcWorker.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,20 +2,6 @@ import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
|
||||||
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export type AppContext = {
|
|
||||||
/**
|
|
||||||
* Path to the repos cache directory.
|
|
||||||
*/
|
|
||||||
reposPath: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path to the index cache directory;
|
|
||||||
*/
|
|
||||||
indexPath: string;
|
|
||||||
|
|
||||||
cachePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Settings = Required<SettingsSchema>;
|
export type Settings = Required<SettingsSchema>;
|
||||||
|
|
||||||
// Structure of the `metadata` field in the `Repo` table.
|
// Structure of the `metadata` field in the `Repo` table.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { Logger } from "winston";
|
import { Logger } from "winston";
|
||||||
import { AppContext, RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
import { RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||||
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
||||||
import { BackendException, BackendError } from "@sourcebot/error";
|
import { BackendException, BackendError } from "@sourcebot/error";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||||
|
import { REPOS_CACHE_DIR } from "./constants.js";
|
||||||
|
|
||||||
export const measure = async <T>(cb: () => Promise<T>) => {
|
export const measure = async <T>(cb: () => Promise<T>) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
@ -69,7 +70,7 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
|
||||||
|
|
||||||
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
|
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
|
||||||
// @todo: we should move this to a shared package.
|
// @todo: we should move this to a shared package.
|
||||||
export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => {
|
export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => {
|
||||||
// If we are dealing with a local repository, then use that as the path.
|
// If we are dealing with a local repository, then use that as the path.
|
||||||
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
|
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
|
||||||
const cloneUrl = new URL(repo.cloneUrl);
|
const cloneUrl = new URL(repo.cloneUrl);
|
||||||
|
|
@ -81,7 +82,7 @@ export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isRead
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: path.join(ctx.reposPath, repo.id.toString()),
|
path: path.join(REPOS_CACHE_DIR, repo.id.toString()),
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -241,3 +242,20 @@ const createGitCloneUrlWithToken = (cloneUrl: string, credentials: { username?:
|
||||||
}
|
}
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps groupmq worker lifecycle callbacks with exception handling. This prevents
|
||||||
|
* uncaught exceptions (e.g., like a RepoIndexingJob not existing in the DB) from crashing
|
||||||
|
* the app.
|
||||||
|
* @see: https://openpanel-dev.github.io/groupmq/api-worker/#events
|
||||||
|
*/
|
||||||
|
export const groupmqLifecycleExceptionWrapper = async (name: string, logger: Logger, fn: () => Promise<void>) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
logger.error(`Exception thrown while executing lifecycle function \`${name}\`.`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { exec } from "child_process";
|
|
||||||
import { AppContext, repoMetadataSchema, Settings } from "./types.js";
|
|
||||||
import { Repo } from "@sourcebot/db";
|
import { Repo } from "@sourcebot/db";
|
||||||
import { getRepoPath } from "./utils.js";
|
|
||||||
import { getShardPrefix } from "./utils.js";
|
|
||||||
import { getBranches, getTags } from "./git.js";
|
|
||||||
import micromatch from "micromatch";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import micromatch from "micromatch";
|
||||||
|
import { INDEX_CACHE_DIR } from "./constants.js";
|
||||||
|
import { getBranches, getTags } from "./git.js";
|
||||||
import { captureEvent } from "./posthog.js";
|
import { captureEvent } from "./posthog.js";
|
||||||
|
import { repoMetadataSchema, Settings } from "./types.js";
|
||||||
|
import { getRepoPath, getShardPrefix } from "./utils.js";
|
||||||
|
|
||||||
const logger = createLogger('zoekt');
|
const logger = createLogger('zoekt');
|
||||||
|
|
||||||
export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: AppContext) => {
|
export const indexGitRepository = async (repo: Repo, settings: Settings, signal?: AbortSignal) => {
|
||||||
let revisions = [
|
let revisions = [
|
||||||
'HEAD'
|
'HEAD'
|
||||||
];
|
];
|
||||||
|
|
||||||
const { path: repoPath } = getRepoPath(repo, ctx);
|
const { path: repoPath } = getRepoPath(repo);
|
||||||
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
||||||
const metadata = repoMetadataSchema.parse(repo.metadata);
|
const metadata = repoMetadataSchema.parse(repo.metadata);
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap
|
||||||
const command = [
|
const command = [
|
||||||
'zoekt-git-index',
|
'zoekt-git-index',
|
||||||
'-allow_missing_branches',
|
'-allow_missing_branches',
|
||||||
`-index ${ctx.indexPath}`,
|
`-index ${INDEX_CACHE_DIR}`,
|
||||||
`-max_trigram_count ${settings.maxTrigramCount}`,
|
`-max_trigram_count ${settings.maxTrigramCount}`,
|
||||||
`-file_limit ${settings.maxFileSize}`,
|
`-file_limit ${settings.maxFileSize}`,
|
||||||
`-branches "${revisions.join(',')}"`,
|
`-branches "${revisions.join(',')}"`,
|
||||||
|
|
@ -71,7 +71,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
return new Promise<{ stdout: string, stderr: string }>((resolve, reject) => {
|
return new Promise<{ stdout: string, stderr: string }>((resolve, reject) => {
|
||||||
exec(command, (error, stdout, stderr) => {
|
exec(command, { signal }, (error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,8 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
watch: false,
|
watch: false,
|
||||||
|
env: {
|
||||||
|
DATA_CACHE_DIR: 'test-data'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `repoIndexingStatus` on the `Repo` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RepoIndexingJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RepoIndexingJobType" AS ENUM ('INDEX', 'CLEANUP');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Repo" DROP COLUMN "repoIndexingStatus";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "RepoIndexingStatus";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RepoIndexingJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" "RepoIndexingJobType" NOT NULL,
|
||||||
|
"status" "RepoIndexingJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"repoId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RepoIndexingJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RepoIndexingJob" ADD CONSTRAINT "RepoIndexingJob_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -10,17 +10,6 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RepoIndexingStatus {
|
|
||||||
NEW
|
|
||||||
IN_INDEX_QUEUE
|
|
||||||
INDEXING
|
|
||||||
INDEXED
|
|
||||||
FAILED
|
|
||||||
IN_GC_QUEUE
|
|
||||||
GARBAGE_COLLECTING
|
|
||||||
GARBAGE_COLLECTION_FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConnectionSyncStatus {
|
enum ConnectionSyncStatus {
|
||||||
SYNC_NEEDED
|
SYNC_NEEDED
|
||||||
IN_SYNC_QUEUE
|
IN_SYNC_QUEUE
|
||||||
|
|
@ -46,7 +35,6 @@ model Repo {
|
||||||
displayName String? /// Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
|
displayName String? /// Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
indexedAt DateTime? /// When the repo was last indexed successfully.
|
|
||||||
isFork Boolean
|
isFork Boolean
|
||||||
isArchived Boolean
|
isArchived Boolean
|
||||||
isPublic Boolean @default(false)
|
isPublic Boolean @default(false)
|
||||||
|
|
@ -55,12 +43,14 @@ model Repo {
|
||||||
webUrl String?
|
webUrl String?
|
||||||
connections RepoToConnection[]
|
connections RepoToConnection[]
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
repoIndexingStatus RepoIndexingStatus @default(NEW)
|
|
||||||
|
|
||||||
permittedUsers UserToRepoPermission[]
|
permittedUsers UserToRepoPermission[]
|
||||||
permissionSyncJobs RepoPermissionSyncJob[]
|
permissionSyncJobs RepoPermissionSyncJob[]
|
||||||
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
|
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
|
||||||
|
|
||||||
|
jobs RepoIndexingJob[]
|
||||||
|
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||||
|
|
||||||
external_id String /// The id of the repo in the external service
|
external_id String /// The id of the repo in the external service
|
||||||
external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
|
external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
|
||||||
external_codeHostUrl String /// The base url of the external service (e.g., https://github.com)
|
external_codeHostUrl String /// The base url of the external service (e.g., https://github.com)
|
||||||
|
|
@ -74,6 +64,32 @@ model Repo {
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RepoIndexingJobStatus {
|
||||||
|
PENDING
|
||||||
|
IN_PROGRESS
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RepoIndexingJobType {
|
||||||
|
INDEX
|
||||||
|
CLEANUP
|
||||||
|
}
|
||||||
|
|
||||||
|
model RepoIndexingJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
type RepoIndexingJobType
|
||||||
|
status RepoIndexingJobStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
|
||||||
|
repoId Int
|
||||||
|
}
|
||||||
|
|
||||||
enum RepoPermissionSyncJobStatus {
|
enum RepoPermissionSyncJobStatus {
|
||||||
PENDING
|
PENDING
|
||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import winston, { format } from 'winston';
|
import winston, { format, Logger } from 'winston';
|
||||||
import { Logtail } from '@logtail/node';
|
import { Logtail } from '@logtail/node';
|
||||||
import { LogtailTransport } from '@logtail/winston';
|
import { LogtailTransport } from '@logtail/winston';
|
||||||
import { MESSAGE } from 'triple-beam';
|
import { MESSAGE } from 'triple-beam';
|
||||||
|
|
@ -48,7 +48,7 @@ const createLogger = (label: string) => {
|
||||||
format: combine(
|
format: combine(
|
||||||
errors({ stack: true }),
|
errors({ stack: true }),
|
||||||
timestamp(),
|
timestamp(),
|
||||||
labelFn({ label: label })
|
labelFn({ label: label }),
|
||||||
),
|
),
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.Console({
|
new winston.transports.Console({
|
||||||
|
|
@ -85,3 +85,7 @@ const createLogger = (label: string) => {
|
||||||
export {
|
export {
|
||||||
createLogger
|
createLogger
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
Logger,
|
||||||
|
}
|
||||||
|
|
@ -143,17 +143,6 @@ export const searchResponseSchema = z.object({
|
||||||
isSearchExhaustive: z.boolean(),
|
isSearchExhaustive: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
enum RepoIndexingStatus {
|
|
||||||
NEW = 'NEW',
|
|
||||||
IN_INDEX_QUEUE = 'IN_INDEX_QUEUE',
|
|
||||||
INDEXING = 'INDEXING',
|
|
||||||
INDEXED = 'INDEXED',
|
|
||||||
FAILED = 'FAILED',
|
|
||||||
IN_GC_QUEUE = 'IN_GC_QUEUE',
|
|
||||||
GARBAGE_COLLECTING = 'GARBAGE_COLLECTING',
|
|
||||||
GARBAGE_COLLECTION_FAILED = 'GARBAGE_COLLECTION_FAILED'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const repositoryQuerySchema = z.object({
|
export const repositoryQuerySchema = z.object({
|
||||||
codeHostType: z.string(),
|
codeHostType: z.string(),
|
||||||
repoId: z.number(),
|
repoId: z.number(),
|
||||||
|
|
@ -163,7 +152,6 @@ export const repositoryQuerySchema = z.object({
|
||||||
webUrl: z.string().optional(),
|
webUrl: z.string().optional(),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
indexedAt: z.coerce.date().optional(),
|
indexedAt: z.coerce.date().optional(),
|
||||||
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listRepositoriesResponseSchema = repositoryQuerySchema.array();
|
export const listRepositoriesResponseSchema = repositoryQuerySchema.array();
|
||||||
|
|
|
||||||
|
|
@ -5,46 +5,32 @@ import { env } from "@/env.mjs";
|
||||||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
|
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
|
||||||
import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
|
import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { render } from "@react-email/components";
|
import { render } from "@react-email/components";
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||||
import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
import { ApiKey, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
|
|
||||||
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
|
||||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
|
||||||
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
|
||||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
|
||||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||||
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
||||||
import { getPlan, hasEntitlement } from "@sourcebot/shared";
|
import { getPlan, hasEntitlement } from "@sourcebot/shared";
|
||||||
import Ajv from "ajv";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer";
|
||||||
import { Octokit } from "octokit";
|
import { Octokit } from "octokit";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { getConnection } from "./data/connection";
|
|
||||||
import { getOrgFromDomain } from "./data/org";
|
import { getOrgFromDomain } from "./data/org";
|
||||||
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
||||||
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
|
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
|
||||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
||||||
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
||||||
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
||||||
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||||
import { ApiKeyPayload, TenancyMode } from "./lib/types";
|
import { ApiKeyPayload, TenancyMode } from "./lib/types";
|
||||||
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
|
import { withOptionalAuthV2 } from "./withAuthV2";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
|
||||||
validateFormats: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const logger = createLogger('web-actions');
|
const logger = createLogger('web-actions');
|
||||||
const auditService = getAuditService();
|
const auditService = getAuditService();
|
||||||
|
|
@ -187,31 +173,6 @@ export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () =>
|
||||||
|
|
||||||
////// Actions ///////
|
////// Actions ///////
|
||||||
|
|
||||||
export const createOrg = async (name: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
|
|
||||||
withTenancyModeEnforcement('multi', () =>
|
|
||||||
withAuth(async (userId) => {
|
|
||||||
const org = await prisma.org.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
domain,
|
|
||||||
members: {
|
|
||||||
create: {
|
|
||||||
role: "OWNER",
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: org.id,
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
|
|
||||||
export const updateOrgName = async (name: string, domain: string) => sew(() =>
|
export const updateOrgName = async (name: string, domain: string) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
|
@ -573,87 +534,20 @@ export const getUserApiKeys = async (domain: string): Promise<{ name: string; cr
|
||||||
}));
|
}));
|
||||||
})));
|
})));
|
||||||
|
|
||||||
export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) => sew(() =>
|
export const getRepos = async ({
|
||||||
withAuth((userId) =>
|
where,
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
take,
|
||||||
const connections = await prisma.connection.findMany({
|
}: {
|
||||||
where: {
|
where?: Prisma.RepoWhereInput,
|
||||||
orgId: org.id,
|
take?: number
|
||||||
...(filter.status ? {
|
} = {}) => sew(() =>
|
||||||
syncStatus: { in: filter.status }
|
|
||||||
} : {}),
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
repos: {
|
|
||||||
include: {
|
|
||||||
repo: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return connections.map((connection) => ({
|
|
||||||
id: connection.id,
|
|
||||||
name: connection.name,
|
|
||||||
syncStatus: connection.syncStatus,
|
|
||||||
syncStatusMetadata: connection.syncStatusMetadata,
|
|
||||||
connectionType: connection.connectionType,
|
|
||||||
updatedAt: connection.updatedAt,
|
|
||||||
syncedAt: connection.syncedAt ?? undefined,
|
|
||||||
linkedRepos: connection.repos.map(({ repo }) => ({
|
|
||||||
id: repo.id,
|
|
||||||
name: repo.name,
|
|
||||||
repoIndexingStatus: repo.repoIndexingStatus,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
})
|
|
||||||
));
|
|
||||||
|
|
||||||
export const getConnectionInfo = async (connectionId: number, domain: string) => sew(() =>
|
|
||||||
withAuth((userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const connection = await prisma.connection.findUnique({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
orgId: org.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
repos: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
name: connection.name,
|
|
||||||
syncStatus: connection.syncStatus,
|
|
||||||
syncStatusMetadata: connection.syncStatusMetadata,
|
|
||||||
connectionType: connection.connectionType,
|
|
||||||
updatedAt: connection.updatedAt,
|
|
||||||
syncedAt: connection.syncedAt ?? undefined,
|
|
||||||
numLinkedRepos: connection.repos.length,
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
|
|
||||||
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
|
||||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
const repos = await prisma.repo.findMany({
|
const repos = await prisma.repo.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
...(filter.status ? {
|
...where,
|
||||||
repoIndexingStatus: { in: filter.status }
|
},
|
||||||
} : {}),
|
take,
|
||||||
...(filter.connectionId ? {
|
|
||||||
connections: {
|
|
||||||
some: {
|
|
||||||
connectionId: filter.connectionId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return repos.map((repo) => repositoryQuerySchema.parse({
|
return repos.map((repo) => repositoryQuerySchema.parse({
|
||||||
|
|
@ -665,10 +559,63 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
|
||||||
webUrl: repo.webUrl ?? undefined,
|
webUrl: repo.webUrl ?? undefined,
|
||||||
imageUrl: repo.imageUrl ?? undefined,
|
imageUrl: repo.imageUrl ?? undefined,
|
||||||
indexedAt: repo.indexedAt ?? undefined,
|
indexedAt: repo.indexedAt ?? undefined,
|
||||||
repoIndexingStatus: repo.repoIndexingStatus,
|
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a set of aggregated stats about the repos in the org
|
||||||
|
*/
|
||||||
|
export const getReposStats = async () => sew(() =>
|
||||||
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
|
const [
|
||||||
|
// Total number of repos.
|
||||||
|
numberOfRepos,
|
||||||
|
// Number of repos with their first time indexing jobs either
|
||||||
|
// pending or in progress.
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||||
|
// Number of repos that have been indexed at least once.
|
||||||
|
numberOfReposWithIndex,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
jobs: {
|
||||||
|
some: {
|
||||||
|
type: RepoIndexingJobType.INDEX,
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
RepoIndexingJobStatus.PENDING,
|
||||||
|
RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indexedAt: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
NOT: {
|
||||||
|
indexedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numberOfRepos,
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||||
|
numberOfReposWithIndex,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
||||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
// @note: repo names are represented by their remote url
|
// @note: repo names are represented by their remote url
|
||||||
|
|
@ -725,58 +672,9 @@ export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
||||||
webUrl: repo.webUrl ?? undefined,
|
webUrl: repo.webUrl ?? undefined,
|
||||||
imageUrl: repo.imageUrl ?? undefined,
|
imageUrl: repo.imageUrl ?? undefined,
|
||||||
indexedAt: repo.indexedAt ?? undefined,
|
indexedAt: repo.indexedAt ?? undefined,
|
||||||
repoIndexingStatus: repo.repoIndexingStatus,
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
|
|
||||||
withAuth((userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
if (env.CONFIG_PATH !== undefined) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.CONNECTION_CONFIG_PATH_SET,
|
|
||||||
message: "A configuration file has been provided. New connections cannot be added through the web interface.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedConfig = parseConnectionConfig(connectionConfig);
|
|
||||||
if (isServiceError(parsedConfig)) {
|
|
||||||
return parsedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingConnectionWithName = await prisma.connection.findUnique({
|
|
||||||
where: {
|
|
||||||
name_orgId: {
|
|
||||||
orgId: org.id,
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingConnectionWithName) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
|
|
||||||
message: "A connection with this name already exists.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await prisma.connection.create({
|
|
||||||
data: {
|
|
||||||
orgId: org.id,
|
|
||||||
name,
|
|
||||||
config: parsedConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
connectionType: type,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
}
|
|
||||||
}, OrgRole.OWNER)
|
|
||||||
));
|
|
||||||
|
|
||||||
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
|
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
|
||||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
|
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
|
||||||
|
|
@ -913,148 +811,6 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
|
||||||
withAuth((userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const connection = await getConnection(connectionId, org.id);
|
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingConnectionWithName = await prisma.connection.findUnique({
|
|
||||||
where: {
|
|
||||||
name_orgId: {
|
|
||||||
orgId: org.id,
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingConnectionWithName) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
|
|
||||||
message: "A connection with this name already exists.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.update({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
orgId: org.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}, OrgRole.OWNER)
|
|
||||||
));
|
|
||||||
|
|
||||||
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
|
||||||
withAuth((userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const connection = await getConnection(connectionId, org.id);
|
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedConfig = parseConnectionConfig(config);
|
|
||||||
if (isServiceError(parsedConfig)) {
|
|
||||||
return parsedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection.syncStatus === "SYNC_NEEDED" ||
|
|
||||||
connection.syncStatus === "IN_SYNC_QUEUE" ||
|
|
||||||
connection.syncStatus === "SYNCING") {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED,
|
|
||||||
message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.update({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
orgId: org.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
config: parsedConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
syncStatus: "SYNC_NEEDED",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}, OrgRole.OWNER)
|
|
||||||
));
|
|
||||||
|
|
||||||
export const flagConnectionForSync = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
|
||||||
withAuth((userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const connection = await getConnection(connectionId, org.id);
|
|
||||||
if (!connection || connection.orgId !== org.id) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.update({
|
|
||||||
where: {
|
|
||||||
id: connection.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
syncStatus: "SYNC_NEEDED",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
));
|
|
||||||
|
|
||||||
export const flagReposForIndex = async (repoIds: number[]) => sew(() =>
|
|
||||||
withAuthV2(async ({ org, prisma }) => {
|
|
||||||
await prisma.repo.updateMany({
|
|
||||||
where: {
|
|
||||||
id: { in: repoIds },
|
|
||||||
orgId: org.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
|
||||||
withAuth((userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const connection = await getConnection(connectionId, org.id);
|
|
||||||
if (!connection) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.connection.delete({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
orgId: org.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
|
||||||
}, OrgRole.OWNER)
|
|
||||||
));
|
|
||||||
|
|
||||||
export const getCurrentUserRole = async (domain: string): Promise<OrgRole | ServiceError> => sew(() =>
|
export const getCurrentUserRole = async (domain: string): Promise<OrgRole | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ userRole }) => {
|
withOrgMembership(userId, domain, async ({ userRole }) => {
|
||||||
|
|
@ -1267,13 +1023,6 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
));
|
));
|
||||||
|
|
||||||
export const getOrgInviteId = async (domain: string) => sew(() =>
|
|
||||||
withAuth(async (userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
return org.inviteLinkId;
|
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
|
||||||
));
|
|
||||||
|
|
||||||
export const getMe = async () => sew(() =>
|
export const getMe = async () => sew(() =>
|
||||||
withAuth(async (userId) => {
|
withAuth(async (userId) => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|
@ -1610,27 +1359,6 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
||||||
export const getOrgMembership = async (domain: string) => sew(() =>
|
|
||||||
withAuth(async (userId) =>
|
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
|
||||||
const membership = await prisma.userToOrg.findUnique({
|
|
||||||
where: {
|
|
||||||
orgId_userId: {
|
|
||||||
orgId: org.id,
|
|
||||||
userId: userId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return membership;
|
|
||||||
})
|
|
||||||
));
|
|
||||||
|
|
||||||
export const getOrgMembers = async (domain: string) => sew(() =>
|
export const getOrgMembers = async (domain: string) => sew(() =>
|
||||||
withAuth(async (userId) =>
|
withAuth(async (userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
|
@ -1826,20 +1554,6 @@ export const setMemberApprovalRequired = async (domain: string, required: boolea
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getInviteLinkEnabled = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
|
|
||||||
const org = await prisma.org.findUnique({
|
|
||||||
where: {
|
|
||||||
domain,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
return orgNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return org.inviteLinkEnabled;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
|
export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
|
||||||
withAuth(async (userId) =>
|
withAuth(async (userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
|
@ -1971,10 +1685,6 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
));
|
));
|
||||||
|
|
||||||
export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => {
|
|
||||||
await (await cookies()).set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true');
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getSearchContexts = async (domain: string) => sew(() =>
|
export const getSearchContexts = async (domain: string) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
|
|
@ -2127,126 +1837,17 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function setSearchModeCookie(searchMode: "precise" | "agentic") {
|
export const setAgenticSearchTutorialDismissedCookie = async (dismissed: boolean) => sew(async () => {
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(SEARCH_MODE_COOKIE_NAME, searchMode, {
|
|
||||||
httpOnly: false, // Allow client-side access
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setAgenticSearchTutorialDismissedCookie(dismissed: boolean) {
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, dismissed ? "true" : "false", {
|
cookieStore.set(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, dismissed ? "true" : "false", {
|
||||||
httpOnly: false, // Allow client-side access
|
httpOnly: false, // Allow client-side access
|
||||||
|
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
|
||||||
});
|
});
|
||||||
}
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
////// Helpers ///////
|
export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => {
|
||||||
|
const cookieStore = await cookies();
|
||||||
const parseConnectionConfig = (config: string) => {
|
cookieStore.set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true');
|
||||||
let parsedConfig: ConnectionConfig;
|
return true;
|
||||||
try {
|
});
|
||||||
parsedConfig = JSON.parse(config);
|
|
||||||
} catch (_e) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: "config must be a valid JSON object."
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionType = parsedConfig.type;
|
|
||||||
const schema = (() => {
|
|
||||||
switch (connectionType) {
|
|
||||||
case "github":
|
|
||||||
return githubSchema;
|
|
||||||
case "gitlab":
|
|
||||||
return gitlabSchema;
|
|
||||||
case 'gitea':
|
|
||||||
return giteaSchema;
|
|
||||||
case 'gerrit':
|
|
||||||
return gerritSchema;
|
|
||||||
case 'bitbucket':
|
|
||||||
return bitbucketSchema;
|
|
||||||
case 'azuredevops':
|
|
||||||
return azuredevopsSchema;
|
|
||||||
case 'git':
|
|
||||||
return genericGitHostSchema;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!schema) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: "invalid connection type",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidConfig = ajv.validate(schema, parsedConfig);
|
|
||||||
if (!isValidConfig) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: `config schema validation failed with errors: ${ajv.errorsText(ajv.errors)}`,
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('token' in parsedConfig && parsedConfig.token && 'env' in parsedConfig.token) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: "Environment variables are not supported for connections created in the web UI. Please use a secret instead.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { numRepos, hasToken } = (() => {
|
|
||||||
switch (connectionType) {
|
|
||||||
case "gitea":
|
|
||||||
case "github":
|
|
||||||
case "bitbucket":
|
|
||||||
case "azuredevops": {
|
|
||||||
return {
|
|
||||||
numRepos: parsedConfig.repos?.length,
|
|
||||||
hasToken: !!parsedConfig.token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "gitlab": {
|
|
||||||
return {
|
|
||||||
numRepos: parsedConfig.projects?.length,
|
|
||||||
hasToken: !!parsedConfig.token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "gerrit": {
|
|
||||||
return {
|
|
||||||
numRepos: parsedConfig.projects?.length,
|
|
||||||
hasToken: true, // gerrit doesn't use a token atm
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "git": {
|
|
||||||
return {
|
|
||||||
numRepos: 1,
|
|
||||||
hasToken: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!hasToken && numRepos && numRepos > env.CONFIG_MAX_REPOS_NO_TOKEN) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: `You must provide a token to sync more than ${env.CONFIG_MAX_REPOS_NO_TOKEN} repositories.`,
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const encryptValue = async (value: string) => {
|
|
||||||
return encrypt(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const decryptValue = async (iv: string, encryptedValue: string) => {
|
|
||||||
return decrypt(iv, encryptedValue);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import { search } from "@codemirror/search";
|
||||||
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
|
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { EditorContextMenu } from "../../../components/editorContextMenu";
|
import { EditorContextMenu } from "../../../components/editorContextMenu";
|
||||||
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
|
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
|
||||||
|
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "../../hooks/utils";
|
||||||
import { useBrowseState } from "../../hooks/useBrowseState";
|
import { useBrowseState } from "../../hooks/useBrowseState";
|
||||||
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
|
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { FileTreeItem } from "@/features/fileTree/actions";
|
import { FileTreeItem } from "@/features/fileTree/actions";
|
||||||
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
|
import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent";
|
||||||
import { getBrowsePath } from "../../hooks/useBrowseNavigation";
|
import { getBrowsePath } from "../../hooks/utils";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useBrowseParams } from "../../hooks/useBrowseParams";
|
import { useBrowseParams } from "../../hooks/useBrowseParams";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { StateField, Range } from "@codemirror/state";
|
import { StateField, Range } from "@codemirror/state";
|
||||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||||
import { BrowseHighlightRange } from "../../hooks/useBrowseNavigation";
|
import { BrowseHighlightRange } from "../../hooks/utils";
|
||||||
|
|
||||||
const markDecoration = Decoration.mark({
|
const markDecoration = Decoration.mark({
|
||||||
class: "searchMatch-selected",
|
class: "searchMatch-selected",
|
||||||
|
|
|
||||||
|
|
@ -3,58 +3,7 @@
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
|
import { getBrowsePath, GetBrowsePathProps } from "./utils";
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
export interface GetBrowsePathProps {
|
|
||||||
repoName: string;
|
|
||||||
revisionName?: string;
|
|
||||||
path: string;
|
|
||||||
pathType: 'blob' | 'tree';
|
|
||||||
highlightRange?: BrowseHighlightRange;
|
|
||||||
setBrowseState?: Partial<BrowseState>;
|
|
||||||
domain: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getBrowsePath = ({
|
|
||||||
repoName,
|
|
||||||
revisionName = 'HEAD',
|
|
||||||
path,
|
|
||||||
pathType,
|
|
||||||
highlightRange,
|
|
||||||
setBrowseState,
|
|
||||||
domain,
|
|
||||||
}: GetBrowsePathProps) => {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(path);
|
|
||||||
const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
|
|
||||||
return browsePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const useBrowseNavigation = () => {
|
export const useBrowseNavigation = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getBrowsePath, GetBrowsePathProps } from "./useBrowseNavigation";
|
import { getBrowsePath, GetBrowsePathProps } from "./utils";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
export const useBrowsePath = ({
|
export const useBrowsePath = ({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,24 @@
|
||||||
|
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
|
||||||
|
|
||||||
|
export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange';
|
||||||
|
|
||||||
|
export type BrowseHighlightRange = {
|
||||||
|
start: { lineNumber: number; column: number; };
|
||||||
|
end: { lineNumber: number; column: number; };
|
||||||
|
} | {
|
||||||
|
start: { lineNumber: number; };
|
||||||
|
end: { lineNumber: number; };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetBrowsePathProps {
|
||||||
|
repoName: string;
|
||||||
|
revisionName?: string;
|
||||||
|
path: string;
|
||||||
|
pathType: 'blob' | 'tree';
|
||||||
|
highlightRange?: BrowseHighlightRange;
|
||||||
|
setBrowseState?: Partial<BrowseState>;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
||||||
const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/);
|
const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/);
|
||||||
|
|
@ -40,4 +61,29 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
||||||
path,
|
path,
|
||||||
pathType,
|
pathType,
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const getBrowsePath = ({
|
||||||
|
repoName, revisionName = 'HEAD', path, pathType, highlightRange, setBrowseState, domain,
|
||||||
|
}: GetBrowsePathProps) => {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedPath = encodeURIComponent(path);
|
||||||
|
const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
|
||||||
|
return browsePath;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export default async function Page(props: PageProps) {
|
||||||
<>
|
<>
|
||||||
<TopBar
|
<TopBar
|
||||||
domain={params.domain}
|
domain={params.domain}
|
||||||
|
homePath={`/${params.domain}/chat`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<span className="text-muted mx-2 select-none">/</span>
|
<span className="text-muted mx-2 select-none">/</span>
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ import { cn, getCodeHostIcon } from "@/lib/utils";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
||||||
|
|
||||||
interface AskSourcebotDemoCardsProps {
|
interface DemoCards {
|
||||||
demoExamples: DemoExamples;
|
demoExamples: DemoExamples;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AskSourcebotDemoCards = ({
|
export const DemoCards = ({
|
||||||
demoExamples,
|
demoExamples,
|
||||||
}: AskSourcebotDemoCardsProps) => {
|
}: DemoCards) => {
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState<number | null>(null);
|
const [selectedFilterSearchScope, setSelectedFilterSearchScope] = useState<number | null>(null);
|
||||||
|
|
||||||
|
|
@ -6,50 +6,32 @@ import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolba
|
||||||
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
||||||
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
|
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
import { DemoExamples } from "@/types";
|
import { SearchModeSelector } from "../../components/searchModeSelector";
|
||||||
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
|
import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner";
|
||||||
import { AgenticSearchTutorialDialog } from "./agenticSearchTutorialDialog";
|
|
||||||
import { setAgenticSearchTutorialDismissedCookie } from "@/actions";
|
|
||||||
import { RepositorySnapshot } from "./repositorySnapshot";
|
|
||||||
|
|
||||||
interface AgenticSearchProps {
|
interface LandingPageChatBox {
|
||||||
searchModeSelectorProps: SearchModeSelectorProps;
|
|
||||||
languageModels: LanguageModelInfo[];
|
languageModels: LanguageModelInfo[];
|
||||||
repos: RepositoryQuery[];
|
repos: RepositoryQuery[];
|
||||||
searchContexts: SearchContextQuery[];
|
searchContexts: SearchContextQuery[];
|
||||||
chatHistory: {
|
|
||||||
id: string;
|
|
||||||
createdAt: Date;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
demoExamples: DemoExamples | undefined;
|
|
||||||
isTutorialDismissed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgenticSearch = ({
|
export const LandingPageChatBox = ({
|
||||||
searchModeSelectorProps,
|
|
||||||
languageModels,
|
languageModels,
|
||||||
repos,
|
repos,
|
||||||
searchContexts,
|
searchContexts,
|
||||||
demoExamples,
|
}: LandingPageChatBox) => {
|
||||||
isTutorialDismissed,
|
|
||||||
}: AgenticSearchProps) => {
|
|
||||||
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
||||||
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
||||||
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
||||||
|
const isChatBoxDisabled = languageModels.length === 0;
|
||||||
const [isTutorialOpen, setIsTutorialOpen] = useState(!isTutorialDismissed);
|
|
||||||
const onTutorialDismissed = useCallback(() => {
|
|
||||||
setIsTutorialOpen(false);
|
|
||||||
setAgenticSearchTutorialDismissedCookie(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center w-full">
|
<div className="w-full max-w-[800px] mt-4">
|
||||||
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
|
|
||||||
|
|
||||||
|
<div className="border rounded-md w-full shadow-sm">
|
||||||
<ChatBox
|
<ChatBox
|
||||||
onSubmit={(children) => {
|
onSubmit={(children) => {
|
||||||
createNewChatThread(children, selectedSearchScopes);
|
createNewChatThread(children, selectedSearchScopes);
|
||||||
|
|
@ -60,6 +42,7 @@ export const AgenticSearch = ({
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
selectedSearchScopes={selectedSearchScopes}
|
||||||
searchContexts={searchContexts}
|
searchContexts={searchContexts}
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||||
|
isDisabled={isChatBoxDisabled}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -74,33 +57,15 @@ export const AgenticSearch = ({
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||||
/>
|
/>
|
||||||
<SearchModeSelector
|
<SearchModeSelector
|
||||||
{...searchModeSelectorProps}
|
searchMode="agentic"
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
{isChatBoxDisabled && (
|
||||||
<RepositorySnapshot
|
<NotConfiguredErrorBanner className="mt-4" />
|
||||||
repos={repos}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center w-fit gap-6">
|
|
||||||
<Separator className="mt-5 w-[700px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{demoExamples && (
|
|
||||||
<AskSourcebotDemoCards
|
|
||||||
demoExamples={demoExamples}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isTutorialOpen && (
|
|
||||||
<AgenticSearchTutorialDialog
|
|
||||||
onClose={onTutorialDismissed}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div >
|
</div >
|
||||||
)
|
)
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { ResizablePanel } from "@/components/ui/resizable";
|
|
||||||
import { ChatBox } from "@/features/chat/components/chatBox";
|
|
||||||
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
|
|
||||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
|
||||||
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
|
||||||
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Descendant } from "slate";
|
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
|
||||||
|
|
||||||
interface NewChatPanelProps {
|
|
||||||
languageModels: LanguageModelInfo[];
|
|
||||||
repos: RepositoryQuery[];
|
|
||||||
searchContexts: SearchContextQuery[];
|
|
||||||
order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NewChatPanel = ({
|
|
||||||
languageModels,
|
|
||||||
repos,
|
|
||||||
searchContexts,
|
|
||||||
order,
|
|
||||||
}: NewChatPanelProps) => {
|
|
||||||
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
|
||||||
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
|
||||||
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
|
||||||
|
|
||||||
const onSubmit = useCallback((children: Descendant[]) => {
|
|
||||||
createNewChatThread(children, selectedSearchScopes);
|
|
||||||
}, [createNewChatThread, selectedSearchScopes]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResizablePanel
|
|
||||||
order={order}
|
|
||||||
id="new-chat-panel"
|
|
||||||
defaultSize={85}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col h-full w-full items-center justify-start pt-[20vh]">
|
|
||||||
<h2 className="text-4xl font-bold mb-8">What can I help you understand?</h2>
|
|
||||||
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
|
|
||||||
<CustomSlateEditor>
|
|
||||||
<ChatBox
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className="min-h-[80px]"
|
|
||||||
preferredSuggestionsBoxPlacement="bottom-start"
|
|
||||||
isRedirecting={isLoading}
|
|
||||||
languageModels={languageModels}
|
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
|
||||||
/>
|
|
||||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
|
||||||
<ChatBoxToolbar
|
|
||||||
languageModels={languageModels}
|
|
||||||
repos={repos}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
|
||||||
onSelectedSearchScopesChange={setSelectedSearchScopes}
|
|
||||||
isContextSelectorOpen={isContextSelectorOpen}
|
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CustomSlateEditor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { setAgenticSearchTutorialDismissedCookie } from "@/actions"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { ModelProviderLogo } from "@/features/chat/components/chatBox/modelProviderLogo"
|
import { ModelProviderLogo } from "@/features/chat/components/chatBox/modelProviderLogo"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png"
|
import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png"
|
||||||
|
|
@ -27,11 +28,9 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
|
|
||||||
|
|
||||||
interface AgenticSearchTutorialDialogProps {
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Star button component that fetches GitHub star count
|
// Star button component that fetches GitHub star count
|
||||||
|
|
@ -249,7 +248,17 @@ const tutorialSteps = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDialogProps) => {
|
interface TutorialDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TutorialDialog = ({ isOpen: _isOpen }: TutorialDialogProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(_isOpen);
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setAgenticSearchTutorialDismissedCookie(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
|
|
@ -269,11 +278,12 @@ export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDi
|
||||||
const currentStepData = tutorialSteps[currentStep];
|
const currentStepData = tutorialSteps[currentStep];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-[900px] p-0 flex flex-col h-[525px] overflow-hidden rounded-xl border-none bg-transparent"
|
className="sm:max-w-[900px] p-0 flex flex-col h-[525px] overflow-hidden rounded-xl border-none bg-transparent"
|
||||||
closeButtonClassName="text-white"
|
closeButtonClassName="text-white"
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">Ask Sourcebot tutorial</DialogTitle>
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
{/* Left Column (Text Content & Navigation) */}
|
{/* Left Column (Text Content & Navigation) */}
|
||||||
<div className="flex-1 flex flex-col justify-between bg-background">
|
<div className="flex-1 flex flex-col justify-between bg-background">
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
|
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME } from '@/lib/constants';
|
||||||
import { NavigationGuardProvider } from 'next-navigation-guard';
|
import { NavigationGuardProvider } from 'next-navigation-guard';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { TutorialDialog } from './components/tutorialDialog';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Layout({ children }: LayoutProps) {
|
export default async function Layout({ children }: LayoutProps) {
|
||||||
|
const isTutorialDismissed = (await cookies()).get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// @note: we use a navigation guard here since we don't support resuming streams yet.
|
// @note: we use a navigation guard here since we don't support resuming streams yet.
|
||||||
|
|
@ -13,6 +17,7 @@ export default async function Layout({ children }: LayoutProps) {
|
||||||
<div className="flex flex-col h-screen w-screen">
|
<div className="flex flex-col h-screen w-screen">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
<TutorialDialog isOpen={!isTutorialDismissed} />
|
||||||
</NavigationGuardProvider>
|
</NavigationGuardProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { getRepos, getSearchContexts } from "@/actions";
|
import { getRepos, getReposStats, getSearchContexts } from "@/actions";
|
||||||
import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
|
||||||
|
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError, measure } from "@/lib/utils";
|
||||||
import { NewChatPanel } from "./components/newChatPanel";
|
import { LandingPageChatBox } from "./components/landingPageChatBox";
|
||||||
import { TopBar } from "../components/topBar";
|
import { RepositoryCarousel } from "../components/repositoryCarousel";
|
||||||
import { ResizablePanelGroup } from "@/components/ui/resizable";
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
import { ChatSidePanel } from "./components/chatSidePanel";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { auth } from "@/auth";
|
import { DemoCards } from "./components/demoCards";
|
||||||
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
import { env } from "@/env.mjs";
|
||||||
|
import { loadJsonFile } from "@sourcebot/shared";
|
||||||
|
import { DemoExamples, demoExamplesSchema } from "@/types";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|
@ -18,47 +22,85 @@ interface PageProps {
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const languageModels = await getConfiguredLanguageModelsInfo();
|
const languageModels = await getConfiguredLanguageModelsInfo();
|
||||||
const repos = await getRepos();
|
|
||||||
const searchContexts = await getSearchContexts(params.domain);
|
const searchContexts = await getSearchContexts(params.domain);
|
||||||
const session = await auth();
|
const allRepos = await getRepos();
|
||||||
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
|
|
||||||
|
|
||||||
if (isServiceError(chatHistory)) {
|
const carouselRepos = await getRepos({
|
||||||
throw new ServiceErrorException(chatHistory);
|
where: {
|
||||||
}
|
indexedAt: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
if (isServiceError(repos)) {
|
const repoStats = await getReposStats();
|
||||||
throw new ServiceErrorException(repos);
|
|
||||||
|
if (isServiceError(allRepos)) {
|
||||||
|
throw new ServiceErrorException(allRepos);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isServiceError(searchContexts)) {
|
if (isServiceError(searchContexts)) {
|
||||||
throw new ServiceErrorException(searchContexts);
|
throw new ServiceErrorException(searchContexts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
if (isServiceError(carouselRepos)) {
|
||||||
|
throw new ServiceErrorException(carouselRepos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServiceError(repoStats)) {
|
||||||
|
throw new ServiceErrorException(repoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => {
|
||||||
|
try {
|
||||||
|
return (await measure(() => loadJsonFile<DemoExamples>(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema), 'loadExamplesJsonFile')).data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load demo examples:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})() : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||||
<TopBar
|
<NavigationMenu
|
||||||
domain={params.domain}
|
domain={params.domain}
|
||||||
/>
|
/>
|
||||||
<ResizablePanelGroup
|
|
||||||
direction="horizontal"
|
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||||
>
|
<div className="max-h-44 w-auto">
|
||||||
<ChatSidePanel
|
<SourcebotLogo
|
||||||
order={1}
|
className="h-18 md:h-40 w-auto"
|
||||||
chatHistory={chatHistory}
|
/>
|
||||||
isAuthenticated={!!session}
|
</div>
|
||||||
isCollapsedInitially={false}
|
<CustomSlateEditor>
|
||||||
/>
|
<LandingPageChatBox
|
||||||
<AnimatedResizableHandle />
|
languageModels={languageModels}
|
||||||
<NewChatPanel
|
repos={allRepos}
|
||||||
languageModels={languageModels}
|
searchContexts={searchContexts}
|
||||||
searchContexts={searchContexts}
|
/>
|
||||||
repos={indexedRepos}
|
</CustomSlateEditor>
|
||||||
order={2}
|
|
||||||
/>
|
<div className="mt-8">
|
||||||
</ResizablePanelGroup>
|
<RepositoryCarousel
|
||||||
</>
|
numberOfReposWithIndex={repoStats.numberOfReposWithIndex}
|
||||||
|
displayRepos={carouselRepos}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{demoExamples && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center w-fit gap-6">
|
||||||
|
<Separator className="mt-5 w-[700px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DemoCards
|
||||||
|
demoExamples={demoExamples}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +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";
|
import { HIGHLIGHT_RANGE_QUERY_PARAM } from "../browse/hooks/utils";
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
|
||||||
import { CircleXIcon } from "lucide-react";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { getConnections } from "@/actions";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
|
|
||||||
export const ErrorNavIndicator = () => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
|
|
||||||
queryKey: ['repos', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getRepos()),
|
|
||||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: connections, isPending: isPendingConnections, isError: isErrorConnections } = useQuery({
|
|
||||||
queryKey: ['connections', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
|
||||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.FAILED),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPendingRepos || isErrorRepos || isPendingConnections || isErrorConnections) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repos.length === 0 && connections.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
|
|
||||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
|
|
||||||
<CircleXIcon className="h-4 w-4" />
|
|
||||||
{repos.length + connections.length > 0 && (
|
|
||||||
<span>{repos.length + connections.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
|
||||||
<div className="flex flex-col gap-6 p-5">
|
|
||||||
{connections.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4 pb-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
|
||||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Connection Sync Issues</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
|
||||||
The following connections have failed to sync:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
{connections
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(connection => (
|
|
||||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
|
||||||
rounded-md text-sm text-red-700 dark:text-red-300
|
|
||||||
border border-red-200/50 dark:border-red-800/50
|
|
||||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="font-medium truncate max-w-[200px]">{connection.name}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{connection.name}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
{connections.length > 10 && (
|
|
||||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
|
|
||||||
And {connections.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{repos.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-red-500"></div>
|
|
||||||
<h3 className="text-sm font-medium text-red-700 dark:text-red-400">Repository Indexing Issues</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-red-600/90 dark:text-red-300/90 leading-relaxed">
|
|
||||||
The following repositories failed to index:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
{repos
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(repo => (
|
|
||||||
<div key={repo.repoId} className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
|
||||||
rounded-md text-sm text-red-700 dark:text-red-300
|
|
||||||
border border-red-200/50 dark:border-red-800/50
|
|
||||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="text-sm font-medium truncate max-w-[200px]">{repo.repoName}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{repo.repoName}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
{repos.length > 10 && (
|
|
||||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 pl-3 pt-1">
|
|
||||||
And {repos.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
|
||||||
import { LanguageModelInfo } from "@/features/chat/types";
|
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
|
||||||
import { AgenticSearch } from "./agenticSearch";
|
|
||||||
import { PreciseSearch } from "./preciseSearch";
|
|
||||||
import { SearchMode } from "./toolbar";
|
|
||||||
import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
|
|
||||||
import { setSearchModeCookie } from "@/actions";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { DemoExamples } from "@/types";
|
|
||||||
|
|
||||||
interface HomepageProps {
|
|
||||||
initialRepos: RepositoryQuery[];
|
|
||||||
searchContexts: SearchContextQuery[];
|
|
||||||
languageModels: LanguageModelInfo[];
|
|
||||||
chatHistory: {
|
|
||||||
id: string;
|
|
||||||
createdAt: Date;
|
|
||||||
name: string | null;
|
|
||||||
}[];
|
|
||||||
initialSearchMode: SearchMode;
|
|
||||||
demoExamples: DemoExamples | undefined;
|
|
||||||
isAgenticSearchTutorialDismissed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const Homepage = ({
|
|
||||||
initialRepos,
|
|
||||||
searchContexts,
|
|
||||||
languageModels,
|
|
||||||
chatHistory,
|
|
||||||
initialSearchMode,
|
|
||||||
demoExamples,
|
|
||||||
isAgenticSearchTutorialDismissed,
|
|
||||||
}: HomepageProps) => {
|
|
||||||
const [searchMode, setSearchMode] = useState<SearchMode>(initialSearchMode);
|
|
||||||
const isAgenticSearchEnabled = languageModels.length > 0;
|
|
||||||
|
|
||||||
const onSearchModeChanged = useCallback(async (newMode: SearchMode) => {
|
|
||||||
setSearchMode(newMode);
|
|
||||||
await setSearchModeCookie(newMode);
|
|
||||||
}, [setSearchMode]);
|
|
||||||
|
|
||||||
useHotkeys("mod+i", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSearchModeChanged("agentic");
|
|
||||||
}, {
|
|
||||||
enableOnFormTags: true,
|
|
||||||
enableOnContentEditable: true,
|
|
||||||
description: "Switch to agentic search",
|
|
||||||
});
|
|
||||||
|
|
||||||
useHotkeys("mod+p", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSearchModeChanged("precise");
|
|
||||||
}, {
|
|
||||||
enableOnFormTags: true,
|
|
||||||
enableOnContentEditable: true,
|
|
||||||
description: "Switch to precise search",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
|
||||||
<div className="max-h-44 w-auto">
|
|
||||||
<SourcebotLogo
|
|
||||||
className="h-18 md:h-40 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchMode === "precise" ? (
|
|
||||||
<PreciseSearch
|
|
||||||
initialRepos={initialRepos}
|
|
||||||
searchModeSelectorProps={{
|
|
||||||
searchMode: "precise",
|
|
||||||
isAgenticSearchEnabled,
|
|
||||||
onSearchModeChange: onSearchModeChanged,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CustomSlateEditor>
|
|
||||||
<AgenticSearch
|
|
||||||
searchModeSelectorProps={{
|
|
||||||
searchMode: "agentic",
|
|
||||||
isAgenticSearchEnabled,
|
|
||||||
onSearchModeChange: onSearchModeChanged,
|
|
||||||
}}
|
|
||||||
languageModels={languageModels}
|
|
||||||
repos={initialRepos}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
chatHistory={chatHistory}
|
|
||||||
demoExamples={demoExamples}
|
|
||||||
isTutorialDismissed={isAgenticSearchTutorialDismissed}
|
|
||||||
/>
|
|
||||||
</CustomSlateEditor>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { SyntaxReferenceGuideHint } from "../syntaxReferenceGuideHint";
|
|
||||||
import { RepositorySnapshot } from "./repositorySnapshot";
|
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { SearchBar } from "../searchBar/searchBar";
|
|
||||||
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
|
|
||||||
|
|
||||||
interface PreciseSearchProps {
|
|
||||||
initialRepos: RepositoryQuery[];
|
|
||||||
searchModeSelectorProps: SearchModeSelectorProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PreciseSearch = ({
|
|
||||||
initialRepos,
|
|
||||||
searchModeSelectorProps,
|
|
||||||
}: PreciseSearchProps) => {
|
|
||||||
const domain = useDomain();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
|
|
||||||
<SearchBar
|
|
||||||
autoFocus={true}
|
|
||||||
className="border-none pt-0.5 pb-0"
|
|
||||||
/>
|
|
||||||
<Separator />
|
|
||||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
|
||||||
<SearchModeSelector
|
|
||||||
{...searchModeSelectorProps}
|
|
||||||
className="ml-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8">
|
|
||||||
<RepositorySnapshot
|
|
||||||
repos={initialRepos}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center w-fit gap-6">
|
|
||||||
<Separator className="mt-5" />
|
|
||||||
<span className="font-semibold">How to search</span>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
||||||
<HowToSection
|
|
||||||
title="Search in files or paths"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
<HowToSection
|
|
||||||
title="Filter results"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
<HowToSection
|
|
||||||
title="Advanced"
|
|
||||||
>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
<QueryExample>
|
|
||||||
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
|
||||||
</QueryExample>
|
|
||||||
</HowToSection>
|
|
||||||
</div>
|
|
||||||
<SyntaxReferenceGuideHint />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-highlight">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-sm font-mono">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/${domain}/search?query=${query}`}
|
|
||||||
className="cursor-pointer hover:underline"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
} from "@/components/ui/carousel";
|
|
||||||
import Autoscroll from "embla-carousel-auto-scroll";
|
|
||||||
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { FileIcon } from "@radix-ui/react-icons";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
|
||||||
import { getBrowsePath } from "../../browse/hooks/useBrowseNavigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
|
|
||||||
interface RepositoryCarouselProps {
|
|
||||||
repos: RepositoryQuery[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepositoryCarousel = ({
|
|
||||||
repos,
|
|
||||||
}: RepositoryCarouselProps) => {
|
|
||||||
return (
|
|
||||||
<Carousel
|
|
||||||
opts={{
|
|
||||||
align: "start",
|
|
||||||
loop: true,
|
|
||||||
}}
|
|
||||||
className="w-full max-w-lg"
|
|
||||||
plugins={[
|
|
||||||
Autoscroll({
|
|
||||||
startDelay: 0,
|
|
||||||
speed: 1,
|
|
||||||
stopOnMouseEnter: true,
|
|
||||||
stopOnInteraction: false,
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CarouselContent>
|
|
||||||
{repos.map((repo, index) => (
|
|
||||||
<CarouselItem key={index} className="basis-auto">
|
|
||||||
<RepositoryBadge
|
|
||||||
key={index}
|
|
||||||
repo={repo}
|
|
||||||
/>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RepositoryBadgeProps {
|
|
||||||
repo: RepositoryQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RepositoryBadge = ({
|
|
||||||
repo
|
|
||||||
}: RepositoryBadgeProps) => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const { repoIcon, displayName } = (() => {
|
|
||||||
const info = getCodeHostInfoForRepo({
|
|
||||||
codeHostType: repo.codeHostType,
|
|
||||||
name: repo.repoName,
|
|
||||||
displayName: repo.repoDisplayName,
|
|
||||||
webUrl: repo.webUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (info) {
|
|
||||||
return {
|
|
||||||
repoIcon: <Image
|
|
||||||
src={info.icon}
|
|
||||||
alt={info.codeHostName}
|
|
||||||
className={`w-4 h-4 ${info.iconClassName}`}
|
|
||||||
/>,
|
|
||||||
displayName: info.displayName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
repoIcon: <FileIcon className="w-4 h-4" />,
|
|
||||||
displayName: repo.repoName,
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={getBrowsePath({
|
|
||||||
repoName: repo.repoName,
|
|
||||||
path: '/',
|
|
||||||
pathType: 'tree',
|
|
||||||
domain
|
|
||||||
})}
|
|
||||||
|
|
||||||
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
|
||||||
>
|
|
||||||
{repoIcon}
|
|
||||||
<span className="text-sm font-mono">
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { RepositoryCarousel } from "./repositoryCarousel";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
} from "@/components/ui/carousel";
|
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
|
||||||
import { captureEvent } from "@/hooks/useCaptureEvent";
|
|
||||||
|
|
||||||
interface RepositorySnapshotProps {
|
|
||||||
repos: RepositoryQuery[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_REPOS_TO_DISPLAY_IN_CAROUSEL = 15;
|
|
||||||
|
|
||||||
export function RepositorySnapshot({
|
|
||||||
repos: initialRepos,
|
|
||||||
}: RepositorySnapshotProps) {
|
|
||||||
const domain = useDomain();
|
|
||||||
|
|
||||||
const { data: repos, isPending, isError } = useQuery({
|
|
||||||
queryKey: ['repos', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getRepos()),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
placeholderData: initialRepos,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending || isError || !repos) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<RepoSkeleton />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use `indexedAt` to determine if a repo has __ever__ been indexed.
|
|
||||||
// The repo indexing status only tells us the repo's current indexing status.
|
|
||||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
|
||||||
|
|
||||||
// If there are no indexed repos...
|
|
||||||
if (indexedRepos.length === 0) {
|
|
||||||
|
|
||||||
// ... show a loading state if repos are being indexed now
|
|
||||||
if (repos.some((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE)) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center gap-3">
|
|
||||||
<SymbolIcon className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">indexing in progress...</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// ... otherwise, show the empty state.
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<EmptyRepoState />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<span className="text-sm">
|
|
||||||
{`${indexedRepos.length} `}
|
|
||||||
<Link
|
|
||||||
href={`${domain}/repos`}
|
|
||||||
className="text-link hover:underline"
|
|
||||||
>
|
|
||||||
{indexedRepos.length > 1 ? 'repositories' : 'repository'}
|
|
||||||
</Link>
|
|
||||||
{` indexed`}
|
|
||||||
</span>
|
|
||||||
<RepositoryCarousel
|
|
||||||
repos={indexedRepos.slice(0, MAX_REPOS_TO_DISPLAY_IN_CAROUSEL)}
|
|
||||||
/>
|
|
||||||
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
Interested in using Sourcebot on your code? Check out our{' '}
|
|
||||||
<a
|
|
||||||
href="https://docs.sourcebot.dev/docs/overview"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={() => captureEvent('wa_demo_docs_link_pressed', {})}
|
|
||||||
>
|
|
||||||
docs
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyRepoState() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<span className="text-sm">No repositories found</span>
|
|
||||||
|
|
||||||
<div className="w-full max-w-lg">
|
|
||||||
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
<>
|
|
||||||
Create a{" "}
|
|
||||||
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
|
||||||
connection
|
|
||||||
</Link>{" "}
|
|
||||||
to start indexing repositories
|
|
||||||
</>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepoSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
{/* Skeleton for "Search X repositories" text */}
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Skeleton className="h-4 w-14" /> {/* "Search X" */}
|
|
||||||
<Skeleton className="h-4 w-24" /> {/* "repositories" */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skeleton for repository carousel */}
|
|
||||||
<Carousel
|
|
||||||
opts={{
|
|
||||||
align: "start",
|
|
||||||
loop: true,
|
|
||||||
}}
|
|
||||||
className="w-full max-w-lg"
|
|
||||||
>
|
|
||||||
<CarouselContent>
|
|
||||||
{[1, 2, 3].map((_, index) => (
|
|
||||||
<CarouselItem key={index} className="basis-auto">
|
|
||||||
<div className="flex flex-row items-center gap-2 border rounded-md p-2">
|
|
||||||
<Skeleton className="h-4 w-4 rounded-sm" /> {/* Icon */}
|
|
||||||
<Skeleton className="h-4 w-32" /> {/* Repository name */}
|
|
||||||
</div>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { SettingsDropdown } from "./settingsDropdown";
|
|
||||||
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { OrgSelector } from "./orgSelector";
|
|
||||||
import { ErrorNavIndicator } from "./errorNavIndicator";
|
|
||||||
import { WarningNavIndicator } from "./warningNavIndicator";
|
|
||||||
import { ProgressNavIndicator } from "./progressNavIndicator";
|
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
|
||||||
import { TrialNavIndicator } from "./trialNavIndicator";
|
|
||||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
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_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
|
||||||
|
|
||||||
interface NavigationMenuProps {
|
|
||||||
domain: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NavigationMenu = async ({
|
|
||||||
domain,
|
|
||||||
}: NavigationMenuProps) => {
|
|
||||||
const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null;
|
|
||||||
const session = await auth();
|
|
||||||
const isAuthenticated = session?.user !== undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 items-center">
|
|
||||||
<Link
|
|
||||||
href={`/${domain}`}
|
|
||||||
className="mr-3 cursor-pointer"
|
|
||||||
>
|
|
||||||
<SourcebotLogo
|
|
||||||
className="h-11"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{env.SOURCEBOT_TENANCY_MODE === 'multi' && (
|
|
||||||
<>
|
|
||||||
<OrgSelector
|
|
||||||
domain={domain}
|
|
||||||
/>
|
|
||||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NavigationMenuBase>
|
|
||||||
<NavigationMenuList>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink
|
|
||||||
href={`/${domain}`}
|
|
||||||
className={navigationMenuTriggerStyle()}
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink
|
|
||||||
href={`/${domain}/repos`}
|
|
||||||
className={navigationMenuTriggerStyle()}
|
|
||||||
>
|
|
||||||
Repositories
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
{isAuthenticated && (
|
|
||||||
<>
|
|
||||||
{env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && (
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink
|
|
||||||
href={`/${domain}/agents`}
|
|
||||||
className={navigationMenuTriggerStyle()}
|
|
||||||
>
|
|
||||||
Agents
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
)}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink
|
|
||||||
href={`/${domain}/connections`}
|
|
||||||
className={navigationMenuTriggerStyle()}
|
|
||||||
>
|
|
||||||
Connections
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink
|
|
||||||
href={`/${domain}/settings`}
|
|
||||||
className={navigationMenuTriggerStyle()}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenuBase>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<ProgressNavIndicator />
|
|
||||||
<WarningNavIndicator />
|
|
||||||
<ErrorNavIndicator />
|
|
||||||
<TrialNavIndicator subscription={subscription} />
|
|
||||||
<WhatsNewIndicator />
|
|
||||||
<form
|
|
||||||
action={async () => {
|
|
||||||
"use server";
|
|
||||||
redirect(SOURCEBOT_DISCORD_URL);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
<DiscordLogoIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<form
|
|
||||||
action={async () => {
|
|
||||||
"use server";
|
|
||||||
redirect(SOURCEBOT_GITHUB_URL);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
<GitHubLogoIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
<SettingsDropdown />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { getRepos, getReposStats } from "@/actions";
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { NavigationMenu as NavigationMenuBase } from "@/components/ui/navigation-menu";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||||
|
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||||
|
import { RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { OrgSelector } from "../orgSelector";
|
||||||
|
import { SettingsDropdown } from "../settingsDropdown";
|
||||||
|
import WhatsNewIndicator from "../whatsNewIndicator";
|
||||||
|
import { NavigationItems } from "./navigationItems";
|
||||||
|
import { ProgressIndicator } from "./progressIndicator";
|
||||||
|
import { TrialIndicator } from "./trialIndicator";
|
||||||
|
|
||||||
|
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||||
|
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||||
|
|
||||||
|
interface NavigationMenuProps {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavigationMenu = async ({
|
||||||
|
domain,
|
||||||
|
}: NavigationMenuProps) => {
|
||||||
|
const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null;
|
||||||
|
const session = await auth();
|
||||||
|
const isAuthenticated = session?.user !== undefined;
|
||||||
|
|
||||||
|
const repoStats = await getReposStats();
|
||||||
|
if (isServiceError(repoStats)) {
|
||||||
|
throw new ServiceErrorException(repoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRepos = await getRepos({
|
||||||
|
where: {
|
||||||
|
jobs: {
|
||||||
|
some: {
|
||||||
|
type: RepoIndexingJobType.INDEX,
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
RepoIndexingJobStatus.PENDING,
|
||||||
|
RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indexedAt: null,
|
||||||
|
},
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isServiceError(sampleRepos)) {
|
||||||
|
throw new ServiceErrorException(sampleRepos);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
numberOfRepos,
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||||
|
} = repoStats;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full h-fit bg-background">
|
||||||
|
<div className="flex flex-row justify-between items-center py-0.5 px-3">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Link
|
||||||
|
href={`/${domain}`}
|
||||||
|
className="mr-3 cursor-pointer"
|
||||||
|
>
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-11"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{env.SOURCEBOT_TENANCY_MODE === 'multi' && (
|
||||||
|
<>
|
||||||
|
<OrgSelector
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NavigationMenuBase>
|
||||||
|
<NavigationItems
|
||||||
|
domain={domain}
|
||||||
|
numberOfRepos={numberOfRepos}
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress={numberOfReposWithFirstTimeIndexingJobsInProgress}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
/>
|
||||||
|
</NavigationMenuBase>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ProgressIndicator
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress={numberOfReposWithFirstTimeIndexingJobsInProgress}
|
||||||
|
sampleRepos={sampleRepos}
|
||||||
|
/>
|
||||||
|
<TrialIndicator subscription={subscription} />
|
||||||
|
<WhatsNewIndicator />
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
redirect(SOURCEBOT_DISCORD_URL);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<DiscordLogoIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
redirect(SOURCEBOT_GITHUB_URL);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<GitHubLogoIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<SettingsDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
|
||||||
|
import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
interface NavigationItemsProps {
|
||||||
|
domain: string;
|
||||||
|
numberOfRepos: number;
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress: number;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavigationItems = ({
|
||||||
|
domain,
|
||||||
|
numberOfRepos,
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
||||||
|
isAuthenticated,
|
||||||
|
}: NavigationItemsProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === `/${domain}`) {
|
||||||
|
return pathname === `/${domain}`;
|
||||||
|
}
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationMenuList className="gap-2">
|
||||||
|
<NavigationMenuItem className="relative">
|
||||||
|
<NavigationMenuLink
|
||||||
|
href={`/${domain}/search`}
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "gap-2")}
|
||||||
|
>
|
||||||
|
<SearchIcon className="w-4 h-4 mr-1" />
|
||||||
|
Search
|
||||||
|
</NavigationMenuLink>
|
||||||
|
{((isActive(`/${domain}`) || isActive(`/${domain}/search`)) && <ActiveIndicator />)}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem className="relative">
|
||||||
|
<NavigationMenuLink
|
||||||
|
href={`/${domain}/chat`}
|
||||||
|
className={navigationMenuTriggerStyle()}
|
||||||
|
>
|
||||||
|
<MessageCircleIcon className="w-4 h-4 mr-1" />
|
||||||
|
Ask
|
||||||
|
</NavigationMenuLink>
|
||||||
|
{isActive(`/${domain}/chat`) && <ActiveIndicator />}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem className="relative">
|
||||||
|
<NavigationMenuLink
|
||||||
|
href={`/${domain}/repos`}
|
||||||
|
className={navigationMenuTriggerStyle()}
|
||||||
|
>
|
||||||
|
<BookMarkedIcon className="w-4 h-4 mr-1" />
|
||||||
|
<span className="mr-2">Repositories</span>
|
||||||
|
<Badge variant="secondary" className="px-1.5 relative">
|
||||||
|
{getShortenedNumberDisplayString(numberOfRepos)}
|
||||||
|
{numberOfReposWithFirstTimeIndexingJobsInProgress > 0 && (
|
||||||
|
<CircleIcon className="absolute -right-0.5 -top-0.5 h-2 w-2 text-green-600" fill="currentColor" />
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
{isActive(`/${domain}/repos`) && <ActiveIndicator />}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<NavigationMenuItem className="relative">
|
||||||
|
<NavigationMenuLink
|
||||||
|
href={`/${domain}/settings`}
|
||||||
|
className={navigationMenuTriggerStyle()}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||||
|
Settings
|
||||||
|
</NavigationMenuLink>
|
||||||
|
{isActive(`/${domain}/settings`) && <ActiveIndicator />}
|
||||||
|
</NavigationMenuItem>
|
||||||
|
)}
|
||||||
|
</NavigationMenuList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActiveIndicator = () => {
|
||||||
|
return (
|
||||||
|
<div className="absolute -bottom-2 left-0 right-0 h-0.5 bg-foreground" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
|
import { getCodeHostInfoForRepo, getShortenedNumberDisplayString } from "@/lib/utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { FileIcon, Loader2Icon, RefreshCwIcon } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface ProgressIndicatorProps {
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress: number;
|
||||||
|
sampleRepos: RepositoryQuery[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressIndicator = ({
|
||||||
|
numberOfReposWithFirstTimeIndexingJobsInProgress: numRepos,
|
||||||
|
sampleRepos,
|
||||||
|
}: ProgressIndicatorProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
if (numRepos === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numReposString = getShortenedNumberDisplayString(numRepos);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Link href={`/${domain}/repos`}>
|
||||||
|
<Badge variant="outline" className="flex flex-row items-center gap-2 h-8">
|
||||||
|
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||||
|
<span>{numReposString}</span>
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="p-4 w-72">
|
||||||
|
<div className="flex flex-row gap-1 items-center">
|
||||||
|
<p className="text-md font-medium">{`Syncing ${numReposString} ${numRepos === 1 ? 'repository' : 'repositories'}`}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
router.refresh();
|
||||||
|
toast({
|
||||||
|
description: "Page refreshed",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sampleRepos.map((repo) => (
|
||||||
|
<RepoItem key={repo.repoId} repo={repo} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{numRepos > sampleRepos.length && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Link href={`/${domain}/repos`} className="text-sm text-link hover:underline">
|
||||||
|
{`View ${numRepos - sampleRepos.length} more`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RepoItem = ({ repo }: { repo: RepositoryQuery }) => {
|
||||||
|
|
||||||
|
const { repoIcon, displayName } = useMemo(() => {
|
||||||
|
const info = getCodeHostInfoForRepo({
|
||||||
|
name: repo.repoName,
|
||||||
|
codeHostType: repo.codeHostType,
|
||||||
|
displayName: repo.repoDisplayName,
|
||||||
|
webUrl: repo.webUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
repoIcon: <Image
|
||||||
|
src={info.icon}
|
||||||
|
alt={info.codeHostName}
|
||||||
|
className={`w-4 h-4 ${info.iconClassName}`}
|
||||||
|
/>,
|
||||||
|
displayName: info.displayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||||
|
displayName: repo.repoName,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}, [repo.repoName, repo.codeHostType, repo.repoDisplayName, repo.webUrl]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||||
|
>
|
||||||
|
{repoIcon}
|
||||||
|
<span className="text-sm truncate">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ interface Props {
|
||||||
} | null | ServiceError;
|
} | null | ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrialNavIndicator = ({ subscription }: Props) => {
|
export const TrialIndicator = ({ subscription }: Props) => {
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
|
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 { getBrowsePath } from "../browse/hooks/useBrowseNavigation";
|
import { getBrowsePath } from "../browse/hooks/utils";
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
|
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { RepoIndexingStatus } from "@prisma/client";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2Icon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
|
|
||||||
export const ProgressNavIndicator = () => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const { data: inProgressRepos, isPending, isError } = useQuery({
|
|
||||||
queryKey: ['repos'],
|
|
||||||
queryFn: () => unwrapServiceError(getRepos()),
|
|
||||||
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending || isError || inProgressRepos.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/${domain}/connections`}
|
|
||||||
onClick={() => captureEvent('wa_progress_nav_pressed', {})}
|
|
||||||
>
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
|
|
||||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
|
||||||
<span>{inProgressRepos.length}</span>
|
|
||||||
</div>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-green-200 dark:border-green-800 rounded-lg">
|
|
||||||
<div className="flex flex-col gap-4 p-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
|
|
||||||
<h3 className="text-sm font-medium text-green-700 dark:text-green-400">Indexing in Progress</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-green-600/90 dark:text-green-300/90 leading-relaxed">
|
|
||||||
The following repositories are currently being indexed:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 pl-4">
|
|
||||||
{
|
|
||||||
inProgressRepos.slice(0, 10)
|
|
||||||
.map(item => (
|
|
||||||
<div key={item.repoId} className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
|
|
||||||
rounded-md text-sm text-green-700 dark:text-green-300
|
|
||||||
border border-green-200/50 dark:border-green-800/50
|
|
||||||
hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
|
|
||||||
<span className="font-medium truncate">{item.repoName}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{inProgressRepos.length > 10 && (
|
|
||||||
<div className="text-sm text-green-600/90 dark:text-green-300/90 pl-3 pt-1">
|
|
||||||
And {inProgressRepos.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
158
packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Normal file
158
packages/web/src/app/[domain]/components/repositoryCarousel.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
} from "@/components/ui/carousel";
|
||||||
|
import { captureEvent } from "@/hooks/useCaptureEvent";
|
||||||
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
|
import { getCodeHostInfoForRepo } from "@/lib/utils";
|
||||||
|
import { FileIcon } from "@radix-ui/react-icons";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Autoscroll from "embla-carousel-auto-scroll";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getBrowsePath } from "../browse/hooks/utils";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
interface RepositoryCarouselProps {
|
||||||
|
displayRepos: RepositoryQuery[];
|
||||||
|
numberOfReposWithIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryCarousel({
|
||||||
|
displayRepos,
|
||||||
|
numberOfReposWithIndex,
|
||||||
|
}: RepositoryCarouselProps) {
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
|
if (numberOfReposWithIndex === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">No repositories found</span>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<>
|
||||||
|
Create a{" "}
|
||||||
|
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||||
|
connection
|
||||||
|
</Link>{" "}
|
||||||
|
to start indexing repositories
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{`${numberOfReposWithIndex} `}
|
||||||
|
<Link
|
||||||
|
href={`/${domain}/repos`}
|
||||||
|
className="text-link hover:underline"
|
||||||
|
>
|
||||||
|
{numberOfReposWithIndex > 1 ? 'repositories' : 'repository'}
|
||||||
|
</Link>
|
||||||
|
{` indexed`}
|
||||||
|
</span>
|
||||||
|
<Carousel
|
||||||
|
opts={{
|
||||||
|
align: "start",
|
||||||
|
loop: true,
|
||||||
|
}}
|
||||||
|
className="w-full max-w-lg"
|
||||||
|
plugins={[
|
||||||
|
Autoscroll({
|
||||||
|
startDelay: 0,
|
||||||
|
speed: 1,
|
||||||
|
stopOnMouseEnter: true,
|
||||||
|
stopOnInteraction: false,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CarouselContent>
|
||||||
|
{displayRepos.map((repo, index) => (
|
||||||
|
<CarouselItem key={index} className="basis-auto">
|
||||||
|
<RepositoryBadge
|
||||||
|
key={index}
|
||||||
|
repo={repo}
|
||||||
|
/>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
</Carousel>
|
||||||
|
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Interested in using Sourcebot on your code? Check out our{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.sourcebot.dev/docs/overview"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
onClick={() => captureEvent('wa_demo_docs_link_pressed', {})}
|
||||||
|
>
|
||||||
|
docs
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepositoryBadgeProps {
|
||||||
|
repo: RepositoryQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RepositoryBadge = ({
|
||||||
|
repo
|
||||||
|
}: RepositoryBadgeProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const { repoIcon, displayName } = (() => {
|
||||||
|
const info = getCodeHostInfoForRepo({
|
||||||
|
codeHostType: repo.codeHostType,
|
||||||
|
name: repo.repoName,
|
||||||
|
displayName: repo.repoDisplayName,
|
||||||
|
webUrl: repo.webUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
repoIcon: <Image
|
||||||
|
src={info.icon}
|
||||||
|
alt={info.codeHostName}
|
||||||
|
className={`w-4 h-4 ${info.iconClassName}`}
|
||||||
|
/>,
|
||||||
|
displayName: info.displayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||||
|
displayName: repo.repoName,
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={getBrowsePath({
|
||||||
|
repoName: repo.repoName,
|
||||||
|
path: '/',
|
||||||
|
pathType: 'tree',
|
||||||
|
domain,
|
||||||
|
})}
|
||||||
|
|
||||||
|
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||||
|
>
|
||||||
|
{repoIcon}
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MessageCircleIcon, SearchIcon, TriangleAlert } from "lucide-react";
|
import { MessageCircleIcon, SearchIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
|
||||||
export type SearchMode = "precise" | "agentic";
|
export type SearchMode = "precise" | "agentic";
|
||||||
|
|
||||||
|
|
@ -17,24 +20,47 @@ const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/ov
|
||||||
|
|
||||||
export interface SearchModeSelectorProps {
|
export interface SearchModeSelectorProps {
|
||||||
searchMode: SearchMode;
|
searchMode: SearchMode;
|
||||||
isAgenticSearchEnabled: boolean;
|
|
||||||
onSearchModeChange: (searchMode: SearchMode) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchModeSelector = ({
|
export const SearchModeSelector = ({
|
||||||
searchMode,
|
searchMode,
|
||||||
isAgenticSearchEnabled,
|
|
||||||
onSearchModeChange,
|
|
||||||
className,
|
className,
|
||||||
}: SearchModeSelectorProps) => {
|
}: SearchModeSelectorProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
const [focusedSearchMode, setFocusedSearchMode] = useState<SearchMode>(searchMode);
|
const [focusedSearchMode, setFocusedSearchMode] = useState<SearchMode>(searchMode);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const onSearchModeChanged = useCallback((value: SearchMode) => {
|
||||||
|
router.push(`/${domain}/${value === "precise" ? "search" : "chat"}`);
|
||||||
|
}, [domain, router]);
|
||||||
|
|
||||||
|
useHotkeys("mod+i", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearchModeChanged("agentic");
|
||||||
|
}, {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
description: "Switch to agentic search",
|
||||||
|
});
|
||||||
|
|
||||||
|
useHotkeys("mod+p", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearchModeChanged("precise");
|
||||||
|
}, {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
description: "Switch to precise search",
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-row items-center", className)}>
|
<div className={cn("flex flex-row items-center", className)}>
|
||||||
<Select
|
<Select
|
||||||
value={searchMode}
|
value={searchMode}
|
||||||
onValueChange={(value) => onSearchModeChange(value as "precise" | "agentic")}
|
onValueChange={(value) => {
|
||||||
|
onSearchModeChanged(value as SearchMode);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="flex flex-row items-center h-6 mt-0.5 font-mono font-semibold text-xs p-0 w-fit border-none bg-inherit rounded-md"
|
className="flex flex-row items-center h-6 mt-0.5 font-mono font-semibold text-xs p-0 w-fit border-none bg-inherit rounded-md"
|
||||||
|
|
@ -99,16 +125,10 @@ export const SearchModeSelector = ({
|
||||||
<div
|
<div
|
||||||
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
||||||
onFocus={() => setFocusedSearchMode("agentic")}
|
onFocus={() => setFocusedSearchMode("agentic")}
|
||||||
className={cn({
|
|
||||||
"cursor-not-allowed": !isAgenticSearchEnabled,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="agentic"
|
value="agentic"
|
||||||
disabled={!isAgenticSearchEnabled}
|
className="cursor-pointer"
|
||||||
className={cn({
|
|
||||||
"cursor-pointer": isAgenticSearchEnabled,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-1.5">
|
<div className="flex flex-row items-center justify-between w-full gap-1.5">
|
||||||
<span>Ask</span>
|
<span>Ask</span>
|
||||||
|
|
@ -129,14 +149,8 @@ export const SearchModeSelector = ({
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{!isAgenticSearchEnabled && (
|
|
||||||
<TriangleAlert className="w-4 h-4 flex-shrink-0 text-warning" />
|
|
||||||
)}
|
|
||||||
<p className="font-semibold">Ask Sourcebot</p>
|
<p className="font-semibold">Ask Sourcebot</p>
|
||||||
</div>
|
</div>
|
||||||
{!isAgenticSearchEnabled && (
|
|
||||||
<p className="text-destructive">Language model not configured. <Link href={AGENTIC_SEARCH_DOCS_URL} className="text-link hover:underline">See setup instructions.</Link></p>
|
|
||||||
)}
|
|
||||||
<Separator orientation="horizontal" className="w-full my-0.5" />
|
<Separator orientation="horizontal" className="w-full my-0.5" />
|
||||||
<p>Use natural language to search, summarize and understand your codebase using a reasoning agent.</p>
|
<p>Use natural language to search, summarize and understand your codebase using a reasoning agent.</p>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -8,18 +8,20 @@ import { Separator } from "@/components/ui/separator";
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
domain: string;
|
domain: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
homePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar = ({
|
export const TopBar = ({
|
||||||
domain,
|
domain,
|
||||||
children,
|
children,
|
||||||
|
homePath = `/${domain}`,
|
||||||
}: TopBarProps) => {
|
}: TopBarProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='sticky top-0 left-0 right-0 z-10'>
|
<div className='sticky top-0 left-0 right-0 z-10'>
|
||||||
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
|
<div className="flex flex-row justify-between items-center py-1.5 px-3 gap-4 bg-background">
|
||||||
<div className="grow flex flex-row gap-4 items-center">
|
<div className="grow flex flex-row gap-4 items-center">
|
||||||
<Link
|
<Link
|
||||||
href={`/${domain}`}
|
href={homePath}
|
||||||
className="shrink-0 cursor-pointer"
|
className="shrink-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
|
||||||
import { AlertTriangleIcon } from "lucide-react";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { getConnections } from "@/actions";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ConnectionSyncStatus } from "@prisma/client";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
export const WarningNavIndicator = () => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const { data: connections, isPending, isError } = useQuery({
|
|
||||||
queryKey: ['connections', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
|
||||||
select: (data) => data.filter(connection => connection.syncStatus === ConnectionSyncStatus.SYNCED_WITH_WARNINGS),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isPending || isError || connections.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_warning_nav_pressed', {})}>
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
|
|
||||||
<AlertTriangleIcon className="h-4 w-4" />
|
|
||||||
<span>{connections.length}</span>
|
|
||||||
</div>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<div className="flex flex-col gap-4 p-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-yellow-500"></div>
|
|
||||||
<h3 className="text-sm font-medium text-yellow-700 dark:text-yellow-400">Missing References</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
|
|
||||||
The following connections have references that could not be found:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2 pl-4">
|
|
||||||
<TooltipProvider>
|
|
||||||
{connections.slice(0, 10).map(connection => (
|
|
||||||
<Link key={connection.name} href={`/${domain}/connections/${connection.id}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
|
|
||||||
rounded-md text-sm text-yellow-700 dark:text-yellow-300
|
|
||||||
border border-yellow-200/50 dark:border-yellow-800/50
|
|
||||||
hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="font-medium truncate max-w-[200px]">{connection.name}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{connection.name}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
|
||||||
{connections.length > 10 && (
|
|
||||||
<div className="text-sm text-yellow-600/90 dark:text-yellow-300/90 pl-3 pt-1">
|
|
||||||
And {connections.length - 10} more...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { BackendError } from "@sourcebot/error";
|
|
||||||
import { Prisma } from "@sourcebot/db";
|
|
||||||
|
|
||||||
export function DisplayConnectionError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
|
|
||||||
const errorCode = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'error' in syncStatusMetadata
|
|
||||||
? (syncStatusMetadata.error as string)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
switch (errorCode) {
|
|
||||||
case BackendError.CONNECTION_SYNC_INVALID_TOKEN:
|
|
||||||
return <InvalidTokenError syncStatusMetadata={syncStatusMetadata} onSecretsClick={onSecretsClick} />
|
|
||||||
case BackendError.CONNECTION_SYNC_SECRET_DNE:
|
|
||||||
return <SecretNotFoundError syncStatusMetadata={syncStatusMetadata} onSecretsClick={onSecretsClick} />
|
|
||||||
case BackendError.CONNECTION_SYNC_SYSTEM_ERROR:
|
|
||||||
return <SystemError />
|
|
||||||
case BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS:
|
|
||||||
return <FailedToFetchGerritProjects syncStatusMetadata={syncStatusMetadata} />
|
|
||||||
default:
|
|
||||||
return <UnknownError />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SecretNotFoundError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
|
|
||||||
const secretKey = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'secretKey' in syncStatusMetadata
|
|
||||||
? (syncStatusMetadata.secretKey as string)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">Secret Not Found</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The secret key provided for this connection was not found. Please ensure your config is referencing a secret
|
|
||||||
that exists in your{" "}
|
|
||||||
<button onClick={onSecretsClick} className="text-primary hover:underline">
|
|
||||||
organization's secrets
|
|
||||||
</button>
|
|
||||||
, and try again.
|
|
||||||
</p>
|
|
||||||
{secretKey && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Secret Key: <span className="text-red-500">{secretKey}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvalidTokenError({ syncStatusMetadata, onSecretsClick }: { syncStatusMetadata: Prisma.JsonValue, onSecretsClick: () => void }) {
|
|
||||||
const secretKey = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'secretKey' in syncStatusMetadata
|
|
||||||
? (syncStatusMetadata.secretKey as string)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">Invalid Authentication Token</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
The authentication token provided for this connection is invalid. Please update your config with a valid token and try again.
|
|
||||||
</p>
|
|
||||||
{secretKey && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Secret Key: <button onClick={onSecretsClick} className="text-red-500 hover:underline">{secretKey}</button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemError() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">System Error</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
An error occurred while syncing this connection. Please try again later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FailedToFetchGerritProjects({ syncStatusMetadata }: { syncStatusMetadata: Prisma.JsonValue}) {
|
|
||||||
const status = syncStatusMetadata && typeof syncStatusMetadata === 'object' && 'status' in syncStatusMetadata
|
|
||||||
? (syncStatusMetadata.status as number)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">Failed to Fetch Gerrit Projects</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
An error occurred while syncing this connection. Please try again later.
|
|
||||||
</p>
|
|
||||||
{status && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Status: <span className="text-red-500">{status}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UnknownError() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="text-sm font-semibold">Unknown Error</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
An unknown error occurred while syncing this connection. Please try again later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { deleteConnection } from "@/actions";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
|
|
||||||
interface DeleteConnectionSettingProps {
|
|
||||||
connectionId: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeleteConnectionSetting = ({
|
|
||||||
connectionId,
|
|
||||||
disabled,
|
|
||||||
}: DeleteConnectionSettingProps) => {
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const domain = useDomain();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
setIsLoading(true);
|
|
||||||
deleteConnection(connectionId, domain)
|
|
||||||
.then((response) => {
|
|
||||||
if (isServiceError(response)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to delete connection. Reason: ${response.message}`
|
|
||||||
});
|
|
||||||
captureEvent('wa_connection_delete_fail', {
|
|
||||||
error: response.errorCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Connection deleted successfully.`
|
|
||||||
});
|
|
||||||
captureEvent('wa_connection_delete_success', {});
|
|
||||||
router.replace(`/${domain}/connections`);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, [connectionId, domain, router, toast, captureEvent]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
|
||||||
<h2 className="text-lg font-semibold">Delete Connection</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
Permanently delete this connection from Sourcebot. All linked repositories that are not linked to any other connection will also be deleted.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-row justify-end">
|
|
||||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
className="mt-4"
|
|
||||||
disabled={isLoading || disabled}
|
|
||||||
>
|
|
||||||
{isLoading && <Loader2 className="animate-spin mr-2" />}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete}>Yes, delete connection</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { updateConnectionDisplayName } from "@/actions";
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface DisplayNameSettingProps {
|
|
||||||
connectionId: number;
|
|
||||||
name: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DisplayNameSetting = ({
|
|
||||||
connectionId,
|
|
||||||
name,
|
|
||||||
disabled,
|
|
||||||
}: DisplayNameSettingProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
|
||||||
const domain = useDomain();
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
updateConnectionDisplayName(connectionId, data.name, domain)
|
|
||||||
.then((response) => {
|
|
||||||
if (isServiceError(response)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to rename connection. Reason: ${response.message}`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Connection renamed successfully.`
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
}).finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, [connectionId, domain, router, toast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
|
||||||
<Form
|
|
||||||
{...form}
|
|
||||||
>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-lg font-semibold">Display Name</FormLabel>
|
|
||||||
{/* @todo : refactor this description into a shared file */}
|
|
||||||
<FormDescription>This is the {`connection's`} display name within Sourcebot. Examples: <b>public-github</b>, <b>self-hosted-gitlab</b>, <b>gerrit-other</b>, etc.</FormDescription>
|
|
||||||
<FormControl className="max-w-lg">
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || disabled}
|
|
||||||
>
|
|
||||||
{isLoading && <Loader2 className="animate-spin mr-2" />}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { AlertTriangle } from "lucide-react"
|
|
||||||
import { Prisma, ConnectionSyncStatus } from "@sourcebot/db"
|
|
||||||
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface NotFoundWarningProps {
|
|
||||||
syncStatus: ConnectionSyncStatus
|
|
||||||
syncStatusMetadata: Prisma.JsonValue
|
|
||||||
onSecretsClick: () => void
|
|
||||||
connectionType: string
|
|
||||||
onRetrySync: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotFoundWarning = ({ syncStatus, syncStatusMetadata, onSecretsClick, connectionType, onRetrySync }: NotFoundWarningProps) => {
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
|
|
||||||
if (syncStatus !== ConnectionSyncStatus.SYNCED_WITH_WARNINGS || !parseResult.success || !parseResult.data.notFound) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { notFound } = parseResult.data;
|
|
||||||
|
|
||||||
if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
captureEvent('wa_connection_not_found_warning_displayed', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-start gap-4 border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 px-5 py-5 text-yellow-700 dark:text-yellow-400 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<h3 className="font-semibold">Unable to fetch all references</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90 leading-relaxed">
|
|
||||||
Some requested references couldn't be found. Please ensure you've provided the information listed below correctly, and that you've provided a{" "}
|
|
||||||
<button onClick={onSecretsClick} className="text-yellow-500 dark:text-yellow-400 font-bold hover:underline">
|
|
||||||
valid token
|
|
||||||
</button>{" "}
|
|
||||||
to access them if they're private.
|
|
||||||
</p>
|
|
||||||
<ul className="w-full space-y-2 text-sm">
|
|
||||||
{notFound.users.length > 0 && (
|
|
||||||
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
|
|
||||||
<span className="font-medium">Users:</span>
|
|
||||||
<span className="text-yellow-600 dark:text-yellow-300">{notFound.users.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{notFound.orgs.length > 0 && (
|
|
||||||
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
|
|
||||||
<span className="font-medium">{connectionType === "gitlab" ? "Groups" : "Organizations"}:</span>
|
|
||||||
<span className="text-yellow-600 dark:text-yellow-300">{notFound.orgs.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{notFound.repos.length > 0 && (
|
|
||||||
<li className="flex items-center gap-2 px-3 py-2 bg-yellow-100/50 dark:bg-yellow-900/30 rounded-md border border-yellow-200/50 dark:border-yellow-800/50">
|
|
||||||
<span className="font-medium">{connectionType === "gitlab" ? "Projects" : "Repositories"}:</span>
|
|
||||||
<span className="text-yellow-600 dark:text-yellow-300">{notFound.repos.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<div className="w-full flex justify-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="ml-2"
|
|
||||||
onClick={onRetrySync}
|
|
||||||
>
|
|
||||||
<ReloadIcon className="h-4 w-4 mr-2" />
|
|
||||||
Retry Sync
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
|
||||||
import { DisplayConnectionError } from "./connectionError"
|
|
||||||
import { NotFoundWarning } from "./notFoundWarning"
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { flagConnectionForSync, getConnectionInfo } from "@/actions";
|
|
||||||
import { isServiceError, unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { ConnectionSyncStatus } from "@sourcebot/db";
|
|
||||||
import { FiLoader } from "react-icons/fi";
|
|
||||||
import { CircleCheckIcon, AlertTriangle, CircleXIcon } from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
|
||||||
import { toast } from "@/components/hooks/use-toast";
|
|
||||||
|
|
||||||
interface OverviewProps {
|
|
||||||
connectionId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Overview = ({ connectionId }: OverviewProps) => {
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
const domain = useDomain();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: connection, isPending, error, refetch } = useQuery({
|
|
||||||
queryKey: ['connection', domain, connectionId],
|
|
||||||
queryFn: () => unwrapServiceError(getConnectionInfo(connectionId, domain)),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSecretsNavigation = useCallback(() => {
|
|
||||||
captureEvent('wa_connection_secrets_navigation_pressed', {});
|
|
||||||
router.push(`/${domain}/secrets`);
|
|
||||||
}, [captureEvent, domain, router]);
|
|
||||||
|
|
||||||
const onRetrySync = useCallback(async () => {
|
|
||||||
const result = await flagConnectionForSync(connectionId, domain);
|
|
||||||
if (isServiceError(result)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to flag connection for sync.`,
|
|
||||||
});
|
|
||||||
captureEvent('wa_connection_retry_sync_fail', {
|
|
||||||
error: result.errorCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: "✅ Connection flagged for sync.",
|
|
||||||
});
|
|
||||||
captureEvent('wa_connection_retry_sync_success', {});
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
}, [connectionId, domain, captureEvent, refetch]);
|
|
||||||
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="text-destructive">
|
|
||||||
{`Error loading connection. Reason: ${error.message}`}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-lg border border-border p-4 bg-background">
|
|
||||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
|
||||||
<div className="mt-2 h-4 w-24 bg-muted rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Connection Type</h2>
|
|
||||||
<p className="mt-2 text-sm">{connection.connectionType}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2>
|
|
||||||
<p className="mt-2 text-sm">
|
|
||||||
{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : "never"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
|
|
||||||
<p className="mt-2 text-sm">{connection.numLinkedRepos}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border p-4 bg-background">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
{connection.syncStatus === "FAILED" ? (
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger onMouseEnter={() => captureEvent('wa_connection_failed_status_hover', {})}>
|
|
||||||
<SyncStatusBadge status={connection.syncStatus} />
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80">
|
|
||||||
<DisplayConnectionError
|
|
||||||
syncStatusMetadata={connection.syncStatusMetadata}
|
|
||||||
onSecretsClick={handleSecretsNavigation}
|
|
||||||
/>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
) : (
|
|
||||||
<SyncStatusBadge status={connection.syncStatus} />
|
|
||||||
)}
|
|
||||||
{connection.syncStatus === "FAILED" && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="ml-2"
|
|
||||||
onClick={onRetrySync}
|
|
||||||
>
|
|
||||||
<ReloadIcon className="h-4 w-4 mr-2" />
|
|
||||||
Retry Sync
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<NotFoundWarning
|
|
||||||
syncStatus={connection.syncStatus}
|
|
||||||
syncStatusMetadata={connection.syncStatusMetadata}
|
|
||||||
onSecretsClick={handleSecretsNavigation}
|
|
||||||
connectionType={connection.connectionType}
|
|
||||||
onRetrySync={onRetrySync}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SyncStatusBadge = ({ status }: { status: ConnectionSyncStatus }) => {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className="select-none px-2 py-1"
|
|
||||||
variant={status === ConnectionSyncStatus.FAILED ? "destructive" : "outline"}
|
|
||||||
>
|
|
||||||
{status === ConnectionSyncStatus.SYNC_NEEDED || status === ConnectionSyncStatus.IN_SYNC_QUEUE ? (
|
|
||||||
<><FiLoader className="w-4 h-4 mr-2 animate-spin-slow" /> Sync queued</>
|
|
||||||
) : status === ConnectionSyncStatus.SYNCING ? (
|
|
||||||
<><FiLoader className="w-4 h-4 mr-2 animate-spin-slow" /> Syncing</>
|
|
||||||
) : status === ConnectionSyncStatus.SYNCED ? (
|
|
||||||
<span className="flex flex-row items-center text-green-700 dark:text-green-400"><CircleCheckIcon className="w-4 h-4 mr-2" /> Synced</span>
|
|
||||||
) : status === ConnectionSyncStatus.SYNCED_WITH_WARNINGS ? (
|
|
||||||
<span className="flex flex-row items-center text-yellow-700 dark:text-yellow-400"><AlertTriangle className="w-4 h-4 mr-2" /> Synced with warnings</span>
|
|
||||||
) : status === ConnectionSyncStatus.FAILED ? (
|
|
||||||
<><CircleXIcon className="w-4 h-4 mr-2" /> Sync failed</>
|
|
||||||
) : null}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { flagReposForIndex, getConnectionInfo, getRepos } from "@/actions";
|
|
||||||
import { RepoListItem } from "./repoListItem";
|
|
||||||
import { isServiceError, unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { Search, Loader2 } from "lucide-react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { RepoListItemSkeleton } from "./repoListItemSkeleton";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { MultiSelect } from "@/components/ui/multi-select";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
|
||||||
|
|
||||||
interface RepoListProps {
|
|
||||||
connectionId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPriority = (status: RepoIndexingStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case RepoIndexingStatus.FAILED:
|
|
||||||
return 0
|
|
||||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
|
||||||
case RepoIndexingStatus.INDEXING:
|
|
||||||
return 1
|
|
||||||
case RepoIndexingStatus.INDEXED:
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertIndexingStatus = (status: RepoIndexingStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case RepoIndexingStatus.FAILED:
|
|
||||||
return 'failed';
|
|
||||||
case RepoIndexingStatus.NEW:
|
|
||||||
return 'waiting';
|
|
||||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
|
||||||
case RepoIndexingStatus.INDEXING:
|
|
||||||
return 'running';
|
|
||||||
case RepoIndexingStatus.INDEXED:
|
|
||||||
return 'succeeded';
|
|
||||||
default:
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepoList = ({ connectionId }: RepoListProps) => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
const [isRetryAllFailedReposLoading, setIsRetryAllFailedReposLoading] = useState(false);
|
|
||||||
|
|
||||||
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
|
|
||||||
queryKey: ['repos', domain, connectionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const repos = await unwrapServiceError(getRepos({ connectionId }));
|
|
||||||
return repos.sort((a, b) => {
|
|
||||||
const priorityA = getPriority(a.repoIndexingStatus);
|
|
||||||
const priorityB = getPriority(b.repoIndexingStatus);
|
|
||||||
|
|
||||||
// First sort by priority
|
|
||||||
if (priorityA !== priorityB) {
|
|
||||||
return priorityA - priorityB;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If same priority, sort by indexedAt
|
|
||||||
return new Date(a.indexedAt ?? new Date()).getTime() - new Date(b.indexedAt ?? new Date()).getTime();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: connection, isPending: isConnectionPending, error: isConnectionError } = useQuery({
|
|
||||||
queryKey: ['connection', domain, connectionId],
|
|
||||||
queryFn: () => unwrapServiceError(getConnectionInfo(connectionId, domain)),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const failedRepos = useMemo(() => {
|
|
||||||
return unfilteredRepos?.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.FAILED) ?? [];
|
|
||||||
}, [unfilteredRepos]);
|
|
||||||
|
|
||||||
|
|
||||||
const onRetryAllFailedRepos = useCallback(() => {
|
|
||||||
if (failedRepos.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsRetryAllFailedReposLoading(true);
|
|
||||||
flagReposForIndex(failedRepos.map((repo) => repo.repoId))
|
|
||||||
.then((response) => {
|
|
||||||
if (isServiceError(response)) {
|
|
||||||
captureEvent('wa_connection_retry_all_failed_repos_fail', {});
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to flag repositories for indexing. Reason: ${response.message}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
captureEvent('wa_connection_retry_all_failed_repos_success', {});
|
|
||||||
toast({
|
|
||||||
description: `✅ ${failedRepos.length} repositories flagged for indexing.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => { refetchRepos() })
|
|
||||||
.finally(() => {
|
|
||||||
setIsRetryAllFailedReposLoading(false);
|
|
||||||
});
|
|
||||||
}, [captureEvent, failedRepos, refetchRepos, toast]);
|
|
||||||
|
|
||||||
const filteredRepos = useMemo(() => {
|
|
||||||
if (isServiceError(unfilteredRepos)) {
|
|
||||||
return unfilteredRepos;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchLower = searchQuery.toLowerCase();
|
|
||||||
return unfilteredRepos?.filter((repo) => {
|
|
||||||
return repo.repoName.toLowerCase().includes(searchLower);
|
|
||||||
}).filter((repo) => {
|
|
||||||
if (selectedStatuses.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedStatuses.includes(convertIndexingStatus(repo.repoIndexingStatus));
|
|
||||||
});
|
|
||||||
}, [unfilteredRepos, searchQuery, selectedStatuses]);
|
|
||||||
|
|
||||||
if (reposError) {
|
|
||||||
return <div className="text-destructive">
|
|
||||||
{`Error loading repositories. Reason: ${reposError.message}`}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex gap-4 flex-col sm:flex-row">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder={`Filter ${isReposPending ? "n" : filteredRepos?.length} ${filteredRepos?.length === 1 ? "repository" : "repositories"} by name`}
|
|
||||||
className="pl-9"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MultiSelect
|
|
||||||
className="bg-background hover:bg-background w-96"
|
|
||||||
options={[
|
|
||||||
{ value: 'waiting', label: 'Waiting' },
|
|
||||||
{ value: 'running', label: 'Running' },
|
|
||||||
{ value: 'succeeded', label: 'Succeeded' },
|
|
||||||
{ value: 'failed', label: 'Failed' },
|
|
||||||
]}
|
|
||||||
onValueChange={(value) => setSelectedStatuses(value)}
|
|
||||||
defaultValue={[]}
|
|
||||||
placeholder="Filter by status"
|
|
||||||
maxCount={2}
|
|
||||||
animation={0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{failedRepos.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={isRetryAllFailedReposLoading}
|
|
||||||
onClick={onRetryAllFailedRepos}
|
|
||||||
>
|
|
||||||
{isRetryAllFailedReposLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Retry All Failed
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="mt-4 h-96 pr-4">
|
|
||||||
{isReposPending ? (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<RepoListItemSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (!filteredRepos || filteredRepos.length === 0) ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-96 p-4 border rounded-lg">
|
|
||||||
<p className="font-medium text-sm">No Repositories Found</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
{
|
|
||||||
searchQuery.length > 0 ? (
|
|
||||||
<span>No repositories found matching your filters.</span>
|
|
||||||
) : (!isConnectionError && !isConnectionPending && (connection.syncStatus === ConnectionSyncStatus.IN_SYNC_QUEUE || connection.syncStatus === ConnectionSyncStatus.SYNCING || connection.syncStatus === ConnectionSyncStatus.SYNC_NEEDED)) ? (
|
|
||||||
<span>Repositories are being synced. Please check back soon.</span>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
router.push(`?tab=settings`)
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Configure connection
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{filteredRepos?.map((repo) => (
|
|
||||||
<RepoListItem
|
|
||||||
key={repo.repoId}
|
|
||||||
imageUrl={repo.imageUrl}
|
|
||||||
name={repo.repoName}
|
|
||||||
indexedAt={repo.indexedAt}
|
|
||||||
status={repo.repoIndexingStatus}
|
|
||||||
repoId={repo.repoId}
|
|
||||||
domain={domain}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { getDisplayTime, getRepoImageSrc } from "@/lib/utils";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { StatusIcon } from "../../components/statusIcon";
|
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { RetryRepoIndexButton } from "./repoRetryIndexButton";
|
|
||||||
|
|
||||||
|
|
||||||
interface RepoListItemProps {
|
|
||||||
name: string;
|
|
||||||
status: RepoIndexingStatus;
|
|
||||||
imageUrl?: string;
|
|
||||||
indexedAt?: Date;
|
|
||||||
repoId: number;
|
|
||||||
domain: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RepoListItem = ({
|
|
||||||
imageUrl,
|
|
||||||
name,
|
|
||||||
indexedAt,
|
|
||||||
status,
|
|
||||||
repoId,
|
|
||||||
domain,
|
|
||||||
}: RepoListItemProps) => {
|
|
||||||
const statusDisplayName = useMemo(() => {
|
|
||||||
switch (status) {
|
|
||||||
case RepoIndexingStatus.NEW:
|
|
||||||
return 'Waiting...';
|
|
||||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
|
||||||
return 'In index queue...';
|
|
||||||
case RepoIndexingStatus.INDEXING:
|
|
||||||
return 'Indexing...';
|
|
||||||
case RepoIndexingStatus.INDEXED:
|
|
||||||
return 'Indexed';
|
|
||||||
case RepoIndexingStatus.FAILED:
|
|
||||||
return 'Index failed';
|
|
||||||
case RepoIndexingStatus.IN_GC_QUEUE:
|
|
||||||
return 'In garbage collection queue...';
|
|
||||||
case RepoIndexingStatus.GARBAGE_COLLECTING:
|
|
||||||
return 'Garbage collecting...';
|
|
||||||
case RepoIndexingStatus.GARBAGE_COLLECTION_FAILED:
|
|
||||||
return 'Garbage collection failed';
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const imageSrc = getRepoImageSrc(imageUrl, repoId, domain);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-row items-center p-4 border rounded-lg bg-background justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
{imageSrc ? (
|
|
||||||
<Image
|
|
||||||
src={imageSrc}
|
|
||||||
alt={name}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-8 w-8 flex items-center justify-center bg-muted text-xs font-medium uppercase text-muted-foreground rounded-md">
|
|
||||||
{name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="font-medium">{name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center gap-4">
|
|
||||||
{status === RepoIndexingStatus.FAILED && (
|
|
||||||
<RetryRepoIndexButton repoId={repoId} />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-row items-center gap-0">
|
|
||||||
<StatusIcon
|
|
||||||
status={convertIndexingStatus(status)}
|
|
||||||
className="w-4 h-4 mr-1"
|
|
||||||
/>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span>{statusDisplayName}</span>
|
|
||||||
{
|
|
||||||
(
|
|
||||||
status === RepoIndexingStatus.INDEXED ||
|
|
||||||
status === RepoIndexingStatus.FAILED
|
|
||||||
) && indexedAt && (
|
|
||||||
<span>{` ${getDisplayTime(indexedAt)}`}</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertIndexingStatus = (status: RepoIndexingStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case RepoIndexingStatus.NEW:
|
|
||||||
return 'waiting';
|
|
||||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
|
||||||
case RepoIndexingStatus.INDEXING:
|
|
||||||
return 'running';
|
|
||||||
case RepoIndexingStatus.IN_GC_QUEUE:
|
|
||||||
case RepoIndexingStatus.GARBAGE_COLLECTING:
|
|
||||||
return "garbage-collecting"
|
|
||||||
case RepoIndexingStatus.INDEXED:
|
|
||||||
return 'succeeded';
|
|
||||||
case RepoIndexingStatus.GARBAGE_COLLECTION_FAILED:
|
|
||||||
case RepoIndexingStatus.FAILED:
|
|
||||||
return 'failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
|
|
||||||
export const RepoListItemSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center p-4 border rounded-lg bg-background justify-between">
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-full animate-pulse" />
|
|
||||||
<Skeleton className="h-4 w-32 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-24 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons"
|
|
||||||
import { toast } from "@/components/hooks/use-toast";
|
|
||||||
import { flagReposForIndex } from "@/actions";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
|
|
||||||
interface RetryRepoIndexButtonProps {
|
|
||||||
repoId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RetryRepoIndexButton = ({ repoId }: RetryRepoIndexButtonProps) => {
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="ml-2"
|
|
||||||
onClick={async () => {
|
|
||||||
const result = await flagReposForIndex([repoId]);
|
|
||||||
if (isServiceError(result)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to flag repository for indexing.`,
|
|
||||||
});
|
|
||||||
captureEvent('wa_repo_retry_index_fail', {
|
|
||||||
error: result.errorCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: "✅ Repository flagged for indexing.",
|
|
||||||
});
|
|
||||||
captureEvent('wa_repo_retry_index_success', {});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReloadIcon className="h-4 w-4 mr-2" />
|
|
||||||
Retry Index
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { NotFound } from "@/app/[domain]/components/notFound"
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb"
|
|
||||||
import { ConnectionIcon } from "../components/connectionIcon"
|
|
||||||
import { Header } from "../../components/header"
|
|
||||||
import { RepoList } from "./components/repoList"
|
|
||||||
import { getConnectionByDomain } from "@/data/connection"
|
|
||||||
import { Overview } from "./components/overview"
|
|
||||||
|
|
||||||
interface ConnectionManagementPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
domain: string
|
|
||||||
id: string
|
|
||||||
}>,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ConnectionManagementPage(props: ConnectionManagementPageProps) {
|
|
||||||
const params = await props.params;
|
|
||||||
const connection = await getConnectionByDomain(Number(params.id), params.domain);
|
|
||||||
if (!connection) {
|
|
||||||
return <NotFound className="flex w-full h-full items-center justify-center" message="Connection not found" />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Header>
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbLink href={`/${params.domain}/connections`}>Connections</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>{connection.name}</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
|
||||||
<ConnectionIcon type={connection.connectionType} />
|
|
||||||
<h1 className="text-3xl font-semibold">{connection.name}</h1>
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-medium mb-4">Overview</h2>
|
|
||||||
<Overview connectionId={connection.id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-medium mb-4">Linked Repositories</h2>
|
|
||||||
<RepoList connectionId={connection.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import placeholderLogo from "@/public/placeholder_avatar.png";
|
|
||||||
|
|
||||||
interface ConnectionIconProps {
|
|
||||||
type: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionIcon = ({
|
|
||||||
type,
|
|
||||||
className,
|
|
||||||
}: ConnectionIconProps) => {
|
|
||||||
const Icon = useMemo(() => {
|
|
||||||
const iconInfo = getCodeHostIcon(type as CodeHostType);
|
|
||||||
if (iconInfo) {
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src={iconInfo.src}
|
|
||||||
className={cn(cn("rounded-full w-8 h-8", iconInfo.className), className)}
|
|
||||||
alt={`${type} logo`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Image
|
|
||||||
src={placeholderLogo}
|
|
||||||
alt={''}
|
|
||||||
className={cn("rounded-full w-8 h-8", className)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
}, [className, type]);
|
|
||||||
|
|
||||||
return Icon;
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import { getDisplayTime } from "@/lib/utils";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { ConnectionIcon } from "../connectionIcon";
|
|
||||||
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
|
||||||
import { StatusIcon } from "../statusIcon";
|
|
||||||
import { ConnectionListItemErrorIndicator } from "./connectionListItemErrorIndicator";
|
|
||||||
import { ConnectionListItemWarningIndicator } from "./connectionListItemWarningIndicator";
|
|
||||||
import { ConnectionListItemManageButton } from "./connectionListItemManageButton";
|
|
||||||
|
|
||||||
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case ConnectionSyncStatus.SYNC_NEEDED:
|
|
||||||
return 'waiting';
|
|
||||||
case ConnectionSyncStatus.IN_SYNC_QUEUE:
|
|
||||||
case ConnectionSyncStatus.SYNCING:
|
|
||||||
return 'running';
|
|
||||||
case ConnectionSyncStatus.SYNCED:
|
|
||||||
return 'succeeded';
|
|
||||||
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
|
|
||||||
return 'succeeded-with-warnings';
|
|
||||||
case ConnectionSyncStatus.FAILED:
|
|
||||||
return 'failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectionListItemProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
status: ConnectionSyncStatus;
|
|
||||||
syncStatusMetadata: Prisma.JsonValue;
|
|
||||||
editedAt: Date;
|
|
||||||
syncedAt?: Date;
|
|
||||||
failedRepos?: { repoId: number, repoName: string }[];
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionListItem = ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
status,
|
|
||||||
syncStatusMetadata,
|
|
||||||
editedAt,
|
|
||||||
syncedAt,
|
|
||||||
failedRepos,
|
|
||||||
disabled,
|
|
||||||
}: ConnectionListItemProps) => {
|
|
||||||
const statusDisplayName = useMemo(() => {
|
|
||||||
switch (status) {
|
|
||||||
case ConnectionSyncStatus.SYNC_NEEDED:
|
|
||||||
return 'Waiting...';
|
|
||||||
case ConnectionSyncStatus.IN_SYNC_QUEUE:
|
|
||||||
case ConnectionSyncStatus.SYNCING:
|
|
||||||
return 'Syncing...';
|
|
||||||
case ConnectionSyncStatus.SYNCED:
|
|
||||||
return 'Synced';
|
|
||||||
case ConnectionSyncStatus.FAILED:
|
|
||||||
return 'Sync failed';
|
|
||||||
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const { notFoundData, displayNotFoundWarning } = useMemo(() => {
|
|
||||||
if (!syncStatusMetadata || typeof syncStatusMetadata !== 'object' || !('notFound' in syncStatusMetadata)) {
|
|
||||||
return { notFoundData: null, displayNotFoundWarning: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const notFoundData = syncStatusMetadata.notFound as {
|
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
}
|
|
||||||
|
|
||||||
return { notFoundData, displayNotFoundWarning: notFoundData.users.length > 0 || notFoundData.orgs.length > 0 || notFoundData.repos.length > 0 };
|
|
||||||
}, [syncStatusMetadata]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-row justify-between items-center border p-4 rounded-lg bg-background"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center gap-3">
|
|
||||||
<ConnectionIcon
|
|
||||||
type={type}
|
|
||||||
className="w-8 h-8"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<p className="font-medium">{name}</p>
|
|
||||||
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
|
|
||||||
</div>
|
|
||||||
<ConnectionListItemErrorIndicator failedRepos={failedRepos} connectionId={id} />
|
|
||||||
<ConnectionListItemWarningIndicator
|
|
||||||
notFoundData={notFoundData}
|
|
||||||
connectionId={id}
|
|
||||||
type={type}
|
|
||||||
displayWarning={displayNotFoundWarning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<StatusIcon
|
|
||||||
status={convertSyncStatus(status)}
|
|
||||||
className="w-4 h-4 mr-1"
|
|
||||||
/>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span>{statusDisplayName}</span>
|
|
||||||
{
|
|
||||||
(
|
|
||||||
status === ConnectionSyncStatus.SYNCED ||
|
|
||||||
status === ConnectionSyncStatus.FAILED
|
|
||||||
) && syncedAt && (
|
|
||||||
<span>{` ${getDisplayTime(syncedAt)}`}</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<ConnectionListItemManageButton id={id} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
||||||
import { CircleX } from "lucide-react";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
|
|
||||||
interface ConnectionListItemErrorIndicatorProps {
|
|
||||||
failedRepos: { repoId: number; repoName: string; }[] | undefined;
|
|
||||||
connectionId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionListItemErrorIndicator = ({
|
|
||||||
failedRepos,
|
|
||||||
connectionId
|
|
||||||
}: ConnectionListItemErrorIndicatorProps) => {
|
|
||||||
const captureEvent = useCaptureEvent()
|
|
||||||
|
|
||||||
if (!failedRepos || failedRepos.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<CircleX
|
|
||||||
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
captureEvent('wa_connection_list_item_error_pressed', {})
|
|
||||||
window.location.href = `connections/${connectionId}`
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => captureEvent('wa_connection_list_item_error_hover', {})}
|
|
||||||
/>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
|
||||||
<div className="flex flex-col space-y-3">
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
|
|
||||||
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
|
|
||||||
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
|
|
||||||
<p>
|
|
||||||
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{failedRepos.slice(0, 10).map(repo => (
|
|
||||||
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
|
|
||||||
))}
|
|
||||||
{failedRepos.length > 10 && (
|
|
||||||
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
|
|
||||||
And {failedRepos.length - 10} more...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs">
|
|
||||||
Navigate to the connection for more details and to retry indexing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
|
|
||||||
interface ConnectionListItemManageButtonProps {
|
|
||||||
id: string;
|
|
||||||
disabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionListItemManageButton = ({
|
|
||||||
id,
|
|
||||||
disabled,
|
|
||||||
}: ConnectionListItemManageButtonProps) => {
|
|
||||||
const captureEvent = useCaptureEvent()
|
|
||||||
const router = useRouter();
|
|
||||||
const domain = useDomain();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size={"sm"}
|
|
||||||
className="ml-4"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => {
|
|
||||||
if (!disabled) {
|
|
||||||
captureEvent('wa_connection_list_item_manage_pressed', {})
|
|
||||||
router.push(`/${domain}/connections/${id}`)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import { NotFoundData } from "@/lib/syncStatusMetadataSchema";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
|
|
||||||
|
|
||||||
interface ConnectionListItemWarningIndicatorProps {
|
|
||||||
notFoundData: NotFoundData | null;
|
|
||||||
connectionId: string;
|
|
||||||
type: string;
|
|
||||||
displayWarning: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionListItemWarningIndicator = ({
|
|
||||||
notFoundData,
|
|
||||||
connectionId,
|
|
||||||
type,
|
|
||||||
displayWarning
|
|
||||||
}: ConnectionListItemWarningIndicatorProps) => {
|
|
||||||
const captureEvent = useCaptureEvent()
|
|
||||||
|
|
||||||
if (!notFoundData || !displayWarning) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={50}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<AlertTriangle
|
|
||||||
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
captureEvent('wa_connection_list_item_warning_pressed', {})
|
|
||||||
window.location.href = `connections/${connectionId}`
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => captureEvent('wa_connection_list_item_warning_hover', {})}
|
|
||||||
/>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<div className="flex flex-col space-y-3">
|
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
|
|
||||||
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
|
|
||||||
Some requested references couldn't be found. Verify the details below and ensure your connection is using a {" "}
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.href = `secrets`}
|
|
||||||
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
|
|
||||||
>
|
|
||||||
valid access token
|
|
||||||
</button>{" "}
|
|
||||||
that has access to any private references.
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
|
|
||||||
{notFoundData.users.length > 0 && (
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
|
|
||||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{notFoundData.orgs.length > 0 && (
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
|
|
||||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{notFoundData.repos.length > 0 && (
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
|
|
||||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { ConnectionListItem } from "./connectionListItem";
|
|
||||||
import { cn, unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
|
||||||
import { getConnections } from "@/actions";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { RepoIndexingStatus, ConnectionSyncStatus } from "@sourcebot/db";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { MultiSelect } from "@/components/ui/multi-select";
|
|
||||||
|
|
||||||
interface ConnectionListProps {
|
|
||||||
className?: string;
|
|
||||||
isDisabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case ConnectionSyncStatus.SYNC_NEEDED:
|
|
||||||
return 'waiting';
|
|
||||||
case ConnectionSyncStatus.SYNCING:
|
|
||||||
return 'running';
|
|
||||||
case ConnectionSyncStatus.SYNCED:
|
|
||||||
return 'succeeded';
|
|
||||||
case ConnectionSyncStatus.SYNCED_WITH_WARNINGS:
|
|
||||||
return 'synced-with-warnings';
|
|
||||||
case ConnectionSyncStatus.FAILED:
|
|
||||||
return 'failed';
|
|
||||||
default:
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionList = ({
|
|
||||||
className,
|
|
||||||
isDisabled,
|
|
||||||
}: ConnectionListProps) => {
|
|
||||||
const domain = useDomain();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const { data: unfilteredConnections, isPending, error } = useQuery({
|
|
||||||
queryKey: ['connections', domain],
|
|
||||||
queryFn: () => unwrapServiceError(getConnections(domain)),
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const connections = useMemo(() => {
|
|
||||||
return unfilteredConnections
|
|
||||||
?.filter((connection) => connection.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
.filter((connection) => {
|
|
||||||
if (selectedStatuses.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedStatuses.includes(convertSyncStatus(connection.syncStatus));
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) ?? [];
|
|
||||||
}, [unfilteredConnections, searchQuery, selectedStatuses]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
|
|
||||||
<p>Error loading connections: {error.message}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-col gap-4", className)}>
|
|
||||||
<div className="flex gap-4 flex-col sm:flex-row">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder={`Filter ${isPending ? "n" : connections.length} ${connections.length === 1 ? "connection" : "connections"} by name`}
|
|
||||||
className="pl-9"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MultiSelect
|
|
||||||
className="bg-background hover:bg-background w-56"
|
|
||||||
options={[
|
|
||||||
{ value: 'waiting', label: 'Waiting' },
|
|
||||||
{ value: 'running', label: 'Syncing' },
|
|
||||||
{ value: 'succeeded', label: 'Synced' },
|
|
||||||
{ value: 'synced-with-warnings', label: 'Warnings' },
|
|
||||||
{ value: 'failed', label: 'Failed' },
|
|
||||||
]}
|
|
||||||
onValueChange={(value) => setSelectedStatuses(value)}
|
|
||||||
defaultValue={[]}
|
|
||||||
placeholder="Filter by status"
|
|
||||||
maxCount={2}
|
|
||||||
animation={0}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isPending ? (
|
|
||||||
// Skeleton for loading state
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-4 border rounded-md p-4">
|
|
||||||
<Skeleton className="w-8 h-8 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-4 w-1/4" />
|
|
||||||
<Skeleton className="h-3 w-1/3" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="w-24 h-8" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : connections.length > 0 ? (
|
|
||||||
connections
|
|
||||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
||||||
.map((connection) => (
|
|
||||||
<ConnectionListItem
|
|
||||||
key={connection.id}
|
|
||||||
id={connection.id.toString()}
|
|
||||||
name={connection.name}
|
|
||||||
type={connection.connectionType}
|
|
||||||
status={connection.syncStatus}
|
|
||||||
syncStatusMetadata={connection.syncStatusMetadata}
|
|
||||||
editedAt={connection.updatedAt}
|
|
||||||
syncedAt={connection.syncedAt ?? undefined}
|
|
||||||
failedRepos={connection.linkedRepos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.FAILED).map((repo) => ({
|
|
||||||
repoId: repo.id,
|
|
||||||
repoName: repo.name,
|
|
||||||
}))}
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
|
|
||||||
<InfoCircledIcon className="w-7 h-7" />
|
|
||||||
<h2 className="mt-2 font-medium">No connections</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { CircleCheckIcon, CircleXIcon } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { FiLoader } from "react-icons/fi";
|
|
||||||
|
|
||||||
export type Status = 'waiting' | 'running' | 'succeeded' | 'succeeded-with-warnings' | 'garbage-collecting' | 'failed';
|
|
||||||
|
|
||||||
export const StatusIcon = ({
|
|
||||||
status,
|
|
||||||
className,
|
|
||||||
}: { status: Status, className?: string }) => {
|
|
||||||
const Icon = useMemo(() => {
|
|
||||||
switch (status) {
|
|
||||||
case 'waiting':
|
|
||||||
case 'garbage-collecting':
|
|
||||||
case 'running':
|
|
||||||
return <FiLoader className={cn('animate-spin-slow', className)} />;
|
|
||||||
case 'succeeded':
|
|
||||||
return <CircleCheckIcon className={cn('text-green-600', className)} />;
|
|
||||||
case 'failed':
|
|
||||||
return <CircleXIcon className={cn('text-destructive', className)} />;
|
|
||||||
case 'succeeded-with-warnings':
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [className, status]);
|
|
||||||
|
|
||||||
return Icon;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { NavigationMenu } from "../components/navigationMenu";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
params: Promise<{ domain: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Layout(
|
|
||||||
props: LayoutProps
|
|
||||||
) {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const {
|
|
||||||
domain
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const {
|
|
||||||
children
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) {
|
|
||||||
return redirect(`/${domain}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex flex-col">
|
|
||||||
<NavigationMenu domain={domain} />
|
|
||||||
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
|
|
||||||
<div className="w-full max-w-6xl p-6">{children}</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { ConnectionList } from "./components/connectionList";
|
|
||||||
import { Header } from "../components/header";
|
|
||||||
import { getConnections, getOrgMembership } from "@/actions";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { notFound, ServiceErrorException } from "@/lib/serviceError";
|
|
||||||
import { OrgRole } from "@sourcebot/db";
|
|
||||||
|
|
||||||
export default async function ConnectionsPage(props: { params: Promise<{ domain: string }> }) {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const {
|
|
||||||
domain
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const connections = await getConnections(domain);
|
|
||||||
if (isServiceError(connections)) {
|
|
||||||
throw new ServiceErrorException(connections);
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await getOrgMembership(domain);
|
|
||||||
if (isServiceError(membership)) {
|
|
||||||
throw new ServiceErrorException(notFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Header>
|
|
||||||
<h1 className="text-3xl">Connections</h1>
|
|
||||||
</Header>
|
|
||||||
<ConnectionList
|
|
||||||
isDisabled={membership.role !== OrgRole.OWNER}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,575 +0,0 @@
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"
|
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
|
||||||
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
|
|
||||||
import { QuickAction } from "../components/configEditor";
|
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
|
||||||
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
|
|
||||||
import { CodeSnippet } from "@/app/components/codeSnippet";
|
|
||||||
|
|
||||||
export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
repos: [
|
|
||||||
...(previous.repos ?? []),
|
|
||||||
"<owner>/<repo name>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a single repo",
|
|
||||||
selectionText: "<owner>/<repo name>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"sourcebot/sourcebot",
|
|
||||||
"vercel/next.js",
|
|
||||||
"torvalds/linux"
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
orgs: [
|
|
||||||
...(previous.orgs ?? []),
|
|
||||||
"<organization name>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add an organization",
|
|
||||||
selectionText: "<organization name>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add an organization to sync with. All repositories in the organization visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-row gap-1 items-center">
|
|
||||||
{[
|
|
||||||
"commaai",
|
|
||||||
"sourcebot",
|
|
||||||
"vercel"
|
|
||||||
].map((org) => (
|
|
||||||
<CodeSnippet key={org}>{org}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
users: [
|
|
||||||
...(previous.users ?? []),
|
|
||||||
"<username>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a user",
|
|
||||||
selectionText: "<username>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a user to sync with. All repositories that the user owns visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-row gap-1 items-center">
|
|
||||||
{[
|
|
||||||
"jane-doe",
|
|
||||||
"torvalds",
|
|
||||||
"octocat"
|
|
||||||
].map((org) => (
|
|
||||||
<CodeSnippet key={org}>{org}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
url: previous.url ?? "https://github.example.com",
|
|
||||||
}),
|
|
||||||
name: "Set url to GitHub instance",
|
|
||||||
selectionText: "https://github.example.com",
|
|
||||||
description: <span>Set a custom GitHub host. Defaults to <CodeSnippet>https://github.com</CodeSnippet>.</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
repos: [
|
|
||||||
...(previous.exclude?.repos ?? []),
|
|
||||||
"<glob pattern>"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude by repo name",
|
|
||||||
selectionText: "<glob pattern>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Exclude repositories from syncing by name. Glob patterns are supported.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"my-org/docs*",
|
|
||||||
"my-org/test*"
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
topics: [
|
|
||||||
...(previous.exclude?.topics ?? []),
|
|
||||||
"<topic>"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude by topic",
|
|
||||||
selectionText: "<topic>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Exclude topics from syncing. Only repos that do not match any of the provided topics will be synced. Glob patterns are supported.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"docs",
|
|
||||||
"ci"
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
topics: [
|
|
||||||
...(previous.topics ?? []),
|
|
||||||
"<topic>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Include by topic",
|
|
||||||
selectionText: "<topic>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Include repositories by topic. Only repos that match at least one of the provided topics will be synced. Glob patterns are supported.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"docs",
|
|
||||||
"ci"
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
archived: true,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude archived repos",
|
|
||||||
description: <span>Exclude archived repositories from syncing.</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
forks: true,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude forked repos",
|
|
||||||
description: <span>Exclude forked repositories from syncing.</span>
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
|
|
||||||
{
|
|
||||||
fn: (previous: GitlabConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
projects: [
|
|
||||||
...previous.projects ?? [],
|
|
||||||
"<project name>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a project",
|
|
||||||
selectionText: "<project name>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a individual project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"gitlab-org/gitlab",
|
|
||||||
"corp/team-project",
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GitlabConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
users: [
|
|
||||||
...previous.users ?? [],
|
|
||||||
"<username>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a user",
|
|
||||||
selectionText: "<username>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a user to sync with. All projects that the user owns visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-row gap-1 items-center">
|
|
||||||
{[
|
|
||||||
"jane-doe",
|
|
||||||
"torvalds"
|
|
||||||
].map((org) => (
|
|
||||||
<CodeSnippet key={org}>{org}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GitlabConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
groups: [
|
|
||||||
...previous.groups ?? [],
|
|
||||||
"<group name>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a group",
|
|
||||||
selectionText: "<group name>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided <CodeSnippet>token</CodeSnippet> (if any) will be synced.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"my-group",
|
|
||||||
"path/to/subgroup"
|
|
||||||
].map((org) => (
|
|
||||||
<CodeSnippet key={org}>{org}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GitlabConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
url: previous.url ?? "https://gitlab.example.com",
|
|
||||||
}),
|
|
||||||
name: "Set url to GitLab instance",
|
|
||||||
selectionText: "https://gitlab.example.com",
|
|
||||||
description: <span>Set a custom GitLab host. Defaults to <CodeSnippet>https://gitlab.com</CodeSnippet>.</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GitlabConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
all: true,
|
|
||||||
}),
|
|
||||||
name: "Sync all projects",
|
|
||||||
description: <span>Sync all projects visible to the provided <CodeSnippet>token</CodeSnippet> (if any). Only available when using a self-hosted GitLab instance.</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GitlabConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
projects: [
|
|
||||||
...(previous.exclude?.projects ?? []),
|
|
||||||
"<glob pattern>"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude a project",
|
|
||||||
selectionText: "<glob pattern>",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>List of projects to exclude from syncing. Glob patterns are supported.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"docs/**",
|
|
||||||
"**/tests/**",
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const giteaQuickActions: QuickAction<GiteaConnectionConfig>[] = [
|
|
||||||
{
|
|
||||||
fn: (previous: GiteaConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
orgs: [
|
|
||||||
...(previous.orgs ?? []),
|
|
||||||
"<organization name>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add an organization",
|
|
||||||
selectionText: "<organization name>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GiteaConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
repos: [
|
|
||||||
...(previous.repos ?? []),
|
|
||||||
"<owner>/<repo name>"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a repo",
|
|
||||||
selectionText: "<owner>/<repo name>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GiteaConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
url: previous.url ?? "https://gitea.example.com",
|
|
||||||
}),
|
|
||||||
name: "Set url to Gitea instance",
|
|
||||||
selectionText: "https://gitea.example.com",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const gerritQuickActions: QuickAction<GerritConnectionConfig>[] = [
|
|
||||||
{
|
|
||||||
fn: (previous: GerritConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
projects: [
|
|
||||||
...(previous.projects ?? []),
|
|
||||||
""
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a project",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: GerritConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
projects: [
|
|
||||||
...(previous.exclude?.projects ?? []),
|
|
||||||
""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude a project",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[] = [
|
|
||||||
{
|
|
||||||
// add user
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
user: previous.user ?? "username"
|
|
||||||
}),
|
|
||||||
name: "Add username",
|
|
||||||
selectionText: "username",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Username to use for authentication. This is only required if you're using an App Password (stored in <CodeSnippet>token</CodeSnippet>) for authentication.</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
workspaces: [
|
|
||||||
...(previous.workspaces ?? []),
|
|
||||||
"myWorkspace"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a workspace",
|
|
||||||
selectionText: "myWorkspace",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a workspace to sync with. Ensure the workspace is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
repos: [
|
|
||||||
...(previous.repos ?? []),
|
|
||||||
"myWorkspace/myRepo"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a repo",
|
|
||||||
selectionText: "myWorkspace/myRepo",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add an individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
projects: [
|
|
||||||
...(previous.projects ?? []),
|
|
||||||
"myProject"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a project",
|
|
||||||
selectionText: "myProject",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
repos: [...(previous.exclude?.repos ?? []), "myWorkspace/myExcludedRepo"]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude a repo",
|
|
||||||
selectionText: "myWorkspace/myExcludedRepo",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Exclude a repository from syncing. Glob patterns are supported.</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// exclude forked
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
forks: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude forked repos",
|
|
||||||
description: <span>Exclude forked repositories from syncing.</span>
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const bitbucketDataCenterQuickActions: QuickAction<BitbucketConnectionConfig>[] = [
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
url: previous.url ?? "https://bitbucket.example.com",
|
|
||||||
}),
|
|
||||||
name: "Set url to Bitbucket DC instance",
|
|
||||||
selectionText: "https://bitbucket.example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
repos: [
|
|
||||||
...(previous.repos ?? []),
|
|
||||||
"myProject/myRepo"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a repo",
|
|
||||||
selectionText: "myProject/myRepo",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"PROJ/repo-name",
|
|
||||||
"MYPROJ/api"
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
projects: [
|
|
||||||
...(previous.projects ?? []),
|
|
||||||
"myProject"
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
name: "Add a project",
|
|
||||||
selectionText: "myProject",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Add a project to sync with. Ensure the project is visible to the provided <CodeSnippet>token</CodeSnippet> (if any).</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
repos: [...(previous.exclude?.repos ?? []), "myProject/myExcludedRepo"]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude a repo",
|
|
||||||
selectionText: "myProject/myExcludedRepo",
|
|
||||||
description: (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>Exclude a repository from syncing. Glob patterns are supported.</span>
|
|
||||||
<span className="text-sm mt-2 mb-1">Examples:</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{[
|
|
||||||
"myProject/myExcludedRepo",
|
|
||||||
"myProject2/*"
|
|
||||||
].map((repo) => (
|
|
||||||
<CodeSnippet key={repo}>{repo}</CodeSnippet>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// exclude archived
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
archived: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude archived repos",
|
|
||||||
},
|
|
||||||
// exclude forked
|
|
||||||
{
|
|
||||||
fn: (previous: BitbucketConnectionConfig) => ({
|
|
||||||
...previous,
|
|
||||||
exclude: {
|
|
||||||
...previous.exclude,
|
|
||||||
forks: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: "Exclude forked repos",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import Ajv, { Schema } from "ajv";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const createZodConnectionConfigValidator = <T>(jsonSchema: Schema, additionalConfigValidation?: (config: T) => { message: string, isValid: boolean }) => {
|
|
||||||
const ajv = new Ajv({
|
|
||||||
validateFormats: false,
|
|
||||||
});
|
|
||||||
const validate = ajv.compile(jsonSchema);
|
|
||||||
|
|
||||||
return z
|
|
||||||
.string()
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
const addIssue = (message: string) => {
|
|
||||||
return ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Schema validation error: ${message}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
addIssue("Invalid JSON");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = validate(parsed);
|
|
||||||
if (!valid) {
|
|
||||||
addIssue(ajv.errorsText(validate.errors));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalConfigValidation) {
|
|
||||||
const result = additionalConfigValidation(parsed as T);
|
|
||||||
if (!result.isValid) {
|
|
||||||
addIssue(result.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions";
|
||||||
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
|
||||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
import { GitHubStarToast } from "./components/githubStarToast";
|
import { GitHubStarToast } from "./components/githubStarToast";
|
||||||
|
import { UpgradeToast } from "./components/upgradeToast";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
|
|
@ -154,6 +155,7 @@ export default async function Layout(props: LayoutProps) {
|
||||||
{children}
|
{children}
|
||||||
<SyntaxReferenceGuide />
|
<SyntaxReferenceGuide />
|
||||||
<GitHubStarToast />
|
<GitHubStarToast />
|
||||||
|
<UpgradeToast />
|
||||||
</SyntaxGuideProvider>
|
</SyntaxGuideProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,101 +1,11 @@
|
||||||
import { getRepos, getSearchContexts } from "@/actions";
|
import SearchPage from "./search/page";
|
||||||
import { Footer } from "@/app/components/footer";
|
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
|
||||||
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
|
|
||||||
import { isServiceError, measure } from "@/lib/utils";
|
|
||||||
import { Homepage } from "./components/homepage";
|
|
||||||
import { NavigationMenu } from "./components/navigationMenu";
|
|
||||||
import { PageNotFound } from "./components/pageNotFound";
|
|
||||||
import { UpgradeToast } from "./components/upgradeToast";
|
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME } from "@/lib/constants";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { loadJsonFile } from "@sourcebot/shared";
|
|
||||||
import { DemoExamples, demoExamplesSchema } from "@/types";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
|
|
||||||
const logger = createLogger('web-homepage');
|
interface Props {
|
||||||
|
params: Promise<{ domain: string }>;
|
||||||
export default async function Home(props: { params: Promise<{ domain: string }> }) {
|
searchParams: Promise<{ query?: string }>;
|
||||||
logger.debug('Starting homepage load...');
|
|
||||||
const { data: HomePage, durationMs } = await measure(() => HomeInternal(props), 'HomeInternal', /* outputLog = */ false);
|
|
||||||
logger.debug(`Homepage load completed in ${durationMs}ms.`);
|
|
||||||
|
|
||||||
return HomePage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HomeInternal = async (props: { params: Promise<{ domain: string }> }) => {
|
export default async function Home(props: Props) {
|
||||||
const params = await props.params;
|
// Default to rendering the search page.
|
||||||
|
return <SearchPage {...props} />;
|
||||||
const {
|
|
||||||
domain
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
|
|
||||||
const org = (await measure(() => getOrgFromDomain(domain), 'getOrgFromDomain')).data;
|
|
||||||
if (!org) {
|
|
||||||
return <PageNotFound />
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = (await measure(() => auth(), 'auth')).data;
|
|
||||||
const models = (await measure(() => getConfiguredLanguageModelsInfo(), 'getConfiguredLanguageModelsInfo')).data;
|
|
||||||
const repos = (await measure(() => getRepos(), 'getRepos')).data;
|
|
||||||
const searchContexts = (await measure(() => getSearchContexts(domain), 'getSearchContexts')).data;
|
|
||||||
const chatHistory = session ? (await measure(() => getUserChatHistory(domain), 'getUserChatHistory')).data : [];
|
|
||||||
|
|
||||||
if (isServiceError(repos)) {
|
|
||||||
throw new ServiceErrorException(repos);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isServiceError(searchContexts)) {
|
|
||||||
throw new ServiceErrorException(searchContexts);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isServiceError(chatHistory)) {
|
|
||||||
throw new ServiceErrorException(chatHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
|
|
||||||
|
|
||||||
// Read search mode from cookie, defaulting to agentic if not set
|
|
||||||
// (assuming a language model is configured).
|
|
||||||
const cookieStore = (await measure(() => cookies(), 'cookies')).data;
|
|
||||||
const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME);
|
|
||||||
const initialSearchMode = (
|
|
||||||
searchModeCookie?.value === "agentic" ||
|
|
||||||
searchModeCookie?.value === "precise"
|
|
||||||
) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise";
|
|
||||||
|
|
||||||
const isAgenticSearchTutorialDismissed = cookieStore.get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
|
|
||||||
|
|
||||||
const demoExamples = env.SOURCEBOT_DEMO_EXAMPLES_PATH ? await (async () => {
|
|
||||||
try {
|
|
||||||
return (await measure(() => loadJsonFile<DemoExamples>(env.SOURCEBOT_DEMO_EXAMPLES_PATH!, demoExamplesSchema), 'loadExamplesJsonFile')).data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load demo examples:', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
})() : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
|
||||||
<NavigationMenu
|
|
||||||
domain={domain}
|
|
||||||
/>
|
|
||||||
<UpgradeToast />
|
|
||||||
|
|
||||||
<Homepage
|
|
||||||
initialRepos={indexedRepos}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
languageModels={models}
|
|
||||||
chatHistory={chatHistory}
|
|
||||||
initialSearchMode={initialSearchMode}
|
|
||||||
demoExamples={demoExamples}
|
|
||||||
isAgenticSearchTutorialDismissed={isAgenticSearchTutorialDismissed}
|
|
||||||
/>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,72 +2,51 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { ColumnDef } from "@tanstack/react-table"
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
import { ArrowUpDown, Clock, Loader2, CheckCircle2, XCircle, Trash2, Check, ListFilter } from "lucide-react"
|
import { ArrowUpDown, Clock, Loader2, CheckCircle2, Check, ListFilter } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { cn, getRepoImageSrc } from "@/lib/utils"
|
import { cn, getRepoImageSrc } from "@/lib/utils"
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { getBrowsePath } from "../browse/hooks/useBrowseNavigation"
|
import { getBrowsePath } from "../browse/hooks/utils"
|
||||||
|
|
||||||
|
export type RepoStatus = 'syncing' | 'indexed' | 'not-indexed';
|
||||||
|
|
||||||
export type RepositoryColumnInfo = {
|
export type RepositoryColumnInfo = {
|
||||||
repoId: number
|
repoId: number
|
||||||
repoName: string;
|
repoName: string;
|
||||||
repoDisplayName: string
|
repoDisplayName: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
repoIndexingStatus: RepoIndexingStatus
|
status: RepoStatus
|
||||||
lastIndexed: string
|
lastIndexed: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels: Record<RepoStatus, string> = {
|
||||||
[RepoIndexingStatus.NEW]: "Queued",
|
'syncing': "Syncing",
|
||||||
[RepoIndexingStatus.IN_INDEX_QUEUE]: "Queued",
|
'indexed': "Indexed",
|
||||||
[RepoIndexingStatus.INDEXING]: "Indexing",
|
'not-indexed': "Pending",
|
||||||
[RepoIndexingStatus.INDEXED]: "Indexed",
|
|
||||||
[RepoIndexingStatus.FAILED]: "Failed",
|
|
||||||
[RepoIndexingStatus.IN_GC_QUEUE]: "Deleting",
|
|
||||||
[RepoIndexingStatus.GARBAGE_COLLECTING]: "Deleting",
|
|
||||||
[RepoIndexingStatus.GARBAGE_COLLECTION_FAILED]: "Deletion Failed"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
|
const StatusIndicator = ({ status }: { status: RepoStatus }) => {
|
||||||
let icon = null
|
let icon = null
|
||||||
let description = ""
|
let description = ""
|
||||||
let className = ""
|
let className = ""
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case RepoIndexingStatus.NEW:
|
case 'syncing':
|
||||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
|
||||||
icon = <Clock className="h-3.5 w-3.5" />
|
|
||||||
description = "Repository is queued for indexing"
|
|
||||||
className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
|
|
||||||
break
|
|
||||||
case RepoIndexingStatus.INDEXING:
|
|
||||||
icon = <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
icon = <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
description = "Repository is being indexed"
|
description = "Repository is currently syncing"
|
||||||
className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400"
|
className = "text-blue-600 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400"
|
||||||
break
|
break
|
||||||
case RepoIndexingStatus.INDEXED:
|
case 'indexed':
|
||||||
icon = <CheckCircle2 className="h-3.5 w-3.5" />
|
icon = <CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
description = "Repository has been successfully indexed"
|
description = "Repository has been successfully indexed and is up to date"
|
||||||
className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400"
|
className = "text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400"
|
||||||
break
|
break
|
||||||
case RepoIndexingStatus.FAILED:
|
case 'not-indexed':
|
||||||
icon = <XCircle className="h-3.5 w-3.5" />
|
icon = <Clock className="h-3.5 w-3.5" />
|
||||||
description = "Repository indexing failed"
|
description = "Repository is pending initial sync"
|
||||||
className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400"
|
className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
|
||||||
break
|
|
||||||
case RepoIndexingStatus.IN_GC_QUEUE:
|
|
||||||
case RepoIndexingStatus.GARBAGE_COLLECTING:
|
|
||||||
icon = <Trash2 className="h-3.5 w-3.5" />
|
|
||||||
description = "Repository is being deleted"
|
|
||||||
className = "text-gray-600 bg-gray-50 dark:bg-gray-900/20 dark:text-gray-400"
|
|
||||||
break
|
|
||||||
case RepoIndexingStatus.GARBAGE_COLLECTION_FAILED:
|
|
||||||
icon = <XCircle className="h-3.5 w-3.5" />
|
|
||||||
description = "Repository deletion failed"
|
|
||||||
className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400"
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +73,7 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "repoDisplayName",
|
accessorKey: "repoDisplayName",
|
||||||
header: 'Repository',
|
header: 'Repository',
|
||||||
|
size: 500,
|
||||||
cell: ({ row: { original: { repoId, repoName, repoDisplayName, imageUrl } } }) => {
|
cell: ({ row: { original: { repoId, repoName, repoDisplayName, imageUrl } } }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-center gap-3 py-2">
|
<div className="flex flex-row items-center gap-3 py-2">
|
||||||
|
|
@ -130,9 +110,10 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "repoIndexingStatus",
|
accessorKey: "status",
|
||||||
|
size: 150,
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const uniqueLabels = Array.from(new Set(Object.values(statusLabels)));
|
const uniqueLabels = Object.values(statusLabels);
|
||||||
const currentFilter = column.getFilterValue() as string | undefined;
|
const currentFilter = column.getFilterValue() as string | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -173,17 +154,18 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <StatusIndicator status={row.original.repoIndexingStatus} />
|
return <StatusIndicator status={row.original.status} />
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value) => {
|
filterFn: (row, id, value) => {
|
||||||
if (value === undefined) return true;
|
if (value === undefined) return true;
|
||||||
|
|
||||||
const status = row.getValue(id) as RepoIndexingStatus;
|
const status = row.getValue(id) as RepoStatus;
|
||||||
return statusLabels[status] === value;
|
return statusLabels[status] === value;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "lastIndexed",
|
accessorKey: "lastIndexed",
|
||||||
|
size: 150,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<div className="w-[150px]">
|
<div className="w-[150px]">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -191,14 +173,14 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="px-0 font-medium hover:bg-transparent focus:bg-transparent active:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
|
className="px-0 font-medium hover:bg-transparent focus:bg-transparent active:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
>
|
>
|
||||||
Last Indexed
|
Last Synced
|
||||||
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
|
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.lastIndexed) {
|
if (!row.original.lastIndexed) {
|
||||||
return <div>-</div>;
|
return <div className="text-muted-foreground">Never</div>;
|
||||||
}
|
}
|
||||||
const date = new Date(row.original.lastIndexed)
|
const date = new Date(row.original.lastIndexed)
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,8 @@ export default async function Layout(
|
||||||
props: LayoutProps
|
props: LayoutProps
|
||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const { domain } = params;
|
||||||
const {
|
const { children } = props;
|
||||||
domain
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const {
|
|
||||||
children
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
import { RepositoryTable } from "./repositoryTable";
|
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
|
||||||
import { PageNotFound } from "../components/pageNotFound";
|
|
||||||
import { Header } from "../components/header";
|
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
|
import { RepoIndexingJob } from "@sourcebot/db";
|
||||||
|
import { Header } from "../components/header";
|
||||||
|
import { RepoStatus } from "./columns";
|
||||||
|
import { RepositoryTable } from "./repositoryTable";
|
||||||
|
import { sew } from "@/actions";
|
||||||
|
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
|
||||||
|
function getRepoStatus(repo: { indexedAt: Date | null, jobs: RepoIndexingJob[] }): RepoStatus {
|
||||||
|
const latestJob = repo.jobs[0];
|
||||||
|
|
||||||
|
if (latestJob?.status === 'PENDING' || latestJob?.status === 'IN_PROGRESS') {
|
||||||
|
return 'syncing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo.indexedAt ? 'indexed' : 'not-indexed';
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ReposPage(props: { params: Promise<{ domain: string }> }) {
|
export default async function ReposPage(props: { params: Promise<{ domain: string }> }) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
@ -11,9 +25,9 @@ export default async function ReposPage(props: { params: Promise<{ domain: strin
|
||||||
domain
|
domain
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const org = await getOrgFromDomain(domain);
|
const repos = await getReposWithJobs();
|
||||||
if (!org) {
|
if (isServiceError(repos)) {
|
||||||
return <PageNotFound />
|
throw new ServiceErrorException(repos);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -21,13 +35,31 @@ export default async function ReposPage(props: { params: Promise<{ domain: strin
|
||||||
<Header>
|
<Header>
|
||||||
<h1 className="text-3xl">Repositories</h1>
|
<h1 className="text-3xl">Repositories</h1>
|
||||||
</Header>
|
</Header>
|
||||||
<div className="flex flex-col items-center">
|
<div className="px-6 py-6">
|
||||||
<div className="w-full">
|
<RepositoryTable
|
||||||
<RepositoryTable
|
repos={repos.map((repo) => ({
|
||||||
isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'}
|
repoId: repo.id,
|
||||||
/>
|
repoName: repo.name,
|
||||||
</div>
|
repoDisplayName: repo.displayName ?? repo.name,
|
||||||
|
imageUrl: repo.imageUrl ?? undefined,
|
||||||
|
indexedAt: repo.indexedAt ?? undefined,
|
||||||
|
status: getRepoStatus(repo),
|
||||||
|
}))}
|
||||||
|
domain={domain}
|
||||||
|
isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getReposWithJobs = async () => sew(() =>
|
||||||
|
withOptionalAuthV2(async ({ prisma }) => {
|
||||||
|
const repos = await prisma.repo.findMany({
|
||||||
|
include: {
|
||||||
|
jobs: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return repos;
|
||||||
|
}));
|
||||||
|
|
@ -1,118 +1,81 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { columns, RepositoryColumnInfo } from "./columns";
|
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { PlusIcon, RefreshCwIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { columns, RepositoryColumnInfo, RepoStatus } from "./columns";
|
||||||
import { AddRepositoryDialog } from "./components/addRepositoryDialog";
|
import { AddRepositoryDialog } from "./components/addRepositoryDialog";
|
||||||
import { useState } from "react";
|
|
||||||
import { getRepos } from "@/app/api/(client)/client";
|
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
interface RepositoryTableProps {
|
||||||
isAddReposButtonVisible: boolean
|
repos: {
|
||||||
|
repoId: number;
|
||||||
|
repoName: string;
|
||||||
|
repoDisplayName: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
indexedAt?: Date;
|
||||||
|
status: RepoStatus;
|
||||||
|
}[];
|
||||||
|
domain: string;
|
||||||
|
isAddReposButtonVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepositoryTable = ({
|
export const RepositoryTable = ({
|
||||||
|
repos,
|
||||||
|
domain,
|
||||||
isAddReposButtonVisible,
|
isAddReposButtonVisible,
|
||||||
}: RepositoryTableProps) => {
|
}: RepositoryTableProps) => {
|
||||||
const domain = useDomain();
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
|
const { toast } = useToast();
|
||||||
queryKey: ['repos'],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await unwrapServiceError(getRepos());
|
|
||||||
},
|
|
||||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tableRepos = useMemo(() => {
|
const tableRepos = useMemo(() => {
|
||||||
if (reposLoading) return Array(4).fill(null).map(() => ({
|
|
||||||
repoId: 0,
|
|
||||||
repoName: "",
|
|
||||||
repoDisplayName: "",
|
|
||||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
|
||||||
lastIndexed: "",
|
|
||||||
imageUrl: "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!repos) return [];
|
|
||||||
return repos.map((repo): RepositoryColumnInfo => ({
|
return repos.map((repo): RepositoryColumnInfo => ({
|
||||||
repoId: repo.repoId,
|
repoId: repo.repoId,
|
||||||
repoName: repo.repoName,
|
repoName: repo.repoName,
|
||||||
repoDisplayName: repo.repoDisplayName ?? repo.repoName,
|
repoDisplayName: repo.repoDisplayName ?? repo.repoName,
|
||||||
imageUrl: repo.imageUrl,
|
imageUrl: repo.imageUrl,
|
||||||
repoIndexingStatus: repo.repoIndexingStatus as RepoIndexingStatus,
|
status: repo.status,
|
||||||
lastIndexed: repo.indexedAt?.toISOString() ?? "",
|
lastIndexed: repo.indexedAt?.toISOString() ?? "",
|
||||||
})).sort((a, b) => {
|
})).sort((a, b) => {
|
||||||
const getPriorityFromStatus = (status: RepoIndexingStatus) => {
|
const getPriorityFromStatus = (status: RepoStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
case 'syncing':
|
||||||
case RepoIndexingStatus.INDEXING:
|
return 0 // Highest priority - currently syncing
|
||||||
return 0 // Highest priority - currently indexing
|
case 'not-indexed':
|
||||||
case RepoIndexingStatus.FAILED:
|
return 1 // Second priority - not yet indexed
|
||||||
return 1 // Second priority - failed repos need attention
|
case 'indexed':
|
||||||
case RepoIndexingStatus.INDEXED:
|
|
||||||
return 2 // Third priority - successfully indexed
|
return 2 // Third priority - successfully indexed
|
||||||
default:
|
default:
|
||||||
return 3 // Lowest priority - other statuses (NEW, etc.)
|
return 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority first
|
// Sort by priority first
|
||||||
const aPriority = getPriorityFromStatus(a.repoIndexingStatus);
|
const aPriority = getPriorityFromStatus(a.status);
|
||||||
const bPriority = getPriorityFromStatus(b.repoIndexingStatus);
|
const bPriority = getPriorityFromStatus(b.status);
|
||||||
|
|
||||||
if (aPriority !== bPriority) {
|
if (aPriority !== bPriority) {
|
||||||
return aPriority - bPriority; // Lower priority number = higher precedence
|
return aPriority - bPriority;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If same priority, sort by last indexed date (most recent first)
|
// If same priority, sort by last indexed date (most recent first)
|
||||||
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime();
|
if (a.lastIndexed && b.lastIndexed) {
|
||||||
|
return new Date(b.lastIndexed).getTime() - new Date(a.lastIndexed).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put items without dates at the end
|
||||||
|
if (!a.lastIndexed) return 1;
|
||||||
|
if (!b.lastIndexed) return -1;
|
||||||
|
return 0;
|
||||||
});
|
});
|
||||||
}, [repos, reposLoading]);
|
}, [repos]);
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
if (reposLoading) {
|
|
||||||
return columns(domain).map((column) => {
|
|
||||||
if ('accessorKey' in column && column.accessorKey === "name") {
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
cell: () => (
|
|
||||||
<div className="flex flex-row items-center gap-3 py-2">
|
|
||||||
<Skeleton className="h-8 w-8 rounded-md" /> {/* Avatar skeleton */}
|
|
||||||
<Skeleton className="h-4 w-48" /> {/* Repository name skeleton */}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
cell: () => (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<Skeleton className="h-5 w-24 rounded-full" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return columns(domain);
|
return columns(domain);
|
||||||
}, [reposLoading, domain]);
|
}, [domain]);
|
||||||
|
|
||||||
|
|
||||||
if (reposError) {
|
|
||||||
return <div>Error loading repositories</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -121,15 +84,32 @@ export const RepositoryTable = ({
|
||||||
data={tableRepos}
|
data={tableRepos}
|
||||||
searchKey="repoDisplayName"
|
searchKey="repoDisplayName"
|
||||||
searchPlaceholder="Search repositories..."
|
searchPlaceholder="Search repositories..."
|
||||||
headerActions={isAddReposButtonVisible && (
|
headerActions={(
|
||||||
<Button
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
variant="default"
|
<Button
|
||||||
size="default"
|
variant="outline"
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
size="default"
|
||||||
>
|
className="ml-2"
|
||||||
<PlusIcon className="w-4 h-4" />
|
onClick={() => {
|
||||||
Add repository
|
router.refresh();
|
||||||
</Button>
|
toast({
|
||||||
|
description: "Page refreshed",
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<RefreshCwIcon className="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{isAddReposButtonVisible && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
Add repository
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { NavigationMenu } from "../../components/navigationMenu"
|
||||||
|
import { RepositoryCarousel } from "../../components/repositoryCarousel"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { SyntaxReferenceGuideHint } from "../../components/syntaxReferenceGuideHint"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { SearchBar } from "../../components/searchBar"
|
||||||
|
import { SearchModeSelector } from "../../components/searchModeSelector"
|
||||||
|
import { getRepos, getReposStats } from "@/actions"
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError"
|
||||||
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface SearchLandingPageProps {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchLandingPage = async ({
|
||||||
|
domain,
|
||||||
|
}: SearchLandingPageProps) => {
|
||||||
|
const carouselRepos = await getRepos({
|
||||||
|
where: {
|
||||||
|
indexedAt: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoStats = await getReposStats();
|
||||||
|
|
||||||
|
if (isServiceError(carouselRepos)) throw new ServiceErrorException(carouselRepos);
|
||||||
|
if (isServiceError(repoStats)) throw new ServiceErrorException(repoStats);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||||
|
<NavigationMenu
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||||
|
<div className="max-h-44 w-auto">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-18 md:h-40 w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 w-full max-w-[800px] border rounded-md shadow-sm">
|
||||||
|
<SearchBar
|
||||||
|
autoFocus={true}
|
||||||
|
className="border-none pt-0.5 pb-0"
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||||
|
<SearchModeSelector
|
||||||
|
searchMode="precise"
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<RepositoryCarousel
|
||||||
|
numberOfReposWithIndex={repoStats.numberOfReposWithIndex}
|
||||||
|
displayRepos={carouselRepos}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center w-fit gap-6">
|
||||||
|
<Separator className="mt-5" />
|
||||||
|
<span className="font-semibold">How to search</span>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<HowToSection
|
||||||
|
title="Search in files or paths"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="test todo" domain={domain}>test todo</Query> <QueryExplanation>(both test and todo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="test or todo" domain={domain}>test <Highlight>or</Highlight> todo</Query> <QueryExplanation>(either test or todo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query={`"exit boot"`} domain={domain}>{`"exit boot"`}</Query> <QueryExplanation>(exact match)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="TODO case:yes" domain={domain}>TODO <Highlight>case:</Highlight>yes</Query> <QueryExplanation>(case sensitive)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
<HowToSection
|
||||||
|
title="Filter results"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="file:README setup" domain={domain}><Highlight>file:</Highlight>README setup</Query> <QueryExplanation>(by filename)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="repo:torvalds/linux test" domain={domain}><Highlight>repo:</Highlight>torvalds/linux test</Query> <QueryExplanation>(by repo)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="lang:typescript" domain={domain}><Highlight>lang:</Highlight>typescript</Query> <QueryExplanation>(by language)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="rev:HEAD" domain={domain}><Highlight>rev:</Highlight>HEAD</Query> <QueryExplanation>(by branch or tag)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
<HowToSection
|
||||||
|
title="Advanced"
|
||||||
|
>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="file:\.py$" domain={domain}><Highlight>file:</Highlight>{`\\.py$`}</Query> <QueryExplanation>{`(files that end in ".py")`}</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="sym:main" domain={domain}><Highlight>sym:</Highlight>main</Query> <QueryExplanation>{`(symbols named "main")`}</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="todo -lang:c" domain={domain}>todo <Highlight>-lang:c</Highlight></Query> <QueryExplanation>(negate filter)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
<QueryExample>
|
||||||
|
<Query query="content:README" domain={domain}><Highlight>content:</Highlight>README</Query> <QueryExplanation>(search content only)</QueryExplanation>
|
||||||
|
</QueryExample>
|
||||||
|
</HowToSection>
|
||||||
|
</div>
|
||||||
|
<SyntaxReferenceGuideHint />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="dark:text-gray-300 text-sm mb-2 underline">{title}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const Highlight = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-highlight">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryExample = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryExplanation = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-3">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${domain}/search?query=${query}`}
|
||||||
|
className="cursor-pointer hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,372 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CodeSnippet } from "@/app/components/codeSnippet";
|
||||||
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { RepositoryInfo, SearchResultFile, SearchStats } from "@/features/search/types";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
|
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||||
|
import { SearchQueryParams } from "@/lib/types";
|
||||||
|
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||||
|
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||||
|
import { AlertTriangleIcon, BugIcon, FilterIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
import { search } from "../../../api/(client)/client";
|
||||||
|
import { CopyIconButton } from "../../components/copyIconButton";
|
||||||
|
import { SearchBar } from "../../components/searchBar";
|
||||||
|
import { TopBar } from "../../components/topBar";
|
||||||
|
import { CodePreviewPanel } from "./codePreviewPanel";
|
||||||
|
import { FilterPanel } from "./filterPanel";
|
||||||
|
import { useFilteredMatches } from "./filterPanel/useFilterMatches";
|
||||||
|
import { SearchResultsPanel } from "./searchResultsPanel";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MATCH_COUNT = 5000;
|
||||||
|
|
||||||
|
interface SearchResultsPageProps {
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchResultsPage = ({
|
||||||
|
searchQuery,
|
||||||
|
}: SearchResultsPageProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setSearchHistory } = useSearchHistory();
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
const domain = useDomain();
|
||||||
|
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,
|
||||||
|
isPending: isSearchPending,
|
||||||
|
isFetching: isFetching,
|
||||||
|
error
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["search", searchQuery, maxMatchCount],
|
||||||
|
queryFn: () => measure(() => unwrapServiceError(search({
|
||||||
|
query: searchQuery,
|
||||||
|
matches: maxMatchCount,
|
||||||
|
contextLines: 3,
|
||||||
|
whole: false,
|
||||||
|
}, domain)), "client.search"),
|
||||||
|
select: ({ data, durationMs }) => ({
|
||||||
|
...data,
|
||||||
|
totalClientSearchDurationMs: durationMs,
|
||||||
|
}),
|
||||||
|
enabled: searchQuery.length > 0,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Search failed. Reason: ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
|
|
||||||
|
// Write the query to the search history
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchQuery.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toUTCString();
|
||||||
|
setSearchHistory((searchHistory) => [
|
||||||
|
{
|
||||||
|
query: searchQuery,
|
||||||
|
date: now,
|
||||||
|
},
|
||||||
|
...searchHistory.filter(search => search.query !== searchQuery),
|
||||||
|
])
|
||||||
|
}, [searchQuery, setSearchHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
||||||
|
|
||||||
|
captureEvent("search_finished", {
|
||||||
|
durationMs: searchResponse.totalClientSearchDurationMs,
|
||||||
|
fileCount: searchResponse.stats.fileCount,
|
||||||
|
matchCount: searchResponse.stats.totalMatchCount,
|
||||||
|
actualMatchCount: searchResponse.stats.actualMatchCount,
|
||||||
|
filesSkipped: searchResponse.stats.filesSkipped,
|
||||||
|
contentBytesLoaded: searchResponse.stats.contentBytesLoaded,
|
||||||
|
indexBytesLoaded: searchResponse.stats.indexBytesLoaded,
|
||||||
|
crashes: searchResponse.stats.crashes,
|
||||||
|
shardFilesConsidered: searchResponse.stats.shardFilesConsidered,
|
||||||
|
filesConsidered: searchResponse.stats.filesConsidered,
|
||||||
|
filesLoaded: searchResponse.stats.filesLoaded,
|
||||||
|
shardsScanned: searchResponse.stats.shardsScanned,
|
||||||
|
shardsSkipped: searchResponse.stats.shardsSkipped,
|
||||||
|
shardsSkippedFilter: searchResponse.stats.shardsSkippedFilter,
|
||||||
|
ngramMatches: searchResponse.stats.ngramMatches,
|
||||||
|
ngramLookups: searchResponse.stats.ngramLookups,
|
||||||
|
wait: searchResponse.stats.wait,
|
||||||
|
matchTreeConstruction: searchResponse.stats.matchTreeConstruction,
|
||||||
|
matchTreeSearch: searchResponse.stats.matchTreeSearch,
|
||||||
|
regexpsConsidered: searchResponse.stats.regexpsConsidered,
|
||||||
|
flushReason: searchResponse.stats.flushReason,
|
||||||
|
fileLanguages,
|
||||||
|
});
|
||||||
|
}, [captureEvent, searchQuery, searchResponse]);
|
||||||
|
|
||||||
|
|
||||||
|
const onLoadMoreResults = useCallback(() => {
|
||||||
|
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||||
|
[SearchQueryParams.query, searchQuery],
|
||||||
|
[SearchQueryParams.matches, `${maxMatchCount * 2}`],
|
||||||
|
)
|
||||||
|
router.push(url);
|
||||||
|
}, [maxMatchCount, router, searchQuery, domain]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-clip">
|
||||||
|
{/* TopBar */}
|
||||||
|
<TopBar
|
||||||
|
domain={domain}
|
||||||
|
>
|
||||||
|
<SearchBar
|
||||||
|
size="sm"
|
||||||
|
defaultQuery={searchQuery}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</TopBar>
|
||||||
|
|
||||||
|
{(isSearchPending || isFetching) ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||||
|
<SymbolIcon className="h-6 w-6 animate-spin" />
|
||||||
|
<p className="font-semibold text-center">Searching...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||||
|
<AlertTriangleIcon className="h-6 w-6" />
|
||||||
|
<p className="font-semibold text-center">Failed to search</p>
|
||||||
|
<p className="text-sm text-center">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PanelGroup
|
||||||
|
fileMatches={searchResponse.files}
|
||||||
|
isMoreResultsButtonVisible={searchResponse.isSearchExhaustive === false}
|
||||||
|
onLoadMoreResults={onLoadMoreResults}
|
||||||
|
isBranchFilteringEnabled={searchResponse.isBranchFilteringEnabled}
|
||||||
|
repoInfo={searchResponse.repositoryInfo}
|
||||||
|
searchDurationMs={searchResponse.totalClientSearchDurationMs}
|
||||||
|
numMatches={searchResponse.stats.actualMatchCount}
|
||||||
|
searchStats={searchResponse.stats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelGroupProps {
|
||||||
|
fileMatches: SearchResultFile[];
|
||||||
|
isMoreResultsButtonVisible?: boolean;
|
||||||
|
onLoadMoreResults: () => void;
|
||||||
|
isBranchFilteringEnabled: boolean;
|
||||||
|
repoInfo: RepositoryInfo[];
|
||||||
|
searchDurationMs: number;
|
||||||
|
numMatches: number;
|
||||||
|
searchStats?: SearchStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PanelGroup = ({
|
||||||
|
fileMatches,
|
||||||
|
isMoreResultsButtonVisible,
|
||||||
|
onLoadMoreResults,
|
||||||
|
isBranchFilteringEnabled,
|
||||||
|
repoInfo: _repoInfo,
|
||||||
|
searchDurationMs: _searchDurationMs,
|
||||||
|
numMatches,
|
||||||
|
searchStats,
|
||||||
|
}: PanelGroupProps) => {
|
||||||
|
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
|
||||||
|
const filteredFileMatches = useFilteredMatches(fileMatches);
|
||||||
|
const filterPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
||||||
|
|
||||||
|
const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false);
|
||||||
|
|
||||||
|
useHotkeys("mod+b", () => {
|
||||||
|
if (isFilterPanelCollapsed) {
|
||||||
|
filterPanelRef.current?.expand();
|
||||||
|
} else {
|
||||||
|
filterPanelRef.current?.collapse();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
enableOnFormTags: true,
|
||||||
|
enableOnContentEditable: true,
|
||||||
|
description: "Toggle filter panel",
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchDurationMs = useMemo(() => {
|
||||||
|
return Math.round(_searchDurationMs);
|
||||||
|
}, [_searchDurationMs]);
|
||||||
|
|
||||||
|
const repoInfo = useMemo(() => {
|
||||||
|
return _repoInfo.reduce((acc, repo) => {
|
||||||
|
acc[repo.id] = repo;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, RepositoryInfo>);
|
||||||
|
}, [_repoInfo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
{/* ~~ Filter panel ~~ */}
|
||||||
|
<ResizablePanel
|
||||||
|
ref={filterPanelRef}
|
||||||
|
minSize={20}
|
||||||
|
maxSize={30}
|
||||||
|
defaultSize={isFilterPanelCollapsed ? 0 : 20}
|
||||||
|
collapsible={true}
|
||||||
|
id={'filter-panel'}
|
||||||
|
order={1}
|
||||||
|
onCollapse={() => setIsFilterPanelCollapsed(true)}
|
||||||
|
onExpand={() => setIsFilterPanelCollapsed(false)}
|
||||||
|
>
|
||||||
|
<FilterPanel
|
||||||
|
matches={fileMatches}
|
||||||
|
repoInfo={repoInfo}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
{isFilterPanelCollapsed && (
|
||||||
|
<div className="flex flex-col items-center h-full p-2">
|
||||||
|
<Tooltip
|
||||||
|
delayDuration={100}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
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 ~~ */}
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={10}
|
||||||
|
id={'search-results-panel'}
|
||||||
|
order={2}
|
||||||
|
>
|
||||||
|
<div className="py-1 px-2 flex flex-row items-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<InfoCircledIcon className="w-4 h-4 mr-2" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="flex flex-col items-start gap-2 p-4">
|
||||||
|
<div className="flex flex-row items-center w-full">
|
||||||
|
<BugIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
<p className="text-md font-medium">Search stats for nerds</p>
|
||||||
|
<CopyIconButton
|
||||||
|
onCopy={() => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(searchStats, null, 2));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
className="ml-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CodeSnippet renderNewlines>
|
||||||
|
{JSON.stringify(searchStats, null, 2)}
|
||||||
|
</CodeSnippet>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{
|
||||||
|
fileMatches.length > 0 ? (
|
||||||
|
<p className="text-sm font-medium">{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium">No results</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{isMoreResultsButtonVisible && (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer text-blue-500 text-sm hover:underline ml-4"
|
||||||
|
onClick={onLoadMoreResults}
|
||||||
|
>
|
||||||
|
(load more)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filteredFileMatches.length > 0 ? (
|
||||||
|
<SearchResultsPanel
|
||||||
|
fileMatches={filteredFileMatches}
|
||||||
|
onOpenFilePreview={(fileMatch, matchIndex) => {
|
||||||
|
setSelectedMatchIndex(matchIndex ?? 0);
|
||||||
|
setPreviewedFile(fileMatch);
|
||||||
|
}}
|
||||||
|
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
||||||
|
onLoadMoreButtonClicked={onLoadMoreResults}
|
||||||
|
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
||||||
|
repoInfo={repoInfo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
<p className="text-sm text-muted-foreground">No results found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
{previewedFile && (
|
||||||
|
<>
|
||||||
|
<AnimatedResizableHandle />
|
||||||
|
{/* ~~ Code preview ~~ */}
|
||||||
|
<ResizablePanel
|
||||||
|
minSize={10}
|
||||||
|
collapsible={true}
|
||||||
|
id={'code-preview-panel'}
|
||||||
|
order={3}
|
||||||
|
onCollapse={() => setPreviewedFile(undefined)}
|
||||||
|
>
|
||||||
|
<CodePreviewPanel
|
||||||
|
previewedFile={previewedFile}
|
||||||
|
onClose={() => setPreviewedFile(undefined)}
|
||||||
|
selectedMatchIndex={selectedMatchIndex}
|
||||||
|
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
import { SearchResultFile, SearchResultChunk } from "@/features/search/types";
|
||||||
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,378 +1,23 @@
|
||||||
'use client';
|
import { SearchLandingPage } from "./components/searchLandingPage";
|
||||||
|
import { SearchResultsPage } from "./components/searchResultsPage";
|
||||||
|
|
||||||
import {
|
interface SearchPageProps {
|
||||||
ResizablePanel,
|
params: Promise<{ domain: string }>;
|
||||||
ResizablePanelGroup,
|
searchParams: Promise<{ query?: string }>;
|
||||||
} from "@/components/ui/resizable";
|
}
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
|
||||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
|
||||||
import { SearchQueryParams } from "@/lib/types";
|
|
||||||
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
|
||||||
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { search } from "../../api/(client)/client";
|
|
||||||
import { TopBar } from "../components/topBar";
|
|
||||||
import { CodePreviewPanel } from "./components/codePreviewPanel";
|
|
||||||
import { FilterPanel } from "./components/filterPanel";
|
|
||||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
|
||||||
import { RepositoryInfo, SearchResultFile, SearchStats } 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 { AlertTriangleIcon, BugIcon, 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";
|
|
||||||
import { SearchBar } from "../components/searchBar";
|
|
||||||
import { CodeSnippet } from "@/app/components/codeSnippet";
|
|
||||||
import { CopyIconButton } from "../components/copyIconButton";
|
|
||||||
|
|
||||||
const DEFAULT_MAX_MATCH_COUNT = 500;
|
export default async function SearchPage(props: SearchPageProps) {
|
||||||
|
const { domain } = await props.params;
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const query = searchParams?.query;
|
||||||
|
|
||||||
|
if (query === undefined || query.length === 0) {
|
||||||
|
return <SearchLandingPage domain={domain} />
|
||||||
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
|
||||||
// We need a suspense boundary here since we are accessing query params
|
|
||||||
// in the top level page.
|
|
||||||
// @see : https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<SearchResultsPage
|
||||||
<SearchPageInternal />
|
searchQuery={query}
|
||||||
</Suspense>
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchPageInternal = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
|
||||||
const { setSearchHistory } = useSearchHistory();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
const domain = useDomain();
|
|
||||||
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,
|
|
||||||
isPending: isSearchPending,
|
|
||||||
isFetching: isFetching,
|
|
||||||
error
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["search", searchQuery, maxMatchCount],
|
|
||||||
queryFn: () => measure(() => unwrapServiceError(search({
|
|
||||||
query: searchQuery,
|
|
||||||
matches: maxMatchCount,
|
|
||||||
contextLines: 3,
|
|
||||||
whole: false,
|
|
||||||
}, domain)), "client.search"),
|
|
||||||
select: ({ data, durationMs }) => ({
|
|
||||||
...data,
|
|
||||||
totalClientSearchDurationMs: durationMs,
|
|
||||||
}),
|
|
||||||
enabled: searchQuery.length > 0,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: false,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (error) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Search failed. Reason: ${error.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [error, toast]);
|
|
||||||
|
|
||||||
|
|
||||||
// Write the query to the search history
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchQuery.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toUTCString();
|
|
||||||
setSearchHistory((searchHistory) => [
|
|
||||||
{
|
|
||||||
query: searchQuery,
|
|
||||||
date: now,
|
|
||||||
},
|
|
||||||
...searchHistory.filter(search => search.query !== searchQuery),
|
|
||||||
])
|
|
||||||
}, [searchQuery, setSearchHistory]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!searchResponse) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileLanguages = searchResponse.files?.map(file => file.language) || [];
|
|
||||||
|
|
||||||
captureEvent("search_finished", {
|
|
||||||
durationMs: searchResponse.totalClientSearchDurationMs,
|
|
||||||
fileCount: searchResponse.stats.fileCount,
|
|
||||||
matchCount: searchResponse.stats.totalMatchCount,
|
|
||||||
actualMatchCount: searchResponse.stats.actualMatchCount,
|
|
||||||
filesSkipped: searchResponse.stats.filesSkipped,
|
|
||||||
contentBytesLoaded: searchResponse.stats.contentBytesLoaded,
|
|
||||||
indexBytesLoaded: searchResponse.stats.indexBytesLoaded,
|
|
||||||
crashes: searchResponse.stats.crashes,
|
|
||||||
shardFilesConsidered: searchResponse.stats.shardFilesConsidered,
|
|
||||||
filesConsidered: searchResponse.stats.filesConsidered,
|
|
||||||
filesLoaded: searchResponse.stats.filesLoaded,
|
|
||||||
shardsScanned: searchResponse.stats.shardsScanned,
|
|
||||||
shardsSkipped: searchResponse.stats.shardsSkipped,
|
|
||||||
shardsSkippedFilter: searchResponse.stats.shardsSkippedFilter,
|
|
||||||
ngramMatches: searchResponse.stats.ngramMatches,
|
|
||||||
ngramLookups: searchResponse.stats.ngramLookups,
|
|
||||||
wait: searchResponse.stats.wait,
|
|
||||||
matchTreeConstruction: searchResponse.stats.matchTreeConstruction,
|
|
||||||
matchTreeSearch: searchResponse.stats.matchTreeSearch,
|
|
||||||
regexpsConsidered: searchResponse.stats.regexpsConsidered,
|
|
||||||
flushReason: searchResponse.stats.flushReason,
|
|
||||||
fileLanguages,
|
|
||||||
});
|
|
||||||
}, [captureEvent, searchQuery, searchResponse]);
|
|
||||||
|
|
||||||
|
|
||||||
const onLoadMoreResults = useCallback(() => {
|
|
||||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
|
||||||
[SearchQueryParams.query, searchQuery],
|
|
||||||
[SearchQueryParams.matches, `${maxMatchCount * 2}`],
|
|
||||||
)
|
|
||||||
router.push(url);
|
|
||||||
}, [maxMatchCount, router, searchQuery, domain]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen overflow-clip">
|
|
||||||
{/* TopBar */}
|
|
||||||
<TopBar
|
|
||||||
domain={domain}
|
|
||||||
>
|
|
||||||
<SearchBar
|
|
||||||
size="sm"
|
|
||||||
defaultQuery={searchQuery}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</TopBar>
|
|
||||||
|
|
||||||
{(isSearchPending || isFetching) ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
|
||||||
<SymbolIcon className="h-6 w-6 animate-spin" />
|
|
||||||
<p className="font-semibold text-center">Searching...</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
|
||||||
<AlertTriangleIcon className="h-6 w-6" />
|
|
||||||
<p className="font-semibold text-center">Failed to search</p>
|
|
||||||
<p className="text-sm text-center">{error.message}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PanelGroup
|
|
||||||
fileMatches={searchResponse.files}
|
|
||||||
isMoreResultsButtonVisible={searchResponse.isSearchExhaustive === false}
|
|
||||||
onLoadMoreResults={onLoadMoreResults}
|
|
||||||
isBranchFilteringEnabled={searchResponse.isBranchFilteringEnabled}
|
|
||||||
repoInfo={searchResponse.repositoryInfo}
|
|
||||||
searchDurationMs={searchResponse.totalClientSearchDurationMs}
|
|
||||||
numMatches={searchResponse.stats.actualMatchCount}
|
|
||||||
searchStats={searchResponse.stats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PanelGroupProps {
|
|
||||||
fileMatches: SearchResultFile[];
|
|
||||||
isMoreResultsButtonVisible?: boolean;
|
|
||||||
onLoadMoreResults: () => void;
|
|
||||||
isBranchFilteringEnabled: boolean;
|
|
||||||
repoInfo: RepositoryInfo[];
|
|
||||||
searchDurationMs: number;
|
|
||||||
numMatches: number;
|
|
||||||
searchStats?: SearchStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PanelGroup = ({
|
|
||||||
fileMatches,
|
|
||||||
isMoreResultsButtonVisible,
|
|
||||||
onLoadMoreResults,
|
|
||||||
isBranchFilteringEnabled,
|
|
||||||
repoInfo: _repoInfo,
|
|
||||||
searchDurationMs: _searchDurationMs,
|
|
||||||
numMatches,
|
|
||||||
searchStats,
|
|
||||||
}: PanelGroupProps) => {
|
|
||||||
const [previewedFile, setPreviewedFile] = useState<SearchResultFile | undefined>(undefined);
|
|
||||||
const filteredFileMatches = useFilteredMatches(fileMatches);
|
|
||||||
const filterPanelRef = useRef<ImperativePanelHandle>(null);
|
|
||||||
const [selectedMatchIndex, setSelectedMatchIndex] = useState(0);
|
|
||||||
|
|
||||||
const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false);
|
|
||||||
|
|
||||||
useHotkeys("mod+b", () => {
|
|
||||||
if (isFilterPanelCollapsed) {
|
|
||||||
filterPanelRef.current?.expand();
|
|
||||||
} else {
|
|
||||||
filterPanelRef.current?.collapse();
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
enableOnFormTags: true,
|
|
||||||
enableOnContentEditable: true,
|
|
||||||
description: "Toggle filter panel",
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchDurationMs = useMemo(() => {
|
|
||||||
return Math.round(_searchDurationMs);
|
|
||||||
}, [_searchDurationMs]);
|
|
||||||
|
|
||||||
const repoInfo = useMemo(() => {
|
|
||||||
return _repoInfo.reduce((acc, repo) => {
|
|
||||||
acc[repo.id] = repo;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<number, RepositoryInfo>);
|
|
||||||
}, [_repoInfo]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResizablePanelGroup
|
|
||||||
direction="horizontal"
|
|
||||||
className="h-full"
|
|
||||||
>
|
|
||||||
{/* ~~ Filter panel ~~ */}
|
|
||||||
<ResizablePanel
|
|
||||||
ref={filterPanelRef}
|
|
||||||
minSize={20}
|
|
||||||
maxSize={30}
|
|
||||||
defaultSize={isFilterPanelCollapsed ? 0 : 20}
|
|
||||||
collapsible={true}
|
|
||||||
id={'filter-panel'}
|
|
||||||
order={1}
|
|
||||||
onCollapse={() => setIsFilterPanelCollapsed(true)}
|
|
||||||
onExpand={() => setIsFilterPanelCollapsed(false)}
|
|
||||||
>
|
|
||||||
<FilterPanel
|
|
||||||
matches={fileMatches}
|
|
||||||
repoInfo={repoInfo}
|
|
||||||
/>
|
|
||||||
</ResizablePanel>
|
|
||||||
{isFilterPanelCollapsed && (
|
|
||||||
<div className="flex flex-col items-center h-full p-2">
|
|
||||||
<Tooltip
|
|
||||||
delayDuration={100}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
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 ~~ */}
|
|
||||||
<ResizablePanel
|
|
||||||
minSize={10}
|
|
||||||
id={'search-results-panel'}
|
|
||||||
order={2}
|
|
||||||
>
|
|
||||||
<div className="py-1 px-2 flex flex-row items-center">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<InfoCircledIcon className="w-4 h-4 mr-2" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="flex flex-col items-start gap-2 p-4">
|
|
||||||
<div className="flex flex-row items-center w-full">
|
|
||||||
<BugIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
<p className="text-md font-medium">Search stats for nerds</p>
|
|
||||||
<CopyIconButton
|
|
||||||
onCopy={() => {
|
|
||||||
navigator.clipboard.writeText(JSON.stringify(searchStats, null, 2));
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
className="ml-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CodeSnippet renderNewlines>
|
|
||||||
{JSON.stringify(searchStats, null, 2)}
|
|
||||||
</CodeSnippet>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{
|
|
||||||
fileMatches.length > 0 ? (
|
|
||||||
<p className="text-sm font-medium">{`[${searchDurationMs} ms] Found ${numMatches} matches in ${fileMatches.length} ${fileMatches.length > 1 ? 'files' : 'file'}`}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-medium">No results</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{isMoreResultsButtonVisible && (
|
|
||||||
<div
|
|
||||||
className="cursor-pointer text-blue-500 text-sm hover:underline ml-4"
|
|
||||||
onClick={onLoadMoreResults}
|
|
||||||
>
|
|
||||||
(load more)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{filteredFileMatches.length > 0 ? (
|
|
||||||
<SearchResultsPanel
|
|
||||||
fileMatches={filteredFileMatches}
|
|
||||||
onOpenFilePreview={(fileMatch, matchIndex) => {
|
|
||||||
setSelectedMatchIndex(matchIndex ?? 0);
|
|
||||||
setPreviewedFile(fileMatch);
|
|
||||||
}}
|
|
||||||
isLoadMoreButtonVisible={!!isMoreResultsButtonVisible}
|
|
||||||
onLoadMoreButtonClicked={onLoadMoreResults}
|
|
||||||
isBranchFilteringEnabled={isBranchFilteringEnabled}
|
|
||||||
repoInfo={repoInfo}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
|
||||||
<p className="text-sm text-muted-foreground">No results found</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
{previewedFile && (
|
|
||||||
<>
|
|
||||||
<AnimatedResizableHandle />
|
|
||||||
{/* ~~ Code preview ~~ */}
|
|
||||||
<ResizablePanel
|
|
||||||
minSize={10}
|
|
||||||
collapsible={true}
|
|
||||||
id={'code-preview-panel'}
|
|
||||||
order={3}
|
|
||||||
onCollapse={() => setPreviewedFile(undefined)}
|
|
||||||
>
|
|
||||||
<CodePreviewPanel
|
|
||||||
previewedFile={previewedFile}
|
|
||||||
onClose={() => setPreviewedFile(undefined)}
|
|
||||||
selectedMatchIndex={selectedMatchIndex}
|
|
||||||
onSelectedMatchIndexChange={setSelectedMatchIndex}
|
|
||||||
/>
|
|
||||||
</ResizablePanel>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions";
|
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions";
|
||||||
import { createAgentStream } from "@/features/chat/agent";
|
import { createAgentStream } from "@/features/chat/agent";
|
||||||
import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
|
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
|
||||||
import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils";
|
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
@ -49,7 +49,11 @@ export async function POST(req: Request) {
|
||||||
return serviceErrorResponse(schemaValidationError(parsed.error));
|
return serviceErrorResponse(schemaValidationError(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, id, selectedSearchScopes, languageModelId } = parsed.data;
|
const { messages, id, selectedSearchScopes, languageModel: _languageModel } = parsed.data;
|
||||||
|
// @note: a bit of type massaging is required here since the
|
||||||
|
// zod schema does not enum on `model` or `provider`.
|
||||||
|
// @see: chat/types.ts
|
||||||
|
const languageModel = _languageModel as LanguageModelInfo;
|
||||||
|
|
||||||
const response = await sew(() =>
|
const response = await sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
|
|
@ -78,13 +82,13 @@ export async function POST(req: Request) {
|
||||||
// corresponding config in `config.json`.
|
// corresponding config in `config.json`.
|
||||||
const languageModelConfig =
|
const languageModelConfig =
|
||||||
(await _getConfiguredLanguageModelsFull())
|
(await _getConfiguredLanguageModelsFull())
|
||||||
.find((model) => model.model === languageModelId);
|
.find((model) => getLanguageModelKey(model) === getLanguageModelKey(languageModel));
|
||||||
|
|
||||||
if (!languageModelConfig) {
|
if (!languageModelConfig) {
|
||||||
return serviceErrorResponse({
|
return serviceErrorResponse({
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
message: `Language model ${languageModelId} is not configured.`,
|
message: `Language model ${languageModel.model} is not configured.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@
|
||||||
--chat-citation-border: hsl(217, 91%, 60%);
|
--chat-citation-border: hsl(217, 91%, 60%);
|
||||||
|
|
||||||
--warning: #ca8a04;
|
--warning: #ca8a04;
|
||||||
|
--error: #fc5c5c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|
@ -201,6 +202,7 @@
|
||||||
--chat-citation-border: hsl(217, 91%, 60%);
|
--chat-citation-border: hsl(217, 91%, 60%);
|
||||||
|
|
||||||
--warning: #fde047;
|
--warning: #fde047;
|
||||||
|
--error: #f87171;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function DataTable<TData, TValue>({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between py-4">
|
<div className="flex items-center py-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||||
|
|
@ -85,7 +85,10 @@ export function DataTable<TData, TValue>({
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead key={header.id}>
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
style={{ width: `${header.getSize()}px` }}
|
||||||
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
|
|
@ -106,7 +109,10 @@ export function DataTable<TData, TValue>({
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{ width: `${cell.column.getSize()}px` }}
|
||||||
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||||
|
|
||||||
const navigationMenuTriggerStyle = cva(
|
const navigationMenuTriggerStyle = cva(
|
||||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
"group inline-flex h-8 w-max items-center justify-center rounded-md bg-background px-1.5 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||||
)
|
)
|
||||||
|
|
||||||
const NavigationMenuTrigger = React.forwardRef<
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/useBrowseNavigation";
|
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||||
import { PathHeader } from "@/app/[domain]/components/pathHeader";
|
import { PathHeader } from "@/app/[domain]/components/pathHeader";
|
||||||
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||||
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
|
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,6 @@ export const env = createEnv({
|
||||||
|
|
||||||
// Misc
|
// Misc
|
||||||
NEXT_PUBLIC_SOURCEBOT_VERSION: z.string().default('unknown'),
|
NEXT_PUBLIC_SOURCEBOT_VERSION: z.string().default('unknown'),
|
||||||
NEXT_PUBLIC_POLLING_INTERVAL_MS: numberSchema.default(5000),
|
|
||||||
|
|
||||||
NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: z.enum(SOURCEBOT_CLOUD_ENVIRONMENT).optional(),
|
NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: z.enum(SOURCEBOT_CLOUD_ENVIRONMENT).optional(),
|
||||||
|
|
||||||
|
|
@ -157,7 +156,6 @@ export const env = createEnv({
|
||||||
experimental__runtimeEnv: {
|
experimental__runtimeEnv: {
|
||||||
NEXT_PUBLIC_POSTHOG_PAPIK: process.env.NEXT_PUBLIC_POSTHOG_PAPIK,
|
NEXT_PUBLIC_POSTHOG_PAPIK: process.env.NEXT_PUBLIC_POSTHOG_PAPIK,
|
||||||
NEXT_PUBLIC_SOURCEBOT_VERSION: process.env.NEXT_PUBLIC_SOURCEBOT_VERSION,
|
NEXT_PUBLIC_SOURCEBOT_VERSION: process.env.NEXT_PUBLIC_SOURCEBOT_VERSION,
|
||||||
NEXT_PUBLIC_POLLING_INTERVAL_MS: process.env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
|
||||||
NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT,
|
NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT,
|
||||||
NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY: process.env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
|
NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY: process.env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
|
||||||
NEXT_PUBLIC_LANGFUSE_BASE_URL: process.env.NEXT_PUBLIC_LANGFUSE_BASE_URL,
|
NEXT_PUBLIC_LANGFUSE_BASE_URL: process.env.NEXT_PUBLIC_LANGFUSE_BASE_URL,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { AtSignIcon } from "lucide-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { ReactEditor, useSlate } from "slate-react";
|
||||||
|
import { AtMentionInfoCard } from "./atMentionInfoCard";
|
||||||
|
|
||||||
|
// @note: we have this as a seperate component to avoid having to re-render the
|
||||||
|
// entire toolbar whenever the user types (since we are using the useSlate hook
|
||||||
|
// here).
|
||||||
|
export const AtMentionButton = () => {
|
||||||
|
const editor = useSlate();
|
||||||
|
|
||||||
|
const onAddContext = useCallback(() => {
|
||||||
|
editor.insertText("@");
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-6 h-6 text-muted-foreground hover:text-primary"
|
||||||
|
onClick={onAddContext}
|
||||||
|
>
|
||||||
|
<AtSignIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
||||||
|
<AtMentionInfoCard />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ interface ChatBoxProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
isRedirecting?: boolean;
|
isRedirecting?: boolean;
|
||||||
isGenerating?: boolean;
|
isGenerating?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
languageModels: LanguageModelInfo[];
|
languageModels: LanguageModelInfo[];
|
||||||
selectedSearchScopes: SearchScope[];
|
selectedSearchScopes: SearchScope[];
|
||||||
searchContexts: SearchContextQuery[];
|
searchContexts: SearchContextQuery[];
|
||||||
|
|
@ -40,6 +41,7 @@ export const ChatBox = ({
|
||||||
className,
|
className,
|
||||||
isRedirecting,
|
isRedirecting,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
|
isDisabled,
|
||||||
languageModels,
|
languageModels,
|
||||||
selectedSearchScopes,
|
selectedSearchScopes,
|
||||||
searchContexts,
|
searchContexts,
|
||||||
|
|
@ -68,7 +70,7 @@ export const ChatBox = ({
|
||||||
}).flat(),
|
}).flat(),
|
||||||
});
|
});
|
||||||
const { selectedLanguageModel } = useSelectedLanguageModel({
|
const { selectedLanguageModel } = useSelectedLanguageModel({
|
||||||
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
|
languageModels,
|
||||||
});
|
});
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|
@ -167,6 +169,13 @@ export const ChatBox = ({
|
||||||
onContextSelectorOpenChanged(true);
|
onContextSelectorOpenChanged(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSubmitDisabledReason === "no-language-model-selected") {
|
||||||
|
toast({
|
||||||
|
description: "⚠️ You must select a language model",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +296,7 @@ export const ChatBox = ({
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
renderLeaf={renderLeaf}
|
renderLeaf={renderLeaf}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
readOnly={isDisabled}
|
||||||
/>
|
/>
|
||||||
<div className="ml-auto z-10">
|
<div className="ml-auto z-10">
|
||||||
{isRedirecting ? (
|
{isRedirecting ? (
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
import { LanguageModelInfo, SearchScope } from "@/features/chat/types";
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||||
import { AtSignIcon } from "lucide-react";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { ReactEditor, useSlate } from "slate-react";
|
|
||||||
import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
|
import { useSelectedLanguageModel } from "../../useSelectedLanguageModel";
|
||||||
|
import { AtMentionButton } from "./atMentionButton";
|
||||||
import { LanguageModelSelector } from "./languageModelSelector";
|
import { LanguageModelSelector } from "./languageModelSelector";
|
||||||
import { SearchScopeSelector } from "./searchScopeSelector";
|
import { SearchScopeSelector } from "./searchScopeSelector";
|
||||||
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
|
||||||
import { AtMentionInfoCard } from "@/features/chat/components/chatBox/atMentionInfoCard";
|
|
||||||
|
|
||||||
export interface ChatBoxToolbarProps {
|
export interface ChatBoxToolbarProps {
|
||||||
languageModels: LanguageModelInfo[];
|
languageModels: LanguageModelInfo[];
|
||||||
|
|
@ -33,67 +27,29 @@ export const ChatBoxToolbar = ({
|
||||||
isContextSelectorOpen,
|
isContextSelectorOpen,
|
||||||
onContextSelectorOpenChanged,
|
onContextSelectorOpenChanged,
|
||||||
}: ChatBoxToolbarProps) => {
|
}: ChatBoxToolbarProps) => {
|
||||||
const editor = useSlate();
|
|
||||||
|
|
||||||
const onAddContext = useCallback(() => {
|
|
||||||
editor.insertText("@");
|
|
||||||
ReactEditor.focus(editor);
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
|
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
|
||||||
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
|
languageModels,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<AtMentionButton />
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="w-6 h-6 text-muted-foreground hover:text-primary"
|
|
||||||
onClick={onAddContext}
|
|
||||||
>
|
|
||||||
<AtSignIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
|
||||||
<AtMentionInfoCard />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Separator orientation="vertical" className="h-3 mx-1" />
|
<Separator orientation="vertical" className="h-3 mx-1" />
|
||||||
<Tooltip>
|
<SearchScopeSelector
|
||||||
<TooltipTrigger asChild>
|
className="bg-inherit w-fit h-6 min-h-6"
|
||||||
<SearchScopeSelector
|
repos={repos}
|
||||||
className="bg-inherit w-fit h-6 min-h-6"
|
searchContexts={searchContexts}
|
||||||
repos={repos}
|
selectedSearchScopes={selectedSearchScopes}
|
||||||
searchContexts={searchContexts}
|
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
isOpen={isContextSelectorOpen}
|
||||||
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
|
onOpenChanged={onContextSelectorOpenChanged}
|
||||||
isOpen={isContextSelectorOpen}
|
/>
|
||||||
onOpenChanged={onContextSelectorOpenChanged}
|
<Separator orientation="vertical" className="h-3 ml-1 mr-2" />
|
||||||
/>
|
<LanguageModelSelector
|
||||||
</TooltipTrigger>
|
languageModels={languageModels}
|
||||||
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
onSelectedModelChange={setSelectedLanguageModel}
|
||||||
<SearchScopeInfoCard />
|
selectedModel={selectedLanguageModel}
|
||||||
</TooltipContent>
|
/>
|
||||||
</Tooltip>
|
|
||||||
{languageModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator orientation="vertical" className="h-3 ml-1 mr-2" />
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<LanguageModelSelector
|
|
||||||
languageModels={languageModels}
|
|
||||||
onSelectedModelChange={setSelectedLanguageModel}
|
|
||||||
selectedModel={selectedLanguageModel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { BotIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const LanguageModelInfoCard = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-popover border border-border rounded-lg shadow-lg p-4 w-80 max-w-[90vw]">
|
||||||
|
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/50">
|
||||||
|
<BotIcon className="h-4 w-4 text-primary" />
|
||||||
|
<h4 className="text-sm font-semibold text-popover-foreground">Language Model</h4>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-popover-foreground leading-relaxed">
|
||||||
|
Select the language model to use for the chat. <Link href="https://docs.sourcebot.dev/docs/configuration/language-model-providers" target="_blank" className="text-link">Configuration docs.</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -23,6 +23,9 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ModelProviderLogo } from "./modelProviderLogo";
|
import { ModelProviderLogo } from "./modelProviderLogo";
|
||||||
|
import { getLanguageModelKey } from "../../utils";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { LanguageModelInfoCard } from "./languageModelInfoCard";
|
||||||
|
|
||||||
interface LanguageModelSelectorProps {
|
interface LanguageModelSelectorProps {
|
||||||
languageModels: LanguageModelInfo[];
|
languageModels: LanguageModelInfo[];
|
||||||
|
|
@ -59,7 +62,7 @@ export const LanguageModelSelector = ({
|
||||||
// De-duplicate models
|
// De-duplicate models
|
||||||
const languageModels = useMemo(() => {
|
const languageModels = useMemo(() => {
|
||||||
return _languageModels.filter((model, selfIndex, selfArray) =>
|
return _languageModels.filter((model, selfIndex, selfArray) =>
|
||||||
selfIndex === selfArray.findIndex((t) => t.model === model.model)
|
selfIndex === selfArray.findIndex((t) => getLanguageModelKey(t) === getLanguageModelKey(model))
|
||||||
);
|
);
|
||||||
}, [_languageModels]);
|
}, [_languageModels]);
|
||||||
|
|
||||||
|
|
@ -68,81 +71,89 @@ export const LanguageModelSelector = ({
|
||||||
open={isPopoverOpen}
|
open={isPopoverOpen}
|
||||||
onOpenChange={setIsPopoverOpen}
|
onOpenChange={setIsPopoverOpen}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
onClick={handleTogglePopover}
|
<TooltipTrigger asChild>
|
||||||
className={cn(
|
<Button
|
||||||
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
onClick={handleTogglePopover}
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mx-auto max-w-64 overflow-hidden">
|
|
||||||
{selectedModel ? (
|
|
||||||
<ModelProviderLogo
|
|
||||||
provider={selectedModel.provider}
|
|
||||||
className="mr-1"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Bot className="h-4 w-4 text-muted-foreground mr-1" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap",
|
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
||||||
selectedModel ? "font-medium" : "font-normal"
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
|
<div className="flex items-center justify-between mx-auto max-w-64 overflow-hidden">
|
||||||
</span>
|
{selectedModel ? (
|
||||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
<ModelProviderLogo
|
||||||
</div>
|
provider={selectedModel.provider}
|
||||||
</Button>
|
className="mr-1"
|
||||||
</PopoverTrigger>
|
/>
|
||||||
<PopoverContent
|
) : (
|
||||||
className="w-auto p-0"
|
<Bot className="h-4 w-4 text-muted-foreground mr-1" />
|
||||||
align="start"
|
)}
|
||||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
<span
|
||||||
>
|
className={cn(
|
||||||
<Command>
|
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap font-medium",
|
||||||
<CommandInput
|
)}
|
||||||
placeholder="Search models..."
|
>
|
||||||
onKeyDown={handleInputKeyDown}
|
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
|
||||||
/>
|
</span>
|
||||||
<CommandList>
|
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||||
<CommandEmpty>No models found.</CommandEmpty>
|
</div>
|
||||||
<CommandGroup>
|
</Button>
|
||||||
{languageModels
|
</TooltipTrigger>
|
||||||
.map((model, index) => {
|
</PopoverTrigger>
|
||||||
const isSelected = selectedModel?.model === model.model;
|
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
||||||
return (
|
<LanguageModelInfoCard />
|
||||||
<CommandItem
|
</TooltipContent>
|
||||||
key={`${model.model}-${index}`}
|
<PopoverContent
|
||||||
onSelect={() => {
|
className="w-auto p-0"
|
||||||
selectModel(model)
|
align="start"
|
||||||
}}
|
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||||
className="cursor-pointer"
|
>
|
||||||
>
|
<Command>
|
||||||
<div
|
<CommandInput
|
||||||
className={cn(
|
placeholder="Search models..."
|
||||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
onKeyDown={handleInputKeyDown}
|
||||||
isSelected
|
/>
|
||||||
? "bg-primary text-primary-foreground"
|
<CommandList>
|
||||||
: "opacity-50 [&_svg]:invisible"
|
<CommandEmpty>
|
||||||
)}
|
<p>No models found.</p>
|
||||||
>
|
</CommandEmpty>
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CommandGroup>
|
||||||
</div>
|
{languageModels
|
||||||
<ModelProviderLogo
|
.map((model) => {
|
||||||
provider={model.provider}
|
const isSelected = selectedModel && getLanguageModelKey(selectedModel) === getLanguageModelKey(model);
|
||||||
className="mr-2"
|
return (
|
||||||
/>
|
<CommandItem
|
||||||
<span>{model.displayName ?? model.model}</span>
|
key={getLanguageModelKey(model)}
|
||||||
</CommandItem>
|
onSelect={() => {
|
||||||
);
|
selectModel(model)
|
||||||
})}
|
}}
|
||||||
</CommandGroup>
|
className="cursor-pointer"
|
||||||
</CommandList>
|
>
|
||||||
</Command>
|
<div
|
||||||
</PopoverContent>
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<ModelProviderLogo
|
||||||
|
provider={model.provider}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span>{model.displayName ?? model.model}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Tooltip>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,29 @@
|
||||||
// Adapted from: web/src/components/ui/multi-select.tsx
|
// Adapted from: web/src/components/ui/multi-select.tsx
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
ChevronDown,
|
|
||||||
ScanSearchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Command,
|
CheckIcon,
|
||||||
CommandEmpty,
|
ChevronDown,
|
||||||
CommandGroup,
|
ScanSearchIcon,
|
||||||
CommandInput,
|
} from "lucide-react";
|
||||||
CommandItem,
|
import { ButtonHTMLAttributes, forwardRef, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
CommandList,
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
CommandSeparator,
|
import { RepoSearchScope, RepoSetSearchScope, SearchScope } from "../../types";
|
||||||
} from "@/components/ui/command";
|
|
||||||
import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types";
|
|
||||||
import { SearchScopeIcon } from "../searchScopeIcon";
|
import { SearchScopeIcon } from "../searchScopeIcon";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { SearchScopeInfoCard } from "./searchScopeInfoCard";
|
||||||
|
|
||||||
interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface SearchScopeSelectorProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
repos: RepositoryQuery[];
|
repos: RepositoryQuery[];
|
||||||
searchContexts: SearchContextQuery[];
|
searchContexts: SearchContextQuery[];
|
||||||
selectedSearchScopes: SearchScope[];
|
selectedSearchScopes: SearchScope[];
|
||||||
|
|
@ -38,7 +33,7 @@ interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButton
|
||||||
onOpenChanged: (isOpen: boolean) => void;
|
onOpenChanged: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchScopeSelector = React.forwardRef<
|
export const SearchScopeSelector = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
SearchScopeSelectorProps
|
SearchScopeSelectorProps
|
||||||
>(
|
>(
|
||||||
|
|
@ -55,23 +50,13 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollPosition = React.useRef<number>(0);
|
const scrollPosition = useRef<number>(0);
|
||||||
const [hasSearchInput, setHasSearchInput] = React.useState(false);
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
|
||||||
|
|
||||||
const handleInputKeyDown = (
|
const toggleItem = useCallback((item: SearchScope) => {
|
||||||
event: React.KeyboardEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
onOpenChanged(true);
|
|
||||||
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
|
||||||
const newSelectedItems = [...selectedSearchScopes];
|
|
||||||
newSelectedItems.pop();
|
|
||||||
onSelectedSearchScopesChange(newSelectedItems);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleItem = (item: SearchScope) => {
|
|
||||||
// Store current scroll position before state update
|
// Store current scroll position before state update
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
scrollPosition.current = scrollContainerRef.current.scrollTop;
|
scrollPosition.current = scrollContainerRef.current.scrollTop;
|
||||||
|
|
@ -88,21 +73,9 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
[...selectedSearchScopes, item];
|
[...selectedSearchScopes, item];
|
||||||
|
|
||||||
onSelectedSearchScopesChange(newSelectedItems);
|
onSelectedSearchScopesChange(newSelectedItems);
|
||||||
};
|
}, [selectedSearchScopes, onSelectedSearchScopesChange]);
|
||||||
|
|
||||||
const handleClear = () => {
|
const allSearchScopeItems = useMemo(() => {
|
||||||
onSelectedSearchScopesChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
onSelectedSearchScopesChange(allSearchScopeItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTogglePopover = () => {
|
|
||||||
onOpenChanged(!isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allSearchScopeItems = React.useMemo(() => {
|
|
||||||
const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
|
const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
|
||||||
type: 'reposet' as const,
|
type: 'reposet' as const,
|
||||||
value: context.name,
|
value: context.name,
|
||||||
|
|
@ -120,8 +93,40 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
|
return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
|
||||||
}, [repos, searchContexts]);
|
}, [repos, searchContexts]);
|
||||||
|
|
||||||
const sortedSearchScopeItems = React.useMemo(() => {
|
const handleClear = useCallback(() => {
|
||||||
|
onSelectedSearchScopesChange([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [onSelectedSearchScopesChange]);
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
onSelectedSearchScopesChange(allSearchScopeItems);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [onSelectedSearchScopesChange, allSearchScopeItems]);
|
||||||
|
|
||||||
|
const handleTogglePopover = useCallback(() => {
|
||||||
|
onOpenChanged(!isOpen);
|
||||||
|
}, [onOpenChanged, isOpen]);
|
||||||
|
|
||||||
|
const sortedSearchScopeItems = useMemo(() => {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
|
||||||
return allSearchScopeItems
|
return allSearchScopeItems
|
||||||
|
.filter((item) => {
|
||||||
|
// Filter by search query
|
||||||
|
if (query && !item.name.toLowerCase().includes(query) && !item.value.toLowerCase().includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
item,
|
item,
|
||||||
isSelected: selectedSearchScopes.some(
|
isSelected: selectedSearchScopes.some(
|
||||||
|
|
@ -137,10 +142,77 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
|
if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
}, [allSearchScopeItems, selectedSearchScopes]);
|
}, [allSearchScopeItems, selectedSearchScopes, searchQuery]);
|
||||||
|
|
||||||
|
const handleInputKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev < sortedSearchScopeItems.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => prev > 0 ? prev - 1 : 0);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (sortedSearchScopeItems.length > 0 && highlightedIndex >= 0) {
|
||||||
|
toggleItem(sortedSearchScopeItems[highlightedIndex].item);
|
||||||
|
}
|
||||||
|
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
||||||
|
const newSelectedItems = [...selectedSearchScopes];
|
||||||
|
newSelectedItems.pop();
|
||||||
|
onSelectedSearchScopesChange(newSelectedItems);
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, onSelectedSearchScopesChange, selectedSearchScopes, sortedSearchScopeItems, toggleItem]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: sortedSearchScopeItems.length,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
estimateSize: () => 36,
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset highlighted index and scroll to top when search query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Reset highlighted index when items change (but don't scroll)
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, [sortedSearchScopeItems.length]);
|
||||||
|
|
||||||
|
// Measure virtualizer when popover opens and container is mounted
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsMounted(true);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
// Give the DOM a tick to render before measuring
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
virtualizer.measure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsMounted(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, virtualizer]);
|
||||||
|
|
||||||
|
// Scroll highlighted item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMounted && highlightedIndex >= 0) {
|
||||||
|
virtualizer.scrollToIndex(highlightedIndex, {
|
||||||
|
align: 'auto',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, isMounted, virtualizer]);
|
||||||
|
|
||||||
// Restore scroll position after re-render
|
// Restore scroll position after re-render
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollContainerRef.current && scrollPosition.current > 0) {
|
if (scrollContainerRef.current && scrollPosition.current > 0) {
|
||||||
scrollContainerRef.current.scrollTop = scrollPosition.current;
|
scrollContainerRef.current.scrollTop = scrollPosition.current;
|
||||||
}
|
}
|
||||||
|
|
@ -151,106 +223,142 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={onOpenChanged}
|
onOpenChange={onOpenChanged}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
ref={ref}
|
<TooltipTrigger asChild>
|
||||||
{...props}
|
<Button
|
||||||
onClick={handleTogglePopover}
|
ref={ref}
|
||||||
className={cn(
|
{...props}
|
||||||
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
onClick={handleTogglePopover}
|
||||||
className
|
className={cn(
|
||||||
)}
|
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
||||||
>
|
className
|
||||||
<div className="flex items-center justify-between w-full mx-auto">
|
)}
|
||||||
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
|
||||||
<span
|
|
||||||
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
|
||||||
>
|
>
|
||||||
{
|
<div className="flex items-center justify-between w-full mx-auto">
|
||||||
selectedSearchScopes.length === 0 ? `Search scopes` :
|
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
||||||
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
<span
|
||||||
`${selectedSearchScopes.length} selected`
|
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-auto p-0"
|
|
||||||
align="start"
|
|
||||||
onEscapeKeyDown={() => onOpenChanged(false)}
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search scopes..."
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
onValueChange={(value) => setHasSearchInput(!!value)}
|
|
||||||
/>
|
|
||||||
<CommandList ref={scrollContainerRef}>
|
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{!hasSearchInput && (
|
|
||||||
<div
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
className="flex items-center px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
|
||||||
>
|
>
|
||||||
<span className="text-xs">Select all</span>
|
{
|
||||||
|
selectedSearchScopes.length === 0 ? `Search scopes` :
|
||||||
|
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
||||||
|
`${selectedSearchScopes.length} selected`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
||||||
|
<SearchScopeInfoCard />
|
||||||
|
</TooltipContent>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[400px] p-0"
|
||||||
|
align="start"
|
||||||
|
onEscapeKeyDown={() => onOpenChanged(false)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search scopes..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="max-h-[300px] overflow-auto"
|
||||||
|
>
|
||||||
|
{sortedSearchScopeItems.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1">
|
||||||
|
{!searchQuery && (
|
||||||
|
<div
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="flex items-center px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors rounded-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="text-xs">Select all</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMounted && virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const { item, isSelected } = sortedSearchScopeItems[virtualItem.index];
|
||||||
|
const isHighlighted = virtualItem.index === highlightedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.type}-${item.value}`}
|
||||||
|
onClick={() => toggleItem(item)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(virtualItem.index)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer absolute top-0 left-0 w-full flex items-center px-2 py-1.5 text-sm rounded-sm",
|
||||||
|
isHighlighted ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<SearchScopeIcon searchScope={item} />
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{item.type === 'reposet' && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sortedSearchScopeItems.map(({ item, isSelected }) => {
|
</div>
|
||||||
return (
|
{selectedSearchScopes.length > 0 && (
|
||||||
<CommandItem
|
<>
|
||||||
key={`${item.type}-${item.value}`}
|
<Separator />
|
||||||
onSelect={() => toggleItem(item)}
|
<div
|
||||||
className="cursor-pointer"
|
onClick={handleClear}
|
||||||
>
|
className="flex items-center justify-center px-2 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
<div
|
>
|
||||||
className={cn(
|
Clear
|
||||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
</div>
|
||||||
isSelected
|
</>
|
||||||
? "bg-primary text-primary-foreground"
|
)}
|
||||||
: "opacity-50 [&_svg]:invisible"
|
</div>
|
||||||
)}
|
</PopoverContent>
|
||||||
>
|
</Tooltip>
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<SearchScopeIcon searchScope={item} />
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
{item.type === 'reposet' && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
|
|
||||||
>
|
|
||||||
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
{selectedSearchScopes.length > 0 && (
|
|
||||||
<>
|
|
||||||
<CommandSeparator />
|
|
||||||
<CommandItem
|
|
||||||
onSelect={handleClear}
|
|
||||||
className="flex-1 justify-center cursor-pointer"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</CommandItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { usePrevious } from '@uidotdev/usehooks';
|
||||||
import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
|
import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
|
||||||
import { generateAndUpdateChatNameFromMessage } from '../../actions';
|
import { generateAndUpdateChatNameFromMessage } from '../../actions';
|
||||||
import { isServiceError } from '@/lib/utils';
|
import { isServiceError } from '@/lib/utils';
|
||||||
|
import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner';
|
||||||
|
|
||||||
type ChatHistoryState = {
|
type ChatHistoryState = {
|
||||||
scrollOffset?: number;
|
scrollOffset?: number;
|
||||||
|
|
@ -73,7 +74,7 @@ export const ChatThread = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const { selectedLanguageModel } = useSelectedLanguageModel({
|
const { selectedLanguageModel } = useSelectedLanguageModel({
|
||||||
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
|
languageModels,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -118,7 +119,7 @@ export const ChatThread = ({
|
||||||
_sendMessage(message, {
|
_sendMessage(message, {
|
||||||
body: {
|
body: {
|
||||||
selectedSearchScopes,
|
selectedSearchScopes,
|
||||||
languageModelId: selectedLanguageModel.model,
|
languageModel: selectedLanguageModel,
|
||||||
} satisfies AdditionalChatRequestParams,
|
} satisfies AdditionalChatRequestParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -355,31 +356,38 @@ export const ChatThread = ({
|
||||||
}
|
}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
{!isChatReadonly && (
|
{!isChatReadonly && (
|
||||||
<div className="border rounded-md w-full max-w-3xl mx-auto mb-8 shadow-sm">
|
<div className="w-full max-w-3xl mx-auto mb-8">
|
||||||
<CustomSlateEditor>
|
{languageModels.length === 0 && (
|
||||||
<ChatBox
|
<NotConfiguredErrorBanner className="mb-2" />
|
||||||
onSubmit={onSubmit}
|
)}
|
||||||
className="min-h-[80px]"
|
|
||||||
preferredSuggestionsBoxPlacement="top-start"
|
<div className="border rounded-md w-full shadow-sm">
|
||||||
isGenerating={status === "streaming" || status === "submitted"}
|
<CustomSlateEditor>
|
||||||
onStop={stop}
|
<ChatBox
|
||||||
languageModels={languageModels}
|
onSubmit={onSubmit}
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
className="min-h-[80px]"
|
||||||
searchContexts={searchContexts}
|
preferredSuggestionsBoxPlacement="top-start"
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
isGenerating={status === "streaming" || status === "submitted"}
|
||||||
/>
|
onStop={stop}
|
||||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
|
||||||
<ChatBoxToolbar
|
|
||||||
languageModels={languageModels}
|
languageModels={languageModels}
|
||||||
repos={repos}
|
|
||||||
searchContexts={searchContexts}
|
|
||||||
selectedSearchScopes={selectedSearchScopes}
|
selectedSearchScopes={selectedSearchScopes}
|
||||||
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
|
searchContexts={searchContexts}
|
||||||
isContextSelectorOpen={isContextSelectorOpen}
|
|
||||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||||
|
isDisabled={languageModels.length === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||||
</CustomSlateEditor>
|
<ChatBoxToolbar
|
||||||
|
languageModels={languageModels}
|
||||||
|
repos={repos}
|
||||||
|
searchContexts={searchContexts}
|
||||||
|
selectedSearchScopes={selectedSearchScopes}
|
||||||
|
onSelectedSearchScopesChange={onSelectedSearchScopesChange}
|
||||||
|
isContextSelectorOpen={isContextSelectorOpen}
|
||||||
|
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomSlateEditor>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
|
||||||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getBrowsePath } from '@/app/[domain]/browse/hooks/useBrowseNavigation';
|
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||||
|
|
||||||
|
|
||||||
export const FileListItem = ({
|
export const FileListItem = ({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { TriangleAlertIcon } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DOCS_URL = "https://docs.sourcebot.dev/docs/configuration/language-model-providers";
|
||||||
|
|
||||||
|
interface NotConfiguredErrorBannerProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotConfiguredErrorBanner = ({ className }: NotConfiguredErrorBannerProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-row items-center bg-error rounded-md p-2", className)}>
|
||||||
|
<TriangleAlertIcon className="h-4 w-4 text-accent mr-1.5" />
|
||||||
|
<span className="text-sm font-medium text-accent"><span className="font-bold">Ask unavailable:</span> no language model configured. See the <Link href={DOCS_URL} target="_blank" className="underline">configuration docs</Link> for more information.</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue