diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 481db8b..be1b038 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() @@ -949,6 +998,104 @@ 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 and returns its URL. +#[tauri::command] +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, "--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(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .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, + 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(&args) + .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 fadd9a4..ed086c2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -254,10 +254,18 @@ 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_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, 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..8e9d262 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -57,7 +57,51 @@ "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}", + "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", + "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..51a1b02 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -57,7 +57,51 @@ "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}", + "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", + "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..31a60d7 --- /dev/null +++ b/src/panels/review/ReviewPanel.ts @@ -0,0 +1,484 @@ +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' +import { reviewT } from './i18n' + +const REPO_KEY = 'bento.review.repo' +const BASE_KEY = 'bento.review.base' + +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 baseBranch = localStorage.getItem(BASE_KEY) ?? '' + let selectedBranch = '' + let allBranches: string[] = [] + let currentPrNumber: number | null = null + let intervalId: ReturnType | null = null + let autoRefresh = false + + // ── Toolbar ─────────────────────────────────────────────────────────────── + const toolbar = document.createElement('div') + toolbar.className = 'review-toolbar' + + 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: 'origin/main', + }) + const branchDropdown = document.createElement('div') + branchDropdown.className = 'review-branch-dropdown hidden' + branchWrap.append(branchInput, branchDropdown) + + 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'), + }) + + toolbar.append(baseLabel, branchWrap, openBtn, refreshBtn, autoBtn) + + // ── Body ────────────────────────────────────────────────────────────────── + const body = document.createElement('div') + body.className = 'review-body' + + // Sidebar: list of branches + const sidebar = document.createElement('div') + sidebar.className = 'review-sidebar' + + 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'), { + 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, + ) + + root.append(toolbar, emptyState, body) + + // ── Helpers ─────────────────────────────────────────────────────────────── + const setEmptyVisible = (on: boolean): void => { + emptyState.classList.toggle('hidden', !on) + 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'}` + setTimeout(() => { commentStatus.textContent = ''; commentStatus.className = 'review-comment-status' }, isError ? 5000 : 3000) + } + + // ── 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 + })) + } + + branchSearch.addEventListener('input', renderBranchList) + + // ── Base dropdown ───────────────────────────────────────────────────────── + const renderBaseDropdown = (): void => { + const q = branchInput.value.toLowerCase() + const matches = q ? allBranches.filter(b => b.toLowerCase().includes(q)) : allBranches + 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, + }) + item.addEventListener('mousedown', e => { + e.preventDefault() + baseBranch = b + branchInput.value = b + localStorage.setItem(BASE_KEY, baseBranch) + branchDropdown.classList.add('hidden') + if (selectedBranch) loadDiff() + }) + return item + })) + branchDropdown.classList.toggle('hidden', matches.length === 0) + } + + branchInput.addEventListener('focus', renderBaseDropdown) + branchInput.addEventListener('input', renderBaseDropdown) + branchInput.addEventListener('blur', () => setTimeout(() => branchDropdown.classList.add('hidden'), 150)) + branchInput.addEventListener('keydown', e => { + if (e.key === 'Escape') { branchDropdown.classList.add('hidden'); return } + 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() } + } + }) + + // 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 + renderBranchList() + loadDiff() + loadPrInfo() + } + + const makeLineForm = (filePath: string, line: number, startLine?: 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, startLine, 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 + 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('+++') + 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) { + 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}`, + }) + + addBtn.addEventListener('mousedown', e => { + e.preventDefault() + dragStart = capturedLine + highlightRange(capturedLine, capturedLine) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }) + + 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') }), + ) + 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, + }) + details.append(sum, buildFileDiff(f.chunk, f.file)) + return details + })) + } catch (e) { + diffView.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-error', textContent: String(e) }), + ) + } + } + + const loadPrInfo = async (): Promise => { + currentPrNumber = null + prInfoEl.replaceChildren() + commentBar.classList.add('hidden') + try { + const pr = await invoke<{ number: number; title: string; url: string } | null>('gh_pr_view_branch', { + path: repoPath, + branch: ghBranch(selectedBranch), + }) + if (pr) { + 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(() => {}) }) + 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 { + const url = await invoke('gh_pr_comment', { path: repoPath, branch: prIdentifier(), body }) + commentInput.value = '' + showSentLink(commentStatus, url) + } catch (e) { + showCommentStatus(String(e), true) + } finally { + commentBtn.disabled = false + } + }) + + // ── Load branches ───────────────────────────────────────────────────────── + const loadBranches = async (): Promise => { + if (!repoPath) return + const [defaultBranch, branches] = await Promise.all([ + diffGit.defaultBranch(repoPath), + diffGit.remoteBranches(repoPath), + ]) + allBranches = branches + 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 ────────────────────────────────────────────────────────── + 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(() => { if (selectedBranch) loadDiff() }, 5000) + } + + // ── 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 = '' + selectedBranch = '' + localStorage.setItem(REPO_KEY, repoPath) + setEmptyVisible(false) + diffView.replaceChildren() + commentBar.classList.add('hidden') + await loadBranches() + } + + openBtn.addEventListener('click', pickRepo) + emptyOpenBtn.addEventListener('click', pickRepo) + refreshBtn.addEventListener('click', () => { loadBranches(); if (selectedBranch) loadDiff() }) + autoBtn.addEventListener('click', () => setAutoRefresh(!autoRefresh)) + + // ── Init ────────────────────────────────────────────────────────────────── + if (repoPath) { + setEmptyVisible(false) + diffView.replaceChildren( + Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: reviewT('selectBranch') }), + ) + loadBranches() + } 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..68f2531 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,93 @@ 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 ───────────────────────────────────────────────────── */ +/* ── 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-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; } +/* 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; 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; 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; } +.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; } +.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; } +.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-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); } 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..f8dc215 --- /dev/null +++ b/tests/panels/review/reviewPanel.test.ts @@ -0,0 +1,54 @@ +// @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 sidebar and detail panes', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-sidebar')).not.toBeNull() + expect(element.querySelector('.review-detail')).not.toBeNull() + }) + + it('comment bar hidden until PR is loaded', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-comment-bar')?.classList.contains('hidden')).toBe(true) + }) + + it('renders branch search input in sidebar', () => { + setup() + const { element } = createReviewPanel() + expect(element.querySelector('.review-branch-search')).not.toBeNull() + }) +})