diff --git a/README.md b/README.md index 149b333..98365d9 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,86 @@ -# 🦅 Grepbase +# Grepbase -**Grepbase** is an AI-powered code exploration and visualization platform that helps developers understand the evolution of a codebase. It transforms complex git histories into an interactive, readable timeline enhanced with AI-generated explanations. +An AI-powered git history explorer. Paste a GitHub repository, walk every commit, and understand what changed and why — with AI. -## ✨ Features +## Features -- 🕰️ **Interactive Timeline**: Visualize the progression of any GitHub repository through its commit history. -- 🤖 **Multi-Provider AI**: Support for the latest models from **OpenAI (GPT-5.3)**, **Google (Gemini 3.1)**, **Anthropic (Claude 4.6)**, **GLM**, and **Kimi**. -- 📝 **AI Code Explanations**: Get deep technical insights into what changed in a commit and why it matters. -- 📂 **File Exploration**: Dive into specific files and have AI explain their purpose and patterns. -- 🔐 **Secure BYOK**: API keys are entered by users, then stored server-side in encrypted, session-scoped storage (never persisted in browser local/session storage). +- **Commit Timeline** — Navigate any public GitHub repository commit-by-commit with keyboard shortcuts (← →) +- **Code View** — Browse the file tree and read source at any point in history +- **Diff View** — Inspect what changed in a commit (unified or split) or compare any two commits +- **Story Mode** — AI narrates the arc of a commit in plain English +- **AI Chat** — Ask questions about any commit with full file context +- **Multi-Provider AI** — Works with OpenAI, Anthropic, Gemini, Ollama, LM Studio, GLM, and Kimi +- **BYOK** — API keys are encrypted server-side per session; never stored in the browser -## 🚀 Getting Started +## Getting Started ### Prerequisites -- [Bun](https://bun.sh) (Recommended runtime) -- Node.js & NPM +- [Bun](https://bun.sh) (recommended) or Node.js 20+ ### Setup -1. **Clone the repository**: - ```bash - git clone https://github.com/Khrees2412/grepbase.git - cd grepbase - ``` +1. Clone the repository: + ```bash + git clone https://github.com/Khrees2412/grepbase.git + cd grepbase + ``` -2. **Install dependencies**: - ```bash - bun install - ``` +2. Install dependencies: + ```bash + bun install + ``` -3. **Environment Variables**: - Create a `.env` file in the root directory: - ```env - GITHUB_TOKEN=your_github_personal_access_token - AI_CREDENTIALS_ENCRYPTION_KEY=generate_a_long_random_secret - AI_CREDENTIALS_SIGNING_KEY=generate_a_second_long_random_secret - ADMIN_API_KEY=generate_an_admin_secret_for_retry_endpoints - CLOUDFLARE_KV_NAMESPACE_ID=your_kv_namespace_id +3. Create a `.env.local` file: + ```env + # Required + GITHUB_TOKEN=your_github_personal_access_token - # Optional provider defaults (used when no user key is stored for a session) - OPENAI_API_KEY= - ANTHROPIC_API_KEY= - GEMINI_API_KEY= - GLM_API_KEY= - KIMI_API_KEY= + # Required — must be stable across restarts (session credentials become unreadable if changed) + AI_CREDENTIALS_ENCRYPTION_KEY=generate_a_long_random_secret + AI_CREDENTIALS_SIGNING_KEY=generate_a_second_long_random_secret - NEXT_PUBLIC_APP_URL=http://localhost:3000 - ``` - `AI_CREDENTIALS_ENCRYPTION_KEY` and `AI_CREDENTIALS_SIGNING_KEY` must be stable across deploys/restarts, otherwise encrypted session credentials become unreadable. + # Optional — used as fallback when no user key is configured for a session + OPENAI_API_KEY= + ANTHROPIC_API_KEY= + GEMINI_API_KEY= + GLM_API_KEY= + KIMI_API_KEY= -4. **Run Development Server**: - ```bash - bun run dev - ``` + # Optional + ADMIN_API_KEY=generate_an_admin_secret_for_retry_endpoints + NEXT_PUBLIC_APP_URL=http://localhost:3000 + ``` -5. **Open Grepbase**: - Navigate to [http://localhost:3000](http://localhost:3000) and enter a GitHub repository URL to start exploring. +4. Start the development server: + ```bash + bun run dev + ``` -## 🛠️ Tech Stack +5. Open [http://localhost:3000](http://localhost:3000) and paste a GitHub repository URL to start exploring. -- **Framework**: [Next.js](https://nextjs.org) (App Router) -- **AI Integration**: [Vercel AI SDK](https://sdk.vercel.ai) -- **Styling**: Vanilla CSS & [Framer Motion](https://www.framer.com/motion/) -- **Database**: Drizzle ORM -- **Runtime**: [Bun](https://bun.sh) +## Tech Stack -## 🤝 Contributing +| Layer | Technology | +|---|---| +| Framework | Next.js (App Router) | +| Runtime | Bun | +| Database | SQLite via Drizzle ORM | +| Styling | Vanilla CSS Modules | +| Animation | Framer Motion | +| AI | Vercel AI SDK (multi-provider) | -Contributions are welcome! Please feel free to submit a Pull Request. +## Usage -## 📄 License +1. On the home page, enter any public GitHub repository URL (e.g. `sindresorhus/is`) +2. Grepbase ingests the commit history into a local SQLite database +3. On the Explore page, use ← → to walk commits, or click in the timeline +4. Open Settings (⚙) to add your AI provider key and unlock explanations -MIT License +## Contributing + +Pull requests are welcome. For significant changes, open an issue first to discuss the approach. + +## License + +MIT diff --git a/bun.lockb b/bun.lockb index d09dbab..8402513 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..3a03fe5 Binary files /dev/null and b/dev.db differ diff --git a/drizzle/0000_nappy_catseye.sql b/drizzle/0000_nappy_catseye.sql new file mode 100644 index 0000000..225da7c --- /dev/null +++ b/drizzle/0000_nappy_catseye.sql @@ -0,0 +1,76 @@ +CREATE TABLE `analyses` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `commit_id` integer NOT NULL, + `provider` text NOT NULL, + `explanation` text NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`commit_id`) REFERENCES `commits`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_analyses_commit_id` ON `analyses` (`commit_id`);--> statement-breakpoint +CREATE TABLE `commits` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `repo_id` text NOT NULL, + `sha` text NOT NULL, + `message` text NOT NULL, + `author_name` text, + `author_email` text, + `date` integer NOT NULL, + `order` integer NOT NULL, + FOREIGN KEY (`repo_id`) REFERENCES `repositories`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `commits_repo_sha_unique` ON `commits` (`repo_id`,`sha`);--> statement-breakpoint +CREATE INDEX `idx_commits_repo_id` ON `commits` (`repo_id`);--> statement-breakpoint +CREATE INDEX `idx_commits_sha` ON `commits` (`sha`);--> statement-breakpoint +CREATE TABLE `files` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `commit_id` integer NOT NULL, + `path` text NOT NULL, + `content` text, + `size` integer DEFAULT 0, + `language` text, + FOREIGN KEY (`commit_id`) REFERENCES `commits`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_files_commit_path` ON `files` (`commit_id`,`path`);--> statement-breakpoint +CREATE INDEX `idx_files_commit_id` ON `files` (`commit_id`);--> statement-breakpoint +CREATE TABLE `ingest_jobs` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `job_id` text NOT NULL, + `url` text NOT NULL, + `status` text NOT NULL, + `repo_id` text, + `progress` integer DEFAULT 0, + `total_commits` integer DEFAULT 0, + `processed_commits` integer DEFAULT 0, + `error` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `retry_count` integer DEFAULT 0, + `max_retries` integer DEFAULT 3, + `last_error` text, + `last_retry_at` integer, + `resume_from_commit` integer DEFAULT 0, + FOREIGN KEY (`repo_id`) REFERENCES `repositories`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ingest_jobs_job_id_unique` ON `ingest_jobs` (`job_id`);--> statement-breakpoint +CREATE INDEX `idx_jobs_job_id` ON `ingest_jobs` (`job_id`);--> statement-breakpoint +CREATE INDEX `idx_jobs_status` ON `ingest_jobs` (`status`);--> statement-breakpoint +CREATE INDEX `idx_jobs_retry` ON `ingest_jobs` (`status`,`last_retry_at`,`retry_count`);--> statement-breakpoint +CREATE TABLE `repositories` ( + `id` text PRIMARY KEY NOT NULL, + `url` text NOT NULL, + `owner` text NOT NULL, + `name` text NOT NULL, + `description` text, + `stars` integer DEFAULT 0, + `default_branch` text DEFAULT 'main', + `readme` text, + `last_fetched` integer NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_url_unique` ON `repositories` (`url`);--> statement-breakpoint +CREATE INDEX `idx_repos_owner_name` ON `repositories` (`owner`,`name`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index b0390b0..8ddcda3 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "dfd2901e-6aa0-453d-a46e-3bc554e8278b", + "id": "cefce261-ebfa-4f47-aed9-acf959d37836", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "analyses": { @@ -83,7 +83,7 @@ }, "repo_id": { "name": "repo_id", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false @@ -132,9 +132,10 @@ } }, "indexes": { - "commits_sha_unique": { - "name": "commits_sha_unique", + "commits_repo_sha_unique": { + "name": "commits_repo_sha_unique", "columns": [ + "repo_id", "sha" ], "isUnique": true @@ -221,6 +222,14 @@ } }, "indexes": { + "idx_files_commit_path": { + "name": "idx_files_commit_path", + "columns": [ + "commit_id", + "path" + ], + "isUnique": true + }, "idx_files_commit_id": { "name": "idx_files_commit_id", "columns": [ @@ -281,7 +290,7 @@ }, "repo_id": { "name": "repo_id", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false @@ -426,10 +435,10 @@ "columns": { "id": { "name": "id", - "type": "integer", + "type": "text", "primaryKey": true, "notNull": true, - "autoincrement": true + "autoincrement": false }, "url": { "name": "url", diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 633ea6c..a6e5712 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,22 +5,8 @@ { "idx": 0, "version": "6", - "when": 1771757966096, - "tag": "0000_green_mandarin", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1771880517123, - "tag": "0001_busy_falcon", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1772025771427, - "tag": "0002_large_fabian_cortez", + "when": 1774997048038, + "tag": "0000_nappy_catseye", "breakpoints": true } ] diff --git a/drizzle/0000_green_mandarin.sql b/drizzle_migrations/0000_green_mandarin.sql similarity index 100% rename from drizzle/0000_green_mandarin.sql rename to drizzle_migrations/0000_green_mandarin.sql diff --git a/drizzle/0001_busy_falcon.sql b/drizzle_migrations/0001_busy_falcon.sql similarity index 100% rename from drizzle/0001_busy_falcon.sql rename to drizzle_migrations/0001_busy_falcon.sql diff --git a/drizzle/0002_large_fabian_cortez.sql b/drizzle_migrations/0002_large_fabian_cortez.sql similarity index 100% rename from drizzle/0002_large_fabian_cortez.sql rename to drizzle_migrations/0002_large_fabian_cortez.sql diff --git a/drizzle_migrations/meta/0000_snapshot.json b/drizzle_migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..b0390b0 --- /dev/null +++ b/drizzle_migrations/meta/0000_snapshot.json @@ -0,0 +1,533 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dfd2901e-6aa0-453d-a46e-3bc554e8278b", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "commit_id": { + "name": "commit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_analyses_commit_id": { + "name": "idx_analyses_commit_id", + "columns": [ + "commit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "analyses_commit_id_commits_id_fk": { + "name": "analyses_commit_id_commits_id_fk", + "tableFrom": "analyses", + "tableTo": "commits", + "columnsFrom": [ + "commit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commits": { + "name": "commits", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "commits_sha_unique": { + "name": "commits_sha_unique", + "columns": [ + "sha" + ], + "isUnique": true + }, + "idx_commits_repo_id": { + "name": "idx_commits_repo_id", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "idx_commits_sha": { + "name": "idx_commits_sha", + "columns": [ + "sha" + ], + "isUnique": false + } + }, + "foreignKeys": { + "commits_repo_id_repositories_id_fk": { + "name": "commits_repo_id_repositories_id_fk", + "tableFrom": "commits", + "tableTo": "repositories", + "columnsFrom": [ + "repo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "files": { + "name": "files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "commit_id": { + "name": "commit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_files_commit_id": { + "name": "idx_files_commit_id", + "columns": [ + "commit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "files_commit_id_commits_id_fk": { + "name": "files_commit_id_commits_id_fk", + "tableFrom": "files", + "tableTo": "commits", + "columnsFrom": [ + "commit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ingest_jobs": { + "name": "ingest_jobs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_commits": { + "name": "total_commits", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "processed_commits": { + "name": "processed_commits", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_retry_at": { + "name": "last_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resume_from_commit": { + "name": "resume_from_commit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "ingest_jobs_job_id_unique": { + "name": "ingest_jobs_job_id_unique", + "columns": [ + "job_id" + ], + "isUnique": true + }, + "idx_jobs_job_id": { + "name": "idx_jobs_job_id", + "columns": [ + "job_id" + ], + "isUnique": false + }, + "idx_jobs_status": { + "name": "idx_jobs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_jobs_retry": { + "name": "idx_jobs_retry", + "columns": [ + "status", + "last_retry_at", + "retry_count" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ingest_jobs_repo_id_repositories_id_fk": { + "name": "ingest_jobs_repo_id_repositories_id_fk", + "tableFrom": "ingest_jobs", + "tableTo": "repositories", + "columnsFrom": [ + "repo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'main'" + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fetched": { + "name": "last_fetched", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repositories_url_unique": { + "name": "repositories_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "idx_repos_owner_name": { + "name": "idx_repos_owner_name", + "columns": [ + "owner", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle_migrations/meta/0001_snapshot.json similarity index 100% rename from drizzle/meta/0001_snapshot.json rename to drizzle_migrations/meta/0001_snapshot.json diff --git a/drizzle/meta/0002_snapshot.json b/drizzle_migrations/meta/0002_snapshot.json similarity index 100% rename from drizzle/meta/0002_snapshot.json rename to drizzle_migrations/meta/0002_snapshot.json diff --git a/drizzle_migrations/meta/_journal.json b/drizzle_migrations/meta/_journal.json new file mode 100644 index 0000000..633ea6c --- /dev/null +++ b/drizzle_migrations/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1771757966096, + "tag": "0000_green_mandarin", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1771880517123, + "tag": "0001_busy_falcon", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1772025771427, + "tag": "0002_large_fabian_cortez", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..44ab4d8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,15 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + { + rules: { + "@typescript-eslint/no-unused-vars": ["warn", { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }], + }, + }, ]); export default eslintConfig; diff --git a/migrations b/migrations new file mode 120000 index 0000000..a0d5db8 --- /dev/null +++ b/migrations @@ -0,0 +1 @@ +drizzle_migrations \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4268213..197da89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,13 @@ "@ai-sdk/react": "^3.0.99", "@vercel/functions": "^3.4.2", "ai": "^6.0.97", + "better-sqlite3": "^12.8.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", + "nanoid": "^5.1.7", "next": "16.1.6", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -35,6 +37,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260228.0", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.3.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -2393,6 +2396,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "license": "MIT", @@ -2441,7 +2454,7 @@ }, "node_modules/@types/node": { "version": "25.3.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -3349,6 +3362,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "license": "Apache-2.0", @@ -3359,6 +3392,40 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "dev": true, @@ -3416,6 +3483,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "dev": true, @@ -3546,6 +3637,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "license": "MIT" @@ -3718,6 +3815,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -3764,7 +3885,6 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4601,6 +4721,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/extend": { "version": "3.0.2", "license": "MIT" @@ -4689,6 +4818,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -4771,6 +4906,12 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "dev": true, @@ -4896,6 +5037,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "dev": true, @@ -5085,6 +5232,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "dev": true, @@ -5116,6 +5283,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "license": "MIT" @@ -6545,6 +6724,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/miniflare": { "version": "4.20260219.0", "dev": true, @@ -6582,6 +6773,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.34.3", "license": "MIT", @@ -6598,7 +6795,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", "funding": [ { "type": "github", @@ -6607,12 +6806,18 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "dev": true, @@ -6683,6 +6888,30 @@ } } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "dev": true, @@ -7068,6 +7297,51 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -7158,6 +7432,30 @@ "version": "4.0.4", "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.3", "license": "MIT", @@ -7226,6 +7524,20 @@ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/real-require": { "version": "0.2.0", "license": "MIT", @@ -7415,6 +7727,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "dev": true, @@ -7663,6 +7995,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "license": "MIT", @@ -7726,6 +8103,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "dev": true, @@ -7935,6 +8321,34 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", @@ -8015,6 +8429,18 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -8157,7 +8583,7 @@ }, "node_modules/undici-types": { "version": "7.18.2", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unenv": { @@ -8322,6 +8748,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "license": "MIT", diff --git a/package.json b/package.json index 1bc79ce..c9de0f1 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,16 @@ "@ai-sdk/openai": "^3.0.30", "@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/react": "^3.0.99", + "@tanstack/react-query": "^5.96.2", "@vercel/functions": "^3.4.2", "ai": "^6.0.97", + "better-sqlite3": "^12.8.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", + "nanoid": "^5.1.7", "next": "16.1.6", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -36,10 +39,12 @@ "react-resizable-panels": "^2.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260228.0", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.3.0", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..d55a0b0 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..42a7771 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..3fa950c Binary files /dev/null and b/public/og-image.png differ diff --git a/sqlite.db b/sqlite.db new file mode 100644 index 0000000..e69de29 diff --git a/src/app/ClientHero.tsx b/src/app/ClientHero.tsx index 6082d45..be14e93 100644 --- a/src/app/ClientHero.tsx +++ b/src/app/ClientHero.tsx @@ -1,12 +1,25 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { Github, ArrowRight, Loader2 } from 'lucide-react'; +import { Github, ArrowRight, Loader2, BookOpen } from 'lucide-react'; import { api } from '@/lib/api-client'; +import { Logo } from '@/components/Logo'; +import BranchPicker from '@/components/BranchPicker'; -interface Repository { - id: number; +const RECENT_KEY = 'grepbase:recent_repos'; + +interface RecentRepo { + id: string; + owner: string; + name: string; + visitedAt: number; +} + +interface JobData { + jobId?: string; + repository?: { id: string | number }; + cached?: boolean; } export default function ClientHero({ styles }: { styles: Record }) { @@ -15,14 +28,60 @@ export default function ClientHero({ styles }: { styles: Record const [error, setError] = useState(null); const [validationError, setValidationError] = useState(null); const [isValid, setIsValid] = useState(false); + const [recentRepos, setRecentRepos] = useState([]); + + // Branch-picker state + const [pickingBranch, setPickingBranch] = useState(false); + const [pendingJobData, setPendingJobData] = useState(null); + const [repoMeta, setRepoMeta] = useState<{ owner: string; repo: string } | null>(null); + const router = useRouter(); + useEffect(() => { + try { + const stored: RecentRepo[] = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); + if (!Array.isArray(stored)) return; + // Deduplicate by owner/name, keeping the most recent entry + const seen = new Set(); + const deduped = stored.filter(r => { + const key = `${r.owner}/${r.name}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (deduped.length !== stored.length) { + localStorage.setItem(RECENT_KEY, JSON.stringify(deduped)); + } + setRecentRepos(deduped); + } catch { /* ignore */ } + }, []); + + function saveRecentRepo(id: string, owner: string, name: string) { + try { + const existing: RecentRepo[] = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); + const filtered = existing.filter(r => r.id !== id && !(r.owner === owner && r.name === name)); + const updated = [{ id, owner, name, visitedAt: Date.now() }, ...filtered].slice(0, 6); + localStorage.setItem(RECENT_KEY, JSON.stringify(updated)); + setRecentRepos(updated); + } catch { /* ignore */ } + } + + function parseOwnerRepo(rawUrl: string): { owner: string; repo: string } | null { + try { + const parts = rawUrl.trim() + .replace(/^(https?:\/\/)?(www\.)?github\.com\//i, '') + .replace(/\.git\/?$/, '') + .split('/') + .filter(Boolean); + if (parts.length >= 2) return { owner: parts[0], repo: parts[1] }; + } catch { /* ignore */ } + return null; + } + function validateRepoUrl(input: string): { valid: boolean; error: string | null } { const trimmed = input.trim(); - if (!trimmed) { - return { valid: false, error: null }; - } + if (!trimmed) return { valid: false, error: null }; let normalized = trimmed .replace(/^(https?:\/\/)?(www\.)?/i, '') @@ -44,15 +103,12 @@ export default function ClientHero({ styles }: { styles: Record if (parts.length === 2) { const [owner, repo] = parts; - if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(owner)) { return { valid: false, error: 'Invalid repository owner name' }; } - if (!/^[a-zA-Z0-9._-]+$/.test(repo)) { return { valid: false, error: 'Invalid repository name' }; } - return { valid: true, error: null }; } @@ -70,74 +126,83 @@ export default function ClientHero({ styles }: { styles: Record setIsValid(result.valid); setValidationError(result.error); if (error) setError(null); + // Reset picker if URL changes + if (pickingBranch) setPickingBranch(false); } - async function handleSubmit(e: React.FormEvent) { + /** Poll a job until a repoId resolves, then navigate. */ + const navigateWithJobData = useCallback(async (data: JobData, owner: string, repo: string) => { + if (data.repository?.id) { + saveRecentRepo(String(data.repository.id), owner, repo); + router.push(`/explore/${data.repository.id}`); + return; + } + + if (!data.jobId) return; + + let attempts = 0; + const maxAttempts = 60; + + const poll = async (): Promise => { + attempts++; + const jobResponse = await api.get<{ + status: string; + error?: string; + ready?: boolean; + processedCommits?: number; + repoId?: number | null; + repository?: { id: number }; + }>(`/api/jobs/${data.jobId}`); + + const resolvedRepoId = jobResponse.repository?.id ?? jobResponse.repoId ?? null; + if (resolvedRepoId) { + saveRecentRepo(String(resolvedRepoId), owner, repo); + router.push(`/explore/${resolvedRepoId}`); + } else if (jobResponse.status === 'failed') { + throw new Error(jobResponse.error || 'Failed to fetch repository'); + } else if (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return poll(); + } else { + throw new Error('Repository fetch timed out'); + } + }; + + await poll(); + }, [router]); + + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!isValid) return; setError(null); setLoading(true); + setPickingBranch(false); try { - const data = await api.post<{ - error?: string; - repository?: Repository; - jobId?: string; - cached?: boolean; - }>('/api/repos', { url }); - - if (data.cached && data.repository) { - // Background job might be running due to explicit revalidation - if (data.jobId) { - router.push(`/explore/${data.repository.id}?jobId=${data.jobId}`); - } else { - router.push(`/explore/${data.repository.id}`); - } - return; - } + const data = await api.post('/api/repos', { url }); + const meta = parseOwnerRepo(url); - if (data.jobId) { - let attempts = 0; - const maxAttempts = 60; - - const poll = async (): Promise => { - attempts++; - const jobResponse = await api.get<{ - status: string; - error?: string; - ready?: boolean; - processedCommits?: number; - repoId?: number | null; - repository?: { id: number }; - }>(`/api/jobs/${data.jobId}`); - - const resolvedRepoId = jobResponse.repository?.id ?? jobResponse.repoId ?? null; - if (resolvedRepoId) { - const basePath = `/explore/${resolvedRepoId}`; - if (jobResponse.status === 'completed') { - router.push(basePath); - } else { - router.push(`${basePath}?jobId=${data.jobId}`); - } - return; - } else if (jobResponse.status === 'failed') { - throw new Error(jobResponse.error || 'Failed to fetch repository'); - } else if (attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, 2000)); - return poll(); - } else { - throw new Error('Repository fetch timed out'); - } - }; - - await poll(); - return; - } + // Show branch picker while default-branch ingestion runs in background + setPendingJobData(data); + setRepoMeta(meta); + setPickingBranch(true); + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch repository'); + setLoading(false); + } + } - if (data.repository) { - router.push(`/explore/${data.repository.id}`); - } + async function handleBranchConfirm(jobData: JobData) { + setPickingBranch(false); + setLoading(true); + + const owner = repoMeta?.owner ?? ''; + const repo = repoMeta?.repo ?? ''; + + try { + await navigateWithJobData(jobData, owner, repo); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch repository'); setLoading(false); @@ -147,12 +212,17 @@ export default function ClientHero({ styles }: { styles: Record return (
+
+ +
+

AI-powered git history explorer

+

Grepbase

- Understand code history with AI-powered explanations. + Paste a GitHub repo. Walk every commit. Understand what changed and why — with AI.

@@ -177,7 +247,7 @@ export default function ClientHero({ styles }: { styles: Record + {pickingBranch && pendingJobData && repoMeta && ( + + )} + {error &&
{error}
} + + {!pickingBranch && recentRepos.length > 0 && ( +
+

Recent

+
+ {recentRepos.map(repo => ( + + + {repo.owner}/{repo.name} + + ))} +
+
+ )}
); diff --git a/src/app/api/explain/commit/route.ts b/src/app/api/explain/commit/route.ts index fd1982e..f8e9466 100644 --- a/src/app/api/explain/commit/route.ts +++ b/src/app/api/explain/commit/route.ts @@ -20,15 +20,18 @@ import { getClientIdFromHeaders, resolveAvailableFilePathsForCommit, resolveProv export async function POST(request: NextRequest) { const requestLogger = logger.child({ endpoint: 'POST /api/explain/commit' }); const startTime = Date.now(); + requestLogger.debug({ method: request.method, url: request.url }, 'Request received'); try { const csrfError = enforceCsrfProtection(request); if (csrfError) { + requestLogger.warn('CSRF validation failed'); return csrfError; } const session = await resolveSession(request); if (!session) { + requestLogger.warn('Session not found'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -63,6 +66,7 @@ export async function POST(request: NextRequest) { const repoAccess = await hasRepoAccess(repoId, session.sessionId); if (!repoAccess) { + requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Repository access denied for explain'); return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } diff --git a/src/app/api/repos/[id]/commits/[sha]/content/route.ts b/src/app/api/repos/[id]/commits/[sha]/content/route.ts index c97a2aa..aa93051 100644 --- a/src/app/api/repos/[id]/commits/[sha]/content/route.ts +++ b/src/app/api/repos/[id]/commits/[sha]/content/route.ts @@ -3,20 +3,11 @@ import { and, eq } from 'drizzle-orm'; import { repositories, commits, files } from '@/db'; import { getDb } from '@/db'; import { logger } from '@/lib/logger'; -import { RATE_LIMITS } from '@/lib/constants'; +import { RATE_LIMITS, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; import { hasRepoAccess } from '@/services/resource-access'; import { fetchFileContent, getLanguageFromPath } from '@/services/github'; - -const COMMIT_SHA_REGEX = /^[0-9a-f]{7,64}$/i; -const MAX_FILE_PATH_LENGTH = 1024; - -function isSafeFilePath(path: string): boolean { - if (path.length === 0 || path.length > MAX_FILE_PATH_LENGTH) return false; - if (path.includes('\0') || path.startsWith('/')) return false; - if (path.includes('?') || path.includes('#') || path.includes('\\')) return false; - return !path.split('/').some(segment => segment === '.' || segment === '..'); -} +import { isSafeFilePath } from '@/lib/sanitize'; export async function GET( request: NextRequest, @@ -41,13 +32,9 @@ export async function GET( } const { id, sha } = await params; - const repoId = Number.parseInt(id, 10); + const repoId = id; const filePath = request.nextUrl.searchParams.get('path')?.trim() || ''; - if (Number.isNaN(repoId)) { - return NextResponse.json({ error: 'Invalid repository ID' }, { status: 400 }); - } - if (!COMMIT_SHA_REGEX.test(sha)) { return NextResponse.json({ error: 'Invalid commit SHA' }, { status: 400 }); } @@ -56,24 +43,24 @@ export async function GET( return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Forbidden repository access'); - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + try { + const repoAccess = await hasRepoAccess(repoId, session.sessionId); + if (!repoAccess) { + const { safeGrantRepoAccess } = await import('@/services/resource-access'); + await safeGrantRepoAccess(repoId, session.sessionId); + requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); + } + } catch { + requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); } - const repo = await db.select() - .from(repositories) - .where(eq(repositories.id, repoId)) - .limit(1); + const [repo, commit] = await Promise.all([ + db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1), + db.select().from(commits).where(and(eq(commits.repoId, repoId), eq(commits.sha, sha))).limit(1), + ]); if (repo.length === 0) { return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); } - - const commit = await db.select() - .from(commits) - .where(and(eq(commits.repoId, repoId), eq(commits.sha, sha))) - .limit(1); if (commit.length === 0) { return NextResponse.json({ error: 'Commit not found' }, { status: 404 }); } @@ -114,6 +101,11 @@ export async function GET( }) ); } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('rate limit exceeded')) { + requestLogger.warn({ error: { message: errorMessage } }, 'GitHub rate limit hit fetching file content'); + return NextResponse.json({ error: errorMessage }, { status: 429 }); + } requestLogger.error({ error }, 'Failed to fetch file content'); return NextResponse.json( { error: 'Failed to fetch file content' }, diff --git a/src/app/api/repos/[id]/commits/[sha]/diff/route.ts b/src/app/api/repos/[id]/commits/[sha]/diff/route.ts index 1e460ef..b6d6554 100644 --- a/src/app/api/repos/[id]/commits/[sha]/diff/route.ts +++ b/src/app/api/repos/[id]/commits/[sha]/diff/route.ts @@ -3,12 +3,11 @@ import { and, eq } from 'drizzle-orm'; import { repositories, commits } from '@/db'; import { getDb } from '@/db'; import { logger } from '@/lib/logger'; -import { RATE_LIMITS } from '@/lib/constants'; +import { RATE_LIMITS, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; import { hasRepoAccess } from '@/services/resource-access'; import { fetchCommitFileDiffs } from '@/services/github'; - -const COMMIT_SHA_REGEX = /^[0-9a-f]{7,64}$/i; +import { isSafeFilePath } from '@/lib/sanitize'; export async function GET( request: NextRequest, @@ -33,48 +32,53 @@ export async function GET( } const { id, sha } = await params; - const repoId = Number.parseInt(id, 10); - - if (Number.isNaN(repoId)) { - return NextResponse.json({ error: 'Invalid repository ID' }, { status: 400 }); - } + const repoId = id; if (!COMMIT_SHA_REGEX.test(sha)) { return NextResponse.json({ error: 'Invalid commit SHA' }, { status: 400 }); } - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Forbidden repository access'); - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const filePathParam = request.nextUrl.searchParams.get('path'); + const filePath = filePathParam?.trim() || null; + if (filePath && !isSafeFilePath(filePath)) { + return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } - const repo = await db.select() - .from(repositories) - .where(eq(repositories.id, repoId)) - .limit(1); + try { + const repoAccess = await hasRepoAccess(repoId, session.sessionId); + if (!repoAccess) { + const { safeGrantRepoAccess } = await import('@/services/resource-access'); + await safeGrantRepoAccess(repoId, session.sessionId); + requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); + } + } catch { + requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); + } + + const [repo, commit] = await Promise.all([ + db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1), + db.select().from(commits).where(and(eq(commits.repoId, repoId), eq(commits.sha, sha))).limit(1), + ]); if (repo.length === 0) { return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); } - - const commit = await db.select() - .from(commits) - .where(and(eq(commits.repoId, repoId), eq(commits.sha, sha))) - .limit(1); if (commit.length === 0) { return NextResponse.json({ error: 'Commit not found' }, { status: 404 }); } - const changedFiles = await fetchCommitFileDiffs(repo[0].owner, repo[0].name, sha); - const totalAdditions = changedFiles.reduce((sum, file) => sum + file.additions, 0); - const totalDeletions = changedFiles.reduce((sum, file) => sum + file.deletions, 0); + const allChangedFiles = await fetchCommitFileDiffs(repo[0].owner, repo[0].name, sha); + const filteredFiles = filePath + ? allChangedFiles.filter(file => file.path === filePath || file.previousPath === filePath) + : allChangedFiles; + const totalAdditions = filteredFiles.reduce((sum, file) => sum + file.additions, 0); + const totalDeletions = filteredFiles.reduce((sum, file) => sum + file.deletions, 0); return applyPrivateNoStoreHeaders( NextResponse.json({ commit: commit[0], - files: changedFiles, + files: filteredFiles, stats: { - changedFiles: changedFiles.length, + changedFiles: filteredFiles.length, additions: totalAdditions, deletions: totalDeletions, }, diff --git a/src/app/api/repos/[id]/commits/[sha]/route.ts b/src/app/api/repos/[id]/commits/[sha]/route.ts index 33a34a7..65c68d5 100644 --- a/src/app/api/repos/[id]/commits/[sha]/route.ts +++ b/src/app/api/repos/[id]/commits/[sha]/route.ts @@ -3,13 +3,11 @@ import { and, eq } from 'drizzle-orm'; import { repositories, commits, files } from '@/db'; import { getDb } from '@/db'; import { logger } from '@/lib/logger'; -import { RATE_LIMITS, INGEST, shouldFetchFileContent } from '@/lib/constants'; +import { RATE_LIMITS, INGEST, shouldFetchFileContent, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; import { hasRepoAccess } from '@/services/resource-access'; import { fetchFilesAtCommit, getLanguageFromPath } from '@/services/github'; -const COMMIT_SHA_REGEX = /^[0-9a-f]{7,64}$/i; - export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string; sha: string }> } @@ -33,34 +31,30 @@ export async function GET( } const { id, sha } = await params; - const repoId = Number.parseInt(id, 10); - - if (Number.isNaN(repoId)) { - return NextResponse.json({ error: 'Invalid repository ID' }, { status: 400 }); - } + const repoId = id; if (!COMMIT_SHA_REGEX.test(sha)) { return NextResponse.json({ error: 'Invalid commit SHA' }, { status: 400 }); } - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Forbidden repository access'); - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + try { + const repoAccess = await hasRepoAccess(repoId, session.sessionId); + if (!repoAccess) { + const { safeGrantRepoAccess } = await import('@/services/resource-access'); + await safeGrantRepoAccess(repoId, session.sessionId); + requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); + } + } catch { + requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); } - const repo = await db.select() - .from(repositories) - .where(eq(repositories.id, repoId)) - .limit(1); + const [repo, commit] = await Promise.all([ + db.select().from(repositories).where(eq(repositories.id, repoId)).limit(1), + db.select().from(commits).where(and(eq(commits.repoId, repoId), eq(commits.sha, sha))).limit(1), + ]); if (repo.length === 0) { return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); } - - const commit = await db.select() - .from(commits) - .where(and(eq(commits.repoId, repoId), eq(commits.sha, sha))) - .limit(1); if (commit.length === 0) { return NextResponse.json({ error: 'Commit not found' }, { status: 404 }); } @@ -118,7 +112,7 @@ export async function GET( const batchSize = INGEST.FILE_BATCH_INSERT_SIZE; for (let i = 0; i < dbFiles.length; i += batchSize) { - await db.insert(files).values(dbFiles.slice(i, i + batchSize)); + await db.insert(files).values(dbFiles.slice(i, i + batchSize)).onConflictDoNothing(); } } @@ -136,9 +130,15 @@ export async function GET( }) ); } catch (error) { - requestLogger.error({ error }, 'Failed to fetch commit files'); + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + if (errorMessage.includes('rate limit exceeded')) { + requestLogger.warn({ error: { message: errorMessage } }, 'GitHub rate limit hit fetching commit files'); + return NextResponse.json({ error: errorMessage }, { status: 429 }); + } + requestLogger.error({ error: { message: errorMessage, stack: errorStack, type: typeof error } }, 'Failed to fetch commit files'); return NextResponse.json( - { error: 'Failed to fetch files' }, + { error: 'Failed to fetch files', details: errorMessage }, { status: 500 } ); } diff --git a/src/app/api/repos/[id]/commits/route.ts b/src/app/api/repos/[id]/commits/route.ts index 71a8230..4506be1 100644 --- a/src/app/api/repos/[id]/commits/route.ts +++ b/src/app/api/repos/[id]/commits/route.ts @@ -33,18 +33,7 @@ export async function GET( } const { id } = await params; - const repoId = parseInt(id, 10); - - if (isNaN(repoId)) { - requestLogger.warn({ id }, 'Invalid repository ID'); - return NextResponse.json({ error: 'Invalid repository ID' }, { status: 400 }); - } - - const repoAccess = await hasRepoAccess(repoId, session.sessionId); - if (!repoAccess) { - requestLogger.warn({ repoId, sessionId: session.sessionId }, 'Forbidden repository access'); - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } + const repoId = id; // Parse pagination params from query string const url = new URL(request.url); @@ -59,7 +48,7 @@ export async function GET( : PAGINATION.DEFAULT_LIMIT; const offset = (page - 1) * limit; - // Check if repo exists + // Check if repo exists and get repo data const repo = await db.select() .from(repositories) .where(eq(repositories.id, repoId)) @@ -70,22 +59,31 @@ export async function GET( return NextResponse.json({ error: 'Repository not found' }, { status: 404 }); } - // Get total count - const totalResult = await db.select({ count: sql`count(*)` }) - .from(commits) - .where(eq(commits.repoId, repoId)); - const total = Number(totalResult[0]?.count || 0); + // Access control: check KV-based access grant, but allow access if repo exists in DB + // This supports both multi-tenant (with KV) and single-user (DB-only) deployments + try { + const repoAccess = await hasRepoAccess(repoId, session.sessionId); + if (!repoAccess) { + // No access grant - try to create one, but don't block access + const { safeGrantRepoAccess } = await import('@/services/resource-access'); + await safeGrantRepoAccess(repoId, session.sessionId); + requestLogger.info({ repoId, sessionId: session.sessionId }, 'Auto-granted repository access'); + } + } catch { + // Access control system unavailable - allow access to existing repos + requestLogger.debug({ repoId, sessionId: session.sessionId }, 'Access control unavailable, allowing access to existing repo'); + } - // Fetch commits with pagination ordered by their position (oldest first) - const repoCommits = await db.select() - .from(commits) - .where(eq(commits.repoId, repoId)) - .orderBy(asc(commits.order)) - .limit(limit) - .offset(offset); + // Run count and data fetch in parallel + const [totalResult, repoCommits] = await Promise.all([ + db.select({ count: sql`count(*)` }).from(commits).where(eq(commits.repoId, repoId)), + db.select().from(commits).where(eq(commits.repoId, repoId)).orderBy(asc(commits.order)).limit(limit).offset(offset), + ]); + const total = Number(totalResult[0]?.count || 0); requestLogger.info({ repoId, page, limit, total }, 'Commits fetched successfully'); + const now = new Date().toISOString(); return applyPrivateNoStoreHeaders( NextResponse.json({ repository: repo[0], @@ -98,6 +96,10 @@ export async function GET( hasNext: offset + limit < total, hasPrev: page > 1, }, + cache: { + stale: false, + lastFetched: now, + }, }) ); } catch (error) { diff --git a/src/app/api/repos/[id]/compare/route.ts b/src/app/api/repos/[id]/compare/route.ts index 54c98a5..4af5efb 100644 --- a/src/app/api/repos/[id]/compare/route.ts +++ b/src/app/api/repos/[id]/compare/route.ts @@ -3,13 +3,11 @@ import { and, eq, inArray } from 'drizzle-orm'; import { repositories, commits } from '@/db'; import { getDb } from '@/db'; import { logger } from '@/lib/logger'; -import { RATE_LIMITS } from '@/lib/constants'; +import { RATE_LIMITS, COMMIT_SHA_REGEX } from '@/lib/constants'; import { applyPrivateNoStoreHeaders, enforceRateLimit, resolveSession } from '@/lib/api-security'; import { hasRepoAccess } from '@/services/resource-access'; import { fetchCompareDiff } from '@/services/github'; - -const COMMIT_SHA_REGEX = /^[0-9a-f]{7,64}$/i; -const MAX_FILE_PATH_LENGTH = 1024; +import { isSafeFilePath } from '@/lib/sanitize'; export async function GET( request: NextRequest, @@ -34,11 +32,7 @@ export async function GET( } const { id } = await params; - const repoId = Number.parseInt(id, 10); - - if (Number.isNaN(repoId)) { - return NextResponse.json({ error: 'Invalid repository ID' }, { status: 400 }); - } + const repoId = id; const repoAccess = await hasRepoAccess(repoId, session.sessionId); if (!repoAccess) { @@ -59,7 +53,7 @@ export async function GET( return NextResponse.json({ error: 'Invalid base/head commit SHA' }, { status: 400 }); } - if (filePath && (filePath.length > MAX_FILE_PATH_LENGTH || filePath.includes('\0') || filePath.startsWith('/') || filePath.split('/').some(s => s === '.' || s === '..'))) { + if (filePath && !isSafeFilePath(filePath)) { return NextResponse.json({ error: 'Invalid file path' }, { status: 400 }); } diff --git a/src/app/api/repos/branches/route.ts b/src/app/api/repos/branches/route.ts new file mode 100644 index 0000000..440de03 --- /dev/null +++ b/src/app/api/repos/branches/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { parseGitHubUrl, sanitizeGitHubUrl } from '@/lib/sanitize'; +import { fetchRepoBranches } from '@/services/github'; +import { + applyPrivateNoStoreHeaders, + enforceRateLimit, + resolveSession, +} from '@/lib/api-security'; +import { RATE_LIMITS } from '@/lib/constants'; + +/** + * GET /api/repos/branches?url= + * Returns the list of branches and the default branch for a public repository. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const rawUrl = searchParams.get('url'); + + if (!rawUrl) { + return NextResponse.json({ error: 'url parameter is required' }, { status: 400 }); + } + + try { + const sanitizedUrl = sanitizeGitHubUrl(rawUrl); + const { owner, repo } = parseGitHubUrl(sanitizedUrl); + + const session = await resolveSession(request, { createIfMissing: false }); + if (session?.sessionId) { + const rateLimitError = await enforceRateLimit(request, { + keyPrefix: 'api:branches:get', + limit: RATE_LIMITS.GENERAL_API, + sessionId: session.sessionId, + }); + if (rateLimitError) { + return applyPrivateNoStoreHeaders(rateLimitError.response); + } + } + + const result = await fetchRepoBranches(owner, repo); + return applyPrivateNoStoreHeaders(NextResponse.json(result)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch branches'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/repos/route.ts b/src/app/api/repos/route.ts index 3355670..73bea12 100644 --- a/src/app/api/repos/route.ts +++ b/src/app/api/repos/route.ts @@ -102,6 +102,7 @@ export async function GET(request: NextRequest) { requestLogger.info({ sessionId: session.sessionId, count: repoList.length }, 'Repositories fetched'); + const now = new Date().toISOString(); return finalizeSessionResponse( session, NextResponse.json({ @@ -114,6 +115,10 @@ export async function GET(request: NextRequest) { hasNext: offset + limit < total, hasPrev: page > 1, }, + cache: { + stale: false, + lastFetched: now, + }, }) ); } catch (error) { @@ -172,14 +177,16 @@ export async function POST(request: NextRequest) { ); } - const { url } = parseResult.data; + const { url, branch, startSha, clearExisting } = parseResult.data; const sanitizedUrl = sanitizeGitHubUrl(url); const { owner, repo: repoName } = parseGitHubUrl(sanitizedUrl); - requestLogger.info({ owner, repo: repoName, sessionId: session.sessionId }, 'Processing repository ingest'); + // Non-default branches get their own DB entry keyed by URL@branch + const repoKey = branch ? `${sanitizedUrl}@${branch}` : sanitizedUrl; + requestLogger.info({ owner, repo: repoName, branch, sessionId: session.sessionId }, 'Processing repository ingest'); const existingRepoResult = await db.select() .from(repositories) - .where(eq(repositories.url, sanitizedUrl)) + .where(eq(repositories.url, repoKey)) .limit(1); if (existingRepoResult.length > 0) { @@ -219,7 +226,7 @@ export async function POST(request: NextRequest) { const now = new Date(); await db.insert(ingestJobs).values({ jobId, - url: sanitizedUrl, + url: repoKey, status: 'pending', progress: 0, createdAt: now, @@ -230,9 +237,14 @@ export async function POST(request: NextRequest) { const ingestionPromise = processRepoIngestion({ jobId, - url: sanitizedUrl, + url: repoKey, clientId: session.sessionId, db, + owner, + repoName, + branch, + startSha, + clearExisting, }).catch((err) => { logger.error({ err, jobId, owner, repo: repoName }, 'Background ingestion failed'); }); @@ -258,7 +270,7 @@ export async function POST(request: NextRequest) { void trackRepoIngestPromise; } - if (existingCommitCount > 0) { + if (existingCommitCount > 0 && !clearExisting) { requestLogger.info({ owner, repo: repoName, duration }, 'Repository already cached, refreshing in background'); return finalizeSessionResponse( session, @@ -289,7 +301,7 @@ export async function POST(request: NextRequest) { const activeUrlJobResult = await db.select() .from(ingestJobs) .where(and( - eq(ingestJobs.url, sanitizedUrl), + eq(ingestJobs.url, repoKey), inArray(ingestJobs.status, ACTIVE_JOB_STATUSES) )) .orderBy(desc(ingestJobs.updatedAt)) @@ -328,7 +340,7 @@ export async function POST(request: NextRequest) { const now = new Date(); await db.insert(ingestJobs).values({ jobId, - url: sanitizedUrl, + url: repoKey, status: 'pending', progress: 0, createdAt: now, @@ -336,11 +348,24 @@ export async function POST(request: NextRequest) { }); await safeGrantJobAccess(jobId, session.sessionId); + const newRepoResult = await db.select() + .from(repositories) + .where(eq(repositories.url, repoKey)) + .limit(1); + if (newRepoResult.length > 0) { + await safeGrantRepoAccess(newRepoResult[0].id, session.sessionId); + } + const ingestionPromise = processRepoIngestion({ jobId, - url: sanitizedUrl, + url: repoKey, clientId: session.sessionId, db, + owner, + repoName, + branch, + startSha, + clearExisting, }).catch((err) => { logger.error({ err, jobId, owner, repo: repoName }, 'Background ingestion failed'); }); diff --git a/src/app/explore/[id]/explore.module.css b/src/app/explore/[id]/explore.module.css index 087e6a1..4fb018d 100644 --- a/src/app/explore/[id]/explore.module.css +++ b/src/app/explore/[id]/explore.module.css @@ -5,618 +5,860 @@ overflow: hidden; } -/* Header */ +/* Background-fetch progress bar */ +.fetchingBar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + z-index: 200; + background: linear-gradient( + 90deg, + transparent 0%, + var(--accent-primary) 40%, + rgba(0, 164, 255, 0.9) 60%, + transparent 100% + ); + background-size: 60% 100%; + animation: fetchSweep 1.6s ease-in-out infinite; +} + +@keyframes fetchSweep { + 0% { background-position: -100% 0; } + 100% { background-position: 220% 0; } +} + +/* ─── Header ───────────────────────────────────────────────────────── */ .header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-md) var(--space-lg); + height: 36px; + padding: 0 var(--space-sm); background: var(--bg-primary); - border-bottom: 2px solid var(--border-subtle); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; -} - -.headerCompact { - padding-top: var(--space-sm); - padding-bottom: var(--space-sm); + gap: var(--space-sm); } .headerLeft, .headerRight { display: flex; align-items: center; - gap: var(--space-sm); + gap: 2px; + flex-shrink: 0; +} + +.headerHomeBtn { + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.headerBtn { + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; } .repoInfo { display: flex; align-items: center; - gap: var(--space-sm); - padding-left: var(--space-md); - border-left: 1px solid var(--border-subtle); - margin-left: var(--space-sm); + gap: 6px; + padding-left: var(--space-sm); + margin-left: 2px; + border-left: 1px solid rgba(255, 255, 255, 0.07); } -.repoInfo svg { +.repoIcon { color: var(--accent-primary); + opacity: 0.8; } .repoName { + font-size: 0.8rem; font-weight: 600; - color: var(--text-primary); + color: var(--text-secondary); + letter-spacing: -0.01em; } -.headerCenter { - flex: 1; +.repoSlash { + color: var(--text-muted); + font-weight: 400; + margin: 0 1px; +} + +/* ─── Branch switcher ────────────────────────────────────────────── */ +.branchSwitcher { + position: relative; display: flex; - justify-content: center; - margin: 0 var(--space-lg); + align-items: center; + margin-left: 4px; } -.chapterTrigger { +.branchBadge { display: flex; align-items: center; - justify-content: space-between; - gap: var(--space-md); - padding: 6px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-default); - border-radius: 0.5rem; + gap: 4px; + padding: 2px 7px; + height: 20px; + background: rgba(0, 112, 243, 0.08); + border: 1px solid rgba(0, 112, 243, 0.2); + border-radius: 999px; + color: var(--accent-primary); + font-size: 0.68rem; + font-family: var(--font-mono); + font-weight: 500; cursor: pointer; - transition: all var(--transition-fast); - min-width: 280px; - max-width: 480px; - text-align: left; + white-space: nowrap; + transition: background var(--transition-fast), border-color var(--transition-fast); } -.chapterTrigger:hover { - border-color: var(--accent-primary); - background: rgba(255, 255, 255, 0.06); +.branchBadge:hover:not(:disabled) { + background: rgba(0, 112, 243, 0.14); + border-color: rgba(0, 112, 243, 0.35); } -.chapterInfo { - display: flex; - flex-direction: column; - gap: 0px; - overflow: hidden; - flex: 1; +.branchBadgeOpen { + background: rgba(0, 112, 243, 0.14); + border-color: rgba(0, 112, 243, 0.35); } -.chapterLabel { - font-size: 0.7rem; - color: var(--text-muted); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; +.branchBadge:disabled { + opacity: 0.6; + cursor: default; } -.chapterTitle { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.branchChevron { + opacity: 0.6; + flex-shrink: 0; } -.chapterChevron { - color: var(--text-muted); - opacity: 0.7; +.branchSpinner { + animation: branchSpin 0.8s linear infinite; } -.progress { - display: flex; - flex-direction: column; - gap: var(--space-xs); +@keyframes branchSpin { + to { transform: rotate(360deg); } } -.progressText { - font-size: 0.8rem; - color: var(--text-secondary); - text-align: center; +.branchMenu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 100; + min-width: 180px; + max-width: 280px; + max-height: 240px; + overflow-y: auto; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + animation: branchMenuIn 120ms ease; } -.progressBar { - height: 4px; - background: var(--bg-tertiary); - border-radius: 2px; - overflow: hidden; +@keyframes branchMenuIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } } -.progressFill { - height: 100%; - background: var(--accent-primary); - transition: width var(--transition-default); +.branchMenuLoading { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + font-size: 0.75rem; + color: var(--text-muted); } -.active { - background: var(--accent-primary) !important; - color: white !important; +.branchMenuItem { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: 0.76rem; + font-family: var(--font-mono); + cursor: pointer; + text-align: left; + transition: background var(--transition-fast), color var(--transition-fast); + overflow: hidden; } -/* Main Layout */ -.main { - display: flex; +.branchMenuItem span:nth-child(2) { flex: 1; overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } -/* Sidebar */ -.sidebar { - width: 280px; - background: var(--bg-primary); - border-right: 1px solid var(--border-subtle); - display: flex; - flex-direction: column; - overflow: hidden; - flex-shrink: 0; +.branchMenuItem:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); } -.sidebarHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; +.branchMenuItemActive { + background: rgba(0, 112, 243, 0.08); + border-color: rgba(0, 112, 243, 0.18); + color: var(--accent-primary); } -.sidebarTitle { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-secondary); - margin: 0; +.branchMenuItemActive:hover { + background: rgba(0, 112, 243, 0.12); + color: var(--accent-primary); +} + +.branchMenuDefault { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + flex-shrink: 0; } -.viewToggle { +/* Header center — commit nav */ +.headerCenter { + flex: 1; display: flex; - gap: 2px; - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - padding: 2px; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; + max-width: 560px; } -.toggleBtn { +.navArrow { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 24px; + height: 24px; background: transparent; - border: none; + border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--text-muted); cursor: pointer; - transition: all var(--transition-fast); + flex-shrink: 0; + transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); } -.toggleBtn:hover { +.navArrow:hover:not(:disabled) { color: var(--text-primary); - background: var(--bg-secondary); + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); } -.toggleBtnActive { - background: var(--accent-primary) !important; - color: white !important; +.navArrow:disabled { + opacity: 0.25; + cursor: default; } -.calendarWrapper { +.chapterTrigger { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + height: 26px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); + min-width: 0; + max-width: 420px; + text-align: left; + overflow: hidden; +} + +.chapterTrigger:hover { + border-color: rgba(0, 112, 243, 0.4); + background: rgba(255, 255, 255, 0.06); +} + +.chapterLabel { + font-size: 0.68rem; + color: var(--accent-primary); + font-weight: 600; + font-family: var(--font-mono); + letter-spacing: 0.02em; + flex-shrink: 0; + opacity: 0.9; +} + +.chapterTitle { + font-size: 0.78rem; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; flex: 1; - overflow-y: auto; - padding: var(--space-md); + min-width: 0; +} + +.chapterChevron { + color: var(--text-muted); + opacity: 0.5; + flex-shrink: 0; } -.timeline { +/* ─── Main Layout ─────────────────────────────────────────────────── */ +.main { + display: flex; flex: 1; - overflow-y: auto; - padding: var(--space-md); + overflow: hidden; } -.timelineItem { +/* ─── Left Sidebar ────────────────────────────────────────────────── */ +.sidebarTabStrip { display: flex; - gap: var(--space-md); - padding: var(--space-sm); - background: transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; + height: 32px; + background: var(--bg-primary); +} + +.sidebarTabBtn { + flex: 1; + height: 32px; border: none; - border-radius: var(--radius-md); + background: transparent; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); cursor: pointer; - text-align: left; - width: 100%; - font-family: inherit; - color: inherit; - transition: background var(--transition-fast); + border-bottom: 2px solid transparent; + transition: color var(--transition-fast), border-color var(--transition-fast); + padding: 0; } -.timelineItem:hover { - background: var(--bg-tertiary); +.sidebarTabBtn:hover { + color: var(--text-secondary); } -.timelineItemActive { - background: rgba(0, 112, 243, 0.1); +.sidebarTabActive { + color: var(--text-primary) !important; + border-bottom-color: var(--accent-primary) !important; } -.timelineItemActive .timelineDot { - background: var(--accent-primary); - box-shadow: 0 0 8px var(--accent-primary); +.sidebarContent { + flex: 1; + overflow-y: auto; + overflow-x: hidden; } -.timelineMarker { - display: flex; - flex-direction: column; - align-items: center; - width: 16px; +.sidebarFooter { + border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; } -.timelineDot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--text-muted); - flex-shrink: 0; +.sidebarFooterBtn { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 30px; + padding: 0 var(--space-md); + border: none; + background: transparent; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast); } -.timelineLine { - width: 2px; - flex: 1; - min-height: 24px; - background: var(--border-default); - margin-top: 4px; +.sidebarFooterBtn:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.03); } -.timelineContent { +/* ─── Commit Sort Bar ─────────────────────────────────────────────── */ +.commitSortBar { display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; } -.timelineOrder { - font-size: 0.75rem; +.commitSortBtn { + flex: 1; + height: 28px; + border: none; + background: transparent; + font-size: 0.7rem; font-weight: 600; - color: var(--accent-primary); + color: var(--text-muted); + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.06em; + transition: color var(--transition-fast), background var(--transition-fast); } -.timelineMessage { - font-size: 0.85rem; +.commitSortBtn:hover { color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + background: rgba(255, 255, 255, 0.03); } -/* Content Area */ -.content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - min-width: 0; +.commitSortActive { + color: var(--accent-primary); + background: rgba(0, 112, 243, 0.08); } -.commitInfo { - padding: var(--space-lg); - border-bottom: 1px solid var(--border-subtle); +/* ─── Commit Strip (slim metadata bar in center panel) ────────────── */ +.commitStrip { + height: 30px; + display: flex; + align-items: center; + gap: var(--space-md); + padding: 0 var(--space-md); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; + background: var(--bg-primary); } -.commitHeader { +.commitSha { display: flex; align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-sm); + gap: 4px; + font-size: 0.75rem; + color: var(--accent-primary); + font-family: var(--font-mono); + background: rgba(0, 112, 243, 0.08); + padding: 1px 6px; + border-radius: 3px; + border: 1px solid rgba(0, 112, 243, 0.15); +} + +.commitAuthor, +.commitDate { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; color: var(--text-muted); } -.commitSha { - font-size: 0.85rem; - color: var(--accent-primary); - background: rgba(0, 112, 243, 0.1); - padding: 2px 8px; - border-radius: 0.25rem; +/* ─── View Tabs ───────────────────────────────────────────────────── */ +.viewTabs { + display: flex; + gap: 2px; + padding: 6px var(--space-md); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: var(--bg-primary); + flex-shrink: 0; } -.commitMessage { - font-size: 1.25rem; +.viewTab { + border: 1px solid transparent; + background: transparent; + color: var(--text-muted); + border-radius: var(--radius-sm); + padding: 4px 10px; + font-size: 0.75rem; font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--space-sm); + letter-spacing: 0.02em; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); } -.commitMeta { - display: flex; - gap: var(--space-lg); - font-size: 0.85rem; +.viewTab:hover { color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); +} + +.viewTabActive { + background: rgba(0, 112, 243, 0.12); + border-color: rgba(0, 112, 243, 0.25); + color: var(--accent-primary) !important; } -.commitMeta span { +/* ─── Code Area ───────────────────────────────────────────────────── */ +.codeArea { + flex: 1; display: flex; - align-items: center; - gap: var(--space-xs); + overflow: hidden; } -/* Code Area */ -.codeArea { +.codeDisplay { flex: 1; + overflow: auto; + background: var(--bg-primary); display: flex; - overflow: hidden; + flex-direction: column; } -.loadingFiles { +.codeViewerWrapper { + position: relative; flex: 1; display: flex; flex-direction: column; + overflow: auto; +} + +.codeLoadingOverlay { + position: absolute; + inset: 0; + display: flex; align-items: center; justify-content: center; - gap: var(--space-md); - color: var(--text-secondary); + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(1px); + pointer-events: none; } .fileList { width: 100%; - border-right: none; overflow-y: auto; padding: var(--space-sm); - flex-shrink: 0; flex: 1; } -.fileItem { +.loadingFiles { + flex: 1; display: flex; + flex-direction: column; align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - width: 100%; - background: transparent; - border: none; - border-radius: var(--radius-sm); - font-size: 0.85rem; - font-family: var(--font-mono); + justify-content: center; + gap: var(--space-md); color: var(--text-secondary); - cursor: pointer; - text-align: left; - transition: all var(--transition-fast); -} - -.fileItem:hover { - background: var(--bg-tertiary); - color: var(--text-primary); } -.fileItemActive { - background: rgba(0, 112, 243, 0.1); - color: var(--accent-primary); +.noFile { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + color: var(--text-muted); } -.fileItem span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.emptyStateIcon { + opacity: 0.2; } -.codeDisplay { +/* ─── Diff ─────────────────────────────────────────────────────────── */ +.diffContainer { flex: 1; - overflow: auto; - background: var(--bg-primary); display: flex; flex-direction: column; + min-height: 0; } -.viewTabs { +.diffToolbar { display: flex; - gap: 4px; - padding: 8px var(--space-md); - border-bottom: 1px solid var(--border-subtle); + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 6px var(--space-md); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); background: var(--bg-secondary); + flex-shrink: 0; } -.viewTab { - border: 1px solid var(--border-default); - background: transparent; - color: var(--text-secondary); +.diffScopeToggle { + display: inline-flex; + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--radius-sm); - padding: 6px 10px; - font-size: 0.78rem; + overflow: hidden; + flex-shrink: 0; +} + +.diffScopeBtn { + border: none; + background: transparent; + color: var(--text-muted); + padding: 4px 10px; + font-size: 0.72rem; font-weight: 600; - letter-spacing: 0.02em; cursor: pointer; - transition: all var(--transition-fast); + transition: color var(--transition-fast), background var(--transition-fast); + white-space: nowrap; } -.viewTab:hover { - color: var(--text-primary); - border-color: var(--accent-primary); +.diffScopeBtn:hover { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); } -.viewTabActive { - background: var(--accent-primary); - border-color: var(--accent-primary); - color: white; +.diffScopeBtnActive { + background: rgba(0, 112, 243, 0.15); + color: var(--accent-primary) !important; } -.diffContainer { - flex: 1; +.diffToolbarControls { display: flex; - flex-direction: column; - min-height: 0; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + flex-wrap: wrap; } -.diffToolbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--space-sm); - padding: 8px var(--space-md); - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-secondary); +.diffFileName { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.diffStats { - font-size: 0.8rem; +.diffNoFile { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; +} + +.diffRange { color: var(--text-muted); + font-size: 0.75rem; + padding: 0 4px; } -.diffToolbarControls, -.diffToolbarControlsWide { +.diffSelectLabel { display: flex; - align-items: center; - gap: var(--space-sm); - min-width: 0; + flex-direction: column; + gap: 2px; + font-size: 0.64rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 600; } -.diffToolbarControlsWide { - width: 100%; +.diffSelectLabel select { + height: 26px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 0 6px; + font-size: 0.75rem; + font-family: var(--font-mono); + min-width: 180px; } .diffToolbar select { - height: 32px; + height: 26px; border-radius: var(--radius-sm); - border: 1px solid var(--border-default); + border: 1px solid rgba(255, 255, 255, 0.08); background: var(--bg-tertiary); - color: var(--text-primary); - padding: 0 8px; - font-size: 0.78rem; + color: var(--text-secondary); + padding: 0 6px; + font-size: 0.75rem; font-family: var(--font-mono); - min-width: 220px; + min-width: 180px; } -.diffToolbar label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 0.7rem; - letter-spacing: 0.04em; - text-transform: uppercase; +.diffStats { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + font-family: var(--font-mono); +} + +.diffFileName { + font-size: 0.75rem; color: var(--text-muted); - min-width: 220px; + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; } .diffModeToggle { display: inline-flex; - border: 1px solid var(--border-default); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--radius-sm); overflow: hidden; + flex-shrink: 0; + margin-left: auto; } .diffModeToggle button { border: none; background: transparent; - color: var(--text-secondary); - padding: 6px 10px; - font-size: 0.75rem; + color: var(--text-muted); + padding: 4px 8px; + font-size: 0.72rem; font-weight: 600; cursor: pointer; - transition: all var(--transition-fast); + transition: color var(--transition-fast), background var(--transition-fast); } .diffModeToggle button:hover { - color: var(--text-primary); - background: var(--bg-tertiary); + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); } -.diffModeToggle .diffModeActive { - background: var(--accent-primary); - color: white; +.diffModeActive { + background: rgba(0, 112, 243, 0.15) !important; + color: var(--accent-primary) !important; } .diffMeta { display: flex; gap: var(--space-md); align-items: center; - padding: 8px var(--space-md); - color: var(--text-secondary); - font-size: 0.78rem; - border-bottom: 1px solid var(--border-subtle); + padding: 5px var(--space-md); + color: var(--text-muted); + font-size: 0.72rem; + font-family: var(--font-mono); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); background: var(--bg-primary); + flex-shrink: 0; +} + +.diffAdd { + color: #3fb950; +} + +.diffDel { + color: #f85149; +} + +/* Diff status badge */ +.diffStatusBadge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 3px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.diffStatusAdded { background: rgba(63, 185, 80, 0.12); color: #3fb950; border: 1px solid rgba(63, 185, 80, 0.22); } +.diffStatusModified { background: rgba(255, 166, 0, 0.10); color: #e3b341; border: 1px solid rgba(255, 166, 0, 0.20); } +.diffStatusRemoved { background: rgba(248, 81, 73, 0.12); color: #f85149; border: 1px solid rgba(248, 81, 73, 0.22); } +.diffStatusRenamed { background: rgba(88, 166, 255, 0.10); color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.20); } + +/* Stat pills for additions / deletions */ +.diffStatPill { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 3px; + font-size: 0.72rem; + font-weight: 600; + font-family: var(--font-mono); +} + +.diffStatAdd { + background: rgba(63, 185, 80, 0.10); + color: #3fb950; + border: 1px solid rgba(63, 185, 80, 0.18); +} + +.diffStatDel { + background: rgba(248, 81, 73, 0.10); + color: #f85149; + border: 1px solid rgba(248, 81, 73, 0.18); } .compareSummary { display: flex; gap: var(--space-md); align-items: center; - padding: 8px var(--space-md); + padding: 5px var(--space-md); color: var(--text-muted); - font-size: 0.78rem; - border-bottom: 1px solid var(--border-subtle); + font-size: 0.72rem; + font-family: var(--font-mono); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; } .errorInline { margin: var(--space-md); - border: 1px solid rgba(239, 68, 68, 0.3); - background: rgba(239, 68, 68, 0.14); + border: 1px solid rgba(239, 68, 68, 0.2); + background: rgba(239, 68, 68, 0.08); color: #ffb7be; border-radius: var(--radius-sm); padding: var(--space-sm) var(--space-md); - font-size: 0.85rem; + font-size: 0.82rem; } -.noFile { +/* ─── AI Panel ─────────────────────────────────────────────────────── */ +.aiPanelInner { + position: relative; height: 100%; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-md); - color: var(--text-muted); + overflow: hidden; } -.emptyStateIcon { - opacity: 0.4; +.aiPanelCollapsed { + align-items: center; + justify-content: center; } -/* Navigation */ -.navigation { +.aiCollapseBtn { + position: absolute; + top: 50%; + left: -1px; + transform: translateY(-50%); + width: 18px; + height: 40px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, 0.08); + border-left: none; + border-radius: 0 4px 4px 0; + cursor: pointer; display: flex; align-items: center; - justify-content: space-between; - padding: var(--space-md) var(--space-lg); - border-top: 1px solid var(--border-subtle); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.navInfo { - font-size: 0.85rem; + justify-content: center; color: var(--text-muted); + z-index: 2; + transition: color var(--transition-fast), background var(--transition-fast); } -/* AI Panel */ -.aiPanel { - width: 400px; - border-left: 1px solid var(--border-subtle); - background: var(--bg-primary); - flex-shrink: 0; - overflow: hidden; -} - -/* Loading/Error States */ -.loadingState, -.errorState { - height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-lg); +.aiCollapseBtn:hover { color: var(--text-secondary); + background: var(--bg-tertiary); } -.spinner { - animation: spin 1s linear infinite; +.aiCollapsedLabel { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 0.65rem; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.12em; + text-transform: uppercase; + padding: var(--space-lg) 0; + opacity: 0.6; } -@keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } +.aiPanelWrapper { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; } -/* Resizable Panels */ +/* ─── Resizable Panels ─────────────────────────────────────────────── */ .group { height: 100%; width: 100%; @@ -634,28 +876,28 @@ align-items: center; justify-content: space-between; padding: var(--space-sm) var(--space-md); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); - border-top: 1px solid var(--border-subtle); + background: var(--bg-primary); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; } .panelTitle { - font-size: 0.75rem; - font-weight: 600; + font-size: 0.68rem; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.07em; color: var(--text-muted); margin: 0; } .resizeHandle { width: 1px; - background: var(--border-subtle); + background: rgba(255, 255, 255, 0.06); cursor: col-resize; - transition: all 0.2s; + transition: background 0.15s, width 0.15s; position: relative; z-index: 10; + flex-shrink: 0; } .resizeHandle:hover, @@ -674,43 +916,43 @@ z-index: 10; } -.aiPanelWrapper { - height: 100%; +/* ─── Active / misc ─────────────────────────────────────────────────── */ +.active { + background: var(--accent-primary) !important; + color: white !important; +} + +/* ─── Loading / Error States ─────────────────────────────────────────── */ +.loadingState, +.errorState { + height: 100vh; display: flex; flex-direction: column; - overflow: hidden; + align-items: center; + justify-content: center; + gap: var(--space-lg); + color: var(--text-secondary); } -/* Responsive */ -@media (max-width: 1200px) { - .aiPanel { - width: 320px; - } +.spinner { + animation: spin 1s linear infinite; } -@media (max-width: 900px) { - .sidebar { - width: 220px; - } - - .aiPanel { - position: fixed; - right: 0; - top: 0; - bottom: 0; - z-index: 50; - box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3); - } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +/* ─── Responsive ─────────────────────────────────────────────────────── */ +@media (max-width: 900px) { .diffToolbar, - .diffToolbarControls, - .diffToolbarControlsWide { + .diffToolbarControls { flex-wrap: wrap; } .diffToolbar select, - .diffToolbar label { - min-width: 180px; + .diffSelectLabel select { + min-width: 140px; width: 100%; } } diff --git a/src/app/explore/[id]/page.tsx b/src/app/explore/[id]/page.tsx index 2f726ee..54ea506 100644 --- a/src/app/explore/[id]/page.tsx +++ b/src/app/explore/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, use, useMemo, useCallback, useRef } from 'react'; +import { useState, use, useMemo, useCallback, useRef, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { BookOpen, @@ -9,7 +9,6 @@ import { Home, Settings, Loader2, - MessageSquare, GitCommit, User, Calendar, @@ -17,6 +16,7 @@ import { Minimize2, ChevronDown, RefreshCw, + GitBranch, } from 'lucide-react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import styles from './explore.module.css'; @@ -25,378 +25,362 @@ import CodeViewer from '@/components/CodeViewer'; import AIPanel from '@/components/AIPanel'; import FileTree from '@/components/FileTree'; import CommitHistoryModal from '@/components/CommitHistoryModal'; +import CommitTimeline from '@/components/CommitTimeline'; import DiffViewer from '@/components/DiffViewer'; import StoryModePanel from '@/components/StoryModePanel'; import { api } from '@/lib/api-client'; -import { - fetchCommitsPageForRepository, - fetchInitialCommitsForRepository, -} from '@/lib/commit-pagination'; +import { useCommits } from '@/hooks/use-commits'; +import { useIngestJob } from '@/hooks/use-ingest-job'; +import { useBranches } from '@/hooks/use-branches'; +import { useCommitFiles } from '@/hooks/use-commit-files'; +import { useFileContent } from '@/hooks/use-file-content'; +import { useCommitDiff } from '@/hooks/use-commit-diff'; +import { useCompareDiff } from '@/hooks/use-compare-diff'; +import { useExploreStore } from '@/stores/explore-store'; +import { fireToast } from '@/stores/toast-store'; +import { getAISettings } from '@/stores/settings-store'; import Link from 'next/link'; -import type { - Repository, - Commit, - FileData, - CommitDiffResponse, - CompareDiffResponse, - DiffFileData, -} from '@/types'; - -type CenterView = 'code' | 'commit-diff' | 'file-diff' | 'story'; - -export default function ExplorePage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params); - const router = useRouter(); - const searchParams = useSearchParams(); - const ingestJobId = searchParams.get('jobId'); - - const [repository, setRepository] = useState(null); - const [commits, setCommits] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [files, setFiles] = useState([]); - const [selectedFile, setSelectedFile] = useState(null); - const [loading, setLoading] = useState(true); - const [loadingFiles, setLoadingFiles] = useState(false); - const [loadingContent, setLoadingContent] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [showAIPanel, setShowAIPanel] = useState(true); - const [showHistoryModal, setShowHistoryModal] = useState(false); - const [focusMode, setFocusMode] = useState(false); - const [syncing, setSyncing] = useState(false); - const [error, setError] = useState(null); - - const [centerView, setCenterView] = useState('code'); - const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>('unified'); - - const [commitDiffFiles, setCommitDiffFiles] = useState([]); - const [commitDiffLoading, setCommitDiffLoading] = useState(false); - const [commitDiffError, setCommitDiffError] = useState(null); - const [selectedCommitDiffPath, setSelectedCommitDiffPath] = useState(''); - - const [compareBaseSha, setCompareBaseSha] = useState(''); - const [compareHeadSha, setCompareHeadSha] = useState(''); - const [compareFiles, setCompareFiles] = useState([]); - const [compareStatus, setCompareStatus] = useState('unknown'); - const [compareTotalFiles, setCompareTotalFiles] = useState(0); - const [compareAheadBy, setCompareAheadBy] = useState(0); - const [compareBehindBy, setCompareBehindBy] = useState(0); - const [compareLoading, setCompareLoading] = useState(false); - const [compareError, setCompareError] = useState(null); - const [selectedComparePath, setSelectedComparePath] = useState(''); - const [pendingCommitSha, setPendingCommitSha] = useState(null); - const [loadingMoreCommits, setLoadingMoreCommits] = useState(false); - const [waitingForInitialCommits, setWaitingForInitialCommits] = useState(false); - const [ingestProgress, setIngestProgress] = useState(0); - const [ingestStatus, setIngestStatus] = useState(null); - - const commitPrefetchRequestRef = useRef(0); - const currentIndexRef = useRef(0); +import type { FileData } from '@/types'; + +// ────────────────────────────────────────────────────────── +// Tiny hooks to absorb DOM side-effects +// ────────────────────────────────────────────────────────── + +/** Dismiss a ref-bound element when clicking outside */ +function useClickOutside( + ref: React.RefObject, + active: boolean, + onClose: () => void, +) { + useEffect(() => { + if (!active) return; + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [active, onClose, ref]); +} - const currentCommit = commits[currentIndex]; - const currentCommitSha = currentCommit?.sha; - const repositoryId = repository?.id; - const commitSelectionKey = useMemo(() => `grepbase:last_commit:${id}`, [id]); +/** Global keyboard shortcuts */ +function useKeyboardNav( + commitsLength: number, + goNext: (n: number) => void, + goPrev: () => void, + setCenterView: (v: 'code' | 'diff' | 'story') => void, + blocked: boolean, +) { + useEffect(() => { + function handler(e: KeyboardEvent) { + if (blocked) return; + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' + || (e.target as HTMLElement).isContentEditable) return; + if (e.key === 'ArrowRight') goNext(commitsLength); + else if (e.key === 'ArrowLeft') goPrev(); + else if (e.key === 'c') setCenterView('code'); + else if (e.key === 'd') setCenterView('diff'); + else if (e.key === 's') setCenterView('story'); + } + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [blocked, commitsLength, goNext, goPrev, setCenterView]); +} - const visibleFilePaths = useMemo( - () => files - .filter(file => file.shouldFetchContent || file.hasContent) - .map(file => file.path), - [files] - ); +/** Persist & restore commit selection to URL + storage */ +function useCommitPersistence( + commits: { sha: string }[], + repoId: string, + setCurrentIndex: (i: number) => void, +) { + const commitSelectionKey = useMemo(() => `grepbase:last_commit:${repoId}`, [repoId]); + const restoredRef = useRef(false); - const selectedCommitDiffFile = useMemo(() => { - if (commitDiffFiles.length === 0) return null; - return commitDiffFiles.find(file => file.path === selectedCommitDiffPath) || commitDiffFiles[0]; - }, [commitDiffFiles, selectedCommitDiffPath]); - - const selectedCompareFile = useMemo(() => { - if (compareFiles.length === 0) return null; - return compareFiles.find(file => file.path === selectedComparePath) || compareFiles[0]; - }, [compareFiles, selectedComparePath]); - - const appendUniqueCommits = useCallback((incoming: Commit[]) => { - if (incoming.length === 0) return; - setCommits(prev => { - const seenShas = new Set(prev.map(commit => commit.sha)); - const additions = incoming.filter(commit => !seenShas.has(commit.sha)); - if (additions.length === 0) return prev; - return [...prev, ...additions]; - }); - }, []); - - const prefetchRemainingCommits = useCallback((startPage: number) => { - const requestId = commitPrefetchRequestRef.current + 1; - commitPrefetchRequestRef.current = requestId; - - if (startPage <= 1) { - setLoadingMoreCommits(false); - return; + // Restore once when commits first arrive + useEffect(() => { + if (restoredRef.current || commits.length === 0 || typeof window === 'undefined') return; + restoredRef.current = true; + + const urlSha = new URLSearchParams(window.location.search).get('sha'); + const storedSha = + sessionStorage.getItem(commitSelectionKey) || + localStorage.getItem(commitSelectionKey); + const targetSha = urlSha || storedSha; + if (!targetSha) return; + + const idx = commits.findIndex(c => c.sha === targetSha); + if (idx > 0) setCurrentIndex(idx); + }, [commits, commitSelectionKey, setCurrentIndex]); + + // Persist current commit — called imperatively, not via effect + const persist = useCallback((sha: string) => { + if (typeof window === 'undefined') return; + sessionStorage.setItem(commitSelectionKey, sha); + localStorage.setItem(commitSelectionKey, sha); + const url = new URL(window.location.href); + if (url.searchParams.get('sha') !== sha) { + url.searchParams.set('sha', sha); + window.history.replaceState({}, '', url.toString()); } + }, [commitSelectionKey]); - setLoadingMoreCommits(true); - - const load = async () => { - let page = startPage; - let hasNext = true; + return { persist }; +} - while (hasNext && commitPrefetchRequestRef.current === requestId) { - const pageData = await fetchCommitsPageForRepository(id, page); - if (commitPrefetchRequestRef.current !== requestId) { - return; - } +/** Auto-select a file when the file list changes */ +function useAutoSelectFile( + files: FileData[], + setSelectedFile: (f: FileData | null) => void, +) { + const lastSelectedPathRef = useRef(null); - appendUniqueCommits(pageData.commits); + const selectFile = useCallback((file: FileData) => { + lastSelectedPathRef.current = file.path; + setSelectedFile(file); + }, [setSelectedFile]); - hasNext = Boolean(pageData.pagination?.hasNext); - page += 1; - } - }; - - void load() - .catch((prefetchError) => { - if (commitPrefetchRequestRef.current !== requestId) return; - console.warn('Background commit prefetch stopped:', prefetchError); - }) - .finally(() => { - if (commitPrefetchRequestRef.current === requestId) { - setLoadingMoreCommits(false); - } - }); - }, [appendUniqueCommits, id]); + // Auto-select best file when file list changes + useEffect(() => { + if (files.length === 0) return; + const lastPath = lastSelectedPathRef.current; + const preferred = lastPath ? files.find(f => f.path === lastPath) : null; + const target = preferred ?? files.find(f => f.shouldFetchContent || f.hasContent) ?? null; + if (target) { + lastSelectedPathRef.current = target.path; + setSelectedFile(target); + } else { + setSelectedFile(null); + } + }, [files, setSelectedFile]); - const fetchRepositoryData = useCallback(async (preserveSha?: string, showLoading = false) => { - commitPrefetchRequestRef.current += 1; - setLoadingMoreCommits(false); + return { selectFile }; +} - if (showLoading) { - setLoading(true); - } - try { - const data = await fetchInitialCommitsForRepository(id); - - let targetSha = preserveSha; - if (!targetSha && typeof window !== 'undefined') { - const urlSha = new URLSearchParams(window.location.search).get('sha'); - const storedSha = - sessionStorage.getItem(commitSelectionKey) || - localStorage.getItem(commitSelectionKey); - targetSha = urlSha || storedSha || undefined; - } - setRepository(data.repository); - setCommits(data.commits); - let nextIndex = data.commits.length === 0 - ? 0 - : Math.min(currentIndexRef.current, data.commits.length - 1); - let unresolvedTargetSha: string | null = null; - - if (targetSha) { - const idx = data.commits.findIndex(commit => commit.sha === targetSha); - if (idx >= 0) { - nextIndex = idx; - } else { - unresolvedTargetSha = targetSha; - } - } +export default function ExplorePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + const ingestJobId = searchParams.get('jobId'); - setCurrentIndex(nextIndex); - setPendingCommitSha(unresolvedTargetSha); + // Zustand store for UI state + const { + currentIndex, setCurrentIndex, + selectedFile, setSelectedFile, + centerView, setCenterView, + sidebarTab, setSidebarTab, + commitOrder, setCommitOrder, + diffScope, setDiffScope, + diffViewMode, setDiffViewMode, + focusMode, toggleFocusMode, + aiPanelExpanded, toggleAiPanel, + showSettings, setShowSettings, + showHistoryModal, setShowHistoryModal, + showBranchMenu, setShowBranchMenu, + pinnedBaseSha, setPinnedBaseSha, + goToCommit, goNext, goPrev, + reset: resetExploreStore, + } = useExploreStore(); + + // Reset UI state whenever the viewed repository changes + useEffect(() => { + resetExploreStore(); + }, [id, resetExploreStore]); - if (data.pagination?.hasNext) { - prefetchRemainingCommits((data.pagination.page || 1) + 1); - } else { - setLoadingMoreCommits(false); - setPendingCommitSha(null); - } + // Local state (not shareable) + const [switchingBranch, setSwitchingBranch] = useState(false); + const [switchBranchJobId, setSwitchBranchJobId] = useState(null); + const [syncing, setSyncing] = useState(false); + const [resyncJobId, setResyncJobId] = useState(null); + const branchMenuRef = useRef(null); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong'); - } finally { - if (showLoading) { - setLoading(false); - } - } - }, [commitSelectionKey, id, prefetchRemainingCommits]); + // ── React Query: Commits ───────────────────────────────── + const commitsQuery = useCommits(id); + const repository = commitsQuery.data?.repository ?? null; + const commits = useMemo(() => commitsQuery.data?.commits ?? [], [commitsQuery.data?.commits]); + // Auto-fetch remaining pages + const { hasNextPage, isFetchingNextPage, fetchNextPage } = commitsQuery; useEffect(() => { - fetchRepositoryData(undefined, true); - }, [fetchRepositoryData]); + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - useEffect(() => { - if (loading) return; + // ── Ingest job polling ──────────────────────────────────── + const waitingForCommits = !!ingestJobId && commits.length === 0 && !commitsQuery.isLoading; + const ingestJob = useIngestJob(ingestJobId, { enabled: waitingForCommits }); - if (ingestJobId && commits.length === 0) { - setWaitingForInitialCommits(true); - return; + // When ingest progresses, refetch commits + const ingestJobData = ingestJob.data; + const { refetch: refetchCommits } = commitsQuery; + useEffect(() => { + if (!ingestJobData) return; + const hasProcessed = Number(ingestJobData.processedCommits || 0) > 0; + const shouldRefetch = (ingestJobData.repository || ingestJobData.repoId) && + (ingestJobData.ready || hasProcessed || ingestJobData.status === 'completed'); + if (shouldRefetch) refetchCommits(); + }, [ingestJobData, refetchCommits]); + + // ── Resync job polling ──────────────────────────────────── + const resyncJob = useIngestJob(resyncJobId, { enabled: !!resyncJobId }); + useEffect(() => { + if (!resyncJobId || !resyncJob.data) return; + const job = resyncJob.data; + const hasProcessed = Number(job.processedCommits || 0) > 0; + if (job.status === 'completed' || job.ready || hasProcessed) { + refetchCommits(); + setSyncing(false); + setResyncJobId(null); + fireToast('Repository synced', 'success'); + } else if (job.status === 'failed') { + fireToast(job.error || 'Resync failed', 'error'); + setSyncing(false); + setResyncJobId(null); } + }, [resyncJob.data, resyncJobId, refetchCommits]); - setWaitingForInitialCommits(false); - setIngestStatus(null); - setIngestProgress(0); - }, [commits.length, ingestJobId, loading]); - + // ── Branch-switch job polling ───────────────────────────── + const switchBranchJob = useIngestJob(switchBranchJobId, { enabled: !!switchBranchJobId }); useEffect(() => { - if (!waitingForInitialCommits || !ingestJobId) return; - - let cancelled = false; - let inFlight = false; - - const poll = async () => { - if (cancelled || inFlight) return; - inFlight = true; - - try { - const jobResponse = await api.get<{ - status: string; - progress?: number; - error?: string; - ready?: boolean; - processedCommits?: number; - repoId?: number | null; - repository?: { id: number }; - }>(`/api/jobs/${ingestJobId}`); - - if (cancelled) return; - - setIngestStatus(jobResponse.status); - setIngestProgress(Number(jobResponse.progress || 0)); - - const hasProcessedCommits = Number(jobResponse.processedCommits || 0) > 0; - - if (jobResponse.status === 'failed') { - setError(jobResponse.error || 'Failed to ingest repository'); - setWaitingForInitialCommits(false); - return; - } - - if ( - (jobResponse.repository || jobResponse.repoId) && - (jobResponse.ready || hasProcessedCommits || jobResponse.status === 'completed') - ) { - await fetchRepositoryData(undefined, false); - } - - if (jobResponse.status === 'completed' && !hasProcessedCommits) { - setWaitingForInitialCommits(false); - } - } catch (pollError) { - if (!cancelled) { - console.error('Failed to poll ingest job:', pollError); - } - } finally { - inFlight = false; - } - }; + if (!switchBranchJobId || !switchBranchJob.data) return; + const job = switchBranchJob.data; + const resolvedId = job.repository?.id ?? job.repoId; + if (resolvedId) { + setSwitchBranchJobId(null); + router.push(`/explore/${resolvedId}?jobId=${switchBranchJobId}`); + } else if (job.status === 'failed') { + fireToast('Failed to load branch', 'error'); + setSwitchingBranch(false); + setSwitchBranchJobId(null); + } + }, [switchBranchJob.data, switchBranchJobId, router]); - void poll(); - const interval = setInterval(() => { - void poll(); - }, 2000); + // ── Derived state ──────────────────────────────────────── + const currentCommit = commits[currentIndex]; + const currentCommitSha = currentCommit?.sha; - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [fetchRepositoryData, ingestJobId, waitingForInitialCommits]); + const activeBranch = useMemo(() => { + if (!repository) return null; + const url = repository.url ?? ''; + const atIdx = url.lastIndexOf('@'); + if (atIdx !== -1) return url.slice(atIdx + 1); + return repository.defaultBranch || 'main'; + }, [repository]); + + const baseRepoUrl = useMemo(() => { + if (!repository) return ''; + const url = repository.url ?? ''; + const atIdx = url.lastIndexOf('@'); + return atIdx !== -1 ? url.slice(0, atIdx) : url; + }, [repository]); + + const orderedCommits = useMemo( + () => commitOrder === 'asc' ? commits : [...commits].reverse(), + [commits, commitOrder] + ); - useEffect(() => { - currentIndexRef.current = currentIndex; - }, [currentIndex]); + // Compare SHAs — derived with useMemo, not synced via useEffect + const defaultCompareBaseSha = useMemo(() => { + if (commits.length === 0) return ''; + return commits[Math.max(0, currentIndex - 1)]?.sha || commits[0].sha; + }, [commits, currentIndex]); - useEffect(() => { - if (!pendingCommitSha) return; - const idx = commits.findIndex(commit => commit.sha === pendingCommitSha); - if (idx < 0) return; - setCurrentIndex(idx); - setPendingCommitSha(null); - }, [commits, pendingCommitSha]); + const defaultCompareHeadSha = currentCommitSha || ''; - useEffect(() => { - return () => { - commitPrefetchRequestRef.current += 1; - }; - }, []); + // Set compareBaseSha from Pinned state or fallback to default + const compareBaseSha = pinnedBaseSha || defaultCompareBaseSha; + const compareHeadSha = defaultCompareHeadSha; - useEffect(() => { - if (!currentCommit?.sha || typeof window === 'undefined') return; + // ── React Query: Branches ──────────────────────────────── + const branchesQuery = useBranches(baseRepoUrl || undefined, { + enabled: showBranchMenu && !!baseRepoUrl, + }); + const branchList = branchesQuery.data?.branches ?? null; - sessionStorage.setItem(commitSelectionKey, currentCommit.sha); - localStorage.setItem(commitSelectionKey, currentCommit.sha); + // ── React Query: Files ─────────────────────────────────── + const filesQuery = useCommitFiles(id, currentCommitSha); + const files = useMemo(() => filesQuery.data ?? [], [filesQuery.data]); - const currentUrl = new URL(window.location.href); - if (currentUrl.searchParams.get('sha') !== currentCommit.sha) { - currentUrl.searchParams.set('sha', currentCommit.sha); - window.history.replaceState({}, '', currentUrl.toString()); - } - }, [commitSelectionKey, currentCommit?.sha]); + const visibleFilePaths = useMemo( + () => files + .filter(file => file.shouldFetchContent || file.hasContent) + .map(file => file.path), + [files] + ); - const selectFile = useCallback(async (file: FileData) => { - if (file.content) { - setSelectedFile(file); - return; + const filePathMap = useMemo(() => { + const map = new Map(); + for (const file of files) { + map.set(file.path, file); + map.set(file.path.toLowerCase(), file); } + return map; + }, [files]); + + // ── Auto-select file (custom hook — 1 effect inside) ───── + const { selectFile } = useAutoSelectFile(files, setSelectedFile); + + // ── React Query: File content ──────────────────────────── + const selectedFilePath = selectedFile?.path; + const needsContent = !!selectedFile && !selectedFile.content; + const fileContentQuery = useFileContent( + id, + currentCommitSha, + needsContent ? selectedFilePath : undefined + ); - if (!currentCommitSha) return; - - setLoadingContent(true); - setSelectedFile({ ...file, content: null }); + // Derive file with content — no effect needed + const resolvedSelectedFile = useMemo(() => { + if (!selectedFile) return null; + if (selectedFile.content) return selectedFile; + if (fileContentQuery.data && needsContent) { + return { ...selectedFile, content: fileContentQuery.data, hasContent: true }; + } + return selectedFile; + }, [selectedFile, fileContentQuery.data, needsContent]); + + // ── React Query: Commit diff ───────────────────────────── + const commitDiffQuery = useCommitDiff( + id, + currentCommitSha, + selectedFilePath, + { enabled: centerView === 'diff' && diffScope === 'commit' && !!selectedFile } + ); - try { - const data = await api.get<{ content?: string }>( - `/api/repos/${id}/commits/${currentCommitSha}/content?path=${encodeURIComponent(file.path)}` - ); + // ── React Query: Compare diff ──────────────────────────── + const compareDiffQuery = useCompareDiff( + id, + compareBaseSha || undefined, + compareHeadSha || undefined, + selectedFilePath, + { enabled: centerView === 'diff' && diffScope === 'compare' && !!selectedFile } + ); - if (data.content) { - const updatedFile = { ...file, content: data.content, hasContent: true }; - setFiles(prev => prev.map(existing => (existing.path === file.path ? updatedFile : existing))); - setSelectedFile(updatedFile); - } - } catch (err) { - console.error('Failed to fetch file content:', err); - } finally { - setLoadingContent(false); - } - }, [currentCommitSha, id]); + // ── Commit persistence ─────────────────────────────────── + const { persist: persistCommit } = useCommitPersistence(commits, id, setCurrentIndex); useEffect(() => { - if (!currentCommitSha || !repositoryId) return; - - let cancelled = false; + if (!currentCommitSha) return; + persistCommit(currentCommitSha); + }, [currentCommitSha, persistCommit]); - async function fetchFilesForCommit() { - setLoadingFiles(true); - setSelectedFile(null); - try { - const data = await api.get<{ files?: FileData[] }>(`/api/repos/${id}/commits/${currentCommitSha}`); - if (cancelled) return; - - const nextFiles = data.files || []; - setFiles(nextFiles); - - const firstLoadable = nextFiles.find(file => file.shouldFetchContent || file.hasContent); - if (firstLoadable) { - void selectFile(firstLoadable); - } - } catch (err) { - if (!cancelled) { - console.error('Failed to fetch files:', err); - } - } finally { - if (!cancelled) { - setLoadingFiles(false); - } - } + // ── AI settings hint (once per session after first load) ─ + const { isLoading: commitsLoading } = commitsQuery; + useEffect(() => { + if (commitsLoading || typeof window === 'undefined') return; + const hintKey = 'grepbase:ai_hint_shown'; + if (!sessionStorage.getItem(hintKey) && !getAISettings()) { + sessionStorage.setItem(hintKey, '1'); + fireToast('Set up an AI provider in Settings to unlock explanations', 'info', 6000); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commitsLoading]); - void fetchFilesForCommit(); - - return () => { - cancelled = true; - }; - }, [currentCommitSha, id, repositoryId, selectFile]); + // ── DOM hooks (2 legitimate effects) ───────────────────── + useClickOutside(branchMenuRef, showBranchMenu, useCallback(() => setShowBranchMenu(false), [setShowBranchMenu])); + useKeyboardNav(commits.length, goNext, goPrev, setCenterView, showSettings || showHistoryModal); + // ── File opening from AI references ────────────────────── const openFileFromAIReference = useCallback(async (path: string) => { const normalized = path .trim() @@ -408,228 +392,121 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } if (!normalized) return; - const exact = - files.find(file => file.path === normalized) || - files.find(file => file.path.toLowerCase() === normalized.toLowerCase()); - - if (exact) { - await selectFile(exact); - return; - } + const exact = filePathMap.get(normalized) ?? filePathMap.get(normalized.toLowerCase()); + if (exact) { selectFile(exact); return; } const suffix = files.find(file => file.path.endsWith(`/${normalized}`)) || files.find(file => file.path.endsWith(normalized)); - - if (suffix) { - await selectFile(suffix); - return; - } + if (suffix) { selectFile(suffix); return; } const directoryPrefix = `${normalized}/`; const firstInDirectory = [...files] .filter(file => file.path.startsWith(directoryPrefix)) .sort((a, b) => a.path.localeCompare(b.path))[0]; + if (firstInDirectory) { selectFile(firstInDirectory); } + }, [filePathMap, files, selectFile]); - if (firstInDirectory) { - await selectFile(firstInDirectory); - } - }, [files, selectFile]); - - const handleResync = useCallback(async () => { - if (!repository || syncing) return; + const handleLoadOlder = useCallback(async () => { + if (!repository || commits.length === 0 || syncing) return; setSyncing(true); + const oldestSha = commits[commits.length - 1].sha; try { const data = await api.post<{ jobId?: string; cached?: boolean }>('/api/repos', { url: `github.com/${repository.owner}/${repository.name}`, + branch: activeBranch || undefined, + startSha: oldestSha, + clearExisting: true }); if (data.jobId) { - let attempts = 0; - const maxAttempts = 60; - - const poll = async (): Promise => { - attempts += 1; - try { - const jobResponse = await api.get<{ - status: string; - error?: string; - ready?: boolean; - processedCommits?: number; - }>(`/api/jobs/${data.jobId}`); - - const hasProcessedCommits = Number(jobResponse.processedCommits || 0) > 0; - if (jobResponse.status === 'completed' || jobResponse.ready || hasProcessedCommits) { - await fetchRepositoryData(currentCommit?.sha, false); - setSyncing(false); - } else if (jobResponse.status === 'failed') { - console.error('Sync failed:', jobResponse.error); - setSyncing(false); - } else if (attempts < maxAttempts) { - setTimeout(poll, 2000); - } else { - console.error('Sync timed out'); - setSyncing(false); - } - } catch (pollError) { - console.error('Polling error:', pollError); - setSyncing(false); - } - }; - - void poll(); - } else { - await fetchRepositoryData(currentCommit?.sha, false); - setSyncing(false); + router.replace(`/explore/${id}?jobId=${data.jobId}`); } - } catch (err) { - console.error('Failed to trigger resync:', err); + } catch (error) { + fireToast('Failed to load older commits', 'error'); + } finally { setSyncing(false); } - }, [currentCommit?.sha, fetchRepositoryData, repository, syncing]); - - const goToCommit = useCallback((index: number) => { - if (index < 0 || index >= commits.length) return; - setCurrentIndex(index); - setSelectedFile(null); - }, [commits.length]); - - const goNext = useCallback(() => { - setCurrentIndex(prev => { - if (commits.length === 0) return 0; - const nextIndex = Math.min(prev + 1, commits.length - 1); - if (nextIndex !== prev) { - setSelectedFile(null); - } - return nextIndex; - }); - }, [commits.length]); - - const goPrev = useCallback(() => { - setCurrentIndex(prev => { - const nextIndex = Math.max(prev - 1, 0); - if (nextIndex !== prev) { - setSelectedFile(null); - } - return nextIndex; - }); - }, []); + }, [repository, commits, activeBranch, id, router, syncing]); - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (showSettings || showHistoryModal) return; + const handleLoadNewer = useCallback(async () => { + // To load newer commits, we just do a fresh sync without a startSha (starts from HEAD) + if (!repository || syncing) return; + setSyncing(true); + try { + const data = await api.post<{ jobId?: string; cached?: boolean }>('/api/repos', { + url: `github.com/${repository.owner}/${repository.name}`, + branch: activeBranch || undefined, + clearExisting: true + }); - if (event.key === 'ArrowRight' && event.metaKey) { - goNext(); - } else if (event.key === 'ArrowLeft' && event.metaKey) { - goPrev(); + if (data.jobId) { + router.replace(`/explore/${id}?jobId=${data.jobId}`); } + } catch (error) { + fireToast('Failed to load newer commits', 'error'); + } finally { + setSyncing(false); } + }, [repository, activeBranch, id, router, syncing]); - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [goNext, goPrev, showHistoryModal, showSettings]); - - useEffect(() => { - if (!currentCommit?.sha) return; - if (centerView !== 'commit-diff') return; - - let cancelled = false; - - async function fetchCommitDiffData() { - setCommitDiffLoading(true); - setCommitDiffError(null); - - try { - const data = await api.get(`/api/repos/${id}/commits/${currentCommit.sha}/diff`); - if (cancelled) return; - - const filesChanged = data.files || []; - setCommitDiffFiles(filesChanged); - setSelectedCommitDiffPath(prev => { - if (prev && filesChanged.some(file => file.path === prev)) { - return prev; - } - return filesChanged[0]?.path || ''; - }); - } catch (err) { - if (!cancelled) { - setCommitDiffFiles([]); - setCommitDiffError(err instanceof Error ? err.message : 'Failed to load commit diff'); - } - } finally { - if (!cancelled) { - setCommitDiffLoading(false); - } + // ── Branch switching ───────────────────────────────────── + const switchBranch = useCallback(async (branch: string) => { + if (!baseRepoUrl || branch === activeBranch || switchingBranch) return; + setShowBranchMenu(false); + setSwitchingBranch(true); + try { + const isDefault = branch === (repository?.defaultBranch || 'main'); + const body = isDefault ? { url: baseRepoUrl } : { url: baseRepoUrl, branch }; + const data = await api.post<{ + jobId?: string; + repository?: { id: string }; + cached?: boolean; + }>('/api/repos', body); + + const targetId = data.repository?.id; + if (targetId) { + router.push(data.jobId + ? `/explore/${targetId}?jobId=${data.jobId}` + : `/explore/${targetId}` + ); + } else if (data.jobId) { + setSwitchBranchJobId(data.jobId); + } else { + setSwitchingBranch(false); } + } catch (err) { + fireToast(err instanceof Error ? err.message : 'Failed to switch branch', 'error'); + setSwitchingBranch(false); } + }, [activeBranch, baseRepoUrl, repository?.defaultBranch, router, setShowBranchMenu, switchingBranch]); - void fetchCommitDiffData(); - - return () => { - cancelled = true; - }; - }, [centerView, currentCommit?.sha, id]); - - useEffect(() => { - if (commits.length === 0) return; - - const head = commits[currentIndex]?.sha || commits[commits.length - 1].sha; - const base = commits[Math.max(0, currentIndex - 1)]?.sha || head; - - setCompareHeadSha(head); - setCompareBaseSha(base); - }, [commits, currentIndex]); - - useEffect(() => { - if (centerView !== 'file-diff') return; - if (!compareBaseSha || !compareHeadSha) return; - - let cancelled = false; - - async function fetchCompareData() { - setCompareLoading(true); - setCompareError(null); - - try { - const data = await api.get( - `/api/repos/${id}/compare?base=${encodeURIComponent(compareBaseSha)}&head=${encodeURIComponent(compareHeadSha)}` - ); - - if (cancelled) return; - - setCompareFiles(data.files || []); - setCompareStatus(data.status || 'unknown'); - setCompareTotalFiles(data.totalFiles || data.files.length || 0); - setCompareAheadBy(data.aheadBy || 0); - setCompareBehindBy(data.behindBy || 0); - setSelectedComparePath(prev => { - if (prev && data.files.some(file => file.path === prev)) { - return prev; - } - return data.files[0]?.path || ''; - }); - } catch (err) { - if (!cancelled) { - setCompareFiles([]); - setCompareError(err instanceof Error ? err.message : 'Failed to compare commits'); - } - } finally { - if (!cancelled) { - setCompareLoading(false); - } + // ── Resync ─────────────────────────────────────────────── + const handleResync = useCallback(async () => { + if (!repository || syncing) return; + setSyncing(true); + try { + const data = await api.post<{ jobId?: string; cached?: boolean }>('/api/repos', { + url: `github.com/${repository.owner}/${repository.name}`, + }); + if (data.jobId) { + setResyncJobId(data.jobId); + } else { + await refetchCommits(); + setSyncing(false); + fireToast('Repository synced', 'success'); } + } catch (err) { + fireToast(err instanceof Error ? err.message : 'Failed to resync repository', 'error'); + setSyncing(false); } + }, [refetchCommits, repository, syncing]); - void fetchCompareData(); - - return () => { - cancelled = true; - }; - }, [centerView, compareBaseSha, compareHeadSha, id]); - - if (loading) { + // ────────────────────────────────────────────────────────── + // Render + // ────────────────────────────────────────────────────────── + if (commitsQuery.isLoading) { return (
@@ -638,10 +515,10 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } ); } - if (error) { + if (commitsQuery.error) { return (
-

{error}

+

{commitsQuery.error instanceof Error ? commitsQuery.error.message : 'Something went wrong'}

@@ -650,7 +527,9 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } } if (!repository || commits.length === 0) { - if (waitingForInitialCommits) { + if (waitingForCommits) { + const ingestProgress = ingestJob.data?.progress ? Number(ingestJob.data.progress) : 0; + const ingestStatus = ingestJob.data?.status; return (
@@ -673,64 +552,126 @@ export default function ExplorePage({ params }: { params: Promise<{ id: string } ); } + // Diff data from queries + const commitDiffFile = commitDiffQuery.data ?? null; + const commitDiffLoading = commitDiffQuery.isLoading; + const commitDiffError = commitDiffQuery.error + ? (commitDiffQuery.error instanceof Error ? commitDiffQuery.error.message : 'Failed to load commit diff') + : null; + + const compareFile = compareDiffQuery.data ?? null; + const compareLoading = compareDiffQuery.isLoading; + const compareError = compareDiffQuery.error + ? (compareDiffQuery.error instanceof Error ? compareDiffQuery.error.message : 'Failed to compare commits') + : null; + + const loadingFiles = filesQuery.isLoading; + const loadingContent = fileContentQuery.isLoading; + return (
-
+ {commitsQuery.isFetchingNextPage &&