chore(worker,web): Repo indexing stability improvements + perf improvements to web (#563)
Some checks failed
Publish to ghcr / build (linux/amd64, blacksmith-4vcpu-ubuntu-2404) (push) Has been cancelled
Publish to ghcr / build (linux/arm64, blacksmith-8vcpu-ubuntu-2204-arm) (push) Has been cancelled
Publish to ghcr / merge (push) Has been cancelled

This commit is contained in:
Brendan Kellam 2025-10-18 16:31:22 -07:00 committed by GitHub
parent 5b09757e92
commit 4ebe4e0475
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
111 changed files with 2922 additions and 5572 deletions

View file

@ -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=

View file

@ -14,10 +14,17 @@ 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)
### 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)

View file

@ -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": {

View file

@ -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",

View file

@ -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();
}
}

View file

@ -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');

View file

@ -101,12 +101,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[]) {

View file

@ -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[]) {

View file

@ -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(),

View file

@ -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;
}

View file

@ -6,15 +6,13 @@ 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 { env } from "./env.js";
import { RepoIndexManager } from "./repoIndexManager.js";
import { PromClient } from './promClient.js';
const logger = createLogger('backend-entrypoint');
@ -33,9 +31,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 });
@ -44,12 +41,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, {
@ -68,14 +59,12 @@ const promClient = new PromClient();
const settings = await getSettings(env.CONFIG_PATH);
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.');
@ -87,12 +76,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();

View file

@ -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();
});
});
}
}

View file

@ -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);

View file

@ -0,0 +1,456 @@
import * as Sentry from '@sentry/node';
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
import { createLogger, Logger } from "@sourcebot/logger";
import { existsSync } from 'fs';
import { readdir, rm } from 'fs/promises';
import { Job, Queue, ReservedJob, Worker } from "groupmq";
import { Redis } from 'ioredis';
import { INDEX_CACHE_DIR } from './constants.js';
import { env } from './env.js';
import { cloneRepository, fetchRepository, isPathAValidGitRepoRoot, unsetGitConfig, upsertGitConfig } from './git.js';
import { repoMetadataSchema, RepoWithConnections, Settings } from "./types.js";
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure } from './utils.js';
import { indexGitRepository } from './zoekt.js';
const LOG_TAG = 'repo-index-manager';
const logger = createLogger(LOG_TAG);
const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId}`);
type JobPayload = {
type: 'INDEX' | 'CLEANUP';
jobId: string;
repoId: number;
repoName: string;
};
const JOB_TIMEOUT_MS = 1000 * 60 * 60 * 6; // 6 hour indexing timeout
/**
* Manages the lifecycle of repository data on disk, including git working copies
* and search index shards. Handles both indexing operations (cloning/fetching repos
* and building search indexes) and cleanup operations (removing orphaned repos and
* their associated data).
*
* Uses a job queue system to process indexing and cleanup tasks asynchronously,
* with configurable concurrency limits and retry logic. Automatically schedules
* re-indexing of repos based on configured intervals and manages garbage collection
* of repos that are no longer connected to any source.
*/
export class RepoIndexManager {
private interval?: NodeJS.Timeout;
private queue: Queue<JobPayload>;
private worker: Worker<JobPayload>;
constructor(
private db: PrismaClient,
private settings: Settings,
redis: Redis,
) {
this.queue = new Queue<JobPayload>({
redis,
namespace: 'repo-index-queue',
jobTimeoutMs: JOB_TIMEOUT_MS,
maxAttempts: 3,
logger: env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true',
});
this.worker = new Worker<JobPayload>({
queue: this.queue,
maxStalledCount: 1,
handler: this.runJob.bind(this),
concurrency: this.settings.maxRepoIndexingJobConcurrency,
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
logger: true,
}: {}),
});
this.worker.on('completed', this.onJobCompleted.bind(this));
this.worker.on('failed', this.onJobFailed.bind(this));
this.worker.on('stalled', this.onJobStalled.bind(this));
this.worker.on('error', this.onWorkerError.bind(this));
}
public async startScheduler() {
logger.debug('Starting scheduler');
this.interval = setInterval(async () => {
await this.scheduleIndexJobs();
await this.scheduleCleanupJobs();
}, 1000 * 5);
this.worker.run();
}
private async scheduleIndexJobs() {
const thresholdDate = new Date(Date.now() - this.settings.reindexIntervalMs);
const reposToIndex = await this.db.repo.findMany({
where: {
AND: [
{
OR: [
{ indexedAt: null },
{ indexedAt: { lt: thresholdDate } },
]
},
{
NOT: {
jobs: {
some: {
AND: [
{
type: RepoIndexingJobType.INDEX,
},
{
OR: [
// Don't schedule if there are active jobs that were created within the threshold date.
// This handles the case where a job is stuck in a pending state and will never be scheduled.
{
AND: [
{
status: {
in: [
RepoIndexingJobStatus.PENDING,
RepoIndexingJobStatus.IN_PROGRESS,
]
},
},
{
createdAt: {
gt: thresholdDate,
}
}
]
},
// Don't schedule if there are recent failed jobs (within the threshold date).
{
AND: [
{ status: RepoIndexingJobStatus.FAILED },
{ completedAt: { gt: thresholdDate } },
]
}
]
}
]
}
}
}
}
],
}
});
if (reposToIndex.length > 0) {
await this.createJobs(reposToIndex, RepoIndexingJobType.INDEX);
}
}
private async scheduleCleanupJobs() {
const thresholdDate = new Date(Date.now() - this.settings.repoGarbageCollectionGracePeriodMs);
const reposToCleanup = await this.db.repo.findMany({
where: {
connections: {
none: {}
},
OR: [
{ indexedAt: null },
{ indexedAt: { lt: thresholdDate } },
],
// Don't schedule if there are active jobs that were created within the threshold date.
NOT: {
jobs: {
some: {
AND: [
{
type: RepoIndexingJobType.CLEANUP,
},
{
status: {
in: [
RepoIndexingJobStatus.PENDING,
RepoIndexingJobStatus.IN_PROGRESS,
]
},
},
{
createdAt: {
gt: thresholdDate,
}
}
]
}
}
}
}
});
if (reposToCleanup.length > 0) {
await this.createJobs(reposToCleanup, RepoIndexingJobType.CLEANUP);
}
}
private async createJobs(repos: Repo[], type: RepoIndexingJobType) {
// @note: we don't perform this in a transaction because
// we want to avoid the situation where a job is created and run
// prior to the transaction being committed.
const jobs = await this.db.repoIndexingJob.createManyAndReturn({
data: repos.map(repo => ({
type,
repoId: repo.id,
})),
include: {
repo: true,
}
});
for (const job of jobs) {
await this.queue.add({
groupId: `repo:${job.repoId}`,
data: {
jobId: job.id,
type,
repoName: job.repo.name,
repoId: job.repo.id,
},
jobId: job.id,
});
}
}
private async runJob(job: ReservedJob<JobPayload>) {
const id = job.data.jobId;
const logger = createJobLogger(id);
logger.info(`Running ${job.data.type} job ${id} for repo ${job.data.repoName} (id: ${job.data.repoId}) (attempt ${job.attempts + 1} / ${job.maxAttempts})`);
const { repo, type: jobType } = await this.db.repoIndexingJob.update({
where: {
id,
},
data: {
status: RepoIndexingJobStatus.IN_PROGRESS,
},
select: {
type: true,
repo: {
include: {
connections: {
include: {
connection: true,
}
}
}
}
}
});
const abortController = new AbortController();
const signalHandler = () => {
logger.info(`Received shutdown signal, aborting...`);
abortController.abort(); // This cancels all operations
};
process.on('SIGTERM', signalHandler);
process.on('SIGINT', signalHandler);
try {
if (jobType === RepoIndexingJobType.INDEX) {
await this.indexRepository(repo, logger, abortController.signal);
} else if (jobType === RepoIndexingJobType.CLEANUP) {
await this.cleanupRepository(repo, logger);
}
} finally {
process.off('SIGTERM', signalHandler);
process.off('SIGINT', signalHandler);
}
}
private async indexRepository(repo: RepoWithConnections, logger: Logger, signal: AbortSignal) {
const { path: repoPath, isReadOnly } = getRepoPath(repo);
const metadata = repoMetadataSchema.parse(repo.metadata);
const credentials = await getAuthCredentialsForRepo(repo, this.db);
const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
const authHeader = credentials?.authHeader ?? undefined;
// If the repo path exists but it is not a valid git repository root, this indicates
// that the repository is in a bad state. To fix, we remove the directory and perform
// a fresh clone.
if (existsSync(repoPath) && !(await isPathAValidGitRepoRoot( { path: repoPath } ))) {
const isValidGitRepo = await isPathAValidGitRepoRoot({
path: repoPath,
signal,
});
if (!isValidGitRepo && !isReadOnly) {
logger.warn(`${repoPath} is not a valid git repository root. Deleting directory and performing fresh clone.`);
await rm(repoPath, { recursive: true, force: true });
}
}
if (existsSync(repoPath) && !isReadOnly) {
// @NOTE: in #483, we changed the cloning method s.t., we _no longer_
// write the clone URL (which could contain a auth token) to the
// `remote.origin.url` entry. For the upgrade scenario, we want
// to unset this key since it is no longer needed, hence this line.
// This will no-op if the key is already unset.
// @see: https://github.com/sourcebot-dev/sourcebot/pull/483
await unsetGitConfig({
path: repoPath,
keys: ["remote.origin.url"],
signal,
});
logger.info(`Fetching ${repo.name} (id: ${repo.id})...`);
const { durationMs } = await measure(() => fetchRepository({
cloneUrl: cloneUrlMaybeWithToken,
authHeader,
path: repoPath,
onProgress: ({ method, stage, progress }) => {
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.name} (id: ${repo.id})`)
},
signal,
}));
const fetchDuration_s = durationMs / 1000;
process.stdout.write('\n');
logger.info(`Fetched ${repo.name} (id: ${repo.id}) in ${fetchDuration_s}s`);
} else if (!isReadOnly) {
logger.info(`Cloning ${repo.name} (id: ${repo.id})...`);
const { durationMs } = await measure(() => cloneRepository({
cloneUrl: cloneUrlMaybeWithToken,
authHeader,
path: repoPath,
onProgress: ({ method, stage, progress }) => {
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.name} (id: ${repo.id})`)
},
signal
}));
const cloneDuration_s = durationMs / 1000;
process.stdout.write('\n');
logger.info(`Cloned ${repo.name} (id: ${repo.id}) in ${cloneDuration_s}s`);
}
// Regardless of clone or fetch, always upsert the git config for the repo.
// This ensures that the git config is always up to date for whatever we
// have in the DB.
if (metadata.gitConfig && !isReadOnly) {
await upsertGitConfig({
path: repoPath,
gitConfig: metadata.gitConfig,
signal,
});
}
logger.info(`Indexing ${repo.name} (id: ${repo.id})...`);
const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, signal));
const indexDuration_s = durationMs / 1000;
logger.info(`Indexed ${repo.name} (id: ${repo.id}) in ${indexDuration_s}s`);
}
private async cleanupRepository(repo: Repo, logger: Logger) {
const { path: repoPath, isReadOnly } = getRepoPath(repo);
if (existsSync(repoPath) && !isReadOnly) {
logger.info(`Deleting repo directory ${repoPath}`);
await rm(repoPath, { recursive: true, force: true });
}
const shardPrefix = getShardPrefix(repo.orgId, repo.id);
const files = (await readdir(INDEX_CACHE_DIR)).filter(file => file.startsWith(shardPrefix));
for (const file of files) {
const filePath = `${INDEX_CACHE_DIR}/${file}`;
logger.info(`Deleting shard file ${filePath}`);
await rm(filePath, { force: true });
}
}
private onJobCompleted = async (job: Job<JobPayload>) =>
groupmqLifecycleExceptionWrapper('onJobCompleted', logger, async () => {
const logger = createJobLogger(job.data.jobId);
const jobData = await this.db.repoIndexingJob.update({
where: { id: job.data.jobId },
data: {
status: RepoIndexingJobStatus.COMPLETED,
completedAt: new Date(),
}
});
if (jobData.type === RepoIndexingJobType.INDEX) {
const repo = await this.db.repo.update({
where: { id: jobData.repoId },
data: {
indexedAt: new Date(),
}
});
logger.info(`Completed index job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id})`);
}
else if (jobData.type === RepoIndexingJobType.CLEANUP) {
const repo = await this.db.repo.delete({
where: { id: jobData.repoId },
});
logger.info(`Completed cleanup job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id})`);
}
});
private onJobFailed = async (job: Job<JobPayload>) =>
groupmqLifecycleExceptionWrapper('onJobFailed', logger, async () => {
const logger = createJobLogger(job.data.jobId);
const attempt = job.attemptsMade + 1;
const wasLastAttempt = attempt >= job.opts.attempts;
if (wasLastAttempt) {
const { repo } = await this.db.repoIndexingJob.update({
where: { id: job.data.jobId },
data: {
status: RepoIndexingJobStatus.FAILED,
completedAt: new Date(),
errorMessage: job.failedReason,
},
select: { repo: true }
});
logger.error(`Failed job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id}). Attempt ${attempt} / ${job.opts.attempts}. Failing job.`);
} else {
const repo = await this.db.repo.findUniqueOrThrow({
where: { id: job.data.repoId },
});
logger.warn(`Failed job ${job.data.jobId} for repo ${repo.name} (id: ${repo.id}). Attempt ${attempt} / ${job.opts.attempts}. Retrying.`);
}
});
private onJobStalled = async (jobId: string) =>
groupmqLifecycleExceptionWrapper('onJobStalled', logger, async () => {
const logger = createJobLogger(jobId);
const { repo } = await this.db.repoIndexingJob.update({
where: { id: jobId },
data: {
status: RepoIndexingJobStatus.FAILED,
completedAt: new Date(),
errorMessage: 'Job stalled',
},
select: { repo: true }
});
logger.error(`Job ${jobId} stalled for repo ${repo.name} (id: ${repo.id})`);
});
private async onWorkerError(error: Error) {
Sentry.captureException(error);
logger.error(`Index syncer worker error.`, error);
}
public async dispose() {
if (this.interval) {
clearInterval(this.interval);
}
await this.worker.close();
await this.queue.close();
}
}

View file

@ -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();
}
}

View file

@ -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.

View file

@ -1,11 +1,12 @@
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";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { REPOS_CACHE_DIR } from "./constants.js";
export const measure = async <T>(cb: () => Promise<T>) => {
const start = Date.now();
@ -69,7 +70,7 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
// @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);
@ -81,7 +82,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,
}
}
@ -241,3 +242,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);
}
}

View file

@ -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;

View file

@ -4,5 +4,8 @@ export default defineConfig({
test: {
environment: 'node',
watch: false,
env: {
DATA_CACHE_DIR: 'test-data'
}
}
});

View file

@ -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;

View file

@ -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

View file

@ -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,
}

View file

@ -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();

View file

@ -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;
});

View file

@ -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";

View file

@ -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";

View file

@ -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",

View file

@ -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();

View file

@ -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 = ({

View file

@ -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;
};

View file

@ -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>

View file

@ -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);

View file

@ -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 >
)

View file

@ -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>
)
}

View file

@ -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">

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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" />
);
};

View file

@ -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>
)
}

View file

@ -13,7 +13,7 @@ interface Props {
} | null | ServiceError;
}
export const TrialNavIndicator = ({ subscription }: Props) => {
export const TrialIndicator = ({ subscription }: Props) => {
const domain = useDomain();
const captureEvent = useCaptureEvent();

View file

@ -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";

View file

@ -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>
);
};

View 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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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>
);
};

View file

@ -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&apos;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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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&apos;t be found. Please ensure you&apos;ve provided the information listed below correctly, and that you&apos;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&apos;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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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';
}
}

View file

@ -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>
)
}

View file

@ -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>
);
};

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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&apos;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>
);
};

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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>
);
}

View file

@ -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&apos;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",
}
]

View file

@ -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);
}
}
});
}

View file

@ -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>
)
}

View file

@ -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} />;
}

View file

@ -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 (

View file

@ -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">

View file

@ -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;
}));

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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";

View file

@ -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>
)
}

View file

@ -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.`,
});
}

View file

@ -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;
}
}

View file

@ -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>
))}

View file

@ -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<

View file

@ -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";

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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 ? (

View file

@ -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>
</>
)}
</>
)
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);

View file

@ -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 = ({

View file

@ -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