From 8c892ae709ea301e8669c098c19641f9fbc943f1 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:24:19 +0200 Subject: [PATCH 1/9] feat: added tech review panel for AI branch diff --- src-tauri/src/git.rs | 49 ++++ src-tauri/src/main.rs | 3 + src/app/createSessionManager.ts | 2 + src/i18n/en.json | 42 ++- src/i18n/es.json | 42 ++- src/i18n/index.ts | 2 +- src/main.ts | 4 + src/panels/diff/DiffPanel.ts | 196 +++++++++++++ src/panels/diff/definition.ts | 12 + src/panels/diff/diffGitClient.ts | 23 ++ src/panels/diff/diffStats.ts | 19 ++ src/panels/diff/i18n.ts | 8 + src/panels/review/ReviewPanel.ts | 350 ++++++++++++++++++++++++ src/panels/review/definition.ts | 12 + src/panels/review/i18n.ts | 8 + src/panels/review/reviewFiles.ts | 25 ++ src/styles.css | 243 +++++++++++----- src/ui/icons.ts | 1 + tests/panels/diff/diffPanel.test.ts | 55 ++++ tests/panels/diff/diffStats.test.ts | 69 +++++ tests/panels/review/reviewFiles.test.ts | 58 ++++ tests/panels/review/reviewPanel.test.ts | 56 ++++ 22 files changed, 1213 insertions(+), 66 deletions(-) create mode 100644 src/panels/diff/DiffPanel.ts create mode 100644 src/panels/diff/definition.ts create mode 100644 src/panels/diff/diffGitClient.ts create mode 100644 src/panels/diff/diffStats.ts create mode 100644 src/panels/diff/i18n.ts create mode 100644 src/panels/review/ReviewPanel.ts create mode 100644 src/panels/review/definition.ts create mode 100644 src/panels/review/i18n.ts create mode 100644 src/panels/review/reviewFiles.ts create mode 100644 tests/panels/diff/diffPanel.test.ts create mode 100644 tests/panels/diff/diffStats.test.ts create mode 100644 tests/panels/review/reviewFiles.test.ts create mode 100644 tests/panels/review/reviewPanel.test.ts diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 481db8b..faa339a 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -303,6 +303,13 @@ fn current_branch(path: &str) -> Result { Ok(branch) } +#[tauri::command] +pub async fn git_current_branch(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || current_branch(&path)) + .await + .map_err(|e| e.to_string())? +} + fn backup_ref_for(path: &str) -> Result { Ok(format!("refs/bento/backups/{}", current_branch(path)?)) } @@ -536,6 +543,27 @@ pub async fn git_remote_branches(repo: String) -> Result, String> { .map_err(|e| e.to_string())? } +// Lists branches from ALL remotes with full remote/branch format (e.g. "daimoxd/feat/foo"). +#[tauri::command] +pub async fn git_all_remote_branches(repo: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&repo) { + return Err("not a git repository".into()); + } + let raw = git_output( + &repo, + &["for-each-ref", "--format=%(refname:short)", "refs/remotes"], + )?; + Ok(raw + .lines() + .filter(|line| !line.ends_with("/HEAD") && is_safe_branch(line)) + .map(str::to_string) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn git_worktree_add( repo: String, @@ -667,6 +695,27 @@ pub async fn git_diff(path: String) -> Result { .map_err(|e| e.to_string())? } +// Accumulated diff of all commits on the current branch vs (three-dot range). +#[tauri::command] +pub async fn git_branch_diff(path: String, base: String) -> Result { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { + return Err("not a git repository".into()); + } + // Try local ref first, fall back to origin/ prefix for remote-only branches + let result = git_output(&path, &["diff", &format!("{base}...HEAD")]); + if result.is_ok() { + return result; + } + git_output(&path, &["diff", &format!("origin/{base}...HEAD")]) + }) + .await + .map_err(|e| e.to_string())? +} + // Validates that a commit message is non-empty (trust boundary: frontend input). fn is_valid_message(msg: &str) -> bool { !msg.trim().is_empty() diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fadd9a4..4bef81e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -254,10 +254,13 @@ fn main() { git::git_rewrite_preflight, git::git_default_branch, git::git_remote_branches, + git::git_all_remote_branches, + git::git_current_branch, git::git_worktree_add, git::git_worktree_remove, git::git_sync, git::git_diff, + git::git_branch_diff, git::git_commit, git::git_fixup, git::git_push, diff --git a/src/app/createSessionManager.ts b/src/app/createSessionManager.ts index ccfbf98..3015a51 100644 --- a/src/app/createSessionManager.ts +++ b/src/app/createSessionManager.ts @@ -308,6 +308,8 @@ export function createSessionManager(panels: PanelRegistry, stateRepo: Workspace { id: 'new-docker', label: appT('newDocker'), keywords: ['docker', 'contenedores', 'containers', 'logs'], run: () => active?.addPanel('docker') }, { id: 'new-tasks', label: appT('newTasks'), keywords: ['tareas', 'tasks', 'worktree', 'git', 'paralelo'], run: () => active?.addPanel('tasks') }, { id: 'new-memory', label: appT('newMemory'), keywords: ['memoria', 'memory', 'contexto', 'decisiones', 'resumen'], run: () => active?.addPanel('memory') }, + { id: 'new-diff', label: appT('newDiff'), keywords: ['diff', 'git', 'cambios', 'changes', 'hunk', 'patch'], run: () => active?.addPanel('diff') }, + { id: 'new-review', label: appT('newReview'), keywords: ['review', 'tech review', 'revisar', 'ia', 'ai', 'agente', 'cambios'], run: () => active?.addPanel('review') }, { id: 'bind-project', label: appT('bindProject'), keywords: ['proyecto', 'project', 'carpeta', 'cwd', 'directorio'], diff --git a/src/i18n/en.json b/src/i18n/en.json index 0f2970c..626e780 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -57,7 +57,47 @@ "commandPalette": "Command palette", "commandPlaceholder": "Type a command…", "addPanel": "Add panel", - "emptyWorkspace": "Empty workspace" + "emptyWorkspace": "Empty workspace", + "panelDiff": "Diff", + "newDiff": "New Diff panel", + "panelReview": "Tech Review", + "newReview": "New Tech Review panel" + }, + "review": { + "title": "Tech Review", + "openRepo": "Open repo…", + "noRepo": "No repository selected", + "noRepoHint": "Open a folder to review its changes", + "noChanges": "No changes to review", + "loading": "Loading…", + "refresh": "Refresh", + "autoRefresh": "Auto-refresh", + "commitMessage": "Commit message…", + "commit": "Commit", + "commitAll": "Commit all", + "discard": "Discard selected", + "discardAll": "Discard all changes", + "discardConfirm": "Discard all uncommitted changes? This cannot be undone.", + "committed": "Committed", + "commitError": "Commit failed", + "files": "{count} file(s) changed", + "noSelection": "Select files to commit", + "modeWorktree": "Working tree", + "modeBranch": "Branch diff", + "baseBranch": "Base:", + "currentBranch": "on {branch}", + "noBranchChanges": "No changes vs {base}" + }, + "diff": { + "worktreeMode": "Worktree", + "logMode": "Log", + "noRepo": "No repository selected", + "noRepoHint": "Open a folder to view its changes", + "openRepo": "Open repo…", + "noChanges": "No changes in the working tree", + "loading": "Loading…", + "noFiles": "No files", + "refresh": "Refresh" }, "tasks": { "tasks": "Tasks", diff --git a/src/i18n/es.json b/src/i18n/es.json index 4ff22cf..7910ebd 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -57,7 +57,47 @@ "commandPalette": "Paleta de comandos", "commandPlaceholder": "Escribe un comando…", "addPanel": "Añadir panel", - "emptyWorkspace": "Espacio vacío" + "emptyWorkspace": "Espacio vacío", + "panelDiff": "Diff", + "newDiff": "Nuevo panel Diff", + "panelReview": "Tech Review", + "newReview": "Nuevo panel Tech Review" + }, + "review": { + "title": "Tech Review", + "openRepo": "Abrir repo…", + "noRepo": "No hay repositorio seleccionado", + "noRepoHint": "Abre una carpeta para revisar sus cambios", + "noChanges": "No hay cambios para revisar", + "loading": "Cargando…", + "refresh": "Actualizar", + "autoRefresh": "Auto-actualizar", + "commitMessage": "Mensaje de commit…", + "commit": "Commit", + "commitAll": "Commit todo", + "discard": "Descartar selección", + "discardAll": "Descartar todos los cambios", + "discardConfirm": "¿Descartar todos los cambios no commiteados? Esta acción no se puede deshacer.", + "committed": "Commit creado", + "commitError": "Error al crear commit", + "files": "{count} archivo(s) cambiado(s)", + "noSelection": "Selecciona archivos para commitear", + "modeWorktree": "Árbol de trabajo", + "modeBranch": "Diff de rama", + "baseBranch": "Base:", + "currentBranch": "en {branch}", + "noBranchChanges": "Sin cambios vs {base}" + }, + "diff": { + "worktreeMode": "Worktree", + "logMode": "Historial", + "noRepo": "No hay repositorio seleccionado", + "noRepoHint": "Abre una carpeta para ver sus cambios", + "openRepo": "Abrir repo…", + "noChanges": "Sin cambios en el árbol de trabajo", + "loading": "Cargando…", + "noFiles": "Sin archivos", + "refresh": "Actualizar" }, "tasks": { "tasks": "Tareas", diff --git a/src/i18n/index.ts b/src/i18n/index.ts index b33715d..dd898e4 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -2,7 +2,7 @@ import esCatalog from './es.json' import enCatalog from './en.json' export type AppLocale = 'es' | 'en' -export type CatalogNamespace = 'app' | 'tasks' +export type CatalogNamespace = 'app' | 'tasks' | 'diff' | 'review' export type TranslationValues = Record export type AppMessageKey = keyof typeof esCatalog.app type PanelCatalog = typeof esCatalog.panels diff --git a/src/main.ts b/src/main.ts index 86fae6c..34da229 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,8 @@ import { dockerPanelDefinition } from './panels/docker/definition' import { vaultPanelDefinition } from './panels/vault/definition' import { tasksPanelDefinition } from './panels/tasks/definition' import { memoryPanelDefinition } from './panels/memory/definition' +import { diffPanelDefinition } from './panels/diff/definition' +import { reviewPanelDefinition } from './panels/review/definition' import { M3UChannelRepository } from './adapters/M3UChannelRepository' import { IptvOrgChannelRepository } from './adapters/IptvOrgChannelRepository' import { LocalStorageFavoritesRepository } from './adapters/LocalStorageFavoritesRepository' @@ -80,6 +82,8 @@ panels.register(dockerPanelDefinition) panels.register(vaultPanelDefinition) panels.register(tasksPanelDefinition) panels.register(memoryPanelDefinition(memoryRepo)) +panels.register(diffPanelDefinition) +panels.register(reviewPanelDefinition) const app = document.getElementById('app')! app.appendChild(createSessionManager(panels, stateRepo)) diff --git a/src/panels/diff/DiffPanel.ts b/src/panels/diff/DiffPanel.ts new file mode 100644 index 0000000..1c8bf3e --- /dev/null +++ b/src/panels/diff/DiffPanel.ts @@ -0,0 +1,196 @@ +import { open as pickFolder } from '@tauri-apps/plugin-dialog' +import { createMasterDetail } from '../../ui/masterDetail' +import { renderPatchHtml, buildCommitFileList } from '../tasks/TaskCodeView' +import { icon } from '../../ui/icons' +import type { CommitEntry } from '../tasks/gitTypes' +import { parseDiffFiles } from './diffStats' +import { diffGit } from './diffGitClient' +import { diffT } from './i18n' + +type Mode = 'worktree' | 'log' + +const REPO_KEY = 'bento.diff.repo' + +export function createDiffPanel(sessionPath?: string): { element: HTMLElement } { + const root = document.createElement('div') + root.className = 'diff-panel' + + let repoPath: string = sessionPath ?? localStorage.getItem(REPO_KEY) ?? '' + let mode: Mode = 'worktree' + let logEntries: CommitEntry[] = [] + let worktreeChunks = new Map() + + // ── Toolbar ─────────────────────────────────────────────────────────────── + const toolbar = document.createElement('div') + toolbar.className = 'diff-toolbar' + + const worktreeBtn = Object.assign(document.createElement('button'), { + className: 'diff-mode-btn diff-mode-btn--active', + textContent: diffT('worktreeMode'), + }) + const logBtn = Object.assign(document.createElement('button'), { + className: 'diff-mode-btn', + textContent: diffT('logMode'), + }) + const refreshBtn = Object.assign(document.createElement('button'), { + className: 'diff-icon-btn', + title: diffT('refresh'), + innerHTML: icon('refresh'), + }) + const openBtn = Object.assign(document.createElement('button'), { + className: 'diff-open-btn', + textContent: diffT('openRepo'), + innerHTML: icon('folder') + `${diffT('openRepo')}`, + }) + + toolbar.append(worktreeBtn, logBtn, refreshBtn, openBtn) + + // ── Master-detail ───────────────────────────────────────────────────────── + const md = createMasterDetail({ + title: '', + onSelect: id => showItem(id), + emptyText: diffT('noFiles'), + }) + + // ── Empty state (no repo) ───────────────────────────────────────────────── + const emptyState = document.createElement('div') + emptyState.className = 'diff-empty-state' + const emptyTitle = Object.assign(document.createElement('p'), { + className: 'diff-empty-title', + textContent: diffT('noRepo'), + }) + const emptyHint = Object.assign(document.createElement('p'), { + className: 'diff-empty-hint', + textContent: diffT('noRepoHint'), + }) + const emptyOpenBtn = Object.assign(document.createElement('button'), { + className: 'diff-empty-open-btn', + textContent: diffT('openRepo'), + }) + emptyState.append(emptyTitle, emptyHint, emptyOpenBtn) + + root.append(toolbar, md.element, emptyState) + + // ── Visibility helpers ──────────────────────────────────────────────────── + const showEmpty = (on: boolean): void => { + emptyState.classList.toggle('hidden', !on) + md.element.classList.toggle('hidden', on) + } + + // ── Item detail ─────────────────────────────────────────────────────────── + const showItem = async (id: string): Promise => { + md.detail.replaceChildren() + if (mode === 'worktree') { + const chunk = worktreeChunks.get(id) ?? '' + const pre = document.createElement('pre') + pre.className = 'diff-patch' + pre.innerHTML = renderPatchHtml(chunk) + md.detail.appendChild(pre) + } else { + const entry = logEntries.find(e => e.hash === id) + if (!entry) return + md.detail.replaceChildren( + Object.assign(document.createElement('div'), { className: 'diff-hint', textContent: diffT('loading') }), + ) + try { + const files = await diffGit.files(repoPath, entry.hash) + const nodes = buildCommitFileList( + files, + file => diffGit.showDiff(repoPath, entry.hash, file), + file => diffGit.showFile(repoPath, entry.hash, file), + ) + md.detail.replaceChildren(...nodes) + } catch (e) { + md.detail.textContent = String(e) + } + } + } + + // ── Refresh ─────────────────────────────────────────────────────────────── + const refresh = async (): Promise => { + if (!repoPath) { showEmpty(true); return } + showEmpty(false) + worktreeChunks = new Map() + logEntries = [] + md.detail.replaceChildren( + Object.assign(document.createElement('div'), { className: 'diff-hint', textContent: diffT('loading') }), + ) + + if (mode === 'worktree') { + try { + const raw = await diffGit.diff(repoPath) + const files = parseDiffFiles(raw) + if (!files.length) { + md.setItems([]) + md.detail.replaceChildren( + Object.assign(document.createElement('div'), { className: 'diff-hint', textContent: diffT('noChanges') }), + ) + return + } + for (const f of files) worktreeChunks.set(f.file, f.chunk) + md.setItems(files.map(f => ({ + id: f.file, + label: f.file, + group: '', + leading: buildStatsBadge(f.additions, f.deletions), + }))) + if (files[0]) { md.select(files[0].file); showItem(files[0].file) } + } catch (e) { + md.detail.textContent = String(e) + } + } else { + try { + logEntries = await diffGit.log(repoPath) + md.setItems(logEntries.map(e => ({ + id: e.hash, + label: e.subject, + group: e.date, + leading: Object.assign(document.createElement('span'), { + className: 'diff-short', + textContent: e.short, + }), + }))) + if (logEntries[0]) { md.select(logEntries[0].hash); showItem(logEntries[0].hash) } + } catch (e) { + md.detail.textContent = String(e) + } + } + } + + // ── Mode toggle ─────────────────────────────────────────────────────────── + const setMode = (next: Mode): void => { + mode = next + worktreeBtn.classList.toggle('diff-mode-btn--active', mode === 'worktree') + logBtn.classList.toggle('diff-mode-btn--active', mode === 'log') + refresh() + } + + worktreeBtn.addEventListener('click', () => setMode('worktree')) + logBtn.addEventListener('click', () => setMode('log')) + refreshBtn.addEventListener('click', () => refresh()) + + // ── Folder picker ───────────────────────────────────────────────────────── + const pickRepo = async (): Promise => { + const picked = await pickFolder({ directory: true, multiple: false }).catch(() => null) + if (!picked || typeof picked !== 'string') return + repoPath = picked + localStorage.setItem(REPO_KEY, repoPath) + refresh() + } + + openBtn.addEventListener('click', pickRepo) + emptyOpenBtn.addEventListener('click', pickRepo) + + // ── Initial render ──────────────────────────────────────────────────────── + refresh() + + return { element: root } +} + +function buildStatsBadge(additions: number, deletions: number): HTMLElement { + const el = document.createElement('span') + el.className = 'diff-stats' + if (additions) el.appendChild(Object.assign(document.createElement('span'), { className: 'diff-add', textContent: `+${additions}` })) + if (deletions) el.appendChild(Object.assign(document.createElement('span'), { className: 'diff-del', textContent: `-${deletions}` })) + return el +} diff --git a/src/panels/diff/definition.ts b/src/panels/diff/definition.ts new file mode 100644 index 0000000..18cb0c9 --- /dev/null +++ b/src/panels/diff/definition.ts @@ -0,0 +1,12 @@ +import type { PanelDefinition } from '../registry' +import { lazyPanel } from '../lazyPanel' +import { appT } from '../../core/i18n' + +export const diffPanelDefinition: PanelDefinition = { + type: 'diff', + title: appT('panelDiff'), + create: ctx => lazyPanel(async () => { + const { createDiffPanel } = await import('./DiffPanel') + return createDiffPanel(ctx.projectPath) + }), +} diff --git a/src/panels/diff/diffGitClient.ts b/src/panels/diff/diffGitClient.ts new file mode 100644 index 0000000..cb8dc07 --- /dev/null +++ b/src/panels/diff/diffGitClient.ts @@ -0,0 +1,23 @@ +import { invoke } from '@tauri-apps/api/core' +import type { CommitEntry, CommitFile, GitStatus } from '../tasks/gitTypes' + +export const diffGit = { + diff: (path: string): Promise => + invoke('git_diff', { path }), + branchDiff: (path: string, base: string): Promise => + invoke('git_branch_diff', { path, base }), + defaultBranch: (repo: string): Promise => + invoke('git_default_branch', { repo }).catch(() => 'main'), + remoteBranches: (repo: string): Promise => + invoke('git_all_remote_branches', { repo }).catch(() => []), + status: (path: string): Promise => + invoke('git_status', { path }), + log: (path: string, limit = 100): Promise => + invoke('git_log', { path, limit, noMerges: false }), + files: (path: string, hash: string): Promise => + invoke('git_show_files', { path, hash }), + showDiff: (path: string, hash: string, file: string): Promise => + invoke('git_show_commit_diff', { path, hash, file }), + showFile: (path: string, hash: string, file: string): Promise => + invoke('git_show_file', { path, hash, file }), +} diff --git a/src/panels/diff/diffStats.ts b/src/panels/diff/diffStats.ts new file mode 100644 index 0000000..aaee0d9 --- /dev/null +++ b/src/panels/diff/diffStats.ts @@ -0,0 +1,19 @@ +import { diffFileNames } from '../../core/git/commitWorkflow' + +export interface DiffFileStat { + file: string + additions: number + deletions: number + chunk: string +} + +export function parseDiffFiles(raw: string): DiffFileStat[] { + if (!raw.trim()) return [] + return raw.split(/(?=^diff --git )/m).filter(Boolean).map(chunk => { + const file = diffFileNames(chunk)[0] ?? chunk + const lines = chunk.split('\n') + const additions = lines.filter(l => l.startsWith('+') && !l.startsWith('+++')).length + const deletions = lines.filter(l => l.startsWith('-') && !l.startsWith('---')).length + return { file, additions, deletions, chunk } + }) +} diff --git a/src/panels/diff/i18n.ts b/src/panels/diff/i18n.ts new file mode 100644 index 0000000..315b8e9 --- /dev/null +++ b/src/panels/diff/i18n.ts @@ -0,0 +1,8 @@ +import esCatalog from '../../i18n/es.json' +import { catalogT, type TranslationValues } from '../../core/i18n' + +export type DiffMessageKey = keyof typeof esCatalog.diff + +export function diffT(key: DiffMessageKey, values: TranslationValues = {}): string { + return catalogT('diff', key, values) +} diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts new file mode 100644 index 0000000..25e530c --- /dev/null +++ b/src/panels/review/ReviewPanel.ts @@ -0,0 +1,350 @@ +import { invoke } from '@tauri-apps/api/core' +import { open as pickFolder } from '@tauri-apps/plugin-dialog' +import { icon } from '../../ui/icons' +import { buildChangesFileView } from '../tasks/ChangesFileView' +import { buildSelectedPatch } from '../../core/git/commitWorkflow' +import { diffGit } from '../diff/diffGitClient' +import { buildReviewFiles, reviewSummary } from './reviewFiles' +import { reviewT } from './i18n' + +type Mode = 'branch' | 'worktree' + +const REPO_KEY = 'bento.review.repo' +const BASE_KEY = 'bento.review.base' +const INTERVAL_MS = 5000 + +export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void } { + const root = document.createElement('div') + root.className = 'review-panel' + + let repoPath: string = sessionPath ?? localStorage.getItem(REPO_KEY) ?? '' + let mode: Mode = 'branch' + let baseBranch = localStorage.getItem(BASE_KEY) ?? 'main' + let autoRefresh = false + let intervalId: ReturnType | null = null + let allBranches: string[] = [] + let worktreeDiff = '' + const checkedFiles = new Set() + const selectedHunks = new Map>() + + // ── Toolbar ─────────────────────────────────────────────────────────────── + const toolbar = document.createElement('div') + toolbar.className = 'review-toolbar' + + // Mode toggle + const branchBtn = Object.assign(document.createElement('button'), { + className: 'review-mode-btn review-mode-btn--active', + textContent: reviewT('modeBranch'), + }) + const worktreeBtn = Object.assign(document.createElement('button'), { + className: 'review-mode-btn', + textContent: reviewT('modeWorktree'), + }) + + // Branch info row (only shown in branch mode) + const branchBar = document.createElement('div') + branchBar.className = 'review-branch-bar' + + const baseLabel = Object.assign(document.createElement('span'), { + className: 'review-base-label', + textContent: reviewT('baseBranch'), + }) + const branchWrap = document.createElement('div') + branchWrap.className = 'review-branch-wrap' + const branchInput = Object.assign(document.createElement('input'), { + className: 'review-branch-input', + type: 'text', + value: baseBranch, + placeholder: 'main', + }) + const branchDropdown = document.createElement('div') + branchDropdown.className = 'review-branch-dropdown hidden' + branchWrap.append(branchInput, branchDropdown) + const currentBranchEl = Object.assign(document.createElement('span'), { className: 'review-current-branch' }) + + branchBar.append(baseLabel, branchWrap, currentBranchEl) + + // Right-side actions + const openBtn = Object.assign(document.createElement('button'), { + className: 'review-icon-btn', + title: reviewT('openRepo'), + innerHTML: icon('folder'), + }) + const refreshBtn = Object.assign(document.createElement('button'), { + className: 'review-refresh-btn review-icon-btn', + title: reviewT('refresh'), + innerHTML: icon('refresh'), + }) + const autoBtn = Object.assign(document.createElement('button'), { + className: 'review-icon-btn', + title: reviewT('autoRefresh'), + innerHTML: icon('eye'), + }) + const summary = Object.assign(document.createElement('span'), { className: 'review-summary' }) + + toolbar.append(branchBtn, worktreeBtn, branchBar, summary, openBtn, refreshBtn, autoBtn) + + // ── File list ───────────────────────────────────────────────────────────── + const fileList = document.createElement('div') + fileList.className = 'review-files' + + // ── Empty state (no repo) ───────────────────────────────────────────────── + const emptyState = document.createElement('div') + emptyState.className = 'review-empty-state' + const emptyOpenBtn = Object.assign(document.createElement('button'), { + className: 'review-empty-open-btn', + textContent: reviewT('openRepo'), + }) + emptyState.append( + Object.assign(document.createElement('p'), { className: 'review-empty-title', textContent: reviewT('noRepo') }), + Object.assign(document.createElement('p'), { className: 'review-empty-hint', textContent: reviewT('noRepoHint') }), + emptyOpenBtn, + ) + + // ── Commit bar (only in worktree mode) ─────────────────────────────────── + const commitBar = document.createElement('div') + commitBar.className = 'review-commit-bar hidden' + const msgInput = Object.assign(document.createElement('input'), { + className: 'review-commit-msg', + type: 'text', + placeholder: reviewT('commitMessage'), + }) + const commitBtn = Object.assign(document.createElement('button'), { + className: 'review-commit-btn', + textContent: reviewT('commit'), + }) + const statusEl = Object.assign(document.createElement('span'), { className: 'review-commit-status' }) + commitBar.append(msgInput, commitBtn, statusEl) + + root.append(toolbar, emptyState, fileList, commitBar) + + // ── Helpers ─────────────────────────────────────────────────────────────── + const setEmptyVisible = (on: boolean): void => { + emptyState.classList.toggle('hidden', !on) + fileList.classList.toggle('hidden', on) + } + + const showStatus = (text: string, isError = false): void => { + statusEl.textContent = text + statusEl.className = `review-commit-status ${isError ? 'review-commit-err' : 'review-commit-ok'}` + setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'review-commit-status' }, isError ? 5000 : 3000) + } + + const renderPatch = (raw: string): string => + raw.split('\n').map((line, i) => { + const cls = line.startsWith('+') && !line.startsWith('+++') ? ' tasks-diff-line-add' + : line.startsWith('-') && !line.startsWith('---') ? ' tasks-diff-line-del' + : line.startsWith('@@') ? ' tasks-diff-hunk' : '' + const esc = line.replace(/&/g, '&').replace(//g, '>') + return `${i + 1}${esc}` + }).join('') + + // ── Load ────────────────────────────────────────────────────────────────── + const load = async (): Promise => { + if (!repoPath) { setEmptyVisible(true); return } + setEmptyVisible(false) + fileList.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), + ) + + try { + let diffRaw: string + if (mode === 'branch') { + diffRaw = await diffGit.branchDiff(repoPath, baseBranch) + } else { + const status = await diffGit.status(repoPath) + diffRaw = await diffGit.diff(repoPath) + worktreeDiff = diffRaw + const files = buildReviewFiles(diffRaw, status.raw) + renderFiles(files) + return + } + + const files = buildReviewFiles(diffRaw, '') + renderFiles(files) + } catch (e) { + fileList.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-error', textContent: String(e) }), + ) + } + } + + const renderFiles = (files: ReturnType): void => { + const s = reviewSummary(files) + summary.textContent = files.length ? reviewT('files', { count: s.files }) : '' + + if (!files.length) { + const msg = mode === 'branch' + ? reviewT('noBranchChanges', { base: baseBranch }) + : reviewT('noChanges') + fileList.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: msg }), + ) + return + } + + checkedFiles.clear() + selectedHunks.clear() + + fileList.replaceChildren(...files.map(f => + buildChangesFileView({ + chunk: f.chunk, + state: f.state, + checkedFiles, + selectedHunks, + renderPatch, + }), + )) + } + + // ── Mode toggle ─────────────────────────────────────────────────────────── + const setMode = (next: Mode): void => { + mode = next + branchBtn.classList.toggle('review-mode-btn--active', mode === 'branch') + worktreeBtn.classList.toggle('review-mode-btn--active', mode === 'worktree') + branchBar.classList.toggle('hidden', mode !== 'branch') + commitBar.classList.toggle('hidden', mode !== 'worktree') + load() + } + + branchBtn.addEventListener('click', () => setMode('branch')) + worktreeBtn.addEventListener('click', () => setMode('worktree')) + + // ── Base branch selector ────────────────────────────────────────────────── + const DROPDOWN_MAX = 20 + + const renderDropdown = (): void => { + const q = branchInput.value.toLowerCase() + const matches = q ? allBranches.filter(b => b.toLowerCase().includes(q)) : allBranches + const top = matches.slice(0, DROPDOWN_MAX) + branchDropdown.replaceChildren(...top.map(b => { + const item = Object.assign(document.createElement('div'), { + className: `review-branch-option${b === baseBranch ? ' review-branch-option--active' : ''}`, + textContent: b, + }) + item.addEventListener('mousedown', e => { + e.preventDefault() + baseBranch = b + branchInput.value = b + localStorage.setItem(BASE_KEY, baseBranch) + branchDropdown.classList.add('hidden') + load() + }) + return item + })) + branchDropdown.classList.toggle('hidden', top.length === 0) + } + + branchInput.addEventListener('focus', renderDropdown) + branchInput.addEventListener('input', renderDropdown) + branchInput.addEventListener('blur', () => setTimeout(() => branchDropdown.classList.add('hidden'), 150)) + const commitRef = (raw: string): void => { + const next = raw.trim().replace(':', '/') + branchInput.value = next + if (!next || next === baseBranch) return + baseBranch = next + localStorage.setItem(BASE_KEY, baseBranch) + load() + } + + branchInput.addEventListener('keydown', e => { + if (e.key === 'Escape') { branchDropdown.classList.add('hidden'); return } + if (e.key === 'Enter') { branchDropdown.classList.add('hidden'); commitRef(branchInput.value) } + }) + branchInput.addEventListener('change', () => commitRef(branchInput.value)) + + const loadBranches = async (): Promise => { + if (!repoPath) return + const [defaultBranch, branches, currentBranch] = await Promise.all([ + diffGit.defaultBranch(repoPath), + diffGit.remoteBranches(repoPath), + invoke('git_current_branch', { path: repoPath }).catch(() => ''), + ]) + allBranches = branches + currentBranchEl.textContent = currentBranch ? `on ${currentBranch}` : '' + + const isCurrentBranch = (b: string): boolean => + !!currentBranch && (b === currentBranch || b === `origin/${currentBranch}`) + + const originDefault = `origin/${defaultBranch}` + const needsReset = !baseBranch || isCurrentBranch(baseBranch) + if (needsReset) { + const candidate = branches.find(b => !isCurrentBranch(b) && (b === originDefault || b === defaultBranch)) + ?? branches.find(b => !isCurrentBranch(b)) + ?? defaultBranch + baseBranch = candidate + branchInput.value = baseBranch + localStorage.setItem(BASE_KEY, baseBranch) + } + } + + // ── Auto-refresh ────────────────────────────────────────────────────────── + const setAutoRefresh = (on: boolean): void => { + autoRefresh = on + autoBtn.classList.toggle('review-icon-btn--active', on) + if (intervalId) { clearInterval(intervalId); intervalId = null } + if (on) intervalId = setInterval(load, INTERVAL_MS) + } + + // ── Commit (worktree mode only) ─────────────────────────────────────────── + commitBtn.addEventListener('click', async () => { + const msg = msgInput.value.trim() + if (!msg) { msgInput.focus(); return } + commitBtn.disabled = true + try { + const patch = buildSelectedPatch(worktreeDiff, checkedFiles, selectedHunks) + await invoke('git_commit', { path: repoPath, message: msg, patch: patch || undefined }) + msgInput.value = '' + checkedFiles.clear() + selectedHunks.clear() + showStatus(reviewT('committed')) + load() + } catch (e) { + showStatus(String(e), true) + } finally { + commitBtn.disabled = false + } + }) + + // ── Repo picker ─────────────────────────────────────────────────────────── + const pickRepo = async (): Promise => { + const picked = await pickFolder({ directory: true, multiple: false }).catch(() => null) + if (!picked || typeof picked !== 'string') return + repoPath = picked + baseBranch = '' + branchInput.value = '' + localStorage.setItem(REPO_KEY, repoPath) + checkedFiles.clear() + selectedHunks.clear() + setEmptyVisible(false) + fileList.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), + ) + await loadBranches() + load() + } + + openBtn.addEventListener('click', pickRepo) + emptyOpenBtn.addEventListener('click', pickRepo) + refreshBtn.addEventListener('click', () => load()) + autoBtn.addEventListener('click', () => setAutoRefresh(!autoRefresh)) + + // ── Init ────────────────────────────────────────────────────────────────── + branchBar.classList.remove('hidden') + commitBar.classList.add('hidden') + + if (repoPath) { + setEmptyVisible(false) + fileList.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), + ) + loadBranches().then(() => load()) + } else { + setEmptyVisible(true) + } + + return { + element: root, + dispose: () => { if (intervalId) clearInterval(intervalId) }, + } +} diff --git a/src/panels/review/definition.ts b/src/panels/review/definition.ts new file mode 100644 index 0000000..beef58b --- /dev/null +++ b/src/panels/review/definition.ts @@ -0,0 +1,12 @@ +import type { PanelDefinition } from '../registry' +import { lazyPanel } from '../lazyPanel' +import { appT } from '../../core/i18n' + +export const reviewPanelDefinition: PanelDefinition = { + type: 'review', + title: appT('panelReview'), + create: ctx => lazyPanel(async () => { + const { createReviewPanel } = await import('./ReviewPanel') + return createReviewPanel(ctx.projectPath) + }), +} diff --git a/src/panels/review/i18n.ts b/src/panels/review/i18n.ts new file mode 100644 index 0000000..76683cb --- /dev/null +++ b/src/panels/review/i18n.ts @@ -0,0 +1,8 @@ +import esCatalog from '../../i18n/es.json' +import { catalogT, type TranslationValues } from '../../core/i18n' + +export type ReviewMessageKey = keyof typeof esCatalog.review + +export function reviewT(key: ReviewMessageKey, values: TranslationValues = {}): string { + return catalogT('review', key, values) +} diff --git a/src/panels/review/reviewFiles.ts b/src/panels/review/reviewFiles.ts new file mode 100644 index 0000000..b050ced --- /dev/null +++ b/src/panels/review/reviewFiles.ts @@ -0,0 +1,25 @@ +import { parseDiffFiles, type DiffFileStat } from '../diff/diffStats' +import { fileStateMap } from '../tasks/TaskCodeView' + +export interface ReviewFile extends DiffFileStat { + state: string +} + +export interface ReviewSummary { + files: number + additions: number + deletions: number +} + +export function buildReviewFiles(diffRaw: string, statusRaw: string): ReviewFile[] { + const stats = parseDiffFiles(diffRaw) + const states = fileStateMap(statusRaw) + return stats.map(f => ({ ...f, state: states.get(f.file) ?? '' })) +} + +export function reviewSummary(files: ReviewFile[]): ReviewSummary { + return files.reduce( + (acc, f) => ({ files: acc.files + 1, additions: acc.additions + f.additions, deletions: acc.deletions + f.deletions }), + { files: 0, additions: 0, deletions: 0 }, + ) +} diff --git a/src/styles.css b/src/styles.css index 2bdec7d..e9d775f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,16 +1,19 @@ :root { /* Default values (Herdr); JS overrides them according to the theme */ - --bg: #0d0e15; - --surface: #171822; - --surface-2: #20212c; - --border: #262838; - --fg: #c4cadd; - --fg-dim: #6b7396; - --accent: #7d9bf5; - --accent-fg: #0d0e15; - --selection: #222842; - --gold: #dbb168; - --radius: 6px; + --bg: #07070f; + --surface: #0e0e1c; + --surface-2: #14142a; + --border: #1e1e3a; + --fg: #e2e8f8; + --fg-dim: #7c82a8; + --accent: #a78bfa; + --accent-2: #22d3ee; + --accent-fg: #07070f; + --selection: #1e1e42; + --gold: #f9c86d; + --radius: 10px; + --glow-accent: color-mix(in srgb, var(--accent) 35%, transparent); + --glow-accent-sm: color-mix(in srgb, var(--accent) 18%, transparent); /* Monospace stack: terminal-native look across the whole UI */ --font-ui: 'SF Mono', ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace; } @@ -21,6 +24,13 @@ padding: 0; } +/* Global focus glow — futuristic AI style */ +input:focus, textarea:focus, select:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent) 50%, transparent) !important; + box-shadow: 0 0 0 2px var(--glow-accent-sm), 0 0 8px var(--glow-accent-sm); +} + /* SVG icons */ .icon { width: 16px; @@ -62,12 +72,9 @@ html, body, #app { -webkit-font-smoothing: antialiased; } -/* Flat dev-terminal background: dark base with very subtle glows in the corners */ +/* Base color only — the visible background lives in .session-instance */ #app { - background: - radial-gradient(60% 50% at 100% 0%, color-mix(in srgb, var(--glow) 20%, transparent), transparent 60%), - radial-gradient(55% 50% at 0% 100%, color-mix(in srgb, var(--glow) 14%, transparent), transparent 55%), - var(--bg); + background: var(--bg); } /* Command palette (Cmd/Ctrl+K) */ @@ -92,11 +99,11 @@ html, body, #app { max-height: 60vh; display: flex; flex-direction: column; - background: color-mix(in srgb, var(--surface) 80%, transparent); - border: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); + background: color-mix(in srgb, var(--surface) 75%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); border-radius: 16px; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); - backdrop-filter: blur(24px); + box-shadow: 0 0 0 1px var(--glow-accent-sm), 0 24px 64px rgba(0,0,0,0.7), 0 0 40px var(--glow-accent-sm); + backdrop-filter: blur(32px) saturate(180%); overflow: hidden; } @@ -104,7 +111,7 @@ html, body, #app { padding: 14px 16px; background: transparent; border: none; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 15%, transparent); color: var(--fg); font-size: 15px; outline: none; @@ -120,14 +127,21 @@ html, body, #app { align-items: center; justify-content: space-between; padding: 9px 12px; - border-radius: 6px; + border-radius: 8px; color: var(--fg); cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.cmdk-item:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); } .cmdk-item.selected { - background: var(--accent); - color: var(--accent-fg); + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); + box-shadow: inset 0 0 0 1px var(--glow-accent-sm); + text-shadow: 0 0 6px var(--glow-accent); } .cmdk-item kbd { @@ -251,10 +265,12 @@ html, body, #app { } .session-tab.active { - color: var(--fg); - background: color-mix(in srgb, var(--surface-2) 70%, transparent); - border-color: color-mix(in srgb, var(--fg) 12%, transparent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); backdrop-filter: blur(10px); + box-shadow: 0 0 12px var(--glow-accent-sm), inset 0 0 12px var(--glow-accent-sm); + text-shadow: 0 0 8px var(--glow-accent); } .session-tab-close { @@ -291,9 +307,14 @@ html, body, #app { .session-instance { position: absolute; inset: 0; - /* Flat theme background: the gaps between panels and dockview's empty - areas show the base color, with no gradients or glows. */ - background: var(--bg); + background: + radial-gradient(60% 50% at 100% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%), + radial-gradient(50% 40% at 0% 100%, color-mix(in srgb, var(--accent-2) 10%, transparent), transparent 60%), + linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px), + var(--bg); + background-size: 100% 100%, 100% 100%, 40px 40px, 40px 40px; + background-position: 0 0, 0 0, -1px -1px, -1px -1px; } /* Hidden session: invisible but rendered → the audio/video doesn't stop. @@ -366,27 +387,32 @@ html, body, #app { (its selectors have 5-6 classes; with .workspace-view in front we win). */ .workspace-view .dv-dockview, .workspace-view .dv-groupview, -.workspace-view .dv-content-container, .workspace-view .dv-void-container { background: transparent; } +/* Grid inside every panel — very faint so text stays readable */ +.workspace-view .dv-content-container { + background: + linear-gradient(color-mix(in srgb, var(--accent) 2%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--accent) 2%, transparent) 1px, transparent 1px), + var(--bg); + background-size: 40px 40px; + background-position: -1px -1px; +} + .workspace-view .dv-groupview { border-radius: 12px; overflow: hidden; - /* Subtle border on all panels so adjacent panels are visually distinct even - when they touch. Dockview layout is not affected (no margin/padding). */ - border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); - transition: border-color 0.15s, box-shadow 0.15s; + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + transition: border-color 0.2s, box-shadow 0.2s; } -/* Active group: no glowing halo or accent border. Just a slightly more - pronounced neutral border; the active panel is already distinguished by the accent line - above its tab. */ .workspace-view .dv-groupview.dv-active-group { - border-color: var(--border); + border-color: color-mix(in srgb, var(--accent) 25%, transparent); + box-shadow: 0 0 0 1px var(--glow-accent-sm), 0 0 24px var(--glow-accent-sm); } /* "+" icon (add terminal) pinned to the right, away from the rounded @@ -416,9 +442,10 @@ html, body, #app { .workspace-view .dv-groupview.dv-active-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-active-tab, .workspace-view .dv-groupview.dv-inactive-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-active-tab { - background-color: color-mix(in srgb, var(--surface) 40%, transparent); - color: var(--fg); - box-shadow: inset 0 2px 0 var(--accent); + background-color: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--accent); + box-shadow: inset 0 2px 0 var(--accent), 0 0 8px var(--glow-accent-sm); + text-shadow: 0 0 6px var(--glow-accent); } .workspace-view .dv-groupview.dv-active-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-inactive-tab, @@ -444,6 +471,24 @@ html, body, #app { height: 100%; } +/* Panel roots transparent so dv-content-container grid shows through */ +.web-panel, .notes-panel, .http-panel, .tv-panel, .scripts-panel, +.db-panel, .memory-panel, .jira-panel, .vault-panel, .docker-panel, +.tasks-panel, .diff-panel, .review-panel { + background: transparent !important; +} + +/* Terminal: grid on the wrapper, xterm canvas sits on top */ +.terminal-panel { + background: + linear-gradient(color-mix(in srgb, var(--accent) 2%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--accent) 2%, transparent) 1px, transparent 1px), + var(--bg) !important; + background-size: 40px 40px !important; + background-position: -1px -1px !important; +} + + /* Terminal */ .terminal-panel { position: relative; @@ -1615,20 +1660,20 @@ html, body, #app { to { opacity: 0; transform: translateX(-50%) translateY(-6px); } } -/* Thin scrollbars matching the theme */ +/* Thin futuristic scrollbars */ ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 4px; + height: 4px; } ::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 5px; - border: 2px solid var(--bg); + background: color-mix(in srgb, var(--accent) 30%, transparent); + border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: var(--fg-dim); + background: color-mix(in srgb, var(--accent) 60%, transparent); + box-shadow: 0 0 6px var(--glow-accent); } ::-webkit-scrollbar-track { @@ -3152,18 +3197,20 @@ select.db-cell-input { appearance: auto; cursor: pointer; } width: 44px; height: 44px; border-radius: 50%; - border: 1px solid var(--border); - background: var(--accent); - color: var(--accent-fg); + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: color-mix(in srgb, var(--accent) 15%, var(--surface)); + color: var(--accent); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); - transition: transform 0.12s ease, filter 0.12s ease; + box-shadow: 0 0 20px var(--glow-accent), 0 6px 20px rgba(0,0,0,0.5); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.ai-fab:hover { + transform: translateY(-2px); + box-shadow: 0 0 32px var(--glow-accent), 0 8px 24px rgba(0,0,0,0.5); } -.ai-fab:hover { transform: translateY(-2px); filter: brightness(1.08); } -/* With the modal open the FAB is redundant (it's closed with the X or Esc). */ .ai-chat.open .ai-fab { display: none; } .ai-chat.busy .ai-fab .icon { animation: spin 0.8s linear infinite; } @@ -3171,18 +3218,17 @@ select.db-cell-input { appearance: auto; cursor: pointer; } display: flex; flex-direction: column; width: min(460px, calc(100vw - 32px)); - /* Almost the full height of the window (leaves room for the FAB and margins). */ height: calc(100vh - 96px); - background: var(--surface); - border: 1px solid var(--border); - border-radius: 12px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); + border-radius: 16px; overflow: hidden; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + box-shadow: 0 0 0 1px var(--glow-accent-sm), 0 24px 64px rgba(0,0,0,0.7), 0 0 40px var(--glow-accent-sm); + backdrop-filter: blur(24px) saturate(160%); transition: width 0.16s ease; } .ai-modal.hidden { display: none; } -/* Widened with the expand button. */ .ai-modal.wide { width: min(820px, calc(100vw - 32px)); } @@ -3192,8 +3238,9 @@ select.db-cell-input { appearance: auto; cursor: pointer; } align-items: center; gap: 6px; padding: 8px; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 12%, transparent); flex-shrink: 0; + background: color-mix(in srgb, var(--accent) 4%, transparent); } .ai-select, @@ -3820,4 +3867,74 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .tasks-reset-mode-name { font-size: 12px; font-weight: 600; color: var(--fg); } .tasks-reset-mode-desc { font-size: 11px; color: var(--fg-dim); } .tasks-reset-danger { background: color-mix(in srgb, #f7768e 15%, transparent); border-color: #f7768e; } + +/* ── Diff panel ─────────────────────────────────────────────────────────── */ +.diff-panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; } +.diff-toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; } +.diff-panel .md-panel { display: flex; flex: 1; overflow: hidden; min-height: 0; } +.diff-panel .md-sidebar { width: 220px; min-width: 140px; border-right: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; } +.diff-panel .md-detail { flex: 1; overflow: auto; min-width: 0; } +.diff-mode-btn { padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--fg-dim); background: transparent; border: 1px solid transparent; cursor: pointer; } +.diff-mode-btn:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.diff-mode-btn--active { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.diff-panel .md-panel { flex: 1; overflow: hidden; } +.diff-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 1; padding: 32px; text-align: center; } +.diff-empty-title { font-size: 14px; font-weight: 600; color: var(--fg); margin: 0; } +.diff-empty-hint { font-size: 12px; color: var(--fg-dim); margin: 0; } +.diff-empty-open-btn { margin-top: 8px; padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); cursor: pointer; } +.diff-empty-open-btn:hover { background: color-mix(in srgb, var(--accent) 25%, transparent); } +.diff-hint { padding: 16px; color: var(--fg-dim); font-size: 12px; } +.diff-open-btn { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--fg-dim); background: transparent; border: 1px solid var(--border); cursor: pointer; } +.diff-open-btn:hover { color: var(--fg); border-color: color-mix(in srgb, var(--accent) 30%, transparent); } +.diff-open-btn .icon { width: 12px; height: 12px; } +.diff-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 4px; color: var(--fg-dim); background: transparent; border: 1px solid transparent; cursor: pointer; } +.diff-icon-btn:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.diff-icon-btn .icon { width: 13px; height: 13px; } +.diff-patch { margin: 0; padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.6; overflow: auto; height: 100%; box-sizing: border-box; } +.diff-stats { display: inline-flex; gap: 4px; font-size: 10px; font-weight: 600; } +.diff-add { color: #73daca; } +.diff-del { color: #f7768e; } +.diff-short { font-family: var(--mono); font-size: 10px; color: var(--fg-dim); } .tasks-reset-danger:hover { background: color-mix(in srgb, #f7768e 25%, transparent); } + +/* ── Tech Review panel ───────────────────────────────────────────────────── */ +.review-panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; } +.review-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; } +.review-repo-label { font-size: 11px; font-weight: 600; color: var(--fg-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.review-summary { font-size: 11px; color: var(--fg-dim); margin-left: auto; } +.review-mode-btn { padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--fg-dim); background: transparent; border: 1px solid transparent; cursor: pointer; } +.review-mode-btn:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.review-mode-btn--active { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.review-branch-bar { display: flex; align-items: center; gap: 6px; } +.review-base-label { font-size: 11px; color: var(--fg-dim); } +.review-branch-wrap { position: relative; } +.review-branch-input { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); color: var(--fg); width: 160px; } +.review-branch-dropdown { position: absolute; top: calc(100% + 2px); left: 0; z-index: 200; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 200px; max-height: 240px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,.25); } +.review-branch-option { padding: 4px 10px; font-size: 11px; cursor: pointer; color: var(--fg); white-space: nowrap; } +.review-branch-option:hover { background: var(--surface-hover, rgba(255,255,255,.06)); } +.review-branch-option--active { color: var(--accent, #7aa2f7); } +.review-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 4px; color: var(--fg-dim); background: transparent; border: 1px solid transparent; cursor: pointer; flex-shrink: 0; } +.review-icon-btn:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.review-icon-btn .icon { width: 13px; height: 13px; } +.review-icon-btn--active { color: var(--accent) !important; background: color-mix(in srgb, var(--accent) 12%, transparent) !important; border-color: color-mix(in srgb, var(--accent) 25%, transparent) !important; } +.review-loading { padding: 16px; color: var(--fg-dim); font-size: 12px; } +.review-files { flex: 1; overflow-y: auto; min-height: 0; padding: 6px 0; } +.review-no-changes { padding: 24px 16px; color: var(--fg-dim); font-size: 12px; } +.review-error { padding: 16px; color: #f7768e; font-size: 12px; font-family: var(--mono); } +.review-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 1; padding: 32px; text-align: center; } +.review-empty-title { font-size: 14px; font-weight: 600; color: var(--fg); margin: 0; } +.review-empty-hint { font-size: 12px; color: var(--fg-dim); margin: 0; } +.review-empty-open-btn { margin-top: 8px; padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); cursor: pointer; } +.review-empty-open-btn:hover { background: color-mix(in srgb, var(--accent) 25%, transparent); } +.review-commit-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); flex-shrink: 0; } +.review-commit-msg { flex: 1; padding: 5px 10px; border-radius: 5px; font-size: 12px; background: var(--surface); border: 1px solid var(--border); color: var(--fg); } +.review-commit-msg:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 0 0 2px var(--glow-accent-sm); } +.review-commit-btn { padding: 5px 14px; border-radius: 5px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); cursor: pointer; } +.review-commit-btn:hover { background: color-mix(in srgb, var(--accent) 30%, transparent); } +.review-commit-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.review-discard-btn { padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: #f7768e; background: color-mix(in srgb, #f7768e 10%, transparent); border: 1px solid color-mix(in srgb, #f7768e 30%, transparent); cursor: pointer; } +.review-discard-btn:hover { background: color-mix(in srgb, #f7768e 18%, transparent); } +.review-commit-status { font-size: 11px; flex-shrink: 0; } +.review-commit-ok { color: #73daca; } +.review-commit-err { color: #f7768e; } +.review-patch { margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; line-height: 1.6; } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index af83359..3202a58 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -50,6 +50,7 @@ const ICONS: Record = { 'chevron-down': '', edit: '', 'git-merge': '', + diff: '', } export function icon(name: string): string { diff --git a/tests/panels/diff/diffPanel.test.ts b/tests/panels/diff/diffPanel.test.ts new file mode 100644 index 0000000..1e4c168 --- /dev/null +++ b/tests/panels/diff/diffPanel.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { createDiffPanel } from '../../../src/panels/diff/DiffPanel' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') +} + +describe('DiffPanel', () => { + it('returns an element with diff-panel class', () => { + setup() + const { element } = createDiffPanel() + expect(element.classList.contains('diff-panel')).toBe(true) + }) + + it('shows empty state when no project path and no saved repo', () => { + setup() + const { element } = createDiffPanel() + const emptyState = element.querySelector('.diff-empty-state') + expect(emptyState?.classList.contains('hidden')).toBe(false) + }) + + it('renders worktree/log toggle buttons', () => { + setup() + const { element } = createDiffPanel() + const buttons = element.querySelectorAll('.diff-mode-btn') + expect(buttons.length).toBe(2) + }) + + it('worktree button is active by default', () => { + setup() + const { element } = createDiffPanel() + const [worktreeBtn] = element.querySelectorAll('.diff-mode-btn') + expect(worktreeBtn?.classList.contains('diff-mode-btn--active')).toBe(true) + }) + + it('shows open-repo button in empty state', () => { + setup() + const { element } = createDiffPanel() + const openBtn = element.querySelector('.diff-empty-open-btn') + expect(openBtn).not.toBeNull() + expect(openBtn?.textContent).toContain('Open repo') + }) + + it('hides empty state when saved repo exists in localStorage', () => { + setup() + localStorage.setItem('bento.diff.repo', '/some/project') + vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue('') })) + const { element } = createDiffPanel() + const emptyState = element.querySelector('.diff-empty-state') + expect(emptyState?.classList.contains('hidden')).toBe(true) + }) +}) diff --git a/tests/panels/diff/diffStats.test.ts b/tests/panels/diff/diffStats.test.ts new file mode 100644 index 0000000..5040176 --- /dev/null +++ b/tests/panels/diff/diffStats.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { parseDiffFiles } from '../../../src/panels/diff/diffStats' + +const SIMPLE_DIFF = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', +].join('\n') + +const MULTI_DIFF = [ + 'diff --git a/a.ts b/a.ts', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1,2 +1,3 @@', + '-x', + '+y', + '+z', + 'diff --git a/b.ts b/b.ts', + '--- a/b.ts', + '+++ b/b.ts', + '@@ -1 +0,0 @@', + '-w', +].join('\n') + +describe('parseDiffFiles', () => { + it('returns empty array for empty diff', () => { + expect(parseDiffFiles('')).toEqual([]) + expect(parseDiffFiles(' ')).toEqual([]) + }) + + it('extracts file name', () => { + const [file] = parseDiffFiles(SIMPLE_DIFF) + expect(file?.file).toBe('src/a.ts') + }) + + it('counts additions and deletions, ignoring header lines', () => { + const [file] = parseDiffFiles(SIMPLE_DIFF) + expect(file?.additions).toBe(1) + expect(file?.deletions).toBe(1) + }) + + it('handles multiple files independently', () => { + const files = parseDiffFiles(MULTI_DIFF) + expect(files).toHaveLength(2) + expect(files[0]).toMatchObject({ file: 'a.ts', additions: 2, deletions: 1 }) + expect(files[1]).toMatchObject({ file: 'b.ts', additions: 0, deletions: 1 }) + }) + + it('preserves the raw chunk for each file', () => { + const [file] = parseDiffFiles(SIMPLE_DIFF) + expect(file?.chunk).toContain('diff --git a/src/a.ts') + expect(file?.chunk).toContain('+new') + }) + + it('ignores +++ and --- header lines in counts', () => { + const diff = [ + 'diff --git a/a.ts b/a.ts', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1 +1 @@', + '+real', + ].join('\n') + expect(parseDiffFiles(diff)[0]?.additions).toBe(1) + expect(parseDiffFiles(diff)[0]?.deletions).toBe(0) + }) +}) diff --git a/tests/panels/review/reviewFiles.test.ts b/tests/panels/review/reviewFiles.test.ts new file mode 100644 index 0000000..85086e4 --- /dev/null +++ b/tests/panels/review/reviewFiles.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { buildReviewFiles, reviewSummary } from '../../../src/panels/review/reviewFiles' + +const DIFF = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1,2 @@', + '-old', + '+new', + '+extra', + 'diff --git a/src/b.ts b/src/b.ts', + '--- a/src/b.ts', + '+++ b/src/b.ts', + '@@ -1 +0,0 @@', + '-removed', +].join('\n') + +const STATUS = 'M src/a.ts\n M src/b.ts\n' + +describe('buildReviewFiles', () => { + it('returns one entry per changed file', () => { + const files = buildReviewFiles(DIFF, STATUS) + expect(files).toHaveLength(2) + }) + + it('attaches file name, additions, deletions and chunk', () => { + const [a] = buildReviewFiles(DIFF, STATUS) + expect(a?.file).toBe('src/a.ts') + expect(a?.additions).toBe(2) + expect(a?.deletions).toBe(1) + expect(a?.chunk).toContain('diff --git a/src/a.ts') + }) + + it('attaches git state from status output', () => { + const [a, b] = buildReviewFiles(DIFF, STATUS) + expect(a?.state).toBe('staged') + expect(b?.state).toBe('unstaged') + }) + + it('returns empty array for empty diff', () => { + expect(buildReviewFiles('', '')).toEqual([]) + }) +}) + +describe('reviewSummary', () => { + it('sums additions and deletions across all files', () => { + const files = buildReviewFiles(DIFF, STATUS) + const s = reviewSummary(files) + expect(s.additions).toBe(2) + expect(s.deletions).toBe(2) + expect(s.files).toBe(2) + }) + + it('returns zeros for empty list', () => { + expect(reviewSummary([])).toEqual({ files: 0, additions: 0, deletions: 0 }) + }) +}) diff --git a/tests/panels/review/reviewPanel.test.ts b/tests/panels/review/reviewPanel.test.ts new file mode 100644 index 0000000..cc37af8 --- /dev/null +++ b/tests/panels/review/reviewPanel.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { createReviewPanel } from '../../../src/panels/review/ReviewPanel' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') +} + +describe('ReviewPanel', () => { + it('renders root with review-panel class', () => { + setup() + const { element } = createReviewPanel() + expect(element.classList.contains('review-panel')).toBe(true) + }) + + it('shows empty state when no repo', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-empty-state')?.classList.contains('hidden')).toBe(false) + }) + + it('has open-repo button in empty state', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-empty-open-btn')?.textContent).toContain('Open repo') + }) + + it('renders refresh button', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-refresh-btn')).not.toBeNull() + }) + + it('renders commit bar hidden by default (branch mode)', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-commit-bar')?.classList.contains('hidden')).toBe(true) + }) + + it('branch mode button is active by default', () => { + setup() + const { element } = createReviewPanel() + const [branchBtn] = element.querySelectorAll('.review-mode-btn') + expect(branchBtn?.classList.contains('review-mode-btn--active')).toBe(true) + }) + + it('clicking worktree mode shows commit bar', () => { + setup() + const { element } = createReviewPanel() + const [, worktreeBtn] = element.querySelectorAll('.review-mode-btn') + worktreeBtn?.click() + expect(element.querySelector('.review-commit-bar')?.classList.contains('hidden')).toBe(false) + }) +}) From e61806b83948a94f63cefc90ad7e96b8142315ce Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:43:21 +0200 Subject: [PATCH 2/9] feat: redesigned review panel as branch selector + PR comment --- src-tauri/src/git.rs | 47 +++ src-tauri/src/main.rs | 3 + src/i18n/en.json | 6 +- src/i18n/es.json | 6 +- src/panels/review/ReviewPanel.ts | 374 +++++++++++------------- src/styles.css | 50 ++-- tests/panels/review/reviewPanel.test.ts | 16 +- 7 files changed, 266 insertions(+), 236 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index faa339a..1c3101e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -998,6 +998,53 @@ pub async fn git_pr_status(path: String) -> Result, String> { .map_err(|e| e.to_string())? } +// Diff between any two git refs (e.g. "origin/main" vs "origin/feat/foo"). +#[tauri::command] +pub async fn git_ref_diff(path: String, base: String, target: String) -> Result { + if !is_safe_branch(&base) { return Err(format!("unsafe base: {base}")); } + if !is_safe_branch(&target) { return Err(format!("unsafe target: {target}")); } + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { return Err("not a git repository".into()); } + git_output(&path, &["diff", &format!("{base}...{target}")]) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns basic PR info (number, title, url) for any branch via gh CLI. +#[tauri::command] +pub async fn gh_pr_view_branch(path: String, branch: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let out = Command::new("gh") + .current_dir(&path) + .args(["pr", "view", &branch, "--json", "number,title,url"]) + .output(); + let Ok(out) = out else { return Ok(None); }; + if !out.status.success() || out.stdout.is_empty() { return Ok(None); } + serde_json::from_slice::(&out.stdout).map(Some).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Posts a comment on the PR associated with a branch via gh CLI. +#[tauri::command] +pub async fn gh_pr_comment(path: String, branch: String, body: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let out = Command::new("gh") + .current_dir(&path) + .args(["pr", "comment", &branch, "--body", &body]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn git_push(path: String, force_with_lease: Option) -> Result { tauri::async_runtime::spawn_blocking(move || { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4bef81e..2ed2226 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -256,6 +256,9 @@ fn main() { git::git_remote_branches, git::git_all_remote_branches, git::git_current_branch, + git::git_ref_diff, + git::gh_pr_view_branch, + git::gh_pr_comment, git::git_worktree_add, git::git_worktree_remove, git::git_sync, diff --git a/src/i18n/en.json b/src/i18n/en.json index 626e780..8e9d262 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -86,7 +86,11 @@ "modeBranch": "Branch diff", "baseBranch": "Base:", "currentBranch": "on {branch}", - "noBranchChanges": "No changes vs {base}" + "noBranchChanges": "No changes vs {base}", + "selectBranch": "Select a branch on the left to review", + "commentPlaceholder": "Leave a review comment on the PR…", + "sendComment": "Send comment", + "commentSent": "Comment sent" }, "diff": { "worktreeMode": "Worktree", diff --git a/src/i18n/es.json b/src/i18n/es.json index 7910ebd..51a1b02 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -86,7 +86,11 @@ "modeBranch": "Diff de rama", "baseBranch": "Base:", "currentBranch": "en {branch}", - "noBranchChanges": "Sin cambios vs {base}" + "noBranchChanges": "Sin cambios vs {base}", + "selectBranch": "Selecciona una rama a la izquierda para revisar", + "commentPlaceholder": "Deja un comentario de revisión en el PR…", + "sendComment": "Enviar comentario", + "commentSent": "Comentario enviado" }, "diff": { "worktreeMode": "Worktree", diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 25e530c..e1abe34 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -1,50 +1,29 @@ import { invoke } from '@tauri-apps/api/core' import { open as pickFolder } from '@tauri-apps/plugin-dialog' import { icon } from '../../ui/icons' -import { buildChangesFileView } from '../tasks/ChangesFileView' -import { buildSelectedPatch } from '../../core/git/commitWorkflow' +import { parseDiffFiles } from '../diff/diffStats' import { diffGit } from '../diff/diffGitClient' -import { buildReviewFiles, reviewSummary } from './reviewFiles' +import { renderPatchHtml } from '../tasks/TaskCodeView' import { reviewT } from './i18n' -type Mode = 'branch' | 'worktree' - const REPO_KEY = 'bento.review.repo' const BASE_KEY = 'bento.review.base' -const INTERVAL_MS = 5000 export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void } { const root = document.createElement('div') root.className = 'review-panel' let repoPath: string = sessionPath ?? localStorage.getItem(REPO_KEY) ?? '' - let mode: Mode = 'branch' - let baseBranch = localStorage.getItem(BASE_KEY) ?? 'main' - let autoRefresh = false - let intervalId: ReturnType | null = null + let baseBranch = localStorage.getItem(BASE_KEY) ?? '' + let selectedBranch = '' let allBranches: string[] = [] - let worktreeDiff = '' - const checkedFiles = new Set() - const selectedHunks = new Map>() + let intervalId: ReturnType | null = null + let autoRefresh = false // ── Toolbar ─────────────────────────────────────────────────────────────── const toolbar = document.createElement('div') toolbar.className = 'review-toolbar' - // Mode toggle - const branchBtn = Object.assign(document.createElement('button'), { - className: 'review-mode-btn review-mode-btn--active', - textContent: reviewT('modeBranch'), - }) - const worktreeBtn = Object.assign(document.createElement('button'), { - className: 'review-mode-btn', - textContent: reviewT('modeWorktree'), - }) - - // Branch info row (only shown in branch mode) - const branchBar = document.createElement('div') - branchBar.className = 'review-branch-bar' - const baseLabel = Object.assign(document.createElement('span'), { className: 'review-base-label', textContent: reviewT('baseBranch'), @@ -55,16 +34,12 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; className: 'review-branch-input', type: 'text', value: baseBranch, - placeholder: 'main', + placeholder: 'origin/main', }) const branchDropdown = document.createElement('div') branchDropdown.className = 'review-branch-dropdown hidden' branchWrap.append(branchInput, branchDropdown) - const currentBranchEl = Object.assign(document.createElement('span'), { className: 'review-current-branch' }) - branchBar.append(baseLabel, branchWrap, currentBranchEl) - - // Right-side actions const openBtn = Object.assign(document.createElement('button'), { className: 'review-icon-btn', title: reviewT('openRepo'), @@ -80,15 +55,52 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; title: reviewT('autoRefresh'), innerHTML: icon('eye'), }) - const summary = Object.assign(document.createElement('span'), { className: 'review-summary' }) - toolbar.append(branchBtn, worktreeBtn, branchBar, summary, openBtn, refreshBtn, autoBtn) + toolbar.append(baseLabel, branchWrap, openBtn, refreshBtn, autoBtn) + + // ── Body ────────────────────────────────────────────────────────────────── + const body = document.createElement('div') + body.className = 'review-body' - // ── File list ───────────────────────────────────────────────────────────── - const fileList = document.createElement('div') - fileList.className = 'review-files' + // Sidebar: list of branches + const sidebar = document.createElement('div') + sidebar.className = 'review-sidebar' - // ── Empty state (no repo) ───────────────────────────────────────────────── + const branchSearch = Object.assign(document.createElement('input'), { + className: 'review-branch-search', + type: 'text', + placeholder: 'Filter…', + }) + const branchList = document.createElement('div') + branchList.className = 'review-branch-list' + sidebar.append(branchSearch, branchList) + + // Detail: diff + comment + const detail = document.createElement('div') + detail.className = 'review-detail' + + const diffView = document.createElement('div') + diffView.className = 'review-diff-view' + + const commentBar = document.createElement('div') + commentBar.className = 'review-comment-bar hidden' + + const prInfoEl = Object.assign(document.createElement('div'), { className: 'review-pr-info' }) + const commentInput = document.createElement('textarea') + commentInput.className = 'review-comment-input' + commentInput.placeholder = reviewT('commentPlaceholder') + commentInput.rows = 3 + const commentBtn = Object.assign(document.createElement('button'), { + className: 'review-comment-btn', + textContent: reviewT('sendComment'), + }) + const commentStatus = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) + commentBar.append(prInfoEl, commentInput, commentBtn, commentStatus) + + detail.append(diffView, commentBar) + body.append(sidebar, detail) + + // ── Empty state ─────────────────────────────────────────────────────────── const emptyState = document.createElement('div') emptyState.className = 'review-empty-state' const emptyOpenBtn = Object.assign(document.createElement('button'), { @@ -101,123 +113,42 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; emptyOpenBtn, ) - // ── Commit bar (only in worktree mode) ─────────────────────────────────── - const commitBar = document.createElement('div') - commitBar.className = 'review-commit-bar hidden' - const msgInput = Object.assign(document.createElement('input'), { - className: 'review-commit-msg', - type: 'text', - placeholder: reviewT('commitMessage'), - }) - const commitBtn = Object.assign(document.createElement('button'), { - className: 'review-commit-btn', - textContent: reviewT('commit'), - }) - const statusEl = Object.assign(document.createElement('span'), { className: 'review-commit-status' }) - commitBar.append(msgInput, commitBtn, statusEl) - - root.append(toolbar, emptyState, fileList, commitBar) + root.append(toolbar, emptyState, body) // ── Helpers ─────────────────────────────────────────────────────────────── const setEmptyVisible = (on: boolean): void => { emptyState.classList.toggle('hidden', !on) - fileList.classList.toggle('hidden', on) - } - - const showStatus = (text: string, isError = false): void => { - statusEl.textContent = text - statusEl.className = `review-commit-status ${isError ? 'review-commit-err' : 'review-commit-ok'}` - setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'review-commit-status' }, isError ? 5000 : 3000) + body.classList.toggle('hidden', on) } - const renderPatch = (raw: string): string => - raw.split('\n').map((line, i) => { - const cls = line.startsWith('+') && !line.startsWith('+++') ? ' tasks-diff-line-add' - : line.startsWith('-') && !line.startsWith('---') ? ' tasks-diff-line-del' - : line.startsWith('@@') ? ' tasks-diff-hunk' : '' - const esc = line.replace(/&/g, '&').replace(//g, '>') - return `${i + 1}${esc}` - }).join('') - - // ── Load ────────────────────────────────────────────────────────────────── - const load = async (): Promise => { - if (!repoPath) { setEmptyVisible(true); return } - setEmptyVisible(false) - fileList.replaceChildren( - Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), - ) - - try { - let diffRaw: string - if (mode === 'branch') { - diffRaw = await diffGit.branchDiff(repoPath, baseBranch) - } else { - const status = await diffGit.status(repoPath) - diffRaw = await diffGit.diff(repoPath) - worktreeDiff = diffRaw - const files = buildReviewFiles(diffRaw, status.raw) - renderFiles(files) - return - } - - const files = buildReviewFiles(diffRaw, '') - renderFiles(files) - } catch (e) { - fileList.replaceChildren( - Object.assign(document.createElement('div'), { className: 'review-error', textContent: String(e) }), - ) - } - } - - const renderFiles = (files: ReturnType): void => { - const s = reviewSummary(files) - summary.textContent = files.length ? reviewT('files', { count: s.files }) : '' - - if (!files.length) { - const msg = mode === 'branch' - ? reviewT('noBranchChanges', { base: baseBranch }) - : reviewT('noChanges') - fileList.replaceChildren( - Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: msg }), - ) - return - } - - checkedFiles.clear() - selectedHunks.clear() - - fileList.replaceChildren(...files.map(f => - buildChangesFileView({ - chunk: f.chunk, - state: f.state, - checkedFiles, - selectedHunks, - renderPatch, - }), - )) + const showCommentStatus = (text: string, isError = false): void => { + commentStatus.textContent = text + commentStatus.className = `review-comment-status ${isError ? 'review-comment-err' : 'review-comment-ok'}` + setTimeout(() => { commentStatus.textContent = ''; commentStatus.className = 'review-comment-status' }, isError ? 5000 : 3000) } - // ── Mode toggle ─────────────────────────────────────────────────────────── - const setMode = (next: Mode): void => { - mode = next - branchBtn.classList.toggle('review-mode-btn--active', mode === 'branch') - worktreeBtn.classList.toggle('review-mode-btn--active', mode === 'worktree') - branchBar.classList.toggle('hidden', mode !== 'branch') - commitBar.classList.toggle('hidden', mode !== 'worktree') - load() + // ── Branch sidebar ──────────────────────────────────────────────────────── + const renderBranchList = (): void => { + const q = branchSearch.value.toLowerCase() + const visible = q ? allBranches.filter(b => b.toLowerCase().includes(q)) : allBranches + branchList.replaceChildren(...visible.slice(0, 50).map(b => { + const item = Object.assign(document.createElement('div'), { + className: `review-branch-item${b === selectedBranch ? ' review-branch-item--active' : ''}`, + textContent: b, + title: b, + }) + item.addEventListener('click', () => selectBranch(b)) + return item + })) } - branchBtn.addEventListener('click', () => setMode('branch')) - worktreeBtn.addEventListener('click', () => setMode('worktree')) + branchSearch.addEventListener('input', renderBranchList) - // ── Base branch selector ────────────────────────────────────────────────── - const DROPDOWN_MAX = 20 - - const renderDropdown = (): void => { + // ── Base dropdown ───────────────────────────────────────────────────────── + const renderBaseDropdown = (): void => { const q = branchInput.value.toLowerCase() const matches = q ? allBranches.filter(b => b.toLowerCase().includes(q)) : allBranches - const top = matches.slice(0, DROPDOWN_MAX) - branchDropdown.replaceChildren(...top.map(b => { + branchDropdown.replaceChildren(...matches.slice(0, 20).map(b => { const item = Object.assign(document.createElement('div'), { className: `review-branch-option${b === baseBranch ? ' review-branch-option--active' : ''}`, textContent: b, @@ -228,54 +159,113 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; branchInput.value = b localStorage.setItem(BASE_KEY, baseBranch) branchDropdown.classList.add('hidden') - load() + if (selectedBranch) loadDiff() }) return item })) - branchDropdown.classList.toggle('hidden', top.length === 0) + branchDropdown.classList.toggle('hidden', matches.length === 0) } - branchInput.addEventListener('focus', renderDropdown) - branchInput.addEventListener('input', renderDropdown) + branchInput.addEventListener('focus', renderBaseDropdown) + branchInput.addEventListener('input', renderBaseDropdown) branchInput.addEventListener('blur', () => setTimeout(() => branchDropdown.classList.add('hidden'), 150)) - const commitRef = (raw: string): void => { - const next = raw.trim().replace(':', '/') - branchInput.value = next - if (!next || next === baseBranch) return - baseBranch = next - localStorage.setItem(BASE_KEY, baseBranch) - load() - } - branchInput.addEventListener('keydown', e => { if (e.key === 'Escape') { branchDropdown.classList.add('hidden'); return } - if (e.key === 'Enter') { branchDropdown.classList.add('hidden'); commitRef(branchInput.value) } + if (e.key === 'Enter') { + branchDropdown.classList.add('hidden') + const next = branchInput.value.trim().replace(':', '/') + branchInput.value = next + if (next && next !== baseBranch) { baseBranch = next; localStorage.setItem(BASE_KEY, baseBranch); if (selectedBranch) loadDiff() } + } }) - branchInput.addEventListener('change', () => commitRef(branchInput.value)) + // ── Select branch → load diff + PR ─────────────────────────────────────── + const selectBranch = (branch: string): void => { + selectedBranch = branch + renderBranchList() + loadDiff() + loadPrInfo() + } + + const loadDiff = async (): Promise => { + diffView.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), + ) + try { + const raw = await invoke('git_ref_diff', { path: repoPath, base: baseBranch, target: selectedBranch }) + if (!raw.trim()) { + diffView.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: reviewT('noBranchChanges', { base: baseBranch }) }), + ) + return + } + const files = parseDiffFiles(raw) + diffView.replaceChildren(...files.map(f => { + const details = document.createElement('details') + details.className = 'review-file-detail' + details.open = files.length <= 5 + const sum = Object.assign(document.createElement('summary'), { + className: 'review-file-summary', + textContent: f.file, + }) + const pre = Object.assign(document.createElement('pre'), { className: 'tasks-diff-code' }) + pre.innerHTML = renderPatchHtml(f.chunk) + details.append(sum, pre) + return details + })) + } catch (e) { + diffView.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-error', textContent: String(e) }), + ) + } + } + + const loadPrInfo = async (): Promise => { + prInfoEl.textContent = '' + commentBar.classList.add('hidden') + try { + const pr = await invoke<{ number: number; title: string } | null>('gh_pr_view_branch', { + path: repoPath, + branch: selectedBranch, + }) + if (pr) { + prInfoEl.textContent = `PR #${pr.number}: ${pr.title}` + commentBar.classList.remove('hidden') + } + } catch { /* no PR */ } + } + + // ── Send PR comment ─────────────────────────────────────────────────────── + commentBtn.addEventListener('click', async () => { + const body = commentInput.value.trim() + if (!body) { commentInput.focus(); return } + commentBtn.disabled = true + try { + await invoke('gh_pr_comment', { path: repoPath, branch: selectedBranch, body }) + commentInput.value = '' + showCommentStatus(reviewT('commentSent')) + } catch (e) { + showCommentStatus(String(e), true) + } finally { + commentBtn.disabled = false + } + }) + + // ── Load branches ───────────────────────────────────────────────────────── const loadBranches = async (): Promise => { if (!repoPath) return - const [defaultBranch, branches, currentBranch] = await Promise.all([ + const [defaultBranch, branches] = await Promise.all([ diffGit.defaultBranch(repoPath), diffGit.remoteBranches(repoPath), - invoke('git_current_branch', { path: repoPath }).catch(() => ''), ]) allBranches = branches - currentBranchEl.textContent = currentBranch ? `on ${currentBranch}` : '' - - const isCurrentBranch = (b: string): boolean => - !!currentBranch && (b === currentBranch || b === `origin/${currentBranch}`) - - const originDefault = `origin/${defaultBranch}` - const needsReset = !baseBranch || isCurrentBranch(baseBranch) - if (needsReset) { - const candidate = branches.find(b => !isCurrentBranch(b) && (b === originDefault || b === defaultBranch)) - ?? branches.find(b => !isCurrentBranch(b)) - ?? defaultBranch - baseBranch = candidate + if (!baseBranch) { + const originDefault = `origin/${defaultBranch}` + baseBranch = branches.includes(originDefault) ? originDefault : (branches[0] ?? defaultBranch) branchInput.value = baseBranch localStorage.setItem(BASE_KEY, baseBranch) } + renderBranchList() } // ── Auto-refresh ────────────────────────────────────────────────────────── @@ -283,29 +273,9 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; autoRefresh = on autoBtn.classList.toggle('review-icon-btn--active', on) if (intervalId) { clearInterval(intervalId); intervalId = null } - if (on) intervalId = setInterval(load, INTERVAL_MS) + if (on) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) } - // ── Commit (worktree mode only) ─────────────────────────────────────────── - commitBtn.addEventListener('click', async () => { - const msg = msgInput.value.trim() - if (!msg) { msgInput.focus(); return } - commitBtn.disabled = true - try { - const patch = buildSelectedPatch(worktreeDiff, checkedFiles, selectedHunks) - await invoke('git_commit', { path: repoPath, message: msg, patch: patch || undefined }) - msgInput.value = '' - checkedFiles.clear() - selectedHunks.clear() - showStatus(reviewT('committed')) - load() - } catch (e) { - showStatus(String(e), true) - } finally { - commitBtn.disabled = false - } - }) - // ── Repo picker ─────────────────────────────────────────────────────────── const pickRepo = async (): Promise => { const picked = await pickFolder({ directory: true, multiple: false }).catch(() => null) @@ -313,32 +283,26 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; repoPath = picked baseBranch = '' branchInput.value = '' + selectedBranch = '' localStorage.setItem(REPO_KEY, repoPath) - checkedFiles.clear() - selectedHunks.clear() setEmptyVisible(false) - fileList.replaceChildren( - Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), - ) + diffView.replaceChildren() + commentBar.classList.add('hidden') await loadBranches() - load() } openBtn.addEventListener('click', pickRepo) emptyOpenBtn.addEventListener('click', pickRepo) - refreshBtn.addEventListener('click', () => load()) + refreshBtn.addEventListener('click', () => { loadBranches(); if (selectedBranch) loadDiff() }) autoBtn.addEventListener('click', () => setAutoRefresh(!autoRefresh)) // ── Init ────────────────────────────────────────────────────────────────── - branchBar.classList.remove('hidden') - commitBar.classList.add('hidden') - if (repoPath) { setEmptyVisible(false) - fileList.replaceChildren( - Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), + diffView.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: reviewT('selectBranch') }), ) - loadBranches().then(() => load()) + loadBranches() } else { setEmptyVisible(true) } diff --git a/src/styles.css b/src/styles.css index e9d775f..66107e2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3897,15 +3897,10 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .diff-short { font-family: var(--mono); font-size: 10px; color: var(--fg-dim); } .tasks-reset-danger:hover { background: color-mix(in srgb, #f7768e 25%, transparent); } +/* ── Tech Review panel ───────────────────────────────────────────────────── */ /* ── Tech Review panel ───────────────────────────────────────────────────── */ .review-panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .review-toolbar { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; } -.review-repo-label { font-size: 11px; font-weight: 600; color: var(--fg-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.review-summary { font-size: 11px; color: var(--fg-dim); margin-left: auto; } -.review-mode-btn { padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--fg-dim); background: transparent; border: 1px solid transparent; cursor: pointer; } -.review-mode-btn:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 8%, transparent); } -.review-mode-btn--active { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); } -.review-branch-bar { display: flex; align-items: center; gap: 6px; } .review-base-label { font-size: 11px; color: var(--fg-dim); } .review-branch-wrap { position: relative; } .review-branch-input { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--surface); border: 1px solid var(--border); color: var(--fg); width: 160px; } @@ -3916,9 +3911,36 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .review-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 4px; color: var(--fg-dim); background: transparent; border: 1px solid transparent; cursor: pointer; flex-shrink: 0; } .review-icon-btn:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 8%, transparent); } .review-icon-btn .icon { width: 13px; height: 13px; } -.review-icon-btn--active { color: var(--accent) !important; background: color-mix(in srgb, var(--accent) 12%, transparent) !important; border-color: color-mix(in srgb, var(--accent) 25%, transparent) !important; } +.review-icon-btn--active { color: var(--accent) !important; background: color-mix(in srgb, var(--accent) 12%, transparent) !important; } +/* Body: sidebar + detail */ +.review-body { display: flex; flex: 1; overflow: hidden; min-height: 0; } +.review-sidebar { width: 200px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); overflow: hidden; } +.review-branch-search { padding: 5px 8px; font-size: 11px; background: var(--surface); border: none; border-bottom: 1px solid var(--border); color: var(--fg); flex-shrink: 0; } +.review-branch-search:focus { outline: none; } +.review-branch-list { flex: 1; overflow-y: auto; padding: 4px 0; } +.review-branch-item { padding: 5px 10px; font-size: 11px; cursor: pointer; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.review-branch-item:hover { background: var(--surface-hover, rgba(255,255,255,.05)); color: var(--fg); } +.review-branch-item--active { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); font-weight: 600; } +/* Detail pane */ +.review-detail { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } +.review-diff-view { flex: 1; overflow-y: auto; padding: 6px 0; } +.review-file-detail { border-bottom: 1px solid var(--border); } +.review-file-summary { padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--fg-dim); list-style: none; user-select: none; } +.review-file-summary:hover { color: var(--fg); } +.review-file-detail[open] .review-file-summary { color: var(--fg); } +/* Comment bar */ +.review-comment-bar { flex-shrink: 0; border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } +.review-pr-info { font-size: 11px; color: var(--accent); font-weight: 600; } +.review-comment-input { resize: vertical; padding: 6px 8px; font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-family: inherit; } +.review-comment-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 50%, transparent); } +.review-comment-btn { align-self: flex-end; padding: 5px 14px; border-radius: 5px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); cursor: pointer; } +.review-comment-btn:hover { background: color-mix(in srgb, var(--accent) 30%, transparent); } +.review-comment-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.review-comment-status { font-size: 11px; } +.review-comment-ok { color: #73daca; } +.review-comment-err { color: #f7768e; } +/* States */ .review-loading { padding: 16px; color: var(--fg-dim); font-size: 12px; } -.review-files { flex: 1; overflow-y: auto; min-height: 0; padding: 6px 0; } .review-no-changes { padding: 24px 16px; color: var(--fg-dim); font-size: 12px; } .review-error { padding: 16px; color: #f7768e; font-size: 12px; font-family: var(--mono); } .review-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 1; padding: 32px; text-align: center; } @@ -3926,15 +3948,3 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .review-empty-hint { font-size: 12px; color: var(--fg-dim); margin: 0; } .review-empty-open-btn { margin-top: 8px; padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); cursor: pointer; } .review-empty-open-btn:hover { background: color-mix(in srgb, var(--accent) 25%, transparent); } -.review-commit-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); flex-shrink: 0; } -.review-commit-msg { flex: 1; padding: 5px 10px; border-radius: 5px; font-size: 12px; background: var(--surface); border: 1px solid var(--border); color: var(--fg); } -.review-commit-msg:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 0 0 2px var(--glow-accent-sm); } -.review-commit-btn { padding: 5px 14px; border-radius: 5px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); cursor: pointer; } -.review-commit-btn:hover { background: color-mix(in srgb, var(--accent) 30%, transparent); } -.review-commit-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.review-discard-btn { padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: #f7768e; background: color-mix(in srgb, #f7768e 10%, transparent); border: 1px solid color-mix(in srgb, #f7768e 30%, transparent); cursor: pointer; } -.review-discard-btn:hover { background: color-mix(in srgb, #f7768e 18%, transparent); } -.review-commit-status { font-size: 11px; flex-shrink: 0; } -.review-commit-ok { color: #73daca; } -.review-commit-err { color: #f7768e; } -.review-patch { margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; line-height: 1.6; } diff --git a/tests/panels/review/reviewPanel.test.ts b/tests/panels/review/reviewPanel.test.ts index cc37af8..f8dc215 100644 --- a/tests/panels/review/reviewPanel.test.ts +++ b/tests/panels/review/reviewPanel.test.ts @@ -33,24 +33,22 @@ describe('ReviewPanel', () => { expect(element.querySelector('.review-refresh-btn')).not.toBeNull() }) - it('renders commit bar hidden by default (branch mode)', () => { + it('renders sidebar and detail panes', () => { setup() const { element } = createReviewPanel() - expect(element.querySelector('.review-commit-bar')?.classList.contains('hidden')).toBe(true) + expect(element.querySelector('.review-sidebar')).not.toBeNull() + expect(element.querySelector('.review-detail')).not.toBeNull() }) - it('branch mode button is active by default', () => { + it('comment bar hidden until PR is loaded', () => { setup() const { element } = createReviewPanel() - const [branchBtn] = element.querySelectorAll('.review-mode-btn') - expect(branchBtn?.classList.contains('review-mode-btn--active')).toBe(true) + expect(element.querySelector('.review-comment-bar')?.classList.contains('hidden')).toBe(true) }) - it('clicking worktree mode shows commit bar', () => { + it('renders branch search input in sidebar', () => { setup() const { element } = createReviewPanel() - const [, worktreeBtn] = element.querySelectorAll('.review-mode-btn') - worktreeBtn?.click() - expect(element.querySelector('.review-commit-bar')?.classList.contains('hidden')).toBe(false) + expect(element.querySelector('.review-branch-search')).not.toBeNull() }) }) From ebb6ed01811d02d77869a6cbc4ace116b6808cf3 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:46:08 +0200 Subject: [PATCH 3/9] feat: added per-file inline comment button in review panel --- src/panels/review/ReviewPanel.ts | 58 +++++++++++++++++++++++++++++--- src/styles.css | 5 ++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index e1abe34..85fd923 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -204,13 +204,63 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; const details = document.createElement('details') details.className = 'review-file-detail' details.open = files.length <= 5 - const sum = Object.assign(document.createElement('summary'), { - className: 'review-file-summary', - textContent: f.file, + + const sum = document.createElement('summary') + sum.className = 'review-file-summary' + const fileName = Object.assign(document.createElement('span'), { textContent: f.file }) + const commentBtn = Object.assign(document.createElement('button'), { + className: 'review-inline-comment-btn', + title: reviewT('sendComment'), + textContent: '💬', }) + sum.append(fileName, commentBtn) + const pre = Object.assign(document.createElement('pre'), { className: 'tasks-diff-code' }) pre.innerHTML = renderPatchHtml(f.chunk) - details.append(sum, pre) + + // Inline comment form (hidden by default) + const inlineForm = document.createElement('div') + inlineForm.className = 'review-inline-form hidden' + const inlineInput = document.createElement('textarea') + inlineInput.className = 'review-comment-input' + inlineInput.placeholder = reviewT('commentPlaceholder') + inlineInput.rows = 3 + const inlineSend = Object.assign(document.createElement('button'), { + className: 'review-comment-btn', + textContent: reviewT('sendComment'), + }) + const inlineStatus = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) + inlineForm.append(inlineInput, inlineSend, inlineStatus) + + commentBtn.addEventListener('click', e => { + e.preventDefault() + inlineForm.classList.toggle('hidden') + if (!inlineForm.classList.contains('hidden')) inlineInput.focus() + }) + + inlineSend.addEventListener('click', async () => { + const body = inlineInput.value.trim() + if (!body) return + inlineSend.disabled = true + try { + await invoke('gh_pr_comment', { + path: repoPath, + branch: selectedBranch, + body: `**\`${f.file}\`**\n\n${body}`, + }) + inlineInput.value = '' + inlineStatus.textContent = reviewT('commentSent') + inlineStatus.className = 'review-comment-status review-comment-ok' + setTimeout(() => { inlineStatus.textContent = ''; inlineForm.classList.add('hidden') }, 2000) + } catch (err) { + inlineStatus.textContent = String(err) + inlineStatus.className = 'review-comment-status review-comment-err' + } finally { + inlineSend.disabled = false + } + }) + + details.append(sum, pre, inlineForm) return details })) } catch (e) { diff --git a/src/styles.css b/src/styles.css index 66107e2..2402117 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3925,9 +3925,12 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .review-detail { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } .review-diff-view { flex: 1; overflow-y: auto; padding: 6px 0; } .review-file-detail { border-bottom: 1px solid var(--border); } -.review-file-summary { padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--fg-dim); list-style: none; user-select: none; } +.review-file-summary { padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--fg-dim); list-style: none; user-select: none; display: flex; align-items: center; gap: 8px; } .review-file-summary:hover { color: var(--fg); } .review-file-detail[open] .review-file-summary { color: var(--fg); } +.review-inline-comment-btn { font-size: 13px; background: none; border: none; cursor: pointer; padding: 0 2px; opacity: 0.5; line-height: 1; } +.review-inline-comment-btn:hover { opacity: 1; } +.review-inline-form { padding: 8px 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; } /* Comment bar */ .review-comment-bar { flex-shrink: 0; border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } .review-pr-info { font-size: 11px; color: var(--accent); font-weight: 600; } From 0d2d9a02488f3a83de7db5834839658cf37a8e50 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:48:48 +0200 Subject: [PATCH 4/9] fix: strip remote prefix before passing branch to gh CLI --- src/panels/review/ReviewPanel.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 85fd923..b79ed6d 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -179,6 +179,9 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; } }) + // Strips remote prefix: "origin/feat/foo" → "feat/foo" + const ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') + // ── Select branch → load diff + PR ─────────────────────────────────────── const selectBranch = (branch: string): void => { selectedBranch = branch @@ -245,7 +248,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; try { await invoke('gh_pr_comment', { path: repoPath, - branch: selectedBranch, + branch: ghBranch(selectedBranch), body: `**\`${f.file}\`**\n\n${body}`, }) inlineInput.value = '' @@ -276,7 +279,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; try { const pr = await invoke<{ number: number; title: string } | null>('gh_pr_view_branch', { path: repoPath, - branch: selectedBranch, + branch: ghBranch(selectedBranch), }) if (pr) { prInfoEl.textContent = `PR #${pr.number}: ${pr.title}` @@ -291,7 +294,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; if (!body) { commentInput.focus(); return } commentBtn.disabled = true try { - await invoke('gh_pr_comment', { path: repoPath, branch: selectedBranch, body }) + await invoke('gh_pr_comment', { path: repoPath, branch: ghBranch(selectedBranch), body }) commentInput.value = '' showCommentStatus(reviewT('commentSent')) } catch (e) { From 8fc50d39145cdfab8884c3b397611b2005770f77 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:50:58 +0200 Subject: [PATCH 5/9] feat: PR link opens in browser from review panel --- src/panels/review/ReviewPanel.ts | 15 +++++++++++++-- src/styles.css | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index b79ed6d..3019c90 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import { open as pickFolder } from '@tauri-apps/plugin-dialog' +import { open as openUrl } from '@tauri-apps/plugin-shell' import { icon } from '../../ui/icons' import { parseDiffFiles } from '../diff/diffStats' import { diffGit } from '../diff/diffGitClient' @@ -277,12 +278,22 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; prInfoEl.textContent = '' commentBar.classList.add('hidden') try { - const pr = await invoke<{ number: number; title: string } | null>('gh_pr_view_branch', { + const pr = await invoke<{ number: number; title: string; url: string } | null>('gh_pr_view_branch', { path: repoPath, branch: ghBranch(selectedBranch), }) if (pr) { - prInfoEl.textContent = `PR #${pr.number}: ${pr.title}` + prInfoEl.replaceChildren() + const link = Object.assign(document.createElement('a'), { + className: 'review-pr-link', + textContent: `PR #${pr.number}: ${pr.title}`, + href: '#', + }) + link.addEventListener('click', e => { + e.preventDefault() + openUrl(pr.url).catch(() => {}) + }) + prInfoEl.append(link) commentBar.classList.remove('hidden') } } catch { /* no PR */ } diff --git a/src/styles.css b/src/styles.css index 2402117..4c27b13 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3933,7 +3933,9 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .review-inline-form { padding: 8px 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; } /* Comment bar */ .review-comment-bar { flex-shrink: 0; border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } -.review-pr-info { font-size: 11px; color: var(--accent); font-weight: 600; } +.review-pr-info { font-size: 11px; } +.review-pr-link { color: var(--accent); font-weight: 600; text-decoration: none; } +.review-pr-link:hover { text-decoration: underline; } .review-comment-input { resize: vertical; padding: 6px 8px; font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-family: inherit; } .review-comment-input:focus { outline: none; border-color: color-mix(in srgb, var(--accent) 50%, transparent); } .review-comment-btn { align-self: flex-end; padding: 5px 14px; border-radius: 5px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); cursor: pointer; } From e155ef694effb4d889067e82554bfeb21935d7d5 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:53:38 +0200 Subject: [PATCH 6/9] fix: use PR number for comments, show clickable link after sending --- src-tauri/src/git.rs | 8 +++--- src/panels/review/ReviewPanel.ts | 44 ++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 1c3101e..70ae484 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1027,19 +1027,19 @@ pub async fn gh_pr_view_branch(path: String, branch: String) -> Result Result<(), String> { +pub async fn gh_pr_comment(path: String, branch: String, body: String) -> Result { tauri::async_runtime::spawn_blocking(move || { let out = Command::new("gh") .current_dir(&path) - .args(["pr", "comment", &branch, "--body", &body]) + .args(["pr", "comment", &branch, "--body", &body, "--json", "url", "--jq", ".url"]) .output() .map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } - Ok(()) + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) }) .await .map_err(|e| e.to_string())? diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 3019c90..897b975 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -18,6 +18,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; let baseBranch = localStorage.getItem(BASE_KEY) ?? '' let selectedBranch = '' let allBranches: string[] = [] + let currentPrNumber: number | null = null let intervalId: ReturnType | null = null let autoRefresh = false @@ -122,6 +123,22 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; body.classList.toggle('hidden', on) } + const showSentLink = (el: HTMLElement, url: string): void => { + el.replaceChildren() + el.className = 'review-comment-status review-comment-ok' + if (url) { + const a = Object.assign(document.createElement('a'), { + className: 'review-pr-link', + textContent: reviewT('commentSent') + ' →', + href: '#', + }) + a.addEventListener('click', e => { e.preventDefault(); openUrl(url).catch(() => {}) }) + el.append(a) + } else { + el.textContent = reviewT('commentSent') + } + } + const showCommentStatus = (text: string, isError = false): void => { commentStatus.textContent = text commentStatus.className = `review-comment-status ${isError ? 'review-comment-err' : 'review-comment-ok'}` @@ -247,15 +264,14 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; if (!body) return inlineSend.disabled = true try { - await invoke('gh_pr_comment', { + const url = await invoke('gh_pr_comment', { path: repoPath, - branch: ghBranch(selectedBranch), + branch: prIdentifier(), body: `**\`${f.file}\`**\n\n${body}`, }) inlineInput.value = '' - inlineStatus.textContent = reviewT('commentSent') - inlineStatus.className = 'review-comment-status review-comment-ok' - setTimeout(() => { inlineStatus.textContent = ''; inlineForm.classList.add('hidden') }, 2000) + showSentLink(inlineStatus, url) + setTimeout(() => { inlineStatus.replaceChildren(); inlineForm.classList.add('hidden') }, 4000) } catch (err) { inlineStatus.textContent = String(err) inlineStatus.className = 'review-comment-status review-comment-err' @@ -275,7 +291,8 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; } const loadPrInfo = async (): Promise => { - prInfoEl.textContent = '' + currentPrNumber = null + prInfoEl.replaceChildren() commentBar.classList.add('hidden') try { const pr = await invoke<{ number: number; title: string; url: string } | null>('gh_pr_view_branch', { @@ -283,31 +300,32 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; branch: ghBranch(selectedBranch), }) if (pr) { - prInfoEl.replaceChildren() + currentPrNumber = pr.number const link = Object.assign(document.createElement('a'), { className: 'review-pr-link', textContent: `PR #${pr.number}: ${pr.title}`, href: '#', }) - link.addEventListener('click', e => { - e.preventDefault() - openUrl(pr.url).catch(() => {}) - }) + link.addEventListener('click', e => { e.preventDefault(); openUrl(pr.url).catch(() => {}) }) prInfoEl.append(link) commentBar.classList.remove('hidden') } } catch { /* no PR */ } } + // Identifier to pass to gh: PR number if known, else branch name + const prIdentifier = (): string => + currentPrNumber !== null ? String(currentPrNumber) : ghBranch(selectedBranch) + // ── Send PR comment ─────────────────────────────────────────────────────── commentBtn.addEventListener('click', async () => { const body = commentInput.value.trim() if (!body) { commentInput.focus(); return } commentBtn.disabled = true try { - await invoke('gh_pr_comment', { path: repoPath, branch: ghBranch(selectedBranch), body }) + const url = await invoke('gh_pr_comment', { path: repoPath, branch: prIdentifier(), body }) commentInput.value = '' - showCommentStatus(reviewT('commentSent')) + showSentLink(commentStatus, url) } catch (e) { showCommentStatus(String(e), true) } finally { From 40f05f780f521d0149c310bd6857f48abc6ce5ef Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:55:29 +0200 Subject: [PATCH 7/9] feat: inline review comments on specific file+line via GitHub API --- src-tauri/src/git.rs | 44 ++++++++++++++++++++++++++++++++ src-tauri/src/main.rs | 2 ++ src/panels/review/ReviewPanel.ts | 36 ++++++++++++++++++++------ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 70ae484..31bb1a6 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1045,6 +1045,50 @@ pub async fn gh_pr_comment(path: String, branch: String, body: String) -> Result .map_err(|e| e.to_string())? } +// Resolves a git ref to its full SHA. +#[tauri::command] +pub async fn git_rev_parse(path: String, reference: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_output(&path, &["rev-parse", &reference]).map(|s| s.trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Posts an inline review comment on a specific file+line via gh api. +#[tauri::command] +pub async fn gh_pr_inline_comment( + path: String, + pr_number: u64, + commit_id: String, + file: String, + line: u64, + body: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments"); + let out = Command::new("gh") + .current_dir(&path) + .args([ + "api", &endpoint, + "-f", &format!("body={body}"), + "-f", &format!("commit_id={commit_id}"), + "-f", &format!("path={file}"), + "-F", &format!("line={line}"), + "-f", "side=RIGHT", + "--jq", ".html_url", + ]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn git_push(path: String, force_with_lease: Option) -> Result { tauri::async_runtime::spawn_blocking(move || { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2ed2226..ed086c2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -257,8 +257,10 @@ fn main() { git::git_all_remote_branches, git::git_current_branch, git::git_ref_diff, + git::git_rev_parse, git::gh_pr_view_branch, git::gh_pr_comment, + git::gh_pr_inline_comment, git::git_worktree_add, git::git_worktree_remove, git::git_sync, diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 897b975..806c557 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -200,6 +200,17 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; // Strips remote prefix: "origin/feat/foo" → "feat/foo" const ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') + // Extracts the first changed line number from a diff chunk (new file side) + const firstChangedLine = (chunk: string): number => { + const match = chunk.match(/@@ -\d+(?:,\d+)? \+(\d+)/) + if (!match) return 1 + const start = parseInt(match[1], 10) + const lines = chunk.split('\n') + const hunkStart = lines.findIndex(l => l.startsWith('@@')) + const offset = lines.slice(hunkStart + 1).findIndex(l => l.startsWith('+')) + return start + (offset >= 0 ? offset : 0) + } + // ── Select branch → load diff + PR ─────────────────────────────────────── const selectBranch = (branch: string): void => { selectedBranch = branch @@ -264,14 +275,23 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; if (!body) return inlineSend.disabled = true try { - const url = await invoke('gh_pr_comment', { - path: repoPath, - branch: prIdentifier(), - body: `**\`${f.file}\`**\n\n${body}`, - }) - inlineInput.value = '' - showSentLink(inlineStatus, url) - setTimeout(() => { inlineStatus.replaceChildren(); inlineForm.classList.add('hidden') }, 4000) + if (currentPrNumber !== null) { + const commitId = await invoke('git_rev_parse', { path: repoPath, reference: selectedBranch }) + const line = firstChangedLine(f.chunk) + const url = await invoke('gh_pr_inline_comment', { + path: repoPath, + prNumber: currentPrNumber, + commitId, + file: f.file, + line, + body, + }) + inlineInput.value = '' + showSentLink(inlineStatus, url) + setTimeout(() => { inlineStatus.replaceChildren(); inlineForm.classList.add('hidden') }, 4000) + } else { + throw new Error('No PR found for this branch') + } } catch (err) { inlineStatus.textContent = String(err) inlineStatus.className = 'review-comment-status review-comment-err' From 01f1f4f6b9697b8a91e89feed48d9ba64447bd4d Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 00:59:26 +0200 Subject: [PATCH 8/9] feat: per-line comment button in diff, posts inline review comment --- src/panels/review/ReviewPanel.ts | 170 +++++++++++++++++-------------- src/styles.css | 9 +- 2 files changed, 100 insertions(+), 79 deletions(-) diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 806c557..a56e4ef 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -4,7 +4,6 @@ import { open as openUrl } from '@tauri-apps/plugin-shell' import { icon } from '../../ui/icons' import { parseDiffFiles } from '../diff/diffStats' import { diffGit } from '../diff/diffGitClient' -import { renderPatchHtml } from '../tasks/TaskCodeView' import { reviewT } from './i18n' const REPO_KEY = 'bento.review.repo' @@ -200,17 +199,6 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; // Strips remote prefix: "origin/feat/foo" → "feat/foo" const ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') - // Extracts the first changed line number from a diff chunk (new file side) - const firstChangedLine = (chunk: string): number => { - const match = chunk.match(/@@ -\d+(?:,\d+)? \+(\d+)/) - if (!match) return 1 - const start = parseInt(match[1], 10) - const lines = chunk.split('\n') - const hunkStart = lines.findIndex(l => l.startsWith('@@')) - const offset = lines.slice(hunkStart + 1).findIndex(l => l.startsWith('+')) - return start + (offset >= 0 ? offset : 0) - } - // ── Select branch → load diff + PR ─────────────────────────────────────── const selectBranch = (branch: string): void => { selectedBranch = branch @@ -219,6 +207,96 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; loadPrInfo() } + const makeLineForm = (filePath: string, line: number): HTMLElement => { + const form = document.createElement('div') + form.className = 'review-line-form' + const input = document.createElement('textarea') + input.className = 'review-comment-input' + input.placeholder = reviewT('commentPlaceholder') + input.rows = 3 + const actions = document.createElement('div') + actions.className = 'review-line-form-actions' + const sendBtn = Object.assign(document.createElement('button'), { className: 'review-comment-btn', textContent: reviewT('sendComment') }) + const cancelBtn = Object.assign(document.createElement('button'), { className: 'review-line-cancel-btn', textContent: 'Cancel' }) + const status = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) + actions.append(cancelBtn, sendBtn, status) + form.append(input, actions) + + cancelBtn.addEventListener('click', () => form.remove()) + sendBtn.addEventListener('click', async () => { + const body = input.value.trim() + if (!body) { input.focus(); return } + if (currentPrNumber === null) { status.textContent = 'No PR for this branch'; return } + sendBtn.disabled = true + try { + const commitId = await invoke('git_rev_parse', { path: repoPath, reference: selectedBranch }) + const url = await invoke('gh_pr_inline_comment', { path: repoPath, prNumber: currentPrNumber, commitId, file: filePath, line, body }) + input.value = '' + showSentLink(status, url) + setTimeout(() => form.remove(), 4000) + } catch (err) { + status.textContent = String(err) + status.className = 'review-comment-status review-comment-err' + } finally { + sendBtn.disabled = false + } + }) + return form + } + + const buildFileDiff = (chunk: string, filePath: string): HTMLElement => { + const container = document.createElement('div') + let newLine = 0 + + for (const raw of chunk.split('\n')) { + const isAdd = raw.startsWith('+') && !raw.startsWith('+++') + const isDel = raw.startsWith('-') && !raw.startsWith('---') + const isHunk = raw.startsWith('@@') + const isMeta = raw.startsWith('diff ') || raw.startsWith('index ') || raw.startsWith('--- ') || raw.startsWith('+++ ') + + if (isHunk) { + const m = raw.match(/@@ -\d+(?:,\d+)? \+(\d+)/) + if (m) newLine = parseInt(m[1], 10) - 1 + } + + let fileLine: number | null = null + if (isAdd) { newLine++; fileLine = newLine } + else if (!isDel && !isHunk && !isMeta) { newLine++; fileLine = newLine } + + const cls = isAdd ? ' tasks-diff-line-add' : isDel ? ' tasks-diff-line-del' : isHunk ? ' tasks-diff-hunk' : '' + const esc = raw.replace(/&/g, '&').replace(//g, '>') + + const wrap = document.createElement('div') + wrap.className = 'review-diff-line-wrap' + + const lineEl = document.createElement('div') + lineEl.className = `tasks-diff-code-line${cls}` + + if (fileLine !== null) { + const addBtn = Object.assign(document.createElement('button'), { + className: 'review-line-comment-btn', + textContent: '+', + title: `Comment line ${fileLine}`, + }) + const capturedLine = fileLine + addBtn.addEventListener('click', () => { + wrap.querySelectorAll('.review-line-form').forEach(el => el.remove()) + const form = makeLineForm(filePath, capturedLine) + wrap.append(form) + form.querySelector('textarea')?.focus() + }) + lineEl.append(addBtn) + } + + const content = document.createElement('span') + content.innerHTML = `${fileLine ?? ''}${esc}` + lineEl.append(content) + wrap.append(lineEl) + container.append(wrap) + } + return container + } + const loadDiff = async (): Promise => { diffView.replaceChildren( Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') }), @@ -236,71 +314,11 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; const details = document.createElement('details') details.className = 'review-file-detail' details.open = files.length <= 5 - - const sum = document.createElement('summary') - sum.className = 'review-file-summary' - const fileName = Object.assign(document.createElement('span'), { textContent: f.file }) - const commentBtn = Object.assign(document.createElement('button'), { - className: 'review-inline-comment-btn', - title: reviewT('sendComment'), - textContent: '💬', + const sum = Object.assign(document.createElement('summary'), { + className: 'review-file-summary', + textContent: f.file, }) - sum.append(fileName, commentBtn) - - const pre = Object.assign(document.createElement('pre'), { className: 'tasks-diff-code' }) - pre.innerHTML = renderPatchHtml(f.chunk) - - // Inline comment form (hidden by default) - const inlineForm = document.createElement('div') - inlineForm.className = 'review-inline-form hidden' - const inlineInput = document.createElement('textarea') - inlineInput.className = 'review-comment-input' - inlineInput.placeholder = reviewT('commentPlaceholder') - inlineInput.rows = 3 - const inlineSend = Object.assign(document.createElement('button'), { - className: 'review-comment-btn', - textContent: reviewT('sendComment'), - }) - const inlineStatus = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) - inlineForm.append(inlineInput, inlineSend, inlineStatus) - - commentBtn.addEventListener('click', e => { - e.preventDefault() - inlineForm.classList.toggle('hidden') - if (!inlineForm.classList.contains('hidden')) inlineInput.focus() - }) - - inlineSend.addEventListener('click', async () => { - const body = inlineInput.value.trim() - if (!body) return - inlineSend.disabled = true - try { - if (currentPrNumber !== null) { - const commitId = await invoke('git_rev_parse', { path: repoPath, reference: selectedBranch }) - const line = firstChangedLine(f.chunk) - const url = await invoke('gh_pr_inline_comment', { - path: repoPath, - prNumber: currentPrNumber, - commitId, - file: f.file, - line, - body, - }) - inlineInput.value = '' - showSentLink(inlineStatus, url) - setTimeout(() => { inlineStatus.replaceChildren(); inlineForm.classList.add('hidden') }, 4000) - } else { - throw new Error('No PR found for this branch') - } - } catch (err) { - inlineStatus.textContent = String(err) - inlineStatus.className = 'review-comment-status review-comment-err' - } finally { - inlineSend.disabled = false - } - }) - - details.append(sum, pre, inlineForm) + details.append(sum, buildFileDiff(f.chunk, f.file)) return details })) } catch (e) { diff --git a/src/styles.css b/src/styles.css index 4c27b13..536fd3e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3928,9 +3928,12 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .review-file-summary { padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--fg-dim); list-style: none; user-select: none; display: flex; align-items: center; gap: 8px; } .review-file-summary:hover { color: var(--fg); } .review-file-detail[open] .review-file-summary { color: var(--fg); } -.review-inline-comment-btn { font-size: 13px; background: none; border: none; cursor: pointer; padding: 0 2px; opacity: 0.5; line-height: 1; } -.review-inline-comment-btn:hover { opacity: 1; } -.review-inline-form { padding: 8px 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; } +.review-diff-line-wrap { position: relative; } +.review-diff-line-wrap:hover .review-line-comment-btn { opacity: 1; } +.review-line-comment-btn { opacity: 0; position: absolute; left: 0; top: 0; width: 18px; height: 100%; background: var(--accent, #7aa2f7); color: #fff; border: none; font-size: 12px; font-weight: 700; cursor: pointer; z-index: 5; padding: 0; line-height: 1; transition: opacity .1s; } +.review-line-form { background: var(--surface); border: 1px solid var(--border); border-top: 2px solid var(--accent, #7aa2f7); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } +.review-line-form-actions { display: flex; align-items: center; gap: 6px; } +.review-line-cancel-btn { padding: 4px 10px; border-radius: 4px; font-size: 12px; background: transparent; border: 1px solid var(--border); color: var(--fg-dim); cursor: pointer; } /* Comment bar */ .review-comment-bar { flex-shrink: 0; border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } .review-pr-info { font-size: 11px; } From a30e3af32484be6fcfa2f7d2765caf3dedba7dbd Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 1 Aug 2026 01:04:20 +0200 Subject: [PATCH 9/9] fix: range selection using mousemove+elementFromPoint instead of pointer capture --- src-tauri/src/git.rs | 25 +++++++----- src/panels/review/ReviewPanel.ts | 66 ++++++++++++++++++++++++++++---- src/styles.css | 3 +- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 31bb1a6..be1b038 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1063,21 +1063,28 @@ pub async fn gh_pr_inline_comment( commit_id: String, file: String, line: u64, + start_line: Option, body: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments"); + let mut args = vec![ + "api".to_string(), endpoint, + "-f".to_string(), format!("body={body}"), + "-f".to_string(), format!("commit_id={commit_id}"), + "-f".to_string(), format!("path={file}"), + "-F".to_string(), format!("line={line}"), + "-f".to_string(), "side=RIGHT".to_string(), + ]; + if let Some(sl) = start_line { + if sl < line { + args.extend(["-F".to_string(), format!("start_line={sl}"), "-f".to_string(), "start_side=RIGHT".to_string()]); + } + } + args.extend(["--jq".to_string(), ".html_url".to_string()]); let out = Command::new("gh") .current_dir(&path) - .args([ - "api", &endpoint, - "-f", &format!("body={body}"), - "-f", &format!("commit_id={commit_id}"), - "-f", &format!("path={file}"), - "-F", &format!("line={line}"), - "-f", "side=RIGHT", - "--jq", ".html_url", - ]) + .args(&args) .output() .map_err(|e| e.to_string())?; if !out.status.success() { diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index a56e4ef..31a60d7 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -207,7 +207,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; loadPrInfo() } - const makeLineForm = (filePath: string, line: number): HTMLElement => { + const makeLineForm = (filePath: string, line: number, startLine?: number): HTMLElement => { const form = document.createElement('div') form.className = 'review-line-form' const input = document.createElement('textarea') @@ -230,7 +230,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; sendBtn.disabled = true try { const commitId = await invoke('git_rev_parse', { path: repoPath, reference: selectedBranch }) - const url = await invoke('gh_pr_inline_comment', { path: repoPath, prNumber: currentPrNumber, commitId, file: filePath, line, body }) + const url = await invoke('gh_pr_inline_comment', { path: repoPath, prNumber: currentPrNumber, commitId, file: filePath, line, startLine, body }) input.value = '' showSentLink(status, url) setTimeout(() => form.remove(), 4000) @@ -247,6 +247,50 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; const buildFileDiff = (chunk: string, filePath: string): HTMLElement => { const container = document.createElement('div') let newLine = 0 + let dragStart: number | null = null + + const lineFromEl = (el: Element | null): number | null => { + const wrap = el?.closest('[data-line]') + const n = parseInt(wrap?.dataset.line ?? '', 10) + return isNaN(n) ? null : n + } + + const clearHighlight = (): void => + container.querySelectorAll('.review-line-wrap--selected').forEach(el => el.classList.remove('review-line-wrap--selected')) + + const highlightRange = (a: number, b: number): void => { + const lo = Math.min(a, b), hi = Math.max(a, b) + container.querySelectorAll('[data-line]').forEach(wrap => { + const ln = parseInt(wrap.dataset.line ?? '', 10) + wrap.classList.toggle('review-line-wrap--selected', ln >= lo && ln <= hi) + }) + } + + const openRangeForm = (lo: number, hi: number): void => { + container.querySelectorAll('.review-line-form').forEach(el => el.remove()) + clearHighlight() + const anchorWrap = container.querySelector(`[data-line="${hi}"]`) + if (!anchorWrap) return + const form = makeLineForm(filePath, hi, lo < hi ? lo : undefined) + anchorWrap.after(form) + form.querySelector('textarea')?.focus() + } + + const onMouseMove = (e: MouseEvent): void => { + if (dragStart === null) return + const ln = lineFromEl(document.elementFromPoint(e.clientX, e.clientY)) + if (ln !== null) highlightRange(dragStart, ln) + } + + const onMouseUp = (e: MouseEvent): void => { + if (dragStart === null) return + const ln = lineFromEl(document.elementFromPoint(e.clientX, e.clientY)) ?? dragStart + const lo = Math.min(dragStart, ln), hi = Math.max(dragStart, ln) + dragStart = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + openRangeForm(lo, hi) + } for (const raw of chunk.split('\n')) { const isAdd = raw.startsWith('+') && !raw.startsWith('+++') @@ -273,18 +317,23 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; lineEl.className = `tasks-diff-code-line${cls}` if (fileLine !== null) { + wrap.dataset.line = String(fileLine) + const capturedLine = fileLine + const addBtn = Object.assign(document.createElement('button'), { className: 'review-line-comment-btn', textContent: '+', title: `Comment line ${fileLine}`, }) - const capturedLine = fileLine - addBtn.addEventListener('click', () => { - wrap.querySelectorAll('.review-line-form').forEach(el => el.remove()) - const form = makeLineForm(filePath, capturedLine) - wrap.append(form) - form.querySelector('textarea')?.focus() + + addBtn.addEventListener('mousedown', e => { + e.preventDefault() + dragStart = capturedLine + highlightRange(capturedLine, capturedLine) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) }) + lineEl.append(addBtn) } @@ -294,6 +343,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; wrap.append(lineEl) container.append(wrap) } + return container } diff --git a/src/styles.css b/src/styles.css index 536fd3e..68f2531 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3928,8 +3928,9 @@ button.tasks-rebase-drag { border: 0; background: transparent; font: inherit; } .review-file-summary { padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--fg-dim); list-style: none; user-select: none; display: flex; align-items: center; gap: 8px; } .review-file-summary:hover { color: var(--fg); } .review-file-detail[open] .review-file-summary { color: var(--fg); } -.review-diff-line-wrap { position: relative; } +.review-diff-line-wrap { position: relative; user-select: none; } .review-diff-line-wrap:hover .review-line-comment-btn { opacity: 1; } +.review-line-wrap--selected { background: color-mix(in srgb, var(--accent, #7aa2f7) 15%, transparent); } .review-line-comment-btn { opacity: 0; position: absolute; left: 0; top: 0; width: 18px; height: 100%; background: var(--accent, #7aa2f7); color: #fff; border: none; font-size: 12px; font-weight: 700; cursor: pointer; z-index: 5; padding: 0; line-height: 1; transition: opacity .1s; } .review-line-form { background: var(--surface); border: 1px solid var(--border); border-top: 2px solid var(--accent, #7aa2f7); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } .review-line-form-actions { display: flex; align-items: center; gap: 6px; }