mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
merge main
This commit is contained in:
commit
b0dc257b2a
111 changed files with 2923 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
|
||||
# INDEX_CONCURRENCY_MULTIPLE=
|
||||
|
||||
# Controls the polling interval for the web app
|
||||
# NEXT_PUBLIC_POLLING_INTERVAL_MS=
|
||||
|
||||
# Controls the version of the web app
|
||||
# NEXT_PUBLIC_SOURCEBOT_VERSION=
|
||||
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -14,10 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Fixed
|
||||
- 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)
|
||||
- Fixed `repoIndexTimeoutMs` not being used for index job timeouts. [#567](https://github.com/sourcebot-dev/sourcebot/pull/567)
|
||||
|
||||
### 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 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 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": {
|
||||
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build",
|
||||
"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 --",
|
||||
"dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"npm-run-all": "^4.1.5"
|
||||
"dotenv-cli": "^8.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0",
|
||||
"resolutions": {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
"git-url-parse": "^16.1.0",
|
||||
"gitea-js": "^1.22.0",
|
||||
"glob": "^11.0.0",
|
||||
"groupmq": "^1.0.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"lowdb": "^7.0.1",
|
||||
"micromatch": "^4.0.8",
|
||||
|
|
|
|||
|
|
@ -364,12 +364,12 @@ export class ConnectionManager {
|
|||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
public async dispose() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.worker.close();
|
||||
this.queue.close();
|
||||
await this.worker.close();
|
||||
await this.queue.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { env } from "./env.js";
|
||||
import { Settings } from "./types.js";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Default settings.
|
||||
|
|
@ -23,3 +25,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
||||
'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');
|
||||
|
|
@ -100,12 +100,12 @@ export class RepoPermissionSyncer {
|
|||
}, 1000 * 5);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
public async dispose() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.worker.close();
|
||||
this.queue.close();
|
||||
await this.worker.close();
|
||||
await this.queue.close();
|
||||
}
|
||||
|
||||
private async schedulePermissionSync(repos: Repo[]) {
|
||||
|
|
|
|||
|
|
@ -101,12 +101,12 @@ export class UserPermissionSyncer {
|
|||
}, 1000 * 5);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
public async dispose() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.worker.close();
|
||||
this.queue.close();
|
||||
await this.worker.close();
|
||||
await this.queue.close();
|
||||
}
|
||||
|
||||
private async schedulePermissionSync(users: User[]) {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const env = createEnv({
|
|||
LOGTAIL_TOKEN: z.string().optional(),
|
||||
LOGTAIL_HOST: z.string().url().optional(),
|
||||
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"),
|
||||
CONFIG_PATH: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -1,30 +1,67 @@
|
|||
import { CheckRepoActions, GitConfigScope, simpleGit, SimpleGitProgressEvent } from 'simple-git';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { env } from './env.js';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
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 (
|
||||
{
|
||||
cloneUrl,
|
||||
authHeader,
|
||||
path,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
cloneUrl: string,
|
||||
authHeader?: string,
|
||||
path: string,
|
||||
onProgress?: onProgressFn
|
||||
signal?: AbortSignal
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
await mkdir(path, { recursive: true });
|
||||
|
||||
const git = simpleGit({
|
||||
progress: onProgress,
|
||||
}).cwd({
|
||||
path,
|
||||
})
|
||||
const git = createGitClientForPath(path, onProgress, signal);
|
||||
|
||||
const cloneArgs = [
|
||||
"--bare",
|
||||
|
|
@ -33,7 +70,11 @@ export const cloneRepository = async (
|
|||
|
||||
await git.clone(cloneUrl, path, cloneArgs);
|
||||
|
||||
await unsetGitConfig(path, ["remote.origin.url"]);
|
||||
await unsetGitConfig({
|
||||
path,
|
||||
keys: ["remote.origin.url"],
|
||||
signal,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const baseLog = `Failed to clone repository: ${path}`;
|
||||
|
||||
|
|
@ -54,20 +95,17 @@ export const fetchRepository = async (
|
|||
authHeader,
|
||||
path,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
cloneUrl: string,
|
||||
authHeader?: string,
|
||||
path: string,
|
||||
onProgress?: onProgressFn
|
||||
onProgress?: onProgressFn,
|
||||
signal?: AbortSignal
|
||||
}
|
||||
) => {
|
||||
const git = createGitClientForPath(path, onProgress, signal);
|
||||
try {
|
||||
const git = simpleGit({
|
||||
progress: onProgress,
|
||||
}).cwd({
|
||||
path: path,
|
||||
})
|
||||
|
||||
if (authHeader) {
|
||||
await git.addConfig("http.extraHeader", authHeader);
|
||||
}
|
||||
|
|
@ -90,12 +128,6 @@ export const fetchRepository = async (
|
|||
}
|
||||
} finally {
|
||||
if (authHeader) {
|
||||
const git = simpleGit({
|
||||
progress: onProgress,
|
||||
}).cwd({
|
||||
path: path,
|
||||
})
|
||||
|
||||
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
|
||||
* present in gitConfig.
|
||||
*/
|
||||
export const upsertGitConfig = async (path: string, gitConfig: Record<string, string>, onProgress?: onProgressFn) => {
|
||||
const git = simpleGit({
|
||||
progress: onProgress,
|
||||
}).cwd(path);
|
||||
export const upsertGitConfig = async (
|
||||
{
|
||||
path,
|
||||
gitConfig,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
path: string,
|
||||
gitConfig: Record<string, string>,
|
||||
onProgress?: onProgressFn,
|
||||
signal?: AbortSignal
|
||||
}) => {
|
||||
const git = createGitClientForPath(path, onProgress, signal);
|
||||
|
||||
try {
|
||||
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.
|
||||
* If a key is not set, this is a no-op.
|
||||
*/
|
||||
export const unsetGitConfig = async (path: string, keys: string[], onProgress?: onProgressFn) => {
|
||||
const git = simpleGit({
|
||||
progress: onProgress,
|
||||
}).cwd(path);
|
||||
export const unsetGitConfig = async (
|
||||
{
|
||||
path,
|
||||
keys,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
path: string,
|
||||
keys: string[],
|
||||
onProgress?: onProgressFn,
|
||||
signal?: AbortSignal
|
||||
}) => {
|
||||
const git = createGitClientForPath(path, onProgress, signal);
|
||||
|
||||
try {
|
||||
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.
|
||||
*/
|
||||
export const isPathAValidGitRepoRoot = async (path: string, onProgress?: onProgressFn) => {
|
||||
const git = simpleGit({
|
||||
progress: onProgress,
|
||||
}).cwd(path);
|
||||
export const isPathAValidGitRepoRoot = async ({
|
||||
path,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
path: string,
|
||||
onProgress?: onProgressFn,
|
||||
signal?: AbortSignal
|
||||
}) => {
|
||||
if (!existsSync(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const git = createGitClientForPath(path, onProgress, signal);
|
||||
|
||||
try {
|
||||
return git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
|
||||
|
|
@ -184,7 +244,7 @@ export const isUrlAValidGitRepo = async (url: string) => {
|
|||
}
|
||||
|
||||
export const getOriginUrl = async (path: string) => {
|
||||
const git = simpleGit().cwd(path);
|
||||
const git = createGitClientForPath(path);
|
||||
|
||||
try {
|
||||
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) => {
|
||||
const git = simpleGit();
|
||||
const branches = await git.cwd({
|
||||
path,
|
||||
}).branch();
|
||||
|
||||
const git = createGitClientForPath(path);
|
||||
const branches = await git.branch();
|
||||
return branches.all;
|
||||
}
|
||||
|
||||
export const getTags = async (path: string) => {
|
||||
const git = simpleGit();
|
||||
const tags = await git.cwd({
|
||||
path,
|
||||
}).tags();
|
||||
const git = createGitClientForPath(path);
|
||||
const tags = await git.tags();
|
||||
return tags.all;
|
||||
}
|
||||
|
|
@ -6,16 +6,14 @@ import { hasEntitlement, loadConfig } from '@sourcebot/shared';
|
|||
import { existsSync } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { Redis } from 'ioredis';
|
||||
import path from 'path';
|
||||
import { ConnectionManager } from './connectionManager.js';
|
||||
import { DEFAULT_SETTINGS } from './constants.js';
|
||||
import { env } from "./env.js";
|
||||
import { DEFAULT_SETTINGS, INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.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 { GithubAppManager } from "./ee/githubAppManager.js";
|
||||
import { env } from "./env.js";
|
||||
import { RepoIndexManager } from "./repoIndexManager.js";
|
||||
import { PromClient } from './promClient.js';
|
||||
|
||||
|
||||
const logger = createLogger('backend-entrypoint');
|
||||
|
|
@ -34,9 +32,8 @@ const getSettings = async (configPath?: string) => {
|
|||
}
|
||||
|
||||
|
||||
const cacheDir = env.DATA_CACHE_DIR;
|
||||
const reposPath = path.join(cacheDir, 'repos');
|
||||
const indexPath = path.join(cacheDir, 'index');
|
||||
const reposPath = REPOS_CACHE_DIR;
|
||||
const indexPath = INDEX_CACHE_DIR;
|
||||
|
||||
if (!existsSync(reposPath)) {
|
||||
await mkdir(reposPath, { recursive: true });
|
||||
|
|
@ -45,12 +42,6 @@ if (!existsSync(indexPath)) {
|
|||
await mkdir(indexPath, { recursive: true });
|
||||
}
|
||||
|
||||
const context: AppContext = {
|
||||
indexPath,
|
||||
reposPath,
|
||||
cachePath: cacheDir,
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const redis = new Redis(env.REDIS_URL, {
|
||||
|
|
@ -74,14 +65,12 @@ if (hasEntitlement('github-app')) {
|
|||
}
|
||||
|
||||
const connectionManager = new ConnectionManager(prisma, settings, redis);
|
||||
const repoManager = new RepoManager(prisma, settings, redis, promClient, context);
|
||||
const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
|
||||
const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
|
||||
|
||||
await repoManager.validateIndexedReposHaveShards();
|
||||
const repoIndexManager = new RepoIndexManager(prisma, settings, redis);
|
||||
|
||||
connectionManager.startScheduler();
|
||||
repoManager.startScheduler();
|
||||
repoIndexManager.startScheduler();
|
||||
|
||||
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.');
|
||||
|
|
@ -93,12 +82,27 @@ else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement(
|
|||
}
|
||||
|
||||
const cleanup = async (signal: string) => {
|
||||
logger.info(`Recieved ${signal}, cleaning up...`);
|
||||
logger.info(`Received ${signal}, cleaning up...`);
|
||||
|
||||
connectionManager.dispose();
|
||||
repoManager.dispose();
|
||||
repoPermissionSyncer.dispose();
|
||||
userPermissionSyncer.dispose();
|
||||
const shutdownTimeout = 30000; // 30 seconds
|
||||
|
||||
try {
|
||||
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 redis.quit();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { Server } from 'http';
|
||||
import client, { Registry, Counter, Gauge } from 'prom-client';
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
|
||||
|
|
@ -7,6 +8,8 @@ const logger = createLogger('prometheus-client');
|
|||
export class PromClient {
|
||||
private registry: Registry;
|
||||
private app: express.Application;
|
||||
private server: Server;
|
||||
|
||||
public activeRepoIndexingJobs: Gauge<string>;
|
||||
public pendingRepoIndexingJobs: Gauge<string>;
|
||||
public repoIndexingReattemptsTotal: Counter<string>;
|
||||
|
|
@ -98,12 +101,17 @@ export class PromClient {
|
|||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
getRegistry(): Registry {
|
||||
return this.registry;
|
||||
async dispose() {
|
||||
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) => {
|
||||
const isGitRepo = await isPathAValidGitRepoRoot(repoPath);
|
||||
const isGitRepo = await isPathAValidGitRepoRoot({
|
||||
path: repoPath,
|
||||
});
|
||||
if (!isGitRepo) {
|
||||
logger.warn(`Skipping ${repoPath} - not a git repository.`);
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: this.settings.repoIndexTimeoutMs,
|
||||
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 timeoutDate = new Date(Date.now() - this.settings.repoIndexTimeoutMs);
|
||||
|
||||
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: timeoutDate,
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// Don't schedule if there are recent failed jobs (within the threshold date).
|
||||
{
|
||||
AND: [
|
||||
{ status: RepoIndexingJobStatus.FAILED },
|
||||
{ completedAt: { gt: timeoutDate } },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
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 { 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>;
|
||||
|
||||
// Structure of the `metadata` field in the `Repo` table.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Logger } from "winston";
|
||||
import { AppContext, RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
||||
import { RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
||||
import path from 'path';
|
||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
||||
|
|
@ -8,6 +8,7 @@ import * as Sentry from "@sentry/node";
|
|||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||
import { GithubAppManager } from "./ee/githubAppManager.js";
|
||||
import { hasEntitlement } from "@sourcebot/shared";
|
||||
import { REPOS_CACHE_DIR } from "./constants.js";
|
||||
|
||||
export const measure = async <T>(cb: () => Promise<T>) => {
|
||||
const start = Date.now();
|
||||
|
|
@ -71,7 +72,7 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
|
|||
|
||||
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
|
||||
// @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.
|
||||
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
|
||||
const cloneUrl = new URL(repo.cloneUrl);
|
||||
|
|
@ -83,7 +84,7 @@ export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isRead
|
|||
}
|
||||
|
||||
return {
|
||||
path: path.join(ctx.reposPath, repo.id.toString()),
|
||||
path: path.join(REPOS_CACHE_DIR, repo.id.toString()),
|
||||
isReadOnly: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -265,3 +266,20 @@ const createGitCloneUrlWithToken = (cloneUrl: string, credentials: { username?:
|
|||
}
|
||||
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 { 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 { 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 { repoMetadataSchema, Settings } from "./types.js";
|
||||
import { getRepoPath, getShardPrefix } from "./utils.js";
|
||||
|
||||
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 = [
|
||||
'HEAD'
|
||||
];
|
||||
|
||||
const { path: repoPath } = getRepoPath(repo, ctx);
|
||||
const { path: repoPath } = getRepoPath(repo);
|
||||
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
|
||||
const metadata = repoMetadataSchema.parse(repo.metadata);
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap
|
|||
const command = [
|
||||
'zoekt-git-index',
|
||||
'-allow_missing_branches',
|
||||
`-index ${ctx.indexPath}`,
|
||||
`-index ${INDEX_CACHE_DIR}`,
|
||||
`-max_trigram_count ${settings.maxTrigramCount}`,
|
||||
`-file_limit ${settings.maxFileSize}`,
|
||||
`-branches "${revisions.join(',')}"`,
|
||||
|
|
@ -71,7 +71,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap
|
|||
].join(' ');
|
||||
|
||||
return new Promise<{ stdout: string, stderr: string }>((resolve, reject) => {
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
exec(command, { signal }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -4,5 +4,8 @@ export default defineConfig({
|
|||
test: {
|
||||
environment: 'node',
|
||||
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")
|
||||
}
|
||||
|
||||
enum RepoIndexingStatus {
|
||||
NEW
|
||||
IN_INDEX_QUEUE
|
||||
INDEXING
|
||||
INDEXED
|
||||
FAILED
|
||||
IN_GC_QUEUE
|
||||
GARBAGE_COLLECTING
|
||||
GARBAGE_COLLECTION_FAILED
|
||||
}
|
||||
|
||||
enum ConnectionSyncStatus {
|
||||
SYNC_NEEDED
|
||||
IN_SYNC_QUEUE
|
||||
|
|
@ -46,7 +35,6 @@ model Repo {
|
|||
displayName String? /// Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||
isFork Boolean
|
||||
isArchived Boolean
|
||||
isPublic Boolean @default(false)
|
||||
|
|
@ -55,12 +43,14 @@ model Repo {
|
|||
webUrl String?
|
||||
connections RepoToConnection[]
|
||||
imageUrl String?
|
||||
repoIndexingStatus RepoIndexingStatus @default(NEW)
|
||||
|
||||
permittedUsers UserToRepoPermission[]
|
||||
permissionSyncJobs RepoPermissionSyncJob[]
|
||||
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_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)
|
||||
|
|
@ -74,6 +64,32 @@ model Repo {
|
|||
@@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 {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import winston, { format } from 'winston';
|
||||
import winston, { format, Logger } from 'winston';
|
||||
import { Logtail } from '@logtail/node';
|
||||
import { LogtailTransport } from '@logtail/winston';
|
||||
import { MESSAGE } from 'triple-beam';
|
||||
|
|
@ -48,7 +48,7 @@ const createLogger = (label: string) => {
|
|||
format: combine(
|
||||
errors({ stack: true }),
|
||||
timestamp(),
|
||||
labelFn({ label: label })
|
||||
labelFn({ label: label }),
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
|
|
@ -85,3 +85,7 @@ const createLogger = (label: string) => {
|
|||
export {
|
||||
createLogger
|
||||
};
|
||||
|
||||
export type {
|
||||
Logger,
|
||||
}
|
||||
|
|
@ -143,17 +143,6 @@ export const searchResponseSchema = z.object({
|
|||
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({
|
||||
codeHostType: z.string(),
|
||||
repoId: z.number(),
|
||||
|
|
@ -163,7 +152,6 @@ export const repositoryQuerySchema = z.object({
|
|||
webUrl: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
indexedAt: z.coerce.date().optional(),
|
||||
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
|
||||
});
|
||||
|
||||
export const listRepositoriesResponseSchema = repositoryQuerySchema.array();
|
||||
|
|
|
|||
|
|
@ -5,46 +5,32 @@ import { env } from "@/env.mjs";
|
|||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
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 { render } from "@react-email/components";
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||
import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||
import { ApiKey, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
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 { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||
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 { getPlan, hasEntitlement } from "@sourcebot/shared";
|
||||
import Ajv from "ajv";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createTransport } from "nodemailer";
|
||||
import { Octokit } from "octokit";
|
||||
import { auth } from "./auth";
|
||||
import { getConnection } from "./data/connection";
|
||||
import { getOrgFromDomain } from "./data/org";
|
||||
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
||||
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
|
||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
||||
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 { ApiKeyPayload, TenancyMode } from "./lib/types";
|
||||
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
|
||||
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
});
|
||||
import { withOptionalAuthV2 } from "./withAuthV2";
|
||||
|
||||
const logger = createLogger('web-actions');
|
||||
const auditService = getAuditService();
|
||||
|
|
@ -187,31 +173,6 @@ export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () =>
|
|||
|
||||
////// 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(() =>
|
||||
withAuth((userId) =>
|
||||
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(() =>
|
||||
withAuth((userId) =>
|
||||
withOrgMembership(userId, domain, async ({ org }) => {
|
||||
const connections = await prisma.connection.findMany({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
...(filter.status ? {
|
||||
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(() =>
|
||||
export const getRepos = async ({
|
||||
where,
|
||||
take,
|
||||
}: {
|
||||
where?: Prisma.RepoWhereInput,
|
||||
take?: number
|
||||
} = {}) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const repos = await prisma.repo.findMany({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
...(filter.status ? {
|
||||
repoIndexingStatus: { in: filter.status }
|
||||
} : {}),
|
||||
...(filter.connectionId ? {
|
||||
connections: {
|
||||
some: {
|
||||
connectionId: filter.connectionId
|
||||
}
|
||||
}
|
||||
} : {}),
|
||||
}
|
||||
...where,
|
||||
},
|
||||
take,
|
||||
});
|
||||
|
||||
return repos.map((repo) => repositoryQuerySchema.parse({
|
||||
|
|
@ -665,10 +559,63 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
|
|||
webUrl: repo.webUrl ?? undefined,
|
||||
imageUrl: repo.imageUrl ?? 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(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
// @note: repo names are represented by their remote url
|
||||
|
|
@ -725,58 +672,9 @@ export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
|||
webUrl: repo.webUrl ?? undefined,
|
||||
imageUrl: repo.imageUrl ?? 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(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
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(() =>
|
||||
withAuth((userId) =>
|
||||
withOrgMembership(userId, domain, async ({ userRole }) => {
|
||||
|
|
@ -1267,13 +1023,6 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
|
|||
}, /* 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(() =>
|
||||
withAuth(async (userId) => {
|
||||
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(() =>
|
||||
withAuth(async (userId) =>
|
||||
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 () =>
|
||||
withAuth(async (userId) =>
|
||||
withOrgMembership(userId, domain, async ({ org }) => {
|
||||
|
|
@ -1971,10 +1685,6 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
|
|||
}, /* 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(() =>
|
||||
withAuth((userId) =>
|
||||
|
|
@ -2127,126 +1837,17 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean)
|
|||
});
|
||||
});
|
||||
|
||||
export async function setSearchModeCookie(searchMode: "precise" | "agentic") {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SEARCH_MODE_COOKIE_NAME, searchMode, {
|
||||
httpOnly: false, // Allow client-side access
|
||||
});
|
||||
}
|
||||
|
||||
export async function setAgenticSearchTutorialDismissedCookie(dismissed: boolean) {
|
||||
export const setAgenticSearchTutorialDismissedCookie = async (dismissed: boolean) => sew(async () => {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, dismissed ? "true" : "false", {
|
||||
httpOnly: false, // Allow client-side access
|
||||
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
////// Helpers ///////
|
||||
|
||||
const parseConnectionConfig = (config: string) => {
|
||||
let parsedConfig: ConnectionConfig;
|
||||
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);
|
||||
}
|
||||
export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true');
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import { search } from "@codemirror/search";
|
|||
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { EditorContextMenu } from "../../../components/editorContextMenu";
|
||||
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation";
|
||||
import { useBrowseNavigation } from "../../hooks/useBrowseNavigation";
|
||||
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "../../hooks/utils";
|
||||
import { useBrowseState } from "../../hooks/useBrowseState";
|
||||
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useRef } from "react";
|
||||
import { FileTreeItem } from "@/features/fileTree/actions";
|
||||
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 { useBrowseParams } from "../../hooks/useBrowseParams";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { StateField, Range } from "@codemirror/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
|
||||
import { BrowseHighlightRange } from "../../hooks/useBrowseNavigation";
|
||||
import { BrowseHighlightRange } from "../../hooks/utils";
|
||||
|
||||
const markDecoration = Decoration.mark({
|
||||
class: "searchMatch-selected",
|
||||
|
|
|
|||
|
|
@ -3,58 +3,7 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useCallback } from "react";
|
||||
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
|
||||
|
||||
export type BrowseHighlightRange = {
|
||||
start: { lineNumber: number; column: number; };
|
||||
end: { lineNumber: number; column: number; };
|
||||
} | {
|
||||
start: { lineNumber: number; };
|
||||
end: { lineNumber: number; };
|
||||
}
|
||||
|
||||
export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
import { getBrowsePath, GetBrowsePathProps } from "./utils";
|
||||
|
||||
export const useBrowseNavigation = () => {
|
||||
const router = useRouter();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { getBrowsePath, GetBrowsePathProps } from "./useBrowseNavigation";
|
||||
import { getBrowsePath, GetBrowsePathProps } from "./utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
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) => {
|
||||
const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/);
|
||||
|
|
@ -40,4 +61,29 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
|
|||
path,
|
||||
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
|
||||
domain={params.domain}
|
||||
homePath={`/${params.domain}/chat`}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<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 { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
||||
|
||||
interface AskSourcebotDemoCardsProps {
|
||||
interface DemoCards {
|
||||
demoExamples: DemoExamples;
|
||||
}
|
||||
|
||||
export const AskSourcebotDemoCards = ({
|
||||
export const DemoCards = ({
|
||||
demoExamples,
|
||||
}: AskSourcebotDemoCardsProps) => {
|
||||
}: DemoCards) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
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 { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
|
||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { DemoExamples } from "@/types";
|
||||
import { AskSourcebotDemoCards } from "./askSourcebotDemoCards";
|
||||
import { AgenticSearchTutorialDialog } from "./agenticSearchTutorialDialog";
|
||||
import { setAgenticSearchTutorialDismissedCookie } from "@/actions";
|
||||
import { RepositorySnapshot } from "./repositorySnapshot";
|
||||
import { SearchModeSelector } from "../../components/searchModeSelector";
|
||||
import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner";
|
||||
|
||||
interface AgenticSearchProps {
|
||||
searchModeSelectorProps: SearchModeSelectorProps;
|
||||
interface LandingPageChatBox {
|
||||
languageModels: LanguageModelInfo[];
|
||||
repos: RepositoryQuery[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
chatHistory: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
name: string | null;
|
||||
}[];
|
||||
demoExamples: DemoExamples | undefined;
|
||||
isTutorialDismissed: boolean;
|
||||
}
|
||||
|
||||
export const AgenticSearch = ({
|
||||
searchModeSelectorProps,
|
||||
export const LandingPageChatBox = ({
|
||||
languageModels,
|
||||
repos,
|
||||
searchContexts,
|
||||
demoExamples,
|
||||
isTutorialDismissed,
|
||||
}: AgenticSearchProps) => {
|
||||
}: LandingPageChatBox) => {
|
||||
const { createNewChatThread, isLoading } = useCreateNewChatThread();
|
||||
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
|
||||
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
|
||||
|
||||
const [isTutorialOpen, setIsTutorialOpen] = useState(!isTutorialDismissed);
|
||||
const onTutorialDismissed = useCallback(() => {
|
||||
setIsTutorialOpen(false);
|
||||
setAgenticSearchTutorialDismissedCookie(true);
|
||||
}, []);
|
||||
const isChatBoxDisabled = languageModels.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="mt-4 w-full border rounded-md shadow-sm max-w-[800px]">
|
||||
<div className="w-full max-w-[800px] mt-4">
|
||||
|
||||
|
||||
<div className="border rounded-md w-full shadow-sm">
|
||||
<ChatBox
|
||||
onSubmit={(children) => {
|
||||
createNewChatThread(children, selectedSearchScopes);
|
||||
|
|
@ -60,6 +42,7 @@ export const AgenticSearch = ({
|
|||
selectedSearchScopes={selectedSearchScopes}
|
||||
searchContexts={searchContexts}
|
||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||
isDisabled={isChatBoxDisabled}
|
||||
/>
|
||||
<Separator />
|
||||
<div className="relative">
|
||||
|
|
@ -74,33 +57,15 @@ export const AgenticSearch = ({
|
|||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||
/>
|
||||
<SearchModeSelector
|
||||
{...searchModeSelectorProps}
|
||||
searchMode="agentic"
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<RepositorySnapshot
|
||||
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}
|
||||
/>
|
||||
{isChatBoxDisabled && (
|
||||
<NotConfiguredErrorBanner className="mt-4" />
|
||||
)}
|
||||
</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"
|
||||
|
||||
import { setAgenticSearchTutorialDismissedCookie } from "@/actions"
|
||||
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 { cn } from "@/lib/utils"
|
||||
import mentionsDemo from "@/public/ask_sb_tutorial_at_mentions.png"
|
||||
|
|
@ -27,11 +28,9 @@ import {
|
|||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
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
|
||||
|
|
@ -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 nextStep = () => {
|
||||
|
|
@ -269,11 +278,12 @@ export const AgenticSearchTutorialDialog = ({ onClose }: AgenticSearchTutorialDi
|
|||
const currentStepData = tutorialSteps[currentStep];
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[900px] p-0 flex flex-col h-[525px] overflow-hidden rounded-xl border-none bg-transparent"
|
||||
closeButtonClassName="text-white"
|
||||
>
|
||||
<DialogTitle className="sr-only">Ask Sourcebot tutorial</DialogTitle>
|
||||
<div className="relative flex h-full">
|
||||
{/* Left Column (Text Content & Navigation) */}
|
||||
<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 { cookies } from 'next/headers';
|
||||
import { TutorialDialog } from './components/tutorialDialog';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function Layout({ children }: LayoutProps) {
|
||||
const isTutorialDismissed = (await cookies()).get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
|
||||
|
||||
return (
|
||||
// @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">
|
||||
{children}
|
||||
</div>
|
||||
<TutorialDialog isOpen={!isTutorialDismissed} />
|
||||
</NavigationGuardProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
import { getRepos, getSearchContexts } from "@/actions";
|
||||
import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
|
||||
import { getRepos, getReposStats, getSearchContexts } from "@/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 { isServiceError } from "@/lib/utils";
|
||||
import { NewChatPanel } from "./components/newChatPanel";
|
||||
import { TopBar } from "../components/topBar";
|
||||
import { ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { ChatSidePanel } from "./components/chatSidePanel";
|
||||
import { auth } from "@/auth";
|
||||
import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle";
|
||||
import { isServiceError, measure } from "@/lib/utils";
|
||||
import { LandingPageChatBox } from "./components/landingPageChatBox";
|
||||
import { RepositoryCarousel } from "../components/repositoryCarousel";
|
||||
import { NavigationMenu } from "../components/navigationMenu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DemoCards } from "./components/demoCards";
|
||||
import { env } from "@/env.mjs";
|
||||
import { loadJsonFile } from "@sourcebot/shared";
|
||||
import { DemoExamples, demoExamplesSchema } from "@/types";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -18,47 +22,85 @@ interface PageProps {
|
|||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const languageModels = await getConfiguredLanguageModelsInfo();
|
||||
const repos = await getRepos();
|
||||
const searchContexts = await getSearchContexts(params.domain);
|
||||
const session = await auth();
|
||||
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
|
||||
const allRepos = await getRepos();
|
||||
|
||||
if (isServiceError(chatHistory)) {
|
||||
throw new ServiceErrorException(chatHistory);
|
||||
}
|
||||
const carouselRepos = await getRepos({
|
||||
where: {
|
||||
indexedAt: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
if (isServiceError(repos)) {
|
||||
throw new ServiceErrorException(repos);
|
||||
const repoStats = await getReposStats();
|
||||
|
||||
if (isServiceError(allRepos)) {
|
||||
throw new ServiceErrorException(allRepos);
|
||||
}
|
||||
|
||||
if (isServiceError(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 (
|
||||
<>
|
||||
<TopBar
|
||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||
<NavigationMenu
|
||||
domain={params.domain}
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
>
|
||||
<ChatSidePanel
|
||||
order={1}
|
||||
chatHistory={chatHistory}
|
||||
isAuthenticated={!!session}
|
||||
isCollapsedInitially={false}
|
||||
|
||||
<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"
|
||||
/>
|
||||
<AnimatedResizableHandle />
|
||||
<NewChatPanel
|
||||
</div>
|
||||
<CustomSlateEditor>
|
||||
<LandingPageChatBox
|
||||
languageModels={languageModels}
|
||||
repos={allRepos}
|
||||
searchContexts={searchContexts}
|
||||
repos={indexedRepos}
|
||||
order={2}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</CustomSlateEditor>
|
||||
|
||||
<div className="mt-8">
|
||||
<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 { useCallback, useEffect, useRef } from "react";
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
export const TrialNavIndicator = ({ subscription }: Props) => {
|
||||
export const TrialIndicator = ({ subscription }: Props) => {
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
|
||||
import { LaptopIcon } from "@radix-ui/react-icons";
|
||||
import Image from "next/image";
|
||||
import { getBrowsePath } from "../browse/hooks/useBrowseNavigation";
|
||||
import { getBrowsePath } from "../browse/hooks/utils";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { useCallback, useState, useMemo, useRef, useEffect } from "react";
|
||||
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';
|
||||
|
||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageCircleIcon, SearchIcon, TriangleAlert } from "lucide-react";
|
||||
import { MessageCircleIcon, SearchIcon } from "lucide-react";
|
||||
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";
|
||||
|
||||
|
|
@ -17,24 +20,47 @@ const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/ov
|
|||
|
||||
export interface SearchModeSelectorProps {
|
||||
searchMode: SearchMode;
|
||||
isAgenticSearchEnabled: boolean;
|
||||
onSearchModeChange: (searchMode: SearchMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SearchModeSelector = ({
|
||||
searchMode,
|
||||
isAgenticSearchEnabled,
|
||||
onSearchModeChange,
|
||||
className,
|
||||
}: SearchModeSelectorProps) => {
|
||||
const domain = useDomain();
|
||||
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 (
|
||||
<div className={cn("flex flex-row items-center", className)}>
|
||||
<Select
|
||||
value={searchMode}
|
||||
onValueChange={(value) => onSearchModeChange(value as "precise" | "agentic")}
|
||||
onValueChange={(value) => {
|
||||
onSearchModeChanged(value as SearchMode);
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
|
|
@ -99,16 +125,10 @@ export const SearchModeSelector = ({
|
|||
<div
|
||||
onMouseEnter={() => setFocusedSearchMode("agentic")}
|
||||
onFocus={() => setFocusedSearchMode("agentic")}
|
||||
className={cn({
|
||||
"cursor-not-allowed": !isAgenticSearchEnabled,
|
||||
})}
|
||||
>
|
||||
<SelectItem
|
||||
value="agentic"
|
||||
disabled={!isAgenticSearchEnabled}
|
||||
className={cn({
|
||||
"cursor-pointer": isAgenticSearchEnabled,
|
||||
})}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-1.5">
|
||||
<span>Ask</span>
|
||||
|
|
@ -129,14 +149,8 @@ export const SearchModeSelector = ({
|
|||
>
|
||||
<div className="flex flex-col 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>
|
||||
</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" />
|
||||
<p>Use natural language to search, summarize and understand your codebase using a reasoning agent.</p>
|
||||
<Link
|
||||
|
|
@ -8,18 +8,20 @@ import { Separator } from "@/components/ui/separator";
|
|||
interface TopBarProps {
|
||||
domain: string;
|
||||
children?: React.ReactNode;
|
||||
homePath?: string;
|
||||
}
|
||||
|
||||
export const TopBar = ({
|
||||
domain,
|
||||
children,
|
||||
homePath = `/${domain}`,
|
||||
}: TopBarProps) => {
|
||||
return (
|
||||
<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="grow flex flex-row gap-4 items-center">
|
||||
<Link
|
||||
href={`/${domain}`}
|
||||
href={homePath}
|
||||
className="shrink-0 cursor-pointer"
|
||||
>
|
||||
<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 { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||
import { GitHubStarToast } from "./components/githubStarToast";
|
||||
import { UpgradeToast } from "./components/upgradeToast";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode,
|
||||
|
|
@ -154,6 +155,7 @@ export default async function Layout(props: LayoutProps) {
|
|||
{children}
|
||||
<SyntaxReferenceGuide />
|
||||
<GitHubStarToast />
|
||||
<UpgradeToast />
|
||||
</SyntaxGuideProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,101 +1,11 @@
|
|||
import { getRepos, getSearchContexts } from "@/actions";
|
||||
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";
|
||||
import SearchPage from "./search/page";
|
||||
|
||||
const logger = createLogger('web-homepage');
|
||||
|
||||
export default async function Home(props: { params: Promise<{ domain: 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;
|
||||
interface Props {
|
||||
params: Promise<{ domain: string }>;
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
}
|
||||
|
||||
const HomeInternal = async (props: { params: Promise<{ domain: string }> }) => {
|
||||
const params = await props.params;
|
||||
|
||||
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>
|
||||
)
|
||||
export default async function Home(props: Props) {
|
||||
// Default to rendering the search page.
|
||||
return <SearchPage {...props} />;
|
||||
}
|
||||
|
|
@ -2,72 +2,51 @@
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn, getRepoImageSrc } from "@/lib/utils"
|
||||
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
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 = {
|
||||
repoId: number
|
||||
repoName: string;
|
||||
repoDisplayName: string
|
||||
imageUrl?: string
|
||||
repoIndexingStatus: RepoIndexingStatus
|
||||
status: RepoStatus
|
||||
lastIndexed: string
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
[RepoIndexingStatus.NEW]: "Queued",
|
||||
[RepoIndexingStatus.IN_INDEX_QUEUE]: "Queued",
|
||||
[RepoIndexingStatus.INDEXING]: "Indexing",
|
||||
[RepoIndexingStatus.INDEXED]: "Indexed",
|
||||
[RepoIndexingStatus.FAILED]: "Failed",
|
||||
[RepoIndexingStatus.IN_GC_QUEUE]: "Deleting",
|
||||
[RepoIndexingStatus.GARBAGE_COLLECTING]: "Deleting",
|
||||
[RepoIndexingStatus.GARBAGE_COLLECTION_FAILED]: "Deletion Failed"
|
||||
const statusLabels: Record<RepoStatus, string> = {
|
||||
'syncing': "Syncing",
|
||||
'indexed': "Indexed",
|
||||
'not-indexed': "Pending",
|
||||
};
|
||||
|
||||
const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
|
||||
const StatusIndicator = ({ status }: { status: RepoStatus }) => {
|
||||
let icon = null
|
||||
let description = ""
|
||||
let className = ""
|
||||
|
||||
switch (status) {
|
||||
case RepoIndexingStatus.NEW:
|
||||
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:
|
||||
case 'syncing':
|
||||
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"
|
||||
break
|
||||
case RepoIndexingStatus.INDEXED:
|
||||
case 'indexed':
|
||||
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"
|
||||
break
|
||||
case RepoIndexingStatus.FAILED:
|
||||
icon = <XCircle className="h-3.5 w-3.5" />
|
||||
description = "Repository indexing failed"
|
||||
className = "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-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"
|
||||
case 'not-indexed':
|
||||
icon = <Clock className="h-3.5 w-3.5" />
|
||||
description = "Repository is pending initial sync"
|
||||
className = "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400"
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +73,7 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
|||
{
|
||||
accessorKey: "repoDisplayName",
|
||||
header: 'Repository',
|
||||
size: 500,
|
||||
cell: ({ row: { original: { repoId, repoName, repoDisplayName, imageUrl } } }) => {
|
||||
return (
|
||||
<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 }) => {
|
||||
const uniqueLabels = Array.from(new Set(Object.values(statusLabels)));
|
||||
const uniqueLabels = Object.values(statusLabels);
|
||||
const currentFilter = column.getFilterValue() as string | undefined;
|
||||
|
||||
return (
|
||||
|
|
@ -173,17 +154,18 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
|||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <StatusIndicator status={row.original.repoIndexingStatus} />
|
||||
return <StatusIndicator status={row.original.status} />
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (value === undefined) return true;
|
||||
|
||||
const status = row.getValue(id) as RepoIndexingStatus;
|
||||
const status = row.getValue(id) as RepoStatus;
|
||||
return statusLabels[status] === value;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lastIndexed",
|
||||
size: 150,
|
||||
header: ({ column }) => (
|
||||
<div className="w-[150px]">
|
||||
<Button
|
||||
|
|
@ -191,14 +173,14 @@ export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
|
|||
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"
|
||||
>
|
||||
Last Indexed
|
||||
Last Synced
|
||||
<ArrowUpDown className="ml-2 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.lastIndexed) {
|
||||
return <div>-</div>;
|
||||
return <div className="text-muted-foreground">Never</div>;
|
||||
}
|
||||
const date = new Date(row.original.lastIndexed)
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,14 +9,8 @@ export default async function Layout(
|
|||
props: LayoutProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
domain
|
||||
} = params;
|
||||
|
||||
const {
|
||||
children
|
||||
} = props;
|
||||
const { domain } = params;
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<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 { 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 }> }) {
|
||||
const params = await props.params;
|
||||
|
|
@ -11,9 +25,9 @@ export default async function ReposPage(props: { params: Promise<{ domain: strin
|
|||
domain
|
||||
} = params;
|
||||
|
||||
const org = await getOrgFromDomain(domain);
|
||||
if (!org) {
|
||||
return <PageNotFound />
|
||||
const repos = await getReposWithJobs();
|
||||
if (isServiceError(repos)) {
|
||||
throw new ServiceErrorException(repos);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -21,13 +35,31 @@ export default async function ReposPage(props: { params: Promise<{ domain: strin
|
|||
<Header>
|
||||
<h1 className="text-3xl">Repositories</h1>
|
||||
</Header>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full">
|
||||
<div className="px-6 py-6">
|
||||
<RepositoryTable
|
||||
repos={repos.map((repo) => ({
|
||||
repoId: repo.id,
|
||||
repoName: repo.name,
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const getReposWithJobs = async () => sew(() =>
|
||||
withOptionalAuthV2(async ({ prisma }) => {
|
||||
const repos = await prisma.repo.findMany({
|
||||
include: {
|
||||
jobs: true,
|
||||
}
|
||||
});
|
||||
|
||||
return repos;
|
||||
}));
|
||||
|
|
@ -1,118 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
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 { useToast } from "@/components/hooks/use-toast";
|
||||
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 { useState } from "react";
|
||||
import { getRepos } from "@/app/api/(client)/client";
|
||||
|
||||
interface RepositoryTableProps {
|
||||
isAddReposButtonVisible: boolean
|
||||
repos: {
|
||||
repoId: number;
|
||||
repoName: string;
|
||||
repoDisplayName: string;
|
||||
imageUrl?: string;
|
||||
indexedAt?: Date;
|
||||
status: RepoStatus;
|
||||
}[];
|
||||
domain: string;
|
||||
isAddReposButtonVisible: boolean;
|
||||
}
|
||||
|
||||
export const RepositoryTable = ({
|
||||
repos,
|
||||
domain,
|
||||
isAddReposButtonVisible,
|
||||
}: RepositoryTableProps) => {
|
||||
const domain = useDomain();
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
|
||||
const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({
|
||||
queryKey: ['repos'],
|
||||
queryFn: async () => {
|
||||
return await unwrapServiceError(getRepos());
|
||||
},
|
||||
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
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 => ({
|
||||
repoId: repo.repoId,
|
||||
repoName: repo.repoName,
|
||||
repoDisplayName: repo.repoDisplayName ?? repo.repoName,
|
||||
imageUrl: repo.imageUrl,
|
||||
repoIndexingStatus: repo.repoIndexingStatus as RepoIndexingStatus,
|
||||
status: repo.status,
|
||||
lastIndexed: repo.indexedAt?.toISOString() ?? "",
|
||||
})).sort((a, b) => {
|
||||
const getPriorityFromStatus = (status: RepoIndexingStatus) => {
|
||||
const getPriorityFromStatus = (status: RepoStatus) => {
|
||||
switch (status) {
|
||||
case RepoIndexingStatus.IN_INDEX_QUEUE:
|
||||
case RepoIndexingStatus.INDEXING:
|
||||
return 0 // Highest priority - currently indexing
|
||||
case RepoIndexingStatus.FAILED:
|
||||
return 1 // Second priority - failed repos need attention
|
||||
case RepoIndexingStatus.INDEXED:
|
||||
case 'syncing':
|
||||
return 0 // Highest priority - currently syncing
|
||||
case 'not-indexed':
|
||||
return 1 // Second priority - not yet indexed
|
||||
case 'indexed':
|
||||
return 2 // Third priority - successfully indexed
|
||||
default:
|
||||
return 3 // Lowest priority - other statuses (NEW, etc.)
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority first
|
||||
const aPriority = getPriorityFromStatus(a.repoIndexingStatus);
|
||||
const bPriority = getPriorityFromStatus(b.repoIndexingStatus);
|
||||
const aPriority = getPriorityFromStatus(a.status);
|
||||
const bPriority = getPriorityFromStatus(b.status);
|
||||
|
||||
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 (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(() => {
|
||||
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);
|
||||
}, [reposLoading, domain]);
|
||||
|
||||
|
||||
if (reposError) {
|
||||
return <div>Error loading repositories</div>;
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -121,7 +84,22 @@ export const RepositoryTable = ({
|
|||
data={tableRepos}
|
||||
searchKey="repoDisplayName"
|
||||
searchPlaceholder="Search repositories..."
|
||||
headerActions={isAddReposButtonVisible && (
|
||||
headerActions={(
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
router.refresh();
|
||||
toast({
|
||||
description: "Page refreshed",
|
||||
});
|
||||
}}>
|
||||
<RefreshCwIcon className="w-4 h-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{isAddReposButtonVisible && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
|
|
@ -131,6 +109,8 @@ export const RepositoryTable = ({
|
|||
Add repository
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AddRepositoryDialog
|
||||
|
|
|
|||
|
|
@ -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 { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||
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";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,378 +1,23 @@
|
|||
'use client';
|
||||
import { SearchLandingPage } from "./components/searchLandingPage";
|
||||
import { SearchResultsPage } from "./components/searchResultsPage";
|
||||
|
||||
import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} 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 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 (
|
||||
<Suspense>
|
||||
<SearchPageInternal />
|
||||
</Suspense>
|
||||
)
|
||||
interface SearchPageProps {
|
||||
params: Promise<{ domain: string }>;
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
}
|
||||
|
||||
const SearchPageInternal = () => {
|
||||
const router = useRouter();
|
||||
const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? "";
|
||||
const { setSearchHistory } = useSearchHistory();
|
||||
const captureEvent = useCaptureEvent();
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
export default async function SearchPage(props: SearchPageProps) {
|
||||
const { domain } = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const query = searchParams?.query;
|
||||
|
||||
// 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}`,
|
||||
});
|
||||
if (query === undefined || query.length === 0) {
|
||||
return <SearchLandingPage domain={domain} />
|
||||
}
|
||||
}, [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"
|
||||
<SearchResultsPage
|
||||
searchQuery={query}
|
||||
/>
|
||||
</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 { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages } from "@/features/chat/actions";
|
||||
import { createAgentStream } from "@/features/chat/agent";
|
||||
import { additionalChatRequestParamsSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
|
||||
import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils";
|
||||
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
|
||||
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
|
@ -49,7 +49,11 @@ export async function POST(req: Request) {
|
|||
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(() =>
|
||||
withAuth((userId) =>
|
||||
|
|
@ -78,13 +82,13 @@ export async function POST(req: Request) {
|
|||
// corresponding config in `config.json`.
|
||||
const languageModelConfig =
|
||||
(await _getConfiguredLanguageModelsFull())
|
||||
.find((model) => model.model === languageModelId);
|
||||
.find((model) => getLanguageModelKey(model) === getLanguageModelKey(languageModel));
|
||||
|
||||
if (!languageModelConfig) {
|
||||
return serviceErrorResponse({
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
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%);
|
||||
|
||||
--warning: #ca8a04;
|
||||
--error: #fc5c5c;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -201,6 +202,7 @@
|
|||
--chat-citation-border: hsl(217, 91%, 60%);
|
||||
|
||||
--warning: #fde047;
|
||||
--error: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function DataTable<TData, TValue>({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||
|
|
@ -85,7 +85,10 @@ export function DataTable<TData, TValue>({
|
|||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: `${header.getSize()}px` }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
|
|
@ -106,7 +109,10 @@ export function DataTable<TData, TValue>({
|
|||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{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())}
|
||||
</TableCell>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
|||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
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<
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'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 { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter";
|
||||
import { FindRelatedSymbolsResponse } from "@/features/codeNav/types";
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ export const env = createEnv({
|
|||
|
||||
// Misc
|
||||
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(),
|
||||
|
||||
|
|
@ -157,7 +156,6 @@ export const env = createEnv({
|
|||
experimental__runtimeEnv: {
|
||||
NEXT_PUBLIC_POSTHOG_PAPIK: process.env.NEXT_PUBLIC_POSTHOG_PAPIK,
|
||||
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_LANGFUSE_PUBLIC_KEY: process.env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY,
|
||||
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;
|
||||
isRedirecting?: boolean;
|
||||
isGenerating?: boolean;
|
||||
isDisabled?: boolean;
|
||||
languageModels: LanguageModelInfo[];
|
||||
selectedSearchScopes: SearchScope[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
|
|
@ -40,6 +41,7 @@ export const ChatBox = ({
|
|||
className,
|
||||
isRedirecting,
|
||||
isGenerating,
|
||||
isDisabled,
|
||||
languageModels,
|
||||
selectedSearchScopes,
|
||||
searchContexts,
|
||||
|
|
@ -68,7 +70,7 @@ export const ChatBox = ({
|
|||
}).flat(),
|
||||
});
|
||||
const { selectedLanguageModel } = useSelectedLanguageModel({
|
||||
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
|
||||
languageModels,
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
|
|
@ -167,6 +169,13 @@ export const ChatBox = ({
|
|||
onContextSelectorOpenChanged(true);
|
||||
}
|
||||
|
||||
if (isSubmitDisabledReason === "no-language-model-selected") {
|
||||
toast({
|
||||
description: "⚠️ You must select a language model",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +296,7 @@ export const ChatBox = ({
|
|||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
onKeyDown={onKeyDown}
|
||||
readOnly={isDisabled}
|
||||
/>
|
||||
<div className="ml-auto z-10">
|
||||
{isRedirecting ? (
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LanguageModelInfo, SearchScope } from "@/features/chat/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 { AtMentionButton } from "./atMentionButton";
|
||||
import { LanguageModelSelector } from "./languageModelSelector";
|
||||
import { SearchScopeSelector } from "./searchScopeSelector";
|
||||
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
||||
import { AtMentionInfoCard } from "@/features/chat/components/chatBox/atMentionInfoCard";
|
||||
|
||||
export interface ChatBoxToolbarProps {
|
||||
languageModels: LanguageModelInfo[];
|
||||
|
|
@ -33,37 +27,14 @@ export const ChatBoxToolbar = ({
|
|||
isContextSelectorOpen,
|
||||
onContextSelectorOpenChanged,
|
||||
}: ChatBoxToolbarProps) => {
|
||||
const editor = useSlate();
|
||||
|
||||
const onAddContext = useCallback(() => {
|
||||
editor.insertText("@");
|
||||
ReactEditor.focus(editor);
|
||||
}, [editor]);
|
||||
|
||||
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
|
||||
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
|
||||
languageModels,
|
||||
});
|
||||
|
||||
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>
|
||||
<AtMentionButton />
|
||||
<Separator orientation="vertical" className="h-3 mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SearchScopeSelector
|
||||
className="bg-inherit w-fit h-6 min-h-6"
|
||||
repos={repos}
|
||||
|
|
@ -73,27 +44,12 @@ export const ChatBoxToolbar = ({
|
|||
isOpen={isContextSelectorOpen}
|
||||
onOpenChanged={onContextSelectorOpenChanged}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
||||
<SearchScopeInfoCard />
|
||||
</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";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ModelProviderLogo } from "./modelProviderLogo";
|
||||
import { getLanguageModelKey } from "../../utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LanguageModelInfoCard } from "./languageModelInfoCard";
|
||||
|
||||
interface LanguageModelSelectorProps {
|
||||
languageModels: LanguageModelInfo[];
|
||||
|
|
@ -59,7 +62,7 @@ export const LanguageModelSelector = ({
|
|||
// De-duplicate models
|
||||
const languageModels = useMemo(() => {
|
||||
return _languageModels.filter((model, selfIndex, selfArray) =>
|
||||
selfIndex === selfArray.findIndex((t) => t.model === model.model)
|
||||
selfIndex === selfArray.findIndex((t) => getLanguageModelKey(t) === getLanguageModelKey(model))
|
||||
);
|
||||
}, [_languageModels]);
|
||||
|
||||
|
|
@ -68,7 +71,9 @@ export const LanguageModelSelector = ({
|
|||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleTogglePopover}
|
||||
className={cn(
|
||||
|
|
@ -87,8 +92,7 @@ export const LanguageModelSelector = ({
|
|||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap",
|
||||
selectedModel ? "font-medium" : "font-normal"
|
||||
"text-sm text-muted-foreground mx-1 text-ellipsis overflow-hidden whitespace-nowrap font-medium",
|
||||
)}
|
||||
>
|
||||
{selectedModel ? (selectedModel.displayName ?? selectedModel.model) : "Select model"}
|
||||
|
|
@ -96,7 +100,11 @@ export const LanguageModelSelector = ({
|
|||
<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">
|
||||
<LanguageModelInfoCard />
|
||||
</TooltipContent>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
|
|
@ -108,14 +116,16 @@ export const LanguageModelSelector = ({
|
|||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
<p>No models found.</p>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{languageModels
|
||||
.map((model, index) => {
|
||||
const isSelected = selectedModel?.model === model.model;
|
||||
.map((model) => {
|
||||
const isSelected = selectedModel && getLanguageModelKey(selectedModel) === getLanguageModelKey(model);
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${model.model}-${index}`}
|
||||
key={getLanguageModelKey(model)}
|
||||
onSelect={() => {
|
||||
selectModel(model)
|
||||
}}
|
||||
|
|
@ -143,6 +153,7 @@ export const LanguageModelSelector = ({
|
|||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,34 +1,29 @@
|
|||
// 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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types";
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ScanSearchIcon,
|
||||
} from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { RepoSearchScope, RepoSetSearchScope, SearchScope } from "../../types";
|
||||
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[];
|
||||
searchContexts: SearchContextQuery[];
|
||||
selectedSearchScopes: SearchScope[];
|
||||
|
|
@ -38,7 +33,7 @@ interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButton
|
|||
onOpenChanged: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const SearchScopeSelector = React.forwardRef<
|
||||
export const SearchScopeSelector = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SearchScopeSelectorProps
|
||||
>(
|
||||
|
|
@ -55,23 +50,13 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
},
|
||||
ref
|
||||
) => {
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollPosition = React.useRef<number>(0);
|
||||
const [hasSearchInput, setHasSearchInput] = React.useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPosition = useRef<number>(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
|
||||
|
||||
const handleInputKeyDown = (
|
||||
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) => {
|
||||
const toggleItem = useCallback((item: SearchScope) => {
|
||||
// Store current scroll position before state update
|
||||
if (scrollContainerRef.current) {
|
||||
scrollPosition.current = scrollContainerRef.current.scrollTop;
|
||||
|
|
@ -88,21 +73,9 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
[...selectedSearchScopes, item];
|
||||
|
||||
onSelectedSearchScopesChange(newSelectedItems);
|
||||
};
|
||||
}, [selectedSearchScopes, onSelectedSearchScopesChange]);
|
||||
|
||||
const handleClear = () => {
|
||||
onSelectedSearchScopesChange([]);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onSelectedSearchScopesChange(allSearchScopeItems);
|
||||
};
|
||||
|
||||
const handleTogglePopover = () => {
|
||||
onOpenChanged(!isOpen);
|
||||
};
|
||||
|
||||
const allSearchScopeItems = React.useMemo(() => {
|
||||
const allSearchScopeItems = useMemo(() => {
|
||||
const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
|
||||
type: 'reposet' as const,
|
||||
value: context.name,
|
||||
|
|
@ -120,8 +93,40 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
|
||||
}, [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
|
||||
.filter((item) => {
|
||||
// Filter by search query
|
||||
if (query && !item.name.toLowerCase().includes(query) && !item.value.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((item) => ({
|
||||
item,
|
||||
isSelected: selectedSearchScopes.some(
|
||||
|
|
@ -137,10 +142,77 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
|
||||
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
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && scrollPosition.current > 0) {
|
||||
scrollContainerRef.current.scrollTop = scrollPosition.current;
|
||||
}
|
||||
|
|
@ -151,7 +223,9 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
open={isOpen}
|
||||
onOpenChange={onOpenChanged}
|
||||
>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
|
@ -175,35 +249,66 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
<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-auto p-0"
|
||||
className="w-[400px] p-0"
|
||||
align="start"
|
||||
onEscapeKeyDown={() => onOpenChanged(false)}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
<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}
|
||||
onValueChange={(value) => setHasSearchInput(!!value)}
|
||||
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-11"
|
||||
/>
|
||||
<CommandList ref={scrollContainerRef}>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{!hasSearchInput && (
|
||||
</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"
|
||||
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>
|
||||
)}
|
||||
{sortedSearchScopeItems.map(({ item, isSelected }) => {
|
||||
<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 (
|
||||
<CommandItem
|
||||
<div
|
||||
key={`${item.type}-${item.value}`}
|
||||
onSelect={() => toggleItem(item)}
|
||||
className="cursor-pointer"
|
||||
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(
|
||||
|
|
@ -233,24 +338,27 @@ export const SearchScopeSelector = React.forwardRef<
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSearchScopes.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandItem
|
||||
onSelect={handleClear}
|
||||
className="flex-1 justify-center cursor-pointer"
|
||||
<Separator />
|
||||
<div
|
||||
onClick={handleClear}
|
||||
className="flex items-center justify-center px-2 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Clear
|
||||
</CommandItem>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { usePrevious } from '@uidotdev/usehooks';
|
|||
import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
|
||||
import { generateAndUpdateChatNameFromMessage } from '../../actions';
|
||||
import { isServiceError } from '@/lib/utils';
|
||||
import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner';
|
||||
|
||||
type ChatHistoryState = {
|
||||
scrollOffset?: number;
|
||||
|
|
@ -73,7 +74,7 @@ export const ChatThread = ({
|
|||
);
|
||||
|
||||
const { selectedLanguageModel } = useSelectedLanguageModel({
|
||||
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
|
||||
languageModels,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -118,7 +119,7 @@ export const ChatThread = ({
|
|||
_sendMessage(message, {
|
||||
body: {
|
||||
selectedSearchScopes,
|
||||
languageModelId: selectedLanguageModel.model,
|
||||
languageModel: selectedLanguageModel,
|
||||
} satisfies AdditionalChatRequestParams,
|
||||
});
|
||||
|
||||
|
|
@ -355,7 +356,12 @@ export const ChatThread = ({
|
|||
}
|
||||
</ScrollArea>
|
||||
{!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">
|
||||
{languageModels.length === 0 && (
|
||||
<NotConfiguredErrorBanner className="mb-2" />
|
||||
)}
|
||||
|
||||
<div className="border rounded-md w-full shadow-sm">
|
||||
<CustomSlateEditor>
|
||||
<ChatBox
|
||||
onSubmit={onSubmit}
|
||||
|
|
@ -367,6 +373,7 @@ export const ChatThread = ({
|
|||
selectedSearchScopes={selectedSearchScopes}
|
||||
searchContexts={searchContexts}
|
||||
onContextSelectorOpenChanged={setIsContextSelectorOpen}
|
||||
isDisabled={languageModels.length === 0}
|
||||
/>
|
||||
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
|
||||
<ChatBoxToolbar
|
||||
|
|
@ -381,6 +388,7 @@ export const ChatThread = ({
|
|||
</div>
|
||||
</CustomSlateEditor>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils';
|
|||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { getBrowsePath } from '@/app/[domain]/browse/hooks/useBrowseNavigation';
|
||||
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
|
||||
|
||||
|
||||
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