mirror of
https://github.com/nilbuild/diffity.git
synced 2026-09-19 07:26:16 +08:00
style: update skills and UI overhaul
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# diffity
|
||||
|
||||
A git diff viewer that runs in your browser. Split or unified view, syntax highlighting, and inline code review with AI agent support.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g diffity
|
||||
```
|
||||
|
||||
Install the AI review skills for your coding agent (Claude Code, Cursor, Codex, etc.):
|
||||
|
||||
```bash
|
||||
npx skills add kamrify/diffity
|
||||
```
|
||||
|
||||
Verify everything works:
|
||||
|
||||
```bash
|
||||
diffity doctor
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run `diffity` inside any git repository:
|
||||
|
||||
```bash
|
||||
# Working tree changes
|
||||
diffity
|
||||
|
||||
# Staged changes
|
||||
diffity --staged
|
||||
|
||||
# Last commit
|
||||
diffity HEAD~1
|
||||
|
||||
# Last 3 commits
|
||||
diffity HEAD~3
|
||||
|
||||
# Compare branches
|
||||
diffity main..feature
|
||||
|
||||
# Compare tags
|
||||
diffity v1.0.0..v2.0.0
|
||||
```
|
||||
|
||||
Your browser opens automatically with a GitHub-style diff view.
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
--staged Show staged changes
|
||||
--port <port> Custom port (default: 5391)
|
||||
--no-open Don't open browser
|
||||
--dark Dark mode
|
||||
--unified Unified view (default: split)
|
||||
--quiet Minimal terminal output
|
||||
```
|
||||
|
||||
## Code Review
|
||||
|
||||
Diffity has a built-in review system. Comments are stored locally in SQLite and appear in the browser in real time.
|
||||
|
||||
### With AI agents
|
||||
|
||||
Use the slash commands inside Claude Code, Cursor, or any supported agent:
|
||||
|
||||
**`/diffity-start`** — Launch diffity and open the browser
|
||||
|
||||
**`/diffity-review`** — AI reviews your diff and leaves inline comments with severity tags:
|
||||
- `[must-fix]` — Bugs, security issues
|
||||
- `[suggestion]` — Meaningful improvements
|
||||
- `[nit]` — Style preferences
|
||||
- `[question]` — Needs clarification
|
||||
|
||||
You can focus the review: `/diffity-review security` or `/diffity-review performance`
|
||||
|
||||
**`/diffity-resolve`** — AI reads the review comments, makes the code fixes, and marks threads as resolved
|
||||
|
||||
The typical workflow:
|
||||
|
||||
```
|
||||
/diffity-start # open the diff viewer
|
||||
/diffity-review # get a code review
|
||||
# read comments in browser, decide what to fix
|
||||
/diffity-resolve # AI fixes the flagged issues
|
||||
```
|
||||
|
||||
### With the CLI
|
||||
|
||||
You can also manage comments directly from the terminal:
|
||||
|
||||
```bash
|
||||
# List open threads
|
||||
diffity agent list --status open
|
||||
|
||||
# Leave a comment
|
||||
diffity agent comment --file src/app.ts --line 42 --body "[must-fix] Missing null check"
|
||||
|
||||
# Leave a general comment (not tied to a line)
|
||||
diffity agent general-comment --body "Looks good overall, one issue to fix"
|
||||
|
||||
# Reply to a thread
|
||||
diffity agent reply abc123 --body "Good catch, fixed"
|
||||
|
||||
# Mark as resolved
|
||||
diffity agent resolve abc123 --summary "Added null check"
|
||||
|
||||
# Mark as won't fix
|
||||
diffity agent dismiss abc123 --reason "Intentional behavior"
|
||||
|
||||
# Get full JSON output for scripting
|
||||
diffity agent list --json
|
||||
```
|
||||
|
||||
Thread IDs accept 8-character prefixes — you don't need to type the full UUID.
|
||||
|
||||
## Other Commands
|
||||
|
||||
```bash
|
||||
# Check for issues
|
||||
diffity doctor
|
||||
|
||||
# Update to latest version
|
||||
diffity update
|
||||
|
||||
# Remove all diffity data (~/.diffity)
|
||||
diffity prune
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
Diffity starts a local HTTP server that serves a React app. The server reads git data from your repository and the browser renders the diff with syntax highlighting (via Shiki).
|
||||
|
||||
Review comments are stored in a SQLite database at `~/.diffity/`. The browser polls for new comments every 2 seconds, so comments from the CLI or AI agents appear almost instantly.
|
||||
|
||||
Each `diffity` session is tied to a specific git HEAD hash. If you make new commits, start a new session to get a fresh review context.
|
||||
|
||||
## License
|
||||
|
||||
[PolyForm Shield 1.0.0](./LICENSE) © [Kamran Ahmed](https://x.com/kamrify)
|
||||
Generated
+3
-3
@@ -4286,9 +4286,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.88.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
|
||||
"integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==",
|
||||
"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"
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
import open from 'open';
|
||||
import pc from 'picocolors';
|
||||
import { isGitRepo } from '@diffity/git';
|
||||
import { startServer } from './server.js';
|
||||
import { registerAgentCommands } from './agent.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('diffity')
|
||||
.description('GitHub-style git diff viewer in the browser')
|
||||
.version('0.1.0')
|
||||
.version(pkg.version)
|
||||
.argument('[refs...]', 'Git refs to diff (e.g. HEAD~3, main, main..feature)')
|
||||
.option('--staged', 'Show staged changes (git diff --staged)')
|
||||
.option('--port <port>', 'Port to use', '5391')
|
||||
@@ -130,6 +135,72 @@ program
|
||||
console.log(pc.green('Pruned all diffity data (~/.diffity).'));
|
||||
});
|
||||
|
||||
program
|
||||
.command('update')
|
||||
.description('Update diffity to the latest version')
|
||||
.action(() => {
|
||||
try {
|
||||
const registry = execSync('npm view diffity version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
if (registry === pkg.version) {
|
||||
console.log(pc.green(`Already on the latest version (${pkg.version}).`));
|
||||
return;
|
||||
}
|
||||
console.log(`${pc.dim(`Current: ${pkg.version}`)} → ${pc.bold(registry)}`);
|
||||
console.log(pc.dim('Updating...'));
|
||||
execSync('npm install -g diffity@latest', { stdio: 'inherit' });
|
||||
console.log(pc.green(`Updated to ${registry}.`));
|
||||
} catch {
|
||||
console.error(pc.red('Failed to update. Try running: npm install -g diffity@latest'));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Check that diffity can run correctly')
|
||||
.action(() => {
|
||||
let ok = true;
|
||||
|
||||
process.stdout.write(' git ');
|
||||
try {
|
||||
const gitVersion = execSync('git --version', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
console.log(pc.green(`✓ ${gitVersion}`));
|
||||
} catch {
|
||||
console.log(pc.red('✗ git not found'));
|
||||
ok = false;
|
||||
}
|
||||
|
||||
process.stdout.write(' git repo ');
|
||||
if (isGitRepo()) {
|
||||
console.log(pc.green('✓ inside a git repository'));
|
||||
} else {
|
||||
console.log(pc.yellow('- not inside a git repository'));
|
||||
}
|
||||
|
||||
process.stdout.write(' node ');
|
||||
console.log(pc.green(`✓ ${process.version}`));
|
||||
|
||||
process.stdout.write(' sqlite ');
|
||||
try {
|
||||
require('better-sqlite3');
|
||||
console.log(pc.green('✓ better-sqlite3 loaded'));
|
||||
} catch {
|
||||
console.log(pc.red('✗ better-sqlite3 failed to load (native module issue)'));
|
||||
ok = false;
|
||||
}
|
||||
|
||||
process.stdout.write(' version ');
|
||||
console.log(pc.green(`✓ diffity ${pkg.version}`));
|
||||
|
||||
console.log('');
|
||||
if (ok) {
|
||||
console.log(pc.green(' All checks passed.'));
|
||||
} else {
|
||||
console.log(pc.red(' Some checks failed. Fix the issues above and try again.'));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
registerAgentCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import {
|
||||
createThread,
|
||||
getThreadsForSession,
|
||||
addReply,
|
||||
updateThreadStatus,
|
||||
deleteThread,
|
||||
deleteAllThreadsForSession,
|
||||
deleteComment,
|
||||
type ThreadStatus,
|
||||
} from './threads.js';
|
||||
import { getCurrentSession } from './session.js';
|
||||
|
||||
function sendJson(res: ServerResponse, data: unknown) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: message }));
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function handleReviewRoute(req: IncomingMessage, res: ServerResponse, pathname: string, url: URL): boolean {
|
||||
if (pathname === '/api/sessions/current' && req.method === 'GET') {
|
||||
const session = getCurrentSession();
|
||||
sendJson(res, session);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/api/threads' && req.method === 'GET') {
|
||||
const sid = url.searchParams.get('session');
|
||||
if (!sid) {
|
||||
sendError(res, 400, 'Missing session parameter');
|
||||
return true;
|
||||
}
|
||||
const status = url.searchParams.get('status') as ThreadStatus | null;
|
||||
const threads = getThreadsForSession(sid, status || undefined);
|
||||
sendJson(res, threads);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/api/threads' && req.method === 'DELETE') {
|
||||
readBody(req).then((raw) => {
|
||||
try {
|
||||
const body = JSON.parse(raw);
|
||||
const { sessionId: sid } = body;
|
||||
if (!sid) {
|
||||
sendError(res, 400, 'Missing sessionId');
|
||||
return;
|
||||
}
|
||||
deleteAllThreadsForSession(sid);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to delete all threads: ${err}`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/api/threads' && req.method === 'POST') {
|
||||
readBody(req).then((raw) => {
|
||||
try {
|
||||
const body = JSON.parse(raw);
|
||||
const { sessionId: sid, filePath, side, startLine, endLine, body: commentBody, author, anchorContent } = body;
|
||||
if (!sid || !filePath || !side || typeof startLine !== 'number' || typeof endLine !== 'number' || !commentBody || !author) {
|
||||
sendError(res, 400, 'Missing required fields');
|
||||
return;
|
||||
}
|
||||
const thread = createThread(sid, filePath, side, startLine, endLine, commentBody, author, anchorContent);
|
||||
sendJson(res, thread);
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to create thread: ${err}`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const threadReplyMatch = pathname.match(/^\/api\/threads\/([^/]+)\/reply$/);
|
||||
if (threadReplyMatch && req.method === 'POST') {
|
||||
readBody(req).then((raw) => {
|
||||
try {
|
||||
const body = JSON.parse(raw);
|
||||
const { body: commentBody, author } = body;
|
||||
if (!commentBody || !author) {
|
||||
sendError(res, 400, 'Missing body or author');
|
||||
return;
|
||||
}
|
||||
const comment = addReply(threadReplyMatch[1], commentBody, author);
|
||||
sendJson(res, comment);
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to add reply: ${err}`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const threadStatusMatch = pathname.match(/^\/api\/threads\/([^/]+)\/status$/);
|
||||
if (threadStatusMatch && req.method === 'PATCH') {
|
||||
readBody(req).then((raw) => {
|
||||
try {
|
||||
const body = JSON.parse(raw);
|
||||
const { status, summary } = body;
|
||||
if (!status) {
|
||||
sendError(res, 400, 'Missing status');
|
||||
return;
|
||||
}
|
||||
const summaryAuthor = summary ? { name: 'System', type: 'user' as const } : undefined;
|
||||
updateThreadStatus(threadStatusMatch[1], status, summary, summaryAuthor);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to update thread status: ${err}`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const threadDeleteMatch = pathname.match(/^\/api\/threads\/([^/]+)$/);
|
||||
if (threadDeleteMatch && req.method === 'DELETE') {
|
||||
try {
|
||||
deleteThread(threadDeleteMatch[1]);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to delete thread: ${err}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const commentDeleteMatch = pathname.match(/^\/api\/comments\/([^/]+)$/);
|
||||
if (commentDeleteMatch && req.method === 'DELETE') {
|
||||
try {
|
||||
deleteComment(commentDeleteMatch[1]);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to delete comment: ${err}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
+4
-118
@@ -22,17 +22,8 @@ import {
|
||||
revertHunk,
|
||||
isActionableRef,
|
||||
} from '@diffity/git';
|
||||
import { findOrCreateSession, getCurrentSession } from './session.js';
|
||||
import {
|
||||
createThread,
|
||||
getThreadsForSession,
|
||||
addReply,
|
||||
updateThreadStatus,
|
||||
deleteThread,
|
||||
deleteAllThreadsForSession,
|
||||
deleteComment,
|
||||
type ThreadStatus,
|
||||
} from './threads.js';
|
||||
import { findOrCreateSession } from './session.js';
|
||||
import { handleReviewRoute } from './review-routes.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -228,7 +219,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
|
||||
fileMap.set(f, 'staged');
|
||||
}
|
||||
for (const f of unstaged) {
|
||||
fileMap.set(f, fileMap.has(f) ? 'modified' : 'modified');
|
||||
fileMap.set(f, 'modified');
|
||||
}
|
||||
for (const f of untracked) {
|
||||
fileMap.set(f, 'added');
|
||||
@@ -337,112 +328,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Review API endpoints ---
|
||||
|
||||
if (pathname === '/api/sessions/current' && req.method === 'GET') {
|
||||
const session = getCurrentSession();
|
||||
sendJson(res, session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/threads' && req.method === 'GET') {
|
||||
const sid = url.searchParams.get('session');
|
||||
if (!sid) {
|
||||
sendError(res, 400, 'Missing session parameter');
|
||||
return;
|
||||
}
|
||||
const status = url.searchParams.get('status') as ThreadStatus | null;
|
||||
const threads = getThreadsForSession(sid, status || undefined);
|
||||
sendJson(res, threads);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/threads' && req.method === 'DELETE') {
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { sessionId: sid } = body;
|
||||
if (!sid) {
|
||||
sendError(res, 400, 'Missing sessionId');
|
||||
return;
|
||||
}
|
||||
deleteAllThreadsForSession(sid);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to delete all threads: ${err}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/threads' && req.method === 'POST') {
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { sessionId: sid, filePath, side, startLine, endLine, body: commentBody, author, anchorContent } = body;
|
||||
if (!sid || !filePath || !side || typeof startLine !== 'number' || typeof endLine !== 'number' || !commentBody || !author) {
|
||||
sendError(res, 400, 'Missing required fields');
|
||||
return;
|
||||
}
|
||||
const thread = createThread(sid, filePath, side, startLine, endLine, commentBody, author, anchorContent);
|
||||
sendJson(res, thread);
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to create thread: ${err}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const threadReplyMatch = pathname.match(/^\/api\/threads\/([^/]+)\/reply$/);
|
||||
if (threadReplyMatch && req.method === 'POST') {
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { body: commentBody, author } = body;
|
||||
if (!commentBody || !author) {
|
||||
sendError(res, 400, 'Missing body or author');
|
||||
return;
|
||||
}
|
||||
const comment = addReply(threadReplyMatch[1], commentBody, author);
|
||||
sendJson(res, comment);
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to add reply: ${err}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const threadStatusMatch = pathname.match(/^\/api\/threads\/([^/]+)\/status$/);
|
||||
if (threadStatusMatch && req.method === 'PATCH') {
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req));
|
||||
const { status, summary } = body;
|
||||
if (!status) {
|
||||
sendError(res, 400, 'Missing status');
|
||||
return;
|
||||
}
|
||||
const summaryAuthor = summary ? { name: 'System', type: 'user' as const } : undefined;
|
||||
updateThreadStatus(threadStatusMatch[1], status, summary, summaryAuthor);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to update thread status: ${err}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const threadDeleteMatch = pathname.match(/^\/api\/threads\/([^/]+)$/);
|
||||
if (threadDeleteMatch && req.method === 'DELETE') {
|
||||
try {
|
||||
deleteThread(threadDeleteMatch[1]);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to delete thread: ${err}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const commentDeleteMatch = pathname.match(/^\/api\/comments\/([^/]+)$/);
|
||||
if (commentDeleteMatch && req.method === 'DELETE') {
|
||||
try {
|
||||
deleteComment(commentDeleteMatch[1]);
|
||||
sendJson(res, { ok: true });
|
||||
} catch (err) {
|
||||
sendError(res, 500, `Failed to delete comment: ${err}`);
|
||||
}
|
||||
if (handleReviewRoute(req, res, pathname, url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,28 @@ function rowToComment(row: CommentRow): ThreadComment {
|
||||
};
|
||||
}
|
||||
|
||||
function getCommentsForThread(threadId: string): ThreadComment[] {
|
||||
function getCommentsForThreads(threadIds: string[]): Map<string, ThreadComment[]> {
|
||||
if (threadIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const db = getDb();
|
||||
const placeholders = threadIds.map(() => '?').join(', ');
|
||||
const rows = db.prepare(
|
||||
'SELECT * FROM comments WHERE thread_id = ? ORDER BY created_at ASC'
|
||||
).all(threadId) as CommentRow[];
|
||||
return rows.map(rowToComment);
|
||||
`SELECT * FROM comments WHERE thread_id IN (${placeholders}) ORDER BY created_at ASC`
|
||||
).all(...threadIds) as CommentRow[];
|
||||
|
||||
const map = new Map<string, ThreadComment[]>();
|
||||
for (const row of rows) {
|
||||
const comments = map.get(row.thread_id) ?? [];
|
||||
comments.push(rowToComment(row));
|
||||
map.set(row.thread_id, comments);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getCommentsForThread(threadId: string): ThreadComment[] {
|
||||
const map = getCommentsForThreads([threadId]);
|
||||
return map.get(threadId) ?? [];
|
||||
}
|
||||
|
||||
export function createThread(
|
||||
@@ -140,7 +156,8 @@ export function getThreadsForSession(sessionId: string, status?: ThreadStatus):
|
||||
).all(sessionId) as ThreadRow[];
|
||||
}
|
||||
|
||||
return rows.map(row => rowToThread(row, getCommentsForThread(row.id)));
|
||||
const commentsByThread = getCommentsForThreads(rows.map(r => r.id));
|
||||
return rows.map(row => rowToThread(row, commentsByThread.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
export function getThread(idOrPrefix: string): Thread | null {
|
||||
|
||||
@@ -31,7 +31,7 @@ You are reading open review comments and resolving them by making the requested
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, tell the user to run `npm run dev` from the diffity repo root to link the CLI.
|
||||
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, {{install_hint}}.
|
||||
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first.
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -31,7 +31,7 @@ You are reviewing a diff and leaving inline comments using the `{{binary}} agent
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, tell the user to run `npm run dev` from the diffity repo root to link the CLI.
|
||||
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, {{install_hint}}.
|
||||
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first (e.g. `{{binary}}` or `{{binary}} --staged`).
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -10,7 +10,7 @@ You are starting the diffity diff viewer so the user can see their changes in th
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, tell the user to run `npm run dev` from the diffity repo root to link the CLI.
|
||||
1. Check that `{{binary}}` is available: run `which {{binary}}`. If not found, {{install_hint}}.
|
||||
2. Determine which mode to start in:
|
||||
- If the user said "staged" or there are staged changes they want to review: `{{binary}} --staged`
|
||||
- Otherwise default to: `{{binary}}`
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CommentFormRow(props: CommentFormRowProps) {
|
||||
) : (
|
||||
<>
|
||||
<td colSpan={colSpan} className="bg-bg-secondary"></td>
|
||||
<td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary">{formContent}</td>
|
||||
<td colSpan={colSpan} className="px-4 py-3 bg-bg-secondary border-l border-border">{formContent}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CommentBubble } from './comment-bubble';
|
||||
import { TrashIcon } from './icons/trash-icon';
|
||||
import { CommentIcon } from './icons/comment-icon';
|
||||
import { ThreadBadge } from './ui/thread-badge';
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
interface CommentThreadProps {
|
||||
thread: CommentThreadType;
|
||||
@@ -27,34 +28,48 @@ function StatusBadge(props: { status: string }) {
|
||||
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
return <ThreadBadge variant="resolved" />;
|
||||
return <ThreadBadge variant='resolved' />;
|
||||
case 'dismissed':
|
||||
return <ThreadBadge variant="dismissed" />;
|
||||
return <ThreadBadge variant='dismissed' />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function CommentThread(props: CommentThreadProps) {
|
||||
const { thread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread, currentAuthor, colSpan, viewMode, side, currentCode } = props;
|
||||
const {
|
||||
thread,
|
||||
onReply,
|
||||
onResolve,
|
||||
onUnresolve,
|
||||
onDeleteComment,
|
||||
onDeleteThread,
|
||||
currentAuthor,
|
||||
colSpan,
|
||||
viewMode,
|
||||
side,
|
||||
currentCode,
|
||||
} = props;
|
||||
const [showReply, setShowReply] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(isThreadResolved(thread));
|
||||
|
||||
const isOutdated = thread.anchorContent && currentCode && thread.anchorContent !== currentCode;
|
||||
const isOutdated =
|
||||
thread.anchorContent && currentCode && thread.anchorContent !== currentCode;
|
||||
|
||||
if (isCollapsed) {
|
||||
const collapsedContent = (
|
||||
<td colSpan={colSpan} className="px-4 py-2 border-l border-border">
|
||||
<td colSpan={colSpan} className='px-4 py-2 border-l border-border'>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="flex items-center gap-2 text-xs text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
|
||||
className='flex items-center gap-2 text-xs text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
|
||||
>
|
||||
<CommentIcon className="w-4 h-4" />
|
||||
<span>{thread.comments.length} comment{thread.comments.length !== 1 ? 's' : ''}</span>
|
||||
<CommentIcon className='w-4 h-4' />
|
||||
<span>
|
||||
{thread.comments.length} comment
|
||||
{thread.comments.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<StatusBadge status={thread.status} />
|
||||
{isOutdated && (
|
||||
<ThreadBadge variant="outdated" />
|
||||
)}
|
||||
{isOutdated && <ThreadBadge variant='outdated' />}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
@@ -63,9 +78,15 @@ export function CommentThread(props: CommentThreadProps) {
|
||||
return (
|
||||
<tr data-thread-id={thread.id}>
|
||||
{side === 'old' ? (
|
||||
<>{collapsedContent}<td colSpan={colSpan}></td></>
|
||||
<>
|
||||
{collapsedContent}
|
||||
<td colSpan={colSpan}></td>
|
||||
</>
|
||||
) : (
|
||||
<><td colSpan={colSpan}></td>{collapsedContent}</>
|
||||
<>
|
||||
<td colSpan={colSpan}></td>
|
||||
{collapsedContent}
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
@@ -74,28 +95,34 @@ export function CommentThread(props: CommentThreadProps) {
|
||||
return <tr data-thread-id={thread.id}>{collapsedContent}</tr>;
|
||||
}
|
||||
|
||||
const lineLabel = thread.startLine === thread.endLine
|
||||
? `Line ${thread.startLine}`
|
||||
: `Lines ${thread.startLine}–${thread.endLine}`;
|
||||
const lineLabel =
|
||||
thread.startLine === thread.endLine
|
||||
? `Line ${thread.startLine}`
|
||||
: `Lines ${thread.startLine}–${thread.endLine}`;
|
||||
|
||||
const resolved = isThreadResolved(thread);
|
||||
|
||||
const threadContent = (
|
||||
<td colSpan={colSpan} className="px-4 py-3 border-l border-border">
|
||||
<div className="border border-border rounded-lg overflow-hidden max-w-[700px]">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-bg-secondary border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-text-muted font-mono">{lineLabel}</span>
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={cn('px-4 py-3', {
|
||||
'border-l border-border': side === 'new' && viewMode === 'split',
|
||||
})}
|
||||
>
|
||||
<div className='border border-border rounded-lg overflow-hidden max-w-[700px]'>
|
||||
<div className='flex items-center justify-between px-3 py-1.5 bg-bg-secondary border-b border-border'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-[11px] text-text-muted font-mono'>
|
||||
{lineLabel}
|
||||
</span>
|
||||
<StatusBadge status={thread.status} />
|
||||
{isOutdated && (
|
||||
<ThreadBadge variant="outdated" />
|
||||
)}
|
||||
{isOutdated && <ThreadBadge variant='outdated' />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className='flex items-center gap-1'>
|
||||
{resolved ? (
|
||||
<button
|
||||
onClick={() => onUnresolve(thread.id)}
|
||||
className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
|
||||
className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
|
||||
>
|
||||
Reopen
|
||||
</button>
|
||||
@@ -105,23 +132,23 @@ export function CommentThread(props: CommentThreadProps) {
|
||||
onResolve(thread.id);
|
||||
setIsCollapsed(true);
|
||||
}}
|
||||
className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
|
||||
className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer'
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer ml-2"
|
||||
className='text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer ml-2'
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteThread(thread.id)}
|
||||
className="text-text-muted hover:text-deleted transition-colors cursor-pointer ml-1"
|
||||
title="Delete thread"
|
||||
className='text-text-muted hover:text-deleted transition-colors cursor-pointer ml-1'
|
||||
title='Delete thread'
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
<TrashIcon className='w-3.5 h-3.5' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,22 +162,22 @@ export function CommentThread(props: CommentThreadProps) {
|
||||
))}
|
||||
</div>
|
||||
{showReply ? (
|
||||
<div className="px-3 py-2 border-t border-border">
|
||||
<div className='px-3 py-2 border-t border-border'>
|
||||
<CommentForm
|
||||
onSubmit={(body) => {
|
||||
onReply(thread.id, body, currentAuthor);
|
||||
setShowReply(false);
|
||||
}}
|
||||
onCancel={() => setShowReply(false)}
|
||||
placeholder="Reply..."
|
||||
submitLabel="Reply"
|
||||
placeholder='Reply...'
|
||||
submitLabel='Reply'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 border-t border-border">
|
||||
<div className='px-3 py-2 border-t border-border'>
|
||||
<button
|
||||
onClick={() => setShowReply(true)}
|
||||
className="text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer"
|
||||
className='text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer'
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
@@ -164,9 +191,15 @@ export function CommentThread(props: CommentThreadProps) {
|
||||
return (
|
||||
<tr data-thread-id={thread.id}>
|
||||
{side === 'old' ? (
|
||||
<>{threadContent}<td colSpan={colSpan}></td></>
|
||||
<>
|
||||
{threadContent}
|
||||
<td colSpan={colSpan}></td>
|
||||
</>
|
||||
) : (
|
||||
<><td colSpan={colSpan}></td>{threadContent}</>
|
||||
<>
|
||||
<td colSpan={colSpan}></td>
|
||||
{threadContent}
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -256,21 +256,21 @@ export function DiffPage(props: DiffPageProps) {
|
||||
|
||||
if (diff && diff.files.length === 0 && !diffLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-bg text-text font-sans gap-3">
|
||||
<div className="text-added opacity-50 mb-2">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-bg text-text font-sans gap-2">
|
||||
<div className="text-added opacity-40 mb-1">
|
||||
<CheckCircleIcon />
|
||||
</div>
|
||||
<h2 className="text-xl text-text-secondary">No changes found</h2>
|
||||
<p className="text-text-muted">There are no differences to display.</p>
|
||||
<div className="mt-4 flex flex-col gap-2 items-center">
|
||||
<p className="text-sm text-text-muted mb-1">Try one of these</p>
|
||||
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-sm text-text">
|
||||
<h2 className="text-base font-medium text-text-secondary">No changes found</h2>
|
||||
<p className="text-xs text-text-muted">There are no differences to display.</p>
|
||||
<div className="mt-4 flex flex-col gap-1.5 items-center">
|
||||
<p className="text-xs text-text-muted mb-1">Try one of these</p>
|
||||
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
|
||||
diffity --staged
|
||||
</code>
|
||||
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-sm text-text">
|
||||
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
|
||||
diffity HEAD~1
|
||||
</code>
|
||||
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-sm text-text">
|
||||
<code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
|
||||
diffity main..feature
|
||||
</code>
|
||||
</div>
|
||||
@@ -293,6 +293,7 @@ export function DiffPage(props: DiffPageProps) {
|
||||
onHideWhitespaceChange={setHideWhitespace}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
onShowHelp={() => setShowHelp(true)}
|
||||
diff={diff || undefined}
|
||||
diffRef={refParam}
|
||||
threads={threads}
|
||||
|
||||
@@ -331,22 +331,22 @@ export function FileBlock(props: FileBlockProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg mx-4 my-4 overflow-hidden ${highlighted ? 'animate-flash-highlight-border' : 'border-border'}`}
|
||||
className={`border rounded-lg mx-4 my-3 overflow-hidden ${highlighted ? 'animate-flash-highlight-border' : 'border-border'}`}
|
||||
id={`file-${encodeURIComponent(filePath)}`}
|
||||
onAnimationEnd={onHighlightEnd}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 border-b border-border text-sm sticky top-0 z-10 shadow-sticky ${highlighted ? 'animate-flash-highlight' : 'bg-bg-secondary'}`}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 border-border text-xs sticky top-0 z-10 shadow-sticky ${highlighted ? 'animate-flash-highlight' : 'bg-bg-secondary'}`}
|
||||
>
|
||||
<IconButton
|
||||
className="text-[10px] w-5 h-5 shrink-0"
|
||||
className="text-[10px] w-4 h-4 shrink-0"
|
||||
onClick={() => onToggleCollapse(filePath)}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{collapsed ? '\u25b6' : '\u25bc'}
|
||||
</IconButton>
|
||||
<button
|
||||
className="font-mono text-sm truncate text-left cursor-pointer hover:text-text-link transition-colors"
|
||||
className="font-mono text-xs truncate text-left cursor-pointer hover:text-accent transition-colors"
|
||||
onClick={() => onToggleCollapse(filePath)}
|
||||
>
|
||||
{showRename ? (
|
||||
@@ -361,21 +361,21 @@ export function FileBlock(props: FileBlockProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyPath(filePath)}
|
||||
className="shrink-0 text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
className="shrink-0 text-text-muted hover:text-text transition-colors cursor-pointer opacity-0 group-hover:opacity-100"
|
||||
title="Copy file path"
|
||||
>
|
||||
{pathCopied ? (
|
||||
<CheckIcon className="w-3.5 h-3.5 text-added" />
|
||||
<CheckIcon className="w-3 h-3 text-added" />
|
||||
) : (
|
||||
<CopyIcon className="w-3.5 h-3.5" />
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
{file.status !== 'modified' && <StatusBadge status={file.status} />}
|
||||
{file.isBinary && <Badge className="bg-bg-tertiary text-text-muted">Binary</Badge>}
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
<div className="ml-auto flex items-center gap-2.5 shrink-0">
|
||||
{(fileThreads.length + orphanedThreads.length) > 0 && (
|
||||
<span className="text-xs text-text-muted flex items-center gap-1">
|
||||
<CommentIcon className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] text-text-muted flex items-center gap-1">
|
||||
<CommentIcon className="w-3 h-3" />
|
||||
{fileThreads.length + orphanedThreads.length}
|
||||
{orphanedThreads.length > 0 && (
|
||||
<ThreadBadge variant="outdated" size="sm">
|
||||
@@ -388,22 +388,22 @@ export function FileBlock(props: FileBlockProps) {
|
||||
<DiffStats additions={file.additions} deletions={file.deletions} />
|
||||
<div className="flex gap-px">
|
||||
{Array.from({ length: addBlocks }).map((_, i) => (
|
||||
<span key={`a${i}`} className="w-2 h-2 rounded-[1px] bg-added" />
|
||||
<span key={`a${i}`} className="w-1.5 h-1.5 rounded-sm bg-added" />
|
||||
))}
|
||||
{Array.from({ length: delBlocks }).map((_, i) => (
|
||||
<span key={`d${i}`} className="w-2 h-2 rounded-[1px] bg-deleted" />
|
||||
<span key={`d${i}`} className="w-1.5 h-1.5 rounded-sm bg-deleted" />
|
||||
))}
|
||||
{Array.from({ length: neutralBlocks }).map((_, i) => (
|
||||
<span key={`n${i}`} className="w-2 h-2 rounded-[1px] bg-border" />
|
||||
<span key={`n${i}`} className="w-1.5 h-1.5 rounded-sm bg-border" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer select-none hover:text-text transition-colors">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-text-muted cursor-pointer select-none hover:text-text transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reviewed}
|
||||
onChange={() => onReviewedChange(filePath, !reviewed)}
|
||||
className="accent-added cursor-pointer"
|
||||
className="accent-added cursor-pointer w-3 h-3"
|
||||
/>
|
||||
Viewed
|
||||
</label>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { DiffStats } from './diff-stats';
|
||||
import { StatusBadge } from './ui/status-badge';
|
||||
import { ChevronIcon } from './icons/chevron-icon';
|
||||
import { FolderIcon } from './icons/folder-icon';
|
||||
import { FileIcon } from './icons/file-icon';
|
||||
import { CommentIcon } from './icons/comment-icon';
|
||||
|
||||
interface FileTreeItemProps {
|
||||
@@ -27,7 +26,7 @@ export function FileTreeItem(props: FileTreeItemProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 w-full py-0.5 pr-2 text-left text-sm hover:bg-hover cursor-pointer"
|
||||
className="flex items-center gap-1.5 w-full py-1 pr-2 text-left text-[13px] hover:bg-hover cursor-pointer"
|
||||
style={{ paddingLeft: `${paddingLeft}px` }}
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
>
|
||||
@@ -59,25 +58,24 @@ export function FileTreeItem(props: FileTreeItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full py-0.5 pr-2 text-left text-sm cursor-pointer border-l-2',
|
||||
'flex items-center gap-1.5 w-full py-1 pr-2 text-left text-[13px] cursor-pointer border-l-2',
|
||||
isActive
|
||||
? 'bg-active border-l-accent'
|
||||
: 'border-l-transparent hover:bg-hover',
|
||||
isReviewed && 'opacity-60'
|
||||
isReviewed && 'opacity-50'
|
||||
)}
|
||||
style={{ paddingLeft: `${paddingLeft + 15}px` }}
|
||||
onClick={() => onFileClick(node.path)}
|
||||
>
|
||||
<FileIcon />
|
||||
<StatusBadge status={node.file.status} compact />
|
||||
<span className={cn('flex-1 min-w-0 truncate', isReviewed && 'line-through')}>
|
||||
{node.name}
|
||||
</span>
|
||||
{hasComments && (
|
||||
<CommentIcon className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<CommentIcon className="w-3 h-3 text-accent shrink-0" />
|
||||
)}
|
||||
{isReviewed ? (
|
||||
<span className="text-added text-xs shrink-0" title="Viewed">✓</span>
|
||||
<span className="text-added text-[10px] shrink-0" title="Viewed">✓</span>
|
||||
) : (
|
||||
<DiffStats additions={node.file.additions} deletions={node.file.deletions} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function EyeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function EyeOffIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<path d="M2 2l12 12" />
|
||||
<path d="M6.5 6.5a2 2 0 0 0 2.83 2.83" />
|
||||
<path d="M4.2 4.2C2.7 5.3 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1.3 0 2.5-.4 3.5-1" />
|
||||
<path d="M12.5 10.5c1-1.1 2-2.5 2-2.5s-2.5-4.5-6.5-4.5c-.5 0-1 .05-1.5.15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function GitBranchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<line x1="5" y1="3.5" x2="5" y2="12.5" />
|
||||
<circle cx="5" cy="3.5" r="1.5" />
|
||||
<circle cx="5" cy="12.5" r="1.5" />
|
||||
<circle cx="11" cy="5.5" r="1.5" />
|
||||
<path d="M11 7c0 2-2 3.5-6 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function KeyboardIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<rect x="1" y="3.5" width="14" height="9" rx="2" />
|
||||
<line x1="4" y1="6.5" x2="5" y2="6.5" />
|
||||
<line x1="7.5" y1="6.5" x2="8.5" y2="6.5" />
|
||||
<line x1="11" y1="6.5" x2="12" y2="6.5" />
|
||||
<line x1="5" y1="9.5" x2="11" y2="9.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function MoonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<path d="M13.5 8.5a5.5 5.5 0 0 1-7-7 5.5 5.5 0 1 0 7 7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function SearchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<line x1="10.2" y1="10.2" x2="14" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function SidebarIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" />
|
||||
<line x1="5.5" y1="2.5" x2="5.5" y2="13.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function SplitViewIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2" />
|
||||
<line x1="8" y1="1.5" x2="8" y2="14.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function SunIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<line x1="8" y1="1" x2="8" y2="2.5" />
|
||||
<line x1="8" y1="13.5" x2="8" y2="15" />
|
||||
<line x1="1" y1="8" x2="2.5" y2="8" />
|
||||
<line x1="13.5" y1="8" x2="15" y2="8" />
|
||||
<line x1="3.05" y1="3.05" x2="4.1" y2="4.1" />
|
||||
<line x1="11.9" y1="11.9" x2="12.95" y2="12.95" />
|
||||
<line x1="3.05" y1="12.95" x2="4.1" y2="11.9" />
|
||||
<line x1="11.9" y1="4.1" x2="12.95" y2="3.05" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function UnifiedViewIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2" />
|
||||
<line x1="4.5" y1="5.5" x2="11.5" y2="5.5" />
|
||||
<line x1="4.5" y1="8" x2="11.5" y2="8" />
|
||||
<line x1="4.5" y1="10.5" x2="11.5" y2="10.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export function XIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<line x1="4" y1="4" x2="12" y2="12" />
|
||||
<line x1="12" y1="4" x2="4" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { XIcon } from './icons/x-icon';
|
||||
|
||||
interface ShortcutModalProps {
|
||||
onClose: () => void;
|
||||
@@ -52,7 +52,7 @@ export function ShortcutModal(props: ShortcutModalProps) {
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="bg-bg border border-border rounded-lg shadow-md w-[480px] max-w-[90vw] max-h-[80vh] overflow-y-auto backdrop:bg-black/50 p-0"
|
||||
className="bg-bg text-text border border-border rounded-xl shadow-md w-[420px] max-w-[90vw] max-h-[80vh] overflow-y-auto backdrop:bg-black/60 backdrop:backdrop-blur-sm p-0 m-auto fixed inset-0 h-fit"
|
||||
onClose={onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === dialogRef.current) {
|
||||
@@ -60,25 +60,28 @@ export function ShortcutModal(props: ShortcutModalProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
|
||||
<IconButton className="text-2xl w-8 h-8" onClick={onClose}>
|
||||
×
|
||||
</IconButton>
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||
<button
|
||||
className="p-1 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="px-5 py-4">
|
||||
{shortcuts.map(group => (
|
||||
<div key={group.category} className="mb-4 last:mb-0">
|
||||
<h3 className="text-sm font-semibold text-text-secondary mb-2 uppercase tracking-wide">
|
||||
<div key={group.category} className="mb-5 last:mb-0">
|
||||
<h3 className="text-[10px] font-semibold text-text-muted mb-2.5 uppercase tracking-widest">
|
||||
{group.category}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{group.items.map(item => (
|
||||
<div key={item.key} className="flex items-center gap-3">
|
||||
<kbd className="inline-flex items-center justify-center min-w-7 h-6 px-1.5 bg-bg-secondary border border-border rounded font-mono text-xs font-semibold shadow-[inset_0_-1px_0_var(--color-border)]">
|
||||
<div key={item.key} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-xs text-text-secondary">{item.description}</span>
|
||||
<kbd className="inline-flex items-center justify-center min-w-6 h-5 px-1.5 bg-bg-secondary border border-border rounded font-mono text-[11px] text-text-muted shadow-[inset_0_-1px_0_var(--color-border)]">
|
||||
{item.key}
|
||||
</kbd>
|
||||
<span className="text-sm text-text-secondary">{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import type { DiffFile } from '@diffity/parser';
|
||||
import { FileTree } from './file-tree';
|
||||
import { SidebarIcon } from './icons/sidebar-icon';
|
||||
import { SearchIcon } from './icons/search-icon';
|
||||
import { XIcon } from './icons/x-icon';
|
||||
|
||||
interface SidebarProps {
|
||||
files: DiffFile[];
|
||||
@@ -17,38 +20,39 @@ export function Sidebar(props: SidebarProps) {
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="w-8 min-w-8 border-r border-border bg-bg-secondary flex items-start justify-center pt-2">
|
||||
<div className="w-10 min-w-10 border-r border-border bg-bg-secondary flex items-start justify-center pt-3">
|
||||
<button
|
||||
className="text-lg p-1 text-text-muted hover:text-text cursor-pointer"
|
||||
className="p-1.5 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title="Show sidebar"
|
||||
>
|
||||
›
|
||||
<SidebarIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-75 min-w-75 border-r border-border bg-bg-secondary flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
|
||||
<span className="font-semibold text-sm flex items-center gap-2">
|
||||
Files changed
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 bg-bg-tertiary rounded-full text-xs font-semibold text-text-secondary">
|
||||
<aside className="w-72 min-w-72 border-r border-border bg-bg-secondary flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border">
|
||||
<span className="text-xs font-medium text-text-secondary flex items-center gap-2 uppercase tracking-wider">
|
||||
Files
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 bg-bg-tertiary rounded-full text-[10px] font-semibold text-text-muted">
|
||||
{reviewedFiles.size > 0 ? `${reviewedFiles.size}/${files.length}` : files.length}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="text-lg p-1 text-text-muted hover:text-text cursor-pointer"
|
||||
className="p-1 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer"
|
||||
onClick={() => setCollapsed(true)}
|
||||
title="Hide sidebar"
|
||||
>
|
||||
‹
|
||||
<SidebarIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3 py-2">
|
||||
<SearchIcon className="absolute left-5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
|
||||
<input
|
||||
className="w-full px-2 py-1 border border-border rounded-md bg-bg text-sm outline-none focus:border-accent focus:ring-2 focus:ring-accent/30"
|
||||
className="w-full pl-7 pr-7 py-1.5 border border-border rounded-md bg-bg text-xs outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 placeholder:text-text-muted"
|
||||
type="text"
|
||||
placeholder="Filter files..."
|
||||
value={search}
|
||||
@@ -56,10 +60,10 @@ export function Sidebar(props: SidebarProps) {
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted text-base cursor-pointer"
|
||||
className="absolute right-5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text cursor-pointer"
|
||||
onClick={() => setSearch('')}
|
||||
>
|
||||
×
|
||||
<XIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,15 @@ export function StaleDiffBanner(props: StaleDiffBannerProps) {
|
||||
const { onRefresh } = props;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-3 px-4 py-2 bg-accent/10 border-b border-accent/20 text-sm animate-slide-down">
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-3 px-4 py-1.5 bg-accent/10 border-b border-accent/20 text-xs animate-slide-down">
|
||||
<span className="text-accent font-medium">
|
||||
Files have changed since this diff was loaded
|
||||
</span>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 py-1 bg-accent text-white rounded-md text-xs font-medium hover:bg-accent-hover transition-colors cursor-pointer"
|
||||
className="px-2.5 py-0.5 bg-accent text-white rounded-md text-[11px] font-medium hover:bg-accent-hover transition-colors cursor-pointer"
|
||||
>
|
||||
Refresh diff
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ParsedDiff } from '@diffity/parser';
|
||||
import { DiffStats } from './diff-stats';
|
||||
import { GitBranchIcon } from './icons/git-branch-icon';
|
||||
|
||||
interface SummaryBarProps {
|
||||
diff: ParsedDiff | null;
|
||||
@@ -12,11 +13,12 @@ export function SummaryBar(props: SummaryBarProps) {
|
||||
const { diff, repoName, branch, description } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-bg-secondary border-b border-border font-sans text-sm min-h-11">
|
||||
<div className="flex items-center gap-2">
|
||||
{repoName && <span className="font-semibold text-accent">{repoName}</span>}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-bg-secondary border-b border-border font-sans text-sm">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{repoName && <span className="font-semibold text-text">{repoName}</span>}
|
||||
{branch && (
|
||||
<span className="px-2 py-0.5 bg-diff-hunk-bg text-diff-hunk-text rounded-md font-mono text-xs">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-diff-hunk-bg text-diff-hunk-text rounded-md font-mono text-xs">
|
||||
<GitBranchIcon className="w-3 h-3" />
|
||||
{branch}
|
||||
</span>
|
||||
)}
|
||||
@@ -24,13 +26,13 @@ export function SummaryBar(props: SummaryBarProps) {
|
||||
</div>
|
||||
{diff ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-secondary">
|
||||
{diff.stats.filesChanged} file{diff.stats.filesChanged !== 1 ? 's' : ''} changed
|
||||
<span className="text-text-muted text-xs">
|
||||
{diff.stats.filesChanged} file{diff.stats.filesChanged !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<DiffStats additions={diff.stats.totalAdditions} deletions={diff.stats.totalDeletions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-48 h-5 bg-bg-tertiary rounded animate-pulse" />
|
||||
<div className="w-48 h-4 bg-bg-tertiary rounded animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,13 @@ import { CheckIcon } from './icons/check-icon';
|
||||
import { ChevronUpIcon } from './icons/chevron-up-icon';
|
||||
import { ChevronDownIcon } from './icons/chevron-down-icon';
|
||||
import { TrashIcon } from './icons/trash-icon';
|
||||
import { UnifiedViewIcon } from './icons/unified-view-icon';
|
||||
import { SplitViewIcon } from './icons/split-view-icon';
|
||||
import { SunIcon } from './icons/sun-icon';
|
||||
import { MoonIcon } from './icons/moon-icon';
|
||||
import { EyeIcon } from './icons/eye-icon';
|
||||
import { EyeOffIcon } from './icons/eye-off-icon';
|
||||
import { KeyboardIcon } from './icons/keyboard-icon';
|
||||
import { ConfirmDialog } from './ui/confirm-dialog';
|
||||
import { GENERAL_THREAD_FILE_PATH } from '../types/comment';
|
||||
import type { ViewMode } from '../lib/diff-utils';
|
||||
@@ -22,6 +29,7 @@ interface ToolbarProps {
|
||||
onHideWhitespaceChange: (hide: boolean) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onShowHelp: () => void;
|
||||
diff?: ParsedDiff;
|
||||
diffRef?: string;
|
||||
threads: CommentThread[];
|
||||
@@ -109,6 +117,7 @@ export function Toolbar(props: ToolbarProps) {
|
||||
onHideWhitespaceChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onShowHelp,
|
||||
diff,
|
||||
diffRef,
|
||||
threads,
|
||||
@@ -119,93 +128,101 @@ export function Toolbar(props: ToolbarProps) {
|
||||
const { copied, copy } = useCopy();
|
||||
const { currentIndex, count: unresolvedCount, goToPrevious, goToNext } = useThreadNavigation(threads, onScrollToThread);
|
||||
|
||||
const baseBtn = 'px-3 py-1 border border-border text-sm text-text-secondary transition-colors duration-150 cursor-pointer';
|
||||
const activeBtn = 'bg-accent text-white border-accent';
|
||||
const baseBtn = 'px-2.5 py-1 text-xs text-text-secondary transition-colors duration-150 cursor-pointer';
|
||||
const activeBtn = 'bg-accent text-white';
|
||||
const inactiveBtn = 'bg-bg hover:bg-hover hover:text-text';
|
||||
|
||||
const iconBtn = 'p-1.5 rounded-md text-text-muted hover:text-text hover:bg-hover transition-colors cursor-pointer';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-4 py-2 bg-bg-secondary border-b border-border font-sans text-sm">
|
||||
<div className="flex items-center gap-px">
|
||||
<div className="flex items-center gap-3 px-4 py-1.5 bg-bg-secondary border-b border-border font-sans text-xs">
|
||||
<div className="flex items-center border border-border rounded-md overflow-hidden">
|
||||
<button
|
||||
className={cn(baseBtn, 'rounded-l-md', viewMode === 'unified' ? activeBtn : inactiveBtn)}
|
||||
className={cn(baseBtn, 'flex items-center gap-1.5', viewMode === 'unified' ? activeBtn : inactiveBtn)}
|
||||
onClick={() => onViewModeChange('unified')}
|
||||
title="Unified view (u)"
|
||||
>
|
||||
<UnifiedViewIcon className="w-3.5 h-3.5" />
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
className={cn(baseBtn, 'rounded-r-md border-l-0', viewMode === 'split' ? activeBtn : inactiveBtn)}
|
||||
className={cn(baseBtn, 'flex items-center gap-1.5', viewMode === 'split' ? activeBtn : inactiveBtn)}
|
||||
onClick={() => onViewModeChange('split')}
|
||||
title="Split view (s)"
|
||||
>
|
||||
<SplitViewIcon className="w-3.5 h-3.5" />
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-px">
|
||||
<label className="flex items-center gap-2 text-text-secondary cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideWhitespace}
|
||||
onChange={e => onHideWhitespaceChange(e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span>Hide whitespace</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-px">
|
||||
<button
|
||||
className={cn(iconBtn, 'flex items-center gap-1.5 text-xs', hideWhitespace && 'text-accent')}
|
||||
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
|
||||
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
|
||||
>
|
||||
{hideWhitespace ? <EyeOffIcon className="w-3.5 h-3.5" /> : <EyeIcon className="w-3.5 h-3.5" />}
|
||||
<span className="text-text-secondary">Whitespace</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className={cn(baseBtn, 'rounded-md', inactiveBtn)}
|
||||
className={iconBtn}
|
||||
onClick={onToggleTheme}
|
||||
title="Toggle theme"
|
||||
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
>
|
||||
{theme === 'light' ? 'Dark' : 'Light'}
|
||||
{theme === 'light' ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
className={iconBtn}
|
||||
onClick={onShowHelp}
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<KeyboardIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{unresolvedCount > 0 && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="flex items-center border border-border rounded-md overflow-hidden">
|
||||
<span className="text-xs text-text-muted px-2.5 py-1">
|
||||
{currentIndex >= 0 ? `${currentIndex + 1}/${unresolvedCount}` : unresolvedCount} comment{unresolvedCount !== 1 ? 's' : ''}
|
||||
<span className="text-xs text-text-muted px-2 py-1">
|
||||
{currentIndex >= 0 ? `${currentIndex + 1}/${unresolvedCount}` : unresolvedCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="px-1.5 py-1 border-l border-border text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
||||
className="px-1 py-1 border-l border-border text-text-muted hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
||||
title="Previous comment"
|
||||
>
|
||||
<ChevronUpIcon className="w-3.5 h-3.5" />
|
||||
<ChevronUpIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="px-1.5 py-1 border-l border-border text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
||||
className="px-1 py-1 border-l border-border text-text-muted hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
||||
title="Next comment"
|
||||
>
|
||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||
<ChevronDownIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center border border-border rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => copy(formatThreadsForCopy(threads, diff, diffRef))}
|
||||
className={cn(baseBtn, 'rounded-l-md flex items-center gap-1.5', inactiveBtn)}
|
||||
className={cn(baseBtn, 'flex items-center gap-1.5', inactiveBtn)}
|
||||
title="Copy unresolved comments to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="w-3.5 h-3.5 text-added" />
|
||||
<CheckIcon className="w-3 h-3 text-added" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="w-3.5 h-3.5" />
|
||||
Copy comments
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className={cn(baseBtn, 'rounded-r-md border-l-0 flex items-center self-stretch', inactiveBtn)}
|
||||
className={cn(baseBtn, 'flex items-center border-l border-border', inactiveBtn)}
|
||||
title="Delete all comments"
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,24 +24,24 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-bg border border-border rounded-lg shadow-lg p-5 max-w-sm w-full mx-4"
|
||||
className="bg-bg border border-border rounded-xl shadow-lg p-5 max-w-sm w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-text mb-2">{title}</h3>
|
||||
<p className="text-sm text-text-muted mb-4">{message}</p>
|
||||
<h3 className="text-sm font-semibold text-text mb-1.5">{title}</h3>
|
||||
<p className="text-xs text-text-muted mb-4 leading-relaxed">{message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border text-text hover:bg-hover cursor-pointer"
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-border text-text hover:bg-hover cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-3 py-1.5 text-sm rounded bg-deleted text-white hover:opacity-90 cursor-pointer"
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-deleted text-white hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
interface KeyboardActions {
|
||||
@@ -17,7 +18,15 @@ interface KeyboardActions {
|
||||
|
||||
const HOTKEY_OPTIONS = { preventDefault: true };
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const tag = document.activeElement?.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA';
|
||||
}
|
||||
|
||||
export function useKeyboard(actions: KeyboardActions) {
|
||||
const actionsRef = useRef(actions);
|
||||
actionsRef.current = actions;
|
||||
|
||||
useHotkeys('j', actions.onNextFile, HOTKEY_OPTIONS);
|
||||
useHotkeys('k', actions.onPrevFile, HOTKEY_OPTIONS);
|
||||
useHotkeys('n', actions.onNextHunk, HOTKEY_OPTIONS);
|
||||
@@ -27,7 +36,20 @@ export function useKeyboard(actions: KeyboardActions) {
|
||||
useHotkeys('r', actions.onToggleReviewed, HOTKEY_OPTIONS);
|
||||
useHotkeys('u', actions.onUnifiedView, HOTKEY_OPTIONS);
|
||||
useHotkeys('s', actions.onSplitView, HOTKEY_OPTIONS);
|
||||
useHotkeys('shift+/', actions.onShowHelp, HOTKEY_OPTIONS);
|
||||
useHotkeys('/', actions.onFocusSearch, HOTKEY_OPTIONS);
|
||||
useHotkeys('escape', actions.onEscape, { enableOnFormTags: ['INPUT', 'TEXTAREA'] });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/' && !isInputFocused()) {
|
||||
e.preventDefault();
|
||||
actionsRef.current.onFocusSearch();
|
||||
}
|
||||
if (e.key === '?' && !isInputFocused()) {
|
||||
e.preventDefault();
|
||||
actionsRef.current.onShowHelp();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -40,47 +40,47 @@
|
||||
--color-hover: rgba(208, 215, 222, 0.32);
|
||||
--color-active: rgba(208, 215, 222, 0.48);
|
||||
|
||||
--shadow-sticky: 0 1px 3px rgba(27, 31, 36, 0.12);
|
||||
--shadow-md: 0 3px 6px rgba(140, 149, 159, 0.15);
|
||||
--shadow-sticky: 0 1px 2px rgba(27, 31, 36, 0.08);
|
||||
--shadow-md: 0 2px 8px rgba(140, 149, 159, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-bg: #0d1117;
|
||||
--color-bg-secondary: #161b22;
|
||||
--color-bg-tertiary: #21262d;
|
||||
--color-border: #30363d;
|
||||
--color-border-muted: #21262d;
|
||||
--color-bg: #171717;
|
||||
--color-bg-secondary: #1a1a1a;
|
||||
--color-bg-tertiary: #262626;
|
||||
--color-border: #262626;
|
||||
--color-border-muted: #1f1f1f;
|
||||
|
||||
--color-text: #e6edf3;
|
||||
--color-text-secondary: #8b949e;
|
||||
--color-text-muted: #6e7681;
|
||||
--color-text: #e5e5e5;
|
||||
--color-text-secondary: #a3a3a3;
|
||||
--color-text-muted: #737373;
|
||||
|
||||
--color-diff-add-bg: rgba(46, 160, 67, 0.15);
|
||||
--color-diff-add-line: rgba(46, 160, 67, 0.25);
|
||||
--color-diff-add-word: rgba(46, 160, 67, 0.4);
|
||||
--color-diff-del-bg: rgba(248, 81, 73, 0.15);
|
||||
--color-diff-del-line: rgba(248, 81, 73, 0.25);
|
||||
--color-diff-del-word: rgba(248, 81, 73, 0.4);
|
||||
--color-diff-hunk-bg: rgba(56, 139, 253, 0.15);
|
||||
--color-diff-hunk-text: #58a6ff;
|
||||
--color-diff-expanded-bg: rgba(56, 139, 253, 0.07);
|
||||
--color-diff-expanded-gutter: rgba(56, 139, 253, 0.10);
|
||||
--color-diff-comment-bg: rgba(210, 153, 34, 0.15);
|
||||
--color-diff-comment-gutter: rgba(210, 153, 34, 0.4);
|
||||
--color-diff-add-bg: rgba(34, 197, 94, 0.1);
|
||||
--color-diff-add-line: rgba(34, 197, 94, 0.18);
|
||||
--color-diff-add-word: rgba(34, 197, 94, 0.35);
|
||||
--color-diff-del-bg: rgba(239, 68, 68, 0.1);
|
||||
--color-diff-del-line: rgba(239, 68, 68, 0.18);
|
||||
--color-diff-del-word: rgba(239, 68, 68, 0.35);
|
||||
--color-diff-hunk-bg: rgba(96, 165, 250, 0.1);
|
||||
--color-diff-hunk-text: #60a5fa;
|
||||
--color-diff-expanded-bg: rgba(96, 165, 250, 0.05);
|
||||
--color-diff-expanded-gutter: rgba(96, 165, 250, 0.08);
|
||||
--color-diff-comment-bg: rgba(234, 179, 8, 0.1);
|
||||
--color-diff-comment-gutter: rgba(234, 179, 8, 0.3);
|
||||
|
||||
--color-accent: #58a6ff;
|
||||
--color-accent-hover: #79c0ff;
|
||||
--color-accent: #60a5fa;
|
||||
--color-accent-hover: #93c5fd;
|
||||
|
||||
--color-added: #3fb950;
|
||||
--color-deleted: #f85149;
|
||||
--color-modified: #d29922;
|
||||
--color-renamed: #58a6ff;
|
||||
--color-added: #4ade80;
|
||||
--color-deleted: #f87171;
|
||||
--color-modified: #facc15;
|
||||
--color-renamed: #60a5fa;
|
||||
|
||||
--color-hover: rgba(177, 186, 196, 0.12);
|
||||
--color-active: rgba(177, 186, 196, 0.2);
|
||||
--color-hover: rgba(161, 161, 170, 0.1);
|
||||
--color-active: rgba(161, 161, 170, 0.18);
|
||||
|
||||
--shadow-sticky: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-sticky: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -88,6 +88,11 @@
|
||||
scrollbar-color: var(--color-border) var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
dialog {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
+8
-18
@@ -1,36 +1,26 @@
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readSkills, cleanDir } from './lib/utils.js';
|
||||
import { claudeCode, cursor, codex } from './lib/transformers/index.js';
|
||||
import type { Skill, TransformOptions } from './lib/utils.js';
|
||||
import { readSkills, renderSkill, writeFile, cleanDir } from './lib/utils.js';
|
||||
import { claudeCode } from './lib/transformers/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
const sourceDir = join(rootDir, 'packages', 'skills');
|
||||
const outputDir = join(rootDir, 'dist', 'skills');
|
||||
const outputDir = join(rootDir, 'skills');
|
||||
const localClaudeSkillsDir = join(rootDir, '.claude', 'skills');
|
||||
|
||||
const transformers: { name: string; fn: (skill: Skill, outputDir: string, options: TransformOptions) => void }[] = [
|
||||
{ name: 'claude-code', fn: claudeCode },
|
||||
{ name: 'cursor', fn: cursor },
|
||||
{ name: 'codex', fn: codex },
|
||||
];
|
||||
|
||||
const skills = readSkills(sourceDir);
|
||||
console.log(`Found ${skills.length} skills`);
|
||||
|
||||
cleanDir(outputDir);
|
||||
|
||||
for (const transformer of transformers) {
|
||||
const providerDir = join(outputDir, transformer.name);
|
||||
for (const skill of skills) {
|
||||
transformer.fn(skill, providerDir, { binary: 'diffity' });
|
||||
}
|
||||
console.log(`Built ${skills.length} skills for ${transformer.name}`);
|
||||
for (const skill of skills) {
|
||||
const content = renderSkill(skill, { binary: 'diffity' });
|
||||
writeFile(join(outputDir, skill.name, 'SKILL.md'), content);
|
||||
}
|
||||
console.log(`Built ${skills.length} skills to skills/`);
|
||||
|
||||
cleanDir(localClaudeSkillsDir);
|
||||
for (const skill of skills) {
|
||||
claudeCode(skill, rootDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-' });
|
||||
claudeCode(skill, rootDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-', installHint: 'run `npm run dev` from the diffity repo root to link the CLI' });
|
||||
}
|
||||
console.log(`Synced ${skills.length} dev skills to .claude/skills/`);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TransformOptions {
|
||||
binary: string;
|
||||
namePrefix?: string;
|
||||
slashPrefix?: string;
|
||||
installHint?: string;
|
||||
}
|
||||
|
||||
export function readSkills(sourceDir: string): Skill[] {
|
||||
@@ -42,11 +43,13 @@ export function readSkills(sourceDir: string): Skill[] {
|
||||
return skills;
|
||||
}
|
||||
|
||||
export function renderSkill(skill: Skill, { binary, namePrefix, slashPrefix }: TransformOptions): string {
|
||||
export function renderSkill(skill: Skill, { binary, namePrefix, slashPrefix, installHint }: TransformOptions): string {
|
||||
const slash = slashPrefix ?? '/diffity-';
|
||||
const hint = installHint ?? 'install it with `npm install -g diffity`';
|
||||
const body = skill.content
|
||||
.replaceAll('{{binary}}', binary)
|
||||
.replaceAll('{{slash}}', slash);
|
||||
.replaceAll('{{slash}}', slash)
|
||||
.replaceAll('{{install_hint}}', hint);
|
||||
const data = { ...skill.data };
|
||||
if (namePrefix) {
|
||||
data.name = data.name.replace('diffity-', `${namePrefix}-`);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: diffity-resolve
|
||||
description: Read open review comments and resolve them by making code fixes
|
||||
user-invokable: true
|
||||
---
|
||||
|
||||
# Diffity Resolve Skill
|
||||
|
||||
You are reading open review comments and resolving them by making the requested code changes.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `thread-id` (optional): Resolve a specific thread by ID instead of all open threads. Example: `/diffity-resolve abc123`
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
diffity agent list [--status open|resolved|dismissed] [--json]
|
||||
diffity agent comment --file <path> --line <n> [--end-line <n>] [--side new|old] --body "<text>"
|
||||
diffity agent general-comment --body "<text>"
|
||||
diffity agent resolve <id> [--summary "<text>"]
|
||||
diffity agent dismiss <id> [--reason "<text>"]
|
||||
diffity agent reply <id> --body "<text>"
|
||||
```
|
||||
|
||||
- `--file`, `--line`, `--body` are required for `comment`
|
||||
- `--end-line` defaults to `--line` (single-line comment)
|
||||
- `--side` defaults to `new`
|
||||
- `general-comment` creates a diff-level comment not tied to any file or line
|
||||
- `<id>` accepts full UUID or 8-char prefix
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Check that `diffity` is available: run `which diffity`. If not found, install it with `npm install -g diffity`.
|
||||
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. List open comment threads with full details:
|
||||
```
|
||||
diffity agent list --status open --json
|
||||
```
|
||||
If a `thread-id` argument was provided, filter to just that thread. The JSON output includes the full comment body, file path, line numbers, and side for each thread.
|
||||
2. If there are no open threads, tell the user there's nothing to resolve.
|
||||
3. For each open thread:
|
||||
a. **Skip** general comments (filePath `__general__`) — these are summaries, not actionable code changes.
|
||||
b. **Skip** threads tagged `[question]` or `[nit]` — these don't require code changes. Tell the user you skipped them and why.
|
||||
c. Read the comment body from the JSON output and understand what change is requested.
|
||||
d. Read the relevant source file to understand the full context around the commented lines, then make the requested code change using the Edit tool.
|
||||
e. After making the change, resolve the thread with a summary:
|
||||
```
|
||||
diffity agent resolve <thread-id> --summary "Fixed: <brief description of what was changed>"
|
||||
```
|
||||
4. After resolving all applicable threads, run `diffity agent list` to confirm status.
|
||||
5. Tell the user to check the browser — resolved status will appear within 2 seconds via polling.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: diffity-review
|
||||
description: Review current diff and leave comments using diffity agent commands
|
||||
user-invokable: true
|
||||
---
|
||||
|
||||
# Diffity Review Skill
|
||||
|
||||
You are reviewing a diff and leaving inline comments using the `diffity agent` CLI.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `focus` (optional): Focus the review on a specific area. One of: `security`, `performance`, `naming`, `errors`, `types`, `logic`. If omitted, review everything.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
diffity agent list [--status open|resolved|dismissed] [--json]
|
||||
diffity agent comment --file <path> --line <n> [--end-line <n>] [--side new|old] --body "<text>"
|
||||
diffity agent general-comment --body "<text>"
|
||||
diffity agent resolve <id> [--summary "<text>"]
|
||||
diffity agent dismiss <id> [--reason "<text>"]
|
||||
diffity agent reply <id> --body "<text>"
|
||||
```
|
||||
|
||||
- `--file`, `--line`, `--body` are required for `comment`
|
||||
- `--end-line` defaults to `--line` (single-line comment)
|
||||
- `--side` defaults to `new`
|
||||
- `general-comment` creates a diff-level comment not tied to any file or line
|
||||
- `<id>` accepts full UUID or 8-char prefix
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Check that `diffity` is available: run `which diffity`. If not found, install it with `npm install -g diffity`.
|
||||
2. Check that a review session exists: run `cat .diffity/current-session`. If the file doesn't exist or is stale, tell the user to start diffity first (e.g. `diffity` or `diffity --staged`).
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Read the current diff using `git diff` (for working tree changes) or `git diff --staged` (for staged changes). Check `.diffity/current-session` to determine which ref is active.
|
||||
2. For each changed file, read the **entire file** (not just the diff hunks) to understand the full context. This prevents false positives from missing surrounding code.
|
||||
3. Analyze the code changes thoroughly. If a `focus` argument was provided, concentrate on that area. Otherwise look for:
|
||||
- Bugs, logic errors, off-by-one errors
|
||||
- Security issues (injection, XSS, auth bypass)
|
||||
- Performance problems
|
||||
- Missing error handling at system boundaries
|
||||
- Race conditions
|
||||
- API contract violations
|
||||
- Unclear or misleading naming
|
||||
4. **Only comment on code that was changed in the diff.** Do not flag pre-existing issues in unchanged code — this is a review of the diff, not an audit of the entire file. The only exception is if a change in the diff introduces a bug in combination with existing code.
|
||||
5. **Prioritize signal over volume.** A clean diff should get a clean review. Do not manufacture findings to appear thorough. If a diff with 5 changed lines only has 1 real issue, leave 1 comment.
|
||||
6. **Do not repeat the same issue across files.** If the same pattern appears in multiple places, leave one inline comment on the first occurrence and mention it in the general summary instead of commenting on every instance.
|
||||
7. Categorize each finding with a severity prefix in the comment body:
|
||||
- `[must-fix]` — Bugs, security issues, data loss risks. These must be addressed.
|
||||
- `[suggestion]` — Improvements that would meaningfully improve the code.
|
||||
- `[nit]` — Style or preference. Fine to ignore.
|
||||
- `[question]` — Something unclear that needs clarification from the author.
|
||||
8. For each finding, leave a comment using:
|
||||
```
|
||||
diffity agent comment --file <path> --line <n> [--end-line <n>] [--side new] --body "<comment>"
|
||||
```
|
||||
- Use `--side new` (default) for comments on added/modified code
|
||||
- Use `--side old` for comments on removed code
|
||||
- Use `--end-line` when the issue spans multiple lines
|
||||
- Be specific and actionable in your comments
|
||||
9. After leaving all inline comments, write a general comment that summarizes your overall assessment of the diff. This should cover:
|
||||
- Overall quality verdict (e.g. "Looks good with minor issues" or "Needs significant changes before merging")
|
||||
- Cross-cutting concerns that don't belong on any single line (architecture, naming consistency across files, missing tests, etc.)
|
||||
- A count of findings by severity (e.g. "2 must-fix, 3 suggestions, 1 nit")
|
||||
```
|
||||
diffity agent general-comment --body "<overall review summary>"
|
||||
```
|
||||
If there are no inline findings, still leave a general comment with your assessment (e.g. "Clean diff — no issues found").
|
||||
10. Run `diffity agent list` to confirm all comments were created.
|
||||
11. Tell the user to check the browser — comments will appear within 2 seconds via polling.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: diffity-start
|
||||
description: >-
|
||||
Start the diffity diff viewer server for the current working tree or staged
|
||||
changes
|
||||
user-invokable: true
|
||||
---
|
||||
|
||||
# Diffity Start Skill
|
||||
|
||||
You are starting the diffity diff viewer so the user can see their changes in the browser.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Check that `diffity` is available: run `which diffity`. If not found, install it with `npm install -g diffity`.
|
||||
2. Determine which mode to start in:
|
||||
- If the user said "staged" or there are staged changes they want to review: `diffity --staged`
|
||||
- Otherwise default to: `diffity`
|
||||
3. Start the server using the Bash tool with `run_in_background: true`:
|
||||
- Command: `diffity` (or `diffity --staged`)
|
||||
- Do NOT use `&` or `--quiet` — let the Bash tool handle backgrounding
|
||||
- The browser will open automatically and the session is auto-created on startup
|
||||
4. Wait 2 seconds, then verify the session exists by checking that `.diffity/current-session` file is present.
|
||||
5. Tell the user diffity is running and show them what they can do next. Keep it short — don't show session IDs, hashes, or other internals. Example:
|
||||
|
||||
> Diffity is running — check your browser.
|
||||
>
|
||||
> Here's what you can do:
|
||||
> - **/diffity-review** — get a code review on your changes
|
||||
> - **/diffity-resolve** — fix issues from review comments
|
||||
Reference in New Issue
Block a user