From 01233008740105477f4c3379f2edd8020eb8cd6b Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 22 Aug 2026 21:22:46 +0200 Subject: [PATCH 01/51] refactor: split ReviewPanel into smaller focused modules Extracted pure formatting helpers, agent-select controls, comment bubble, and branch/PR sidebar lists out of the 2078-line ReviewPanel.ts into their own files, following the same options-bag/callback pattern already used by the Tasks panel split. ReviewPanel.ts drops to 1571 lines. Diff rendering, data loading, and the AI-review trigger stay inline for now (higher risk, thin existing test coverage). --- src/panels/review/ReviewAgentControls.ts | 121 ++++ src/panels/review/ReviewCommentBubble.ts | 173 +++++ src/panels/review/ReviewPanel.ts | 644 ++---------------- src/panels/review/ReviewSidebarLists.ts | 158 +++++ src/panels/review/reviewFormat.ts | 224 ++++++ .../panels/review/ReviewAgentControls.test.ts | 66 ++ .../panels/review/ReviewCommentBubble.test.ts | 159 +++++ .../panels/review/ReviewSidebarLists.test.ts | 242 +++++++ tests/panels/review/reviewFormat.test.ts | 173 +++++ 9 files changed, 1385 insertions(+), 575 deletions(-) create mode 100644 src/panels/review/ReviewAgentControls.ts create mode 100644 src/panels/review/ReviewCommentBubble.ts create mode 100644 src/panels/review/ReviewSidebarLists.ts create mode 100644 src/panels/review/reviewFormat.ts create mode 100644 tests/panels/review/ReviewAgentControls.test.ts create mode 100644 tests/panels/review/ReviewCommentBubble.test.ts create mode 100644 tests/panels/review/ReviewSidebarLists.test.ts create mode 100644 tests/panels/review/reviewFormat.test.ts diff --git a/src/panels/review/ReviewAgentControls.ts b/src/panels/review/ReviewAgentControls.ts new file mode 100644 index 0000000..1ab28c3 --- /dev/null +++ b/src/panels/review/ReviewAgentControls.ts @@ -0,0 +1,121 @@ +import { agentLabel, type AgentType } from '../../core/ai/config' +import { reviewT } from './i18n' +import { t as i18nT } from '../../i18n' + +const REVIEW_AGENT_KEY = 'bento.review.agent' +const REVIEW_COMPARE_AGENTS_KEY = 'bento.review.compare-agents' +const REVIEW_SECONDARY_AGENT_KEY = 'bento.review.agent.secondary' +const REVIEW_TERTIARY_AGENT_KEY = 'bento.review.agent.tertiary' +const REVIEW_AGENT_TYPES: AgentType[] = ['claude', 'opencode', 'codex'] + +export interface ReviewAgentControls { + reviewAgentSelect: HTMLSelectElement + reviewCompareAgentsToggle: HTMLInputElement + reviewCompareAgentsLabel: HTMLLabelElement + reviewAgentHint: HTMLDivElement + reviewSecondaryRow: HTMLDivElement + reviewTertiaryRow: HTMLDivElement + reviewAgentBadge: HTMLSpanElement + selectedReviewAgents: () => AgentType[] +} + +export function buildReviewAgentControls(): ReviewAgentControls { + const reviewAgentSelect = document.createElement('select') + reviewAgentSelect.className = 'review-agent-select' + ;(['claude', 'opencode', 'codex'] as const).forEach(val => { + reviewAgentSelect.appendChild(Object.assign(document.createElement('option'), { + value: val, textContent: agentLabel(val), + })) + }) + reviewAgentSelect.value = localStorage.getItem(REVIEW_AGENT_KEY) ?? 'claude' + const reviewCompareAgentsToggle = Object.assign(document.createElement('input'), { + type: 'checkbox', + className: 'review-agent-toggle-input', + }) + reviewCompareAgentsToggle.checked = localStorage.getItem(REVIEW_COMPARE_AGENTS_KEY) === '1' + reviewCompareAgentsToggle.dataset.testid = 'review-compare-agents-toggle' + const reviewCompareAgentsLabel = document.createElement('label') + reviewCompareAgentsLabel.className = 'review-agent-toggle' + reviewCompareAgentsLabel.append(reviewCompareAgentsToggle, Object.assign(document.createElement('span'), { + textContent: i18nT('common.reviewCompareAgents'), + })) + const reviewAgentHint = Object.assign(document.createElement('div'), { className: 'review-agent-hint' }) + + const mkOptionalAgentSelect = (value: string | null, testid: string): HTMLSelectElement => { + const select = document.createElement('select') + select.className = 'review-agent-select review-agent-select--optional' + select.dataset.testid = testid + select.appendChild(Object.assign(document.createElement('option'), { value: '', textContent: i18nT('common.reviewAgentNone') })) + REVIEW_AGENT_TYPES.forEach(agent => { + select.appendChild(Object.assign(document.createElement('option'), { value: agent, textContent: agentLabel(agent) })) + }) + select.value = value && REVIEW_AGENT_TYPES.includes(value as AgentType) ? value : '' + return select + } + + const reviewSecondaryAgentSelect = mkOptionalAgentSelect(localStorage.getItem(REVIEW_SECONDARY_AGENT_KEY), 'review-secondary-agent') + const reviewTertiaryAgentSelect = mkOptionalAgentSelect(localStorage.getItem(REVIEW_TERTIARY_AGENT_KEY), 'review-tertiary-agent') + const reviewSecondaryRow = document.createElement('div') + reviewSecondaryRow.className = 'review-agent-extra hidden' + reviewSecondaryRow.append(Object.assign(document.createElement('span'), { className: 'review-agent-extra-label', textContent: i18nT('common.reviewAgentSecondary') }), reviewSecondaryAgentSelect) + const reviewTertiaryRow = document.createElement('div') + reviewTertiaryRow.className = 'review-agent-extra hidden' + reviewTertiaryRow.append(Object.assign(document.createElement('span'), { className: 'review-agent-extra-label', textContent: i18nT('common.reviewAgentTertiary') }), reviewTertiaryAgentSelect) + + const reviewAgentBadge = document.createElement('span') + reviewAgentBadge.className = 'review-agent-badge' + reviewAgentBadge.dataset.testid = 'review-agent-badge' + + const selectedReviewAgents = (): AgentType[] => { + const selected: AgentType[] = [reviewAgentSelect.value as AgentType] + if (!reviewCompareAgentsToggle.checked) return selected + const extras = [reviewSecondaryAgentSelect.value, reviewTertiaryAgentSelect.value] + .filter((value): value is AgentType => REVIEW_AGENT_TYPES.includes(value as AgentType)) + return [...selected, ...extras] + } + + const normalizeReviewAgents = (): void => { + if (!reviewCompareAgentsToggle.checked) return + const primary = reviewAgentSelect.value as AgentType + if (!reviewSecondaryAgentSelect.value) { + reviewSecondaryAgentSelect.value = primary + } + if (!reviewTertiaryAgentSelect.value) reviewTertiaryAgentSelect.value = primary + } + + const syncReviewAgentUi = (): void => { + reviewSecondaryRow.classList.toggle('hidden', !reviewCompareAgentsToggle.checked) + reviewTertiaryRow.classList.toggle('hidden', !reviewCompareAgentsToggle.checked) + normalizeReviewAgents() + localStorage.setItem(REVIEW_AGENT_KEY, reviewAgentSelect.value) + localStorage.setItem(REVIEW_COMPARE_AGENTS_KEY, reviewCompareAgentsToggle.checked ? '1' : '0') + if (reviewSecondaryAgentSelect.value) localStorage.setItem(REVIEW_SECONDARY_AGENT_KEY, reviewSecondaryAgentSelect.value) + else localStorage.removeItem(REVIEW_SECONDARY_AGENT_KEY) + if (reviewTertiaryAgentSelect.value) localStorage.setItem(REVIEW_TERTIARY_AGENT_KEY, reviewTertiaryAgentSelect.value) + else localStorage.removeItem(REVIEW_TERTIARY_AGENT_KEY) + const agents = selectedReviewAgents().map(agentLabel) + reviewAgentBadge.textContent = agents.length === 1 + ? i18nT('common.reviewAgentFixed', { agent: agents[0] }) + : i18nT('common.reviewAgentsFixed', { agents: agents.join(' + ') }) + reviewAgentHint.textContent = reviewCompareAgentsToggle.checked + ? reviewT('agentModeHintCombined') + : reviewT('agentModeHintSingle') + } + + reviewCompareAgentsToggle.addEventListener('change', syncReviewAgentUi) + reviewAgentSelect.addEventListener('change', syncReviewAgentUi) + reviewSecondaryAgentSelect.addEventListener('change', syncReviewAgentUi) + reviewTertiaryAgentSelect.addEventListener('change', syncReviewAgentUi) + syncReviewAgentUi() + + return { + reviewAgentSelect, + reviewCompareAgentsToggle, + reviewCompareAgentsLabel, + reviewAgentHint, + reviewSecondaryRow, + reviewTertiaryRow, + reviewAgentBadge, + selectedReviewAgents, + } +} diff --git a/src/panels/review/ReviewCommentBubble.ts b/src/panels/review/ReviewCommentBubble.ts new file mode 100644 index 0000000..2617c43 --- /dev/null +++ b/src/panels/review/ReviewCommentBubble.ts @@ -0,0 +1,173 @@ +import { invoke } from '@tauri-apps/api/core' +import type { GhComment } from './reviewFormat' +import { relativeTime } from './reviewFormat' +import { reviewT } from './i18n' + +export interface ReviewCommentActions { + repoPath: () => string + isResolved: (id: number) => boolean + setResolved: (id: number, resolved: boolean) => void + refresh: () => Promise +} + +// ── Comment bubble (edit/delete/reply) ──────────────────────────────────── +export function buildReviewCommentBubble(c: GhComment, actions: ReviewCommentActions): HTMLElement { + const bubble = document.createElement('div') + bubble.className = 'review-existing-comment' + bubble.dataset.commentId = String(c.id) + if (actions.isResolved(c.id)) bubble.classList.add('review-existing-comment--resolved') + + const header = document.createElement('div') + header.className = 'review-existing-comment-header' + const userSpan = Object.assign(document.createElement('span'), { className: 'review-comment-author', textContent: c.user.login }) + const editBtn = Object.assign(document.createElement('button'), { className: 'review-comment-action-btn', textContent: reviewT('editComment') }) + const replyBtn = Object.assign(document.createElement('button'), { className: 'review-comment-action-btn', textContent: reviewT('replyComment') }) + const deleteBtn = Object.assign(document.createElement('button'), { className: 'review-comment-action-btn review-comment-delete-btn', textContent: reviewT('deleteComment') }) + const resolveBtn = Object.assign(document.createElement('button'), { + className: 'review-resolve-btn', + textContent: actions.isResolved(c.id) ? reviewT('unresolveComment') : reviewT('resolveComment'), + }) + if (c.created_at) { + const timeSpan = Object.assign(document.createElement('span'), { className: 'review-comment-time', textContent: relativeTime(c.created_at) }) + header.append(userSpan, timeSpan, editBtn, replyBtn, deleteBtn, resolveBtn) + } else { + header.append(userSpan, editBtn, replyBtn, deleteBtn, resolveBtn) + } + + const bodyEl = Object.assign(document.createElement('div'), { className: 'review-existing-comment-body', textContent: c.body }) + bubble.append(header, bodyEl) + + bubble.addEventListener('click', e => { + if ((e.target as Element).closest('button')) return + if (bubble.classList.contains('review-existing-comment--resolved')) { + bubble.classList.toggle('review-existing-comment--expanded') + } + }) + resolveBtn.addEventListener('click', () => { + const nowResolved = !actions.isResolved(c.id) + actions.setResolved(c.id, nowResolved) + bubble.classList.toggle('review-existing-comment--resolved', nowResolved) + bubble.classList.remove('review-existing-comment--expanded') + resolveBtn.textContent = nowResolved ? reviewT('unresolveComment') : reviewT('resolveComment') + }) + + editBtn.addEventListener('click', () => { + if (bubble.querySelector('.review-edit-wrap')) return + const editArea = document.createElement('textarea') + editArea.className = 'review-comment-input' + editArea.value = c.body + editArea.rows = 3 + const actionsRow = document.createElement('div') + actionsRow.className = 'review-line-form-actions' + const saveBtn = Object.assign(document.createElement('button'), { className: 'review-comment-btn', textContent: 'Save' }) + const cancelBtn = Object.assign(document.createElement('button'), { className: 'review-line-cancel-btn', textContent: 'Cancel' }) + actionsRow.append(cancelBtn, saveBtn) + const wrap = document.createElement('div') + wrap.className = 'review-edit-wrap' + wrap.append(editArea, actionsRow) + bodyEl.after(wrap) + bodyEl.classList.add('hidden') + editArea.focus() + cancelBtn.addEventListener('click', () => { wrap.remove(); bodyEl.classList.remove('hidden') }) + saveBtn.addEventListener('click', async () => { + const newBody = editArea.value.trim() + if (!newBody) return + saveBtn.disabled = true + try { + await invoke('gh_pr_update_comment', { path: actions.repoPath(), commentId: c.id, body: newBody }) + await actions.refresh() + } catch (err) { console.error(err) } finally { saveBtn.disabled = false } + }) + }) + + deleteBtn.addEventListener('click', async () => { + if (!confirm(reviewT('deleteConfirm'))) return + try { + await invoke('gh_pr_delete_comment', { path: actions.repoPath(), commentId: c.id }) + await actions.refresh() + } catch (err) { console.error(err) } + }) + + replyBtn.addEventListener('click', () => { + if (bubble.querySelector('.review-reply-wrap')) return + const replyArea = document.createElement('textarea') + replyArea.className = 'review-comment-input' + replyArea.placeholder = reviewT('commentPlaceholder') + replyArea.rows = 2 + const actionsRow = document.createElement('div') + actionsRow.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' }) + actionsRow.append(cancelBtn, sendBtn) + const wrap = document.createElement('div') + wrap.className = 'review-reply-wrap' + wrap.append(replyArea, actionsRow) + bubble.append(wrap) + replyArea.focus() + cancelBtn.addEventListener('click', () => wrap.remove()) + sendBtn.addEventListener('click', async () => { + const body = replyArea.value.trim() + if (!body) return + sendBtn.disabled = true + try { + await invoke('gh_pr_reply_comment', { path: actions.repoPath(), commentId: c.id, body }) + await actions.refresh() + } catch (err) { console.error(err) } finally { sendBtn.disabled = false } + }) + }) + + return bubble +} + +export interface ReviewLineFormActions { + repoPath: () => string + selectedBranch: () => string + currentPrNumber: () => number | null + refresh: () => Promise + showSentLink: (el: HTMLElement, url: string) => void +} + +// ── Inline comment form (with draft) ───────────────────────────────────── +export function buildReviewLineForm(filePath: string, line: number, startLine: number | undefined, actions: ReviewLineFormActions): 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 draftKey = `bento.review.draft.${actions.repoPath()}.${actions.selectedBranch()}.${filePath}.${line}` + const saved = localStorage.getItem(draftKey) + if (saved) input.value = saved + input.addEventListener('input', () => { + if (input.value) localStorage.setItem(draftKey, input.value); else localStorage.removeItem(draftKey) + }) + const actionsRow = document.createElement('div') + actionsRow.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' }) + actionsRow.append(cancelBtn, sendBtn, status) + form.append(input, actionsRow) + cancelBtn.addEventListener('click', () => form.remove()) + sendBtn.addEventListener('click', async () => { + const body = input.value.trim() + if (!body) { input.focus(); return } + const prNumber = actions.currentPrNumber() + if (prNumber === null) { status.textContent = 'No PR for this branch'; return } + sendBtn.disabled = true + try { + const repoPath = actions.repoPath() + const commitId = await invoke('git_rev_parse', { path: repoPath, reference: actions.selectedBranch() }) + const url = await invoke('gh_pr_inline_comment', { path: repoPath, prNumber, commitId, file: filePath, line, startLine, body }) + localStorage.removeItem(draftKey) + input.value = '' + actions.showSentLink(status, url) + await actions.refresh() + setTimeout(() => form.remove(), 4000) + } catch (err) { + status.textContent = String(err) + status.className = 'review-comment-status review-comment-err' + } finally { sendBtn.disabled = false } + }) + return form +} diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 413af9a..f36ee5f 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -14,230 +14,37 @@ import { askAi } from '../../ui/askAi' import { techReviewConversationKey, techReviewCheckpointKey } from '../../core/ai/chatHistory' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import { t as i18nT } from '../../i18n' +import { buildReviewAgentControls } from './ReviewAgentControls' +import { buildReviewCommentBubble, buildReviewLineForm } from './ReviewCommentBubble' +import { buildReviewSidebarLists } from './ReviewSidebarLists' +import type { ReviewChangeFile, GhComment, GhPr, SidebarMode, FileTypeFilter } from './reviewFormat' +import { + resolveReviewFollowUpSession, + buildReviewFileManifest, + buildReviewFileBatches, + describeReviewPrState, + describeReviewNoBranchChanges, + filterReviewPrs, + highlightCode, + getFileState, + computeCiStatus, + relativeTime, + wordDiff, + esc, +} from './reviewFormat' + +export { + resolveReviewFollowUpSession, + buildReviewFileManifest, + buildReviewFileBatches, + describeReviewPrState, + describeReviewNoBranchChanges, + filterReviewPrs, +} const REPO_KEY = 'bento.review.repo' const BASE_KEY = 'bento.review.base' -type ReviewChangeFile = ReturnType[0] & { state: 'A' | 'D' | 'M' } - -export function resolveReviewFollowUpSession(reviewRuns: MultiAgentReviewRun[], reviewAgentCount: number): { sessionId: string | null; sessionAgent: AgentType | null } { - const run = reviewRuns - .slice(0, reviewAgentCount) - .reverse() - .find(run => run.sessionId) - return { - sessionId: run?.sessionId ?? null, - sessionAgent: run?.agent ?? null, - } -} - -export function buildReviewFileManifest(files: ReviewChangeFile[]): string { - return files.map(file => `${file.state} ${file.file} (+${file.additions}/-${file.deletions})`).join('\n') -} - -export function buildReviewFileBatches(files: ReviewChangeFile[], maxBatchChars = 12_000): ReviewChangeFile[][] { - if (!files.length) return [] - const batches: ReviewChangeFile[][] = [] - let batch: ReviewChangeFile[] = [] - let chars = 0 - files.forEach(file => { - const nextChars = chars + file.chunk.length - if (batch.length && nextChars > maxBatchChars) { - batches.push(batch) - batch = [] - chars = 0 - } - batch.push(file) - chars += file.chunk.length - }) - if (batch.length) batches.push(batch) - return batches -} - -export function describeReviewPrState(state?: string | null, mergedAt?: string | null): { text: string; cls: string; title: string } | null { - const normalized = (state ?? '').toUpperCase() - const map: Record = { - OPEN: { text: 'Open', cls: 'review-pr-state--open' }, - DRAFT: { text: 'Draft', cls: 'review-pr-state--draft' }, - MERGED: { text: 'Merged', cls: 'review-pr-state--merged' }, - CLOSED: { text: 'Closed', cls: 'review-pr-state--closed' }, - } - const badge = map[normalized] - if (!badge) return null - return { - text: badge.text, - cls: badge.cls, - title: mergedAt ? `Merged at ${new Date(mergedAt).toLocaleString()}` : normalized, - } -} - -export function describeReviewNoBranchChanges(state?: string | null, baseBranch = ''): string { - if ((state ?? '').toUpperCase() === 'MERGED') { - return reviewT('mergedNoBranchChanges', { base: baseBranch }) - } - return reviewT('noBranchChanges', { base: baseBranch }) -} - -export function filterReviewPrs(prs: readonly GhPr[], query: string): GhPr[] { - const q = query.trim().toLowerCase() - if (!q) return [...prs] - return prs.filter(pr => { - const fields = [ - String(pr.number), - pr.title, - pr.author.login, - pr.headRefName, - pr.baseRefName, - pr.state ?? '', - ] - return fields.some(value => value.toLowerCase().includes(q)) - }) -} - -interface GhComment { - id: number - path: string - line: number - body: string - user: { login: string } - html_url: string - created_at?: string -} - -interface GhPr { - number: number - title: string - url: string - headRefName: string - baseRefName: string - author: { login: string } - state?: 'OPEN' | 'CLOSED' | 'MERGED' | string - mergedAt?: string | null -} - -type SidebarMode = 'branches' | 'prs' -type FileTypeFilter = 'all' | 'A' | 'M' | 'D' | 'commented' - -// ── Syntax highlighting ─────────────────────────────────────────────────────── -const KW: Record = { - ts: ['const','let','var','function','return','if','else','for','while','class','import','export','from','default','async','await','new','this','typeof','null','undefined','true','false','void','type','interface','enum','extends','implements','public','private','protected','readonly','static','abstract','switch','case','break','continue','try','catch','finally','throw','delete','in','of','instanceof'], - rs: ['fn','let','mut','const','struct','enum','impl','trait','use','pub','mod','return','if','else','for','while','match','Some','None','Ok','Err','true','false','self','Self','super','crate','async','await','move','where','type','ref','loop','break','continue'], - py: ['def','class','return','if','else','elif','for','while','import','from','as','with','in','not','and','or','is','None','True','False','pass','break','continue','try','except','finally','raise','yield','async','await','lambda','global','nonlocal'], - go: ['func','var','const','return','if','else','for','range','go','select','case','default','break','continue','type','struct','interface','import','package','nil','true','false','defer','make','new','len','cap','chan','map','switch'], - css: ['@import','@media','@keyframes','@font-face','!important'], -} -const EXT_LANG: Record = { - ts:'ts', tsx:'ts', js:'ts', jsx:'ts', mjs:'ts', cjs:'ts', - rs:'rs', py:'py', go:'go', css:'css', scss:'css', -} - -const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>') -const sp = (cls: string, text: string): string => `${esc(text)}` - -function highlightCode(code: string, ext: string): string { - const lang = EXT_LANG[ext.toLowerCase()] - if (!lang) return esc(code) - const kws = new Set(KW[lang] ?? []) - const commentPfx = lang === 'py' ? '#' : '//' - const result: string[] = [] - let i = 0 - while (i < code.length) { - if (code.startsWith(commentPfx, i)) { result.push(sp('comment', code.slice(i))); break } - if (lang !== 'py' && code.startsWith('/*', i)) { - const end = code.indexOf('*/', i + 2) - const s = end === -1 ? code.slice(i) : code.slice(i, end + 2) - result.push(sp('comment', s)); i += s.length; continue - } - const q = code[i] - if (q === '"' || q === "'" || q === '`') { - let j = i + 1 - while (j < code.length) { - if (code[j] === '\\') { j += 2; continue } - if (code[j] === q) { j++; break } - j++ - } - result.push(sp('string', code.slice(i, j))); i = j; continue - } - if (code[i] >= '0' && code[i] <= '9') { - let j = i - while (j < code.length && /[\d._a-zA-Z]/.test(code[j])) j++ - result.push(sp('number', code.slice(i, j))); i = j; continue - } - if (/[a-zA-Z_$]/.test(code[i])) { - let j = i - while (j < code.length && /[\w$]/.test(code[j])) j++ - const word = code.slice(i, j) - result.push(kws.has(word) ? sp('keyword', word) : esc(word)); i = j; continue - } - result.push(esc(code[i])); i++ - } - return result.join('') -} - -// ── File state from diff chunk ──────────────────────────────────────────────── -const getFileState = (chunk: string): 'A' | 'D' | 'M' => { - if (/^new file mode/m.test(chunk)) return 'A' - if (/^deleted file mode/m.test(chunk)) return 'D' - return 'M' -} - -// ── CI status ───────────────────────────────────────────────────────────────── -const computeCiStatus = (rollup: Array<{ conclusion?: string | null; state?: string }>): 'success' | 'failure' | 'pending' | 'none' => { - if (!rollup?.length) return 'none' - const vals = rollup.map(c => (c.conclusion ?? c.state ?? '').toUpperCase()) - if (vals.some(v => ['FAILURE','ERROR','TIMED_OUT','CANCELLED'].includes(v))) return 'failure' - if (vals.some(v => ['PENDING','IN_PROGRESS','QUEUED','WAITING','ACTION_REQUIRED'].includes(v))) return 'pending' - return 'success' -} - -// ── Relative time ───────────────────────────────────────────────────────────── -const relativeTime = (iso: string): string => { - const diff = Date.now() - new Date(iso).getTime() - if (diff < 60000) return 'just now' - const min = Math.floor(diff / 60000) - if (min < 60) return `${min}m ago` - const hr = Math.floor(min / 60) - if (hr < 24) return `${hr}h ago` - return `${Math.floor(hr / 24)}d ago` -} - -// ── Word-level diff ─────────────────────────────────────────────────────────── -const wordDiff = (oldText: string, newText: string): { oldHtml: string; newHtml: string } => { - const tokenize = (s: string): string[] => { - const r: string[] = [] - let i = 0 - while (i < s.length) { - if (/\w/.test(s[i])) { - let j = i; while (j < s.length && /\w/.test(s[j])) j++ - r.push(s.slice(i, j)); i = j - } else { r.push(s[i]); i++ } - } - return r - } - const a = tokenize(oldText), b = tokenize(newText) - if (a.length > 300 || b.length > 300) return { oldHtml: esc(oldText), newHtml: esc(newText) } - const m = a.length, n = b.length - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) - for (let ii = 1; ii <= m; ii++) - for (let jj = 1; jj <= n; jj++) - dp[ii][jj] = a[ii-1] === b[jj-1] ? dp[ii-1][jj-1] + 1 : Math.max(dp[ii-1][jj], dp[ii][jj-1]) - type Op = { t: '='; v: string } | { t: '-'; v: string } | { t: '+'; v: string } - const ops: Op[] = [] - let i = m, j = n - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && a[i-1] === b[j-1]) { ops.unshift({ t: '=', v: a[i-1] }); i--; j-- } - else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { ops.unshift({ t: '+', v: b[j-1] }); j-- } - else { ops.unshift({ t: '-', v: a[i-1] }); i-- } - } - let oldHtml = '', newHtml = '' - for (const op of ops) { - if (op.t === '=') { oldHtml += esc(op.v); newHtml += esc(op.v) } - else if (op.t === '-') oldHtml += `${esc(op.v)}` - else newHtml += `${esc(op.v)}` - } - return { oldHtml, newHtml } -} - export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void; onVisibilityChange?: (visible: boolean) => void } { const root = document.createElement('div') root.className = 'review-panel' @@ -305,104 +112,16 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; const viewedCounterEl = Object.assign(document.createElement('span'), { className: 'review-viewed-counter hidden' }) - const REVIEW_AGENT_KEY = 'bento.review.agent' - const REVIEW_COMPARE_AGENTS_KEY = 'bento.review.compare-agents' - const REVIEW_SECONDARY_AGENT_KEY = 'bento.review.agent.secondary' - const REVIEW_TERTIARY_AGENT_KEY = 'bento.review.agent.tertiary' - const REVIEW_AGENT_TYPES: AgentType[] = ['claude', 'opencode', 'codex'] - const reviewAgentSelect = document.createElement('select') - reviewAgentSelect.className = 'review-agent-select' - ;(['claude', 'opencode', 'codex'] as const).forEach(val => { - reviewAgentSelect.appendChild(Object.assign(document.createElement('option'), { - value: val, textContent: agentLabel(val), - })) - }) - reviewAgentSelect.value = localStorage.getItem(REVIEW_AGENT_KEY) ?? 'claude' - const reviewCompareAgentsToggle = Object.assign(document.createElement('input'), { - type: 'checkbox', - className: 'review-agent-toggle-input', - }) - reviewCompareAgentsToggle.checked = localStorage.getItem(REVIEW_COMPARE_AGENTS_KEY) === '1' - reviewCompareAgentsToggle.dataset.testid = 'review-compare-agents-toggle' - const reviewCompareAgentsLabel = document.createElement('label') - reviewCompareAgentsLabel.className = 'review-agent-toggle' - reviewCompareAgentsLabel.append(reviewCompareAgentsToggle, Object.assign(document.createElement('span'), { - textContent: i18nT('common.reviewCompareAgents'), - })) - const reviewAgentHint = Object.assign(document.createElement('div'), { className: 'review-agent-hint' }) - - const mkOptionalAgentSelect = (value: string | null, testid: string): HTMLSelectElement => { - const select = document.createElement('select') - select.className = 'review-agent-select review-agent-select--optional' - select.dataset.testid = testid - select.appendChild(Object.assign(document.createElement('option'), { value: '', textContent: i18nT('common.reviewAgentNone') })) - REVIEW_AGENT_TYPES.forEach(agent => { - select.appendChild(Object.assign(document.createElement('option'), { value: agent, textContent: agentLabel(agent) })) - }) - select.value = value && REVIEW_AGENT_TYPES.includes(value as AgentType) ? value : '' - return select - } - - const reviewSecondaryAgentSelect = mkOptionalAgentSelect(localStorage.getItem(REVIEW_SECONDARY_AGENT_KEY), 'review-secondary-agent') - const reviewTertiaryAgentSelect = mkOptionalAgentSelect(localStorage.getItem(REVIEW_TERTIARY_AGENT_KEY), 'review-tertiary-agent') - const reviewSecondaryRow = document.createElement('div') - reviewSecondaryRow.className = 'review-agent-extra hidden' - reviewSecondaryRow.append(Object.assign(document.createElement('span'), { className: 'review-agent-extra-label', textContent: i18nT('common.reviewAgentSecondary') }), reviewSecondaryAgentSelect) - const reviewTertiaryRow = document.createElement('div') - reviewTertiaryRow.className = 'review-agent-extra hidden' - reviewTertiaryRow.append(Object.assign(document.createElement('span'), { className: 'review-agent-extra-label', textContent: i18nT('common.reviewAgentTertiary') }), reviewTertiaryAgentSelect) - - const reviewAgentBadge = document.createElement('span') - reviewAgentBadge.className = 'review-agent-badge' - reviewAgentBadge.dataset.testid = 'review-agent-badge' - - const selectedReviewAgents = (): AgentType[] => { - const selected: AgentType[] = [reviewAgentSelect.value as AgentType] - if (!reviewCompareAgentsToggle.checked) return selected - const extras = [reviewSecondaryAgentSelect.value, reviewTertiaryAgentSelect.value] - .filter((value): value is AgentType => REVIEW_AGENT_TYPES.includes(value as AgentType)) - return [...selected, ...extras] - } - - const syncReviewAgentOptionState = (): void => { - // Repeated agents are allowed: the compare UI is only a configuration of - // how many runs to launch, not a uniqueness constraint. - } - - const normalizeReviewAgents = (): void => { - if (!reviewCompareAgentsToggle.checked) return - const primary = reviewAgentSelect.value as AgentType - if (!reviewSecondaryAgentSelect.value) { - reviewSecondaryAgentSelect.value = primary - } - if (!reviewTertiaryAgentSelect.value) reviewTertiaryAgentSelect.value = primary - } - - const syncReviewAgentUi = (): void => { - reviewSecondaryRow.classList.toggle('hidden', !reviewCompareAgentsToggle.checked) - reviewTertiaryRow.classList.toggle('hidden', !reviewCompareAgentsToggle.checked) - normalizeReviewAgents() - syncReviewAgentOptionState() - localStorage.setItem(REVIEW_AGENT_KEY, reviewAgentSelect.value) - localStorage.setItem(REVIEW_COMPARE_AGENTS_KEY, reviewCompareAgentsToggle.checked ? '1' : '0') - if (reviewSecondaryAgentSelect.value) localStorage.setItem(REVIEW_SECONDARY_AGENT_KEY, reviewSecondaryAgentSelect.value) - else localStorage.removeItem(REVIEW_SECONDARY_AGENT_KEY) - if (reviewTertiaryAgentSelect.value) localStorage.setItem(REVIEW_TERTIARY_AGENT_KEY, reviewTertiaryAgentSelect.value) - else localStorage.removeItem(REVIEW_TERTIARY_AGENT_KEY) - const agents = selectedReviewAgents().map(agentLabel) - reviewAgentBadge.textContent = agents.length === 1 - ? i18nT('common.reviewAgentFixed', { agent: agents[0] }) - : i18nT('common.reviewAgentsFixed', { agents: agents.join(' + ') }) - reviewAgentHint.textContent = reviewCompareAgentsToggle.checked - ? reviewT('agentModeHintCombined') - : reviewT('agentModeHintSingle') - } - - reviewCompareAgentsToggle.addEventListener('change', syncReviewAgentUi) - reviewAgentSelect.addEventListener('change', syncReviewAgentUi) - reviewSecondaryAgentSelect.addEventListener('change', syncReviewAgentUi) - reviewTertiaryAgentSelect.addEventListener('change', syncReviewAgentUi) - syncReviewAgentUi() + const { + reviewAgentSelect, + reviewCompareAgentsToggle, + reviewCompareAgentsLabel, + reviewAgentHint, + reviewSecondaryRow, + reviewTertiaryRow, + reviewAgentBadge, + selectedReviewAgents, + } = buildReviewAgentControls() // ── Body: collapsible sidebar (all controls + lists) + free detail ────────── const body = document.createElement('div') @@ -695,269 +414,44 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; } // ── Comment bubble (edit/delete/reply) ──────────────────────────────────── - const buildCommentBubble = (c: GhComment): HTMLElement => { - const bubble = document.createElement('div') - bubble.className = 'review-existing-comment' - bubble.dataset.commentId = String(c.id) - if (resolvedComments.has(c.id)) bubble.classList.add('review-existing-comment--resolved') - - const header = document.createElement('div') - header.className = 'review-existing-comment-header' - const userSpan = Object.assign(document.createElement('span'), { className: 'review-comment-author', textContent: c.user.login }) - const editBtn = Object.assign(document.createElement('button'), { className: 'review-comment-action-btn', textContent: reviewT('editComment') }) - const replyBtn = Object.assign(document.createElement('button'), { className: 'review-comment-action-btn', textContent: reviewT('replyComment') }) - const deleteBtn = Object.assign(document.createElement('button'), { className: 'review-comment-action-btn review-comment-delete-btn', textContent: reviewT('deleteComment') }) - const resolveBtn = Object.assign(document.createElement('button'), { - className: 'review-resolve-btn', - textContent: resolvedComments.has(c.id) ? reviewT('unresolveComment') : reviewT('resolveComment'), - }) - if (c.created_at) { - const timeSpan = Object.assign(document.createElement('span'), { className: 'review-comment-time', textContent: relativeTime(c.created_at) }) - header.append(userSpan, timeSpan, editBtn, replyBtn, deleteBtn, resolveBtn) - } else { - header.append(userSpan, editBtn, replyBtn, deleteBtn, resolveBtn) - } - - const bodyEl = Object.assign(document.createElement('div'), { className: 'review-existing-comment-body', textContent: c.body }) - bubble.append(header, bodyEl) - - bubble.addEventListener('click', e => { - if ((e.target as Element).closest('button')) return - if (bubble.classList.contains('review-existing-comment--resolved')) { - bubble.classList.toggle('review-existing-comment--expanded') - } - }) - resolveBtn.addEventListener('click', () => { - const nowResolved = !resolvedComments.has(c.id) - setCommentResolved(c.id, nowResolved) - bubble.classList.toggle('review-existing-comment--resolved', nowResolved) - bubble.classList.remove('review-existing-comment--expanded') - resolveBtn.textContent = nowResolved ? reviewT('unresolveComment') : reviewT('resolveComment') - }) - - editBtn.addEventListener('click', () => { - if (bubble.querySelector('.review-edit-wrap')) return - const editArea = document.createElement('textarea') - editArea.className = 'review-comment-input' - editArea.value = c.body - editArea.rows = 3 - const actions = document.createElement('div') - actions.className = 'review-line-form-actions' - const saveBtn = Object.assign(document.createElement('button'), { className: 'review-comment-btn', textContent: 'Save' }) - const cancelBtn = Object.assign(document.createElement('button'), { className: 'review-line-cancel-btn', textContent: 'Cancel' }) - actions.append(cancelBtn, saveBtn) - const wrap = document.createElement('div') - wrap.className = 'review-edit-wrap' - wrap.append(editArea, actions) - bodyEl.after(wrap) - bodyEl.classList.add('hidden') - editArea.focus() - cancelBtn.addEventListener('click', () => { wrap.remove(); bodyEl.classList.remove('hidden') }) - saveBtn.addEventListener('click', async () => { - const newBody = editArea.value.trim() - if (!newBody) return - saveBtn.disabled = true - try { - await invoke('gh_pr_update_comment', { path: repoPath, commentId: c.id, body: newBody }) - await loadExistingComments() - injectExistingComments() - } catch (err) { console.error(err) } finally { saveBtn.disabled = false } - }) - }) - - deleteBtn.addEventListener('click', async () => { - if (!confirm(reviewT('deleteConfirm'))) return - try { - await invoke('gh_pr_delete_comment', { path: repoPath, commentId: c.id }) - await loadExistingComments() - injectExistingComments() - } catch (err) { console.error(err) } - }) - - replyBtn.addEventListener('click', () => { - if (bubble.querySelector('.review-reply-wrap')) return - const replyArea = document.createElement('textarea') - replyArea.className = 'review-comment-input' - replyArea.placeholder = reviewT('commentPlaceholder') - replyArea.rows = 2 - 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' }) - actions.append(cancelBtn, sendBtn) - const wrap = document.createElement('div') - wrap.className = 'review-reply-wrap' - wrap.append(replyArea, actions) - bubble.append(wrap) - replyArea.focus() - cancelBtn.addEventListener('click', () => wrap.remove()) - sendBtn.addEventListener('click', async () => { - const body = replyArea.value.trim() - if (!body) return - sendBtn.disabled = true - try { - await invoke('gh_pr_reply_comment', { path: repoPath, commentId: c.id, body }) - await loadExistingComments() - injectExistingComments() - } catch (err) { console.error(err) } finally { sendBtn.disabled = false } - }) - }) - - return bubble - } - - // ── Sidebar: branches ───────────────────────────────────────────────────── - 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 - })) + const commentActions = { + repoPath: () => repoPath, + isResolved: (id: number) => resolvedComments.has(id), + setResolved: setCommentResolved, + refresh: async () => { await loadExistingComments(); injectExistingComments() }, } - branchSearch.addEventListener('input', () => { - if (sidebarMode === 'prs') { renderPrList(); return } - renderBranchList() - }) - - // ── Sidebar: PR list ────────────────────────────────────────────────────── - const renderPrList = (): void => { - const visiblePrs = filterReviewPrs(openPrs, branchSearch.value) - if (!openPrs.length) { - prList.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-pr-list-empty', textContent: reviewT('noPrs') })) - return - } - if (!visiblePrs.length) { - prList.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-pr-list-empty', textContent: reviewT('noMatchingPrs') })) - return - } - prList.replaceChildren(...visiblePrs.map(pr => { - const item = document.createElement('div') - item.className = `review-pr-item${currentPrNumber === pr.number ? ' review-pr-item--active' : ''}` - item.append( - Object.assign(document.createElement('div'), { className: 'review-pr-item-title', textContent: `#${pr.number} ${pr.title}` }), - Object.assign(document.createElement('div'), { className: 'review-pr-item-author', textContent: pr.author.login }), - ) - const stateBadge = describeReviewPrState(pr.state, pr.mergedAt) - if (stateBadge) { - item.append(Object.assign(document.createElement('span'), { - className: `review-pr-item-state ${stateBadge.cls}`, - textContent: stateBadge.text, - title: stateBadge.title, - })) - } - item.addEventListener('click', () => { - const branch = allBranches.find(b => b.endsWith('/' + pr.headRefName)) ?? ('origin/' + pr.headRefName) - // Auto-set base branch from PR's base - const prBase = allBranches.find(b => b.endsWith('/' + pr.baseRefName)) ?? ('origin/' + pr.baseRefName) - baseBranch = prBase - branchInput.value = prBase - localStorage.setItem(BASE_KEY, baseBranch) - selectBranch(branch) - }) - return item - })) - } - - const loadPrList = async (): Promise => { - if (!repoPath) return - try { - openPrs = await invoke('gh_pr_list_open', { path: repoPath }) - if (sidebarMode === 'prs') renderPrList() - } catch { openPrs = [] } - } - - const setSidebarMode = (mode: SidebarMode): void => { - sidebarMode = mode - branchesTab.classList.toggle('review-tab--active', mode === 'branches') - prsTab.classList.toggle('review-tab--active', mode === 'prs') - branchList.classList.toggle('hidden', mode === 'prs') - prList.classList.toggle('hidden', mode === 'branches') - if (mode === 'prs') { renderPrList(); if (!openPrs.length) loadPrList() } - } - branchesTab.addEventListener('click', () => setSidebarMode('branches')) - prsTab.addEventListener('click', () => setSidebarMode('prs')) - - // ── 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() } - } - }) + const buildCommentBubble = (c: GhComment): HTMLElement => buildReviewCommentBubble(c, commentActions) + + const { renderBranchList, renderPrList, loadPrList, setSidebarMode, renderBaseDropdown } = buildReviewSidebarLists( + { branchSearch, branchList, prList, branchesTab, prsTab, branchInput, branchDropdown }, + { + repoPath: () => repoPath, + allBranches: () => allBranches, + selectedBranch: () => selectedBranch, + baseBranch: () => baseBranch, + setBaseBranch: value => { baseBranch = value; localStorage.setItem(BASE_KEY, baseBranch) }, + sidebarMode: () => sidebarMode, + setSidebarModeState: mode => { sidebarMode = mode }, + openPrs: () => openPrs, + setOpenPrs: prs => { openPrs = prs }, + currentPrNumber: () => currentPrNumber, + selectBranch: branch => selectBranch(branch), + loadDiff: () => loadDiff(), + }, + ) const ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') // ── Inline comment form (with draft) ───────────────────────────────────── - 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 draftKey = `bento.review.draft.${repoPath}.${selectedBranch}.${filePath}.${line}` - const saved = localStorage.getItem(draftKey) - if (saved) input.value = saved - input.addEventListener('input', () => { - if (input.value) localStorage.setItem(draftKey, input.value); else localStorage.removeItem(draftKey) - }) - 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 }) - localStorage.removeItem(draftKey) - input.value = '' - showSentLink(status, url) - await loadExistingComments() - injectExistingComments() - 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 lineFormActions = { + repoPath: () => repoPath, + selectedBranch: () => selectedBranch, + currentPrNumber: () => currentPrNumber, + refresh: async () => { await loadExistingComments(); injectExistingComments() }, + showSentLink, } + const makeLineForm = (filePath: string, line: number, startLine?: number): HTMLElement => + buildReviewLineForm(filePath, line, startLine, lineFormActions) // ── Diff renderer ───────────────────────────────────────────────────────── const buildFileDiff = (chunk: string, filePath: string): HTMLElement => { diff --git a/src/panels/review/ReviewSidebarLists.ts b/src/panels/review/ReviewSidebarLists.ts new file mode 100644 index 0000000..532b845 --- /dev/null +++ b/src/panels/review/ReviewSidebarLists.ts @@ -0,0 +1,158 @@ +import { invoke } from '@tauri-apps/api/core' +import { reviewT } from './i18n' +import { describeReviewPrState, filterReviewPrs, type GhPr, type SidebarMode } from './reviewFormat' + +export interface ReviewSidebarRefs { + branchSearch: HTMLInputElement + branchList: HTMLElement + prList: HTMLElement + branchesTab: HTMLButtonElement + prsTab: HTMLButtonElement + branchInput: HTMLInputElement + branchDropdown: HTMLElement +} + +export interface ReviewSidebarState { + repoPath: () => string + allBranches: () => string[] + selectedBranch: () => string + baseBranch: () => string + setBaseBranch: (value: string) => void + sidebarMode: () => SidebarMode + setSidebarModeState: (mode: SidebarMode) => void + openPrs: () => GhPr[] + setOpenPrs: (prs: GhPr[]) => void + currentPrNumber: () => number | null + selectBranch: (branch: string) => void + loadDiff: () => void +} + +export interface ReviewSidebarLists { + renderBranchList: () => void + renderPrList: () => void + loadPrList: () => Promise + setSidebarMode: (mode: SidebarMode) => void + renderBaseDropdown: () => void +} + +export function buildReviewSidebarLists(refs: ReviewSidebarRefs, state: ReviewSidebarState): ReviewSidebarLists { + const { branchSearch, branchList, prList, branchesTab, prsTab, branchInput, branchDropdown } = refs + + // ── Sidebar: branches ───────────────────────────────────────────────────── + const renderBranchList = (): void => { + const q = branchSearch.value.toLowerCase() + const branches = state.allBranches() + const visible = q ? branches.filter(b => b.toLowerCase().includes(q)) : branches + branchList.replaceChildren(...visible.slice(0, 50).map(b => { + const item = Object.assign(document.createElement('div'), { + className: `review-branch-item${b === state.selectedBranch() ? ' review-branch-item--active' : ''}`, + textContent: b, title: b, + }) + item.addEventListener('click', () => { state.selectBranch(b) }) + return item + })) + } + + // ── Sidebar: PR list ────────────────────────────────────────────────────── + const renderPrList = (): void => { + const openPrsList = state.openPrs() + const visiblePrs = filterReviewPrs(openPrsList, branchSearch.value) + if (!openPrsList.length) { + prList.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-pr-list-empty', textContent: reviewT('noPrs') })) + return + } + if (!visiblePrs.length) { + prList.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-pr-list-empty', textContent: reviewT('noMatchingPrs') })) + return + } + prList.replaceChildren(...visiblePrs.map(pr => { + const item = document.createElement('div') + item.className = `review-pr-item${state.currentPrNumber() === pr.number ? ' review-pr-item--active' : ''}` + item.append( + Object.assign(document.createElement('div'), { className: 'review-pr-item-title', textContent: `#${pr.number} ${pr.title}` }), + Object.assign(document.createElement('div'), { className: 'review-pr-item-author', textContent: pr.author.login }), + ) + const stateBadge = describeReviewPrState(pr.state, pr.mergedAt) + if (stateBadge) { + item.append(Object.assign(document.createElement('span'), { + className: `review-pr-item-state ${stateBadge.cls}`, + textContent: stateBadge.text, + title: stateBadge.title, + })) + } + item.addEventListener('click', () => { + const branches = state.allBranches() + const branch = branches.find(b => b.endsWith('/' + pr.headRefName)) ?? ('origin/' + pr.headRefName) + // Auto-set base branch from PR's base + const prBase = branches.find(b => b.endsWith('/' + pr.baseRefName)) ?? ('origin/' + pr.baseRefName) + state.setBaseBranch(prBase) + branchInput.value = prBase + state.selectBranch(branch) + }) + return item + })) + } + + const loadPrList = async (): Promise => { + if (!state.repoPath()) return + try { + const prs = await invoke('gh_pr_list_open', { path: state.repoPath() }) + state.setOpenPrs(prs) + if (state.sidebarMode() === 'prs') renderPrList() + } catch { state.setOpenPrs([]) } + } + + const setSidebarMode = (mode: SidebarMode): void => { + state.setSidebarModeState(mode) + branchesTab.classList.toggle('review-tab--active', mode === 'branches') + prsTab.classList.toggle('review-tab--active', mode === 'prs') + branchList.classList.toggle('hidden', mode === 'prs') + prList.classList.toggle('hidden', mode === 'branches') + if (mode === 'prs') { renderPrList(); if (!state.openPrs().length) loadPrList() } + } + + // ── Base dropdown ───────────────────────────────────────────────────────── + const renderBaseDropdown = (): void => { + const q = branchInput.value.toLowerCase() + const branches = state.allBranches() + const matches = q ? branches.filter(b => b.toLowerCase().includes(q)) : branches + branchDropdown.replaceChildren(...matches.slice(0, 20).map(b => { + const item = Object.assign(document.createElement('div'), { + className: `review-branch-option${b === state.baseBranch() ? ' review-branch-option--active' : ''}`, textContent: b, + }) + item.addEventListener('mousedown', e => { + e.preventDefault() + state.setBaseBranch(b) + branchInput.value = b + branchDropdown.classList.add('hidden') + if (state.selectedBranch()) state.loadDiff() + }) + return item + })) + branchDropdown.classList.toggle('hidden', matches.length === 0) + } + + branchSearch.addEventListener('input', () => { + if (state.sidebarMode() === 'prs') { renderPrList(); return } + renderBranchList() + }) + branchesTab.addEventListener('click', () => setSidebarMode('branches')) + prsTab.addEventListener('click', () => setSidebarMode('prs')) + 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 !== state.baseBranch()) { + state.setBaseBranch(next) + if (state.selectedBranch()) state.loadDiff() + } + } + }) + + return { renderBranchList, renderPrList, loadPrList, setSidebarMode, renderBaseDropdown } +} diff --git a/src/panels/review/reviewFormat.ts b/src/panels/review/reviewFormat.ts new file mode 100644 index 0000000..c6902ef --- /dev/null +++ b/src/panels/review/reviewFormat.ts @@ -0,0 +1,224 @@ +import { parseDiffFiles } from '../diff/diffStats' +import type { AgentType } from '../../core/ai/config' +import type { MultiAgentReviewRun } from '../../core/ai/techReview' +import { reviewT } from './i18n' + +export type ReviewChangeFile = ReturnType[0] & { state: 'A' | 'D' | 'M' } + +export interface GhComment { + id: number + path: string + line: number + body: string + user: { login: string } + html_url: string + created_at?: string +} + +export interface GhPr { + number: number + title: string + url: string + headRefName: string + baseRefName: string + author: { login: string } + state?: 'OPEN' | 'CLOSED' | 'MERGED' | string + mergedAt?: string | null +} + +export type SidebarMode = 'branches' | 'prs' +export type FileTypeFilter = 'all' | 'A' | 'M' | 'D' | 'commented' + +export function resolveReviewFollowUpSession(reviewRuns: MultiAgentReviewRun[], reviewAgentCount: number): { sessionId: string | null; sessionAgent: AgentType | null } { + const run = reviewRuns + .slice(0, reviewAgentCount) + .reverse() + .find(run => run.sessionId) + return { + sessionId: run?.sessionId ?? null, + sessionAgent: run?.agent ?? null, + } +} + +export function buildReviewFileManifest(files: ReviewChangeFile[]): string { + return files.map(file => `${file.state} ${file.file} (+${file.additions}/-${file.deletions})`).join('\n') +} + +export function buildReviewFileBatches(files: ReviewChangeFile[], maxBatchChars = 12_000): ReviewChangeFile[][] { + if (!files.length) return [] + const batches: ReviewChangeFile[][] = [] + let batch: ReviewChangeFile[] = [] + let chars = 0 + files.forEach(file => { + const nextChars = chars + file.chunk.length + if (batch.length && nextChars > maxBatchChars) { + batches.push(batch) + batch = [] + chars = 0 + } + batch.push(file) + chars += file.chunk.length + }) + if (batch.length) batches.push(batch) + return batches +} + +export function describeReviewPrState(state?: string | null, mergedAt?: string | null): { text: string; cls: string; title: string } | null { + const normalized = (state ?? '').toUpperCase() + const map: Record = { + OPEN: { text: 'Open', cls: 'review-pr-state--open' }, + DRAFT: { text: 'Draft', cls: 'review-pr-state--draft' }, + MERGED: { text: 'Merged', cls: 'review-pr-state--merged' }, + CLOSED: { text: 'Closed', cls: 'review-pr-state--closed' }, + } + const badge = map[normalized] + if (!badge) return null + return { + text: badge.text, + cls: badge.cls, + title: mergedAt ? `Merged at ${new Date(mergedAt).toLocaleString()}` : normalized, + } +} + +export function describeReviewNoBranchChanges(state?: string | null, baseBranch = ''): string { + if ((state ?? '').toUpperCase() === 'MERGED') { + return reviewT('mergedNoBranchChanges', { base: baseBranch }) + } + return reviewT('noBranchChanges', { base: baseBranch }) +} + +export function filterReviewPrs(prs: readonly GhPr[], query: string): GhPr[] { + const q = query.trim().toLowerCase() + if (!q) return [...prs] + return prs.filter(pr => { + const fields = [ + String(pr.number), + pr.title, + pr.author.login, + pr.headRefName, + pr.baseRefName, + pr.state ?? '', + ] + return fields.some(value => value.toLowerCase().includes(q)) + }) +} + +// ── Syntax highlighting ─────────────────────────────────────────────────────── +const KW: Record = { + ts: ['const','let','var','function','return','if','else','for','while','class','import','export','from','default','async','await','new','this','typeof','null','undefined','true','false','void','type','interface','enum','extends','implements','public','private','protected','readonly','static','abstract','switch','case','break','continue','try','catch','finally','throw','delete','in','of','instanceof'], + rs: ['fn','let','mut','const','struct','enum','impl','trait','use','pub','mod','return','if','else','for','while','match','Some','None','Ok','Err','true','false','self','Self','super','crate','async','await','move','where','type','ref','loop','break','continue'], + py: ['def','class','return','if','else','elif','for','while','import','from','as','with','in','not','and','or','is','None','True','False','pass','break','continue','try','except','finally','raise','yield','async','await','lambda','global','nonlocal'], + go: ['func','var','const','return','if','else','for','range','go','select','case','default','break','continue','type','struct','interface','import','package','nil','true','false','defer','make','new','len','cap','chan','map','switch'], + css: ['@import','@media','@keyframes','@font-face','!important'], +} +const EXT_LANG: Record = { + ts:'ts', tsx:'ts', js:'ts', jsx:'ts', mjs:'ts', cjs:'ts', + rs:'rs', py:'py', go:'go', css:'css', scss:'css', +} + +export const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>') +export const sp = (cls: string, text: string): string => `${esc(text)}` + +export function highlightCode(code: string, ext: string): string { + const lang = EXT_LANG[ext.toLowerCase()] + if (!lang) return esc(code) + const kws = new Set(KW[lang] ?? []) + const commentPfx = lang === 'py' ? '#' : '//' + const result: string[] = [] + let i = 0 + while (i < code.length) { + if (code.startsWith(commentPfx, i)) { result.push(sp('comment', code.slice(i))); break } + if (lang !== 'py' && code.startsWith('/*', i)) { + const end = code.indexOf('*/', i + 2) + const s = end === -1 ? code.slice(i) : code.slice(i, end + 2) + result.push(sp('comment', s)); i += s.length; continue + } + const q = code[i] + if (q === '"' || q === "'" || q === '`') { + let j = i + 1 + while (j < code.length) { + if (code[j] === '\\') { j += 2; continue } + if (code[j] === q) { j++; break } + j++ + } + result.push(sp('string', code.slice(i, j))); i = j; continue + } + if (code[i] >= '0' && code[i] <= '9') { + let j = i + while (j < code.length && /[\d._a-zA-Z]/.test(code[j])) j++ + result.push(sp('number', code.slice(i, j))); i = j; continue + } + if (/[a-zA-Z_$]/.test(code[i])) { + let j = i + while (j < code.length && /[\w$]/.test(code[j])) j++ + const word = code.slice(i, j) + result.push(kws.has(word) ? sp('keyword', word) : esc(word)); i = j; continue + } + result.push(esc(code[i])); i++ + } + return result.join('') +} + +// ── File state from diff chunk ──────────────────────────────────────────────── +export const getFileState = (chunk: string): 'A' | 'D' | 'M' => { + if (/^new file mode/m.test(chunk)) return 'A' + if (/^deleted file mode/m.test(chunk)) return 'D' + return 'M' +} + +// ── CI status ───────────────────────────────────────────────────────────────── +export const computeCiStatus = (rollup: Array<{ conclusion?: string | null; state?: string }>): 'success' | 'failure' | 'pending' | 'none' => { + if (!rollup?.length) return 'none' + const vals = rollup.map(c => (c.conclusion ?? c.state ?? '').toUpperCase()) + if (vals.some(v => ['FAILURE','ERROR','TIMED_OUT','CANCELLED'].includes(v))) return 'failure' + if (vals.some(v => ['PENDING','IN_PROGRESS','QUEUED','WAITING','ACTION_REQUIRED'].includes(v))) return 'pending' + return 'success' +} + +// ── Relative time ───────────────────────────────────────────────────────────── +export const relativeTime = (iso: string): string => { + const diff = Date.now() - new Date(iso).getTime() + if (diff < 60000) return 'just now' + const min = Math.floor(diff / 60000) + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + return `${Math.floor(hr / 24)}d ago` +} + +// ── Word-level diff ─────────────────────────────────────────────────────────── +export const wordDiff = (oldText: string, newText: string): { oldHtml: string; newHtml: string } => { + const tokenize = (s: string): string[] => { + const r: string[] = [] + let i = 0 + while (i < s.length) { + if (/\w/.test(s[i])) { + let j = i; while (j < s.length && /\w/.test(s[j])) j++ + r.push(s.slice(i, j)); i = j + } else { r.push(s[i]); i++ } + } + return r + } + const a = tokenize(oldText), b = tokenize(newText) + if (a.length > 300 || b.length > 300) return { oldHtml: esc(oldText), newHtml: esc(newText) } + const m = a.length, n = b.length + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) + for (let ii = 1; ii <= m; ii++) + for (let jj = 1; jj <= n; jj++) + dp[ii][jj] = a[ii-1] === b[jj-1] ? dp[ii-1][jj-1] + 1 : Math.max(dp[ii-1][jj], dp[ii][jj-1]) + type Op = { t: '='; v: string } | { t: '-'; v: string } | { t: '+'; v: string } + const ops: Op[] = [] + let i = m, j = n + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i-1] === b[j-1]) { ops.unshift({ t: '=', v: a[i-1] }); i--; j-- } + else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { ops.unshift({ t: '+', v: b[j-1] }); j-- } + else { ops.unshift({ t: '-', v: a[i-1] }); i-- } + } + let oldHtml = '', newHtml = '' + for (const op of ops) { + if (op.t === '=') { oldHtml += esc(op.v); newHtml += esc(op.v) } + else if (op.t === '-') oldHtml += `${esc(op.v)}` + else newHtml += `${esc(op.v)}` + } + return { oldHtml, newHtml } +} diff --git a/tests/panels/review/ReviewAgentControls.test.ts b/tests/panels/review/ReviewAgentControls.test.ts new file mode 100644 index 0000000..86351dc --- /dev/null +++ b/tests/panels/review/ReviewAgentControls.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { buildReviewAgentControls } from '../../../src/panels/review/ReviewAgentControls' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') +} + +describe('buildReviewAgentControls', () => { + it('defaults to a single claude agent selected', () => { + setup() + const { selectedReviewAgents, reviewAgentBadge } = buildReviewAgentControls() + expect(selectedReviewAgents()).toEqual(['claude']) + expect(reviewAgentBadge.textContent).toContain('Claude') + }) + + it('keeps secondary/tertiary rows hidden until compare mode is enabled', () => { + setup() + const { reviewSecondaryRow, reviewTertiaryRow } = buildReviewAgentControls() + expect(reviewSecondaryRow.classList.contains('hidden')).toBe(true) + expect(reviewTertiaryRow.classList.contains('hidden')).toBe(true) + }) + + it('reveals extra agent rows and normalizes them to the primary agent on compare toggle', () => { + setup() + const controls = buildReviewAgentControls() + controls.reviewCompareAgentsToggle.checked = true + controls.reviewCompareAgentsToggle.dispatchEvent(new Event('change')) + + expect(controls.reviewSecondaryRow.classList.contains('hidden')).toBe(false) + expect(controls.reviewTertiaryRow.classList.contains('hidden')).toBe(false) + expect(controls.selectedReviewAgents()).toEqual(['claude', 'claude', 'claude']) + }) + + it('includes explicitly chosen secondary/tertiary agents once compare mode is on', () => { + setup() + const controls = buildReviewAgentControls() + controls.reviewCompareAgentsToggle.checked = true + controls.reviewCompareAgentsToggle.dispatchEvent(new Event('change')) + + const secondary = controls.reviewSecondaryRow.querySelector('select')! + secondary.value = 'opencode' + secondary.dispatchEvent(new Event('change')) + + expect(controls.selectedReviewAgents()).toEqual(['claude', 'opencode', 'claude']) + expect(controls.reviewAgentBadge.textContent).toContain('+') + }) + + it('persists the selected agent to localStorage', () => { + setup() + const { reviewAgentSelect } = buildReviewAgentControls() + reviewAgentSelect.value = 'codex' + reviewAgentSelect.dispatchEvent(new Event('change')) + + expect(localStorage.getItem('bento.review.agent')).toBe('codex') + }) + + it('restores the previously saved agent on the next build', () => { + setup() + localStorage.setItem('bento.review.agent', 'opencode') + const { reviewAgentSelect } = buildReviewAgentControls() + expect(reviewAgentSelect.value).toBe('opencode') + }) +}) diff --git a/tests/panels/review/ReviewCommentBubble.test.ts b/tests/panels/review/ReviewCommentBubble.test.ts new file mode 100644 index 0000000..5866ac3 --- /dev/null +++ b/tests/panels/review/ReviewCommentBubble.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { buildReviewCommentBubble, buildReviewLineForm, type ReviewCommentActions, type ReviewLineFormActions } from '../../../src/panels/review/ReviewCommentBubble' +import type { GhComment } from '../../../src/panels/review/reviewFormat' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +} + +const comment: GhComment = { id: 1, path: 'src/a.ts', line: 3, body: 'hello', user: { login: 'alice' }, html_url: '' } + +describe('buildReviewCommentBubble', () => { + it('renders author and body', () => { + setup() + const actions: ReviewCommentActions = { + repoPath: () => '/repo', + isResolved: () => false, + setResolved: vi.fn(), + refresh: vi.fn(async () => {}), + } + const bubble = buildReviewCommentBubble(comment, actions) + expect(bubble.querySelector('.review-comment-author')?.textContent).toBe('alice') + expect(bubble.querySelector('.review-existing-comment-body')?.textContent).toBe('hello') + expect(bubble.classList.contains('review-existing-comment--resolved')).toBe(false) + }) + + it('toggles resolved state through the resolve button', () => { + setup() + const setResolved = vi.fn() + let resolved = false + const actions: ReviewCommentActions = { + repoPath: () => '/repo', + isResolved: () => resolved, + setResolved: (id, value) => { resolved = value; setResolved(id, value) }, + refresh: vi.fn(async () => {}), + } + const bubble = buildReviewCommentBubble(comment, actions) + const resolveBtn = bubble.querySelector('.review-resolve-btn')! + resolveBtn.click() + expect(setResolved).toHaveBeenCalledWith(1, true) + expect(bubble.classList.contains('review-existing-comment--resolved')).toBe(true) + }) + + it('saves an edit through gh_pr_update_comment and refreshes', async () => { + setup() + const refresh = vi.fn(async () => {}) + const actions: ReviewCommentActions = { repoPath: () => '/repo', isResolved: () => false, setResolved: vi.fn(), refresh } + const bubble = buildReviewCommentBubble(comment, actions) + bubble.querySelectorAll('.review-comment-action-btn')[0].click() + const textarea = bubble.querySelector('.review-edit-wrap textarea')! + textarea.value = 'updated body' + bubble.querySelector('.review-edit-wrap .review-comment-btn')!.click() + await new Promise(r => setTimeout(r, 0)) + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_update_comment', { path: '/repo', commentId: 1, body: 'updated body' }) + expect(refresh).toHaveBeenCalled() + }) + + it('deletes after confirm through gh_pr_delete_comment and refreshes', async () => { + setup() + vi.stubGlobal('confirm', vi.fn(() => true)) + const refresh = vi.fn(async () => {}) + const actions: ReviewCommentActions = { repoPath: () => '/repo', isResolved: () => false, setResolved: vi.fn(), refresh } + const bubble = buildReviewCommentBubble(comment, actions) + bubble.querySelector('.review-comment-delete-btn')!.click() + await new Promise(r => setTimeout(r, 0)) + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_delete_comment', { path: '/repo', commentId: 1 }) + expect(refresh).toHaveBeenCalled() + }) + + it('does not delete when the confirm dialog is dismissed', async () => { + setup() + vi.stubGlobal('confirm', vi.fn(() => false)) + const refresh = vi.fn(async () => {}) + const actions: ReviewCommentActions = { repoPath: () => '/repo', isResolved: () => false, setResolved: vi.fn(), refresh } + const bubble = buildReviewCommentBubble(comment, actions) + bubble.querySelector('.review-comment-delete-btn')!.click() + await new Promise(r => setTimeout(r, 0)) + expect(mocks.invoke).not.toHaveBeenCalled() + expect(refresh).not.toHaveBeenCalled() + }) +}) + +describe('buildReviewLineForm', () => { + function makeActions(overrides: Partial = {}): ReviewLineFormActions { + return { + repoPath: () => '/repo', + selectedBranch: () => 'feat/x', + currentPrNumber: () => 42, + refresh: vi.fn(async () => {}), + showSentLink: vi.fn(), + ...overrides, + } + } + + it('restores a saved draft for the file/line', () => { + setup() + localStorage.setItem('bento.review.draft./repo.feat/x.src/a.ts.5', 'draft text') + const form = buildReviewLineForm('src/a.ts', 5, undefined, makeActions()) + expect(form.querySelector('textarea')!.value).toBe('draft text') + }) + + it('persists typed text as a draft and clears it when emptied', () => { + setup() + const form = buildReviewLineForm('src/a.ts', 5, undefined, makeActions()) + const textarea = form.querySelector('textarea')! + textarea.value = 'work in progress' + textarea.dispatchEvent(new Event('input')) + expect(localStorage.getItem('bento.review.draft./repo.feat/x.src/a.ts.5')).toBe('work in progress') + textarea.value = '' + textarea.dispatchEvent(new Event('input')) + expect(localStorage.getItem('bento.review.draft./repo.feat/x.src/a.ts.5')).toBeNull() + }) + + it('blocks sending when there is no PR for the branch', async () => { + setup() + const actions = makeActions({ currentPrNumber: () => null }) + const form = buildReviewLineForm('src/a.ts', 5, undefined, actions) + form.querySelector('textarea')!.value = 'a comment' + form.querySelector('.review-comment-btn')!.click() + await new Promise(r => setTimeout(r, 0)) + expect(form.querySelector('.review-comment-status')?.textContent).toBe('No PR for this branch') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('sends an inline comment, clears the draft, and reports the link', async () => { + setup() + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'git_rev_parse') return 'commit123' + if (cmd === 'gh_pr_inline_comment') return 'https://example.com/pr/1' + return undefined + }) + const actions = makeActions() + const form = buildReviewLineForm('src/a.ts', 5, 3, actions) + const textarea = form.querySelector('textarea')! + textarea.value = 'a comment' + textarea.dispatchEvent(new Event('input')) + form.querySelector('.review-comment-btn')!.click() + await new Promise(r => setTimeout(r, 0)) + + expect(mocks.invoke).toHaveBeenCalledWith('git_rev_parse', { path: '/repo', reference: 'feat/x' }) + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_inline_comment', { + path: '/repo', prNumber: 42, commitId: 'commit123', file: 'src/a.ts', line: 5, startLine: 3, body: 'a comment', + }) + expect(localStorage.getItem('bento.review.draft./repo.feat/x.src/a.ts.5')).toBeNull() + expect(actions.showSentLink).toHaveBeenCalledWith(expect.anything(), 'https://example.com/pr/1') + expect(actions.refresh).toHaveBeenCalled() + }) +}) diff --git a/tests/panels/review/ReviewSidebarLists.test.ts b/tests/panels/review/ReviewSidebarLists.test.ts new file mode 100644 index 0000000..c31f4cd --- /dev/null +++ b/tests/panels/review/ReviewSidebarLists.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => [] as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { buildReviewSidebarLists, type ReviewSidebarState } from '../../../src/panels/review/ReviewSidebarLists' +import type { GhPr, SidebarMode } from '../../../src/panels/review/reviewFormat' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue([]) +} + +function makeRefs() { + return { + branchSearch: Object.assign(document.createElement('input'), { type: 'text' }), + branchList: document.createElement('div'), + prList: document.createElement('div'), + branchesTab: document.createElement('button'), + prsTab: document.createElement('button'), + branchInput: Object.assign(document.createElement('input'), { type: 'text' }), + branchDropdown: document.createElement('div'), + } +} + +interface Harness { + refs: ReturnType + state: ReviewSidebarState + data: { + repoPath: string + allBranches: string[] + selectedBranch: string + baseBranch: string + sidebarMode: SidebarMode + openPrs: GhPr[] + currentPrNumber: number | null + } + selectBranch: ReturnType + loadDiff: ReturnType +} + +function makeHarness(overrides: Partial = {}): Harness { + const refs = makeRefs() + const data: Harness['data'] = { + repoPath: '/repo', + allBranches: ['origin/main', 'origin/feat/login', 'origin/feat/payments'], + selectedBranch: '', + baseBranch: 'origin/main', + sidebarMode: 'branches', + openPrs: [], + currentPrNumber: null, + ...overrides, + } + const selectBranch = vi.fn() + const loadDiff = vi.fn() + const state: ReviewSidebarState = { + repoPath: () => data.repoPath, + allBranches: () => data.allBranches, + selectedBranch: () => data.selectedBranch, + baseBranch: () => data.baseBranch, + setBaseBranch: value => { data.baseBranch = value }, + sidebarMode: () => data.sidebarMode, + setSidebarModeState: mode => { data.sidebarMode = mode }, + openPrs: () => data.openPrs, + setOpenPrs: prs => { data.openPrs = prs }, + currentPrNumber: () => data.currentPrNumber, + selectBranch, + loadDiff, + } + return { refs, state, data, selectBranch, loadDiff } +} + +describe('renderBranchList', () => { + it('lists all branches and marks the selected one active', () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/main' }) + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.renderBranchList() + const items = h.refs.branchList.querySelectorAll('.review-branch-item') + expect(items).toHaveLength(3) + expect([...items].find(i => i.textContent === 'origin/main')?.classList.contains('review-branch-item--active')).toBe(true) + }) + + it('filters by the search input and selects on click', () => { + setup() + const h = makeHarness() + const lists = buildReviewSidebarLists(h.refs, h.state) + h.refs.branchSearch.value = 'login' + lists.renderBranchList() + const items = h.refs.branchList.querySelectorAll('.review-branch-item') + expect(items).toHaveLength(1) + items[0].click() + expect(h.selectBranch).toHaveBeenCalledWith('origin/feat/login') + }) + + it('re-renders through the search input event, routed by sidebar mode', () => { + setup() + const h = makeHarness() + buildReviewSidebarLists(h.refs, h.state) + h.refs.branchSearch.value = 'payments' + h.refs.branchSearch.dispatchEvent(new Event('input')) + expect(h.refs.branchList.querySelectorAll('.review-branch-item')).toHaveLength(1) + }) +}) + +describe('renderPrList', () => { + const prs: GhPr[] = [ + { number: 1, title: 'Fix login', url: '', headRefName: 'feat/login', baseRefName: 'main', author: { login: 'alice' } }, + { number: 2, title: 'Add payments', url: '', headRefName: 'feat/payments', baseRefName: 'main', author: { login: 'bob' } }, + ] + + it('shows an empty message when there are no PRs at all', () => { + setup() + const h = makeHarness() + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.renderPrList() + expect(h.refs.prList.querySelector('.review-pr-list-empty')?.textContent).toBeTruthy() + }) + + it('shows a no-match message when the search filters everything out', () => { + setup() + const h = makeHarness({ openPrs: prs }) + const lists = buildReviewSidebarLists(h.refs, h.state) + h.refs.branchSearch.value = 'nonexistent' + lists.renderPrList() + expect(h.refs.prList.querySelectorAll('.review-pr-item')).toHaveLength(0) + expect(h.refs.prList.querySelector('.review-pr-list-empty')).not.toBeNull() + }) + + it('renders matching PRs and marks the current one active', () => { + setup() + const h = makeHarness({ openPrs: prs, currentPrNumber: 2 }) + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.renderPrList() + const items = h.refs.prList.querySelectorAll('.review-pr-item') + expect(items).toHaveLength(2) + expect(items[1].classList.contains('review-pr-item--active')).toBe(true) + }) + + it('selecting a PR resolves head/base branches and updates base branch', () => { + setup() + const h = makeHarness({ openPrs: prs }) + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.renderPrList() + h.refs.prList.querySelectorAll('.review-pr-item')[0].click() + expect(h.data.baseBranch).toBe('origin/main') + expect(h.selectBranch).toHaveBeenCalledWith('origin/feat/login') + }) +}) + +describe('loadPrList', () => { + it('does nothing without a repo path', async () => { + setup() + const h = makeHarness({ repoPath: '' }) + const lists = buildReviewSidebarLists(h.refs, h.state) + await lists.loadPrList() + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('fetches and stores open PRs', async () => { + setup() + const prs: GhPr[] = [{ number: 5, title: 'X', url: '', headRefName: 'x', baseRefName: 'main', author: { login: 'a' } }] + mocks.invoke.mockResolvedValue(prs) + const h = makeHarness() + const lists = buildReviewSidebarLists(h.refs, h.state) + await lists.loadPrList() + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_list_open', { path: '/repo' }) + expect(h.data.openPrs).toEqual(prs) + }) + + it('clears the PR list on failure instead of throwing', async () => { + setup() + mocks.invoke.mockRejectedValue(new Error('boom')) + const h = makeHarness({ openPrs: [{ number: 1, title: 'x', url: '', headRefName: 'x', baseRefName: 'main', author: { login: 'a' } }] }) + const lists = buildReviewSidebarLists(h.refs, h.state) + await expect(lists.loadPrList()).resolves.toBeUndefined() + expect(h.data.openPrs).toEqual([]) + }) +}) + +describe('setSidebarMode', () => { + it('toggles tab/list visibility and loads PRs on first switch to prs', () => { + setup() + const h = makeHarness() + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.setSidebarMode('prs') + expect(h.data.sidebarMode).toBe('prs') + expect(h.refs.branchesTab.classList.contains('review-tab--active')).toBe(false) + expect(h.refs.prsTab.classList.contains('review-tab--active')).toBe(true) + expect(h.refs.branchList.classList.contains('hidden')).toBe(true) + expect(h.refs.prList.classList.contains('hidden')).toBe(false) + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_list_open', { path: '/repo' }) + }) + + it('does not refetch PRs when some are already loaded', () => { + setup() + const h = makeHarness({ openPrs: [{ number: 1, title: 'x', url: '', headRefName: 'x', baseRefName: 'main', author: { login: 'a' } }] }) + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.setSidebarMode('prs') + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) + +describe('renderBaseDropdown', () => { + it('filters branches by the base input and hides when nothing matches', () => { + setup() + const h = makeHarness() + const lists = buildReviewSidebarLists(h.refs, h.state) + h.refs.branchInput.value = 'zzz' + lists.renderBaseDropdown() + expect(h.refs.branchDropdown.classList.contains('hidden')).toBe(true) + }) + + it('selecting an option sets the base branch and reloads the diff when a branch is active', () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat/login' }) + const lists = buildReviewSidebarLists(h.refs, h.state) + lists.renderBaseDropdown() + const option = h.refs.branchDropdown.querySelector('.review-branch-option')! + option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) + expect(h.data.baseBranch).toBe('origin/main') + expect(h.loadDiff).toHaveBeenCalled() + }) + + it('pressing Enter normalizes colon syntax into the base branch', () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat/login', baseBranch: 'origin/develop' }) + buildReviewSidebarLists(h.refs, h.state) + h.refs.branchInput.value = 'origin:main' + h.refs.branchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + expect(h.refs.branchInput.value).toBe('origin/main') + expect(h.data.baseBranch).toBe('origin/main') + expect(h.loadDiff).toHaveBeenCalled() + }) +}) diff --git a/tests/panels/review/reviewFormat.test.ts b/tests/panels/review/reviewFormat.test.ts new file mode 100644 index 0000000..8ba45bc --- /dev/null +++ b/tests/panels/review/reviewFormat.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { + buildReviewFileBatches, + buildReviewFileManifest, + computeCiStatus, + describeReviewNoBranchChanges, + describeReviewPrState, + esc, + filterReviewPrs, + getFileState, + highlightCode, + relativeTime, + resolveReviewFollowUpSession, + wordDiff, +} from '../../../src/panels/review/reviewFormat' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') +} + +describe('resolveReviewFollowUpSession', () => { + it('keeps follow-up sessions attached to the last real review agent', () => { + expect(resolveReviewFollowUpSession([ + { label: 'Orchestrator', sessionId: 's1' }, + { label: 'Synthesis', sessionId: 's2' }, + { label: 'Verification', sessionId: 'verifier-session' }, + ].map(run => ({ ...run, agent: 'claude' as const })), 2)).toEqual({ sessionId: 's2', sessionAgent: 'claude' }) + }) + + it('returns nulls when no run in scope has a session id', () => { + expect(resolveReviewFollowUpSession([{ label: 'A', agent: 'claude', sessionId: undefined }], 1)) + .toEqual({ sessionId: null, sessionAgent: null }) + }) +}) + +describe('buildReviewFileManifest / buildReviewFileBatches', () => { + const files = [ + { file: 'src/a.ts', additions: 3, deletions: 1, chunk: 'a'.repeat(6), state: 'M' as const }, + { file: 'src/b.ts', additions: 1, deletions: 0, chunk: 'b'.repeat(6), state: 'D' as const }, + ] + + it('builds a complete manifest before batching review files', () => { + expect(buildReviewFileManifest(files)).toBe('M src/a.ts (+3/-1)\nD src/b.ts (+1/-0)') + expect(buildReviewFileBatches(files, 6)).toHaveLength(2) + }) + + it('keeps files under the char budget in the same batch', () => { + expect(buildReviewFileBatches(files, 100)).toHaveLength(1) + }) + + it('returns an empty array for no files', () => { + expect(buildReviewFileBatches([])).toEqual([]) + }) +}) + +describe('describeReviewPrState / describeReviewNoBranchChanges', () => { + it('describes merged PRs explicitly', () => { + expect(describeReviewPrState('MERGED', '2026-08-14T00:00:00Z')).toMatchObject({ + text: 'Merged', + cls: 'review-pr-state--merged', + }) + }) + + it('returns null for an unknown state', () => { + expect(describeReviewPrState('BOGUS', null)).toBeNull() + }) + + it('describes merged branches without the generic no-changes copy', () => { + setup() + expect(describeReviewNoBranchChanges('MERGED', 'origin/main')).toBe('Merged PR has no remaining changes vs origin/main') + }) + + it('falls back to the generic no-changes copy for open branches', () => { + setup() + expect(describeReviewNoBranchChanges('OPEN', 'origin/main')).not.toContain('Merged') + }) +}) + +describe('filterReviewPrs', () => { + const prs = [ + { number: 12, title: 'Fix login flow', headRefName: 'feat/login', baseRefName: 'main', author: { login: 'alice' }, state: 'OPEN' }, + { number: 34, title: 'Refactor payments', headRefName: 'feat/pay', baseRefName: 'main', author: { login: 'bob' }, state: 'MERGED' }, + ] + + it('filters PRs by metadata', () => { + expect(filterReviewPrs(prs, 'pay')).toHaveLength(1) + expect(filterReviewPrs(prs, 'alice')).toHaveLength(1) + expect(filterReviewPrs(prs, 'merged')).toHaveLength(1) + }) + + it('returns all PRs for an empty query', () => { + expect(filterReviewPrs(prs, ' ')).toHaveLength(2) + }) +}) + +describe('getFileState', () => { + it('detects added files', () => { + expect(getFileState('diff --git a/x b/x\nnew file mode 100644\n')).toBe('A') + }) + + it('detects deleted files', () => { + expect(getFileState('diff --git a/x b/x\ndeleted file mode 100644\n')).toBe('D') + }) + + it('defaults to modified', () => { + expect(getFileState('diff --git a/x b/x\n@@ -1 +1 @@\n')).toBe('M') + }) +}) + +describe('computeCiStatus', () => { + it('returns none for an empty rollup', () => { + expect(computeCiStatus([])).toBe('none') + }) + + it('flags failure when any check failed', () => { + expect(computeCiStatus([{ conclusion: 'SUCCESS' }, { conclusion: 'FAILURE' }])).toBe('failure') + }) + + it('flags pending when nothing failed but something is in progress', () => { + expect(computeCiStatus([{ state: 'PENDING' }])).toBe('pending') + }) + + it('is success when every check passed', () => { + expect(computeCiStatus([{ conclusion: 'SUCCESS' }, { conclusion: 'SUCCESS' }])).toBe('success') + }) +}) + +describe('relativeTime', () => { + it('reports just now for very recent timestamps', () => { + expect(relativeTime(new Date().toISOString())).toBe('just now') + }) + + it('reports minutes ago', () => { + expect(relativeTime(new Date(Date.now() - 5 * 60000).toISOString())).toBe('5m ago') + }) +}) + +describe('wordDiff', () => { + it('marks added and removed words at the word level', () => { + const { oldHtml, newHtml } = wordDiff('hello world', 'hello there') + expect(oldHtml).toContain('world') + expect(newHtml).toContain('there') + expect(oldHtml).toContain('hello') + }) + + it('falls back to plain escaped text for very long inputs', () => { + const long = Array.from({ length: 400 }, (_, i) => `w${i}`).join(' ') + const { oldHtml, newHtml } = wordDiff(long, long) + expect(oldHtml).not.toContain(' { + it('escapes html-significant characters', () => { + expect(esc('&"b"')).toBe('<a>&"b"</a>') + }) +}) + +describe('highlightCode', () => { + it('highlights keywords, strings and comments for known extensions', () => { + const html = highlightCode('const x = "hi" // note', 'ts') + expect(html).toContain('sh-keyword">const') + expect(html).toContain('sh-string">"hi"') + expect(html).toContain('sh-comment">// note') + }) + + it('escapes but does not tokenize unknown extensions', () => { + expect(highlightCode('const', 'weirdext')).toBe('<b>const</b>') + }) +}) From 4194de2c6a3397236afacd7c024225af50c611a1 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 22 Aug 2026 22:09:32 +0200 Subject: [PATCH 02/51] refactor: extracted diff view, data loader, and AI-review trigger from ReviewPanel Split the remaining high-coupling pieces out of ReviewPanel.ts: diff rendering (unified+split, file list/tree, filters), git/gh data loading (loadDiff/loadPrInfo/selectBranch/submitReview), and the AI-review orchestration. Each got a characterization test written against its pre-extraction behavior before the move, covering race guards, guard clauses, and the multi-agent synthesis path. ReviewPanel.ts now sits at 612 lines, down from the original 2078. --- src/panels/review/ReviewDiffView.ts | 561 +++++++++ src/panels/review/ReviewPanel.ts | 1143 ++---------------- src/panels/review/reviewAiRun.ts | 363 ++++++ src/panels/review/reviewDataLoader.ts | 317 +++++ tests/panels/review/ReviewDiffView.test.ts | 316 +++++ tests/panels/review/reviewAiRun.test.ts | 278 +++++ tests/panels/review/reviewDataLoader.test.ts | 444 +++++++ 7 files changed, 2371 insertions(+), 1051 deletions(-) create mode 100644 src/panels/review/ReviewDiffView.ts create mode 100644 src/panels/review/reviewAiRun.ts create mode 100644 src/panels/review/reviewDataLoader.ts create mode 100644 tests/panels/review/ReviewDiffView.test.ts create mode 100644 tests/panels/review/reviewAiRun.test.ts create mode 100644 tests/panels/review/reviewDataLoader.test.ts diff --git a/src/panels/review/ReviewDiffView.ts b/src/panels/review/ReviewDiffView.ts new file mode 100644 index 0000000..9a9fa3a --- /dev/null +++ b/src/panels/review/ReviewDiffView.ts @@ -0,0 +1,561 @@ +import { invoke } from '@tauri-apps/api/core' +import { icon } from '../../ui/icons' +import { reviewT } from './i18n' +import type { ReviewChangeFile, GhComment, FileTypeFilter } from './reviewFormat' +import { esc, highlightCode, wordDiff } from './reviewFormat' + +export interface ReviewDiffDom { + diffView: HTMLElement + diffSearchInput: HTMLInputElement + filterBar: HTMLElement +} + +export interface ReviewDiffState { + getLastFiles: () => ReviewChangeFile[] + getTreeView: () => boolean + getSplitView: () => boolean + getExistingComments: () => GhComment[] + getFileTypeFilter: () => FileTypeFilter + setFileTypeFilter: (value: FileTypeFilter) => void + resetFocusedFileIdx: () => void + getViewedFiles: () => Set + setFileViewed: (file: string, viewed: boolean) => void + repoPath: () => string + getCurrentPrNumber: () => number | null + getPrIdentifier: () => string + buildCommentBubble: (c: GhComment) => HTMLElement + makeLineForm: (filePath: string, line: number, startLine?: number) => HTMLElement + updateCommentNav: () => void + showSentLink: (el: HTMLElement, url: string) => void +} + +export interface ReviewDiffView { + renderFiles: () => void + applyVisibility: () => void + injectExistingComments: () => void + updateCommentBadges: () => void +} + +export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): ReviewDiffView { + const { diffView, diffSearchInput, filterBar } = dom + + // ── Diff renderer ───────────────────────────────────────────────────────── + const buildFileDiff = (chunk: string, filePath: string): HTMLElement => { + const container = document.createElement('div') + container.dataset.filepath = filePath + const ext = filePath.split('.').pop() ?? '' + 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 = state.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) + } + + // Parse diff into typed entries for two-pass rendering with word diff + type UEntry = + | { kind: 'hunk'; raw: string } + | { kind: 'meta' } + | { kind: 'add'; lineNo: number; code: string } + | { kind: 'del'; code: string } + | { kind: 'ctx'; lineNo: number; code: string } + + const entries: UEntry[] = [] + 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 + entries.push({ kind: 'hunk', raw }) + } else if (isMeta) { + entries.push({ kind: 'meta' }) + } else if (isDel) { + entries.push({ kind: 'del', code: raw.slice(1) }) + } else if (isAdd) { + entries.push({ kind: 'add', lineNo: ++newLine, code: raw.slice(1) }) + } else { + entries.push({ kind: 'ctx', lineNo: ++newLine, code: raw.slice(1) }) + } + } + + const mkWrap = (lineNo: number | null, prefix: string, codeHtml: string, extraCls: string): HTMLElement => { + const wrap = document.createElement('div') + wrap.className = 'review-diff-line-wrap' + const lineEl = document.createElement('div') + lineEl.className = `tasks-diff-code-line${extraCls ? ' ' + extraCls : ''}` + if (lineNo !== null) { + wrap.dataset.line = String(lineNo) + const capturedLine = lineNo + const addBtn = Object.assign(document.createElement('button'), { + className: 'review-line-comment-btn', textContent: '+', title: `Comment line ${lineNo}`, + }) + 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 = `${lineNo ?? ''}${esc(prefix)}${codeHtml}` + lineEl.append(content); wrap.append(lineEl) + return wrap + } + + let i = 0 + while (i < entries.length) { + const e = entries[i] + if (e.kind === 'meta') { i++; continue } + if (e.kind === 'hunk') { + const hw = document.createElement('div'); hw.className = 'review-diff-line-wrap' + const hl = document.createElement('div'); hl.className = 'tasks-diff-code-line tasks-diff-hunk' + const hc = document.createElement('span') + hc.innerHTML = `${esc(e.raw)}` + hl.append(hc); hw.append(hl); container.append(hw) + i++; continue + } + if (e.kind === 'ctx') { + container.append(mkWrap(e.lineNo, ' ', highlightCode(e.code, ext), '')) + i++; continue + } + // Collect consecutive del then add block, apply word diff for paired lines + const dels: string[] = [] + while (i < entries.length && entries[i].kind === 'del') { dels.push((entries[i] as { kind: 'del'; code: string }).code); i++ } + const adds: { lineNo: number; code: string }[] = [] + while (i < entries.length && entries[i].kind === 'add') { adds.push(entries[i] as { kind: 'add'; lineNo: number; code: string }); i++ } + for (let j = 0; j < dels.length; j++) { + const html = (adds[j] !== undefined) ? wordDiff(dels[j], adds[j].code).oldHtml : highlightCode(dels[j], ext) + container.append(mkWrap(null, '-', html, 'tasks-diff-line-del')) + } + for (let j = 0; j < adds.length; j++) { + const html = (dels[j] !== undefined) ? wordDiff(dels[j], adds[j].code).newHtml : highlightCode(adds[j].code, ext) + container.append(mkWrap(adds[j].lineNo, '+', html, 'tasks-diff-line-add')) + } + } + return container + } + + // ── Side-by-side diff renderer ──────────────────────────────────────────── + const buildFileDiffSideBySide = (chunk: string, filePath: string): HTMLElement => { + const container = document.createElement('div') + container.className = 'review-split-diff' + container.dataset.filepath = filePath + const ext = filePath.split('.').pop() ?? '' + + type DiffEntry = + | { kind: 'hunk'; text: string } + | { kind: 'meta' } + | { kind: 'context'; oldNo: number; newNo: number; text: string } + | { kind: 'del'; oldNo: number; text: string } + | { kind: 'add'; newNo: number; text: string } + + const entries: DiffEntry[] = [] + let oldLine = 0, 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) { oldLine = parseInt(m[1]) - 1; newLine = parseInt(m[2]) - 1 } + entries.push({ kind: 'hunk', text: raw }) + } else if (isMeta) { + entries.push({ kind: 'meta' }) + } else if (isDel) { + entries.push({ kind: 'del', oldNo: ++oldLine, text: raw.slice(1) }) + } else if (isAdd) { + entries.push({ kind: 'add', newNo: ++newLine, text: raw.slice(1) }) + } else { + entries.push({ kind: 'context', oldNo: ++oldLine, newNo: ++newLine, text: raw }) + } + } + + // Drag-to-select (right side only) + 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 row = anchorWrap.closest('.review-split-row') ?? anchorWrap + const form = state.makeLineForm(filePath, hi, lo < hi ? lo : undefined) + row.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) + } + + const mkRightCell = (lineNo: number, text: string, extraCls: string, preHtml?: string): HTMLElement => { + const cell = document.createElement('div') + cell.className = `review-split-cell review-split-cell--right ${extraCls}` + cell.dataset.line = String(lineNo) + const addBtn = Object.assign(document.createElement('button'), { + className: 'review-line-comment-btn', textContent: '+', title: `Comment line ${lineNo}`, + }) + const cap = lineNo + addBtn.addEventListener('mousedown', e => { + e.preventDefault(); dragStart = cap + highlightRange(cap, cap) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }) + cell.innerHTML = `${lineNo}${preHtml ?? highlightCode(text, ext)}` + cell.prepend(addBtn) + return cell + } + + let i = 0 + while (i < entries.length) { + const entry = entries[i] + if (entry.kind === 'meta') { i++; continue } + if (entry.kind === 'hunk') { + const hunkEl = Object.assign(document.createElement('div'), { className: 'review-split-hunk', textContent: entry.text }) + container.append(hunkEl); i++; continue + } + if (entry.kind === 'context') { + const row = document.createElement('div') + row.className = 'review-split-row' + const left = document.createElement('div') + left.className = 'review-split-cell review-split-cell--left' + left.innerHTML = `${entry.oldNo}${highlightCode(entry.text, ext)}` + row.append(left, mkRightCell(entry.newNo, entry.text, '')) + container.append(row); i++; continue + } + // del/add block: collect and pair + const dels: Array<{ kind: 'del'; oldNo: number; text: string }> = [] + const adds: Array<{ kind: 'add'; newNo: number; text: string }> = [] + while (i < entries.length && entries[i].kind === 'del') { + dels.push(entries[i] as { kind: 'del'; oldNo: number; text: string }); i++ + } + while (i < entries.length && entries[i].kind === 'add') { + adds.push(entries[i] as { kind: 'add'; newNo: number; text: string }); i++ + } + for (let j = 0; j < Math.max(dels.length, adds.length); j++) { + const del = dels[j], add = adds[j] + const wdiff = (del && add) ? wordDiff(del.text, add.text) : null + const row = document.createElement('div') + row.className = 'review-split-row' + const left = document.createElement('div') + if (del) { + left.className = 'review-split-cell review-split-cell--left review-split-cell--del' + left.innerHTML = `${del.oldNo}${wdiff ? wdiff.oldHtml : highlightCode(del.text, ext)}` + } else { + left.className = 'review-split-cell review-split-cell--left review-split-cell--empty' + } + const right = add + ? mkRightCell(add.newNo, add.text, 'review-split-cell--add', wdiff?.newHtml) + : Object.assign(document.createElement('div'), { className: 'review-split-cell review-split-cell--right review-split-cell--empty' }) + row.append(left, right) + container.append(row) + } + } + return container + } + + // ── Build a file
element ──────────────────────────────────────── + const makeFileDetails = (f: ReviewChangeFile): HTMLDetailsElement => { + const viewedSet = state.getViewedFiles() + const details = document.createElement('details') + details.className = 'review-file-detail' + details.dataset.filestate = f.state + details.dataset.filename = f.file + details.open = state.getLastFiles().length <= 5 + details.classList.toggle('review-file-viewed', viewedSet.has(f.file)) + + const viewedCb = document.createElement('input') + viewedCb.type = 'checkbox'; viewedCb.className = 'review-viewed-cb' + viewedCb.checked = viewedSet.has(f.file); viewedCb.title = reviewT('viewed') + viewedCb.addEventListener('click', e => e.stopPropagation()) + viewedCb.addEventListener('change', e => { + e.stopPropagation() + state.setFileViewed(f.file, viewedCb.checked) + details.classList.toggle('review-file-viewed', viewedCb.checked) + if (viewedCb.checked) details.open = false + }) + + const stateTag = Object.assign(document.createElement('span'), { + className: `review-file-state review-file-state--${f.state.toLowerCase()}`, textContent: f.state, + }) + const nameEl = Object.assign(document.createElement('span'), { + className: 'review-file-name', textContent: f.file, title: reviewT('copyPath'), + }) + nameEl.addEventListener('click', e => { + e.stopPropagation() + navigator.clipboard.writeText(f.file).then(() => { + nameEl.textContent = '✓ copied' + setTimeout(() => { nameEl.textContent = f.file }, 1500) + }).catch(() => {}) + }) + const editorBtn = Object.assign(document.createElement('button'), { + className: 'review-editor-btn review-icon-btn', title: reviewT('openInEditor'), innerHTML: icon('edit'), + }) + editorBtn.addEventListener('click', e => { + e.stopPropagation() + invoke('open_in_editor', { path: `${state.repoPath()}/${f.file}` }).catch(() => {}) + }) + const statsEl = document.createElement('span') + statsEl.className = 'review-file-stats' + statsEl.append( + Object.assign(document.createElement('span'), { className: 'review-stat-add', textContent: `+${f.additions}` }), + Object.assign(document.createElement('span'), { className: 'review-stat-del', textContent: `-${f.deletions}` }), + ) + + const fileCommentCount = state.getExistingComments().filter(c => c.path === f.file).length + const commentBadge = Object.assign(document.createElement('span'), { + className: `review-comment-badge${fileCommentCount === 0 ? ' hidden' : ''}`, + textContent: fileCommentCount > 0 ? `💬 ${fileCommentCount}` : '', + title: `${fileCommentCount} comment${fileCommentCount !== 1 ? 's' : ''}`, + }) + commentBadge.addEventListener('click', e => { + e.stopPropagation() + details.open = true + requestAnimationFrame(() => { + const first = details.querySelector('.review-existing-comment') + first?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }) + }) + + const fileCommentBtn = Object.assign(document.createElement('button'), { + className: 'review-file-comment-btn', title: reviewT('fileComment'), textContent: '💬', + }) + fileCommentBtn.addEventListener('click', e => { + e.stopPropagation() + if (details.querySelector('.review-file-comment-form')) return + const form = document.createElement('div') + form.className = 'review-file-comment-form' + const ta = document.createElement('textarea') + ta.className = 'review-comment-input'; ta.placeholder = reviewT('commentPlaceholder'); ta.rows = 2 + const acts = document.createElement('div'); acts.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 st = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) + acts.append(cancelBtn, sendBtn, st); form.append(ta, acts) + cancelBtn.addEventListener('click', () => form.remove()) + sendBtn.addEventListener('click', async () => { + const body = ta.value.trim() + if (!body || state.getCurrentPrNumber() === null) return + sendBtn.disabled = true + try { + const url = await invoke('gh_pr_comment', { path: state.repoPath(), branch: state.getPrIdentifier(), body: `**${f.file}**\n\n${body}` }) + ta.value = ''; state.showSentLink(st, url) + setTimeout(() => form.remove(), 4000) + } catch (err) { + st.textContent = String(err); st.className = 'review-comment-status review-comment-err' + } finally { sendBtn.disabled = false } + }) + sum.after(form); ta.focus() + }) + + const sum = document.createElement('summary') + sum.className = 'review-file-summary' + sum.append(viewedCb, stateTag, nameEl, commentBadge, editorBtn, fileCommentBtn, statsEl) + details.append(sum, state.getSplitView() ? buildFileDiffSideBySide(f.chunk, f.file) : buildFileDiff(f.chunk, f.file)) + return details + } + + // ── Render files (flat or tree) ─────────────────────────────────────────── + const renderFiles = (): void => { + state.resetFocusedFileIdx() + const lastFiles = state.getLastFiles() + if (!state.getTreeView()) { + diffView.replaceChildren(...lastFiles.map(f => makeFileDetails(f))) + } else { + const dirs = new Map() + for (const f of lastFiles) { + const parts = f.file.split('/') + const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '' + const grp = dirs.get(dir) ?? []; grp.push(f); dirs.set(dir, grp) + } + const sorted = [...dirs.entries()].sort(([a], [b]) => a.localeCompare(b)) + diffView.replaceChildren(...sorted.flatMap(([dir, files]) => { + const nodes: HTMLElement[] = [] + if (dir) { + nodes.push(Object.assign(document.createElement('div'), { className: 'review-tree-dir-name', textContent: dir + '/' })) + } + nodes.push(...files.map(f => makeFileDetails(f))) + return nodes + })) + } + applyVisibility() + renderFilterBar() + } + + // ── Search + filter visibility ──────────────────────────────────────────── + const applyVisibility = (): void => { + const q = diffSearchInput.value.toLowerCase() + const commentedPaths = new Set(state.getExistingComments().map(c => c.path)) + const fileTypeFilter = state.getFileTypeFilter() + diffView.querySelectorAll('.review-file-detail').forEach(el => { + const s = el.dataset.filestate ?? 'M' + const filename = el.dataset.filename ?? '' + const isCommentedFilter = fileTypeFilter === 'commented' + const failsType = !isCommentedFilter && fileTypeFilter !== 'all' && s !== fileTypeFilter + const failsCommented = isCommentedFilter && !commentedPaths.has(filename) + const failsSearch = q !== '' && !filename.toLowerCase().includes(q) + el.classList.toggle('hidden', failsType || failsCommented || failsSearch) + }) + } + + diffSearchInput.addEventListener('input', applyVisibility) + + // ── Filter bar ──────────────────────────────────────────────────────────── + const renderFilterBar = (): void => { + const lastFiles = state.getLastFiles() + const counts = { A: 0, M: 0, D: 0 } + lastFiles.forEach(f => { counts[f.state]++ }) + const total = lastFiles.length + if (total === 0) { filterBar.classList.add('hidden'); return } + filterBar.classList.remove('hidden') + const mkBtn = (label: string, value: FileTypeFilter): HTMLButtonElement => { + const btn = Object.assign(document.createElement('button'), { + className: `review-filter-btn${state.getFileTypeFilter() === value ? ' review-filter-btn--active' : ''}`, textContent: label, + }) + btn.addEventListener('click', () => { + state.setFileTypeFilter(value) + filterBar.querySelectorAll('.review-filter-btn').forEach(b => b.classList.remove('review-filter-btn--active')) + btn.classList.add('review-filter-btn--active') + applyVisibility() + }) + return btn + } + const commentedPaths = new Set(state.getExistingComments().map(c => c.path)) + const commentedCount = lastFiles.filter(f => commentedPaths.has(f.file)).length + if (state.getFileTypeFilter() === 'commented' && commentedCount === 0) state.setFileTypeFilter('all') + const filterBtns: HTMLButtonElement[] = [ + mkBtn(`All ${total}`, 'all'), + mkBtn(`+${counts.A} Added`, 'A'), + mkBtn(`~${counts.M} Modified`, 'M'), + mkBtn(`−${counts.D} Deleted`, 'D'), + ] + if (commentedCount > 0) filterBtns.push(mkBtn(`💬 ${commentedCount}`, 'commented')) + filterBar.replaceChildren(...filterBtns) + } + + // ── Update comment badges on file headers ──────────────────────────────── + const updateCommentBadges = (): void => { + diffView.querySelectorAll('.review-file-detail').forEach(el => { + const filename = el.dataset.filename ?? '' + const count = state.getExistingComments().filter(c => c.path === filename).length + const badge = el.querySelector('.review-comment-badge') + if (!badge) return + if (count > 0) { + badge.textContent = `💬 ${count}` + badge.title = `${count} comment${count !== 1 ? 's' : ''}` + badge.classList.remove('hidden') + } else { + badge.classList.add('hidden') + } + }) + renderFilterBar() + } + + // ── Inject existing PR comments ─────────────────────────────────────────── + const injectExistingComments = (): void => { + diffView.querySelectorAll('.review-existing-comment').forEach(el => el.remove()) + diffView.querySelectorAll('.review-comment-orphans').forEach(el => el.remove()) + const fileContainers = [...diffView.querySelectorAll('[data-filepath]')] + const orphans = new Map() + + for (const c of state.getExistingComments()) { + const fileContainer = fileContainers.find(el => el.dataset.filepath === c.path) + if (!fileContainer) continue + const lineWrap = fileContainer.querySelector(`[data-line="${c.line}"]`) + if (lineWrap) { + // Line is visible in the diff — inject inline + const insertAnchor = lineWrap.closest('.review-split-row') ?? lineWrap + insertAnchor.after(state.buildCommentBubble(c)) + } else { + // Line not in diff context — collect as orphan to show at file bottom + const list = orphans.get(fileContainer) ?? [] + list.push(c) + orphans.set(fileContainer, list) + } + } + + // Append orphan comments at the bottom of their file diff + for (const [container, comments] of orphans) { + const section = document.createElement('div') + section.className = 'review-comment-orphans' + for (const c of comments) { + const bubble = state.buildCommentBubble(c) + const lineNote = Object.assign(document.createElement('div'), { + className: 'review-orphan-line-note', + textContent: `Line ${c.line} · ${c.path.split('/').pop()}`, + }) + bubble.prepend(lineNote) + section.append(bubble) + } + container.append(section) + } + + updateCommentBadges() + state.updateCommentNav() + } + + return { renderFiles, applyVisibility, injectExistingComments, updateCommentBadges } +} diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index f36ee5f..4974374 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -1,15 +1,12 @@ 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' import { renderMarkdown } from '../../core/notes/renderMarkdown' import { getUiZoom, toLayoutPixels } from '../../ui/zoom' -import { redact, startAgent } from '../../core/ai/agentClient' -import { agentLabel, type AgentType } from '../../core/ai/config' -import { buildReviewPrompt, buildReviewSynthesisPrompt, buildReviewDocument, parseReviewCheckpoint, isRetryableReviewError, createContextProvider, type MultiAgentReviewRun } from '../../core/ai/techReview' +import type { AgentType } from '../../core/ai/config' +import { parseReviewCheckpoint } from '../../core/ai/techReview' import { askAi } from '../../ui/askAi' import { techReviewConversationKey, techReviewCheckpointKey } from '../../core/ai/chatHistory' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' @@ -17,7 +14,10 @@ import { t as i18nT } from '../../i18n' import { buildReviewAgentControls } from './ReviewAgentControls' import { buildReviewCommentBubble, buildReviewLineForm } from './ReviewCommentBubble' import { buildReviewSidebarLists } from './ReviewSidebarLists' -import type { ReviewChangeFile, GhComment, GhPr, SidebarMode, FileTypeFilter } from './reviewFormat' +import { buildReviewDiffView } from './ReviewDiffView' +import { buildReviewDataLoader } from './reviewDataLoader' +import { buildReviewAiRun } from './reviewAiRun' +import type { GhComment, GhPr, SidebarMode, FileTypeFilter } from './reviewFormat' import { resolveReviewFollowUpSession, buildReviewFileManifest, @@ -25,11 +25,6 @@ import { describeReviewPrState, describeReviewNoBranchChanges, filterReviewPrs, - highlightCode, - getFileState, - computeCiStatus, - relativeTime, - wordDiff, esc, } from './reviewFormat' @@ -422,7 +417,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; } const buildCommentBubble = (c: GhComment): HTMLElement => buildReviewCommentBubble(c, commentActions) - const { renderBranchList, renderPrList, loadPrList, setSidebarMode, renderBaseDropdown } = buildReviewSidebarLists( + const { renderBranchList, renderPrList, loadPrList } = buildReviewSidebarLists( { branchSearch, branchList, prList, branchesTab, prsTab, branchInput, branchDropdown }, { repoPath: () => repoPath, @@ -440,8 +435,6 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; }, ) - const ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') - // ── Inline comment form (with draft) ───────────────────────────────────── const lineFormActions = { repoPath: () => repoPath, @@ -453,690 +446,76 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; const makeLineForm = (filePath: string, line: number, startLine?: number): HTMLElement => buildReviewLineForm(filePath, line, startLine, lineFormActions) - // ── Diff renderer ───────────────────────────────────────────────────────── - const buildFileDiff = (chunk: string, filePath: string): HTMLElement => { - const container = document.createElement('div') - container.dataset.filepath = filePath - const ext = filePath.split('.').pop() ?? '' - 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) - } - - // Parse diff into typed entries for two-pass rendering with word diff - type UEntry = - | { kind: 'hunk'; raw: string } - | { kind: 'meta' } - | { kind: 'add'; lineNo: number; code: string } - | { kind: 'del'; code: string } - | { kind: 'ctx'; lineNo: number; code: string } - - const entries: UEntry[] = [] - 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 - entries.push({ kind: 'hunk', raw }) - } else if (isMeta) { - entries.push({ kind: 'meta' }) - } else if (isDel) { - entries.push({ kind: 'del', code: raw.slice(1) }) - } else if (isAdd) { - entries.push({ kind: 'add', lineNo: ++newLine, code: raw.slice(1) }) - } else { - entries.push({ kind: 'ctx', lineNo: ++newLine, code: raw.slice(1) }) - } - } - - const mkWrap = (lineNo: number | null, prefix: string, codeHtml: string, extraCls: string): HTMLElement => { - const wrap = document.createElement('div') - wrap.className = 'review-diff-line-wrap' - const lineEl = document.createElement('div') - lineEl.className = `tasks-diff-code-line${extraCls ? ' ' + extraCls : ''}` - if (lineNo !== null) { - wrap.dataset.line = String(lineNo) - const capturedLine = lineNo - const addBtn = Object.assign(document.createElement('button'), { - className: 'review-line-comment-btn', textContent: '+', title: `Comment line ${lineNo}`, - }) - 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 = `${lineNo ?? ''}${esc(prefix)}${codeHtml}` - lineEl.append(content); wrap.append(lineEl) - return wrap - } - - let i = 0 - while (i < entries.length) { - const e = entries[i] - if (e.kind === 'meta') { i++; continue } - if (e.kind === 'hunk') { - const hw = document.createElement('div'); hw.className = 'review-diff-line-wrap' - const hl = document.createElement('div'); hl.className = 'tasks-diff-code-line tasks-diff-hunk' - const hc = document.createElement('span') - hc.innerHTML = `${esc(e.raw)}` - hl.append(hc); hw.append(hl); container.append(hw) - i++; continue - } - if (e.kind === 'ctx') { - container.append(mkWrap(e.lineNo, ' ', highlightCode(e.code, ext), '')) - i++; continue - } - // Collect consecutive del then add block, apply word diff for paired lines - const dels: string[] = [] - while (i < entries.length && entries[i].kind === 'del') { dels.push((entries[i] as { kind: 'del'; code: string }).code); i++ } - const adds: { lineNo: number; code: string }[] = [] - while (i < entries.length && entries[i].kind === 'add') { adds.push(entries[i] as { kind: 'add'; lineNo: number; code: string }); i++ } - for (let j = 0; j < dels.length; j++) { - const html = (adds[j] !== undefined) ? wordDiff(dels[j], adds[j].code).oldHtml : highlightCode(dels[j], ext) - container.append(mkWrap(null, '-', html, 'tasks-diff-line-del')) - } - for (let j = 0; j < adds.length; j++) { - const html = (dels[j] !== undefined) ? wordDiff(dels[j], adds[j].code).newHtml : highlightCode(adds[j].code, ext) - container.append(mkWrap(adds[j].lineNo, '+', html, 'tasks-diff-line-add')) - } - } - return container - } - - // ── Side-by-side diff renderer ──────────────────────────────────────────── - const buildFileDiffSideBySide = (chunk: string, filePath: string): HTMLElement => { - const container = document.createElement('div') - container.className = 'review-split-diff' - container.dataset.filepath = filePath - const ext = filePath.split('.').pop() ?? '' - - type DiffEntry = - | { kind: 'hunk'; text: string } - | { kind: 'meta' } - | { kind: 'context'; oldNo: number; newNo: number; text: string } - | { kind: 'del'; oldNo: number; text: string } - | { kind: 'add'; newNo: number; text: string } - - const entries: DiffEntry[] = [] - let oldLine = 0, 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) { oldLine = parseInt(m[1]) - 1; newLine = parseInt(m[2]) - 1 } - entries.push({ kind: 'hunk', text: raw }) - } else if (isMeta) { - entries.push({ kind: 'meta' }) - } else if (isDel) { - entries.push({ kind: 'del', oldNo: ++oldLine, text: raw.slice(1) }) - } else if (isAdd) { - entries.push({ kind: 'add', newNo: ++newLine, text: raw.slice(1) }) - } else { - entries.push({ kind: 'context', oldNo: ++oldLine, newNo: ++newLine, text: raw }) - } - } - - // Drag-to-select (right side only) - 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 row = anchorWrap.closest('.review-split-row') ?? anchorWrap - const form = makeLineForm(filePath, hi, lo < hi ? lo : undefined) - row.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) - } - - const mkRightCell = (lineNo: number, text: string, extraCls: string, preHtml?: string): HTMLElement => { - const cell = document.createElement('div') - cell.className = `review-split-cell review-split-cell--right ${extraCls}` - cell.dataset.line = String(lineNo) - const addBtn = Object.assign(document.createElement('button'), { - className: 'review-line-comment-btn', textContent: '+', title: `Comment line ${lineNo}`, - }) - const cap = lineNo - addBtn.addEventListener('mousedown', e => { - e.preventDefault(); dragStart = cap - highlightRange(cap, cap) - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('mouseup', onMouseUp) - }) - cell.innerHTML = `${lineNo}${preHtml ?? highlightCode(text, ext)}` - cell.prepend(addBtn) - return cell - } - - let i = 0 - while (i < entries.length) { - const entry = entries[i] - if (entry.kind === 'meta') { i++; continue } - if (entry.kind === 'hunk') { - const hunkEl = Object.assign(document.createElement('div'), { className: 'review-split-hunk', textContent: entry.text }) - container.append(hunkEl); i++; continue - } - if (entry.kind === 'context') { - const row = document.createElement('div') - row.className = 'review-split-row' - const left = document.createElement('div') - left.className = 'review-split-cell review-split-cell--left' - left.innerHTML = `${entry.oldNo}${highlightCode(entry.text, ext)}` - row.append(left, mkRightCell(entry.newNo, entry.text, '')) - container.append(row); i++; continue - } - // del/add block: collect and pair - const dels: Array<{ kind: 'del'; oldNo: number; text: string }> = [] - const adds: Array<{ kind: 'add'; newNo: number; text: string }> = [] - while (i < entries.length && entries[i].kind === 'del') { - dels.push(entries[i] as { kind: 'del'; oldNo: number; text: string }); i++ - } - while (i < entries.length && entries[i].kind === 'add') { - adds.push(entries[i] as { kind: 'add'; newNo: number; text: string }); i++ - } - for (let j = 0; j < Math.max(dels.length, adds.length); j++) { - const del = dels[j], add = adds[j] - const wdiff = (del && add) ? wordDiff(del.text, add.text) : null - const row = document.createElement('div') - row.className = 'review-split-row' - const left = document.createElement('div') - if (del) { - left.className = 'review-split-cell review-split-cell--left review-split-cell--del' - left.innerHTML = `${del.oldNo}${wdiff ? wdiff.oldHtml : highlightCode(del.text, ext)}` - } else { - left.className = 'review-split-cell review-split-cell--left review-split-cell--empty' - } - const right = add - ? mkRightCell(add.newNo, add.text, 'review-split-cell--add', wdiff?.newHtml) - : Object.assign(document.createElement('div'), { className: 'review-split-cell review-split-cell--right review-split-cell--empty' }) - row.append(left, right) - container.append(row) - } - } - return container - } - - // ── Build a file
element ──────────────────────────────────────── - const makeFileDetails = (f: typeof lastFiles[0]): HTMLDetailsElement => { - const viewedSet = getViewedFiles() - const details = document.createElement('details') - details.className = 'review-file-detail' - details.dataset.filestate = f.state - details.dataset.filename = f.file - details.open = lastFiles.length <= 5 - details.classList.toggle('review-file-viewed', viewedSet.has(f.file)) - - const viewedCb = document.createElement('input') - viewedCb.type = 'checkbox'; viewedCb.className = 'review-viewed-cb' - viewedCb.checked = viewedSet.has(f.file); viewedCb.title = reviewT('viewed') - viewedCb.addEventListener('click', e => e.stopPropagation()) - viewedCb.addEventListener('change', e => { - e.stopPropagation() - setFileViewed(f.file, viewedCb.checked) - details.classList.toggle('review-file-viewed', viewedCb.checked) - if (viewedCb.checked) details.open = false - }) - - const stateTag = Object.assign(document.createElement('span'), { - className: `review-file-state review-file-state--${f.state.toLowerCase()}`, textContent: f.state, - }) - const nameEl = Object.assign(document.createElement('span'), { - className: 'review-file-name', textContent: f.file, title: reviewT('copyPath'), - }) - nameEl.addEventListener('click', e => { - e.stopPropagation() - navigator.clipboard.writeText(f.file).then(() => { - nameEl.textContent = '✓ copied' - setTimeout(() => { nameEl.textContent = f.file }, 1500) - }).catch(() => {}) - }) - const editorBtn = Object.assign(document.createElement('button'), { - className: 'review-editor-btn review-icon-btn', title: reviewT('openInEditor'), innerHTML: icon('edit'), - }) - editorBtn.addEventListener('click', e => { - e.stopPropagation() - invoke('open_in_editor', { path: `${repoPath}/${f.file}` }).catch(() => {}) - }) - const statsEl = document.createElement('span') - statsEl.className = 'review-file-stats' - statsEl.append( - Object.assign(document.createElement('span'), { className: 'review-stat-add', textContent: `+${f.additions}` }), - Object.assign(document.createElement('span'), { className: 'review-stat-del', textContent: `-${f.deletions}` }), - ) - - const fileCommentCount = existingComments.filter(c => c.path === f.file).length - const commentBadge = Object.assign(document.createElement('span'), { - className: `review-comment-badge${fileCommentCount === 0 ? ' hidden' : ''}`, - textContent: fileCommentCount > 0 ? `💬 ${fileCommentCount}` : '', - title: `${fileCommentCount} comment${fileCommentCount !== 1 ? 's' : ''}`, - }) - commentBadge.addEventListener('click', e => { - e.stopPropagation() - details.open = true - requestAnimationFrame(() => { - const first = details.querySelector('.review-existing-comment') - first?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }) - }) - - const fileCommentBtn = Object.assign(document.createElement('button'), { - className: 'review-file-comment-btn', title: reviewT('fileComment'), textContent: '💬', - }) - fileCommentBtn.addEventListener('click', e => { - e.stopPropagation() - if (details.querySelector('.review-file-comment-form')) return - const form = document.createElement('div') - form.className = 'review-file-comment-form' - const ta = document.createElement('textarea') - ta.className = 'review-comment-input'; ta.placeholder = reviewT('commentPlaceholder'); ta.rows = 2 - const acts = document.createElement('div'); acts.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 st = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) - acts.append(cancelBtn, sendBtn, st); form.append(ta, acts) - cancelBtn.addEventListener('click', () => form.remove()) - sendBtn.addEventListener('click', async () => { - const body = ta.value.trim() - if (!body || currentPrNumber === null) return - sendBtn.disabled = true - try { - const url = await invoke('gh_pr_comment', { path: repoPath, branch: prIdentifier(), body: `**${f.file}**\n\n${body}` }) - ta.value = ''; showSentLink(st, url) - setTimeout(() => form.remove(), 4000) - } catch (err) { - st.textContent = String(err); st.className = 'review-comment-status review-comment-err' - } finally { sendBtn.disabled = false } - }) - sum.after(form); ta.focus() - }) - - const sum = document.createElement('summary') - sum.className = 'review-file-summary' - sum.append(viewedCb, stateTag, nameEl, commentBadge, editorBtn, fileCommentBtn, statsEl) - details.append(sum, splitView ? buildFileDiffSideBySide(f.chunk, f.file) : buildFileDiff(f.chunk, f.file)) - return details - } - - // ── Render files (flat or tree) ─────────────────────────────────────────── - const renderFiles = (): void => { - focusedFileIdx = -1 - if (!treeView) { - diffView.replaceChildren(...lastFiles.map(f => makeFileDetails(f))) - } else { - const dirs = new Map() - for (const f of lastFiles) { - const parts = f.file.split('/') - const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '' - const grp = dirs.get(dir) ?? []; grp.push(f); dirs.set(dir, grp) - } - const sorted = [...dirs.entries()].sort(([a], [b]) => a.localeCompare(b)) - diffView.replaceChildren(...sorted.flatMap(([dir, files]) => { - const nodes: HTMLElement[] = [] - if (dir) { - nodes.push(Object.assign(document.createElement('div'), { className: 'review-tree-dir-name', textContent: dir + '/' })) - } - nodes.push(...files.map(f => makeFileDetails(f))) - return nodes - })) - } - applyVisibility() - renderFilterBar() - } - - // ── Search + filter visibility ──────────────────────────────────────────── - const applyVisibility = (): void => { - const q = diffSearchInput.value.toLowerCase() - const commentedPaths = new Set(existingComments.map(c => c.path)) - diffView.querySelectorAll('.review-file-detail').forEach(el => { - const state = el.dataset.filestate ?? 'M' - const filename = el.dataset.filename ?? '' - const isCommentedFilter = fileTypeFilter === 'commented' - const failsType = !isCommentedFilter && fileTypeFilter !== 'all' && state !== fileTypeFilter - const failsCommented = isCommentedFilter && !commentedPaths.has(filename) - const failsSearch = q !== '' && !filename.toLowerCase().includes(q) - el.classList.toggle('hidden', failsType || failsCommented || failsSearch) - }) - } - - diffSearchInput.addEventListener('input', applyVisibility) - - // ── Filter bar ──────────────────────────────────────────────────────────── - const renderFilterBar = (): void => { - const counts = { A: 0, M: 0, D: 0 } - lastFiles.forEach(f => { counts[f.state]++ }) - const total = lastFiles.length - if (total === 0) { filterBar.classList.add('hidden'); return } - filterBar.classList.remove('hidden') - const mkBtn = (label: string, value: FileTypeFilter): HTMLButtonElement => { - const btn = Object.assign(document.createElement('button'), { - className: `review-filter-btn${fileTypeFilter === value ? ' review-filter-btn--active' : ''}`, textContent: label, - }) - btn.addEventListener('click', () => { - fileTypeFilter = value - filterBar.querySelectorAll('.review-filter-btn').forEach(b => b.classList.remove('review-filter-btn--active')) - btn.classList.add('review-filter-btn--active') - applyVisibility() - }) - return btn - } - const commentedPaths = new Set(existingComments.map(c => c.path)) - const commentedCount = lastFiles.filter(f => commentedPaths.has(f.file)).length - if (fileTypeFilter === 'commented' && commentedCount === 0) fileTypeFilter = 'all' - const filterBtns: HTMLButtonElement[] = [ - mkBtn(`All ${total}`, 'all'), - mkBtn(`+${counts.A} Added`, 'A'), - mkBtn(`~${counts.M} Modified`, 'M'), - mkBtn(`−${counts.D} Deleted`, 'D'), - ] - if (commentedCount > 0) filterBtns.push(mkBtn(`💬 ${commentedCount}`, 'commented')) - filterBar.replaceChildren(...filterBtns) - } - - // ── Update comment badges on file headers ──────────────────────────────── - const updateCommentBadges = (): void => { - diffView.querySelectorAll('.review-file-detail').forEach(el => { - const filename = el.dataset.filename ?? '' - const count = existingComments.filter(c => c.path === filename).length - const badge = el.querySelector('.review-comment-badge') - if (!badge) return - if (count > 0) { - badge.textContent = `💬 ${count}` - badge.title = `${count} comment${count !== 1 ? 's' : ''}` - badge.classList.remove('hidden') - } else { - badge.classList.add('hidden') - } - }) - renderFilterBar() - } - - // ── Inject existing PR comments ─────────────────────────────────────────── - const injectExistingComments = (): void => { - diffView.querySelectorAll('.review-existing-comment').forEach(el => el.remove()) - diffView.querySelectorAll('.review-comment-orphans').forEach(el => el.remove()) - const fileContainers = [...diffView.querySelectorAll('[data-filepath]')] - const orphans = new Map() - - for (const c of existingComments) { - const fileContainer = fileContainers.find(el => el.dataset.filepath === c.path) - if (!fileContainer) continue - const lineWrap = fileContainer.querySelector(`[data-line="${c.line}"]`) - if (lineWrap) { - // Line is visible in the diff — inject inline - const insertAnchor = lineWrap.closest('.review-split-row') ?? lineWrap - insertAnchor.after(buildCommentBubble(c)) - } else { - // Line not in diff context — collect as orphan to show at file bottom - const list = orphans.get(fileContainer) ?? [] - list.push(c) - orphans.set(fileContainer, list) - } - } - - // Append orphan comments at the bottom of their file diff - for (const [container, comments] of orphans) { - const section = document.createElement('div') - section.className = 'review-comment-orphans' - for (const c of comments) { - const bubble = buildCommentBubble(c) - const lineNote = Object.assign(document.createElement('div'), { - className: 'review-orphan-line-note', - textContent: `Line ${c.line} · ${c.path.split('/').pop()}`, - }) - bubble.prepend(lineNote) - section.append(bubble) - } - container.append(section) - } - - updateCommentBadges() - updateCommentNav() - } - - const loadExistingComments = async (): Promise => { - if (currentPrNumber === null) { existingComments = []; return } - try { - const raw = await invoke('gh_pr_list_comments', { path: repoPath, prNumber: currentPrNumber }) - existingComments = raw.filter(c => c.line != null) - resolvedComments = getResolvedComments() - } catch { existingComments = [] } - } - - // ── Load diff ───────────────────────────────────────────────────────────── - const loadDiff = async (): Promise => { - filterBar.classList.add('hidden') - diffSearchInput.classList.add('hidden') - fileTypeFilter = 'all' - diffView.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') })) - try { - let raw = selectedBranch === activeLocalBranch - ? await diffGit.reviewWorktreeDiff(repoPath, baseBranch) - : await invoke('git_ref_diff', { path: repoPath, base: baseBranch, target: selectedBranch }) - if (!raw.trim() && currentPrState === 'MERGED' && currentPrNumber !== null) { - const prDiff = await invoke('gh_pr_diff_number', { path: repoPath, prNumber: currentPrNumber }).catch(() => '') - if (prDiff.trim()) raw = prDiff - } - if (!raw.trim()) { - totalFiles = 0; lastFiles = []; updateViewedCounter() - diffView.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: describeReviewNoBranchChanges(currentPrState, baseBranch) })) - return - } - lastFiles = parseDiffFiles(raw).map(f => ({ ...f, state: getFileState(f.chunk) })) - totalFiles = lastFiles.length - updateViewedCounter() - renderFiles() - diffSearchInput.classList.remove('hidden') - } catch (e) { - diffView.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-error', textContent: String(e) })) - } - } - - // ── Load PR info ────────────────────────────────────────────────────────── - const loadPrInfo = async (): Promise => { - const myPrSeq = ++prInfoSeq - currentPrNumber = null; existingComments = []; currentPrTitle = ''; currentPrBody = '' - prMetaEl.replaceChildren(); prBodyEl.innerHTML = ''; prBodyEl.classList.add('hidden') - discussionEl.replaceChildren(); discussionEl.classList.add('hidden') - commentBar.classList.add('hidden') - lastStatusRollup = [] - try { - const pr = await invoke<{ - number: number; title: string; url: string; body: string; state?: string; mergedAt?: string | null - statusCheckRollup: Array<{ name?: string; workflowName?: string; conclusion?: string | null; state?: string; context?: string; targetUrl?: string }> - reviewDecision: string | null - } | null>('gh_pr_view_branch', { path: repoPath, branch: ghBranch(selectedBranch) }) - if (prInfoSeq !== myPrSeq) return - if (pr) { - currentPrNumber = pr.number - currentPrTitle = pr.title - currentPrBody = pr.body ?? '' - currentPrState = pr.state ?? null - lastStatusRollup = pr.statusCheckRollup ?? [] - 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(() => {}) }) - prMetaEl.append(link) - - const stateBadge = describeReviewPrState(pr.state, pr.mergedAt) - if (stateBadge) { - prMetaEl.append(Object.assign(document.createElement('span'), { - className: `review-pr-state ${stateBadge.cls}`, - textContent: stateBadge.text, - title: stateBadge.title, - })) - } - - const ci = computeCiStatus(lastStatusRollup) - if (ci !== 'none') { - const ciEl = Object.assign(document.createElement('span'), { - className: `review-ci review-ci--${ci}`, - textContent: ci === 'success' ? '✓ CI' : ci === 'failure' ? '✗ CI' : '⟳ CI', - }) - ciEl.style.cursor = 'pointer' - ciEl.addEventListener('click', e => { e.stopPropagation(); showCiPopover(ciEl) }) - prMetaEl.append(ciEl) - } - - const decMap: Record = { - APPROVED: { text: '✓ Approved', cls: 'review-decision--approved' }, - CHANGES_REQUESTED: { text: '✗ Changes requested', cls: 'review-decision--changes' }, - REVIEW_REQUIRED: { text: '? Review required', cls: 'review-decision--required' }, - } - const dec = pr.reviewDecision ? decMap[pr.reviewDecision] : null - if (dec) prMetaEl.append(Object.assign(document.createElement('span'), { className: `review-decision ${dec.cls}`, textContent: dec.text })) - - if (pr.body?.trim()) { - prBodyEl.innerHTML = `Description${renderMarkdown(pr.body)}` - prBodyEl.classList.remove('hidden') - } - commentBar.classList.remove('hidden') - await loadExistingComments() - - // ── Discussion thread — loaded separately so a failure doesn't break PR info ── - const myDiscSeq = ++discSeq - invoke<{ comments: any[]; reviews: any[] }>('gh_pr_list_discussion', { path: repoPath, prNumber: pr.number }) - .then(disc => { - if (discSeq !== myDiscSeq) return // newer loadPrInfo started - type DiscItem = { author: string; body: string; time: string; decision?: { text: string; cls: string } } - const discItems: DiscItem[] = [ - ...(disc.reviews ?? []) - .filter((r: any) => r.body?.trim() && r.state !== 'PENDING') - .map((r: any) => ({ author: r.user?.login ?? '?', body: r.body, time: r.submitted_at ?? '', decision: decMap[r.state] })), - ...(disc.comments ?? []) - .filter((c: any) => c.body?.trim()) - .map((c: any) => ({ author: c.user?.login ?? '?', body: c.body, time: c.created_at ?? '' })), - ].sort((a, b) => a.time.localeCompare(b.time)) - if (discItems.length === 0) return - const hdr = Object.assign(document.createElement('div'), { - className: 'review-discussion-header', - textContent: `Discussion · ${discItems.length}`, - }) - discussionEl.replaceChildren(hdr, ...discItems.map(item => { - const msg = document.createElement('div') - msg.className = 'review-discussion-item' - const meta = document.createElement('div') - meta.className = 'review-discussion-meta' - meta.append(Object.assign(document.createElement('span'), { className: 'review-comment-author', textContent: item.author })) - if (item.decision) meta.append(Object.assign(document.createElement('span'), { className: `review-decision ${item.decision.cls} review-decision--sm`, textContent: item.decision.text })) - if (item.time) meta.append(Object.assign(document.createElement('span'), { className: 'review-comment-time', textContent: relativeTime(item.time) })) - const bodyDiv = Object.assign(document.createElement('div'), { className: 'review-discussion-body' }) - bodyDiv.innerHTML = renderMarkdown(item.body) - msg.append(meta, bodyDiv) - return msg - })) - discussionEl.classList.remove('hidden') - }) - .catch(() => { /* discussion unavailable, PR info unaffected */ }) - - if (sidebarMode === 'prs') renderPrList() - } - } catch { currentPrState = null } - } - - const prIdentifier = (): string => currentPrNumber !== null ? String(currentPrNumber) : ghBranch(selectedBranch) - - // ── Select branch ───────────────────────────────────────────────────────── - const selectBranch = async (branch: string): Promise => { - selectedBranch = branch; loadingBranch = branch - renderBranchList() - if (sidebarMode === 'prs') renderPrList() - await Promise.all([loadDiff(), loadPrInfo()]) - if (loadingBranch === branch) injectExistingComments() - } + const { renderFiles, injectExistingComments } = buildReviewDiffView( + { diffView, diffSearchInput, filterBar }, + { + getLastFiles: () => lastFiles, + getTreeView: () => treeView, + getSplitView: () => splitView, + getExistingComments: () => existingComments, + getFileTypeFilter: () => fileTypeFilter, + setFileTypeFilter: value => { fileTypeFilter = value }, + resetFocusedFileIdx: () => { focusedFileIdx = -1 }, + getViewedFiles, + setFileViewed, + repoPath: () => repoPath, + getCurrentPrNumber: () => currentPrNumber, + getPrIdentifier: () => prIdentifier(), + buildCommentBubble, + makeLineForm, + updateCommentNav, + showSentLink, + }, + ) - // ── Submit PR review (with summary confirm) ─────────────────────────────── - const submitReview = async (event: 'APPROVE' | 'REQUEST_CHANGES'): Promise => { - if (currentPrNumber === null) return - const body = commentInput.value.trim() - const viewed = getViewedFiles().size - const key = event === 'APPROVE' ? 'approveConfirm' : 'requestChangesConfirm' - const msg = reviewT(key, { number: currentPrNumber, viewed, total: totalFiles, comments: existingComments.length }) - if (!confirm(msg)) return - approveBtn.disabled = true; requestChangesBtn.disabled = true - try { - await invoke('gh_pr_submit_review', { path: repoPath, prNumber: currentPrNumber, event, body }) - commentInput.value = '' - showCommentStatus(reviewT('reviewSubmitted')) - await loadPrInfo() - injectExistingComments() - } catch (e) { - showCommentStatus(String(e), true) - } finally { approveBtn.disabled = false; requestChangesBtn.disabled = false } - } + const { loadDiff, loadExistingComments, selectBranch, submitReview, loadBranches, pickRepo, prIdentifier } = buildReviewDataLoader( + { filterBar, diffSearchInput, diffView, prMetaEl, prBodyEl, discussionEl, commentBar, branchInput, viewedCounterEl, commentNavWrap, commentInput, approveBtn, requestChangesBtn }, + { + getRepoPath: () => repoPath, + setRepoPath: v => { repoPath = v }, + getBaseBranch: () => baseBranch, + setBaseBranch: v => { baseBranch = v }, + getSelectedBranch: () => selectedBranch, + setSelectedBranch: v => { selectedBranch = v }, + getActiveLocalBranch: () => activeLocalBranch, + setActiveLocalBranch: v => { activeLocalBranch = v }, + getAllBranches: () => allBranches, + setAllBranches: v => { allBranches = v }, + getCurrentPrNumber: () => currentPrNumber, + setCurrentPrNumber: v => { currentPrNumber = v }, + getExistingComments: () => existingComments, + setExistingComments: v => { existingComments = v }, + getLoadingBranch: () => loadingBranch, + setLoadingBranch: v => { loadingBranch = v }, + getSidebarMode: () => sidebarMode, + setOpenPrs: v => { openPrs = v }, + setFileTypeFilter: v => { fileTypeFilter = v }, + getTotalFiles: () => totalFiles, + setTotalFiles: v => { totalFiles = v }, + getLastFiles: () => lastFiles, + setLastFiles: v => { lastFiles = v }, + setLastStatusRollup: v => { lastStatusRollup = v }, + setResolvedComments: v => { resolvedComments = v }, + getResolvedComments, + nextDiscSeq: () => ++discSeq, + getDiscSeq: () => discSeq, + nextPrInfoSeq: () => ++prInfoSeq, + getPrInfoSeq: () => prInfoSeq, + setCurrentPrTitle: v => { currentPrTitle = v }, + setCurrentPrBody: v => { currentPrBody = v }, + getCurrentPrState: () => currentPrState, + setCurrentPrState: v => { currentPrState = v }, + getViewedFiles, + renderBranchList, + renderPrList, + loadPrList, + renderFiles, + injectExistingComments, + updateViewedCounter, + showCommentStatus, + showCiPopover, + }, + ) // ── Event handlers ──────────────────────────────────────────────────────── commentBtn.addEventListener('click', async () => { @@ -1181,31 +560,6 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; }).catch(() => {}) }) - // ── Load branches ───────────────────────────────────────────────────────── - const loadBranches = async (): Promise => { - if (!repoPath) return - const [defaultBranch, branches, currentBranch] = await Promise.all([ - diffGit.defaultBranch(repoPath), - diffGit.reviewBranches(repoPath), - diffGit.currentBranch(repoPath), - ]) - allBranches = currentBranch - ? [currentBranch, ...branches.filter(branch => branch !== currentBranch)] - : branches - activeLocalBranch = currentBranch - if (!baseBranch) { - const originDefault = `origin/${defaultBranch}` - baseBranch = allBranches.includes(originDefault) ? originDefault : defaultBranch - branchInput.value = baseBranch - localStorage.setItem(BASE_KEY, baseBranch) - } - renderBranchList() - loadPrList() - if (!selectedBranch && currentBranch && currentBranch !== defaultBranch) { - void selectBranch(currentBranch) - } - } - const setAutoRefresh = (on: boolean): void => { autoRefresh = on autoBtn.classList.toggle('review-icon-btn--active', on) @@ -1213,340 +567,27 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; if (on && panelVisible) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) } - 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 = ''; activeLocalBranch = ''; existingComments = []; totalFiles = 0 - fileTypeFilter = 'all'; openPrs = []; lastFiles = []; lastStatusRollup = [] - localStorage.setItem(REPO_KEY, repoPath) - diffView.replaceChildren(); filterBar.classList.add('hidden') - diffSearchInput.classList.add('hidden'); prBodyEl.classList.add('hidden') - commentBar.classList.add('hidden'); viewedCounterEl.classList.add('hidden') - commentNavWrap.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)) - // Optional author context typed before a review (what the branch does / what to - // focus on). Persisted per branch and injected into the review prompt. - const reviewContextKey = (): string => `bento.review.context:${repoPath}:${selectedBranch}` - let pendingReviewContext: string | null = null - const showReviewContextForm = (): void => { - const form = document.createElement('div') - form.className = 'review-context-form' - const label = Object.assign(document.createElement('label'), { className: 'review-context-label', textContent: 'Contexto para la review (opcional): ¿qué hace esta rama y en qué fijarse?' }) - const ta = Object.assign(document.createElement('textarea'), { - className: 'review-context-input', - value: (() => { try { return localStorage.getItem(reviewContextKey()) ?? '' } catch { return '' } })(), - placeholder: 'Ej: añade tests de contrato de la API; comprueba que no rompa el refactor de la BD…', - }) - const runBtn = Object.assign(document.createElement('button'), { className: 'review-context-run', textContent: 'Revisar' }) - runBtn.addEventListener('click', () => { - const value = ta.value.trim() - try { if (value) localStorage.setItem(reviewContextKey(), value); else localStorage.removeItem(reviewContextKey()) } catch { /* storage full */ } - pendingReviewContext = value - aiReviewBtn.click() - }) - const actions = Object.assign(document.createElement('div'), { className: 'review-context-actions' }) - actions.append(runBtn) - form.append(label, ta, actions) - reviewDrawerMeta.textContent = '' - reviewDrawerBody.replaceChildren(form) - showReviewDrawer() - ta.focus() - } - - aiReviewBtn.addEventListener('click', async () => { - const showReviewError = (message: string): void => { - console.error('[AI Review]', message) - const error = Object.assign(document.createElement('div'), { className: 'review-error', textContent: message }) - if (reviewDrawer.classList.contains('visible')) { - reviewDrawerBody.replaceChildren(error) - error.scrollIntoView({ block: 'start', behavior: 'smooth' }) - return - } - diffView.prepend(error) - error.scrollIntoView({ block: 'start', behavior: 'smooth' }) - } - if (!repoPath) { showReviewError('Open a repository first'); return } - if (!selectedBranch) { showReviewError('Select a branch first'); return } - if (!lastFiles.length) { showReviewError('There are no changes to review'); return } - const reviewAgents = selectedReviewAgents() - if (reviewCompareAgentsToggle.checked && reviewAgents.length < 2) { - showReviewError(i18nT('common.reviewSelectAnotherAgent')) - return - } - // First click shows the optional context form; its "Revisar" re-triggers this - // with the context set. Reset after reading so the next review asks again. - if (pendingReviewContext === null) { showReviewContextForm(); return } - const reviewContext = pendingReviewContext - pendingReviewContext = null - const reviewRepoPath = repoPath - const reviewBranch = selectedBranch - const reviewBaseBranch = baseBranch - const reviewAgent = reviewAgents.at(-1) ?? reviewAgents[0] - const reviewConversationKey = techReviewConversationKey(reviewRepoPath, reviewBranch) - const reviewProjectName = reviewRepoPath.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() ?? reviewRepoPath - const prLine = currentPrNumber ? `PR #${currentPrNumber}: ${currentPrTitle}` : `Branch: ${reviewBranch}` - const descSection = currentPrBody.trim() ? `\nDescription:\n${currentPrBody.trim()}\n` : '' - const authorContext = reviewContext.trim() ? `\nContexto del autor (qué hace la rama / en qué fijarse):\n${reviewContext.trim()}\n` : '' - const reviewFileManifest = buildReviewFileManifest(lastFiles) - const reviewOverview = `${prLine}\nBase: ${reviewBaseBranch} <- ${reviewBranch}\n${descSection}${authorContext}Files:\n${reviewFileManifest}\n\nReview the files in the current batch first. If a file is not included below, read it directly from the worktree before deciding.` - const reviewChangedFiles = lastFiles.map(file => file.file) - aiReviewBtn.disabled = true - aiReviewBtn.title = 'Reviewing...' - const reviewEvidence: string[] = [] - - // Progress box visible desde el principio - const progressBox = document.createElement('div') - progressBox.className = 'review-ai-progress' - const progressHeader = document.createElement('div') - progressHeader.className = 'review-ai-progress-header' - const progressStatus = Object.assign(document.createElement('span'), { className: 'review-ai-progress-status', textContent: 'Preparing review…' }) - const progressMeta = Object.assign(document.createElement('span'), { className: 'review-ai-progress-meta' }) - const stopReviewBtn = Object.assign(document.createElement('button'), { - className: 'review-ai-stop-btn', - textContent: 'Stop', - disabled: true, - }) - const progressStream = Object.assign(document.createElement('pre'), { className: 'review-ai-progress-stream' }) - const progressToggleBtn = mkIconBtn('review-ai-toggle-btn', 'Ocultar/mostrar la salida del agente', 'chevron-up') - progressToggleBtn.addEventListener('click', () => { - const collapsed = progressStream.classList.toggle('collapsed') - progressToggleBtn.innerHTML = icon(collapsed ? 'chevron-down' : 'chevron-up') - }) - progressHeader.append(progressStatus, progressMeta, progressToggleBtn, stopReviewBtn) - progressBox.append(progressHeader, progressStream) - reviewDrawerMeta.textContent = '' - reviewDrawerBody.replaceChildren(progressBox) - showReviewDrawer() - progressBox.scrollIntoView({ block: 'start', behavior: 'smooth' }) - - const startedAt = Date.now() - const timer = setInterval(() => { - const secs = Math.floor((Date.now() - startedAt) / 1000) - const chars = progressStream.textContent?.length ?? 0 - progressMeta.textContent = chars ? `${chars} chars · ${secs}s` : `${secs}s` - }, 500) - // Agents run in parallel, so track every in-flight handle (not just one) to - // cancel them all on Stop. - const activeReviewHandles = new Set>() - let reviewStopped = false - stopReviewBtn.addEventListener('click', async () => { - if (reviewStopped || !activeReviewHandles.size) return - reviewStopped = true - stopReviewBtn.disabled = true - progressStatus.textContent = 'Stopping review…' - await Promise.all([...activeReviewHandles].map(handle => handle.cancel().catch(() => {}))) - }) - - const showResult = (content: string, reviewCommit: string, followUpSession: { sessionId: string | null; sessionAgent: AgentType | null }): void => { - reviewDrawerMeta.textContent = `${reviewBranch} · ${reviewCommit.slice(0, 7)}` - reviewDrawerBody.replaceChildren(Object.assign(document.createElement('div'), { - className: 'review-drawer-result', - innerHTML: renderMarkdown(content), - })) - showReviewDrawer() - const followUpAgent = followUpSession.sessionAgent ?? reviewAgent - askAi('', false, undefined, undefined, { role: 'assistant', content }, reviewRepoPath, followUpAgent, reviewConversationKey, `${reviewProjectName} · ${reviewBranch}`, reviewBranch, reviewCommit, followUpSession.sessionId ?? undefined, followUpSession.sessionAgent ?? undefined, reviewEvidence) - } - let worktree = '' - let managedWorktree = false - let reviewCommit = '' - // Declared outside the try so the catch can salvage whatever completed. - const reviewRuns: MultiAgentReviewRun[] = [] - // In-flight batches of the current agent, used to salvage a crash that - // happens before any consolidated run lands in reviewRuns. - let lastBatchRuns: MultiAgentReviewRun[] = [] - const reviewMeta = () => ({ - branch: reviewBranch, - base: reviewBaseBranch, - commit: reviewCommit, - compareAgents: reviewCompareAgentsToggle.checked, - fallbackAgentLabel: agentLabel(reviewAgent), - }) - const outputRuns = (): MultiAgentReviewRun[] => (reviewRuns.length ? reviewRuns : lastBatchRuns) - // Persist the document after every stage so a crash/reload never loses findings. - const saveReviewCheckpoint = (): void => { - const runs = outputRuns().filter(run => run.report || run.error) - if (!runs.length || !reviewCommit) return - const followUpSession = resolveReviewFollowUpSession(runs, runs.length) - try { - localStorage.setItem(techReviewCheckpointKey(reviewRepoPath, reviewBranch), JSON.stringify({ - content: buildReviewDocument(reviewMeta(), runs), - commit: reviewCommit, - branch: reviewBranch, - sessionId: followUpSession.sessionId ?? null, - sessionAgent: followUpSession.sessionAgent ?? null, - })) - } catch { /* storage full — the on-screen salvage still applies */ } - } - try { - progressStatus.textContent = 'Creating isolated worktree…' - const branchContext = await invoke<{ path: string; commit: string; managed: boolean }>('review_branch_context_prepare', { - repoPath: reviewRepoPath, - reference: reviewBranch, - commit: null, - }) - worktree = branchContext.path - managedWorktree = branchContext.managed - reviewCommit = branchContext.commit - const snapshotBefore = await invoke('review_snapshot', { repoPath: worktree }) - progressStatus.textContent = 'Gathering context…' - const contextProvider = createContextProvider({ - lexis: async () => { - const content = await invoke('review_lexis_context', { - path: worktree, - question: [ - `Build a compact review bundle for: ${reviewChangedFiles.join(', ')}`, - 'Return impact, callers, definitions, tests, risks and likely blast radius.', - 'Prefer structured evidence over prose.', - ].join(' '), - }) - if (!content) throw new Error('Lexis returned no context') - return [{ path: '', content, reason: 'reference' as const }] - }, - direct: async () => lastFiles.map(file => ({ path: file.file, content: file.chunk, reason: 'changed' as const })), - }) - const context = await contextProvider.collect({ repoRoot: worktree, diff: reviewOverview, changedFiles: reviewChangedFiles }) - const sharedPrompt = buildReviewPrompt({ - diff: reviewOverview, - files: [], - contextSources: context.sources, - lexisContext: context.snippets.filter(snippet => snippet.reason !== 'changed').map(snippet => `${snippet.path}\n${snippet.content}`).join('\n\n'), - }) - // One full-change prompt per agent: the whole diff + as much file content as - // fits inline (large files truncated; the agent reads the rest via its tools). - const ONE_PASS_CONTENT_BUDGET = 150_000 - const perFileBudget = Math.max(800, Math.floor(ONE_PASS_CONTENT_BUDGET / Math.max(lastFiles.length, 1))) - const onePassPrompt = buildReviewPrompt({ - diff: reviewOverview, - files: lastFiles.map(file => ({ - path: file.file, - content: file.chunk.length > perFileBudget - ? `${file.chunk.slice(0, perFileBudget)}\n[truncado; lee el resto en el worktree]` - : file.chunk, - })), - contextSources: context.sources, - lexisContext: context.snippets.filter(snippet => snippet.reason !== 'changed').map(snippet => `${snippet.path}\n${snippet.content}`).join('\n\n'), - }) - const snapshotBeforeAgent = await invoke('review_snapshot', { repoPath: worktree }) - if (snapshotBeforeAgent !== snapshotBefore) throw new Error('Repository changed while preparing the review') - const MAX_REVIEW_ATTEMPTS = 2 - const runReviewAgent = async (agent: AgentType, prompt: string, kind: 'analysis' | 'verification' = 'analysis'): Promise => { - const label = agentLabel(agent) - const run: MultiAgentReviewRun = { label, agent } - const stageLabel = kind === 'verification' ? 'Síntesis final' : 'Análisis' - // A transient blip (rate limit, network, generic exit) used to kill the - // stage; retry it once. Timeouts are NOT retried (see isRetryableReviewError). - for (let attempt = 1; attempt <= MAX_REVIEW_ATTEMPTS; attempt++) { - if (reviewStopped) break - run.error = undefined - run.report = undefined - let output = '' - const handle = startAgent( - { agent, message: prompt, history: [], projectPath: worktree, review: true }, - chunk => { - output += chunk - // Show the full process (bounded), and keep it pinned to the bottom. - progressStream.textContent = output.length > 40_000 ? '…' + output.slice(-40_000) : output - progressStream.scrollTop = progressStream.scrollHeight - }, - sessionId => { run.sessionId = sessionId }, - message => { run.error = message }, - tool => { - const safeTool = redact(tool).slice(0, 1_000) - if (!reviewEvidence.includes(safeTool)) reviewEvidence.push(safeTool) - progressStatus.textContent = `${label} · ${stageLabel}: ${safeTool}` - }, - ) - activeReviewHandles.add(handle) - stopReviewBtn.disabled = false - try { - await handle.ready - // `completed` resolves right after the done/error callback has already - // run synchronously, so run.error / run.sessionId are set by this point. - await handle.completed - if (!run.error && !reviewStopped) { - const report = output.trim() - if (!report) throw new Error('El agente no devolvió ningún análisis') - run.report = report - } - } catch (error) { - run.error = error instanceof Error ? error.message : String(error) - } finally { - handle.unlisten() - activeReviewHandles.delete(handle) - if (!activeReviewHandles.size) stopReviewBtn.disabled = true - } - const shouldRetry = attempt < MAX_REVIEW_ATTEMPTS && !reviewStopped && !run.report && !!run.error && isRetryableReviewError(run.error) - if (!shouldRetry) break - await new Promise(resolve => setTimeout(resolve, 3_000 * attempt)) - } - return run - } - - // Each agent does ONE full-change analysis (reading files itself), all in - // parallel. The final verifier then consolidates: the multi-agent pipeline is - // kept; only the per-agent file batching (that made it take hours) is gone. - progressStatus.textContent = `Revisando con ${reviewAgents.length} agente(s) en paralelo…` - const agentRuns = await Promise.all(reviewAgents.map(agent => runReviewAgent(agent, onePassPrompt, 'analysis'))) - lastBatchRuns = agentRuns - reviewRuns.push(...agentRuns.filter(run => run.report || run.error)) - saveReviewCheckpoint() - - // With ≥2 agents, one of them consolidates everyone's analysis into a final - // report (the pipeline: each agent analyses, the last one synthesizes). - const reportsToSynthesize = reviewRuns.filter(run => run.report).map(run => ({ label: run.label, report: run.report as string })) - if (!reviewStopped && reportsToSynthesize.length >= 2) { - progressStatus.textContent = 'Síntesis final…' - const verifierAgent = reviewAgents.at(-1) ?? reviewAgents[0] - const synthesisPrompt = buildReviewSynthesisPrompt(sharedPrompt, reportsToSynthesize) - const synthesisRun = await runReviewAgent(verifierAgent, synthesisPrompt, 'verification') - synthesisRun.label = 'Síntesis final' - reviewRuns.push(synthesisRun) - saveReviewCheckpoint() - } - - if (reviewStopped) throw new Error('Review stopped') - const successfulRuns = reviewRuns.filter(run => run.report) - if (!successfulRuns.length) throw new Error('No valid review responses') - - const snapshotAfter = await invoke('review_snapshot', { repoPath: worktree }) - const content = buildReviewDocument(reviewMeta(), reviewRuns) - const followUpSession = resolveReviewFollowUpSession(reviewRuns, reviewRuns.length) - saveReviewCheckpoint() - showResult(content, reviewCommit, followUpSession) - if (snapshotAfter !== snapshotBefore) showReviewError('Repository changed during review; findings may be stale') - } catch (error) { - // Never discard completed findings on failure/stop: render + persist what - // we have and show the error as a note, instead of wiping the drawer. - const salvaged = outputRuns().filter(run => run.report) - if (salvaged.length) { - saveReviewCheckpoint() - showResult(buildReviewDocument(reviewMeta(), salvaged), reviewCommit, resolveReviewFollowUpSession(salvaged, salvaged.length)) - const note = Object.assign(document.createElement('div'), { className: 'review-error', textContent: `Review incompleto (se guardó lo revisado): ${String(error)}` }) - reviewDrawerBody.prepend(note) - note.scrollIntoView({ block: 'start', behavior: 'smooth' }) - } else { - reviewDrawerBody.replaceChildren(); showReviewDrawer(); showReviewError(String(error)) - } - } - finally { - clearInterval(timer) - if (!reviewStopped) reviewDrawerMeta.textContent = reviewDrawerMeta.textContent || reviewT('title') - if (managedWorktree) { - await invoke('review_branch_context_release', { path: worktree }).catch(error => showReviewError(String(error))) - } - aiReviewBtn.disabled = false - aiReviewBtn.title = 'AI Review' - } - }) + const { handleAiReviewClick } = buildReviewAiRun( + { aiReviewBtn, reviewCompareAgentsToggle, reviewDrawer, reviewDrawerMeta, reviewDrawerBody, diffView }, + { + getRepoPath: () => repoPath, + getSelectedBranch: () => selectedBranch, + getBaseBranch: () => baseBranch, + getLastFiles: () => lastFiles, + getCurrentPrNumber: () => currentPrNumber, + getCurrentPrTitle: () => currentPrTitle, + getCurrentPrBody: () => currentPrBody, + selectedReviewAgents, + showReviewDrawer, + mkIconBtn, + }, + ) + aiReviewBtn.addEventListener('click', handleAiReviewClick) // ── Init ────────────────────────────────────────────────────────────────── if (repoPath) { diff --git a/src/panels/review/reviewAiRun.ts b/src/panels/review/reviewAiRun.ts new file mode 100644 index 0000000..5738928 --- /dev/null +++ b/src/panels/review/reviewAiRun.ts @@ -0,0 +1,363 @@ +import { invoke } from '@tauri-apps/api/core' +import { icon } from '../../ui/icons' +import { reviewT } from './i18n' +import { t as i18nT } from '../../i18n' +import { redact, startAgent } from '../../core/ai/agentClient' +import { agentLabel, type AgentType } from '../../core/ai/config' +import { buildReviewPrompt, buildReviewSynthesisPrompt, buildReviewDocument, isRetryableReviewError, createContextProvider, type MultiAgentReviewRun } from '../../core/ai/techReview' +import { askAi } from '../../ui/askAi' +import { techReviewConversationKey, techReviewCheckpointKey } from '../../core/ai/chatHistory' +import { renderMarkdown } from '../../core/notes/renderMarkdown' +import { resolveReviewFollowUpSession, buildReviewFileManifest, type ReviewChangeFile } from './reviewFormat' + +export interface ReviewAiRunDom { + aiReviewBtn: HTMLButtonElement + reviewCompareAgentsToggle: HTMLInputElement + reviewDrawer: HTMLElement + reviewDrawerMeta: HTMLElement + reviewDrawerBody: HTMLElement + diffView: HTMLElement +} + +export interface ReviewAiRunState { + getRepoPath: () => string + getSelectedBranch: () => string + getBaseBranch: () => string + getLastFiles: () => ReviewChangeFile[] + getCurrentPrNumber: () => number | null + getCurrentPrTitle: () => string + getCurrentPrBody: () => string + selectedReviewAgents: () => AgentType[] + showReviewDrawer: () => void + mkIconBtn: (cls: string, title: string, ic: string) => HTMLButtonElement +} + +export interface ReviewAiRun { + handleAiReviewClick: () => Promise +} + +export function buildReviewAiRun(dom: ReviewAiRunDom, state: ReviewAiRunState): ReviewAiRun { + const { aiReviewBtn, reviewCompareAgentsToggle, reviewDrawer, reviewDrawerMeta, reviewDrawerBody, diffView } = dom + + // Optional author context typed before a review (what the branch does / what to + // focus on). Persisted per branch and injected into the review prompt. + const reviewContextKey = (): string => `bento.review.context:${state.getRepoPath()}:${state.getSelectedBranch()}` + let pendingReviewContext: string | null = null + const showReviewContextForm = (): void => { + const form = document.createElement('div') + form.className = 'review-context-form' + const label = Object.assign(document.createElement('label'), { className: 'review-context-label', textContent: 'Contexto para la review (opcional): ¿qué hace esta rama y en qué fijarse?' }) + const ta = Object.assign(document.createElement('textarea'), { + className: 'review-context-input', + value: (() => { try { return localStorage.getItem(reviewContextKey()) ?? '' } catch { return '' } })(), + placeholder: 'Ej: añade tests de contrato de la API; comprueba que no rompa el refactor de la BD…', + }) + const runBtn = Object.assign(document.createElement('button'), { className: 'review-context-run', textContent: 'Revisar' }) + runBtn.addEventListener('click', () => { + const value = ta.value.trim() + try { if (value) localStorage.setItem(reviewContextKey(), value); else localStorage.removeItem(reviewContextKey()) } catch { /* storage full */ } + pendingReviewContext = value + aiReviewBtn.click() + }) + const actions = Object.assign(document.createElement('div'), { className: 'review-context-actions' }) + actions.append(runBtn) + form.append(label, ta, actions) + reviewDrawerMeta.textContent = '' + reviewDrawerBody.replaceChildren(form) + state.showReviewDrawer() + ta.focus() + } + + const handleAiReviewClick = async (): Promise => { + const showReviewError = (message: string): void => { + console.error('[AI Review]', message) + const error = Object.assign(document.createElement('div'), { className: 'review-error', textContent: message }) + if (reviewDrawer.classList.contains('visible')) { + reviewDrawerBody.replaceChildren(error) + error.scrollIntoView({ block: 'start', behavior: 'smooth' }) + return + } + diffView.prepend(error) + error.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } + const repoPath = state.getRepoPath() + const selectedBranch = state.getSelectedBranch() + const lastFiles = state.getLastFiles() + if (!repoPath) { showReviewError('Open a repository first'); return } + if (!selectedBranch) { showReviewError('Select a branch first'); return } + if (!lastFiles.length) { showReviewError('There are no changes to review'); return } + const reviewAgents = state.selectedReviewAgents() + if (reviewCompareAgentsToggle.checked && reviewAgents.length < 2) { + showReviewError(i18nT('common.reviewSelectAnotherAgent')) + return + } + // First click shows the optional context form; its "Revisar" re-triggers this + // with the context set. Reset after reading so the next review asks again. + if (pendingReviewContext === null) { showReviewContextForm(); return } + const reviewContext = pendingReviewContext + pendingReviewContext = null + const reviewRepoPath = repoPath + const reviewBranch = selectedBranch + const reviewBaseBranch = state.getBaseBranch() + const reviewAgent = reviewAgents.at(-1) ?? reviewAgents[0] + const reviewConversationKey = techReviewConversationKey(reviewRepoPath, reviewBranch) + const reviewProjectName = reviewRepoPath.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() ?? reviewRepoPath + const currentPrNumber = state.getCurrentPrNumber() + const prLine = currentPrNumber ? `PR #${currentPrNumber}: ${state.getCurrentPrTitle()}` : `Branch: ${reviewBranch}` + const descSection = state.getCurrentPrBody().trim() ? `\nDescription:\n${state.getCurrentPrBody().trim()}\n` : '' + const authorContext = reviewContext.trim() ? `\nContexto del autor (qué hace la rama / en qué fijarse):\n${reviewContext.trim()}\n` : '' + const reviewFileManifest = buildReviewFileManifest(lastFiles) + const reviewOverview = `${prLine}\nBase: ${reviewBaseBranch} <- ${reviewBranch}\n${descSection}${authorContext}Files:\n${reviewFileManifest}\n\nReview the files in the current batch first. If a file is not included below, read it directly from the worktree before deciding.` + const reviewChangedFiles = lastFiles.map(file => file.file) + aiReviewBtn.disabled = true + aiReviewBtn.title = 'Reviewing...' + const reviewEvidence: string[] = [] + + // Progress box visible desde el principio + const progressBox = document.createElement('div') + progressBox.className = 'review-ai-progress' + const progressHeader = document.createElement('div') + progressHeader.className = 'review-ai-progress-header' + const progressStatus = Object.assign(document.createElement('span'), { className: 'review-ai-progress-status', textContent: 'Preparing review…' }) + const progressMeta = Object.assign(document.createElement('span'), { className: 'review-ai-progress-meta' }) + const stopReviewBtn = Object.assign(document.createElement('button'), { + className: 'review-ai-stop-btn', + textContent: 'Stop', + disabled: true, + }) + const progressStream = Object.assign(document.createElement('pre'), { className: 'review-ai-progress-stream' }) + const progressToggleBtn = state.mkIconBtn('review-ai-toggle-btn', 'Ocultar/mostrar la salida del agente', 'chevron-up') + progressToggleBtn.addEventListener('click', () => { + const collapsed = progressStream.classList.toggle('collapsed') + progressToggleBtn.innerHTML = icon(collapsed ? 'chevron-down' : 'chevron-up') + }) + progressHeader.append(progressStatus, progressMeta, progressToggleBtn, stopReviewBtn) + progressBox.append(progressHeader, progressStream) + reviewDrawerMeta.textContent = '' + reviewDrawerBody.replaceChildren(progressBox) + state.showReviewDrawer() + progressBox.scrollIntoView({ block: 'start', behavior: 'smooth' }) + + const startedAt = Date.now() + const timer = setInterval(() => { + const secs = Math.floor((Date.now() - startedAt) / 1000) + const chars = progressStream.textContent?.length ?? 0 + progressMeta.textContent = chars ? `${chars} chars · ${secs}s` : `${secs}s` + }, 500) + // Agents run in parallel, so track every in-flight handle (not just one) to + // cancel them all on Stop. + const activeReviewHandles = new Set>() + let reviewStopped = false + stopReviewBtn.addEventListener('click', async () => { + if (reviewStopped || !activeReviewHandles.size) return + reviewStopped = true + stopReviewBtn.disabled = true + progressStatus.textContent = 'Stopping review…' + await Promise.all([...activeReviewHandles].map(handle => handle.cancel().catch(() => {}))) + }) + + const showResult = (content: string, reviewCommit: string, followUpSession: { sessionId: string | null; sessionAgent: AgentType | null }): void => { + reviewDrawerMeta.textContent = `${reviewBranch} · ${reviewCommit.slice(0, 7)}` + reviewDrawerBody.replaceChildren(Object.assign(document.createElement('div'), { + className: 'review-drawer-result', + innerHTML: renderMarkdown(content), + })) + state.showReviewDrawer() + const followUpAgent = followUpSession.sessionAgent ?? reviewAgent + askAi('', false, undefined, undefined, { role: 'assistant', content }, reviewRepoPath, followUpAgent, reviewConversationKey, `${reviewProjectName} · ${reviewBranch}`, reviewBranch, reviewCommit, followUpSession.sessionId ?? undefined, followUpSession.sessionAgent ?? undefined, reviewEvidence) + } + let worktree = '' + let managedWorktree = false + let reviewCommit = '' + // Declared outside the try so the catch can salvage whatever completed. + const reviewRuns: MultiAgentReviewRun[] = [] + // In-flight batches of the current agent, used to salvage a crash that + // happens before any consolidated run lands in reviewRuns. + let lastBatchRuns: MultiAgentReviewRun[] = [] + const reviewMeta = () => ({ + branch: reviewBranch, + base: reviewBaseBranch, + commit: reviewCommit, + compareAgents: reviewCompareAgentsToggle.checked, + fallbackAgentLabel: agentLabel(reviewAgent), + }) + const outputRuns = (): MultiAgentReviewRun[] => (reviewRuns.length ? reviewRuns : lastBatchRuns) + // Persist the document after every stage so a crash/reload never loses findings. + const saveReviewCheckpoint = (): void => { + const runs = outputRuns().filter(run => run.report || run.error) + if (!runs.length || !reviewCommit) return + const followUpSession = resolveReviewFollowUpSession(runs, runs.length) + try { + localStorage.setItem(techReviewCheckpointKey(reviewRepoPath, reviewBranch), JSON.stringify({ + content: buildReviewDocument(reviewMeta(), runs), + commit: reviewCommit, + branch: reviewBranch, + sessionId: followUpSession.sessionId ?? null, + sessionAgent: followUpSession.sessionAgent ?? null, + })) + } catch { /* storage full — the on-screen salvage still applies */ } + } + try { + progressStatus.textContent = 'Creating isolated worktree…' + const branchContext = await invoke<{ path: string; commit: string; managed: boolean }>('review_branch_context_prepare', { + repoPath: reviewRepoPath, + reference: reviewBranch, + commit: null, + }) + worktree = branchContext.path + managedWorktree = branchContext.managed + reviewCommit = branchContext.commit + const snapshotBefore = await invoke('review_snapshot', { repoPath: worktree }) + progressStatus.textContent = 'Gathering context…' + const contextProvider = createContextProvider({ + lexis: async () => { + const content = await invoke('review_lexis_context', { + path: worktree, + question: [ + `Build a compact review bundle for: ${reviewChangedFiles.join(', ')}`, + 'Return impact, callers, definitions, tests, risks and likely blast radius.', + 'Prefer structured evidence over prose.', + ].join(' '), + }) + if (!content) throw new Error('Lexis returned no context') + return [{ path: '', content, reason: 'reference' as const }] + }, + direct: async () => lastFiles.map(file => ({ path: file.file, content: file.chunk, reason: 'changed' as const })), + }) + const context = await contextProvider.collect({ repoRoot: worktree, diff: reviewOverview, changedFiles: reviewChangedFiles }) + const sharedPrompt = buildReviewPrompt({ + diff: reviewOverview, + files: [], + contextSources: context.sources, + lexisContext: context.snippets.filter(snippet => snippet.reason !== 'changed').map(snippet => `${snippet.path}\n${snippet.content}`).join('\n\n'), + }) + // One full-change prompt per agent: the whole diff + as much file content as + // fits inline (large files truncated; the agent reads the rest via its tools). + const ONE_PASS_CONTENT_BUDGET = 150_000 + const perFileBudget = Math.max(800, Math.floor(ONE_PASS_CONTENT_BUDGET / Math.max(lastFiles.length, 1))) + const onePassPrompt = buildReviewPrompt({ + diff: reviewOverview, + files: lastFiles.map(file => ({ + path: file.file, + content: file.chunk.length > perFileBudget + ? `${file.chunk.slice(0, perFileBudget)}\n[truncado; lee el resto en el worktree]` + : file.chunk, + })), + contextSources: context.sources, + lexisContext: context.snippets.filter(snippet => snippet.reason !== 'changed').map(snippet => `${snippet.path}\n${snippet.content}`).join('\n\n'), + }) + const snapshotBeforeAgent = await invoke('review_snapshot', { repoPath: worktree }) + if (snapshotBeforeAgent !== snapshotBefore) throw new Error('Repository changed while preparing the review') + const MAX_REVIEW_ATTEMPTS = 2 + const runReviewAgent = async (agent: AgentType, prompt: string, kind: 'analysis' | 'verification' = 'analysis'): Promise => { + const label = agentLabel(agent) + const run: MultiAgentReviewRun = { label, agent } + const stageLabel = kind === 'verification' ? 'Síntesis final' : 'Análisis' + // A transient blip (rate limit, network, generic exit) used to kill the + // stage; retry it once. Timeouts are NOT retried (see isRetryableReviewError). + for (let attempt = 1; attempt <= MAX_REVIEW_ATTEMPTS; attempt++) { + if (reviewStopped) break + run.error = undefined + run.report = undefined + let output = '' + const handle = startAgent( + { agent, message: prompt, history: [], projectPath: worktree, review: true }, + chunk => { + output += chunk + // Show the full process (bounded), and keep it pinned to the bottom. + progressStream.textContent = output.length > 40_000 ? '…' + output.slice(-40_000) : output + progressStream.scrollTop = progressStream.scrollHeight + }, + sessionId => { run.sessionId = sessionId }, + message => { run.error = message }, + tool => { + const safeTool = redact(tool).slice(0, 1_000) + if (!reviewEvidence.includes(safeTool)) reviewEvidence.push(safeTool) + progressStatus.textContent = `${label} · ${stageLabel}: ${safeTool}` + }, + ) + activeReviewHandles.add(handle) + stopReviewBtn.disabled = false + try { + await handle.ready + // `completed` resolves right after the done/error callback has already + // run synchronously, so run.error / run.sessionId are set by this point. + await handle.completed + if (!run.error && !reviewStopped) { + const report = output.trim() + if (!report) throw new Error('El agente no devolvió ningún análisis') + run.report = report + } + } catch (error) { + run.error = error instanceof Error ? error.message : String(error) + } finally { + handle.unlisten() + activeReviewHandles.delete(handle) + if (!activeReviewHandles.size) stopReviewBtn.disabled = true + } + const shouldRetry = attempt < MAX_REVIEW_ATTEMPTS && !reviewStopped && !run.report && !!run.error && isRetryableReviewError(run.error) + if (!shouldRetry) break + await new Promise(resolve => setTimeout(resolve, 3_000 * attempt)) + } + return run + } + + // Each agent does ONE full-change analysis (reading files itself), all in + // parallel. The final verifier then consolidates: the multi-agent pipeline is + // kept; only the per-agent file batching (that made it take hours) is gone. + progressStatus.textContent = `Revisando con ${reviewAgents.length} agente(s) en paralelo…` + const agentRuns = await Promise.all(reviewAgents.map(agent => runReviewAgent(agent, onePassPrompt, 'analysis'))) + lastBatchRuns = agentRuns + reviewRuns.push(...agentRuns.filter(run => run.report || run.error)) + saveReviewCheckpoint() + + // With ≥2 agents, one of them consolidates everyone's analysis into a final + // report (the pipeline: each agent analyses, the last one synthesizes). + const reportsToSynthesize = reviewRuns.filter(run => run.report).map(run => ({ label: run.label, report: run.report as string })) + if (!reviewStopped && reportsToSynthesize.length >= 2) { + progressStatus.textContent = 'Síntesis final…' + const verifierAgent = reviewAgents.at(-1) ?? reviewAgents[0] + const synthesisPrompt = buildReviewSynthesisPrompt(sharedPrompt, reportsToSynthesize) + const synthesisRun = await runReviewAgent(verifierAgent, synthesisPrompt, 'verification') + synthesisRun.label = 'Síntesis final' + reviewRuns.push(synthesisRun) + saveReviewCheckpoint() + } + + if (reviewStopped) throw new Error('Review stopped') + const successfulRuns = reviewRuns.filter(run => run.report) + if (!successfulRuns.length) throw new Error('No valid review responses') + + const snapshotAfter = await invoke('review_snapshot', { repoPath: worktree }) + const content = buildReviewDocument(reviewMeta(), reviewRuns) + const followUpSession = resolveReviewFollowUpSession(reviewRuns, reviewRuns.length) + saveReviewCheckpoint() + showResult(content, reviewCommit, followUpSession) + if (snapshotAfter !== snapshotBefore) showReviewError('Repository changed during review; findings may be stale') + } catch (error) { + // Never discard completed findings on failure/stop: render + persist what + // we have and show the error as a note, instead of wiping the drawer. + const salvaged = outputRuns().filter(run => run.report) + if (salvaged.length) { + saveReviewCheckpoint() + showResult(buildReviewDocument(reviewMeta(), salvaged), reviewCommit, resolveReviewFollowUpSession(salvaged, salvaged.length)) + const note = Object.assign(document.createElement('div'), { className: 'review-error', textContent: `Review incompleto (se guardó lo revisado): ${String(error)}` }) + reviewDrawerBody.prepend(note) + note.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } else { + reviewDrawerBody.replaceChildren(); state.showReviewDrawer(); showReviewError(String(error)) + } + } + finally { + clearInterval(timer) + if (!reviewStopped) reviewDrawerMeta.textContent = reviewDrawerMeta.textContent || reviewT('title') + if (managedWorktree) { + await invoke('review_branch_context_release', { path: worktree }).catch(error => showReviewError(String(error))) + } + aiReviewBtn.disabled = false + aiReviewBtn.title = 'AI Review' + } + } + + return { handleAiReviewClick } +} diff --git a/src/panels/review/reviewDataLoader.ts b/src/panels/review/reviewDataLoader.ts new file mode 100644 index 0000000..62dee5e --- /dev/null +++ b/src/panels/review/reviewDataLoader.ts @@ -0,0 +1,317 @@ +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 { parseDiffFiles } from '../diff/diffStats' +import { diffGit } from '../diff/diffGitClient' +import { reviewT } from './i18n' +import { renderMarkdown } from '../../core/notes/renderMarkdown' +import type { ReviewChangeFile, GhComment, GhPr, SidebarMode, FileTypeFilter } from './reviewFormat' +import { describeReviewPrState, describeReviewNoBranchChanges, getFileState, computeCiStatus, relativeTime } from './reviewFormat' + +export type StatusRollupEntry = { name?: string; workflowName?: string; conclusion?: string | null; state?: string; context?: string; targetUrl?: string } + +export interface ReviewDataLoaderDom { + filterBar: HTMLElement + diffSearchInput: HTMLInputElement + diffView: HTMLElement + prMetaEl: HTMLElement + prBodyEl: HTMLElement + discussionEl: HTMLElement + commentBar: HTMLElement + branchInput: HTMLInputElement + viewedCounterEl: HTMLElement + commentNavWrap: HTMLElement + commentInput: HTMLTextAreaElement + approveBtn: HTMLButtonElement + requestChangesBtn: HTMLButtonElement +} + +export interface ReviewDataLoaderState { + getRepoPath: () => string + setRepoPath: (v: string) => void + getBaseBranch: () => string + setBaseBranch: (v: string) => void + getSelectedBranch: () => string + setSelectedBranch: (v: string) => void + getActiveLocalBranch: () => string + setActiveLocalBranch: (v: string) => void + getAllBranches: () => string[] + setAllBranches: (v: string[]) => void + getCurrentPrNumber: () => number | null + setCurrentPrNumber: (v: number | null) => void + getExistingComments: () => GhComment[] + setExistingComments: (v: GhComment[]) => void + getLoadingBranch: () => string + setLoadingBranch: (v: string) => void + getSidebarMode: () => SidebarMode + setOpenPrs: (v: GhPr[]) => void + setFileTypeFilter: (v: FileTypeFilter) => void + getTotalFiles: () => number + setTotalFiles: (v: number) => void + getLastFiles: () => ReviewChangeFile[] + setLastFiles: (v: ReviewChangeFile[]) => void + setLastStatusRollup: (v: StatusRollupEntry[]) => void + setResolvedComments: (v: Set) => void + getResolvedComments: () => Set + nextDiscSeq: () => number + getDiscSeq: () => number + nextPrInfoSeq: () => number + getPrInfoSeq: () => number + setCurrentPrTitle: (v: string) => void + setCurrentPrBody: (v: string) => void + getCurrentPrState: () => string | null + setCurrentPrState: (v: string | null) => void + getViewedFiles: () => Set + renderBranchList: () => void + renderPrList: () => void + loadPrList: () => Promise + renderFiles: () => void + injectExistingComments: () => void + updateViewedCounter: () => void + showCommentStatus: (text: string, isError?: boolean) => void + showCiPopover: (anchor: HTMLElement) => void +} + +export interface ReviewDataLoader { + loadDiff: () => Promise + loadPrInfo: () => Promise + loadExistingComments: () => Promise + selectBranch: (branch: string) => Promise + submitReview: (event: 'APPROVE' | 'REQUEST_CHANGES') => Promise + loadBranches: () => Promise + pickRepo: () => Promise + prIdentifier: () => string +} + +const REPO_KEY = 'bento.review.repo' +const BASE_KEY = 'bento.review.base' + +export function buildReviewDataLoader(dom: ReviewDataLoaderDom, state: ReviewDataLoaderState): ReviewDataLoader { + const { filterBar, diffSearchInput, diffView, prMetaEl, prBodyEl, discussionEl, commentBar, branchInput, viewedCounterEl, commentNavWrap, commentInput, approveBtn, requestChangesBtn } = dom + + const ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') + + const loadExistingComments = async (): Promise => { + if (state.getCurrentPrNumber() === null) { state.setExistingComments([]); return } + try { + const raw = await invoke('gh_pr_list_comments', { path: state.getRepoPath(), prNumber: state.getCurrentPrNumber() }) + state.setExistingComments(raw.filter(c => c.line != null)) + state.setResolvedComments(state.getResolvedComments()) + } catch { state.setExistingComments([]) } + } + + // ── Load diff ───────────────────────────────────────────────────────────── + const loadDiff = async (): Promise => { + filterBar.classList.add('hidden') + diffSearchInput.classList.add('hidden') + state.setFileTypeFilter('all') + diffView.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-loading', textContent: reviewT('loading') })) + try { + const repoPath = state.getRepoPath() + const baseBranch = state.getBaseBranch() + const selectedBranch = state.getSelectedBranch() + let raw = selectedBranch === state.getActiveLocalBranch() + ? await diffGit.reviewWorktreeDiff(repoPath, baseBranch) + : await invoke('git_ref_diff', { path: repoPath, base: baseBranch, target: selectedBranch }) + if (!raw.trim() && state.getCurrentPrState() === 'MERGED' && state.getCurrentPrNumber() !== null) { + const prDiff = await invoke('gh_pr_diff_number', { path: repoPath, prNumber: state.getCurrentPrNumber() }).catch(() => '') + if (prDiff.trim()) raw = prDiff + } + if (!raw.trim()) { + state.setTotalFiles(0); state.setLastFiles([]); state.updateViewedCounter() + diffView.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-no-changes', textContent: describeReviewNoBranchChanges(state.getCurrentPrState(), baseBranch) })) + return + } + const lastFiles = parseDiffFiles(raw).map(f => ({ ...f, state: getFileState(f.chunk) })) + state.setLastFiles(lastFiles) + state.setTotalFiles(lastFiles.length) + state.updateViewedCounter() + state.renderFiles() + diffSearchInput.classList.remove('hidden') + } catch (e) { + diffView.replaceChildren(Object.assign(document.createElement('div'), { className: 'review-error', textContent: String(e) })) + } + } + + // ── Load PR info ────────────────────────────────────────────────────────── + const loadPrInfo = async (): Promise => { + const myPrSeq = state.nextPrInfoSeq() + state.setCurrentPrNumber(null); state.setExistingComments([]); state.setCurrentPrTitle(''); state.setCurrentPrBody('') + prMetaEl.replaceChildren(); prBodyEl.innerHTML = ''; prBodyEl.classList.add('hidden') + discussionEl.replaceChildren(); discussionEl.classList.add('hidden') + commentBar.classList.add('hidden') + state.setLastStatusRollup([]) + try { + const repoPath = state.getRepoPath() + const pr = await invoke<{ + number: number; title: string; url: string; body: string; state?: string; mergedAt?: string | null + statusCheckRollup: StatusRollupEntry[] + reviewDecision: string | null + } | null>('gh_pr_view_branch', { path: repoPath, branch: ghBranch(state.getSelectedBranch()) }) + if (state.getPrInfoSeq() !== myPrSeq) return + if (pr) { + state.setCurrentPrNumber(pr.number) + state.setCurrentPrTitle(pr.title) + state.setCurrentPrBody(pr.body ?? '') + state.setCurrentPrState(pr.state ?? null) + const statusRollup = pr.statusCheckRollup ?? [] + state.setLastStatusRollup(statusRollup) + 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(() => {}) }) + prMetaEl.append(link) + + const stateBadge = describeReviewPrState(pr.state, pr.mergedAt) + if (stateBadge) { + prMetaEl.append(Object.assign(document.createElement('span'), { + className: `review-pr-state ${stateBadge.cls}`, + textContent: stateBadge.text, + title: stateBadge.title, + })) + } + + const ci = computeCiStatus(statusRollup) + if (ci !== 'none') { + const ciEl = Object.assign(document.createElement('span'), { + className: `review-ci review-ci--${ci}`, + textContent: ci === 'success' ? '✓ CI' : ci === 'failure' ? '✗ CI' : '⟳ CI', + }) + ciEl.style.cursor = 'pointer' + ciEl.addEventListener('click', e => { e.stopPropagation(); state.showCiPopover(ciEl) }) + prMetaEl.append(ciEl) + } + + const decMap: Record = { + APPROVED: { text: '✓ Approved', cls: 'review-decision--approved' }, + CHANGES_REQUESTED: { text: '✗ Changes requested', cls: 'review-decision--changes' }, + REVIEW_REQUIRED: { text: '? Review required', cls: 'review-decision--required' }, + } + const dec = pr.reviewDecision ? decMap[pr.reviewDecision] : null + if (dec) prMetaEl.append(Object.assign(document.createElement('span'), { className: `review-decision ${dec.cls}`, textContent: dec.text })) + + if (pr.body?.trim()) { + prBodyEl.innerHTML = `Description${renderMarkdown(pr.body)}` + prBodyEl.classList.remove('hidden') + } + commentBar.classList.remove('hidden') + await loadExistingComments() + + // ── Discussion thread — loaded separately so a failure doesn't break PR info ── + const myDiscSeq = state.nextDiscSeq() + invoke<{ comments: any[]; reviews: any[] }>('gh_pr_list_discussion', { path: repoPath, prNumber: pr.number }) + .then(disc => { + if (state.getDiscSeq() !== myDiscSeq) return // newer loadPrInfo started + type DiscItem = { author: string; body: string; time: string; decision?: { text: string; cls: string } } + const discItems: DiscItem[] = [ + ...(disc.reviews ?? []) + .filter((r: any) => r.body?.trim() && r.state !== 'PENDING') + .map((r: any) => ({ author: r.user?.login ?? '?', body: r.body, time: r.submitted_at ?? '', decision: decMap[r.state] })), + ...(disc.comments ?? []) + .filter((c: any) => c.body?.trim()) + .map((c: any) => ({ author: c.user?.login ?? '?', body: c.body, time: c.created_at ?? '' })), + ].sort((a, b) => a.time.localeCompare(b.time)) + if (discItems.length === 0) return + const hdr = Object.assign(document.createElement('div'), { + className: 'review-discussion-header', + textContent: `Discussion · ${discItems.length}`, + }) + discussionEl.replaceChildren(hdr, ...discItems.map(item => { + const msg = document.createElement('div') + msg.className = 'review-discussion-item' + const meta = document.createElement('div') + meta.className = 'review-discussion-meta' + meta.append(Object.assign(document.createElement('span'), { className: 'review-comment-author', textContent: item.author })) + if (item.decision) meta.append(Object.assign(document.createElement('span'), { className: `review-decision ${item.decision.cls} review-decision--sm`, textContent: item.decision.text })) + if (item.time) meta.append(Object.assign(document.createElement('span'), { className: 'review-comment-time', textContent: relativeTime(item.time) })) + const bodyDiv = Object.assign(document.createElement('div'), { className: 'review-discussion-body' }) + bodyDiv.innerHTML = renderMarkdown(item.body) + msg.append(meta, bodyDiv) + return msg + })) + discussionEl.classList.remove('hidden') + }) + .catch(() => { /* discussion unavailable, PR info unaffected */ }) + + if (state.getSidebarMode() === 'prs') state.renderPrList() + } + } catch { state.setCurrentPrState(null) } + } + + const prIdentifier = (): string => { + const currentPrNumber = state.getCurrentPrNumber() + return currentPrNumber !== null ? String(currentPrNumber) : ghBranch(state.getSelectedBranch()) + } + + // ── Select branch ───────────────────────────────────────────────────────── + const selectBranch = async (branch: string): Promise => { + state.setSelectedBranch(branch); state.setLoadingBranch(branch) + state.renderBranchList() + if (state.getSidebarMode() === 'prs') state.renderPrList() + await Promise.all([loadDiff(), loadPrInfo()]) + if (state.getLoadingBranch() === branch) state.injectExistingComments() + } + + // ── Submit PR review (with summary confirm) ─────────────────────────────── + const submitReview = async (event: 'APPROVE' | 'REQUEST_CHANGES'): Promise => { + const currentPrNumber = state.getCurrentPrNumber() + if (currentPrNumber === null) return + const body = commentInput.value.trim() + const viewed = state.getViewedFiles().size + const key = event === 'APPROVE' ? 'approveConfirm' : 'requestChangesConfirm' + const msg = reviewT(key, { number: currentPrNumber, viewed, total: state.getTotalFiles(), comments: state.getExistingComments().length }) + if (!confirm(msg)) return + approveBtn.disabled = true; requestChangesBtn.disabled = true + try { + await invoke('gh_pr_submit_review', { path: state.getRepoPath(), prNumber: currentPrNumber, event, body }) + commentInput.value = '' + state.showCommentStatus(reviewT('reviewSubmitted')) + await loadPrInfo() + state.injectExistingComments() + } catch (e) { + state.showCommentStatus(String(e), true) + } finally { approveBtn.disabled = false; requestChangesBtn.disabled = false } + } + + // ── Load branches ───────────────────────────────────────────────────────── + const loadBranches = async (): Promise => { + const repoPath = state.getRepoPath() + if (!repoPath) return + const [defaultBranch, branches, currentBranch] = await Promise.all([ + diffGit.defaultBranch(repoPath), + diffGit.reviewBranches(repoPath), + diffGit.currentBranch(repoPath), + ]) + const allBranches = currentBranch + ? [currentBranch, ...branches.filter(branch => branch !== currentBranch)] + : branches + state.setAllBranches(allBranches) + state.setActiveLocalBranch(currentBranch) + if (!state.getBaseBranch()) { + const originDefault = `origin/${defaultBranch}` + const baseBranch = allBranches.includes(originDefault) ? originDefault : defaultBranch + state.setBaseBranch(baseBranch) + branchInput.value = baseBranch + localStorage.setItem(BASE_KEY, baseBranch) + } + state.renderBranchList() + state.loadPrList() + if (!state.getSelectedBranch() && currentBranch && currentBranch !== defaultBranch) { + void selectBranch(currentBranch) + } + } + + const pickRepo = async (): Promise => { + const picked = await pickFolder({ directory: true, multiple: false }).catch(() => null) + if (!picked || typeof picked !== 'string') return + state.setRepoPath(picked); state.setBaseBranch(''); branchInput.value = '' + state.setSelectedBranch(''); state.setActiveLocalBranch(''); state.setExistingComments([]); state.setTotalFiles(0) + state.setFileTypeFilter('all'); state.setOpenPrs([]); state.setLastFiles([]); state.setLastStatusRollup([]) + localStorage.setItem(REPO_KEY, picked) + diffView.replaceChildren(); filterBar.classList.add('hidden') + diffSearchInput.classList.add('hidden'); prBodyEl.classList.add('hidden') + commentBar.classList.add('hidden'); viewedCounterEl.classList.add('hidden') + commentNavWrap.classList.add('hidden') + await loadBranches() + } + + return { loadDiff, loadPrInfo, loadExistingComments, selectBranch, submitReview, loadBranches, pickRepo, prIdentifier } +} diff --git a/tests/panels/review/ReviewDiffView.test.ts b/tests/panels/review/ReviewDiffView.test.ts new file mode 100644 index 0000000..782b327 --- /dev/null +++ b/tests/panels/review/ReviewDiffView.test.ts @@ -0,0 +1,316 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { buildReviewDiffView, type ReviewDiffState } from '../../../src/panels/review/ReviewDiffView' +import type { ReviewChangeFile, GhComment } from '../../../src/panels/review/reviewFormat' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue('') +} + +function makeDom() { + return { + diffView: document.createElement('div'), + diffSearchInput: Object.assign(document.createElement('input'), { type: 'search' }) as HTMLInputElement, + filterBar: document.createElement('div'), + } +} + +const UNIFIED_CHUNK = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,2 +1,2 @@', + '-old line', + '+new line', + ' context line', +].join('\n') + +function makeFile(over: Partial = {}): ReviewChangeFile { + return { file: 'src/a.ts', additions: 1, deletions: 1, chunk: UNIFIED_CHUNK, state: 'M', ...over } as ReviewChangeFile +} + +interface Harness { + dom: ReturnType + state: ReviewDiffState + data: { + lastFiles: ReviewChangeFile[] + treeView: boolean + splitView: boolean + existingComments: GhComment[] + fileTypeFilter: import('../../../src/panels/review/reviewFormat').FileTypeFilter + viewedFiles: Set + repoPath: string + currentPrNumber: number | null + } + setFileViewed: ReturnType + buildCommentBubble: ReturnType + makeLineForm: ReturnType + updateCommentNav: ReturnType + showSentLink: ReturnType +} + +function makeHarness(overrides: Partial = {}): Harness { + const dom = makeDom() + const data: Harness['data'] = { + lastFiles: [makeFile()], + treeView: false, + splitView: false, + existingComments: [], + fileTypeFilter: 'all', + viewedFiles: new Set(), + repoPath: '/repo', + currentPrNumber: 42, + ...overrides, + } + const setFileViewed = vi.fn((file: string, viewed: boolean) => { + if (viewed) data.viewedFiles.add(file); else data.viewedFiles.delete(file) + }) + const buildCommentBubble = vi.fn((c: GhComment) => { + const el = document.createElement('div') + el.className = 'review-existing-comment' + el.dataset.commentId = String(c.id) + return el + }) + const makeLineForm = vi.fn(() => document.createElement('div')) + const updateCommentNav = vi.fn() + const showSentLink = vi.fn() + const state: ReviewDiffState = { + getLastFiles: () => data.lastFiles, + getTreeView: () => data.treeView, + getSplitView: () => data.splitView, + getExistingComments: () => data.existingComments, + getFileTypeFilter: () => data.fileTypeFilter, + setFileTypeFilter: value => { data.fileTypeFilter = value }, + resetFocusedFileIdx: vi.fn(), + getViewedFiles: () => data.viewedFiles, + setFileViewed, + repoPath: () => data.repoPath, + getCurrentPrNumber: () => data.currentPrNumber, + getPrIdentifier: () => (data.currentPrNumber !== null ? String(data.currentPrNumber) : 'branch'), + buildCommentBubble, + makeLineForm, + updateCommentNav, + showSentLink, + } + return { dom, state, data, setFileViewed, buildCommentBubble, makeLineForm, updateCommentNav, showSentLink } +} + +describe('renderFiles', () => { + it('renders one file-detail per file with correct state/filename dataset', () => { + setup() + const h = makeHarness({ lastFiles: [makeFile({ file: 'src/a.ts', state: 'M' }), makeFile({ file: 'src/b.ts', state: 'A' })] }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const details = h.dom.diffView.querySelectorAll('.review-file-detail') + expect(details).toHaveLength(2) + expect(details[0].dataset.filename).toBe('src/a.ts') + expect(details[0].dataset.filestate).toBe('M') + expect(details[1].dataset.filestate).toBe('A') + }) + + it('groups files by directory in tree view with a dir-name header', () => { + setup() + const h = makeHarness({ + treeView: true, + lastFiles: [makeFile({ file: 'src/nested/a.ts' }), makeFile({ file: 'root.ts' })], + }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const dirHeaders = [...h.dom.diffView.querySelectorAll('.review-tree-dir-name')].map(el => el.textContent) + expect(dirHeaders).toEqual(['src/nested/']) + expect(h.dom.diffView.querySelectorAll('.review-file-detail')).toHaveLength(2) + }) + + it('renders unified diff lines with correct add/del markup and line numbers', () => { + setup() + const h = makeHarness() + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const detail = h.dom.diffView.querySelector('.review-file-detail')! + const addWrap = detail.querySelector('.tasks-diff-line-add')!.closest('[data-line]') as HTMLElement + expect(addWrap.dataset.line).toBe('1') + expect(detail.querySelector('.tasks-diff-line-del')).toBeTruthy() + }) + + it('renders split view as left/right rows when splitView is enabled', () => { + setup() + const h = makeHarness({ splitView: true }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const detail = h.dom.diffView.querySelector('.review-file-detail')! + expect(detail.querySelector('.review-split-diff')).toBeTruthy() + expect(detail.querySelectorAll('.review-split-row').length).toBeGreaterThan(0) + }) + + it('resets focused file index on every render', () => { + setup() + const h = makeHarness() + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + expect(h.state.resetFocusedFileIdx).toHaveBeenCalled() + }) +}) + +describe('applyVisibility', () => { + it('hides files that do not match the active type filter', () => { + setup() + const h = makeHarness({ + lastFiles: [makeFile({ file: 'src/a.ts', state: 'M' }), makeFile({ file: 'src/b.ts', state: 'A' })], + fileTypeFilter: 'A', + }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const [a, b] = [...h.dom.diffView.querySelectorAll('.review-file-detail')] + expect(a.classList.contains('hidden')).toBe(true) + expect(b.classList.contains('hidden')).toBe(false) + }) + + it('hides files without comments when the commented filter is active', () => { + setup() + const h = makeHarness({ + lastFiles: [makeFile({ file: 'src/a.ts' }), makeFile({ file: 'src/b.ts' })], + existingComments: [{ id: 1, path: 'src/b.ts', line: 1, body: 'x', user: { login: 'u' }, html_url: '' }], + fileTypeFilter: 'commented', + }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const [a, b] = [...h.dom.diffView.querySelectorAll('.review-file-detail')] + expect(a.classList.contains('hidden')).toBe(true) + expect(b.classList.contains('hidden')).toBe(false) + }) + + it('hides files that do not match the search text', () => { + setup() + const h = makeHarness({ lastFiles: [makeFile({ file: 'src/a.ts' }), makeFile({ file: 'src/b.ts' })] }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + h.dom.diffSearchInput.value = 'b.ts' + view.applyVisibility() + const [a, b] = [...h.dom.diffView.querySelectorAll('.review-file-detail')] + expect(a.classList.contains('hidden')).toBe(true) + expect(b.classList.contains('hidden')).toBe(false) + }) + + it('re-applies visibility automatically when the search input fires', () => { + setup() + const h = makeHarness({ lastFiles: [makeFile({ file: 'src/a.ts' }), makeFile({ file: 'src/b.ts' })] }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + h.dom.diffSearchInput.value = 'a.ts' + h.dom.diffSearchInput.dispatchEvent(new Event('input')) + const [a, b] = [...h.dom.diffView.querySelectorAll('.review-file-detail')] + expect(a.classList.contains('hidden')).toBe(false) + expect(b.classList.contains('hidden')).toBe(true) + }) +}) + +describe('viewed checkbox', () => { + it('calls setFileViewed and marks the file viewed', () => { + setup() + const h = makeHarness() + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + const cb = h.dom.diffView.querySelector('.review-viewed-cb')! + cb.checked = true + cb.dispatchEvent(new Event('change')) + expect(h.setFileViewed).toHaveBeenCalledWith('src/a.ts', true) + expect(h.dom.diffView.querySelector('.review-file-detail')?.classList.contains('review-file-viewed')).toBe(true) + }) +}) + +describe('editor button', () => { + it('opens the file in the editor with the full repo path', () => { + setup() + const h = makeHarness({ repoPath: '/my/repo' }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + h.dom.diffView.querySelector('.review-editor-btn')!.click() + expect(mocks.invoke).toHaveBeenCalledWith('open_in_editor', { path: '/my/repo/src/a.ts' }) + }) +}) + +describe('file comment button', () => { + it('sends a file-level comment through gh_pr_comment and shows the sent link', async () => { + setup() + mocks.invoke.mockResolvedValueOnce('https://github.com/x/y/pull/42#comment') + const h = makeHarness({ currentPrNumber: 42 }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + h.dom.diffView.querySelector('.review-file-comment-btn')!.click() + const textarea = h.dom.diffView.querySelector('.review-file-comment-form textarea')! + textarea.value = 'looks good' + const sendBtn = h.dom.diffView.querySelector('.review-file-comment-form .review-comment-btn')! + sendBtn.click() + await vi.waitFor(() => expect(h.showSentLink).toHaveBeenCalled()) + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_comment', { path: '/repo', branch: '42', body: '**src/a.ts**\n\nlooks good' }) + }) + + it('does not send when there is no active PR', () => { + setup() + const h = makeHarness({ currentPrNumber: null }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + h.dom.diffView.querySelector('.review-file-comment-btn')!.click() + const textarea = h.dom.diffView.querySelector('.review-file-comment-form textarea')! + textarea.value = 'no pr yet' + h.dom.diffView.querySelector('.review-file-comment-form .review-comment-btn')!.click() + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) + +describe('injectExistingComments', () => { + it('injects a bubble inline next to the matching line and calls updateCommentNav', () => { + setup() + const h = makeHarness({ + existingComments: [{ id: 7, path: 'src/a.ts', line: 1, body: 'hi', user: { login: 'u' }, html_url: '' }], + }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + view.injectExistingComments() + expect(h.buildCommentBubble).toHaveBeenCalledTimes(1) + expect(h.dom.diffView.querySelector('.review-existing-comment')).toBeTruthy() + expect(h.dom.diffView.querySelector('.review-comment-orphans')).toBeFalsy() + expect(h.updateCommentNav).toHaveBeenCalled() + }) + + it('puts comments whose line is not in the diff into an orphan section', () => { + setup() + const h = makeHarness({ + existingComments: [{ id: 9, path: 'src/a.ts', line: 999, body: 'gone', user: { login: 'u' }, html_url: '' }], + }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + view.injectExistingComments() + expect(h.dom.diffView.querySelector('.review-comment-orphans')).toBeTruthy() + }) +}) + +describe('updateCommentBadges', () => { + it('shows a badge with the comment count for a file and hides it when empty', () => { + setup() + const h = makeHarness({ + existingComments: [{ id: 1, path: 'src/a.ts', line: 1, body: 'x', user: { login: 'u' }, html_url: '' }], + }) + const view = buildReviewDiffView(h.dom, h.state) + view.renderFiles() + view.updateCommentBadges() + const badge = h.dom.diffView.querySelector('.review-comment-badge')! + expect(badge.classList.contains('hidden')).toBe(false) + expect(badge.textContent).toContain('1') + + h.data.existingComments = [] + view.updateCommentBadges() + expect(badge.classList.contains('hidden')).toBe(true) + }) +}) diff --git a/tests/panels/review/reviewAiRun.test.ts b/tests/panels/review/reviewAiRun.test.ts new file mode 100644 index 0000000..1f8a9a0 --- /dev/null +++ b/tests/panels/review/reviewAiRun.test.ts @@ -0,0 +1,278 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown), + startAgent: vi.fn(), + askAi: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) +vi.mock('../../../src/core/ai/agentClient', () => ({ + redact: (v: string) => v, + startAgent: mocks.startAgent, +})) +vi.mock('../../../src/ui/askAi', () => ({ askAi: mocks.askAi })) + +import { buildReviewAiRun, type ReviewAiRunState } from '../../../src/panels/review/reviewAiRun' +import type { ReviewChangeFile } from '../../../src/panels/review/reviewFormat' +import type { AgentType } from '../../../src/core/ai/config' +import { techReviewCheckpointKey } from '../../../src/core/ai/chatHistory' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.startAgent.mockReset() + mocks.askAi.mockReset() +} + +function makeDom() { + return { + aiReviewBtn: document.createElement('button') as HTMLButtonElement, + reviewCompareAgentsToggle: Object.assign(document.createElement('input'), { type: 'checkbox' }) as HTMLInputElement, + reviewDrawer: document.createElement('aside'), + reviewDrawerMeta: document.createElement('span'), + reviewDrawerBody: document.createElement('div'), + diffView: document.createElement('div'), + } +} + +const SAMPLE_FILE: ReviewChangeFile = { + file: 'src/a.ts', additions: 1, deletions: 1, state: 'M', + chunk: 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n', +} as ReviewChangeFile + +interface Data { + repoPath: string + selectedBranch: string + baseBranch: string + lastFiles: ReviewChangeFile[] + currentPrNumber: number | null + currentPrTitle: string + currentPrBody: string + agents: AgentType[] +} + +interface Harness { + dom: ReturnType + data: Data + state: ReviewAiRunState + showReviewDrawer: ReturnType +} + +function makeHarness(overrides: Partial = {}): Harness { + const dom = makeDom() + const data: Data = { + repoPath: '/repo', + selectedBranch: 'origin/feat', + baseBranch: 'origin/main', + lastFiles: [SAMPLE_FILE], + currentPrNumber: null, + currentPrTitle: '', + currentPrBody: '', + agents: ['claude'], + ...overrides, + } + const showReviewDrawer = vi.fn(() => { dom.reviewDrawer.classList.add('visible'); dom.reviewDrawer.classList.remove('hidden') }) + const state: ReviewAiRunState = { + getRepoPath: () => data.repoPath, + getSelectedBranch: () => data.selectedBranch, + getBaseBranch: () => data.baseBranch, + getLastFiles: () => data.lastFiles, + getCurrentPrNumber: () => data.currentPrNumber, + getCurrentPrTitle: () => data.currentPrTitle, + getCurrentPrBody: () => data.currentPrBody, + selectedReviewAgents: () => data.agents, + showReviewDrawer, + mkIconBtn: (cls, title) => Object.assign(document.createElement('button'), { className: cls, title }), + } + return { dom, data, state, showReviewDrawer } +} + +function mockInvoke(map: Record) { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd in map) return map[cmd] + throw new Error(`unmocked invoke: ${cmd}`) + }) +} + +function successHandle() { + return { requestId: 'r', ready: Promise.resolve(), completed: Promise.resolve(), cancel: vi.fn(async () => {}), unlisten: vi.fn() } +} + +// Wires aiReviewBtn.click() -> handleAiReviewClick, exactly like ReviewPanel.ts does, +// so the context-form's self-retrigger ("Revisar" clicks aiReviewBtn) works in isolation. +function makeLoader(h: Harness) { + const loader = buildReviewAiRun(h.dom, h.state) + h.dom.aiReviewBtn.addEventListener('click', () => { void loader.handleAiReviewClick() }) + return loader +} + +describe('handleAiReviewClick guards', () => { + it('shows an error and does nothing when there is no repo', async () => { + setup() + const h = makeHarness({ repoPath: '' }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + expect(h.dom.diffView.querySelector('.review-error')?.textContent).toContain('Open a repository') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('shows an error and does nothing when there is no selected branch', async () => { + setup() + const h = makeHarness({ selectedBranch: '' }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + expect(h.dom.diffView.querySelector('.review-error')?.textContent).toContain('Select a branch') + }) + + it('shows an error and does nothing when there are no changed files', async () => { + setup() + const h = makeHarness({ lastFiles: [] }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + expect(h.dom.diffView.querySelector('.review-error')?.textContent).toContain('no changes') + }) + + it('requires at least two agents when compare mode is on', async () => { + setup() + const h = makeHarness({ agents: ['claude'] }) + h.dom.reviewCompareAgentsToggle.checked = true + const loader = makeLoader(h) + await loader.handleAiReviewClick() + expect(mocks.invoke).not.toHaveBeenCalled() + expect(h.dom.diffView.querySelector('.review-error')).toBeTruthy() + }) +}) + +describe('review context form', () => { + it('shows the context form on first click without starting a run', async () => { + setup() + const h = makeHarness() + const loader = makeLoader(h) + await loader.handleAiReviewClick() + expect(h.dom.reviewDrawerBody.querySelector('.review-context-form')).toBeTruthy() + expect(h.showReviewDrawer).toHaveBeenCalled() + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) + +describe('happy path', () => { + it('runs a single-agent review end to end and shows the result', async () => { + setup() + const h = makeHarness() + mockInvoke({ + review_branch_context_prepare: { path: '/wt', commit: 'abc1234', managed: true }, + review_snapshot: 'snap1', + review_branch_context_release: undefined, + }) + mocks.startAgent.mockImplementation((_p: unknown, onChunk: (c: string) => void, onDone: (s: string) => void) => { + onChunk('All good.') + onDone('sess-1') + return successHandle() + }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() // shows context form + h.dom.reviewDrawerBody.querySelector('.review-context-run')!.click() + await vi.waitFor(() => expect(h.dom.reviewDrawerBody.querySelector('.review-drawer-result')).toBeTruthy()) + expect(mocks.startAgent).toHaveBeenCalledTimes(1) + expect(mocks.askAi).toHaveBeenCalled() + expect(h.dom.aiReviewBtn.disabled).toBe(false) + const checkpoint = localStorage.getItem(techReviewCheckpointKey(h.data.repoPath, h.data.selectedBranch)) + expect(checkpoint).toBeTruthy() + expect(JSON.parse(checkpoint!).content).toContain('All good.') + }) + + it('synthesizes a final report when two agents both succeed', async () => { + setup() + const h = makeHarness({ agents: ['claude', 'codex'] }) + h.dom.reviewCompareAgentsToggle.checked = true + mockInvoke({ + review_branch_context_prepare: { path: '/wt', commit: 'abc1234', managed: false }, + review_snapshot: 'snap1', + }) + mocks.startAgent.mockImplementation((_p: unknown, onChunk: (c: string) => void, onDone: (s: string) => void) => { + onChunk('Report text.') + onDone('sess-x') + return successHandle() + }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + h.dom.reviewDrawerBody.querySelector('.review-context-run')!.click() + await vi.waitFor(() => expect(mocks.startAgent).toHaveBeenCalledTimes(3)) + }) +}) + +describe('failure handling', () => { + it('shows an error with no salvage when the worktree cannot be prepared', async () => { + setup() + const h = makeHarness() + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'review_branch_context_prepare') throw new Error('worktree busy') + throw new Error(`unmocked: ${cmd}`) + }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + h.dom.reviewDrawerBody.querySelector('.review-context-run')!.click() + await vi.waitFor(() => expect(h.dom.reviewDrawerBody.textContent).toContain('worktree busy')) + expect(h.dom.reviewDrawerBody.querySelector('.review-drawer-result')).toBeFalsy() + expect(h.dom.aiReviewBtn.disabled).toBe(false) + }) + + it('salvages a completed run when a later step fails', async () => { + setup() + const h = makeHarness() + let snapshotCalls = 0 + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'review_branch_context_prepare') return { path: '/wt', commit: 'abc1234', managed: true } + if (cmd === 'review_snapshot') { + snapshotCalls += 1 + if (snapshotCalls <= 2) return 'snap1' + throw new Error('snapshot failed') + } + if (cmd === 'review_branch_context_release') return undefined + throw new Error(`unmocked: ${cmd}`) + }) + mocks.startAgent.mockImplementation((_p: unknown, onChunk: (c: string) => void, onDone: (s: string) => void) => { + onChunk('Partial report.') + onDone('sess-2') + return successHandle() + }) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + h.dom.reviewDrawerBody.querySelector('.review-context-run')!.click() + await vi.waitFor(() => expect(h.dom.reviewDrawerBody.querySelector('.review-drawer-result')).toBeTruthy()) + expect(h.dom.reviewDrawerBody.textContent).toContain('Review incompleto') + }) +}) + +describe('stop button', () => { + it('cancels the active agent handle', async () => { + setup() + const h = makeHarness() + mockInvoke({ + review_branch_context_prepare: { path: '/wt', commit: 'abc1234', managed: true }, + review_snapshot: 'snap1', + review_branch_context_release: undefined, + }) + const cancel = vi.fn(async () => {}) + let resolveReady!: () => void + let resolveCompleted!: () => void + const ready = new Promise(resolve => { resolveReady = resolve }) + const completed = new Promise(resolve => { resolveCompleted = resolve }) + mocks.startAgent.mockImplementation(() => ({ requestId: 'r', ready, completed, cancel, unlisten: vi.fn() })) + const loader = makeLoader(h) + await loader.handleAiReviewClick() + h.dom.reviewDrawerBody.querySelector('.review-context-run')!.click() + const stopBtn = await vi.waitFor(() => { + const btn = h.dom.reviewDrawerBody.querySelector('.review-ai-stop-btn') + expect(btn?.disabled).toBe(false) + return btn! + }) + stopBtn.click() + await vi.waitFor(() => expect(cancel).toHaveBeenCalled()) + resolveReady(); resolveCompleted() + }) +}) diff --git a/tests/panels/review/reviewDataLoader.test.ts b/tests/panels/review/reviewDataLoader.test.ts new file mode 100644 index 0000000..dba1194 --- /dev/null +++ b/tests/panels/review/reviewDataLoader.test.ts @@ -0,0 +1,444 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown), + pickFolder: vi.fn(async () => null as string | null), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: mocks.pickFolder })) + +import { buildReviewDataLoader, type ReviewDataLoaderState, type StatusRollupEntry } from '../../../src/panels/review/reviewDataLoader' +import type { ReviewChangeFile, GhComment, GhPr, SidebarMode, FileTypeFilter } from '../../../src/panels/review/reviewFormat' + +function setup() { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.pickFolder.mockReset() + mocks.pickFolder.mockResolvedValue(null) +} + +function makeDom() { + return { + filterBar: document.createElement('div'), + diffSearchInput: Object.assign(document.createElement('input'), { type: 'search' }) as HTMLInputElement, + diffView: document.createElement('div'), + prMetaEl: document.createElement('div'), + prBodyEl: document.createElement('div'), + discussionEl: document.createElement('div'), + commentBar: document.createElement('div'), + branchInput: Object.assign(document.createElement('input'), { type: 'text' }) as HTMLInputElement, + viewedCounterEl: document.createElement('span'), + commentNavWrap: document.createElement('div'), + commentInput: document.createElement('textarea') as unknown as HTMLTextAreaElement, + approveBtn: document.createElement('button') as HTMLButtonElement, + requestChangesBtn: document.createElement('button') as HTMLButtonElement, + } +} + +interface Data { + repoPath: string + baseBranch: string + selectedBranch: string + activeLocalBranch: string + allBranches: string[] + currentPrNumber: number | null + existingComments: GhComment[] + loadingBranch: string + sidebarMode: SidebarMode + openPrs: GhPr[] + fileTypeFilter: FileTypeFilter + totalFiles: number + lastFiles: ReviewChangeFile[] + lastStatusRollup: StatusRollupEntry[] + resolvedComments: Set + discSeq: number + prInfoSeq: number + currentPrTitle: string + currentPrBody: string + currentPrState: string | null +} + +interface Harness { + dom: ReturnType + data: Data + state: ReviewDataLoaderState + renderBranchList: ReturnType + renderPrList: ReturnType + loadPrList: ReturnType + renderFiles: ReturnType + injectExistingComments: ReturnType + updateViewedCounter: ReturnType + showCommentStatus: ReturnType + showCiPopover: ReturnType +} + +function makeHarness(overrides: Partial = {}): Harness { + const dom = makeDom() + const data: Data = { + repoPath: '/repo', + baseBranch: 'origin/main', + selectedBranch: '', + activeLocalBranch: '', + allBranches: [], + currentPrNumber: null, + existingComments: [], + loadingBranch: '', + sidebarMode: 'branches', + openPrs: [], + fileTypeFilter: 'all', + totalFiles: 0, + lastFiles: [], + lastStatusRollup: [], + resolvedComments: new Set(), + discSeq: 0, + prInfoSeq: 0, + currentPrTitle: '', + currentPrBody: '', + currentPrState: null, + ...overrides, + } + const renderBranchList = vi.fn() + const renderPrList = vi.fn() + const loadPrList = vi.fn(async () => {}) + const renderFiles = vi.fn() + const injectExistingComments = vi.fn() + const updateViewedCounter = vi.fn() + const showCommentStatus = vi.fn() + const showCiPopover = vi.fn() + const state: ReviewDataLoaderState = { + getRepoPath: () => data.repoPath, + setRepoPath: v => { data.repoPath = v }, + getBaseBranch: () => data.baseBranch, + setBaseBranch: v => { data.baseBranch = v }, + getSelectedBranch: () => data.selectedBranch, + setSelectedBranch: v => { data.selectedBranch = v }, + getActiveLocalBranch: () => data.activeLocalBranch, + setActiveLocalBranch: v => { data.activeLocalBranch = v }, + getAllBranches: () => data.allBranches, + setAllBranches: v => { data.allBranches = v }, + getCurrentPrNumber: () => data.currentPrNumber, + setCurrentPrNumber: v => { data.currentPrNumber = v }, + getExistingComments: () => data.existingComments, + setExistingComments: v => { data.existingComments = v }, + getLoadingBranch: () => data.loadingBranch, + setLoadingBranch: v => { data.loadingBranch = v }, + getSidebarMode: () => data.sidebarMode, + setOpenPrs: v => { data.openPrs = v }, + setFileTypeFilter: v => { data.fileTypeFilter = v }, + getTotalFiles: () => data.totalFiles, + setTotalFiles: v => { data.totalFiles = v }, + getLastFiles: () => data.lastFiles, + setLastFiles: v => { data.lastFiles = v }, + setLastStatusRollup: v => { data.lastStatusRollup = v }, + setResolvedComments: v => { data.resolvedComments = v }, + getResolvedComments: () => data.resolvedComments, + nextDiscSeq: () => ++data.discSeq, + getDiscSeq: () => data.discSeq, + nextPrInfoSeq: () => ++data.prInfoSeq, + getPrInfoSeq: () => data.prInfoSeq, + setCurrentPrTitle: v => { data.currentPrTitle = v }, + setCurrentPrBody: v => { data.currentPrBody = v }, + getCurrentPrState: () => data.currentPrState, + setCurrentPrState: v => { data.currentPrState = v }, + getViewedFiles: () => new Set(), + renderBranchList, + renderPrList, + loadPrList, + renderFiles, + injectExistingComments, + updateViewedCounter, + showCommentStatus, + showCiPopover, + } + return { dom, data, state, renderBranchList, renderPrList, loadPrList, renderFiles, injectExistingComments, updateViewedCounter, showCommentStatus, showCiPopover } +} + +const DIFF_A = 'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\n' + +function mockInvoke(map: Record) { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd in map) return map[cmd] + throw new Error(`unmocked invoke: ${cmd}`) + }) +} + +describe('loadDiff', () => { + it('fetches via git_ref_diff for a non-active branch and renders the parsed files', async () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat', activeLocalBranch: 'main' }) + mockInvoke({ git_ref_diff: DIFF_A }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadDiff() + expect(mocks.invoke).toHaveBeenCalledWith('git_ref_diff', { path: '/repo', base: 'origin/main', target: 'origin/feat' }) + expect(h.data.lastFiles).toHaveLength(1) + expect(h.data.totalFiles).toBe(1) + expect(h.renderFiles).toHaveBeenCalled() + expect(h.updateViewedCounter).toHaveBeenCalled() + expect(h.dom.diffSearchInput.classList.contains('hidden')).toBe(false) + }) + + it('uses the worktree diff when the selected branch is the active local branch', async () => { + setup() + const h = makeHarness({ selectedBranch: 'main', activeLocalBranch: 'main' }) + mockInvoke({ git_review_worktree_diff: DIFF_A }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadDiff() + expect(mocks.invoke).toHaveBeenCalledWith('git_review_worktree_diff', { path: '/repo', base: 'origin/main' }) + expect(h.data.lastFiles).toHaveLength(1) + }) + + it('shows a no-changes message and resets file state when the diff is empty', async () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat', activeLocalBranch: 'main', lastFiles: [{ file: 'x' } as ReviewChangeFile], totalFiles: 1 }) + mockInvoke({ git_ref_diff: '' }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadDiff() + expect(h.data.totalFiles).toBe(0) + expect(h.data.lastFiles).toHaveLength(0) + expect(h.dom.diffView.querySelector('.review-no-changes')).toBeTruthy() + }) + + it('falls back to the PR diff when a merged PR has no direct diff', async () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat', activeLocalBranch: 'main', currentPrState: 'MERGED', currentPrNumber: 7 }) + mockInvoke({ git_ref_diff: '', gh_pr_diff_number: DIFF_A }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadDiff() + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_diff_number', { path: '/repo', prNumber: 7 }) + expect(h.data.lastFiles).toHaveLength(1) + }) + + it('shows an error message when the diff fetch rejects', async () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat', activeLocalBranch: 'main' }) + mocks.invoke.mockImplementation(async () => { throw new Error('boom') }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadDiff() + expect(h.dom.diffView.querySelector('.review-error')?.textContent).toContain('boom') + }) +}) + +describe('loadPrInfo', () => { + const pr = { + number: 42, title: 'Add thing', url: 'https://x/42', body: 'desc', state: 'OPEN', mergedAt: null, + statusCheckRollup: [{ conclusion: 'SUCCESS' }] as StatusRollupEntry[], + reviewDecision: 'APPROVED', + } + + it('populates PR metadata, comments and CI status on success', async () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat' }) + mockInvoke({ + gh_pr_view_branch: pr, + gh_pr_list_comments: [{ id: 1, path: 'a.ts', line: 3, body: 'x', user: { login: 'u' }, html_url: '' }], + gh_pr_list_discussion: { comments: [], reviews: [] }, + }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadPrInfo() + expect(h.data.currentPrNumber).toBe(42) + expect(h.data.currentPrTitle).toBe('Add thing') + expect(h.dom.prMetaEl.querySelector('.review-pr-link')?.textContent).toContain('#42') + expect(h.dom.prMetaEl.querySelector('.review-ci--success')).toBeTruthy() + expect(h.dom.prMetaEl.querySelector('.review-decision--approved')).toBeTruthy() + expect(h.data.existingComments).toHaveLength(1) + expect(h.dom.commentBar.classList.contains('hidden')).toBe(false) + }) + + it('ignores a stale response when a newer loadPrInfo call has already started', async () => { + setup() + const h = makeHarness({ selectedBranch: 'origin/feat' }) + let resolveFirst!: (v: typeof pr) => void + const firstPr = new Promise(resolve => { resolveFirst = resolve }) + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'gh_pr_view_branch') return h.data.prInfoSeq === 1 ? firstPr : { ...pr, number: 99 } + if (cmd === 'gh_pr_list_comments') return [] + if (cmd === 'gh_pr_list_discussion') return { comments: [], reviews: [] } + throw new Error(`unmocked: ${cmd}`) + }) + const loader = buildReviewDataLoader(h.dom, h.state) + const first = loader.loadPrInfo() + await loader.loadPrInfo() + expect(h.data.currentPrNumber).toBe(99) + resolveFirst(pr) + await first + // The stale first response must not clobber the second, newer result. + expect(h.data.currentPrNumber).toBe(99) + }) +}) + +describe('selectBranch', () => { + it('sets the branch, loads diff and PR info, then injects comments', async () => { + setup() + const h = makeHarness() + mockInvoke({ + git_ref_diff: '', + gh_pr_view_branch: null, + }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.selectBranch('origin/feat') + expect(h.data.selectedBranch).toBe('origin/feat') + expect(h.renderBranchList).toHaveBeenCalled() + expect(h.injectExistingComments).toHaveBeenCalled() + }) + + it('does not inject comments for a branch superseded by a newer selection', async () => { + setup() + const h = makeHarness() + let resolveFirstDiff!: (v: string) => void + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'git_ref_diff') { + return new Promise(resolve => { resolveFirstDiff = resolve }) + } + if (cmd === 'gh_pr_view_branch') return null + throw new Error(`unmocked: ${cmd}`) + }) + const loader = buildReviewDataLoader(h.dom, h.state) + const firstSelect = loader.selectBranch('origin/a') + // Supersede before the first diff resolves. + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'git_ref_diff') return '' + if (cmd === 'gh_pr_view_branch') return null + throw new Error(`unmocked: ${cmd}`) + }) + await loader.selectBranch('origin/b') + expect(h.injectExistingComments).toHaveBeenCalledTimes(1) + resolveFirstDiff('') + await firstSelect + // The stale first selectBranch call must not fire a second injection. + expect(h.injectExistingComments).toHaveBeenCalledTimes(1) + }) +}) + +describe('submitReview', () => { + it('does nothing without an active PR', async () => { + setup() + const h = makeHarness({ currentPrNumber: null }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.submitReview('APPROVE') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('does nothing when the confirmation is cancelled', async () => { + setup() + vi.stubGlobal('confirm', () => false) + const h = makeHarness({ currentPrNumber: 5 }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.submitReview('APPROVE') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('submits the review and refreshes PR info on confirm', async () => { + setup() + vi.stubGlobal('confirm', () => true) + const h = makeHarness({ currentPrNumber: 5, selectedBranch: 'origin/feat' }) + mockInvoke({ gh_pr_submit_review: 'ok', gh_pr_view_branch: null }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.submitReview('APPROVE') + expect(mocks.invoke).toHaveBeenCalledWith('gh_pr_submit_review', { path: '/repo', prNumber: 5, event: 'APPROVE', body: '' }) + expect(h.showCommentStatus).toHaveBeenCalledWith('Review submitted') + expect(h.injectExistingComments).toHaveBeenCalled() + }) +}) + +describe('loadBranches', () => { + it('puts the current branch first and defaults the base branch to origin/', async () => { + setup() + const h = makeHarness({ baseBranch: '' }) + mockInvoke({ + git_default_branch: 'main', + git_review_branches: ['origin/main', 'origin/feat/login'], + git_current_branch: 'feat/login', + }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadBranches() + expect(h.data.allBranches[0]).toBe('feat/login') + expect(h.data.baseBranch).toBe('origin/main') + expect(h.renderBranchList).toHaveBeenCalled() + expect(h.loadPrList).toHaveBeenCalled() + }) + + it('auto-selects the current branch when it differs from the default', async () => { + setup() + const h = makeHarness({ baseBranch: 'origin/main' }) + mockInvoke({ + git_default_branch: 'main', + git_review_branches: ['origin/main', 'origin/feat/login'], + git_current_branch: 'feat/login', + git_ref_diff: '', + gh_pr_view_branch: null, + }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadBranches() + expect(h.data.selectedBranch).toBe('feat/login') + }) +}) + +describe('pickRepo', () => { + it('does nothing when the folder picker is cancelled', async () => { + setup() + mocks.pickFolder.mockResolvedValue(null) + const h = makeHarness({ repoPath: '/old' }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.pickRepo() + expect(h.data.repoPath).toBe('/old') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('resets state and loads branches for the picked repo', async () => { + setup() + mocks.pickFolder.mockResolvedValue('/new/repo') + const h = makeHarness({ repoPath: '/old', existingComments: [{ id: 1 } as GhComment], totalFiles: 3 }) + mockInvoke({ git_default_branch: 'main', git_review_branches: [], git_current_branch: '' }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.pickRepo() + expect(h.data.repoPath).toBe('/new/repo') + expect(h.data.existingComments).toHaveLength(0) + expect(h.data.totalFiles).toBe(0) + expect(mocks.invoke).toHaveBeenCalledWith('git_default_branch', { repo: '/new/repo' }) + }) +}) + +describe('prIdentifier', () => { + it('returns the PR number when one is set', () => { + setup() + const h = makeHarness({ currentPrNumber: 11, selectedBranch: 'origin/feat' }) + const loader = buildReviewDataLoader(h.dom, h.state) + expect(loader.prIdentifier()).toBe('11') + }) + + it('falls back to the bare branch name (remote prefix stripped) when there is no PR', () => { + setup() + const h = makeHarness({ currentPrNumber: null, selectedBranch: 'origin/feat/login' }) + const loader = buildReviewDataLoader(h.dom, h.state) + expect(loader.prIdentifier()).toBe('feat/login') + }) +}) + +describe('loadExistingComments', () => { + it('filters out comments without a line number', async () => { + setup() + const h = makeHarness({ currentPrNumber: 3 }) + mockInvoke({ + gh_pr_list_comments: [ + { id: 1, path: 'a.ts', line: 2, body: 'x', user: { login: 'u' }, html_url: '' }, + { id: 2, path: 'a.ts', line: null, body: 'y', user: { login: 'u' }, html_url: '' }, + ], + }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadExistingComments() + expect(h.data.existingComments).toHaveLength(1) + expect(h.data.existingComments[0].id).toBe(1) + }) + + it('clears comments when there is no active PR', async () => { + setup() + const h = makeHarness({ currentPrNumber: null, existingComments: [{ id: 1 } as GhComment] }) + const loader = buildReviewDataLoader(h.dom, h.state) + await loader.loadExistingComments() + expect(h.data.existingComments).toHaveLength(0) + }) +}) From 72c1c7c8b3e8eb611d1d50fd41b6d75c374aae09 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 22 Aug 2026 22:37:05 +0200 Subject: [PATCH 03/51] refactor: split mobile web client into shared/terminal/review modules Broke the single 1398-line index.html (CSS + JS all inline) into two panels plus a shared bridge, matching the terminals/tech-review split the daemon already exposes: shared.{js,css}, terminal.{js,css}, and review.{js,css}. index.html now holds only markup plus the link/script tags that wire them back together. Added unauthenticated static-asset routes in mod.rs for the new files (the HTML has no server-side templating to carry the auth token into a script src, and the content itself is non-sensitive UI code); /, /api/*, and /ws/* keep their existing token check untouched. Added Rust tests asserting index.html references the split files and that each file contains its expected functions. --- daemon/bento-daemon/src/remote/mod.rs | 46 + daemon/bento-daemon/src/remote/web/index.html | 1151 +---------------- daemon/bento-daemon/src/remote/web/review.css | 151 +++ daemon/bento-daemon/src/remote/web/review.js | 753 +++++++++++ daemon/bento-daemon/src/remote/web/shared.css | 28 + daemon/bento-daemon/src/remote/web/shared.js | 25 + .../bento-daemon/src/remote/web/terminal.css | 26 + .../bento-daemon/src/remote/web/terminal.js | 154 +++ 8 files changed, 1189 insertions(+), 1145 deletions(-) create mode 100644 daemon/bento-daemon/src/remote/web/review.css create mode 100644 daemon/bento-daemon/src/remote/web/review.js create mode 100644 daemon/bento-daemon/src/remote/web/shared.css create mode 100644 daemon/bento-daemon/src/remote/web/shared.js create mode 100644 daemon/bento-daemon/src/remote/web/terminal.css create mode 100644 daemon/bento-daemon/src/remote/web/terminal.js diff --git a/daemon/bento-daemon/src/remote/mod.rs b/daemon/bento-daemon/src/remote/mod.rs index 8ce1c7a..9e15496 100644 --- a/daemon/bento-daemon/src/remote/mod.rs +++ b/daemon/bento-daemon/src/remote/mod.rs @@ -35,6 +35,12 @@ use review::{ }; const MOBILE_HTML: &str = include_str!("web/index.html"); +const SHARED_CSS: &str = include_str!("web/shared.css"); +const TERMINAL_CSS: &str = include_str!("web/terminal.css"); +const REVIEW_CSS: &str = include_str!("web/review.css"); +const SHARED_JS: &str = include_str!("web/shared.js"); +const TERMINAL_JS: &str = include_str!("web/terminal.js"); +const REVIEW_JS: &str = include_str!("web/review.js"); // ── Public types ───────────────────────────────────────────────────────────── @@ -121,6 +127,12 @@ impl RemoteControl { let state = Arc::new(RemoteState { manager, token, herdr_socket }); let app = Router::new() .route("/", get(index)) + .route("/shared.css", get(|| asset("text/css", SHARED_CSS))) + .route("/terminal.css", get(|| asset("text/css", TERMINAL_CSS))) + .route("/review.css", get(|| asset("text/css", REVIEW_CSS))) + .route("/shared.js", get(|| asset("text/javascript", SHARED_JS))) + .route("/terminal.js", get(|| asset("text/javascript", TERMINAL_JS))) + .route("/review.js", get(|| asset("text/javascript", REVIEW_JS))) .route("/api/terminals", get(terminals)) .route("/api/terminals", post(new_terminal)) .route("/api/terminals/:id", delete(kill_terminal)) @@ -243,6 +255,15 @@ async fn index(State(state): State>, Query(auth): Query) Html(MOBILE_HTML).into_response() } +// Static CSS/JS assets for the mobile web client. Unauthenticated: `index.html` +// is a compile-time `include_str!` constant with no server-side templating, so +// a ` - + + + @@ -458,941 +252,8 @@ - + + + diff --git a/daemon/bento-daemon/src/remote/web/review.css b/daemon/bento-daemon/src/remote/web/review.css new file mode 100644 index 0000000..5d34e3e --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/review.css @@ -0,0 +1,151 @@ +/* ── Review page ──────────────────────── */ +#page-review{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden} +#rv-project-bar{padding:12px 16px;border-bottom:1px solid var(--b);flex-shrink:0} +#rv-subtabs{display:flex;border-bottom:1px solid var(--b);flex-shrink:0} +.rv-tab{flex:1;padding:10px 4px;font-size:12px;font-weight:600;color:var(--dim);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent} +.rv-tab.active{color:var(--a);border-bottom-color:var(--a)} +.rv-subpage{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden} +.rv-subpage.active{display:flex} + +/* ── IA Review sub-page ───────────────── */ +#rv-ai-controls{padding:16px;display:flex;flex-direction:column;gap:12px;border-bottom:1px solid var(--b);flex-shrink:0} +.btn-stop{padding:12px 18px;background:transparent;border:1px solid var(--b);border-radius:14px;color:var(--dim);font-weight:600;font-size:14px;cursor:pointer;flex-shrink:0;white-space:nowrap} +.btn-stop:active{opacity:.7} +#rv-history{padding:8px 16px;display:flex;flex-wrap:nowrap;gap:8px;overflow-x:auto;border-bottom:1px solid var(--b);flex-shrink:0;-webkit-overflow-scrolling:touch;scrollbar-width:none} +#rv-history::-webkit-scrollbar{display:none} +.rv-hist-chip{flex-shrink:0;background:var(--s2);border:1px solid var(--b);border-radius:20px;padding:5px 10px 5px 12px;font-size:12px;cursor:pointer;display:flex;flex-direction:row;align-items:center;gap:6px;text-align:left} +.rv-hist-chip.active{border-color:var(--a);background:color-mix(in srgb,var(--a) 12%,transparent)} +.rv-hist-chip-info{display:flex;flex-direction:column;gap:2px;min-width:0} +.rv-hist-chip-branch{font-weight:600;color:var(--fg);white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis} +.rv-hist-chip-date{color:var(--dim);font-size:11px} +.rv-hist-chip-del{background:none;border:none;color:var(--dim);font-size:16px;line-height:1;padding:0 2px;cursor:pointer;flex-shrink:0;opacity:.6} +.rv-hist-chip-del:hover{opacity:1;color:var(--fg)} +.rv-agent-row{display:flex;align-items:center;gap:8px} +.rv-agent-select{flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--b);border-radius:10px;color:var(--fg);font-size:13px;-webkit-appearance:none;appearance:none} +.rv-agent-toggle-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--fg);cursor:pointer} +.rv-agent-toggle-row input{accent-color:var(--a);width:15px;height:15px;cursor:pointer;flex-shrink:0} +.rv-agent-extra{display:flex;align-items:center;gap:8px} +.rv-agent-extra.hidden{display:none} +.rv-agent-extra-label{font-size:12px;color:var(--dim);flex-shrink:0;min-width:72px} +.rv-agent-badge{font-size:12px;color:var(--dim);padding:4px 8px;background:var(--s2);border-radius:8px;border:1px solid var(--b);display:none} +#rv-progress{padding:12px 16px;border-bottom:1px solid var(--b);flex-shrink:0;background:var(--s)} +#rv-progress-header{display:flex;align-items:center;gap:8px;margin-bottom:8px} +#rv-progress-status{font-size:13px;font-weight:600;color:var(--fg);flex:1} +#rv-progress-meta{font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums} +#rv-progress-toggle{background:none;border:none;color:var(--dim);cursor:pointer;padding:2px 8px;font-size:12px;border-radius:6px;border:1px solid var(--b)} +#rv-progress-stream{font-size:11px;font-family:Menlo,Monaco,monospace;color:var(--dim);background:var(--bg);border:1px solid var(--b);border-radius:8px;padding:10px;max-height:140px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;-webkit-overflow-scrolling:touch} +#rv-progress-stream.collapsed{display:none} +#rv-output-wrap{display:flex;flex-direction:column;flex:1;min-height:0} +#rv-output-wrap.expanded{position:fixed;top:0;left:0;right:0;height:100dvh;z-index:999;background:var(--bg);display:flex;flex-direction:column} +#rv-output-bar{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;border-bottom:1px solid var(--b);flex-shrink:0;min-height:32px} +#rv-output-bar-label{font-size:11px;color:var(--dim)} +#rv-expand-btn{background:none;border:none;color:var(--dim);font-size:18px;cursor:pointer;padding:2px 4px;line-height:1} +#rv-expand-btn:hover{color:var(--fg)} +#rv-output{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:16px;touch-action:pan-y;-webkit-overflow-scrolling:touch} +#rv-output.empty-state{display:flex;align-items:center;justify-content:center} +#rv-chat{border-top:1px solid var(--b);flex-shrink:0;display:flex;flex-direction:column;max-height:45dvh} +#rv-chat-msgs{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;touch-action:pan-y;-webkit-overflow-scrolling:touch;padding:10px 14px;display:flex;flex-direction:column;gap:10px} +.rv-chat-q{align-self:flex-end;background:var(--a);color:#07070f;padding:8px 12px;border-radius:14px 14px 4px 14px;font-size:13px;max-width:85%;white-space:pre-wrap;word-break:break-word} +.rv-chat-a{align-self:flex-start;background:var(--s2);border:1px solid var(--b);padding:10px 12px;border-radius:4px 14px 14px 14px;font-size:13px;max-width:95%;min-width:60px} +.rv-chat-a.streaming{opacity:.8} +#rv-chat-input-row{display:flex;gap:8px;padding:8px 12px;border-top:1px solid var(--b);align-items:flex-end;flex-shrink:0} +#rv-ask-input{flex:1;padding:9px 12px;background:var(--bg);border:1px solid var(--b);border-radius:12px;color:var(--fg);font-size:14px;resize:none;outline:none;max-height:120px;overflow-y:auto;-webkit-appearance:none;line-height:1.4} +#rv-ask-send{padding:9px 14px;background:var(--a);border:none;border-radius:10px;color:#07070f;font-size:16px;cursor:pointer;flex-shrink:0;line-height:1} +#rv-ask-send:disabled{opacity:.4;cursor:default} +.rv-placeholder{text-align:center;color:var(--dim);font-size:14px;line-height:1.7} +.rv-md h2{font-size:16px;font-weight:700;color:var(--a);margin:20px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--b)} +.rv-md h2:first-child{margin-top:0} +.rv-md h3{font-size:14px;font-weight:600;color:var(--fg);margin:14px 0 6px} +.rv-md p{font-size:14px;line-height:1.65;margin-bottom:10px;color:var(--fg)} +.rv-md ul,.rv-md ol{padding-left:20px;margin-bottom:10px} +.rv-md li{font-size:14px;line-height:1.6;color:var(--fg);margin-bottom:4px} +.rv-md code{font-family:Menlo,Monaco,monospace;font-size:12px;background:var(--s2);border:1px solid var(--b);padding:1px 5px;border-radius:4px;color:var(--ag)} +.rv-md pre{background:var(--s);border:1px solid var(--b);border-radius:10px;padding:12px;overflow-x:auto;margin-bottom:12px} +.rv-md pre code{background:none;border:none;padding:0;color:var(--fg);font-size:12px} +.rv-md strong{font-weight:700;color:var(--fg)} +.rv-md em{font-style:italic;color:var(--dim)} + +/* ── Archivos sub-page ────────────────── */ +#rv-files-controls{padding:12px 16px;display:flex;gap:8px;align-items:flex-end;border-bottom:1px solid var(--b);flex-shrink:0} +#rv-files-controls .field{flex:1;caret-color:var(--a)} +#rv-files-load{padding:11px 16px;background:var(--a);border:none;border-radius:12px;color:#07070f;font-weight:700;font-size:14px;cursor:pointer;flex-shrink:0;white-space:nowrap} +#rv-files-load:active{opacity:.8} +#rv-files-list{flex:1;overflow-y:auto;touch-action:pan-y;-webkit-overflow-scrolling:touch} +.file-item{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--b);cursor:pointer} +.file-item:active{background:var(--s2)} +.file-badge{font-size:10px;font-weight:700;width:20px;text-align:center;padding:2px 4px;border-radius:4px;flex-shrink:0} +.badge-M{background:rgba(167,139,250,.15);color:var(--a)} +.badge-A{background:rgba(115,218,202,.15);color:var(--ag)} +.badge-D{background:rgba(247,118,142,.15);color:var(--re)} +.badge-R{background:rgba(224,175,104,.15);color:var(--yw)} +.file-path{flex:1;font-size:12px;font-family:Menlo,Monaco,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)} +.file-stat{font-size:11px;flex-shrink:0;text-align:right;min-width:60px} +.stat-add{color:var(--ag)} +.stat-del{color:var(--re)} + +/* ── Folder browser ──────────────────── */ +.dir-item{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--b);cursor:pointer;font-size:14px;color:var(--fg)} +.dir-item:active{background:var(--s2)} +.dir-up{color:var(--dim);font-size:13px} +.dir-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dir-arrow{color:var(--dim);flex-shrink:0} +.folder-select-bar{padding:12px 16px;border-top:1px solid var(--b);flex-shrink:0} + +/* ── PRs sub-page ─────────────────────── */ +#rv-prs-list{flex:1;overflow-y:auto;touch-action:pan-y;-webkit-overflow-scrolling:touch} +.pr-item{display:flex;flex-direction:column;gap:4px;padding:14px 16px;border-bottom:1px solid var(--b);cursor:pointer} +.pr-item:active{background:var(--s2)} +.pr-number{font-size:11px;color:var(--dim)} +.pr-title-text{font-size:14px;font-weight:600;color:var(--fg);line-height:1.3} +.pr-meta{font-size:11px;color:var(--dim)} + +/* ── Overlay (file diff + PR detail) ─── */ +.overlay{display:none;flex-direction:column;position:fixed;inset:0;background:var(--bg);z-index:200} +.overlay.on{display:flex} +.overlay-topbar{display:flex;align-items:center;gap:10px;padding:0 12px;height:48px;background:var(--s);border-bottom:1px solid var(--b);flex-shrink:0} +.overlay-back{background:none;border:none;color:var(--a);font-size:26px;padding:4px 6px;cursor:pointer;line-height:1} +.overlay-title{flex:1;font-size:13px;font-family:Menlo,Monaco,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--fg)} + +/* ── Diff view ────────────────────────── */ +.diff-scroll{flex:1;overflow:auto;touch-action:pan-y;-webkit-overflow-scrolling:touch} +.diff-inner{padding:0;font-family:Menlo,Monaco,monospace;font-size:12px;line-height:1.5;min-width:max-content} +.dl{padding:1px 12px;white-space:pre;display:block} +.dl-add{background:rgba(115,218,202,.08);color:#73daca} +.dl-del{background:rgba(247,118,142,.08);color:#f7768e} +.dl-hunk{background:rgba(167,139,250,.08);color:#a78bfa} +.dl-ctx{color:var(--fg)} +.dl-hdr{color:var(--dim)} + +/* ── PR overlay sub-tabs ──────────────── */ +#rv-pr-subtabs{display:flex;border-bottom:1px solid var(--b);flex-shrink:0} +.rv-pr-tab{flex:1;padding:10px 4px;font-size:12px;font-weight:600;color:var(--dim);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent} +.rv-pr-tab.active{color:var(--a);border-bottom-color:var(--a)} +.pr-subpage{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden} +.pr-subpage.active{display:flex} + +/* ── Comments ─────────────────────────── */ +#rv-comments-list{flex:1;overflow-y:auto;touch-action:pan-y;-webkit-overflow-scrolling:touch} +.comment-item{padding:14px 16px;border-bottom:1px solid var(--b)} +.comment-header{display:flex;align-items:baseline;gap:8px;margin-bottom:6px} +.comment-author{font-size:12px;font-weight:700;color:var(--a)} +.comment-date{font-size:11px;color:var(--dim)} +.comment-state{font-size:10px;font-weight:700;padding:2px 6px;border-radius:4px;margin-left:auto} +.state-APPROVED{background:rgba(115,218,202,.15);color:var(--ag)} +.state-CHANGES_REQUESTED{background:rgba(247,118,142,.15);color:var(--re)} +.state-COMMENTED{background:var(--s2);color:var(--dim)} +.comment-body-text{font-size:13px;line-height:1.55;color:var(--fg);white-space:pre-wrap;word-break:break-word} +.comment-form{padding:12px 16px;border-top:1px solid var(--b);flex-shrink:0;display:flex;flex-direction:column;gap:8px} +.comment-textarea{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--b);border-radius:10px;color:var(--fg);font-size:14px;resize:none;height:80px;outline:none;caret-color:var(--a)} +.comment-textarea:focus{border-color:var(--a)} +.btn-comment{padding:11px;background:var(--a);border:none;border-radius:10px;color:#07070f;font-weight:700;font-size:14px;cursor:pointer} +.btn-comment:active{opacity:.8} +.btn-comment:disabled{opacity:.4;cursor:default} + +/* ── Submit review ────────────────────── */ +#rv-submit-form{padding:16px;display:flex;flex-direction:column;gap:12px;overflow-y:auto;touch-action:pan-y;-webkit-overflow-scrolling:touch} +.submit-option{display:flex;align-items:center;gap:12px;padding:12px;background:var(--s);border:1px solid var(--b);border-radius:10px;cursor:pointer} +.submit-option input[type=radio]{accent-color:var(--a);width:16px;height:16px;flex-shrink:0} +.submit-option-label{font-size:14px;font-weight:600} +.opt-approve .submit-option-label{color:var(--ag)} +.opt-request .submit-option-label{color:var(--re)} +.opt-comment .submit-option-label{color:var(--fg)} diff --git a/daemon/bento-daemon/src/remote/web/review.js b/daemon/bento-daemon/src/remote/web/review.js new file mode 100644 index 0000000..ad6a4e3 --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/review.js @@ -0,0 +1,753 @@ +// ── Review state ─────────────────────────────────────────────────────────────── +let reviewSse=null; +let currentPR=null; +let reviewSessionId=null; +let reviewSessionAgent=null; + +function fmtRelDate(iso){ + try{ + const diff=Date.now()-new Date(iso).getTime(); + const mins=Math.floor(diff/60000); + if(mins<1)return 'ahora'; + if(mins<60)return mins+'m'; + const hrs=Math.floor(mins/60); + if(hrs<24)return hrs+'h'; + const days=Math.floor(hrs/24); + if(days<30)return days+'d'; + return new Date(iso).toLocaleDateString([],{day:'numeric',month:'short'}); + }catch(_){return '';} +} +async function saveReviewCheckpoint(dir,base,buf){ + if(!buf.trim())return; + try{ + const body={cwd:dir,base,content:buf,saved_at:new Date().toISOString()}; + if(reviewSessionId){body.session_id=reviewSessionId;} + if(reviewSessionAgent){body.session_agent=reviewSessionAgent;} + await fetch('/api/review/checkpoint'+q,{ + method:'PUT', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(body) + }); + await renderReviewHistory(dir); + }catch(_){} +} +async function renderReviewHistory(dir){ + const hist=document.getElementById('rv-history'); + if(!hist)return; + let items=[]; + try{ + const res=await fetch('/api/review/checkpoints'+q+'&cwd='+encodeURIComponent(dir)); + if(res.ok)items=await res.json(); + }catch(_){} + if(!items.length){hist.style.display='none';return;} + const currentBase=(document.getElementById('rv-base').value||'').trim()||'main'; + hist.innerHTML=''; + hist.style.display='flex'; + items.forEach(item=>{ + const chip=document.createElement('div'); + chip.className='rv-hist-chip'+(item.base===currentBase?' active':''); + chip.style.cursor='pointer'; + const info=document.createElement('div'); + info.className='rv-hist-chip-info'; + info.innerHTML=''+esc(item.base)+'' + +(item.saved_at?''+esc(fmtRelDate(item.saved_at))+'':''); + info.onclick=()=>{ + document.getElementById('rv-base').value=item.base; + void restoreReviewCheckpoint(dir,item.base); + void renderReviewHistory(dir); + }; + const del=document.createElement('button'); + del.className='rv-hist-chip-del'; + del.textContent='×'; + del.title='Eliminar review'; + del.onclick=async ev=>{ + ev.stopPropagation(); + try{await fetch('/api/review/checkpoint'+q+'&cwd='+encodeURIComponent(dir)+'&base='+encodeURIComponent(item.base),{method:'DELETE'});}catch(_){} + if(item.base===currentBase){ + const out=document.getElementById('rv-output'); + if(out){out.className='empty-state';out.innerHTML='
Elige un proyecto y una rama base
para iniciar la revisión.
';} + } + await renderReviewHistory(dir); + }; + chip.append(info,del); + hist.append(chip); + }); +} +async function restoreReviewCheckpoint(dir,base){ + const out=document.getElementById('rv-output'); + if(!out)return; + try{ + const res=await fetch('/api/review/checkpoint'+q+'&cwd='+encodeURIComponent(dir)+'&base='+encodeURIComponent(base)); + if(!res.ok){ + hideChat(); + out.className='empty-state'; + out.innerHTML='
Elige un proyecto y una rama base
para iniciar la revisión.
'; + return; + } + const cp=await res.json(); + out.className='rv-md'; + out.innerHTML=mdToHtml(cp.content||''); + showChat(); + }catch(_){} +} + +function cwd(){return document.getElementById('rv-project').value} + +// ── Output expand ────────────────────────────────────────────────────────────── +function toggleOutputExpand(){ + const wrap=document.getElementById('rv-output-wrap'); + const btn=document.getElementById('rv-expand-btn'); + const expanded=wrap.classList.toggle('expanded'); + btn.textContent=expanded?'✕':'⤢'; +} + +// ── Chat / follow-up ask ─────────────────────────────────────────────────────── +let askSse=null; + +function showChat(){ + const chat=document.getElementById('rv-chat'); + if(chat)chat.style.display='flex'; +} +function hideChat(){ + const chat=document.getElementById('rv-chat'); + if(chat)chat.style.display='none'; + const msgs=document.getElementById('rv-chat-msgs'); + if(msgs)msgs.innerHTML=''; +} + +function onAskKey(e){ + if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendAsk();} +} + +function sendAsk(){ + const input=document.getElementById('rv-ask-input'); + const question=(input.value||'').trim(); + if(!question)return; + if(askSse){askSse.close();askSse=null;} + + const dir=cwd(); + const base=(document.getElementById('rv-base').value||'').trim()||'main'; + const agent=document.getElementById('rv-agent')?.value||'claude'; + + const msgs=document.getElementById('rv-chat-msgs'); + const qEl=document.createElement('div'); + qEl.className='rv-chat-q'; + qEl.textContent=question; + msgs.append(qEl); + input.value=''; + input.style.height=''; + + const aEl=document.createElement('div'); + aEl.className='rv-chat-a streaming'; + msgs.append(aEl); + msgs.scrollTop=msgs.scrollHeight; + + const sendBtn=document.getElementById('rv-ask-send'); + sendBtn.disabled=true; + + let buf=''; + const url='/api/review/ask'+q+'&cwd='+encodeURIComponent(dir)+'&base='+encodeURIComponent(base) + +'&agent='+encodeURIComponent(agent)+'&question='+encodeURIComponent(question); + askSse=new EventSource(url); + askSse.onmessage=e=>{ + let data;try{data=JSON.parse(e.data);}catch(_){data=e.data;} + if(data==='[DONE]'){ + askSse.close();askSse=null; + aEl.classList.remove('streaming'); + sendBtn.disabled=false; + return; + } + buf+=data; + appendPinned(msgs,()=>{aEl.innerHTML=mdToHtml(buf);}); + }; + askSse.onerror=()=>{ + askSse.close();askSse=null; + aEl.classList.remove('streaming'); + if(!buf)aEl.textContent='Error al conectar con el agente.'; + sendBtn.disabled=false; + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +// Margen en px dentro del cual se considera que el usuario sigue "pegado" al fondo. +const SCROLL_PIN_SLACK=40; + +function isPinnedToBottom(el){ + return el.scrollHeight-el.scrollTop-el.clientHeight<=SCROLL_PIN_SLACK; +} + +// Escribe contenido nuevo y solo baja al fondo si el usuario ya estaba abajo. +// Hay que medir ANTES de mutar: al crecer scrollHeight nadie sigue "pegado". +function appendPinned(el,render){ + const wasPinned=isPinnedToBottom(el); + render(); + if(wasPinned)el.scrollTop=el.scrollHeight; +} + +function fmtDate(iso){ + const d=new Date(iso); + return d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); +} + +function renderDiff(text){ + return text.split('\n').map(line=>{ + if(line.startsWith('+++') || line.startsWith('---')) return `${esc(line)}`; + if(line.startsWith('+')) return `${esc(line)}`; + if(line.startsWith('-')) return `${esc(line)}`; + if(line.startsWith('@@')) return `${esc(line)}`; + return `${esc(line)}`; + }).join(''); +} + +function mdToHtml(text){ + // Escape first so any HTML in the reviewed content (diff text, AI output) can't + // inject markup — the markdown syntax below (#, *, `, -) survives esc() untouched. + text=esc(text); + text=text.replace(/```[\w]*\n([\s\S]*?)```/g,(_,c)=>'
'+c.trim()+'
'); + text=text.replace(/`([^`]+)`/g,(_,c)=>''+c+''); + text=text.replace(/^### (.+)$/gm,'

$1

'); + text=text.replace(/^## (.+)$/gm,'

$1

'); + text=text.replace(/^# (.+)$/gm,'

$1

'); + text=text.replace(/\*\*(.+?)\*\*/g,'$1'); + text=text.replace(/\*(.+?)\*/g,'$1'); + text=text.replace(/^[-*] (.+)$/gm,'
  • $1
  • '); + text=text.replace(/(
  • .*<\/li>\n?)+/g,'
      $&
    '); + text=text.replace(/^(?!<[hup]|<\/|$1

    '); + text=text.replace(/\n{2,}/g,''); + return text; +} + +// ── Review sub-tab switching ─────────────────────────────────────────────────── + +function switchReviewTab(name){ + document.querySelectorAll('.rv-tab').forEach((t,i)=>{ + const names=['ai','files','prs']; + t.classList.toggle('active',names[i]===name); + }); + document.querySelectorAll('.rv-subpage').forEach(p=>p.classList.remove('active')); + document.getElementById('rv-page-'+name).classList.add('active'); + if(name==='prs' && cwd()) loadPRs(); +} + +function onProjectChange(){ + const dir=cwd(); + if(!dir) return; + loadBranches(dir); + document.getElementById('rv-base').value='main'; + document.getElementById('rv-files-base').value='main'; + document.getElementById('rv-branch-info').style.display='none'; + document.getElementById('rv-files-list').innerHTML='
    Selecciona un proyecto y pulsa Ver.
    '; + void restoreReviewCheckpoint(dir,'main'); + void renderReviewHistory(dir); + initAgentUi(); + const tab=document.querySelector('.rv-tab.active'); + const idx=[...document.querySelectorAll('.rv-tab')].indexOf(tab); + if(idx===2) loadPRs(); +} + +async function loadBranches(dir){ + try{ + const r=await fetch('/api/review/branches'+q+'&cwd='+encodeURIComponent(dir)); + if(!r.ok) return; + const branches=await r.json(); + const dl=document.getElementById('rv-branches-datalist'); + dl.innerHTML=branches.map(b=>`';return} + const ps=await r.json(); + if(!ps.length){sel.innerHTML='';return} + const prev=sel.value; + sel.innerHTML=ps.map(p=>{ + const name=p.cwd.split('/').filter(Boolean).pop()||p.cwd; + const label=p.branch?`${name} · ${p.branch}`:name; + return ``; + }).join(''); + if(prev && [...sel.options].some(o=>o.value===prev)) sel.value=prev; + }catch(_){sel.innerHTML=''} +} + +// ── IA Review ────────────────────────────────────────────────────────────────── + +let reviewTimer=null; + +function toggleProgressStream(){ + const s=document.getElementById('rv-progress-stream'); + const t=document.getElementById('rv-progress-toggle'); + const collapsed=s.classList.toggle('collapsed'); + t.textContent=collapsed?'▶':'▼'; +} + +// ── Agent selector ───────────────────────────────────────────────────────────── + +const AGENT_LABELS={claude:'Claude',opencode:'OpenCode',codex:'Codex'}; +const AGENT_TYPES=['claude','opencode','codex']; +function agentLabel(a){return AGENT_LABELS[a]||a;} + +function selectedAgents(){ + const primary=document.getElementById('rv-agent').value||'claude'; + const toggle=document.getElementById('rv-compare-toggle').checked; + if(!toggle)return[primary]; + const sec=document.getElementById('rv-agent-secondary').value; + const ter=document.getElementById('rv-agent-tertiary').value; + const extras=[sec,ter].filter(v=>AGENT_TYPES.includes(v)); + return[primary,...extras]; +} + +function normalizeAgentSelects(){ + const primary=document.getElementById('rv-agent').value||'claude'; + const sec=document.getElementById('rv-agent-secondary'); + const ter=document.getElementById('rv-agent-tertiary'); + if(!sec.value)sec.value=primary; + if(!ter.value)ter.value=primary; +} + +function syncAgentUi(){ + const toggle=document.getElementById('rv-compare-toggle').checked; + document.getElementById('rv-agent-secondary-row').classList.toggle('hidden',!toggle); + document.getElementById('rv-agent-tertiary-row').classList.toggle('hidden',!toggle); + if(toggle)normalizeAgentSelects(); + localStorage.setItem('bento.review.agent',document.getElementById('rv-agent').value); + localStorage.setItem('bento.review.compare-agents',toggle?'1':'0'); + const sec=document.getElementById('rv-agent-secondary').value; + const ter=document.getElementById('rv-agent-tertiary').value; + if(sec)localStorage.setItem('bento.review.agent.secondary',sec); + else localStorage.removeItem('bento.review.agent.secondary'); + if(ter)localStorage.setItem('bento.review.agent.tertiary',ter); + else localStorage.removeItem('bento.review.agent.tertiary'); + const agents=selectedAgents().map(agentLabel); + const badge=document.getElementById('rv-agent-badge'); + badge.textContent=agents.length===1?'Agente: '+agents[0]:'Agentes: '+agents.join(' + '); + badge.style.display='block'; +} + +function initAgentUi(){ + const primary=localStorage.getItem('bento.review.agent')||'claude'; + const compare=localStorage.getItem('bento.review.compare-agents')==='1'; + const sec=localStorage.getItem('bento.review.agent.secondary')||''; + const ter=localStorage.getItem('bento.review.agent.tertiary')||''; + const agentEl=document.getElementById('rv-agent'); + const compareEl=document.getElementById('rv-compare-toggle'); + const secEl=document.getElementById('rv-agent-secondary'); + const terEl=document.getElementById('rv-agent-tertiary'); + if(agentEl&&AGENT_TYPES.includes(primary))agentEl.value=primary; + if(compareEl)compareEl.checked=compare; + if(secEl&&AGENT_TYPES.includes(sec))secEl.value=sec; + if(terEl&&AGENT_TYPES.includes(ter))terEl.value=ter; + syncAgentUi(); +} + +function stopReview(){ + if(reviewSse){reviewSse.close();reviewSse=null;} + if(reviewTimer){clearInterval(reviewTimer);reviewTimer=null;} + const btn=document.getElementById('rv-start'); + const stopBtn=document.getElementById('rv-stop'); + document.getElementById('rv-progress').style.display='none'; + btn.disabled=false;btn.textContent='Iniciar revisión'; + stopBtn.style.display='none'; +} + +function startReview(){ + const dir=cwd(); + const base=document.getElementById('rv-base').value.trim()||'main'; + const branch=(document.getElementById('rv-branch').value||'').trim(); + const context=document.getElementById('rv-context').value.trim(); + const agents=selectedAgents(); + if(!dir)return; + + if(reviewSse){reviewSse.close();reviewSse=null;} + if(reviewTimer){clearInterval(reviewTimer);reviewTimer=null;} + + const out=document.getElementById('rv-output'); + const progress=document.getElementById('rv-progress'); + const progressStatus=document.getElementById('rv-progress-status'); + const progressMeta=document.getElementById('rv-progress-meta'); + const progressStream=document.getElementById('rv-progress-stream'); + const btn=document.getElementById('rv-start'); + const stopBtn=document.getElementById('rv-stop'); + + hideChat(); + reviewSessionId=null; + reviewSessionAgent=null; + out.className='empty-state'; + out.innerHTML='
    Esperando síntesis…
    '; + progressStatus.textContent='Iniciando…'; + progressMeta.textContent='0s'; + progressStream.textContent=''; + progressStream.classList.remove('collapsed'); + document.getElementById('rv-progress-toggle').textContent='▼'; + progress.style.display='block'; + btn.disabled=true; + btn.innerHTML=' Analizando…'; + stopBtn.style.display='block'; + + // finalBuf = synthesis output (or single batch if no synthesis) + let finalBuf=''; + // batchBuf = live text for current batch (shown in progress stream) + let batchBuf=''; + let hasSynthesis=false; + // agentReports keeps each completed batch so we can fall back if synthesis fails + let agentReports=[]; + let startedAt=Date.now(); + + reviewTimer=setInterval(()=>{ + const secs=Math.floor((Date.now()-startedAt)/1000); + progressMeta.textContent=secs+'s'; + },500); + + const endReview=()=>{ + if(reviewTimer){clearInterval(reviewTimer);reviewTimer=null;} + progress.style.display='none'; + btn.disabled=false;btn.textContent='Iniciar revisión'; + stopBtn.style.display='none'; + }; + + let url='/api/review'+q+'&cwd='+encodeURIComponent(dir)+'&base='+encodeURIComponent(base)+'&agents='+encodeURIComponent(agents.join(',')); + if(branch)url+='&branch='+encodeURIComponent(branch); + if(context)url+='&context='+encodeURIComponent(context); + reviewSse=new EventSource(url); + + reviewSse.onmessage=e=>{ + let data;try{data=JSON.parse(e.data);}catch(_){data=e.data;} + + if(data==='[DONE]'){ + reviewSse.close();reviewSse=null; + if(batchBuf.trim())agentReports.push(batchBuf); + if(!hasSynthesis){ + finalBuf=agentReports[agentReports.length-1]||batchBuf; + out.className='rv-md'; + out.innerHTML=mdToHtml(finalBuf); + } + saveReviewCheckpoint(dir,base,finalBuf); + showChat(); + endReview(); + return; + } + + if(data.startsWith('[ERROR]')){ + out.className='rv-md'; + out.innerHTML='

    '+esc(data.slice(7).trim())+'

    '; + reviewSse.close();reviewSse=null; + endReview(); + return; + } + + const sessionMatch=data.match(/^\[SESSION:([^:]+):(.+)\]$/); + if(sessionMatch){ + reviewSessionAgent=sessionMatch[1]; + reviewSessionId=sessionMatch[2]; + return; + } + + const batchMatch=data.match(/^\[BATCH:(\d+)\/(\d+)\]$/); + if(batchMatch){ + const n=parseInt(batchMatch[1]),total=parseInt(batchMatch[2]); + if(batchBuf.trim()){agentReports.push(batchBuf);saveReviewCheckpoint(dir,base,batchBuf);} + batchBuf=''; + startedAt=Date.now(); + const isMulti=agents.length>1; + const label=isMulti + ?(agentLabel(agents[n-1]||agents[0])+' · '+n+'/'+total) + :('Batch '+n+'/'+total); + progressStatus.textContent=label; + progressStream.textContent=''; + return; + } + + if(data==='[SYNTHESIS]'){ + if(batchBuf.trim()){agentReports.push(batchBuf);saveReviewCheckpoint(dir,base,batchBuf);} + batchBuf=''; + hasSynthesis=true; + startedAt=Date.now(); + progressStatus.textContent='Síntesis final…'; + out.className='rv-md'; + out.innerHTML=''; + return; + } + + if(hasSynthesis){ + // Synthesis streams into the main output + finalBuf+=data; + appendPinned(out,()=>{out.innerHTML=mdToHtml(finalBuf);}); + } else { + // Batch streams into the progress area (live detail) + batchBuf+=data; + appendPinned(progressStream,()=>{progressStream.textContent=batchBuf;}); + } + }; + + reviewSse.onerror=()=>{ + reviewSse.close();reviewSse=null; + if(!finalBuf&&!batchBuf)out.innerHTML='

    Error de conexión.

    '; + endReview(); + }; +} + +// ── Archivos ─────────────────────────────────────────────────────────────────── + +async function loadFiles(){ + const dir=cwd(); + const base=document.getElementById('rv-files-base').value.trim()||'main'; + if(!dir)return; + + const el=document.getElementById('rv-files-list'); + el.innerHTML='
    Cargando…
    '; + + // Show branch indicator + const info=document.getElementById('rv-branch-info'); + const selOpt=[...document.getElementById('rv-project').selectedOptions][0]; + const optLabel=selOpt?.textContent||''; + const branchMatch=optLabel.match(/·\s*(.+)$/); + const currentBranch=branchMatch?branchMatch[1].trim():'HEAD'; + document.getElementById('rv-branch-current').textContent=currentBranch; + document.getElementById('rv-branch-base').textContent=base; + info.style.display='flex'; + + try{ + const r=await fetch('/api/review/files'+q+'&cwd='+encodeURIComponent(dir)+'&base='+encodeURIComponent(base)); + if(!r.ok){el.innerHTML='
    '+esc(await r.text())+'
    ';info.style.display='none';return} + const files=await r.json(); + if(!files.length){el.innerHTML='
    Sin cambios respecto a '+esc(base)+'.
    ';return} + + el.innerHTML=files.map(f=>{ + const badge=f.status||'M'; + const add=f.added>0?`+${f.added}`:''; + const del=f.deleted>0?` -${f.deleted}`:''; + return `
    + ${esc(badge)} + ${esc(f.path)} + ${add}${del} +
    `; + }).join(''); + }catch(e){el.innerHTML='
    Error de conexión.
    '} +} + +async function openFileDiff(path, base){ + const dir=cwd(); + const overlay=document.getElementById('rv-file-view'); + document.getElementById('rv-file-title').textContent=path; + document.getElementById('rv-diff-content').innerHTML='Cargando…'; + overlay.classList.add('on'); + + try{ + const r=await fetch('/api/review/file'+q+'&cwd='+encodeURIComponent(dir)+'&base='+encodeURIComponent(base)+'&path='+encodeURIComponent(path)); + const text=await r.text(); + document.getElementById('rv-diff-content').innerHTML=r.ok ? renderDiff(text) : `${esc(text)}`; + }catch(e){ + document.getElementById('rv-diff-content').innerHTML='Error de conexión.'; + } +} + +function closeFileDiff(){ + document.getElementById('rv-file-view').classList.remove('on'); +} + +// ── PRs ──────────────────────────────────────────────────────────────────────── + +async function loadPRs(){ + const dir=cwd(); + const el=document.getElementById('rv-prs-list'); + if(!dir){el.innerHTML='
    Selecciona un proyecto.
    ';return} + el.innerHTML='
    Cargando PRs…
    '; + + try{ + const r=await fetch('/api/review/prs'+q+'&cwd='+encodeURIComponent(dir)); + if(!r.ok){el.innerHTML='
    '+esc(await r.text())+'
    ';return} + const prs=await r.json(); + if(!prs.length){el.innerHTML='
    No hay PRs abiertos.
    ';return} + + el.innerHTML=prs.map(p=>` +
    + #${p.number} + ${esc(p.title)} + ${esc(p.headRefName||'')} · ${esc(p.author?.login||'')} +
    `).join(''); + }catch(e){el.innerHTML='
    Error de conexión.
    '} +} + +async function openPR(number, title){ + currentPR=number; + document.getElementById('rv-pr-title').textContent='#'+number+' '+title; + document.getElementById('rv-pr-view').classList.add('on'); + + // reset to Diff tab + switchPRTab('diff'); + loadPRDiff(); +} + +function closePRView(){ + document.getElementById('rv-pr-view').classList.remove('on'); + currentPR=null; +} + +function switchPRTab(name){ + document.querySelectorAll('.rv-pr-tab').forEach((t,i)=>{ + const names=['diff','comments','submit']; + t.classList.toggle('active',names[i]===name); + }); + document.querySelectorAll('.pr-subpage').forEach(p=>p.classList.remove('active')); + document.getElementById('rv-pr-page-'+name).classList.add('active'); + if(name==='comments') loadPRComments(); +} + +// ── PR Diff ──────────────────────────────────────────────────────────────────── + +async function loadPRDiff(){ + const dir=cwd(); + const el=document.getElementById('rv-pr-diff-content'); + el.innerHTML='Cargando diff…'; + + try{ + const r=await fetch('/api/review/pr/diff'+q+'&cwd='+encodeURIComponent(dir)+'&pr='+currentPR); + const text=await r.text(); + el.innerHTML=r.ok ? renderDiff(text) : `${esc(text)}`; + }catch(e){ + el.innerHTML='Error de conexión.'; + } +} + +// ── PR Comments ──────────────────────────────────────────────────────────────── + +async function loadPRComments(){ + const dir=cwd(); + const el=document.getElementById('rv-comments-list'); + el.innerHTML='
    Cargando…
    '; + + try{ + const r=await fetch('/api/review/pr/comments'+q+'&cwd='+encodeURIComponent(dir)+'&pr='+currentPR); + if(!r.ok){el.innerHTML='
    '+esc(await r.text())+'
    ';return} + const data=await r.json(); + + const comments=(data.comments||[]).map(c=>({...c,kind:'comment'})); + const reviews=(data.reviews||[]).filter(r=>r.body||r.state).map(r=>({...r,kind:'review'})); + const all=[...comments,...reviews].sort((a,b)=>new Date(a.createdAt)-new Date(b.createdAt)); + + if(!all.length){el.innerHTML='
    Sin comentarios aún.
    ';return} + + el.innerHTML=all.map(c=>{ + const author=c.author?.login||'unknown'; + const date=c.createdAt?fmtDate(c.createdAt):''; + const stateTag=c.kind==='review' && c.state && c.state!=='PENDING' + ? `${esc(c.state.replace('_',' '))}` : ''; + return `
    +
    + @${esc(author)} + ${esc(date)} + ${stateTag} +
    +
    ${esc(c.body||'')}
    +
    `; + }).join(''); + }catch(e){el.innerHTML='
    Error de conexión.
    '} +} + +async function submitComment(){ + const dir=cwd(); + const textarea=document.getElementById('rv-comment-text'); + const btn=document.getElementById('rv-comment-submit'); + const body=textarea.value.trim(); + if(!body)return; + + btn.disabled=true;btn.textContent='Enviando…'; + try{ + const r=await fetch('/api/review/pr/comment'+q+'&cwd='+encodeURIComponent(dir)+'&pr='+currentPR,{ + method:'POST', + headers:{'content-type':'application/json'}, + body:JSON.stringify({body}), + }); + if(r.ok){ + textarea.value=''; + loadPRComments(); + } + }finally{ + btn.disabled=false;btn.textContent='Comentar'; + } +} + +// ── Submit review ────────────────────────────────────────────────────────────── + +async function submitReview(){ + const dir=cwd(); + const event=[...document.querySelectorAll('input[name=rv-event]')].find(r=>r.checked)?.value||'COMMENT'; + const body=document.getElementById('rv-submit-body').value.trim(); + const btn=document.getElementById('rv-submit-btn'); + + btn.disabled=true;btn.textContent='Enviando…'; + try{ + const r=await fetch('/api/review/pr/submit'+q+'&cwd='+encodeURIComponent(dir)+'&pr='+currentPR,{ + method:'POST', + headers:{'content-type':'application/json'}, + body:JSON.stringify({event,body:body||undefined}), + }); + if(r.ok){ + document.getElementById('rv-submit-body').value=''; + closePRView(); + } + }finally{ + btn.disabled=false;btn.textContent='Enviar revisión'; + } +} + +// ── Folder browser ──────────────────────────────────────────────────────────── + +let folderCurrentPath = ''; + +function openFolderBrowser(){ + document.getElementById('rv-folder-view').classList.add('on'); + const startPath=cwd()||''; + browseDir(startPath||null); +} + +function closeFolderBrowser(){ + document.getElementById('rv-folder-view').classList.remove('on'); +} + +async function browseDir(path){ + const el=document.getElementById('rv-folder-list'); + el.innerHTML='
    Cargando…
    '; + try{ + const url='/api/fs/dirs'+q+(path?'&path='+encodeURIComponent(path):''); + const r=await fetch(url); + if(!r.ok){el.innerHTML='
    Error al leer directorio.
    ';return} + const data=await r.json(); + folderCurrentPath=data.path; + document.getElementById('rv-folder-path').textContent=data.path; + let html=''; + if(data.parent){ + html+=`
    .. subir
    `; + } + if(data.dirs.length){ + html+=data.dirs.map(d=>{ + const full=data.path.replace(/\/$/,'')+'/'+d; + return `
    📁${esc(d)}
    `; + }).join(''); + } else { + html+='
    Sin subcarpetas.
    '; + } + el.innerHTML=html; + el.querySelectorAll('.dir-item[data-path]').forEach(item=>{ + item.addEventListener('click',()=>browseDir(item.dataset.path)); + }); + }catch(e){el.innerHTML='
    Error de conexión.
    '} +} + +function selectFolder(){ + if(!folderCurrentPath) return; + const sel=document.getElementById('rv-project'); + let opt=[...sel.options].find(o=>o.value===folderCurrentPath); + if(!opt){ + opt=new Option(folderCurrentPath,folderCurrentPath); + sel.appendChild(opt); + } + sel.value=folderCurrentPath; + closeFolderBrowser(); + onProjectChange(); +} diff --git a/daemon/bento-daemon/src/remote/web/shared.css b/daemon/bento-daemon/src/remote/web/shared.css new file mode 100644 index 0000000..5af16e9 --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/shared.css @@ -0,0 +1,28 @@ +*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} +:root{--bg:#0d0d0d;--s:#161616;--s2:#1e1e1e;--b:#2a2a2a;--a:#a78bfa;--ag:#73daca;--re:#f7768e;--yw:#e0af68;--fg:#e2e8f8;--dim:#555} +html,body{height:100%;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,sans-serif;overflow:hidden} + +/* ── Tab bar ──────────────────────────── */ +#tabbar{display:flex;height:44px;background:var(--s);border-bottom:1px solid var(--b);flex-shrink:0} +.tab{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;font-size:13px;font-weight:600;color:var(--dim);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:color .15s,border-color .15s} +.tab.active{color:var(--a);border-bottom-color:var(--a)} + +/* ── Shared ───────────────────────────── */ +.list-head{font-size:11px;font-weight:700;letter-spacing:.1em;color:var(--dim);text-transform:uppercase;margin-bottom:14px;padding:0 2px} +.tb{display:flex;align-items:center;gap:12px;width:100%;padding:15px 16px;background:var(--s);border:1px solid var(--b);border-radius:14px;color:var(--fg);text-align:left;margin-bottom:10px;cursor:pointer;transition:background .1s} +.tb:active{background:var(--s2)} +.tb-ico{font-size:22px;flex-shrink:0} +.tb-info{flex:1;min-width:0} +.tb-name{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tb-cwd{font-size:11px;color:var(--dim);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tb-arrow{color:var(--dim);font-size:18px;flex-shrink:0} +.empty{padding:40px 16px;text-align:center;color:var(--dim);font-size:14px;line-height:1.6} +.err-msg{color:var(--re)} +input.field,select.field{width:100%;padding:11px 14px;background:var(--bg);border:1px solid var(--b);border-radius:12px;color:var(--fg);font-size:15px;outline:none;-webkit-appearance:none} +input.field:focus,select.field:focus{border-color:var(--a)} +.btn-primary{width:100%;padding:14px;background:var(--a);border:none;border-radius:12px;color:#07070f;font-size:15px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px} +.btn-primary:active{opacity:.8} +.btn-primary:disabled{opacity:.4;cursor:default} +.field-label{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin-bottom:4px} +.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(0,0,0,.3);border-top-color:#07070f;border-radius:50%;animation:spin .7s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} diff --git a/daemon/bento-daemon/src/remote/web/shared.js b/daemon/bento-daemon/src/remote/web/shared.js new file mode 100644 index 0000000..7a8e3da --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/shared.js @@ -0,0 +1,25 @@ +const token=new URLSearchParams(location.search).get('token')||''; +const q='?token='+encodeURIComponent(token); + +function esc(s){ + return String(s) + .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') +} + +// ── Main tab switching ───────────────────────────────────────────────────────── + +function switchTab(name){ + document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',i===(name==='review'?1:0))); + const pt=document.getElementById('page-terminals'); + const pr=document.getElementById('page-review'); + if(name==='review'){ + pt.style.display='none'; + pr.style.display='flex'; + loadProjects().then(()=>{const dir=cwd();if(dir)loadBranches(dir)}); + } else { + pr.style.display='none'; + pt.style.display='flex'; + load(); + } +} diff --git a/daemon/bento-daemon/src/remote/web/terminal.css b/daemon/bento-daemon/src/remote/web/terminal.css new file mode 100644 index 0000000..462f6a0 --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/terminal.css @@ -0,0 +1,26 @@ +/* ── Terminals page ───────────────────── */ +#page-terminals{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden} +#list{flex:1;overflow-y:auto;padding:16px 16px 80px;touch-action:pan-y;-webkit-overflow-scrolling:touch} +#newbtn{position:fixed;bottom:20px;left:16px;right:16px;padding:14px;background:var(--s);border:1px solid var(--b);border-radius:14px;color:var(--fg);font-size:15px;font-weight:600;cursor:pointer;text-align:center;z-index:10} +#newbtn:active{opacity:.7} + +/* ── Terminal view ────────────────────── */ +#view{display:none;flex-direction:column;height:100dvh} +#topbar{display:flex;align-items:center;gap:10px;padding:0 12px;height:48px;background:var(--s);border-bottom:1px solid var(--b);flex-shrink:0} +#back{background:none;border:none;color:var(--a);font-size:26px;padding:4px 6px;cursor:pointer;line-height:1} +#ttitle{flex:1;font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#dot{width:8px;height:8px;border-radius:50%;background:var(--ag);flex-shrink:0;transition:background .3s} +#dot.off{background:var(--re)} +#killbtn{background:none;border:none;color:var(--dim);font-size:20px;padding:4px 6px;cursor:pointer;line-height:1;flex-shrink:0} +#killbtn:active{color:var(--re)} +#tcon{flex:1;min-height:0;background:#000;overflow:hidden;touch-action:none;overscroll-behavior:contain} +#tcon .xterm,#tcon .xterm-viewport,#tcon .xterm-screen{height:100%!important} +#keys{display:flex;gap:5px;padding:6px 8px;background:var(--s);border-top:1px solid var(--b);overflow-x:auto;flex-shrink:0;scrollbar-width:none} +#keys::-webkit-scrollbar{display:none} +.k{flex-shrink:0;padding:7px 12px;background:var(--bg);border:1px solid var(--b);border-radius:8px;color:var(--fg);font-size:13px;font-family:monospace;cursor:pointer} +.k:active{background:var(--s2);border-color:var(--a)} +#inputbar{display:flex;gap:8px;padding:8px 10px;background:var(--s);border-top:1px solid var(--b);flex-shrink:0} +#inp{flex:1;padding:11px 14px;background:var(--bg);border:1px solid var(--b);border-radius:12px;color:var(--fg);font-size:16px;outline:none;-webkit-appearance:none;caret-color:var(--a)} +#inp:focus{border-color:var(--a)} +#sendbtn{padding:11px 18px;background:var(--a);border:none;border-radius:12px;color:#07070f;font-weight:700;font-size:16px;cursor:pointer} +#sendbtn:active{opacity:.8} diff --git a/daemon/bento-daemon/src/remote/web/terminal.js b/daemon/bento-daemon/src/remote/web/terminal.js new file mode 100644 index 0000000..cee45c1 --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/terminal.js @@ -0,0 +1,154 @@ +// ── Terminal state ───────────────────────────────────────────────────────────── +let ws,term,fit,ro,reconnTimer,reconnDelay,activeId,activeTitle,leaving=false; + +// ── Terminals ───────────────────────────────────────────────────────────────── + +function s(d){if(ws&&ws.readyState===1)ws.send(d)} + +function sendInp(){ + const inp=document.getElementById('inp'); + s(inp.value+'\r');inp.value='';inp.focus(); +} + +document.getElementById('inp').addEventListener('keydown',e=>{ + if(e.key==='Enter'){e.preventDefault();sendInp()} +}); + +async function load(){ + const el=document.getElementById('list'); + try{ + const r=await fetch('/api/terminals'+q); + if(!r.ok){el.innerHTML='
    Token inválido.
    ';return} + const ts=await r.json(); + if(!ts.length){el.innerHTML='
    No hay terminales abiertos.
    Abre un agente o terminal en Bento.
    ';return} + el.innerHTML='
    Terminales activos
    '; + ts.forEach(t=>{ + const b=document.createElement('button'); + b.className='tb'; + const sub=t.branch?'⎷ '+t.branch+(t.cwd?' · '+t.cwd:''):t.cwd||''; + b.innerHTML='
    '+esc(t.title||t.id)+'
    '+esc(sub)+'
    '; + b.onclick=()=>attach(t.id,t.title||t.id); + el.appendChild(b); + }); + }catch(e){el.innerHTML='
    No se pudo conectar al daemon.
    '} +} + +function sendResize(){ + if(ws&&ws.readyState===1&&term) + ws.send(JSON.stringify({type:'resize',rows:term.rows,cols:term.cols})); +} + +function connect(id){ + if(leaving)return; + const dot=document.getElementById('dot'); + ws=new WebSocket((location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/ws/'+id+q); + ws.onopen=()=>{dot.className='';reconnDelay=1000;sendResize()}; + ws.onmessage=e=>{ + if(typeof e.data==='string'){ + try{ + const msg=JSON.parse(e.data); + if(msg.type==='title'){activeTitle=msg.value;document.getElementById('ttitle').textContent=msg.value;return} + if(msg.type==='exit'){goBack();return} + }catch(_){} + term&&term.write(e.data); + }else{term&&term.write(new Uint8Array(e.data))} + }; + ws.onclose=()=>{ + if(leaving)return; + dot.className='off'; + term&&term.write('\r\n\x1b[33m[reconectando en '+(reconnDelay/1000)+'s…]\x1b[0m\r\n'); + reconnTimer=setTimeout(()=>connect(id),reconnDelay); + reconnDelay=Math.min(reconnDelay*2,16000); + }; +} + +// En móvil no existe el evento `wheel`, único que xterm traduce, así que el dedo +// no movía nada. No reimplementamos el scroll: xterm ya decide bien según el caso +// (scrollback normal, flechas en alt-screen respetando applicationCursorKeys, o +// eventos SGR si la TUI captura el ratón), y acumula el desplazamiento parcial. +// Basta con sintetizar el wheel que el navegador no emite. +function enableTouchScroll(el){ + let lastY=null; + el.addEventListener('touchstart',e=>{ + if(e.touches.length!==1)return; + lastY=e.touches[0].clientY; + },{passive:true}); + el.addEventListener('touchmove',e=>{ + const isTrackingOneFinger=lastY!==null&&e.touches.length===1; + if(!isTrackingOneFinger||!term||!term.element)return; + const t=e.touches[0]; + // Dedo hacia arriba (y decrece) = deltaY positivo = scroll hacia abajo. + const deltaY=lastY-t.clientY; + lastY=t.clientY; + if(!deltaY)return; + // Se despacha en el nodo más interno para que alcance a todos sus ancestros: + // el listener de xterm cuelga del contenedor y los eventos solo burbujean. + const target=term.element.querySelector('.xterm-screen')||term.element; + target.dispatchEvent(new WheelEvent('wheel',{ + deltaY,deltaMode:0,bubbles:true,cancelable:true, + clientX:t.clientX,clientY:t.clientY, + })); + e.preventDefault(); + },{passive:false}); + const stop=()=>{lastY=null}; + el.addEventListener('touchend',stop,{passive:true}); + el.addEventListener('touchcancel',stop,{passive:true}); +} + +function attach(id,title){ + leaving=false;activeId=id;activeTitle=title;reconnDelay=1000; + document.getElementById('page-terminals').style.display='none'; + document.getElementById('tabbar').style.display='none'; + const viewEl=document.getElementById('view'); + viewEl.style.display='flex'; + document.getElementById('ttitle').textContent=title; + document.getElementById('dot').className='off'; + + const con=document.getElementById('tcon'); + con.innerHTML=''; + term=new Terminal({fontSize:13,fontFamily:'Menlo,Monaco,"Cascadia Code",monospace',theme:{background:'#000000',foreground:'#e2e8f8',cursor:'#a78bfa',selectionBackground:'#3a3a5c'},convertEol:false,cursorBlink:true,scrollback:2000}); + fit=new FitAddon.FitAddon(); + term.loadAddon(fit);term.open(con);fit.fit(); + enableTouchScroll(con); + term.onData(d=>s(d)); + ro=new ResizeObserver(()=>{if(fit){fit.fit();sendResize()}}); + ro.observe(con); + connect(id); +} + +function goBack(){ + leaving=true;clearTimeout(reconnTimer); + if(ro){ro.disconnect();ro=null} + if(ws){ws.close();ws=null} + if(term){term.dispose();term=null} + document.getElementById('view').style.display='none'; + document.getElementById('tabbar').style.display='flex'; + document.getElementById('page-terminals').style.display='flex'; + const nb=document.getElementById('newbtn'); + nb.textContent='+ Nueva terminal';nb.disabled=false; + load(); +} + +async function killTerminal(){ + if(!activeId)return; + if(!confirm('¿Cerrar "'+activeTitle+'"?'))return; + try{await fetch('/api/terminals/'+encodeURIComponent(activeId)+q,{method:'DELETE'})}catch(_){} + goBack(); +} + +async function newTerminal(){ + const btn=document.getElementById('newbtn'); + btn.textContent='Abriendo…';btn.disabled=true; + try{ + const r=await fetch('/api/terminals'+q,{method:'POST'}); + if(!r.ok){btn.textContent='+ Nueva terminal';btn.disabled=false;return} + const {id}=await r.json(); + await load();attach(id,id); + }catch(e){btn.textContent='+ Nueva terminal';btn.disabled=false} +} + +// ── Init ─────────────────────────────────────────────────────────────────────── +load(); +setInterval(()=>{ + if(document.getElementById('page-terminals').style.display!=='none') load(); +},3000); From 7da226ae4f38a073924ba16d329c850cfb92820b Mon Sep 17 00:00:00 2001 From: romadesign Date: Sat, 22 Aug 2026 22:54:12 +0200 Subject: [PATCH 04/51] refactor: deduplicated code found by review across the split modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReviewPanel.ts: import ReviewChangeFile from reviewFormat instead of redefining it; dropped two getters (getAllBranches/getLastFiles) passed to the data loader but never read there. - ReviewDiffView.ts: extracted createLineRangeSelector, shared by the unified and split-diff renderers instead of each defining its own copy of the drag-to-select machinery. - ReviewCommentBubble.ts / ReviewDiffView.ts: extracted buildCommentInputRow for the textarea+actions-row shape repeated across the edit, reply, inline-line, and file-comment forms. - reviewFormat.ts: added renderReviewPrStateBadge, used by both ReviewSidebarLists.ts and reviewDataLoader.ts instead of each rebuilding the same badge span. - daemon web split: moved the terminal-list CSS rules (.tb, .tb-ico, etc.) out of shared.css into terminal.css, the only place they're used — a pre-existing mislabeling from the original monolith that the split was a natural point to fix. --- daemon/bento-daemon/src/remote/web/shared.css | 8 - .../bento-daemon/src/remote/web/terminal.css | 8 + src/panels/review/ReviewCommentBubble.ts | 66 +++++--- src/panels/review/ReviewDiffView.ts | 160 ++++++++---------- src/panels/review/ReviewPanel.ts | 7 +- src/panels/review/ReviewSidebarLists.ts | 12 +- src/panels/review/reviewDataLoader.ts | 14 +- src/panels/review/reviewFormat.ts | 10 ++ 8 files changed, 131 insertions(+), 154 deletions(-) diff --git a/daemon/bento-daemon/src/remote/web/shared.css b/daemon/bento-daemon/src/remote/web/shared.css index 5af16e9..7f511f1 100644 --- a/daemon/bento-daemon/src/remote/web/shared.css +++ b/daemon/bento-daemon/src/remote/web/shared.css @@ -8,14 +8,6 @@ html,body{height:100%;background:var(--bg);color:var(--fg);font-family:-apple-sy .tab.active{color:var(--a);border-bottom-color:var(--a)} /* ── Shared ───────────────────────────── */ -.list-head{font-size:11px;font-weight:700;letter-spacing:.1em;color:var(--dim);text-transform:uppercase;margin-bottom:14px;padding:0 2px} -.tb{display:flex;align-items:center;gap:12px;width:100%;padding:15px 16px;background:var(--s);border:1px solid var(--b);border-radius:14px;color:var(--fg);text-align:left;margin-bottom:10px;cursor:pointer;transition:background .1s} -.tb:active{background:var(--s2)} -.tb-ico{font-size:22px;flex-shrink:0} -.tb-info{flex:1;min-width:0} -.tb-name{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.tb-cwd{font-size:11px;color:var(--dim);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.tb-arrow{color:var(--dim);font-size:18px;flex-shrink:0} .empty{padding:40px 16px;text-align:center;color:var(--dim);font-size:14px;line-height:1.6} .err-msg{color:var(--re)} input.field,select.field{width:100%;padding:11px 14px;background:var(--bg);border:1px solid var(--b);border-radius:12px;color:var(--fg);font-size:15px;outline:none;-webkit-appearance:none} diff --git a/daemon/bento-daemon/src/remote/web/terminal.css b/daemon/bento-daemon/src/remote/web/terminal.css index 462f6a0..a66ab94 100644 --- a/daemon/bento-daemon/src/remote/web/terminal.css +++ b/daemon/bento-daemon/src/remote/web/terminal.css @@ -1,4 +1,12 @@ /* ── Terminals page ───────────────────── */ +.list-head{font-size:11px;font-weight:700;letter-spacing:.1em;color:var(--dim);text-transform:uppercase;margin-bottom:14px;padding:0 2px} +.tb{display:flex;align-items:center;gap:12px;width:100%;padding:15px 16px;background:var(--s);border:1px solid var(--b);border-radius:14px;color:var(--fg);text-align:left;margin-bottom:10px;cursor:pointer;transition:background .1s} +.tb:active{background:var(--s2)} +.tb-ico{font-size:22px;flex-shrink:0} +.tb-info{flex:1;min-width:0} +.tb-name{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tb-cwd{font-size:11px;color:var(--dim);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tb-arrow{color:var(--dim);font-size:18px;flex-shrink:0} #page-terminals{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden} #list{flex:1;overflow-y:auto;padding:16px 16px 80px;touch-action:pan-y;-webkit-overflow-scrolling:touch} #newbtn{position:fixed;bottom:20px;left:16px;right:16px;padding:14px;background:var(--s);border:1px solid var(--b);border-radius:14px;color:var(--fg);font-size:15px;font-weight:600;cursor:pointer;text-align:center;z-index:10} diff --git a/src/panels/review/ReviewCommentBubble.ts b/src/panels/review/ReviewCommentBubble.ts index 2617c43..7dce8ae 100644 --- a/src/panels/review/ReviewCommentBubble.ts +++ b/src/panels/review/ReviewCommentBubble.ts @@ -10,6 +10,40 @@ export interface ReviewCommentActions { refresh: () => Promise } +// A textarea + Cancel/Send actions row, the shared shape behind every comment +// form in the review panel (edit, reply, inline line-comment, file-comment). +// `status` is always created (callers that don't need it just leave it +// unappended-to-DOM) so callers get a uniform, non-optional return shape. +export interface CommentInputRow { + textarea: HTMLTextAreaElement + actionsRow: HTMLElement + sendBtn: HTMLButtonElement + cancelBtn: HTMLButtonElement + status: HTMLSpanElement +} + +export function buildCommentInputRow(options: { + rows: number + placeholder?: string + value?: string + sendLabel: string + withStatus?: boolean +}): CommentInputRow { + const textarea = document.createElement('textarea') + textarea.className = 'review-comment-input' + textarea.rows = options.rows + if (options.placeholder) textarea.placeholder = options.placeholder + if (options.value !== undefined) textarea.value = options.value + const actionsRow = document.createElement('div') + actionsRow.className = 'review-line-form-actions' + const sendBtn = Object.assign(document.createElement('button'), { className: 'review-comment-btn', textContent: options.sendLabel }) + 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' }) + actionsRow.append(cancelBtn, sendBtn) + if (options.withStatus) actionsRow.append(status) + return { textarea, actionsRow, sendBtn, cancelBtn, status } +} + // ── Comment bubble (edit/delete/reply) ──────────────────────────────────── export function buildReviewCommentBubble(c: GhComment, actions: ReviewCommentActions): HTMLElement { const bubble = document.createElement('div') @@ -53,15 +87,7 @@ export function buildReviewCommentBubble(c: GhComment, actions: ReviewCommentAct editBtn.addEventListener('click', () => { if (bubble.querySelector('.review-edit-wrap')) return - const editArea = document.createElement('textarea') - editArea.className = 'review-comment-input' - editArea.value = c.body - editArea.rows = 3 - const actionsRow = document.createElement('div') - actionsRow.className = 'review-line-form-actions' - const saveBtn = Object.assign(document.createElement('button'), { className: 'review-comment-btn', textContent: 'Save' }) - const cancelBtn = Object.assign(document.createElement('button'), { className: 'review-line-cancel-btn', textContent: 'Cancel' }) - actionsRow.append(cancelBtn, saveBtn) + const { textarea: editArea, actionsRow, sendBtn: saveBtn, cancelBtn } = buildCommentInputRow({ rows: 3, value: c.body, sendLabel: 'Save' }) const wrap = document.createElement('div') wrap.className = 'review-edit-wrap' wrap.append(editArea, actionsRow) @@ -90,15 +116,7 @@ export function buildReviewCommentBubble(c: GhComment, actions: ReviewCommentAct replyBtn.addEventListener('click', () => { if (bubble.querySelector('.review-reply-wrap')) return - const replyArea = document.createElement('textarea') - replyArea.className = 'review-comment-input' - replyArea.placeholder = reviewT('commentPlaceholder') - replyArea.rows = 2 - const actionsRow = document.createElement('div') - actionsRow.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' }) - actionsRow.append(cancelBtn, sendBtn) + const { textarea: replyArea, actionsRow, sendBtn, cancelBtn } = buildCommentInputRow({ rows: 2, placeholder: reviewT('commentPlaceholder'), sendLabel: reviewT('sendComment') }) const wrap = document.createElement('div') wrap.className = 'review-reply-wrap' wrap.append(replyArea, actionsRow) @@ -131,22 +149,14 @@ export interface ReviewLineFormActions { export function buildReviewLineForm(filePath: string, line: number, startLine: number | undefined, actions: ReviewLineFormActions): 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 { textarea: input, actionsRow, sendBtn, cancelBtn, status } = + buildCommentInputRow({ rows: 3, placeholder: reviewT('commentPlaceholder'), sendLabel: reviewT('sendComment'), withStatus: true }) const draftKey = `bento.review.draft.${actions.repoPath()}.${actions.selectedBranch()}.${filePath}.${line}` const saved = localStorage.getItem(draftKey) if (saved) input.value = saved input.addEventListener('input', () => { if (input.value) localStorage.setItem(draftKey, input.value); else localStorage.removeItem(draftKey) }) - const actionsRow = document.createElement('div') - actionsRow.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' }) - actionsRow.append(cancelBtn, sendBtn, status) form.append(input, actionsRow) cancelBtn.addEventListener('click', () => form.remove()) sendBtn.addEventListener('click', async () => { diff --git a/src/panels/review/ReviewDiffView.ts b/src/panels/review/ReviewDiffView.ts index 9a9fa3a..a9711a5 100644 --- a/src/panels/review/ReviewDiffView.ts +++ b/src/panels/review/ReviewDiffView.ts @@ -3,6 +3,7 @@ import { icon } from '../../ui/icons' import { reviewT } from './i18n' import type { ReviewChangeFile, GhComment, FileTypeFilter } from './reviewFormat' import { esc, highlightCode, wordDiff } from './reviewFormat' +import { buildCommentInputRow } from './ReviewCommentBubble' export interface ReviewDiffDom { diffView: HTMLElement @@ -36,6 +37,62 @@ export interface ReviewDiffView { updateCommentBadges: () => void } +// Drag-to-select a line range within a diff container, opening a comment form +// anchored after `getInsertTarget(anchorWrap)` once the drag ends. +function createLineRangeSelector( + container: HTMLElement, + filePath: string, + makeLineForm: ReviewDiffState['makeLineForm'], + getInsertTarget: (anchorWrap: HTMLElement) => Element, +): { start: (line: number) => void } { + 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) + getInsertTarget(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) + } + return { + start: (line: number) => { + dragStart = line + highlightRange(line, line) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, + } +} + export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): ReviewDiffView { const { diffView, diffSearchInput, filterBar } = dom @@ -44,45 +101,7 @@ export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): const container = document.createElement('div') container.dataset.filepath = filePath const ext = filePath.split('.').pop() ?? '' - 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 = state.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) - } + const rangeSelector = createLineRangeSelector(container, filePath, state.makeLineForm, anchorWrap => anchorWrap) // Parse diff into typed entries for two-pass rendering with word diff type UEntry = @@ -126,10 +145,7 @@ export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): className: 'review-line-comment-btn', textContent: '+', title: `Comment line ${lineNo}`, }) addBtn.addEventListener('mousedown', e => { - e.preventDefault(); dragStart = capturedLine - highlightRange(capturedLine, capturedLine) - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('mouseup', onMouseUp) + e.preventDefault(); rangeSelector.start(capturedLine) }) lineEl.append(addBtn) } @@ -210,45 +226,10 @@ export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): } // Drag-to-select (right side only) - 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 row = anchorWrap.closest('.review-split-row') ?? anchorWrap - const form = state.makeLineForm(filePath, hi, lo < hi ? lo : undefined) - row.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) - } + const rangeSelector = createLineRangeSelector( + container, filePath, state.makeLineForm, + anchorWrap => anchorWrap.closest('.review-split-row') ?? anchorWrap, + ) const mkRightCell = (lineNo: number, text: string, extraCls: string, preHtml?: string): HTMLElement => { const cell = document.createElement('div') @@ -259,10 +240,7 @@ export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): }) const cap = lineNo addBtn.addEventListener('mousedown', e => { - e.preventDefault(); dragStart = cap - highlightRange(cap, cap) - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('mouseup', onMouseUp) + e.preventDefault(); rangeSelector.start(cap) }) cell.innerHTML = `${lineNo}${preHtml ?? highlightCode(text, ext)}` cell.prepend(addBtn) @@ -388,13 +366,9 @@ export function buildReviewDiffView(dom: ReviewDiffDom, state: ReviewDiffState): if (details.querySelector('.review-file-comment-form')) return const form = document.createElement('div') form.className = 'review-file-comment-form' - const ta = document.createElement('textarea') - ta.className = 'review-comment-input'; ta.placeholder = reviewT('commentPlaceholder'); ta.rows = 2 - const acts = document.createElement('div'); acts.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 st = Object.assign(document.createElement('span'), { className: 'review-comment-status' }) - acts.append(cancelBtn, sendBtn, st); form.append(ta, acts) + const { textarea: ta, actionsRow: acts, sendBtn, cancelBtn, status: st } = + buildCommentInputRow({ rows: 2, placeholder: reviewT('commentPlaceholder'), sendLabel: reviewT('sendComment'), withStatus: true }) + form.append(ta, acts) cancelBtn.addEventListener('click', () => form.remove()) sendBtn.addEventListener('click', async () => { const body = ta.value.trim() diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 4974374..dcfd81f 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -1,7 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import { open as openUrl } from '@tauri-apps/plugin-shell' import { icon } from '../../ui/icons' -import { parseDiffFiles } from '../diff/diffStats' import { reviewT } from './i18n' import { renderMarkdown } from '../../core/notes/renderMarkdown' import { getUiZoom, toLayoutPixels } from '../../ui/zoom' @@ -17,7 +16,7 @@ import { buildReviewSidebarLists } from './ReviewSidebarLists' import { buildReviewDiffView } from './ReviewDiffView' import { buildReviewDataLoader } from './reviewDataLoader' import { buildReviewAiRun } from './reviewAiRun' -import type { GhComment, GhPr, SidebarMode, FileTypeFilter } from './reviewFormat' +import type { GhComment, GhPr, SidebarMode, FileTypeFilter, ReviewChangeFile } from './reviewFormat' import { resolveReviewFollowUpSession, buildReviewFileManifest, @@ -64,7 +63,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; let focusedFileIdx = -1 let treeView = false let splitView = false - let lastFiles: Array[0] & { state: 'A'|'D'|'M' }> = [] + let lastFiles: ReviewChangeFile[] = [] let lastStatusRollup: Array<{ name?: string; workflowName?: string; conclusion?: string|null; state?: string; context?: string; targetUrl?: string }> = [] let resolvedComments: Set = new Set() let discSeq = 0 @@ -479,7 +478,6 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; setSelectedBranch: v => { selectedBranch = v }, getActiveLocalBranch: () => activeLocalBranch, setActiveLocalBranch: v => { activeLocalBranch = v }, - getAllBranches: () => allBranches, setAllBranches: v => { allBranches = v }, getCurrentPrNumber: () => currentPrNumber, setCurrentPrNumber: v => { currentPrNumber = v }, @@ -492,7 +490,6 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; setFileTypeFilter: v => { fileTypeFilter = v }, getTotalFiles: () => totalFiles, setTotalFiles: v => { totalFiles = v }, - getLastFiles: () => lastFiles, setLastFiles: v => { lastFiles = v }, setLastStatusRollup: v => { lastStatusRollup = v }, setResolvedComments: v => { resolvedComments = v }, diff --git a/src/panels/review/ReviewSidebarLists.ts b/src/panels/review/ReviewSidebarLists.ts index 532b845..8cc97a2 100644 --- a/src/panels/review/ReviewSidebarLists.ts +++ b/src/panels/review/ReviewSidebarLists.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import { reviewT } from './i18n' -import { describeReviewPrState, filterReviewPrs, type GhPr, type SidebarMode } from './reviewFormat' +import { renderReviewPrStateBadge, filterReviewPrs, type GhPr, type SidebarMode } from './reviewFormat' export interface ReviewSidebarRefs { branchSearch: HTMLInputElement @@ -72,14 +72,8 @@ export function buildReviewSidebarLists(refs: ReviewSidebarRefs, state: ReviewSi Object.assign(document.createElement('div'), { className: 'review-pr-item-title', textContent: `#${pr.number} ${pr.title}` }), Object.assign(document.createElement('div'), { className: 'review-pr-item-author', textContent: pr.author.login }), ) - const stateBadge = describeReviewPrState(pr.state, pr.mergedAt) - if (stateBadge) { - item.append(Object.assign(document.createElement('span'), { - className: `review-pr-item-state ${stateBadge.cls}`, - textContent: stateBadge.text, - title: stateBadge.title, - })) - } + const stateBadge = renderReviewPrStateBadge(pr.state, pr.mergedAt, 'review-pr-item-state') + if (stateBadge) item.append(stateBadge) item.addEventListener('click', () => { const branches = state.allBranches() const branch = branches.find(b => b.endsWith('/' + pr.headRefName)) ?? ('origin/' + pr.headRefName) diff --git a/src/panels/review/reviewDataLoader.ts b/src/panels/review/reviewDataLoader.ts index 62dee5e..16f1bc0 100644 --- a/src/panels/review/reviewDataLoader.ts +++ b/src/panels/review/reviewDataLoader.ts @@ -6,7 +6,7 @@ import { diffGit } from '../diff/diffGitClient' import { reviewT } from './i18n' import { renderMarkdown } from '../../core/notes/renderMarkdown' import type { ReviewChangeFile, GhComment, GhPr, SidebarMode, FileTypeFilter } from './reviewFormat' -import { describeReviewPrState, describeReviewNoBranchChanges, getFileState, computeCiStatus, relativeTime } from './reviewFormat' +import { renderReviewPrStateBadge, describeReviewNoBranchChanges, getFileState, computeCiStatus, relativeTime } from './reviewFormat' export type StatusRollupEntry = { name?: string; workflowName?: string; conclusion?: string | null; state?: string; context?: string; targetUrl?: string } @@ -35,7 +35,6 @@ export interface ReviewDataLoaderState { setSelectedBranch: (v: string) => void getActiveLocalBranch: () => string setActiveLocalBranch: (v: string) => void - getAllBranches: () => string[] setAllBranches: (v: string[]) => void getCurrentPrNumber: () => number | null setCurrentPrNumber: (v: number | null) => void @@ -48,7 +47,6 @@ export interface ReviewDataLoaderState { setFileTypeFilter: (v: FileTypeFilter) => void getTotalFiles: () => number setTotalFiles: (v: number) => void - getLastFiles: () => ReviewChangeFile[] setLastFiles: (v: ReviewChangeFile[]) => void setLastStatusRollup: (v: StatusRollupEntry[]) => void setResolvedComments: (v: Set) => void @@ -160,14 +158,8 @@ export function buildReviewDataLoader(dom: ReviewDataLoaderDom, state: ReviewDat link.addEventListener('click', e => { e.preventDefault(); openUrl(pr.url).catch(() => {}) }) prMetaEl.append(link) - const stateBadge = describeReviewPrState(pr.state, pr.mergedAt) - if (stateBadge) { - prMetaEl.append(Object.assign(document.createElement('span'), { - className: `review-pr-state ${stateBadge.cls}`, - textContent: stateBadge.text, - title: stateBadge.title, - })) - } + const stateBadge = renderReviewPrStateBadge(pr.state, pr.mergedAt, 'review-pr-state') + if (stateBadge) prMetaEl.append(stateBadge) const ci = computeCiStatus(statusRollup) if (ci !== 'none') { diff --git a/src/panels/review/reviewFormat.ts b/src/panels/review/reviewFormat.ts index c6902ef..ed28983 100644 --- a/src/panels/review/reviewFormat.ts +++ b/src/panels/review/reviewFormat.ts @@ -80,6 +80,16 @@ export function describeReviewPrState(state?: string | null, mergedAt?: string | } } +export function renderReviewPrStateBadge(state: string | null | undefined, mergedAt: string | null | undefined, classPrefix: string): HTMLSpanElement | null { + const badge = describeReviewPrState(state, mergedAt) + if (!badge) return null + return Object.assign(document.createElement('span'), { + className: `${classPrefix} ${badge.cls}`, + textContent: badge.text, + title: badge.title, + }) +} + export function describeReviewNoBranchChanges(state?: string | null, baseBranch = ''): string { if ((state ?? '').toUpperCase() === 'MERGED') { return reviewT('mergedNoBranchChanges', { base: baseBranch }) From 39a248286b75b02047c037d898f4ebbce410f211 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:19:09 +0200 Subject: [PATCH 05/51] refactor: extracted DB engine predicates and data access into dbAccess Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 89 +-------------- src/panels/db/dbAccess.ts | 94 ++++++++++++++++ tests/panels/db/dbAccess.test.ts | 188 +++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 83 deletions(-) create mode 100644 src/panels/db/dbAccess.ts create mode 100644 tests/panels/db/dbAccess.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index a19bec8..4899585 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -4,34 +4,24 @@ import { parseDockerPs } from '../../core/db/dockerPs' import { serverKind } from '../../core/db/serverKind' import { publishedPort } from '../../core/db/hostPort' import { mysqlCreds, mongoCreds, pgCreds } from '../../core/db/credentials' -import { DEFAULT_PORT, LISTABLE, kindForPort, type DbServer, type DbKind } from '../../core/db/dbServer' +import { DEFAULT_PORT, LISTABLE, kindForPort, type DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' import { askAi, type AiQueryRunner, type AiTool } from '../../ui/askAi' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import { buildJoinPath, type Relation } from '../../core/db/joinPath' import { withRowLimit } from '../../core/db/rowLimit' import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' +import { + KIND_LABEL, isMongo, isPg, isRedis, envValue, sqlCmd, creds, target, + parseRedisLines, fetchColumns, listDatabases, listTables, fetchRelations, + type TableData, type EditMeta, +} from './dbAccess' import { parseStructuredJson } from './jsonValues' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 let closeOpenPanel: (() => void) | null = null -const KIND_LABEL: Record = { - mysql: 'MySQL', mariadb: 'MariaDB', mongodb: 'MongoDB', postgres: 'PostgreSQL', redis: 'Redis', -} - -interface TableData { columns: string[]; rows: string[][] } -const isMongo = (s: DbServer): boolean => s.kind === 'mongodb' -const isPg = (s: DbServer): boolean => s.kind === 'postgres' -const isRedis = (s: DbServer): boolean => s.kind === 'redis' -const envValue = (env: string[], key: string): string => env.find(e => e.startsWith(`${key}=`))?.slice(key.length + 1) ?? '' -// SQL engines share the same grid logic; only the command prefix differs. -const sqlCmd = (s: DbServer, op: string): string => `db_docker_${isPg(s) ? 'pg' : 'mysql'}_${op}` -const creds = (s: DbServer): { user: string; password: string } => ({ user: s.user ?? '', password: s.password ?? '' }) -// Where to run: a Docker container, or a local server (empty container → host:port). -const target = (s: DbServer): { container: string; host: string; port: number } => ({ container: s.container ?? '', host: s.host, port: s.port }) - const note = (text: string, cls = 'db-note'): HTMLElement => { const el = document.createElement('div') el.className = cls @@ -288,52 +278,6 @@ const makeResultWrap = (tbl: HTMLElement, toolbarItems: HTMLElement[]): HTMLElem return wrap } -const sqlEscQ = (v: string): string => v.replace(/'/g, "''") - -const parseRedisLines = (raw: string): string[] => - raw.split('\n') - .map(l => l.trim()) - .filter(l => /^\d+\)/.test(l)) - .map(l => { - const m = l.match(/^\d+\)\s+(.*)$/) - if (!m) return '' - let v = m[1] - if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') - return v - }) - -const fetchColumns = async (s: DbServer, db: string, table: string): Promise => { - try { - if (isMongo(s)) { - const esc = sqlEscQ - const script = `Object.keys(db.getSiblingDB('${esc(db)}').getCollection('${esc(table)}').findOne()||{}).join('\\n')` - const out = await invoke('db_docker_mongo_query', { ...target(s), db, script, ...creds(s) }) - return out.split('\n').map(x => x.trim()).filter(Boolean) - } - if (isPg(s)) { - const parts = table.split('.') - const tbl = parts.pop() ?? table - const schema = parts.pop() ?? 'public' - const sql = `SELECT column_name, data_type FROM information_schema.columns WHERE table_schema='${sqlEscQ(schema)}' AND table_name='${sqlEscQ(tbl)}' ORDER BY ordinal_position` - const data = await invoke('db_docker_pg_query', { ...target(s), db, sql, ...creds(s) }) - return data.rows.map(r => `${r[0]} (${r[1]})`) - } - const sql = `SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${sqlEscQ(db)}' AND TABLE_NAME='${sqlEscQ(table)}' ORDER BY ORDINAL_POSITION` - const data = await invoke('db_docker_mysql_query', { ...target(s), db, sql, ...creds(s) }) - return data.rows.map(r => `${r[0]} (${r[1]})`) - } catch { - return [] - } -} - -interface EditMeta { - s: DbServer - db: string - table: string - pkIdx: number[] - fkColMap: Map -} - export function createDbPanel(): { element: HTMLElement } { const root = document.createElement('div') root.className = 'db-panel' @@ -412,20 +356,6 @@ export function createDbPanel(): { element: HTMLElement } { else { s.user = 'root' } } - // ---- data access (Docker via exec, local via the host's own client) ---- - const listDatabases = (s: DbServer): Promise => { - if (isRedis(s)) return invoke('db_docker_redis_dbs', { ...target(s), password: s.password ?? '' }) - if (isMongo(s)) return invoke('db_docker_list_mongo', { ...target(s), ...creds(s) }) - if (isPg(s)) return invoke('db_docker_pg_databases', { ...target(s), db: s.connectDb ?? 'postgres', ...creds(s) }) - return invoke('db_docker_list_mysql', { ...target(s), ...creds(s) }) - } - - const listTables = (s: DbServer, db: string): Promise => { - if (isRedis(s)) return invoke('db_docker_redis_keys', { ...target(s), db, password: s.password ?? '' }) - const cmd = isMongo(s) ? 'db_docker_mongo_collections' : sqlCmd(s, 'tables') - return invoke(cmd, { ...target(s), db, ...creds(s) }) - } - const renderRedisValue = (s: DbServer, db: string, key: string, v: { kind: string; value: string }, ttl: number): void => { const ttlLabel = ttl > 0 ? i18nT('db.ttlSeconds', { ttl }) : ttl === -1 ? i18nT('db.ttlPersists') : '' const kindStr = ttlLabel ? `${v.kind} · ${ttlLabel}` : v.kind @@ -717,13 +647,6 @@ export function createDbPanel(): { element: HTMLElement } { return pre } - // DB relations: FKs in SQL, heuristic references in Mongo, nothing in Redis. - const fetchRelations = (s: DbServer, db: string): Promise => { - if (isRedis(s)) return Promise.resolve([]) - const cmd = isMongo(s) ? 'db_docker_mongo_refs' : sqlCmd(s, 'fks') - return invoke(cmd, { ...target(s), db, ...creds(s) }).catch(() => [] as ForeignKey[]) - } - const openQuery = (s: DbServer, db: string, names: string[]): void => { // Relations loaded once and shared (chips, AI, and the JOIN builder). let relations: ForeignKey[] = [] diff --git a/src/panels/db/dbAccess.ts b/src/panels/db/dbAccess.ts new file mode 100644 index 0000000..5d8263a --- /dev/null +++ b/src/panels/db/dbAccess.ts @@ -0,0 +1,94 @@ +import { invoke } from '@tauri-apps/api/core' +import type { DbServer, DbKind } from '../../core/db/dbServer' +import type { ForeignKey } from './queryBuilders' + +// Shape returned by every tabular backend command (SQL rows, EXPLAIN plans…). +export interface TableData { columns: string[]; rows: string[][] } + +// What a grid needs to turn a read-only result into an editable one. +export interface EditMeta { + s: DbServer + db: string + table: string + pkIdx: number[] + fkColMap: Map +} + +export const KIND_LABEL: Record = { + mysql: 'MySQL', mariadb: 'MariaDB', mongodb: 'MongoDB', postgres: 'PostgreSQL', redis: 'Redis', +} + +export const isMongo = (s: DbServer): boolean => s.kind === 'mongodb' +export const isPg = (s: DbServer): boolean => s.kind === 'postgres' +export const isRedis = (s: DbServer): boolean => s.kind === 'redis' + +export const envValue = (env: string[], key: string): string => + env.find(e => e.startsWith(`${key}=`))?.slice(key.length + 1) ?? '' + +// SQL engines share the same grid logic; only the command prefix differs. +export const sqlCmd = (s: DbServer, op: string): string => `db_docker_${isPg(s) ? 'pg' : 'mysql'}_${op}` + +export const creds = (s: DbServer): { user: string; password: string } => + ({ user: s.user ?? '', password: s.password ?? '' }) + +// Where to run: a Docker container, or a local server (empty container → host:port). +export const target = (s: DbServer): { container: string; host: string; port: number } => + ({ container: s.container ?? '', host: s.host, port: s.port }) + +export const sqlEscQ = (v: string): string => v.replace(/'/g, "''") + +export const parseRedisLines = (raw: string): string[] => + raw.split('\n') + .map(l => l.trim()) + .filter(l => /^\d+\)/.test(l)) + .map(l => { + const m = l.match(/^\d+\)\s+(.*)$/) + if (!m) return '' + let v = m[1] + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') + return v + }) + +export const listDatabases = (s: DbServer): Promise => { + if (isRedis(s)) return invoke('db_docker_redis_dbs', { ...target(s), password: s.password ?? '' }) + if (isMongo(s)) return invoke('db_docker_list_mongo', { ...target(s), ...creds(s) }) + if (isPg(s)) return invoke('db_docker_pg_databases', { ...target(s), db: s.connectDb ?? 'postgres', ...creds(s) }) + return invoke('db_docker_list_mysql', { ...target(s), ...creds(s) }) +} + +export const listTables = (s: DbServer, db: string): Promise => { + if (isRedis(s)) return invoke('db_docker_redis_keys', { ...target(s), db, password: s.password ?? '' }) + const cmd = isMongo(s) ? 'db_docker_mongo_collections' : sqlCmd(s, 'tables') + return invoke(cmd, { ...target(s), db, ...creds(s) }) +} + +// DB relations: FKs in SQL, heuristic references in Mongo, nothing in Redis. +export const fetchRelations = (s: DbServer, db: string): Promise => { + if (isRedis(s)) return Promise.resolve([]) + const cmd = isMongo(s) ? 'db_docker_mongo_refs' : sqlCmd(s, 'fks') + return invoke(cmd, { ...target(s), db, ...creds(s) }).catch(() => [] as ForeignKey[]) +} + +export const fetchColumns = async (s: DbServer, db: string, table: string): Promise => { + try { + if (isMongo(s)) { + const esc = sqlEscQ + const script = `Object.keys(db.getSiblingDB('${esc(db)}').getCollection('${esc(table)}').findOne()||{}).join('\\n')` + const out = await invoke('db_docker_mongo_query', { ...target(s), db, script, ...creds(s) }) + return out.split('\n').map(x => x.trim()).filter(Boolean) + } + if (isPg(s)) { + const parts = table.split('.') + const tbl = parts.pop() ?? table + const schema = parts.pop() ?? 'public' + const sql = `SELECT column_name, data_type FROM information_schema.columns WHERE table_schema='${sqlEscQ(schema)}' AND table_name='${sqlEscQ(tbl)}' ORDER BY ordinal_position` + const data = await invoke('db_docker_pg_query', { ...target(s), db, sql, ...creds(s) }) + return data.rows.map(r => `${r[0]} (${r[1]})`) + } + const sql = `SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${sqlEscQ(db)}' AND TABLE_NAME='${sqlEscQ(table)}' ORDER BY ORDINAL_POSITION` + const data = await invoke('db_docker_mysql_query', { ...target(s), db, sql, ...creds(s) }) + return data.rows.map(r => `${r[0]} (${r[1]})`) + } catch { + return [] + } +} diff --git a/tests/panels/db/dbAccess.test.ts b/tests/panels/db/dbAccess.test.ts new file mode 100644 index 0000000..3e718c1 --- /dev/null +++ b/tests/panels/db/dbAccess.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { + KIND_LABEL, isMongo, isPg, isRedis, envValue, sqlCmd, creds, target, sqlEscQ, + parseRedisLines, fetchColumns, listDatabases, listTables, fetchRelations, +} from '../../../src/panels/db/dbAccess' +import type { DbServer, DbKind } from '../../../src/core/db/dbServer' + +function server(over: Partial = {}): DbServer { + return { kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'db1', ...over } +} + +beforeEach(() => { + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +describe('engine predicates', () => { + it('recognises each engine and treats mariadb as plain SQL', () => { + expect(isMongo(server({ kind: 'mongodb' }))).toBe(true) + expect(isPg(server({ kind: 'postgres' }))).toBe(true) + expect(isRedis(server({ kind: 'redis' }))).toBe(true) + const maria = server({ kind: 'mariadb' }) + expect([isMongo(maria), isPg(maria), isRedis(maria)]).toEqual([false, false, false]) + }) + + it('labels every kind', () => { + const kinds: DbKind[] = ['mysql', 'mariadb', 'mongodb', 'postgres', 'redis'] + kinds.forEach(k => expect(KIND_LABEL[k]).toBeTruthy()) + }) +}) + +describe('invoke argument helpers', () => { + it('routes SQL commands to the pg or mysql backend', () => { + expect(sqlCmd(server({ kind: 'postgres' }), 'rows')).toBe('db_docker_pg_rows') + expect(sqlCmd(server({ kind: 'mysql' }), 'rows')).toBe('db_docker_mysql_rows') + expect(sqlCmd(server({ kind: 'mariadb' }), 'pk')).toBe('db_docker_mysql_pk') + }) + + it('defaults missing credentials to empty strings', () => { + expect(creds(server())).toEqual({ user: '', password: '' }) + expect(creds(server({ user: 'root', password: 'pw' }))).toEqual({ user: 'root', password: 'pw' }) + }) + + it('targets the container when there is one and the host otherwise', () => { + expect(target(server({ container: 'c1' }))).toEqual({ container: 'c1', host: '127.0.0.1', port: 3306 }) + expect(target(server({ source: 'local', container: undefined, host: 'localhost', port: 5432 }))) + .toEqual({ container: '', host: 'localhost', port: 5432 }) + }) +}) + +describe('envValue', () => { + it('reads the value after the first equals sign', () => { + expect(envValue(['A=1', 'MYSQL_ROOT_PASSWORD=p=ss'], 'MYSQL_ROOT_PASSWORD')).toBe('p=ss') + }) + + it('returns empty for a missing key and does not match a key that merely shares a prefix', () => { + expect(envValue(['REDIS_PASSWORD_FILE=/x'], 'REDIS_PASSWORD')).toBe('') + expect(envValue([], 'ANY')).toBe('') + }) +}) + +describe('sqlEscQ', () => { + it('doubles single quotes so a value cannot break out of a literal', () => { + expect(sqlEscQ("O'Brien")).toBe("O''Brien") + expect(sqlEscQ("'; DROP TABLE t; --")).toBe("''; DROP TABLE t; --") + }) +}) + +describe('parseRedisLines', () => { + it('keeps only numbered lines and unwraps quoted values', () => { + const raw = 'some header\n1) "hello"\n2) 42\nnot numbered\n3) "a\\"b"' + expect(parseRedisLines(raw)).toEqual(['hello', '42', 'a"b']) + }) + + it('unescapes backslashes inside quoted values', () => { + expect(parseRedisLines('1) "a\\\\b"')).toEqual(['a\\b']) + }) + + it('returns nothing when no line is numbered', () => { + expect(parseRedisLines('(empty array)')).toEqual([]) + }) +}) + +describe('listDatabases', () => { + it('picks the command for each engine', async () => { + mocks.invoke.mockResolvedValue([]) + await listDatabases(server({ kind: 'redis', password: 'pw' })) + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_redis_dbs', expect.objectContaining({ password: 'pw' })) + + await listDatabases(server({ kind: 'mongodb' })) + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_list_mongo', expect.anything()) + + await listDatabases(server({ kind: 'mysql' })) + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_list_mysql', expect.anything()) + }) + + it('connects Postgres through its maintenance database', async () => { + mocks.invoke.mockResolvedValue([]) + await listDatabases(server({ kind: 'postgres' })) + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_pg_databases', expect.objectContaining({ db: 'postgres' })) + + await listDatabases(server({ kind: 'postgres', connectDb: 'app' })) + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_pg_databases', expect.objectContaining({ db: 'app' })) + }) +}) + +describe('listTables', () => { + it('lists keys, collections or tables depending on the engine', async () => { + mocks.invoke.mockResolvedValue([]) + await listTables(server({ kind: 'redis' }), '0') + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_redis_keys', expect.objectContaining({ db: '0' })) + + await listTables(server({ kind: 'mongodb' }), 'app') + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_mongo_collections', expect.anything()) + + await listTables(server({ kind: 'postgres' }), 'app') + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_pg_tables', expect.anything()) + }) +}) + +describe('fetchRelations', () => { + it('has no relations for Redis and never calls the backend', async () => { + expect(await fetchRelations(server({ kind: 'redis' }), '0')).toEqual([]) + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('uses the heuristic reference command for Mongo and the FK one for SQL', async () => { + mocks.invoke.mockResolvedValue([]) + await fetchRelations(server({ kind: 'mongodb' }), 'app') + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_mongo_refs', expect.anything()) + + await fetchRelations(server({ kind: 'mysql' }), 'app') + expect(mocks.invoke).toHaveBeenLastCalledWith('db_docker_mysql_fks', expect.anything()) + }) + + it('degrades to no relations when the backend fails', async () => { + mocks.invoke.mockRejectedValue(new Error('no permission')) + expect(await fetchRelations(server({ kind: 'mysql' }), 'app')).toEqual([]) + }) +}) + +describe('fetchColumns', () => { + it('reads Mongo keys from the first document', async () => { + mocks.invoke.mockResolvedValue('_id\nname\n') + expect(await fetchColumns(server({ kind: 'mongodb' }), 'app', 'users')).toEqual(['_id', 'name']) + }) + + // Pre-existing quirk kept by this refactor: the mongosh script is escaped + // SQL-style (doubling quotes) rather than with backslashes. + it('escapes quotes in the Mongo script the SQL way', async () => { + mocks.invoke.mockResolvedValue('') + await fetchColumns(server({ kind: 'mongodb' }), "a'b", 'users') + const script = (mocks.invoke.mock.calls[0][1] as { script: string }).script + expect(script).toContain("getSiblingDB('a''b')") + }) + + it('splits a qualified Postgres name into schema and table', async () => { + mocks.invoke.mockResolvedValue({ columns: ['column_name', 'data_type'], rows: [['id', 'integer']] }) + expect(await fetchColumns(server({ kind: 'postgres' }), 'app', 'sales.orders')).toEqual(['id (integer)']) + const sql = (mocks.invoke.mock.calls[0][1] as { sql: string }).sql + expect(sql).toContain("table_schema='sales'") + expect(sql).toContain("table_name='orders'") + }) + + it('defaults the Postgres schema to public', async () => { + mocks.invoke.mockResolvedValue({ columns: [], rows: [] }) + await fetchColumns(server({ kind: 'postgres' }), 'app', 'orders') + expect((mocks.invoke.mock.calls[0][1] as { sql: string }).sql).toContain("table_schema='public'") + }) + + it('queries information_schema for MySQL', async () => { + mocks.invoke.mockResolvedValue({ columns: [], rows: [['id', 'int']] }) + expect(await fetchColumns(server({ kind: 'mysql' }), 'app', 'orders')).toEqual(['id (int)']) + }) + + it('returns no columns instead of throwing when the query fails', async () => { + mocks.invoke.mockRejectedValue(new Error('denied')) + expect(await fetchColumns(server({ kind: 'mysql' }), 'app', 'orders')).toEqual([]) + }) +}) From 10716c30e8e7d4e6bec8832a61a401e16dce740d Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:20:09 +0200 Subject: [PATCH 06/51] refactor: extracted JSON tree and cell rendering into dbCellRender Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 208 +------------------------- src/panels/db/dbCellRender.ts | 211 +++++++++++++++++++++++++++ tests/panels/db/dbCellRender.test.ts | 164 +++++++++++++++++++++ 3 files changed, 376 insertions(+), 207 deletions(-) create mode 100644 src/panels/db/dbCellRender.ts create mode 100644 tests/panels/db/dbCellRender.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 4899585..e9c5a94 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -17,10 +17,10 @@ import { type TableData, type EditMeta, } from './dbAccess' import { parseStructuredJson } from './jsonValues' +import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from './dbCellRender' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 -let closeOpenPanel: (() => void) | null = null const note = (text: string, cls = 'db-note'): HTMLElement => { const el = document.createElement('div') @@ -29,212 +29,6 @@ const note = (text: string, cls = 'db-note'): HTMLElement => { return el } -const prettyJson = (json: string): string => { - try { return JSON.stringify(JSON.parse(json), null, 2) } catch { return json } -} - -const mkSpan = (cls: string, text: string): HTMLSpanElement => { - const s = document.createElement('span') - s.className = cls - s.textContent = text - return s -} - -// Matches: key+colon | string value | number | true/false/null | punctuation -const JSON_TOKEN_RE = /("(?:[^"\\]|\\.)*")(\s*:)|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],])/g - -const primitiveClass = (val: unknown): string => { - if (typeof val === 'string') return 'js' - if (typeof val === 'number') return 'jn' - return 'jl' -} - -const buildJsonTree = (val: unknown, depth: number): HTMLElement => { - if (val === null || typeof val !== 'object') { - return mkSpan(primitiveClass(val), JSON.stringify(val)) - } - const isArr = Array.isArray(val) - const entries: [string, unknown][] = isArr - ? (val as unknown[]).map((v, i) => [String(i), v]) - : Object.entries(val as Record) - const openB = isArr ? '[' : '{' - const closeB = isArr ? ']' : '}' - if (depth >= 6) return mkSpan('jt-hint', `${openB}…${entries.length}${closeB}`) - const initialOpen = depth < 2 - - const node = document.createElement('div') - node.className = 'jt-node' - - const header = document.createElement('span') - header.className = 'jt-header' - const toggle = document.createElement('button') - toggle.className = 'jt-toggle' - toggle.textContent = initialOpen ? '▼' : '▶' - const hint = document.createElement('span') - hint.className = 'jt-hint' - hint.textContent = `${entries.length}${closeB}` - hint.style.display = initialOpen ? 'none' : 'inline' - header.append(toggle, mkSpan('jp', openB), hint) - - const body = document.createElement('div') - body.className = 'jt-body' - body.style.display = initialOpen ? 'block' : 'none' - entries.forEach(([key, childVal]) => { - const row = document.createElement('div') - row.className = 'jt-row' - if (!isArr) { - row.appendChild(mkSpan('jk', `"${key}"`)) - row.appendChild(document.createTextNode(': ')) - } - row.appendChild(buildJsonTree(childVal, depth + 1)) - body.appendChild(row) - }) - - const close = document.createElement('span') - close.className = 'jp jt-close' - close.textContent = closeB - close.style.display = initialOpen ? 'block' : 'none' - - toggle.addEventListener('click', e => { - e.stopPropagation() - const nowOpen = body.style.display === 'none' - body.style.display = nowOpen ? 'block' : 'none' - hint.style.display = nowOpen ? 'none' : 'inline' - close.style.display = nowOpen ? 'block' : 'none' - toggle.textContent = nowOpen ? '▼' : '▶' - }) - - node.append(header, body, close) - return node -} - -const highlightJson = (pre: HTMLPreElement, src: string): void => { - const frag = document.createDocumentFragment() - let cursor = 0 - let m: RegExpExecArray | null - JSON_TOKEN_RE.lastIndex = 0 - while ((m = JSON_TOKEN_RE.exec(src)) !== null) { - if (m.index > cursor) frag.appendChild(document.createTextNode(src.slice(cursor, m.index))) - if (m[1] !== undefined) { - frag.appendChild(mkSpan('jk', m[1])) - frag.appendChild(document.createTextNode(m[2] ?? '')) - } else if (m[3] !== undefined) { - frag.appendChild(mkSpan('js', m[3])) - } else if (m[4] !== undefined) { - frag.appendChild(mkSpan('jn', m[4])) - } else if (m[5] !== undefined) { - frag.appendChild(mkSpan('jl', m[5])) - } else if (m[6] !== undefined) { - frag.appendChild(mkSpan('jp', m[6])) - } - cursor = m.index + m[0].length - } - if (cursor < src.length) frag.appendChild(document.createTextNode(src.slice(cursor))) - pre.replaceChildren(frag) -} - -const renderCellValue = (td: HTMLTableCellElement, value: string): void => { - td.replaceChildren() - td.classList.toggle('db-null', value === 'NULL') - td.classList.remove('db-json-td') - - const json = parseStructuredJson(value) - const isLongText = !json && (value.includes('\n') || value.length > 40 || value.endsWith('…')) - - if (!json && !isLongText) { - td.textContent = value - return - } - - td.classList.add('db-json-td') - const cell = document.createElement('div') - cell.className = 'db-json-cell' - const summaryEl = document.createElement('div') - summaryEl.className = 'db-json-summary' - - const closeCell = (): void => { - cell.classList.remove('db-json-open') - document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) - closeOpenPanel = null - } - - const onPointerDown = (e: PointerEvent): void => { - if (!cell.contains(e.target as Node)) closeCell() - } - - const onKeyDown = (e: KeyboardEvent): void => { - if (e.key === 'Escape') closeCell() - } - - summaryEl.addEventListener('click', () => { - const nowOpen = cell.classList.toggle('db-json-open') - if (nowOpen) { - closeOpenPanel?.() - closeOpenPanel = closeCell - document.addEventListener('pointerdown', onPointerDown) - document.addEventListener('keydown', onKeyDown) - requestAnimationFrame(() => { - const rect = panel.getBoundingClientRect() - panel.classList.toggle('db-json-flip', rect.bottom > window.innerHeight - 8) - }) - } else { - closeCell() - } - }) - - if (json) { - summaryEl.title = i18nT('db.expandJson') - const badge = document.createElement('span') - badge.className = 'db-json-badge' - badge.textContent = i18nT('db.jsonBadge') - const preview = document.createElement('span') - preview.className = 'db-json-preview' - preview.textContent = json.truncated - ? i18nT('db.jsonTruncated') - : json.kind === 'array' - ? i18nT('db.jsonItems', { count: json.size }) - : i18nT('db.jsonKeys', { count: json.size }) - summaryEl.append(badge, preview) - } else { - const textPreview = document.createElement('span') - textPreview.className = 'db-text-preview' - textPreview.textContent = value.split('\n')[0].trim() - summaryEl.appendChild(textPreview) - } - - const rawContent = json ? json.formatted : value - let contentEl: HTMLElement - if (json && !json.truncated) { - contentEl = document.createElement('div') - contentEl.className = 'db-json-content' - contentEl.appendChild(buildJsonTree(JSON.parse(json.formatted), 0)) - } else { - contentEl = document.createElement('pre') - contentEl.className = 'db-json-content' - contentEl.textContent = rawContent - } - contentEl.addEventListener('dblclick', event => event.stopPropagation()) - - const copyBtn = document.createElement('button') - copyBtn.className = 'db-json-copy' - copyBtn.title = i18nT('db.jsonCopy') - copyBtn.textContent = '⎘' - copyBtn.addEventListener('click', e => { - e.stopPropagation() - void navigator.clipboard.writeText(rawContent).then(() => { - copyBtn.textContent = '✓' - setTimeout(() => { copyBtn.textContent = '⎘' }, 1200) - }) - }) - - const panel = document.createElement('div') - panel.className = 'db-json-panel' - panel.append(copyBtn, contentEl) - cell.append(summaryEl, panel) - td.appendChild(cell) -} - const makeFilterInput = (onChange: (q: string) => void): HTMLInputElement => { const input = document.createElement('input') input.className = 'db-filter' diff --git a/src/panels/db/dbCellRender.ts b/src/panels/db/dbCellRender.ts new file mode 100644 index 0000000..edcb1d8 --- /dev/null +++ b/src/panels/db/dbCellRender.ts @@ -0,0 +1,211 @@ +import { t as i18nT } from '../../i18n' +import { parseStructuredJson } from './jsonValues' + +// Only one expanded JSON/text panel at a time: opening one closes the previous. +let closeOpenPanel: (() => void) | null = null + +export const prettyJson = (json: string): string => { + try { return JSON.stringify(JSON.parse(json), null, 2) } catch { return json } +} + +const mkSpan = (cls: string, text: string): HTMLSpanElement => { + const s = document.createElement('span') + s.className = cls + s.textContent = text + return s +} + +// Matches: key+colon | string value | number | true/false/null | punctuation +const JSON_TOKEN_RE = /("(?:[^"\\]|\\.)*")(\s*:)|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],])/g + +const primitiveClass = (val: unknown): string => { + if (typeof val === 'string') return 'js' + if (typeof val === 'number') return 'jn' + return 'jl' +} + +export const buildJsonTree = (val: unknown, depth: number): HTMLElement => { + if (val === null || typeof val !== 'object') { + return mkSpan(primitiveClass(val), JSON.stringify(val)) + } + const isArr = Array.isArray(val) + const entries: [string, unknown][] = isArr + ? (val as unknown[]).map((v, i) => [String(i), v]) + : Object.entries(val as Record) + const openB = isArr ? '[' : '{' + const closeB = isArr ? ']' : '}' + if (depth >= 6) return mkSpan('jt-hint', `${openB}…${entries.length}${closeB}`) + const initialOpen = depth < 2 + + const node = document.createElement('div') + node.className = 'jt-node' + + const header = document.createElement('span') + header.className = 'jt-header' + const toggle = document.createElement('button') + toggle.className = 'jt-toggle' + toggle.textContent = initialOpen ? '▼' : '▶' + const hint = document.createElement('span') + hint.className = 'jt-hint' + hint.textContent = `${entries.length}${closeB}` + hint.style.display = initialOpen ? 'none' : 'inline' + header.append(toggle, mkSpan('jp', openB), hint) + + const body = document.createElement('div') + body.className = 'jt-body' + body.style.display = initialOpen ? 'block' : 'none' + entries.forEach(([key, childVal]) => { + const row = document.createElement('div') + row.className = 'jt-row' + if (!isArr) { + row.appendChild(mkSpan('jk', `"${key}"`)) + row.appendChild(document.createTextNode(': ')) + } + row.appendChild(buildJsonTree(childVal, depth + 1)) + body.appendChild(row) + }) + + const close = document.createElement('span') + close.className = 'jp jt-close' + close.textContent = closeB + close.style.display = initialOpen ? 'block' : 'none' + + toggle.addEventListener('click', e => { + e.stopPropagation() + const nowOpen = body.style.display === 'none' + body.style.display = nowOpen ? 'block' : 'none' + hint.style.display = nowOpen ? 'none' : 'inline' + close.style.display = nowOpen ? 'block' : 'none' + toggle.textContent = nowOpen ? '▼' : '▶' + }) + + node.append(header, body, close) + return node +} + +export const highlightJson = (pre: HTMLPreElement, src: string): void => { + const frag = document.createDocumentFragment() + let cursor = 0 + let m: RegExpExecArray | null + JSON_TOKEN_RE.lastIndex = 0 + while ((m = JSON_TOKEN_RE.exec(src)) !== null) { + if (m.index > cursor) frag.appendChild(document.createTextNode(src.slice(cursor, m.index))) + if (m[1] !== undefined) { + frag.appendChild(mkSpan('jk', m[1])) + frag.appendChild(document.createTextNode(m[2] ?? '')) + } else if (m[3] !== undefined) { + frag.appendChild(mkSpan('js', m[3])) + } else if (m[4] !== undefined) { + frag.appendChild(mkSpan('jn', m[4])) + } else if (m[5] !== undefined) { + frag.appendChild(mkSpan('jl', m[5])) + } else if (m[6] !== undefined) { + frag.appendChild(mkSpan('jp', m[6])) + } + cursor = m.index + m[0].length + } + if (cursor < src.length) frag.appendChild(document.createTextNode(src.slice(cursor))) + pre.replaceChildren(frag) +} + +export const renderCellValue = (td: HTMLTableCellElement, value: string): void => { + td.replaceChildren() + td.classList.toggle('db-null', value === 'NULL') + td.classList.remove('db-json-td') + + const json = parseStructuredJson(value) + const isLongText = !json && (value.includes('\n') || value.length > 40 || value.endsWith('…')) + + if (!json && !isLongText) { + td.textContent = value + return + } + + td.classList.add('db-json-td') + const cell = document.createElement('div') + cell.className = 'db-json-cell' + const summaryEl = document.createElement('div') + summaryEl.className = 'db-json-summary' + + const closeCell = (): void => { + cell.classList.remove('db-json-open') + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + closeOpenPanel = null + } + + const onPointerDown = (e: PointerEvent): void => { + if (!cell.contains(e.target as Node)) closeCell() + } + + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') closeCell() + } + + summaryEl.addEventListener('click', () => { + const nowOpen = cell.classList.toggle('db-json-open') + if (nowOpen) { + closeOpenPanel?.() + closeOpenPanel = closeCell + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + requestAnimationFrame(() => { + const rect = panel.getBoundingClientRect() + panel.classList.toggle('db-json-flip', rect.bottom > window.innerHeight - 8) + }) + } else { + closeCell() + } + }) + + if (json) { + summaryEl.title = i18nT('db.expandJson') + const badge = document.createElement('span') + badge.className = 'db-json-badge' + badge.textContent = i18nT('db.jsonBadge') + const preview = document.createElement('span') + preview.className = 'db-json-preview' + preview.textContent = json.truncated + ? i18nT('db.jsonTruncated') + : json.kind === 'array' + ? i18nT('db.jsonItems', { count: json.size }) + : i18nT('db.jsonKeys', { count: json.size }) + summaryEl.append(badge, preview) + } else { + const textPreview = document.createElement('span') + textPreview.className = 'db-text-preview' + textPreview.textContent = value.split('\n')[0].trim() + summaryEl.appendChild(textPreview) + } + + const rawContent = json ? json.formatted : value + let contentEl: HTMLElement + if (json && !json.truncated) { + contentEl = document.createElement('div') + contentEl.className = 'db-json-content' + contentEl.appendChild(buildJsonTree(JSON.parse(json.formatted), 0)) + } else { + contentEl = document.createElement('pre') + contentEl.className = 'db-json-content' + contentEl.textContent = rawContent + } + contentEl.addEventListener('dblclick', event => event.stopPropagation()) + + const copyBtn = document.createElement('button') + copyBtn.className = 'db-json-copy' + copyBtn.title = i18nT('db.jsonCopy') + copyBtn.textContent = '⎘' + copyBtn.addEventListener('click', e => { + e.stopPropagation() + void navigator.clipboard.writeText(rawContent).then(() => { + copyBtn.textContent = '✓' + setTimeout(() => { copyBtn.textContent = '⎘' }, 1200) + }) + }) + + const panel = document.createElement('div') + panel.className = 'db-json-panel' + panel.append(copyBtn, contentEl) + cell.append(summaryEl, panel) + td.appendChild(cell) +} diff --git a/tests/panels/db/dbCellRender.test.ts b/tests/panels/db/dbCellRender.test.ts new file mode 100644 index 0000000..31b344c --- /dev/null +++ b/tests/panels/db/dbCellRender.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from '../../../src/panels/db/dbCellRender' + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() +}) + +const td = (): HTMLTableCellElement => document.createElement('td') + +describe('prettyJson', () => { + it('re-indents valid JSON', () => { + expect(prettyJson('{"a":1}')).toBe('{\n "a": 1\n}') + }) + + it('returns the input untouched when it is not JSON', () => { + expect(prettyJson('not json')).toBe('not json') + }) +}) + +describe('buildJsonTree', () => { + it('renders a primitive as a single span classed by type', () => { + expect(buildJsonTree('hi', 0).className).toBe('js') + expect(buildJsonTree(7, 0).className).toBe('jn') + expect(buildJsonTree(null, 0).className).toBe('jl') + expect(buildJsonTree(true, 0).className).toBe('jl') + }) + + it('shows object keys and nests children', () => { + const el = buildJsonTree({ a: { b: 1 } }, 0) + expect(el.querySelectorAll('.jk')[0].textContent).toBe('"a"') + expect(el.textContent).toContain('1') + }) + + it('labels arrays with brackets and omits index keys', () => { + const el = buildJsonTree([1, 2], 0) + expect(el.querySelector('.jp')!.textContent).toBe('[') + expect(el.querySelector('.jk')).toBeNull() + }) + + it('opens the first two levels and keeps deeper ones collapsed', () => { + const open = buildJsonTree({ a: 1 }, 1) + expect((open.querySelector('.jt-body') as HTMLElement).style.display).toBe('block') + const collapsed = buildJsonTree({ a: 1 }, 2) + expect((collapsed.querySelector('.jt-body') as HTMLElement).style.display).toBe('none') + }) + + it('stops recursing past depth 6 and shows a size hint instead', () => { + const el = buildJsonTree([1, 2, 3], 6) + expect(el.className).toBe('jt-hint') + expect(el.textContent).toBe('[…3]') + }) + + it('toggles a node open and closed on click', () => { + const el = buildJsonTree({ a: 1 }, 0) + const body = el.querySelector('.jt-body') as HTMLElement + const toggle = el.querySelector('.jt-toggle') as HTMLButtonElement + toggle.click() + expect(body.style.display).toBe('none') + expect(toggle.textContent).toBe('▶') + toggle.click() + expect(body.style.display).toBe('block') + expect(toggle.textContent).toBe('▼') + }) +}) + +describe('highlightJson', () => { + it('classes keys, strings, numbers, literals and punctuation apart', () => { + const pre = document.createElement('pre') + highlightJson(pre, '{"k": "v", "n": 1, "b": null}') + const cls = (c: string) => [...pre.querySelectorAll(c)].map(e => e.textContent) + expect(cls('.jk')).toEqual(['"k"', '"n"', '"b"']) + expect(cls('.js')).toEqual(['"v"']) + expect(cls('.jn')).toEqual(['1']) + expect(cls('.jl')).toEqual(['null']) + expect(cls('.jp')).toEqual(['{', ',', ',', '}']) + }) + + it('replaces previous content instead of appending on a second call', () => { + const pre = document.createElement('pre') + highlightJson(pre, '{"a": 1}') + highlightJson(pre, '{"b": 2}') + expect(pre.textContent).toBe('{"b": 2}') + }) +}) + +describe('renderCellValue', () => { + it('writes a short scalar as plain text with no expander', () => { + const cell = td() + renderCellValue(cell, 'hello') + expect(cell.textContent).toBe('hello') + expect(cell.querySelector('.db-json-cell')).toBeNull() + }) + + it('marks NULL cells and clears the mark when the value changes', () => { + const cell = td() + renderCellValue(cell, 'NULL') + expect(cell.classList.contains('db-null')).toBe(true) + renderCellValue(cell, 'x') + expect(cell.classList.contains('db-null')).toBe(false) + }) + + it('gives long or multiline text an expandable preview of the first line', () => { + const cell = td() + renderCellValue(cell, 'first line\nsecond line') + expect(cell.classList.contains('db-json-td')).toBe(true) + expect(cell.querySelector('.db-text-preview')!.textContent).toBe('first line') + }) + + it('summarises JSON objects by key count and arrays by item count', () => { + const obj = td() + renderCellValue(obj, '{"a":1,"b":2}') + expect(obj.querySelector('.db-json-badge')).not.toBeNull() + expect(obj.querySelector('.db-json-preview')!.textContent).toContain('2') + + const arr = td() + renderCellValue(arr, '[1,2,3]') + expect(arr.querySelector('.db-json-preview')!.textContent).toContain('3') + }) + + it('renders parsed JSON as a tree and truncated JSON as raw text', () => { + const full = td() + renderCellValue(full, '{"a":1}') + expect(full.querySelector('.db-json-content .jt-node')).not.toBeNull() + + const cut = td() + renderCellValue(cut, '{"a":1,…') + expect(cut.querySelector('.db-json-content')!.tagName).toBe('PRE') + }) + + it('opens the panel on click and closes it on Escape', () => { + const cell = td() + document.body.appendChild(cell) + renderCellValue(cell, '{"a":1}') + const wrap = cell.querySelector('.db-json-cell')! + ;(cell.querySelector('.db-json-summary') as HTMLElement).click() + expect(wrap.classList.contains('db-json-open')).toBe(true) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(wrap.classList.contains('db-json-open')).toBe(false) + }) + + it('closes the previously open cell when another one opens', () => { + const a = td(), b = td() + document.body.append(a, b) + renderCellValue(a, '{"a":1}') + renderCellValue(b, '{"b":2}') + ;(a.querySelector('.db-json-summary') as HTMLElement).click() + ;(b.querySelector('.db-json-summary') as HTMLElement).click() + expect(a.querySelector('.db-json-cell')!.classList.contains('db-json-open')).toBe(false) + expect(b.querySelector('.db-json-cell')!.classList.contains('db-json-open')).toBe(true) + }) + + it('copies the raw value, not the rendered tree', async () => { + const writeText = vi.fn(async () => {}) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const cell = td() + renderCellValue(cell, '{"a":1}') + ;(cell.querySelector('.db-json-copy') as HTMLButtonElement).click() + expect(writeText).toHaveBeenCalledWith('{\n "a": 1\n}') + }) +}) From e7977608b2f91a20cd56b19819506c04df872d1a Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:21:10 +0200 Subject: [PATCH 07/51] refactor: extracted shared DB widgets and tree rows into dbWidgets Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 95 +---------------------- src/panels/db/dbWidgets.ts | 96 +++++++++++++++++++++++ tests/panels/db/dbWidgets.test.ts | 125 ++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 94 deletions(-) create mode 100644 src/panels/db/dbWidgets.ts create mode 100644 tests/panels/db/dbWidgets.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index e9c5a94..46c17fb 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -18,60 +18,11 @@ import { } from './dbAccess' import { parseStructuredJson } from './jsonValues' import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from './dbCellRender' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap, buildWheres, rowEl, appendExpandable } from './dbWidgets' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 -const note = (text: string, cls = 'db-note'): HTMLElement => { - const el = document.createElement('div') - el.className = cls - el.textContent = text - return el -} - -const makeFilterInput = (onChange: (q: string) => void): HTMLInputElement => { - const input = document.createElement('input') - input.className = 'db-filter' - input.placeholder = i18nT('db.filterRows') - input.type = 'search' - let t: ReturnType | null = null - input.addEventListener('input', () => { - if (t) clearTimeout(t) - t = setTimeout(() => onChange(input.value.toLowerCase()), 150) - }) - return input -} - -const makeCsvBtn = (getData: () => { cols: string[]; rows: string[][]; filename: string }): HTMLButtonElement => { - const btn = document.createElement('button') - btn.className = 'db-action' - btn.title = i18nT('db.exportCsv') - btn.innerHTML = icon('download') - btn.addEventListener('click', () => { - const { cols, rows, filename } = getData() - const csv = [cols, ...rows].map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n') - const a = document.createElement('a') - a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) - a.download = filename - a.click() - URL.revokeObjectURL(a.href) - }) - return btn -} - -const buildWheres = (pkIdx: number[], columns: string[], row: string[]): [string, string][] => - pkIdx.map(i => [columns[i], row[i]]) - -const makeResultWrap = (tbl: HTMLElement, toolbarItems: HTMLElement[]): HTMLElement => { - const toolbar = document.createElement('div') - toolbar.className = 'db-result-toolbar' - toolbar.append(...toolbarItems) - const wrap = document.createElement('div') - wrap.className = 'db-result-wrap' - wrap.append(toolbar, tbl) - return wrap -} - export function createDbPanel(): { element: HTMLElement } { const root = document.createElement('div') root.className = 'db-panel' @@ -1342,50 +1293,6 @@ export function createDbPanel(): { element: HTMLElement } { } // ---- tree ---- - const rowEl = (depth: number, iconName: string, label: string, expandable: boolean): HTMLButtonElement => { - const row = document.createElement('button') - row.className = 'db-row' - row.style.paddingLeft = `${8 + depth * 14}px` - if (expandable) { - const chevron = document.createElement('span') - chevron.className = 'db-chevron' - chevron.innerHTML = icon('chevron') - row.appendChild(chevron) - } - const ic = document.createElement('span') - ic.className = 'db-row-icon' - ic.innerHTML = icon(iconName) - const lbl = document.createElement('span') - lbl.className = 'db-row-label' - lbl.textContent = label - row.append(ic, lbl) - return row - } - - const appendExpandable = ( - parent: HTMLElement, - row: HTMLButtonElement, - onFirstExpand: (children: HTMLElement) => void, - ): void => { - let children: HTMLElement | null = null - let loaded = false - row.addEventListener('click', () => { - if (!children) { - children = document.createElement('div') - children.className = 'db-children' - row.insertAdjacentElement('afterend', children) - row.classList.add('open') - if (!loaded) { loaded = true; onFirstExpand(children) } - return - } - const willOpen = children.classList.contains('hidden') - row.classList.toggle('open', willOpen) - children.classList.toggle('hidden', !willOpen) - if (willOpen && !loaded) { loaded = true; onFirstExpand(children) } - }) - parent.appendChild(row) - } - const selectLeaf = (row: HTMLElement): void => { tree.querySelectorAll('.db-leaf.selected').forEach(el => el.classList.remove('selected')) row.classList.add('selected') diff --git a/src/panels/db/dbWidgets.ts b/src/panels/db/dbWidgets.ts new file mode 100644 index 0000000..759dddc --- /dev/null +++ b/src/panels/db/dbWidgets.ts @@ -0,0 +1,96 @@ +import { t as i18nT } from '../../i18n' +import { icon } from '../../ui/icons' + +export const note = (text: string, cls = 'db-note'): HTMLElement => { + const el = document.createElement('div') + el.className = cls + el.textContent = text + return el +} + +export const makeFilterInput = (onChange: (q: string) => void): HTMLInputElement => { + const input = document.createElement('input') + input.className = 'db-filter' + input.placeholder = i18nT('db.filterRows') + input.type = 'search' + let t: ReturnType | null = null + input.addEventListener('input', () => { + if (t) clearTimeout(t) + t = setTimeout(() => onChange(input.value.toLowerCase()), 150) + }) + return input +} + +export const makeCsvBtn = (getData: () => { cols: string[]; rows: string[][]; filename: string }): HTMLButtonElement => { + const btn = document.createElement('button') + btn.className = 'db-action' + btn.title = i18nT('db.exportCsv') + btn.innerHTML = icon('download') + btn.addEventListener('click', () => { + const { cols, rows, filename } = getData() + const csv = [cols, ...rows].map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n') + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) + a.download = filename + a.click() + URL.revokeObjectURL(a.href) + }) + return btn +} + +export const buildWheres = (pkIdx: number[], columns: string[], row: string[]): [string, string][] => + pkIdx.map(i => [columns[i], row[i]]) + +export const makeResultWrap = (tbl: HTMLElement, toolbarItems: HTMLElement[]): HTMLElement => { + const toolbar = document.createElement('div') + toolbar.className = 'db-result-toolbar' + toolbar.append(...toolbarItems) + const wrap = document.createElement('div') + wrap.className = 'db-result-wrap' + wrap.append(toolbar, tbl) + return wrap +} + +export const rowEl = (depth: number, iconName: string, label: string, expandable: boolean): HTMLButtonElement => { + const row = document.createElement('button') + row.className = 'db-row' + row.style.paddingLeft = `${8 + depth * 14}px` + if (expandable) { + const chevron = document.createElement('span') + chevron.className = 'db-chevron' + chevron.innerHTML = icon('chevron') + row.appendChild(chevron) + } + const ic = document.createElement('span') + ic.className = 'db-row-icon' + ic.innerHTML = icon(iconName) + const lbl = document.createElement('span') + lbl.className = 'db-row-label' + lbl.textContent = label + row.append(ic, lbl) + return row +} + +export const appendExpandable = ( + parent: HTMLElement, + row: HTMLButtonElement, + onFirstExpand: (children: HTMLElement) => void, +): void => { + let children: HTMLElement | null = null + let loaded = false + row.addEventListener('click', () => { + if (!children) { + children = document.createElement('div') + children.className = 'db-children' + row.insertAdjacentElement('afterend', children) + row.classList.add('open') + if (!loaded) { loaded = true; onFirstExpand(children) } + return + } + const willOpen = children.classList.contains('hidden') + row.classList.toggle('open', willOpen) + children.classList.toggle('hidden', !willOpen) + if (willOpen && !loaded) { loaded = true; onFirstExpand(children) } + }) + parent.appendChild(row) +} diff --git a/tests/panels/db/dbWidgets.test.ts b/tests/panels/db/dbWidgets.test.ts new file mode 100644 index 0000000..eb12f9e --- /dev/null +++ b/tests/panels/db/dbWidgets.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap, buildWheres, rowEl, appendExpandable } from '../../../src/panels/db/dbWidgets' + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + vi.useRealTimers() +}) + +describe('note', () => { + it('carries the text and the default class', () => { + const el = note('nothing here') + expect(el.textContent).toBe('nothing here') + expect(el.className).toBe('db-note') + }) + + it('takes an override class', () => { + expect(note('boom', 'db-detail-error').className).toBe('db-detail-error') + }) +}) + +describe('makeFilterInput', () => { + it('debounces and reports the query lowercased', () => { + vi.useFakeTimers() + const onChange = vi.fn() + const input = makeFilterInput(onChange) + input.value = 'AbC' + input.dispatchEvent(new Event('input')) + input.value = 'AbCd' + input.dispatchEvent(new Event('input')) + expect(onChange).not.toHaveBeenCalled() + vi.advanceTimersByTime(150) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('abcd') + }) +}) + +describe('makeCsvBtn', () => { + it('quotes every field and doubles embedded quotes', () => { + let csv = '' + vi.stubGlobal('URL', { + createObjectURL: (b: { text: () => Promise }) => { void b; return 'blob:x' }, + revokeObjectURL: () => {}, + }) + vi.stubGlobal('Blob', class { + constructor(parts: string[]) { csv = parts.join('') } + }) + + const btn = makeCsvBtn(() => ({ cols: ['a', 'b'], rows: [['1', 'say "hi"']], filename: 'out.csv' })) + btn.click() + expect(csv).toBe('"a","b"\n"1","say ""hi"""') + }) +}) + +describe('makeResultWrap', () => { + it('puts the toolbar above the table', () => { + const tbl = document.createElement('table') + const wrap = makeResultWrap(tbl, [document.createElement('span')]) + expect(wrap.className).toBe('db-result-wrap') + expect(wrap.children[0].className).toBe('db-result-toolbar') + expect(wrap.children[1]).toBe(tbl) + }) +}) + +describe('buildWheres', () => { + it('pairs each primary-key column with its value in the row', () => { + expect(buildWheres([0, 2], ['id', 'name', 'tenant'], ['7', 'ana', 'acme'])) + .toEqual([['id', '7'], ['tenant', 'acme']]) + }) + + it('is empty for a table with no primary key', () => { + expect(buildWheres([], ['a'], ['1'])).toEqual([]) + }) +}) + +describe('rowEl', () => { + it('indents by depth and shows the label', () => { + const row = rowEl(2, 'table', 'orders', false) + expect(row.style.paddingLeft).toBe('36px') + expect(row.querySelector('.db-row-label')!.textContent).toBe('orders') + }) + + it('only gets a chevron when it is expandable', () => { + expect(rowEl(0, 'database', 'app', true).querySelector('.db-chevron')).not.toBeNull() + expect(rowEl(0, 'database', 'app', false).querySelector('.db-chevron')).toBeNull() + }) +}) + +describe('appendExpandable', () => { + it('loads the children only on the first expand', () => { + const parent = document.createElement('div') + const row = rowEl(0, 'database', 'app', true) + const onFirstExpand = vi.fn() + appendExpandable(parent, row, onFirstExpand) + expect(parent.contains(row)).toBe(true) + expect(onFirstExpand).not.toHaveBeenCalled() + + row.click() + expect(onFirstExpand).toHaveBeenCalledTimes(1) + expect(row.classList.contains('open')).toBe(true) + + row.click() + row.click() + expect(onFirstExpand).toHaveBeenCalledTimes(1) + }) + + it('collapses and re-expands the same children container', () => { + const parent = document.createElement('div') + const row = rowEl(0, 'database', 'app', true) + appendExpandable(parent, row, children => { children.textContent = 'loaded' }) + row.click() + const children = parent.querySelector('.db-children') as HTMLElement + expect(children.textContent).toBe('loaded') + + row.click() + expect(children.classList.contains('hidden')).toBe(true) + expect(row.classList.contains('open')).toBe(false) + + row.click() + expect(children.classList.contains('hidden')).toBe(false) + expect(row.classList.contains('open')).toBe(true) + }) +}) From bb43b292d4a72343045e2427142059b36882c2e5 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:22:40 +0200 Subject: [PATCH 08/51] refactor: extracted server detection and credential resolution into dbDetect Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 52 +----------- src/panels/db/dbDetect.ts | 48 +++++++++++ tests/panels/db/dbDetect.test.ts | 134 +++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 49 deletions(-) create mode 100644 src/panels/db/dbDetect.ts create mode 100644 tests/panels/db/dbDetect.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 46c17fb..5fd96e2 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -1,10 +1,6 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' -import { parseDockerPs } from '../../core/db/dockerPs' -import { serverKind } from '../../core/db/serverKind' -import { publishedPort } from '../../core/db/hostPort' -import { mysqlCreds, mongoCreds, pgCreds } from '../../core/db/credentials' -import { DEFAULT_PORT, LISTABLE, kindForPort, type DbServer } from '../../core/db/dbServer' +import { LISTABLE, type DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' import { askAi, type AiQueryRunner, type AiTool } from '../../ui/askAi' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' @@ -12,13 +8,14 @@ import { buildJoinPath, type Relation } from '../../core/db/joinPath' import { withRowLimit } from '../../core/db/rowLimit' import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' import { - KIND_LABEL, isMongo, isPg, isRedis, envValue, sqlCmd, creds, target, + KIND_LABEL, isMongo, isPg, isRedis, sqlCmd, creds, target, parseRedisLines, fetchColumns, listDatabases, listTables, fetchRelations, type TableData, type EditMeta, } from './dbAccess' import { parseStructuredJson } from './jsonValues' import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from './dbCellRender' import { note, makeFilterInput, makeCsvBtn, makeResultWrap, buildWheres, rowEl, appendExpandable } from './dbWidgets' +import { detectDocker, detectLocal, resolveCreds } from './dbDetect' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -58,49 +55,6 @@ export function createDbPanel(): { element: HTMLElement } { const showDetail = (...nodes: HTMLElement[]): void => { detail.replaceChildren(...nodes) } showDetail(note(i18nT('db.selectATableOrCollectionToViewIts'), 'db-detail-hint')) - // ---- detection (same as before) ---- - const detectDocker = async (): Promise => { - const raw = await invoke('db_docker_ps').catch(() => '') - const servers: DbServer[] = [] - for (const c of parseDockerPs(raw)) { - const kind = serverKind(c.image, c.ports) - if (!kind) continue - const port = publishedPort(c.ports, DEFAULT_PORT[kind]) ?? DEFAULT_PORT[kind] - servers.push({ kind, source: 'docker', host: '127.0.0.1', port, container: c.name }) - } - return servers - } - - const detectLocal = async (taken: Set): Promise => { - const ports = [...new Set(Object.values(DEFAULT_PORT))] - const open = await invoke('db_check_ports', { ports }).catch(() => [] as number[]) - return open - .filter(p => !taken.has(p)) - .map(p => ({ kind: kindForPort(p)!, source: 'local', host: '127.0.0.1', port: p } as DbServer)) - } - - // ---- credentials ---- - const resolveCreds = async (s: DbServer): Promise => { - if (s.source === 'docker' && s.container) { - const env = await invoke('db_inspect_env', { container: s.container }).catch(() => [] as string[]) - if (isPg(s)) { - const c = pgCreds(env) - s.user = c.user; s.password = c.password; s.connectDb = c.db - } else if (isRedis(s)) { - s.password = envValue(env, 'REDIS_PASSWORD') - } else { - const c = isMongo(s) ? mongoCreds(env) : mysqlCreds(env) - s.user = c.user; s.password = c.password - } - return - } - // Local (non-Docker): sensible default users per engine; no env to read. - s.password = '' - if (isPg(s)) { s.user = 'postgres'; s.connectDb = 'postgres' } - else if (isMongo(s) || isRedis(s)) { s.user = '' } - else { s.user = 'root' } - } - const renderRedisValue = (s: DbServer, db: string, key: string, v: { kind: string; value: string }, ttl: number): void => { const ttlLabel = ttl > 0 ? i18nT('db.ttlSeconds', { ttl }) : ttl === -1 ? i18nT('db.ttlPersists') : '' const kindStr = ttlLabel ? `${v.kind} · ${ttlLabel}` : v.kind diff --git a/src/panels/db/dbDetect.ts b/src/panels/db/dbDetect.ts new file mode 100644 index 0000000..3bb5c4b --- /dev/null +++ b/src/panels/db/dbDetect.ts @@ -0,0 +1,48 @@ +import { invoke } from '@tauri-apps/api/core' +import { parseDockerPs } from '../../core/db/dockerPs' +import { serverKind } from '../../core/db/serverKind' +import { publishedPort } from '../../core/db/hostPort' +import { mysqlCreds, mongoCreds, pgCreds } from '../../core/db/credentials' +import { DEFAULT_PORT, kindForPort, type DbServer } from '../../core/db/dbServer' +import { isMongo, isPg, isRedis, envValue } from './dbAccess' + +export const detectDocker = async (): Promise => { + const raw = await invoke('db_docker_ps').catch(() => '') + const servers: DbServer[] = [] + for (const c of parseDockerPs(raw)) { + const kind = serverKind(c.image, c.ports) + if (!kind) continue + const port = publishedPort(c.ports, DEFAULT_PORT[kind]) ?? DEFAULT_PORT[kind] + servers.push({ kind, source: 'docker', host: '127.0.0.1', port, container: c.name }) + } + return servers +} + +export const detectLocal = async (taken: Set): Promise => { + const ports = [...new Set(Object.values(DEFAULT_PORT))] + const open = await invoke('db_check_ports', { ports }).catch(() => [] as number[]) + return open + .filter(p => !taken.has(p)) + .map(p => ({ kind: kindForPort(p)!, source: 'local', host: '127.0.0.1', port: p } as DbServer)) +} + +export const resolveCreds = async (s: DbServer): Promise => { + if (s.source === 'docker' && s.container) { + const env = await invoke('db_inspect_env', { container: s.container }).catch(() => [] as string[]) + if (isPg(s)) { + const c = pgCreds(env) + s.user = c.user; s.password = c.password; s.connectDb = c.db + } else if (isRedis(s)) { + s.password = envValue(env, 'REDIS_PASSWORD') + } else { + const c = isMongo(s) ? mongoCreds(env) : mysqlCreds(env) + s.user = c.user; s.password = c.password + } + return + } + // Local (non-Docker): sensible default users per engine; no env to read. + s.password = '' + if (isPg(s)) { s.user = 'postgres'; s.connectDb = 'postgres' } + else if (isMongo(s) || isRedis(s)) { s.user = '' } + else { s.user = 'root' } +} diff --git a/tests/panels/db/dbDetect.test.ts b/tests/panels/db/dbDetect.test.ts new file mode 100644 index 0000000..0a97c16 --- /dev/null +++ b/tests/panels/db/dbDetect.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { detectDocker, detectLocal, resolveCreds } from '../../../src/panels/db/dbDetect' +import type { DbServer } from '../../../src/core/db/dbServer' + +const PS = (lines: string[]): string => lines.join('\n') + +beforeEach(() => { + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +describe('detectDocker', () => { + it('maps a recognised container to its engine and published port', async () => { + mocks.invoke.mockResolvedValue(PS(['pg1|postgres:16|0.0.0.0:55432->5432/tcp'])) + expect(await detectDocker()).toEqual([ + { kind: 'postgres', source: 'docker', host: '127.0.0.1', port: 55432, container: 'pg1' }, + ]) + }) + + it('falls back to the engine default when no port is published', async () => { + mocks.invoke.mockResolvedValue(PS(['r1|redis:7|'])) + expect((await detectDocker())[0]).toMatchObject({ kind: 'redis', port: 6379 }) + }) + + it('skips containers that are not databases', async () => { + mocks.invoke.mockResolvedValue(PS(['web|nginx:latest|0.0.0.0:80->80/tcp'])) + expect(await detectDocker()).toEqual([]) + }) + + it('reports nothing instead of throwing when Docker is not running', async () => { + mocks.invoke.mockRejectedValue(new Error('docker daemon not running')) + expect(await detectDocker()).toEqual([]) + }) +}) + +describe('detectLocal', () => { + it('turns each open default port into a local server', async () => { + mocks.invoke.mockResolvedValue([5432]) + expect(await detectLocal(new Set())).toEqual([ + { kind: 'postgres', source: 'local', host: '127.0.0.1', port: 5432 }, + ]) + }) + + it('skips ports already claimed by a Docker container', async () => { + mocks.invoke.mockResolvedValue([3306, 6379]) + const found = await detectLocal(new Set([3306])) + expect(found.map(s => s.port)).toEqual([6379]) + }) + + it('probes each default port once, with no duplicates', async () => { + mocks.invoke.mockResolvedValue([]) + await detectLocal(new Set()) + const { ports } = mocks.invoke.mock.calls[0][1] as { ports: number[] } + expect(new Set(ports).size).toBe(ports.length) + expect(ports).toContain(3306) + }) + + it('reports nothing when the port probe fails', async () => { + mocks.invoke.mockRejectedValue(new Error('nope')) + expect(await detectLocal(new Set())).toEqual([]) + }) +}) + +describe('resolveCreds for Docker servers', () => { + const docker = (kind: DbServer['kind']): DbServer => + ({ kind, source: 'docker', host: '127.0.0.1', port: 1, container: 'c1' }) + + it('reads Postgres user, password and maintenance database from the env', async () => { + mocks.invoke.mockResolvedValue(['POSTGRES_USER=app', 'POSTGRES_PASSWORD=pw', 'POSTGRES_DB=appdb']) + const s = docker('postgres') + await resolveCreds(s) + expect(s).toMatchObject({ user: 'app', password: 'pw', connectDb: 'appdb' }) + }) + + it('reads only the password for Redis', async () => { + mocks.invoke.mockResolvedValue(['REDIS_PASSWORD=secret']) + const s = docker('redis') + await resolveCreds(s) + expect(s.password).toBe('secret') + }) + + it('reads Mongo and MySQL credentials from their own env vars', async () => { + mocks.invoke.mockResolvedValue(['MONGO_INITDB_ROOT_USERNAME=m', 'MONGO_INITDB_ROOT_PASSWORD=mp']) + const mongo = docker('mongodb') + await resolveCreds(mongo) + expect(mongo).toMatchObject({ user: 'm', password: 'mp' }) + + mocks.invoke.mockResolvedValue(['MYSQL_ROOT_PASSWORD=rp']) + const mysql = docker('mysql') + await resolveCreds(mysql) + expect(mysql.password).toBe('rp') + }) + + it('falls back to the engine default user with no password when the container cannot be inspected', async () => { + mocks.invoke.mockRejectedValue(new Error('no such container')) + const s = docker('mysql') + await resolveCreds(s) + expect(s).toMatchObject({ user: 'root', password: '' }) + }) +}) + +describe('resolveCreds for local servers', () => { + const local = (kind: DbServer['kind']): DbServer => + ({ kind, source: 'local', host: '127.0.0.1', port: 1 }) + + it('never inspects a container', async () => { + await resolveCreds(local('mysql')) + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('uses the conventional superuser per engine and no password', async () => { + const pg = local('postgres') + await resolveCreds(pg) + expect(pg).toMatchObject({ user: 'postgres', password: '', connectDb: 'postgres' }) + + const mysql = local('mysql') + await resolveCreds(mysql) + expect(mysql).toMatchObject({ user: 'root', password: '' }) + + for (const kind of ['mongodb', 'redis'] as const) { + const s = local(kind) + await resolveCreds(s) + expect(s).toMatchObject({ user: '', password: '' }) + } + }) +}) From ad47c2ace546b7cca934b3bb66c66fabf9c20bbb Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:23:46 +0200 Subject: [PATCH 09/51] refactor: extracted cell editing and row deletion into dbRowEdit Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 147 +----------------------- src/panels/db/dbRowEdit.ts | 150 ++++++++++++++++++++++++ tests/panels/db/dbRowEdit.test.ts | 184 ++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 145 deletions(-) create mode 100644 src/panels/db/dbRowEdit.ts create mode 100644 tests/panels/db/dbRowEdit.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 5fd96e2..3f9f33c 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -14,8 +14,9 @@ import { } from './dbAccess' import { parseStructuredJson } from './jsonValues' import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from './dbCellRender' -import { note, makeFilterInput, makeCsvBtn, makeResultWrap, buildWheres, rowEl, appendExpandable } from './dbWidgets' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap, rowEl, appendExpandable } from './dbWidgets' import { detectDocker, detectLocal, resolveCreds } from './dbDetect' +import { editCell, deleteRow } from './dbRowEdit' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -761,150 +762,6 @@ export function createDbPanel(): { element: HTMLElement } { editor.focus() } - const editCell = ( - s: DbServer, db: string, table: string, columns: string[], - row: string[], colIdx: number, pkIdx: number[], td: HTMLElement, - fkRef?: { ref_table: string; ref_column: string }, - ): void => { - const column = columns[colIdx] - const old = row[colIdx] - const restore = (): void => { renderCellValue(td as HTMLTableCellElement, old) } - - const applyUpdate = async (value: string, setNull = false): Promise => { - const wheres = buildWheres(pkIdx, columns, row) - const summary = setNull - ? `UPDATE ${table}\nSET ${column} = NULL\nWHERE ${wheres.map(([c, v]) => `${c}=${v}`).join(' AND ')}` - : `UPDATE ${table}\nSET ${column} = '${value}'\nWHERE ${wheres.map(([c, v]) => `${c}=${v}`).join(' AND ')}` - if (!confirm(summary)) { restore(); return } - try { - if (setNull) { - const ident = (id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` - const w = wheres.map(([c, v]) => `${ident(c)} = '${v.replace(/'/g, "''")}'`).join(' AND ') - const tblQ = isPg(s) - ? table.split('.').map(p => `"${p}"`).join('.') - : `\`${db}\`.\`${table}\`` - await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `UPDATE ${tblQ} SET ${ident(column)} = NULL WHERE ${w}`, ...creds(s) }) - row[colIdx] = 'NULL' - renderCellValue(td as HTMLTableCellElement, 'NULL') - return - } - await invoke(sqlCmd(s, 'update'), { ...target(s), db, table, column, value, wheres, ...creds(s) }) - row[colIdx] = value - renderCellValue(td as HTMLTableCellElement, value) - } catch (e) { - const err = String(e) - const isFk = /foreign key/i.test(err) - if (isFk && !isPg(s)) { - if (!confirm(i18nT('db.fkBypass'))) { restore(); return } - try { - const q = value.replace(/'/g, "''") - const w = wheres.map(([c, v]) => `\`${c}\` = '${v.replace(/'/g, "''")}'`).join(' AND ') - await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `SET FOREIGN_KEY_CHECKS=0; UPDATE \`${table}\` SET \`${column}\` = '${q}' WHERE ${w}; SET FOREIGN_KEY_CHECKS=1`, ...creds(s) }) - row[colIdx] = value - renderCellValue(td as HTMLTableCellElement, value) - } catch (e2) { alert(String(e2)); restore() } - } else { - alert(isFk ? i18nT('db.fkError') : err) - restore() - } - } - } - - if (fkRef) { - td.replaceChildren(document.createTextNode('…')) - void invoke(sqlCmd(s, 'rows'), { ...target(s), db, table: fkRef.ref_table, ...creds(s) }) - .then(refData => { - const refColIdx = refData.columns.indexOf(fkRef.ref_column) - if (refColIdx < 0) { showInput(); return } - const sel = document.createElement('select') - sel.className = 'db-cell-input' - refData.rows.forEach(r => { - const o = document.createElement('option') - o.value = r[refColIdx] - const lbl = r.slice(0, 3).join(' · ') - o.textContent = lbl.length > 60 ? lbl.slice(0, 57) + '…' : lbl - if (r[refColIdx] === old) o.selected = true - sel.appendChild(o) - }) - td.replaceChildren(sel) - sel.focus() - let done = false - sel.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); sel.blur() } - if (e.key === 'Escape') { done = true; restore() } - }) - sel.addEventListener('blur', () => { - if (done) return - done = true - if (sel.value !== old) void applyUpdate(sel.value) - else restore() - }) - }) - .catch(showInput) - return - } - - showInput() - - function showInput(): void { - const input = document.createElement('input') - input.className = 'db-cell-input' - input.value = old === 'NULL' ? '' : old - const nullBtn = document.createElement('button') - nullBtn.className = 'db-null-btn' - nullBtn.textContent = 'NULL' - nullBtn.title = i18nT('db.setNull') - const wrap = document.createElement('div') - wrap.className = 'db-cell-edit-wrap' - wrap.append(input, nullBtn) - td.replaceChildren(wrap) - input.focus() - input.select() - let done = false - nullBtn.addEventListener('mousedown', e => { - e.preventDefault() - done = true - void applyUpdate('', true) - }) - input.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); input.blur() } - else if (e.key === 'Escape') { done = true; restore() } - else if (e.key === 'Tab') { - e.preventDefault() - const forward = !e.shiftKey - input.blur() - requestAnimationFrame(() => { - const tr = td.closest('tr')! - const tdsInRow = Array.from(tr.querySelectorAll('td[tabindex]')) as HTMLElement[] - ;(tdsInRow[tdsInRow.indexOf(td) + (forward ? 1 : -1)] as HTMLElement | undefined)?.focus() - }) - } - }) - input.addEventListener('blur', () => { - if (done) return - done = true - if (input.value === old) { restore(); return } - void applyUpdate(input.value) - }) - } - } - - const deleteRow = async ( - s: DbServer, db: string, table: string, columns: string[], - row: string[], pkIdx: number[], tr: HTMLElement, - onDeleted?: () => void, - ): Promise => { - const wheres = buildWheres(pkIdx, columns, row) - if (!confirm(`DELETE FROM ${table}\nWHERE ${wheres.map(([c, v]) => `${c}=${v}`).join(' AND ')}`)) return - try { - await invoke(sqlCmd(s, 'delete'), { ...target(s), db, table, wheres, ...creds(s) }) - if (onDeleted) onDeleted() - else tr.remove() - } catch (e) { - alert(String(e)) - } - } - const renderGrid = (s: DbServer, db: string, table: string, data: TableData, pk: string[], fkColMap: Map, onRefresh?: () => void): void => { const pkIdx = pk.map(c => data.columns.indexOf(c)).filter(i => i >= 0) const editable = pkIdx.length > 0 diff --git a/src/panels/db/dbRowEdit.ts b/src/panels/db/dbRowEdit.ts new file mode 100644 index 0000000..587a5ea --- /dev/null +++ b/src/panels/db/dbRowEdit.ts @@ -0,0 +1,150 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { DbServer } from '../../core/db/dbServer' +import { isPg, sqlCmd, creds, target, type TableData } from './dbAccess' +import { renderCellValue } from './dbCellRender' +import { buildWheres } from './dbWidgets' + +export const editCell = ( + s: DbServer, db: string, table: string, columns: string[], + row: string[], colIdx: number, pkIdx: number[], td: HTMLElement, + fkRef?: { ref_table: string; ref_column: string }, +): void => { + const column = columns[colIdx] + const old = row[colIdx] + const restore = (): void => { renderCellValue(td as HTMLTableCellElement, old) } + + const applyUpdate = async (value: string, setNull = false): Promise => { + const wheres = buildWheres(pkIdx, columns, row) + const summary = setNull + ? `UPDATE ${table}\nSET ${column} = NULL\nWHERE ${wheres.map(([c, v]) => `${c}=${v}`).join(' AND ')}` + : `UPDATE ${table}\nSET ${column} = '${value}'\nWHERE ${wheres.map(([c, v]) => `${c}=${v}`).join(' AND ')}` + if (!confirm(summary)) { restore(); return } + try { + if (setNull) { + const ident = (id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` + const w = wheres.map(([c, v]) => `${ident(c)} = '${v.replace(/'/g, "''")}'`).join(' AND ') + const tblQ = isPg(s) + ? table.split('.').map(p => `"${p}"`).join('.') + : `\`${db}\`.\`${table}\`` + await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `UPDATE ${tblQ} SET ${ident(column)} = NULL WHERE ${w}`, ...creds(s) }) + row[colIdx] = 'NULL' + renderCellValue(td as HTMLTableCellElement, 'NULL') + return + } + await invoke(sqlCmd(s, 'update'), { ...target(s), db, table, column, value, wheres, ...creds(s) }) + row[colIdx] = value + renderCellValue(td as HTMLTableCellElement, value) + } catch (e) { + const err = String(e) + const isFk = /foreign key/i.test(err) + if (isFk && !isPg(s)) { + if (!confirm(i18nT('db.fkBypass'))) { restore(); return } + try { + const q = value.replace(/'/g, "''") + const w = wheres.map(([c, v]) => `\`${c}\` = '${v.replace(/'/g, "''")}'`).join(' AND ') + await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `SET FOREIGN_KEY_CHECKS=0; UPDATE \`${table}\` SET \`${column}\` = '${q}' WHERE ${w}; SET FOREIGN_KEY_CHECKS=1`, ...creds(s) }) + row[colIdx] = value + renderCellValue(td as HTMLTableCellElement, value) + } catch (e2) { alert(String(e2)); restore() } + } else { + alert(isFk ? i18nT('db.fkError') : err) + restore() + } + } + } + + if (fkRef) { + td.replaceChildren(document.createTextNode('…')) + void invoke(sqlCmd(s, 'rows'), { ...target(s), db, table: fkRef.ref_table, ...creds(s) }) + .then(refData => { + const refColIdx = refData.columns.indexOf(fkRef.ref_column) + if (refColIdx < 0) { showInput(); return } + const sel = document.createElement('select') + sel.className = 'db-cell-input' + refData.rows.forEach(r => { + const o = document.createElement('option') + o.value = r[refColIdx] + const lbl = r.slice(0, 3).join(' · ') + o.textContent = lbl.length > 60 ? lbl.slice(0, 57) + '…' : lbl + if (r[refColIdx] === old) o.selected = true + sel.appendChild(o) + }) + td.replaceChildren(sel) + sel.focus() + let done = false + sel.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); sel.blur() } + if (e.key === 'Escape') { done = true; restore() } + }) + sel.addEventListener('blur', () => { + if (done) return + done = true + if (sel.value !== old) void applyUpdate(sel.value) + else restore() + }) + }) + .catch(showInput) + return + } + + showInput() + + function showInput(): void { + const input = document.createElement('input') + input.className = 'db-cell-input' + input.value = old === 'NULL' ? '' : old + const nullBtn = document.createElement('button') + nullBtn.className = 'db-null-btn' + nullBtn.textContent = 'NULL' + nullBtn.title = i18nT('db.setNull') + const wrap = document.createElement('div') + wrap.className = 'db-cell-edit-wrap' + wrap.append(input, nullBtn) + td.replaceChildren(wrap) + input.focus() + input.select() + let done = false + nullBtn.addEventListener('mousedown', e => { + e.preventDefault() + done = true + void applyUpdate('', true) + }) + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); input.blur() } + else if (e.key === 'Escape') { done = true; restore() } + else if (e.key === 'Tab') { + e.preventDefault() + const forward = !e.shiftKey + input.blur() + requestAnimationFrame(() => { + const tr = td.closest('tr')! + const tdsInRow = Array.from(tr.querySelectorAll('td[tabindex]')) as HTMLElement[] + ;(tdsInRow[tdsInRow.indexOf(td) + (forward ? 1 : -1)] as HTMLElement | undefined)?.focus() + }) + } + }) + input.addEventListener('blur', () => { + if (done) return + done = true + if (input.value === old) { restore(); return } + void applyUpdate(input.value) + }) + } +} + +export const deleteRow = async ( + s: DbServer, db: string, table: string, columns: string[], + row: string[], pkIdx: number[], tr: HTMLElement, + onDeleted?: () => void, +): Promise => { + const wheres = buildWheres(pkIdx, columns, row) + if (!confirm(`DELETE FROM ${table}\nWHERE ${wheres.map(([c, v]) => `${c}=${v}`).join(' AND ')}`)) return + try { + await invoke(sqlCmd(s, 'delete'), { ...target(s), db, table, wheres, ...creds(s) }) + if (onDeleted) onDeleted() + else tr.remove() + } catch (e) { + alert(String(e)) + } +} diff --git a/tests/panels/db/dbRowEdit.test.ts b/tests/panels/db/dbRowEdit.test.ts new file mode 100644 index 0000000..deaaeae --- /dev/null +++ b/tests/panels/db/dbRowEdit.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { editCell, deleteRow } from '../../../src/panels/db/dbRowEdit' +import type { DbServer } from '../../../src/core/db/dbServer' + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const COLUMNS = ['id', 'name'] +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +let confirmed: boolean +let alerts: string[] + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) + confirmed = true + alerts = [] + vi.stubGlobal('confirm', () => confirmed) + vi.stubGlobal('alert', (m: string) => { alerts.push(String(m)) }) +}) + +function openEditor(over: { row?: string[]; colIdx?: number; s?: DbServer } = {}) { + const row = over.row ?? ['7', 'ana'] + const td = document.createElement('td') + const tr = document.createElement('tr') + tr.appendChild(td) + editCell(over.s ?? server(), 'app', 'users', COLUMNS, row, over.colIdx ?? 1, [0], td) + return { td, row, input: td.querySelector('input') as HTMLInputElement } +} + +describe('editCell input', () => { + it('opens prefilled with the current value and selected', () => { + const { input } = openEditor() + expect(input.value).toBe('ana') + }) + + it('shows an empty box for a NULL cell rather than the literal NULL', () => { + const { input } = openEditor({ row: ['7', 'NULL'] }) + expect(input.value).toBe('') + }) + + it('restores the original value on Escape without touching the backend', () => { + const { td, input } = openEditor() + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(td.textContent).toBe('ana') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('does not write when the value is unchanged', async () => { + const { td, input } = openEditor() + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + expect(td.textContent).toBe('ana') + }) +}) + +describe('editCell update', () => { + it('sends the update and repaints the cell with the new value', async () => { + const { td, row, input } = openEditor() + input.value = 'eva' + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_mysql_update', expect.objectContaining({ + db: 'app', table: 'users', column: 'name', value: 'eva', wheres: [['id', '7']], + })) + expect(row[1]).toBe('eva') + expect(td.textContent).toBe('eva') + }) + + it('asks for confirmation first and restores the cell when refused', async () => { + confirmed = false + const { td, row, input } = openEditor() + input.value = 'eva' + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + expect(row[1]).toBe('ana') + expect(td.textContent).toBe('ana') + }) + + it('writes a real NULL through raw SQL when the NULL button is used', async () => { + const { td, row } = openEditor() + const nullBtn = td.querySelector('.db-null-btn') as HTMLButtonElement + nullBtn.dispatchEvent(new MouseEvent('mousedown', { cancelable: true })) + await flush() + const [cmd, args] = mocks.invoke.mock.calls[0] as [string, { sql: string }] + expect(cmd).toBe('db_docker_mysql_query') + expect(args.sql).toContain('SET `name` = NULL') + expect(row[1]).toBe('NULL') + expect(td.textContent).toBe('NULL') + }) + + it('quotes identifiers the Postgres way and the MySQL way', async () => { + const { td } = openEditor({ s: server({ kind: 'postgres' }), row: ['7', 'ana'] }) + ;(td.querySelector('.db-null-btn') as HTMLButtonElement).dispatchEvent(new MouseEvent('mousedown', { cancelable: true })) + await flush() + expect((mocks.invoke.mock.calls[0][1] as { sql: string }).sql).toContain('SET "name" = NULL') + }) + + it('reports a plain failure and puts the old value back', async () => { + mocks.invoke.mockRejectedValue(new Error('column is generated')) + const { td, input } = openEditor() + input.value = 'eva' + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(alerts.join()).toContain('column is generated') + expect(td.textContent).toBe('ana') + }) +}) + +describe('editCell foreign-key column', () => { + it('offers the referenced rows as a dropdown with the current value selected', async () => { + mocks.invoke.mockResolvedValue({ columns: ['id', 'label'], rows: [['1', 'one'], ['7', 'seven']] }) + const td = document.createElement('td') + editCell(server(), 'app', 'orders', ['id', 'user_id'], ['1', '7'], 1, [0], td, + { ref_table: 'users', ref_column: 'id' }) + await flush() + const sel = td.querySelector('select') as HTMLSelectElement + expect([...sel.options].map(o => o.value)).toEqual(['1', '7']) + expect(sel.value).toBe('7') + }) + + it('falls back to a plain text box when the referenced column is missing', async () => { + mocks.invoke.mockResolvedValue({ columns: ['other'], rows: [] }) + const td = document.createElement('td') + editCell(server(), 'app', 'orders', ['id', 'user_id'], ['1', '7'], 1, [0], td, + { ref_table: 'users', ref_column: 'id' }) + await flush() + expect(td.querySelector('select')).toBeNull() + expect(td.querySelector('input')).not.toBeNull() + }) +}) + +describe('deleteRow', () => { + it('deletes by primary key and drops the row element', async () => { + const tr = document.createElement('tr') + document.body.appendChild(tr) + await deleteRow(server(), 'app', 'users', COLUMNS, ['7', 'ana'], [0], tr) + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_mysql_delete', expect.objectContaining({ + table: 'users', wheres: [['id', '7']], + })) + expect(tr.isConnected).toBe(false) + }) + + it('does nothing when the confirmation is refused', async () => { + confirmed = false + const tr = document.createElement('tr') + document.body.appendChild(tr) + await deleteRow(server(), 'app', 'users', COLUMNS, ['7', 'ana'], [0], tr) + expect(mocks.invoke).not.toHaveBeenCalled() + expect(tr.isConnected).toBe(true) + }) + + it('hands control to the caller instead of removing the row when a callback is given', async () => { + const tr = document.createElement('tr') + document.body.appendChild(tr) + const onDeleted = vi.fn() + await deleteRow(server(), 'app', 'users', COLUMNS, ['7', 'ana'], [0], tr, onDeleted) + expect(onDeleted).toHaveBeenCalled() + expect(tr.isConnected).toBe(true) + }) + + it('keeps the row and reports the error when the delete fails', async () => { + mocks.invoke.mockRejectedValue(new Error('fk constraint')) + const tr = document.createElement('tr') + document.body.appendChild(tr) + await deleteRow(server(), 'app', 'users', COLUMNS, ['7', 'ana'], [0], tr) + expect(alerts.join()).toContain('fk constraint') + expect(tr.isConnected).toBe(true) + }) +}) From 82454987928f1132091093922cf866126a065423 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:24:54 +0200 Subject: [PATCH 10/51] refactor: extracted the query result grid into dbResultTable Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 141 +-------------------- src/panels/db/dbResultTable.ts | 143 +++++++++++++++++++++ tests/panels/db/dbResultTable.test.ts | 171 ++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 139 deletions(-) create mode 100644 src/panels/db/dbResultTable.ts create mode 100644 tests/panels/db/dbResultTable.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 3f9f33c..07cd881 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -10,13 +10,14 @@ import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type import { KIND_LABEL, isMongo, isPg, isRedis, sqlCmd, creds, target, parseRedisLines, fetchColumns, listDatabases, listTables, fetchRelations, - type TableData, type EditMeta, + type TableData, } from './dbAccess' import { parseStructuredJson } from './jsonValues' import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from './dbCellRender' import { note, makeFilterInput, makeCsvBtn, makeResultWrap, rowEl, appendExpandable } from './dbWidgets' import { detectDocker, detectLocal, resolveCreds } from './dbDetect' import { editCell, deleteRow } from './dbRowEdit' +import { renderResultTable, preResult } from './dbResultTable' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -209,144 +210,6 @@ export function createDbPanel(): { element: HTMLElement } { return bar } - // ---- query editor (detects the DB type) ---- - // Render cap: a SELECT * over a wide JOIN yields hundreds of columns; painting - // tens of thousands of cells at once freezes/crashes the WebView. We limit the DOM - // (the full data is still there; this only bounds what gets drawn). - const MAX_COLS = 60 - const MAX_ROWS = 200 - const renderResultTable = (data: TableData, em?: EditMeta, loadMore?: (offset: number) => Promise): HTMLElement => { - if (!data.columns.length) return note(data.rows.length ? i18nT('db.ok') : i18nT('db.noResults'), 'db-detail-hint') - const cols = data.columns.slice(0, MAX_COLS) - let sortCol = -1 - let sortDir: 'asc' | 'desc' = 'asc' - let currentFilter = '' - - const tbl = document.createElement('table') - tbl.className = 'db-grid' - const thead = document.createElement('thead') - const htr = document.createElement('tr') - cols.forEach((col, i) => { - const th = document.createElement('th') - th.textContent = col - th.className = 'db-grid-th' - th.addEventListener('click', () => { - if (sortCol === i) { - sortDir = sortDir === 'asc' ? 'desc' : 'asc' - } else { - sortCol = i; sortDir = 'asc' - } - htr.querySelectorAll('th').forEach((t, j) => { - t.classList.toggle('db-sort-asc', j === sortCol && sortDir === 'asc') - t.classList.toggle('db-sort-desc', j === sortCol && sortDir === 'desc') - }) - renderRows() - }) - htr.appendChild(th) - }) - if (em?.pkIdx.length) htr.appendChild(document.createElement('th')) - thead.appendChild(htr) - const tbody = document.createElement('tbody') - tbl.append(thead, tbody) - - const getSortedRows = (): string[][] => { - let rows = data.rows - if (sortCol >= 0) { - rows = [...rows].sort((a, b) => { - const av = a[sortCol] ?? '', bv = b[sortCol] ?? '' - const an = parseFloat(av), bn = parseFloat(bv) - const numeric = !isNaN(an) && !isNaN(bn) && av.trim() !== '' && bv.trim() !== '' - const cmp = numeric ? an - bn : av.localeCompare(bv) - return sortDir === 'asc' ? cmp : -cmp - }) - } - return currentFilter ? rows.filter(row => row.some(cell => cell.toLowerCase().includes(currentFilter))) : rows - } - - const countEl = document.createElement('span') - countEl.className = 'db-result-count' - const total = data.rows.length - - const renderRows = (): void => { - const rows = getSortedRows() - countEl.textContent = currentFilter ? `${rows.length} / ${total}` : `${rows.length}` - tbody.replaceChildren() - rows.forEach(row => { - const tr = document.createElement('tr') - row.slice(0, MAX_COLS).forEach((cell, colIdx) => { - const td = document.createElement('td') - renderCellValue(td, cell) - if (em) { - td.classList.add('db-editable') - td.addEventListener('dblclick', () => - editCell(em.s, em.db, em.table, data.columns, row, colIdx, em.pkIdx, td, em.fkColMap.get(data.columns[colIdx]))) - } - tr.appendChild(td) - }) - if (em?.pkIdx.length) { - const actTd = document.createElement('td') - actTd.className = 'db-row-actions' - const del = document.createElement('button') - del.className = 'db-del' - del.title = i18nT('db.deleteRow') - del.innerHTML = icon('trash') - del.addEventListener('click', () => deleteRow(em.s, em.db, em.table, data.columns, row, em.pkIdx, tr, () => { - const idx = data.rows.indexOf(row) - if (idx >= 0) data.rows.splice(idx, 1) - renderRows() - })) - actTd.appendChild(del) - tr.appendChild(actTd) - } - tbody.appendChild(tr) - }) - } - - const filterInput = makeFilterInput(q => { currentFilter = q; renderRows() }) - const csvBtn = makeCsvBtn(() => ({ cols, rows: getSortedRows().map(r => r.slice(0, MAX_COLS)), filename: 'result.csv' })) - renderRows() - - const overflow: string[] = [] - if (data.columns.length > MAX_COLS) overflow.push(i18nT('db.columnsShown', { count: data.columns.length, shown: MAX_COLS })) - - const wrap = makeResultWrap(tbl, [filterInput, countEl, csvBtn]) - if (overflow.length) wrap.prepend(note(i18nT('db.largeResult', { size: overflow.join(', ') }), 'db-detail-hint')) - - if (loadMore && data.rows.length >= MAX_ROWS) { - const loadBtn = document.createElement('button') - loadBtn.className = 'db-load-more' - loadBtn.textContent = i18nT('db.loadMore') - loadBtn.addEventListener('click', async () => { - loadBtn.disabled = true - loadBtn.textContent = i18nT('common.loading') - try { - const more = await loadMore(data.rows.length) - if (!more.length) { loadBtn.remove(); return } - data.rows.push(...more) - countEl.textContent = `${data.rows.length}` - renderRows() - if (more.length < MAX_ROWS) loadBtn.remove() - else { loadBtn.disabled = false; loadBtn.textContent = i18nT('db.loadMore') } - } catch (e) { - loadBtn.disabled = false - loadBtn.textContent = i18nT('db.loadMore') - alert(String(e)) - } - }) - wrap.appendChild(loadBtn) - } - - return wrap - } - - const preResult = (out: string): HTMLElement => { - const pre = document.createElement('pre') - pre.className = 'db-doc' - const text = out.trim() - pre.textContent = text.length > 200000 ? i18nT('db.truncated', { text: text.slice(0, 200000) }) : text || i18nT('db.noOutput') - return pre - } - const openQuery = (s: DbServer, db: string, names: string[]): void => { // Relations loaded once and shared (chips, AI, and the JOIN builder). let relations: ForeignKey[] = [] diff --git a/src/panels/db/dbResultTable.ts b/src/panels/db/dbResultTable.ts new file mode 100644 index 0000000..02642a0 --- /dev/null +++ b/src/panels/db/dbResultTable.ts @@ -0,0 +1,143 @@ +import { t as i18nT } from '../../i18n' +import { icon } from '../../ui/icons' +import { type TableData, type EditMeta } from './dbAccess' +import { renderCellValue } from './dbCellRender' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap } from './dbWidgets' +import { editCell, deleteRow } from './dbRowEdit' + +// Render cap: a SELECT * over a wide JOIN yields hundreds of columns; painting +// tens of thousands of cells at once freezes/crashes the WebView. We limit the DOM +// (the full data is still there; this only bounds what gets drawn). +export const MAX_COLS = 60 +export const MAX_ROWS = 200 +export const renderResultTable = (data: TableData, em?: EditMeta, loadMore?: (offset: number) => Promise): HTMLElement => { + if (!data.columns.length) return note(data.rows.length ? i18nT('db.ok') : i18nT('db.noResults'), 'db-detail-hint') + const cols = data.columns.slice(0, MAX_COLS) + let sortCol = -1 + let sortDir: 'asc' | 'desc' = 'asc' + let currentFilter = '' + + const tbl = document.createElement('table') + tbl.className = 'db-grid' + const thead = document.createElement('thead') + const htr = document.createElement('tr') + cols.forEach((col, i) => { + const th = document.createElement('th') + th.textContent = col + th.className = 'db-grid-th' + th.addEventListener('click', () => { + if (sortCol === i) { + sortDir = sortDir === 'asc' ? 'desc' : 'asc' + } else { + sortCol = i; sortDir = 'asc' + } + htr.querySelectorAll('th').forEach((t, j) => { + t.classList.toggle('db-sort-asc', j === sortCol && sortDir === 'asc') + t.classList.toggle('db-sort-desc', j === sortCol && sortDir === 'desc') + }) + renderRows() + }) + htr.appendChild(th) + }) + if (em?.pkIdx.length) htr.appendChild(document.createElement('th')) + thead.appendChild(htr) + const tbody = document.createElement('tbody') + tbl.append(thead, tbody) + + const getSortedRows = (): string[][] => { + let rows = data.rows + if (sortCol >= 0) { + rows = [...rows].sort((a, b) => { + const av = a[sortCol] ?? '', bv = b[sortCol] ?? '' + const an = parseFloat(av), bn = parseFloat(bv) + const numeric = !isNaN(an) && !isNaN(bn) && av.trim() !== '' && bv.trim() !== '' + const cmp = numeric ? an - bn : av.localeCompare(bv) + return sortDir === 'asc' ? cmp : -cmp + }) + } + return currentFilter ? rows.filter(row => row.some(cell => cell.toLowerCase().includes(currentFilter))) : rows + } + + const countEl = document.createElement('span') + countEl.className = 'db-result-count' + const total = data.rows.length + + const renderRows = (): void => { + const rows = getSortedRows() + countEl.textContent = currentFilter ? `${rows.length} / ${total}` : `${rows.length}` + tbody.replaceChildren() + rows.forEach(row => { + const tr = document.createElement('tr') + row.slice(0, MAX_COLS).forEach((cell, colIdx) => { + const td = document.createElement('td') + renderCellValue(td, cell) + if (em) { + td.classList.add('db-editable') + td.addEventListener('dblclick', () => + editCell(em.s, em.db, em.table, data.columns, row, colIdx, em.pkIdx, td, em.fkColMap.get(data.columns[colIdx]))) + } + tr.appendChild(td) + }) + if (em?.pkIdx.length) { + const actTd = document.createElement('td') + actTd.className = 'db-row-actions' + const del = document.createElement('button') + del.className = 'db-del' + del.title = i18nT('db.deleteRow') + del.innerHTML = icon('trash') + del.addEventListener('click', () => deleteRow(em.s, em.db, em.table, data.columns, row, em.pkIdx, tr, () => { + const idx = data.rows.indexOf(row) + if (idx >= 0) data.rows.splice(idx, 1) + renderRows() + })) + actTd.appendChild(del) + tr.appendChild(actTd) + } + tbody.appendChild(tr) + }) + } + + const filterInput = makeFilterInput(q => { currentFilter = q; renderRows() }) + const csvBtn = makeCsvBtn(() => ({ cols, rows: getSortedRows().map(r => r.slice(0, MAX_COLS)), filename: 'result.csv' })) + renderRows() + + const overflow: string[] = [] + if (data.columns.length > MAX_COLS) overflow.push(i18nT('db.columnsShown', { count: data.columns.length, shown: MAX_COLS })) + + const wrap = makeResultWrap(tbl, [filterInput, countEl, csvBtn]) + if (overflow.length) wrap.prepend(note(i18nT('db.largeResult', { size: overflow.join(', ') }), 'db-detail-hint')) + + if (loadMore && data.rows.length >= MAX_ROWS) { + const loadBtn = document.createElement('button') + loadBtn.className = 'db-load-more' + loadBtn.textContent = i18nT('db.loadMore') + loadBtn.addEventListener('click', async () => { + loadBtn.disabled = true + loadBtn.textContent = i18nT('common.loading') + try { + const more = await loadMore(data.rows.length) + if (!more.length) { loadBtn.remove(); return } + data.rows.push(...more) + countEl.textContent = `${data.rows.length}` + renderRows() + if (more.length < MAX_ROWS) loadBtn.remove() + else { loadBtn.disabled = false; loadBtn.textContent = i18nT('db.loadMore') } + } catch (e) { + loadBtn.disabled = false + loadBtn.textContent = i18nT('db.loadMore') + alert(String(e)) + } + }) + wrap.appendChild(loadBtn) + } + + return wrap +} + +export const preResult = (out: string): HTMLElement => { + const pre = document.createElement('pre') + pre.className = 'db-doc' + const text = out.trim() + pre.textContent = text.length > 200000 ? i18nT('db.truncated', { text: text.slice(0, 200000) }) : text || i18nT('db.noOutput') + return pre +} diff --git a/tests/panels/db/dbResultTable.test.ts b/tests/panels/db/dbResultTable.test.ts new file mode 100644 index 0000000..ffdd22f --- /dev/null +++ b/tests/panels/db/dbResultTable.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { renderResultTable, preResult, MAX_COLS, MAX_ROWS } from '../../../src/panels/db/dbResultTable' +import type { TableData, EditMeta } from '../../../src/panels/db/dbAccess' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + vi.stubGlobal('confirm', () => true) + vi.stubGlobal('alert', () => {}) + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) + vi.useRealTimers() +}) + +const data = (over: Partial = {}): TableData => + ({ columns: ['id', 'name'], rows: [['2', 'bea'], ['10', 'ana']], ...over }) + +const bodyRows = (el: HTMLElement): string[][] => + [...el.querySelectorAll('tbody tr')].map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent ?? '')) + +const editMeta = (): EditMeta => ({ + s: { kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1' } as DbServer, + db: 'app', table: 'users', pkIdx: [0], fkColMap: new Map(), +}) + +describe('empty results', () => { + it('says there are no results for a SELECT that returned nothing', () => { + expect(renderResultTable({ columns: [], rows: [] }).className).toBe('db-detail-hint') + }) + + it('says OK for a statement that returned rows but no columns', () => { + const el = renderResultTable({ columns: [], rows: [[]] }) + expect(el.textContent).not.toBe('') + expect(el.className).toBe('db-detail-hint') + }) +}) + +describe('rendering and counting', () => { + it('paints one row per record and shows the total', () => { + const el = renderResultTable(data()) + expect(bodyRows(el)).toEqual([['2', 'bea'], ['10', 'ana']]) + expect(el.querySelector('.db-result-count')!.textContent).toBe('2') + }) + + it('caps the painted columns and warns when there are more', () => { + const columns = Array.from({ length: MAX_COLS + 5 }, (_, i) => `c${i}`) + const el = renderResultTable({ columns, rows: [columns.map(String)] }) + expect(el.querySelectorAll('thead th')).toHaveLength(MAX_COLS) + expect(el.querySelector('.db-detail-hint')).not.toBeNull() + }) +}) + +describe('sorting', () => { + it('sorts numerically on the first click and reverses on the second', () => { + const el = renderResultTable(data()) + const th = el.querySelectorAll('thead th')[0] as HTMLElement + th.click() + expect(bodyRows(el).map(r => r[0])).toEqual(['2', '10']) + th.click() + expect(bodyRows(el).map(r => r[0])).toEqual(['10', '2']) + expect(th.classList.contains('db-sort-desc')).toBe(true) + }) + + it('sorts text alphabetically', () => { + const el = renderResultTable(data()) + ;(el.querySelectorAll('thead th')[1] as HTMLElement).click() + expect(bodyRows(el).map(r => r[1])).toEqual(['ana', 'bea']) + }) +}) + +describe('filtering', () => { + it('keeps only matching rows and shows matched over total', () => { + vi.useFakeTimers() + const el = renderResultTable(data()) + const input = el.querySelector('.db-filter') as HTMLInputElement + input.value = 'ANA' + input.dispatchEvent(new Event('input')) + vi.advanceTimersByTime(150) + expect(bodyRows(el)).toEqual([['10', 'ana']]) + expect(el.querySelector('.db-result-count')!.textContent).toBe('1 / 2') + }) +}) + +describe('editable results', () => { + it('stays read-only without edit metadata', () => { + const el = renderResultTable(data()) + expect(el.querySelector('.db-editable')).toBeNull() + expect(el.querySelector('.db-row-actions')).toBeNull() + }) + + it('marks cells editable and adds a delete button when there is a primary key', () => { + const el = renderResultTable(data(), editMeta()) + expect(el.querySelectorAll('.db-editable').length).toBe(4) + expect(el.querySelectorAll('.db-row-actions').length).toBe(2) + }) + + it('adds no delete column when the table has no primary key', () => { + const el = renderResultTable(data(), { ...editMeta(), pkIdx: [] }) + expect(el.querySelector('.db-row-actions')).toBeNull() + }) + + it('drops the deleted record from the data, not just from the DOM', async () => { + const d = data() + const el = renderResultTable(d, editMeta()) + ;(el.querySelector('.db-row-actions button') as HTMLButtonElement).click() + await flush() + expect(d.rows).toEqual([['10', 'ana']]) + // the trailing cell is the row-actions column + expect(bodyRows(el)).toEqual([['10', 'ana', '']]) + }) +}) + +describe('pagination', () => { + const fullPage = (): TableData => + ({ columns: ['id'], rows: Array.from({ length: MAX_ROWS }, (_, i) => [String(i)]) }) + + it('offers no load-more button for a partial page', () => { + expect(renderResultTable(data(), undefined, async () => []).querySelector('.db-load-more')).toBeNull() + }) + + it('appends the next page and keeps the button while pages stay full', async () => { + const el = renderResultTable(fullPage(), undefined, async () => Array.from({ length: MAX_ROWS }, (_, i) => [`n${i}`])) + const btn = el.querySelector('.db-load-more') as HTMLButtonElement + btn.click() + await flush() + expect(el.querySelectorAll('tbody tr')).toHaveLength(MAX_ROWS * 2) + expect(el.querySelector('.db-load-more')).not.toBeNull() + }) + + it('removes the button once a short page comes back', async () => { + const el = renderResultTable(fullPage(), undefined, async () => [['x']]) + ;(el.querySelector('.db-load-more') as HTMLButtonElement).click() + await flush() + expect(el.querySelector('.db-load-more')).toBeNull() + }) + + it('re-enables the button when loading more fails', async () => { + const el = renderResultTable(fullPage(), undefined, async () => { throw new Error('gone') }) + const btn = el.querySelector('.db-load-more') as HTMLButtonElement + btn.click() + await flush() + expect(btn.disabled).toBe(false) + }) +}) + +describe('preResult', () => { + it('shows trimmed output', () => { + expect(preResult(' hello\n').textContent).toBe('hello') + }) + + it('reports empty output instead of showing nothing', () => { + expect(preResult(' ').textContent).not.toBe('') + }) + + it('truncates very long output', () => { + const el = preResult('x'.repeat(250000)) + expect(el.textContent!.length).toBeLessThan(250000) + }) +}) From f8e3b192392490297f034658e13ae7cc5cf3b7e2 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:25:50 +0200 Subject: [PATCH 11/51] refactor: extracted the detail pane host into dbDetailHost Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 27 +--------- src/panels/db/dbDetailHost.ts | 39 ++++++++++++++ tests/panels/db/dbDetailHost.test.ts | 79 ++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 src/panels/db/dbDetailHost.ts create mode 100644 tests/panels/db/dbDetailHost.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 07cd881..cb043e4 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -18,6 +18,7 @@ import { note, makeFilterInput, makeCsvBtn, makeResultWrap, rowEl, appendExpanda import { detectDocker, detectLocal, resolveCreds } from './dbDetect' import { editCell, deleteRow } from './dbRowEdit' import { renderResultTable, preResult } from './dbResultTable' +import { createDetailHost } from './dbDetailHost' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -54,7 +55,7 @@ export function createDbPanel(): { element: HTMLElement } { body.append(cs.element, cs.resizer, detail) root.append(body) - const showDetail = (...nodes: HTMLElement[]): void => { detail.replaceChildren(...nodes) } + const { showDetail, detailHead } = createDetailHost(detail) showDetail(note(i18nT('db.selectATableOrCollectionToViewIts'), 'db-detail-hint')) const renderRedisValue = (s: DbServer, db: string, key: string, v: { kind: string; value: string }, ttl: number): void => { @@ -186,30 +187,6 @@ export function createDbPanel(): { element: HTMLElement } { } } - // ---- detail renderers ---- - const detailHead = (path: string, count: string): HTMLElement => { - const bar = document.createElement('div') - bar.className = 'db-detail-head' - const p = document.createElement('span') - p.className = 'db-detail-path' - p.textContent = path - const c = document.createElement('span') - c.className = 'db-detail-count' - c.textContent = count - // Send to the AI chat: the selection or, if there's none, the current view (table/docs). - const askBtn = document.createElement('button') - askBtn.className = 'db-action' - askBtn.title = i18nT('common.sendToAiChat') - askBtn.innerHTML = icon('chat') - askBtn.addEventListener('click', () => { - const selection = window.getSelection()?.toString().trim() - const content = (selection || detail.textContent || '').slice(-12000) - if (content.trim()) askAi(`Contexto — datos de BD (${path}):\n\n\`\`\`\n${content}\n\`\`\`\n\n`) - }) - bar.append(p, c, askBtn) - return bar - } - const openQuery = (s: DbServer, db: string, names: string[]): void => { // Relations loaded once and shared (chips, AI, and the JOIN builder). let relations: ForeignKey[] = [] diff --git a/src/panels/db/dbDetailHost.ts b/src/panels/db/dbDetailHost.ts new file mode 100644 index 0000000..2000c93 --- /dev/null +++ b/src/panels/db/dbDetailHost.ts @@ -0,0 +1,39 @@ +import { t as i18nT } from '../../i18n' +import { icon } from '../../ui/icons' +import { askAi } from '../../ui/askAi' + +// The right-hand pane: every view renders into it, and its header can hand the +// current contents (or the user's selection) to the AI chat. +export interface DbDetailHost { + showDetail: (...nodes: HTMLElement[]) => void + detailHead: (path: string, count: string) => HTMLElement +} + +export function createDetailHost(detail: HTMLElement): DbDetailHost { + const showDetail = (...nodes: HTMLElement[]): void => { detail.replaceChildren(...nodes) } + + const detailHead = (path: string, count: string): HTMLElement => { + const bar = document.createElement('div') + bar.className = 'db-detail-head' + const p = document.createElement('span') + p.className = 'db-detail-path' + p.textContent = path + const c = document.createElement('span') + c.className = 'db-detail-count' + c.textContent = count + // Send to the AI chat: the selection or, if there's none, the current view (table/docs). + const askBtn = document.createElement('button') + askBtn.className = 'db-action' + askBtn.title = i18nT('common.sendToAiChat') + askBtn.innerHTML = icon('chat') + askBtn.addEventListener('click', () => { + const selection = window.getSelection()?.toString().trim() + const content = (selection || detail.textContent || '').slice(-12000) + if (content.trim()) askAi(`Contexto — datos de BD (${path}):\n\n\`\`\`\n${content}\n\`\`\`\n\n`) + }) + bar.append(p, c, askBtn) + return bar + } + + return { showDetail, detailHead } +} diff --git a/tests/panels/db/dbDetailHost.test.ts b/tests/panels/db/dbDetailHost.test.ts new file mode 100644 index 0000000..2ae2819 --- /dev/null +++ b/tests/panels/db/dbDetailHost.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ askAi: vi.fn() })) +vi.mock('../../../src/ui/askAi', () => ({ askAi: mocks.askAi })) + +import { createDetailHost } from '../../../src/panels/db/dbDetailHost' + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.askAi.mockReset() + document.body.replaceChildren() +}) + +function setup(): { detail: HTMLElement; host: ReturnType } { + const detail = document.createElement('div') + document.body.appendChild(detail) + return { detail, host: createDetailHost(detail) } +} + +describe('showDetail', () => { + it('replaces whatever was in the detail pane', () => { + const { detail, host } = setup() + detail.appendChild(document.createElement('span')) + const fresh = document.createElement('p') + host.showDetail(fresh) + expect([...detail.children]).toEqual([fresh]) + }) +}) + +describe('detailHead', () => { + it('shows the path and the count side by side', () => { + const { host } = setup() + const bar = host.detailHead('app.users', '12 rows') + expect(bar.querySelector('.db-detail-path')!.textContent).toBe('app.users') + expect(bar.querySelector('.db-detail-count')!.textContent).toBe('12 rows') + }) + + it('sends the current view to the AI chat when nothing is selected', () => { + const { detail, host } = setup() + const bar = host.detailHead('app.users', '') + host.showDetail(bar) + detail.appendChild(Object.assign(document.createElement('pre'), { textContent: 'row data here' })) + vi.stubGlobal('getSelection', () => ({ toString: () => '' })) + ;(bar.querySelector('.db-action') as HTMLButtonElement).click() + expect(mocks.askAi).toHaveBeenCalledTimes(1) + expect(mocks.askAi.mock.calls[0][0]).toContain('row data here') + expect(mocks.askAi.mock.calls[0][0]).toContain('app.users') + }) + + it('prefers the user selection over the whole view', () => { + const { detail, host } = setup() + const bar = host.detailHead('app.users', '') + detail.appendChild(Object.assign(document.createElement('pre'), { textContent: 'everything' })) + vi.stubGlobal('getSelection', () => ({ toString: () => ' just this ' })) + ;(bar.querySelector('.db-action') as HTMLButtonElement).click() + expect(mocks.askAi.mock.calls[0][0]).toContain('just this') + expect(mocks.askAi.mock.calls[0][0]).not.toContain('everything') + }) + + it('sends nothing when the view is empty', () => { + const { host } = setup() + const bar = host.detailHead('app.users', '') + vi.stubGlobal('getSelection', () => ({ toString: () => '' })) + ;(bar.querySelector('.db-action') as HTMLButtonElement).click() + expect(mocks.askAi).not.toHaveBeenCalled() + }) + + it('caps how much context it sends', () => { + const { detail, host } = setup() + const bar = host.detailHead('app.users', '') + detail.appendChild(Object.assign(document.createElement('pre'), { textContent: 'x'.repeat(20000) })) + vi.stubGlobal('getSelection', () => ({ toString: () => '' })) + ;(bar.querySelector('.db-action') as HTMLButtonElement).click() + expect((mocks.askAi.mock.calls[0][0] as string).length).toBeLessThan(13000) + }) +}) From c39db4bdcbd2e03c103a75ea6ec6a60738df994b Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:27:05 +0200 Subject: [PATCH 12/51] refactor: extracted the browsable table grid into dbTableGrid Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 237 +-------------------------- src/panels/db/dbTableGrid.ts | 243 +++++++++++++++++++++++++++ tests/panels/db/dbTableGrid.test.ts | 244 ++++++++++++++++++++++++++++ 3 files changed, 491 insertions(+), 233 deletions(-) create mode 100644 src/panels/db/dbTableGrid.ts create mode 100644 tests/panels/db/dbTableGrid.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index cb043e4..09294c1 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -13,12 +13,12 @@ import { type TableData, } from './dbAccess' import { parseStructuredJson } from './jsonValues' -import { prettyJson, buildJsonTree, highlightJson, renderCellValue } from './dbCellRender' -import { note, makeFilterInput, makeCsvBtn, makeResultWrap, rowEl, appendExpandable } from './dbWidgets' +import { prettyJson, highlightJson } from './dbCellRender' +import { note, makeFilterInput, rowEl, appendExpandable } from './dbWidgets' import { detectDocker, detectLocal, resolveCreds } from './dbDetect' -import { editCell, deleteRow } from './dbRowEdit' import { renderResultTable, preResult } from './dbResultTable' import { createDetailHost } from './dbDetailHost' +import { renderGrid } from './dbTableGrid' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -180,7 +180,7 @@ export function createDbPanel(): { element: HTMLElement } { fetchRelations(s, db).then(fks => { fks.filter(f => f.table === name).forEach(f => fkColMap.set(f.column, { ref_table: f.ref_table, ref_column: f.ref_column })) }).catch(() => {}) - renderGrid(s, db, name, data, pk, fkColMap, () => openData(s, db, name)) + renderGrid({ showDetail, detailHead }, s, db, name, data, pk, fkColMap, () => openData(s, db, name)) } } catch (e) { showDetail(note(String(e), 'db-detail-error')) @@ -602,235 +602,6 @@ export function createDbPanel(): { element: HTMLElement } { editor.focus() } - const renderGrid = (s: DbServer, db: string, table: string, data: TableData, pk: string[], fkColMap: Map, onRefresh?: () => void): void => { - const pkIdx = pk.map(c => data.columns.indexOf(c)).filter(i => i >= 0) - const editable = pkIdx.length > 0 - const scroll = document.createElement('div') - scroll.className = 'db-grid-scroll' - if (!data.columns.length) { - scroll.append(note(i18nT('db.noRows'))) - } else { - const tbl = document.createElement('table') - tbl.className = 'db-grid' - const thead = document.createElement('thead') - const htr = document.createElement('tr') - let sortCol = -1 - let sortDir: 'asc' | 'desc' = 'asc' - - data.columns.forEach((col, i) => { - const th = document.createElement('th') - th.textContent = col - th.className = 'db-grid-th' - th.addEventListener('click', () => { - if (sortCol === i) { - sortDir = sortDir === 'asc' ? 'desc' : 'asc' - } else { - sortCol = i; sortDir = 'asc' - } - htr.querySelectorAll('th').forEach((t, j) => { - t.classList.toggle('db-sort-asc', j === sortCol && sortDir === 'asc') - t.classList.toggle('db-sort-desc', j === sortCol && sortDir === 'desc') - }) - sortRows() - }) - htr.appendChild(th) - }) - htr.appendChild(document.createElement('th')) - thead.appendChild(htr) - const tbody = document.createElement('tbody') - const rowEls: Array<{ tr: HTMLTableRowElement; cells: string[] }> = [] - - const showRowDetail = (row: string[]): void => { - const overlay = document.createElement('div'); overlay.className = 'db-row-modal' - const panel = document.createElement('div'); panel.className = 'db-row-modal-panel' - const head = document.createElement('div'); head.className = 'db-row-modal-head' - const title = document.createElement('span'); title.textContent = table - const closeBtn = document.createElement('button'); closeBtn.className = 'db-action'; closeBtn.innerHTML = icon('x') - closeBtn.addEventListener('click', () => overlay.remove()) - head.append(title, closeBtn) - const body = document.createElement('div'); body.className = 'db-row-modal-body' - data.columns.forEach((col, i) => { - const val = row[i] - const rowDiv = document.createElement('div'); rowDiv.className = 'db-row-modal-row' - const keyEl = document.createElement('span'); keyEl.className = 'db-row-modal-key'; keyEl.textContent = col - const valEl = document.createElement('div'); valEl.className = 'db-row-modal-val' - const json = parseStructuredJson(val) - if (json && !json.truncated) valEl.appendChild(buildJsonTree(JSON.parse(json.formatted), 0)) - else if (val === 'NULL') { const s2 = document.createElement('span'); s2.className = 'db-null'; s2.textContent = 'NULL'; valEl.appendChild(s2) } - else valEl.textContent = val - rowDiv.append(keyEl, valEl); body.appendChild(rowDiv) - }) - panel.append(head, body); overlay.appendChild(panel); document.body.appendChild(overlay) - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) - const onEsc = (e: KeyboardEvent): void => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc) } } - document.addEventListener('keydown', onEsc) - } - - data.rows.forEach(row => { - const tr = document.createElement('tr') - row.forEach((cell, colIdx) => { - const td = document.createElement('td') - renderCellValue(td, cell) - if (editable) { - td.classList.add('db-editable') - td.setAttribute('tabIndex', '0') - td.addEventListener('dblclick', () => - editCell(s, db, table, data.columns, row, colIdx, pkIdx, td, fkColMap.get(data.columns[colIdx]))) - td.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); editCell(s, db, table, data.columns, row, colIdx, pkIdx, td, fkColMap.get(data.columns[colIdx])) } - const tds = Array.from(tr.querySelectorAll('td[tabindex]')) as HTMLElement[] - const ti = tds.indexOf(td) - const trs = Array.from(tbody.children) as HTMLElement[] - const ri = trs.indexOf(tr) - if (e.key === 'ArrowRight') { e.preventDefault(); tds[ti + 1]?.focus() } - else if (e.key === 'ArrowLeft') { e.preventDefault(); tds[ti - 1]?.focus() } - else if (e.key === 'ArrowDown') { e.preventDefault(); ;(trs[ri + 1]?.querySelectorAll('td[tabindex]')[ti] as HTMLElement | undefined)?.focus() } - else if (e.key === 'ArrowUp') { e.preventDefault(); ;(trs[ri - 1]?.querySelectorAll('td[tabindex]')[ti] as HTMLElement | undefined)?.focus() } - }) - } - tr.appendChild(td) - }) - const actions = document.createElement('td'); actions.className = 'db-row-actions' - const detailBtn = document.createElement('button'); detailBtn.className = 'db-del'; detailBtn.title = i18nT('db.rowDetail'); detailBtn.innerHTML = icon('eye') - detailBtn.addEventListener('click', () => showRowDetail(row)); actions.appendChild(detailBtn) - const copyBtn2 = document.createElement('button'); copyBtn2.className = 'db-del'; copyBtn2.title = i18nT('db.copyRow'); copyBtn2.innerHTML = icon('copy') - copyBtn2.addEventListener('click', () => { - const obj: Record = {} - data.columns.forEach((col, i) => { obj[col] = row[i] }) - void navigator.clipboard.writeText(JSON.stringify(obj, null, 2)).then(() => { copyBtn2.innerHTML = '✓'; setTimeout(() => { copyBtn2.innerHTML = icon('copy') }, 1200) }) - }) - actions.appendChild(copyBtn2) - if (editable) { - const del = document.createElement('button'); del.className = 'db-del'; del.title = i18nT('db.deleteRow'); del.innerHTML = icon('trash') - del.addEventListener('click', () => deleteRow(s, db, table, data.columns, row, pkIdx, tr)) - actions.appendChild(del) - } - tr.appendChild(actions) - rowEls.push({ tr, cells: row }) - tbody.appendChild(tr) - }) - tbl.append(thead, tbody) - - const sortRows = (): void => { - if (sortCol < 0) return - const sorted = [...rowEls].sort((a, b) => { - const av = a.cells[sortCol] ?? '' - const bv = b.cells[sortCol] ?? '' - const an = parseFloat(av), bn = parseFloat(bv) - const numeric = !isNaN(an) && !isNaN(bn) && av.trim() !== '' && bv.trim() !== '' - const cmp = numeric ? an - bn : av.localeCompare(bv) - return sortDir === 'asc' ? cmp : -cmp - }) - sorted.forEach(({ tr }) => tbody.appendChild(tr)) - } - - const countEl = document.createElement('span') - countEl.className = 'db-result-count' - countEl.textContent = `${data.rows.length}` - - const filterInput = makeFilterInput(q => { - let visible = 0 - rowEls.forEach(({ tr, cells }) => { - const show = !q || cells.some(c => c.toLowerCase().includes(q)) - tr.style.display = show ? '' : 'none' - if (show) visible++ - }) - countEl.textContent = q ? `${visible} / ${data.rows.length}` : `${data.rows.length}` - }) - const csvBtn = makeCsvBtn(() => ({ - cols: data.columns, - rows: rowEls.filter(({ tr }) => tr.style.display !== 'none').map(({ cells }) => cells), - filename: `${table}.csv`, - })) - - const showInsertRow = (): void => { - tbody.querySelector('.db-insert-row')?.remove() - const itr = document.createElement('tr') - itr.className = 'db-insert-row' - const cellStates: Array<{ input: HTMLInputElement; isNull: boolean }> = [] - data.columns.forEach(col => { - const td = document.createElement('td') - const input = document.createElement('input') - input.className = 'db-cell-input' - input.placeholder = col - const state = { input, isNull: false } - cellStates.push(state) - const nullBtn = document.createElement('button') - nullBtn.className = 'db-null-btn' - nullBtn.textContent = 'NULL' - nullBtn.addEventListener('click', () => { - state.isNull = !state.isNull - nullBtn.classList.toggle('db-null-active', state.isNull) - input.disabled = state.isNull - input.value = state.isNull ? '' : input.value - }) - const wrap = document.createElement('div') - wrap.className = 'db-cell-edit-wrap' - wrap.append(input, nullBtn) - td.appendChild(wrap) - itr.appendChild(td) - }) - const actTd = document.createElement('td') - actTd.className = 'db-row-actions' - const okBtn = document.createElement('button') - okBtn.className = 'db-connect' - okBtn.textContent = '✓' - okBtn.title = i18nT('db.insertRow') - okBtn.addEventListener('click', async () => { - const ident = (id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` - const quote = (v: string): string => isPg(s) - ? `'${v.replace(/'/g, "''")}'` - : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` - const vals: Array<[string, string | null]> = [] - cellStates.forEach(({ input: inp, isNull }, i) => { - if (isNull) vals.push([data.columns[i], null]) - else if (inp.value !== '') vals.push([data.columns[i], inp.value]) - }) - if (!vals.length) { alert(i18nT('db.insertNeedValue')); return } - const colSql = vals.map(([c]) => ident(c)).join(', ') - const valSql = vals.map(([, v]) => v === null ? 'NULL' : quote(v)).join(', ') - const tblQ = isPg(s) - ? table.split('.').map(p => `"${p}"`).join('.') - : `\`${db}\`.\`${table}\`` - okBtn.disabled = true - try { - await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `INSERT INTO ${tblQ} (${colSql}) VALUES (${valSql})`, ...creds(s) }) - onRefresh?.() - } catch (e) { okBtn.disabled = false; alert(String(e)) } - }) - const cancelBtn = document.createElement('button') - cancelBtn.className = 'db-doc-cancel' - cancelBtn.textContent = '✕' - cancelBtn.addEventListener('click', () => itr.remove()) - actTd.append(okBtn, cancelBtn) - itr.appendChild(actTd) - tbody.appendChild(itr) - cellStates[0]?.input.focus() - } - - const toolbarItems: HTMLElement[] = [filterInput, countEl, csvBtn] - if (onRefresh) { - const refreshBtn = document.createElement('button') - refreshBtn.className = 'db-action' - refreshBtn.title = i18nT('common.refresh') - refreshBtn.innerHTML = icon('refresh') - refreshBtn.addEventListener('click', onRefresh) - toolbarItems.push(refreshBtn) - } - if (editable && onRefresh) { - const addBtn = document.createElement('button') - addBtn.className = 'db-action' - addBtn.title = i18nT('db.insertRow') - addBtn.innerHTML = icon('plus') - addBtn.addEventListener('click', showInsertRow) - toolbarItems.push(addBtn) - } - scroll.appendChild(makeResultWrap(tbl, toolbarItems)) - } - const hint = editable ? i18nT('db.editHint') : i18nT('db.readOnlyHint') - showDetail(detailHead(`${db}.${table}`, i18nT('db.rowsSummary', { count: data.rows.length, suffix: hint })), scroll) - } - const editDoc = (s: DbServer, db: string, coll: string, pre: HTMLElement): void => { const original = pre.textContent ?? '' const ta = document.createElement('textarea') diff --git a/src/panels/db/dbTableGrid.ts b/src/panels/db/dbTableGrid.ts new file mode 100644 index 0000000..7ec69a6 --- /dev/null +++ b/src/panels/db/dbTableGrid.ts @@ -0,0 +1,243 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { DbServer } from '../../core/db/dbServer' +import { icon } from '../../ui/icons' +import { parseStructuredJson } from './jsonValues' +import { isPg, sqlCmd, creds, target, type TableData } from './dbAccess' +import { buildJsonTree, renderCellValue } from './dbCellRender' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap } from './dbWidgets' +import { editCell, deleteRow } from './dbRowEdit' +import type { DbDetailHost } from './dbDetailHost' + +export const renderGrid = ( + host: DbDetailHost, s: DbServer, db: string, table: string, data: TableData, pk: string[], + fkColMap: Map, onRefresh?: () => void, +): void => { + const { showDetail, detailHead } = host + const pkIdx = pk.map(c => data.columns.indexOf(c)).filter(i => i >= 0) + const editable = pkIdx.length > 0 + const scroll = document.createElement('div') + scroll.className = 'db-grid-scroll' + if (!data.columns.length) { + scroll.append(note(i18nT('db.noRows'))) + } else { + const tbl = document.createElement('table') + tbl.className = 'db-grid' + const thead = document.createElement('thead') + const htr = document.createElement('tr') + let sortCol = -1 + let sortDir: 'asc' | 'desc' = 'asc' + + data.columns.forEach((col, i) => { + const th = document.createElement('th') + th.textContent = col + th.className = 'db-grid-th' + th.addEventListener('click', () => { + if (sortCol === i) { + sortDir = sortDir === 'asc' ? 'desc' : 'asc' + } else { + sortCol = i; sortDir = 'asc' + } + htr.querySelectorAll('th').forEach((t, j) => { + t.classList.toggle('db-sort-asc', j === sortCol && sortDir === 'asc') + t.classList.toggle('db-sort-desc', j === sortCol && sortDir === 'desc') + }) + sortRows() + }) + htr.appendChild(th) + }) + htr.appendChild(document.createElement('th')) + thead.appendChild(htr) + const tbody = document.createElement('tbody') + const rowEls: Array<{ tr: HTMLTableRowElement; cells: string[] }> = [] + + const showRowDetail = (row: string[]): void => { + const overlay = document.createElement('div'); overlay.className = 'db-row-modal' + const panel = document.createElement('div'); panel.className = 'db-row-modal-panel' + const head = document.createElement('div'); head.className = 'db-row-modal-head' + const title = document.createElement('span'); title.textContent = table + const closeBtn = document.createElement('button'); closeBtn.className = 'db-action'; closeBtn.innerHTML = icon('x') + closeBtn.addEventListener('click', () => overlay.remove()) + head.append(title, closeBtn) + const body = document.createElement('div'); body.className = 'db-row-modal-body' + data.columns.forEach((col, i) => { + const val = row[i] + const rowDiv = document.createElement('div'); rowDiv.className = 'db-row-modal-row' + const keyEl = document.createElement('span'); keyEl.className = 'db-row-modal-key'; keyEl.textContent = col + const valEl = document.createElement('div'); valEl.className = 'db-row-modal-val' + const json = parseStructuredJson(val) + if (json && !json.truncated) valEl.appendChild(buildJsonTree(JSON.parse(json.formatted), 0)) + else if (val === 'NULL') { const s2 = document.createElement('span'); s2.className = 'db-null'; s2.textContent = 'NULL'; valEl.appendChild(s2) } + else valEl.textContent = val + rowDiv.append(keyEl, valEl); body.appendChild(rowDiv) + }) + panel.append(head, body); overlay.appendChild(panel); document.body.appendChild(overlay) + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) + const onEsc = (e: KeyboardEvent): void => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc) } } + document.addEventListener('keydown', onEsc) + } + + data.rows.forEach(row => { + const tr = document.createElement('tr') + row.forEach((cell, colIdx) => { + const td = document.createElement('td') + renderCellValue(td, cell) + if (editable) { + td.classList.add('db-editable') + td.setAttribute('tabIndex', '0') + td.addEventListener('dblclick', () => + editCell(s, db, table, data.columns, row, colIdx, pkIdx, td, fkColMap.get(data.columns[colIdx]))) + td.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); editCell(s, db, table, data.columns, row, colIdx, pkIdx, td, fkColMap.get(data.columns[colIdx])) } + const tds = Array.from(tr.querySelectorAll('td[tabindex]')) as HTMLElement[] + const ti = tds.indexOf(td) + const trs = Array.from(tbody.children) as HTMLElement[] + const ri = trs.indexOf(tr) + if (e.key === 'ArrowRight') { e.preventDefault(); tds[ti + 1]?.focus() } + else if (e.key === 'ArrowLeft') { e.preventDefault(); tds[ti - 1]?.focus() } + else if (e.key === 'ArrowDown') { e.preventDefault(); ;(trs[ri + 1]?.querySelectorAll('td[tabindex]')[ti] as HTMLElement | undefined)?.focus() } + else if (e.key === 'ArrowUp') { e.preventDefault(); ;(trs[ri - 1]?.querySelectorAll('td[tabindex]')[ti] as HTMLElement | undefined)?.focus() } + }) + } + tr.appendChild(td) + }) + const actions = document.createElement('td'); actions.className = 'db-row-actions' + const detailBtn = document.createElement('button'); detailBtn.className = 'db-del'; detailBtn.title = i18nT('db.rowDetail'); detailBtn.innerHTML = icon('eye') + detailBtn.addEventListener('click', () => showRowDetail(row)); actions.appendChild(detailBtn) + const copyBtn2 = document.createElement('button'); copyBtn2.className = 'db-del'; copyBtn2.title = i18nT('db.copyRow'); copyBtn2.innerHTML = icon('copy') + copyBtn2.addEventListener('click', () => { + const obj: Record = {} + data.columns.forEach((col, i) => { obj[col] = row[i] }) + void navigator.clipboard.writeText(JSON.stringify(obj, null, 2)).then(() => { copyBtn2.innerHTML = '✓'; setTimeout(() => { copyBtn2.innerHTML = icon('copy') }, 1200) }) + }) + actions.appendChild(copyBtn2) + if (editable) { + const del = document.createElement('button'); del.className = 'db-del'; del.title = i18nT('db.deleteRow'); del.innerHTML = icon('trash') + del.addEventListener('click', () => deleteRow(s, db, table, data.columns, row, pkIdx, tr)) + actions.appendChild(del) + } + tr.appendChild(actions) + rowEls.push({ tr, cells: row }) + tbody.appendChild(tr) + }) + tbl.append(thead, tbody) + + const sortRows = (): void => { + if (sortCol < 0) return + const sorted = [...rowEls].sort((a, b) => { + const av = a.cells[sortCol] ?? '' + const bv = b.cells[sortCol] ?? '' + const an = parseFloat(av), bn = parseFloat(bv) + const numeric = !isNaN(an) && !isNaN(bn) && av.trim() !== '' && bv.trim() !== '' + const cmp = numeric ? an - bn : av.localeCompare(bv) + return sortDir === 'asc' ? cmp : -cmp + }) + sorted.forEach(({ tr }) => tbody.appendChild(tr)) + } + + const countEl = document.createElement('span') + countEl.className = 'db-result-count' + countEl.textContent = `${data.rows.length}` + + const filterInput = makeFilterInput(q => { + let visible = 0 + rowEls.forEach(({ tr, cells }) => { + const show = !q || cells.some(c => c.toLowerCase().includes(q)) + tr.style.display = show ? '' : 'none' + if (show) visible++ + }) + countEl.textContent = q ? `${visible} / ${data.rows.length}` : `${data.rows.length}` + }) + const csvBtn = makeCsvBtn(() => ({ + cols: data.columns, + rows: rowEls.filter(({ tr }) => tr.style.display !== 'none').map(({ cells }) => cells), + filename: `${table}.csv`, + })) + + const showInsertRow = (): void => { + tbody.querySelector('.db-insert-row')?.remove() + const itr = document.createElement('tr') + itr.className = 'db-insert-row' + const cellStates: Array<{ input: HTMLInputElement; isNull: boolean }> = [] + data.columns.forEach(col => { + const td = document.createElement('td') + const input = document.createElement('input') + input.className = 'db-cell-input' + input.placeholder = col + const state = { input, isNull: false } + cellStates.push(state) + const nullBtn = document.createElement('button') + nullBtn.className = 'db-null-btn' + nullBtn.textContent = 'NULL' + nullBtn.addEventListener('click', () => { + state.isNull = !state.isNull + nullBtn.classList.toggle('db-null-active', state.isNull) + input.disabled = state.isNull + input.value = state.isNull ? '' : input.value + }) + const wrap = document.createElement('div') + wrap.className = 'db-cell-edit-wrap' + wrap.append(input, nullBtn) + td.appendChild(wrap) + itr.appendChild(td) + }) + const actTd = document.createElement('td') + actTd.className = 'db-row-actions' + const okBtn = document.createElement('button') + okBtn.className = 'db-connect' + okBtn.textContent = '✓' + okBtn.title = i18nT('db.insertRow') + okBtn.addEventListener('click', async () => { + const ident = (id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` + const quote = (v: string): string => isPg(s) + ? `'${v.replace(/'/g, "''")}'` + : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` + const vals: Array<[string, string | null]> = [] + cellStates.forEach(({ input: inp, isNull }, i) => { + if (isNull) vals.push([data.columns[i], null]) + else if (inp.value !== '') vals.push([data.columns[i], inp.value]) + }) + if (!vals.length) { alert(i18nT('db.insertNeedValue')); return } + const colSql = vals.map(([c]) => ident(c)).join(', ') + const valSql = vals.map(([, v]) => v === null ? 'NULL' : quote(v)).join(', ') + const tblQ = isPg(s) + ? table.split('.').map(p => `"${p}"`).join('.') + : `\`${db}\`.\`${table}\`` + okBtn.disabled = true + try { + await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `INSERT INTO ${tblQ} (${colSql}) VALUES (${valSql})`, ...creds(s) }) + onRefresh?.() + } catch (e) { okBtn.disabled = false; alert(String(e)) } + }) + const cancelBtn = document.createElement('button') + cancelBtn.className = 'db-doc-cancel' + cancelBtn.textContent = '✕' + cancelBtn.addEventListener('click', () => itr.remove()) + actTd.append(okBtn, cancelBtn) + itr.appendChild(actTd) + tbody.appendChild(itr) + cellStates[0]?.input.focus() + } + + const toolbarItems: HTMLElement[] = [filterInput, countEl, csvBtn] + if (onRefresh) { + const refreshBtn = document.createElement('button') + refreshBtn.className = 'db-action' + refreshBtn.title = i18nT('common.refresh') + refreshBtn.innerHTML = icon('refresh') + refreshBtn.addEventListener('click', onRefresh) + toolbarItems.push(refreshBtn) + } + if (editable && onRefresh) { + const addBtn = document.createElement('button') + addBtn.className = 'db-action' + addBtn.title = i18nT('db.insertRow') + addBtn.innerHTML = icon('plus') + addBtn.addEventListener('click', showInsertRow) + toolbarItems.push(addBtn) + } + scroll.appendChild(makeResultWrap(tbl, toolbarItems)) + } + const hint = editable ? i18nT('db.editHint') : i18nT('db.readOnlyHint') + showDetail(detailHead(`${db}.${table}`, i18nT('db.rowsSummary', { count: data.rows.length, suffix: hint })), scroll) +} diff --git a/tests/panels/db/dbTableGrid.test.ts b/tests/panels/db/dbTableGrid.test.ts new file mode 100644 index 0000000..1977780 --- /dev/null +++ b/tests/panels/db/dbTableGrid.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { renderGrid } from '../../../src/panels/db/dbTableGrid' +import type { DbDetailHost } from '../../../src/panels/db/dbDetailHost' +import type { TableData } from '../../../src/panels/db/dbAccess' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +let shown: HTMLElement[] +let alerts: string[] +let confirmed: boolean + +const host = (): DbDetailHost => ({ + showDetail: (...nodes) => { shown = nodes; document.body.replaceChildren(...nodes) }, + detailHead: (path, count) => { + const el = document.createElement('div') + el.className = 'db-detail-head' + el.dataset.path = path + el.dataset.count = count + return el + }, +}) + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const data = (over: Partial = {}): TableData => + ({ columns: ['id', 'name'], rows: [['2', 'bea'], ['10', 'ana']], ...over }) + +function grid(over: { + pk?: string[] + data?: TableData + onRefresh?: () => void + s?: DbServer + fk?: Map +} = {}) { + renderGrid(host(), over.s ?? server(), 'app', 'users', over.data ?? data(), + over.pk ?? ['id'], over.fk ?? new Map(), over.onRefresh) + return { root: document.body, head: shown[0] as HTMLElement } +} + +const bodyRows = (): string[][] => + [...document.querySelectorAll('tbody tr')].map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent ?? '')) + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() + shown = [] + alerts = [] + confirmed = true + vi.stubGlobal('confirm', () => confirmed) + vi.stubGlobal('alert', (m: string) => { alerts.push(String(m)) }) + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) + vi.useRealTimers() +}) + +describe('rendering', () => { + it('renders the rows into the detail pane under a header naming the table', () => { + const { head } = grid() + expect(head.dataset.path).toBe('app.users') + expect(bodyRows().map(r => r.slice(0, 2))).toEqual([['2', 'bea'], ['10', 'ana']]) + }) + + it('shows an empty-table note when there are no columns', () => { + grid({ data: { columns: [], rows: [] } }) + expect(document.querySelector('.db-note')).not.toBeNull() + expect(document.querySelector('table')).toBeNull() + }) +}) + +describe('editability', () => { + it('makes cells editable and focusable when a primary key exists', () => { + grid() + expect(document.querySelectorAll('td.db-editable[tabindex]').length).toBe(4) + }) + + it('stays read-only when the table has no primary key', () => { + grid({ pk: [] }) + expect(document.querySelector('td.db-editable')).toBeNull() + }) + + it('offers a delete button only when editable', () => { + grid({ pk: [] }) + const readOnlyActions = document.querySelectorAll('.db-row-actions button').length + document.body.replaceChildren() + grid() + expect(document.querySelectorAll('.db-row-actions button').length).toBeGreaterThan(readOnlyActions) + }) +}) + +describe('keyboard navigation', () => { + it('moves focus between cells with the arrow keys', () => { + grid() + const cells = [...document.querySelectorAll('td[tabindex]')] as HTMLElement[] + cells[0].focus() + cells[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + expect(document.activeElement).toBe(cells[1]) + cells[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + expect(document.activeElement).toBe(cells[3]) + cells[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + expect(document.activeElement).toBe(cells[1]) + cells[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })) + expect(document.activeElement).toBe(cells[0]) + }) + + it('opens the editor on Enter', () => { + grid() + const cell = document.querySelector('td[tabindex]') as HTMLElement + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + expect(cell.querySelector('input')).not.toBeNull() + }) +}) + +describe('sorting and filtering', () => { + it('sorts numbers numerically, not as text', () => { + grid() + ;(document.querySelectorAll('thead th')[0] as HTMLElement).click() + expect(bodyRows().map(r => r[0])).toEqual(['2', '10']) + }) + + it('hides non-matching rows and updates the count', () => { + vi.useFakeTimers() + grid() + const input = document.querySelector('.db-filter') as HTMLInputElement + input.value = 'ana' + input.dispatchEvent(new Event('input')) + vi.advanceTimersByTime(150) + const visible = [...document.querySelectorAll('tbody tr')].filter(tr => (tr as HTMLElement).style.display !== 'none') + expect(visible).toHaveLength(1) + expect(document.querySelector('.db-result-count')!.textContent).toBe('1 / 2') + }) +}) + +describe('row detail', () => { + it('opens a modal listing every column of the row and closes it on Escape', () => { + grid() + ;(document.querySelector('.db-row-actions button') as HTMLButtonElement).click() + const modal = document.querySelector('.db-row-modal')! + expect([...modal.querySelectorAll('.db-row-modal-key')].map(e => e.textContent)).toEqual(['id', 'name']) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(document.querySelector('.db-row-modal')).toBeNull() + }) + + it('renders a JSON column as a tree and NULL with its own styling', () => { + grid({ data: { columns: ['payload', 'note'], rows: [['{"a":1}', 'NULL']] }, pk: [] }) + ;(document.querySelector('.db-row-actions button') as HTMLButtonElement).click() + const vals = document.querySelectorAll('.db-row-modal-val') + expect(vals[0].querySelector('.jt-node')).not.toBeNull() + expect(vals[1].querySelector('.db-null')).not.toBeNull() + }) +}) + +describe('toolbar', () => { + it('offers refresh and insert only when the caller can refresh', () => { + grid() + const withoutRefresh = document.querySelectorAll('.db-result-toolbar .db-action').length + document.body.replaceChildren() + grid({ onRefresh: () => {} }) + expect(document.querySelectorAll('.db-result-toolbar .db-action').length).toBe(withoutRefresh + 2) + }) + + it('offers refresh but not insert on a table with no primary key', () => { + grid({ pk: [], onRefresh: () => {} }) + const titles = [...document.querySelectorAll('.db-result-toolbar .db-action')].map(b => b.getAttribute('title')) + expect(titles.filter(Boolean)).toHaveLength(2) + }) +}) + +describe('insert row', () => { + const openInsert = (): HTMLTableRowElement => { + const buttons = [...document.querySelectorAll('.db-result-toolbar .db-action')] as HTMLButtonElement[] + buttons[buttons.length - 1].click() + return document.querySelector('.db-insert-row') as HTMLTableRowElement + } + + it('refuses to insert when every field was left empty', async () => { + grid({ onRefresh: () => {} }) + openInsert() + ;(document.querySelector('.db-insert-row .db-connect') as HTMLButtonElement).click() + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + expect(alerts).toHaveLength(1) + }) + + it('inserts only the filled columns and refreshes', async () => { + const onRefresh = vi.fn() + grid({ onRefresh }) + const itr = openInsert() + ;(itr.querySelectorAll('input')[1] as HTMLInputElement).value = 'eva' + ;(itr.querySelector('.db-connect') as HTMLButtonElement).click() + await flush() + const sql = (mocks.invoke.mock.calls[0][1] as { sql: string }).sql + expect(sql).toContain('INSERT INTO `app`.`users` (`name`) VALUES (\'eva\')') + expect(onRefresh).toHaveBeenCalled() + }) + + it('sends a real NULL for fields toggled to NULL', async () => { + grid({ onRefresh: () => {} }) + const itr = openInsert() + ;(itr.querySelectorAll('.db-null-btn')[1] as HTMLButtonElement).click() + ;(itr.querySelector('.db-connect') as HTMLButtonElement).click() + await flush() + expect((mocks.invoke.mock.calls[0][1] as { sql: string }).sql).toContain('VALUES (NULL)') + }) + + it('quotes the table the Postgres way', async () => { + grid({ s: server({ kind: 'postgres' }), onRefresh: () => {} }) + const itr = openInsert() + ;(itr.querySelectorAll('input')[1] as HTMLInputElement).value = 'eva' + ;(itr.querySelector('.db-connect') as HTMLButtonElement).click() + await flush() + expect((mocks.invoke.mock.calls[0][1] as { sql: string }).sql).toContain('INSERT INTO "users" ("name")') + }) + + it('re-enables the button and reports the error when the insert fails', async () => { + mocks.invoke.mockRejectedValue(new Error('duplicate key')) + grid({ onRefresh: () => {} }) + const itr = openInsert() + ;(itr.querySelectorAll('input')[0] as HTMLInputElement).value = '3' + const ok = itr.querySelector('.db-connect') as HTMLButtonElement + ok.click() + await flush() + expect(ok.disabled).toBe(false) + expect(alerts.join()).toContain('duplicate key') + }) + + it('discards the draft row on cancel', () => { + grid({ onRefresh: () => {} }) + openInsert() + ;(document.querySelector('.db-insert-row .db-doc-cancel') as HTMLButtonElement).click() + expect(document.querySelector('.db-insert-row')).toBeNull() + }) +}) From 6eeacc07a167dcf1a825356c398e1ce60d324c29 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:28:13 +0200 Subject: [PATCH 13/51] refactor: extracted the Mongo document view into dbDocsView Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 117 +---------------- src/panels/db/dbDocsView.ts | 122 ++++++++++++++++++ tests/panels/db/dbDocsView.test.ts | 194 +++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 114 deletions(-) create mode 100644 src/panels/db/dbDocsView.ts create mode 100644 tests/panels/db/dbDocsView.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 09294c1..50c5b06 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -14,11 +14,12 @@ import { } from './dbAccess' import { parseStructuredJson } from './jsonValues' import { prettyJson, highlightJson } from './dbCellRender' -import { note, makeFilterInput, rowEl, appendExpandable } from './dbWidgets' +import { note, rowEl, appendExpandable } from './dbWidgets' import { detectDocker, detectLocal, resolveCreds } from './dbDetect' import { renderResultTable, preResult } from './dbResultTable' import { createDetailHost } from './dbDetailHost' import { renderGrid } from './dbTableGrid' +import { renderDocs } from './dbDocsView' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -170,7 +171,7 @@ export function createDbPanel(): { element: HTMLElement } { } if (isMongo(s)) { const docs = await invoke('db_docker_mongo_docs', { ...target(s), db, collection: name, ...creds(s) }) - renderDocs(s, db, name, docs) + renderDocs({ showDetail, detailHead }, s, db, name, docs) } else { const [data, pk] = await Promise.all([ invoke(sqlCmd(s, 'rows'), { ...target(s), db, table: name, ...creds(s) }), @@ -602,118 +603,6 @@ export function createDbPanel(): { element: HTMLElement } { editor.focus() } - const editDoc = (s: DbServer, db: string, coll: string, pre: HTMLElement): void => { - const original = pre.textContent ?? '' - const ta = document.createElement('textarea') - ta.className = 'db-doc-edit' - ta.value = original - const actions = document.createElement('div') - actions.className = 'db-doc-actions' - const save = document.createElement('button') - save.className = 'db-connect' - save.textContent = i18nT('common.save') - const cancel = document.createElement('button') - cancel.className = 'db-doc-cancel' - cancel.textContent = i18nT('common.cancel') - actions.append(save, cancel) - const wrap = document.createElement('div') - wrap.className = 'db-doc-wrap' - wrap.append(ta, actions) - pre.replaceWith(wrap) - ta.focus() - const restore = (text: string): void => { wrap.replaceWith(makeDocPre(s, db, coll, text)) } - cancel.addEventListener('click', () => restore(original)) - save.addEventListener('click', async () => { - if (!confirm(i18nT('db.replaceTheDocumentById'))) return - try { - await invoke('db_docker_mongo_update', { ...target(s), db, collection: coll, doc: ta.value, ...creds(s) }) - restore(prettyJson(ta.value)) - } catch (e) { - alert(String(e)) - } - }) - } - - const makeDocPre = (s: DbServer, db: string, coll: string, text: string): HTMLPreElement => { - const pre = document.createElement('pre') - pre.className = 'db-doc' - pre.textContent = text - pre.addEventListener('dblclick', () => editDoc(s, db, coll, pre)) - return pre - } - - const deleteDoc = async (s: DbServer, db: string, coll: string, item: HTMLElement, current: string): Promise => { - if (!confirm(i18nT('db.deleteThisDocument'))) return - try { - await invoke('db_docker_mongo_delete', { ...target(s), db, collection: coll, doc: current, ...creds(s) }) - item.remove() - } catch (e) { - alert(String(e)) - } - } - - const renderDocs = (s: DbServer, db: string, coll: string, docs: string[]): void => { - const scroll = document.createElement('div') - scroll.className = 'db-docs' - - const addNewDocRow = (): void => { - scroll.querySelector('.db-new-doc-wrap')?.remove() - const ta = document.createElement('textarea'); ta.className = 'db-doc-edit'; ta.value = '{\n \n}' - const acts = document.createElement('div'); acts.className = 'db-doc-actions' - const saveBtn = document.createElement('button'); saveBtn.className = 'db-connect'; saveBtn.textContent = i18nT('common.save') - const cancelBtn = document.createElement('button'); cancelBtn.className = 'db-doc-cancel'; cancelBtn.textContent = i18nT('common.cancel') - acts.append(saveBtn, cancelBtn) - const wrap = document.createElement('div'); wrap.className = 'db-doc-wrap db-new-doc-wrap'; wrap.append(ta, acts) - scroll.prepend(wrap); ta.focus() - cancelBtn.addEventListener('click', () => wrap.remove()) - saveBtn.addEventListener('click', async () => { - try { - const esc = (v: string): string => v.replace(/'/g, "\\'") - await invoke('db_docker_mongo_query', { ...target(s), db, script: `db.getSiblingDB('${esc(db)}').getCollection('${esc(coll)}').insertOne(${ta.value})`, ...creds(s) }) - wrap.remove() - const fresh = await invoke('db_docker_mongo_docs', { ...target(s), db, collection: coll, ...creds(s) }) - renderDocs(s, db, coll, fresh) - } catch (e) { alert(String(e)) } - }) - } - - const items: Array<{ el: HTMLElement; text: string }> = [] - const DOCS_PAGE = 20 - let docsShown = 0 - - const addDocBatch = (): void => { - scroll.querySelector('.db-load-more')?.remove() - docs.slice(docsShown, docsShown + DOCS_PAGE).forEach(d => { - const item = document.createElement('div'); item.className = 'db-doc-item' - const del = document.createElement('button'); del.className = 'db-del db-doc-del' - del.title = i18nT('db.deleteDocument'); del.innerHTML = icon('trash') - del.addEventListener('click', () => deleteDoc(s, db, coll, item, item.querySelector('.db-doc')?.textContent ?? prettyJson(d))) - const pre = makeDocPre(s, db, coll, prettyJson(d)) - item.append(del, pre); scroll.appendChild(item) - items.push({ el: item, text: prettyJson(d).toLowerCase() }) - }) - docsShown += DOCS_PAGE - if (docsShown < docs.length) { - const btn = document.createElement('button'); btn.className = 'db-load-more' - btn.textContent = i18nT('db.loadMore'); btn.addEventListener('click', addDocBatch) - scroll.appendChild(btn) - } - } - - if (!docs.length) scroll.append(note(i18nT('db.noDocuments'))) - else addDocBatch() - - const addBtn = document.createElement('button'); addBtn.className = 'db-action'; addBtn.title = i18nT('db.newDoc'); addBtn.innerHTML = icon('plus') - addBtn.addEventListener('click', addNewDocRow) - const filterInput = makeFilterInput(q => { - items.forEach(({ el, text }) => { el.style.display = !q || text.includes(q) ? '' : 'none' }) - }) - filterInput.placeholder = i18nT('db.filterDocs') - const toolbar = document.createElement('div'); toolbar.className = 'db-result-toolbar' - toolbar.append(addBtn, filterInput) - showDetail(detailHead(`${db}.${coll}`, i18nT('db.documentsSummary', { name: docs.length })), toolbar, scroll) - } - // ---- tree ---- const selectLeaf = (row: HTMLElement): void => { tree.querySelectorAll('.db-leaf.selected').forEach(el => el.classList.remove('selected')) diff --git a/src/panels/db/dbDocsView.ts b/src/panels/db/dbDocsView.ts new file mode 100644 index 0000000..0aa1947 --- /dev/null +++ b/src/panels/db/dbDocsView.ts @@ -0,0 +1,122 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { DbServer } from '../../core/db/dbServer' +import { icon } from '../../ui/icons' +import { creds, target } from './dbAccess' +import { prettyJson } from './dbCellRender' +import { note, makeFilterInput } from './dbWidgets' +import type { DbDetailHost } from './dbDetailHost' + +export const DOCS_PAGE = 20 + +const editDoc = (s: DbServer, db: string, coll: string, pre: HTMLElement): void => { + const original = pre.textContent ?? '' + const ta = document.createElement('textarea') + ta.className = 'db-doc-edit' + ta.value = original + const actions = document.createElement('div') + actions.className = 'db-doc-actions' + const save = document.createElement('button') + save.className = 'db-connect' + save.textContent = i18nT('common.save') + const cancel = document.createElement('button') + cancel.className = 'db-doc-cancel' + cancel.textContent = i18nT('common.cancel') + actions.append(save, cancel) + const wrap = document.createElement('div') + wrap.className = 'db-doc-wrap' + wrap.append(ta, actions) + pre.replaceWith(wrap) + ta.focus() + const restore = (text: string): void => { wrap.replaceWith(makeDocPre(s, db, coll, text)) } + cancel.addEventListener('click', () => restore(original)) + save.addEventListener('click', async () => { + if (!confirm(i18nT('db.replaceTheDocumentById'))) return + try { + await invoke('db_docker_mongo_update', { ...target(s), db, collection: coll, doc: ta.value, ...creds(s) }) + restore(prettyJson(ta.value)) + } catch (e) { + alert(String(e)) + } + }) +} + +const makeDocPre = (s: DbServer, db: string, coll: string, text: string): HTMLPreElement => { + const pre = document.createElement('pre') + pre.className = 'db-doc' + pre.textContent = text + pre.addEventListener('dblclick', () => editDoc(s, db, coll, pre)) + return pre +} + +const deleteDoc = async (s: DbServer, db: string, coll: string, item: HTMLElement, current: string): Promise => { + if (!confirm(i18nT('db.deleteThisDocument'))) return + try { + await invoke('db_docker_mongo_delete', { ...target(s), db, collection: coll, doc: current, ...creds(s) }) + item.remove() + } catch (e) { + alert(String(e)) + } +} + +export const renderDocs = (host: DbDetailHost, s: DbServer, db: string, coll: string, docs: string[]): void => { + const { showDetail, detailHead } = host + const scroll = document.createElement('div') + scroll.className = 'db-docs' + + const addNewDocRow = (): void => { + scroll.querySelector('.db-new-doc-wrap')?.remove() + const ta = document.createElement('textarea'); ta.className = 'db-doc-edit'; ta.value = '{\n \n}' + const acts = document.createElement('div'); acts.className = 'db-doc-actions' + const saveBtn = document.createElement('button'); saveBtn.className = 'db-connect'; saveBtn.textContent = i18nT('common.save') + const cancelBtn = document.createElement('button'); cancelBtn.className = 'db-doc-cancel'; cancelBtn.textContent = i18nT('common.cancel') + acts.append(saveBtn, cancelBtn) + const wrap = document.createElement('div'); wrap.className = 'db-doc-wrap db-new-doc-wrap'; wrap.append(ta, acts) + scroll.prepend(wrap); ta.focus() + cancelBtn.addEventListener('click', () => wrap.remove()) + saveBtn.addEventListener('click', async () => { + try { + const esc = (v: string): string => v.replace(/'/g, "\\'") + await invoke('db_docker_mongo_query', { ...target(s), db, script: `db.getSiblingDB('${esc(db)}').getCollection('${esc(coll)}').insertOne(${ta.value})`, ...creds(s) }) + wrap.remove() + const fresh = await invoke('db_docker_mongo_docs', { ...target(s), db, collection: coll, ...creds(s) }) + renderDocs(host, s, db, coll, fresh) + } catch (e) { alert(String(e)) } + }) + } + + const items: Array<{ el: HTMLElement; text: string }> = [] + let docsShown = 0 + + const addDocBatch = (): void => { + scroll.querySelector('.db-load-more')?.remove() + docs.slice(docsShown, docsShown + DOCS_PAGE).forEach(d => { + const item = document.createElement('div'); item.className = 'db-doc-item' + const del = document.createElement('button'); del.className = 'db-del db-doc-del' + del.title = i18nT('db.deleteDocument'); del.innerHTML = icon('trash') + del.addEventListener('click', () => deleteDoc(s, db, coll, item, item.querySelector('.db-doc')?.textContent ?? prettyJson(d))) + const pre = makeDocPre(s, db, coll, prettyJson(d)) + item.append(del, pre); scroll.appendChild(item) + items.push({ el: item, text: prettyJson(d).toLowerCase() }) + }) + docsShown += DOCS_PAGE + if (docsShown < docs.length) { + const btn = document.createElement('button'); btn.className = 'db-load-more' + btn.textContent = i18nT('db.loadMore'); btn.addEventListener('click', addDocBatch) + scroll.appendChild(btn) + } + } + + if (!docs.length) scroll.append(note(i18nT('db.noDocuments'))) + else addDocBatch() + + const addBtn = document.createElement('button'); addBtn.className = 'db-action'; addBtn.title = i18nT('db.newDoc'); addBtn.innerHTML = icon('plus') + addBtn.addEventListener('click', addNewDocRow) + const filterInput = makeFilterInput(q => { + items.forEach(({ el, text }) => { el.style.display = !q || text.includes(q) ? '' : 'none' }) + }) + filterInput.placeholder = i18nT('db.filterDocs') + const toolbar = document.createElement('div'); toolbar.className = 'db-result-toolbar' + toolbar.append(addBtn, filterInput) + showDetail(detailHead(`${db}.${coll}`, i18nT('db.documentsSummary', { name: docs.length })), toolbar, scroll) +} diff --git a/tests/panels/db/dbDocsView.test.ts b/tests/panels/db/dbDocsView.test.ts new file mode 100644 index 0000000..5ec32d4 --- /dev/null +++ b/tests/panels/db/dbDocsView.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { renderDocs, DOCS_PAGE } from '../../../src/panels/db/dbDocsView' +import type { DbDetailHost } from '../../../src/panels/db/dbDetailHost' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +let shown: HTMLElement[] +let alerts: string[] +let confirmed: boolean + +const host = (): DbDetailHost => ({ + showDetail: (...nodes) => { shown = nodes; document.body.replaceChildren(...nodes) }, + detailHead: (path, count) => { + const el = document.createElement('div') + el.className = 'db-detail-head' + el.dataset.path = path + el.dataset.count = count + return el + }, +}) + +const server = (): DbServer => + ({ kind: 'mongodb', source: 'docker', host: '127.0.0.1', port: 27017, container: 'm1' }) + +const docs = (n: number): string[] => Array.from({ length: n }, (_, i) => `{"_id":${i}}`) + +const show = (list: string[]): void => { renderDocs(host(), server(), 'app', 'users', list) } + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() + shown = [] + alerts = [] + confirmed = true + vi.stubGlobal('confirm', () => confirmed) + vi.stubGlobal('alert', (m: string) => { alerts.push(String(m)) }) + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) + vi.useRealTimers() +}) + +describe('listing', () => { + it('pretty-prints each document under a header naming the collection', () => { + show(['{"a":1}']) + expect((shown[0] as HTMLElement).dataset.path).toBe('app.users') + expect(document.querySelector('.db-doc')!.textContent).toBe('{\n "a": 1\n}') + }) + + it('says the collection is empty when there are no documents', () => { + show([]) + expect(document.querySelector('.db-doc-item')).toBeNull() + expect(document.querySelector('.db-note')).not.toBeNull() + }) + + it('pages long collections and drops the button on the last page', () => { + show(docs(DOCS_PAGE + 3)) + expect(document.querySelectorAll('.db-doc-item')).toHaveLength(DOCS_PAGE) + ;(document.querySelector('.db-load-more') as HTMLButtonElement).click() + expect(document.querySelectorAll('.db-doc-item')).toHaveLength(DOCS_PAGE + 3) + expect(document.querySelector('.db-load-more')).toBeNull() + }) + + it('hides documents that do not match the filter', () => { + vi.useFakeTimers() + show(['{"name":"ana"}', '{"name":"bea"}']) + const input = document.querySelector('.db-filter') as HTMLInputElement + input.value = 'ana' + input.dispatchEvent(new Event('input')) + vi.advanceTimersByTime(150) + const visible = [...document.querySelectorAll('.db-doc-item')].filter(el => (el as HTMLElement).style.display !== 'none') + expect(visible).toHaveLength(1) + }) +}) + +describe('editing a document', () => { + const openEditor = (): HTMLTextAreaElement => { + ;(document.querySelector('.db-doc') as HTMLElement).dispatchEvent(new MouseEvent('dblclick')) + return document.querySelector('.db-doc-edit') as HTMLTextAreaElement + } + + it('opens an editor prefilled with the document', () => { + show(['{"a":1}']) + expect(openEditor().value).toBe('{\n "a": 1\n}') + }) + + it('restores the original document on cancel', () => { + show(['{"a":1}']) + openEditor() + ;(document.querySelector('.db-doc-cancel') as HTMLButtonElement).click() + expect(document.querySelector('.db-doc')!.textContent).toBe('{\n "a": 1\n}') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('replaces the document after confirmation and shows the new content', async () => { + show(['{"a":1}']) + openEditor().value = '{"a":2}' + ;(document.querySelector('.db-doc-actions .db-connect') as HTMLButtonElement).click() + await flush() + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_mongo_update', expect.objectContaining({ + collection: 'users', doc: '{"a":2}', + })) + expect(document.querySelector('.db-doc')!.textContent).toBe('{\n "a": 2\n}') + }) + + it('does not write when the confirmation is refused', async () => { + confirmed = false + show(['{"a":1}']) + openEditor().value = '{"a":2}' + ;(document.querySelector('.db-doc-actions .db-connect') as HTMLButtonElement).click() + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('keeps the editor open and reports the error when the update fails', async () => { + mocks.invoke.mockRejectedValue(new Error('immutable field _id')) + show(['{"a":1}']) + openEditor().value = '{"a":2}' + ;(document.querySelector('.db-doc-actions .db-connect') as HTMLButtonElement).click() + await flush() + expect(alerts.join()).toContain('immutable field _id') + expect(document.querySelector('.db-doc-edit')).not.toBeNull() + }) +}) + +describe('deleting a document', () => { + it('removes the item after confirmation', async () => { + show(['{"a":1}']) + ;(document.querySelector('.db-doc-del') as HTMLButtonElement).click() + await flush() + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_mongo_delete', expect.anything()) + expect(document.querySelector('.db-doc-item')).toBeNull() + }) + + it('keeps the item when the confirmation is refused', async () => { + confirmed = false + show(['{"a":1}']) + ;(document.querySelector('.db-doc-del') as HTMLButtonElement).click() + await flush() + expect(document.querySelector('.db-doc-item')).not.toBeNull() + }) +}) + +describe('adding a document', () => { + const openNew = (): HTMLTextAreaElement => { + ;(document.querySelector('.db-result-toolbar .db-action') as HTMLButtonElement).click() + return document.querySelector('.db-new-doc-wrap textarea') as HTMLTextAreaElement + } + + it('inserts the document and reloads the collection', async () => { + show(['{"a":1}']) + openNew().value = '{"b":2}' + mocks.invoke.mockResolvedValueOnce(undefined).mockResolvedValueOnce(['{"b":2}']) + ;(document.querySelector('.db-new-doc-wrap .db-connect') as HTMLButtonElement).click() + await flush() + expect(mocks.invoke.mock.calls[0][0]).toBe('db_docker_mongo_query') + expect((mocks.invoke.mock.calls[0][1] as { script: string }).script).toContain('insertOne({"b":2})') + expect(mocks.invoke.mock.calls[1][0]).toBe('db_docker_mongo_docs') + }) + + it('keeps the draft and reports the error when the insert fails', async () => { + mocks.invoke.mockRejectedValue(new Error('bad JSON')) + show([]) + openNew().value = '{oops' + ;(document.querySelector('.db-new-doc-wrap .db-connect') as HTMLButtonElement).click() + await flush() + expect(alerts.join()).toContain('bad JSON') + expect(document.querySelector('.db-new-doc-wrap')).not.toBeNull() + }) + + it('discards the draft on cancel', () => { + show([]) + openNew() + ;(document.querySelector('.db-new-doc-wrap .db-doc-cancel') as HTMLButtonElement).click() + expect(document.querySelector('.db-new-doc-wrap')).toBeNull() + }) + + it('replaces an open draft instead of stacking a second one', () => { + show([]) + openNew() + openNew() + expect(document.querySelectorAll('.db-new-doc-wrap')).toHaveLength(1) + }) +}) From 978ac3b96405dd150e12d14244ac10cd650ce9a4 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:29:09 +0200 Subject: [PATCH 14/51] refactor: extracted the Redis value view into dbRedisView Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 106 +------------- src/panels/db/dbRedisView.ts | 111 +++++++++++++++ tests/panels/db/dbRedisView.test.ts | 205 ++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 103 deletions(-) create mode 100644 src/panels/db/dbRedisView.ts create mode 100644 tests/panels/db/dbRedisView.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 50c5b06..9e9653c 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -9,17 +9,16 @@ import { withRowLimit } from '../../core/db/rowLimit' import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' import { KIND_LABEL, isMongo, isPg, isRedis, sqlCmd, creds, target, - parseRedisLines, fetchColumns, listDatabases, listTables, fetchRelations, + fetchColumns, listDatabases, listTables, fetchRelations, type TableData, } from './dbAccess' -import { parseStructuredJson } from './jsonValues' -import { prettyJson, highlightJson } from './dbCellRender' import { note, rowEl, appendExpandable } from './dbWidgets' import { detectDocker, detectLocal, resolveCreds } from './dbDetect' import { renderResultTable, preResult } from './dbResultTable' import { createDetailHost } from './dbDetailHost' import { renderGrid } from './dbTableGrid' import { renderDocs } from './dbDocsView' +import { renderRedisValue } from './dbRedisView' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -59,105 +58,6 @@ export function createDbPanel(): { element: HTMLElement } { const { showDetail, detailHead } = createDetailHost(detail) showDetail(note(i18nT('db.selectATableOrCollectionToViewIts'), 'db-detail-hint')) - const renderRedisValue = (s: DbServer, db: string, key: string, v: { kind: string; value: string }, ttl: number): void => { - const ttlLabel = ttl > 0 ? i18nT('db.ttlSeconds', { ttl }) : ttl === -1 ? i18nT('db.ttlPersists') : '' - const kindStr = ttlLabel ? `${v.kind} · ${ttlLabel}` : v.kind - const lines = v.value ? parseRedisLines(v.value) : [] - const rawValue = v.value || '' - - const buildContent = (): HTMLElement => { - if (!v.value) return note(i18nT('db.empty')) - - if (v.kind === 'hash' && lines.length >= 2) { - const tbl = document.createElement('table') - tbl.className = 'db-redis-table' - const thead = document.createElement('thead') - const htr = document.createElement('tr') - ;['Field', 'Value'].forEach(h => { const th = document.createElement('th'); th.textContent = h; htr.appendChild(th) }) - thead.appendChild(htr) - const tbody = document.createElement('tbody') - for (let i = 0; i < lines.length - 1; i += 2) { - const field = lines[i], val = lines[i + 1] - const tr = document.createElement('tr') - const keyTd = document.createElement('td'); keyTd.textContent = field; tr.appendChild(keyTd) - const valTd = document.createElement('td'); valTd.textContent = val - valTd.classList.add('db-editable') - valTd.addEventListener('dblclick', () => { - const inp = document.createElement('input'); inp.className = 'db-cell-input'; inp.value = val - valTd.replaceChildren(inp); inp.focus(); inp.select() - let done = false - inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); inp.blur() } if (e.key === 'Escape') { done = true; valTd.textContent = val } }) - inp.addEventListener('blur', async () => { - if (done) return; done = true - if (inp.value === val) { valTd.textContent = val; return } - try { - await invoke('db_docker_redis_command', { ...target(s), db, command: `HSET ${key} ${field} ${inp.value}`, password: s.password ?? '' }) - valTd.textContent = inp.value - } catch (e2) { alert(String(e2)); valTd.textContent = val } - }) - }) - tr.appendChild(valTd); tbody.appendChild(tr) - } - tbl.append(thead, tbody); return tbl - } - - if ((v.kind === 'list' || v.kind === 'set') && lines.length) { - const ol = document.createElement('ol'); ol.className = 'db-redis-list' - lines.forEach(item => { const li = document.createElement('li'); li.textContent = item; ol.appendChild(li) }) - return ol - } - - if (v.kind === 'zset' && lines.length >= 2) { - const tbl = document.createElement('table'); tbl.className = 'db-redis-table' - const thead = document.createElement('thead'); const htr = document.createElement('tr') - ;[i18nT('db.member'), i18nT('db.score')].forEach(h => { const th = document.createElement('th'); th.textContent = h; htr.appendChild(th) }) - thead.appendChild(htr); const tbody = document.createElement('tbody') - for (let i = 0; i < lines.length - 1; i += 2) { - const tr = document.createElement('tr') - ;[lines[i], lines[i + 1]].forEach(v2 => { const td = document.createElement('td'); td.textContent = v2; tr.appendChild(td) }) - tbody.appendChild(tr) - } - tbl.append(thead, tbody); return tbl - } - - // string / stream / unknown: existing behavior with optional editing - const pre = document.createElement('pre'); pre.className = 'db-doc' - const parsed = parseStructuredJson(rawValue) - if (parsed && !parsed.truncated) highlightJson(pre, parsed.formatted) - else pre.textContent = prettyJson(rawValue) - - if (v.kind === 'string') { - pre.addEventListener('dblclick', () => { - const ta = document.createElement('textarea'); ta.className = 'db-doc-edit'; ta.value = rawValue - const acts = document.createElement('div'); acts.className = 'db-doc-actions' - const saveBtn = document.createElement('button'); saveBtn.className = 'db-connect'; saveBtn.textContent = i18nT('common.save') - const cancelBtn = document.createElement('button'); cancelBtn.className = 'db-doc-cancel'; cancelBtn.textContent = i18nT('common.cancel') - acts.append(saveBtn, cancelBtn) - const wrap = document.createElement('div'); wrap.className = 'db-doc-wrap'; wrap.append(ta, acts) - pre.replaceWith(wrap); ta.focus() - cancelBtn.addEventListener('click', () => wrap.replaceWith(pre)) - saveBtn.addEventListener('click', async () => { - try { - await invoke('db_docker_redis_set', { ...target(s), db, key, value: ta.value, password: s.password ?? '' }) - pre.textContent = ta.value; wrap.replaceWith(pre) - } catch (e) { alert(String(e)) } - }) - }) - } - return pre - } - - const content = buildContent() - const copyBtn = document.createElement('button') - copyBtn.className = 'db-action'; copyBtn.title = i18nT('db.jsonCopy'); copyBtn.textContent = '⎘' - copyBtn.addEventListener('click', () => { - void navigator.clipboard.writeText(rawValue).then(() => { copyBtn.textContent = '✓'; setTimeout(() => { copyBtn.textContent = '⎘' }, 1200) }) - }) - const toolbar = document.createElement('div'); toolbar.className = 'db-result-toolbar'; toolbar.appendChild(copyBtn) - const scroll = document.createElement('div'); scroll.className = 'db-docs'; scroll.appendChild(content) - showDetail(detailHead(`db${db} · ${key}`, kindStr), toolbar, scroll) - } - const openData = async (s: DbServer, db: string, name: string): Promise => { showDetail(note(i18nT('common.loading'), 'db-detail-loading')) try { @@ -166,7 +66,7 @@ export function createDbPanel(): { element: HTMLElement } { invoke<{ kind: string; value: string }>('db_docker_redis_value', { ...target(s), db, key: name, password: s.password ?? '' }), invoke('db_docker_redis_ttl', { ...target(s), db, key: name, password: s.password ?? '' }).catch(() => -2), ]) - renderRedisValue(s, db, name, v, ttl) + renderRedisValue({ showDetail, detailHead }, s, db, name, v, ttl) return } if (isMongo(s)) { diff --git a/src/panels/db/dbRedisView.ts b/src/panels/db/dbRedisView.ts new file mode 100644 index 0000000..27312c2 --- /dev/null +++ b/src/panels/db/dbRedisView.ts @@ -0,0 +1,111 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { DbServer } from '../../core/db/dbServer' +import { parseStructuredJson } from './jsonValues' +import { target, parseRedisLines } from './dbAccess' +import { prettyJson, highlightJson } from './dbCellRender' +import { note } from './dbWidgets' +import type { DbDetailHost } from './dbDetailHost' + +export const renderRedisValue = ( + host: DbDetailHost, s: DbServer, db: string, key: string, + v: { kind: string; value: string }, ttl: number, +): void => { + const { showDetail, detailHead } = host + const ttlLabel = ttl > 0 ? i18nT('db.ttlSeconds', { ttl }) : ttl === -1 ? i18nT('db.ttlPersists') : '' + const kindStr = ttlLabel ? `${v.kind} · ${ttlLabel}` : v.kind + const lines = v.value ? parseRedisLines(v.value) : [] + const rawValue = v.value || '' + + const buildContent = (): HTMLElement => { + if (!v.value) return note(i18nT('db.empty')) + + if (v.kind === 'hash' && lines.length >= 2) { + const tbl = document.createElement('table') + tbl.className = 'db-redis-table' + const thead = document.createElement('thead') + const htr = document.createElement('tr') + ;['Field', 'Value'].forEach(h => { const th = document.createElement('th'); th.textContent = h; htr.appendChild(th) }) + thead.appendChild(htr) + const tbody = document.createElement('tbody') + for (let i = 0; i < lines.length - 1; i += 2) { + const field = lines[i], val = lines[i + 1] + const tr = document.createElement('tr') + const keyTd = document.createElement('td'); keyTd.textContent = field; tr.appendChild(keyTd) + const valTd = document.createElement('td'); valTd.textContent = val + valTd.classList.add('db-editable') + valTd.addEventListener('dblclick', () => { + const inp = document.createElement('input'); inp.className = 'db-cell-input'; inp.value = val + valTd.replaceChildren(inp); inp.focus(); inp.select() + let done = false + inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); inp.blur() } if (e.key === 'Escape') { done = true; valTd.textContent = val } }) + inp.addEventListener('blur', async () => { + if (done) return; done = true + if (inp.value === val) { valTd.textContent = val; return } + try { + await invoke('db_docker_redis_command', { ...target(s), db, command: `HSET ${key} ${field} ${inp.value}`, password: s.password ?? '' }) + valTd.textContent = inp.value + } catch (e2) { alert(String(e2)); valTd.textContent = val } + }) + }) + tr.appendChild(valTd); tbody.appendChild(tr) + } + tbl.append(thead, tbody); return tbl + } + + if ((v.kind === 'list' || v.kind === 'set') && lines.length) { + const ol = document.createElement('ol'); ol.className = 'db-redis-list' + lines.forEach(item => { const li = document.createElement('li'); li.textContent = item; ol.appendChild(li) }) + return ol + } + + if (v.kind === 'zset' && lines.length >= 2) { + const tbl = document.createElement('table'); tbl.className = 'db-redis-table' + const thead = document.createElement('thead'); const htr = document.createElement('tr') + ;[i18nT('db.member'), i18nT('db.score')].forEach(h => { const th = document.createElement('th'); th.textContent = h; htr.appendChild(th) }) + thead.appendChild(htr); const tbody = document.createElement('tbody') + for (let i = 0; i < lines.length - 1; i += 2) { + const tr = document.createElement('tr') + ;[lines[i], lines[i + 1]].forEach(v2 => { const td = document.createElement('td'); td.textContent = v2; tr.appendChild(td) }) + tbody.appendChild(tr) + } + tbl.append(thead, tbody); return tbl + } + + // string / stream / unknown: existing behavior with optional editing + const pre = document.createElement('pre'); pre.className = 'db-doc' + const parsed = parseStructuredJson(rawValue) + if (parsed && !parsed.truncated) highlightJson(pre, parsed.formatted) + else pre.textContent = prettyJson(rawValue) + + if (v.kind === 'string') { + pre.addEventListener('dblclick', () => { + const ta = document.createElement('textarea'); ta.className = 'db-doc-edit'; ta.value = rawValue + const acts = document.createElement('div'); acts.className = 'db-doc-actions' + const saveBtn = document.createElement('button'); saveBtn.className = 'db-connect'; saveBtn.textContent = i18nT('common.save') + const cancelBtn = document.createElement('button'); cancelBtn.className = 'db-doc-cancel'; cancelBtn.textContent = i18nT('common.cancel') + acts.append(saveBtn, cancelBtn) + const wrap = document.createElement('div'); wrap.className = 'db-doc-wrap'; wrap.append(ta, acts) + pre.replaceWith(wrap); ta.focus() + cancelBtn.addEventListener('click', () => wrap.replaceWith(pre)) + saveBtn.addEventListener('click', async () => { + try { + await invoke('db_docker_redis_set', { ...target(s), db, key, value: ta.value, password: s.password ?? '' }) + pre.textContent = ta.value; wrap.replaceWith(pre) + } catch (e) { alert(String(e)) } + }) + }) + } + return pre + } + + const content = buildContent() + const copyBtn = document.createElement('button') + copyBtn.className = 'db-action'; copyBtn.title = i18nT('db.jsonCopy'); copyBtn.textContent = '⎘' + copyBtn.addEventListener('click', () => { + void navigator.clipboard.writeText(rawValue).then(() => { copyBtn.textContent = '✓'; setTimeout(() => { copyBtn.textContent = '⎘' }, 1200) }) + }) + const toolbar = document.createElement('div'); toolbar.className = 'db-result-toolbar'; toolbar.appendChild(copyBtn) + const scroll = document.createElement('div'); scroll.className = 'db-docs'; scroll.appendChild(content) + showDetail(detailHead(`db${db} · ${key}`, kindStr), toolbar, scroll) +} diff --git a/tests/panels/db/dbRedisView.test.ts b/tests/panels/db/dbRedisView.test.ts new file mode 100644 index 0000000..ea59330 --- /dev/null +++ b/tests/panels/db/dbRedisView.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { renderRedisValue } from '../../../src/panels/db/dbRedisView' +import type { DbDetailHost } from '../../../src/panels/db/dbDetailHost' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +let shown: HTMLElement[] +let alerts: string[] + +const host = (): DbDetailHost => ({ + showDetail: (...nodes) => { shown = nodes; document.body.replaceChildren(...nodes) }, + detailHead: (path, count) => { + const el = document.createElement('div') + el.className = 'db-detail-head' + el.dataset.path = path + el.dataset.count = count + return el + }, +}) + +const server = (): DbServer => + ({ kind: 'redis', source: 'docker', host: '127.0.0.1', port: 6379, container: 'r1', password: 'pw' }) + +const show = (kind: string, value: string, ttl = -1): void => { + renderRedisValue(host(), server(), '0', 'k1', { kind, value }, ttl) +} + +const head = (): HTMLElement => shown[0] as HTMLElement + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() + shown = [] + alerts = [] + vi.stubGlobal('alert', (m: string) => { alerts.push(String(m)) }) + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +describe('header', () => { + it('names the database and key', () => { + show('string', 'hello') + expect(head().dataset.path).toBe('db0 · k1') + }) + + it('shows the remaining TTL when the key expires', () => { + show('string', 'hello', 30) + expect(head().dataset.count).toContain('30') + }) + + it('says the key persists when it has no TTL', () => { + show('string', 'hello', -1) + expect(head().dataset.count).toContain('string') + expect(head().dataset.count).not.toBe('string') + }) + + it('shows the kind alone when the TTL is unknown', () => { + show('string', 'hello', -2) + expect(head().dataset.count).toBe('string') + }) +}) + +describe('value shapes', () => { + it('says the key is empty when there is no value', () => { + show('string', '') + expect(document.querySelector('.db-note')).not.toBeNull() + }) + + it('renders a hash as a field/value table', () => { + show('hash', '1) "name"\n2) "ana"\n3) "age"\n4) "30"') + const rows = [...document.querySelectorAll('.db-redis-table tbody tr')] + .map(tr => [...tr.querySelectorAll('td')].map(td => td.textContent)) + expect(rows).toEqual([['name', 'ana'], ['age', '30']]) + }) + + it('renders a list and a set as an ordered list', () => { + show('list', '1) "a"\n2) "b"') + expect([...document.querySelectorAll('.db-redis-list li')].map(li => li.textContent)).toEqual(['a', 'b']) + show('set', '1) "x"') + expect(document.querySelectorAll('.db-redis-list li')).toHaveLength(1) + }) + + it('renders a zset as member/score pairs', () => { + show('zset', '1) "ana"\n2) "10"') + const cells = [...document.querySelectorAll('.db-redis-table tbody td')].map(td => td.textContent) + expect(cells).toEqual(['ana', '10']) + }) + + it('highlights a JSON string value', () => { + show('string', '{"a":1}') + expect(document.querySelector('.db-doc .jk')!.textContent).toBe('"a"') + }) + + it('shows a plain string as text', () => { + show('string', 'just text') + expect(document.querySelector('.db-doc')!.textContent).toBe('just text') + }) +}) + +describe('editing a hash field', () => { + const editFirst = (): HTMLInputElement => { + const td = document.querySelectorAll('.db-redis-table tbody td')[1] as HTMLElement + td.dispatchEvent(new MouseEvent('dblclick')) + return td.querySelector('input') as HTMLInputElement + } + + it('only makes the value column editable', () => { + show('hash', '1) "name"\n2) "ana"') + const tds = document.querySelectorAll('.db-redis-table tbody td') + expect(tds[0].classList.contains('db-editable')).toBe(false) + expect(tds[1].classList.contains('db-editable')).toBe(true) + }) + + it('sends HSET with the new value', async () => { + show('hash', '1) "name"\n2) "ana"') + const input = editFirst() + input.value = 'eva' + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_redis_command', expect.objectContaining({ + command: 'HSET k1 name eva', password: 'pw', + })) + expect(document.querySelectorAll('.db-redis-table tbody td')[1].textContent).toBe('eva') + }) + + it('does not write when the value is unchanged or on Escape', async () => { + show('hash', '1) "name"\n2) "ana"') + editFirst().dispatchEvent(new FocusEvent('blur')) + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + + const input = editFirst() + input.value = 'eva' + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('puts the old value back when the write fails', async () => { + mocks.invoke.mockRejectedValue(new Error('WRONGTYPE')) + show('hash', '1) "name"\n2) "ana"') + const input = editFirst() + input.value = 'eva' + input.dispatchEvent(new FocusEvent('blur')) + await flush() + expect(alerts.join()).toContain('WRONGTYPE') + expect(document.querySelectorAll('.db-redis-table tbody td')[1].textContent).toBe('ana') + }) +}) + +describe('editing a string value', () => { + const openEditor = (): HTMLTextAreaElement => { + ;(document.querySelector('.db-doc') as HTMLElement).dispatchEvent(new MouseEvent('dblclick')) + return document.querySelector('.db-doc-edit') as HTMLTextAreaElement + } + + it('is offered for strings but not for lists', () => { + show('string', 'hello') + expect(openEditor()).not.toBeNull() + show('list', '1) "a"') + ;(document.querySelector('.db-doc') as HTMLElement | null)?.dispatchEvent(new MouseEvent('dblclick')) + expect(document.querySelector('.db-doc-edit')).toBeNull() + }) + + it('saves through SET and shows the new value', async () => { + show('string', 'hello') + openEditor().value = 'bye' + ;(document.querySelector('.db-doc-actions .db-connect') as HTMLButtonElement).click() + await flush() + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_redis_set', expect.objectContaining({ + key: 'k1', value: 'bye', + })) + expect(document.querySelector('.db-doc')!.textContent).toBe('bye') + }) + + it('restores the original view on cancel', () => { + show('string', 'hello') + openEditor() + ;(document.querySelector('.db-doc-cancel') as HTMLButtonElement).click() + expect(document.querySelector('.db-doc')!.textContent).toBe('hello') + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) + +describe('copy', () => { + it('copies the raw value rather than the rendered table', async () => { + const writeText = vi.fn(async () => {}) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + show('hash', '1) "name"\n2) "ana"') + ;(document.querySelector('.db-result-toolbar .db-action') as HTMLButtonElement).click() + expect(writeText).toHaveBeenCalledWith('1) "name"\n2) "ana"') + }) +}) From 1269a0992acc709b5fb1af3edd968fbe385f6675 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:30:33 +0200 Subject: [PATCH 15/51] refactor: extracted the query history dropdown into dbQueryHistory Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 46 +---------- src/panels/db/dbQueryHistory.ts | 64 ++++++++++++++ tests/panels/db/dbQueryHistory.test.ts | 110 +++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 42 deletions(-) create mode 100644 src/panels/db/dbQueryHistory.ts create mode 100644 tests/panels/db/dbQueryHistory.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 9e9653c..44fd075 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -19,6 +19,7 @@ import { createDetailHost } from './dbDetailHost' import { renderGrid } from './dbTableGrid' import { renderDocs } from './dbDocsView' import { renderRedisValue } from './dbRedisView' +import { createQueryHistory } from './dbQueryHistory' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -182,43 +183,11 @@ export function createDbPanel(): { element: HTMLElement } { askAi(`${schema}\n\nEscríbeme ${dialect} para: ${guide}`, false, runner, tools) }) - const histBtn = document.createElement('button') - histBtn.className = 'db-connect' - histBtn.title = i18nT('db.queryHistory') - histBtn.textContent = '⏱' - const histDrop = document.createElement('div') - histDrop.className = 'db-hist-drop hidden' - let offHistClick: (() => void) | null = null - histBtn.addEventListener('click', e => { - e.stopPropagation() - if (offHistClick) { document.removeEventListener('click', offHistClick); offHistClick = null } - const h = getHistory() - histDrop.replaceChildren() - if (!h.length) { - histDrop.append(note(i18nT('db.noHistory'), 'db-detail-hint')) - } else { - h.forEach(q => { - const btn = document.createElement('button') - btn.className = 'db-hist-item' - btn.textContent = q.split('\n')[0].slice(0, 80) - btn.title = q - btn.addEventListener('click', () => { editor.value = q; histDrop.classList.add('hidden'); editor.focus() }) - histDrop.appendChild(btn) - }) - } - histDrop.classList.toggle('hidden') - if (!histDrop.classList.contains('hidden')) { - offHistClick = (): void => { histDrop.classList.add('hidden'); offHistClick = null } - setTimeout(() => { if (offHistClick) document.addEventListener('click', offHistClick, { once: true }) }, 0) - } - }) - const histWrap = document.createElement('div') - histWrap.className = 'db-hist-wrap' - histWrap.append(histBtn, histDrop) + const history = createQueryHistory(s, db, q => { editor.value = q; editor.focus() }) const actions = document.createElement('div') actions.className = 'db-query-actions' - actions.append(runBtn, aiBtn, histWrap) + actions.append(runBtn, aiBtn, history.element) // Deterministic JOIN builder (no AI): you pick tables and Bento finds the // JOIN path through the foreign keys. SQL only. @@ -393,13 +362,6 @@ export function createDbPanel(): { element: HTMLElement } { return out } - const HIST_KEY = `bento.db.qhist.${s.kind}.${db}` - const getHistory = (): string[] => { try { return JSON.parse(localStorage.getItem(HIST_KEY) ?? '[]') as string[] } catch { return [] } } - const saveHistory = (q: string): void => { - const h = [q, ...getHistory().filter(x => x !== q)].slice(0, 20) - localStorage.setItem(HIST_KEY, JSON.stringify(h)) - } - // Runs a query and returns the element with the result (table or text). // Reused by the editor and by the "Run" button in the AI chat. const executeQuery = async (text: string): Promise => { @@ -473,7 +435,7 @@ export function createDbPanel(): { element: HTMLElement } { resultArea.replaceChildren(note(i18nT('db.running'), 'db-detail-loading')) try { const result = await executeQuery(text) - saveHistory(text) + history.saveHistory(text) resultArea.replaceChildren(result) } catch (e) { const errEl = note(String(e), 'db-detail-error') diff --git a/src/panels/db/dbQueryHistory.ts b/src/panels/db/dbQueryHistory.ts new file mode 100644 index 0000000..d19b606 --- /dev/null +++ b/src/panels/db/dbQueryHistory.ts @@ -0,0 +1,64 @@ +import { t as i18nT } from '../../i18n' +import type { DbServer } from '../../core/db/dbServer' +import { note } from './dbWidgets' + +export const HISTORY_LIMIT = 20 + +export interface DbQueryHistory { + element: HTMLElement + getHistory: () => string[] + saveHistory: (q: string) => void +} + +/** Recent queries for one database, kept in localStorage, plus the button that lists them. */ +export function createQueryHistory(s: DbServer, db: string, onPick: (query: string) => void): DbQueryHistory { + const key = `bento.db.qhist.${s.kind}.${db}` + + const getHistory = (): string[] => { + try { return JSON.parse(localStorage.getItem(key) ?? '[]') as string[] } catch { return [] } + } + + const saveHistory = (q: string): void => { + const h = [q, ...getHistory().filter(x => x !== q)].slice(0, HISTORY_LIMIT) + localStorage.setItem(key, JSON.stringify(h)) + } + + const btn = document.createElement('button') + btn.className = 'db-connect' + btn.title = i18nT('db.queryHistory') + btn.textContent = '⏱' + + const drop = document.createElement('div') + drop.className = 'db-hist-drop hidden' + + // A single outside-click listener, re-armed each time the dropdown opens. + let offClick: (() => void) | null = null + btn.addEventListener('click', e => { + e.stopPropagation() + if (offClick) { document.removeEventListener('click', offClick); offClick = null } + const h = getHistory() + drop.replaceChildren() + if (!h.length) { + drop.append(note(i18nT('db.noHistory'), 'db-detail-hint')) + } else { + h.forEach(q => { + const item = document.createElement('button') + item.className = 'db-hist-item' + item.textContent = q.split('\n')[0].slice(0, 80) + item.title = q + item.addEventListener('click', () => { drop.classList.add('hidden'); onPick(q) }) + drop.appendChild(item) + }) + } + drop.classList.toggle('hidden') + if (drop.classList.contains('hidden')) return + offClick = (): void => { drop.classList.add('hidden'); offClick = null } + setTimeout(() => { if (offClick) document.addEventListener('click', offClick, { once: true }) }, 0) + }) + + const element = document.createElement('div') + element.className = 'db-hist-wrap' + element.append(btn, drop) + + return { element, getHistory, saveHistory } +} diff --git a/tests/panels/db/dbQueryHistory.test.ts b/tests/panels/db/dbQueryHistory.test.ts new file mode 100644 index 0000000..a23e955 --- /dev/null +++ b/tests/panels/db/dbQueryHistory.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { createQueryHistory, HISTORY_LIMIT } from '../../../src/panels/db/dbQueryHistory' +import type { DbServer } from '../../../src/core/db/dbServer' + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const flushTimers = (): Promise => new Promise(r => setTimeout(r, 0)) + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() +}) + +describe('storage', () => { + it('starts empty and remembers what it saves', () => { + const h = createQueryHistory(server(), 'app', () => {}) + expect(h.getHistory()).toEqual([]) + h.saveHistory('SELECT 1') + expect(h.getHistory()).toEqual(['SELECT 1']) + }) + + it('keeps the newest first and never duplicates a query', () => { + const h = createQueryHistory(server(), 'app', () => {}) + h.saveHistory('a') + h.saveHistory('b') + h.saveHistory('a') + expect(h.getHistory()).toEqual(['a', 'b']) + }) + + it('drops the oldest entries past the limit', () => { + const h = createQueryHistory(server(), 'app', () => {}) + for (let i = 0; i <= HISTORY_LIMIT; i++) h.saveHistory(`q${i}`) + const stored = h.getHistory() + expect(stored).toHaveLength(HISTORY_LIMIT) + expect(stored[0]).toBe(`q${HISTORY_LIMIT}`) + expect(stored).not.toContain('q0') + }) + + it('keeps a separate history per engine and database', () => { + createQueryHistory(server(), 'app', () => {}).saveHistory('mysql query') + expect(createQueryHistory(server(), 'other', () => {}).getHistory()).toEqual([]) + expect(createQueryHistory(server({ kind: 'postgres' }), 'app', () => {}).getHistory()).toEqual([]) + }) + + it('survives corrupted stored data', () => { + const h = createQueryHistory(server(), 'app', () => {}) + localStorage.setItem('bento.db.qhist.mysql.app', 'not json') + expect(h.getHistory()).toEqual([]) + }) +}) + +describe('dropdown', () => { + it('starts hidden and opens on click', () => { + const h = createQueryHistory(server(), 'app', () => {}) + document.body.appendChild(h.element) + const btn = h.element.querySelector('button') as HTMLButtonElement + const drop = h.element.querySelector('.db-hist-drop') as HTMLElement + expect(drop.classList.contains('hidden')).toBe(true) + btn.click() + expect(drop.classList.contains('hidden')).toBe(false) + }) + + it('says there is no history when nothing was saved', () => { + const h = createQueryHistory(server(), 'app', () => {}) + ;(h.element.querySelector('button') as HTMLButtonElement).click() + expect(h.element.querySelector('.db-hist-item')).toBeNull() + expect(h.element.querySelector('.db-detail-hint')).not.toBeNull() + }) + + it('lists the first line of each query and keeps the whole one as the tooltip', () => { + const h = createQueryHistory(server(), 'app', () => {}) + h.saveHistory('SELECT *\nFROM users') + ;(h.element.querySelector('button') as HTMLButtonElement).click() + const item = h.element.querySelector('.db-hist-item') as HTMLButtonElement + expect(item.textContent).toBe('SELECT *') + expect(item.title).toBe('SELECT *\nFROM users') + }) + + it('hands the picked query back and closes', () => { + const onPick = vi.fn() + const h = createQueryHistory(server(), 'app', onPick) + h.saveHistory('SELECT 1') + ;(h.element.querySelector('button') as HTMLButtonElement).click() + ;(h.element.querySelector('.db-hist-item') as HTMLButtonElement).click() + expect(onPick).toHaveBeenCalledWith('SELECT 1') + expect((h.element.querySelector('.db-hist-drop') as HTMLElement).classList.contains('hidden')).toBe(true) + }) + + it('closes again on a second click of the button', () => { + const h = createQueryHistory(server(), 'app', () => {}) + const btn = h.element.querySelector('button') as HTMLButtonElement + const drop = h.element.querySelector('.db-hist-drop') as HTMLElement + btn.click() + btn.click() + expect(drop.classList.contains('hidden')).toBe(true) + }) + + it('closes when the user clicks elsewhere', async () => { + const h = createQueryHistory(server(), 'app', () => {}) + document.body.appendChild(h.element) + ;(h.element.querySelector('button') as HTMLButtonElement).click() + await flushTimers() + document.body.click() + expect((h.element.querySelector('.db-hist-drop') as HTMLElement).classList.contains('hidden')).toBe(true) + }) +}) From e8c4d001efc7e603a3244f2f64a681855761bead Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:32:53 +0200 Subject: [PATCH 16/51] refactor: extracted query execution and EXPLAIN into dbQueryExec Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 93 +-------------- src/panels/db/dbQueryExec.ts | 109 +++++++++++++++++ tests/panels/db/dbQueryExec.test.ts | 176 ++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 91 deletions(-) create mode 100644 src/panels/db/dbQueryExec.ts create mode 100644 tests/panels/db/dbQueryExec.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 44fd075..0a5440f 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -5,7 +5,6 @@ import { icon } from '../../ui/icons' import { askAi, type AiQueryRunner, type AiTool } from '../../ui/askAi' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import { buildJoinPath, type Relation } from '../../core/db/joinPath' -import { withRowLimit } from '../../core/db/rowLimit' import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' import { KIND_LABEL, isMongo, isPg, isRedis, sqlCmd, creds, target, @@ -14,12 +13,12 @@ import { } from './dbAccess' import { note, rowEl, appendExpandable } from './dbWidgets' import { detectDocker, detectLocal, resolveCreds } from './dbDetect' -import { renderResultTable, preResult } from './dbResultTable' import { createDetailHost } from './dbDetailHost' import { renderGrid } from './dbTableGrid' import { renderDocs } from './dbDocsView' import { renderRedisValue } from './dbRedisView' import { createQueryHistory } from './dbQueryHistory' +import { createQueryRunner } from './dbQueryExec' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -184,6 +183,7 @@ export function createDbPanel(): { element: HTMLElement } { }) const history = createQueryHistory(s, db, q => { editor.value = q; editor.focus() }) + const { executeQuery, explain } = createQueryRunner(s, db, names, relationsReady) const actions = document.createElement('div') actions.className = 'db-query-actions' @@ -340,95 +340,6 @@ export function createDbPanel(): { element: HTMLElement } { resultArea.className = 'db-grid-scroll' resultArea.append(note(i18nT('db.writeAQueryAndRunIt'), 'db-detail-hint')) - // Postgres safety net: quotes known table names with uppercase letters if - // they come unquoted (Postgres would lowercase them and fail). Covers what - // the AI forgets to quote. - const pgFixIdents = (sql: string): string => { - let out = sql - const esc = (t: string): string => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - names.forEach(full => { - if (!full.includes('.')) return - const quotedRight = full.split('.').map(p => `"${p}"`).join('.') - // Wrongly quoted as a single piece: "schema.table" → "schema"."table". - out = out.split(`"${full}"`).join(quotedRight) - }) - names.forEach(full => { - const table = full.includes('.') ? full.split('.').slice(-1)[0] : full - if (!/[A-Z]/.test(table)) return // the rest is only at risk due to uppercase letters - const quotedFull = full.split('.').map(p => `"${p}"`).join('.') - out = out.replace(new RegExp(`(^|[^"\\w.])${esc(full)}(?![\\w"])`, 'g'), `$1${quotedFull}`) - out = out.replace(new RegExp(`(^|[^"\\w.])${esc(table)}(?![\\w"])`, 'g'), `$1"${table}"`) - }) - return out - } - - // Runs a query and returns the element with the result (table or text). - // Reused by the editor and by the "Run" button in the AI chat. - const executeQuery = async (text: string): Promise => { - if (isMongo(s)) return preResult(await invoke('db_docker_mongo_query', { ...target(s), db, script: text, ...creds(s) })) - if (isRedis(s)) return preResult(await invoke('db_docker_redis_command', { ...target(s), db, command: text, password: s.password ?? '' })) - const limited = withRowLimit(text) - // MySQL/MariaDB: with many tables the optimizer takes forever to find the - // optimal JOIN ORDER (combinatorial explosion during PLANNING, even if the - // query executes few rows). With depth=1 it plans greedily instantly. - // Postgres doesn't suffer from this. - const sql = isPg(s) ? pgFixIdents(limited) : `SET SESSION optimizer_search_depth=1; ${limited}` - const data = await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql, ...creds(s) }) - - // Enable editing when the query is a plain SELECT * FROM with no joins or aggregations. - // Pagination: offer "load more" when the query had no explicit LIMIT (withRowLimit added one). - const trimmedText = text.trim().replace(/;\s*$/, '') - const limitWasAdded = !/\blimit\b\s+\d/i.test(trimmedText) && /^(select|with)\b/i.test(trimmedText) - const loadMore = limitWasAdded - ? async (offset: number): Promise => { - const pageSql = `${trimmedText} LIMIT 200 OFFSET ${offset}` - const moreSql = isPg(s) ? pgFixIdents(pageSql) : `SET SESSION optimizer_search_depth=1; ${pageSql}` - const more = await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: moreSql, ...creds(s) }) - return more.rows - } - : undefined - - const simpleMatch = /^\s*select\s+\*\s+from\s+((?:"[^"]+"\."[^"]+"|"[^"]+"|`[^`]+`|\w+(?:\.\w+)*))\s*(?:limit\s+\d+\s*)?;?\s*$/i.exec(text.trim()) - if (simpleMatch) { - const rawTable = simpleMatch[1].replace(/["'`]/g, '') - const matched = names.find(n => n === rawTable || n.split('.').pop() === rawTable.split('.').pop()) - if (matched) { - try { - const [pk, allFks] = await Promise.all([ - invoke(sqlCmd(s, 'pk'), { ...target(s), db, table: matched, ...creds(s) }).catch(() => [] as string[]), - relationsReady.catch(() => [] as ForeignKey[]), - ]) - const pkIdx = pk.map(c => data.columns.indexOf(c)).filter(i => i >= 0) - const fkColMap = new Map() - allFks.filter(f => f.table === matched).forEach(f => fkColMap.set(f.column, { ref_table: f.ref_table, ref_column: f.ref_column })) - return renderResultTable(data, { s, db, table: matched, pkIdx, fkColMap }, loadMore) - } catch { /* fall through to read-only */ } - } - } - - return renderResultTable(data, undefined, loadMore) - } - - // EXPLAIN: asks the engine for the execution plan WITHOUT running the query. It's - // instant and reveals why a query is slow: which table is scanned in full - // (join type ALL, no index) and how many rows it estimates combining. - const explain = async (text: string): Promise => { - const raw = text.trim().replace(/;\s*$/, '') - // With many tables, MySQL/MariaDB takes so long to PLAN the JOIN order that - // even the EXPLAIN hangs. optimizer_search_depth=1 forces an immediate - // greedy plan: the diagnostic returns instead of blowing up. - const sql = isPg(s) - ? `EXPLAIN ${pgFixIdents(raw)}` - : `SET SESSION optimizer_search_depth=1; EXPLAIN ${raw}` - const plan = renderResultTable(await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql, ...creds(s) })) - const wrap = document.createElement('div') - wrap.append( - note(i18nT('db.executionPlanHighRowCountsOrTypeAll'), 'db-detail-hint'), - plan, - ) - return wrap - } - const run = async (): Promise => { const text = editor.value.trim() if (!text) return diff --git a/src/panels/db/dbQueryExec.ts b/src/panels/db/dbQueryExec.ts new file mode 100644 index 0000000..2f0270c --- /dev/null +++ b/src/panels/db/dbQueryExec.ts @@ -0,0 +1,109 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { DbServer } from '../../core/db/dbServer' +import { withRowLimit } from '../../core/db/rowLimit' +import type { ForeignKey } from './queryBuilders' +import { isMongo, isPg, isRedis, sqlCmd, creds, target, type TableData } from './dbAccess' +import { note } from './dbWidgets' +import { renderResultTable, preResult } from './dbResultTable' + +// Postgres safety net: quotes known table names with uppercase letters if +// they come unquoted (Postgres would lowercase them and fail). Covers what +// the AI forgets to quote. +export const pgFixIdents = (sql: string, names: string[]): string => { + let out = sql + const esc = (t: string): string => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + names.forEach(full => { + if (!full.includes('.')) return + const quotedRight = full.split('.').map(p => `"${p}"`).join('.') + // Wrongly quoted as a single piece: "schema.table" → "schema"."table". + out = out.split(`"${full}"`).join(quotedRight) + }) + names.forEach(full => { + const table = full.includes('.') ? full.split('.').slice(-1)[0] : full + if (!/[A-Z]/.test(table)) return // the rest is only at risk due to uppercase letters + const quotedFull = full.split('.').map(p => `"${p}"`).join('.') + out = out.replace(new RegExp(`(^|[^"\\w.])${esc(full)}(?![\\w"])`, 'g'), `$1${quotedFull}`) + out = out.replace(new RegExp(`(^|[^"\\w.])${esc(table)}(?![\\w"])`, 'g'), `$1"${table}"`) + }) + return out +} + +export interface DbQueryRunner { + executeQuery: (text: string) => Promise + explain: (text: string) => Promise +} + +/** Runs queries against one database and renders the result, editable when it safely can be. */ +export function createQueryRunner( + s: DbServer, db: string, names: string[], relationsReady: Promise, +): DbQueryRunner { + // Runs a query and returns the element with the result (table or text). + // Reused by the editor and by the "Run" button in the AI chat. + const executeQuery = async (text: string): Promise => { + if (isMongo(s)) return preResult(await invoke('db_docker_mongo_query', { ...target(s), db, script: text, ...creds(s) })) + if (isRedis(s)) return preResult(await invoke('db_docker_redis_command', { ...target(s), db, command: text, password: s.password ?? '' })) + const limited = withRowLimit(text) + // MySQL/MariaDB: with many tables the optimizer takes forever to find the + // optimal JOIN ORDER (combinatorial explosion during PLANNING, even if the + // query executes few rows). With depth=1 it plans greedily instantly. + // Postgres doesn't suffer from this. + const sql = isPg(s) ? pgFixIdents(limited, names) : `SET SESSION optimizer_search_depth=1; ${limited}` + const data = await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql, ...creds(s) }) + + // Enable editing when the query is a plain SELECT * FROM
    with no joins or aggregations. + // Pagination: offer "load more" when the query had no explicit LIMIT (withRowLimit added one). + const trimmedText = text.trim().replace(/;\s*$/, '') + const limitWasAdded = !/\blimit\b\s+\d/i.test(trimmedText) && /^(select|with)\b/i.test(trimmedText) + const loadMore = limitWasAdded + ? async (offset: number): Promise => { + const pageSql = `${trimmedText} LIMIT 200 OFFSET ${offset}` + const moreSql = isPg(s) ? pgFixIdents(pageSql, names) : `SET SESSION optimizer_search_depth=1; ${pageSql}` + const more = await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: moreSql, ...creds(s) }) + return more.rows + } + : undefined + + const simpleMatch = /^\s*select\s+\*\s+from\s+((?:"[^"]+"\."[^"]+"|"[^"]+"|`[^`]+`|\w+(?:\.\w+)*))\s*(?:limit\s+\d+\s*)?;?\s*$/i.exec(text.trim()) + if (simpleMatch) { + const rawTable = simpleMatch[1].replace(/["'`]/g, '') + const matched = names.find(n => n === rawTable || n.split('.').pop() === rawTable.split('.').pop()) + if (matched) { + try { + const [pk, allFks] = await Promise.all([ + invoke(sqlCmd(s, 'pk'), { ...target(s), db, table: matched, ...creds(s) }).catch(() => [] as string[]), + relationsReady.catch(() => [] as ForeignKey[]), + ]) + const pkIdx = pk.map(c => data.columns.indexOf(c)).filter(i => i >= 0) + const fkColMap = new Map() + allFks.filter(f => f.table === matched).forEach(f => fkColMap.set(f.column, { ref_table: f.ref_table, ref_column: f.ref_column })) + return renderResultTable(data, { s, db, table: matched, pkIdx, fkColMap }, loadMore) + } catch { /* fall through to read-only */ } + } + } + + return renderResultTable(data, undefined, loadMore) + } + + // EXPLAIN: asks the engine for the execution plan WITHOUT running the query. It's + // instant and reveals why a query is slow: which table is scanned in full + // (join type ALL, no index) and how many rows it estimates combining. + const explain = async (text: string): Promise => { + const raw = text.trim().replace(/;\s*$/, '') + // With many tables, MySQL/MariaDB takes so long to PLAN the JOIN order that + // even the EXPLAIN hangs. optimizer_search_depth=1 forces an immediate + // greedy plan: the diagnostic returns instead of blowing up. + const sql = isPg(s) + ? `EXPLAIN ${pgFixIdents(raw, names)}` + : `SET SESSION optimizer_search_depth=1; EXPLAIN ${raw}` + const plan = renderResultTable(await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql, ...creds(s) })) + const wrap = document.createElement('div') + wrap.append( + note(i18nT('db.executionPlanHighRowCountsOrTypeAll'), 'db-detail-hint'), + plan, + ) + return wrap + } + + return { executeQuery, explain } +} diff --git a/tests/panels/db/dbQueryExec.test.ts b/tests/panels/db/dbQueryExec.test.ts new file mode 100644 index 0000000..e824476 --- /dev/null +++ b/tests/panels/db/dbQueryExec.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { pgFixIdents, createQueryRunner } from '../../../src/panels/db/dbQueryExec' +import type { TableData } from '../../../src/panels/db/dbAccess' +import type { ForeignKey } from '../../../src/panels/db/queryBuilders' +import type { DbServer } from '../../../src/core/db/dbServer' + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const runner = (over: { s?: DbServer; names?: string[]; rels?: ForeignKey[] } = {}) => + createQueryRunner(over.s ?? server(), 'app', over.names ?? ['users'], Promise.resolve(over.rels ?? [])) + +const sqlOf = (call: number): string => (mocks.invoke.mock.calls[call][1] as { sql: string }).sql + +const rows = (over: Partial = {}): TableData => + ({ columns: ['id', 'name'], rows: [['1', 'ana']], ...over }) + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(rows()) +}) + +describe('pgFixIdents', () => { + it('leaves an all-lowercase name alone', () => { + expect(pgFixIdents('SELECT * FROM users', ['users'])).toBe('SELECT * FROM users') + }) + + it('splits a wrongly quoted schema.table into two quoted parts', () => { + expect(pgFixIdents('SELECT * FROM "public.client"', ['public.client'])) + .toBe('SELECT * FROM "public"."client"') + }) + + it('quotes a mixed-case table so Postgres does not lowercase it', () => { + expect(pgFixIdents('SELECT * FROM Client', ['public.Client'])) + .toBe('SELECT * FROM "Client"') + }) + + it('quotes a qualified mixed-case name in full', () => { + expect(pgFixIdents('SELECT * FROM public.Client', ['public.Client'])) + .toBe('SELECT * FROM "public"."Client"') + }) + + it('does not touch a name that is already quoted', () => { + expect(pgFixIdents('SELECT * FROM "Client"', ['public.Client'])) + .toBe('SELECT * FROM "Client"') + }) + + it('ignores names it does not know', () => { + expect(pgFixIdents('SELECT * FROM Unknown', ['public.Client'])) + .toBe('SELECT * FROM Unknown') + }) +}) + +describe('executeQuery dispatch', () => { + it('runs a mongosh script and shows the raw output', async () => { + mocks.invoke.mockResolvedValue(' two docs ') + const el = await runner({ s: server({ kind: 'mongodb' }) }).executeQuery('db.users.find()') + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_mongo_query', expect.objectContaining({ script: 'db.users.find()' })) + expect(el.textContent).toBe('two docs') + }) + + it('runs a redis-cli command and shows the raw output', async () => { + mocks.invoke.mockResolvedValue('OK') + const el = await runner({ s: server({ kind: 'redis', password: 'pw' }) }).executeQuery('GET k') + expect(mocks.invoke).toHaveBeenCalledWith('db_docker_redis_command', expect.objectContaining({ command: 'GET k', password: 'pw' })) + expect(el.textContent).toBe('OK') + }) +}) + +describe('executeQuery for SQL', () => { + it('caps the row count and pins the MySQL planner to a greedy plan', async () => { + await runner().executeQuery('SELECT * FROM users') + expect(sqlOf(0)).toContain('optimizer_search_depth=1') + expect(sqlOf(0).toLowerCase()).toContain('limit') + }) + + it('rewrites identifiers instead of pinning the planner on Postgres', async () => { + await runner({ s: server({ kind: 'postgres' }), names: ['public.Client'] }).executeQuery('SELECT * FROM public.Client') + expect(sqlOf(0)).not.toContain('optimizer_search_depth') + expect(sqlOf(0)).toContain('"public"."Client"') + }) + + it('returns a grid of the rows', async () => { + const el = await runner().executeQuery('SELECT id FROM users LIMIT 1') + expect(el.querySelectorAll('tbody tr')).toHaveLength(1) + }) +}) + +describe('editable results', () => { + it('makes a plain SELECT * of a known table editable', async () => { + mocks.invoke + .mockResolvedValueOnce(rows()) + .mockResolvedValueOnce(['id']) + const el = await runner().executeQuery('SELECT * FROM users') + expect(el.querySelector('.db-editable')).not.toBeNull() + }) + + it('leaves a join or a projection read-only', async () => { + const el = await runner().executeQuery('SELECT u.id FROM users u JOIN orders o ON o.user_id = u.id') + expect(el.querySelector('.db-editable')).toBeNull() + }) + + it('leaves a SELECT * of an unknown table read-only', async () => { + const el = await runner().executeQuery('SELECT * FROM ghosts') + expect(el.querySelector('.db-editable')).toBeNull() + }) + + it('offers no delete column when the primary key lookup fails', async () => { + mocks.invoke + .mockResolvedValueOnce(rows()) + .mockRejectedValueOnce(new Error('denied')) + const el = await runner().executeQuery('SELECT * FROM users') + // Without a primary key there is no way to address a row; the backend + // rejects an UPDATE with no WHERE, so only the delete column is dropped. + expect(el.querySelector('.db-row-actions')).toBeNull() + }) +}) + +describe('pagination of a capped query', () => { + const page = (n: number): TableData => ({ columns: ['id'], rows: Array.from({ length: n }, (_, i) => [String(i)]) }) + + it('pages a query the runner had to cap', async () => { + mocks.invoke.mockResolvedValue(page(200)) + const el = await runner({ names: ['ghosts'] }).executeQuery('SELECT id FROM ghosts') + const btn = el.querySelector('.db-load-more') as HTMLButtonElement + expect(btn).not.toBeNull() + btn.click() + await new Promise(r => setTimeout(r, 0)) + expect(sqlOf(1)).toContain('OFFSET 200') + }) + + it('does not page a query that already had its own LIMIT', async () => { + mocks.invoke.mockResolvedValue(page(200)) + const el = await runner({ names: ['ghosts'] }).executeQuery('SELECT id FROM ghosts LIMIT 200') + expect(el.querySelector('.db-load-more')).toBeNull() + }) +}) + +describe('explain', () => { + it('asks the engine for the plan without running the query', async () => { + await runner().explain('SELECT * FROM users') + expect(sqlOf(0)).toContain('EXPLAIN SELECT * FROM users') + }) + + it('drops a trailing semicolon before prefixing EXPLAIN', async () => { + await runner().explain('SELECT 1;') + expect(sqlOf(0)).toContain('EXPLAIN SELECT 1') + expect(sqlOf(0)).not.toContain('SELECT 1;') + }) + + it('fixes identifiers on Postgres and pins the planner on MySQL', async () => { + await runner({ s: server({ kind: 'postgres' }), names: ['public.Client'] }).explain('SELECT * FROM public.Client') + expect(sqlOf(0)).toBe('EXPLAIN SELECT * FROM "public"."Client"') + + mocks.invoke.mockClear() + await runner().explain('SELECT * FROM users') + expect(sqlOf(0)).toContain('optimizer_search_depth=1') + }) + + it('shows the plan under a hint on how to read it', async () => { + const el = await runner().explain('SELECT * FROM users') + expect(el.querySelector('.db-detail-hint')).not.toBeNull() + expect(el.querySelector('table')).not.toBeNull() + }) +}) From 48697d307bebc9dac07e87559671858666e04ba7 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:34:45 +0200 Subject: [PATCH 17/51] refactor: extracted AI query generation into dbQueryAi Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 82 +-------------- src/panels/db/dbQueryAi.ts | 106 ++++++++++++++++++++ tests/panels/db/dbQueryAi.test.ts | 159 ++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 79 deletions(-) create mode 100644 src/panels/db/dbQueryAi.ts create mode 100644 tests/panels/db/dbQueryAi.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 0a5440f..509df05 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -2,12 +2,11 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import { LISTABLE, type DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' -import { askAi, type AiQueryRunner, type AiTool } from '../../ui/askAi' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import { buildJoinPath, type Relation } from '../../core/db/joinPath' import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' import { - KIND_LABEL, isMongo, isPg, isRedis, sqlCmd, creds, target, + KIND_LABEL, isMongo, isRedis, sqlCmd, creds, target, fetchColumns, listDatabases, listTables, fetchRelations, type TableData, } from './dbAccess' @@ -19,6 +18,7 @@ import { renderDocs } from './dbDocsView' import { renderRedisValue } from './dbRedisView' import { createQueryHistory } from './dbQueryHistory' import { createQueryRunner } from './dbQueryExec' +import { createAiQueryButton } from './dbQueryAi' // Counter for unique datalist ids (several DB panels/views at once). let joinListSeq = 0 @@ -105,85 +105,9 @@ export function createDbPanel(): { element: HTMLElement } { runBtn.className = 'db-connect' runBtn.textContent = i18nT('db.runShortcut') - // (B) Generate the query with AI: sends the schema (tables + relations) to the - // chat and you describe in natural language what you want. - const aiBtn = document.createElement('button') - aiBtn.className = 'db-connect db-query-ai' - aiBtn.textContent = i18nT('db.generateWithAi') - aiBtn.addEventListener('click', async () => { - const noun = isMongo(s) ? i18nT('db.collections') : i18nT('db.tables') - const noun2 = isMongo(s) ? 'colecciones' : 'tablas' - const rels = await relationsReady - let schema = `Base de datos ${KIND_LABEL[s.kind]} "${db}".\n${noun}: ${names.join(', ')}.` - // Inline relations only if there are few; with many, the AI requests them via the tool. - if (rels.length && rels.length <= 50) { - schema += `\nRelaciones (FK): ${rels.map(f => `${f.table}.${f.column} → ${f.ref_table}.${f.ref_column}`).join('; ')}.` - } - const dialect = isMongo(s) - ? 'una consulta mongosh (usa $lookup para unir colecciones relacionadas)' - : isRedis(s) - ? 'un comando redis-cli' - : isPg(s) - ? 'una consulta SQL de PostgreSQL. IMPORTANTE: entrecomilla SIEMPRE los identificadores y CADA PARTE por separado: "esquema"."tabla" (NUNCA "esquema.tabla" con el punto dentro de las comillas). Ej.: FROM "public"."client"' - : 'una consulta SQL' - // The runner executes the query the AI writes against this DB. If it fails, it offers - // "Fix with AI": resends the query + the error so the model corrects it. - const runner: AiQueryRunner = async query => { - try { - return await executeQuery(query) - } catch (e) { - const err = String(e) - const wrap = document.createElement('div') - wrap.className = 'db-query-fix' - wrap.append(note(err, 'db-detail-error')) - const fixBtn = document.createElement('button') - fixBtn.className = 'db-connect db-query-ai' - fixBtn.textContent = i18nT('db.fixWithAi') - fixBtn.addEventListener('click', () => askAi( - `La consulta falló al ejecutarse. Corrígela (usa get_columns/get_relations si hace falta) y devuélvela lista para ejecutar.\n\nConsulta:\n${query}\n\nError:\n${err}`, - true, runner, tools, - )) - wrap.append(fixBtn) - return wrap - } - } - // Tools: the AI requests real columns and relations on demand (scales with many tables). - const arrayParam = (desc: string) => ({ - type: 'object', - properties: { tables: { type: 'array', items: { type: 'string' }, description: desc } }, - required: ['tables'], - }) - const tableDesc = `Nombres de ${noun2}${isPg(s) ? ' (formato schema.tabla)' : ''}` - const tools: AiTool[] = isRedis(s) ? [] : [ - { - name: 'get_columns', - schema: { type: 'function', function: { name: 'get_columns', description: `Columnas reales (nombre y tipo) de las ${noun2} indicadas. Úsalo antes de escribir la consulta.`, parameters: arrayParam(tableDesc) } }, - run: async args => { - const wanted = Array.isArray(args.tables) ? (args.tables as string[]).slice(0, 30) : [] - const parts = await Promise.all(wanted.map(async t => `${t}: ${(await fetchColumns(s, db, t)).join(', ') || '(desconocidas)'}`)) - return parts.join('\n') || '(sin columnas)' - }, - }, - { - name: 'get_relations', - schema: { type: 'function', function: { name: 'get_relations', description: `Relaciones (claves foráneas) que tocan las ${noun2} indicadas: por qué columnas unirlas (JOIN${isMongo(s) ? '/$lookup' : ''}).`, parameters: arrayParam(tableDesc) } }, - run: async args => { - const wanted = new Set(Array.isArray(args.tables) ? (args.tables as string[]) : []) - const relevant = rels.filter(f => wanted.has(f.table) || wanted.has(f.ref_table)) - return relevant.map(f => `${f.table}.${f.column} → ${f.ref_table}.${f.ref_column}`).join('\n') || '(sin relaciones para esas tablas)' - }, - }, - ] - const verb = isMongo(s) ? 'etapas $lookup' : 'los JOIN' - const fence = isMongo(s) ? '```js' : '```sql' - const guide = tools.length - ? ` Usa get_columns (columnas reales) y get_relations (claves foráneas) antes de responder. Une SOLO ${noun2} con una relación real (compruébalo con get_relations) y ordena ${verb} de modo que cada tabla referenciada ya se haya introducido antes. Si la petición implica varias ${noun2}, escribe la consulta COMPLETA; no te limites a un SELECT de una sola tabla. Devuelve SIEMPRE la consulta final dentro de un único bloque de código (${fence} … \`\`\`), sin indentarlo.` - : '' - askAi(`${schema}\n\nEscríbeme ${dialect} para: ${guide}`, false, runner, tools) - }) - const history = createQueryHistory(s, db, q => { editor.value = q; editor.focus() }) const { executeQuery, explain } = createQueryRunner(s, db, names, relationsReady) + const aiBtn = createAiQueryButton({ s, db, names, relationsReady, executeQuery, fetchColumns }) const actions = document.createElement('div') actions.className = 'db-query-actions' diff --git a/src/panels/db/dbQueryAi.ts b/src/panels/db/dbQueryAi.ts new file mode 100644 index 0000000..3593adc --- /dev/null +++ b/src/panels/db/dbQueryAi.ts @@ -0,0 +1,106 @@ +import { t as i18nT } from '../../i18n' +import type { DbServer } from '../../core/db/dbServer' +import { askAi, type AiQueryRunner, type AiTool } from '../../ui/askAi' +import type { ForeignKey } from './queryBuilders' +import { KIND_LABEL, isMongo, isPg, isRedis } from './dbAccess' +import { note } from './dbWidgets' + +export interface AiQueryButtonDeps { + s: DbServer + db: string + names: string[] + relationsReady: Promise + executeQuery: (query: string) => Promise + fetchColumns: (s: DbServer, db: string, table: string) => Promise +} + +// Inline relations only up to this many; beyond it the AI asks for them with the tool. +const INLINE_RELATIONS_CAP = 50 +// One column lookup may not fan out further than this. +const COLUMN_LOOKUP_CAP = 30 + +/** + * Generate the query with AI: sends the schema (tables + relations) to the chat + * and you describe in natural language what you want. The AI's query runs against + * this database, and a failure offers "Fix with AI" with the error attached. + */ +export function createAiQueryButton(deps: AiQueryButtonDeps): HTMLButtonElement { + const { s, db, names, relationsReady, executeQuery, fetchColumns } = deps + + const aiBtn = document.createElement('button') + aiBtn.className = 'db-connect db-query-ai' + aiBtn.textContent = i18nT('db.generateWithAi') + aiBtn.addEventListener('click', async () => { + const noun = isMongo(s) ? i18nT('db.collections') : i18nT('db.tables') + const noun2 = isMongo(s) ? 'colecciones' : 'tablas' + const rels = await relationsReady + let schema = `Base de datos ${KIND_LABEL[s.kind]} "${db}".\n${noun}: ${names.join(', ')}.` + // Inline relations only if there are few; with many, the AI requests them via the tool. + if (rels.length && rels.length <= INLINE_RELATIONS_CAP) { + schema += `\nRelaciones (FK): ${rels.map(f => `${f.table}.${f.column} → ${f.ref_table}.${f.ref_column}`).join('; ')}.` + } + const dialect = isMongo(s) + ? 'una consulta mongosh (usa $lookup para unir colecciones relacionadas)' + : isRedis(s) + ? 'un comando redis-cli' + : isPg(s) + ? 'una consulta SQL de PostgreSQL. IMPORTANTE: entrecomilla SIEMPRE los identificadores y CADA PARTE por separado: "esquema"."tabla" (NUNCA "esquema.tabla" con el punto dentro de las comillas). Ej.: FROM "public"."client"' + : 'una consulta SQL' + // The runner executes the query the AI writes against this DB. If it fails, it offers + // "Fix with AI": resends the query + the error so the model corrects it. + const runner: AiQueryRunner = async query => { + try { + return await executeQuery(query) + } catch (e) { + const err = String(e) + const wrap = document.createElement('div') + wrap.className = 'db-query-fix' + wrap.append(note(err, 'db-detail-error')) + const fixBtn = document.createElement('button') + fixBtn.className = 'db-connect db-query-ai' + fixBtn.textContent = i18nT('db.fixWithAi') + fixBtn.addEventListener('click', () => askAi( + `La consulta falló al ejecutarse. Corrígela (usa get_columns/get_relations si hace falta) y devuélvela lista para ejecutar.\n\nConsulta:\n${query}\n\nError:\n${err}`, + true, runner, tools, + )) + wrap.append(fixBtn) + return wrap + } + } + // Tools: the AI requests real columns and relations on demand (scales with many tables). + const arrayParam = (desc: string) => ({ + type: 'object', + properties: { tables: { type: 'array', items: { type: 'string' }, description: desc } }, + required: ['tables'], + }) + const tableDesc = `Nombres de ${noun2}${isPg(s) ? ' (formato schema.tabla)' : ''}` + const tools: AiTool[] = isRedis(s) ? [] : [ + { + name: 'get_columns', + schema: { type: 'function', function: { name: 'get_columns', description: `Columnas reales (nombre y tipo) de las ${noun2} indicadas. Úsalo antes de escribir la consulta.`, parameters: arrayParam(tableDesc) } }, + run: async args => { + const wanted = Array.isArray(args.tables) ? (args.tables as string[]).slice(0, COLUMN_LOOKUP_CAP) : [] + const parts = await Promise.all(wanted.map(async t => `${t}: ${(await fetchColumns(s, db, t)).join(', ') || '(desconocidas)'}`)) + return parts.join('\n') || '(sin columnas)' + }, + }, + { + name: 'get_relations', + schema: { type: 'function', function: { name: 'get_relations', description: `Relaciones (claves foráneas) que tocan las ${noun2} indicadas: por qué columnas unirlas (JOIN${isMongo(s) ? '/$lookup' : ''}).`, parameters: arrayParam(tableDesc) } }, + run: async args => { + const wanted = new Set(Array.isArray(args.tables) ? (args.tables as string[]) : []) + const relevant = rels.filter(f => wanted.has(f.table) || wanted.has(f.ref_table)) + return relevant.map(f => `${f.table}.${f.column} → ${f.ref_table}.${f.ref_column}`).join('\n') || '(sin relaciones para esas tablas)' + }, + }, + ] + const verb = isMongo(s) ? 'etapas $lookup' : 'los JOIN' + const fence = isMongo(s) ? '```js' : '```sql' + const guide = tools.length + ? ` Usa get_columns (columnas reales) y get_relations (claves foráneas) antes de responder. Une SOLO ${noun2} con una relación real (compruébalo con get_relations) y ordena ${verb} de modo que cada tabla referenciada ya se haya introducido antes. Si la petición implica varias ${noun2}, escribe la consulta COMPLETA; no te limites a un SELECT de una sola tabla. Devuelve SIEMPRE la consulta final dentro de un único bloque de código (${fence} … \`\`\`), sin indentarlo.` + : '' + askAi(`${schema}\n\nEscríbeme ${dialect} para: ${guide}`, false, runner, tools) + }) + + return aiBtn +} diff --git a/tests/panels/db/dbQueryAi.test.ts b/tests/panels/db/dbQueryAi.test.ts new file mode 100644 index 0000000..6ae0c3f --- /dev/null +++ b/tests/panels/db/dbQueryAi.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + askAi: vi.fn(), + fetchColumns: vi.fn(async () => [] as string[]), +})) + +vi.mock('../../../src/ui/askAi', () => ({ askAi: mocks.askAi })) + +import { createAiQueryButton } from '../../../src/panels/db/dbQueryAi' +import type { ForeignKey } from '../../../src/panels/db/queryBuilders' +import type { AiTool, AiQueryRunner } from '../../../src/ui/askAi' +import type { DbServer } from '../../../src/core/db/dbServer' + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const fk = (table: string, column: string, refTable: string): ForeignKey => + ({ table, column, ref_table: refTable, ref_column: 'id' }) + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +function button(over: { + s?: DbServer + names?: string[] + rels?: ForeignKey[] + executeQuery?: (q: string) => Promise +} = {}): HTMLButtonElement { + return createAiQueryButton({ + s: over.s ?? server(), + db: 'app', + names: over.names ?? ['users', 'orders'], + relationsReady: Promise.resolve(over.rels ?? []), + executeQuery: over.executeQuery ?? (async () => document.createElement('div')), + fetchColumns: mocks.fetchColumns, + }) +} + +const click = async (btn: HTMLButtonElement): Promise => { btn.click(); await flush() } + +const prompt = (): string => mocks.askAi.mock.calls[0][0] as string +const tools = (): AiTool[] => mocks.askAi.mock.calls[0][3] as AiTool[] +const runner = (): AiQueryRunner => mocks.askAi.mock.calls[0][2] as AiQueryRunner + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.askAi.mockReset() + mocks.fetchColumns.mockReset() + mocks.fetchColumns.mockResolvedValue([]) +}) + +describe('schema in the prompt', () => { + it('names the engine, the database and its tables', async () => { + await click(button()) + expect(prompt()).toContain('MySQL') + expect(prompt()).toContain('"app"') + expect(prompt()).toContain('users, orders') + }) + + it('inlines a small set of relations', async () => { + await click(button({ rels: [fk('orders', 'user_id', 'users')] })) + expect(prompt()).toContain('orders.user_id → users.id') + }) + + it('leaves a large set of relations to the tool instead of inlining them', async () => { + const many = Array.from({ length: 51 }, (_, i) => fk(`t${i}`, 'c', 'users')) + await click(button({ rels: many })) + expect(prompt()).not.toContain('t50.c → users.id') + }) +}) + +describe('dialect per engine', () => { + it('asks for mongosh with $lookup on Mongo', async () => { + await click(button({ s: server({ kind: 'mongodb' }) })) + expect(prompt()).toContain('mongosh') + expect(prompt()).toContain('$lookup') + }) + + it('asks for a redis-cli command on Redis', async () => { + await click(button({ s: server({ kind: 'redis' }) })) + expect(prompt()).toContain('redis-cli') + }) + + it('spells out the Postgres quoting rule', async () => { + await click(button({ s: server({ kind: 'postgres' }) })) + expect(prompt()).toContain('"esquema"."tabla"') + }) +}) + +describe('tools', () => { + it('offers column and relation lookups on SQL and Mongo', async () => { + await click(button()) + expect(tools().map(t => t.name)).toEqual(['get_columns', 'get_relations']) + }) + + it('offers no tools on Redis and drops the tool guidance from the prompt', async () => { + await click(button({ s: server({ kind: 'redis' }) })) + expect(tools()).toEqual([]) + expect(prompt()).not.toContain('get_columns') + }) + + it('reads real columns for the requested tables', async () => { + mocks.fetchColumns.mockResolvedValue(['id (int)']) + await click(button()) + const out = await tools()[0].run({ tables: ['users'] }) + expect(out).toContain('users: id (int)') + }) + + it('says so when a table has no columns to report', async () => { + await click(button()) + expect(await tools()[0].run({ tables: ['ghosts'] })).toContain('desconocidas') + }) + + it('caps how many tables one column lookup may ask about', async () => { + await click(button()) + await tools()[0].run({ tables: Array.from({ length: 40 }, (_, i) => `t${i}`) }) + expect(mocks.fetchColumns).toHaveBeenCalledTimes(30) + }) + + it('ignores a malformed tool argument instead of throwing', async () => { + await click(button()) + expect(await tools()[0].run({ tables: 'not an array' })).toContain('sin columnas') + expect(await tools()[1].run({})).toContain('sin relaciones') + }) + + it('returns only the relations touching the requested tables', async () => { + await click(button({ rels: [fk('orders', 'user_id', 'users'), fk('items', 'sku', 'products')] })) + const out = await tools()[1].run({ tables: ['users'] }) + expect(out).toContain('orders.user_id → users.id') + expect(out).not.toContain('items.sku') + }) +}) + +describe('running what the AI wrote', () => { + it('hands back the result element when the query works', async () => { + const result = document.createElement('table') + await click(button({ executeQuery: async () => result })) + expect(await runner()('SELECT 1')).toBe(result) + }) + + it('shows the error with a fix-with-AI button when the query fails', async () => { + await click(button({ executeQuery: async () => { throw new Error('unknown column x') } })) + const el = await runner()('SELECT x FROM users') + expect(el.textContent).toContain('unknown column x') + expect(el.querySelector('.db-connect')).not.toBeNull() + }) + + it('resends the failed query and its error when fix-with-AI is used', async () => { + await click(button({ executeQuery: async () => { throw new Error('unknown column x') } })) + const el = await runner()('SELECT x FROM users') + ;(el.querySelector('.db-connect') as HTMLButtonElement).click() + const retry = mocks.askAi.mock.calls[1][0] as string + expect(retry).toContain('SELECT x FROM users') + expect(retry).toContain('unknown column x') + }) +}) From 2982219e9fcf4aada258bb25cbfad8a7eac29cb9 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:36:21 +0200 Subject: [PATCH 18/51] refactor: extracted the JOIN builder and query chips into their own modules Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 158 ++------------------------ src/panels/db/dbJoinBuilder.ts | 76 +++++++++++++ src/panels/db/dbQueryChips.ts | 114 +++++++++++++++++++ tests/panels/db/dbJoinBuilder.test.ts | 132 +++++++++++++++++++++ tests/panels/db/dbQueryChips.test.ts | 118 +++++++++++++++++++ 5 files changed, 449 insertions(+), 149 deletions(-) create mode 100644 src/panels/db/dbJoinBuilder.ts create mode 100644 src/panels/db/dbQueryChips.ts create mode 100644 tests/panels/db/dbJoinBuilder.test.ts create mode 100644 tests/panels/db/dbQueryChips.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 509df05..4a7abdd 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -3,8 +3,7 @@ import { invoke } from '@tauri-apps/api/core' import { LISTABLE, type DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' -import { buildJoinPath, type Relation } from '../../core/db/joinPath' -import { buildJoinQuery, buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' +import type { ForeignKey } from './queryBuilders' import { KIND_LABEL, isMongo, isRedis, sqlCmd, creds, target, fetchColumns, listDatabases, listTables, fetchRelations, @@ -19,9 +18,8 @@ import { renderRedisValue } from './dbRedisView' import { createQueryHistory } from './dbQueryHistory' import { createQueryRunner } from './dbQueryExec' import { createAiQueryButton } from './dbQueryAi' - -// Counter for unique datalist ids (several DB panels/views at once). -let joinListSeq = 0 +import { createJoinBuilder } from './dbJoinBuilder' +import { createQueryChips } from './dbQueryChips' export function createDbPanel(): { element: HTMLElement } { const root = document.createElement('div') @@ -108,157 +106,19 @@ export function createDbPanel(): { element: HTMLElement } { const history = createQueryHistory(s, db, q => { editor.value = q; editor.focus() }) const { executeQuery, explain } = createQueryRunner(s, db, names, relationsReady) const aiBtn = createAiQueryButton({ s, db, names, relationsReady, executeQuery, fetchColumns }) + const joinBuilder = createJoinBuilder({ + s, names, getRelations: () => relations, relationsReady, + onBuild: q => { editor.value = q; editor.focus() }, + }) + const chips = createQueryChips({ s, names, relationsReady, onPick: q => { editor.value = q; editor.focus() } }) const actions = document.createElement('div') actions.className = 'db-query-actions' actions.append(runBtn, aiBtn, history.element) - // Deterministic JOIN builder (no AI): you pick tables and Bento finds the - // JOIN path through the foreign keys. SQL only. - const joinBuilder = document.createElement('div') - joinBuilder.className = 'db-join-builder' - if (!isMongo(s) && !isRedis(s)) { - const picked: string[] = [] - const jLabel = document.createElement('span') - jLabel.className = 'db-query-examples-label' - jLabel.textContent = i18nT('db.joinTables') - const jChips = document.createElement('span') - jChips.className = 'db-join-chips' - const jAdd = document.createElement('input') - jAdd.className = 'db-join-add' - jAdd.placeholder = i18nT('db.addTable') - const listId = `db-join-list-${++joinListSeq}` - jAdd.setAttribute('list', listId) - const jList = document.createElement('datalist') - jList.id = listId - names.forEach(n => { const o = document.createElement('option'); o.value = n; jList.appendChild(o) }) - const jBuild = document.createElement('button') - jBuild.className = 'db-connect' - jBuild.textContent = i18nT('db.buildJoin') - const jMsg = document.createElement('span') - jMsg.className = 'db-join-msg' - - const renderPicked = (): void => { - jChips.replaceChildren() - picked.forEach(t => { - const c = document.createElement('button') - c.className = 'db-query-chip db-query-chip-rel' - c.textContent = `${t} ✕` - c.title = i18nT('common.remove') - c.addEventListener('click', () => { picked.splice(picked.indexOf(t), 1); renderPicked() }) - jChips.appendChild(c) - }) - } - jAdd.addEventListener('change', () => { - const v = jAdd.value.trim() - if (v && names.includes(v) && !picked.includes(v)) { picked.push(v); renderPicked() } - jAdd.value = '' - }) - jBuild.addEventListener('click', async () => { - jMsg.textContent = '' - if (!picked.length) return - await relationsReady - const rels: Relation[] = relations.map(f => ({ table: f.table, column: f.column, refTable: f.ref_table, refColumn: f.ref_column })) - const plan = buildJoinPath(picked, rels) - if (!plan) { jMsg.textContent = i18nT('db.thoseTablesAreNotConnectedByTheirRelationships'); return } - editor.value = buildJoinQuery(s, plan) - editor.focus() - }) - joinBuilder.append(jLabel, jChips, jAdd, jList, jBuild, jMsg) - } - - // Filtered search + group toggle. DATA-DRIVEN render with a CAP: a large DB - // has thousands of tables/relations and painting them all as buttons (each - // with a listener) froze the UI. We paint at most CHIP_CAP and the filter - // re-renders the matches from the whole list. - type Group = 'all' | 'table' | 'rel' - interface ChipItem { group: 'table' | 'rel'; label: string; title: string; fill: () => string } - const CHIP_CAP = 200 - let activeGroup: Group = 'all' - const chipItems: ChipItem[] = names.map(name => ({ - group: 'table', label: name, title: i18nT('db.insertExampleQuery'), fill: () => exampleQuery(s, name), - })) - - const filter = document.createElement('input') - filter.className = 'db-query-filter' - filter.placeholder = i18nT('db.filterTablesRelationships') - filter.spellcheck = false - - const examples = document.createElement('div') - examples.className = 'db-query-examples' - - const groupLabel = (g: 'table' | 'rel'): string => - g === 'rel' ? i18nT('db.relationsLabel') : isRedis(s) ? i18nT('db.keysLabel') : isMongo(s) ? i18nT('db.collectionsLabel') : i18nT('db.tablesLabel') - - const renderChips = (): void => { - const q = filter.value.trim().toLowerCase() - const matches = chipItems.filter(it => - (activeGroup === 'all' || it.group === activeGroup) && (!q || it.label.toLowerCase().includes(q))) - examples.replaceChildren() - let lastGroup = '' - matches.slice(0, CHIP_CAP).forEach(it => { - if (it.group !== lastGroup) { - lastGroup = it.group - const lbl = document.createElement('span') - lbl.className = 'db-query-examples-label' - lbl.textContent = groupLabel(it.group) - examples.appendChild(lbl) - } - const chip = document.createElement('button') - chip.className = it.group === 'rel' ? 'db-query-chip db-query-chip-rel' : 'db-query-chip' - chip.textContent = it.label - chip.title = it.title - chip.addEventListener('click', () => { editor.value = it.fill(); editor.focus() }) - examples.appendChild(chip) - }) - if (matches.length > CHIP_CAP) { - examples.appendChild(note(i18nT('db.moreResults', { count: matches.length - CHIP_CAP }), 'db-detail-hint')) - } - } - filter.addEventListener('input', renderChips) - - const toggle = document.createElement('div') - toggle.className = 'db-query-toggle' - if (!isRedis(s)) { - const groups: Array<[Group, string]> = [ - ['all', i18nT('db.allGroup')], - ['table', isMongo(s) ? i18nT('db.collections') : i18nT('db.tables')], - ['rel', i18nT('db.relationsLabel')], - ] - groups.forEach(([g, label]) => { - const b = document.createElement('button') - b.className = g === 'all' ? 'db-query-toggle-btn active' : 'db-query-toggle-btn' - b.textContent = label - b.addEventListener('click', () => { - activeGroup = g - toggle.querySelectorAll('.db-query-toggle-btn').forEach(x => x.classList.remove('active')) - b.classList.add('active') - renderChips() - }) - toggle.appendChild(b) - }) - } - - renderChips() - - // Relations (grouped by table) as additional items, after the FKs load. - if (!isRedis(s)) { - relationsReady.then(rels => { - ;[...groupRelations(rels).entries()].forEach(([table, fks]) => { - chipItems.push({ - group: 'rel', - label: `${table} ▸ ${fks.map(f => f.ref_table).join(', ')}`, - title: fks.map(f => `${f.table}.${f.column} → ${f.ref_table}.${f.ref_column}`).join('\n'), - fill: () => buildRelationQuery(s, table, fks), - }) - }) - renderChips() - }).catch(() => {}) - } - const bar = document.createElement('div') bar.className = 'db-query-bar' - bar.append(editor, actions, joinBuilder, filter, toggle, examples) + bar.append(editor, actions, joinBuilder, chips) const resultArea = document.createElement('div') resultArea.className = 'db-grid-scroll' diff --git a/src/panels/db/dbJoinBuilder.ts b/src/panels/db/dbJoinBuilder.ts new file mode 100644 index 0000000..1f9ddde --- /dev/null +++ b/src/panels/db/dbJoinBuilder.ts @@ -0,0 +1,76 @@ +import { t as i18nT } from '../../i18n' +import type { DbServer } from '../../core/db/dbServer' +import { buildJoinPath, type Relation } from '../../core/db/joinPath' +import { buildJoinQuery, type ForeignKey } from './queryBuilders' +import { isMongo, isRedis } from './dbAccess' + +// Unique datalist ids: several DB panels can be open at once. +let joinListSeq = 0 + +export interface JoinBuilderDeps { + s: DbServer + names: string[] + getRelations: () => ForeignKey[] + relationsReady: Promise + onBuild: (query: string) => void +} + +/** + * Deterministic JOIN builder (no AI): you pick tables and Bento finds the JOIN + * path through the foreign keys. SQL only — Mongo and Redis get an empty node. + */ +export function createJoinBuilder(deps: JoinBuilderDeps): HTMLElement { + const { s, names, getRelations, relationsReady, onBuild } = deps + const joinBuilder = document.createElement('div') + joinBuilder.className = 'db-join-builder' + if (!isMongo(s) && !isRedis(s)) { + const picked: string[] = [] + const jLabel = document.createElement('span') + jLabel.className = 'db-query-examples-label' + jLabel.textContent = i18nT('db.joinTables') + const jChips = document.createElement('span') + jChips.className = 'db-join-chips' + const jAdd = document.createElement('input') + jAdd.className = 'db-join-add' + jAdd.placeholder = i18nT('db.addTable') + const listId = `db-join-list-${++joinListSeq}` + jAdd.setAttribute('list', listId) + const jList = document.createElement('datalist') + jList.id = listId + names.forEach(n => { const o = document.createElement('option'); o.value = n; jList.appendChild(o) }) + const jBuild = document.createElement('button') + jBuild.className = 'db-connect' + jBuild.textContent = i18nT('db.buildJoin') + const jMsg = document.createElement('span') + jMsg.className = 'db-join-msg' + + const renderPicked = (): void => { + jChips.replaceChildren() + picked.forEach(t => { + const c = document.createElement('button') + c.className = 'db-query-chip db-query-chip-rel' + c.textContent = `${t} ✕` + c.title = i18nT('common.remove') + c.addEventListener('click', () => { picked.splice(picked.indexOf(t), 1); renderPicked() }) + jChips.appendChild(c) + }) + } + jAdd.addEventListener('change', () => { + const v = jAdd.value.trim() + if (v && names.includes(v) && !picked.includes(v)) { picked.push(v); renderPicked() } + jAdd.value = '' + }) + jBuild.addEventListener('click', async () => { + jMsg.textContent = '' + if (!picked.length) return + await relationsReady + const rels: Relation[] = getRelations().map(f => ({ table: f.table, column: f.column, refTable: f.ref_table, refColumn: f.ref_column })) + const plan = buildJoinPath(picked, rels) + if (!plan) { jMsg.textContent = i18nT('db.thoseTablesAreNotConnectedByTheirRelationships'); return } + onBuild(buildJoinQuery(s, plan)) + }) + joinBuilder.append(jLabel, jChips, jAdd, jList, jBuild, jMsg) + } + + return joinBuilder +} diff --git a/src/panels/db/dbQueryChips.ts b/src/panels/db/dbQueryChips.ts new file mode 100644 index 0000000..97e0e8e --- /dev/null +++ b/src/panels/db/dbQueryChips.ts @@ -0,0 +1,114 @@ +import { t as i18nT } from '../../i18n' +import type { DbServer } from '../../core/db/dbServer' +import { buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' +import { isMongo, isRedis } from './dbAccess' +import { note } from './dbWidgets' + +// A large DB has thousands of tables/relations; painting them all as buttons +// (each with a listener) froze the UI. We paint at most this many and let the +// filter re-render the matches from the whole list. +export const CHIP_CAP = 200 + +export interface QueryChipsDeps { + s: DbServer + names: string[] + relationsReady: Promise + onPick: (query: string) => void +} + +/** Filterable table and relation chips that fill the editor with an example query. */ +export function createQueryChips(deps: QueryChipsDeps): HTMLElement { + const { s, names, relationsReady, onPick } = deps + // Filtered search + group toggle. DATA-DRIVEN render with a CAP: a large DB + // has thousands of tables/relations and painting them all as buttons (each + // with a listener) froze the UI. We paint at most CHIP_CAP and the filter + // re-renders the matches from the whole list. + type Group = 'all' | 'table' | 'rel' + interface ChipItem { group: 'table' | 'rel'; label: string; title: string; fill: () => string } + let activeGroup: Group = 'all' + const chipItems: ChipItem[] = names.map(name => ({ + group: 'table', label: name, title: i18nT('db.insertExampleQuery'), fill: () => exampleQuery(s, name), + })) + + const filter = document.createElement('input') + filter.className = 'db-query-filter' + filter.placeholder = i18nT('db.filterTablesRelationships') + filter.spellcheck = false + + const examples = document.createElement('div') + examples.className = 'db-query-examples' + + const groupLabel = (g: 'table' | 'rel'): string => + g === 'rel' ? i18nT('db.relationsLabel') : isRedis(s) ? i18nT('db.keysLabel') : isMongo(s) ? i18nT('db.collectionsLabel') : i18nT('db.tablesLabel') + + const renderChips = (): void => { + const q = filter.value.trim().toLowerCase() + const matches = chipItems.filter(it => + (activeGroup === 'all' || it.group === activeGroup) && (!q || it.label.toLowerCase().includes(q))) + examples.replaceChildren() + let lastGroup = '' + matches.slice(0, CHIP_CAP).forEach(it => { + if (it.group !== lastGroup) { + lastGroup = it.group + const lbl = document.createElement('span') + lbl.className = 'db-query-examples-label' + lbl.textContent = groupLabel(it.group) + examples.appendChild(lbl) + } + const chip = document.createElement('button') + chip.className = it.group === 'rel' ? 'db-query-chip db-query-chip-rel' : 'db-query-chip' + chip.textContent = it.label + chip.title = it.title + chip.addEventListener('click', () => onPick(it.fill())) + examples.appendChild(chip) + }) + if (matches.length > CHIP_CAP) { + examples.appendChild(note(i18nT('db.moreResults', { count: matches.length - CHIP_CAP }), 'db-detail-hint')) + } + } + filter.addEventListener('input', renderChips) + + const toggle = document.createElement('div') + toggle.className = 'db-query-toggle' + if (!isRedis(s)) { + const groups: Array<[Group, string]> = [ + ['all', i18nT('db.allGroup')], + ['table', isMongo(s) ? i18nT('db.collections') : i18nT('db.tables')], + ['rel', i18nT('db.relationsLabel')], + ] + groups.forEach(([g, label]) => { + const b = document.createElement('button') + b.className = g === 'all' ? 'db-query-toggle-btn active' : 'db-query-toggle-btn' + b.textContent = label + b.addEventListener('click', () => { + activeGroup = g + toggle.querySelectorAll('.db-query-toggle-btn').forEach(x => x.classList.remove('active')) + b.classList.add('active') + renderChips() + }) + toggle.appendChild(b) + }) + } + + renderChips() + + // Relations (grouped by table) as additional items, after the FKs load. + if (!isRedis(s)) { + relationsReady.then(rels => { + ;[...groupRelations(rels).entries()].forEach(([table, fks]) => { + chipItems.push({ + group: 'rel', + label: `${table} ▸ ${fks.map(f => f.ref_table).join(', ')}`, + title: fks.map(f => `${f.table}.${f.column} → ${f.ref_table}.${f.ref_column}`).join('\n'), + fill: () => buildRelationQuery(s, table, fks), + }) + }) + renderChips() + }).catch(() => {}) + } + + const element = document.createElement('div') + element.className = 'db-query-chips' + element.append(filter, toggle, examples) + return element +} diff --git a/tests/panels/db/dbJoinBuilder.test.ts b/tests/panels/db/dbJoinBuilder.test.ts new file mode 100644 index 0000000..6751694 --- /dev/null +++ b/tests/panels/db/dbJoinBuilder.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { createJoinBuilder } from '../../../src/panels/db/dbJoinBuilder' +import type { ForeignKey } from '../../../src/panels/db/queryBuilders' +import type { DbServer } from '../../../src/core/db/dbServer' + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const fk = (table: string, column: string, refTable: string): ForeignKey => + ({ table, column, ref_table: refTable, ref_column: 'id' }) + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +const NAMES = ['users', 'orders', 'products'] + +function builder(over: { s?: DbServer; rels?: ForeignKey[] } = {}) { + const onBuild = vi.fn() + const el = createJoinBuilder({ + s: over.s ?? server(), + names: NAMES, + getRelations: () => over.rels ?? [], + relationsReady: Promise.resolve(over.rels ?? []), + onBuild, + }) + document.body.replaceChildren(el) + return { el, onBuild } +} + +const pick = (el: HTMLElement, value: string): void => { + const input = el.querySelector('.db-join-add') as HTMLInputElement + input.value = value + input.dispatchEvent(new Event('change')) +} + +const chips = (el: HTMLElement): string[] => + [...el.querySelectorAll('.db-join-chips button')].map(b => b.textContent ?? '') + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') +}) + +describe('availability', () => { + it('is empty on Mongo and Redis, where there are no foreign keys to walk', () => { + expect(builder({ s: server({ kind: 'mongodb' }) }).el.children).toHaveLength(0) + expect(builder({ s: server({ kind: 'redis' }) }).el.children).toHaveLength(0) + }) + + it('offers every table as an autocomplete option on SQL', () => { + const { el } = builder() + expect([...el.querySelectorAll('datalist option')].map(o => (o as HTMLOptionElement).value)).toEqual(NAMES) + }) + + it('gives each builder its own datalist so two panels do not collide', () => { + const a = builder().el.querySelector('datalist')!.id + const b = builder().el.querySelector('datalist')!.id + expect(a).not.toBe(b) + }) +}) + +describe('picking tables', () => { + it('adds a chip per picked table and clears the box', () => { + const { el } = builder() + pick(el, 'users') + expect(chips(el)[0]).toContain('users') + expect((el.querySelector('.db-join-add') as HTMLInputElement).value).toBe('') + }) + + it('ignores an unknown table and a repeated one', () => { + const { el } = builder() + pick(el, 'ghosts') + pick(el, 'users') + pick(el, 'users') + expect(chips(el)).toHaveLength(1) + }) + + it('removes a table when its chip is clicked', () => { + const { el } = builder() + pick(el, 'users') + ;(el.querySelector('.db-join-chips button') as HTMLButtonElement).click() + expect(chips(el)).toHaveLength(0) + }) +}) + +describe('building the query', () => { + const build = (el: HTMLElement): void => { (el.querySelector('.db-connect') as HTMLButtonElement).click() } + + it('does nothing when no table was picked', async () => { + const { el, onBuild } = builder() + build(el) + await flush() + expect(onBuild).not.toHaveBeenCalled() + }) + + it('hands back a JOIN query for connected tables', async () => { + const { el, onBuild } = builder({ rels: [fk('orders', 'user_id', 'users')] }) + pick(el, 'users') + pick(el, 'orders') + build(el) + await flush() + expect(onBuild).toHaveBeenCalledTimes(1) + const sql = onBuild.mock.calls[0][0] as string + expect(sql.toLowerCase()).toContain('join') + expect(sql).toContain('orders') + expect(sql).toContain('users') + }) + + it('explains that unconnected tables cannot be joined, and builds nothing', async () => { + const { el, onBuild } = builder({ rels: [fk('orders', 'user_id', 'users')] }) + pick(el, 'users') + pick(el, 'products') + build(el) + await flush() + expect(onBuild).not.toHaveBeenCalled() + expect(el.querySelector('.db-join-msg')!.textContent).not.toBe('') + }) + + it('clears a previous message on the next attempt', async () => { + const { el } = builder({ rels: [fk('orders', 'user_id', 'users')] }) + pick(el, 'users') + pick(el, 'products') + build(el) + await flush() + ;(el.querySelectorAll('.db-join-chips button')[1] as HTMLButtonElement).click() + pick(el, 'orders') + build(el) + await flush() + expect(el.querySelector('.db-join-msg')!.textContent).toBe('') + }) +}) diff --git a/tests/panels/db/dbQueryChips.test.ts b/tests/panels/db/dbQueryChips.test.ts new file mode 100644 index 0000000..a50a809 --- /dev/null +++ b/tests/panels/db/dbQueryChips.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { createQueryChips, CHIP_CAP } from '../../../src/panels/db/dbQueryChips' +import type { ForeignKey } from '../../../src/panels/db/queryBuilders' +import type { DbServer } from '../../../src/core/db/dbServer' + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const fk = (table: string, column: string, refTable: string): ForeignKey => + ({ table, column, ref_table: refTable, ref_column: 'id' }) + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +function chips(over: { s?: DbServer; names?: string[]; rels?: ForeignKey[] } = {}) { + const onPick = vi.fn() + const el = createQueryChips({ + s: over.s ?? server(), + names: over.names ?? ['users', 'orders'], + relationsReady: Promise.resolve(over.rels ?? []), + onPick, + }) + document.body.replaceChildren(el) + return { el, onPick } +} + +const labels = (el: HTMLElement): string[] => + [...el.querySelectorAll('.db-query-chip')].map(c => c.textContent ?? '') + +const typeFilter = (el: HTMLElement, q: string): void => { + const input = el.querySelector('.db-query-filter') as HTMLInputElement + input.value = q + input.dispatchEvent(new Event('input')) +} + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') +}) + +describe('table chips', () => { + it('shows one chip per table', () => { + expect(labels(chips().el)).toEqual(['users', 'orders']) + }) + + it('hands back an example query when a chip is clicked', () => { + const { el, onPick } = chips() + ;(el.querySelector('.db-query-chip') as HTMLButtonElement).click() + expect(onPick).toHaveBeenCalledTimes(1) + expect(onPick.mock.calls[0][0]).toContain('users') + }) + + it('caps how many chips it paints and says how many were left out', () => { + const names = Array.from({ length: CHIP_CAP + 7 }, (_, i) => `t${i}`) + const { el } = chips({ names }) + expect(labels(el)).toHaveLength(CHIP_CAP) + expect(el.querySelector('.db-detail-hint')!.textContent).toContain('7') + }) +}) + +describe('relation chips', () => { + it('adds a chip per table once the relations arrive', async () => { + const { el } = chips({ rels: [fk('orders', 'user_id', 'users')] }) + await flush() + expect(labels(el).some(l => l.includes('orders') && l.includes('users'))).toBe(true) + }) + + it('describes the joining columns in the tooltip', async () => { + const { el } = chips({ rels: [fk('orders', 'user_id', 'users')] }) + await flush() + const rel = el.querySelector('.db-query-chip-rel') as HTMLButtonElement + expect(rel.title).toContain('orders.user_id → users.id') + }) + + it('skips relations entirely on Redis', async () => { + const { el } = chips({ s: server({ kind: 'redis' }), rels: [fk('orders', 'user_id', 'users')] }) + await flush() + expect(el.querySelector('.db-query-chip-rel')).toBeNull() + }) +}) + +describe('filtering', () => { + it('keeps only chips matching the text, case-insensitively', () => { + const { el } = chips() + typeFilter(el, 'ORD') + expect(labels(el)).toEqual(['orders']) + }) + + it('shows everything again when the filter is cleared', () => { + const { el } = chips() + typeFilter(el, 'ord') + typeFilter(el, '') + expect(labels(el)).toHaveLength(2) + }) +}) + +describe('group toggle', () => { + it('offers no toggle on Redis, where there is only one group', () => { + expect(chips({ s: server({ kind: 'redis' }) }).el.querySelector('.db-query-toggle-btn')).toBeNull() + }) + + it('narrows to tables or to relations and back to all', async () => { + const { el } = chips({ rels: [fk('orders', 'user_id', 'users')] }) + await flush() + const [all, tablesBtn, relsBtn] = [...el.querySelectorAll('.db-query-toggle-btn')] as HTMLButtonElement[] + + relsBtn.click() + expect(labels(el).every(l => l.includes('▸'))).toBe(true) + expect(relsBtn.classList.contains('active')).toBe(true) + + tablesBtn.click() + expect(labels(el)).toEqual(['users', 'orders']) + + all.click() + expect(labels(el)).toHaveLength(3) + }) +}) From a6846d1c4bfad11169524f78c9df4d385eeb883c Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:40:09 +0200 Subject: [PATCH 19/51] refactor: extracted the query view, sidebar tree and data opener from DbPanel Co-Authored-By: Claude Opus 5 --- src/panels/db/DbPanel.ts | 254 ++-------------------------- src/panels/db/dbOpenData.ts | 42 +++++ src/panels/db/dbQueryView.ts | 87 ++++++++++ src/panels/db/dbTree.ts | 139 +++++++++++++++ tests/panels/db/dbOpenData.test.ts | 136 +++++++++++++++ tests/panels/db/dbQueryView.test.ts | 173 +++++++++++++++++++ tests/panels/db/dbTree.test.ts | 167 ++++++++++++++++++ tests/panels/db/dbWidgets.test.ts | 7 +- 8 files changed, 760 insertions(+), 245 deletions(-) create mode 100644 src/panels/db/dbOpenData.ts create mode 100644 src/panels/db/dbQueryView.ts create mode 100644 src/panels/db/dbTree.ts create mode 100644 tests/panels/db/dbOpenData.test.ts create mode 100644 tests/panels/db/dbQueryView.test.ts create mode 100644 tests/panels/db/dbTree.test.ts diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index 4a7abdd..123ea9f 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -1,25 +1,12 @@ import { t as i18nT } from '../../i18n' -import { invoke } from '@tauri-apps/api/core' -import { LISTABLE, type DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' -import type { ForeignKey } from './queryBuilders' -import { - KIND_LABEL, isMongo, isRedis, sqlCmd, creds, target, - fetchColumns, listDatabases, listTables, fetchRelations, - type TableData, -} from './dbAccess' -import { note, rowEl, appendExpandable } from './dbWidgets' -import { detectDocker, detectLocal, resolveCreds } from './dbDetect' +import { note } from './dbWidgets' +import { detectDocker, detectLocal } from './dbDetect' import { createDetailHost } from './dbDetailHost' -import { renderGrid } from './dbTableGrid' -import { renderDocs } from './dbDocsView' -import { renderRedisValue } from './dbRedisView' -import { createQueryHistory } from './dbQueryHistory' -import { createQueryRunner } from './dbQueryExec' -import { createAiQueryButton } from './dbQueryAi' -import { createJoinBuilder } from './dbJoinBuilder' -import { createQueryChips } from './dbQueryChips' +import { openData } from './dbOpenData' +import { openQuery } from './dbQueryView' +import { createDbTree } from './dbTree' export function createDbPanel(): { element: HTMLElement } { const root = document.createElement('div') @@ -53,234 +40,19 @@ export function createDbPanel(): { element: HTMLElement } { body.append(cs.element, cs.resizer, detail) root.append(body) - const { showDetail, detailHead } = createDetailHost(detail) - showDetail(note(i18nT('db.selectATableOrCollectionToViewIts'), 'db-detail-hint')) + const host = createDetailHost(detail) + host.showDetail(note(i18nT('db.selectATableOrCollectionToViewIts'), 'db-detail-hint')) - const openData = async (s: DbServer, db: string, name: string): Promise => { - showDetail(note(i18nT('common.loading'), 'db-detail-loading')) - try { - if (isRedis(s)) { - const [v, ttl] = await Promise.all([ - invoke<{ kind: string; value: string }>('db_docker_redis_value', { ...target(s), db, key: name, password: s.password ?? '' }), - invoke('db_docker_redis_ttl', { ...target(s), db, key: name, password: s.password ?? '' }).catch(() => -2), - ]) - renderRedisValue({ showDetail, detailHead }, s, db, name, v, ttl) - return - } - if (isMongo(s)) { - const docs = await invoke('db_docker_mongo_docs', { ...target(s), db, collection: name, ...creds(s) }) - renderDocs({ showDetail, detailHead }, s, db, name, docs) - } else { - const [data, pk] = await Promise.all([ - invoke(sqlCmd(s, 'rows'), { ...target(s), db, table: name, ...creds(s) }), - invoke(sqlCmd(s, 'pk'), { ...target(s), db, table: name, ...creds(s) }).catch(() => [] as string[]), - ]) - const fkColMap = new Map() - fetchRelations(s, db).then(fks => { - fks.filter(f => f.table === name).forEach(f => fkColMap.set(f.column, { ref_table: f.ref_table, ref_column: f.ref_column })) - }).catch(() => {}) - renderGrid({ showDetail, detailHead }, s, db, name, data, pk, fkColMap, () => openData(s, db, name)) - } - } catch (e) { - showDetail(note(String(e), 'db-detail-error')) - } - } - - const openQuery = (s: DbServer, db: string, names: string[]): void => { - // Relations loaded once and shared (chips, AI, and the JOIN builder). - let relations: ForeignKey[] = [] - const relationsReady = fetchRelations(s, db).then(r => { relations = r; return r }) - - const editor = document.createElement('textarea') - editor.className = 'db-query-input' - editor.spellcheck = false - editor.placeholder = isMongo(s) - ? i18nT('db.mongoPlaceholder') - : isRedis(s) - ? i18nT('db.redisPlaceholder') - : i18nT('db.sqlPlaceholder') - const runBtn = document.createElement('button') - runBtn.className = 'db-connect' - runBtn.textContent = i18nT('db.runShortcut') - - const history = createQueryHistory(s, db, q => { editor.value = q; editor.focus() }) - const { executeQuery, explain } = createQueryRunner(s, db, names, relationsReady) - const aiBtn = createAiQueryButton({ s, db, names, relationsReady, executeQuery, fetchColumns }) - const joinBuilder = createJoinBuilder({ - s, names, getRelations: () => relations, relationsReady, - onBuild: q => { editor.value = q; editor.focus() }, - }) - const chips = createQueryChips({ s, names, relationsReady, onPick: q => { editor.value = q; editor.focus() } }) - - const actions = document.createElement('div') - actions.className = 'db-query-actions' - actions.append(runBtn, aiBtn, history.element) - - const bar = document.createElement('div') - bar.className = 'db-query-bar' - bar.append(editor, actions, joinBuilder, chips) - - const resultArea = document.createElement('div') - resultArea.className = 'db-grid-scroll' - resultArea.append(note(i18nT('db.writeAQueryAndRunIt'), 'db-detail-hint')) - - const run = async (): Promise => { - const text = editor.value.trim() - if (!text) return - resultArea.replaceChildren(note(i18nT('db.running'), 'db-detail-loading')) - try { - const result = await executeQuery(text) - history.saveHistory(text) - resultArea.replaceChildren(result) - } catch (e) { - const errEl = note(String(e), 'db-detail-error') - const isExplainable = !isMongo(s) && !isRedis(s) && /^\s*(select|with)\b/i.test(text) - if (!isExplainable) { resultArea.replaceChildren(errEl); return } - const explainBtn = document.createElement('button') - explainBtn.className = 'db-query-run' - explainBtn.textContent = i18nT('db.seeWhyExplain') - explainBtn.addEventListener('click', async () => { - explainBtn.disabled = true - explainBtn.textContent = i18nT('db.analyzing') - try { - resultArea.replaceChildren(await explain(text)) - } catch (e2) { - resultArea.replaceChildren(errEl, note(String(e2), 'db-detail-error')) - } - }) - resultArea.replaceChildren(errEl, explainBtn) - } - } - runBtn.addEventListener('click', run) - editor.addEventListener('keydown', e => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run() } - }) - - showDetail(detailHead(i18nT('db.queryLabel', { name: db }), KIND_LABEL[s.kind]), bar, resultArea) - editor.focus() - } - - // ---- tree ---- - const selectLeaf = (row: HTMLElement): void => { - tree.querySelectorAll('.db-leaf.selected').forEach(el => el.classList.remove('selected')) - row.classList.add('selected') - } - - const credsForm = (container: HTMLElement, s: DbServer, retry: () => void): void => { - container.replaceChildren(note(i18nT('db.connectionFailedTryDifferentCredentials'), 'db-error')) - const userIn = document.createElement('input') - userIn.className = 'db-input' - userIn.placeholder = i18nT('db.userPlaceholder') - userIn.value = s.user ?? '' - const passIn = document.createElement('input') - passIn.className = 'db-input' - passIn.type = 'password' - passIn.placeholder = i18nT('db.password') - passIn.value = s.password ?? '' - const btn = document.createElement('button') - btn.className = 'db-connect' - btn.textContent = i18nT('common.connect') - btn.addEventListener('click', () => { s.user = userIn.value; s.password = passIn.value; retry() }) - container.append(userIn, passIn, btn) - } - - const populateTables = async (s: DbServer, db: string, container: HTMLElement): Promise => { - container.replaceChildren(note(i18nT('common.loading'))) - try { - const names = await listTables(s, db) - container.replaceChildren() - // Free-form query (SQL / mongosh / redis-cli depending on the DB type). - const queryRow = rowEl(2, 'scripts', i18nT('db.newQuery'), false) - queryRow.classList.add('db-leaf', 'db-query-leaf') - queryRow.addEventListener('click', () => { selectLeaf(queryRow); openQuery(s, db, names) }) - container.appendChild(queryRow) - if (!names.length) { container.append(note(i18nT('db.noTables'))); return } - const isLeaf = isMongo(s) || isRedis(s) - const TREE_PAGE = 30 - let offset = 0 - const addRow = (name: string): void => { - const row = rowEl(2, isRedis(s) ? 'list' : isMongo(s) ? 'list' : 'table', name, !isLeaf) - row.classList.add('db-leaf') - row.addEventListener('click', () => { selectLeaf(row); openData(s, db, name) }) - if (isLeaf) { - container.appendChild(row) - } else { - appendExpandable(container, row, async children => { - children.append(note(i18nT('common.loading'))) - const cols = await fetchColumns(s, db, name) - children.replaceChildren() - if (!cols.length) { children.append(note('—')); return } - cols.forEach(colStr => { - const div = document.createElement('div') - div.className = 'db-col-row' - div.textContent = colStr - children.appendChild(div) - }) - }) - } - } - const showPage = (): void => { - container.querySelector('.db-tree-more')?.remove() - names.slice(offset, offset + TREE_PAGE).forEach(addRow) - offset += TREE_PAGE - if (offset < names.length) { - const more = document.createElement('button') - more.className = 'db-row db-tree-more' - more.style.paddingLeft = `${8 + 2 * 14}px` - more.textContent = i18nT('db.showMore', { count: names.length - offset }) - more.addEventListener('click', showPage) - container.appendChild(more) - } - } - showPage() - } catch (e) { - container.replaceChildren(note(String(e), 'db-error')) - } - } - - const populateDatabases = async (s: DbServer, container: HTMLElement): Promise => { - container.replaceChildren(note(i18nT('db.connecting'))) - try { - const names = await listDatabases(s) - container.replaceChildren() - if (!names.length) { container.append(note(isRedis(s) ? i18nT('db.emptyRedisDatabaseNoKeys') : i18nT('db.noDatabases'))); return } - names.forEach(db => { - const row = rowEl(1, 'database', isRedis(s) ? `db${db}` : db, true) - appendExpandable(container, row, child => populateTables(s, db, child)) - }) - } catch { - credsForm(container, s, () => populateDatabases(s, container)) - } - } - - const renderServers = (servers: DbServer[]): void => { - tree.replaceChildren() - if (!servers.length) { - tree.append(note(i18nT('db.noServersWereDetectedIsDockerRunningOr'), 'db-hint')) - return - } - servers.forEach(s => { - const row = rowEl(0, 'database', KIND_LABEL[s.kind], true) - const badge = document.createElement('span') - badge.className = `db-server-badge db-badge-${s.source}` - badge.textContent = s.source === 'docker' ? (s.container ?? i18nT('db.dockerSource')) : i18nT('db.localSource') - const addr = document.createElement('span') - addr.className = 'db-server-addr' - addr.textContent = s.source === 'docker' ? `:${s.port}` : `${s.host}:${s.port}` - row.append(badge, addr) - appendExpandable(tree, row, async child => { - if (!LISTABLE.includes(s.kind)) { child.replaceChildren(note(i18nT('db.listingIsNotSupportedYet'))); return } - child.replaceChildren(note(i18nT('db.connecting'))) - await resolveCreds(s) - populateDatabases(s, child) - }) - }) - } + const { renderServers } = createDbTree({ + element: tree, + onOpenData: (s, db, name) => void openData(host, s, db, name), + onOpenQuery: (s, db, names) => openQuery(host, s, db, names), + }) const detect = async (): Promise => { tree.replaceChildren(note(i18nT('db.detecting'))) const docker = await detectDocker() - const local = await detectLocal(new Set(docker.map(s => s.port))) + const local = await detectLocal(new Set(docker.map(srv => srv.port))) renderServers([...docker, ...local]) } diff --git a/src/panels/db/dbOpenData.ts b/src/panels/db/dbOpenData.ts new file mode 100644 index 0000000..ad222eb --- /dev/null +++ b/src/panels/db/dbOpenData.ts @@ -0,0 +1,42 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { DbServer } from '../../core/db/dbServer' +import { isMongo, isRedis, sqlCmd, creds, target, fetchRelations, type TableData } from './dbAccess' +import { note } from './dbWidgets' +import type { DbDetailHost } from './dbDetailHost' +import { renderGrid } from './dbTableGrid' +import { renderDocs } from './dbDocsView' +import { renderRedisValue } from './dbRedisView' + +/** Opens one table, collection or key in the detail pane, picking the view per engine. */ +export const openData = async (host: DbDetailHost, s: DbServer, db: string, name: string): Promise => { + host.showDetail(note(i18nT('common.loading'), 'db-detail-loading')) + try { + if (isRedis(s)) { + const [v, ttl] = await Promise.all([ + invoke<{ kind: string; value: string }>('db_docker_redis_value', { ...target(s), db, key: name, password: s.password ?? '' }), + invoke('db_docker_redis_ttl', { ...target(s), db, key: name, password: s.password ?? '' }).catch(() => -2), + ]) + renderRedisValue(host, s, db, name, v, ttl) + return + } + if (isMongo(s)) { + const docs = await invoke('db_docker_mongo_docs', { ...target(s), db, collection: name, ...creds(s) }) + renderDocs(host, s, db, name, docs) + return + } + const [data, pk] = await Promise.all([ + invoke(sqlCmd(s, 'rows'), { ...target(s), db, table: name, ...creds(s) }), + invoke(sqlCmd(s, 'pk'), { ...target(s), db, table: name, ...creds(s) }).catch(() => [] as string[]), + ]) + // Relations fill the map in the background: the grid reads it lazily when a + // foreign-key cell is edited, so the rows need not wait for them. + const fkColMap = new Map() + fetchRelations(s, db).then(fks => { + fks.filter(f => f.table === name).forEach(f => fkColMap.set(f.column, { ref_table: f.ref_table, ref_column: f.ref_column })) + }).catch(() => {}) + renderGrid(host, s, db, name, data, pk, fkColMap, () => openData(host, s, db, name)) + } catch (e) { + host.showDetail(note(String(e), 'db-detail-error')) + } +} diff --git a/src/panels/db/dbQueryView.ts b/src/panels/db/dbQueryView.ts new file mode 100644 index 0000000..a2333bc --- /dev/null +++ b/src/panels/db/dbQueryView.ts @@ -0,0 +1,87 @@ +import { t as i18nT } from '../../i18n' +import type { DbServer } from '../../core/db/dbServer' +import type { ForeignKey } from './queryBuilders' +import { KIND_LABEL, isMongo, isRedis, fetchColumns, fetchRelations } from './dbAccess' +import { note } from './dbWidgets' +import type { DbDetailHost } from './dbDetailHost' +import { createQueryHistory } from './dbQueryHistory' +import { createQueryRunner } from './dbQueryExec' +import { createAiQueryButton } from './dbQueryAi' +import { createJoinBuilder } from './dbJoinBuilder' +import { createQueryChips } from './dbQueryChips' + +/** The free-form query editor: run, EXPLAIN on failure, history, AI, JOIN builder and chips. */ +export const openQuery = (host: DbDetailHost, s: DbServer, db: string, names: string[]): void => { + const { showDetail, detailHead } = host + // Relations loaded once and shared (chips, AI, and the JOIN builder). + let relations: ForeignKey[] = [] + const relationsReady = fetchRelations(s, db).then(r => { relations = r; return r }) + + const editor = document.createElement('textarea') + editor.className = 'db-query-input' + editor.spellcheck = false + editor.placeholder = isMongo(s) + ? i18nT('db.mongoPlaceholder') + : isRedis(s) + ? i18nT('db.redisPlaceholder') + : i18nT('db.sqlPlaceholder') + const runBtn = document.createElement('button') + runBtn.className = 'db-connect' + runBtn.textContent = i18nT('db.runShortcut') + + const history = createQueryHistory(s, db, q => { editor.value = q; editor.focus() }) + const { executeQuery, explain } = createQueryRunner(s, db, names, relationsReady) + const aiBtn = createAiQueryButton({ s, db, names, relationsReady, executeQuery, fetchColumns }) + const joinBuilder = createJoinBuilder({ + s, names, getRelations: () => relations, relationsReady, + onBuild: q => { editor.value = q; editor.focus() }, + }) + const chips = createQueryChips({ s, names, relationsReady, onPick: q => { editor.value = q; editor.focus() } }) + + const actions = document.createElement('div') + actions.className = 'db-query-actions' + actions.append(runBtn, aiBtn, history.element) + + const bar = document.createElement('div') + bar.className = 'db-query-bar' + bar.append(editor, actions, joinBuilder, chips) + + const resultArea = document.createElement('div') + resultArea.className = 'db-grid-scroll' + resultArea.append(note(i18nT('db.writeAQueryAndRunIt'), 'db-detail-hint')) + + const run = async (): Promise => { + const text = editor.value.trim() + if (!text) return + resultArea.replaceChildren(note(i18nT('db.running'), 'db-detail-loading')) + try { + const result = await executeQuery(text) + history.saveHistory(text) + resultArea.replaceChildren(result) + } catch (e) { + const errEl = note(String(e), 'db-detail-error') + const isExplainable = !isMongo(s) && !isRedis(s) && /^\s*(select|with)\b/i.test(text) + if (!isExplainable) { resultArea.replaceChildren(errEl); return } + const explainBtn = document.createElement('button') + explainBtn.className = 'db-query-run' + explainBtn.textContent = i18nT('db.seeWhyExplain') + explainBtn.addEventListener('click', async () => { + explainBtn.disabled = true + explainBtn.textContent = i18nT('db.analyzing') + try { + resultArea.replaceChildren(await explain(text)) + } catch (e2) { + resultArea.replaceChildren(errEl, note(String(e2), 'db-detail-error')) + } + }) + resultArea.replaceChildren(errEl, explainBtn) + } + } + runBtn.addEventListener('click', run) + editor.addEventListener('keydown', e => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run() } + }) + + showDetail(detailHead(i18nT('db.queryLabel', { name: db }), KIND_LABEL[s.kind]), bar, resultArea) + editor.focus() +} diff --git a/src/panels/db/dbTree.ts b/src/panels/db/dbTree.ts new file mode 100644 index 0000000..3a024f8 --- /dev/null +++ b/src/panels/db/dbTree.ts @@ -0,0 +1,139 @@ +import { t as i18nT } from '../../i18n' +import { LISTABLE, type DbServer } from '../../core/db/dbServer' +import { KIND_LABEL, isMongo, isRedis, fetchColumns, listDatabases, listTables } from './dbAccess' +import { note, rowEl, appendExpandable } from './dbWidgets' +import { resolveCreds } from './dbDetect' + +// Tables are revealed a page at a time: a large DB has thousands of them. +const TREE_PAGE = 30 + +export interface DbTreeDeps { + element: HTMLElement + onOpenData: (s: DbServer, db: string, name: string) => void + onOpenQuery: (s: DbServer, db: string, names: string[]) => void +} + +export interface DbTree { + renderServers: (servers: DbServer[]) => void +} + +/** The sidebar tree: servers → databases → tables/collections/keys → columns. */ +export function createDbTree(deps: DbTreeDeps): DbTree { + const { element, onOpenData, onOpenQuery } = deps +const selectLeaf = (row: HTMLElement): void => { + element.querySelectorAll('.db-leaf.selected').forEach(el => el.classList.remove('selected')) + row.classList.add('selected') +} + +const credsForm = (container: HTMLElement, s: DbServer, retry: () => void): void => { + container.replaceChildren(note(i18nT('db.connectionFailedTryDifferentCredentials'), 'db-error')) + const userIn = document.createElement('input') + userIn.className = 'db-input' + userIn.placeholder = i18nT('db.userPlaceholder') + userIn.value = s.user ?? '' + const passIn = document.createElement('input') + passIn.className = 'db-input' + passIn.type = 'password' + passIn.placeholder = i18nT('db.password') + passIn.value = s.password ?? '' + const btn = document.createElement('button') + btn.className = 'db-connect' + btn.textContent = i18nT('common.connect') + btn.addEventListener('click', () => { s.user = userIn.value; s.password = passIn.value; retry() }) + container.append(userIn, passIn, btn) +} + +const populateTables = async (s: DbServer, db: string, container: HTMLElement): Promise => { + container.replaceChildren(note(i18nT('common.loading'))) + try { + const names = await listTables(s, db) + container.replaceChildren() + // Free-form query (SQL / mongosh / redis-cli depending on the DB type). + const queryRow = rowEl(2, 'scripts', i18nT('db.newQuery'), false) + queryRow.classList.add('db-leaf', 'db-query-leaf') + queryRow.addEventListener('click', () => { selectLeaf(queryRow); onOpenQuery(s, db, names) }) + container.appendChild(queryRow) + if (!names.length) { container.append(note(i18nT('db.noTables'))); return } + const isLeaf = isMongo(s) || isRedis(s) + let offset = 0 + const addRow = (name: string): void => { + const row = rowEl(2, isRedis(s) ? 'list' : isMongo(s) ? 'list' : 'table', name, !isLeaf) + row.classList.add('db-leaf') + row.addEventListener('click', () => { selectLeaf(row); onOpenData(s, db, name) }) + if (isLeaf) { + container.appendChild(row) + } else { + appendExpandable(container, row, async children => { + children.append(note(i18nT('common.loading'))) + const cols = await fetchColumns(s, db, name) + children.replaceChildren() + if (!cols.length) { children.append(note('—')); return } + cols.forEach(colStr => { + const div = document.createElement('div') + div.className = 'db-col-row' + div.textContent = colStr + children.appendChild(div) + }) + }) + } + } + const showPage = (): void => { + container.querySelector('.db-tree-more')?.remove() + names.slice(offset, offset + TREE_PAGE).forEach(addRow) + offset += TREE_PAGE + if (offset < names.length) { + const more = document.createElement('button') + more.className = 'db-row db-tree-more' + more.style.paddingLeft = `${8 + 2 * 14}px` + more.textContent = i18nT('db.showMore', { count: names.length - offset }) + more.addEventListener('click', showPage) + container.appendChild(more) + } + } + showPage() + } catch (e) { + container.replaceChildren(note(String(e), 'db-error')) + } +} + +const populateDatabases = async (s: DbServer, container: HTMLElement): Promise => { + container.replaceChildren(note(i18nT('db.connecting'))) + try { + const names = await listDatabases(s) + container.replaceChildren() + if (!names.length) { container.append(note(isRedis(s) ? i18nT('db.emptyRedisDatabaseNoKeys') : i18nT('db.noDatabases'))); return } + names.forEach(db => { + const row = rowEl(1, 'database', isRedis(s) ? `db${db}` : db, true) + appendExpandable(container, row, child => populateTables(s, db, child)) + }) + } catch { + credsForm(container, s, () => populateDatabases(s, container)) + } +} + +const renderServers = (servers: DbServer[]): void => { + element.replaceChildren() + if (!servers.length) { + element.append(note(i18nT('db.noServersWereDetectedIsDockerRunningOr'), 'db-hint')) + return + } + servers.forEach(s => { + const row = rowEl(0, 'database', KIND_LABEL[s.kind], true) + const badge = document.createElement('span') + badge.className = `db-server-badge db-badge-${s.source}` + badge.textContent = s.source === 'docker' ? (s.container ?? i18nT('db.dockerSource')) : i18nT('db.localSource') + const addr = document.createElement('span') + addr.className = 'db-server-addr' + addr.textContent = s.source === 'docker' ? `:${s.port}` : `${s.host}:${s.port}` + row.append(badge, addr) + appendExpandable(element, row, async child => { + if (!LISTABLE.includes(s.kind)) { child.replaceChildren(note(i18nT('db.listingIsNotSupportedYet'))); return } + child.replaceChildren(note(i18nT('db.connecting'))) + await resolveCreds(s) + populateDatabases(s, child) + }) + }) +} + + return { renderServers } +} diff --git a/tests/panels/db/dbOpenData.test.ts b/tests/panels/db/dbOpenData.test.ts new file mode 100644 index 0000000..8737a20 --- /dev/null +++ b/tests/panels/db/dbOpenData.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { openData } from '../../../src/panels/db/dbOpenData' +import type { DbDetailHost } from '../../../src/panels/db/dbDetailHost' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +let shown: HTMLElement[][] + +const host = (): DbDetailHost => ({ + showDetail: (...nodes) => { shown.push(nodes); document.body.replaceChildren(...nodes) }, + detailHead: (path, count) => { + const el = document.createElement('div') + el.className = 'db-detail-head' + el.dataset.path = path + el.dataset.count = count + return el + }, +}) + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const open = (s = server(), name = 'users'): Promise => openData(host(), s, 'app', name) + +const called = (cmd: string): boolean => mocks.invoke.mock.calls.some(c => c[0] === cmd) + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + vi.stubGlobal('confirm', () => true) + vi.stubGlobal('alert', () => {}) + document.body.replaceChildren() + shown = [] + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +describe('while loading', () => { + it('shows a loading note before the data arrives', async () => { + mocks.invoke.mockResolvedValue({ columns: [], rows: [] }) + await open() + expect((shown[0][0] as HTMLElement).className).toBe('db-detail-loading') + }) +}) + +describe('SQL tables', () => { + beforeEach(() => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'db_docker_mysql_rows') return { columns: ['id'], rows: [['1']] } + if (cmd === 'db_docker_mysql_pk') return ['id'] + if (cmd === 'db_docker_mysql_fks') return [{ table: 'users', column: 'org_id', ref_table: 'orgs', ref_column: 'id' }] + return undefined + }) + }) + + it('renders the rows in an editable grid', async () => { + await open() + await flush() + expect(document.querySelector('tbody tr')).not.toBeNull() + expect(document.querySelector('td.db-editable')).not.toBeNull() + }) + + it('loads rows and primary key together, and relations alongside', async () => { + await open() + await flush() + expect(called('db_docker_mysql_rows')).toBe(true) + expect(called('db_docker_mysql_pk')).toBe(true) + expect(called('db_docker_mysql_fks')).toBe(true) + }) + + it('still renders when the primary key cannot be read', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'db_docker_mysql_rows') return { columns: ['id'], rows: [['1']] } + if (cmd === 'db_docker_mysql_pk') throw new Error('denied') + return [] + }) + await open() + expect(document.querySelector('tbody tr')).not.toBeNull() + expect(document.querySelector('td.db-editable')).toBeNull() + }) + + it('uses the Postgres backend for Postgres', async () => { + mocks.invoke.mockResolvedValue({ columns: [], rows: [] }) + await open(server({ kind: 'postgres' })) + expect(called('db_docker_pg_rows')).toBe(true) + }) +}) + +describe('Mongo collections', () => { + it('renders the documents', async () => { + mocks.invoke.mockResolvedValue(['{"a":1}']) + await open(server({ kind: 'mongodb' })) + expect(called('db_docker_mongo_docs')).toBe(true) + expect(document.querySelector('.db-doc-item')).not.toBeNull() + }) +}) + +describe('Redis keys', () => { + it('reads the value and its TTL and renders them', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'db_docker_redis_value') return { kind: 'string', value: 'hello' } + if (cmd === 'db_docker_redis_ttl') return 30 + return undefined + }) + await open(server({ kind: 'redis' }), 'k1') + expect(document.querySelector('.db-doc')!.textContent).toBe('hello') + expect((document.querySelector('.db-detail-head') as HTMLElement).dataset.count).toContain('30') + }) + + it('still shows the value when the TTL lookup fails', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'db_docker_redis_value') return { kind: 'string', value: 'hello' } + throw new Error('no TTL') + }) + await open(server({ kind: 'redis' }), 'k1') + expect(document.querySelector('.db-doc')!.textContent).toBe('hello') + }) +}) + +describe('failures', () => { + it('shows the error in the detail pane instead of throwing', async () => { + mocks.invoke.mockRejectedValue(new Error('table does not exist')) + await expect(open()).resolves.toBeUndefined() + expect(document.querySelector('.db-detail-error')!.textContent).toContain('table does not exist') + }) +}) diff --git a/tests/panels/db/dbQueryView.test.ts b/tests/panels/db/dbQueryView.test.ts new file mode 100644 index 0000000..7559bc0 --- /dev/null +++ b/tests/panels/db/dbQueryView.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), + askAi: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) +vi.mock('../../../src/ui/askAi', () => ({ askAi: mocks.askAi })) + +import { openQuery } from '../../../src/panels/db/dbQueryView' +import type { DbDetailHost } from '../../../src/panels/db/dbDetailHost' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +let shown: HTMLElement[] + +const host = (): DbDetailHost => ({ + showDetail: (...nodes) => { shown = nodes; document.body.replaceChildren(...nodes) }, + detailHead: (path, count) => { + const el = document.createElement('div') + el.className = 'db-detail-head' + el.dataset.path = path + el.dataset.count = count + return el + }, +}) + +const server = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +const open = (s = server()): void => { openQuery(host(), s, 'app', ['users']) } + +const editor = (): HTMLTextAreaElement => document.querySelector('.db-query-input') as HTMLTextAreaElement +const results = (): HTMLElement => document.querySelector('.db-grid-scroll') as HTMLElement +const runBtn = (): HTMLButtonElement => document.querySelector('.db-query-actions .db-connect') as HTMLButtonElement + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() + shown = [] + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue({ columns: ['id'], rows: [['1']] }) + mocks.askAi.mockReset() +}) + +describe('layout', () => { + it('shows the editor, the actions and an empty result area under a header', () => { + open() + expect((shown[0] as HTMLElement).dataset.count).toBe('MySQL') + expect(editor()).not.toBeNull() + expect(document.querySelector('.db-query-actions')).not.toBeNull() + expect(results().querySelector('.db-detail-hint')).not.toBeNull() + }) + + it('hints the right language per engine', () => { + open(server({ kind: 'mongodb' })) + const mongo = editor().placeholder + open(server({ kind: 'redis' })) + const redis = editor().placeholder + open() + expect(new Set([mongo, redis, editor().placeholder]).size).toBe(3) + }) + + it('offers the JOIN builder on SQL but not on Mongo', () => { + open() + expect(document.querySelector('.db-join-add')).not.toBeNull() + open(server({ kind: 'mongodb' })) + expect(document.querySelector('.db-join-add')).toBeNull() + }) +}) + +describe('running a query', () => { + it('does nothing for an empty editor', async () => { + open() + await flush() + mocks.invoke.mockClear() // opening the view already loaded the relations + runBtn().click() + await flush() + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('runs on click and shows the result grid', async () => { + open() + editor().value = 'SELECT id FROM users LIMIT 1' + runBtn().click() + await flush() + expect(results().querySelector('tbody tr')).not.toBeNull() + }) + + it('runs on Cmd/Ctrl+Enter too', async () => { + open() + editor().value = 'SELECT id FROM users LIMIT 1' + editor().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true })) + await flush() + expect(mocks.invoke).toHaveBeenCalled() + }) + + it('remembers the query it just ran', async () => { + open() + editor().value = 'SELECT id FROM users LIMIT 1' + runBtn().click() + await flush() + expect(localStorage.getItem('bento.db.qhist.mysql.app')).toContain('SELECT id FROM users') + }) +}) + +describe('when a query fails', () => { + beforeEach(() => { mocks.invoke.mockRejectedValue(new Error('syntax error near FROM')) }) + + it('shows the error', async () => { + open() + editor().value = 'SELECT FROM users' + runBtn().click() + await flush() + expect(results().textContent).toContain('syntax error near FROM') + }) + + it('offers EXPLAIN for a failing SELECT', async () => { + open() + editor().value = 'SELECT FROM users' + runBtn().click() + await flush() + expect(results().querySelector('.db-query-run')).not.toBeNull() + }) + + it('offers no EXPLAIN for a non-SELECT or on Mongo and Redis', async () => { + open() + editor().value = 'DROP TABLE users' + runBtn().click() + await flush() + expect(results().querySelector('.db-query-run')).toBeNull() + + open(server({ kind: 'mongodb' })) + editor().value = 'db.users.find()' + runBtn().click() + await flush() + expect(results().querySelector('.db-query-run')).toBeNull() + }) + + it('shows the plan when EXPLAIN is used', async () => { + open() + editor().value = 'SELECT FROM users' + runBtn().click() + await flush() + mocks.invoke.mockResolvedValue({ columns: ['type'], rows: [['ALL']] }) + ;(results().querySelector('.db-query-run') as HTMLButtonElement).click() + await flush() + expect(results().querySelector('table')).not.toBeNull() + }) + + it('keeps the original error visible when EXPLAIN also fails', async () => { + open() + editor().value = 'SELECT FROM users' + runBtn().click() + await flush() + ;(results().querySelector('.db-query-run') as HTMLButtonElement).click() + await flush() + expect(results().textContent).toContain('syntax error near FROM') + }) +}) + +describe('filling the editor', () => { + it('drops a table example in when its chip is clicked', () => { + open() + ;(document.querySelector('.db-query-chip') as HTMLButtonElement).click() + expect(editor().value).toContain('users') + }) +}) diff --git a/tests/panels/db/dbTree.test.ts b/tests/panels/db/dbTree.test.ts new file mode 100644 index 0000000..0070f59 --- /dev/null +++ b/tests/panels/db/dbTree.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { createDbTree } from '../../../src/panels/db/dbTree' +import type { DbServer } from '../../../src/core/db/dbServer' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +const docker = (over: Partial = {}): DbServer => + ({ kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1', ...over }) + +function tree(over: { onOpenData?: () => void; onOpenQuery?: () => void } = {}) { + const el = document.createElement('div') + document.body.replaceChildren(el) + const api = createDbTree({ + element: el, + onOpenData: over.onOpenData ?? (() => {}), + onOpenQuery: over.onOpenQuery ?? (() => {}), + }) + return { el, api } +} + +const rows = (el: HTMLElement): string[] => + [...el.querySelectorAll('.db-row-label')].map(l => l.textContent ?? '') + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +describe('server list', () => { + it('says nothing was found when there are no servers', () => { + const { el, api } = tree() + api.renderServers([]) + expect(el.querySelector('.db-hint')).not.toBeNull() + }) + + it('shows one row per server with its origin and address', () => { + const { el, api } = tree() + api.renderServers([docker(), { kind: 'redis', source: 'local', host: '127.0.0.1', port: 6379 }]) + expect(rows(el)).toEqual(['MySQL', 'Redis']) + const badges = [...el.querySelectorAll('.db-server-badge')].map(b => b.textContent) + expect(badges[0]).toBe('c1') + const addrs = [...el.querySelectorAll('.db-server-addr')].map(a => a.textContent) + expect(addrs).toEqual([':3306', '127.0.0.1:6379']) + }) + + it('replaces the previous list on a re-render', () => { + const { el, api } = tree() + api.renderServers([docker()]) + api.renderServers([docker({ kind: 'postgres', port: 5432 })]) + expect(rows(el)).toEqual(['PostgreSQL']) + }) +}) + +describe('expanding a server', () => { + it('resolves credentials and lists the databases', async () => { + const { el, api } = tree() + api.renderServers([docker()]) + mocks.invoke + .mockResolvedValueOnce(['MYSQL_ROOT_PASSWORD=pw']) + .mockResolvedValueOnce(['app', 'other']) + ;(el.querySelector('.db-row') as HTMLButtonElement).click() + await flush() + expect(rows(el)).toEqual(['MySQL', 'app', 'other']) + }) + + it('offers credentials again when the connection fails', async () => { + const { el, api } = tree() + api.renderServers([docker()]) + mocks.invoke.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('access denied')) + ;(el.querySelector('.db-row') as HTMLButtonElement).click() + await flush() + expect(el.querySelector('.db-error')).not.toBeNull() + expect(el.querySelectorAll('.db-input')).toHaveLength(2) + }) + + it('retries with the credentials the user typed', async () => { + const { el, api } = tree() + api.renderServers([docker()]) + mocks.invoke.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('access denied')) + ;(el.querySelector('.db-row') as HTMLButtonElement).click() + await flush() + const [userIn, passIn] = [...el.querySelectorAll('.db-input')] as HTMLInputElement[] + userIn.value = 'root' + passIn.value = 'pw' + mocks.invoke.mockResolvedValueOnce(['app']) + ;(el.querySelector('.db-connect') as HTMLButtonElement).click() + await flush() + const args = mocks.invoke.mock.calls.at(-1)![1] as { user: string; password: string } + expect(args).toMatchObject({ user: 'root', password: 'pw' }) + }) + + it('says listing is unsupported for an engine it cannot browse', async () => { + const { el, api } = tree() + api.renderServers([{ kind: 'unknown', source: 'local', host: 'h', port: 1 } as unknown as DbServer]) + ;(el.querySelector('.db-row') as HTMLButtonElement).click() + await flush() + expect(el.querySelector('.db-note')).not.toBeNull() + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) + +describe('expanding a database', () => { + const openDb = async (el: HTMLElement, api: { renderServers: (s: DbServer[]) => void }, tables: string[]): Promise => { + api.renderServers([docker()]) + mocks.invoke.mockResolvedValueOnce([]).mockResolvedValueOnce(['app']) + ;(el.querySelector('.db-row') as HTMLButtonElement).click() + await flush() + mocks.invoke.mockResolvedValueOnce(tables) + ;([...el.querySelectorAll('.db-row')][1] as HTMLButtonElement).click() + await flush() + } + + it('always offers a free-form query row first', async () => { + const onOpenQuery = vi.fn() + const { el, api } = tree({ onOpenQuery }) + await openDb(el, api, ['users']) + const queryRow = el.querySelector('.db-query-leaf') as HTMLButtonElement + expect(queryRow).not.toBeNull() + queryRow.click() + expect(onOpenQuery).toHaveBeenCalledWith(expect.anything(), 'app', ['users']) + }) + + it('opens the table data when a table is clicked', async () => { + const onOpenData = vi.fn() + const { el, api } = tree({ onOpenData }) + await openDb(el, api, ['users']) + const tableRow = [...el.querySelectorAll('.db-leaf')].find(r => r.textContent?.includes('users')) as HTMLButtonElement + tableRow.click() + expect(onOpenData).toHaveBeenCalledWith(expect.anything(), 'app', 'users') + expect(tableRow.classList.contains('selected')).toBe(true) + }) + + it('marks only one leaf selected at a time', async () => { + const { el, api } = tree() + await openDb(el, api, ['users', 'orders']) + const leaves = [...el.querySelectorAll('.db-leaf')] as HTMLButtonElement[] + leaves[1].click() + leaves[2].click() + expect(el.querySelectorAll('.db-leaf.selected')).toHaveLength(1) + }) + + it('says so when the database has no tables', async () => { + const { el, api } = tree() + await openDb(el, api, []) + expect(el.querySelector('.db-note')).not.toBeNull() + }) + + it('pages a long table list', async () => { + const { el, api } = tree() + await openDb(el, api, Array.from({ length: 35 }, (_, i) => `t${i}`)) + expect(el.querySelectorAll('.db-leaf')).toHaveLength(31) // 30 tables + the query row + ;(el.querySelector('.db-tree-more') as HTMLButtonElement).click() + expect(el.querySelectorAll('.db-leaf')).toHaveLength(36) + expect(el.querySelector('.db-tree-more')).toBeNull() + }) +}) diff --git a/tests/panels/db/dbWidgets.test.ts b/tests/panels/db/dbWidgets.test.ts index eb12f9e..3416dfb 100644 --- a/tests/panels/db/dbWidgets.test.ts +++ b/tests/panels/db/dbWidgets.test.ts @@ -40,10 +40,9 @@ describe('makeFilterInput', () => { describe('makeCsvBtn', () => { it('quotes every field and doubles embedded quotes', () => { let csv = '' - vi.stubGlobal('URL', { - createObjectURL: (b: { text: () => Promise }) => { void b; return 'blob:x' }, - revokeObjectURL: () => {}, - }) + // Patch only the two statics: replacing URL itself breaks the anchor click. + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) vi.stubGlobal('Blob', class { constructor(parts: string[]) { csv = parts.join('') } }) From 91a0ad4c6c54be7ac60256f2124bc99f345ee467 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:42:33 +0200 Subject: [PATCH 20/51] refactor: deduplicated SQL quoting and clipboard feedback across the split modules Co-Authored-By: Claude Opus 5 --- src/panels/db/dbCellRender.ts | 6 ++-- src/panels/db/dbRedisView.ts | 6 ++-- src/panels/db/dbRowEdit.ts | 10 +++---- src/panels/db/dbSqlQuote.ts | 21 +++++++++++++ src/panels/db/dbTableGrid.ts | 19 +++++------- src/panels/db/dbWidgets.ts | 12 ++++++++ tests/panels/db/dbSqlQuote.test.ts | 47 ++++++++++++++++++++++++++++++ tests/panels/db/dbWidgets.test.ts | 37 ++++++++++++++++++++++- 8 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 src/panels/db/dbSqlQuote.ts create mode 100644 tests/panels/db/dbSqlQuote.test.ts diff --git a/src/panels/db/dbCellRender.ts b/src/panels/db/dbCellRender.ts index edcb1d8..4a012aa 100644 --- a/src/panels/db/dbCellRender.ts +++ b/src/panels/db/dbCellRender.ts @@ -1,5 +1,6 @@ import { t as i18nT } from '../../i18n' import { parseStructuredJson } from './jsonValues' +import { copyToClipboard } from './dbWidgets' // Only one expanded JSON/text panel at a time: opening one closes the previous. let closeOpenPanel: (() => void) | null = null @@ -197,10 +198,7 @@ export const renderCellValue = (td: HTMLTableCellElement, value: string): void = copyBtn.textContent = '⎘' copyBtn.addEventListener('click', e => { e.stopPropagation() - void navigator.clipboard.writeText(rawContent).then(() => { - copyBtn.textContent = '✓' - setTimeout(() => { copyBtn.textContent = '⎘' }, 1200) - }) + void copyToClipboard(copyBtn, rawContent) }) const panel = document.createElement('div') diff --git a/src/panels/db/dbRedisView.ts b/src/panels/db/dbRedisView.ts index 27312c2..cf60f97 100644 --- a/src/panels/db/dbRedisView.ts +++ b/src/panels/db/dbRedisView.ts @@ -4,7 +4,7 @@ import type { DbServer } from '../../core/db/dbServer' import { parseStructuredJson } from './jsonValues' import { target, parseRedisLines } from './dbAccess' import { prettyJson, highlightJson } from './dbCellRender' -import { note } from './dbWidgets' +import { note, copyToClipboard } from './dbWidgets' import type { DbDetailHost } from './dbDetailHost' export const renderRedisValue = ( @@ -102,9 +102,7 @@ export const renderRedisValue = ( const content = buildContent() const copyBtn = document.createElement('button') copyBtn.className = 'db-action'; copyBtn.title = i18nT('db.jsonCopy'); copyBtn.textContent = '⎘' - copyBtn.addEventListener('click', () => { - void navigator.clipboard.writeText(rawValue).then(() => { copyBtn.textContent = '✓'; setTimeout(() => { copyBtn.textContent = '⎘' }, 1200) }) - }) + copyBtn.addEventListener('click', () => { void copyToClipboard(copyBtn, rawValue) }) const toolbar = document.createElement('div'); toolbar.className = 'db-result-toolbar'; toolbar.appendChild(copyBtn) const scroll = document.createElement('div'); scroll.className = 'db-docs'; scroll.appendChild(content) showDetail(detailHead(`db${db} · ${key}`, kindStr), toolbar, scroll) diff --git a/src/panels/db/dbRowEdit.ts b/src/panels/db/dbRowEdit.ts index 587a5ea..304b7bc 100644 --- a/src/panels/db/dbRowEdit.ts +++ b/src/panels/db/dbRowEdit.ts @@ -4,6 +4,7 @@ import type { DbServer } from '../../core/db/dbServer' import { isPg, sqlCmd, creds, target, type TableData } from './dbAccess' import { renderCellValue } from './dbCellRender' import { buildWheres } from './dbWidgets' +import { ident, qualifiedTable } from './dbSqlQuote' export const editCell = ( s: DbServer, db: string, table: string, columns: string[], @@ -22,12 +23,9 @@ export const editCell = ( if (!confirm(summary)) { restore(); return } try { if (setNull) { - const ident = (id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` - const w = wheres.map(([c, v]) => `${ident(c)} = '${v.replace(/'/g, "''")}'`).join(' AND ') - const tblQ = isPg(s) - ? table.split('.').map(p => `"${p}"`).join('.') - : `\`${db}\`.\`${table}\`` - await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `UPDATE ${tblQ} SET ${ident(column)} = NULL WHERE ${w}`, ...creds(s) }) + const w = wheres.map(([c, v]) => `${ident(s, c)} = '${v.replace(/'/g, "''")}'`).join(' AND ') + const tblQ = qualifiedTable(s, db, table) + await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `UPDATE ${tblQ} SET ${ident(s, column)} = NULL WHERE ${w}`, ...creds(s) }) row[colIdx] = 'NULL' renderCellValue(td as HTMLTableCellElement, 'NULL') return diff --git a/src/panels/db/dbSqlQuote.ts b/src/panels/db/dbSqlQuote.ts new file mode 100644 index 0000000..7153f0e --- /dev/null +++ b/src/panels/db/dbSqlQuote.ts @@ -0,0 +1,21 @@ +import type { DbServer } from '../../core/db/dbServer' +import { isPg } from './dbAccess' + +/** Quotes a column or table name for the engine's own identifier syntax. */ +export const ident = (s: DbServer, id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` + +/** + * The table as the engine addresses it: MySQL qualifies with the database, + * Postgres quotes each part on its own so the dot stays outside the quotes + * ("schema"."table", never "schema.table"). + */ +export const qualifiedTable = (s: DbServer, db: string, table: string): string => + isPg(s) + ? table.split('.').map(p => `"${p}"`).join('.') + : `\`${db}\`.\`${table}\`` + +/** Quotes a literal value: Postgres doubles quotes, MySQL escapes with backslashes. */ +export const quoteValue = (s: DbServer, v: string): string => + isPg(s) + ? `'${v.replace(/'/g, "''")}'` + : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` diff --git a/src/panels/db/dbTableGrid.ts b/src/panels/db/dbTableGrid.ts index 7ec69a6..7717fb9 100644 --- a/src/panels/db/dbTableGrid.ts +++ b/src/panels/db/dbTableGrid.ts @@ -3,10 +3,11 @@ import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' import { parseStructuredJson } from './jsonValues' -import { isPg, sqlCmd, creds, target, type TableData } from './dbAccess' +import { sqlCmd, creds, target, type TableData } from './dbAccess' import { buildJsonTree, renderCellValue } from './dbCellRender' -import { note, makeFilterInput, makeCsvBtn, makeResultWrap } from './dbWidgets' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap, copyToClipboard } from './dbWidgets' import { editCell, deleteRow } from './dbRowEdit' +import { ident, qualifiedTable, quoteValue } from './dbSqlQuote' import type { DbDetailHost } from './dbDetailHost' export const renderGrid = ( @@ -108,7 +109,7 @@ export const renderGrid = ( copyBtn2.addEventListener('click', () => { const obj: Record = {} data.columns.forEach((col, i) => { obj[col] = row[i] }) - void navigator.clipboard.writeText(JSON.stringify(obj, null, 2)).then(() => { copyBtn2.innerHTML = '✓'; setTimeout(() => { copyBtn2.innerHTML = icon('copy') }, 1200) }) + void copyToClipboard(copyBtn2, JSON.stringify(obj, null, 2)) }) actions.appendChild(copyBtn2) if (editable) { @@ -188,21 +189,15 @@ export const renderGrid = ( okBtn.textContent = '✓' okBtn.title = i18nT('db.insertRow') okBtn.addEventListener('click', async () => { - const ident = (id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` - const quote = (v: string): string => isPg(s) - ? `'${v.replace(/'/g, "''")}'` - : `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` const vals: Array<[string, string | null]> = [] cellStates.forEach(({ input: inp, isNull }, i) => { if (isNull) vals.push([data.columns[i], null]) else if (inp.value !== '') vals.push([data.columns[i], inp.value]) }) if (!vals.length) { alert(i18nT('db.insertNeedValue')); return } - const colSql = vals.map(([c]) => ident(c)).join(', ') - const valSql = vals.map(([, v]) => v === null ? 'NULL' : quote(v)).join(', ') - const tblQ = isPg(s) - ? table.split('.').map(p => `"${p}"`).join('.') - : `\`${db}\`.\`${table}\`` + const colSql = vals.map(([c]) => ident(s, c)).join(', ') + const valSql = vals.map(([, v]) => v === null ? 'NULL' : quoteValue(s, v)).join(', ') + const tblQ = qualifiedTable(s, db, table) okBtn.disabled = true try { await invoke(sqlCmd(s, 'query'), { ...target(s), db, sql: `INSERT INTO ${tblQ} (${colSql}) VALUES (${valSql})`, ...creds(s) }) diff --git a/src/panels/db/dbWidgets.ts b/src/panels/db/dbWidgets.ts index 759dddc..39da7eb 100644 --- a/src/panels/db/dbWidgets.ts +++ b/src/panels/db/dbWidgets.ts @@ -94,3 +94,15 @@ export const appendExpandable = ( }) parent.appendChild(row) } + +/** Copies text and flashes a tick on the button, restoring whatever it showed before. */ +export const copyToClipboard = async (btn: HTMLButtonElement, text: string): Promise => { + try { + await navigator.clipboard.writeText(text) + } catch { + return // nothing copied: leave the button as it was + } + const original = btn.innerHTML + btn.textContent = '✓' + setTimeout(() => { btn.innerHTML = original }, 1200) +} diff --git a/tests/panels/db/dbSqlQuote.test.ts b/tests/panels/db/dbSqlQuote.test.ts new file mode 100644 index 0000000..2a12619 --- /dev/null +++ b/tests/panels/db/dbSqlQuote.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { ident, qualifiedTable, quoteValue } from '../../../src/panels/db/dbSqlQuote' +import type { DbServer } from '../../../src/core/db/dbServer' + +const mysql: DbServer = { kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1' } +const maria: DbServer = { ...mysql, kind: 'mariadb' } +const pg: DbServer = { ...mysql, kind: 'postgres', port: 5432 } + +describe('ident', () => { + it('backticks identifiers on MySQL and MariaDB', () => { + expect(ident(mysql, 'name')).toBe('`name`') + expect(ident(maria, 'name')).toBe('`name`') + }) + + it('double-quotes identifiers on Postgres', () => { + expect(ident(pg, 'name')).toBe('"name"') + }) +}) + +describe('qualifiedTable', () => { + it('qualifies with the database on MySQL', () => { + expect(qualifiedTable(mysql, 'app', 'users')).toBe('`app`.`users`') + }) + + it('quotes each part separately on Postgres so the dot stays outside the quotes', () => { + expect(qualifiedTable(pg, 'app', 'sales.orders')).toBe('"sales"."orders"') + }) + + it('quotes an unqualified Postgres table on its own', () => { + expect(qualifiedTable(pg, 'app', 'users')).toBe('"users"') + }) +}) + +describe('quoteValue', () => { + it('doubles single quotes on Postgres', () => { + expect(quoteValue(pg, "O'Brien")).toBe("'O''Brien'") + }) + + it('backslash-escapes quotes and backslashes on MySQL', () => { + expect(quoteValue(mysql, "O'Brien")).toBe("'O\\'Brien'") + expect(quoteValue(mysql, 'back\\slash')).toBe("'back\\\\slash'") + }) + + it('escapes the backslash before the quote so the quote stays escaped', () => { + expect(quoteValue(mysql, "a\\'b")).toBe("'a\\\\\\'b'") + }) +}) diff --git a/tests/panels/db/dbWidgets.test.ts b/tests/panels/db/dbWidgets.test.ts index 3416dfb..522e7c2 100644 --- a/tests/panels/db/dbWidgets.test.ts +++ b/tests/panels/db/dbWidgets.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it, beforeEach, vi } from 'vitest' import { makeLocalStorage } from '../../helpers/localStorage' -import { note, makeFilterInput, makeCsvBtn, makeResultWrap, buildWheres, rowEl, appendExpandable } from '../../../src/panels/db/dbWidgets' +import { note, makeFilterInput, makeCsvBtn, makeResultWrap, buildWheres, rowEl, appendExpandable, copyToClipboard } from '../../../src/panels/db/dbWidgets' beforeEach(() => { vi.stubGlobal('localStorage', makeLocalStorage()) @@ -122,3 +122,38 @@ describe('appendExpandable', () => { expect(row.classList.contains('open')).toBe(true) }) }) + +describe('copyToClipboard', () => { + const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + + it('writes the given text, not what the button shows', async () => { + const writeText = vi.fn(async () => {}) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const btn = document.createElement('button') + btn.textContent = '\u2398' + await copyToClipboard(btn, 'the raw value') + expect(writeText).toHaveBeenCalledWith('the raw value') + }) + + it('flashes a tick and restores the original content', async () => { + vi.stubGlobal('navigator', { clipboard: { writeText: async () => {} } }) + vi.useFakeTimers() + const btn = document.createElement('button') + btn.innerHTML = '' + const done = copyToClipboard(btn, 'x') + await vi.advanceTimersByTimeAsync(0) + await done + expect(btn.textContent).toBe('\u2713') + await vi.advanceTimersByTimeAsync(1200) + expect(btn.innerHTML).toBe('') + }) + + it('leaves the button alone when the clipboard refuses', async () => { + vi.stubGlobal('navigator', { clipboard: { writeText: async () => { throw new Error('denied') } } }) + const btn = document.createElement('button') + btn.textContent = '\u2398' + await copyToClipboard(btn, 'x') + await flush() + expect(btn.textContent).toBe('\u2398') + }) +}) From 2dbfce8b8a9d25a96487c807b32690992a80b0e7 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:56:00 +0200 Subject: [PATCH 21/51] refactor: moved pure DB logic from the panel into the core layer Co-Authored-By: Claude Opus 5 --- src/core/db/dbEngine.ts | 39 ++++++++++ src/core/db/pgIdents.ts | 23 ++++++ .../db/dbSqlQuote.ts => core/db/sqlQuote.ts} | 2 +- src/panels/db/dbAccess.ts | 41 +--------- src/panels/db/dbDetect.ts | 2 +- src/panels/db/dbDocsView.ts | 2 +- src/panels/db/dbJoinBuilder.ts | 2 +- src/panels/db/dbOpenData.ts | 3 +- src/panels/db/dbQueryAi.ts | 2 +- src/panels/db/dbQueryChips.ts | 2 +- src/panels/db/dbQueryExec.ts | 25 +----- src/panels/db/dbQueryView.ts | 3 +- src/panels/db/dbRedisView.ts | 2 +- src/panels/db/dbResultTable.ts | 3 +- src/panels/db/dbRowEdit.ts | 4 +- src/panels/db/dbTableGrid.ts | 4 +- src/panels/db/dbTree.ts | 3 +- tests/core/db/dbEngine.test.ts | 76 +++++++++++++++++++ tests/core/db/pgIdents.test.ts | 33 ++++++++ .../db/sqlQuote.test.ts} | 2 +- tests/panels/db/dbAccess.test.ts | 74 +----------------- tests/panels/db/dbQueryExec.test.ts | 33 +------- 22 files changed, 198 insertions(+), 182 deletions(-) create mode 100644 src/core/db/dbEngine.ts create mode 100644 src/core/db/pgIdents.ts rename src/{panels/db/dbSqlQuote.ts => core/db/sqlQuote.ts} (96%) create mode 100644 tests/core/db/dbEngine.test.ts create mode 100644 tests/core/db/pgIdents.test.ts rename tests/{panels/db/dbSqlQuote.test.ts => core/db/sqlQuote.test.ts} (98%) diff --git a/src/core/db/dbEngine.ts b/src/core/db/dbEngine.ts new file mode 100644 index 0000000..8ff8092 --- /dev/null +++ b/src/core/db/dbEngine.ts @@ -0,0 +1,39 @@ +import type { DbServer, DbKind } from './dbServer' + +// Shape returned by every tabular backend command (SQL rows, EXPLAIN plans…). +export interface TableData { columns: string[]; rows: string[][] } + +export const KIND_LABEL: Record = { + mysql: 'MySQL', mariadb: 'MariaDB', mongodb: 'MongoDB', postgres: 'PostgreSQL', redis: 'Redis', +} + +export const isMongo = (s: DbServer): boolean => s.kind === 'mongodb' +export const isPg = (s: DbServer): boolean => s.kind === 'postgres' +export const isRedis = (s: DbServer): boolean => s.kind === 'redis' + +export const envValue = (env: string[], key: string): string => + env.find(e => e.startsWith(`${key}=`))?.slice(key.length + 1) ?? '' + +// SQL engines share the same grid logic; only the command prefix differs. +export const sqlCmd = (s: DbServer, op: string): string => `db_docker_${isPg(s) ? 'pg' : 'mysql'}_${op}` + +export const creds = (s: DbServer): { user: string; password: string } => + ({ user: s.user ?? '', password: s.password ?? '' }) + +// Where to run: a Docker container, or a local server (empty container → host:port). +export const target = (s: DbServer): { container: string; host: string; port: number } => + ({ container: s.container ?? '', host: s.host, port: s.port }) + +export const sqlEscQ = (v: string): string => v.replace(/'/g, "''") + +export const parseRedisLines = (raw: string): string[] => + raw.split('\n') + .map(l => l.trim()) + .filter(l => /^\d+\)/.test(l)) + .map(l => { + const m = l.match(/^\d+\)\s+(.*)$/) + if (!m) return '' + let v = m[1] + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') + return v + }) diff --git a/src/core/db/pgIdents.ts b/src/core/db/pgIdents.ts new file mode 100644 index 0000000..5415e36 --- /dev/null +++ b/src/core/db/pgIdents.ts @@ -0,0 +1,23 @@ +/** + * Postgres safety net: quotes known table names with uppercase letters if they + * come unquoted (Postgres would lowercase them and fail). Covers what the AI + * forgets to quote. + */ +export const pgFixIdents = (sql: string, names: string[]): string => { + let out = sql + const esc = (t: string): string => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + names.forEach(full => { + if (!full.includes('.')) return + const quotedRight = full.split('.').map(p => `"${p}"`).join('.') + // Wrongly quoted as a single piece: "schema.table" → "schema"."table". + out = out.split(`"${full}"`).join(quotedRight) + }) + names.forEach(full => { + const table = full.includes('.') ? full.split('.').slice(-1)[0] : full + if (!/[A-Z]/.test(table)) return // the rest is only at risk due to uppercase letters + const quotedFull = full.split('.').map(p => `"${p}"`).join('.') + out = out.replace(new RegExp(`(^|[^"\\w.])${esc(full)}(?![\\w"])`, 'g'), `$1${quotedFull}`) + out = out.replace(new RegExp(`(^|[^"\\w.])${esc(table)}(?![\\w"])`, 'g'), `$1"${table}"`) + }) + return out +} diff --git a/src/panels/db/dbSqlQuote.ts b/src/core/db/sqlQuote.ts similarity index 96% rename from src/panels/db/dbSqlQuote.ts rename to src/core/db/sqlQuote.ts index 7153f0e..5fba5c4 100644 --- a/src/panels/db/dbSqlQuote.ts +++ b/src/core/db/sqlQuote.ts @@ -1,5 +1,5 @@ import type { DbServer } from '../../core/db/dbServer' -import { isPg } from './dbAccess' +import { isPg } from './dbEngine' /** Quotes a column or table name for the engine's own identifier syntax. */ export const ident = (s: DbServer, id: string): string => isPg(s) ? `"${id}"` : `\`${id}\`` diff --git a/src/panels/db/dbAccess.ts b/src/panels/db/dbAccess.ts index 5d8263a..8d0e1f9 100644 --- a/src/panels/db/dbAccess.ts +++ b/src/panels/db/dbAccess.ts @@ -1,10 +1,8 @@ import { invoke } from '@tauri-apps/api/core' -import type { DbServer, DbKind } from '../../core/db/dbServer' +import type { DbServer } from '../../core/db/dbServer' +import { isMongo, isPg, isRedis, sqlCmd, creds, target, sqlEscQ, type TableData } from '../../core/db/dbEngine' import type { ForeignKey } from './queryBuilders' -// Shape returned by every tabular backend command (SQL rows, EXPLAIN plans…). -export interface TableData { columns: string[]; rows: string[][] } - // What a grid needs to turn a read-only result into an editable one. export interface EditMeta { s: DbServer @@ -14,41 +12,6 @@ export interface EditMeta { fkColMap: Map } -export const KIND_LABEL: Record = { - mysql: 'MySQL', mariadb: 'MariaDB', mongodb: 'MongoDB', postgres: 'PostgreSQL', redis: 'Redis', -} - -export const isMongo = (s: DbServer): boolean => s.kind === 'mongodb' -export const isPg = (s: DbServer): boolean => s.kind === 'postgres' -export const isRedis = (s: DbServer): boolean => s.kind === 'redis' - -export const envValue = (env: string[], key: string): string => - env.find(e => e.startsWith(`${key}=`))?.slice(key.length + 1) ?? '' - -// SQL engines share the same grid logic; only the command prefix differs. -export const sqlCmd = (s: DbServer, op: string): string => `db_docker_${isPg(s) ? 'pg' : 'mysql'}_${op}` - -export const creds = (s: DbServer): { user: string; password: string } => - ({ user: s.user ?? '', password: s.password ?? '' }) - -// Where to run: a Docker container, or a local server (empty container → host:port). -export const target = (s: DbServer): { container: string; host: string; port: number } => - ({ container: s.container ?? '', host: s.host, port: s.port }) - -export const sqlEscQ = (v: string): string => v.replace(/'/g, "''") - -export const parseRedisLines = (raw: string): string[] => - raw.split('\n') - .map(l => l.trim()) - .filter(l => /^\d+\)/.test(l)) - .map(l => { - const m = l.match(/^\d+\)\s+(.*)$/) - if (!m) return '' - let v = m[1] - if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') - return v - }) - export const listDatabases = (s: DbServer): Promise => { if (isRedis(s)) return invoke('db_docker_redis_dbs', { ...target(s), password: s.password ?? '' }) if (isMongo(s)) return invoke('db_docker_list_mongo', { ...target(s), ...creds(s) }) diff --git a/src/panels/db/dbDetect.ts b/src/panels/db/dbDetect.ts index 3bb5c4b..df92ae5 100644 --- a/src/panels/db/dbDetect.ts +++ b/src/panels/db/dbDetect.ts @@ -4,7 +4,7 @@ import { serverKind } from '../../core/db/serverKind' import { publishedPort } from '../../core/db/hostPort' import { mysqlCreds, mongoCreds, pgCreds } from '../../core/db/credentials' import { DEFAULT_PORT, kindForPort, type DbServer } from '../../core/db/dbServer' -import { isMongo, isPg, isRedis, envValue } from './dbAccess' +import { isMongo, isPg, isRedis, envValue } from '../../core/db/dbEngine' export const detectDocker = async (): Promise => { const raw = await invoke('db_docker_ps').catch(() => '') diff --git a/src/panels/db/dbDocsView.ts b/src/panels/db/dbDocsView.ts index 0aa1947..57580e7 100644 --- a/src/panels/db/dbDocsView.ts +++ b/src/panels/db/dbDocsView.ts @@ -2,7 +2,7 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' -import { creds, target } from './dbAccess' +import { creds, target } from '../../core/db/dbEngine' import { prettyJson } from './dbCellRender' import { note, makeFilterInput } from './dbWidgets' import type { DbDetailHost } from './dbDetailHost' diff --git a/src/panels/db/dbJoinBuilder.ts b/src/panels/db/dbJoinBuilder.ts index 1f9ddde..702720b 100644 --- a/src/panels/db/dbJoinBuilder.ts +++ b/src/panels/db/dbJoinBuilder.ts @@ -2,7 +2,7 @@ import { t as i18nT } from '../../i18n' import type { DbServer } from '../../core/db/dbServer' import { buildJoinPath, type Relation } from '../../core/db/joinPath' import { buildJoinQuery, type ForeignKey } from './queryBuilders' -import { isMongo, isRedis } from './dbAccess' +import { isMongo, isRedis } from '../../core/db/dbEngine' // Unique datalist ids: several DB panels can be open at once. let joinListSeq = 0 diff --git a/src/panels/db/dbOpenData.ts b/src/panels/db/dbOpenData.ts index ad222eb..f46e96e 100644 --- a/src/panels/db/dbOpenData.ts +++ b/src/panels/db/dbOpenData.ts @@ -1,7 +1,8 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' -import { isMongo, isRedis, sqlCmd, creds, target, fetchRelations, type TableData } from './dbAccess' +import { isMongo, isRedis, sqlCmd, creds, target, type TableData } from '../../core/db/dbEngine' +import { fetchRelations } from './dbAccess' import { note } from './dbWidgets' import type { DbDetailHost } from './dbDetailHost' import { renderGrid } from './dbTableGrid' diff --git a/src/panels/db/dbQueryAi.ts b/src/panels/db/dbQueryAi.ts index 3593adc..454662e 100644 --- a/src/panels/db/dbQueryAi.ts +++ b/src/panels/db/dbQueryAi.ts @@ -2,7 +2,7 @@ import { t as i18nT } from '../../i18n' import type { DbServer } from '../../core/db/dbServer' import { askAi, type AiQueryRunner, type AiTool } from '../../ui/askAi' import type { ForeignKey } from './queryBuilders' -import { KIND_LABEL, isMongo, isPg, isRedis } from './dbAccess' +import { KIND_LABEL, isMongo, isPg, isRedis } from '../../core/db/dbEngine' import { note } from './dbWidgets' export interface AiQueryButtonDeps { diff --git a/src/panels/db/dbQueryChips.ts b/src/panels/db/dbQueryChips.ts index 97e0e8e..7bb0b0e 100644 --- a/src/panels/db/dbQueryChips.ts +++ b/src/panels/db/dbQueryChips.ts @@ -1,7 +1,7 @@ import { t as i18nT } from '../../i18n' import type { DbServer } from '../../core/db/dbServer' import { buildRelationQuery, exampleQuery, groupRelations, type ForeignKey } from './queryBuilders' -import { isMongo, isRedis } from './dbAccess' +import { isMongo, isRedis } from '../../core/db/dbEngine' import { note } from './dbWidgets' // A large DB has thousands of tables/relations; painting them all as buttons diff --git a/src/panels/db/dbQueryExec.ts b/src/panels/db/dbQueryExec.ts index 2f0270c..92da300 100644 --- a/src/panels/db/dbQueryExec.ts +++ b/src/panels/db/dbQueryExec.ts @@ -3,32 +3,11 @@ import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' import { withRowLimit } from '../../core/db/rowLimit' import type { ForeignKey } from './queryBuilders' -import { isMongo, isPg, isRedis, sqlCmd, creds, target, type TableData } from './dbAccess' +import { isMongo, isPg, isRedis, sqlCmd, creds, target, type TableData } from '../../core/db/dbEngine' +import { pgFixIdents } from '../../core/db/pgIdents' import { note } from './dbWidgets' import { renderResultTable, preResult } from './dbResultTable' -// Postgres safety net: quotes known table names with uppercase letters if -// they come unquoted (Postgres would lowercase them and fail). Covers what -// the AI forgets to quote. -export const pgFixIdents = (sql: string, names: string[]): string => { - let out = sql - const esc = (t: string): string => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - names.forEach(full => { - if (!full.includes('.')) return - const quotedRight = full.split('.').map(p => `"${p}"`).join('.') - // Wrongly quoted as a single piece: "schema.table" → "schema"."table". - out = out.split(`"${full}"`).join(quotedRight) - }) - names.forEach(full => { - const table = full.includes('.') ? full.split('.').slice(-1)[0] : full - if (!/[A-Z]/.test(table)) return // the rest is only at risk due to uppercase letters - const quotedFull = full.split('.').map(p => `"${p}"`).join('.') - out = out.replace(new RegExp(`(^|[^"\\w.])${esc(full)}(?![\\w"])`, 'g'), `$1${quotedFull}`) - out = out.replace(new RegExp(`(^|[^"\\w.])${esc(table)}(?![\\w"])`, 'g'), `$1"${table}"`) - }) - return out -} - export interface DbQueryRunner { executeQuery: (text: string) => Promise explain: (text: string) => Promise diff --git a/src/panels/db/dbQueryView.ts b/src/panels/db/dbQueryView.ts index a2333bc..0b61059 100644 --- a/src/panels/db/dbQueryView.ts +++ b/src/panels/db/dbQueryView.ts @@ -1,7 +1,8 @@ import { t as i18nT } from '../../i18n' import type { DbServer } from '../../core/db/dbServer' import type { ForeignKey } from './queryBuilders' -import { KIND_LABEL, isMongo, isRedis, fetchColumns, fetchRelations } from './dbAccess' +import { KIND_LABEL, isMongo, isRedis } from '../../core/db/dbEngine' +import { fetchColumns, fetchRelations } from './dbAccess' import { note } from './dbWidgets' import type { DbDetailHost } from './dbDetailHost' import { createQueryHistory } from './dbQueryHistory' diff --git a/src/panels/db/dbRedisView.ts b/src/panels/db/dbRedisView.ts index cf60f97..b7a9df5 100644 --- a/src/panels/db/dbRedisView.ts +++ b/src/panels/db/dbRedisView.ts @@ -2,7 +2,7 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' import { parseStructuredJson } from './jsonValues' -import { target, parseRedisLines } from './dbAccess' +import { target, parseRedisLines } from '../../core/db/dbEngine' import { prettyJson, highlightJson } from './dbCellRender' import { note, copyToClipboard } from './dbWidgets' import type { DbDetailHost } from './dbDetailHost' diff --git a/src/panels/db/dbResultTable.ts b/src/panels/db/dbResultTable.ts index 02642a0..13ab14d 100644 --- a/src/panels/db/dbResultTable.ts +++ b/src/panels/db/dbResultTable.ts @@ -1,6 +1,7 @@ import { t as i18nT } from '../../i18n' import { icon } from '../../ui/icons' -import { type TableData, type EditMeta } from './dbAccess' +import { type TableData } from '../../core/db/dbEngine' +import { type EditMeta } from './dbAccess' import { renderCellValue } from './dbCellRender' import { note, makeFilterInput, makeCsvBtn, makeResultWrap } from './dbWidgets' import { editCell, deleteRow } from './dbRowEdit' diff --git a/src/panels/db/dbRowEdit.ts b/src/panels/db/dbRowEdit.ts index 304b7bc..35a6f17 100644 --- a/src/panels/db/dbRowEdit.ts +++ b/src/panels/db/dbRowEdit.ts @@ -1,10 +1,10 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' -import { isPg, sqlCmd, creds, target, type TableData } from './dbAccess' +import { isPg, sqlCmd, creds, target, type TableData } from '../../core/db/dbEngine' import { renderCellValue } from './dbCellRender' import { buildWheres } from './dbWidgets' -import { ident, qualifiedTable } from './dbSqlQuote' +import { ident, qualifiedTable } from '../../core/db/sqlQuote' export const editCell = ( s: DbServer, db: string, table: string, columns: string[], diff --git a/src/panels/db/dbTableGrid.ts b/src/panels/db/dbTableGrid.ts index 7717fb9..91ac92f 100644 --- a/src/panels/db/dbTableGrid.ts +++ b/src/panels/db/dbTableGrid.ts @@ -3,11 +3,11 @@ import { invoke } from '@tauri-apps/api/core' import type { DbServer } from '../../core/db/dbServer' import { icon } from '../../ui/icons' import { parseStructuredJson } from './jsonValues' -import { sqlCmd, creds, target, type TableData } from './dbAccess' +import { sqlCmd, creds, target, type TableData } from '../../core/db/dbEngine' import { buildJsonTree, renderCellValue } from './dbCellRender' import { note, makeFilterInput, makeCsvBtn, makeResultWrap, copyToClipboard } from './dbWidgets' import { editCell, deleteRow } from './dbRowEdit' -import { ident, qualifiedTable, quoteValue } from './dbSqlQuote' +import { ident, qualifiedTable, quoteValue } from '../../core/db/sqlQuote' import type { DbDetailHost } from './dbDetailHost' export const renderGrid = ( diff --git a/src/panels/db/dbTree.ts b/src/panels/db/dbTree.ts index 3a024f8..8c26eb5 100644 --- a/src/panels/db/dbTree.ts +++ b/src/panels/db/dbTree.ts @@ -1,6 +1,7 @@ import { t as i18nT } from '../../i18n' import { LISTABLE, type DbServer } from '../../core/db/dbServer' -import { KIND_LABEL, isMongo, isRedis, fetchColumns, listDatabases, listTables } from './dbAccess' +import { KIND_LABEL, isMongo, isRedis } from '../../core/db/dbEngine' +import { fetchColumns, listDatabases, listTables } from './dbAccess' import { note, rowEl, appendExpandable } from './dbWidgets' import { resolveCreds } from './dbDetect' diff --git a/tests/core/db/dbEngine.test.ts b/tests/core/db/dbEngine.test.ts new file mode 100644 index 0000000..8ef4d59 --- /dev/null +++ b/tests/core/db/dbEngine.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + KIND_LABEL, isMongo, isPg, isRedis, envValue, sqlCmd, creds, target, sqlEscQ, parseRedisLines, +} from '../../../src/core/db/dbEngine' +import type { DbServer, DbKind } from '../../../src/core/db/dbServer' + +function server(over: Partial = {}): DbServer { + return { kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'db1', ...over } +} + +describe('engine predicates', () => { + it('recognises each engine and treats mariadb as plain SQL', () => { + expect(isMongo(server({ kind: 'mongodb' }))).toBe(true) + expect(isPg(server({ kind: 'postgres' }))).toBe(true) + expect(isRedis(server({ kind: 'redis' }))).toBe(true) + const maria = server({ kind: 'mariadb' }) + expect([isMongo(maria), isPg(maria), isRedis(maria)]).toEqual([false, false, false]) + }) + + it('labels every kind', () => { + const kinds: DbKind[] = ['mysql', 'mariadb', 'mongodb', 'postgres', 'redis'] + kinds.forEach(k => expect(KIND_LABEL[k]).toBeTruthy()) + }) +}) + +describe('invoke argument helpers', () => { + it('routes SQL commands to the pg or mysql backend', () => { + expect(sqlCmd(server({ kind: 'postgres' }), 'rows')).toBe('db_docker_pg_rows') + expect(sqlCmd(server({ kind: 'mysql' }), 'rows')).toBe('db_docker_mysql_rows') + expect(sqlCmd(server({ kind: 'mariadb' }), 'pk')).toBe('db_docker_mysql_pk') + }) + + it('defaults missing credentials to empty strings', () => { + expect(creds(server())).toEqual({ user: '', password: '' }) + expect(creds(server({ user: 'root', password: 'pw' }))).toEqual({ user: 'root', password: 'pw' }) + }) + + it('targets the container when there is one and the host otherwise', () => { + expect(target(server({ container: 'c1' }))).toEqual({ container: 'c1', host: '127.0.0.1', port: 3306 }) + expect(target(server({ source: 'local', container: undefined, host: 'localhost', port: 5432 }))) + .toEqual({ container: '', host: 'localhost', port: 5432 }) + }) +}) + +describe('envValue', () => { + it('reads the value after the first equals sign', () => { + expect(envValue(['A=1', 'MYSQL_ROOT_PASSWORD=p=ss'], 'MYSQL_ROOT_PASSWORD')).toBe('p=ss') + }) + + it('returns empty for a missing key and does not match a key that merely shares a prefix', () => { + expect(envValue(['REDIS_PASSWORD_FILE=/x'], 'REDIS_PASSWORD')).toBe('') + expect(envValue([], 'ANY')).toBe('') + }) +}) + +describe('sqlEscQ', () => { + it('doubles single quotes so a value cannot break out of a literal', () => { + expect(sqlEscQ("O'Brien")).toBe("O''Brien") + expect(sqlEscQ("'; DROP TABLE t; --")).toBe("''; DROP TABLE t; --") + }) +}) + +describe('parseRedisLines', () => { + it('keeps only numbered lines and unwraps quoted values', () => { + const raw = 'some header\n1) "hello"\n2) 42\nnot numbered\n3) "a\\"b"' + expect(parseRedisLines(raw)).toEqual(['hello', '42', 'a"b']) + }) + + it('unescapes backslashes inside quoted values', () => { + expect(parseRedisLines('1) "a\\\\b"')).toEqual(['a\\b']) + }) + + it('returns nothing when no line is numbered', () => { + expect(parseRedisLines('(empty array)')).toEqual([]) + }) +}) diff --git a/tests/core/db/pgIdents.test.ts b/tests/core/db/pgIdents.test.ts new file mode 100644 index 0000000..ee0bef5 --- /dev/null +++ b/tests/core/db/pgIdents.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { pgFixIdents } from '../../../src/core/db/pgIdents' + +describe('pgFixIdents', () => { + it('leaves an all-lowercase name alone', () => { + expect(pgFixIdents('SELECT * FROM users', ['users'])).toBe('SELECT * FROM users') + }) + + it('splits a wrongly quoted schema.table into two quoted parts', () => { + expect(pgFixIdents('SELECT * FROM "public.client"', ['public.client'])) + .toBe('SELECT * FROM "public"."client"') + }) + + it('quotes a mixed-case table so Postgres does not lowercase it', () => { + expect(pgFixIdents('SELECT * FROM Client', ['public.Client'])) + .toBe('SELECT * FROM "Client"') + }) + + it('quotes a qualified mixed-case name in full', () => { + expect(pgFixIdents('SELECT * FROM public.Client', ['public.Client'])) + .toBe('SELECT * FROM "public"."Client"') + }) + + it('does not touch a name that is already quoted', () => { + expect(pgFixIdents('SELECT * FROM "Client"', ['public.Client'])) + .toBe('SELECT * FROM "Client"') + }) + + it('ignores names it does not know', () => { + expect(pgFixIdents('SELECT * FROM Unknown', ['public.Client'])) + .toBe('SELECT * FROM Unknown') + }) +}) diff --git a/tests/panels/db/dbSqlQuote.test.ts b/tests/core/db/sqlQuote.test.ts similarity index 98% rename from tests/panels/db/dbSqlQuote.test.ts rename to tests/core/db/sqlQuote.test.ts index 2a12619..2d6f1b8 100644 --- a/tests/panels/db/dbSqlQuote.test.ts +++ b/tests/core/db/sqlQuote.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ident, qualifiedTable, quoteValue } from '../../../src/panels/db/dbSqlQuote' +import { ident, qualifiedTable, quoteValue } from '../../../src/core/db/sqlQuote' import type { DbServer } from '../../../src/core/db/dbServer' const mysql: DbServer = { kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'c1' } diff --git a/tests/panels/db/dbAccess.test.ts b/tests/panels/db/dbAccess.test.ts index 3e718c1..24f4558 100644 --- a/tests/panels/db/dbAccess.test.ts +++ b/tests/panels/db/dbAccess.test.ts @@ -7,11 +7,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) -import { - KIND_LABEL, isMongo, isPg, isRedis, envValue, sqlCmd, creds, target, sqlEscQ, - parseRedisLines, fetchColumns, listDatabases, listTables, fetchRelations, -} from '../../../src/panels/db/dbAccess' -import type { DbServer, DbKind } from '../../../src/core/db/dbServer' +import { fetchColumns, listDatabases, listTables, fetchRelations } from '../../../src/panels/db/dbAccess' +import type { DbServer } from '../../../src/core/db/dbServer' function server(over: Partial = {}): DbServer { return { kind: 'mysql', source: 'docker', host: '127.0.0.1', port: 3306, container: 'db1', ...over } @@ -22,73 +19,6 @@ beforeEach(() => { mocks.invoke.mockResolvedValue(undefined) }) -describe('engine predicates', () => { - it('recognises each engine and treats mariadb as plain SQL', () => { - expect(isMongo(server({ kind: 'mongodb' }))).toBe(true) - expect(isPg(server({ kind: 'postgres' }))).toBe(true) - expect(isRedis(server({ kind: 'redis' }))).toBe(true) - const maria = server({ kind: 'mariadb' }) - expect([isMongo(maria), isPg(maria), isRedis(maria)]).toEqual([false, false, false]) - }) - - it('labels every kind', () => { - const kinds: DbKind[] = ['mysql', 'mariadb', 'mongodb', 'postgres', 'redis'] - kinds.forEach(k => expect(KIND_LABEL[k]).toBeTruthy()) - }) -}) - -describe('invoke argument helpers', () => { - it('routes SQL commands to the pg or mysql backend', () => { - expect(sqlCmd(server({ kind: 'postgres' }), 'rows')).toBe('db_docker_pg_rows') - expect(sqlCmd(server({ kind: 'mysql' }), 'rows')).toBe('db_docker_mysql_rows') - expect(sqlCmd(server({ kind: 'mariadb' }), 'pk')).toBe('db_docker_mysql_pk') - }) - - it('defaults missing credentials to empty strings', () => { - expect(creds(server())).toEqual({ user: '', password: '' }) - expect(creds(server({ user: 'root', password: 'pw' }))).toEqual({ user: 'root', password: 'pw' }) - }) - - it('targets the container when there is one and the host otherwise', () => { - expect(target(server({ container: 'c1' }))).toEqual({ container: 'c1', host: '127.0.0.1', port: 3306 }) - expect(target(server({ source: 'local', container: undefined, host: 'localhost', port: 5432 }))) - .toEqual({ container: '', host: 'localhost', port: 5432 }) - }) -}) - -describe('envValue', () => { - it('reads the value after the first equals sign', () => { - expect(envValue(['A=1', 'MYSQL_ROOT_PASSWORD=p=ss'], 'MYSQL_ROOT_PASSWORD')).toBe('p=ss') - }) - - it('returns empty for a missing key and does not match a key that merely shares a prefix', () => { - expect(envValue(['REDIS_PASSWORD_FILE=/x'], 'REDIS_PASSWORD')).toBe('') - expect(envValue([], 'ANY')).toBe('') - }) -}) - -describe('sqlEscQ', () => { - it('doubles single quotes so a value cannot break out of a literal', () => { - expect(sqlEscQ("O'Brien")).toBe("O''Brien") - expect(sqlEscQ("'; DROP TABLE t; --")).toBe("''; DROP TABLE t; --") - }) -}) - -describe('parseRedisLines', () => { - it('keeps only numbered lines and unwraps quoted values', () => { - const raw = 'some header\n1) "hello"\n2) 42\nnot numbered\n3) "a\\"b"' - expect(parseRedisLines(raw)).toEqual(['hello', '42', 'a"b']) - }) - - it('unescapes backslashes inside quoted values', () => { - expect(parseRedisLines('1) "a\\\\b"')).toEqual(['a\\b']) - }) - - it('returns nothing when no line is numbered', () => { - expect(parseRedisLines('(empty array)')).toEqual([]) - }) -}) - describe('listDatabases', () => { it('picks the command for each engine', async () => { mocks.invoke.mockResolvedValue([]) diff --git a/tests/panels/db/dbQueryExec.test.ts b/tests/panels/db/dbQueryExec.test.ts index e824476..38e0d27 100644 --- a/tests/panels/db/dbQueryExec.test.ts +++ b/tests/panels/db/dbQueryExec.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) -import { pgFixIdents, createQueryRunner } from '../../../src/panels/db/dbQueryExec' +import { createQueryRunner } from '../../../src/panels/db/dbQueryExec' import type { TableData } from '../../../src/panels/db/dbAccess' import type { ForeignKey } from '../../../src/panels/db/queryBuilders' import type { DbServer } from '../../../src/core/db/dbServer' @@ -31,37 +31,6 @@ beforeEach(() => { mocks.invoke.mockResolvedValue(rows()) }) -describe('pgFixIdents', () => { - it('leaves an all-lowercase name alone', () => { - expect(pgFixIdents('SELECT * FROM users', ['users'])).toBe('SELECT * FROM users') - }) - - it('splits a wrongly quoted schema.table into two quoted parts', () => { - expect(pgFixIdents('SELECT * FROM "public.client"', ['public.client'])) - .toBe('SELECT * FROM "public"."client"') - }) - - it('quotes a mixed-case table so Postgres does not lowercase it', () => { - expect(pgFixIdents('SELECT * FROM Client', ['public.Client'])) - .toBe('SELECT * FROM "Client"') - }) - - it('quotes a qualified mixed-case name in full', () => { - expect(pgFixIdents('SELECT * FROM public.Client', ['public.Client'])) - .toBe('SELECT * FROM "public"."Client"') - }) - - it('does not touch a name that is already quoted', () => { - expect(pgFixIdents('SELECT * FROM "Client"', ['public.Client'])) - .toBe('SELECT * FROM "Client"') - }) - - it('ignores names it does not know', () => { - expect(pgFixIdents('SELECT * FROM Unknown', ['public.Client'])) - .toBe('SELECT * FROM Unknown') - }) -}) - describe('executeQuery dispatch', () => { it('runs a mongosh script and shows the raw output', async () => { mocks.invoke.mockResolvedValue(' two docs ') From 8e2ebf974dc63d7267b58d81c2011ae96af7be4a Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:57:30 +0200 Subject: [PATCH 22/51] refactor: extracted memory formatting helpers and domain types into the core layer Co-Authored-By: Claude Opus 5 --- src/core/memory/memoryFormat.ts | 45 ++++++++++ src/core/memory/memorySource.ts | 45 ++++++++++ src/panels/memory/MemoryPanel.ts | 83 ++---------------- tests/core/memory/memoryFormat.test.ts | 113 +++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 src/core/memory/memoryFormat.ts create mode 100644 src/core/memory/memorySource.ts create mode 100644 tests/core/memory/memoryFormat.test.ts diff --git a/src/core/memory/memoryFormat.ts b/src/core/memory/memoryFormat.ts new file mode 100644 index 0000000..e35466a --- /dev/null +++ b/src/core/memory/memoryFormat.ts @@ -0,0 +1,45 @@ +import { t as i18nT } from '../../i18n' +import { uniqMemoryValues } from './normalize' +import type { MemoryEntry, MemoryKind } from './MemoryEntry' + +export const KIND_LABEL: Record = { + decision: i18nT('memory.decision'), + fact: i18nT('memory.fact'), + task: i18nT('memory.task'), + note: i18nT('common.note'), +} + +export const KIND_OPTIONS: Array = ['all', 'decision', 'fact', 'task', 'note'] + +export const splitList = (value: string): string[] => uniqMemoryValues(value.split(',')) + +export const basename = (value: string): string => value.split(/[\\/]/).filter(Boolean).pop() ?? '' + +export const projectName = (value: string): string => basename(value) || value + +/** The project an imported memory says it was indexed from, if it names one. */ +export const detailProject = (value: string): string | null => { + const match = value.match(/^Proyecto indexado:\s+(.+)$/m) + return match?.[1]?.trim() ?? null +} + +/** The project folder inside a `.lexis/projects//…` path. */ +export const lexisProjectFolder = (value: string): string | null => { + const normalized = value.replace(/\\/g, '/') + const marker = '/.lexis/projects/' + const start = normalized.indexOf(marker) + if (start < 0) return null + const rest = normalized.slice(start + marker.length) + const folder = rest.split('/')[0]?.trim() + return folder || null +} + +export const timeLabel = (iso: string): string => { + try { return new Date(iso).toLocaleString() } catch { return iso } +} + +export const sourceLabel = (value: string): string => value || i18nT('memory.manual') + +/** Only session summaries can be asked for again; the rest are imported as-is. */ +export const canRegenerateSummary = (entry?: MemoryEntry): boolean => + Boolean(entry?.externalId && entry.externalId.includes(':session-summary:')) diff --git a/src/core/memory/memorySource.ts b/src/core/memory/memorySource.ts new file mode 100644 index 0000000..bef0998 --- /dev/null +++ b/src/core/memory/memorySource.ts @@ -0,0 +1,45 @@ +/** An external folder Bento scans for memories to import. */ +export interface MemorySource { + id: string + projectPath: string + kind: 'filesystem' + label: string + path: string + createdAt: string + updatedAt: string +} + +/** A memory found in a source, before the user decides to import it. */ +export interface ImportedMemoryCandidate { + title: string + summary: string + details: string + source: string + externalId: string + createdAt: string + files: string[] + tags: string[] +} + +/** Whether a candidate already exists, so the preview can warn before importing. */ +export interface PreviewCandidateState { + duplicateExternal: boolean + duplicateSemantic: boolean + duplicateTitle?: string +} + +/** A queued request to summarize an agent session into a memory. */ +export interface MemorySummaryJob { + id: string + projectPath: string + agent: 'claude' | 'codex' + sessionId: string + transcriptExternalId: string + transcriptHash: string + status: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped' + error: string + attempts: number + metadataJson: string + createdAt: string + updatedAt: string +} diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 9b6c1ba..25c9f10 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -19,83 +19,16 @@ import { uniqMemoryValues, } from '../../core/memory/normalize' import { matchesMemoryQuery } from '../../core/memory/memorySearch' -import type { MemoryRepository } from '../../ports/MemoryRepository' - -const KIND_LABEL: Record = { - decision: i18nT('memory.decision'), - fact: i18nT('memory.fact'), - task: i18nT('memory.task'), - note: i18nT('common.note'), -} +import { + KIND_LABEL, KIND_OPTIONS, splitList, basename, projectName, + detailProject, lexisProjectFolder, timeLabel, sourceLabel, canRegenerateSummary, +} from '../../core/memory/memoryFormat' +import type { + MemorySource, ImportedMemoryCandidate, PreviewCandidateState, MemorySummaryJob, +} from '../../core/memory/memorySource' -const KIND_OPTIONS: Array = ['all', 'decision', 'fact', 'task', 'note'] const SOURCE_PREVIEW_LIMIT = 200 - -const splitList = (value: string): string[] => uniqMemoryValues(value.split(',')) -const basename = (value: string): string => value.split(/[\\/]/).filter(Boolean).pop() ?? '' -const projectName = (value: string): string => basename(value) || value -const detailProject = (value: string): string | null => { - const match = value.match(/^Proyecto indexado:\s+(.+)$/m) - return match?.[1]?.trim() ?? null -} -const lexisProjectFolder = (value: string): string | null => { - const normalized = value.replace(/\\/g, '/') - const marker = '/.lexis/projects/' - const start = normalized.indexOf(marker) - if (start < 0) return null - const rest = normalized.slice(start + marker.length) - const folder = rest.split('/')[0]?.trim() - return folder || null -} - -const timeLabel = (iso: string): string => { - try { return new Date(iso).toLocaleString() } catch { return iso } -} - -const sourceLabel = (value: string): string => value || i18nT('memory.manual') -const canRegenerateSummary = (entry?: MemoryEntry): boolean => Boolean(entry?.externalId && entry.externalId.includes(':session-summary:')) - -interface MemorySource { - id: string - projectPath: string - kind: 'filesystem' - label: string - path: string - createdAt: string - updatedAt: string -} - -interface ImportedMemoryCandidate { - title: string - summary: string - details: string - source: string - externalId: string - createdAt: string - files: string[] - tags: string[] -} - -interface PreviewCandidateState { - duplicateExternal: boolean - duplicateSemantic: boolean - duplicateTitle?: string -} - -interface MemorySummaryJob { - id: string - projectPath: string - agent: 'claude' | 'codex' - sessionId: string - transcriptExternalId: string - transcriptHash: string - status: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped' - error: string - attempts: number - metadataJson: string - createdAt: string - updatedAt: string -} +import type { MemoryRepository } from '../../ports/MemoryRepository' export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): { element: HTMLElement } { const root = document.createElement('div') diff --git a/tests/core/memory/memoryFormat.test.ts b/tests/core/memory/memoryFormat.test.ts new file mode 100644 index 0000000..20b1ef4 --- /dev/null +++ b/tests/core/memory/memoryFormat.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + KIND_LABEL, KIND_OPTIONS, splitList, basename, projectName, + detailProject, lexisProjectFolder, timeLabel, sourceLabel, canRegenerateSummary, +} from '../../../src/core/memory/memoryFormat' +import type { MemoryEntry, MemoryKind } from '../../../src/core/memory/MemoryEntry' + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: '1', kind: 'note', title: 't', summary: '', details: '', source: '', + tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +describe('kinds', () => { + it('labels every kind', () => { + const kinds: MemoryKind[] = ['decision', 'fact', 'task', 'note'] + kinds.forEach(k => expect(KIND_LABEL[k]).toBeTruthy()) + }) + + it('offers every kind as a filter, plus "all" first', () => { + expect(KIND_OPTIONS[0]).toBe('all') + expect(KIND_OPTIONS.slice(1)).toEqual(['decision', 'fact', 'task', 'note']) + }) +}) + +describe('splitList', () => { + it('splits on commas and trims', () => { + expect(splitList('a, b ,c')).toEqual(['a', 'b', 'c']) + }) + + it('drops empties and duplicates', () => { + expect(splitList('a,,a, ,b')).toEqual(['a', 'b']) + }) + + it('is empty for an empty string', () => { + expect(splitList('')).toEqual([]) + }) +}) + +describe('basename and projectName', () => { + it('takes the last segment of a POSIX or Windows path', () => { + expect(basename('/home/ana/bento')).toBe('bento') + expect(basename('C:\\Users\\ana\\bento')).toBe('bento') + }) + + it('ignores a trailing separator', () => { + expect(basename('/home/ana/bento/')).toBe('bento') + }) + + it('falls back to the whole value when there is no path to strip', () => { + expect(projectName('bento')).toBe('bento') + expect(projectName('/')).toBe('/') + }) +}) + +describe('detailProject', () => { + it('reads the indexed project off its own line', () => { + expect(detailProject('algo\nProyecto indexado: /home/ana/bento \notra cosa')).toBe('/home/ana/bento') + }) + + it('is null when the marker is absent', () => { + expect(detailProject('sin marcador')).toBeNull() + }) +}) + +describe('lexisProjectFolder', () => { + it('picks the folder right after the lexis projects marker', () => { + expect(lexisProjectFolder('/home/ana/.lexis/projects/bento/notes.json')).toBe('bento') + }) + + it('accepts Windows separators', () => { + expect(lexisProjectFolder('C:\\Users\\ana\\.lexis\\projects\\bento\\notes.json')).toBe('bento') + }) + + it('is null outside a lexis projects path or with nothing after the marker', () => { + expect(lexisProjectFolder('/home/ana/other/bento')).toBeNull() + expect(lexisProjectFolder('/home/ana/.lexis/projects/')).toBeNull() + }) +}) + +describe('timeLabel', () => { + it('formats a valid timestamp', () => { + expect(timeLabel('2026-08-23T10:00:00.000Z')).not.toBe('2026-08-23T10:00:00.000Z') + }) + + // The catch was meant to give the raw value back, but Date never throws here: + // an unparseable timestamp renders as "Invalid Date". Behavior kept as-is. + it('renders an unparseable timestamp as Invalid Date', () => { + expect(timeLabel('not a date')).toBe('Invalid Date') + }) +}) + +describe('sourceLabel', () => { + it('shows the source when there is one', () => { + expect(sourceLabel('claude')).toBe('claude') + }) + + it('falls back to a manual label when there is none', () => { + expect(sourceLabel('')).toBeTruthy() + expect(sourceLabel('')).not.toBe('') + }) +}) + +describe('canRegenerateSummary', () => { + it('is true only for a session-summary entry', () => { + expect(canRegenerateSummary(entry({ externalId: 'claude:session-summary:abc' }))).toBe(true) + }) + + it('is false for another external entry, a manual one, or none at all', () => { + expect(canRegenerateSummary(entry({ externalId: 'claude:transcript:abc' }))).toBe(false) + expect(canRegenerateSummary(entry())).toBe(false) + expect(canRegenerateSummary(undefined)).toBe(false) + }) +}) From 21abab0f1a64a3ed7eb1feb29f3ab54cc38c6034 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:58:47 +0200 Subject: [PATCH 23/51] refactor: extracted import candidate grouping and duplicate detection into core Co-Authored-By: Claude Opus 5 --- src/core/memory/memoryCandidates.ts | 63 ++++++++++++++++ src/panels/memory/MemoryPanel.ts | 43 +---------- tests/core/memory/memoryCandidates.test.ts | 84 ++++++++++++++++++++++ 3 files changed, 150 insertions(+), 40 deletions(-) create mode 100644 src/core/memory/memoryCandidates.ts create mode 100644 tests/core/memory/memoryCandidates.test.ts diff --git a/src/core/memory/memoryCandidates.ts b/src/core/memory/memoryCandidates.ts new file mode 100644 index 0000000..085e69f --- /dev/null +++ b/src/core/memory/memoryCandidates.ts @@ -0,0 +1,63 @@ +import type { MemoryEntry, NewMemoryEntry } from './MemoryEntry' +import { findSemanticallyDuplicate, normalizeNewMemoryEntry } from './normalize' +import { detailProject, lexisProjectFolder, projectName } from './memoryFormat' +import type { ImportedMemoryCandidate, PreviewCandidateState } from './memorySource' + +// Absolute paths that identify a real project rather than the lexis index. +const isAbsoluteProjectPath = (file: string): boolean => + file.startsWith('/Users/') || file.startsWith('/private/') || file.startsWith('/var/') + +const LEXIS_INDEX_MARKER = '/.lexis/projects/' +const LEXIS_TITLE_PREFIX = /^Lexis snapshot ·\s*/ + +/** + * Which project a candidate belongs to, so the preview can group by it. Lexis + * snapshots hide it in several places, tried here from most to least reliable. + */ +export const candidateProject = (candidate: ImportedMemoryCandidate): string => { + const isLexisSnapshot = candidate.source.startsWith('source:') && candidate.tags.includes('lexis') + if (!isLexisSnapshot) return projectName(candidate.files[0] || candidate.externalId) + + const detailed = detailProject(candidate.details) + if (detailed) return projectName(detailed) + + const absoluteProject = candidate.files.find(isAbsoluteProjectPath) + if (absoluteProject && !absoluteProject.includes(LEXIS_INDEX_MARKER)) return projectName(absoluteProject) + + const lexisIndex = candidate.files.find(file => file.includes(LEXIS_INDEX_MARKER)) + const folder = lexisIndex ? lexisProjectFolder(lexisIndex) : null + if (folder) return folder + + const titled = candidate.title.replace(LEXIS_TITLE_PREFIX, '').trim() + if (titled && titled !== candidate.title) return titled + + return 'Proyecto desconocido' +} + +/** Whether importing this candidate would duplicate something already stored. */ +export const computePreviewCandidateState = ( + projectPath: string, candidate: ImportedMemoryCandidate, existing: MemoryEntry[], +): PreviewCandidateState => { + const payload: NewMemoryEntry = { + kind: 'note', + title: candidate.title, + summary: candidate.summary, + details: candidate.details, + source: candidate.source, + externalId: candidate.externalId, + files: candidate.files, + tags: candidate.tags, + createdAt: candidate.createdAt, + updatedAt: candidate.createdAt, + } + const normalized = normalizeNewMemoryEntry(projectPath, payload) + const duplicateExternal = existing.some(entry => entry.externalId === normalized.externalId) + const duplicate = duplicateExternal + ? existing.find(entry => entry.externalId === normalized.externalId) + : findSemanticallyDuplicate(existing, normalized) + return { + duplicateExternal, + duplicateSemantic: !duplicateExternal && Boolean(duplicate), + duplicateTitle: duplicate?.title || undefined, + } +} diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 25c9f10..782d19a 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -21,8 +21,9 @@ import { import { matchesMemoryQuery } from '../../core/memory/memorySearch' import { KIND_LABEL, KIND_OPTIONS, splitList, basename, projectName, - detailProject, lexisProjectFolder, timeLabel, sourceLabel, canRegenerateSummary, + timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' +import { candidateProject, computePreviewCandidateState } from '../../core/memory/memoryCandidates' import type { MemorySource, ImportedMemoryCandidate, PreviewCandidateState, MemorySummaryJob, } from '../../core/memory/memorySource' @@ -302,21 +303,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): if (previewSourceId === '__draft__') return sourceLabelInput.value.trim() || basename(sourcePathInput.value.trim()) || i18nT('memory.currentSelection') return currentSource()?.label ?? i18nT('memory.currentSelection') } - const candidateProject = (candidate: ImportedMemoryCandidate): string => { - if (candidate.source.startsWith('source:') && candidate.tags.includes('lexis')) { - const detailed = detailProject(candidate.details) - if (detailed) return projectName(detailed) - const absoluteProject = candidate.files.find(file => file.startsWith('/Users/') || file.startsWith('/private/') || file.startsWith('/var/')) - if (absoluteProject && !absoluteProject.includes('/.lexis/projects/')) return projectName(absoluteProject) - const lexisIndex = candidate.files.find(file => file.includes('/.lexis/projects/')) - const folder = lexisIndex ? lexisProjectFolder(lexisIndex) : null - if (folder) return folder - const titled = candidate.title.replace(/^Lexis snapshot ·\s*/, '').trim() - if (titled && titled !== candidate.title) return titled - return 'Proyecto desconocido' - } - return projectName(candidate.files[0] || candidate.externalId) - } const visiblePreviewCandidates = (): ImportedMemoryCandidate[] => previewCandidates.filter(candidate => { if (selectedSourceProject === 'all') return true return candidateProject(candidate) === selectedSourceProject @@ -417,34 +403,11 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): clearVisiblePreviewBtn.disabled = visibleCount === 0 || selectedCount === 0 } - const computePreviewCandidateState = (candidate: ImportedMemoryCandidate, existing: MemoryEntry[]): PreviewCandidateState => { - const payload: NewMemoryEntry = { - kind: 'note', - title: candidate.title, - summary: candidate.summary, - details: candidate.details, - source: candidate.source, - externalId: candidate.externalId, - files: candidate.files, - tags: candidate.tags, - createdAt: candidate.createdAt, - updatedAt: candidate.createdAt, - } - const normalized = normalizeNewMemoryEntry(currentProject, payload) - const duplicateExternal = existing.some(entry => entry.externalId === normalized.externalId) - const duplicate = duplicateExternal ? existing.find(entry => entry.externalId === normalized.externalId) : findSemanticallyDuplicate(existing, normalized) - return { - duplicateExternal, - duplicateSemantic: !duplicateExternal && Boolean(duplicate), - duplicateTitle: duplicate?.title || undefined, - } - } - const refreshPreviewCandidateState = async (): Promise => { previewCandidateState.clear() if (!previewCandidates.length) return const existing = await targetProjectEntries() - previewCandidates.forEach(candidate => previewCandidateState.set(candidate.externalId, computePreviewCandidateState(candidate, existing))) + previewCandidates.forEach(candidate => previewCandidateState.set(candidate.externalId, computePreviewCandidateState(currentProject, candidate, existing))) } const syncSourceForm = (): void => { diff --git a/tests/core/memory/memoryCandidates.test.ts b/tests/core/memory/memoryCandidates.test.ts new file mode 100644 index 0000000..ddeffcc --- /dev/null +++ b/tests/core/memory/memoryCandidates.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { candidateProject, computePreviewCandidateState } from '../../../src/core/memory/memoryCandidates' +import type { ImportedMemoryCandidate } from '../../../src/core/memory/memorySource' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' + +const candidate = (over: Partial = {}): ImportedMemoryCandidate => ({ + title: 'A title', summary: 's', details: '', source: 'claude', + externalId: 'claude:abc', createdAt: '2026-01-01T00:00:00.000Z', files: [], tags: [], ...over, +}) + +const lexis = (over: Partial = {}): ImportedMemoryCandidate => + candidate({ source: 'source:1', tags: ['lexis'], ...over }) + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: '1', projectPath: '/p', kind: 'note', title: 'A title', summary: 's', details: '', + source: 'claude', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +describe('candidateProject for ordinary candidates', () => { + it('names the project after the first file', () => { + expect(candidateProject(candidate({ files: ['/home/ana/bento/src/a.ts'] }))).toBe('a.ts') + }) + + it('falls back to the external id when there are no files', () => { + expect(candidateProject(candidate({ files: [], externalId: 'claude:abc' }))).toBe('claude:abc') + }) +}) + +describe('candidateProject for lexis snapshots', () => { + it('prefers the project the details name', () => { + expect(candidateProject(lexis({ details: 'Proyecto indexado: /home/ana/bento' }))).toBe('bento') + }) + + it('then an absolute file that is not the lexis index itself', () => { + expect(candidateProject(lexis({ files: ['/Users/ana/bento'] }))).toBe('bento') + }) + + it('then the folder inside the lexis index path', () => { + expect(candidateProject(lexis({ files: ['/Users/ana/.lexis/projects/bento/notes.json'] }))).toBe('bento') + }) + + it('then whatever the title says after the snapshot prefix', () => { + expect(candidateProject(lexis({ title: 'Lexis snapshot · bento' }))).toBe('bento') + }) + + it('gives up with a placeholder when nothing identifies the project', () => { + expect(candidateProject(lexis({ title: 'Untitled' }))).toBe('Proyecto desconocido') + }) + + it('treats a candidate without the lexis tag as an ordinary one', () => { + const notLexis = candidate({ source: 'source:1', tags: [], details: 'Proyecto indexado: /home/ana/bento', files: ['/x/y.ts'] }) + expect(candidateProject(notLexis)).toBe('y.ts') + }) +}) + +describe('computePreviewCandidateState', () => { + it('reports no duplicate against an empty project', () => { + expect(computePreviewCandidateState('/p', candidate(), [])).toEqual({ + duplicateExternal: false, duplicateSemantic: false, duplicateTitle: undefined, + }) + }) + + it('flags an exact re-import by external id and names it', () => { + const state = computePreviewCandidateState('/p', candidate({ externalId: 'claude:abc' }), + [entry({ externalId: 'claude:abc', title: 'Already here' })]) + expect(state.duplicateExternal).toBe(true) + expect(state.duplicateSemantic).toBe(false) + expect(state.duplicateTitle).toBe('Already here') + }) + + it('flags a semantic duplicate when the ids differ but the content matches', () => { + const state = computePreviewCandidateState('/p', candidate({ externalId: 'claude:new', title: 'A title' }), + [entry({ externalId: 'claude:old', title: 'A title' })]) + expect(state.duplicateExternal).toBe(false) + expect(state.duplicateSemantic).toBe(true) + expect(state.duplicateTitle).toBe('A title') + }) + + it('never reports both kinds of duplicate at once', () => { + const state = computePreviewCandidateState('/p', candidate({ externalId: 'claude:abc' }), + [entry({ externalId: 'claude:abc' })]) + expect(state.duplicateExternal && state.duplicateSemantic).toBe(false) + }) +}) From aa56959e898285c1eab577d92cf1ca8bbf74c3a4 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 11:59:38 +0200 Subject: [PATCH 24/51] refactor: extracted memory list filtering into core Co-Authored-By: Claude Opus 5 --- src/core/memory/memoryFilter.ts | 19 +++++++ src/panels/memory/MemoryPanel.ts | 18 +++--- tests/core/memory/memoryFilter.test.ts | 79 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 src/core/memory/memoryFilter.ts create mode 100644 tests/core/memory/memoryFilter.test.ts diff --git a/src/core/memory/memoryFilter.ts b/src/core/memory/memoryFilter.ts new file mode 100644 index 0000000..04ce75f --- /dev/null +++ b/src/core/memory/memoryFilter.ts @@ -0,0 +1,19 @@ +import type { MemoryEntry, MemoryKind } from './MemoryEntry' +import { isArchivedMemory } from './normalize' +import { matchesMemoryQuery } from './memorySearch' + +export interface MemoryFilter { + query: string + kind: MemoryKind | 'all' + source: string + includeArchived: boolean +} + +/** The entries the list should show for the current filters. */ +export const filterMemoryEntries = (entries: MemoryEntry[], filter: MemoryFilter): MemoryEntry[] => + entries.filter(entry => { + if (!filter.includeArchived && isArchivedMemory(entry)) return false + if (filter.kind !== 'all' && entry.kind !== filter.kind) return false + if (filter.source !== 'all' && entry.source !== filter.source) return false + return matchesMemoryQuery(entry, filter.query) + }) diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 782d19a..1dd9ae4 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -18,7 +18,7 @@ import { toggleMemoryTag, uniqMemoryValues, } from '../../core/memory/normalize' -import { matchesMemoryQuery } from '../../core/memory/memorySearch' +import { filterMemoryEntries } from '../../core/memory/memoryFilter' import { KIND_LABEL, KIND_OPTIONS, splitList, basename, projectName, timeLabel, sourceLabel, canRegenerateSummary, @@ -318,16 +318,12 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): } const selectedPreviewCount = (): number => previewCheckedIds().size - const visibleRows = (): MemoryEntry[] => { - const kindValue = kindFilter.value as MemoryKind | 'all' - const sourceValue = sourceFilter.value - return entries.filter(entry => { - if (!archivedCheckbox.checked && isArchivedMemory(entry)) return false - if (kindValue !== 'all' && entry.kind !== kindValue) return false - if (sourceValue !== 'all' && entry.source !== sourceValue) return false - return matchesMemoryQuery(entry, search.value) - }) - } + const visibleRows = (): MemoryEntry[] => filterMemoryEntries(entries, { + query: search.value, + kind: kindFilter.value as MemoryKind | 'all', + source: sourceFilter.value, + includeArchived: archivedCheckbox.checked, + }) const selectedRows = (): MemoryEntry[] => entries.filter(entry => selectedIds.has(entry.id)) const targetProjectEntries = async (): Promise => { diff --git a/tests/core/memory/memoryFilter.test.ts b/tests/core/memory/memoryFilter.test.ts new file mode 100644 index 0000000..0e2854d --- /dev/null +++ b/tests/core/memory/memoryFilter.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { filterMemoryEntries, type MemoryFilter } from '../../../src/core/memory/memoryFilter' +import { MEMORY_ARCHIVED_TAG } from '../../../src/core/memory/normalize' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: '1', projectPath: '/p', kind: 'note', title: 'A title', summary: '', details: '', + source: 'claude', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +const filter = (over: Partial = {}): MemoryFilter => + ({ query: '', kind: 'all', source: 'all', includeArchived: false, ...over }) + +const ids = (rows: MemoryEntry[]): string[] => rows.map(r => r.id) + +const ENTRIES = [ + entry({ id: 'a', kind: 'decision', source: 'claude', title: 'Chose SQLite' }), + entry({ id: 'b', kind: 'note', source: 'codex', title: 'Random note' }), + entry({ id: 'c', kind: 'note', source: 'claude', title: 'Old thing', tags: [MEMORY_ARCHIVED_TAG] }), +] + +describe('defaults', () => { + it('returns everything that is not archived', () => { + expect(ids(filterMemoryEntries(ENTRIES, filter()))).toEqual(['a', 'b']) + }) + + it('includes archived entries when asked to', () => { + expect(ids(filterMemoryEntries(ENTRIES, filter({ includeArchived: true })))).toEqual(['a', 'b', 'c']) + }) + + it('returns an empty list for no entries', () => { + expect(filterMemoryEntries([], filter())).toEqual([]) + }) +}) + +describe('kind', () => { + it('keeps only the chosen kind', () => { + expect(ids(filterMemoryEntries(ENTRIES, filter({ kind: 'decision' })))).toEqual(['a']) + }) + + it('keeps every kind on "all"', () => { + expect(filterMemoryEntries(ENTRIES, filter({ kind: 'all' }))).toHaveLength(2) + }) +}) + +describe('source', () => { + it('keeps only the chosen source', () => { + expect(ids(filterMemoryEntries(ENTRIES, filter({ source: 'codex' })))).toEqual(['b']) + }) + + it('keeps every source on "all"', () => { + expect(filterMemoryEntries(ENTRIES, filter({ source: 'all' }))).toHaveLength(2) + }) +}) + +describe('query', () => { + it('matches the text against the entry, case-insensitively', () => { + expect(ids(filterMemoryEntries(ENTRIES, filter({ query: 'SQLITE' })))).toEqual(['a']) + }) + + it('keeps everything for an empty query', () => { + expect(filterMemoryEntries(ENTRIES, filter({ query: ' ' }))).toHaveLength(2) + }) +}) + +describe('combining filters', () => { + it('applies every filter at once', () => { + const rows = filterMemoryEntries(ENTRIES, filter({ kind: 'note', source: 'claude', includeArchived: true })) + expect(ids(rows)).toEqual(['c']) + }) + + it('can end up with nothing', () => { + expect(filterMemoryEntries(ENTRIES, filter({ kind: 'decision', source: 'codex' }))).toEqual([]) + }) + + it('excludes an archived entry even when it matches everything else', () => { + expect(filterMemoryEntries(ENTRIES, filter({ query: 'Old thing' }))).toEqual([]) + }) +}) From 166bec55648bd43519a922fe2bf2e30d81ab51b6 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 12:02:10 +0200 Subject: [PATCH 25/51] refactor: extracted the import skip/merge/create decision into core Co-Authored-By: Claude Opus 5 --- src/core/memory/memoryImportPlan.ts | 52 +++++++++++++++ src/panels/memory/MemoryPanel.ts | 34 +++------- tests/core/memory/memoryImportPlan.test.ts | 73 ++++++++++++++++++++++ 3 files changed, 133 insertions(+), 26 deletions(-) create mode 100644 src/core/memory/memoryImportPlan.ts create mode 100644 tests/core/memory/memoryImportPlan.test.ts diff --git a/src/core/memory/memoryImportPlan.ts b/src/core/memory/memoryImportPlan.ts new file mode 100644 index 0000000..420c2b7 --- /dev/null +++ b/src/core/memory/memoryImportPlan.ts @@ -0,0 +1,52 @@ +import type { MemoryEntry, NewMemoryEntry } from './MemoryEntry' +import { findSemanticallyDuplicate, normalizeNewMemoryEntry, uniqMemoryValues } from './normalize' +import type { ImportedMemoryCandidate } from './memorySource' + +/** What importing one candidate should do, given what the project already holds. */ +export type ImportDecision = + | { action: 'skip'; entryId: string } + | { action: 'merge'; entry: MemoryEntry; patch: Partial } + | { action: 'create'; payload: NewMemoryEntry } + +/** A candidate as a storable entry. Imported memories are always notes. */ +export const candidatePayload = (candidate: ImportedMemoryCandidate, updatedAt: string): NewMemoryEntry => ({ + kind: 'note', + title: candidate.title, + summary: candidate.summary, + details: candidate.details, + source: candidate.source, + externalId: candidate.externalId, + files: candidate.files, + tags: candidate.tags, + createdAt: candidate.createdAt, + updatedAt, +}) + +type NormalizedMemory = Pick + +// Merging keeps the richer text and the union of the metadata. +const mergePatch = (duplicate: MemoryEntry, incoming: NormalizedMemory): Partial => ({ + tags: uniqMemoryValues([...duplicate.tags, ...incoming.tags]), + files: uniqMemoryValues([...duplicate.files, ...incoming.files]), + summary: duplicate.summary.length >= incoming.summary.length ? duplicate.summary : incoming.summary, + details: duplicate.details.length >= incoming.details.length ? duplicate.details : incoming.details, +}) + +/** + * Decides how to import one candidate: skip what was already imported under the + * same external id, merge into a semantically equal entry, or create a new one. + */ +export const planCandidateImport = ( + projectPath: string, candidate: ImportedMemoryCandidate, existing: MemoryEntry[], +): ImportDecision => { + const payload = candidatePayload(candidate, new Date().toISOString()) + const normalized = normalizeNewMemoryEntry(projectPath, payload) + + const alreadyImported = existing.find(entry => entry.externalId === normalized.externalId) + if (alreadyImported) return { action: 'skip', entryId: alreadyImported.id } + + const duplicate = findSemanticallyDuplicate(existing, normalized) + if (duplicate) return { action: 'merge', entry: duplicate, patch: mergePatch(duplicate, normalized) } + + return { action: 'create', payload } +} diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 1dd9ae4..36d7d80 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -24,6 +24,7 @@ import { timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' import { candidateProject, computePreviewCandidateState } from '../../core/memory/memoryCandidates' +import { planCandidateImport } from '../../core/memory/memoryImportPlan' import type { MemorySource, ImportedMemoryCandidate, PreviewCandidateState, MemorySummaryJob, } from '../../core/memory/memorySource' @@ -674,38 +675,19 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): let lastAffectedId: string | null = null for (const [index, candidate] of candidates.entries()) { setSourceActivity(i18nT('memory.importingProgress', { label: item.label, current: index + 1, total: candidates.length }), ((index + 1) / Math.max(candidates.length, 1)) * 100) - const payload: NewMemoryEntry = { - kind: 'note', - title: candidate.title, - summary: candidate.summary, - details: candidate.details, - source: candidate.source, - externalId: candidate.externalId, - files: candidate.files, - tags: candidate.tags, - createdAt: candidate.createdAt, - updatedAt: new Date().toISOString(), - } - const normalized = normalizeNewMemoryEntry(currentProject, payload) - const existingExternal = existing.find(entry => entry.externalId === normalized.externalId) - if (existingExternal) { - lastAffectedId = existingExternal.id + const plan = planCandidateImport(currentProject, candidate, existing) + if (plan.action === 'skip') { + lastAffectedId = plan.entryId skipped++ continue } - const duplicate = findSemanticallyDuplicate(existing, normalized) - if (duplicate) { - const updated = await repo.update(currentProject, duplicate.id, { - tags: uniqMemoryValues([...duplicate.tags, ...normalized.tags]), - files: uniqMemoryValues([...duplicate.files, ...normalized.files]), - summary: duplicate.summary.length >= normalized.summary.length ? duplicate.summary : normalized.summary, - details: duplicate.details.length >= normalized.details.length ? duplicate.details : normalized.details, - }) - lastAffectedId = updated?.id ?? duplicate.id + if (plan.action === 'merge') { + const updated = await repo.update(currentProject, plan.entry.id, plan.patch) + lastAffectedId = updated?.id ?? plan.entry.id merged++ continue } - const created = await repo.create(currentProject, payload) + const created = await repo.create(currentProject, plan.payload) existing.unshift(created) lastAffectedId = created.id saved++ diff --git a/tests/core/memory/memoryImportPlan.test.ts b/tests/core/memory/memoryImportPlan.test.ts new file mode 100644 index 0000000..b094bf2 --- /dev/null +++ b/tests/core/memory/memoryImportPlan.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { candidatePayload, planCandidateImport } from '../../../src/core/memory/memoryImportPlan' +import type { ImportedMemoryCandidate } from '../../../src/core/memory/memorySource' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' + +const candidate = (over: Partial = {}): ImportedMemoryCandidate => ({ + title: 'A title', summary: 'short', details: 'brief', source: 'claude', + externalId: 'claude:new', createdAt: '2026-01-01T00:00:00.000Z', files: ['a.ts'], tags: ['x'], ...over, +}) + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: 'e1', projectPath: '/p', kind: 'note', title: 'A title', summary: 'short', details: 'brief', + source: 'claude', externalId: 'claude:old', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +describe('candidatePayload', () => { + it('carries the candidate over as a note, stamped with the given update time', () => { + const payload = candidatePayload(candidate(), '2026-08-23T00:00:00.000Z') + expect(payload).toMatchObject({ + kind: 'note', title: 'A title', source: 'claude', externalId: 'claude:new', + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-08-23T00:00:00.000Z', + }) + }) +}) + +describe('planCandidateImport', () => { + it('creates when nothing like it exists', () => { + const plan = planCandidateImport('/p', candidate(), []) + expect(plan.action).toBe('create') + if (plan.action === 'create') expect(plan.payload.externalId).toBe('claude:new') + }) + + it('skips a candidate already imported under the same external id', () => { + const plan = planCandidateImport('/p', candidate({ externalId: 'claude:same' }), + [entry({ id: 'kept', externalId: 'claude:same' })]) + expect(plan).toEqual({ action: 'skip', entryId: 'kept' }) + }) + + it('merges into a semantically equal entry rather than duplicating it', () => { + const plan = planCandidateImport('/p', candidate(), [entry({ id: 'dup', title: 'A title' })]) + expect(plan.action).toBe('merge') + if (plan.action === 'merge') expect(plan.entry.id).toBe('dup') + }) +}) + +describe('the merge patch', () => { + // The entries must actually look alike for a merge to be planned: similarity + // is containment of title + summary + details. + const merge = (existingOver: Partial, candOver: Partial = {}) => { + const plan = planCandidateImport('/p', candidate(candOver), [entry({ id: 'dup', ...existingOver })]) + if (plan.action !== 'merge') throw new Error('expected a merge') + return plan.patch + } + + it('unions tags and files without duplicating them', () => { + const patch = merge({ tags: ['x', 'y'], files: ['a.ts', 'b.ts'] }) + expect(patch.tags).toEqual(['x', 'y']) + expect(patch.files).toEqual(['a.ts', 'b.ts']) + }) + + it('keeps the details it already had when they say more', () => { + expect(merge({ details: 'brief and then some' }).details).toBe('brief and then some') + }) + + it('takes the incoming details when they say more', () => { + expect(merge({}, { details: 'brief and then some' }).details).toBe('brief and then some') + }) + + it('keeps what it already had on a tie', () => { + expect(merge({}).summary).toBe('short') + expect(merge({}).details).toBe('brief') + }) +}) From 7d9f86dbb7277187d4dfdab528125a2ab05c4a19 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 12:13:40 +0200 Subject: [PATCH 26/51] fix: raised the coverage run heap so the larger test suite fits Co-Authored-By: Claude Opus 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ae2d119..e09ee2a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dev": "vite", "build": "vite build", "test": "vitest run", - "test:coverage": "vitest run --coverage", + "test:coverage": "node --max-old-space-size=6144 ./node_modules/vitest/vitest.mjs run --coverage", "test:watch": "vitest", "lint": "eslint src tests scripts", "typecheck": "tsc --noEmit", From 2f7ce26eeddfb0b0a5b4d7e623027341867da881 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 14:53:53 +0200 Subject: [PATCH 27/51] refactor: unified the two candidate import loops behind a shared runner Co-Authored-By: Claude Opus 5 --- src/panels/memory/MemoryPanel.ts | 81 +++------------- src/panels/memory/memoryImportRunner.ts | 51 ++++++++++ .../panels/memory/memoryImportRunner.test.ts | 93 +++++++++++++++++++ 3 files changed, 159 insertions(+), 66 deletions(-) create mode 100644 src/panels/memory/memoryImportRunner.ts create mode 100644 tests/panels/memory/memoryImportRunner.test.ts diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 36d7d80..f9b04e6 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -24,7 +24,7 @@ import { timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' import { candidateProject, computePreviewCandidateState } from '../../core/memory/memoryCandidates' -import { planCandidateImport } from '../../core/memory/memoryImportPlan' +import { runCandidateImport } from './memoryImportRunner' import type { MemorySource, ImportedMemoryCandidate, PreviewCandidateState, MemorySummaryJob, } from '../../core/memory/memorySource' @@ -669,29 +669,13 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): limit: 50, }) const existing = await targetProjectEntries() - let saved = 0 - let merged = 0 - let skipped = 0 - let lastAffectedId: string | null = null - for (const [index, candidate] of candidates.entries()) { - setSourceActivity(i18nT('memory.importingProgress', { label: item.label, current: index + 1, total: candidates.length }), ((index + 1) / Math.max(candidates.length, 1)) * 100) - const plan = planCandidateImport(currentProject, candidate, existing) - if (plan.action === 'skip') { - lastAffectedId = plan.entryId - skipped++ - continue - } - if (plan.action === 'merge') { - const updated = await repo.update(currentProject, plan.entry.id, plan.patch) - lastAffectedId = updated?.id ?? plan.entry.id - merged++ - continue - } - const created = await repo.create(currentProject, plan.payload) - existing.unshift(created) - lastAffectedId = created.id - saved++ - } + const { saved, merged, skipped, lastAffectedId } = await runCandidateImport( + repo, currentProject, candidates, existing, + (current, total) => setSourceActivity( + i18nT('memory.importingProgress', { label: item.label, current, total }), + (current / Math.max(total, 1)) * 100, + ), + ) await reload() revealMemoryEntry(lastAffectedId) await reloadSources() @@ -1029,48 +1013,13 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): importSelectedSourceBtn.disabled = true setStatus(i18nT('memory.importingSelected', { count: candidates.length, label: sourceLabel })) const existing = await targetProjectEntries() - let saved = 0 - let merged = 0 - let skipped = 0 - let lastAffectedId: string | null = null - for (const [index, candidate] of candidates.entries()) { - setSourceActivity(i18nT('memory.importingSelectionProgress', { current: index + 1, total: candidates.length }), ((index + 1) / Math.max(candidates.length, 1)) * 100) - const payload: NewMemoryEntry = { - kind: 'note', - title: candidate.title, - summary: candidate.summary, - details: candidate.details, - source: candidate.source, - externalId: candidate.externalId, - files: candidate.files, - tags: candidate.tags, - createdAt: candidate.createdAt, - updatedAt: new Date().toISOString(), - } - const normalized = normalizeNewMemoryEntry(currentProject, payload) - const existingExternal = existing.find(entry => entry.externalId === normalized.externalId) - if (existingExternal) { - lastAffectedId = existingExternal.id - skipped++ - continue - } - const duplicate = findSemanticallyDuplicate(existing, normalized) - if (duplicate) { - const updated = await repo.update(currentProject, duplicate.id, { - tags: uniqMemoryValues([...duplicate.tags, ...normalized.tags]), - files: uniqMemoryValues([...duplicate.files, ...normalized.files]), - summary: duplicate.summary.length >= normalized.summary.length ? duplicate.summary : normalized.summary, - details: duplicate.details.length >= normalized.details.length ? duplicate.details : normalized.details, - }) - lastAffectedId = updated?.id ?? duplicate.id - merged++ - continue - } - const created = await repo.create(currentProject, payload) - existing.unshift(created) - lastAffectedId = created.id - saved++ - } + const { saved, merged, skipped, lastAffectedId } = await runCandidateImport( + repo, currentProject, candidates, existing, + (current, total) => setSourceActivity( + i18nT('memory.importingSelectionProgress', { current, total }), + (current / Math.max(total, 1)) * 100, + ), + ) await reload() revealMemoryEntry(lastAffectedId) await refreshPreviewCandidateState() diff --git a/src/panels/memory/memoryImportRunner.ts b/src/panels/memory/memoryImportRunner.ts new file mode 100644 index 0000000..95c7240 --- /dev/null +++ b/src/panels/memory/memoryImportRunner.ts @@ -0,0 +1,51 @@ +import type { MemoryEntry } from '../../core/memory/MemoryEntry' +import type { ImportedMemoryCandidate } from '../../core/memory/memorySource' +import { planCandidateImport } from '../../core/memory/memoryImportPlan' +import type { MemoryRepository } from '../../ports/MemoryRepository' + +export interface ImportOutcome { + saved: number + merged: number + skipped: number + /** The entry the caller should reveal: the last one created, merged or skipped. */ + lastAffectedId: string | null +} + +/** + * Imports candidates one by one, deciding each against what the project holds. + * Entries created along the way join that set, so a repeat within the same run + * merges instead of landing twice. + */ +export async function runCandidateImport( + repo: MemoryRepository, + projectPath: string, + candidates: ImportedMemoryCandidate[], + existing: MemoryEntry[], + onProgress?: (current: number, total: number) => void, +): Promise { + const known = [...existing] + const outcome: ImportOutcome = { saved: 0, merged: 0, skipped: 0, lastAffectedId: null } + + for (const [index, candidate] of candidates.entries()) { + onProgress?.(index + 1, candidates.length) + const plan = planCandidateImport(projectPath, candidate, known) + + if (plan.action === 'skip') { + outcome.lastAffectedId = plan.entryId + outcome.skipped++ + continue + } + if (plan.action === 'merge') { + const updated = await repo.update(projectPath, plan.entry.id, plan.patch) + outcome.lastAffectedId = updated?.id ?? plan.entry.id + outcome.merged++ + continue + } + const created = await repo.create(projectPath, plan.payload) + known.unshift(created) + outcome.lastAffectedId = created.id + outcome.saved++ + } + + return outcome +} diff --git a/tests/panels/memory/memoryImportRunner.test.ts b/tests/panels/memory/memoryImportRunner.test.ts new file mode 100644 index 0000000..dc40225 --- /dev/null +++ b/tests/panels/memory/memoryImportRunner.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import { runCandidateImport } from '../../../src/panels/memory/memoryImportRunner' +import type { ImportedMemoryCandidate } from '../../../src/core/memory/memorySource' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' +import type { MemoryRepository } from '../../../src/ports/MemoryRepository' + +const candidate = (over: Partial = {}): ImportedMemoryCandidate => ({ + title: 'A title', summary: 'short', details: 'brief', source: 'claude', + externalId: 'claude:new', createdAt: '2026-01-01T00:00:00.000Z', files: [], tags: [], ...over, +}) + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: 'e1', projectPath: '/p', kind: 'note', title: 'A title', summary: 'short', details: 'brief', + source: 'claude', externalId: 'claude:old', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +function repo(over: Partial = {}): MemoryRepository { + return { + list: vi.fn(async () => []), + create: vi.fn(async (_p: string, e) => entry({ id: 'created', ...e } as Partial)), + update: vi.fn(async (_p: string, id: string) => entry({ id })), + remove: vi.fn(async () => true), + ...over, + } as MemoryRepository +} + +describe('counting outcomes', () => { + it('reports nothing done for no candidates', async () => { + const r = repo() + const out = await runCandidateImport(r, '/p', [], []) + expect(out).toEqual({ saved: 0, merged: 0, skipped: 0, lastAffectedId: null }) + expect(r.create).not.toHaveBeenCalled() + }) + + it('creates what is new and names the last entry it touched', async () => { + const r = repo() + const out = await runCandidateImport(r, '/p', [candidate()], []) + expect(out).toMatchObject({ saved: 1, merged: 0, skipped: 0, lastAffectedId: 'created' }) + expect(r.create).toHaveBeenCalledTimes(1) + }) + + it('skips a candidate already imported, without writing', async () => { + const r = repo() + const out = await runCandidateImport(r, '/p', [candidate({ externalId: 'claude:same' })], + [entry({ id: 'kept', externalId: 'claude:same' })]) + expect(out).toMatchObject({ saved: 0, merged: 0, skipped: 1, lastAffectedId: 'kept' }) + expect(r.create).not.toHaveBeenCalled() + expect(r.update).not.toHaveBeenCalled() + }) + + it('merges into a duplicate instead of creating a second copy', async () => { + const r = repo() + const out = await runCandidateImport(r, '/p', [candidate()], [entry({ id: 'dup' })]) + expect(out).toMatchObject({ saved: 0, merged: 1, skipped: 0, lastAffectedId: 'dup' }) + expect(r.update).toHaveBeenCalledTimes(1) + expect(r.create).not.toHaveBeenCalled() + }) + + it('falls back to the duplicate id when the update returns nothing', async () => { + const r = repo({ update: vi.fn(async () => null) }) + const out = await runCandidateImport(r, '/p', [candidate()], [entry({ id: 'dup' })]) + expect(out.lastAffectedId).toBe('dup') + }) +}) + +describe('across several candidates', () => { + it('sees what it just created, so a repeat is merged rather than duplicated', async () => { + const r = repo() + const twice = [candidate({ externalId: 'a' }), candidate({ externalId: 'b' })] + const out = await runCandidateImport(r, '/p', twice, []) + expect(out).toMatchObject({ saved: 1, merged: 1 }) + }) + + it('leaves the caller-supplied list of existing entries alone', async () => { + const existing: MemoryEntry[] = [] + await runCandidateImport(repo(), '/p', [candidate()], existing) + expect(existing).toHaveLength(0) + }) +}) + +describe('progress', () => { + it('reports each step in order', async () => { + const seen: Array<[number, number]> = [] + await runCandidateImport(repo(), '/p', + [candidate({ externalId: 'a' }), candidate({ externalId: 'b' })], [], + (current, total) => seen.push([current, total])) + expect(seen).toEqual([[1, 2], [2, 2]]) + }) + + it('works without a progress callback', async () => { + await expect(runCandidateImport(repo(), '/p', [candidate()], [])).resolves.toBeTruthy() + }) +}) From 1f83e706f4a4786aedbaef379b3db51f44d304cd Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 14:57:13 +0200 Subject: [PATCH 28/51] refactor: extracted the external sources section into memorySourcesView Co-Authored-By: Claude Opus 5 --- src/panels/memory/MemoryPanel.ts | 542 +---------------- src/panels/memory/memorySourcesView.ts | 561 ++++++++++++++++++ tests/panels/memory/memorySourcesView.test.ts | 298 ++++++++++ 3 files changed, 877 insertions(+), 524 deletions(-) create mode 100644 src/panels/memory/memorySourcesView.ts create mode 100644 tests/panels/memory/memorySourcesView.test.ts diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index f9b04e6..99491b9 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -1,6 +1,6 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' -import { confirm as askConfirm, open as pickFolder } from '@tauri-apps/plugin-dialog' +import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' import { askAi } from '../../ui/askAi' import { icon } from '../../ui/icons' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' @@ -20,16 +20,12 @@ import { } from '../../core/memory/normalize' import { filterMemoryEntries } from '../../core/memory/memoryFilter' import { - KIND_LABEL, KIND_OPTIONS, splitList, basename, projectName, + KIND_LABEL, KIND_OPTIONS, splitList, projectName, timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' -import { candidateProject, computePreviewCandidateState } from '../../core/memory/memoryCandidates' -import { runCandidateImport } from './memoryImportRunner' -import type { - MemorySource, ImportedMemoryCandidate, PreviewCandidateState, MemorySummaryJob, -} from '../../core/memory/memorySource' +import { createMemorySourcesView } from './memorySourcesView' +import type { MemorySummaryJob } from '../../core/memory/memorySource' -const SOURCE_PREVIEW_LIMIT = 200 import type { MemoryRepository } from '../../ports/MemoryRepository' export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): { element: HTMLElement } { @@ -37,9 +33,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): root.className = 'memory-panel' const currentProject = projectPath?.trim() ?? '' - const sourcesCollapsedKey = `bento.memory.sources.collapsed:${currentProject || '__global__'}` - let sourcesCollapsed = localStorage.getItem(sourcesCollapsedKey) !== '0' - const addBtn = document.createElement('button') addBtn.title = i18nT('memory.newEntry') addBtn.innerHTML = icon('plus') @@ -113,84 +106,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): summaryJobsList.className = 'memory-summary-jobs-list' summaryJobsPanel.append(summaryJobsTitle, summaryJobsList) - const sourcesPanel = document.createElement('div') - sourcesPanel.className = 'memory-sources' - const sourcesHead = document.createElement('div') - sourcesHead.className = 'memory-sources-head' - const sourcesToggle = document.createElement('button') - sourcesToggle.className = 'memory-sources-toggle' - sourcesToggle.type = 'button' - const sourcesTitle = document.createElement('span') - sourcesTitle.className = 'memory-sources-title' - const baseSourcesTitle = 'Fuentes externas' - sourcesTitle.textContent = baseSourcesTitle - const sourcesHint = document.createElement('span') - sourcesHint.className = 'memory-sources-hint' - sourcesHint.textContent = i18nT('memory.importSummariesNotesAndSnapshotsFromExternalFolders') - const sourcesChevron = document.createElement('span') - sourcesChevron.className = 'memory-sources-chevron' - sourcesChevron.innerHTML = icon('chevron') - const sourcesGrid = document.createElement('div') - sourcesGrid.className = 'memory-sources-grid' - const sourcesControl = document.createElement('div') - sourcesControl.className = 'memory-sources-control' - const sourceForm = document.createElement('div') - sourceForm.className = 'memory-source-form' - const sourceLabelInput = document.createElement('input') - sourceLabelInput.className = 'memory-input' - sourceLabelInput.placeholder = i18nT('memory.label') - const sourcePathInput = document.createElement('input') - sourcePathInput.className = 'memory-input' - sourcePathInput.placeholder = i18nT('memory.pathToSummariesOrNotes') - const sourceFormActions = document.createElement('div') - sourceFormActions.className = 'memory-source-form-actions' - const pickSourceBtn = document.createElement('button') - pickSourceBtn.className = 'memory-action' - pickSourceBtn.textContent = i18nT('memory.selectFolder') - const addSourceBtn = document.createElement('button') - addSourceBtn.className = 'memory-action' - addSourceBtn.textContent = i18nT('memory.registerSource') - const sourceList = document.createElement('div') - sourceList.className = 'memory-source-list' - const sourcePreviewPanel = document.createElement('div') - sourcePreviewPanel.className = 'memory-source-preview-panel' - const sourceActivity = document.createElement('div') - sourceActivity.className = 'memory-source-activity hidden' - const sourceActivityText = document.createElement('div') - sourceActivityText.className = 'memory-source-activity-text' - const sourceActivityBar = document.createElement('div') - sourceActivityBar.className = 'memory-source-activity-bar' - const sourceActivityBarFill = document.createElement('div') - sourceActivityBarFill.className = 'memory-source-activity-bar-fill' - sourceActivityBar.appendChild(sourceActivityBarFill) - const sourcePreviewActions = document.createElement('div') - sourcePreviewActions.className = 'memory-source-preview-actions' - const selectVisiblePreviewBtn = document.createElement('button') - selectVisiblePreviewBtn.className = 'memory-action' - selectVisiblePreviewBtn.textContent = i18nT('memory.selectVisible') - const clearVisiblePreviewBtn = document.createElement('button') - clearVisiblePreviewBtn.className = 'memory-action' - clearVisiblePreviewBtn.textContent = i18nT('memory.clearVisible') - const sourceProjectFilter = document.createElement('select') - sourceProjectFilter.className = 'memory-filter memory-source-project-filter' - const sourcePreview = document.createElement('div') - sourcePreview.className = 'memory-source-preview' - sourcePreview.textContent = i18nT('memory.noImportPreview') - const importSelectedSourceBtn = document.createElement('button') - importSelectedSourceBtn.className = 'memory-action' - importSelectedSourceBtn.textContent = i18nT('memory.importSelected') - importSelectedSourceBtn.disabled = true - sourcesToggle.append(sourcesChevron, sourcesTitle) - sourcesHead.append(sourcesToggle, sourcesHint) - sourceFormActions.append(pickSourceBtn, addSourceBtn) - sourceForm.append(sourceLabelInput, sourcePathInput, sourceFormActions) - sourcesControl.append(sourceForm, sourceList) - sourceActivity.append(sourceActivityText, sourceActivityBar) - sourcePreviewActions.append(selectVisiblePreviewBtn, clearVisiblePreviewBtn) - sourcePreviewPanel.append(sourceActivity, sourcePreviewActions, sourceProjectFilter, sourcePreview, importSelectedSourceBtn) - sourcesGrid.append(sourcesControl, sourcePreviewPanel) - sourcesPanel.append(sourcesHead, sourcesGrid) - const list = document.createElement('div') list.className = 'memory-list' @@ -273,18 +188,25 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): form.append(kind, source, titleInput, tags, files, summary, details, saveBtn) detail.append(detailHead, form) - cs.list.append(controls, summaryJobsPanel, sourcesPanel, list) + // The callbacks are wrapped rather than passed directly: reload and + // revealMemoryEntry are declared further down. + const sourcesView = createMemorySourcesView({ + repo, + currentProject, + setStatus: message => setStatus(message), + onImported: async lastAffectedId => { + await reload() + revealMemoryEntry(lastAffectedId) + }, + }) + + cs.list.append(controls, summaryJobsPanel, sourcesView.element, list) root.append(cs.element, cs.resizer, detail) let entries: MemoryEntry[] = [] let summaryJobs: MemorySummaryJob[] = [] let selectedId: string | null = null const selectedIds = new Set() - let sources: MemorySource[] = [] - let previewCandidates: ImportedMemoryCandidate[] = [] - let previewSourceId: string | null = null - const previewCandidateState = new Map() - let selectedSourceProject = 'all' interface ImportedMemory { title: string @@ -298,27 +220,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): } const selected = (): MemoryEntry | undefined => entries.find(entry => entry.id === selectedId) - const currentSource = (): MemorySource | undefined => sources.find(source => source.id === previewSourceId) - const importSourceLabel = (): string => currentSource()?.label ?? previewLabel() - const previewLabel = (): string => { - if (previewSourceId === '__draft__') return sourceLabelInput.value.trim() || basename(sourcePathInput.value.trim()) || i18nT('memory.currentSelection') - return currentSource()?.label ?? i18nT('memory.currentSelection') - } - const visiblePreviewCandidates = (): ImportedMemoryCandidate[] => previewCandidates.filter(candidate => { - if (selectedSourceProject === 'all') return true - return candidateProject(candidate) === selectedSourceProject - }) - const previewCheckedIds = (): Set => new Set( - Array.from(sourcePreview.querySelectorAll('.memory-source-preview-checkbox:checked')) - .map(input => input.value) - .filter(Boolean), - ) - const selectedPreviewCandidates = (): ImportedMemoryCandidate[] => { - const checked = previewCheckedIds() - return visiblePreviewCandidates().filter(candidate => checked.has(candidate.externalId)) - } - const selectedPreviewCount = (): number => previewCheckedIds().size - const visibleRows = (): MemoryEntry[] => filterMemoryEntries(entries, { query: search.value, kind: kindFilter.value as MemoryKind | 'all', @@ -389,77 +290,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): renderSummaryJobs() } - const syncSourceActions = (): void => { - const selectedCount = selectedPreviewCount() - importSelectedSourceBtn.disabled = selectedCount === 0 - importSelectedSourceBtn.textContent = selectedCount > 0 - ? i18nT('memory.importSelectedCount', { count: selectedCount }) - : i18nT('memory.importSelected') - const visibleCount = visiblePreviewCandidates().length - selectVisiblePreviewBtn.disabled = visibleCount === 0 - clearVisiblePreviewBtn.disabled = visibleCount === 0 || selectedCount === 0 - } - - const refreshPreviewCandidateState = async (): Promise => { - previewCandidateState.clear() - if (!previewCandidates.length) return - const existing = await targetProjectEntries() - previewCandidates.forEach(candidate => previewCandidateState.set(candidate.externalId, computePreviewCandidateState(currentProject, candidate, existing))) - } - - const syncSourceForm = (): void => { - addSourceBtn.disabled = sourcePathInput.value.trim().length === 0 - } - - const syncSourcesTitle = (): void => { - sourcesTitle.textContent = `${baseSourcesTitle} (${sources.length})` - } - - const syncSourcesCollapsed = (): void => { - sourcesPanel.classList.toggle('collapsed', sourcesCollapsed) - sourcesChevron.classList.toggle('collapsed', sourcesCollapsed) - sourcesHint.textContent = sourcesCollapsed - ? i18nT('memory.sectionCollapsedOpenItToRegisterScanOr') - : i18nT('memory.importSummariesNotesAndSnapshotsFromExternalFolders') - } - - const setSourceActivity = (message?: string, progress?: number): void => { - if (!message) { - sourceActivity.classList.add('hidden') - sourceActivityBar.classList.toggle('indeterminate', false) - sourceActivityBarFill.style.width = '0%' - sourceActivityText.textContent = '' - return - } - sourceActivity.classList.remove('hidden') - sourceActivityText.textContent = message - if (progress === undefined) { - sourceActivityBar.classList.add('indeterminate') - sourceActivityBarFill.style.width = '100%' - return - } - sourceActivityBar.classList.remove('indeterminate') - sourceActivityBarFill.style.width = `${Math.max(0, Math.min(100, progress))}%` - } - - const refreshSourceProjectFilter = (): void => { - const counts = new Map() - previewCandidates.map(candidateProject).forEach(project => counts.set(project, (counts.get(project) ?? 0) + 1)) - const projects = ['all', ...[...counts.keys()].sort((a, b) => a.localeCompare(b))] - if (!projects.includes(selectedSourceProject)) selectedSourceProject = 'all' - sourceProjectFilter.innerHTML = '' - projects.forEach(value => { - const option = document.createElement('option') - option.value = value - option.textContent = value === 'all' - ? i18nT('memory.allProjectsCount', { count: previewCandidates.length }) - : `${projectName(value)} (${counts.get(value) ?? 0})` - sourceProjectFilter.appendChild(option) - }) - sourceProjectFilter.value = selectedSourceProject - sourceProjectFilter.disabled = projects.length <= 1 - } - const syncBulkButtons = (): void => { const count = selectedIds.size clearSelectionBtn.disabled = count === 0 @@ -484,229 +314,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): sourceFilter.value = sources.includes(previous) ? previous : 'all' } - const renderSourcePreview = (): void => { - const label = previewLabel() - refreshSourceProjectFilter() - const candidates = visiblePreviewCandidates() - if (!previewCandidates.length) { - sourcePreview.textContent = previewSourceId ? i18nT('memory.noImportableCandidates', { label }) : i18nT('memory.noImportPreview') - syncSourceActions() - return - } - if (!candidates.length) { - sourcePreview.textContent = i18nT('memory.thereAreNoCandidatesForTheFilteredProject') - syncSourceActions() - return - } - sourcePreview.innerHTML = '' - const heading = document.createElement('div') - heading.className = 'memory-source-preview-title' - heading.textContent = i18nT('memory.previewHeading', { label, visible: candidates.length, total: previewCandidates.length }) - sourcePreview.appendChild(heading) - candidates.forEach(candidate => { - const state = previewCandidateState.get(candidate.externalId) - const row = document.createElement('div') - row.className = `memory-source-preview-item${state?.duplicateExternal || state?.duplicateSemantic ? ' duplicate' : ''}` - const checkbox = document.createElement('input') - checkbox.type = 'checkbox' - checkbox.className = 'memory-source-preview-checkbox' - checkbox.value = candidate.externalId - checkbox.checked = false - checkbox.addEventListener('click', event => event.stopPropagation()) - checkbox.addEventListener('change', syncSourceActions) - const text = document.createElement('div') - text.className = 'memory-source-preview-copy' - const title = document.createElement('div') - title.className = 'memory-source-preview-name' - title.textContent = candidate.title || i18nT('memory.untitled') - const summary = document.createElement('div') - summary.className = 'memory-source-preview-summary' - summary.textContent = candidate.summary || i18nT('memory.noSummary') - const file = document.createElement('div') - file.className = 'memory-source-preview-file' - file.textContent = candidateProject(candidate) - text.append(title, summary, file) - if (state?.duplicateExternal || state?.duplicateSemantic) { - const badge = document.createElement('div') - badge.className = `memory-source-preview-badge ${state.duplicateExternal ? 'existing' : 'merge'}` - badge.textContent = state.duplicateExternal - ? i18nT('memory.alreadyImported') - : state.duplicateTitle ? i18nT('memory.willMergeWith', { title: state.duplicateTitle }) : i18nT('memory.willMerge') - text.appendChild(badge) - } - row.append(checkbox, text) - sourcePreview.appendChild(row) - }) - syncSourceActions() - } - - const renderSources = (): void => { - sourceList.innerHTML = '' - syncSourceForm() - syncSourcesTitle() - if (!sources.length) { - const empty = document.createElement('div') - empty.className = 'memory-source-empty' - empty.textContent = i18nT('memory.thereAreNoRegisteredSourcesYet') - sourceList.appendChild(empty) - renderSourcePreview() - return - } - sources.forEach(item => { - const row = document.createElement('div') - row.className = 'memory-source-item' - const meta = document.createElement('div') - meta.className = 'memory-source-item-meta' - const text = document.createElement('div') - text.className = 'memory-source-item-text' - text.textContent = item.label - const path = document.createElement('div') - path.className = 'memory-source-item-path' - path.textContent = item.path - meta.append(text, path) - const actions = document.createElement('div') - actions.className = 'memory-source-item-actions' - const scanBtn = document.createElement('button') - scanBtn.className = 'memory-action' - scanBtn.textContent = i18nT('memory.scan') - scanBtn.addEventListener('click', () => { void scanSource(item) }) - const importBtn = document.createElement('button') - importBtn.className = 'memory-action' - importBtn.textContent = i18nT('common.import') - importBtn.addEventListener('click', () => { void importSource(item) }) - const removeBtn = document.createElement('button') - removeBtn.className = 'memory-action danger' - removeBtn.textContent = i18nT('common.delete2') - removeBtn.addEventListener('click', () => { void removeSource(item) }) - actions.append(scanBtn, importBtn, removeBtn) - row.append(meta, actions) - row.addEventListener('click', event => { - if (event.target instanceof HTMLButtonElement) return - void scanSource(item) - }) - sourceList.appendChild(row) - }) - renderSourcePreview() - } - - const reloadSources = async (): Promise => { - try { - sources = await invoke('memory_source_list', { projectPath: currentProject }) - } catch { - sources = [] - } - if (previewSourceId && !sources.some(source => source.id === previewSourceId)) { - previewSourceId = null - previewCandidates = [] - previewCandidateState.clear() - } - renderSources() - if (!previewSourceId && sources.length === 1) { - void scanSource(sources[0]) - } - } - - const scanSource = async (item: MemorySource): Promise => { - try { - setStatus(i18nT('memory.scanning', { label: item.label })) - setSourceActivity(i18nT('memory.scanning', { label: item.label })) - previewCandidates = await invoke('memory_source_scan', { - projectPath: currentProject, - id: item.id, - limit: SOURCE_PREVIEW_LIMIT, - }) - selectedSourceProject = 'all' - previewSourceId = item.id - await refreshPreviewCandidateState() - renderSourcePreview() - setSourceActivity(i18nT('memory.candidatesReady', { count: previewCandidates.length, label: item.label }), 100) - setStatus(i18nT('memory.candidatesDetected', { count: previewCandidates.length, label: item.label })) - } catch (error) { - setSourceActivity(undefined) - setStatus(i18nT('memory.scanSourceFailed', { error: error instanceof Error ? error.message : String(error) })) - } - } - - const previewDraftSource = async (): Promise => { - const path = sourcePathInput.value.trim() - if (!path) { - previewSourceId = null - previewCandidates = [] - renderSourcePreview() - return - } - try { - setStatus(i18nT('memory.scanningSelectedFolder')) - setSourceActivity(i18nT('memory.scanningSelectedFolder')) - previewCandidates = await invoke('memory_source_scan_path', { - path, - label: sourceLabelInput.value.trim() || undefined, - limit: SOURCE_PREVIEW_LIMIT, - }) - selectedSourceProject = 'all' - previewSourceId = '__draft__' - await refreshPreviewCandidateState() - renderSourcePreview() - setSourceActivity(i18nT('memory.candidatesReady', { count: previewCandidates.length, label: i18nT('memory.selectedFolder') }), 100) - setStatus(i18nT('memory.candidatesDetected', { count: previewCandidates.length, label: i18nT('memory.selectedFolder') })) - } catch (error) { - previewSourceId = '__draft__' - previewCandidates = [] - previewCandidateState.clear() - renderSourcePreview() - setSourceActivity(undefined) - setStatus(i18nT('memory.previewFolderFailed', { error: error instanceof Error ? error.message : String(error) })) - } - } - - const importSource = async (item: MemorySource): Promise => { - try { - setStatus(i18nT('memory.preparingImport', { label: item.label })) - setSourceActivity(i18nT('memory.scanningBeforeImport', { label: item.label })) - const candidates = await invoke('memory_source_scan', { - projectPath: currentProject, - id: item.id, - limit: 50, - }) - const existing = await targetProjectEntries() - const { saved, merged, skipped, lastAffectedId } = await runCandidateImport( - repo, currentProject, candidates, existing, - (current, total) => setSourceActivity( - i18nT('memory.importingProgress', { label: item.label, current, total }), - (current / Math.max(total, 1)) * 100, - ), - ) - await reload() - revealMemoryEntry(lastAffectedId) - await reloadSources() - const result = i18nT('memory.importResultSkipped', { saved, merged, skipped, label: item.label }) - setSourceActivity(result, 100) - setStatus(result) - } catch (error) { - setSourceActivity(undefined) - setStatus(i18nT('memory.importSourceFailed', { error: error instanceof Error ? error.message : String(error) })) - } - } - - const removeSource = async (item: MemorySource): Promise => { - const confirmed = await askConfirm( - i18nT('memory.deleteSourceQuestion', { label: item.label }), - { title: i18nT('memory.deleteSource'), kind: 'warning', okLabel: i18nT('common.delete'), cancelLabel: i18nT('common.cancel') }, - ) - if (!confirmed) return - try { - await invoke('memory_source_remove', { projectPath: currentProject, id: item.id }) - if (previewSourceId === item.id) { - previewSourceId = null - previewCandidates = [] - } - await reloadSources() - setStatus(i18nT('memory.sourceDeleted', { label: item.label })) - } catch (error) { - setStatus(i18nT('memory.deleteSourceFailed', { error: error instanceof Error ? error.message : String(error) })) - } - } - const fillForm = (entry?: MemoryEntry): void => { kind.value = entry?.kind ?? 'decision' source.value = entry?.source ?? 'manual' @@ -800,10 +407,7 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): fillForm(selected()) renderList() syncBulkButtons() - if (previewCandidates.length) { - await refreshPreviewCandidateState() - renderSourcePreview() - } + await sourcesView.refreshPreview() } const revealMemoryEntry = (entryId: string | null): void => { @@ -926,115 +530,9 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): renderList() syncBulkButtons() }) - sourcesToggle.addEventListener('click', () => { - sourcesCollapsed = !sourcesCollapsed - localStorage.setItem(sourcesCollapsedKey, sourcesCollapsed ? '1' : '0') - syncSourcesCollapsed() - }) - sourceLabelInput.addEventListener('input', syncSourceForm) - sourceProjectFilter.addEventListener('change', () => { - selectedSourceProject = sourceProjectFilter.value - renderSourcePreview() - }) - selectVisiblePreviewBtn.addEventListener('click', () => { - sourcePreview.querySelectorAll('.memory-source-preview-checkbox').forEach(input => { - input.checked = true - }) - syncSourceActions() - }) - clearVisiblePreviewBtn.addEventListener('click', () => { - sourcePreview.querySelectorAll('.memory-source-preview-checkbox').forEach(input => { - input.checked = false - }) - syncSourceActions() - }) - sourcePathInput.addEventListener('input', () => { - if (!sourceLabelInput.value.trim()) sourceLabelInput.value = basename(sourcePathInput.value.trim()) - syncSourceForm() - }) - pickSourceBtn.addEventListener('click', () => { void (async () => { - const picked = await pickFolder({ - directory: true, - defaultPath: sourcePathInput.value.trim() || currentProject || undefined, - }).catch(() => null) - if (typeof picked !== 'string') return - sourcePathInput.value = picked - if (!sourceLabelInput.value.trim()) sourceLabelInput.value = basename(picked) - syncSourceForm() - void previewDraftSource() - })() }) - addSourceBtn.addEventListener('click', () => { void (async () => { - const path = sourcePathInput.value.trim() - const label = sourceLabelInput.value.trim() || basename(path) - sourceLabelInput.value = label - if (!label || !path) { - setStatus(i18nT('memory.theSourceNeedsALabelAndPath')) - return - } - try { - addSourceBtn.disabled = true - await invoke('memory_source_create', { - source: { - id: crypto.randomUUID(), - projectPath: currentProject, - kind: 'filesystem', - label, - path, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }) - sourceLabelInput.value = '' - sourcePathInput.value = '' - await reloadSources() - setStatus(i18nT('memory.sourceRegistered', { label })) - } catch (error) { - setStatus(i18nT('memory.registerSourceFailed', { error: error instanceof Error ? error.message : String(error) })) - } finally { - syncSourceForm() - } - })() }) - archiveSelectedBtn.addEventListener('click', () => { void archiveEntries(selectedRows()) }) mergeSelectedBtn.addEventListener('click', () => { void mergeSelected().catch(error => setStatus(String(error))) }) deleteSelectedBtn.addEventListener('click', () => { void deleteEntries(selectedRows()).catch(error => setStatus(String(error))) }) - importSelectedSourceBtn.addEventListener('click', () => { void (async () => { - if (!previewSourceId) { - setStatus(i18nT('memory.thereIsNoScannedSourceToImport')) - return - } - const sourceLabel = importSourceLabel() - const candidates = selectedPreviewCandidates() - if (!candidates.length) { - setStatus(i18nT('memory.selectAtLeastOneFileBeforeImporting')) - return - } - try { - importSelectedSourceBtn.disabled = true - setStatus(i18nT('memory.importingSelected', { count: candidates.length, label: sourceLabel })) - const existing = await targetProjectEntries() - const { saved, merged, skipped, lastAffectedId } = await runCandidateImport( - repo, currentProject, candidates, existing, - (current, total) => setSourceActivity( - i18nT('memory.importingSelectionProgress', { current, total }), - (current / Math.max(total, 1)) * 100, - ), - ) - await reload() - revealMemoryEntry(lastAffectedId) - await refreshPreviewCandidateState() - renderSourcePreview() - const result = i18nT('memory.importResultExistingFrom', { saved, merged, skipped, label: sourceLabel }) - setSourceActivity(result, 100) - setStatus(result) - } catch (error) { - setSourceActivity(undefined) - setStatus(i18nT('memory.importSelectionFailed', { error: error instanceof Error ? error.message : String(error) })) - } finally { - syncSourceActions() - } - })() }) - const importEntries = async (sourceName: 'claude' | 'codex'): Promise => { if (!currentProject) { setStatus(i18nT('memory.openAProjectBeforeImportingMemory')) @@ -1166,10 +664,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): }) syncBulkButtons() - syncSourceActions() - syncSourceForm() - syncSourcesCollapsed() - void reloadSources() void reload() void reloadSummaryJobs() return { element: root } diff --git a/src/panels/memory/memorySourcesView.ts b/src/panels/memory/memorySourcesView.ts new file mode 100644 index 0000000..69e5ff7 --- /dev/null +++ b/src/panels/memory/memorySourcesView.ts @@ -0,0 +1,561 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import { icon } from '../../ui/icons' +import { confirm as askConfirm, open as pickFolder } from '@tauri-apps/plugin-dialog' +import type { MemoryEntry } from '../../core/memory/MemoryEntry' +import { basename, projectName } from '../../core/memory/memoryFormat' +import { candidateProject, computePreviewCandidateState } from '../../core/memory/memoryCandidates' +import type { + MemorySource, ImportedMemoryCandidate, PreviewCandidateState, +} from '../../core/memory/memorySource' +import type { MemoryRepository } from '../../ports/MemoryRepository' +import { runCandidateImport } from './memoryImportRunner' + +const SOURCE_PREVIEW_LIMIT = 200 + +export interface MemorySourcesViewDeps { + repo: MemoryRepository + currentProject: string + setStatus: (message?: string) => void + /** Called after an import so the panel can reload and reveal the entry it touched. */ + onImported: (lastAffectedId: string | null) => Promise +} + +export interface MemorySourcesView { + element: HTMLElement + reload: () => Promise + /** Re-checks the previewed candidates against the stored entries. */ + refreshPreview: () => Promise +} + +/** + * The "external sources" section: register folders, scan them for importable + * memories, preview what would land (flagging duplicates) and import a selection. + */ +export function createMemorySourcesView(deps: MemorySourcesViewDeps): MemorySourcesView { + const { repo, currentProject, setStatus, onImported } = deps + + let sources: MemorySource[] = [] + let previewCandidates: ImportedMemoryCandidate[] = [] + let previewSourceId: string | null = null + const previewCandidateState = new Map() + let selectedSourceProject = 'all' + + const targetProjectEntries = async (): Promise => { + const rows = await repo.list(currentProject) + return rows.filter(entry => entry.projectPath === currentProject) + } + + const sourcesCollapsedKey = `bento.memory.sources.collapsed:${currentProject || '__global__'}` + let sourcesCollapsed = localStorage.getItem(sourcesCollapsedKey) !== '0' + + const sourcesPanel = document.createElement('div') + sourcesPanel.className = 'memory-sources' + const sourcesHead = document.createElement('div') + sourcesHead.className = 'memory-sources-head' + const sourcesToggle = document.createElement('button') + sourcesToggle.className = 'memory-sources-toggle' + sourcesToggle.type = 'button' + const sourcesTitle = document.createElement('span') + sourcesTitle.className = 'memory-sources-title' + const baseSourcesTitle = 'Fuentes externas' + sourcesTitle.textContent = baseSourcesTitle + const sourcesHint = document.createElement('span') + sourcesHint.className = 'memory-sources-hint' + sourcesHint.textContent = i18nT('memory.importSummariesNotesAndSnapshotsFromExternalFolders') + const sourcesChevron = document.createElement('span') + sourcesChevron.className = 'memory-sources-chevron' + sourcesChevron.innerHTML = icon('chevron') + const sourcesGrid = document.createElement('div') + sourcesGrid.className = 'memory-sources-grid' + const sourcesControl = document.createElement('div') + sourcesControl.className = 'memory-sources-control' + const sourceForm = document.createElement('div') + sourceForm.className = 'memory-source-form' + const sourceLabelInput = document.createElement('input') + sourceLabelInput.className = 'memory-input' + sourceLabelInput.placeholder = i18nT('memory.label') + const sourcePathInput = document.createElement('input') + sourcePathInput.className = 'memory-input' + sourcePathInput.placeholder = i18nT('memory.pathToSummariesOrNotes') + const sourceFormActions = document.createElement('div') + sourceFormActions.className = 'memory-source-form-actions' + const pickSourceBtn = document.createElement('button') + pickSourceBtn.className = 'memory-action' + pickSourceBtn.textContent = i18nT('memory.selectFolder') + const addSourceBtn = document.createElement('button') + addSourceBtn.className = 'memory-action' + addSourceBtn.textContent = i18nT('memory.registerSource') + const sourceList = document.createElement('div') + sourceList.className = 'memory-source-list' + const sourcePreviewPanel = document.createElement('div') + sourcePreviewPanel.className = 'memory-source-preview-panel' + const sourceActivity = document.createElement('div') + sourceActivity.className = 'memory-source-activity hidden' + const sourceActivityText = document.createElement('div') + sourceActivityText.className = 'memory-source-activity-text' + const sourceActivityBar = document.createElement('div') + sourceActivityBar.className = 'memory-source-activity-bar' + const sourceActivityBarFill = document.createElement('div') + sourceActivityBarFill.className = 'memory-source-activity-bar-fill' + sourceActivityBar.appendChild(sourceActivityBarFill) + const sourcePreviewActions = document.createElement('div') + sourcePreviewActions.className = 'memory-source-preview-actions' + const selectVisiblePreviewBtn = document.createElement('button') + selectVisiblePreviewBtn.className = 'memory-action' + selectVisiblePreviewBtn.textContent = i18nT('memory.selectVisible') + const clearVisiblePreviewBtn = document.createElement('button') + clearVisiblePreviewBtn.className = 'memory-action' + clearVisiblePreviewBtn.textContent = i18nT('memory.clearVisible') + const sourceProjectFilter = document.createElement('select') + sourceProjectFilter.className = 'memory-filter memory-source-project-filter' + const sourcePreview = document.createElement('div') + sourcePreview.className = 'memory-source-preview' + sourcePreview.textContent = i18nT('memory.noImportPreview') + const importSelectedSourceBtn = document.createElement('button') + importSelectedSourceBtn.className = 'memory-action' + importSelectedSourceBtn.textContent = i18nT('memory.importSelected') + importSelectedSourceBtn.disabled = true + sourcesToggle.append(sourcesChevron, sourcesTitle) + sourcesHead.append(sourcesToggle, sourcesHint) + sourceFormActions.append(pickSourceBtn, addSourceBtn) + sourceForm.append(sourceLabelInput, sourcePathInput, sourceFormActions) + sourcesControl.append(sourceForm, sourceList) + sourceActivity.append(sourceActivityText, sourceActivityBar) + sourcePreviewActions.append(selectVisiblePreviewBtn, clearVisiblePreviewBtn) + sourcePreviewPanel.append(sourceActivity, sourcePreviewActions, sourceProjectFilter, sourcePreview, importSelectedSourceBtn) + sourcesGrid.append(sourcesControl, sourcePreviewPanel) + sourcesPanel.append(sourcesHead, sourcesGrid) + + const currentSource = (): MemorySource | undefined => sources.find(source => source.id === previewSourceId) + const importSourceLabel = (): string => currentSource()?.label ?? previewLabel() + const previewLabel = (): string => { + if (previewSourceId === '__draft__') return sourceLabelInput.value.trim() || basename(sourcePathInput.value.trim()) || i18nT('memory.currentSelection') + return currentSource()?.label ?? i18nT('memory.currentSelection') + } + const visiblePreviewCandidates = (): ImportedMemoryCandidate[] => previewCandidates.filter(candidate => { + if (selectedSourceProject === 'all') return true + return candidateProject(candidate) === selectedSourceProject + }) + const previewCheckedIds = (): Set => new Set( + Array.from(sourcePreview.querySelectorAll('.memory-source-preview-checkbox:checked')) + .map(input => input.value) + .filter(Boolean), + ) + const selectedPreviewCandidates = (): ImportedMemoryCandidate[] => { + const checked = previewCheckedIds() + return visiblePreviewCandidates().filter(candidate => checked.has(candidate.externalId)) + } + const selectedPreviewCount = (): number => previewCheckedIds().size + + const syncSourceActions = (): void => { + const selectedCount = selectedPreviewCount() + importSelectedSourceBtn.disabled = selectedCount === 0 + importSelectedSourceBtn.textContent = selectedCount > 0 + ? i18nT('memory.importSelectedCount', { count: selectedCount }) + : i18nT('memory.importSelected') + const visibleCount = visiblePreviewCandidates().length + selectVisiblePreviewBtn.disabled = visibleCount === 0 + clearVisiblePreviewBtn.disabled = visibleCount === 0 || selectedCount === 0 + } + + const refreshPreviewCandidateState = async (): Promise => { + previewCandidateState.clear() + if (!previewCandidates.length) return + const existing = await targetProjectEntries() + previewCandidates.forEach(candidate => previewCandidateState.set(candidate.externalId, computePreviewCandidateState(currentProject, candidate, existing))) + } + + const syncSourceForm = (): void => { + addSourceBtn.disabled = sourcePathInput.value.trim().length === 0 + } + + const syncSourcesTitle = (): void => { + sourcesTitle.textContent = `${baseSourcesTitle} (${sources.length})` + } + + const syncSourcesCollapsed = (): void => { + sourcesPanel.classList.toggle('collapsed', sourcesCollapsed) + sourcesChevron.classList.toggle('collapsed', sourcesCollapsed) + sourcesHint.textContent = sourcesCollapsed + ? i18nT('memory.sectionCollapsedOpenItToRegisterScanOr') + : i18nT('memory.importSummariesNotesAndSnapshotsFromExternalFolders') + } + + const setSourceActivity = (message?: string, progress?: number): void => { + if (!message) { + sourceActivity.classList.add('hidden') + sourceActivityBar.classList.toggle('indeterminate', false) + sourceActivityBarFill.style.width = '0%' + sourceActivityText.textContent = '' + return + } + sourceActivity.classList.remove('hidden') + sourceActivityText.textContent = message + if (progress === undefined) { + sourceActivityBar.classList.add('indeterminate') + sourceActivityBarFill.style.width = '100%' + return + } + sourceActivityBar.classList.remove('indeterminate') + sourceActivityBarFill.style.width = `${Math.max(0, Math.min(100, progress))}%` + } + + const refreshSourceProjectFilter = (): void => { + const counts = new Map() + previewCandidates.map(candidateProject).forEach(project => counts.set(project, (counts.get(project) ?? 0) + 1)) + const projects = ['all', ...[...counts.keys()].sort((a, b) => a.localeCompare(b))] + if (!projects.includes(selectedSourceProject)) selectedSourceProject = 'all' + sourceProjectFilter.innerHTML = '' + projects.forEach(value => { + const option = document.createElement('option') + option.value = value + option.textContent = value === 'all' + ? i18nT('memory.allProjectsCount', { count: previewCandidates.length }) + : `${projectName(value)} (${counts.get(value) ?? 0})` + sourceProjectFilter.appendChild(option) + }) + sourceProjectFilter.value = selectedSourceProject + sourceProjectFilter.disabled = projects.length <= 1 + } + + const renderSourcePreview = (): void => { + const label = previewLabel() + refreshSourceProjectFilter() + const candidates = visiblePreviewCandidates() + if (!previewCandidates.length) { + sourcePreview.textContent = previewSourceId ? i18nT('memory.noImportableCandidates', { label }) : i18nT('memory.noImportPreview') + syncSourceActions() + return + } + if (!candidates.length) { + sourcePreview.textContent = i18nT('memory.thereAreNoCandidatesForTheFilteredProject') + syncSourceActions() + return + } + sourcePreview.innerHTML = '' + const heading = document.createElement('div') + heading.className = 'memory-source-preview-title' + heading.textContent = i18nT('memory.previewHeading', { label, visible: candidates.length, total: previewCandidates.length }) + sourcePreview.appendChild(heading) + candidates.forEach(candidate => { + const state = previewCandidateState.get(candidate.externalId) + const row = document.createElement('div') + row.className = `memory-source-preview-item${state?.duplicateExternal || state?.duplicateSemantic ? ' duplicate' : ''}` + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.className = 'memory-source-preview-checkbox' + checkbox.value = candidate.externalId + checkbox.checked = false + checkbox.addEventListener('click', event => event.stopPropagation()) + checkbox.addEventListener('change', syncSourceActions) + const text = document.createElement('div') + text.className = 'memory-source-preview-copy' + const title = document.createElement('div') + title.className = 'memory-source-preview-name' + title.textContent = candidate.title || i18nT('memory.untitled') + const summary = document.createElement('div') + summary.className = 'memory-source-preview-summary' + summary.textContent = candidate.summary || i18nT('memory.noSummary') + const file = document.createElement('div') + file.className = 'memory-source-preview-file' + file.textContent = candidateProject(candidate) + text.append(title, summary, file) + if (state?.duplicateExternal || state?.duplicateSemantic) { + const badge = document.createElement('div') + badge.className = `memory-source-preview-badge ${state.duplicateExternal ? 'existing' : 'merge'}` + badge.textContent = state.duplicateExternal + ? i18nT('memory.alreadyImported') + : state.duplicateTitle ? i18nT('memory.willMergeWith', { title: state.duplicateTitle }) : i18nT('memory.willMerge') + text.appendChild(badge) + } + row.append(checkbox, text) + sourcePreview.appendChild(row) + }) + syncSourceActions() + } + + const renderSources = (): void => { + sourceList.innerHTML = '' + syncSourceForm() + syncSourcesTitle() + if (!sources.length) { + const empty = document.createElement('div') + empty.className = 'memory-source-empty' + empty.textContent = i18nT('memory.thereAreNoRegisteredSourcesYet') + sourceList.appendChild(empty) + renderSourcePreview() + return + } + sources.forEach(item => { + const row = document.createElement('div') + row.className = 'memory-source-item' + const meta = document.createElement('div') + meta.className = 'memory-source-item-meta' + const text = document.createElement('div') + text.className = 'memory-source-item-text' + text.textContent = item.label + const path = document.createElement('div') + path.className = 'memory-source-item-path' + path.textContent = item.path + meta.append(text, path) + const actions = document.createElement('div') + actions.className = 'memory-source-item-actions' + const scanBtn = document.createElement('button') + scanBtn.className = 'memory-action' + scanBtn.textContent = i18nT('memory.scan') + scanBtn.addEventListener('click', () => { void scanSource(item) }) + const importBtn = document.createElement('button') + importBtn.className = 'memory-action' + importBtn.textContent = i18nT('common.import') + importBtn.addEventListener('click', () => { void importSource(item) }) + const removeBtn = document.createElement('button') + removeBtn.className = 'memory-action danger' + removeBtn.textContent = i18nT('common.delete2') + removeBtn.addEventListener('click', () => { void removeSource(item) }) + actions.append(scanBtn, importBtn, removeBtn) + row.append(meta, actions) + row.addEventListener('click', event => { + if (event.target instanceof HTMLButtonElement) return + void scanSource(item) + }) + sourceList.appendChild(row) + }) + renderSourcePreview() + } + + const reloadSources = async (): Promise => { + try { + sources = await invoke('memory_source_list', { projectPath: currentProject }) + } catch { + sources = [] + } + if (previewSourceId && !sources.some(source => source.id === previewSourceId)) { + previewSourceId = null + previewCandidates = [] + previewCandidateState.clear() + } + renderSources() + if (!previewSourceId && sources.length === 1) { + void scanSource(sources[0]) + } + } + + const scanSource = async (item: MemorySource): Promise => { + try { + setStatus(i18nT('memory.scanning', { label: item.label })) + setSourceActivity(i18nT('memory.scanning', { label: item.label })) + previewCandidates = await invoke('memory_source_scan', { + projectPath: currentProject, + id: item.id, + limit: SOURCE_PREVIEW_LIMIT, + }) + selectedSourceProject = 'all' + previewSourceId = item.id + await refreshPreviewCandidateState() + renderSourcePreview() + setSourceActivity(i18nT('memory.candidatesReady', { count: previewCandidates.length, label: item.label }), 100) + setStatus(i18nT('memory.candidatesDetected', { count: previewCandidates.length, label: item.label })) + } catch (error) { + setSourceActivity(undefined) + setStatus(i18nT('memory.scanSourceFailed', { error: error instanceof Error ? error.message : String(error) })) + } + } + + const previewDraftSource = async (): Promise => { + const path = sourcePathInput.value.trim() + if (!path) { + previewSourceId = null + previewCandidates = [] + renderSourcePreview() + return + } + try { + setStatus(i18nT('memory.scanningSelectedFolder')) + setSourceActivity(i18nT('memory.scanningSelectedFolder')) + previewCandidates = await invoke('memory_source_scan_path', { + path, + label: sourceLabelInput.value.trim() || undefined, + limit: SOURCE_PREVIEW_LIMIT, + }) + selectedSourceProject = 'all' + previewSourceId = '__draft__' + await refreshPreviewCandidateState() + renderSourcePreview() + setSourceActivity(i18nT('memory.candidatesReady', { count: previewCandidates.length, label: i18nT('memory.selectedFolder') }), 100) + setStatus(i18nT('memory.candidatesDetected', { count: previewCandidates.length, label: i18nT('memory.selectedFolder') })) + } catch (error) { + previewSourceId = '__draft__' + previewCandidates = [] + previewCandidateState.clear() + renderSourcePreview() + setSourceActivity(undefined) + setStatus(i18nT('memory.previewFolderFailed', { error: error instanceof Error ? error.message : String(error) })) + } + } + + const importSource = async (item: MemorySource): Promise => { + try { + setStatus(i18nT('memory.preparingImport', { label: item.label })) + setSourceActivity(i18nT('memory.scanningBeforeImport', { label: item.label })) + const candidates = await invoke('memory_source_scan', { + projectPath: currentProject, + id: item.id, + limit: 50, + }) + const existing = await targetProjectEntries() + const { saved, merged, skipped, lastAffectedId } = await runCandidateImport( + repo, currentProject, candidates, existing, + (current, total) => setSourceActivity( + i18nT('memory.importingProgress', { label: item.label, current, total }), + (current / Math.max(total, 1)) * 100, + ), + ) + await onImported(lastAffectedId) + await reloadSources() + const result = i18nT('memory.importResultSkipped', { saved, merged, skipped, label: item.label }) + setSourceActivity(result, 100) + setStatus(result) + } catch (error) { + setSourceActivity(undefined) + setStatus(i18nT('memory.importSourceFailed', { error: error instanceof Error ? error.message : String(error) })) + } + } + + const removeSource = async (item: MemorySource): Promise => { + const confirmed = await askConfirm( + i18nT('memory.deleteSourceQuestion', { label: item.label }), + { title: i18nT('memory.deleteSource'), kind: 'warning', okLabel: i18nT('common.delete'), cancelLabel: i18nT('common.cancel') }, + ) + if (!confirmed) return + try { + await invoke('memory_source_remove', { projectPath: currentProject, id: item.id }) + if (previewSourceId === item.id) { + previewSourceId = null + previewCandidates = [] + } + await reloadSources() + setStatus(i18nT('memory.sourceDeleted', { label: item.label })) + } catch (error) { + setStatus(i18nT('memory.deleteSourceFailed', { error: error instanceof Error ? error.message : String(error) })) + } + } + + sourcesToggle.addEventListener('click', () => { + sourcesCollapsed = !sourcesCollapsed + localStorage.setItem(sourcesCollapsedKey, sourcesCollapsed ? '1' : '0') + syncSourcesCollapsed() + }) + sourceLabelInput.addEventListener('input', syncSourceForm) + sourceProjectFilter.addEventListener('change', () => { + selectedSourceProject = sourceProjectFilter.value + renderSourcePreview() + }) + selectVisiblePreviewBtn.addEventListener('click', () => { + sourcePreview.querySelectorAll('.memory-source-preview-checkbox').forEach(input => { + input.checked = true + }) + syncSourceActions() + }) + clearVisiblePreviewBtn.addEventListener('click', () => { + sourcePreview.querySelectorAll('.memory-source-preview-checkbox').forEach(input => { + input.checked = false + }) + syncSourceActions() + }) + sourcePathInput.addEventListener('input', () => { + if (!sourceLabelInput.value.trim()) sourceLabelInput.value = basename(sourcePathInput.value.trim()) + syncSourceForm() + }) + pickSourceBtn.addEventListener('click', () => { void (async () => { + const picked = await pickFolder({ + directory: true, + defaultPath: sourcePathInput.value.trim() || currentProject || undefined, + }).catch(() => null) + if (typeof picked !== 'string') return + sourcePathInput.value = picked + if (!sourceLabelInput.value.trim()) sourceLabelInput.value = basename(picked) + syncSourceForm() + void previewDraftSource() + })() }) + addSourceBtn.addEventListener('click', () => { void (async () => { + const path = sourcePathInput.value.trim() + const label = sourceLabelInput.value.trim() || basename(path) + sourceLabelInput.value = label + if (!label || !path) { + setStatus(i18nT('memory.theSourceNeedsALabelAndPath')) + return + } + try { + addSourceBtn.disabled = true + await invoke('memory_source_create', { + source: { + id: crypto.randomUUID(), + projectPath: currentProject, + kind: 'filesystem', + label, + path, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }) + sourceLabelInput.value = '' + sourcePathInput.value = '' + await reloadSources() + setStatus(i18nT('memory.sourceRegistered', { label })) + } catch (error) { + setStatus(i18nT('memory.registerSourceFailed', { error: error instanceof Error ? error.message : String(error) })) + } finally { + syncSourceForm() + } + })() }) + + importSelectedSourceBtn.addEventListener('click', () => { void (async () => { + if (!previewSourceId) { + setStatus(i18nT('memory.thereIsNoScannedSourceToImport')) + return + } + const sourceLabel = importSourceLabel() + const candidates = selectedPreviewCandidates() + if (!candidates.length) { + setStatus(i18nT('memory.selectAtLeastOneFileBeforeImporting')) + return + } + try { + importSelectedSourceBtn.disabled = true + setStatus(i18nT('memory.importingSelected', { count: candidates.length, label: sourceLabel })) + const existing = await targetProjectEntries() + const { saved, merged, skipped, lastAffectedId } = await runCandidateImport( + repo, currentProject, candidates, existing, + (current, total) => setSourceActivity( + i18nT('memory.importingSelectionProgress', { current, total }), + (current / Math.max(total, 1)) * 100, + ), + ) + await onImported(lastAffectedId) + await refreshPreviewCandidateState() + renderSourcePreview() + const result = i18nT('memory.importResultExistingFrom', { saved, merged, skipped, label: sourceLabel }) + setSourceActivity(result, 100) + setStatus(result) + } catch (error) { + setSourceActivity(undefined) + setStatus(i18nT('memory.importSelectionFailed', { error: error instanceof Error ? error.message : String(error) })) + } finally { + syncSourceActions() + } + })() }) + + const refreshPreview = async (): Promise => { + if (!previewCandidates.length) return + await refreshPreviewCandidateState() + renderSourcePreview() + } + + syncSourceActions() + syncSourceForm() + syncSourcesCollapsed() + void reloadSources() + + return { element: sourcesPanel, reload: reloadSources, refreshPreview } +} diff --git a/tests/panels/memory/memorySourcesView.test.ts b/tests/panels/memory/memorySourcesView.test.ts new file mode 100644 index 0000000..6648c5a --- /dev/null +++ b/tests/panels/memory/memorySourcesView.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), + askConfirm: vi.fn(async () => true), + pickFolder: vi.fn(async () => null as string | null), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) +vi.mock('@tauri-apps/plugin-dialog', () => ({ confirm: mocks.askConfirm, open: mocks.pickFolder })) + +import { createMemorySourcesView } from '../../../src/panels/memory/memorySourcesView' +import type { MemorySource, ImportedMemoryCandidate } from '../../../src/core/memory/memorySource' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' +import type { MemoryRepository } from '../../../src/ports/MemoryRepository' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +const source = (over: Partial = {}): MemorySource => ({ + id: 's1', projectPath: '/p', kind: 'filesystem', label: 'Notes', path: '/notes', + createdAt: '', updatedAt: '', ...over, +}) + +const candidate = (over: Partial = {}): ImportedMemoryCandidate => ({ + title: 'A title', summary: 'a summary', details: '', source: 'claude', + externalId: 'claude:1', createdAt: '2026-01-01T00:00:00.000Z', files: ['/Users/ana/bento/a.ts'], tags: [], ...over, +}) + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: 'e1', projectPath: '/p', kind: 'note', title: 'A title', summary: 'a summary', details: '', + source: 'claude', externalId: 'claude:old', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +let statuses: string[] +let imported: Array + +function repo(over: Partial = {}): MemoryRepository { + return { + list: vi.fn(async () => [] as MemoryEntry[]), + create: vi.fn(async (_p: string, e) => entry({ id: 'created', ...e } as Partial)), + update: vi.fn(async (_p: string, id: string) => entry({ id })), + remove: vi.fn(async () => true), + ...over, + } as MemoryRepository +} + +function view(over: { repo?: MemoryRepository; projectPath?: string } = {}) { + const api = createMemorySourcesView({ + repo: over.repo ?? repo(), + currentProject: over.projectPath ?? '/p', + setStatus: m => { statuses.push(m ?? '') }, + onImported: async id => { imported.push(id) }, + }) + document.body.replaceChildren(api.element) + return api +} + +const q = (sel: string): T => document.querySelector(sel) as T +const qa = (sel: string): Element[] => [...document.querySelectorAll(sel)] + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' }) + document.body.replaceChildren() + statuses = [] + imported = [] + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue([]) + mocks.askConfirm.mockReset() + mocks.askConfirm.mockResolvedValue(true) + mocks.pickFolder.mockReset() + mocks.pickFolder.mockResolvedValue(null) +}) + +describe('the source list', () => { + it('says there are none registered yet', async () => { + view() + await flush() + expect(q('.memory-source-empty')).not.toBeNull() + }) + + it('shows each source with its label and path, and counts them in the title', async () => { + mocks.invoke.mockResolvedValue([source(), source({ id: 's2', label: 'Codex', path: '/codex' })]) + view() + await flush() + expect(qa('.memory-source-item-text').map(e => e.textContent)).toEqual(['Notes', 'Codex']) + expect(qa('.memory-source-item-path').map(e => e.textContent)).toEqual(['/notes', '/codex']) + expect(q('.memory-sources-title').textContent).toContain('2') + }) + + it('lists nothing when the backend cannot be reached', async () => { + mocks.invoke.mockRejectedValue(new Error('no backend')) + view() + await flush() + expect(q('.memory-source-empty')).not.toBeNull() + }) + + it('scans a lone source on its own so the user sees something', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'memory_source_list') return [source()] + if (cmd === 'memory_source_scan') return [candidate()] + return [] + }) + view() + await flush() + expect(qa('.memory-source-preview-item')).toHaveLength(1) + }) +}) + +describe('collapsing', () => { + it('starts collapsed and remembers being opened', async () => { + const api = view() + await flush() + expect(api.element.classList.contains('collapsed')).toBe(true) + q('.memory-sources-toggle').click() + expect(api.element.classList.contains('collapsed')).toBe(false) + expect(localStorage.getItem('bento.memory.sources.collapsed:/p')).toBe('0') + }) + + it('honours what was stored for this project', async () => { + localStorage.setItem('bento.memory.sources.collapsed:/p', '0') + const api = view() + await flush() + expect(api.element.classList.contains('collapsed')).toBe(false) + }) +}) + +describe('registering a source', () => { + const path = (): HTMLInputElement => q('.memory-source-path, input[placeholder]') + const inputs = (): HTMLInputElement[] => qa('input[type="text"], input:not([type])') as HTMLInputElement[] + + it('cannot be submitted without a path', async () => { + view() + await flush() + const addBtn = qa('button').find(b => b.textContent === 'Add') as HTMLButtonElement | undefined + expect(addBtn?.disabled ?? true).toBe(true) + }) + + it('defaults the label to the folder name as you type the path', async () => { + view() + await flush() + const [label, pathInput] = inputs() + pathInput.value = '/home/ana/notes' + pathInput.dispatchEvent(new Event('input')) + expect(label.value).toBe('notes') + void path + }) +}) + +describe('scanning a source', () => { + const setup = async (candidates: ImportedMemoryCandidate[], over: Partial = {}) => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'memory_source_list') return [source(), source({ id: 's2' })] + if (cmd === 'memory_source_scan') return candidates + return [] + }) + const api = view({ repo: repo(over) }) + await flush() + ;(qa('.memory-source-item-actions button')[0] as HTMLButtonElement).click() + await flush() + return api + } + + it('lists the candidates with their title, summary and project', async () => { + await setup([candidate()]) + expect(q('.memory-source-preview-name').textContent).toBe('A title') + expect(q('.memory-source-preview-summary').textContent).toBe('a summary') + expect(q('.memory-source-preview-file').textContent).toBe('a.ts') + }) + + it('marks a candidate already imported', async () => { + await setup([candidate({ externalId: 'claude:1' })], { list: vi.fn(async () => [entry({ externalId: 'claude:1' })]) }) + expect(q('.memory-source-preview-item').classList.contains('duplicate')).toBe(true) + expect(q('.memory-source-preview-badge').classList.contains('existing')).toBe(true) + }) + + it('marks a candidate that would merge into an existing entry', async () => { + await setup([candidate({ externalId: 'claude:new' })], { list: vi.fn(async () => [entry({ id: 'dup', externalId: 'claude:old' })]) }) + expect(q('.memory-source-preview-badge').classList.contains('merge')).toBe(true) + }) + + it('reports a failed scan and shows no candidates', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'memory_source_list') return [source(), source({ id: 's2' })] + if (cmd === 'memory_source_scan') throw new Error('folder is gone') + return [] + }) + view() + await flush() + ;(qa('.memory-source-item-actions button')[0] as HTMLButtonElement).click() + await flush() + expect(statuses.join()).toContain('folder is gone') + expect(qa('.memory-source-preview-item')).toHaveLength(0) + }) +}) + +describe('selecting candidates', () => { + const setup = async (candidates: ImportedMemoryCandidate[]) => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'memory_source_list') return [source()] + if (cmd === 'memory_source_scan') return candidates + return [] + }) + const api = view() + await flush() + return api + } + + it('selects and clears every visible candidate', async () => { + await setup([candidate({ externalId: 'a' }), candidate({ externalId: 'b' })]) + const checkboxes = (): HTMLInputElement[] => qa('.memory-source-preview-checkbox') as HTMLInputElement[] + const [selectVisible, clearVisible] = qa('.memory-source-preview-actions button') as HTMLButtonElement[] + expect(checkboxes().every(c => !c.checked)).toBe(true) + + selectVisible.click() + expect(checkboxes().every(c => c.checked)).toBe(true) + + clearVisible.click() + expect(checkboxes().every(c => !c.checked)).toBe(true) + }) + + it('filters the preview by project', async () => { + await setup([ + candidate({ externalId: 'a', files: ['/Users/ana/one.ts'] }), + candidate({ externalId: 'b', files: ['/Users/ana/two.ts'] }), + ]) + const select = q('select') + expect([...select.options].map(o => o.value)).toEqual(['all', 'one.ts', 'two.ts']) + select.value = 'one.ts' + select.dispatchEvent(new Event('change')) + expect(qa('.memory-source-preview-item')).toHaveLength(1) + }) +}) + +describe('importing the selection', () => { + const setup = async (over: Partial = {}) => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'memory_source_list') return [source()] + if (cmd === 'memory_source_scan') return [candidate({ externalId: 'a' })] + return [] + }) + const api = view({ repo: repo(over) }) + await flush() + return api + } + + const importBtn = (): HTMLButtonElement => + qa('button').filter(b => (b.textContent ?? '').toLowerCase().includes('import')).at(-1) as HTMLButtonElement + + it('is disabled until something is checked', async () => { + await setup() + expect(importBtn().disabled).toBe(true) + ;(q('.memory-source-preview-checkbox') as HTMLInputElement).checked = true + q('.memory-source-preview-checkbox').dispatchEvent(new Event('change')) + expect(importBtn().disabled).toBe(false) + }) + + it('imports what was checked and tells the panel which entry to reveal', async () => { + const r = repo() + await setup(r) + const box = q('.memory-source-preview-checkbox') + box.checked = true + box.dispatchEvent(new Event('change')) + importBtn().click() + await flush() + expect(r.create).toHaveBeenCalledTimes(1) + expect(imported).toEqual(['created']) + }) +}) + +describe('removing a source', () => { + const setup = async () => { + mocks.invoke.mockImplementation(async (cmd: string) => (cmd === 'memory_source_list' ? [source(), source({ id: 's2' })] : [])) + view() + await flush() + } + + const removeBtn = (): HTMLButtonElement => q('.memory-source-item-actions .danger') + + it('asks first and removes on confirmation', async () => { + await setup() + removeBtn().click() + await flush() + expect(mocks.askConfirm).toHaveBeenCalled() + expect(mocks.invoke.mock.calls.some(c => c[0] === 'memory_source_remove')).toBe(true) + }) + + it('does nothing when the confirmation is refused', async () => { + mocks.askConfirm.mockResolvedValue(false) + await setup() + removeBtn().click() + await flush() + expect(mocks.invoke.mock.calls.some(c => c[0] === 'memory_source_remove')).toBe(false) + }) +}) From a6c11ccdb8e170156eae78ab4f154a653719f118 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 14:59:59 +0200 Subject: [PATCH 29/51] refactor: extracted the session summary jobs section into its own view Co-Authored-By: Claude Opus 5 --- src/panels/memory/MemoryPanel.ts | 89 ++-------- src/panels/memory/memorySummaryJobsView.ts | 100 +++++++++++ .../memory/memorySummaryJobsView.test.ts | 168 ++++++++++++++++++ 3 files changed, 281 insertions(+), 76 deletions(-) create mode 100644 src/panels/memory/memorySummaryJobsView.ts create mode 100644 tests/panels/memory/memorySummaryJobsView.test.ts diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 99491b9..502d055 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -20,11 +20,11 @@ import { } from '../../core/memory/normalize' import { filterMemoryEntries } from '../../core/memory/memoryFilter' import { - KIND_LABEL, KIND_OPTIONS, splitList, projectName, + KIND_LABEL, KIND_OPTIONS, splitList, timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' import { createMemorySourcesView } from './memorySourcesView' -import type { MemorySummaryJob } from '../../core/memory/memorySource' +import { createMemorySummaryJobsView } from './memorySummaryJobsView' import type { MemoryRepository } from '../../ports/MemoryRepository' @@ -98,14 +98,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): deleteSelectedBtn.textContent = i18nT('common.delete2') controls.append(search, kindFilter, sourceFilter, archivedToggle, selectVisibleBtn, clearSelectionBtn, archiveSelectedBtn, mergeSelectedBtn, deleteSelectedBtn) - const summaryJobsPanel = document.createElement('details') - summaryJobsPanel.className = 'memory-summary-jobs' - const summaryJobsTitle = document.createElement('summary') - summaryJobsTitle.textContent = i18nT('memory.sessionSummaries') - const summaryJobsList = document.createElement('div') - summaryJobsList.className = 'memory-summary-jobs-list' - summaryJobsPanel.append(summaryJobsTitle, summaryJobsList) - const list = document.createElement('div') list.className = 'memory-list' @@ -188,6 +180,15 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): form.append(kind, source, titleInput, tags, files, summary, details, saveBtn) detail.append(detailHead, form) + const summaryJobsView = createMemorySummaryJobsView({ + currentProject, + setStatus: (message, entry) => setStatus(message, entry), + onRegenerated: async updated => { + if (updated) selectedId = updated.id + await reload() + }, + }) + // The callbacks are wrapped rather than passed directly: reload and // revealMemoryEntry are declared further down. const sourcesView = createMemorySourcesView({ @@ -200,11 +201,10 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): }, }) - cs.list.append(controls, summaryJobsPanel, sourcesView.element, list) + cs.list.append(controls, summaryJobsView.element, sourcesView.element, list) root.append(cs.element, cs.resizer, detail) let entries: MemoryEntry[] = [] - let summaryJobs: MemorySummaryJob[] = [] let selectedId: string | null = null const selectedIds = new Set() @@ -245,51 +245,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): : i18nT('memory.globalMemory') } - const renderSummaryJobs = (): void => { - const pending = summaryJobs.filter(job => job.status === 'pending' || job.status === 'processing') - const failed = summaryJobs.filter(job => job.status === 'failed') - const completed = summaryJobs.filter(job => job.status === 'completed' || job.status === 'skipped') - summaryJobsTitle.textContent = i18nT('memory.summaryJobs', { - pending: pending.length ? i18nT('memory.pendingCount', { count: pending.length }) : '', - failed: failed.length ? i18nT('memory.failedCount', { count: failed.length }) : '', - completed: completed.length ? i18nT('memory.processedCount', { count: completed.length }) : '', - }) - summaryJobsList.innerHTML = '' - const actionable = [...pending, ...failed] - if (!actionable.length) { - summaryJobsList.textContent = summaryJobs.length - ? i18nT('memory.thereAreNoPendingOrFailedSummaries') - : i18nT('memory.thereAreNoRecordedSessionClosuresYet') - return - } - actionable.forEach(job => { - const row = document.createElement('div') - row.className = `memory-summary-job ${job.status}` - const text = document.createElement('div') - const projectLabel = projectName(job.projectPath) || i18nT('common.global') - text.textContent = `${job.agent} · ${projectLabel} · ${job.status}${job.error ? ` · ${job.error}` : ''}` - row.appendChild(text) - if (job.status === 'failed' || job.status === 'pending') { - const retry = document.createElement('button') - retry.className = 'memory-action' - retry.textContent = i18nT('memory.retry') - retry.addEventListener('click', () => { void retrySummaryJob(job) }) - row.appendChild(retry) - } - summaryJobsList.appendChild(row) - }) - if (failed.length) summaryJobsPanel.open = true - } - - const reloadSummaryJobs = async (): Promise => { - try { - summaryJobs = await invoke('memory_summary_job_list', { projectPath: currentProject }) - } catch { - summaryJobs = [] - } - renderSummaryJobs() - } - const syncBulkButtons = (): void => { const count = selectedIds.size clearSelectionBtn.disabled = count === 0 @@ -424,23 +379,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): requestAnimationFrame(() => list.querySelector('.memory-item.active')?.scrollIntoView({ block: 'nearest' })) } - const retrySummaryJob = async (job: MemorySummaryJob): Promise => { - try { - setStatus(i18nT('memory.regeneratingAgent', { agent: job.agent })) - const updated = await invoke('memory_regenerate_summary', { - projectPath: job.projectPath, - externalId: `${job.agent}:session-summary:${job.sessionId}`, - }) - if (updated) selectedId = updated.id - await reload() - await reloadSummaryJobs() - setStatus(updated ? i18nT('memory.summaryRegenerated') : i18nT('memory.theSummarizerReturnedNoReusableMemory'), updated ?? undefined) - } catch (error) { - await reloadSummaryJobs() - setStatus(i18nT('memory.regenerateFailed', { error: error instanceof Error ? error.message : String(error) })) - } - } - const toggleSelectedTag = async (tag: string): Promise => { const entry = selected() if (!entry) return @@ -514,7 +452,7 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): renderList() titleInput.focus() }) - refreshBtn.addEventListener('click', () => { void Promise.all([reload(), reloadSummaryJobs()]) }) + refreshBtn.addEventListener('click', () => { void Promise.all([reload(), summaryJobsView.reload()]) }) search.addEventListener('input', renderList) kindFilter.addEventListener('change', renderList) @@ -665,6 +603,5 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): syncBulkButtons() void reload() - void reloadSummaryJobs() return { element: root } } diff --git a/src/panels/memory/memorySummaryJobsView.ts b/src/panels/memory/memorySummaryJobsView.ts new file mode 100644 index 0000000..87c61c7 --- /dev/null +++ b/src/panels/memory/memorySummaryJobsView.ts @@ -0,0 +1,100 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import type { MemoryEntry } from '../../core/memory/MemoryEntry' +import { projectName } from '../../core/memory/memoryFormat' +import type { MemorySummaryJob } from '../../core/memory/memorySource' + +export interface MemorySummaryJobsViewDeps { + currentProject: string + setStatus: (message?: string, entry?: MemoryEntry) => void + /** Called with whatever the summarizer produced, so the panel can reload and select it. */ + onRegenerated: (updated: MemoryEntry | null) => Promise +} + +export interface MemorySummaryJobsView { + element: HTMLElement + reload: () => Promise +} + +/** + * The queue of agent sessions waiting to be summarized into memories. Only jobs + * the user can act on are listed; the rest are counted in the header. + */ +export function createMemorySummaryJobsView(deps: MemorySummaryJobsViewDeps): MemorySummaryJobsView { + const { currentProject, setStatus, onRegenerated } = deps + + let summaryJobs: MemorySummaryJob[] = [] + + const summaryJobsPanel = document.createElement('details') + summaryJobsPanel.className = 'memory-summary-jobs' + const summaryJobsTitle = document.createElement('summary') + summaryJobsTitle.textContent = i18nT('memory.sessionSummaries') + const summaryJobsList = document.createElement('div') + summaryJobsList.className = 'memory-summary-jobs-list' + summaryJobsPanel.append(summaryJobsTitle, summaryJobsList) + + const renderSummaryJobs = (): void => { + const pending = summaryJobs.filter(job => job.status === 'pending' || job.status === 'processing') + const failed = summaryJobs.filter(job => job.status === 'failed') + const completed = summaryJobs.filter(job => job.status === 'completed' || job.status === 'skipped') + summaryJobsTitle.textContent = i18nT('memory.summaryJobs', { + pending: pending.length ? i18nT('memory.pendingCount', { count: pending.length }) : '', + failed: failed.length ? i18nT('memory.failedCount', { count: failed.length }) : '', + completed: completed.length ? i18nT('memory.processedCount', { count: completed.length }) : '', + }) + summaryJobsList.innerHTML = '' + const actionable = [...pending, ...failed] + if (!actionable.length) { + summaryJobsList.textContent = summaryJobs.length + ? i18nT('memory.thereAreNoPendingOrFailedSummaries') + : i18nT('memory.thereAreNoRecordedSessionClosuresYet') + return + } + actionable.forEach(job => { + const row = document.createElement('div') + row.className = `memory-summary-job ${job.status}` + const text = document.createElement('div') + const projectLabel = projectName(job.projectPath) || i18nT('common.global') + text.textContent = `${job.agent} · ${projectLabel} · ${job.status}${job.error ? ` · ${job.error}` : ''}` + row.appendChild(text) + if (job.status === 'failed' || job.status === 'pending') { + const retry = document.createElement('button') + retry.className = 'memory-action' + retry.textContent = i18nT('memory.retry') + retry.addEventListener('click', () => { void retrySummaryJob(job) }) + row.appendChild(retry) + } + summaryJobsList.appendChild(row) + }) + if (failed.length) summaryJobsPanel.open = true + } + + const reloadSummaryJobs = async (): Promise => { + try { + summaryJobs = await invoke('memory_summary_job_list', { projectPath: currentProject }) + } catch { + summaryJobs = [] + } + renderSummaryJobs() + } + + const retrySummaryJob = async (job: MemorySummaryJob): Promise => { + try { + setStatus(i18nT('memory.regeneratingAgent', { agent: job.agent })) + const updated = await invoke('memory_regenerate_summary', { + projectPath: job.projectPath, + externalId: `${job.agent}:session-summary:${job.sessionId}`, + }) + await onRegenerated(updated) + await reloadSummaryJobs() + setStatus(updated ? i18nT('memory.summaryRegenerated') : i18nT('memory.theSummarizerReturnedNoReusableMemory'), updated ?? undefined) + } catch (error) { + await reloadSummaryJobs() + setStatus(i18nT('memory.regenerateFailed', { error: error instanceof Error ? error.message : String(error) })) + } + } + + void reloadSummaryJobs() + + return { element: summaryJobsPanel, reload: reloadSummaryJobs } +} diff --git a/tests/panels/memory/memorySummaryJobsView.test.ts b/tests/panels/memory/memorySummaryJobsView.test.ts new file mode 100644 index 0000000..a30319b --- /dev/null +++ b/tests/panels/memory/memorySummaryJobsView.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) + +import { createMemorySummaryJobsView } from '../../../src/panels/memory/memorySummaryJobsView' +import type { MemorySummaryJob } from '../../../src/core/memory/memorySource' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +const job = (over: Partial = {}): MemorySummaryJob => ({ + id: 'j1', projectPath: '/home/ana/bento', agent: 'claude', sessionId: 's1', + transcriptExternalId: 't1', transcriptHash: 'h', status: 'pending', error: '', + attempts: 0, metadataJson: '', createdAt: '', updatedAt: '', ...over, +}) + +let statuses: Array<{ message?: string; entry?: MemoryEntry }> +let regenerated: Array + +function view() { + const api = createMemorySummaryJobsView({ + currentProject: '/home/ana/bento', + setStatus: (message, entry) => { statuses.push({ message, entry }) }, + onRegenerated: async updated => { regenerated.push(updated) }, + }) + document.body.replaceChildren(api.element) + return api +} + +const q = (sel: string): T => document.querySelector(sel) as T +const qa = (sel: string): Element[] => [...document.querySelectorAll(sel)] + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() + statuses = [] + regenerated = [] + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue([]) +}) + +describe('the job list', () => { + it('says nothing has been recorded yet', async () => { + view() + await flush() + expect(q('.memory-summary-job')).toBeNull() + expect(document.body.textContent).not.toBe('') + }) + + it('lists only the jobs the user can act on', async () => { + mocks.invoke.mockResolvedValue([ + job({ id: 'a', status: 'pending' }), + job({ id: 'b', status: 'failed' }), + job({ id: 'c', status: 'completed' }), + job({ id: 'd', status: 'skipped' }), + ]) + view() + await flush() + expect(qa('.memory-summary-job')).toHaveLength(2) + }) + + it('says there is nothing to act on when every job finished', async () => { + mocks.invoke.mockResolvedValue([job({ status: 'completed' })]) + view() + await flush() + expect(qa('.memory-summary-job')).toHaveLength(0) + expect(document.body.textContent).not.toBe('') + }) + + it('describes a job by agent, project and status, with its error', async () => { + mocks.invoke.mockResolvedValue([job({ status: 'failed', error: 'model timed out' })]) + view() + await flush() + const text = q('.memory-summary-job div').textContent ?? '' + expect(text).toContain('claude') + expect(text).toContain('bento') + expect(text).toContain('failed') + expect(text).toContain('model timed out') + }) + + it('opens itself when something failed', async () => { + mocks.invoke.mockResolvedValue([job({ status: 'failed' })]) + const api = view() + await flush() + expect((api.element as HTMLDetailsElement).open).toBe(true) + }) + + it('stays closed when only pending work is listed', async () => { + mocks.invoke.mockResolvedValue([job({ status: 'pending' })]) + const api = view() + await flush() + expect((api.element as HTMLDetailsElement).open).toBe(false) + }) + + it('shows nothing rather than failing when the backend is unreachable', async () => { + mocks.invoke.mockRejectedValue(new Error('no backend')) + view() + await flush() + expect(qa('.memory-summary-job')).toHaveLength(0) + }) +}) + +describe('retrying a job', () => { + // The list command must keep answering: retrying reloads the list afterwards. + const setup = async (status: MemorySummaryJob['status'] = 'failed') => { + mocks.invoke.mockResolvedValue([job({ status, sessionId: 'sess-9' })]) + view() + await flush() + mocks.invoke.mockClear() + } + + const answerRegenerate = (result: MemoryEntry | null | Error): void => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd !== 'memory_regenerate_summary') return [] + if (result instanceof Error) throw result + return result + }) + } + + it('asks the backend to regenerate that session summary', async () => { + await setup() + answerRegenerate(null) + q('.memory-summary-job button').click() + await flush() + expect(mocks.invoke).toHaveBeenCalledWith('memory_regenerate_summary', { + projectPath: '/home/ana/bento', + externalId: 'claude:session-summary:sess-9', + }) + }) + + it('hands the regenerated entry back to the panel', async () => { + await setup() + const updated = { id: 'm1' } as MemoryEntry + answerRegenerate(updated) + q('.memory-summary-job button').click() + await flush() + expect(regenerated).toEqual([updated]) + }) + + it('reports when the summarizer had nothing to give back', async () => { + await setup() + answerRegenerate(null) + q('.memory-summary-job button').click() + await flush() + expect(regenerated).toEqual([null]) + expect(statuses.at(-1)?.entry).toBeUndefined() + }) + + it('reports a failure and reloads the list', async () => { + await setup() + answerRegenerate(new Error('agent unavailable')) + q('.memory-summary-job button').click() + await flush() + expect(statuses.map(s => s.message).join()).toContain('agent unavailable') + }) + + it('offers a retry for a pending job too', async () => { + await setup('pending') + expect(q('.memory-summary-job button')).not.toBeNull() + }) +}) From a9a8c9ad19c3f5201f091e2641ef8ac6437d5a6a Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 15:02:00 +0200 Subject: [PATCH 30/51] refactor: reused the import runner for Claude and Codex history imports Co-Authored-By: Claude Opus 5 --- src/core/memory/memoryImportPlan.ts | 3 +- src/panels/memory/MemoryPanel.ts | 59 +++++++------------ src/panels/memory/memoryImportRunner.ts | 4 +- tests/core/memory/memoryImportPlan.test.ts | 13 ++++ .../panels/memory/memoryImportRunner.test.ts | 21 +++++++ 5 files changed, 59 insertions(+), 41 deletions(-) diff --git a/src/core/memory/memoryImportPlan.ts b/src/core/memory/memoryImportPlan.ts index 420c2b7..513d683 100644 --- a/src/core/memory/memoryImportPlan.ts +++ b/src/core/memory/memoryImportPlan.ts @@ -38,8 +38,9 @@ const mergePatch = (duplicate: MemoryEntry, incoming: NormalizedMemory): Partial */ export const planCandidateImport = ( projectPath: string, candidate: ImportedMemoryCandidate, existing: MemoryEntry[], + updatedAt: string = new Date().toISOString(), ): ImportDecision => { - const payload = candidatePayload(candidate, new Date().toISOString()) + const payload = candidatePayload(candidate, updatedAt) const normalized = normalizeNewMemoryEntry(projectPath, payload) const alreadyImported = existing.find(entry => entry.externalId === normalized.externalId) diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 502d055..22109d6 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -11,20 +11,19 @@ import { MEMORY_SUPERSEDED_TAG, MEMORY_VERIFIED_TAG, archiveMemoryTags, - findSemanticallyDuplicate, isArchivedMemory, mergeMemoryEntries, - normalizeNewMemoryEntry, toggleMemoryTag, - uniqMemoryValues, } from '../../core/memory/normalize' import { filterMemoryEntries } from '../../core/memory/memoryFilter' import { KIND_LABEL, KIND_OPTIONS, splitList, timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' +import { runCandidateImport } from './memoryImportRunner' import { createMemorySourcesView } from './memorySourcesView' import { createMemorySummaryJobsView } from './memorySummaryJobsView' +import type { ImportedMemoryCandidate } from '../../core/memory/memorySource' import type { MemoryRepository } from '../../ports/MemoryRepository' @@ -208,6 +207,19 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): let selectedId: string | null = null const selectedIds = new Set() + // The Rust importer answers in snake_case; the rest of the panel speaks the + // candidate shape. + const toCandidate = (item: ImportedMemory): ImportedMemoryCandidate => ({ + title: item.title, + summary: item.summary, + details: item.details, + source: item.source, + externalId: item.external_id, + createdAt: item.created_at, + files: item.files, + tags: item.tags, + }) + interface ImportedMemory { title: string summary: string @@ -485,42 +497,11 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): return } const existing = await targetProjectEntries() - let saved = 0 - let merged = 0 - let skipped = 0 - for (const item of imported) { - const payload: NewMemoryEntry = { - kind: 'note', - title: item.title, - summary: item.summary, - details: item.details, - source: item.source, - externalId: item.external_id, - files: item.files, - tags: item.tags, - createdAt: item.created_at, - updatedAt: item.created_at, - } - const normalized = normalizeNewMemoryEntry(currentProject, payload) - if (existing.some(entry => entry.externalId === normalized.externalId)) { - skipped++ - continue - } - const duplicate = findSemanticallyDuplicate(existing, normalized) - if (duplicate) { - await repo.update(currentProject, duplicate.id, { - tags: uniqMemoryValues([...duplicate.tags, ...normalized.tags]), - files: uniqMemoryValues([...duplicate.files, ...normalized.files]), - summary: duplicate.summary.length >= normalized.summary.length ? duplicate.summary : normalized.summary, - details: duplicate.details.length >= normalized.details.length ? duplicate.details : normalized.details, - }) - merged++ - continue - } - const created = await repo.create(currentProject, payload) - existing.unshift(created) - saved++ - } + const { saved, merged, skipped } = await runCandidateImport( + repo, currentProject, imported.map(toCandidate), existing, + // Agent imports keep the memory's own timestamp instead of stamping now. + undefined, candidate => candidate.createdAt, + ) await reload() setStatus(i18nT('memory.importResultExisting', { saved, merged, skipped })) } catch (error) { diff --git a/src/panels/memory/memoryImportRunner.ts b/src/panels/memory/memoryImportRunner.ts index 95c7240..ee8a7de 100644 --- a/src/panels/memory/memoryImportRunner.ts +++ b/src/panels/memory/memoryImportRunner.ts @@ -22,13 +22,15 @@ export async function runCandidateImport( candidates: ImportedMemoryCandidate[], existing: MemoryEntry[], onProgress?: (current: number, total: number) => void, + /** What to stamp as updatedAt for each candidate; defaults to now. */ + updatedAt?: (candidate: ImportedMemoryCandidate) => string, ): Promise { const known = [...existing] const outcome: ImportOutcome = { saved: 0, merged: 0, skipped: 0, lastAffectedId: null } for (const [index, candidate] of candidates.entries()) { onProgress?.(index + 1, candidates.length) - const plan = planCandidateImport(projectPath, candidate, known) + const plan = planCandidateImport(projectPath, candidate, known, updatedAt?.(candidate)) if (plan.action === 'skip') { outcome.lastAffectedId = plan.entryId diff --git a/tests/core/memory/memoryImportPlan.test.ts b/tests/core/memory/memoryImportPlan.test.ts index b094bf2..943611e 100644 --- a/tests/core/memory/memoryImportPlan.test.ts +++ b/tests/core/memory/memoryImportPlan.test.ts @@ -24,6 +24,19 @@ describe('candidatePayload', () => { }) describe('planCandidateImport', () => { + it('stamps the payload with now by default', () => { + const before = Date.now() + const plan = planCandidateImport('/p', candidate(), []) + if (plan.action !== 'create') throw new Error('expected a create') + expect(new Date(plan.payload.updatedAt as string).getTime()).toBeGreaterThanOrEqual(before) + }) + + it('stamps the payload with the time it is given', () => { + const plan = planCandidateImport('/p', candidate(), [], '2020-05-05T00:00:00.000Z') + if (plan.action !== 'create') throw new Error('expected a create') + expect(plan.payload.updatedAt).toBe('2020-05-05T00:00:00.000Z') + }) + it('creates when nothing like it exists', () => { const plan = planCandidateImport('/p', candidate(), []) expect(plan.action).toBe('create') diff --git a/tests/panels/memory/memoryImportRunner.test.ts b/tests/panels/memory/memoryImportRunner.test.ts index dc40225..6aa427b 100644 --- a/tests/panels/memory/memoryImportRunner.test.ts +++ b/tests/panels/memory/memoryImportRunner.test.ts @@ -78,6 +78,27 @@ describe('across several candidates', () => { }) }) +describe('the update stamp', () => { + it('stamps each entry with what the resolver says for that candidate', async () => { + const r = repo() + await runCandidateImport( + r, '/p', + [candidate({ externalId: 'a', createdAt: '2020-05-05T00:00:00.000Z' })], + [], undefined, c => c.createdAt, + ) + const created = (r.create as ReturnType).mock.calls[0][1] as { updatedAt: string } + expect(created.updatedAt).toBe('2020-05-05T00:00:00.000Z') + }) + + it('stamps with now when no resolver is given', async () => { + const before = Date.now() + const r = repo() + await runCandidateImport(r, '/p', [candidate()], []) + const created = (r.create as ReturnType).mock.calls[0][1] as { updatedAt: string } + expect(new Date(created.updatedAt).getTime()).toBeGreaterThanOrEqual(before) + }) +}) + describe('progress', () => { it('reports each step in order', async () => { const seen: Array<[number, number]> = [] From 535f38e73430a6966fd650fce7271ff5ab8ca141 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 15:03:37 +0200 Subject: [PATCH 31/51] refactor: extracted archive, delete, merge and tagging into memoryEntryActions Co-Authored-By: Claude Opus 5 --- src/panels/memory/MemoryPanel.ts | 81 +------ src/panels/memory/memoryEntryActions.ts | 106 +++++++++ .../panels/memory/memoryEntryActions.test.ts | 218 ++++++++++++++++++ 3 files changed, 334 insertions(+), 71 deletions(-) create mode 100644 src/panels/memory/memoryEntryActions.ts create mode 100644 tests/panels/memory/memoryEntryActions.test.ts diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 22109d6..738b68d 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -1,19 +1,14 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' -import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' import { askAi } from '../../ui/askAi' import { icon } from '../../ui/icons' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import type { MemoryEntry, MemoryKind, NewMemoryEntry } from '../../core/memory/MemoryEntry' import { - MEMORY_ARCHIVED_TAG, MEMORY_PINNED_TAG, MEMORY_SUPERSEDED_TAG, MEMORY_VERIFIED_TAG, - archiveMemoryTags, isArchivedMemory, - mergeMemoryEntries, - toggleMemoryTag, } from '../../core/memory/normalize' import { filterMemoryEntries } from '../../core/memory/memoryFilter' import { @@ -21,6 +16,7 @@ import { timeLabel, sourceLabel, canRegenerateSummary, } from '../../core/memory/memoryFormat' import { runCandidateImport } from './memoryImportRunner' +import { createMemoryEntryActions } from './memoryEntryActions' import { createMemorySourcesView } from './memorySourcesView' import { createMemorySummaryJobsView } from './memorySummaryJobsView' import type { ImportedMemoryCandidate } from '../../core/memory/memorySource' @@ -391,72 +387,15 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): requestAnimationFrame(() => list.querySelector('.memory-item.active')?.scrollIntoView({ block: 'nearest' })) } - const toggleSelectedTag = async (tag: string): Promise => { - const entry = selected() - if (!entry) return - const updated = await repo.update(entry.projectPath, entry.id, { tags: toggleMemoryTag(entry, tag) }) - if (!updated) return - selectedId = updated.id - await reload() - } - - const updateEntry = async (entry: MemoryEntry, patch: Partial): Promise => ( - repo.update(entry.projectPath, entry.id, patch) - ) - - const archiveEntries = async (rows: MemoryEntry[]): Promise => { - if (!rows.length) return - for (const entry of rows) { - await updateEntry(entry, { tags: archiveMemoryTags(entry) }) - selectedIds.delete(entry.id) - } - await reload() - setStatus(rows.length === 1 ? i18nT('memory.memoryArchived') : i18nT('memory.archivedCount', { count: rows.length })) - } - - const deleteEntries = async (rows: MemoryEntry[]): Promise => { - if (!rows.length) return - const confirmed = await askConfirm( - rows.length === 1 - ? i18nT('memory.deleteOneQuestion', { title: rows[0].title || i18nT('memory.untitled2') }) - : i18nT('memory.deleteManyQuestion', { count: rows.length }), - { title: i18nT('memory.deleteMemory'), kind: 'warning', okLabel: i18nT('common.delete'), cancelLabel: i18nT('common.cancel') }, - ) - if (!confirmed) return - for (const entry of rows) { - await repo.remove(entry.projectPath, entry.id) - selectedIds.delete(entry.id) - if (selectedId === entry.id) selectedId = null - } - await reload() - setStatus(rows.length === 1 ? i18nT('memory.memoryDeleted') : i18nT('memory.deletedCount', { count: rows.length })) - } - - const mergeSelected = async (): Promise => { - const rows = selectedRows() - if (rows.length < 2) return - const merged = mergeMemoryEntries(rows) - const target = selected() && selectedIds.has(selectedId!) ? selected()! : rows[0] - const patch: Partial = { - kind: merged.kind, - title: merged.title, - summary: merged.summary, - details: merged.details, - tags: merged.tags.filter(tag => tag !== MEMORY_ARCHIVED_TAG), - files: merged.files, - source: merged.source, - externalId: merged.externalId, - } - const saved = await repo.update(target.projectPath, target.id, patch) - if (!saved) throw new Error('No se pudo fusionar la memoria principal.') - for (const entry of rows) { - if (entry.id !== target.id) await repo.remove(entry.projectPath, entry.id) - } - selectedIds.clear() - selectedId = target.id - await reload() - setStatus(i18nT('memory.mergedCount', { count: rows.length }), saved) - } + const { archiveEntries, deleteEntries, mergeSelected, toggleSelectedTag } = createMemoryEntryActions({ + repo, + getEntries: () => entries, + getSelectedId: () => selectedId, + setSelectedId: id => { selectedId = id }, + selectedIds, + reload: () => reload(), + setStatus: (message, entry) => setStatus(message, entry), + }) addBtn.addEventListener('click', () => { selectedId = null diff --git a/src/panels/memory/memoryEntryActions.ts b/src/panels/memory/memoryEntryActions.ts new file mode 100644 index 0000000..ad632c5 --- /dev/null +++ b/src/panels/memory/memoryEntryActions.ts @@ -0,0 +1,106 @@ +import { t as i18nT } from '../../i18n' +import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' +import type { MemoryEntry, NewMemoryEntry } from '../../core/memory/MemoryEntry' +import { + MEMORY_ARCHIVED_TAG, archiveMemoryTags, mergeMemoryEntries, toggleMemoryTag, +} from '../../core/memory/normalize' +import type { MemoryRepository } from '../../ports/MemoryRepository' + +export interface MemoryEntryActionsDeps { + repo: MemoryRepository + getEntries: () => MemoryEntry[] + getSelectedId: () => string | null + setSelectedId: (id: string | null) => void + /** The multi-selection, shared with the list so both see the same set. */ + selectedIds: Set + reload: () => Promise + setStatus: (message?: string, entry?: MemoryEntry) => void +} + +export interface MemoryEntryActions { + archiveEntries: (rows: MemoryEntry[]) => Promise + deleteEntries: (rows: MemoryEntry[]) => Promise + mergeSelected: () => Promise + toggleSelectedTag: (tag: string) => Promise +} + +/** What the user can do to stored memories: archive, delete, merge and tag them. */ +export function createMemoryEntryActions(deps: MemoryEntryActionsDeps): MemoryEntryActions { + const { repo, getEntries, getSelectedId, setSelectedId, selectedIds, reload, setStatus } = deps + + const selectedEntry = (): MemoryEntry | undefined => + getEntries().find(entry => entry.id === getSelectedId()) + const selectedRows = (): MemoryEntry[] => + getEntries().filter(entry => selectedIds.has(entry.id)) + + const toggleSelectedTag = async (tag: string): Promise => { + const entry = selectedEntry() + if (!entry) return + const updated = await repo.update(entry.projectPath, entry.id, { tags: toggleMemoryTag(entry, tag) }) + if (!updated) return + setSelectedId(updated.id) + await reload() + } + + const updateEntry = async (entry: MemoryEntry, patch: Partial): Promise => ( + repo.update(entry.projectPath, entry.id, patch) + ) + + const archiveEntries = async (rows: MemoryEntry[]): Promise => { + if (!rows.length) return + for (const entry of rows) { + await updateEntry(entry, { tags: archiveMemoryTags(entry) }) + selectedIds.delete(entry.id) + } + await reload() + setStatus(rows.length === 1 ? i18nT('memory.memoryArchived') : i18nT('memory.archivedCount', { count: rows.length })) + } + + const deleteEntries = async (rows: MemoryEntry[]): Promise => { + if (!rows.length) return + const confirmed = await askConfirm( + rows.length === 1 + ? i18nT('memory.deleteOneQuestion', { title: rows[0].title || i18nT('memory.untitled2') }) + : i18nT('memory.deleteManyQuestion', { count: rows.length }), + { title: i18nT('memory.deleteMemory'), kind: 'warning', okLabel: i18nT('common.delete'), cancelLabel: i18nT('common.cancel') }, + ) + if (!confirmed) return + for (const entry of rows) { + await repo.remove(entry.projectPath, entry.id) + selectedIds.delete(entry.id) + if (getSelectedId() === entry.id) setSelectedId(null) + } + await reload() + setStatus(rows.length === 1 ? i18nT('memory.memoryDeleted') : i18nT('memory.deletedCount', { count: rows.length })) + } + + const mergeSelected = async (): Promise => { + const rows = selectedRows() + if (rows.length < 2) return + const merged = mergeMemoryEntries(rows) + const open = selectedEntry() + const isOpenEntryPartOfTheMerge = Boolean(open) && selectedIds.has(open!.id) + const target = isOpenEntryPartOfTheMerge ? open! : rows[0] + const patch: Partial = { + kind: merged.kind, + title: merged.title, + summary: merged.summary, + details: merged.details, + tags: merged.tags.filter(tag => tag !== MEMORY_ARCHIVED_TAG), + files: merged.files, + source: merged.source, + externalId: merged.externalId, + } + const saved = await repo.update(target.projectPath, target.id, patch) + if (!saved) throw new Error('No se pudo fusionar la memoria principal.') + for (const entry of rows) { + if (entry.id !== target.id) await repo.remove(entry.projectPath, entry.id) + } + selectedIds.clear() + setSelectedId(target.id) + await reload() + setStatus(i18nT('memory.mergedCount', { count: rows.length }), saved) + } + + return { archiveEntries, deleteEntries, mergeSelected, toggleSelectedTag } +} diff --git a/tests/panels/memory/memoryEntryActions.test.ts b/tests/panels/memory/memoryEntryActions.test.ts new file mode 100644 index 0000000..9eb7234 --- /dev/null +++ b/tests/panels/memory/memoryEntryActions.test.ts @@ -0,0 +1,218 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ askConfirm: vi.fn(async () => true) })) +vi.mock('@tauri-apps/plugin-dialog', () => ({ confirm: mocks.askConfirm })) + +import { createMemoryEntryActions } from '../../../src/panels/memory/memoryEntryActions' +import { MEMORY_ARCHIVED_TAG, MEMORY_PINNED_TAG } from '../../../src/core/memory/normalize' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' +import type { MemoryRepository } from '../../../src/ports/MemoryRepository' + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: 'e1', projectPath: '/p', kind: 'note', title: 'A title', summary: 's', details: 'd', + source: 'manual', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +function repo(over: Partial = {}): MemoryRepository { + return { + list: vi.fn(async () => []), + create: vi.fn(async () => entry()), + update: vi.fn(async (_p: string, id: string) => entry({ id })), + remove: vi.fn(async () => true), + ...over, + } as MemoryRepository +} + +function setup(over: { repo?: MemoryRepository; entries?: MemoryEntry[]; selectedId?: string | null } = {}) { + const state = { + entries: over.entries ?? [entry()], + selectedId: over.selectedId ?? null as string | null, + selectedIds: new Set(), + reloads: 0, + statuses: [] as string[], + } + const r = over.repo ?? repo() + const actions = createMemoryEntryActions({ + repo: r, + getEntries: () => state.entries, + getSelectedId: () => state.selectedId, + setSelectedId: id => { state.selectedId = id }, + selectedIds: state.selectedIds, + reload: async () => { state.reloads++ }, + setStatus: (message, _e) => { state.statuses.push(message ?? '') }, + }) + return { actions, state, repo: r } +} + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + mocks.askConfirm.mockReset() + mocks.askConfirm.mockResolvedValue(true) +}) + +describe('archiveEntries', () => { + it('does nothing for an empty selection', async () => { + const { actions, repo: r, state } = setup() + await actions.archiveEntries([]) + expect(r.update).not.toHaveBeenCalled() + expect(state.reloads).toBe(0) + }) + + it('tags each row as archived and drops it from the selection', async () => { + const rows = [entry({ id: 'a' }), entry({ id: 'b' })] + const { actions, repo: r, state } = setup({ entries: rows }) + state.selectedIds.add('a') + state.selectedIds.add('b') + await actions.archiveEntries(rows) + expect(r.update).toHaveBeenCalledTimes(2) + expect((r.update as ReturnType).mock.calls[0][2].tags).toContain(MEMORY_ARCHIVED_TAG) + expect(state.selectedIds.size).toBe(0) + expect(state.reloads).toBe(1) + }) + + it('reports one archived differently from several', async () => { + const { actions, state } = setup() + await actions.archiveEntries([entry()]) + const single = state.statuses.at(-1) + await actions.archiveEntries([entry({ id: 'a' }), entry({ id: 'b' })]) + expect(state.statuses.at(-1)).not.toBe(single) + }) +}) + +describe('deleteEntries', () => { + it('does nothing for an empty selection, without asking', async () => { + const { actions, repo: r } = setup() + await actions.deleteEntries([]) + expect(mocks.askConfirm).not.toHaveBeenCalled() + expect(r.remove).not.toHaveBeenCalled() + }) + + it('asks first and removes on confirmation', async () => { + const rows = [entry({ id: 'a' }), entry({ id: 'b' })] + const { actions, repo: r, state } = setup({ entries: rows }) + await actions.deleteEntries(rows) + expect(mocks.askConfirm).toHaveBeenCalledTimes(1) + expect(r.remove).toHaveBeenCalledTimes(2) + expect(state.reloads).toBe(1) + }) + + it('removes nothing when the confirmation is refused', async () => { + mocks.askConfirm.mockResolvedValue(false) + const { actions, repo: r, state } = setup() + await actions.deleteEntries([entry()]) + expect(r.remove).not.toHaveBeenCalled() + expect(state.reloads).toBe(0) + }) + + it('clears the selection when the deleted entry was the selected one', async () => { + const { actions, state } = setup({ selectedId: 'a' }) + state.selectedIds.add('a') + await actions.deleteEntries([entry({ id: 'a' })]) + expect(state.selectedId).toBeNull() + expect(state.selectedIds.size).toBe(0) + }) + + it('leaves the selection alone when another entry is deleted', async () => { + const { actions, state } = setup({ selectedId: 'keep' }) + await actions.deleteEntries([entry({ id: 'other' })]) + expect(state.selectedId).toBe('keep') + }) +}) + +describe('mergeSelected', () => { + const twoSelected = (selectedId: string | null = null) => { + const rows = [entry({ id: 'a', title: 'First' }), entry({ id: 'b', title: 'Second' })] + const s = setup({ entries: rows, selectedId }) + s.state.selectedIds.add('a') + s.state.selectedIds.add('b') + return s + } + + it('needs at least two entries', async () => { + const { actions, repo: r, state } = setup() + state.selectedIds.add('e1') + await actions.mergeSelected() + expect(r.update).not.toHaveBeenCalled() + }) + + it('merges into the first row when nothing is selected in the detail pane', async () => { + const { actions, repo: r } = twoSelected(null) + await actions.mergeSelected() + expect((r.update as ReturnType).mock.calls[0][1]).toBe('a') + }) + + it('merges into the entry open in the detail pane when it is one of them', async () => { + const { actions, repo: r } = twoSelected('b') + await actions.mergeSelected() + expect((r.update as ReturnType).mock.calls[0][1]).toBe('b') + }) + + it('ignores a detail entry that is not part of the selection', async () => { + const { actions, repo: r } = twoSelected('elsewhere') + await actions.mergeSelected() + expect((r.update as ReturnType).mock.calls[0][1]).toBe('a') + }) + + it('removes the rows it folded in and keeps the target', async () => { + const { actions, repo: r } = twoSelected(null) + await actions.mergeSelected() + expect(r.remove).toHaveBeenCalledTimes(1) + expect((r.remove as ReturnType).mock.calls[0][1]).toBe('b') + }) + + it('never carries the archived tag into the merged entry', async () => { + const rows = [entry({ id: 'a', tags: [MEMORY_ARCHIVED_TAG, 'keep'] }), entry({ id: 'b' })] + const s = setup({ entries: rows }) + s.state.selectedIds.add('a') + s.state.selectedIds.add('b') + await s.actions.mergeSelected() + const patch = (s.repo.update as ReturnType).mock.calls[0][2] + expect(patch.tags).not.toContain(MEMORY_ARCHIVED_TAG) + expect(patch.tags).toContain('keep') + }) + + it('selects the merged entry and clears the multi-selection', async () => { + const { actions, state } = twoSelected(null) + await actions.mergeSelected() + expect(state.selectedId).toBe('a') + expect(state.selectedIds.size).toBe(0) + }) + + it('fails loudly when the merge cannot be saved', async () => { + const rows = [entry({ id: 'a' }), entry({ id: 'b' })] + const s = setup({ entries: rows, repo: repo({ update: vi.fn(async () => null) }) }) + s.state.selectedIds.add('a') + s.state.selectedIds.add('b') + await expect(s.actions.mergeSelected()).rejects.toThrow() + expect(s.repo.remove).not.toHaveBeenCalled() + }) +}) + +describe('toggleSelectedTag', () => { + it('does nothing when no entry is open', async () => { + const { actions, repo: r } = setup({ selectedId: null }) + await actions.toggleSelectedTag(MEMORY_PINNED_TAG) + expect(r.update).not.toHaveBeenCalled() + }) + + it('adds the tag when it is absent and removes it when present', async () => { + const { actions, repo: r } = setup({ entries: [entry({ id: 'e1' })], selectedId: 'e1' }) + await actions.toggleSelectedTag(MEMORY_PINNED_TAG) + expect((r.update as ReturnType).mock.calls[0][2].tags).toContain(MEMORY_PINNED_TAG) + + const pinned = setup({ entries: [entry({ id: 'e1', tags: [MEMORY_PINNED_TAG] })], selectedId: 'e1' }) + await pinned.actions.toggleSelectedTag(MEMORY_PINNED_TAG) + expect((pinned.repo.update as ReturnType).mock.calls[0][2].tags).not.toContain(MEMORY_PINNED_TAG) + }) + + it('does not reload when the update returns nothing', async () => { + const { actions, state } = setup({ + entries: [entry({ id: 'e1' })], selectedId: 'e1', repo: repo({ update: vi.fn(async () => null) }), + }) + await actions.toggleSelectedTag(MEMORY_PINNED_TAG) + expect(state.reloads).toBe(0) + }) +}) From d3a145b38a4a5e5dc4662dc4e46393b0cbb240e4 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 15:05:06 +0200 Subject: [PATCH 32/51] refactor: extracted the memory list rendering into memoryListView Co-Authored-By: Claude Opus 5 --- src/panels/memory/MemoryPanel.ts | 75 ++-------- src/panels/memory/memoryListView.ts | 92 ++++++++++++ tests/panels/memory/memoryListView.test.ts | 164 +++++++++++++++++++++ 3 files changed, 271 insertions(+), 60 deletions(-) create mode 100644 src/panels/memory/memoryListView.ts create mode 100644 tests/panels/memory/memoryListView.test.ts diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 738b68d..6151bd7 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -17,6 +17,7 @@ import { } from '../../core/memory/memoryFormat' import { runCandidateImport } from './memoryImportRunner' import { createMemoryEntryActions } from './memoryEntryActions' +import { createMemoryListView } from './memoryListView' import { createMemorySourcesView } from './memorySourcesView' import { createMemorySummaryJobsView } from './memorySummaryJobsView' import type { ImportedMemoryCandidate } from '../../core/memory/memorySource' @@ -24,6 +25,9 @@ import type { ImportedMemoryCandidate } from '../../core/memory/memorySource' import type { MemoryRepository } from '../../ports/MemoryRepository' export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): { element: HTMLElement } { + // Shared with the list view and the bulk actions, so all three see one set. + const selectedIds = new Set() + const root = document.createElement('div') root.className = 'memory-panel' @@ -93,8 +97,17 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): deleteSelectedBtn.textContent = i18nT('common.delete2') controls.append(search, kindFilter, sourceFilter, archivedToggle, selectVisibleBtn, clearSelectionBtn, archiveSelectedBtn, mergeSelectedBtn, deleteSelectedBtn) - const list = document.createElement('div') - list.className = 'memory-list' + const listView = createMemoryListView({ + currentProject, + getVisibleRows: () => visibleRows(), + getSelectedId: () => selectedId, + selectedIds, + setMiniItems: itemsToShow => cs.setMiniItems(itemsToShow), + onSelect: entry => { selectedId = entry.id; fillForm(entry) }, + onSelectionChanged: () => syncBulkButtons(), + }) + const list = listView.element + const renderList = (): void => listView.render() const detail = document.createElement('div') detail.className = 'memory-detail' @@ -201,7 +214,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): let entries: MemoryEntry[] = [] let selectedId: string | null = null - const selectedIds = new Set() // The Rust importer answers in snake_case; the rest of the panel speaks the // candidate shape. @@ -298,63 +310,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): setStatus(undefined, entry) } - const renderList = (): void => { - list.innerHTML = '' - const rows = visibleRows() - cs.setMiniItems(rows.map(entry => ({ - label: entry.title || i18nT('memory.untitled'), - active: entry.id === selectedId, - onClick: () => { selectedId = entry.id; fillForm(entry); renderList() }, - }))) - if (!rows.length) { - const empty = document.createElement('div') - empty.className = 'memory-empty' - empty.textContent = i18nT('memory.thereIsNoSavedMemoryForThisFilter') - list.appendChild(empty) - return - } - rows.forEach(entry => { - const item = document.createElement('div') - item.className = entry.id === selectedId ? 'memory-item active' : 'memory-item' - const top = document.createElement('div') - top.className = 'memory-item-top' - const checkbox = document.createElement('input') - checkbox.type = 'checkbox' - checkbox.checked = selectedIds.has(entry.id) - checkbox.addEventListener('click', event => event.stopPropagation()) - checkbox.addEventListener('change', () => { - if (checkbox.checked) selectedIds.add(entry.id) - else selectedIds.delete(entry.id) - syncBulkButtons() - }) - const badge = document.createElement('span') - badge.className = `memory-kind ${entry.kind}` - badge.textContent = KIND_LABEL[entry.kind] - const entryTitle = document.createElement('span') - entryTitle.className = 'memory-item-title' - entryTitle.textContent = entry.title || i18nT('memory.untitled') - const sourceBadge = document.createElement('span') - sourceBadge.className = 'memory-source' - sourceBadge.textContent = sourceLabel(entry.source) - if (entry.tags.includes(MEMORY_PINNED_TAG)) item.classList.add('pinned') - if (entry.tags.includes(MEMORY_VERIFIED_TAG)) item.classList.add('verified') - if (isArchivedMemory(entry)) item.classList.add('archived') - top.append(checkbox, badge, entryTitle, sourceBadge) - const text = document.createElement('div') - text.className = 'memory-item-summary' - text.textContent = currentProject - ? entry.summary || entry.details || i18nT('memory.noSummary') - : `${entry.projectPath || i18nT('common.global')} · ${entry.summary || entry.details || i18nT('memory.noSummary')}` - item.append(top, text) - item.addEventListener('click', () => { - selectedId = entry.id - fillForm(entry) - renderList() - }) - list.appendChild(item) - }) - } - const reload = async (): Promise => { try { entries = await repo.list(currentProject) diff --git a/src/panels/memory/memoryListView.ts b/src/panels/memory/memoryListView.ts new file mode 100644 index 0000000..9d50852 --- /dev/null +++ b/src/panels/memory/memoryListView.ts @@ -0,0 +1,92 @@ +import { t as i18nT } from '../../i18n' +import type { MemoryEntry } from '../../core/memory/MemoryEntry' +import { KIND_LABEL, sourceLabel } from '../../core/memory/memoryFormat' +import { + MEMORY_PINNED_TAG, MEMORY_VERIFIED_TAG, isArchivedMemory, +} from '../../core/memory/normalize' + +export interface MemoryListViewDeps { + /** Empty when browsing every project's memory, which is then named per row. */ + currentProject: string + getVisibleRows: () => MemoryEntry[] + getSelectedId: () => string | null + /** The multi-selection, shared with the bulk actions. */ + selectedIds: Set + setMiniItems: (items: Array<{ label: string; active: boolean; onClick: () => void }>) => void + onSelect: (entry: MemoryEntry) => void + onSelectionChanged: () => void +} + +export interface MemoryListView { + element: HTMLElement + render: () => void +} + +/** The list of stored memories: one row per entry, tickable for bulk actions. */ +export function createMemoryListView(deps: MemoryListViewDeps): MemoryListView { + const { + currentProject, getVisibleRows, getSelectedId, selectedIds, + setMiniItems, onSelect, onSelectionChanged, + } = deps + + const list = document.createElement('div') + list.className = 'memory-list' + + const render = (): void => { + list.innerHTML = '' + const rows = getVisibleRows() + setMiniItems(rows.map(entry => ({ + label: entry.title || i18nT('memory.untitled'), + active: entry.id === getSelectedId(), + onClick: () => { onSelect(entry); render() }, + }))) + if (!rows.length) { + const empty = document.createElement('div') + empty.className = 'memory-empty' + empty.textContent = i18nT('memory.thereIsNoSavedMemoryForThisFilter') + list.appendChild(empty) + return + } + rows.forEach(entry => { + const item = document.createElement('div') + item.className = entry.id === getSelectedId() ? 'memory-item active' : 'memory-item' + const top = document.createElement('div') + top.className = 'memory-item-top' + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.checked = selectedIds.has(entry.id) + checkbox.addEventListener('click', event => event.stopPropagation()) + checkbox.addEventListener('change', () => { + if (checkbox.checked) selectedIds.add(entry.id) + else selectedIds.delete(entry.id) + onSelectionChanged() + }) + const badge = document.createElement('span') + badge.className = `memory-kind ${entry.kind}` + badge.textContent = KIND_LABEL[entry.kind] + const entryTitle = document.createElement('span') + entryTitle.className = 'memory-item-title' + entryTitle.textContent = entry.title || i18nT('memory.untitled') + const sourceBadge = document.createElement('span') + sourceBadge.className = 'memory-source' + sourceBadge.textContent = sourceLabel(entry.source) + if (entry.tags.includes(MEMORY_PINNED_TAG)) item.classList.add('pinned') + if (entry.tags.includes(MEMORY_VERIFIED_TAG)) item.classList.add('verified') + if (isArchivedMemory(entry)) item.classList.add('archived') + top.append(checkbox, badge, entryTitle, sourceBadge) + const text = document.createElement('div') + text.className = 'memory-item-summary' + text.textContent = currentProject + ? entry.summary || entry.details || i18nT('memory.noSummary') + : `${entry.projectPath || i18nT('common.global')} · ${entry.summary || entry.details || i18nT('memory.noSummary')}` + item.append(top, text) + item.addEventListener('click', () => { + onSelect(entry) + render() + }) + list.appendChild(item) + }) + } + + return { element: list, render } +} diff --git a/tests/panels/memory/memoryListView.test.ts b/tests/panels/memory/memoryListView.test.ts new file mode 100644 index 0000000..f211d22 --- /dev/null +++ b/tests/panels/memory/memoryListView.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' +import { createMemoryListView } from '../../../src/panels/memory/memoryListView' +import { + MEMORY_ARCHIVED_TAG, MEMORY_PINNED_TAG, MEMORY_VERIFIED_TAG, +} from '../../../src/core/memory/normalize' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: 'e1', projectPath: '/p', kind: 'note', title: 'A title', summary: 'a summary', details: '', + source: 'manual', tags: [], files: [], createdAt: '', updatedAt: '', ...over, +} as MemoryEntry) + +function setup(over: { rows?: MemoryEntry[]; selectedId?: string | null; currentProject?: string } = {}) { + const state = { + selectedId: over.selectedId ?? null as string | null, + selectedIds: new Set(), + miniItems: [] as Array<{ label: string; active: boolean; onClick: () => void }>, + selections: [] as Array, + bulkSyncs: 0, + } + const view = createMemoryListView({ + currentProject: over.currentProject ?? '/p', + getVisibleRows: () => over.rows ?? [entry()], + getSelectedId: () => state.selectedId, + selectedIds: state.selectedIds, + setMiniItems: items => { state.miniItems = items }, + onSelect: e => { state.selectedId = e.id; state.selections.push(e) }, + onSelectionChanged: () => { state.bulkSyncs++ }, + }) + document.body.replaceChildren(view.element) + view.render() + return { view, state } +} + +const items = (): HTMLElement[] => [...document.querySelectorAll('.memory-item')] as HTMLElement[] +const q = (sel: string): T => document.querySelector(sel) as T + +beforeEach(() => { + vi.stubGlobal('localStorage', makeLocalStorage()) + localStorage.setItem('bento.locale', 'en') + document.body.replaceChildren() +}) + +describe('the empty state', () => { + it('says nothing matches the filter', () => { + setup({ rows: [] }) + expect(q('.memory-empty')).not.toBeNull() + expect(items()).toHaveLength(0) + }) + + it('clears the mini list too', () => { + const { state } = setup({ rows: [] }) + expect(state.miniItems).toEqual([]) + }) +}) + +describe('rendering rows', () => { + it('shows the kind, title and source of each entry', () => { + setup({ rows: [entry({ kind: 'decision', title: 'Chose SQLite', source: 'claude' })] }) + expect(q('.memory-kind').textContent).toBeTruthy() + expect(q('.memory-item-title').textContent).toBe('Chose SQLite') + expect(q('.memory-source').textContent).toBe('claude') + }) + + it('falls back to a placeholder title and summary', () => { + setup({ rows: [entry({ title: '', summary: '', details: '' })] }) + expect(q('.memory-item-title').textContent).not.toBe('') + expect(q('.memory-item-summary').textContent).not.toBe('') + }) + + it('shows the details when there is no summary', () => { + setup({ rows: [entry({ summary: '', details: 'the details' })] }) + expect(q('.memory-item-summary').textContent).toBe('the details') + }) + + it('prefixes the project when browsing memory globally', () => { + setup({ currentProject: '', rows: [entry({ projectPath: '/home/ana/bento' })] }) + expect(q('.memory-item-summary').textContent).toContain('/home/ana/bento') + }) + + it('marks pinned, verified and archived entries', () => { + setup({ rows: [ + entry({ id: 'a', tags: [MEMORY_PINNED_TAG] }), + entry({ id: 'b', tags: [MEMORY_VERIFIED_TAG] }), + entry({ id: 'c', tags: [MEMORY_ARCHIVED_TAG] }), + ] }) + expect(items()[0].classList.contains('pinned')).toBe(true) + expect(items()[1].classList.contains('verified')).toBe(true) + expect(items()[2].classList.contains('archived')).toBe(true) + }) + + it('highlights the open entry', () => { + setup({ rows: [entry({ id: 'a' }), entry({ id: 'b' })], selectedId: 'b' }) + expect(items().map(i => i.classList.contains('active'))).toEqual([false, true]) + }) + + it('replaces the rows instead of appending on a re-render', () => { + const { view } = setup({ rows: [entry()] }) + view.render() + expect(items()).toHaveLength(1) + }) +}) + +describe('opening an entry', () => { + it('reports the clicked entry', () => { + const { state } = setup({ rows: [entry({ id: 'a' }), entry({ id: 'b' })] }) + items()[1].click() + expect(state.selections.at(-1)?.id).toBe('b') + }) + + it('re-renders so the highlight moves', () => { + setup({ rows: [entry({ id: 'a' }), entry({ id: 'b' })] }) + items()[1].click() + expect(items()[1].classList.contains('active')).toBe(true) + }) +}) + +describe('the multi-selection', () => { + const boxes = (): HTMLInputElement[] => + [...document.querySelectorAll('.memory-item input[type="checkbox"]')] as HTMLInputElement[] + + it('reflects what is already selected', () => { + const { state, view } = setup({ rows: [entry({ id: 'a' }), entry({ id: 'b' })] }) + state.selectedIds.add('b') + view.render() + expect(boxes().map(b => b.checked)).toEqual([false, true]) + }) + + it('adds and removes on tick, telling the panel each time', () => { + const { state } = setup({ rows: [entry({ id: 'a' })] }) + const box = boxes()[0] + box.checked = true + box.dispatchEvent(new Event('change')) + expect([...state.selectedIds]).toEqual(['a']) + expect(state.bulkSyncs).toBe(1) + + box.checked = false + box.dispatchEvent(new Event('change')) + expect(state.selectedIds.size).toBe(0) + expect(state.bulkSyncs).toBe(2) + }) + + it('does not open the entry when only the checkbox is clicked', () => { + const { state } = setup({ rows: [entry({ id: 'a' })] }) + boxes()[0].click() + expect(state.selections).toHaveLength(0) + }) +}) + +describe('the mini list', () => { + it('mirrors the rows and marks the open one', () => { + const { state } = setup({ rows: [entry({ id: 'a', title: 'One' }), entry({ id: 'b', title: 'Two' })], selectedId: 'b' }) + expect(state.miniItems.map(i => i.label)).toEqual(['One', 'Two']) + expect(state.miniItems.map(i => i.active)).toEqual([false, true]) + }) + + it('opens an entry when its mini item is clicked', () => { + const { state } = setup({ rows: [entry({ id: 'a' })] }) + state.miniItems[0].onClick() + expect(state.selections.at(-1)?.id).toBe('a') + }) +}) From a47d5158b1d7c385a10fd16b48975752655ddf34 Mon Sep 17 00:00:00 2001 From: romadesign Date: Sun, 23 Aug 2026 15:08:38 +0200 Subject: [PATCH 33/51] refactor: extracted the entry detail pane into memoryDetailView Co-Authored-By: Claude Opus 5 --- src/panels/memory/MemoryPanel.ts | 228 ++------------ src/panels/memory/memoryDetailView.ts | 224 ++++++++++++++ tests/panels/memory/memoryDetailView.test.ts | 297 +++++++++++++++++++ 3 files changed, 547 insertions(+), 202 deletions(-) create mode 100644 src/panels/memory/memoryDetailView.ts create mode 100644 tests/panels/memory/memoryDetailView.test.ts diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 6151bd7..a33c3a2 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -1,23 +1,15 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' -import { askAi } from '../../ui/askAi' import { icon } from '../../ui/icons' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' -import type { MemoryEntry, MemoryKind, NewMemoryEntry } from '../../core/memory/MemoryEntry' -import { - MEMORY_PINNED_TAG, - MEMORY_SUPERSEDED_TAG, - MEMORY_VERIFIED_TAG, - isArchivedMemory, -} from '../../core/memory/normalize' +import type { MemoryEntry, MemoryKind } from '../../core/memory/MemoryEntry' +import { isArchivedMemory } from '../../core/memory/normalize' import { filterMemoryEntries } from '../../core/memory/memoryFilter' -import { - KIND_LABEL, KIND_OPTIONS, splitList, - timeLabel, sourceLabel, canRegenerateSummary, -} from '../../core/memory/memoryFormat' +import { KIND_LABEL, KIND_OPTIONS } from '../../core/memory/memoryFormat' import { runCandidateImport } from './memoryImportRunner' import { createMemoryEntryActions } from './memoryEntryActions' import { createMemoryListView } from './memoryListView' +import { createMemoryDetailView } from './memoryDetailView' import { createMemorySourcesView } from './memorySourcesView' import { createMemorySummaryJobsView } from './memorySummaryJobsView' import type { ImportedMemoryCandidate } from '../../core/memory/memorySource' @@ -109,85 +101,30 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): const list = listView.element const renderList = (): void => listView.render() - const detail = document.createElement('div') - detail.className = 'memory-detail' - - const detailHead = document.createElement('div') - detailHead.className = 'memory-detail-head' - const status = document.createElement('div') - status.className = 'memory-status' - const askBtn = document.createElement('button') - askBtn.className = 'memory-action' - askBtn.title = i18nT('common.sendToAiChat') - askBtn.innerHTML = icon('chat') - const regenerateBtn = document.createElement('button') - regenerateBtn.className = 'memory-action' - regenerateBtn.title = i18nT('memory.regenerateSummaryFromTranscript') - regenerateBtn.textContent = i18nT('memory.regenerate') - const archiveBtn = document.createElement('button') - archiveBtn.className = 'memory-action' - archiveBtn.title = i18nT('memory.archiveEntry') - archiveBtn.textContent = i18nT('memory.archive') - const pinBtn = document.createElement('button') - pinBtn.className = 'memory-action' - pinBtn.title = i18nT('memory.keepThisMemoryPrioritized') - pinBtn.textContent = i18nT('memory.pin') - const verifyBtn = document.createElement('button') - verifyBtn.className = 'memory-action' - verifyBtn.title = i18nT('memory.markContentAsManuallyReviewed') - verifyBtn.textContent = i18nT('memory.verify') - const supersedeBtn = document.createElement('button') - supersedeBtn.className = 'memory-action' - supersedeBtn.title = i18nT('memory.markAsObsoleteOrReplaced') - supersedeBtn.textContent = i18nT('memory.obsolete') - const deleteBtn = document.createElement('button') - deleteBtn.className = 'memory-action danger' - deleteBtn.title = i18nT('memory.deleteEntry') - deleteBtn.innerHTML = icon('trash') - detailHead.append(status, askBtn, regenerateBtn, pinBtn, verifyBtn, supersedeBtn, archiveBtn, deleteBtn) - - const form = document.createElement('div') - form.className = 'memory-form' - - const kind = document.createElement('select') - kind.className = 'memory-input' - KIND_OPTIONS.filter((value): value is MemoryKind => value !== 'all').forEach(value => { - const option = document.createElement('option') - option.value = value - option.textContent = KIND_LABEL[value] - kind.appendChild(option) + const entryActions = createMemoryEntryActions({ + repo, + getEntries: () => entries, + getSelectedId: () => selectedId, + setSelectedId: id => { selectedId = id }, + selectedIds, + reload: () => reload(), + setStatus: (message, entry) => setStatus(message, entry), }) - const source = document.createElement('input') - source.className = 'memory-input' - source.placeholder = i18nT('memory.sourceManualCodexClaude') - - const titleInput = document.createElement('input') - titleInput.className = 'memory-input' - titleInput.placeholder = i18nT('common.title') - - const tags = document.createElement('input') - tags.className = 'memory-input' - tags.placeholder = i18nT('memory.tagsPlaceholder') - - const files = document.createElement('input') - files.className = 'memory-input' - files.placeholder = i18nT('memory.filesSrcATsSrcBTs') - - const summary = document.createElement('textarea') - summary.className = 'memory-textarea summary' - summary.placeholder = i18nT('memory.shortReusableSummary') - - const details = document.createElement('textarea') - details.className = 'memory-textarea' - details.placeholder = i18nT('memory.detailsContextWhyNextStep') - - const saveBtn = document.createElement('button') - saveBtn.className = 'memory-primary' - saveBtn.textContent = i18nT('common.save') + const detailView = createMemoryDetailView({ + repo, + currentProject, + getSelectedEntry: () => selected(), + getSelectedId: () => selectedId, + setSelectedId: id => { selectedId = id }, + reload: () => reload(), + actions: entryActions, + }) + const detail = detailView.element + const fillForm = (entry?: MemoryEntry): void => detailView.fill(entry) + const setStatus = (message?: string, entry?: MemoryEntry): void => detailView.setStatus(message, entry) + const { archiveEntries, deleteEntries, mergeSelected } = entryActions - form.append(kind, source, titleInput, tags, files, summary, details, saveBtn) - detail.append(detailHead, form) const summaryJobsView = createMemorySummaryJobsView({ currentProject, setStatus: (message, entry) => setStatus(message, entry), @@ -253,18 +190,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): return rows.filter(entry => entry.projectPath === currentProject) } - const setStatus = (message?: string, entry?: MemoryEntry): void => { - if (message) { - status.textContent = message - return - } - status.textContent = entry - ? `${KIND_LABEL[entry.kind]} · ${sourceLabel(entry.source)} · ${timeLabel(entry.updatedAt)}` - : currentProject - ? i18nT('memory.projectLabel', { project: currentProject }) - : i18nT('memory.globalMemory') - } - const syncBulkButtons = (): void => { const count = selectedIds.size clearSelectionBtn.disabled = count === 0 @@ -289,27 +214,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): sourceFilter.value = sources.includes(previous) ? previous : 'all' } - const fillForm = (entry?: MemoryEntry): void => { - kind.value = entry?.kind ?? 'decision' - source.value = entry?.source ?? 'manual' - titleInput.value = entry?.title ?? '' - tags.value = entry?.tags.join(', ') ?? '' - files.value = entry?.files.join(', ') ?? '' - summary.value = entry?.summary ?? '' - details.value = entry?.details ?? '' - deleteBtn.disabled = !entry - askBtn.disabled = !entry - archiveBtn.disabled = !entry - pinBtn.disabled = !entry - verifyBtn.disabled = !entry - supersedeBtn.disabled = !entry - pinBtn.textContent = entry?.tags.includes(MEMORY_PINNED_TAG) ? i18nT('memory.unpin') : i18nT('memory.pin') - verifyBtn.textContent = entry?.tags.includes(MEMORY_VERIFIED_TAG) ? i18nT('memory.verified') : i18nT('memory.verify') - supersedeBtn.textContent = entry?.tags.includes(MEMORY_SUPERSEDED_TAG) ? i18nT('memory.restore') : i18nT('memory.obsolete') - regenerateBtn.disabled = !canRegenerateSummary(entry) - setStatus(undefined, entry) - } - const reload = async (): Promise => { try { entries = await repo.list(currentProject) @@ -342,21 +246,11 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): requestAnimationFrame(() => list.querySelector('.memory-item.active')?.scrollIntoView({ block: 'nearest' })) } - const { archiveEntries, deleteEntries, mergeSelected, toggleSelectedTag } = createMemoryEntryActions({ - repo, - getEntries: () => entries, - getSelectedId: () => selectedId, - setSelectedId: id => { selectedId = id }, - selectedIds, - reload: () => reload(), - setStatus: (message, entry) => setStatus(message, entry), - }) - addBtn.addEventListener('click', () => { selectedId = null fillForm() renderList() - titleInput.focus() + detailView.focusTitle() }) refreshBtn.addEventListener('click', () => { void Promise.all([reload(), summaryJobsView.reload()]) }) @@ -406,76 +300,6 @@ export function createMemoryPanel(repo: MemoryRepository, projectPath?: string): importClaudeBtn.addEventListener('click', () => { void importEntries('claude') }) importCodexBtn.addEventListener('click', () => { void importEntries('codex') }) - saveBtn.addEventListener('click', () => { void (async () => { - const payload: NewMemoryEntry = { - kind: kind.value as MemoryKind, - source: source.value.trim() || 'manual', - title: titleInput.value.trim(), - summary: summary.value.trim(), - details: details.value.trim(), - tags: splitList(tags.value), - files: splitList(files.value), - } - if (!payload.title && !payload.summary && !payload.details) return - try { - saveBtn.disabled = true - const entry = selectedId - ? await repo.update(currentProject, selectedId, payload) - : await repo.create(currentProject, payload) - if (!entry) throw new Error('La entrada ya no existe.') - selectedId = entry.id - await reload() - setStatus(i18nT('memory.memorySaved'), entry) - } catch (error) { - setStatus(i18nT('memory.saveFailed', { error: error instanceof Error ? error.message : String(error) })) - } finally { - saveBtn.disabled = false - } - })() }) - - archiveBtn.addEventListener('click', () => { void archiveEntries(selected() ? [selected()!] : []).catch(error => setStatus(String(error))) }) - pinBtn.addEventListener('click', () => { void toggleSelectedTag(MEMORY_PINNED_TAG).catch(error => setStatus(String(error))) }) - verifyBtn.addEventListener('click', () => { void toggleSelectedTag(MEMORY_VERIFIED_TAG).catch(error => setStatus(String(error))) }) - supersedeBtn.addEventListener('click', () => { void toggleSelectedTag(MEMORY_SUPERSEDED_TAG).catch(error => setStatus(String(error))) }) - deleteBtn.addEventListener('click', () => { void deleteEntries(selected() ? [selected()!] : []).catch(error => setStatus(String(error))) }) - regenerateBtn.addEventListener('click', () => { void (async () => { - const entry = selected() - if (!entry || !entry.externalId.includes(':session-summary:')) return - try { - regenerateBtn.disabled = true - setStatus(i18nT('memory.regeneratingSummaryFromTranscript')) - const updated = await invoke('memory_regenerate_summary', { - projectPath: entry.projectPath, - externalId: entry.externalId, - }) - if (!updated) { - setStatus(i18nT('memory.theSummaryCouldNotBeRegeneratedOrThere')) - return - } - selectedId = updated.id - await reload() - setStatus(i18nT('memory.summaryRegenerated'), updated) - } catch (error) { - setStatus(i18nT('memory.regenerateFailed', { error: error instanceof Error ? error.message : String(error) })) - } finally { - regenerateBtn.disabled = !canRegenerateSummary(selected()) - } - })() }) - - askBtn.addEventListener('click', () => { - const entry = selected() - if (!entry) return - askAi( - `Contexto — memoria reutilizable del proyecto${currentProject ? ` (${currentProject})` : ''}:\n\n` + - `Tipo: ${KIND_LABEL[entry.kind]}\n` + - `Origen: ${entry.source}\n` + - `Título: ${entry.title}\n` + - `Tags: ${entry.tags.join(', ')}\n` + - `Archivos: ${entry.files.join(', ')}\n\n` + - `${entry.summary}\n\n${entry.details}\n` - ) - }) - syncBulkButtons() void reload() return { element: root } diff --git a/src/panels/memory/memoryDetailView.ts b/src/panels/memory/memoryDetailView.ts new file mode 100644 index 0000000..70ad9ff --- /dev/null +++ b/src/panels/memory/memoryDetailView.ts @@ -0,0 +1,224 @@ +import { t as i18nT } from '../../i18n' +import { invoke } from '@tauri-apps/api/core' +import { askAi } from '../../ui/askAi' +import { icon } from '../../ui/icons' +import type { MemoryEntry, MemoryKind, NewMemoryEntry } from '../../core/memory/MemoryEntry' +import { + KIND_LABEL, KIND_OPTIONS, splitList, timeLabel, sourceLabel, canRegenerateSummary, +} from '../../core/memory/memoryFormat' +import { + MEMORY_PINNED_TAG, MEMORY_SUPERSEDED_TAG, MEMORY_VERIFIED_TAG, +} from '../../core/memory/normalize' +import type { MemoryRepository } from '../../ports/MemoryRepository' +import type { MemoryEntryActions } from './memoryEntryActions' + +export interface MemoryDetailViewDeps { + repo: MemoryRepository + currentProject: string + getSelectedEntry: () => MemoryEntry | undefined + getSelectedId: () => string | null + setSelectedId: (id: string | null) => void + reload: () => Promise + actions: MemoryEntryActions +} + +export interface MemoryDetailView { + element: HTMLElement + /** Shows an entry in the form, or clears it when given nothing. */ + fill: (entry?: MemoryEntry) => void + /** The status line, shared by the whole panel. */ + setStatus: (message?: string, entry?: MemoryEntry) => void + /** Moves the cursor into the title field, for a brand new entry. */ + focusTitle: () => void +} + +/** The right-hand pane: the entry form plus the actions that act on one entry. */ +export function createMemoryDetailView(deps: MemoryDetailViewDeps): MemoryDetailView { + const { repo, currentProject, getSelectedEntry, getSelectedId, setSelectedId, reload, actions } = deps + + const detail = document.createElement('div') + detail.className = 'memory-detail' + + const detailHead = document.createElement('div') + detailHead.className = 'memory-detail-head' + const status = document.createElement('div') + status.className = 'memory-status' + const askBtn = document.createElement('button') + askBtn.className = 'memory-action' + askBtn.title = i18nT('common.sendToAiChat') + askBtn.innerHTML = icon('chat') + const regenerateBtn = document.createElement('button') + regenerateBtn.className = 'memory-action' + regenerateBtn.title = i18nT('memory.regenerateSummaryFromTranscript') + regenerateBtn.textContent = i18nT('memory.regenerate') + const archiveBtn = document.createElement('button') + archiveBtn.className = 'memory-action' + archiveBtn.title = i18nT('memory.archiveEntry') + archiveBtn.textContent = i18nT('memory.archive') + const pinBtn = document.createElement('button') + pinBtn.className = 'memory-action' + pinBtn.title = i18nT('memory.keepThisMemoryPrioritized') + pinBtn.textContent = i18nT('memory.pin') + const verifyBtn = document.createElement('button') + verifyBtn.className = 'memory-action' + verifyBtn.title = i18nT('memory.markContentAsManuallyReviewed') + verifyBtn.textContent = i18nT('memory.verify') + const supersedeBtn = document.createElement('button') + supersedeBtn.className = 'memory-action' + supersedeBtn.title = i18nT('memory.markAsObsoleteOrReplaced') + supersedeBtn.textContent = i18nT('memory.obsolete') + const deleteBtn = document.createElement('button') + deleteBtn.className = 'memory-action danger' + deleteBtn.title = i18nT('memory.deleteEntry') + deleteBtn.innerHTML = icon('trash') + detailHead.append(status, askBtn, regenerateBtn, pinBtn, verifyBtn, supersedeBtn, archiveBtn, deleteBtn) + + const form = document.createElement('div') + form.className = 'memory-form' + + const kind = document.createElement('select') + kind.className = 'memory-input' + KIND_OPTIONS.filter((value): value is MemoryKind => value !== 'all').forEach(value => { + const option = document.createElement('option') + option.value = value + option.textContent = KIND_LABEL[value] + kind.appendChild(option) + }) + + const source = document.createElement('input') + source.className = 'memory-input' + source.placeholder = i18nT('memory.sourceManualCodexClaude') + + const titleInput = document.createElement('input') + titleInput.className = 'memory-input' + titleInput.placeholder = i18nT('common.title') + + const tags = document.createElement('input') + tags.className = 'memory-input' + tags.placeholder = i18nT('memory.tagsPlaceholder') + + const files = document.createElement('input') + files.className = 'memory-input' + files.placeholder = i18nT('memory.filesSrcATsSrcBTs') + + const summary = document.createElement('textarea') + summary.className = 'memory-textarea summary' + summary.placeholder = i18nT('memory.shortReusableSummary') + + const details = document.createElement('textarea') + details.className = 'memory-textarea' + details.placeholder = i18nT('memory.detailsContextWhyNextStep') + + const saveBtn = document.createElement('button') + saveBtn.className = 'memory-primary' + saveBtn.textContent = i18nT('common.save') + + form.append(kind, source, titleInput, tags, files, summary, details, saveBtn) + detail.append(detailHead, form) + + const setStatus = (message?: string, entry?: MemoryEntry): void => { + if (message) { + status.textContent = message + return + } + status.textContent = entry + ? `${KIND_LABEL[entry.kind]} · ${sourceLabel(entry.source)} · ${timeLabel(entry.updatedAt)}` + : currentProject + ? i18nT('memory.projectLabel', { project: currentProject }) + : i18nT('memory.globalMemory') + } + + const fill = (entry?: MemoryEntry): void => { + kind.value = entry?.kind ?? 'decision' + source.value = entry?.source ?? 'manual' + titleInput.value = entry?.title ?? '' + tags.value = entry?.tags.join(', ') ?? '' + files.value = entry?.files.join(', ') ?? '' + summary.value = entry?.summary ?? '' + details.value = entry?.details ?? '' + deleteBtn.disabled = !entry + askBtn.disabled = !entry + archiveBtn.disabled = !entry + pinBtn.disabled = !entry + verifyBtn.disabled = !entry + supersedeBtn.disabled = !entry + pinBtn.textContent = entry?.tags.includes(MEMORY_PINNED_TAG) ? i18nT('memory.unpin') : i18nT('memory.pin') + verifyBtn.textContent = entry?.tags.includes(MEMORY_VERIFIED_TAG) ? i18nT('memory.verified') : i18nT('memory.verify') + supersedeBtn.textContent = entry?.tags.includes(MEMORY_SUPERSEDED_TAG) ? i18nT('memory.restore') : i18nT('memory.obsolete') + regenerateBtn.disabled = !canRegenerateSummary(entry) + setStatus(undefined, entry) + } + + saveBtn.addEventListener('click', () => { void (async () => { + const payload: NewMemoryEntry = { + kind: kind.value as MemoryKind, + source: source.value.trim() || 'manual', + title: titleInput.value.trim(), + summary: summary.value.trim(), + details: details.value.trim(), + tags: splitList(tags.value), + files: splitList(files.value), + } + if (!payload.title && !payload.summary && !payload.details) return + try { + saveBtn.disabled = true + const openId = getSelectedId() + const entry = openId + ? await repo.update(currentProject, openId, payload) + : await repo.create(currentProject, payload) + if (!entry) throw new Error('La entrada ya no existe.') + setSelectedId(entry.id) + await reload() + setStatus(i18nT('memory.memorySaved'), entry) + } catch (error) { + setStatus(i18nT('memory.saveFailed', { error: error instanceof Error ? error.message : String(error) })) + } finally { + saveBtn.disabled = false + } + })() }) + + archiveBtn.addEventListener('click', () => { void actions.archiveEntries(getSelectedEntry() ? [getSelectedEntry()!] : []).catch(error => setStatus(String(error))) }) + pinBtn.addEventListener('click', () => { void actions.toggleSelectedTag(MEMORY_PINNED_TAG).catch(error => setStatus(String(error))) }) + verifyBtn.addEventListener('click', () => { void actions.toggleSelectedTag(MEMORY_VERIFIED_TAG).catch(error => setStatus(String(error))) }) + supersedeBtn.addEventListener('click', () => { void actions.toggleSelectedTag(MEMORY_SUPERSEDED_TAG).catch(error => setStatus(String(error))) }) + deleteBtn.addEventListener('click', () => { void actions.deleteEntries(getSelectedEntry() ? [getSelectedEntry()!] : []).catch(error => setStatus(String(error))) }) + regenerateBtn.addEventListener('click', () => { void (async () => { + const entry = getSelectedEntry() + if (!entry || !entry.externalId.includes(':session-summary:')) return + try { + regenerateBtn.disabled = true + setStatus(i18nT('memory.regeneratingSummaryFromTranscript')) + const updated = await invoke('memory_regenerate_summary', { + projectPath: entry.projectPath, + externalId: entry.externalId, + }) + if (!updated) { + setStatus(i18nT('memory.theSummaryCouldNotBeRegeneratedOrThere')) + return + } + setSelectedId(updated.id) + await reload() + setStatus(i18nT('memory.summaryRegenerated'), updated) + } catch (error) { + setStatus(i18nT('memory.regenerateFailed', { error: error instanceof Error ? error.message : String(error) })) + } finally { + regenerateBtn.disabled = !canRegenerateSummary(getSelectedEntry()) + } + })() }) + + askBtn.addEventListener('click', () => { + const entry = getSelectedEntry() + if (!entry) return + askAi( + `Contexto — memoria reutilizable del proyecto${currentProject ? ` (${currentProject})` : ''}:\n\n` + + `Tipo: ${KIND_LABEL[entry.kind]}\n` + + `Origen: ${entry.source}\n` + + `Título: ${entry.title}\n` + + `Tags: ${entry.tags.join(', ')}\n` + + `Archivos: ${entry.files.join(', ')}\n\n` + + `${entry.summary}\n\n${entry.details}\n` + ) + }) + + return { element: detail, fill, setStatus, focusTitle: () => titleInput.focus() } +} diff --git a/tests/panels/memory/memoryDetailView.test.ts b/tests/panels/memory/memoryDetailView.test.ts new file mode 100644 index 0000000..c82e0ae --- /dev/null +++ b/tests/panels/memory/memoryDetailView.test.ts @@ -0,0 +1,297 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { makeLocalStorage } from '../../helpers/localStorage' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async () => undefined as unknown), + askAi: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })) +vi.mock('../../../src/ui/askAi', () => ({ askAi: mocks.askAi })) + +import { createMemoryDetailView } from '../../../src/panels/memory/memoryDetailView' +import { + MEMORY_PINNED_TAG, MEMORY_VERIFIED_TAG, MEMORY_SUPERSEDED_TAG, +} from '../../../src/core/memory/normalize' +import type { MemoryEntry } from '../../../src/core/memory/MemoryEntry' +import type { MemoryRepository } from '../../../src/ports/MemoryRepository' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +const entry = (over: Partial = {}): MemoryEntry => ({ + id: 'e1', projectPath: '/p', kind: 'decision', title: 'A title', summary: 'a summary', + details: 'the details', source: 'manual', externalId: '', tags: [], files: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', ...over, +} as MemoryEntry) + +function repo(over: Partial = {}): MemoryRepository { + return { + list: vi.fn(async () => []), + create: vi.fn(async () => entry({ id: 'created' })), + update: vi.fn(async (_p: string, id: string) => entry({ id })), + remove: vi.fn(async () => true), + ...over, + } as MemoryRepository +} + +function setup(over: { repo?: MemoryRepository; currentProject?: string; open?: MemoryEntry } = {}) { + const state = { + selectedId: over.open?.id ?? null as string | null, + reloads: 0, + archived: [] as MemoryEntry[][], + deleted: [] as MemoryEntry[][], + toggled: [] as string[], + } + const r = over.repo ?? repo() + const view = createMemoryDetailView({ + repo: r, + currentProject: over.currentProject ?? '/p', + getSelectedEntry: () => over.open, + getSelectedId: () => state.selectedId, + setSelectedId: id => { state.selectedId = id }, + reload: async () => { state.reloads++ }, + actions: { + archiveEntries: async rows => { state.archived.push(rows) }, + deleteEntries: async rows => { state.deleted.push(rows) }, + toggleSelectedTag: async tag => { state.toggled.push(tag) }, + mergeSelected: async () => {}, + }, + }) + document.body.replaceChildren(view.element) + view.fill(over.open) + return { view, state, repo: r } +} + +const q = (sel: string): T => document.querySelector(sel) as T +const btn = (title: string): HTMLButtonElement => + [...document.querySelectorAll('button')] + .find(b => (b.title ?? '').toLowerCase().includes(title.toLowerCase())) as HTMLButtonElement +// The kind