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..7f511f1 --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/shared.css @@ -0,0 +1,20 @@ +*{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 ───────────────────────────── */ +.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..a66ab94 --- /dev/null +++ b/daemon/bento-daemon/src/remote/web/terminal.css @@ -0,0 +1,34 @@ +/* ── 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} +#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); 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", diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs deleted file mode 100644 index d81f2b0..0000000 --- a/src-tauri/src/db.rs +++ /dev/null @@ -1,1268 +0,0 @@ -// Detect database servers (Docker containers + local ports) and explore them: -// list databases/tables/collections/keys, read rows, edit and delete. -// Detection parsing lives in the frontend (src/core/db, TDD'd); here we do the I/O. -// -// One runner (`run_client`) serves both targets: a Docker container (run the -// client inside it) and a local server (run the host's own client with -h/-p). - -use crate::docker::{docker_bin, docker_output, is_safe_container}; -use std::io::Read; -use std::net::{SocketAddr, TcpStream}; -use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -// Cap on client output (mysql/psql/…): a wide SELECT * can spit out hundreds of -// MB (HTML/blob columns) and blow up the backend when read into memory. -const MAX_CLIENT_OUTPUT: usize = 8 * 1024 * 1024; - -// Time cap: an unbounded wide JOIN leaves the server computing without returning -// a single row, and the backend stays blocked reading a stdout that never arrives -// (the UI shows "Ejecutando…" forever). Past this, we kill the client. -const CLIENT_TIMEOUT: Duration = Duration::from_secs(20); - -// Per-client flags to connect to a local (non-Docker) server over TCP. -fn host_flags(client: &str, host: &str, port: u16) -> Vec { - let p = port.to_string(); - match client { - "mysql" => vec!["-h".into(), host.into(), "-P".into(), p], - "psql" => vec!["-h".into(), host.into(), "-p".into(), p], - "mongosh" | "mongo" => vec!["--host".into(), host.into(), "--port".into(), p], - "redis-cli" => vec!["-h".into(), host.into(), "-p".into(), p], - _ => vec![], - } -} - -// Run a database client. An empty container means a local server: run the host's -// own client with -h/-p (you have it if you installed the DB natively). Otherwise -// run the client inside the container with `docker exec`. `op` is everything after -// the client name; `env` holds vars like PGPASSWORD (passed via -e for Docker). -fn run_client( - container: &str, - host: &str, - port: u16, - client: &str, - op: &[&str], - env: &[(&str, &str)], -) -> Result { - let local = container.is_empty(); - let program: String; - let mut args: Vec = Vec::new(); - if local { - program = client.to_string(); - args.extend(host_flags(client, host, port)); - args.extend(op.iter().map(|s| s.to_string())); - } else { - if !is_safe_container(container) { - return Err("contenedor inválido".into()); - } - program = docker_bin().ok_or("docker no encontrado")?; - args.push("exec".into()); - for (k, v) in env { - args.push("-e".into()); - args.push(format!("{}={}", k, v)); - } - args.push(container.to_string()); - args.push(client.to_string()); - args.extend(op.iter().map(|s| s.to_string())); - } - let mut cmd = Command::new(&program); - cmd.args(&args); - if local { - for (k, v) in env { - cmd.env(k, v); - } - } - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - let mut child = cmd - .spawn() - .map_err(|_| format!("'{}' no está disponible (instálalo o usa Docker)", client))?; - - // Watchdog: if the client takes longer than CLIENT_TIMEOUT (query hung on the - // server), we kill it by pid so the stdout read unblocks. On a normal exit, - // `finished` stops the thread before killing anything. - let pid = child.id(); - let finished = Arc::new(AtomicBool::new(false)); - let timed_out = Arc::new(AtomicBool::new(false)); - let watch_finished = finished.clone(); - let watch_timed_out = timed_out.clone(); - let watchdog = std::thread::spawn(move || { - let step = Duration::from_millis(100); - let mut waited = Duration::ZERO; - while waited < CLIENT_TIMEOUT { - std::thread::sleep(step); - if watch_finished.load(Ordering::Relaxed) { - return; - } - waited += step; - } - watch_timed_out.store(true, Ordering::Relaxed); - let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status(); - }); - - // stderr on a thread (bounded) to avoid blocking or deadlocking with stdout. - let stderr_pipe = child.stderr.take(); - let stderr_handle = std::thread::spawn(move || { - let mut s = String::new(); - if let Some(se) = stderr_pipe { - let _ = se.take(64 * 1024).read_to_string(&mut s); - } - s - }); - - // stdout read with a cap: if exceeded, we kill the process and truncate. - let mut buf: Vec = Vec::new(); - if let Some(mut stdout) = child.stdout.take() { - let mut chunk = [0u8; 64 * 1024]; - loop { - match stdout.read(&mut chunk) { - Ok(0) => break, - Ok(n) => { - let room = MAX_CLIENT_OUTPUT.saturating_sub(buf.len()); - buf.extend_from_slice(&chunk[..n.min(room)]); - if n > room { - let _ = child.kill(); - break; - } - } - Err(_) => break, - } - } - } - - let status = child.wait().map_err(|e| e.to_string())?; - finished.store(true, Ordering::Relaxed); - let _ = watchdog.join(); - let stderr = stderr_handle.join().unwrap_or_default(); - if timed_out.load(Ordering::Relaxed) { - return Err(format!( - "La consulta superó el límite de {}s y se canceló. Reduce el número de tablas/JOINs o añade condiciones (WHERE) más selectivas.", - CLIENT_TIMEOUT.as_secs() - )); - } - if !status.success() && buf.is_empty() { - return Err(stderr.trim().to_string()); - } - Ok(String::from_utf8_lossy(&buf).to_string()) -} - -fn lines_of(out: String) -> Vec { - out.lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect() -} - -// Reject identifiers that could break out of a backtick (SQL) or single-quote (JS) -// context. Names come from the database's own metadata, so this is belt-and-braces. -fn is_safe_ident(s: &str) -> bool { - !s.is_empty() && s.len() <= 128 && !s.contains(['`', '\'', '"', '\\', '\n', '\r', ';']) -} - -#[derive(serde::Serialize)] -pub struct TableData { - columns: Vec, - rows: Vec>, -} - -// Foreign-key relation: table.column → ref_table.ref_column. -#[derive(serde::Serialize)] -pub struct ForeignKey { - table: String, - column: String, - ref_table: String, - ref_column: String, -} - -fn parse_fks(out: String) -> Vec { - out.lines() - .filter_map(|l| { - let p: Vec<&str> = l.split('\t').collect(); - if p.len() >= 4 && !p[0].is_empty() && !p[2].is_empty() { - Some(ForeignKey { - table: p[0].into(), - column: p[1].into(), - ref_table: p[2].into(), - ref_column: p[3].into(), - }) - } else { - None - } - }) - .collect() -} - -// ---------------- detection (Docker + local ports) ---------------- - -#[tauri::command] -pub fn db_docker_ps() -> String { - docker_output(&["ps", "--format", "{{.Names}}|{{.Image}}|{{.Ports}}"]).unwrap_or_default() -} - -#[tauri::command] -pub fn db_inspect_env(container: String) -> Vec { - if !is_safe_container(&container) { - return Vec::new(); - } - docker_output(&[ - "inspect", - "-f", - "{{range .Config.Env}}{{println .}}{{end}}", - &container, - ]) - .map(|s| { - s.lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn is_open(port: u16) -> bool { - let addr: SocketAddr = ([127, 0, 0, 1], port).into(); - TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok() -} - -#[tauri::command] -pub fn db_check_ports(ports: Vec) -> Vec { - ports.into_iter().filter(|p| is_open(*p)).collect() -} - -// ---------------- MySQL / MariaDB ---------------- - -fn mysql_op(user: &str, password: &str, query: &str, raw: bool) -> Vec { - // -N drops the header row (used for plain lists); table data keeps it. - let mut a: Vec = vec![ - "-u".into(), - user.into(), - "-B".into(), - "-e".into(), - query.into(), - ]; - if raw { - a.insert(2, "-N".into()); - } - if !password.is_empty() { - a.insert(2, format!("-p{}", password)); - } - a -} - -fn run_mysql(container: &str, host: &str, port: u16, op: &[String]) -> Result { - let refs: Vec<&str> = op.iter().map(String::as_str).collect(); - run_client(container, host, port, "mysql", &refs, &[]) -} - -fn sql_quote(v: &str) -> String { - format!("'{}'", v.replace('\\', "\\\\").replace('\'', "\\'")) -} - -#[tauri::command] -pub fn db_docker_list_mysql( - container: String, - host: String, - port: u16, - user: String, - password: String, -) -> Result, String> { - let op = mysql_op(&user, &password, "SHOW DATABASES", true); - run_mysql(&container, &host, port, &op).map(lines_of) -} - -#[tauri::command] -pub fn db_docker_mysql_tables( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - if !is_safe_ident(&db) { - return Err("nombre de base inválido".into()); - } - let op = mysql_op(&user, &password, &format!("SHOW TABLES IN `{}`", db), true); - run_mysql(&container, &host, port, &op).map(lines_of) -} - -fn parse_table(out: String) -> TableData { - // Defensive caps: a SELECT * over a wide JOIN can bring back hundreds of - // columns and huge cells (HTML, blobs). Unbounded, the payload traveling - // to the WebView blows it up. We clip cells and rows; the front also limits rendering. - const MAX_ROWS: usize = 200; - const MAX_COLS: usize = 80; - const MAX_CELL: usize = 500; - const MAX_JSON_CELL: usize = 50_000; - let clip = |s: &str| -> String { - let trimmed = s.trim_start(); - let limit = if trimmed.starts_with('{') || trimmed.starts_with('[') { - MAX_JSON_CELL - } else { - MAX_CELL - }; - if s.chars().take(limit + 1).count() > limit { - format!("{}…", s.chars().take(limit).collect::()) - } else { - s.to_string() - } - }; - let mut lines = out.lines(); - let columns: Vec = match lines.next() { - Some(header) => header - .split('\t') - .take(MAX_COLS) - .map(str::to_string) - .collect(), - None => { - return TableData { - columns: vec![], - rows: vec![], - } - } - }; - let rows = lines - .take(MAX_ROWS) - .map(|l| l.split('\t').take(MAX_COLS).map(clip).collect()) - .collect(); - TableData { columns, rows } -} - -#[tauri::command] -pub fn db_docker_mysql_rows( - container: String, - host: String, - port: u16, - db: String, - table: String, - user: String, - password: String, -) -> Result { - if !is_safe_ident(&db) || !is_safe_ident(&table) { - return Err("nombre inválido".into()); - } - let op = mysql_op( - &user, - &password, - &format!("SELECT * FROM `{}`.`{}` LIMIT 200", db, table), - false, - ); - run_mysql(&container, &host, port, &op).map(parse_table) -} - -// Runs free-form SQL against the `db` database. A dev tool over your own local -// databases: the query is intentionally arbitrary (like any client). -#[tauri::command] -pub fn db_docker_mysql_query( - container: String, - host: String, - port: u16, - db: String, - sql: String, - user: String, - password: String, -) -> Result { - if !is_safe_ident(&db) { - return Err("nombre de base inválido".into()); - } - let op = mysql_op(&user, &password, &format!("USE `{}`; {}", db, sql), false); - run_mysql(&container, &host, port, &op).map(parse_table) -} - -// Relations (foreign keys) of a MySQL/MariaDB database. -#[tauri::command] -pub fn db_docker_mysql_fks( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - if !is_safe_ident(&db) { - return Err("nombre de base inválido".into()); - } - let query = format!( - "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='{}' AND REFERENCED_TABLE_NAME IS NOT NULL", - db - ); - let op = mysql_op(&user, &password, &query, true); - run_mysql(&container, &host, port, &op).map(parse_fks) -} - -#[tauri::command] -pub fn db_docker_mysql_pk( - container: String, - host: String, - port: u16, - db: String, - table: String, - user: String, - password: String, -) -> Result, String> { - if !is_safe_ident(&db) || !is_safe_ident(&table) { - return Err("nombre inválido".into()); - } - let query = format!( - "SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='{}' AND TABLE_NAME='{}' AND CONSTRAINT_NAME='PRIMARY' ORDER BY ORDINAL_POSITION", - db, table - ); - let op = mysql_op(&user, &password, &query, true); - run_mysql(&container, &host, port, &op).map(lines_of) -} - -fn mysql_where(wheres: &[(String, String)]) -> Result { - if wheres.is_empty() { - return Err("la tabla no tiene clave primaria".into()); - } - let mut conds = Vec::new(); - for (col, val) in wheres { - if !is_safe_ident(col) { - return Err("columna inválida".into()); - } - conds.push(format!("`{}` = {}", col, sql_quote(val))); - } - Ok(conds.join(" AND ")) -} - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub fn db_docker_mysql_update( - container: String, - host: String, - port: u16, - db: String, - table: String, - column: String, - value: String, - wheres: Vec<(String, String)>, - user: String, - password: String, -) -> Result<(), String> { - if !is_safe_ident(&db) || !is_safe_ident(&table) || !is_safe_ident(&column) { - return Err("nombre inválido".into()); - } - let where_clause = mysql_where(&wheres)?; - let query = format!( - "UPDATE `{}`.`{}` SET `{}` = {} WHERE {}", - db, - table, - column, - sql_quote(&value), - where_clause - ); - let op = mysql_op(&user, &password, &query, false); - run_mysql(&container, &host, port, &op).map(|_| ()) -} - -#[tauri::command] -pub fn db_docker_mysql_delete( - container: String, - host: String, - port: u16, - db: String, - table: String, - wheres: Vec<(String, String)>, - user: String, - password: String, -) -> Result<(), String> { - if !is_safe_ident(&db) || !is_safe_ident(&table) { - return Err("nombre inválido".into()); - } - let where_clause = mysql_where(&wheres)?; - let cascade_query = format!( - "SELECT TABLE_NAME FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='{}' AND REFERENCED_TABLE_NAME='{}' AND DELETE_RULE='CASCADE'", - db, table - ); - let cascades = run_mysql( - &container, - &host, - port, - &mysql_op(&user, &password, &cascade_query, true), - ) - .map(lines_of)?; - if !cascades.is_empty() { - return Err(format!( - "Bloqueado: borrar aquí arrastraría en cascada (ON DELETE CASCADE) a: {}", - cascades.join(", ") - )); - } - let query = format!("DELETE FROM `{}`.`{}` WHERE {}", db, table, where_clause); - run_mysql( - &container, - &host, - port, - &mysql_op(&user, &password, &query, false), - ) - .map(|_| ()) -} - -// ---------------- MongoDB ---------------- - -// Run a JS snippet in the mongo shell: mongosh (mongo:5+) with a fallback to the -// legacy `mongo` shell. -fn mongo_eval( - container: &str, - host: &str, - port: u16, - user: &str, - password: &str, - script: &str, -) -> Result { - let mut op: Vec = vec!["--quiet".into()]; - if !user.is_empty() { - op.extend([ - "-u".into(), - user.into(), - "-p".into(), - password.into(), - "--authenticationDatabase".into(), - "admin".into(), - ]); - } - op.extend(["--eval".into(), script.into()]); - let refs: Vec<&str> = op.iter().map(String::as_str).collect(); - run_client(container, host, port, "mongosh", &refs, &[]) - .or_else(|_| run_client(container, host, port, "mongo", &refs, &[])) -} - -fn mongo_escape(doc: &str) -> String { - doc.replace('\\', "\\\\") - .replace('\'', "\\'") - .replace('\n', " ") - .replace('\r', "") -} - -#[tauri::command] -pub fn db_docker_list_mongo( - container: String, - host: String, - port: u16, - user: String, - password: String, -) -> Result, String> { - let script = - "db.adminCommand('listDatabases').databases.map(function(d){return d.name}).join('\\n')"; - mongo_eval(&container, &host, port, &user, &password, script).map(lines_of) -} - -#[tauri::command] -pub fn db_docker_mongo_collections( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - if !is_safe_ident(&db) { - return Err("nombre de base inválido".into()); - } - let script = format!("db.getSiblingDB('{}').getCollectionNames().join('\\n')", db); - mongo_eval(&container, &host, port, &user, &password, &script).map(lines_of) -} - -#[tauri::command] -pub fn db_docker_mongo_docs( - container: String, - host: String, - port: u16, - db: String, - collection: String, - user: String, - password: String, -) -> Result, String> { - if !is_safe_ident(&db) || !is_safe_ident(&collection) { - return Err("nombre inválido".into()); - } - let script = format!( - "db.getSiblingDB('{}').getCollection('{}').find().limit(50).toArray().map(function(d){{return EJSON.stringify(d)}}).join('\\n')", - db, collection - ); - mongo_eval(&container, &host, port, &user, &password, &script).map(lines_of) -} - -// Runs a free-form mongosh script in the context of `db` (dev tool). -#[tauri::command] -pub fn db_docker_mongo_query( - container: String, - host: String, - port: u16, - db: String, - script: String, - user: String, - password: String, -) -> Result { - if !is_safe_ident(&db) { - return Err("nombre de base inválido".into()); - } - let wrapped = format!("db = db.getSiblingDB('{}'); {}", db, script); - mongo_eval(&container, &host, port, &user, &password, &wrapped) -} - -// Mongo relations (heuristic): *Id/*_id or ObjectId fields that point to -// another collection, guessed by name. References, not enforced FKs. -#[tauri::command] -pub fn db_docker_mongo_refs( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - if !is_safe_ident(&db) { - return Err("nombre de base inválido".into()); - } - let script = format!( - r#"var D=db.getSiblingDB('{}');var names=D.getCollectionNames();var out=[];var seen={{}};names.forEach(function(coll){{var docs=D.getCollection(coll).find().limit(20).toArray();docs.forEach(function(doc){{Object.keys(doc).forEach(function(k){{if(k==='_id')return;var key=coll+'|'+k;if(seen[key])return;var v=doc[k];var looksId=/(_id|Id)$/.test(k)||(v instanceof ObjectId);if(!looksId)return;seen[key]=1;var base=k.replace(/(_id|Id)$/,'').toLowerCase();var target='';for(var i=0;i Vec { - out.lines() - .filter_map(|l| { - let p: Vec<&str> = l.split('\t').collect(); - if p.len() >= 3 && !p[0].is_empty() && !p[2].is_empty() { - Some(ForeignKey { - table: p[0].into(), - column: p[1].into(), - ref_table: p[2].into(), - ref_column: "_id".into(), - }) - } else { - None - } - }) - .collect() -} - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub fn db_docker_mongo_update( - container: String, - host: String, - port: u16, - db: String, - collection: String, - doc: String, - user: String, - password: String, -) -> Result<(), String> { - if !is_safe_ident(&db) || !is_safe_ident(&collection) { - return Err("nombre inválido".into()); - } - let script = format!( - "var d=EJSON.parse('{}');var id=d._id;delete d._id;db.getSiblingDB('{}').getCollection('{}').replaceOne({{_id:id}},d)", - mongo_escape(&doc), db, collection - ); - mongo_eval(&container, &host, port, &user, &password, &script).map(|_| ()) -} - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub fn db_docker_mongo_delete( - container: String, - host: String, - port: u16, - db: String, - collection: String, - doc: String, - user: String, - password: String, -) -> Result<(), String> { - if !is_safe_ident(&db) || !is_safe_ident(&collection) { - return Err("nombre inválido".into()); - } - let script = format!( - "var d=EJSON.parse('{}');db.getSiblingDB('{}').getCollection('{}').deleteOne({{_id:d._id}})", - mongo_escape(&doc), db, collection - ); - mongo_eval(&container, &host, port, &user, &password, &script).map(|_| ()) -} - -// ---------------- PostgreSQL ---------------- - -fn psql( - container: &str, - host: &str, - port: u16, - db: &str, - user: &str, - password: &str, - extra: &[&str], -) -> Result { - if !is_safe_ident(db) || !is_safe_ident(user) { - return Err("parámetro inválido".into()); - } - let mut op: Vec = vec!["-U".into(), user.into(), "-d".into(), db.into()]; - op.extend(extra.iter().map(|s| s.to_string())); - let refs: Vec<&str> = op.iter().map(String::as_str).collect(); - run_client( - container, - host, - port, - "psql", - &refs, - &[("PGPASSWORD", password)], - ) -} - -fn split_qualified(name: &str) -> (String, String) { - match name.split_once('.') { - Some((schema, table)) => (schema.to_string(), table.to_string()), - None => ("public".to_string(), name.to_string()), - } -} - -fn pg_quote(v: &str) -> String { - format!("'{}'", v.replace('\'', "''")) -} - -fn pg_where(wheres: &[(String, String)]) -> Result { - if wheres.is_empty() { - return Err("la tabla no tiene clave primaria".into()); - } - let mut conds = Vec::new(); - for (col, val) in wheres { - if !is_safe_ident(col) { - return Err("columna inválida".into()); - } - conds.push(format!("\"{}\" = {}", col, pg_quote(val))); - } - Ok(conds.join(" AND ")) -} - -#[tauri::command] -pub fn db_docker_pg_databases( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - let out = psql(&container, &host, port, &db, &user, &password, &[ - "-t", "-A", "-c", - "SELECT datname FROM pg_database WHERE datistemplate=false AND datallowconn ORDER BY datname", - ])?; - Ok(lines_of(out)) -} - -#[tauri::command] -pub fn db_docker_pg_tables( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - let out = psql(&container, &host, port, &db, &user, &password, &[ - "-t", "-A", "-c", - "SELECT table_schema||'.'||table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1", - ])?; - Ok(lines_of(out)) -} - -#[tauri::command] -pub fn db_docker_pg_rows( - container: String, - host: String, - port: u16, - db: String, - table: String, - user: String, - password: String, -) -> Result { - let (schema, tbl) = split_qualified(&table); - if !is_safe_ident(&schema) || !is_safe_ident(&tbl) { - return Err("nombre inválido".into()); - } - let query = format!("SELECT * FROM \"{}\".\"{}\" LIMIT 200", schema, tbl); - let out = psql( - &container, - &host, - port, - &db, - &user, - &password, - &[ - "-A", - "-F", - "\t", - "-P", - "footer=off", - "-P", - "null=NULL", - "-c", - &query, - ], - )?; - Ok(parse_table(out)) -} - -// Runs free-form SQL against `db` (dev tool; arbitrary query). -#[tauri::command] -pub fn db_docker_pg_query( - container: String, - host: String, - port: u16, - db: String, - sql: String, - user: String, - password: String, -) -> Result { - let out = psql( - &container, - &host, - port, - &db, - &user, - &password, - &[ - "-A", - "-F", - "\t", - "-P", - "footer=off", - "-P", - "null=NULL", - "-c", - &sql, - ], - )?; - Ok(parse_table(out)) -} - -// Relations (foreign keys) of a PostgreSQL database. -#[tauri::command] -pub fn db_docker_pg_fks( - container: String, - host: String, - port: u16, - db: String, - user: String, - password: String, -) -> Result, String> { - // schema.table on both sides: Postgres has several schemas, not just public. - let query = "SELECT tc.table_schema||'.'||tc.table_name, kcu.column_name, ccu.table_schema||'.'||ccu.table_name, ccu.column_name \ - FROM information_schema.table_constraints tc \ - JOIN information_schema.key_column_usage kcu ON tc.constraint_name=kcu.constraint_name AND tc.table_schema=kcu.table_schema \ - JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name=tc.constraint_name AND ccu.table_schema=tc.table_schema \ - WHERE tc.constraint_type='FOREIGN KEY'"; - let out = psql( - &container, - &host, - port, - &db, - &user, - &password, - &["-t", "-A", "-F", "\t", "-c", query], - )?; - Ok(parse_fks(out)) -} - -#[tauri::command] -pub fn db_docker_pg_pk( - container: String, - host: String, - port: u16, - db: String, - table: String, - user: String, - password: String, -) -> Result, String> { - let (schema, tbl) = split_qualified(&table); - if !is_safe_ident(&schema) || !is_safe_ident(&tbl) { - return Err("nombre inválido".into()); - } - let query = format!( - "SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey) WHERE i.indrelid='\"{}\".\"{}\"'::regclass AND i.indisprimary ORDER BY array_position(i.indkey, a.attnum)", - schema, tbl - ); - let out = psql( - &container, - &host, - port, - &db, - &user, - &password, - &["-t", "-A", "-c", &query], - )?; - Ok(lines_of(out)) -} - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub fn db_docker_pg_update( - container: String, - host: String, - port: u16, - db: String, - table: String, - column: String, - value: String, - wheres: Vec<(String, String)>, - user: String, - password: String, -) -> Result<(), String> { - let (schema, tbl) = split_qualified(&table); - if !is_safe_ident(&schema) || !is_safe_ident(&tbl) || !is_safe_ident(&column) { - return Err("nombre inválido".into()); - } - let where_clause = pg_where(&wheres)?; - let query = format!( - "UPDATE \"{}\".\"{}\" SET \"{}\" = {} WHERE {}", - schema, - tbl, - column, - pg_quote(&value), - where_clause - ); - psql( - &container, - &host, - port, - &db, - &user, - &password, - &["-c", &query], - ) - .map(|_| ()) -} - -#[tauri::command] -pub fn db_docker_pg_delete( - container: String, - host: String, - port: u16, - db: String, - table: String, - wheres: Vec<(String, String)>, - user: String, - password: String, -) -> Result<(), String> { - let (schema, tbl) = split_qualified(&table); - if !is_safe_ident(&schema) || !is_safe_ident(&tbl) { - return Err("nombre inválido".into()); - } - let where_clause = pg_where(&wheres)?; - let cascade_query = format!( - "SELECT conrelid::regclass::text FROM pg_constraint WHERE confrelid='\"{}\".\"{}\"'::regclass AND confdeltype='c'", - schema, tbl - ); - let cascades = lines_of(psql( - &container, - &host, - port, - &db, - &user, - &password, - &["-t", "-A", "-c", &cascade_query], - )?); - if !cascades.is_empty() { - return Err(format!( - "Bloqueado: borrar aquí arrastraría en cascada (ON DELETE CASCADE) a: {}", - cascades.join(", ") - )); - } - let query = format!( - "DELETE FROM \"{}\".\"{}\" WHERE {}", - schema, tbl, where_clause - ); - psql( - &container, - &host, - port, - &db, - &user, - &password, - &["-c", &query], - ) - .map(|_| ()) -} - -// ---------------- Redis (db index → keys → value by type) ---------------- - -fn redis_cli( - container: &str, - host: &str, - port: u16, - db: &str, - password: &str, - args: &[&str], -) -> Result { - if db.is_empty() || !db.chars().all(|c| c.is_ascii_digit()) { - return Err("parámetro inválido".into()); - } - let mut op: Vec = Vec::new(); - if !password.is_empty() { - op.extend(["-a".into(), password.into(), "--no-auth-warning".into()]); - } - op.extend(["-n".into(), db.into()]); - op.extend(args.iter().map(|s| s.to_string())); - let refs: Vec<&str> = op.iter().map(String::as_str).collect(); - run_client(container, host, port, "redis-cli", &refs, &[]) -} - -#[tauri::command] -pub fn db_docker_redis_dbs( - container: String, - host: String, - port: u16, - password: String, -) -> Result, String> { - // INFO keyspace lists only the logical DBs that hold keys (db0:keys=2,...). - let out = redis_cli( - &container, - &host, - port, - "0", - &password, - &["INFO", "keyspace"], - )?; - let dbs = out - .lines() - .filter_map(|l| { - let idx = l.trim().strip_prefix("db")?.split(':').next()?; - let numeric = !idx.is_empty() && idx.chars().all(|c| c.is_ascii_digit()); - numeric.then(|| idx.to_string()) - }) - .collect(); - Ok(dbs) -} - -#[tauri::command] -pub fn db_docker_redis_keys( - container: String, - host: String, - port: u16, - db: String, - password: String, -) -> Result, String> { - let out = redis_cli(&container, &host, port, &db, &password, &["--scan"])?; - Ok(lines_of(out).into_iter().take(1000).collect()) -} - -#[derive(serde::Serialize)] -pub struct RedisValue { - kind: String, - value: String, -} - -#[tauri::command] -pub fn db_docker_redis_value( - container: String, - host: String, - port: u16, - db: String, - key: String, - password: String, -) -> Result { - let kind = redis_cli(&container, &host, port, &db, &password, &["TYPE", &key])? - .trim() - .to_string(); - let value = match kind.as_str() { - "string" => redis_cli(&container, &host, port, &db, &password, &["GET", &key])?, - "hash" => redis_cli(&container, &host, port, &db, &password, &["HGETALL", &key])?, - "list" => redis_cli( - &container, - &host, - port, - &db, - &password, - &["LRANGE", &key, "0", "-1"], - )?, - "set" => redis_cli(&container, &host, port, &db, &password, &["SMEMBERS", &key])?, - "zset" => redis_cli( - &container, - &host, - port, - &db, - &password, - &["ZRANGE", &key, "0", "-1", "WITHSCORES"], - )?, - "stream" => redis_cli( - &container, - &host, - port, - &db, - &password, - &["XRANGE", &key, "-", "+", "COUNT", "50"], - )?, - _ => String::new(), - }; - Ok(RedisValue { kind, value }) -} - -#[tauri::command] -pub fn db_docker_redis_set( - container: String, - host: String, - port: u16, - db: String, - key: String, - value: String, - password: String, -) -> Result<(), String> { - redis_cli( - &container, - &host, - port, - &db, - &password, - &["SET", &key, &value], - ) - .map(|_| ()) -} - -#[tauri::command] -pub fn db_docker_redis_ttl( - container: String, - host: String, - port: u16, - db: String, - key: String, - password: String, -) -> Result { - redis_cli(&container, &host, port, &db, &password, &["TTL", &key]) - .map(|s| s.trim().parse::().unwrap_or(-2)) -} - -// Runs a free-form redis-cli command against the `db` database (dev tool). -#[tauri::command] -pub fn db_docker_redis_command( - container: String, - host: String, - port: u16, - db: String, - command: String, - password: String, -) -> Result { - let args: Vec<&str> = command.split_whitespace().collect(); - if args.is_empty() { - return Err("comando vacío".into()); - } - redis_cli(&container, &host, port, &db, &password, &args) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn host_flags_per_client() { - assert_eq!( - host_flags("mysql", "127.0.0.1", 3306), - vec!["-h", "127.0.0.1", "-P", "3306"] - ); - assert_eq!( - host_flags("psql", "127.0.0.1", 5432), - vec!["-h", "127.0.0.1", "-p", "5432"] - ); - assert_eq!( - host_flags("mongosh", "localhost", 27017), - vec!["--host", "localhost", "--port", "27017"] - ); - assert_eq!( - host_flags("redis-cli", "127.0.0.1", 6379), - vec!["-h", "127.0.0.1", "-p", "6379"] - ); - assert!(host_flags("psql", "h", 1).contains(&"-p".to_string())); // lowercase for postgres - assert!(host_flags("mysql", "h", 1).contains(&"-P".to_string())); // uppercase for mysql - } - - #[test] - fn mysql_op_orders_password_and_raw() { - assert_eq!( - mysql_op("root", "", "SHOW DATABASES", true), - vec!["-u", "root", "-N", "-B", "-e", "SHOW DATABASES"] - ); - assert_eq!( - mysql_op("root", "pw", "Q", false), - vec!["-u", "root", "-ppw", "-B", "-e", "Q"] - ); - assert_eq!( - mysql_op("root", "pw", "Q", true), - vec!["-u", "root", "-ppw", "-N", "-B", "-e", "Q"] - ); - } - - #[test] - fn sql_quote_escapes_quote_and_backslash() { - assert_eq!(sql_quote("a'b"), "'a\\'b'"); - assert_eq!(sql_quote("a\\b"), "'a\\\\b'"); - } - - #[test] - fn pg_quote_doubles_single_quotes() { - assert_eq!(pg_quote("a'b"), "'a''b'"); - } - - #[test] - fn is_safe_ident_rejects_injection() { - assert!(is_safe_ident("users")); - assert!(is_safe_ident("public.app_settings")); - assert!(!is_safe_ident("a`b")); - assert!(!is_safe_ident("a';DROP")); - assert!(!is_safe_ident("")); - } - - #[test] - fn split_qualified_defaults_to_public() { - assert_eq!( - split_qualified("public.users"), - ("public".into(), "users".into()) - ); - assert_eq!(split_qualified("users"), ("public".into(), "users".into())); - } - - #[test] - fn mongo_escape_neutralizes_quotes_and_newlines() { - assert_eq!(mongo_escape("a'b"), "a\\'b"); - assert_eq!(mongo_escape("a\\b"), "a\\\\b"); - assert_eq!(mongo_escape("a\nb"), "a b"); - } - - #[test] - fn parse_table_reads_header_and_rows() { - let t = parse_table("id\tname\n1\ta\n2\tb".to_string()); - assert_eq!(t.columns, vec!["id", "name"]); - assert_eq!(t.rows, vec![vec!["1", "a"], vec!["2", "b"]]); - assert!(parse_table(String::new()).columns.is_empty()); - } - - #[test] - fn lines_of_trims_and_drops_empty() { - assert_eq!(lines_of("a\n\n b \n".to_string()), vec!["a", "b"]); - } - - #[test] - fn parse_table_caps_rows_cols_and_cells() { - // 300 rows, 100 columns, 2000-char cells → an unbounded wide JOIN. - let header = (0..100) - .map(|c| format!("c{c}")) - .collect::>() - .join("\t"); - let big_cell = "x".repeat(2000); - let row = (0..100) - .map(|_| big_cell.clone()) - .collect::>() - .join("\t"); - let body = std::iter::repeat(row) - .take(300) - .collect::>() - .join("\n"); - let t = parse_table(format!("{header}\n{body}")); - assert_eq!(t.columns.len(), 80, "columnas acotadas"); - assert_eq!(t.rows.len(), 200, "filas acotadas"); - assert_eq!(t.rows[0].len(), 80, "columnas por fila acotadas"); - assert!(t.rows[0][0].chars().count() <= 501, "celda recortada"); - } -} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs new file mode 100644 index 0000000..2620240 --- /dev/null +++ b/src-tauri/src/db/mod.rs @@ -0,0 +1,282 @@ +// Detect database servers (Docker containers + local ports) and explore them: +// list databases/tables/collections/keys, read rows, edit and delete. +// Detection parsing lives in the frontend (src/core/db, TDD'd); here we do the I/O. +// +// One runner (`run_client`) serves both targets: a Docker container (run the +// client inside it) and a local server (run the host's own client with -h/-p). + +use crate::docker::{docker_bin, docker_output, is_safe_container}; +use std::io::Read; +use std::net::{SocketAddr, TcpStream}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +pub(crate) mod mysql; +pub(crate) mod mongo; +pub(crate) mod postgres; +pub(crate) mod redis; + +// Cap on client output (mysql/psql/…): a wide SELECT * can spit out hundreds of +// MB (HTML/blob columns) and blow up the backend when read into memory. +const MAX_CLIENT_OUTPUT: usize = 8 * 1024 * 1024; + +// Time cap: an unbounded wide JOIN leaves the server computing without returning +// a single row, and the backend stays blocked reading a stdout that never arrives +// (the UI shows "Ejecutando…" forever). Past this, we kill the client. +const CLIENT_TIMEOUT: Duration = Duration::from_secs(20); + +// Per-client flags to connect to a local (non-Docker) server over TCP. +fn host_flags(client: &str, host: &str, port: u16) -> Vec { + let p = port.to_string(); + match client { + "mysql" => vec!["-h".into(), host.into(), "-P".into(), p], + "psql" => vec!["-h".into(), host.into(), "-p".into(), p], + "mongosh" | "mongo" => vec!["--host".into(), host.into(), "--port".into(), p], + "redis-cli" => vec!["-h".into(), host.into(), "-p".into(), p], + _ => vec![], + } +} + +// Run a database client. An empty container means a local server: run the host's +// own client with -h/-p (you have it if you installed the DB natively). Otherwise +// run the client inside the container with `docker exec`. `op` is everything after +// the client name; `env` holds vars like PGPASSWORD (passed via -e for Docker). +fn run_client( + container: &str, + host: &str, + port: u16, + client: &str, + op: &[&str], + env: &[(&str, &str)], +) -> Result { + let local = container.is_empty(); + let program: String; + let mut args: Vec = Vec::new(); + if local { + program = client.to_string(); + args.extend(host_flags(client, host, port)); + args.extend(op.iter().map(|s| s.to_string())); + } else { + if !is_safe_container(container) { + return Err("contenedor inválido".into()); + } + program = docker_bin().ok_or("docker no encontrado")?; + args.push("exec".into()); + for (k, v) in env { + args.push("-e".into()); + args.push(format!("{}={}", k, v)); + } + args.push(container.to_string()); + args.push(client.to_string()); + args.extend(op.iter().map(|s| s.to_string())); + } + let mut cmd = Command::new(&program); + cmd.args(&args); + if local { + for (k, v) in env { + cmd.env(k, v); + } + } + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|_| format!("'{}' no está disponible (instálalo o usa Docker)", client))?; + + // Watchdog: if the client takes longer than CLIENT_TIMEOUT (query hung on the + // server), we kill it by pid so the stdout read unblocks. On a normal exit, + // `finished` stops the thread before killing anything. + let pid = child.id(); + let finished = Arc::new(AtomicBool::new(false)); + let timed_out = Arc::new(AtomicBool::new(false)); + let watch_finished = finished.clone(); + let watch_timed_out = timed_out.clone(); + let watchdog = std::thread::spawn(move || { + let step = Duration::from_millis(100); + let mut waited = Duration::ZERO; + while waited < CLIENT_TIMEOUT { + std::thread::sleep(step); + if watch_finished.load(Ordering::Relaxed) { + return; + } + waited += step; + } + watch_timed_out.store(true, Ordering::Relaxed); + let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status(); + }); + + // stderr on a thread (bounded) to avoid blocking or deadlocking with stdout. + let stderr_pipe = child.stderr.take(); + let stderr_handle = std::thread::spawn(move || { + let mut s = String::new(); + if let Some(se) = stderr_pipe { + let _ = se.take(64 * 1024).read_to_string(&mut s); + } + s + }); + + // stdout read with a cap: if exceeded, we kill the process and truncate. + let mut buf: Vec = Vec::new(); + if let Some(mut stdout) = child.stdout.take() { + let mut chunk = [0u8; 64 * 1024]; + loop { + match stdout.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + let room = MAX_CLIENT_OUTPUT.saturating_sub(buf.len()); + buf.extend_from_slice(&chunk[..n.min(room)]); + if n > room { + let _ = child.kill(); + break; + } + } + Err(_) => break, + } + } + } + + let status = child.wait().map_err(|e| e.to_string())?; + finished.store(true, Ordering::Relaxed); + let _ = watchdog.join(); + let stderr = stderr_handle.join().unwrap_or_default(); + if timed_out.load(Ordering::Relaxed) { + return Err(format!( + "La consulta superó el límite de {}s y se canceló. Reduce el número de tablas/JOINs o añade condiciones (WHERE) más selectivas.", + CLIENT_TIMEOUT.as_secs() + )); + } + if !status.success() && buf.is_empty() { + return Err(stderr.trim().to_string()); + } + Ok(String::from_utf8_lossy(&buf).to_string()) +} + +fn lines_of(out: String) -> Vec { + out.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() +} + +// Reject identifiers that could break out of a backtick (SQL) or single-quote (JS) +// context. Names come from the database's own metadata, so this is belt-and-braces. +fn is_safe_ident(s: &str) -> bool { + !s.is_empty() && s.len() <= 128 && !s.contains(['`', '\'', '"', '\\', '\n', '\r', ';']) +} + +#[derive(serde::Serialize)] +pub struct TableData { + columns: Vec, + rows: Vec>, +} + +// Foreign-key relation: table.column → ref_table.ref_column. +#[derive(serde::Serialize)] +pub struct ForeignKey { + table: String, + column: String, + ref_table: String, + ref_column: String, +} + +fn parse_fks(out: String) -> Vec { + out.lines() + .filter_map(|l| { + let p: Vec<&str> = l.split('\t').collect(); + if p.len() >= 4 && !p[0].is_empty() && !p[2].is_empty() { + Some(ForeignKey { + table: p[0].into(), + column: p[1].into(), + ref_table: p[2].into(), + ref_column: p[3].into(), + }) + } else { + None + } + }) + .collect() +} + +// ---------------- detection (Docker + local ports) ---------------- + +#[tauri::command] +pub fn db_docker_ps() -> String { + docker_output(&["ps", "--format", "{{.Names}}|{{.Image}}|{{.Ports}}"]).unwrap_or_default() +} + +#[tauri::command] +pub fn db_inspect_env(container: String) -> Vec { + if !is_safe_container(&container) { + return Vec::new(); + } + docker_output(&[ + "inspect", + "-f", + "{{range .Config.Env}}{{println .}}{{end}}", + &container, + ]) + .map(|s| { + s.lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn is_open(port: u16) -> bool { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok() +} + +#[tauri::command] +pub fn db_check_ports(ports: Vec) -> Vec { + ports.into_iter().filter(|p| is_open(*p)).collect() +} + +// ---------------- MySQL / MariaDB ---------------- + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_flags_per_client() { + assert_eq!( + host_flags("mysql", "127.0.0.1", 3306), + vec!["-h", "127.0.0.1", "-P", "3306"] + ); + assert_eq!( + host_flags("psql", "127.0.0.1", 5432), + vec!["-h", "127.0.0.1", "-p", "5432"] + ); + assert_eq!( + host_flags("mongosh", "localhost", 27017), + vec!["--host", "localhost", "--port", "27017"] + ); + assert_eq!( + host_flags("redis-cli", "127.0.0.1", 6379), + vec!["-h", "127.0.0.1", "-p", "6379"] + ); + assert!(host_flags("psql", "h", 1).contains(&"-p".to_string())); // lowercase for postgres + assert!(host_flags("mysql", "h", 1).contains(&"-P".to_string())); // uppercase for mysql + } + + #[test] + fn is_safe_ident_rejects_injection() { + assert!(is_safe_ident("users")); + assert!(is_safe_ident("public.app_settings")); + assert!(!is_safe_ident("a`b")); + assert!(!is_safe_ident("a';DROP")); + assert!(!is_safe_ident("")); + } + + #[test] + fn lines_of_trims_and_drops_empty() { + assert_eq!(lines_of("a\n\n b \n".to_string()), vec!["a", "b"]); + } +} diff --git a/src-tauri/src/db/mongo.rs b/src-tauri/src/db/mongo.rs new file mode 100644 index 0000000..eebca35 --- /dev/null +++ b/src-tauri/src/db/mongo.rs @@ -0,0 +1,201 @@ +use super::*; + + +// Run a JS snippet in the mongo shell: mongosh (mongo:5+) with a fallback to the +// legacy `mongo` shell. +fn mongo_eval( + container: &str, + host: &str, + port: u16, + user: &str, + password: &str, + script: &str, +) -> Result { + let mut op: Vec = vec!["--quiet".into()]; + if !user.is_empty() { + op.extend([ + "-u".into(), + user.into(), + "-p".into(), + password.into(), + "--authenticationDatabase".into(), + "admin".into(), + ]); + } + op.extend(["--eval".into(), script.into()]); + let refs: Vec<&str> = op.iter().map(String::as_str).collect(); + run_client(container, host, port, "mongosh", &refs, &[]) + .or_else(|_| run_client(container, host, port, "mongo", &refs, &[])) +} + +fn mongo_escape(doc: &str) -> String { + doc.replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', " ") + .replace('\r', "") +} + +#[tauri::command] +pub fn db_docker_list_mongo( + container: String, + host: String, + port: u16, + user: String, + password: String, +) -> Result, String> { + let script = + "db.adminCommand('listDatabases').databases.map(function(d){return d.name}).join('\\n')"; + mongo_eval(&container, &host, port, &user, &password, script).map(lines_of) +} + +#[tauri::command] +pub fn db_docker_mongo_collections( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + if !is_safe_ident(&db) { + return Err("nombre de base inválido".into()); + } + let script = format!("db.getSiblingDB('{}').getCollectionNames().join('\\n')", db); + mongo_eval(&container, &host, port, &user, &password, &script).map(lines_of) +} + +#[tauri::command] +pub fn db_docker_mongo_docs( + container: String, + host: String, + port: u16, + db: String, + collection: String, + user: String, + password: String, +) -> Result, String> { + if !is_safe_ident(&db) || !is_safe_ident(&collection) { + return Err("nombre inválido".into()); + } + let script = format!( + "db.getSiblingDB('{}').getCollection('{}').find().limit(50).toArray().map(function(d){{return EJSON.stringify(d)}}).join('\\n')", + db, collection + ); + mongo_eval(&container, &host, port, &user, &password, &script).map(lines_of) +} + +// Runs a free-form mongosh script in the context of `db` (dev tool). +#[tauri::command] +pub fn db_docker_mongo_query( + container: String, + host: String, + port: u16, + db: String, + script: String, + user: String, + password: String, +) -> Result { + if !is_safe_ident(&db) { + return Err("nombre de base inválido".into()); + } + let wrapped = format!("db = db.getSiblingDB('{}'); {}", db, script); + mongo_eval(&container, &host, port, &user, &password, &wrapped) +} + +// Mongo relations (heuristic): *Id/*_id or ObjectId fields that point to +// another collection, guessed by name. References, not enforced FKs. +#[tauri::command] +pub fn db_docker_mongo_refs( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + if !is_safe_ident(&db) { + return Err("nombre de base inválido".into()); + } + let script = format!( + r#"var D=db.getSiblingDB('{}');var names=D.getCollectionNames();var out=[];var seen={{}};names.forEach(function(coll){{var docs=D.getCollection(coll).find().limit(20).toArray();docs.forEach(function(doc){{Object.keys(doc).forEach(function(k){{if(k==='_id')return;var key=coll+'|'+k;if(seen[key])return;var v=doc[k];var looksId=/(_id|Id)$/.test(k)||(v instanceof ObjectId);if(!looksId)return;seen[key]=1;var base=k.replace(/(_id|Id)$/,'').toLowerCase();var target='';for(var i=0;i Vec { + out.lines() + .filter_map(|l| { + let p: Vec<&str> = l.split('\t').collect(); + if p.len() >= 3 && !p[0].is_empty() && !p[2].is_empty() { + Some(ForeignKey { + table: p[0].into(), + column: p[1].into(), + ref_table: p[2].into(), + ref_column: "_id".into(), + }) + } else { + None + } + }) + .collect() +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn db_docker_mongo_update( + container: String, + host: String, + port: u16, + db: String, + collection: String, + doc: String, + user: String, + password: String, +) -> Result<(), String> { + if !is_safe_ident(&db) || !is_safe_ident(&collection) { + return Err("nombre inválido".into()); + } + let script = format!( + "var d=EJSON.parse('{}');var id=d._id;delete d._id;db.getSiblingDB('{}').getCollection('{}').replaceOne({{_id:id}},d)", + mongo_escape(&doc), db, collection + ); + mongo_eval(&container, &host, port, &user, &password, &script).map(|_| ()) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn db_docker_mongo_delete( + container: String, + host: String, + port: u16, + db: String, + collection: String, + doc: String, + user: String, + password: String, +) -> Result<(), String> { + if !is_safe_ident(&db) || !is_safe_ident(&collection) { + return Err("nombre inválido".into()); + } + let script = format!( + "var d=EJSON.parse('{}');db.getSiblingDB('{}').getCollection('{}').deleteOne({{_id:d._id}})", + mongo_escape(&doc), db, collection + ); + mongo_eval(&container, &host, port, &user, &password, &script).map(|_| ()) +} + +// ---------------- PostgreSQL ---------------- + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mongo_escape_neutralizes_quotes_and_newlines() { + assert_eq!(mongo_escape("a'b"), "a\\'b"); + assert_eq!(mongo_escape("a\\b"), "a\\\\b"); + assert_eq!(mongo_escape("a\nb"), "a b"); + } +} diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs new file mode 100644 index 0000000..9b1cf96 --- /dev/null +++ b/src-tauri/src/db/mysql.rs @@ -0,0 +1,329 @@ +use super::*; + + +fn mysql_op(user: &str, password: &str, query: &str, raw: bool) -> Vec { + // -N drops the header row (used for plain lists); table data keeps it. + let mut a: Vec = vec![ + "-u".into(), + user.into(), + "-B".into(), + "-e".into(), + query.into(), + ]; + if raw { + a.insert(2, "-N".into()); + } + if !password.is_empty() { + a.insert(2, format!("-p{}", password)); + } + a +} + +fn run_mysql(container: &str, host: &str, port: u16, op: &[String]) -> Result { + let refs: Vec<&str> = op.iter().map(String::as_str).collect(); + run_client(container, host, port, "mysql", &refs, &[]) +} + +fn sql_quote(v: &str) -> String { + format!("'{}'", v.replace('\\', "\\\\").replace('\'', "\\'")) +} + +#[tauri::command] +pub fn db_docker_list_mysql( + container: String, + host: String, + port: u16, + user: String, + password: String, +) -> Result, String> { + let op = mysql_op(&user, &password, "SHOW DATABASES", true); + run_mysql(&container, &host, port, &op).map(lines_of) +} + +#[tauri::command] +pub fn db_docker_mysql_tables( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + if !is_safe_ident(&db) { + return Err("nombre de base inválido".into()); + } + let op = mysql_op(&user, &password, &format!("SHOW TABLES IN `{}`", db), true); + run_mysql(&container, &host, port, &op).map(lines_of) +} + +pub(super) fn parse_table(out: String) -> TableData { + // Defensive caps: a SELECT * over a wide JOIN can bring back hundreds of + // columns and huge cells (HTML, blobs). Unbounded, the payload traveling + // to the WebView blows it up. We clip cells and rows; the front also limits rendering. + const MAX_ROWS: usize = 200; + const MAX_COLS: usize = 80; + const MAX_CELL: usize = 500; + const MAX_JSON_CELL: usize = 50_000; + let clip = |s: &str| -> String { + let trimmed = s.trim_start(); + let limit = if trimmed.starts_with('{') || trimmed.starts_with('[') { + MAX_JSON_CELL + } else { + MAX_CELL + }; + if s.chars().take(limit + 1).count() > limit { + format!("{}…", s.chars().take(limit).collect::()) + } else { + s.to_string() + } + }; + let mut lines = out.lines(); + let columns: Vec = match lines.next() { + Some(header) => header + .split('\t') + .take(MAX_COLS) + .map(str::to_string) + .collect(), + None => { + return TableData { + columns: vec![], + rows: vec![], + } + } + }; + let rows = lines + .take(MAX_ROWS) + .map(|l| l.split('\t').take(MAX_COLS).map(clip).collect()) + .collect(); + TableData { columns, rows } +} + +#[tauri::command] +pub fn db_docker_mysql_rows( + container: String, + host: String, + port: u16, + db: String, + table: String, + user: String, + password: String, +) -> Result { + if !is_safe_ident(&db) || !is_safe_ident(&table) { + return Err("nombre inválido".into()); + } + let op = mysql_op( + &user, + &password, + &format!("SELECT * FROM `{}`.`{}` LIMIT 200", db, table), + false, + ); + run_mysql(&container, &host, port, &op).map(parse_table) +} + +// Runs free-form SQL against the `db` database. A dev tool over your own local +// databases: the query is intentionally arbitrary (like any client). +#[tauri::command] +pub fn db_docker_mysql_query( + container: String, + host: String, + port: u16, + db: String, + sql: String, + user: String, + password: String, +) -> Result { + if !is_safe_ident(&db) { + return Err("nombre de base inválido".into()); + } + let op = mysql_op(&user, &password, &format!("USE `{}`; {}", db, sql), false); + run_mysql(&container, &host, port, &op).map(parse_table) +} + +// Relations (foreign keys) of a MySQL/MariaDB database. +#[tauri::command] +pub fn db_docker_mysql_fks( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + if !is_safe_ident(&db) { + return Err("nombre de base inválido".into()); + } + let query = format!( + "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='{}' AND REFERENCED_TABLE_NAME IS NOT NULL", + db + ); + let op = mysql_op(&user, &password, &query, true); + run_mysql(&container, &host, port, &op).map(parse_fks) +} + +#[tauri::command] +pub fn db_docker_mysql_pk( + container: String, + host: String, + port: u16, + db: String, + table: String, + user: String, + password: String, +) -> Result, String> { + if !is_safe_ident(&db) || !is_safe_ident(&table) { + return Err("nombre inválido".into()); + } + let query = format!( + "SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='{}' AND TABLE_NAME='{}' AND CONSTRAINT_NAME='PRIMARY' ORDER BY ORDINAL_POSITION", + db, table + ); + let op = mysql_op(&user, &password, &query, true); + run_mysql(&container, &host, port, &op).map(lines_of) +} + +fn mysql_where(wheres: &[(String, String)]) -> Result { + if wheres.is_empty() { + return Err("la tabla no tiene clave primaria".into()); + } + let mut conds = Vec::new(); + for (col, val) in wheres { + if !is_safe_ident(col) { + return Err("columna inválida".into()); + } + conds.push(format!("`{}` = {}", col, sql_quote(val))); + } + Ok(conds.join(" AND ")) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn db_docker_mysql_update( + container: String, + host: String, + port: u16, + db: String, + table: String, + column: String, + value: String, + wheres: Vec<(String, String)>, + user: String, + password: String, +) -> Result<(), String> { + if !is_safe_ident(&db) || !is_safe_ident(&table) || !is_safe_ident(&column) { + return Err("nombre inválido".into()); + } + let where_clause = mysql_where(&wheres)?; + let query = format!( + "UPDATE `{}`.`{}` SET `{}` = {} WHERE {}", + db, + table, + column, + sql_quote(&value), + where_clause + ); + let op = mysql_op(&user, &password, &query, false); + run_mysql(&container, &host, port, &op).map(|_| ()) +} + +#[tauri::command] +pub fn db_docker_mysql_delete( + container: String, + host: String, + port: u16, + db: String, + table: String, + wheres: Vec<(String, String)>, + user: String, + password: String, +) -> Result<(), String> { + if !is_safe_ident(&db) || !is_safe_ident(&table) { + return Err("nombre inválido".into()); + } + let where_clause = mysql_where(&wheres)?; + let cascade_query = format!( + "SELECT TABLE_NAME FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='{}' AND REFERENCED_TABLE_NAME='{}' AND DELETE_RULE='CASCADE'", + db, table + ); + let cascades = run_mysql( + &container, + &host, + port, + &mysql_op(&user, &password, &cascade_query, true), + ) + .map(lines_of)?; + if !cascades.is_empty() { + return Err(format!( + "Bloqueado: borrar aquí arrastraría en cascada (ON DELETE CASCADE) a: {}", + cascades.join(", ") + )); + } + let query = format!("DELETE FROM `{}`.`{}` WHERE {}", db, table, where_clause); + run_mysql( + &container, + &host, + port, + &mysql_op(&user, &password, &query, false), + ) + .map(|_| ()) +} + +// ---------------- MongoDB ---------------- + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mysql_op_orders_password_and_raw() { + assert_eq!( + mysql_op("root", "", "SHOW DATABASES", true), + vec!["-u", "root", "-N", "-B", "-e", "SHOW DATABASES"] + ); + assert_eq!( + mysql_op("root", "pw", "Q", false), + vec!["-u", "root", "-ppw", "-B", "-e", "Q"] + ); + assert_eq!( + mysql_op("root", "pw", "Q", true), + vec!["-u", "root", "-ppw", "-N", "-B", "-e", "Q"] + ); + } + + #[test] + fn sql_quote_escapes_quote_and_backslash() { + assert_eq!(sql_quote("a'b"), "'a\\'b'"); + assert_eq!(sql_quote("a\\b"), "'a\\\\b'"); + } + + #[test] + fn parse_table_reads_header_and_rows() { + let t = parse_table("id\tname\n1\ta\n2\tb".to_string()); + assert_eq!(t.columns, vec!["id", "name"]); + assert_eq!(t.rows, vec![vec!["1", "a"], vec!["2", "b"]]); + assert!(parse_table(String::new()).columns.is_empty()); + } + + #[test] + fn parse_table_caps_rows_cols_and_cells() { + // 300 rows, 100 columns, 2000-char cells → an unbounded wide JOIN. + let header = (0..100) + .map(|c| format!("c{c}")) + .collect::>() + .join("\t"); + let big_cell = "x".repeat(2000); + let row = (0..100) + .map(|_| big_cell.clone()) + .collect::>() + .join("\t"); + let body = std::iter::repeat(row) + .take(300) + .collect::>() + .join("\n"); + let t = parse_table(format!("{header}\n{body}")); + assert_eq!(t.columns.len(), 80, "columnas acotadas"); + assert_eq!(t.rows.len(), 200, "filas acotadas"); + assert_eq!(t.rows[0].len(), 80, "columnas por fila acotadas"); + assert!(t.rows[0][0].chars().count() <= 501, "celda recortada"); + } +} diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs new file mode 100644 index 0000000..0329f81 --- /dev/null +++ b/src-tauri/src/db/postgres.rs @@ -0,0 +1,325 @@ +use super::*; +use super::mysql::parse_table; + + +fn psql( + container: &str, + host: &str, + port: u16, + db: &str, + user: &str, + password: &str, + extra: &[&str], +) -> Result { + if !is_safe_ident(db) || !is_safe_ident(user) { + return Err("parámetro inválido".into()); + } + let mut op: Vec = vec!["-U".into(), user.into(), "-d".into(), db.into()]; + op.extend(extra.iter().map(|s| s.to_string())); + let refs: Vec<&str> = op.iter().map(String::as_str).collect(); + run_client( + container, + host, + port, + "psql", + &refs, + &[("PGPASSWORD", password)], + ) +} + +fn split_qualified(name: &str) -> (String, String) { + match name.split_once('.') { + Some((schema, table)) => (schema.to_string(), table.to_string()), + None => ("public".to_string(), name.to_string()), + } +} + +fn pg_quote(v: &str) -> String { + format!("'{}'", v.replace('\'', "''")) +} + +fn pg_where(wheres: &[(String, String)]) -> Result { + if wheres.is_empty() { + return Err("la tabla no tiene clave primaria".into()); + } + let mut conds = Vec::new(); + for (col, val) in wheres { + if !is_safe_ident(col) { + return Err("columna inválida".into()); + } + conds.push(format!("\"{}\" = {}", col, pg_quote(val))); + } + Ok(conds.join(" AND ")) +} + +#[tauri::command] +pub fn db_docker_pg_databases( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + let out = psql(&container, &host, port, &db, &user, &password, &[ + "-t", "-A", "-c", + "SELECT datname FROM pg_database WHERE datistemplate=false AND datallowconn ORDER BY datname", + ])?; + Ok(lines_of(out)) +} + +#[tauri::command] +pub fn db_docker_pg_tables( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + let out = psql(&container, &host, port, &db, &user, &password, &[ + "-t", "-A", "-c", + "SELECT table_schema||'.'||table_name FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1", + ])?; + Ok(lines_of(out)) +} + +#[tauri::command] +pub fn db_docker_pg_rows( + container: String, + host: String, + port: u16, + db: String, + table: String, + user: String, + password: String, +) -> Result { + let (schema, tbl) = split_qualified(&table); + if !is_safe_ident(&schema) || !is_safe_ident(&tbl) { + return Err("nombre inválido".into()); + } + let query = format!("SELECT * FROM \"{}\".\"{}\" LIMIT 200", schema, tbl); + let out = psql( + &container, + &host, + port, + &db, + &user, + &password, + &[ + "-A", + "-F", + "\t", + "-P", + "footer=off", + "-P", + "null=NULL", + "-c", + &query, + ], + )?; + Ok(parse_table(out)) +} + +// Runs free-form SQL against `db` (dev tool; arbitrary query). +#[tauri::command] +pub fn db_docker_pg_query( + container: String, + host: String, + port: u16, + db: String, + sql: String, + user: String, + password: String, +) -> Result { + let out = psql( + &container, + &host, + port, + &db, + &user, + &password, + &[ + "-A", + "-F", + "\t", + "-P", + "footer=off", + "-P", + "null=NULL", + "-c", + &sql, + ], + )?; + Ok(parse_table(out)) +} + +// Relations (foreign keys) of a PostgreSQL database. +#[tauri::command] +pub fn db_docker_pg_fks( + container: String, + host: String, + port: u16, + db: String, + user: String, + password: String, +) -> Result, String> { + // schema.table on both sides: Postgres has several schemas, not just public. + let query = "SELECT tc.table_schema||'.'||tc.table_name, kcu.column_name, ccu.table_schema||'.'||ccu.table_name, ccu.column_name \ + FROM information_schema.table_constraints tc \ + JOIN information_schema.key_column_usage kcu ON tc.constraint_name=kcu.constraint_name AND tc.table_schema=kcu.table_schema \ + JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name=tc.constraint_name AND ccu.table_schema=tc.table_schema \ + WHERE tc.constraint_type='FOREIGN KEY'"; + let out = psql( + &container, + &host, + port, + &db, + &user, + &password, + &["-t", "-A", "-F", "\t", "-c", query], + )?; + Ok(parse_fks(out)) +} + +#[tauri::command] +pub fn db_docker_pg_pk( + container: String, + host: String, + port: u16, + db: String, + table: String, + user: String, + password: String, +) -> Result, String> { + let (schema, tbl) = split_qualified(&table); + if !is_safe_ident(&schema) || !is_safe_ident(&tbl) { + return Err("nombre inválido".into()); + } + let query = format!( + "SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey) WHERE i.indrelid='\"{}\".\"{}\"'::regclass AND i.indisprimary ORDER BY array_position(i.indkey, a.attnum)", + schema, tbl + ); + let out = psql( + &container, + &host, + port, + &db, + &user, + &password, + &["-t", "-A", "-c", &query], + )?; + Ok(lines_of(out)) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn db_docker_pg_update( + container: String, + host: String, + port: u16, + db: String, + table: String, + column: String, + value: String, + wheres: Vec<(String, String)>, + user: String, + password: String, +) -> Result<(), String> { + let (schema, tbl) = split_qualified(&table); + if !is_safe_ident(&schema) || !is_safe_ident(&tbl) || !is_safe_ident(&column) { + return Err("nombre inválido".into()); + } + let where_clause = pg_where(&wheres)?; + let query = format!( + "UPDATE \"{}\".\"{}\" SET \"{}\" = {} WHERE {}", + schema, + tbl, + column, + pg_quote(&value), + where_clause + ); + psql( + &container, + &host, + port, + &db, + &user, + &password, + &["-c", &query], + ) + .map(|_| ()) +} + +#[tauri::command] +pub fn db_docker_pg_delete( + container: String, + host: String, + port: u16, + db: String, + table: String, + wheres: Vec<(String, String)>, + user: String, + password: String, +) -> Result<(), String> { + let (schema, tbl) = split_qualified(&table); + if !is_safe_ident(&schema) || !is_safe_ident(&tbl) { + return Err("nombre inválido".into()); + } + let where_clause = pg_where(&wheres)?; + let cascade_query = format!( + "SELECT conrelid::regclass::text FROM pg_constraint WHERE confrelid='\"{}\".\"{}\"'::regclass AND confdeltype='c'", + schema, tbl + ); + let cascades = lines_of(psql( + &container, + &host, + port, + &db, + &user, + &password, + &["-t", "-A", "-c", &cascade_query], + )?); + if !cascades.is_empty() { + return Err(format!( + "Bloqueado: borrar aquí arrastraría en cascada (ON DELETE CASCADE) a: {}", + cascades.join(", ") + )); + } + let query = format!( + "DELETE FROM \"{}\".\"{}\" WHERE {}", + schema, tbl, where_clause + ); + psql( + &container, + &host, + port, + &db, + &user, + &password, + &["-c", &query], + ) + .map(|_| ()) +} + +// ---------------- Redis (db index → keys → value by type) ---------------- + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pg_quote_doubles_single_quotes() { + assert_eq!(pg_quote("a'b"), "'a''b'"); + } + + #[test] + fn split_qualified_defaults_to_public() { + assert_eq!( + split_qualified("public.users"), + ("public".into(), "users".into()) + ); + assert_eq!(split_qualified("users"), ("public".into(), "users".into())); + } +} diff --git a/src-tauri/src/db/redis.rs b/src-tauri/src/db/redis.rs new file mode 100644 index 0000000..ac422df --- /dev/null +++ b/src-tauri/src/db/redis.rs @@ -0,0 +1,164 @@ +use super::*; + + +fn redis_cli( + container: &str, + host: &str, + port: u16, + db: &str, + password: &str, + args: &[&str], +) -> Result { + if db.is_empty() || !db.chars().all(|c| c.is_ascii_digit()) { + return Err("parámetro inválido".into()); + } + let mut op: Vec = Vec::new(); + if !password.is_empty() { + op.extend(["-a".into(), password.into(), "--no-auth-warning".into()]); + } + op.extend(["-n".into(), db.into()]); + op.extend(args.iter().map(|s| s.to_string())); + let refs: Vec<&str> = op.iter().map(String::as_str).collect(); + run_client(container, host, port, "redis-cli", &refs, &[]) +} + +#[tauri::command] +pub fn db_docker_redis_dbs( + container: String, + host: String, + port: u16, + password: String, +) -> Result, String> { + // INFO keyspace lists only the logical DBs that hold keys (db0:keys=2,...). + let out = redis_cli( + &container, + &host, + port, + "0", + &password, + &["INFO", "keyspace"], + )?; + let dbs = out + .lines() + .filter_map(|l| { + let idx = l.trim().strip_prefix("db")?.split(':').next()?; + let numeric = !idx.is_empty() && idx.chars().all(|c| c.is_ascii_digit()); + numeric.then(|| idx.to_string()) + }) + .collect(); + Ok(dbs) +} + +#[tauri::command] +pub fn db_docker_redis_keys( + container: String, + host: String, + port: u16, + db: String, + password: String, +) -> Result, String> { + let out = redis_cli(&container, &host, port, &db, &password, &["--scan"])?; + Ok(lines_of(out).into_iter().take(1000).collect()) +} + +#[derive(serde::Serialize)] +pub struct RedisValue { + kind: String, + value: String, +} + +#[tauri::command] +pub fn db_docker_redis_value( + container: String, + host: String, + port: u16, + db: String, + key: String, + password: String, +) -> Result { + let kind = redis_cli(&container, &host, port, &db, &password, &["TYPE", &key])? + .trim() + .to_string(); + let value = match kind.as_str() { + "string" => redis_cli(&container, &host, port, &db, &password, &["GET", &key])?, + "hash" => redis_cli(&container, &host, port, &db, &password, &["HGETALL", &key])?, + "list" => redis_cli( + &container, + &host, + port, + &db, + &password, + &["LRANGE", &key, "0", "-1"], + )?, + "set" => redis_cli(&container, &host, port, &db, &password, &["SMEMBERS", &key])?, + "zset" => redis_cli( + &container, + &host, + port, + &db, + &password, + &["ZRANGE", &key, "0", "-1", "WITHSCORES"], + )?, + "stream" => redis_cli( + &container, + &host, + port, + &db, + &password, + &["XRANGE", &key, "-", "+", "COUNT", "50"], + )?, + _ => String::new(), + }; + Ok(RedisValue { kind, value }) +} + +#[tauri::command] +pub fn db_docker_redis_set( + container: String, + host: String, + port: u16, + db: String, + key: String, + value: String, + password: String, +) -> Result<(), String> { + redis_cli( + &container, + &host, + port, + &db, + &password, + &["SET", &key, &value], + ) + .map(|_| ()) +} + +#[tauri::command] +pub fn db_docker_redis_ttl( + container: String, + host: String, + port: u16, + db: String, + key: String, + password: String, +) -> Result { + redis_cli(&container, &host, port, &db, &password, &["TTL", &key]) + .map(|s| s.trim().parse::().unwrap_or(-2)) +} + +// Runs a free-form redis-cli command against the `db` database (dev tool). +#[tauri::command] +pub fn db_docker_redis_command( + container: String, + host: String, + port: u16, + db: String, + command: String, + password: String, +) -> Result { + let args: Vec<&str> = command.split_whitespace().collect(); + if args.is_empty() { + return Err("comando vacío".into()); + } + redis_cli(&container, &host, port, &db, &password, &args) +} diff --git a/src-tauri/src/docker.rs b/src-tauri/src/docker.rs deleted file mode 100644 index a3f7f9b..0000000 --- a/src-tauri/src/docker.rs +++ /dev/null @@ -1,2296 +0,0 @@ -// Shared Docker plumbing (used by the Docker panel and the DB panel) plus the -// container-management commands: list, start/stop/restart, logs. - -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read}; -use std::path::{Component, Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; -use tauri::{AppHandle, Emitter}; - -// macOS GUI apps don't inherit the shell PATH, so `docker` may not be on PATH. -// Resolve it through a login shell (Unix only; returns None on Windows). -fn login_shell_output(cmd: &str) -> Option { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let out = Command::new(shell).arg("-lc").arg(cmd).output().ok()?; - if !out.status.success() { - return None; - } - Some(String::from_utf8_lossy(&out.stdout).to_string()) -} - -// The docker executable: bare `docker` when it's on PATH (Linux/Windows GUI apps -// inherit it), else the path resolved via a login shell (the macOS case). -pub fn docker_bin() -> Option { - let on_path = Command::new("docker") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if on_path { - return Some("docker".into()); - } - let path = login_shell_output("command -v docker")?; - let path = path.trim().to_string(); - if path.is_empty() { - None - } else { - Some(path) - } -} - -pub fn docker_output(args: &[&str]) -> Option { - let bin = docker_bin()?; - let out = Command::new(bin).args(args).output().ok()?; - if !out.status.success() { - return None; - } - Some(String::from_utf8_lossy(&out.stdout).to_string()) -} - -// Container names/ids from docker are alphanumeric plus _-. — reject anything -// else before using one in a command. -pub fn is_safe_container(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) -} - -// These shell out to docker, which can take seconds (restart stops + starts the -// container). They're `async` + run on a blocking pool so the UI thread never -// freezes while waiting. - -#[tauri::command] -pub async fn docker_list() -> String { - tauri::async_runtime::spawn_blocking(|| { - docker_output(&["ps", "-a", "--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}|{{.Ports}}|{{.Label \"com.docker.compose.project\"}}"]).unwrap_or_default() - }) - .await - .unwrap_or_default() -} - -fn docker_action(action: &str, id: &str) -> Result<(), String> { - if !is_safe_container(id) { - return Err("contenedor inválido".into()); - } - let bin = docker_bin().ok_or("docker no encontrado")?; - let out = Command::new(bin) - .args([action, id]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) -} - -async fn run_action(action: &'static str, id: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || docker_action(action, &id)) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn docker_start(id: String) -> Result<(), String> { - run_action("start", id).await -} - -#[tauri::command] -pub async fn docker_stop(id: String) -> Result<(), String> { - run_action("stop", id).await -} - -#[tauri::command] -pub async fn docker_restart(id: String) -> Result<(), String> { - run_action("restart", id).await -} - -#[tauri::command] -pub async fn docker_logs(id: String, tail: u32) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_container(&id) { - return Err("contenedor inválido".to_string()); - } - let bin = docker_bin().ok_or("docker no encontrado")?; - let tail = tail.to_string(); - let out = Command::new(bin) - .args(["logs", "--tail", &tail, &id]) - .output() - .map_err(|e| e.to_string())?; - // docker writes container logs to both stdout and stderr; show both. - let mut combined = String::from_utf8_lossy(&out.stdout).to_string(); - combined.push_str(&String::from_utf8_lossy(&out.stderr)); - Ok(combined) - }) - .await - .map_err(|e| e.to_string())? -} - -// --- Live logs: `docker logs -f` streamed to the frontend via events --- - -// Running follow processes, keyed by container, so they can be stopped. -#[derive(Default)] -pub struct LogStreams(Mutex>); - -fn pipe_lines(reader: impl Read + Send + 'static, app: AppHandle, event: String) { - std::thread::spawn(move || { - for line in BufReader::new(reader).lines().map_while(Result::ok) { - let _ = app.emit(&event, format!("{}\n", line)); - } - }); -} - -#[tauri::command] -pub fn docker_logs_follow( - id: String, - tail: u32, - app: AppHandle, - state: tauri::State, -) -> Result<(), String> { - if !is_safe_container(&id) { - return Err("contenedor inválido".into()); - } - // Replace any existing stream for this container. - if let Some(mut child) = state.0.lock().unwrap().remove(&id) { - let _ = child.kill(); - } - let bin = docker_bin().ok_or("docker no encontrado")?; - let tail = tail.to_string(); - let mut child = Command::new(bin) - .args(["logs", "-f", "--tail", &tail, &id]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| e.to_string())?; - let event = format!("docker-logs-{}", id); - if let Some(o) = child.stdout.take() { - pipe_lines(o, app.clone(), event.clone()); - } - if let Some(e) = child.stderr.take() { - pipe_lines(e, app.clone(), event.clone()); - } - state.0.lock().unwrap().insert(id, child); - Ok(()) -} - -#[tauri::command] -pub fn docker_logs_stop(id: String, state: tauri::State) -> Result<(), String> { - if let Some(mut child) = state.0.lock().unwrap().remove(&id) { - let _ = child.kill(); - } - Ok(()) -} - -// --- Exec terminal: argv to open a shell inside a container (run in a PTY) --- - -#[tauri::command] -pub fn docker_exec_argv(container: String) -> Result, String> { - if !is_safe_container(&container) { - return Err("contenedor inválido".into()); - } - let bin = docker_bin().ok_or("docker no encontrado")?; - // Prefer bash (Tab completion via readline); fall back to sh when it's absent. - Ok(vec![ - bin, - "exec".into(), - "-it".into(), - container, - "sh".into(), - "-c".into(), - "command -v bash >/dev/null 2>&1 && exec bash || exec sh".into(), - ]) -} - -// --- docker-compose isolation: per-worktree override with remapped subnet + container names --- - -struct ComposeService { - name: String, - ip: String, - container_name: Option, -} - -#[derive(serde::Serialize)] -pub struct ServiceUrl { - pub service: String, - pub url: String, -} - -#[derive(serde::Serialize)] -pub struct IsolateResult { - pub subnet: String, - pub urls: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub recipe: Option, -} - -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecipeFilePreview { - pub path: String, - pub action: String, - pub tracked: bool, -} - -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecipePreview { - pub project_key: String, - pub recipe_dir: Option, - pub recipe_exists: bool, - pub devcontainer_dirs: Vec, - pub files: Vec, - pub warnings: Vec, -} - -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecipeApplyResult { - pub project_key: String, - pub recipe_dir: String, - pub devcontainer_dir: String, - pub applied: Vec, - pub skipped: Vec, - pub errors: Vec, - pub applied_at: u64, -} - -// Parse a docker-compose.yml and return (network_name, subnet_prefix, services). -// subnet_prefix: "10.189.4" (without the .0/24 part). -fn parse_compose_info(content: &str) -> Option<(String, String, Vec)> { - #[derive(PartialEq)] - enum Section { - Other, - Services, - Networks, - } - - let mut section = Section::Other; - let mut current_service: Option = None; - let mut current_container_name: Option = None; - let mut services: Vec = vec![]; - let mut subnet_prefix: Option = None; - let mut network_name: Option = None; - - for line in content.lines() { - // Top-level section key: non-indented, non-empty, ends with ':' - if !line.starts_with(' ') - && !line.starts_with('\t') - && line.ends_with(':') - && !line.starts_with('#') - { - let key = line.trim_end_matches(':').trim(); - section = match key { - "services" => Section::Services, - "networks" => Section::Networks, - _ => Section::Other, - }; - current_service = None; - current_container_name = None; - continue; - } - - match section { - Section::Services => { - if line.starts_with(" ") && !line.starts_with(" ") { - let t = line.trim(); - if t.ends_with(':') { - current_service = Some(t.trim_end_matches(':').to_string()); - current_container_name = None; - } - } else if let Some(ref svc_name) = current_service.clone() { - let t = line.trim(); - if let Some(rest) = t.strip_prefix("ipv4_address:") { - services.push(ComposeService { - name: svc_name.clone(), - ip: rest.trim().to_string(), - container_name: current_container_name.clone(), - }); - } else if let Some(rest) = t.strip_prefix("container_name:") { - current_container_name = Some(rest.trim().to_string()); - } - } - } - Section::Networks => { - if line.starts_with(" ") && !line.starts_with(" ") { - let t = line.trim(); - if t.ends_with(':') && network_name.is_none() { - network_name = Some(t.trim_end_matches(':').to_string()); - } - } else { - // Subnet can appear as "subnet: x" or "- subnet: x" (YAML list item) - let t = line.trim(); - let subnet_val = t - .strip_prefix("subnet:") - .or_else(|| t.strip_prefix("- subnet:")); - if let Some(rest) = subnet_val { - if let Some(without_mask) = rest.trim().split('/').next() { - let parts: Vec<&str> = without_mask.split('.').collect(); - if parts.len() == 4 && subnet_prefix.is_none() { - subnet_prefix = - Some(format!("{}.{}.{}", parts[0], parts[1], parts[2])); - } - } - } - } - } - Section::Other => {} - } - } - - Some((network_name?, subnet_prefix?, services)) -} - -// Inspect a running container to get the ports it listens on internally. -// Tries ExposedPorts first; falls back to /proc/net/tcp6 + /proc/net/tcp -// Query docker inspect for the actual host port bound to an internal port of a -// running container. Returns None if the container is not running or has no binding. -fn get_actual_host_port(container_name: &str, internal_port: u16) -> Option { - let bin = docker_bin()?; - let format = "{{json .HostConfig.PortBindings}}"; - let out = Command::new(&bin) - .args(["inspect", "--format", format, container_name]) - .output() - .ok() - .filter(|o| o.status.success())?; - let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); - // Parse: {"3000/tcp":[{"HostIp":"","HostPort":"20231"}], ...} - let key = format!("{}/tcp", internal_port); - let v: serde_json::Value = serde_json::from_str(&raw).ok()?; - v.get(&key)? - .as_array()? - .first()? - .get("HostPort")? - .as_str()? - .parse() - .ok() -} - -// for images that listen on ports without declaring EXPOSE in their Dockerfile. -fn get_exposed_ports(container_name: &str) -> Vec { - let bin = match docker_bin() { - Some(b) => b, - None => return vec![], - }; - let out = match Command::new(&bin) - .args([ - "inspect", - "--format", - "{{json .Config.ExposedPorts}}", - container_name, - ]) - .output() - { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), - _ => return vec![], - }; - let mut ports = vec![]; - for part in out.split('"') { - let stripped = part - .strip_suffix("/tcp") - .or_else(|| part.strip_suffix("/udp")); - if let Some(port_str) = stripped { - if let Ok(p) = port_str.parse::() { - ports.push(p); - } - } - } - if !ports.is_empty() { - return ports; - } - - // Fallback: parse LISTEN entries from /proc/net/tcp6 and /proc/net/tcp. - // State 0A = LISTEN; local_address format is {ip_hex}:{port_hex}. - let mut proc_ports: Vec = vec![]; - for proc_file in &["/proc/net/tcp6", "/proc/net/tcp"] { - let raw = Command::new(&bin) - .args(["exec", container_name, "cat", proc_file]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default(); - for line in raw.lines().skip(1) { - let mut cols = line.split_whitespace(); - let _sl = cols.next(); - let local = match cols.next() { - Some(v) => v, - None => continue, - }; - cols.next(); // remote_address - let state = match cols.next() { - Some(v) => v, - None => continue, - }; - if state != "0A" { - continue; - } - if let Some(port_hex) = local.rsplit(':').next() { - if let Ok(p) = u16::from_str_radix(port_hex, 16) { - // Skip ephemeral ports (>= 32768) — these are HMR sockets, - // random kernel-assigned ports, etc., not real service ports. - if p > 0 && p < 32768 && !proc_ports.contains(&p) { - proc_ports.push(p); - } - } - } - } - } - proc_ports -} - -// Read the Vite base path from a running container. -// Finds the Vite process working directory via /proc//cwd, then reads -// vite.config.{ts,js} from there and extracts the `base` option. -// Returns Some("/brand/") etc. when found, None otherwise. -fn get_vite_base_path(container_name: &str) -> Option { - let bin = docker_bin()?; - // Find the PID of the running Vite process. - let pgrep_out = Command::new(&bin) - .args([ - "exec", - container_name, - "pgrep", - "-f", - "node_modules/.bin/vite", - ]) - .output() - .ok() - .filter(|o| o.status.success())?; - let pid = String::from_utf8_lossy(&pgrep_out.stdout) - .lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .map(String::from)?; - // Resolve the working directory of that process. - let cwd_out = Command::new(&bin) - .args([ - "exec", - container_name, - "readlink", - &format!("/proc/{}/cwd", pid), - ]) - .output() - .ok() - .filter(|o| o.status.success())?; - let cwd = String::from_utf8_lossy(&cwd_out.stdout).trim().to_string(); - if cwd.is_empty() { - return None; - } - // Try vite.config.ts then vite.config.js from the working directory. - for config_name in &["vite.config.ts", "vite.config.js"] { - let config_path = format!("{}/{}", cwd, config_name); - let cat_out = Command::new(&bin) - .args(["exec", container_name, "cat", &config_path]) - .output() - .ok() - .filter(|o| o.status.success()); - let content = match cat_out { - Some(o) => String::from_utf8_lossy(&o.stdout).to_string(), - None => continue, - }; - for line in content.lines() { - let t = line.trim(); - // Match: const base = '/brand/'; or base: '/brand/', - let rest = if let Some(r) = t.strip_prefix("const base = ") { - r - } else if let Some(r) = t.strip_prefix("base:") { - r.trim() - } else { - continue; - }; - let path = rest - .trim() - .trim_end_matches([',', ';']) - .trim_matches(|c: char| c == '\'' || c == '"'); - if !path.is_empty() && path.starts_with('/') && path != "/" { - return Some(path.to_string()); - } - } - } - None -} - -// Generic HTTP probe: connects to localhost:host_port, sends GET /, reads the -// response status and Location header. Returns the usable path: -// - 200 → "/" -// - 3xx + Location → the redirect path -// - anything else (timeout, 404, hang) → "" -// Used as fallback when no Vite process is detected. -fn probe_http_path(host_port: u16) -> String { - use std::io::{BufRead, BufReader, Write}; - use std::net::{SocketAddr, TcpStream}; - use std::time::Duration; - - let addr: SocketAddr = match format!("127.0.0.1:{}", host_port).parse() { - Ok(a) => a, - Err(_) => return String::new(), - }; - let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { - return String::new(); - }; - let _ = stream.set_read_timeout(Some(Duration::from_millis(1500))); - let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); - - let request = format!( - "GET / HTTP/1.1\r\nHost: localhost:{}\r\nConnection: close\r\n\r\n", - host_port - ); - if stream.write_all(request.as_bytes()).is_err() { - return String::new(); - } - - let mut reader = BufReader::new(&stream); - let mut status_line = String::new(); - if reader.read_line(&mut status_line).is_err() { - return String::new(); - } - let status: u16 = status_line - .split_whitespace() - .nth(1) - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - - if status == 200 { - return "/".to_string(); - } - if matches!(status, 301 | 302 | 307 | 308) { - let mut line = String::new(); - while reader.read_line(&mut line).unwrap_or(0) > 0 { - let trimmed = line.trim(); - if trimmed.is_empty() { - break; - } - if let Some(loc) = trimmed.strip_prefix("Location:") { - let loc = loc.trim(); - if loc.starts_with('/') { - return loc.to_string(); - } - let after_scheme = loc - .strip_prefix("http://") - .or_else(|| loc.strip_prefix("https://")) - .unwrap_or(""); - if let Some(slash_idx) = after_scheme.find('/') { - return after_scheme[slash_idx..].to_string(); - } - } - line.clear(); - } - } - String::new() -} - -fn get_docker_used_subnets() -> Vec { - let bin = match docker_bin() { - Some(b) => b, - None => return vec![], - }; - let ids = match Command::new(&bin).args(["network", "ls", "-q"]).output() { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), - _ => return vec![], - }; - let mut subnets = vec![]; - for id in ids.lines().map(str::trim).filter(|s| !s.is_empty()) { - if let Ok(out) = Command::new(&bin) - .args([ - "network", - "inspect", - "--format", - "{{range .IPAM.Config}}{{.Subnet}}|{{end}}", - id, - ]) - .output() - { - for part in String::from_utf8_lossy(&out.stdout).split('|') { - let s = part.trim().to_string(); - if !s.is_empty() { - subnets.push(s); - } - } - } - } - subnets -} - -// Scan sibling directories for existing override files to avoid assigning the -// same subnet to two worktrees that haven't started their Docker stack yet. -fn get_sibling_override_subnets(worktree_path: &str) -> Vec { - let parent = match std::path::Path::new(worktree_path).parent() { - Some(p) => p, - None => return vec![], - }; - let mut subnets = vec![]; - if let Ok(entries) = std::fs::read_dir(parent) { - for entry in entries.flatten() { - let override_file = entry.path().join("docker-compose.override.yml"); - if override_file - == std::path::Path::new(worktree_path).join("docker-compose.override.yml") - { - continue; // skip the worktree we're about to write - } - if let Ok(content) = std::fs::read_to_string(override_file) { - for line in content.lines() { - if let Some(rest) = line.trim().strip_prefix("subnet:") { - subnets.push(rest.trim().to_string()); - } - } - } - } - } - subnets -} - -fn find_free_subnet_prefix(base_prefix: &str, worktree_path: &str) -> Option { - let parts: Vec<&str> = base_prefix.split('.').collect(); - if parts.len() != 3 { - return None; - } - let base_third: u8 = parts[2].parse().ok()?; - let prefix16 = format!("{}.{}", parts[0], parts[1]); - - let mut used = get_docker_used_subnets(); - used.extend(get_sibling_override_subnets(worktree_path)); - - for delta in 1u8..=50 { - let new_third = base_third.checked_add(delta)?; - let candidate = format!("{}.{}", prefix16, new_third); - let candidate_subnet = format!("{}.0/24", candidate); - let in_use = used.iter().any(|s| { - let s = s.trim(); - s == candidate_subnet || s.starts_with(&format!("{}.", candidate)) - }); - if !in_use { - return Some(candidate); - } - } - None -} - -fn ensure_global_gitignore(pattern: &str) { - let path = login_shell_output("git config --global core.excludesFile") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{}/.config/git/ignore", home) - }); - if path.is_empty() { - return; - } - let existing = std::fs::read_to_string(&path).unwrap_or_default(); - if existing.lines().any(|l| l.trim() == pattern) { - return; - } - if let Some(parent) = std::path::Path::new(&path).parent() { - let _ = std::fs::create_dir_all(parent); - } - let mut content = existing; - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(pattern); - content.push('\n'); - let _ = std::fs::write(&path, content); -} - -/// Rewrites a devcontainer's `docker-compose.yml` text so a worktree gets an -/// isolated stack. Generic — it only touches what a given compose declares: -/// - a unique top-level `name:` (compose project) — always; -/// - a fixed `container_name:` gets worktree-prefixed (container names are global); -/// - static IPs + subnet remapped only when `subnet` is `Some((old, new))`; -/// - published host ports remapped as `20000 + port_offset*100 + index`. -/// -/// Editing the base (vs a `docker-compose.override.yml`) is required because -/// compose-merge only *appends* `ports:`, so it can never move a project's fixed -/// host ports. Returns the new YAML and the remapped host URLs. -fn isolate_compose_yaml( - content: &str, - project_name: &str, - subnet: Option<(&str, &str)>, - port_offset: u16, - git_mount: Option<&str>, -) -> (String, Vec) { - let old_ip_prefix = subnet.map(|(old, _)| format!("{}.", old)); - - let mut out = String::with_capacity(content.len() + 32); - let mut urls: Vec = vec![]; - let mut in_ports = false; - let mut port_index: u16 = 0; - let mut name_set = false; - let mut git_injected = false; - - for line in content.lines() { - // Top-level project name (column 0). Replace the first one we see. - if !name_set && line.starts_with("name:") { - out.push_str(&format!("name: {}\n", project_name)); - name_set = true; - continue; - } - - let trimmed = line.trim_start(); - let indent = &line[..line.len() - trimmed.len()]; - - if trimmed.trim_end() == "ports:" { - in_ports = true; - out.push_str(line); - out.push('\n'); - continue; - } - // A non-list line at any indent ends the current ports block. - if in_ports && !trimmed.starts_with('-') { - in_ports = false; - } - - // The workspace bind (`- ..:/workspace`) mounts the worktree, whose `.git` - // is a file pointing to the MAIN repo's gitdir. Mount that gitdir at the - // same absolute path so git works inside the container (else "not a git - // repository"). Same trick the plain-compose isolate uses. - if let Some(git) = git_mount { - if !git_injected && trimmed.starts_with("- ..:") { - out.push_str(line); - out.push('\n'); - out.push_str(&format!("{}- {}:{}\n", indent, git, git)); - git_injected = true; - continue; - } - } - - // Explicit container_name collides across projects (names are global) — - // prefix it with the worktree so it stays unique. - if let Some(rest) = trimmed.strip_prefix("container_name:") { - out.push_str(&format!( - "{}container_name: {}-{}\n", - indent, - project_name, - rest.trim() - )); - continue; - } - - // Static IP + subnet remap only when the compose declares a custom subnet. - if let (Some((_, new_prefix)), Some(old_ip)) = (subnet, old_ip_prefix.as_deref()) { - if let Some(rest) = trimmed.strip_prefix("ipv4_address:") { - if let Some(octet) = rest.trim().strip_prefix(old_ip) { - out.push_str(&format!("{}ipv4_address: {}.{}\n", indent, new_prefix, octet)); - continue; - } - } - let is_dashed = trimmed.starts_with("- subnet:"); - if let Some(rest) = trimmed - .strip_prefix("- subnet:") - .or_else(|| trimmed.strip_prefix("subnet:")) - { - let mask = rest.trim().split('/').nth(1).unwrap_or("24"); - let dash = if is_dashed { "- " } else { "" }; - out.push_str(&format!("{}{}subnet: {}.0/{}\n", indent, dash, new_prefix, mask)); - continue; - } - } - - // Published host port inside a ports: block. - if in_ports { - if let Some((_, container, quoted)) = parse_port_mapping(trimmed) { - let new_host = 20000 + port_offset * 100 + port_index; - port_index += 1; - let q = if quoted { "\"" } else { "" }; - out.push_str(&format!("{}- {}{}:{}{}\n", indent, q, new_host, container, q)); - urls.push(ServiceUrl { - service: format!("port {}", container), - url: format!("http://localhost:{}", new_host), - }); - continue; - } - } - - out.push_str(line); - out.push('\n'); - } - - if !name_set { - out.insert_str(0, &format!("name: {}\n", project_name)); - } - - (out, urls) -} - -/// Parses a compose `ports:` list item like `- "8108:8108"` into -/// `(host_port, container_port, was_quoted)`. Returns `None` for anything that is -/// not a plain `HOST:CONTAINER` numeric mapping (e.g. `host_ip:host:container`). -fn parse_port_mapping(item: &str) -> Option<(u16, String, bool)> { - let rest = item.strip_prefix('-')?.trim(); - let quoted = rest.starts_with('"'); - let inner = rest.trim_matches('"'); - let mut parts = inner.split(':'); - let host = parts.next()?.trim(); - let container = parts.next()?.trim(); - // Reject host_ip:host:container and any other non `HOST:CONTAINER` shape. - if parts.next().is_some() { - return None; - } - let host_port: u16 = host.parse().ok()?; - container.parse::().ok()?; - Some((host_port, container.to_string(), quoted)) -} - -/// First `/24` subnet prefix declared in a compose (`10.189.20` from -/// `10.189.20.0/24`), or `None` when it relies on the default network. -fn first_subnet_prefix(content: &str) -> Option { - content.lines().find_map(|line| { - let t = line.trim(); - let s = t - .strip_prefix("- subnet:") - .or_else(|| t.strip_prefix("subnet:"))?; - let without_mask = s.trim().split('/').next()?; - let parts: Vec<&str> = without_mask.split('.').collect(); - (parts.len() == 4).then(|| format!("{}.{}.{}", parts[0], parts[1], parts[2])) - }) -} - -/// Deterministic per-worktree port offset (1..=90) for projects without a custom -/// subnet — FNV-1a so it's stable across runs without Date/random. -fn stable_port_offset(seed: &str) -> u16 { - let mut h: u32 = 2166136261; - for b in seed.bytes() { - h = (h ^ b as u32).wrapping_mul(16777619); - } - 1 + (h % 90) as u16 -} - -/// Builds browsable localhost URLs from `(containerPort, hostPort)` pairs — every -/// isolated port (base compose + override), so bento lists the frontend/backend/etc. -fn pairs_to_urls(pairs: &[(u16, u16)]) -> Vec { - pairs - .iter() - .map(|(c, h)| ServiceUrl { - service: format!("port {}", c), - url: format!("http://localhost:{}", h), - }) - .collect() -} - -fn relative_path_string(path: &Path) -> String { - path.components() - .filter_map(|component| match component { - Component::Normal(part) => part.to_str(), - _ => None, - }) - .collect::>() - .join("/") -} - -fn valid_project_key(project_key: &str) -> bool { - let key = Path::new(project_key); - !project_key.is_empty() - && key.components().count() == 1 - && matches!(key.components().next(), Some(Component::Normal(_))) -} - -/// Finds every `.devcontainer` containing a `devcontainer.json`, ordered by depth -/// and then lexically. Paths are relative to the worktree. -fn find_devcontainer_dirs(worktree: &str) -> Vec { - let root = Path::new(worktree); - let mut pending = vec![root.to_path_buf()]; - let mut found = Vec::::new(); - while let Some(directory) = pending.pop() { - let Ok(read_dir) = std::fs::read_dir(&directory) else { - continue; - }; - let mut entries: Vec<_> = read_dir.filter_map(Result::ok).collect(); - entries.sort_by_key(|entry| entry.file_name()); - for entry in entries.into_iter().rev() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_dir() { - if entry.file_name() != ".git" { - pending.push(entry.path()); - } - } else if file_type.is_file() - && entry.file_name() == "devcontainer.json" - && entry.path().parent().and_then(Path::file_name).and_then(|name| name.to_str()) == Some(".devcontainer") - { - if let Some(relative) = entry.path().parent().and_then(|parent| parent.strip_prefix(root).ok()) { - found.push(relative.to_path_buf()); - } - } - } - } - found.sort_by(|left, right| { - left.components() - .count() - .cmp(&right.components().count()) - .then_with(|| left.cmp(right)) - }); - found.iter().map(|path| relative_path_string(path)).collect() -} - -#[cfg_attr(not(test), allow(dead_code))] -fn find_devcontainer_dir(worktree: &str) -> Option { - find_devcontainer_dirs(worktree).into_iter().next() -} - -fn recipe_files(recipes_dir: &str, project_key: &str) -> Result, String> { - if !valid_project_key(project_key) { - return Err("invalid project key".into()); - } - let recipe_root = Path::new(recipes_dir).join(project_key); - if !recipe_root.is_dir() { - return Ok(vec![]); - } - let mut pending = vec![recipe_root.clone()]; - let mut files = Vec::new(); - while let Some(directory) = pending.pop() { - let read_dir = std::fs::read_dir(&directory) - .map_err(|error| format!("{}: {error}", directory.display()))?; - let mut entries: Vec<_> = read_dir - .collect::, _>>() - .map_err(|error| error.to_string())?; - entries.sort_by_key(|entry| entry.file_name()); - for entry in entries.into_iter().rev() { - let file_type = entry.file_type().map_err(|error| error.to_string())?; - if file_type.is_symlink() { - return Err(format!("recipe symlinks are not supported: {}", entry.path().display())); - } - if file_type.is_dir() { - pending.push(entry.path()); - } else if file_type.is_file() { - let relative = entry - .path() - .strip_prefix(&recipe_root) - .map(relative_path_string) - .map_err(|error| error.to_string())?; - files.push((entry.path(), relative)); - } - } - } - files.sort_by(|left, right| left.1.cmp(&right.1)); - Ok(files) -} - -fn git_file_is_tracked(worktree: &str, relative: &str) -> bool { - Command::new("git") - .args(["ls-files", "--error-unmatch", "--", relative]) - .current_dir(worktree) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn recipe_preview(recipes_dir: Option<&str>, project_key: &str, worktree: &str) -> RecipePreview { - let devcontainer_dirs = find_devcontainer_dirs(worktree); - let mut warnings = Vec::new(); - if devcontainer_dirs.len() > 1 { - warnings.push("multiple-devcontainers".into()); - } - let Some(recipes_dir) = recipes_dir.filter(|path| !path.trim().is_empty()) else { - return RecipePreview { - project_key: project_key.into(), recipe_dir: None, recipe_exists: false, - devcontainer_dirs, files: vec![], warnings, - }; - }; - let recipe_dir = Path::new(recipes_dir).join(project_key); - let recipe_exists = recipe_dir.is_dir(); - let mut files = Vec::new(); - match recipe_files(recipes_dir, project_key) { - Ok(recipe_files) => for (source, relative) in recipe_files { - let destination = Path::new(worktree).join(&relative); - let tracked = git_file_is_tracked(worktree, &relative); - let action = if !destination.exists() { - "create" - } else if std::fs::read(&source).ok() == std::fs::read(&destination).ok() { - "unchanged" - } else if tracked { - "overwrite-tracked" - } else { - "overwrite" - }; - files.push(RecipeFilePreview { path: relative, action: action.into(), tracked }); - - if files.last().map(|file| file.path.ends_with("docker-compose.override.yml")).unwrap_or(false) { - let valid = std::fs::read_to_string(&source) - .map(|content| content.lines().any(|line| line.trim_end() == "services:")) - .unwrap_or(false); - if !valid { - warnings.push(format!("invalid-compose-override:{}", files.last().unwrap().path)); - } - } - }, - Err(error) => warnings.push(error), - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for (source, relative) in recipe_files(recipes_dir, project_key).unwrap_or_default() { - if relative.ends_with("bento-postcreate.sh") - && source.metadata().map(|m| m.permissions().mode() & 0o111 == 0).unwrap_or(false) - { - warnings.push(format!("postcreate-not-executable:{relative}")); - } - } - } - RecipePreview { - project_key: project_key.into(), - recipe_dir: Some(recipe_dir.to_string_lossy().into_owned()), - recipe_exists, - devcontainer_dirs, - files, - warnings, - } -} - -/// Mirrors every regular file in `/` into the worktree. -/// Paths are returned relative to the worktree, using `/` on every platform. -#[cfg_attr(not(test), allow(dead_code))] -fn overlay_recipe(recipes_dir: &str, project_key: &str, worktree: &str) -> Vec { - let mut applied = Vec::new(); - for (source, relative) in recipe_files(recipes_dir, project_key).unwrap_or_default() { - let destination = Path::new(worktree).join(&relative); - let copied = destination - .parent() - .and_then(|parent| std::fs::create_dir_all(parent).ok()) - .and_then(|_| std::fs::copy(&source, &destination).ok()); - if copied.is_some() { - applied.push(relative); - } - } - applied -} - -fn overlay_recipe_detailed( - recipes_dir: &str, - project_key: &str, - worktree: &str, - allow_tracked: bool, -) -> RecipeApplyResult { - let recipe_dir = Path::new(recipes_dir).join(project_key); - let mut result = RecipeApplyResult { - project_key: project_key.into(), - recipe_dir: recipe_dir.to_string_lossy().into_owned(), - devcontainer_dir: String::new(), - applied: vec![], skipped: vec![], errors: vec![], - applied_at: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(), - }; - let files = match recipe_files(recipes_dir, project_key) { - Ok(files) => files, - Err(error) => { result.errors.push(error); return result; } - }; - for (source, relative) in files { - let destination = Path::new(worktree).join(&relative); - let tracked = git_file_is_tracked(worktree, &relative); - if tracked && destination.exists() && !allow_tracked - && std::fs::read(&source).ok() != std::fs::read(&destination).ok() - { - result.skipped.push(relative); - continue; - } - if destination.exists() && std::fs::read(&source).ok() == std::fs::read(&destination).ok() { - result.skipped.push(relative); - continue; - } - let copy_result = destination - .parent() - .ok_or_else(|| "invalid destination".to_string()) - .and_then(|parent| std::fs::create_dir_all(parent).map_err(|e| e.to_string())) - .and_then(|_| std::fs::copy(&source, &destination).map_err(|e| e.to_string())); - match copy_result { - Ok(_) => { - if tracked { skip_worktree(worktree, &relative); } - result.applied.push(relative); - } - Err(error) => result.errors.push(format!("{relative}: {error}")), - } - } - result -} - -/// Appends `&& ` to a devcontainer.json `postCreateCommand` string, so bento's -/// setup runs after the project's own postCreate. Idempotent. Returns `Err` if the -/// key is missing or isn't a string — never corrupts the file. -fn add_postcreate_hook_to_devcontainer_json(json: &str, hook: &str) -> Result { - if json.contains(hook) { - return Ok(json.to_string()); // already chained — idempotent - } - let key = "\"postCreateCommand\""; - let key_pos = json.find(key).ok_or("postCreateCommand not found")?; - let colon_rel = json[key_pos + key.len()..] - .find(':') - .ok_or("malformed postCreateCommand")?; - let after_colon = key_pos + key.len() + colon_rel + 1; - let trimmed = json[after_colon..].trim_start(); - let value_start = json.len() - json[after_colon..].len() + (json[after_colon..].len() - trimmed.len()); - let rest = trimmed - .strip_prefix('"') - .ok_or("postCreateCommand is not a string")?; - let end_rel = rest.find('"').ok_or("unterminated string")?; - let existing = &rest[..end_rel]; - let value_end = value_start + 1 + end_rel + 1; - let replacement = format!("\"{} && {}\"", existing, hook); - Ok(format!("{}{}{}", &json[..value_start], replacement, &json[value_end..])) -} - -/// Adds `override_file` to a devcontainer.json `dockerComposeFile` value, turning a -/// string into an array (or appending to an existing array). Idempotent. Returns -/// `Err` if the key is missing or the value is neither a string nor an array — never -/// corrupts the file. Handles plain JSON (devcontainer.json is JSONC, but the common -/// case has no comments around this key). -fn add_override_to_devcontainer_json(json: &str, override_file: &str) -> Result { - if json.contains(override_file) { - return Ok(json.to_string()); // already referenced — idempotent - } - let key = "\"dockerComposeFile\""; - let key_pos = json.find(key).ok_or("dockerComposeFile not found")?; - let colon_rel = json[key_pos + key.len()..] - .find(':') - .ok_or("malformed dockerComposeFile")?; - let after_colon = key_pos + key.len() + colon_rel + 1; - let trimmed = json[after_colon..].trim_start(); - let value_start = json.len() - json[after_colon..].len() + (json[after_colon..].len() - trimmed.len()); - - if let Some(rest) = trimmed.strip_prefix('"') { - let end_rel = rest.find('"').ok_or("unterminated string")?; - let base = &rest[..end_rel]; - let value_end = value_start + 1 + end_rel + 1; // both quotes - let replacement = format!("[\"{}\", \"{}\"]", base, override_file); - Ok(format!("{}{}{}", &json[..value_start], replacement, &json[value_end..])) - } else if trimmed.starts_with('[') { - let end_rel = trimmed.find(']').ok_or("unterminated array")?; - let close = value_start + end_rel; // position of ']' - let inner = json[value_start + 1..close].trim(); - let insert = if inner.is_empty() { - format!("\"{}\"", override_file) - } else { - format!("{}, \"{}\"", inner, override_file) - }; - Ok(format!("{}[{}]{}", &json[..value_start], insert, &json[close + 1..])) - } else { - Err("dockerComposeFile is neither a string nor an array".into()) - } -} - -/// Wires recipe files belonging to the discovered devcontainer into its JSON. -fn wire_recipe_into_devcontainer( - worktree_path: &str, - devcontainer_dir: &str, - applied: &[String], -) -> Vec { - let mut errors = Vec::new(); - let json_relative = format!("{devcontainer_dir}/devcontainer.json"); - let json_path = Path::new(worktree_path).join(&json_relative); - let Ok(original) = std::fs::read_to_string(&json_path) else { - return vec![format!("cannot read {json_relative}")]; - }; - let mut json = original.clone(); - let override_path = format!("{devcontainer_dir}/docker-compose.override.yml"); - if applied.iter().any(|path| path == &override_path) { - match add_override_to_devcontainer_json(&json, "docker-compose.override.yml") { - Ok(updated) => json = updated, - Err(error) => errors.push(format!("{json_relative}: {error}")), - } - } - let postcreate_path = format!("{devcontainer_dir}/bento-postcreate.sh"); - if applied.iter().any(|path| path == &postcreate_path) { - let hook = format!("bash {postcreate_path}"); - match add_postcreate_hook_to_devcontainer_json(&json, &hook) { - Ok(updated) => json = updated, - Err(error) => errors.push(format!("{json_relative}: {error}")), - } - } - if json != original { - match std::fs::write(&json_path, json) { - Ok(_) => skip_worktree(worktree_path, &json_relative), - Err(error) => errors.push(format!("{json_relative}: {error}")), - } - } - errors -} - -fn write_recipe_state(worktree_path: &str, devcontainer_dir: &str, result: &RecipeApplyResult) { - let env_path = Path::new(worktree_path).join(devcontainer_dir).join(".env"); - let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); - let mut lines: Vec<&str> = existing - .lines() - .filter(|line| !line.starts_with("BENTO_RECIPE_STATE_HEX=")) - .collect(); - let Ok(json) = serde_json::to_string(result) else { return }; - let state = format!("BENTO_RECIPE_STATE_HEX={}", hex::encode(json)); - lines.push(&state); - let _ = std::fs::write(env_path, lines.join("\n") + "\n"); -} - -fn read_recipe_state(worktree_path: &str, devcontainer_dir: &str) -> Option { - let env_path = Path::new(worktree_path).join(devcontainer_dir).join(".env"); - let content = std::fs::read_to_string(env_path).ok()?; - let encoded = content.lines().find_map(|line| line.strip_prefix("BENTO_RECIPE_STATE_HEX="))?; - let raw = hex::decode(encoded).ok()?; - serde_json::from_slice(&raw).ok() -} - -/// Marks a file as `--skip-worktree` in the worktree's git index so local edits -/// (our compose rewrite) never show up in status or land in the branch. -fn skip_worktree(worktree_path: &str, file: &str) { - let git = login_shell_output("command -v git") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "git".into()); - let _ = Command::new(&git) - .args(["update-index", "--skip-worktree", file]) - .current_dir(worktree_path) - .output(); -} - -/// Extracts published `(containerPort, hostPort)` pairs from a compose's `ports:`. -fn published_port_pairs(content: &str) -> Vec<(u16, u16)> { - let mut out = vec![]; - let mut in_ports = false; - for line in content.lines() { - let trimmed = line.trim_start(); - if trimmed.trim_end() == "ports:" { - in_ports = true; - continue; - } - if in_ports && !trimmed.starts_with('-') { - in_ports = false; - } - if in_ports { - if let Some((host, container, _)) = parse_port_mapping(trimmed) { - if let Ok(c) = container.parse::() { - out.push((c, host)); - } - } - } - } - out -} - -/// Finds `${BENTO_HOST_}` container ports referenced in a file (e.g. an override -/// that wires a service by port). bento allocates a host port for each. -fn referenced_bento_hosts(content: &str) -> Vec { - let mut out = vec![]; - for part in content.split("BENTO_HOST_").skip(1) { - let digits: String = part.chars().take_while(char::is_ascii_digit).collect(); - if let Ok(n) = digits.parse::() { - if !out.contains(&n) { - out.push(n); - } - } - } - out -} - -/// Writes the isolated host-port map to `.devcontainer/.env` (auto-loaded by Compose) -/// so the compose/override can build per-worktree URLs via `${BENTO_HOST_*}`. Records -/// the base compose's remapped ports and allocates a fresh host port for any -/// `${BENTO_HOST_}` the override references but the base doesn't publish (e.g. -/// keycloak). Reuses prior allocations (idempotent) and preserves non-BENTO lines. -fn write_bento_env( - worktree_path: &str, - devcontainer_dir: &str, - compose: &str, -) -> Vec<(u16, u16)> { - let env_path = Path::new(worktree_path).join(devcontainer_dir).join(".env"); - let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); - let kept: Vec = existing - .lines() - .filter(|l| !l.starts_with("BENTO_HOST_") && !l.trim().is_empty()) - .map(str::to_string) - .collect(); - let prior: Vec<(u16, u16)> = existing - .lines() - .filter_map(|l| { - let (n, h) = l.strip_prefix("BENTO_HOST_")?.split_once('=')?; - Some((n.parse().ok()?, h.parse().ok()?)) - }) - .collect(); - - let mut pairs = published_port_pairs(compose); - let override_content = std::fs::read_to_string( - Path::new(worktree_path) - .join(devcontainer_dir) - .join("docker-compose.override.yml"), - ) - .unwrap_or_default(); - let mut next = pairs.iter().map(|(_, h)| *h).max().unwrap_or(20000) + 1; - for n in referenced_bento_hosts(&override_content) { - if pairs.iter().any(|(c, _)| *c == n) { - continue; - } - if let Some((_, h)) = prior.iter().find(|(c, _)| *c == n) { - pairs.push((n, *h)); - } else { - while pairs.iter().any(|(_, h)| *h == next) { - next += 1; - } - pairs.push((n, next)); - next += 1; - } - } - - let mut lines = kept; - for (c, h) in &pairs { - lines.push(format!("BENTO_HOST_{}={}", c, h)); - } - if !lines.is_empty() { - let _ = std::fs::write(&env_path, lines.join("\n") + "\n"); - } - pairs -} - -/// Generates a `docker-compose.override.yml` in the worktree that remaps the -/// network subnet, container names, and exposes ports so the stack can run -/// alongside the main repo stack without conflicts. -/// -/// Ports are assigned with the formula: 20000 + subnet_offset×100 + ip_last_octet. -/// Exposed ports are discovered by inspecting the main stack's running containers. -/// -/// Returns "no-compose" error if no docker-compose.yml found (treat as no-op). -#[tauri::command] -pub async fn docker_compose_isolate(worktree_path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let compose_path = format!("{}/docker-compose.yml", worktree_path); - if !std::path::Path::new(&compose_path).exists() { - return Err("no-compose".into()); - } - - let content = std::fs::read_to_string(&compose_path).map_err(|e| e.to_string())?; - let (network_name, old_prefix, services) = - parse_compose_info(&content).ok_or("could not parse compose network info")?; - - if services.is_empty() { - return Err("no services with static IPs found".into()); - } - - let worktree_dir = std::path::Path::new(&worktree_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("worktree") - .to_string(); - - // If this is a git worktree, the .git entry is a file pointing to the - // main repo's .git dir. We expose that dir as a volume so git inside - // containers can resolve the gitdir pointer (needed for yarn install). - let git_file = format!("{}/.git", worktree_path); - let git_volume_line = if std::path::Path::new(&git_file).is_file() { - std::fs::read_to_string(&git_file) - .ok() - .and_then(|c| { - c.lines() - .find_map(|l| l.strip_prefix("gitdir:").map(|s| s.trim().to_string())) - }) - .and_then(|gitdir| { - std::path::Path::new(&gitdir) - .parent() // worktrees/ - .and_then(|p| p.parent()) // .git/ - .and_then(|p| p.to_str()) - .map(|main_git| format!(" - {}:{}:ro\n", main_git, main_git)) - }) - } else { - None - }; - - // Reuse the subnet already assigned to this worktree if the override - // exists — avoids regenerating a new subnet (and thus new ports) every - // time the button is clicked while containers are running. - let override_path_check = format!("{}/docker-compose.override.yml", worktree_path); - let existing_prefix = std::fs::read_to_string(&override_path_check) - .ok() - .and_then(|c| { - c.lines().find_map(|l| { - let t = l.trim(); - let s = t - .strip_prefix("- subnet:") - .or_else(|| t.strip_prefix("subnet:"))?; - let without_mask = s.trim().split('/').next()?; - let parts: Vec<&str> = without_mask.split('.').collect(); - if parts.len() == 4 { - Some(format!("{}.{}.{}", parts[0], parts[1], parts[2])) - } else { - None - } - }) - }); - - let new_prefix = match existing_prefix { - Some(p) => p, - None => find_free_subnet_prefix(&old_prefix, &worktree_path) - .ok_or("no free subnet available in range")?, - }; - let new_subnet = format!("{}.0/24", new_prefix); - - let base_third: u16 = old_prefix - .split('.') - .nth(2) - .unwrap_or("0") - .parse() - .unwrap_or(0); - let new_third: u16 = new_prefix - .split('.') - .nth(2) - .unwrap_or("0") - .parse() - .unwrap_or(0); - let subnet_offset = new_third.saturating_sub(base_third); - - let mut yaml = format!( - "networks:\n {}:\n ipam:\n config:\n - subnet: {}\n\nservices:\n", - network_name, new_subnet - ); - - let mut urls: Vec = vec![]; - - for svc in &services { - let last_octet_str = svc.ip.rsplit('.').next().unwrap_or("0"); - let last_octet: u16 = last_octet_str.parse().unwrap_or(0); - let new_ip = format!("{}.{}", new_prefix, last_octet_str); - let new_container = format!("{}-{}", worktree_dir, svc.name); - - // Port base for this service: 20000 + offset×100 + last_octet - let host_port_base = 20000 + subnet_offset * 100 + last_octet; - - // Discover internal ports by inspecting the main stack container - let exposed = svc - .container_name - .as_deref() - .map(get_exposed_ports) - .unwrap_or_default(); - - yaml.push_str(&format!( - " {}:\n container_name: {}\n", - svc.name, new_container - )); - - if !exposed.is_empty() { - // Detect URL base path using the WORKTREE container (new_container), - // not the main stack container — the worktree one is the running instance. - // 1. Vite config detection (reads base from vite.config.{ts,js}) - // 2. HTTP probe on the primary mapped port (generic fallback) - let actual_first_port = - get_actual_host_port(&new_container, exposed[0]).unwrap_or(host_port_base); - let url_base = get_vite_base_path(&new_container) - .or_else(|| { - let p = probe_http_path(actual_first_port); - if p.is_empty() { - None - } else { - Some(p) - } - }) - .unwrap_or_default(); - yaml.push_str(" ports:\n"); - for (i, &internal_port) in exposed.iter().enumerate() { - // Prefer the actual running port; fall back to computed port - let host_port = get_actual_host_port(&new_container, internal_port) - .unwrap_or(host_port_base + i as u16); - yaml.push_str(&format!(" - \"{}:{}\"\n", host_port, internal_port)); - urls.push(ServiceUrl { - service: svc.name.clone(), - url: format!("http://localhost:{}{}", host_port, url_base), - }); - } - } - - if let Some(ref vol) = git_volume_line { - yaml.push_str(" volumes:\n"); - yaml.push_str(vol); - } - - yaml.push_str(&format!( - " networks:\n {}:\n ipv4_address: {}\n", - network_name, new_ip - )); - } - - let override_path = format!("{}/docker-compose.override.yml", worktree_path); - std::fs::write(&override_path, yaml).map_err(|e| e.to_string())?; - - ensure_global_gitignore("docker-compose.override.yml"); - - Ok(IsolateResult { - subnet: new_subnet, - urls, - recipe: None, - }) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn devcontainer_recipe_preview( - worktree_path: String, - recipes_dir: Option, - project_key: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !Path::new(&worktree_path).is_dir() { - return Err("invalid worktree".into()); - } - if !valid_project_key(&project_key) { - return Err("invalid project key".into()); - } - Ok(recipe_preview(recipes_dir.as_deref(), &project_key, &worktree_path)) - }) - .await - .map_err(|error| error.to_string())? -} - -#[tauri::command] -pub async fn devcontainer_recipe_create( - recipes_dir: String, - project_key: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - create_recipe_dir(&recipes_dir, &project_key) - }) - .await - .map_err(|error| error.to_string())? -} - -fn create_recipe_dir(recipes_dir: &str, project_key: &str) -> Result { - if recipes_dir.trim().is_empty() || !valid_project_key(project_key) { - return Err("invalid recipe path".into()); - } - let project_dir = Path::new(recipes_dir).join(project_key); - let devcontainer_dir = project_dir.join(".devcontainer"); - std::fs::create_dir_all(&devcontainer_dir) - .map_err(|error| error.to_string())?; - Ok(project_dir.to_string_lossy().into_owned()) -} - -fn run_recipe_git(recipes_dir: &str, action: &str, message: Option<&str>) -> Result { - let root = Path::new(recipes_dir); - if !root.is_dir() { - return Err("recipes directory does not exist".into()); - } - let run = |args: &[&str]| -> Result { - let output = Command::new("git") - .args(args) - .current_dir(root) - .output() - .map_err(|error| error.to_string())?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - }; - match action { - "init" => run(&["init"]), - "status" => run(&["status", "--short", "--branch"]), - "pull" => run(&["pull", "--ff-only"]), - "push" => run(&["push"]), - "commit" => { - let message = message.map(str::trim).filter(|value| !value.is_empty()) - .ok_or_else(|| "commit message is required".to_string())?; - run(&["add", "-A"])?; - run(&["commit", "-m", message]) - } - _ => Err("unsupported recipe git action".into()), - } -} - -#[tauri::command] -pub async fn devcontainer_recipe_git( - recipes_dir: String, - action: String, - message: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - run_recipe_git(&recipes_dir, &action, message.as_deref()) - }) - .await - .map_err(|error| error.to_string())? -} - -/// Prepares a devcontainer worktree so VS Code's "Reopen in Container" starts an -/// isolated stack, then mirrors the optional project recipe over the worktree. -/// The devcontainer can live at any depth; without a recipes directory this still -/// performs the generic compose isolation. -#[tauri::command] -pub async fn devcontainer_isolate( - worktree_path: String, - recipes_dir: Option, - project_key: String, - devcontainer_dir: Option, - allow_tracked: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let candidates = find_devcontainer_dirs(&worktree_path); - if candidates.is_empty() { - return Err("no-devcontainer".into()); - } - let devcontainer_dir = match devcontainer_dir { - Some(selected) if candidates.contains(&selected) => selected, - Some(_) => return Err("invalid-devcontainer".into()), - None if candidates.len() == 1 => candidates[0].clone(), - None => return Err("multiple-devcontainers".into()), - }; - let compose_relative = format!("{devcontainer_dir}/docker-compose.yml"); - let compose_path = Path::new(&worktree_path).join(&compose_relative); - if !compose_path.is_file() { - return Err("no-devcontainer".into()); - } - - let worktree_dir = std::path::Path::new(&worktree_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("worktree") - .to_string(); - - let content = std::fs::read_to_string(&compose_path).map_err(|e| e.to_string())?; - - // Idempotent: if this worktree was already isolated (name == worktree dir), - // don't shift subnet/ports again — just report the current state. - let target_name = format!("name: {}", worktree_dir); - let already = content - .lines() - .any(|l| l.starts_with("name:") && l.trim_end() == target_name); - let result_subnet = if already { - first_subnet_prefix(&content) - .map(|p| format!("{}.0/24", p)) - .unwrap_or_default() - } else { - // Remap the custom subnet when present; otherwise Docker auto-assigns a - // non-overlapping default network, so only name + ports need isolating. - let (subnet_remap, port_offset, subnet) = match first_subnet_prefix(&content) { - Some(old_prefix) => { - let new_prefix = find_free_subnet_prefix(&old_prefix, &worktree_path) - .ok_or("no free subnet available in range")?; - let base_third: u16 = old_prefix - .rsplit('.') - .next() - .unwrap_or("0") - .parse() - .unwrap_or(0); - let new_third: u16 = new_prefix - .rsplit('.') - .next() - .unwrap_or("0") - .parse() - .unwrap_or(0); - let offset = new_third.saturating_sub(base_third).max(1); - let subnet = format!("{}.0/24", new_prefix); - (Some((old_prefix, new_prefix)), offset, subnet) - } - None => (None, stable_port_offset(&worktree_dir), String::new()), - }; - - // Mount the main repo's gitdir into the container. A worktree's `.git` - // file points outside its own directory, which would otherwise be absent. - let git_mount = std::fs::read_to_string(Path::new(&worktree_path).join(".git")) - .ok() - .and_then(|c| { - c.lines() - .find_map(|l| l.strip_prefix("gitdir:").map(|s| s.trim().to_string())) - }) - .and_then(|gitdir| { - Path::new(&gitdir) - .parent() - .and_then(|p| p.parent()) - .and_then(|p| p.to_str()) - .map(String::from) - }); - - let remap_ref = subnet_remap.as_ref().map(|(o, n)| (o.as_str(), n.as_str())); - let (new_content, _) = isolate_compose_yaml( - &content, - &worktree_dir, - remap_ref, - port_offset, - git_mount.as_deref(), - ); - std::fs::write(&compose_path, new_content).map_err(|e| e.to_string())?; - skip_worktree(&worktree_path, &compose_relative); - subnet - }; - - // Recipes intentionally run after isolation: they are a generic filesystem - // overlay, not a second source of project files. - let mut recipe = recipes_dir - .as_deref() - .filter(|directory| !directory.trim().is_empty()) - .map(|directory| overlay_recipe_detailed( - directory, &project_key, &worktree_path, allow_tracked.unwrap_or(false) - )); - let applied = recipe.as_ref().map(|result| result.applied.clone()).unwrap_or_default(); - for relative in &applied { - if !git_file_is_tracked(&worktree_path, relative) { - ensure_global_gitignore(&format!("/{relative}")); - } - } - let recipe_files_present = recipes_dir.as_deref().map(|directory| { - recipe_files(directory, &project_key).unwrap_or_default().into_iter() - .filter(|(source, relative)| { - std::fs::read(source).ok() - == std::fs::read(Path::new(&worktree_path).join(relative)).ok() - }) - .map(|(_, relative)| relative) - .collect::>() - }).unwrap_or_default(); - let wiring_errors = wire_recipe_into_devcontainer( - &worktree_path, - &devcontainer_dir, - &recipe_files_present, - ); - skip_worktree( - &worktree_path, - &format!("{devcontainer_dir}/devcontainer.json"), - ); - let final_compose = std::fs::read_to_string(&compose_path).map_err(|e| e.to_string())?; - let pairs = write_bento_env(&worktree_path, &devcontainer_dir, &final_compose); - if let Some(result) = recipe.as_mut() { - result.devcontainer_dir = devcontainer_dir.clone(); - result.errors.extend(wiring_errors); - write_recipe_state(&worktree_path, &devcontainer_dir, result); - } - - Ok(IsolateResult { - subnet: result_subnet, - urls: pairs_to_urls(&pairs), - recipe, - }) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn devcontainer_recipe_status( - worktree_path: String, - devcontainer_dir: Option, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let candidates = find_devcontainer_dirs(&worktree_path); - let selected = match devcontainer_dir { - Some(path) if candidates.contains(&path) => path, - Some(_) => return Err("invalid-devcontainer".into()), - None if candidates.len() == 1 => candidates[0].clone(), - None if candidates.is_empty() => return Err("no-devcontainer".into()), - None => return Err("multiple-devcontainers".into()), - }; - Ok(read_recipe_state(&worktree_path, &selected)) - }) - .await - .map_err(|error| error.to_string())? -} - -/// Reads the `.devcontainer/.env` host-port map (written by `devcontainer_isolate`) -/// and returns browsable localhost URLs. Cheap + read-only — used to re-display a -/// prepared task's URLs without re-isolating. Returns "no-devcontainer" if absent. -#[tauri::command] -pub async fn devcontainer_urls( - worktree_path: String, - devcontainer_dir: Option, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let candidates = find_devcontainer_dirs(&worktree_path); - let devcontainer_dir = match devcontainer_dir { - Some(path) if candidates.contains(&path) => path, - Some(_) => return Err("invalid-devcontainer".into()), - None => candidates.into_iter().next().ok_or_else(|| "no-devcontainer".to_string())?, - }; - let env_path = Path::new(&worktree_path).join(devcontainer_dir).join(".env"); - let content = std::fs::read_to_string(&env_path).map_err(|_| "no-devcontainer".to_string())?; - let pairs: Vec<(u16, u16)> = content - .lines() - .filter_map(|l| { - let (n, h) = l.strip_prefix("BENTO_HOST_")?.split_once('=')?; - Some((n.parse().ok()?, h.parse().ok()?)) - }) - .collect(); - if pairs.is_empty() { - return Err("no-devcontainer".into()); - } - Ok(pairs_to_urls(&pairs)) - }) - .await - .map_err(|e| e.to_string())? -} - -/// Runs `docker compose up -d` in the given worktree directory. -#[tauri::command] -pub async fn docker_compose_up(worktree_path: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let compose_file = format!("{}/docker-compose.yml", worktree_path); - if !std::path::Path::new(&compose_file).exists() { - return Err("no-compose".into()); - } - let bin = match docker_bin() { - Some(b) => b, - None => return Err("docker not found".into()), - }; - let out = Command::new(&bin) - .args(["compose", "up", "-d", "--remove-orphans"]) - .current_dir(&worktree_path) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -/// Runs `docker compose down` in the given worktree directory to stop and remove -/// the containers, networks, and anonymous volumes created for that task. -/// Silently succeeds if there is no compose file or Docker is not available. -#[tauri::command] -pub async fn docker_compose_down(worktree_path: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let compose_file = format!("{}/docker-compose.yml", worktree_path); - if !std::path::Path::new(&compose_file).exists() { - return Ok(()); // no compose project — nothing to do - } - let bin = match docker_bin() { - Some(b) => b, - None => return Ok(()), // docker not available - }; - // `docker compose` (v2 plugin) preferred; fall back to `docker-compose` (v1). - let out = Command::new(&bin) - .args(["compose", "down", "--remove-orphans"]) - .current_dir(&worktree_path) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); - // "no configuration file provided" means nothing was running — not an error. - if !err.contains("no configuration file") { - return Err(err); - } - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Stream `docker compose logs -f` for all services; emits to event `docker-compose-logs-`. -#[tauri::command] -pub fn docker_compose_logs_follow( - worktree_path: String, - tail: u32, - app: AppHandle, - state: tauri::State, -) -> Result<(), String> { - let key = format!("compose:{}", worktree_path); - if let Some(mut child) = state.0.lock().unwrap().remove(&key) { - let _ = child.kill(); - } - let bin = docker_bin().ok_or("docker no encontrado")?; - let tail_s = tail.to_string(); - let mut child = Command::new(&bin) - .args(["compose", "logs", "-f", "--tail", &tail_s]) - .current_dir(&worktree_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| e.to_string())?; - let dir = std::path::Path::new(&worktree_path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "compose".into()); - let event = format!("docker-compose-logs-{}", dir); - if let Some(o) = child.stdout.take() { - pipe_lines(o, app.clone(), event.clone()); - } - if let Some(e) = child.stderr.take() { - pipe_lines(e, app, event); - } - state.0.lock().unwrap().insert(key, child); - Ok(()) -} - -#[tauri::command] -pub fn docker_compose_logs_stop( - worktree_path: String, - state: tauri::State, -) -> Result<(), String> { - let key = format!("compose:{}", worktree_path); - if let Some(mut child) = state.0.lock().unwrap().remove(&key) { - let _ = child.kill(); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temporary_directory(name: &str) -> PathBuf { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!( - "bento-docker-{name}-{}-{nonce}", - std::process::id() - )) - } - - const SAMPLE: &str = "name: nixon_devcontainer -services: - app: - volumes: - - ..:/workspace:cached - networks: - nixon-network: - ipv4_address: 10.189.20.10 - typesense: - ports: - - \"8108:8108\" - networks: - nixon-network: - ipv4_address: 10.189.20.6 -networks: - nixon-network: - ipam: - config: - - subnet: 10.189.20.0/24 -"; - - // A generic devcontainer compose with NO custom subnet, but a fixed - // container_name and a published port — both must still be isolated. - const SAMPLE_NO_SUBNET: &str = "services: - web: - image: nginx - container_name: web - ports: - - \"3000:3000\" -"; - - #[test] - fn finds_root_devcontainer_directory() { - let worktree = temporary_directory("find-root"); - let directory = worktree.join(".devcontainer"); - std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("devcontainer.json"), "{}").unwrap(); - assert_eq!( - find_devcontainer_dir(worktree.to_str().unwrap()).as_deref(), - Some(".devcontainer") - ); - let _ = std::fs::remove_dir_all(worktree); - } - - #[test] - fn finds_nested_devcontainer_directory() { - let worktree = temporary_directory("find-nested"); - let directory = worktree.join("apps/foo/.devcontainer"); - std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("devcontainer.json"), "{}").unwrap(); - assert_eq!( - find_devcontainer_dir(worktree.to_str().unwrap()).as_deref(), - Some("apps/foo/.devcontainer") - ); - let _ = std::fs::remove_dir_all(worktree); - } - - #[test] - fn recipe_overlay_mirrors_files_and_creates_parent_directories() { - let root = temporary_directory("overlay"); - let recipes = root.join("recipes"); - let worktree = root.join("worktree"); - let project = recipes.join("konect-nixon"); - std::fs::create_dir_all(project.join("apps/foo/.devcontainer")).unwrap(); - std::fs::write(project.join(".env"), "APP_ENV=local\n").unwrap(); - std::fs::write( - project.join("apps/foo/.devcontainer/x"), - "nested recipe\n", - ) - .unwrap(); - - let applied = overlay_recipe( - recipes.to_str().unwrap(), - "konect-nixon", - worktree.to_str().unwrap(), - ); - - assert_eq!(applied, vec![".env", "apps/foo/.devcontainer/x"]); - assert_eq!( - std::fs::read_to_string(worktree.join(".env")).unwrap(), - "APP_ENV=local\n" - ); - assert_eq!( - std::fs::read_to_string(worktree.join("apps/foo/.devcontainer/x")).unwrap(), - "nested recipe\n" - ); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn finds_all_devcontainers_in_stable_order() { - let worktree = temporary_directory("find-multiple"); - for relative in ["apps/web/.devcontainer", ".devcontainer", "apps/api/.devcontainer"] { - let directory = worktree.join(relative); - std::fs::create_dir_all(&directory).unwrap(); - std::fs::write(directory.join("devcontainer.json"), "{}").unwrap(); - } - assert_eq!(find_devcontainer_dirs(worktree.to_str().unwrap()), vec![ - ".devcontainer", "apps/api/.devcontainer", "apps/web/.devcontainer", - ]); - let _ = std::fs::remove_dir_all(worktree); - } - - fn init_test_git_repo(path: &Path) { - std::fs::create_dir_all(path).unwrap(); - for args in [ - vec!["init", "-q"], - vec!["config", "user.email", "bento@example.test"], - vec!["config", "user.name", "Bento Test"], - ] { - assert!(Command::new("git").args(args).current_dir(path).status().unwrap().success()); - } - } - - #[test] - fn preview_marks_tracked_overwrites_and_apply_requires_permission() { - let root = temporary_directory("tracked-preview"); - let worktree = root.join("worktree"); - let recipes = root.join("recipes"); - init_test_git_repo(&worktree); - std::fs::write(worktree.join("config.local"), "project\n").unwrap(); - assert!(Command::new("git").args(["add", "config.local"]).current_dir(&worktree).status().unwrap().success()); - assert!(Command::new("git").args(["commit", "-qm", "base"]).current_dir(&worktree).status().unwrap().success()); - std::fs::create_dir_all(recipes.join("project")).unwrap(); - std::fs::write(recipes.join("project/config.local"), "recipe\n").unwrap(); - - let preview = recipe_preview(Some(recipes.to_str().unwrap()), "project", worktree.to_str().unwrap()); - assert_eq!(preview.files[0].action, "overwrite-tracked"); - assert!(preview.files[0].tracked); - - let denied = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), false); - assert!(denied.applied.is_empty()); - assert_eq!(denied.skipped, vec!["config.local"]); - assert_eq!(std::fs::read_to_string(worktree.join("config.local")).unwrap(), "project\n"); - - let allowed = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), true); - assert_eq!(allowed.applied, vec!["config.local"]); - assert_eq!(std::fs::read_to_string(worktree.join("config.local")).unwrap(), "recipe\n"); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn recipe_pipeline_is_idempotent_for_nested_devcontainer() { - let root = temporary_directory("pipeline"); - let worktree = root.join("worktree"); - let recipes = root.join("recipes"); - let devcontainer = worktree.join("apps/api/.devcontainer"); - init_test_git_repo(&worktree); - std::fs::create_dir_all(&devcontainer).unwrap(); - std::fs::write(devcontainer.join("devcontainer.json"), r#"{ - "dockerComposeFile": "docker-compose.yml", - "postCreateCommand": "bash setup.sh" -}"#).unwrap(); - let (isolated, _) = isolate_compose_yaml(SAMPLE_NO_SUBNET, "task-1", None, 2, None); - std::fs::write(devcontainer.join("docker-compose.yml"), &isolated).unwrap(); - let recipe_devcontainer = recipes.join("project/apps/api/.devcontainer"); - std::fs::create_dir_all(&recipe_devcontainer).unwrap(); - std::fs::write(recipe_devcontainer.join("docker-compose.override.yml"), "services:\n web:\n environment:\n LOCAL: 1\n").unwrap(); - std::fs::write(recipe_devcontainer.join("bento-postcreate.sh"), "#!/bin/sh\ntrue\n").unwrap(); - - let mut first = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), false); - first.devcontainer_dir = "apps/api/.devcontainer".into(); - assert!(wire_recipe_into_devcontainer(worktree.to_str().unwrap(), &first.devcontainer_dir, &first.applied).is_empty()); - write_bento_env(worktree.to_str().unwrap(), &first.devcontainer_dir, &isolated); - write_recipe_state(worktree.to_str().unwrap(), &first.devcontainer_dir, &first); - - let json = std::fs::read_to_string(devcontainer.join("devcontainer.json")).unwrap(); - assert!(json.contains("docker-compose.override.yml"), "{json}"); - assert!(json.contains("bash apps/api/.devcontainer/bento-postcreate.sh"), "{json}"); - assert_eq!(read_recipe_state(worktree.to_str().unwrap(), &first.devcontainer_dir).unwrap().project_key, "project"); - - let second = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), false); - assert!(second.applied.is_empty()); - assert_eq!(second.skipped.len(), 2); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn creates_recipe_scaffold_and_initializes_version_control() { - let root = temporary_directory("recipe-create"); - let recipes = root.join("recipes"); - let created = create_recipe_dir(recipes.to_str().unwrap(), "company--api").unwrap(); - assert!(Path::new(&created).join(".devcontainer").is_dir()); - assert!(create_recipe_dir(recipes.to_str().unwrap(), "../escape").is_err()); - run_recipe_git(recipes.to_str().unwrap(), "init", None).unwrap(); - assert!(recipes.join(".git").is_dir()); - let status = run_recipe_git(recipes.to_str().unwrap(), "status", None).unwrap(); - assert!(status.starts_with("##")); - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(unix)] - #[test] - fn recipe_rejects_symbolic_links() { - use std::os::unix::fs::symlink; - let root = temporary_directory("recipe-symlink"); - let project = root.join("recipes/project"); - std::fs::create_dir_all(&project).unwrap(); - std::fs::write(root.join("secret"), "outside\n").unwrap(); - symlink(root.join("secret"), project.join("linked-secret")).unwrap(); - let error = recipe_files(root.join("recipes").to_str().unwrap(), "project").unwrap_err(); - assert!(error.contains("symlinks are not supported"), "{error}"); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn isolates_name_subnet_ips_and_ports() { - let (out, urls) = isolate_compose_yaml( - SAMPLE, - "konect-nixon-nixon-459", - Some(("10.189.20", "10.189.21")), - 1, - None, - ); - assert!(out.contains("name: konect-nixon-nixon-459"), "{out}"); - assert!(!out.contains("name: nixon_devcontainer"), "{out}"); - assert!(out.contains("ipv4_address: 10.189.21.10"), "{out}"); - assert!(out.contains("ipv4_address: 10.189.21.6"), "{out}"); - assert!(out.contains("- subnet: 10.189.21.0/24"), "{out}"); - assert!(out.contains("- \"20100:8108\""), "{out}"); - assert!(!out.contains("8108:8108"), "{out}"); - assert_eq!(urls.len(), 1); - assert_eq!(urls[0].url, "http://localhost:20100"); - } - - #[test] - fn adds_name_when_missing() { - let src = "services:\n app:\n image: x\n"; - let (out, _) = isolate_compose_yaml(src, "proj-1", None, 5, None); - assert!(out.starts_with("name: proj-1\n"), "{out}"); - } - - #[test] - fn isolates_without_custom_subnet() { - let (out, urls) = isolate_compose_yaml(SAMPLE_NO_SUBNET, "proj", None, 7, None); - assert!(out.starts_with("name: proj\n"), "{out}"); - // fixed container_name gets worktree-prefixed so it stays globally unique - assert!(out.contains("container_name: proj-web"), "{out}"); - // port remapped with offset 7 -> 20000 + 700 + 0 - assert!(out.contains("- \"20700:3000\""), "{out}"); - assert!(!out.contains("\"3000:3000\""), "{out}"); - assert_eq!(urls[0].url, "http://localhost:20700"); - } - - #[test] - fn mounts_main_git_dir_once() { - let (out, _) = isolate_compose_yaml( - SAMPLE, - "wt", - Some(("10.189.20", "10.189.21")), - 1, - Some("/repo/.git"), - ); - assert!(out.contains("- ..:/workspace:cached"), "{out}"); - assert!(out.contains("- /repo/.git:/repo/.git"), "{out}"); - assert_eq!(out.matches("/repo/.git:/repo/.git").count(), 1, "{out}"); - } - - #[test] - fn parses_simple_port_mapping() { - assert_eq!( - parse_port_mapping("- \"8108:8108\""), - Some((8108, "8108".into(), true)) - ); - assert_eq!( - parse_port_mapping("- 5540:5540"), - Some((5540, "5540".into(), false)) - ); - } - - #[test] - fn skips_non_numeric_or_triple_port() { - assert_eq!(parse_port_mapping("- \"127.0.0.1:8108:8108\""), None); - assert_eq!(parse_port_mapping("- ../.env:/x"), None); - } - - #[test] - fn first_subnet_prefix_optional() { - assert_eq!(first_subnet_prefix(SAMPLE).as_deref(), Some("10.189.20")); - assert_eq!(first_subnet_prefix(SAMPLE_NO_SUBNET), None); - } - - #[test] - fn stable_port_offset_is_deterministic_and_bounded() { - let a = stable_port_offset("konect-nixon-nixon-459"); - assert_eq!(a, stable_port_offset("konect-nixon-nixon-459")); - assert!((1..=90).contains(&a)); - } - - #[test] - fn override_json_string_to_array() { - let json = "{\n \"name\": \"x\",\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\"\n}"; - let out = add_override_to_devcontainer_json(json, "docker-compose.override.yml").unwrap(); - assert!( - out.contains("[\"docker-compose.yml\", \"docker-compose.override.yml\"]"), - "{out}" - ); - assert!(out.contains("\"service\": \"app\""), "{out}"); - } - - #[test] - fn override_json_is_idempotent() { - let json = "{\"dockerComposeFile\": [\"docker-compose.yml\", \"docker-compose.override.yml\"]}"; - let out = add_override_to_devcontainer_json(json, "docker-compose.override.yml").unwrap(); - assert_eq!(out, json); - } - - #[test] - fn override_json_appends_to_array() { - let json = "{\"dockerComposeFile\": [\"docker-compose.yml\"]}"; - let out = add_override_to_devcontainer_json(json, "docker-compose.override.yml").unwrap(); - assert!(out.contains("\"docker-compose.yml\", \"docker-compose.override.yml\""), "{out}"); - } - - #[test] - fn override_json_errors_when_key_missing() { - assert!(add_override_to_devcontainer_json("{\"service\": \"app\"}", "o.yml").is_err()); - } - - #[test] - fn postcreate_hook_chains_string() { - let json = "{\n \"postCreateCommand\": \"bash x.sh\",\n \"service\": \"app\"\n}"; - let out = add_postcreate_hook_to_devcontainer_json(json, "bash .devcontainer/bento-postcreate.sh").unwrap(); - assert!(out.contains("\"bash x.sh && bash .devcontainer/bento-postcreate.sh\""), "{out}"); - assert!(out.contains("\"service\": \"app\""), "{out}"); - } - - #[test] - fn postcreate_hook_is_idempotent() { - let json = "{\"postCreateCommand\": \"bash x.sh && bash .devcontainer/bento-postcreate.sh\"}"; - assert_eq!( - add_postcreate_hook_to_devcontainer_json(json, "bash .devcontainer/bento-postcreate.sh").unwrap(), - json - ); - } - - #[test] - fn postcreate_hook_errors_when_missing() { - assert!(add_postcreate_hook_to_devcontainer_json("{\"x\": 1}", "h").is_err()); - } - - #[test] - fn published_port_pairs_reads_ports() { - let (isolated, _) = isolate_compose_yaml( - SAMPLE, - "wt", - Some(("10.189.20", "10.189.21")), - 1, - None, - ); - assert!(published_port_pairs(&isolated).contains(&(8108, 20100)), "{isolated}"); - } - - #[test] - fn referenced_bento_hosts_finds_refs() { - let ov = "services:\n keycloak:\n ports:\n - \"${BENTO_HOST_8080:-8080}:8080\"\n"; - assert_eq!(referenced_bento_hosts(ov), vec![8080]); - } - - #[test] - fn pairs_to_urls_builds_localhost_urls() { - let urls = pairs_to_urls(&[(8108, 20100), (3000, 20104)]); - assert_eq!(urls.len(), 2); - assert_eq!(urls[0].url, "http://localhost:20100"); - assert_eq!(urls[1].url, "http://localhost:20104"); - } -} diff --git a/src-tauri/src/docker/compose_yaml.rs b/src-tauri/src/docker/compose_yaml.rs new file mode 100644 index 0000000..a5c6f90 --- /dev/null +++ b/src-tauri/src/docker/compose_yaml.rs @@ -0,0 +1,350 @@ +use super::*; +use super::port_probe::ServiceUrl; + + +pub(super) struct ComposeService { + pub(super) name: String, + pub(super) ip: String, + pub(super) container_name: Option, +} + +// Parse a docker-compose.yml and return (network_name, subnet_prefix, services). +// subnet_prefix: "10.189.4" (without the .0/24 part). +pub(super) fn parse_compose_info(content: &str) -> Option<(String, String, Vec)> { + + #[derive(PartialEq)] + enum Section { + Other, + Services, + Networks, + } + + let mut section = Section::Other; + let mut current_service: Option = None; + let mut current_container_name: Option = None; + let mut services: Vec = vec![]; + let mut subnet_prefix: Option = None; + let mut network_name: Option = None; + + for line in content.lines() { + // Top-level section key: non-indented, non-empty, ends with ':' + if !line.starts_with(' ') + && !line.starts_with('\t') + && line.ends_with(':') + && !line.starts_with('#') + { + let key = line.trim_end_matches(':').trim(); + section = match key { + "services" => Section::Services, + "networks" => Section::Networks, + _ => Section::Other, + }; + current_service = None; + current_container_name = None; + continue; + } + + match section { + Section::Services => { + if line.starts_with(" ") && !line.starts_with(" ") { + let t = line.trim(); + if t.ends_with(':') { + current_service = Some(t.trim_end_matches(':').to_string()); + current_container_name = None; + } + } else if let Some(ref svc_name) = current_service.clone() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("ipv4_address:") { + services.push(ComposeService { + name: svc_name.clone(), + ip: rest.trim().to_string(), + container_name: current_container_name.clone(), + }); + } else if let Some(rest) = t.strip_prefix("container_name:") { + current_container_name = Some(rest.trim().to_string()); + } + } + } + Section::Networks => { + if line.starts_with(" ") && !line.starts_with(" ") { + let t = line.trim(); + if t.ends_with(':') && network_name.is_none() { + network_name = Some(t.trim_end_matches(':').to_string()); + } + } else { + // Subnet can appear as "subnet: x" or "- subnet: x" (YAML list item) + let t = line.trim(); + let subnet_val = t + .strip_prefix("subnet:") + .or_else(|| t.strip_prefix("- subnet:")); + if let Some(rest) = subnet_val { + if let Some(without_mask) = rest.trim().split('/').next() { + let parts: Vec<&str> = without_mask.split('.').collect(); + if parts.len() == 4 && subnet_prefix.is_none() { + subnet_prefix = + Some(format!("{}.{}.{}", parts[0], parts[1], parts[2])); + } + } + } + } + } + Section::Other => {} + } + } + + Some((network_name?, subnet_prefix?, services)) +} + +/// Rewrites a devcontainer's `docker-compose.yml` text so a worktree gets an +/// isolated stack. Generic — it only touches what a given compose declares: +/// - a unique top-level `name:` (compose project) — always; +/// - a fixed `container_name:` gets worktree-prefixed (container names are global); +/// - static IPs + subnet remapped only when `subnet` is `Some((old, new))`; +/// - published host ports remapped as `20000 + port_offset*100 + index`. +/// +/// Editing the base (vs a `docker-compose.override.yml`) is required because +/// compose-merge only *appends* `ports:`, so it can never move a project's fixed +/// host ports. Returns the new YAML and the remapped host URLs. +pub(super) fn isolate_compose_yaml( + content: &str, + project_name: &str, + subnet: Option<(&str, &str)>, + port_offset: u16, + git_mount: Option<&str>, +) -> (String, Vec) { + let old_ip_prefix = subnet.map(|(old, _)| format!("{}.", old)); + + let mut out = String::with_capacity(content.len() + 32); + let mut urls: Vec = vec![]; + let mut in_ports = false; + let mut port_index: u16 = 0; + let mut name_set = false; + let mut git_injected = false; + + for line in content.lines() { + // Top-level project name (column 0). Replace the first one we see. + if !name_set && line.starts_with("name:") { + out.push_str(&format!("name: {}\n", project_name)); + name_set = true; + continue; + } + + let trimmed = line.trim_start(); + let indent = &line[..line.len() - trimmed.len()]; + + if trimmed.trim_end() == "ports:" { + in_ports = true; + out.push_str(line); + out.push('\n'); + continue; + } + // A non-list line at any indent ends the current ports block. + if in_ports && !trimmed.starts_with('-') { + in_ports = false; + } + + // The workspace bind (`- ..:/workspace`) mounts the worktree, whose `.git` + // is a file pointing to the MAIN repo's gitdir. Mount that gitdir at the + // same absolute path so git works inside the container (else "not a git + // repository"). Same trick the plain-compose isolate uses. + if let Some(git) = git_mount { + if !git_injected && trimmed.starts_with("- ..:") { + out.push_str(line); + out.push('\n'); + out.push_str(&format!("{}- {}:{}\n", indent, git, git)); + git_injected = true; + continue; + } + } + + // Explicit container_name collides across projects (names are global) — + // prefix it with the worktree so it stays unique. + if let Some(rest) = trimmed.strip_prefix("container_name:") { + out.push_str(&format!( + "{}container_name: {}-{}\n", + indent, + project_name, + rest.trim() + )); + continue; + } + + // Static IP + subnet remap only when the compose declares a custom subnet. + if let (Some((_, new_prefix)), Some(old_ip)) = (subnet, old_ip_prefix.as_deref()) { + if let Some(rest) = trimmed.strip_prefix("ipv4_address:") { + if let Some(octet) = rest.trim().strip_prefix(old_ip) { + out.push_str(&format!("{}ipv4_address: {}.{}\n", indent, new_prefix, octet)); + continue; + } + } + let is_dashed = trimmed.starts_with("- subnet:"); + if let Some(rest) = trimmed + .strip_prefix("- subnet:") + .or_else(|| trimmed.strip_prefix("subnet:")) + { + let mask = rest.trim().split('/').nth(1).unwrap_or("24"); + let dash = if is_dashed { "- " } else { "" }; + out.push_str(&format!("{}{}subnet: {}.0/{}\n", indent, dash, new_prefix, mask)); + continue; + } + } + + // Published host port inside a ports: block. + if in_ports { + if let Some((_, container, quoted)) = parse_port_mapping(trimmed) { + let new_host = 20000 + port_offset * 100 + port_index; + port_index += 1; + let q = if quoted { "\"" } else { "" }; + out.push_str(&format!("{}- {}{}:{}{}\n", indent, q, new_host, container, q)); + urls.push(ServiceUrl { + service: format!("port {}", container), + url: format!("http://localhost:{}", new_host), + }); + continue; + } + } + + out.push_str(line); + out.push('\n'); + } + + if !name_set { + out.insert_str(0, &format!("name: {}\n", project_name)); + } + + (out, urls) +} + +/// Parses a compose `ports:` list item like `- "8108:8108"` into +/// `(host_port, container_port, was_quoted)`. Returns `None` for anything that is +/// not a plain `HOST:CONTAINER` numeric mapping (e.g. `host_ip:host:container`). +pub(super) fn parse_port_mapping(item: &str) -> Option<(u16, String, bool)> { + let rest = item.strip_prefix('-')?.trim(); + let quoted = rest.starts_with('"'); + let inner = rest.trim_matches('"'); + let mut parts = inner.split(':'); + let host = parts.next()?.trim(); + let container = parts.next()?.trim(); + // Reject host_ip:host:container and any other non `HOST:CONTAINER` shape. + if parts.next().is_some() { + return None; + } + let host_port: u16 = host.parse().ok()?; + container.parse::().ok()?; + Some((host_port, container.to_string(), quoted)) +} + +/// First `/24` subnet prefix declared in a compose (`10.189.20` from +/// `10.189.20.0/24`), or `None` when it relies on the default network. +pub(super) fn first_subnet_prefix(content: &str) -> Option { + content.lines().find_map(|line| { + let t = line.trim(); + let s = t + .strip_prefix("- subnet:") + .or_else(|| t.strip_prefix("subnet:"))?; + let without_mask = s.trim().split('/').next()?; + let parts: Vec<&str> = without_mask.split('.').collect(); + (parts.len() == 4).then(|| format!("{}.{}.{}", parts[0], parts[1], parts[2])) + }) +} + +pub(super) fn relative_path_string(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect::>() + .join("/") +} + +pub(super) fn valid_project_key(project_key: &str) -> bool { + let key = Path::new(project_key); + !project_key.is_empty() + && key.components().count() == 1 + && matches!(key.components().next(), Some(Component::Normal(_))) +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::test_support::*; + + #[test] + fn isolates_name_subnet_ips_and_ports() { + let (out, urls) = isolate_compose_yaml( + SAMPLE, + "konect-nixon-nixon-459", + Some(("10.189.20", "10.189.21")), + 1, + None, + ); + assert!(out.contains("name: konect-nixon-nixon-459"), "{out}"); + assert!(!out.contains("name: nixon_devcontainer"), "{out}"); + assert!(out.contains("ipv4_address: 10.189.21.10"), "{out}"); + assert!(out.contains("ipv4_address: 10.189.21.6"), "{out}"); + assert!(out.contains("- subnet: 10.189.21.0/24"), "{out}"); + assert!(out.contains("- \"20100:8108\""), "{out}"); + assert!(!out.contains("8108:8108"), "{out}"); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].url, "http://localhost:20100"); + } + + #[test] + fn adds_name_when_missing() { + let src = "services:\n app:\n image: x\n"; + let (out, _) = isolate_compose_yaml(src, "proj-1", None, 5, None); + assert!(out.starts_with("name: proj-1\n"), "{out}"); + } + + #[test] + fn isolates_without_custom_subnet() { + let (out, urls) = isolate_compose_yaml(SAMPLE_NO_SUBNET, "proj", None, 7, None); + assert!(out.starts_with("name: proj\n"), "{out}"); + // fixed container_name gets worktree-prefixed so it stays globally unique + assert!(out.contains("container_name: proj-web"), "{out}"); + // port remapped with offset 7 -> 20000 + 700 + 0 + assert!(out.contains("- \"20700:3000\""), "{out}"); + assert!(!out.contains("\"3000:3000\""), "{out}"); + assert_eq!(urls[0].url, "http://localhost:20700"); + } + + #[test] + fn mounts_main_git_dir_once() { + let (out, _) = isolate_compose_yaml( + SAMPLE, + "wt", + Some(("10.189.20", "10.189.21")), + 1, + Some("/repo/.git"), + ); + assert!(out.contains("- ..:/workspace:cached"), "{out}"); + assert!(out.contains("- /repo/.git:/repo/.git"), "{out}"); + assert_eq!(out.matches("/repo/.git:/repo/.git").count(), 1, "{out}"); + } + + #[test] + fn parses_simple_port_mapping() { + assert_eq!( + parse_port_mapping("- \"8108:8108\""), + Some((8108, "8108".into(), true)) + ); + assert_eq!( + parse_port_mapping("- 5540:5540"), + Some((5540, "5540".into(), false)) + ); + } + + #[test] + fn skips_non_numeric_or_triple_port() { + assert_eq!(parse_port_mapping("- \"127.0.0.1:8108:8108\""), None); + assert_eq!(parse_port_mapping("- ../.env:/x"), None); + } + + #[test] + fn first_subnet_prefix_optional() { + assert_eq!(first_subnet_prefix(SAMPLE).as_deref(), Some("10.189.20")); + assert_eq!(first_subnet_prefix(SAMPLE_NO_SUBNET), None); + } +} diff --git a/src-tauri/src/docker/devcontainer/commands.rs b/src-tauri/src/docker/devcontainer/commands.rs new file mode 100644 index 0000000..fac00b6 --- /dev/null +++ b/src-tauri/src/docker/devcontainer/commands.rs @@ -0,0 +1,298 @@ +use super::*; +use super::recipe::{ + create_recipe_dir, git_file_is_tracked, overlay_recipe_detailed, read_recipe_state, + recipe_files, recipe_preview, run_recipe_git, write_recipe_state, +}; +use super::json_patch::wire_recipe_into_devcontainer; +use super::env_ports::write_bento_env; + +#[tauri::command] +pub async fn devcontainer_recipe_preview( + worktree_path: String, + recipes_dir: Option, + project_key: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !Path::new(&worktree_path).is_dir() { + return Err("invalid worktree".into()); + } + if !valid_project_key(&project_key) { + return Err("invalid project key".into()); + } + Ok(recipe_preview(recipes_dir.as_deref(), &project_key, &worktree_path)) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn devcontainer_recipe_create( + recipes_dir: String, + project_key: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + create_recipe_dir(&recipes_dir, &project_key) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn devcontainer_recipe_git( + recipes_dir: String, + action: String, + message: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + run_recipe_git(&recipes_dir, &action, message.as_deref()) + }) + .await + .map_err(|error| error.to_string())? +} + +/// Prepares a devcontainer worktree so VS Code's "Reopen in Container" starts an +/// isolated stack, then mirrors the optional project recipe over the worktree. +/// The devcontainer can live at any depth; without a recipes directory this still +/// performs the generic compose isolation. +#[tauri::command] +pub async fn devcontainer_isolate( + worktree_path: String, + recipes_dir: Option, + project_key: String, + devcontainer_dir: Option, + allow_tracked: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let candidates = find_devcontainer_dirs(&worktree_path); + if candidates.is_empty() { + return Err("no-devcontainer".into()); + } + let devcontainer_dir = match devcontainer_dir { + Some(selected) if candidates.contains(&selected) => selected, + Some(_) => return Err("invalid-devcontainer".into()), + None if candidates.len() == 1 => candidates[0].clone(), + None => return Err("multiple-devcontainers".into()), + }; + let compose_relative = format!("{devcontainer_dir}/docker-compose.yml"); + let compose_path = Path::new(&worktree_path).join(&compose_relative); + if !compose_path.is_file() { + return Err("no-devcontainer".into()); + } + + let worktree_dir = std::path::Path::new(&worktree_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("worktree") + .to_string(); + + let content = std::fs::read_to_string(&compose_path).map_err(|e| e.to_string())?; + + // Idempotent: if this worktree was already isolated (name == worktree dir), + // don't shift subnet/ports again — just report the current state. + let target_name = format!("name: {}", worktree_dir); + let already = content + .lines() + .any(|l| l.starts_with("name:") && l.trim_end() == target_name); + let result_subnet = if already { + first_subnet_prefix(&content) + .map(|p| format!("{}.0/24", p)) + .unwrap_or_default() + } else { + // Remap the custom subnet when present; otherwise Docker auto-assigns a + // non-overlapping default network, so only name + ports need isolating. + let (subnet_remap, port_offset, subnet) = match first_subnet_prefix(&content) { + Some(old_prefix) => { + let new_prefix = find_free_subnet_prefix(&old_prefix, &worktree_path) + .ok_or("no free subnet available in range")?; + let base_third: u16 = old_prefix + .rsplit('.') + .next() + .unwrap_or("0") + .parse() + .unwrap_or(0); + let new_third: u16 = new_prefix + .rsplit('.') + .next() + .unwrap_or("0") + .parse() + .unwrap_or(0); + let offset = new_third.saturating_sub(base_third).max(1); + let subnet = format!("{}.0/24", new_prefix); + (Some((old_prefix, new_prefix)), offset, subnet) + } + None => (None, stable_port_offset(&worktree_dir), String::new()), + }; + + // Mount the main repo's gitdir into the container. A worktree's `.git` + // file points outside its own directory, which would otherwise be absent. + let git_mount = std::fs::read_to_string(Path::new(&worktree_path).join(".git")) + .ok() + .and_then(|c| { + c.lines() + .find_map(|l| l.strip_prefix("gitdir:").map(|s| s.trim().to_string())) + }) + .and_then(|gitdir| { + Path::new(&gitdir) + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.to_str()) + .map(String::from) + }); + + let remap_ref = subnet_remap.as_ref().map(|(o, n)| (o.as_str(), n.as_str())); + let (new_content, _) = isolate_compose_yaml( + &content, + &worktree_dir, + remap_ref, + port_offset, + git_mount.as_deref(), + ); + std::fs::write(&compose_path, new_content).map_err(|e| e.to_string())?; + skip_worktree(&worktree_path, &compose_relative); + subnet + }; + + // Recipes intentionally run after isolation: they are a generic filesystem + // overlay, not a second source of project files. + let mut recipe = recipes_dir + .as_deref() + .filter(|directory| !directory.trim().is_empty()) + .map(|directory| overlay_recipe_detailed( + directory, &project_key, &worktree_path, allow_tracked.unwrap_or(false) + )); + let applied = recipe.as_ref().map(|result| result.applied.clone()).unwrap_or_default(); + for relative in &applied { + if !git_file_is_tracked(&worktree_path, relative) { + ensure_global_gitignore(&format!("/{relative}")); + } + } + let recipe_files_present = recipes_dir.as_deref().map(|directory| { + recipe_files(directory, &project_key).unwrap_or_default().into_iter() + .filter(|(source, relative)| { + std::fs::read(source).ok() + == std::fs::read(Path::new(&worktree_path).join(relative)).ok() + }) + .map(|(_, relative)| relative) + .collect::>() + }).unwrap_or_default(); + let wiring_errors = wire_recipe_into_devcontainer( + &worktree_path, + &devcontainer_dir, + &recipe_files_present, + ); + skip_worktree( + &worktree_path, + &format!("{devcontainer_dir}/devcontainer.json"), + ); + let final_compose = std::fs::read_to_string(&compose_path).map_err(|e| e.to_string())?; + let pairs = write_bento_env(&worktree_path, &devcontainer_dir, &final_compose); + if let Some(result) = recipe.as_mut() { + result.devcontainer_dir = devcontainer_dir.clone(); + result.errors.extend(wiring_errors); + write_recipe_state(&worktree_path, &devcontainer_dir, result); + } + + Ok(IsolateResult { + subnet: result_subnet, + urls: pairs_to_urls(&pairs), + recipe, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn devcontainer_recipe_status( + worktree_path: String, + devcontainer_dir: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let candidates = find_devcontainer_dirs(&worktree_path); + let selected = match devcontainer_dir { + Some(path) if candidates.contains(&path) => path, + Some(_) => return Err("invalid-devcontainer".into()), + None if candidates.len() == 1 => candidates[0].clone(), + None if candidates.is_empty() => return Err("no-devcontainer".into()), + None => return Err("multiple-devcontainers".into()), + }; + Ok(read_recipe_state(&worktree_path, &selected)) + }) + .await + .map_err(|error| error.to_string())? +} + +/// Reads the `.devcontainer/.env` host-port map (written by `devcontainer_isolate`) +/// and returns browsable localhost URLs. Cheap + read-only — used to re-display a +/// prepared task's URLs without re-isolating. Returns "no-devcontainer" if absent. +#[tauri::command] +pub async fn devcontainer_urls( + worktree_path: String, + devcontainer_dir: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let candidates = find_devcontainer_dirs(&worktree_path); + let devcontainer_dir = match devcontainer_dir { + Some(path) if candidates.contains(&path) => path, + Some(_) => return Err("invalid-devcontainer".into()), + None => candidates.into_iter().next().ok_or_else(|| "no-devcontainer".to_string())?, + }; + let env_path = Path::new(&worktree_path).join(devcontainer_dir).join(".env"); + let content = std::fs::read_to_string(&env_path).map_err(|_| "no-devcontainer".to_string())?; + let pairs: Vec<(u16, u16)> = content + .lines() + .filter_map(|l| { + let (n, h) = l.strip_prefix("BENTO_HOST_")?.split_once('=')?; + Some((n.parse().ok()?, h.parse().ok()?)) + }) + .collect(); + if pairs.is_empty() { + return Err("no-devcontainer".into()); + } + Ok(pairs_to_urls(&pairs)) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::test_support::*; + + #[test] + fn recipe_pipeline_is_idempotent_for_nested_devcontainer() { + let root = temporary_directory("pipeline"); + let worktree = root.join("worktree"); + let recipes = root.join("recipes"); + let devcontainer = worktree.join("apps/api/.devcontainer"); + init_test_git_repo(&worktree); + std::fs::create_dir_all(&devcontainer).unwrap(); + std::fs::write(devcontainer.join("devcontainer.json"), r#"{ + "dockerComposeFile": "docker-compose.yml", + "postCreateCommand": "bash setup.sh" +}"#).unwrap(); + let (isolated, _) = isolate_compose_yaml(SAMPLE_NO_SUBNET, "task-1", None, 2, None); + std::fs::write(devcontainer.join("docker-compose.yml"), &isolated).unwrap(); + let recipe_devcontainer = recipes.join("project/apps/api/.devcontainer"); + std::fs::create_dir_all(&recipe_devcontainer).unwrap(); + std::fs::write(recipe_devcontainer.join("docker-compose.override.yml"), "services:\n web:\n environment:\n LOCAL: 1\n").unwrap(); + std::fs::write(recipe_devcontainer.join("bento-postcreate.sh"), "#!/bin/sh\ntrue\n").unwrap(); + + let mut first = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), false); + first.devcontainer_dir = "apps/api/.devcontainer".into(); + assert!(wire_recipe_into_devcontainer(worktree.to_str().unwrap(), &first.devcontainer_dir, &first.applied).is_empty()); + write_bento_env(worktree.to_str().unwrap(), &first.devcontainer_dir, &isolated); + write_recipe_state(worktree.to_str().unwrap(), &first.devcontainer_dir, &first); + + let json = std::fs::read_to_string(devcontainer.join("devcontainer.json")).unwrap(); + assert!(json.contains("docker-compose.override.yml"), "{json}"); + assert!(json.contains("bash apps/api/.devcontainer/bento-postcreate.sh"), "{json}"); + assert_eq!(read_recipe_state(worktree.to_str().unwrap(), &first.devcontainer_dir).unwrap().project_key, "project"); + + let second = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), false); + assert!(second.applied.is_empty()); + assert_eq!(second.skipped.len(), 2); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/docker/devcontainer/env_ports.rs b/src-tauri/src/docker/devcontainer/env_ports.rs new file mode 100644 index 0000000..11adfff --- /dev/null +++ b/src-tauri/src/docker/devcontainer/env_ports.rs @@ -0,0 +1,122 @@ +use super::*; + +/// Extracts published `(containerPort, hostPort)` pairs from a compose's `ports:`. +fn published_port_pairs(content: &str) -> Vec<(u16, u16)> { + let mut out = vec![]; + let mut in_ports = false; + for line in content.lines() { + let trimmed = line.trim_start(); + if trimmed.trim_end() == "ports:" { + in_ports = true; + continue; + } + if in_ports && !trimmed.starts_with('-') { + in_ports = false; + } + if in_ports { + if let Some((host, container, _)) = parse_port_mapping(trimmed) { + if let Ok(c) = container.parse::() { + out.push((c, host)); + } + } + } + } + out +} + +/// Finds `${BENTO_HOST_}` container ports referenced in a file (e.g. an override +/// that wires a service by port). bento allocates a host port for each. +fn referenced_bento_hosts(content: &str) -> Vec { + let mut out = vec![]; + for part in content.split("BENTO_HOST_").skip(1) { + let digits: String = part.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() { + if !out.contains(&n) { + out.push(n); + } + } + } + out +} + +/// Writes the isolated host-port map to `.devcontainer/.env` (auto-loaded by Compose) +/// so the compose/override can build per-worktree URLs via `${BENTO_HOST_*}`. Records +/// the base compose's remapped ports and allocates a fresh host port for any +/// `${BENTO_HOST_}` the override references but the base doesn't publish (e.g. +/// keycloak). Reuses prior allocations (idempotent) and preserves non-BENTO lines. +pub(super) fn write_bento_env( + worktree_path: &str, + devcontainer_dir: &str, + compose: &str, +) -> Vec<(u16, u16)> { + let env_path = Path::new(worktree_path).join(devcontainer_dir).join(".env"); + let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); + let kept: Vec = existing + .lines() + .filter(|l| !l.starts_with("BENTO_HOST_") && !l.trim().is_empty()) + .map(str::to_string) + .collect(); + let prior: Vec<(u16, u16)> = existing + .lines() + .filter_map(|l| { + let (n, h) = l.strip_prefix("BENTO_HOST_")?.split_once('=')?; + Some((n.parse().ok()?, h.parse().ok()?)) + }) + .collect(); + + let mut pairs = published_port_pairs(compose); + let override_content = std::fs::read_to_string( + Path::new(worktree_path) + .join(devcontainer_dir) + .join("docker-compose.override.yml"), + ) + .unwrap_or_default(); + let mut next = pairs.iter().map(|(_, h)| *h).max().unwrap_or(20000) + 1; + for n in referenced_bento_hosts(&override_content) { + if pairs.iter().any(|(c, _)| *c == n) { + continue; + } + if let Some((_, h)) = prior.iter().find(|(c, _)| *c == n) { + pairs.push((n, *h)); + } else { + while pairs.iter().any(|(_, h)| *h == next) { + next += 1; + } + pairs.push((n, next)); + next += 1; + } + } + + let mut lines = kept; + for (c, h) in &pairs { + lines.push(format!("BENTO_HOST_{}={}", c, h)); + } + if !lines.is_empty() { + let _ = std::fs::write(&env_path, lines.join("\n") + "\n"); + } + pairs +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::test_support::*; + + #[test] + fn published_port_pairs_reads_ports() { + let (isolated, _) = isolate_compose_yaml( + SAMPLE, + "wt", + Some(("10.189.20", "10.189.21")), + 1, + None, + ); + assert!(published_port_pairs(&isolated).contains(&(8108, 20100)), "{isolated}"); + } + + #[test] + fn referenced_bento_hosts_finds_refs() { + let ov = "services:\n keycloak:\n ports:\n - \"${BENTO_HOST_8080:-8080}:8080\"\n"; + assert_eq!(referenced_bento_hosts(ov), vec![8080]); + } +} diff --git a/src-tauri/src/docker/devcontainer/json_patch.rs b/src-tauri/src/docker/devcontainer/json_patch.rs new file mode 100644 index 0000000..c4ba79c --- /dev/null +++ b/src-tauri/src/docker/devcontainer/json_patch.rs @@ -0,0 +1,159 @@ +use super::*; + +/// Appends `&& ` to a devcontainer.json `postCreateCommand` string, so bento's +/// setup runs after the project's own postCreate. Idempotent. Returns `Err` if the +/// key is missing or isn't a string — never corrupts the file. +fn add_postcreate_hook_to_devcontainer_json(json: &str, hook: &str) -> Result { + if json.contains(hook) { + return Ok(json.to_string()); // already chained — idempotent + } + let key = "\"postCreateCommand\""; + let key_pos = json.find(key).ok_or("postCreateCommand not found")?; + let colon_rel = json[key_pos + key.len()..] + .find(':') + .ok_or("malformed postCreateCommand")?; + let after_colon = key_pos + key.len() + colon_rel + 1; + let trimmed = json[after_colon..].trim_start(); + let value_start = json.len() - json[after_colon..].len() + (json[after_colon..].len() - trimmed.len()); + let rest = trimmed + .strip_prefix('"') + .ok_or("postCreateCommand is not a string")?; + let end_rel = rest.find('"').ok_or("unterminated string")?; + let existing = &rest[..end_rel]; + let value_end = value_start + 1 + end_rel + 1; + let replacement = format!("\"{} && {}\"", existing, hook); + Ok(format!("{}{}{}", &json[..value_start], replacement, &json[value_end..])) +} + +/// Adds `override_file` to a devcontainer.json `dockerComposeFile` value, turning a +/// string into an array (or appending to an existing array). Idempotent. Returns +/// `Err` if the key is missing or the value is neither a string nor an array — never +/// corrupts the file. Handles plain JSON (devcontainer.json is JSONC, but the common +/// case has no comments around this key). +fn add_override_to_devcontainer_json(json: &str, override_file: &str) -> Result { + if json.contains(override_file) { + return Ok(json.to_string()); // already referenced — idempotent + } + let key = "\"dockerComposeFile\""; + let key_pos = json.find(key).ok_or("dockerComposeFile not found")?; + let colon_rel = json[key_pos + key.len()..] + .find(':') + .ok_or("malformed dockerComposeFile")?; + let after_colon = key_pos + key.len() + colon_rel + 1; + let trimmed = json[after_colon..].trim_start(); + let value_start = json.len() - json[after_colon..].len() + (json[after_colon..].len() - trimmed.len()); + + if let Some(rest) = trimmed.strip_prefix('"') { + let end_rel = rest.find('"').ok_or("unterminated string")?; + let base = &rest[..end_rel]; + let value_end = value_start + 1 + end_rel + 1; // both quotes + let replacement = format!("[\"{}\", \"{}\"]", base, override_file); + Ok(format!("{}{}{}", &json[..value_start], replacement, &json[value_end..])) + } else if trimmed.starts_with('[') { + let end_rel = trimmed.find(']').ok_or("unterminated array")?; + let close = value_start + end_rel; // position of ']' + let inner = json[value_start + 1..close].trim(); + let insert = if inner.is_empty() { + format!("\"{}\"", override_file) + } else { + format!("{}, \"{}\"", inner, override_file) + }; + Ok(format!("{}[{}]{}", &json[..value_start], insert, &json[close + 1..])) + } else { + Err("dockerComposeFile is neither a string nor an array".into()) + } +} + +/// Wires recipe files belonging to the discovered devcontainer into its JSON. +pub(super) fn wire_recipe_into_devcontainer( + worktree_path: &str, + devcontainer_dir: &str, + applied: &[String], +) -> Vec { + let mut errors = Vec::new(); + let json_relative = format!("{devcontainer_dir}/devcontainer.json"); + let json_path = Path::new(worktree_path).join(&json_relative); + let Ok(original) = std::fs::read_to_string(&json_path) else { + return vec![format!("cannot read {json_relative}")]; + }; + let mut json = original.clone(); + let override_path = format!("{devcontainer_dir}/docker-compose.override.yml"); + if applied.iter().any(|path| path == &override_path) { + match add_override_to_devcontainer_json(&json, "docker-compose.override.yml") { + Ok(updated) => json = updated, + Err(error) => errors.push(format!("{json_relative}: {error}")), + } + } + let postcreate_path = format!("{devcontainer_dir}/bento-postcreate.sh"); + if applied.iter().any(|path| path == &postcreate_path) { + let hook = format!("bash {postcreate_path}"); + match add_postcreate_hook_to_devcontainer_json(&json, &hook) { + Ok(updated) => json = updated, + Err(error) => errors.push(format!("{json_relative}: {error}")), + } + } + if json != original { + match std::fs::write(&json_path, json) { + Ok(_) => skip_worktree(worktree_path, &json_relative), + Err(error) => errors.push(format!("{json_relative}: {error}")), + } + } + errors +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn override_json_string_to_array() { + let json = "{\n \"name\": \"x\",\n \"dockerComposeFile\": \"docker-compose.yml\",\n \"service\": \"app\"\n}"; + let out = add_override_to_devcontainer_json(json, "docker-compose.override.yml").unwrap(); + assert!( + out.contains("[\"docker-compose.yml\", \"docker-compose.override.yml\"]"), + "{out}" + ); + assert!(out.contains("\"service\": \"app\""), "{out}"); + } + + #[test] + fn override_json_is_idempotent() { + let json = "{\"dockerComposeFile\": [\"docker-compose.yml\", \"docker-compose.override.yml\"]}"; + let out = add_override_to_devcontainer_json(json, "docker-compose.override.yml").unwrap(); + assert_eq!(out, json); + } + + #[test] + fn override_json_appends_to_array() { + let json = "{\"dockerComposeFile\": [\"docker-compose.yml\"]}"; + let out = add_override_to_devcontainer_json(json, "docker-compose.override.yml").unwrap(); + assert!(out.contains("\"docker-compose.yml\", \"docker-compose.override.yml\""), "{out}"); + } + + #[test] + fn override_json_errors_when_key_missing() { + assert!(add_override_to_devcontainer_json("{\"service\": \"app\"}", "o.yml").is_err()); + } + + #[test] + fn postcreate_hook_chains_string() { + let json = "{\n \"postCreateCommand\": \"bash x.sh\",\n \"service\": \"app\"\n}"; + let out = add_postcreate_hook_to_devcontainer_json(json, "bash .devcontainer/bento-postcreate.sh").unwrap(); + assert!(out.contains("\"bash x.sh && bash .devcontainer/bento-postcreate.sh\""), "{out}"); + assert!(out.contains("\"service\": \"app\""), "{out}"); + } + + #[test] + fn postcreate_hook_is_idempotent() { + let json = "{\"postCreateCommand\": \"bash x.sh && bash .devcontainer/bento-postcreate.sh\"}"; + assert_eq!( + add_postcreate_hook_to_devcontainer_json(json, "bash .devcontainer/bento-postcreate.sh").unwrap(), + json + ); + } + + #[test] + fn postcreate_hook_errors_when_missing() { + assert!(add_postcreate_hook_to_devcontainer_json("{\"x\": 1}", "h").is_err()); + } +} diff --git a/src-tauri/src/docker/devcontainer/mod.rs b/src-tauri/src/docker/devcontainer/mod.rs new file mode 100644 index 0000000..7c1097a --- /dev/null +++ b/src-tauri/src/docker/devcontainer/mod.rs @@ -0,0 +1,146 @@ +use super::*; +use super::compose_yaml::*; +use super::port_probe::*; +use super::subnet::*; +use super::isolate::IsolateResult; + +pub(crate) mod recipe; +pub(crate) mod json_patch; +pub(crate) mod env_ports; +pub(crate) mod commands; + +pub use commands::*; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipeFilePreview { + pub path: String, + pub action: String, + pub tracked: bool, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipePreview { + pub project_key: String, + pub recipe_dir: Option, + pub recipe_exists: bool, + pub devcontainer_dirs: Vec, + pub files: Vec, + pub warnings: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipeApplyResult { + pub project_key: String, + pub recipe_dir: String, + pub devcontainer_dir: String, + pub applied: Vec, + pub skipped: Vec, + pub errors: Vec, + pub applied_at: u64, +} + +/// Finds every `.devcontainer` containing a `devcontainer.json`, ordered by depth +/// and then lexically. Paths are relative to the worktree. +fn find_devcontainer_dirs(worktree: &str) -> Vec { + let root = Path::new(worktree); + let mut pending = vec![root.to_path_buf()]; + let mut found = Vec::::new(); + while let Some(directory) = pending.pop() { + let Ok(read_dir) = std::fs::read_dir(&directory) else { + continue; + }; + let mut entries: Vec<_> = read_dir.filter_map(Result::ok).collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries.into_iter().rev() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if entry.file_name() != ".git" { + pending.push(entry.path()); + } + } else if file_type.is_file() + && entry.file_name() == "devcontainer.json" + && entry.path().parent().and_then(Path::file_name).and_then(|name| name.to_str()) == Some(".devcontainer") + { + if let Some(relative) = entry.path().parent().and_then(|parent| parent.strip_prefix(root).ok()) { + found.push(relative.to_path_buf()); + } + } + } + } + found.sort_by(|left, right| { + left.components() + .count() + .cmp(&right.components().count()) + .then_with(|| left.cmp(right)) + }); + found.iter().map(|path| relative_path_string(path)).collect() +} + +#[cfg_attr(not(test), allow(dead_code))] +fn find_devcontainer_dir(worktree: &str) -> Option { + find_devcontainer_dirs(worktree).into_iter().next() +} + +/// Marks a file as `--skip-worktree` in the worktree's git index so local edits +/// (our compose rewrite) never show up in status or land in the branch. +fn skip_worktree(worktree_path: &str, file: &str) { + let git = login_shell_output("command -v git") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "git".into()); + let _ = Command::new(&git) + .args(["update-index", "--skip-worktree", file]) + .current_dir(worktree_path) + .output(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::test_support::*; + + #[test] + fn finds_root_devcontainer_directory() { + let worktree = temporary_directory("find-root"); + let directory = worktree.join(".devcontainer"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("devcontainer.json"), "{}").unwrap(); + assert_eq!( + find_devcontainer_dir(worktree.to_str().unwrap()).as_deref(), + Some(".devcontainer") + ); + let _ = std::fs::remove_dir_all(worktree); + } + + #[test] + fn finds_nested_devcontainer_directory() { + let worktree = temporary_directory("find-nested"); + let directory = worktree.join("apps/foo/.devcontainer"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("devcontainer.json"), "{}").unwrap(); + assert_eq!( + find_devcontainer_dir(worktree.to_str().unwrap()).as_deref(), + Some("apps/foo/.devcontainer") + ); + let _ = std::fs::remove_dir_all(worktree); + } + + #[test] + fn finds_all_devcontainers_in_stable_order() { + let worktree = temporary_directory("find-multiple"); + for relative in ["apps/web/.devcontainer", ".devcontainer", "apps/api/.devcontainer"] { + let directory = worktree.join(relative); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("devcontainer.json"), "{}").unwrap(); + } + assert_eq!(find_devcontainer_dirs(worktree.to_str().unwrap()), vec![ + ".devcontainer", "apps/api/.devcontainer", "apps/web/.devcontainer", + ]); + let _ = std::fs::remove_dir_all(worktree); + } +} diff --git a/src-tauri/src/docker/devcontainer/recipe.rs b/src-tauri/src/docker/devcontainer/recipe.rs new file mode 100644 index 0000000..8c23cc6 --- /dev/null +++ b/src-tauri/src/docker/devcontainer/recipe.rs @@ -0,0 +1,331 @@ +use super::*; + +pub(super) fn recipe_files(recipes_dir: &str, project_key: &str) -> Result, String> { + if !valid_project_key(project_key) { + return Err("invalid project key".into()); + } + let recipe_root = Path::new(recipes_dir).join(project_key); + if !recipe_root.is_dir() { + return Ok(vec![]); + } + let mut pending = vec![recipe_root.clone()]; + let mut files = Vec::new(); + while let Some(directory) = pending.pop() { + let read_dir = std::fs::read_dir(&directory) + .map_err(|error| format!("{}: {error}", directory.display()))?; + let mut entries: Vec<_> = read_dir + .collect::, _>>() + .map_err(|error| error.to_string())?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries.into_iter().rev() { + let file_type = entry.file_type().map_err(|error| error.to_string())?; + if file_type.is_symlink() { + return Err(format!("recipe symlinks are not supported: {}", entry.path().display())); + } + if file_type.is_dir() { + pending.push(entry.path()); + } else if file_type.is_file() { + let relative = entry + .path() + .strip_prefix(&recipe_root) + .map(relative_path_string) + .map_err(|error| error.to_string())?; + files.push((entry.path(), relative)); + } + } + } + files.sort_by(|left, right| left.1.cmp(&right.1)); + Ok(files) +} + +pub(super) fn git_file_is_tracked(worktree: &str, relative: &str) -> bool { + Command::new("git") + .args(["ls-files", "--error-unmatch", "--", relative]) + .current_dir(worktree) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub(super) fn recipe_preview(recipes_dir: Option<&str>, project_key: &str, worktree: &str) -> RecipePreview { + let devcontainer_dirs = find_devcontainer_dirs(worktree); + let mut warnings = Vec::new(); + if devcontainer_dirs.len() > 1 { + warnings.push("multiple-devcontainers".into()); + } + let Some(recipes_dir) = recipes_dir.filter(|path| !path.trim().is_empty()) else { + return RecipePreview { + project_key: project_key.into(), recipe_dir: None, recipe_exists: false, + devcontainer_dirs, files: vec![], warnings, + }; + }; + let recipe_dir = Path::new(recipes_dir).join(project_key); + let recipe_exists = recipe_dir.is_dir(); + let mut files = Vec::new(); + match recipe_files(recipes_dir, project_key) { + Ok(recipe_files) => for (source, relative) in recipe_files { + let destination = Path::new(worktree).join(&relative); + let tracked = git_file_is_tracked(worktree, &relative); + let action = if !destination.exists() { + "create" + } else if std::fs::read(&source).ok() == std::fs::read(&destination).ok() { + "unchanged" + } else if tracked { + "overwrite-tracked" + } else { + "overwrite" + }; + files.push(RecipeFilePreview { path: relative, action: action.into(), tracked }); + + if files.last().map(|file| file.path.ends_with("docker-compose.override.yml")).unwrap_or(false) { + let valid = std::fs::read_to_string(&source) + .map(|content| content.lines().any(|line| line.trim_end() == "services:")) + .unwrap_or(false); + if !valid { + warnings.push(format!("invalid-compose-override:{}", files.last().unwrap().path)); + } + } + }, + Err(error) => warnings.push(error), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for (source, relative) in recipe_files(recipes_dir, project_key).unwrap_or_default() { + if relative.ends_with("bento-postcreate.sh") + && source.metadata().map(|m| m.permissions().mode() & 0o111 == 0).unwrap_or(false) + { + warnings.push(format!("postcreate-not-executable:{relative}")); + } + } + } + RecipePreview { + project_key: project_key.into(), + recipe_dir: Some(recipe_dir.to_string_lossy().into_owned()), + recipe_exists, + devcontainer_dirs, + files, + warnings, + } +} + +/// Mirrors every regular file in `/` into the worktree. +/// Paths are returned relative to the worktree, using `/` on every platform. +#[cfg_attr(not(test), allow(dead_code))] +fn overlay_recipe(recipes_dir: &str, project_key: &str, worktree: &str) -> Vec { + let mut applied = Vec::new(); + for (source, relative) in recipe_files(recipes_dir, project_key).unwrap_or_default() { + let destination = Path::new(worktree).join(&relative); + let copied = destination + .parent() + .and_then(|parent| std::fs::create_dir_all(parent).ok()) + .and_then(|_| std::fs::copy(&source, &destination).ok()); + if copied.is_some() { + applied.push(relative); + } + } + applied +} + +pub(super) fn overlay_recipe_detailed( + recipes_dir: &str, + project_key: &str, + worktree: &str, + allow_tracked: bool, +) -> RecipeApplyResult { + let recipe_dir = Path::new(recipes_dir).join(project_key); + let mut result = RecipeApplyResult { + project_key: project_key.into(), + recipe_dir: recipe_dir.to_string_lossy().into_owned(), + devcontainer_dir: String::new(), + applied: vec![], skipped: vec![], errors: vec![], + applied_at: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(), + }; + let files = match recipe_files(recipes_dir, project_key) { + Ok(files) => files, + Err(error) => { result.errors.push(error); return result; } + }; + for (source, relative) in files { + let destination = Path::new(worktree).join(&relative); + let tracked = git_file_is_tracked(worktree, &relative); + if tracked && destination.exists() && !allow_tracked + && std::fs::read(&source).ok() != std::fs::read(&destination).ok() + { + result.skipped.push(relative); + continue; + } + if destination.exists() && std::fs::read(&source).ok() == std::fs::read(&destination).ok() { + result.skipped.push(relative); + continue; + } + let copy_result = destination + .parent() + .ok_or_else(|| "invalid destination".to_string()) + .and_then(|parent| std::fs::create_dir_all(parent).map_err(|e| e.to_string())) + .and_then(|_| std::fs::copy(&source, &destination).map_err(|e| e.to_string())); + match copy_result { + Ok(_) => { + if tracked { skip_worktree(worktree, &relative); } + result.applied.push(relative); + } + Err(error) => result.errors.push(format!("{relative}: {error}")), + } + } + result +} + +pub(super) fn write_recipe_state(worktree_path: &str, devcontainer_dir: &str, result: &RecipeApplyResult) { + let env_path = Path::new(worktree_path).join(devcontainer_dir).join(".env"); + let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); + let mut lines: Vec<&str> = existing + .lines() + .filter(|line| !line.starts_with("BENTO_RECIPE_STATE_HEX=")) + .collect(); + let Ok(json) = serde_json::to_string(result) else { return }; + let state = format!("BENTO_RECIPE_STATE_HEX={}", hex::encode(json)); + lines.push(&state); + let _ = std::fs::write(env_path, lines.join("\n") + "\n"); +} + +pub(super) fn read_recipe_state(worktree_path: &str, devcontainer_dir: &str) -> Option { + let env_path = Path::new(worktree_path).join(devcontainer_dir).join(".env"); + let content = std::fs::read_to_string(env_path).ok()?; + let encoded = content.lines().find_map(|line| line.strip_prefix("BENTO_RECIPE_STATE_HEX="))?; + let raw = hex::decode(encoded).ok()?; + serde_json::from_slice(&raw).ok() +} + +pub(super) fn create_recipe_dir(recipes_dir: &str, project_key: &str) -> Result { + if recipes_dir.trim().is_empty() || !valid_project_key(project_key) { + return Err("invalid recipe path".into()); + } + let project_dir = Path::new(recipes_dir).join(project_key); + let devcontainer_dir = project_dir.join(".devcontainer"); + std::fs::create_dir_all(&devcontainer_dir) + .map_err(|error| error.to_string())?; + Ok(project_dir.to_string_lossy().into_owned()) +} + +pub(super) fn run_recipe_git(recipes_dir: &str, action: &str, message: Option<&str>) -> Result { + let root = Path::new(recipes_dir); + if !root.is_dir() { + return Err("recipes directory does not exist".into()); + } + let run = |args: &[&str]| -> Result { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + match action { + "init" => run(&["init"]), + "status" => run(&["status", "--short", "--branch"]), + "pull" => run(&["pull", "--ff-only"]), + "push" => run(&["push"]), + "commit" => { + let message = message.map(str::trim).filter(|value| !value.is_empty()) + .ok_or_else(|| "commit message is required".to_string())?; + run(&["add", "-A"])?; + run(&["commit", "-m", message]) + } + _ => Err("unsupported recipe git action".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::test_support::*; + + #[test] + fn recipe_overlay_mirrors_files_and_creates_parent_directories() { + let root = temporary_directory("overlay"); + let recipes = root.join("recipes"); + let worktree = root.join("worktree"); + let project = recipes.join("konect-nixon"); + std::fs::create_dir_all(project.join("apps/foo/.devcontainer")).unwrap(); + std::fs::write(project.join(".env"), "APP_ENV=local\n").unwrap(); + std::fs::write( + project.join("apps/foo/.devcontainer/x"), + "nested recipe\n", + ) + .unwrap(); + + let applied = overlay_recipe( + recipes.to_str().unwrap(), + "konect-nixon", + worktree.to_str().unwrap(), + ); + + assert_eq!(applied, vec![".env", "apps/foo/.devcontainer/x"]); + assert_eq!( + std::fs::read_to_string(worktree.join(".env")).unwrap(), + "APP_ENV=local\n" + ); + assert_eq!( + std::fs::read_to_string(worktree.join("apps/foo/.devcontainer/x")).unwrap(), + "nested recipe\n" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn preview_marks_tracked_overwrites_and_apply_requires_permission() { + let root = temporary_directory("tracked-preview"); + let worktree = root.join("worktree"); + let recipes = root.join("recipes"); + init_test_git_repo(&worktree); + std::fs::write(worktree.join("config.local"), "project\n").unwrap(); + assert!(Command::new("git").args(["add", "config.local"]).current_dir(&worktree).status().unwrap().success()); + assert!(Command::new("git").args(["commit", "-qm", "base"]).current_dir(&worktree).status().unwrap().success()); + std::fs::create_dir_all(recipes.join("project")).unwrap(); + std::fs::write(recipes.join("project/config.local"), "recipe\n").unwrap(); + + let preview = recipe_preview(Some(recipes.to_str().unwrap()), "project", worktree.to_str().unwrap()); + assert_eq!(preview.files[0].action, "overwrite-tracked"); + assert!(preview.files[0].tracked); + + let denied = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), false); + assert!(denied.applied.is_empty()); + assert_eq!(denied.skipped, vec!["config.local"]); + assert_eq!(std::fs::read_to_string(worktree.join("config.local")).unwrap(), "project\n"); + + let allowed = overlay_recipe_detailed(recipes.to_str().unwrap(), "project", worktree.to_str().unwrap(), true); + assert_eq!(allowed.applied, vec!["config.local"]); + assert_eq!(std::fs::read_to_string(worktree.join("config.local")).unwrap(), "recipe\n"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn creates_recipe_scaffold_and_initializes_version_control() { + let root = temporary_directory("recipe-create"); + let recipes = root.join("recipes"); + let created = create_recipe_dir(recipes.to_str().unwrap(), "company--api").unwrap(); + assert!(Path::new(&created).join(".devcontainer").is_dir()); + assert!(create_recipe_dir(recipes.to_str().unwrap(), "../escape").is_err()); + run_recipe_git(recipes.to_str().unwrap(), "init", None).unwrap(); + assert!(recipes.join(".git").is_dir()); + let status = run_recipe_git(recipes.to_str().unwrap(), "status", None).unwrap(); + assert!(status.starts_with("##")); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn recipe_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + let root = temporary_directory("recipe-symlink"); + let project = root.join("recipes/project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(root.join("secret"), "outside\n").unwrap(); + symlink(root.join("secret"), project.join("linked-secret")).unwrap(); + let error = recipe_files(root.join("recipes").to_str().unwrap(), "project").unwrap_err(); + assert!(error.contains("symlinks are not supported"), "{error}"); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/docker/isolate.rs b/src-tauri/src/docker/isolate.rs new file mode 100644 index 0000000..51bdde8 --- /dev/null +++ b/src-tauri/src/docker/isolate.rs @@ -0,0 +1,192 @@ +use super::compose_yaml::*; +use super::port_probe::*; +use super::subnet::*; +use super::devcontainer::RecipeApplyResult; + + +#[derive(serde::Serialize)] +pub struct IsolateResult { + pub subnet: String, + pub urls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub recipe: Option, +} + +/// Generates a `docker-compose.override.yml` in the worktree that remaps the +/// network subnet, container names, and exposes ports so the stack can run +/// alongside the main repo stack without conflicts. +/// +/// Ports are assigned with the formula: 20000 + subnet_offset×100 + ip_last_octet. +/// Exposed ports are discovered by inspecting the main stack's running containers. +/// +/// Returns "no-compose" error if no docker-compose.yml found (treat as no-op). +#[tauri::command] +pub async fn docker_compose_isolate(worktree_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let compose_path = format!("{}/docker-compose.yml", worktree_path); + if !std::path::Path::new(&compose_path).exists() { + return Err("no-compose".into()); + } + + let content = std::fs::read_to_string(&compose_path).map_err(|e| e.to_string())?; + let (network_name, old_prefix, services) = + parse_compose_info(&content).ok_or("could not parse compose network info")?; + + if services.is_empty() { + return Err("no services with static IPs found".into()); + } + + let worktree_dir = std::path::Path::new(&worktree_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("worktree") + .to_string(); + + // If this is a git worktree, the .git entry is a file pointing to the + // main repo's .git dir. We expose that dir as a volume so git inside + // containers can resolve the gitdir pointer (needed for yarn install). + let git_file = format!("{}/.git", worktree_path); + let git_volume_line = if std::path::Path::new(&git_file).is_file() { + std::fs::read_to_string(&git_file) + .ok() + .and_then(|c| { + c.lines() + .find_map(|l| l.strip_prefix("gitdir:").map(|s| s.trim().to_string())) + }) + .and_then(|gitdir| { + std::path::Path::new(&gitdir) + .parent() // worktrees/ + .and_then(|p| p.parent()) // .git/ + .and_then(|p| p.to_str()) + .map(|main_git| format!(" - {}:{}:ro\n", main_git, main_git)) + }) + } else { + None + }; + + // Reuse the subnet already assigned to this worktree if the override + // exists — avoids regenerating a new subnet (and thus new ports) every + // time the button is clicked while containers are running. + let override_path_check = format!("{}/docker-compose.override.yml", worktree_path); + let existing_prefix = std::fs::read_to_string(&override_path_check) + .ok() + .and_then(|c| { + c.lines().find_map(|l| { + let t = l.trim(); + let s = t + .strip_prefix("- subnet:") + .or_else(|| t.strip_prefix("subnet:"))?; + let without_mask = s.trim().split('/').next()?; + let parts: Vec<&str> = without_mask.split('.').collect(); + if parts.len() == 4 { + Some(format!("{}.{}.{}", parts[0], parts[1], parts[2])) + } else { + None + } + }) + }); + + let new_prefix = match existing_prefix { + Some(p) => p, + None => find_free_subnet_prefix(&old_prefix, &worktree_path) + .ok_or("no free subnet available in range")?, + }; + let new_subnet = format!("{}.0/24", new_prefix); + + let base_third: u16 = old_prefix + .split('.') + .nth(2) + .unwrap_or("0") + .parse() + .unwrap_or(0); + let new_third: u16 = new_prefix + .split('.') + .nth(2) + .unwrap_or("0") + .parse() + .unwrap_or(0); + let subnet_offset = new_third.saturating_sub(base_third); + + let mut yaml = format!( + "networks:\n {}:\n ipam:\n config:\n - subnet: {}\n\nservices:\n", + network_name, new_subnet + ); + + let mut urls: Vec = vec![]; + + for svc in &services { + let last_octet_str = svc.ip.rsplit('.').next().unwrap_or("0"); + let last_octet: u16 = last_octet_str.parse().unwrap_or(0); + let new_ip = format!("{}.{}", new_prefix, last_octet_str); + let new_container = format!("{}-{}", worktree_dir, svc.name); + + // Port base for this service: 20000 + offset×100 + last_octet + let host_port_base = 20000 + subnet_offset * 100 + last_octet; + + // Discover internal ports by inspecting the main stack container + let exposed = svc + .container_name + .as_deref() + .map(get_exposed_ports) + .unwrap_or_default(); + + yaml.push_str(&format!( + " {}:\n container_name: {}\n", + svc.name, new_container + )); + + if !exposed.is_empty() { + // Detect URL base path using the WORKTREE container (new_container), + // not the main stack container — the worktree one is the running instance. + // 1. Vite config detection (reads base from vite.config.{ts,js}) + // 2. HTTP probe on the primary mapped port (generic fallback) + let actual_first_port = + get_actual_host_port(&new_container, exposed[0]).unwrap_or(host_port_base); + let url_base = get_vite_base_path(&new_container) + .or_else(|| { + let p = probe_http_path(actual_first_port); + if p.is_empty() { + None + } else { + Some(p) + } + }) + .unwrap_or_default(); + yaml.push_str(" ports:\n"); + for (i, &internal_port) in exposed.iter().enumerate() { + // Prefer the actual running port; fall back to computed port + let host_port = get_actual_host_port(&new_container, internal_port) + .unwrap_or(host_port_base + i as u16); + yaml.push_str(&format!(" - \"{}:{}\"\n", host_port, internal_port)); + urls.push(ServiceUrl { + service: svc.name.clone(), + url: format!("http://localhost:{}{}", host_port, url_base), + }); + } + } + + if let Some(ref vol) = git_volume_line { + yaml.push_str(" volumes:\n"); + yaml.push_str(vol); + } + + yaml.push_str(&format!( + " networks:\n {}:\n ipv4_address: {}\n", + network_name, new_ip + )); + } + + let override_path = format!("{}/docker-compose.override.yml", worktree_path); + std::fs::write(&override_path, yaml).map_err(|e| e.to_string())?; + + ensure_global_gitignore("docker-compose.override.yml"); + + Ok(IsolateResult { + subnet: new_subnet, + urls, + recipe: None, + }) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/docker/mod.rs b/src-tauri/src/docker/mod.rs new file mode 100644 index 0000000..397491f --- /dev/null +++ b/src-tauri/src/docker/mod.rs @@ -0,0 +1,320 @@ +// Shared Docker plumbing (used by the Docker panel and the DB panel) plus the +// container-management commands: list, start/stop/restart, logs. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter}; + +pub(crate) mod compose_yaml; +pub(crate) mod port_probe; +pub(crate) mod subnet; +pub(crate) mod isolate; +pub(crate) mod devcontainer; +#[cfg(test)] +pub(crate) mod test_support; + +// macOS GUI apps don't inherit the shell PATH, so `docker` may not be on PATH. +// Resolve it through a login shell (Unix only; returns None on Windows). +fn login_shell_output(cmd: &str) -> Option { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let out = Command::new(shell).arg("-lc").arg(cmd).output().ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).to_string()) +} + +// The docker executable: bare `docker` when it's on PATH (Linux/Windows GUI apps +// inherit it), else the path resolved via a login shell (the macOS case). +pub fn docker_bin() -> Option { + let on_path = Command::new("docker") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if on_path { + return Some("docker".into()); + } + let path = login_shell_output("command -v docker")?; + let path = path.trim().to_string(); + if path.is_empty() { + None + } else { + Some(path) + } +} + +pub fn docker_output(args: &[&str]) -> Option { + let bin = docker_bin()?; + let out = Command::new(bin).args(args).output().ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).to_string()) +} + +// Container names/ids from docker are alphanumeric plus _-. — reject anything +// else before using one in a command. +pub fn is_safe_container(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) +} + +// These shell out to docker, which can take seconds (restart stops + starts the +// container). They're `async` + run on a blocking pool so the UI thread never +// freezes while waiting. + +#[tauri::command] +pub async fn docker_list() -> String { + tauri::async_runtime::spawn_blocking(|| { + docker_output(&["ps", "-a", "--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}|{{.Ports}}|{{.Label \"com.docker.compose.project\"}}"]).unwrap_or_default() + }) + .await + .unwrap_or_default() +} + +fn docker_action(action: &str, id: &str) -> Result<(), String> { + if !is_safe_container(id) { + return Err("contenedor inválido".into()); + } + let bin = docker_bin().ok_or("docker no encontrado")?; + let out = Command::new(bin) + .args([action, id]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) +} + +async fn run_action(action: &'static str, id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || docker_action(action, &id)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn docker_start(id: String) -> Result<(), String> { + run_action("start", id).await +} + +#[tauri::command] +pub async fn docker_stop(id: String) -> Result<(), String> { + run_action("stop", id).await +} + +#[tauri::command] +pub async fn docker_restart(id: String) -> Result<(), String> { + run_action("restart", id).await +} + +#[tauri::command] +pub async fn docker_logs(id: String, tail: u32) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_container(&id) { + return Err("contenedor inválido".to_string()); + } + let bin = docker_bin().ok_or("docker no encontrado")?; + let tail = tail.to_string(); + let out = Command::new(bin) + .args(["logs", "--tail", &tail, &id]) + .output() + .map_err(|e| e.to_string())?; + // docker writes container logs to both stdout and stderr; show both. + let mut combined = String::from_utf8_lossy(&out.stdout).to_string(); + combined.push_str(&String::from_utf8_lossy(&out.stderr)); + Ok(combined) + }) + .await + .map_err(|e| e.to_string())? +} + +// --- Live logs: `docker logs -f` streamed to the frontend via events --- + +// Running follow processes, keyed by container, so they can be stopped. +#[derive(Default)] +pub struct LogStreams(Mutex>); + +fn pipe_lines(reader: impl Read + Send + 'static, app: AppHandle, event: String) { + std::thread::spawn(move || { + for line in BufReader::new(reader).lines().map_while(Result::ok) { + let _ = app.emit(&event, format!("{}\n", line)); + } + }); +} + +#[tauri::command] +pub fn docker_logs_follow( + id: String, + tail: u32, + app: AppHandle, + state: tauri::State, +) -> Result<(), String> { + if !is_safe_container(&id) { + return Err("contenedor inválido".into()); + } + // Replace any existing stream for this container. + if let Some(mut child) = state.0.lock().unwrap().remove(&id) { + let _ = child.kill(); + } + let bin = docker_bin().ok_or("docker no encontrado")?; + let tail = tail.to_string(); + let mut child = Command::new(bin) + .args(["logs", "-f", "--tail", &tail, &id]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let event = format!("docker-logs-{}", id); + if let Some(o) = child.stdout.take() { + pipe_lines(o, app.clone(), event.clone()); + } + if let Some(e) = child.stderr.take() { + pipe_lines(e, app.clone(), event.clone()); + } + state.0.lock().unwrap().insert(id, child); + Ok(()) +} + +#[tauri::command] +pub fn docker_logs_stop(id: String, state: tauri::State) -> Result<(), String> { + if let Some(mut child) = state.0.lock().unwrap().remove(&id) { + let _ = child.kill(); + } + Ok(()) +} + +// --- Exec terminal: argv to open a shell inside a container (run in a PTY) --- + +#[tauri::command] +pub fn docker_exec_argv(container: String) -> Result, String> { + if !is_safe_container(&container) { + return Err("contenedor inválido".into()); + } + let bin = docker_bin().ok_or("docker no encontrado")?; + // Prefer bash (Tab completion via readline); fall back to sh when it's absent. + Ok(vec![ + bin, + "exec".into(), + "-it".into(), + container, + "sh".into(), + "-c".into(), + "command -v bash >/dev/null 2>&1 && exec bash || exec sh".into(), + ]) +} + +// --- docker-compose isolation: per-worktree override with remapped subnet + container names --- + +/// Runs `docker compose up -d` in the given worktree directory. +#[tauri::command] +pub async fn docker_compose_up(worktree_path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let compose_file = format!("{}/docker-compose.yml", worktree_path); + if !std::path::Path::new(&compose_file).exists() { + return Err("no-compose".into()); + } + let bin = match docker_bin() { + Some(b) => b, + None => return Err("docker not found".into()), + }; + let out = Command::new(&bin) + .args(["compose", "up", "-d", "--remove-orphans"]) + .current_dir(&worktree_path) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Runs `docker compose down` in the given worktree directory to stop and remove +/// the containers, networks, and anonymous volumes created for that task. +/// Silently succeeds if there is no compose file or Docker is not available. +#[tauri::command] +pub async fn docker_compose_down(worktree_path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let compose_file = format!("{}/docker-compose.yml", worktree_path); + if !std::path::Path::new(&compose_file).exists() { + return Ok(()); // no compose project — nothing to do + } + let bin = match docker_bin() { + Some(b) => b, + None => return Ok(()), // docker not available + }; + // `docker compose` (v2 plugin) preferred; fall back to `docker-compose` (v1). + let out = Command::new(&bin) + .args(["compose", "down", "--remove-orphans"]) + .current_dir(&worktree_path) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + // "no configuration file provided" means nothing was running — not an error. + if !err.contains("no configuration file") { + return Err(err); + } + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Stream `docker compose logs -f` for all services; emits to event `docker-compose-logs-`. +#[tauri::command] +pub fn docker_compose_logs_follow( + worktree_path: String, + tail: u32, + app: AppHandle, + state: tauri::State, +) -> Result<(), String> { + let key = format!("compose:{}", worktree_path); + if let Some(mut child) = state.0.lock().unwrap().remove(&key) { + let _ = child.kill(); + } + let bin = docker_bin().ok_or("docker no encontrado")?; + let tail_s = tail.to_string(); + let mut child = Command::new(&bin) + .args(["compose", "logs", "-f", "--tail", &tail_s]) + .current_dir(&worktree_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let dir = std::path::Path::new(&worktree_path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "compose".into()); + let event = format!("docker-compose-logs-{}", dir); + if let Some(o) = child.stdout.take() { + pipe_lines(o, app.clone(), event.clone()); + } + if let Some(e) = child.stderr.take() { + pipe_lines(e, app, event); + } + state.0.lock().unwrap().insert(key, child); + Ok(()) +} + +#[tauri::command] +pub fn docker_compose_logs_stop( + worktree_path: String, + state: tauri::State, +) -> Result<(), String> { + let key = format!("compose:{}", worktree_path); + if let Some(mut child) = state.0.lock().unwrap().remove(&key) { + let _ = child.kill(); + } + Ok(()) +} diff --git a/src-tauri/src/docker/port_probe.rs b/src-tauri/src/docker/port_probe.rs new file mode 100644 index 0000000..f20d1be --- /dev/null +++ b/src-tauri/src/docker/port_probe.rs @@ -0,0 +1,291 @@ +use super::*; + + +#[derive(serde::Serialize)] +pub struct ServiceUrl { + pub service: String, + pub url: String, +} + +// Inspect a running container to get the ports it listens on internally. +// Tries ExposedPorts first; falls back to /proc/net/tcp6 + /proc/net/tcp +// Query docker inspect for the actual host port bound to an internal port of a +// running container. Returns None if the container is not running or has no binding. +pub(super) fn get_actual_host_port(container_name: &str, internal_port: u16) -> Option { + let bin = docker_bin()?; + let format = "{{json .HostConfig.PortBindings}}"; + let out = Command::new(&bin) + .args(["inspect", "--format", format, container_name]) + .output() + .ok() + .filter(|o| o.status.success())?; + let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); + // Parse: {"3000/tcp":[{"HostIp":"","HostPort":"20231"}], ...} + let key = format!("{}/tcp", internal_port); + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + v.get(&key)? + .as_array()? + .first()? + .get("HostPort")? + .as_str()? + .parse() + .ok() +} + +// for images that listen on ports without declaring EXPOSE in their Dockerfile. +pub(super) fn get_exposed_ports(container_name: &str) -> Vec { + let bin = match docker_bin() { + Some(b) => b, + None => return vec![], + }; + let out = match Command::new(&bin) + .args([ + "inspect", + "--format", + "{{json .Config.ExposedPorts}}", + container_name, + ]) + .output() + { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(), + _ => return vec![], + }; + let mut ports = vec![]; + for part in out.split('"') { + let stripped = part + .strip_suffix("/tcp") + .or_else(|| part.strip_suffix("/udp")); + if let Some(port_str) = stripped { + if let Ok(p) = port_str.parse::() { + ports.push(p); + } + } + } + if !ports.is_empty() { + return ports; + } + + // Fallback: parse LISTEN entries from /proc/net/tcp6 and /proc/net/tcp. + // State 0A = LISTEN; local_address format is {ip_hex}:{port_hex}. + let mut proc_ports: Vec = vec![]; + for proc_file in &["/proc/net/tcp6", "/proc/net/tcp"] { + let raw = Command::new(&bin) + .args(["exec", container_name, "cat", proc_file]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + for line in raw.lines().skip(1) { + let mut cols = line.split_whitespace(); + let _sl = cols.next(); + let local = match cols.next() { + Some(v) => v, + None => continue, + }; + cols.next(); // remote_address + let state = match cols.next() { + Some(v) => v, + None => continue, + }; + if state != "0A" { + continue; + } + if let Some(port_hex) = local.rsplit(':').next() { + if let Ok(p) = u16::from_str_radix(port_hex, 16) { + // Skip ephemeral ports (>= 32768) — these are HMR sockets, + // random kernel-assigned ports, etc., not real service ports. + if p > 0 && p < 32768 && !proc_ports.contains(&p) { + proc_ports.push(p); + } + } + } + } + } + proc_ports +} + +// Read the Vite base path from a running container. +// Finds the Vite process working directory via /proc//cwd, then reads +// vite.config.{ts,js} from there and extracts the `base` option. +// Returns Some("/brand/") etc. when found, None otherwise. +pub(super) fn get_vite_base_path(container_name: &str) -> Option { + let bin = docker_bin()?; + // Find the PID of the running Vite process. + let pgrep_out = Command::new(&bin) + .args([ + "exec", + container_name, + "pgrep", + "-f", + "node_modules/.bin/vite", + ]) + .output() + .ok() + .filter(|o| o.status.success())?; + let pid = String::from_utf8_lossy(&pgrep_out.stdout) + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .map(String::from)?; + // Resolve the working directory of that process. + let cwd_out = Command::new(&bin) + .args([ + "exec", + container_name, + "readlink", + &format!("/proc/{}/cwd", pid), + ]) + .output() + .ok() + .filter(|o| o.status.success())?; + let cwd = String::from_utf8_lossy(&cwd_out.stdout).trim().to_string(); + if cwd.is_empty() { + return None; + } + // Try vite.config.ts then vite.config.js from the working directory. + for config_name in &["vite.config.ts", "vite.config.js"] { + let config_path = format!("{}/{}", cwd, config_name); + let cat_out = Command::new(&bin) + .args(["exec", container_name, "cat", &config_path]) + .output() + .ok() + .filter(|o| o.status.success()); + let content = match cat_out { + Some(o) => String::from_utf8_lossy(&o.stdout).to_string(), + None => continue, + }; + for line in content.lines() { + let t = line.trim(); + // Match: const base = '/brand/'; or base: '/brand/', + let rest = if let Some(r) = t.strip_prefix("const base = ") { + r + } else if let Some(r) = t.strip_prefix("base:") { + r.trim() + } else { + continue; + }; + let path = rest + .trim() + .trim_end_matches([',', ';']) + .trim_matches(|c: char| c == '\'' || c == '"'); + if !path.is_empty() && path.starts_with('/') && path != "/" { + return Some(path.to_string()); + } + } + } + None +} + +// Generic HTTP probe: connects to localhost:host_port, sends GET /, reads the +// response status and Location header. Returns the usable path: +// - 200 → "/" +// - 3xx + Location → the redirect path +// - anything else (timeout, 404, hang) → "" +// Used as fallback when no Vite process is detected. +pub(super) fn probe_http_path(host_port: u16) -> String { + use std::io::{BufRead, BufReader, Write}; + use std::net::{SocketAddr, TcpStream}; + use std::time::Duration; + + let addr: SocketAddr = match format!("127.0.0.1:{}", host_port).parse() { + Ok(a) => a, + Err(_) => return String::new(), + }; + let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) else { + return String::new(); + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(1500))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + + let request = format!( + "GET / HTTP/1.1\r\nHost: localhost:{}\r\nConnection: close\r\n\r\n", + host_port + ); + if stream.write_all(request.as_bytes()).is_err() { + return String::new(); + } + + let mut reader = BufReader::new(&stream); + let mut status_line = String::new(); + if reader.read_line(&mut status_line).is_err() { + return String::new(); + } + let status: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + if status == 200 { + return "/".to_string(); + } + if matches!(status, 301 | 302 | 307 | 308) { + let mut line = String::new(); + while reader.read_line(&mut line).unwrap_or(0) > 0 { + let trimmed = line.trim(); + if trimmed.is_empty() { + break; + } + if let Some(loc) = trimmed.strip_prefix("Location:") { + let loc = loc.trim(); + if loc.starts_with('/') { + return loc.to_string(); + } + let after_scheme = loc + .strip_prefix("http://") + .or_else(|| loc.strip_prefix("https://")) + .unwrap_or(""); + if let Some(slash_idx) = after_scheme.find('/') { + return after_scheme[slash_idx..].to_string(); + } + } + line.clear(); + } + } + String::new() +} + +/// Builds browsable localhost URLs from `(containerPort, hostPort)` pairs — every +/// isolated port (base compose + override), so bento lists the frontend/backend/etc. +pub(super) fn pairs_to_urls(pairs: &[(u16, u16)]) -> Vec { + pairs + .iter() + .map(|(c, h)| ServiceUrl { + service: format!("port {}", c), + url: format!("http://localhost:{}", h), + }) + .collect() +} + +/// Deterministic per-worktree port offset (1..=90) for projects without a custom +/// subnet — FNV-1a so it's stable across runs without Date/random. +pub(super) fn stable_port_offset(seed: &str) -> u16 { + let mut h: u32 = 2166136261; + for b in seed.bytes() { + h = (h ^ b as u32).wrapping_mul(16777619); + } + 1 + (h % 90) as u16 +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::test_support::*; + + #[test] + fn stable_port_offset_is_deterministic_and_bounded() { + let a = stable_port_offset("konect-nixon-nixon-459"); + assert_eq!(a, stable_port_offset("konect-nixon-nixon-459")); + assert!((1..=90).contains(&a)); + } + + #[test] + fn pairs_to_urls_builds_localhost_urls() { + let urls = pairs_to_urls(&[(8108, 20100), (3000, 20104)]); + assert_eq!(urls.len(), 2); + assert_eq!(urls[0].url, "http://localhost:20100"); + assert_eq!(urls[1].url, "http://localhost:20104"); + } +} diff --git a/src-tauri/src/docker/subnet.rs b/src-tauri/src/docker/subnet.rs new file mode 100644 index 0000000..d8a8ebb --- /dev/null +++ b/src-tauri/src/docker/subnet.rs @@ -0,0 +1,115 @@ +use super::*; + + +pub(super) fn get_docker_used_subnets() -> Vec { + let bin = match docker_bin() { + Some(b) => b, + None => return vec![], + }; + let ids = match Command::new(&bin).args(["network", "ls", "-q"]).output() { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => return vec![], + }; + let mut subnets = vec![]; + for id in ids.lines().map(str::trim).filter(|s| !s.is_empty()) { + if let Ok(out) = Command::new(&bin) + .args([ + "network", + "inspect", + "--format", + "{{range .IPAM.Config}}{{.Subnet}}|{{end}}", + id, + ]) + .output() + { + for part in String::from_utf8_lossy(&out.stdout).split('|') { + let s = part.trim().to_string(); + if !s.is_empty() { + subnets.push(s); + } + } + } + } + subnets +} + +// Scan sibling directories for existing override files to avoid assigning the +// same subnet to two worktrees that haven't started their Docker stack yet. +pub(super) fn get_sibling_override_subnets(worktree_path: &str) -> Vec { + let parent = match std::path::Path::new(worktree_path).parent() { + Some(p) => p, + None => return vec![], + }; + let mut subnets = vec![]; + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let override_file = entry.path().join("docker-compose.override.yml"); + if override_file + == std::path::Path::new(worktree_path).join("docker-compose.override.yml") + { + continue; // skip the worktree we're about to write + } + if let Ok(content) = std::fs::read_to_string(override_file) { + for line in content.lines() { + if let Some(rest) = line.trim().strip_prefix("subnet:") { + subnets.push(rest.trim().to_string()); + } + } + } + } + } + subnets +} + +pub(super) fn find_free_subnet_prefix(base_prefix: &str, worktree_path: &str) -> Option { + let parts: Vec<&str> = base_prefix.split('.').collect(); + if parts.len() != 3 { + return None; + } + let base_third: u8 = parts[2].parse().ok()?; + let prefix16 = format!("{}.{}", parts[0], parts[1]); + + let mut used = get_docker_used_subnets(); + used.extend(get_sibling_override_subnets(worktree_path)); + + for delta in 1u8..=50 { + let new_third = base_third.checked_add(delta)?; + let candidate = format!("{}.{}", prefix16, new_third); + let candidate_subnet = format!("{}.0/24", candidate); + let in_use = used.iter().any(|s| { + let s = s.trim(); + s == candidate_subnet || s.starts_with(&format!("{}.", candidate)) + }); + if !in_use { + return Some(candidate); + } + } + None +} + +pub(super) fn ensure_global_gitignore(pattern: &str) { + let path = login_shell_output("git config --global core.excludesFile") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_default(); + format!("{}/.config/git/ignore", home) + }); + if path.is_empty() { + return; + } + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + if existing.lines().any(|l| l.trim() == pattern) { + return; + } + if let Some(parent) = std::path::Path::new(&path).parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut content = existing; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(pattern); + content.push('\n'); + let _ = std::fs::write(&path, content); +} diff --git a/src-tauri/src/docker/test_support.rs b/src-tauri/src/docker/test_support.rs new file mode 100644 index 0000000..91c3034 --- /dev/null +++ b/src-tauri/src/docker/test_support.rs @@ -0,0 +1,57 @@ +#![cfg(test)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) fn temporary_directory(name: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "bento-docker-{name}-{}-{nonce}", + std::process::id() + )) +} + +pub(crate) const SAMPLE: &str = "name: nixon_devcontainer +services: + app: + volumes: + - ..:/workspace:cached + networks: + nixon-network: + ipv4_address: 10.189.20.10 + typesense: + ports: + - \"8108:8108\" + networks: + nixon-network: + ipv4_address: 10.189.20.6 +networks: + nixon-network: + ipam: + config: + - subnet: 10.189.20.0/24 +"; + +// A generic devcontainer compose with NO custom subnet, but a fixed +// container_name and a published port — both must still be isolated. +pub(crate) const SAMPLE_NO_SUBNET: &str = "services: + web: + image: nginx + container_name: web + ports: + - \"3000:3000\" +"; + +pub(crate) fn init_test_git_repo(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + for args in [ + vec!["init", "-q"], + vec!["config", "user.email", "bento@example.test"], + vec!["config", "user.name", "Bento Test"], + ] { + assert!(Command::new("git").args(args).current_dir(path).status().unwrap().success()); + } +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs deleted file mode 100644 index 41d30a2..0000000 --- a/src-tauri/src/git.rs +++ /dev/null @@ -1,2871 +0,0 @@ -// Git worktree commands for the parallel tasks panel. -// Follows the same patterns as docker.rs: login-shell PATH resolution, -// spawn_blocking for all blocking I/O, input validation at trust boundaries. - -use std::fs; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct WorktreeInfo { - path: String, - branch: Option, - head: String, - bare: bool, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct GitStatus { - raw: String, - staged: u32, - unstaged: u32, - untracked: u32, - total: u32, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct CommitEntry { - hash: String, - short: String, - subject: String, - date: String, - author: String, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct CommitFile { - status: String, - paths: Vec, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct CommitRecommendation { - hash: String, - score: u32, - files: Vec, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct RewritePreflight { - branch: String, - base: String, - dirty: bool, - operation: String, - upstream: String, - published_commits: u32, - protected_base: bool, - signing: bool, - hooks: Vec, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct FetchInfo { - #[ts(type = "number")] - fetched_at: u64, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct BackupStatus { - available: bool, - different: Option, - hash: Option, - short: Option, - subject: Option, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct BackupEntry { - reference: String, - hash: String, - short: String, - subject: String, - #[ts(type = "number")] - created_at: u64, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct UpstreamStatus { - branch: String, - upstream: Option, - has_upstream: bool, - state: String, - ahead: u32, - behind: u32, -} - -#[derive(serde::Serialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct RebaseStatus { - active: bool, - sha: Option, - short: Option, - subject: Option, - body: Option, - branch: Option, - current: Option, - total: Option, - conflicts: Vec, -} - -#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct PrCheck { - name: Option, - context: Option, - conclusion: Option, - state: Option, - status: Option, -} - -#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/generated/bindings/")] -pub struct PrStatus { - state: String, - title: String, - url: String, - #[ts(type = "number")] - number: u64, - base_ref_name: Option, - is_draft: Option, - mergeable: Option, - review_decision: Option, - #[serde(default)] - status_check_rollup: Vec, -} - -// macOS GUI apps don't inherit the shell PATH, so `git` may not be on PATH. -fn login_shell_output(cmd: &str) -> Option { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let out = Command::new(shell).arg("-lc").arg(cmd).output().ok()?; - if !out.status.success() { - return None; - } - Some(String::from_utf8_lossy(&out.stdout).to_string()) -} - -fn git_bin() -> Option { - let on_path = Command::new("git") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if on_path { - return Some("git".into()); - } - let path = login_shell_output("command -v git")?; - let path = path.trim().to_string(); - if path.is_empty() { - None - } else { - Some(path) - } -} - -fn git_output(repo: &str, args: &[&str]) -> Result { - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let out = Command::new(&bin) - .arg("-C") - .arg(repo) - .args(args) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).to_string()) -} - -fn parse_worktrees(raw: &str) -> Vec { - // Git for Windows may emit CRLF even when stdout is captured through a - // pipe. Normalize record separators before splitting porcelain blocks. - raw.replace("\r\n", "\n") - .trim() - .split("\n\n") - .filter_map(|block| { - let mut path = None; - let mut head = None; - let mut branch = None; - let mut bare = false; - for line in block.lines() { - if let Some(value) = line.strip_prefix("worktree ") { - path = Some(value.to_string()); - } - if let Some(value) = line.strip_prefix("HEAD ") { - head = Some(value.to_string()); - } - if let Some(value) = line.strip_prefix("branch refs/heads/") { - branch = Some(value.to_string()); - } - if line == "bare" { - bare = true; - } - } - if bare { - return None; - } - Some(WorktreeInfo { - path: path?, - head: head?, - branch, - bare, - }) - }) - .collect() -} - -fn parse_status(raw: String) -> GitStatus { - let mut staged = 0; - let mut unstaged = 0; - let mut untracked = 0; - let mut total = 0; - for line in raw.lines().filter(|line| !line.trim().is_empty()) { - total += 1; - let bytes = line.as_bytes(); - let x = bytes.first().copied().unwrap_or(b' '); - let y = bytes.get(1).copied().unwrap_or(b' '); - if x == b'?' && y == b'?' { - untracked += 1; - } else { - if x != b' ' { - staged += 1; - } - if y != b' ' { - unstaged += 1; - } - } - } - GitStatus { - raw, - staged, - unstaged, - untracked, - total, - } -} - -fn parse_commit_log(raw: String) -> Vec { - raw.lines() - .filter_map(|line| { - let mut fields = line.split('\x1f'); - Some(CommitEntry { - hash: fields.next()?.to_string(), - short: fields.next().unwrap_or_default().to_string(), - subject: fields.next().unwrap_or_default().to_string(), - date: fields.next().unwrap_or_default().to_string(), - author: fields.next().unwrap_or_default().to_string(), - }) - }) - .collect() -} - -// Accepts [A-Za-z0-9._/-], rejects `..` and spaces. -fn is_safe_branch(name: &str) -> bool { - !name.is_empty() - && !name.contains("..") - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-')) -} - -fn is_git_repo(path: &str) -> bool { - git_output(path, &["rev-parse", "--git-dir"]).is_ok() -} - -fn current_branch(path: &str) -> Result { - let branch = git_output(path, &["rev-parse", "--abbrev-ref", "HEAD"])? - .trim() - .to_string(); - if branch.is_empty() || branch == "HEAD" || !is_safe_branch(&branch) { - return Err("cannot operate on detached HEAD".into()); - } - Ok(branch) -} - -fn resolve_commit_reference(repo: &str, reference: &str) -> Result { - if !is_safe_branch(reference) { - return Err(format!("unsafe reference: {reference}")); - } - let resolve = |repo: &str, reference: &str| { - let candidates = [ - format!("refs/heads/{reference}"), - format!("refs/remotes/{reference}"), - reference.to_string(), - ]; - for candidate in candidates { - if let Ok(value) = git_output(repo, &["rev-parse", "--verify", &format!("{candidate}^{{commit}}")]) { - return Ok(value.trim().to_string()); - } - } - Err(format!("unknown reference: {reference}")) - }; - - if let Ok(commit) = resolve(repo, reference) { - return Ok(commit); - } - - let _ = git_output(repo, &["fetch", "--all", "--prune"]); - if let Ok(commit) = resolve(repo, reference) { - return Ok(commit); - } - - Err(format!("unknown reference: {reference}")) -} - -fn diff_between_refs(repo: &str, base: &str, target: &str) -> Result { - let base_commit = resolve_commit_reference(repo, base)?; - let target_commit = resolve_commit_reference(repo, target)?; - git_output(repo, &["diff", &format!("{base_commit}...{target_commit}")]) -} - -#[tauri::command] -pub async fn git_current_branch(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || current_branch(&path)) - .await - .map_err(|e| e.to_string())? -} - -fn backup_ref_for(path: &str) -> Result { - Ok(format!("refs/bento/backups/{}", current_branch(path)?)) -} - -fn create_history_backup(path: &str) -> Result { - let backup_ref = backup_ref_for(path)?; - git_output(path, &["update-ref", &backup_ref, "HEAD"])?; - let branch = current_branch(path)?; - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .as_millis(); - let history_ref = format!("refs/bento/history/{branch}/{stamp}"); - git_output(path, &["update-ref", &history_ref, "HEAD"])?; - - // Keep the history bounded per branch. - let prefix = format!("refs/bento/history/{branch}"); - if let Ok(refs) = git_output( - path, - &[ - "for-each-ref", - "--sort=-refname", - "--format=%(refname)", - &prefix, - ], - ) { - for old_ref in refs.lines().skip(20) { - let _ = git_output(path, &["update-ref", "-d", old_ref]); - } - } - Ok(backup_ref) -} - -fn apply_selected_patch(path: &str, patch: &str) -> Result<(), String> { - if patch.trim().is_empty() || !patch.contains("diff --git ") { - return Err("selected patch is empty or invalid".into()); - } - if patch.len() > 16 * 1024 * 1024 { - return Err("selected patch is too large".into()); - } - // Clear the index only; working-tree contents are preserved. This ensures - // unrelated staged paths cannot leak into the partial commit. - git_output(path, &["reset", "--mixed", "HEAD"])?; - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let mut child = Command::new(&bin) - .arg("-C") - .arg(path) - .arg("apply") - .arg("--cached") - .arg("--unidiff-zero") - .arg("--whitespace=nowarn") - .arg("-") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| e.to_string())?; - child - .stdin - .as_mut() - .ok_or("could not open git apply stdin")? - .write_all(patch.as_bytes()) - .map_err(|e| e.to_string())?; - let out = child.wait_with_output().map_err(|e| e.to_string())?; - if !out.status.success() { - let _ = git_output(path, &["reset", "--mixed", "HEAD"]); - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) -} - -fn append_untracked_diffs(path: &str, combined: &mut String) -> Result<(), String> { - let untracked = git_output(path, &["ls-files", "--others", "--exclude-standard"])?; - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let null_file = if cfg!(windows) { "NUL" } else { "/dev/null" }; - for file in untracked.lines().filter(|line| !line.is_empty()) { - let out = Command::new(&bin) - .arg("-C") - .arg(path) - .arg("diff") - .arg("--no-index") - .arg("--src-prefix=a/") - .arg("--dst-prefix=b/") - .arg("--") - .arg(null_file) - .arg(file) - .output() - .map_err(|e| e.to_string())?; - if out.status.code() == Some(0) || out.status.code() == Some(1) { - combined.push_str(&String::from_utf8_lossy(&out.stdout)); - } else { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - } - Ok(()) -} - -fn collect_worktree_diff(path: &str) -> Result { - let mut combined = git_output(path, &["diff", "--no-ext-diff", "HEAD"])?; - append_untracked_diffs(path, &mut combined)?; - Ok(combined) -} - -fn collect_review_worktree_diff(path: &str, base: &str) -> Result { - if !is_safe_branch(base) { - return Err(format!("unsafe base: {base}")); - } - let mut combined = git_output(path, &["diff", "--no-ext-diff", base, "--"])?; - append_untracked_diffs(path, &mut combined)?; - Ok(combined) -} - -#[tauri::command] -pub async fn git_worktree_list(repo: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - // Prune stale worktree refs (folders deleted manually without `git worktree remove`). - let _ = git_output(&repo, &["worktree", "prune"]); - git_output(&repo, &["worktree", "list", "--porcelain"]).map(|raw| parse_worktrees(&raw)) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_status(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git_output(&path, &["status", "--porcelain"]).map(parse_status) - }) - .await - .map_err(|e| e.to_string())? -} - -// Read-only safety report used before rewriting task history. The frontend can -// explain every risk before invoking rebase/fixup instead of discovering it -// after Git has already started the operation. -#[tauri::command] -pub async fn git_rewrite_preflight(path: String, base: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let branch = current_branch(&path)?; - let dirty = !git_output(&path, &["status", "--porcelain"])? - .trim() - .is_empty(); - let git_dir = resolve_git_dir(&path); - let operation = - if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() { - "rebase" - } else if git_dir.join("MERGE_HEAD").exists() { - "merge" - } else if git_dir.join("CHERRY_PICK_HEAD").exists() { - "cherry-pick" - } else if git_dir.join("REVERT_HEAD").exists() { - "revert" - } else { - "" - }; - let upstream = git_output( - &path, - &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], - ) - .unwrap_or_default() - .trim() - .to_string(); - let published_commits = if upstream.is_empty() { - 0 - } else { - let range = format!("origin/{base}..@{{u}}"); - git_output(&path, &["rev-list", "--count", &range]) - .ok() - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or(0) - }; - let hooks = ["pre-rebase", "pre-commit", "commit-msg"] - .iter() - .filter(|name| git_dir.join("hooks").join(name).exists()) - .map(|name| name.to_string()) - .collect::>(); - let signing = git_output(&path, &["config", "--bool", "commit.gpgsign"]) - .map(|value| value.trim() == "true") - .unwrap_or(false); - let protected_base = branch == base || matches!(branch.as_str(), "main" | "master"); - Ok(RewritePreflight { - branch, - base, - dirty, - operation: operation.into(), - upstream, - published_commits, - protected_base, - signing, - hooks, - }) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_default_branch(repo: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - // Try origin/HEAD first. - if let Ok(out) = git_output( - &repo, - &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], - ) { - let branch = out.trim().trim_start_matches("origin/").to_string(); - if !branch.is_empty() { - return Ok(branch); - } - } - // Fall back to checking for `main`, then `master`. - if git_output(&repo, &["rev-parse", "--verify", "main"]).is_ok() { - return Ok("main".into()); - } - Ok("master".into()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_remote_branches(repo: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&repo) { - return Err("not a git repository".into()); - } - let raw = git_output( - &repo, - &[ - "for-each-ref", - "--format=%(refname:short)", - "refs/remotes/origin", - ], - )?; - Ok(raw - .lines() - .filter_map(|line| line.strip_prefix("origin/")) - .filter(|branch| *branch != "HEAD" && is_safe_branch(branch)) - .map(str::to_string) - .collect()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Lists branches from ALL remotes with full remote/branch format (e.g. "daimoxd/feat/foo"). -#[tauri::command] -pub async fn git_all_remote_branches(repo: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&repo) { - return Err("not a git repository".into()); - } - let raw = git_output( - &repo, - &["for-each-ref", "--format=%(refname:short)", "refs/remotes"], - )?; - Ok(raw - .lines() - .filter(|line| !line.ends_with("/HEAD") && is_safe_branch(line)) - .map(str::to_string) - .collect()) - }) - .await - .map_err(|e| e.to_string())? -} - -fn parse_review_branches(local: &str, remote: &str) -> Vec { - let mut branches = Vec::new(); - for branch in local.lines().chain(remote.lines()) { - let branch = branch.trim(); - if branch.is_empty() - || branch == "HEAD" - || branch.ends_with("/HEAD") - || !is_safe_branch(branch) - || branches.iter().any(|existing| existing == branch) - { - continue; - } - branches.push(branch.to_string()); - } - branches -} - -/// Branches available for review: local task/worktree branches first, followed -/// by fully-qualified remote branches such as `origin/main`. -#[tauri::command] -pub async fn git_review_branches(repo: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&repo) { - return Err("not a git repository".into()); - } - let local = git_output( - &repo, - &["for-each-ref", "--format=%(refname:short)", "refs/heads"], - )?; - let remote = git_output( - &repo, - &["for-each-ref", "--format=%(refname:short)", "refs/remotes"], - )?; - Ok(parse_review_branches(&local, &remote)) - }) - .await - .map_err(|error| error.to_string())? -} - -#[tauri::command] -pub async fn git_worktree_add( - repo: String, - path: String, - branch: String, - base: String, -) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&repo) { - return Err("not a git repository".into()); - } - if !is_safe_branch(&branch) { - return Err(format!("unsafe branch name: {branch}")); - } - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - if Path::new(&path).exists() { - return Err(format!("path already exists: {path}")); - } - git_output(&repo, &["worktree", "add", &path, "-b", &branch, &base])?; - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_worktree_remove( - repo: String, - path: String, - force: bool, - branch: Option, -) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - - let try_remove = |extra_force: bool| -> Result<(), String> { - let mut cmd = Command::new(&bin); - cmd.arg("-C").arg(&repo).arg("worktree").arg("remove"); - if force || extra_force { - cmd.arg("--force"); - } - cmd.arg(&path); - let out = cmd.output().map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) - }; - - match try_remove(false) { - Ok(()) => {} - Err(e) if e.contains("not a working tree") => { - // The .git file inside the worktree is missing/broken. - // Repair the link so git can locate the metadata, then retry. - let _ = git_output(&repo, &["worktree", "repair", &path]); - try_remove(true)?; - } - Err(e) => return Err(e), - } - - // Delete the branch too — the task is gone, the branch should follow. - if let Some(b) = branch { - if is_safe_branch(&b) { - // -D: force-delete regardless of merge status (user confirmed deletion). - let _ = git_output(&repo, &["branch", "-D", &b]); - } - } - - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Sync a worktree against origin/: fetch, then optionally merge or rebase. -// `mode` is one of "fetch", "merge", "rebase". -// `autostash`: stash before merge/rebase and pop after (asked by the user beforehand). -#[tauri::command] -pub async fn git_sync( - path: String, - base: String, - mode: String, - autostash: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let fetched = git_output(&path, &["fetch", "origin"])?; - let target = format!("origin/{base}"); - let do_stash = autostash.unwrap_or(false); - match mode.as_str() { - "fetch" => Ok(if fetched.trim().is_empty() { - "Fetch completado".into() - } else { - fetched - }), - "merge" => { - if do_stash { - git_output(&path, &["stash"])?; - } - let result = git_output(&path, &["merge", &target]); - if do_stash { - let _ = git_output(&path, &["stash", "pop"]); - } - result - } - "rebase" => { - create_history_backup(&path)?; - let extra: &[&str] = if do_stash { &["--autostash"] } else { &[] }; - let mut args = vec!["rebase"]; - args.extend_from_slice(extra); - args.push(&target); - git_output(&path, &args) - } - other => Err(format!("modo desconocido: {other}")), - } - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_diff(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || collect_worktree_diff(&path)) - .await - .map_err(|e| e.to_string())? -} - -// Accumulated diff of all commits on the current branch vs (three-dot range). -#[tauri::command] -pub async fn git_branch_diff(path: String, base: String) -> Result { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&path) { - return Err("not a git repository".into()); - } - // Try local ref first, fall back to origin/ prefix for remote-only branches. - // The original error is preserved so transient failures are not silently swallowed. - match git_output(&path, &["diff", &format!("{base}...HEAD")]) { - Ok(out) => Ok(out), - Err(first_err) => git_output(&path, &["diff", &format!("origin/{base}...HEAD")]) - .map_err(|_| first_err), - } - }) - .await - .map_err(|e| e.to_string())? -} - -/// Diff from `base` to the current worktree, including committed, staged, -/// unstaged and untracked changes. -#[tauri::command] -pub async fn git_review_worktree_diff(path: String, base: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&path) { - return Err("not a git repository".into()); - } - collect_review_worktree_diff(&path, &base) - }) - .await - .map_err(|error| error.to_string())? -} - -// Validates that a commit message is non-empty (trust boundary: frontend input). -fn is_valid_message(msg: &str) -> bool { - !msg.trim().is_empty() -} - -// `files`: stage only specific paths; if empty/None, stages everything. -// `amend`: if true, amends the last commit. An empty `message` keeps the original message (--no-edit). -#[tauri::command] -pub async fn git_commit( - path: String, - message: String, - amend: Option, - files: Option>, - patch: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let do_amend = amend.unwrap_or(false); - if !do_amend && !is_valid_message(&message) { - return Err("commit message cannot be empty".into()); - } - - let selected = files.as_ref().filter(|items| !items.is_empty()); - if let Some(ref selected_patch) = patch { - apply_selected_patch(&path, selected_patch)?; - } else if let Some(items) = selected { - // Intent-to-add makes new files known to `commit --only`; --only - // then ignores every unrelated path already present in the index. - let mut args = vec!["add", "-N", "--"]; - args.extend(items.iter().map(String::as_str)); - git_output(&path, &args)?; - } else { - git_output(&path, &["add", "-A"])?; - } - - let mut commit_args = vec!["commit"]; - if do_amend { - commit_args.push("--amend"); - } - if is_valid_message(&message) { - commit_args.extend(["-m", &message]); - } else { - commit_args.push("--no-edit"); - } - if patch.is_none() { - if let Some(items) = selected { - commit_args.push("--only"); - commit_args.push("--"); - commit_args.extend(items.iter().map(String::as_str)); - } - } - let result = git_output(&path, &commit_args); - if result.is_err() && patch.is_some() { - let _ = git_output(&path, &["reset", "--mixed", "HEAD"]); - } - result - }) - .await - .map_err(|e| e.to_string())? -} - -// Adds the selected working-tree changes to an existing task commit by creating -// a fixup commit and immediately autosquashing it over origin/. -#[tauri::command] -pub async fn git_fixup( - path: String, - target: String, - base: String, - files: Option>, - patch: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - if target.len() < 7 || !target.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("invalid target commit".into()); - } - - let base_ref = format!("origin/{base}"); - git_output(&path, &["rev-parse", "--verify", &base_ref])?; - let range = format!("{base_ref}..HEAD"); - let branch_commits = git_output(&path, &["rev-list", &range])?; - if !branch_commits.lines().any(|hash| hash == target) { - return Err("target commit is not part of this task branch".into()); - } - - create_history_backup(&path)?; - - let selected = files.as_ref().filter(|items| !items.is_empty()); - if let Some(ref selected_patch) = patch { - apply_selected_patch(&path, selected_patch)?; - } else if let Some(items) = selected { - let mut args = vec!["add", "-N", "--"]; - args.extend(items.iter().map(String::as_str)); - git_output(&path, &args)?; - } else { - git_output(&path, &["add", "-A"])?; - } - - let fixup_arg = format!("--fixup={target}"); - let mut commit_args = vec!["commit", fixup_arg.as_str()]; - if patch.is_none() { - if let Some(items) = selected { - commit_args.push("--only"); - commit_args.push("--"); - commit_args.extend(items.iter().map(String::as_str)); - } - } - if let Err(error) = git_output(&path, &commit_args) { - if patch.is_some() { - let _ = git_output(&path, &["reset", "--mixed", "HEAD"]); - } - return Err(format!( - "{error}\n\nNo se creó el fixup; los cambios siguen en el worktree." - )); - } - - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let out = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("rebase") - .arg("-i") - .arg("--autosquash") - .arg("--autostash") - .arg(&base_ref) - .env("GIT_SEQUENCE_EDITOR", "true") - .env("GIT_EDITOR", "true") - .output() - .map_err(|e| e.to_string())?; - - let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); - if rebase_dir.exists() { - return Ok("paused".into()); - } - if !out.status.success() { - // The fixup commit exists but no recoverable rebase is active. - // Return to the pre-operation commit with --mixed so every file - // change remains available in the worktree. - let backup_ref = backup_ref_for(&path)?; - let _ = git_output(&path, &["reset", "--mixed", &backup_ref]); - return Err(format!( - "{}\n\nEl fixup se revirtió automáticamente y los cambios siguen en el worktree.", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok("completed".into()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_branch_rename(path: String, new_name: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&new_name) { - return Err(format!("unsafe branch name: {new_name}")); - } - git_output(&path, &["branch", "-m", &new_name]).map(|_| ()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns newline-separated entries: "\x1f\x1f\x1f\x1f" -#[tauri::command] -pub async fn git_log( - path: String, - limit: u32, - no_merges: Option, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let n = limit.clamp(1, 200).to_string(); - let mut args = vec![ - "log".to_string(), - format!("-{n}"), - "--format=%H\x1f%h\x1f%s\x1f%ad\x1f%an".to_string(), - "--date=relative".to_string(), - ]; - if no_merges.unwrap_or(false) { - args.push("--no-merges".to_string()); - } - let refs: Vec<&str> = args.iter().map(String::as_str).collect(); - git_output(&path, &refs).map(parse_commit_log) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_graph(path: String, base: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let base_ref = format!("origin/{base}"); - git_output( - &path, - &[ - "log", - "--graph", - "--decorate", - "--oneline", - "--date-order", - "--boundary", - "-100", - &base_ref, - "HEAD", - ], - ) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns every non-merge commit owned by the task branch, in the same -// oldest-to-newest order used by `git rebase -i origin/`. -#[tauri::command] -pub async fn git_rebase_log(path: String, base: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let target = format!("origin/{base}"); - let range = format!("{target}..HEAD"); - git_output( - &path, - &[ - "log", - "--reverse", - "--no-merges", - "--format=%H\x1f%h\x1f%s\x1f%ad\x1f%an", - "--date=relative", - &range, - ], - ) - .map(parse_commit_log) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_merge_log(path: String, base: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let range = format!("origin/{base}..HEAD"); - git_output( - &path, - &[ - "log", - "--reverse", - "--merges", - "--format=%H\x1f%h\x1f%s\x1f%ad\x1f%an", - "--date=relative", - &range, - ], - ) - .map(parse_commit_log) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns typed PR metadata or null if no PR / gh is unavailable. -#[tauri::command] -pub async fn git_pr_status(path: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let out = Command::new("gh") - .current_dir(&path) - .args(["pr", "view", "--json", "state,title,url,number,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup"]) - .output(); - let Ok(out) = out else { return Ok(None); }; - if !out.status.success() || out.stdout.is_empty() { return Ok(None); } - serde_json::from_slice::(&out.stdout).map(Some).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Diff between any two git refs (e.g. "origin/main" vs "origin/feat/foo"). -#[tauri::command] -pub async fn git_ref_diff(path: String, base: String, target: String) -> Result { - if !is_safe_branch(&base) { - return Err(format!("unsafe base: {base}")); - } - if !is_safe_branch(&target) { - return Err(format!("unsafe target: {target}")); - } - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&path) { - return Err("not a git repository".into()); - } - diff_between_refs(&path, &base, &target) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns PR info for any branch via gh CLI. -#[tauri::command] -pub async fn gh_pr_view_branch( - path: String, - branch: String, -) -> Result, String> { - if !is_safe_branch(&branch) { - return Err(format!("unsafe branch: {branch}")); - } - tauri::async_runtime::spawn_blocking(move || { - let out = Command::new("gh") - .current_dir(&path) - .args([ - "pr", - "view", - &branch, - "--json", - "number,title,url,body,state,mergedAt,statusCheckRollup,reviewDecision", - ]) - .output(); - let Ok(out) = out else { - return Ok(None); - }; - if !out.status.success() || out.stdout.is_empty() { - return Ok(None); - } - serde_json::from_slice::(&out.stdout) - .map(Some) - .map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn gh_pr_diff_number( - path: String, - pr_number: i64, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let out = Command::new("gh") - .current_dir(&path) - .args(["pr", "diff", &pr_number.to_string(), "--color=never"]) - .output(); - let Ok(out) = out else { - return Ok(String::new()); - }; - if !out.status.success() { - return Ok(String::new()); - } - Ok(String::from_utf8_lossy(&out.stdout).to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Fetches PR discussion: general comments + review submissions (with body). -#[tauri::command] -pub async fn gh_pr_list_discussion( - path: String, - pr_number: i64, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let comments = std::process::Command::new("gh") - .current_dir(&path) - .args([ - "api", - "--paginate", - &format!("repos/{{owner}}/{{repo}}/issues/{}/comments", pr_number), - ]) - .output() - .ok() - .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) - .unwrap_or(serde_json::Value::Array(vec![])); - let reviews = std::process::Command::new("gh") - .current_dir(&path) - .args([ - "api", - &format!("repos/{{owner}}/{{repo}}/pulls/{}/reviews", pr_number), - ]) - .output() - .ok() - .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) - .unwrap_or(serde_json::Value::Array(vec![])); - Ok(serde_json::json!({ "comments": comments, "reviews": reviews })) - }) - .await - .map_err(|e| e.to_string())? -} - -// Posts a comment on the PR and returns its URL. -#[tauri::command] -pub async fn gh_pr_comment(path: String, branch: String, body: String) -> Result { - if !is_safe_branch(&branch) { - return Err(format!("unsafe branch: {branch}")); - } - if body.len() > 65_536 { - return Err("comment body exceeds maximum length".into()); - } - tauri::async_runtime::spawn_blocking(move || { - // Resolve PR number from branch so we can use the REST API (gh pr comment - // does not support --json/--jq, but gh api returns html_url). - let num_out = Command::new("gh") - .current_dir(&path) - .args(["pr", "view", &branch, "--json", "number", "--jq", ".number"]) - .output() - .map_err(|e| e.to_string())?; - if !num_out.status.success() { - return Err(String::from_utf8_lossy(&num_out.stderr).trim().to_string()); - } - let pr_number = String::from_utf8_lossy(&num_out.stdout).trim().to_string(); - let endpoint = format!("repos/{{owner}}/{{repo}}/issues/{pr_number}/comments"); - let out = Command::new("gh") - .current_dir(&path) - .args([ - "api", - "--method", - "POST", - &endpoint, - "-f", - &format!("body={body}"), - "--jq", - ".html_url", - ]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Resolves a git ref to its full SHA. -#[tauri::command] -pub async fn git_rev_parse(path: String, reference: String) -> Result { - if reference.starts_with('-') { - return Err(format!("invalid git reference: {reference}")); - } - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&path) { - return Err("not a git repository".into()); - } - git_output(&path, &["rev-parse", &reference]).map(|s| s.trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Posts an inline review comment on a specific file+line via gh api. -#[tauri::command] -pub async fn gh_pr_inline_comment( - path: String, - pr_number: u64, - commit_id: String, - file: String, - line: u64, - start_line: Option, - body: String, -) -> Result { - if line == 0 { - return Err("line must be >= 1".into()); - } - if body.len() > 65_536 { - return Err("comment body exceeds maximum length".into()); - } - let is_valid_sha = !commit_id.is_empty() - && commit_id.len() <= 40 - && commit_id.chars().all(|c| c.is_ascii_hexdigit()); - if !is_valid_sha { - return Err(format!("invalid commit SHA: {commit_id}")); - } - tauri::async_runtime::spawn_blocking(move || { - if !is_git_repo(&path) { - return Err("not a git repository".into()); - } - let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments"); - let mut args = vec![ - "api".to_string(), - endpoint, - "-f".to_string(), - format!("body={body}"), - "-f".to_string(), - format!("commit_id={commit_id}"), - "-f".to_string(), - format!("path={file}"), - "-F".to_string(), - format!("line={line}"), - "-f".to_string(), - "side=RIGHT".to_string(), - ]; - if let Some(sl) = start_line { - if sl < line { - args.extend([ - "-F".to_string(), - format!("start_line={sl}"), - "-f".to_string(), - "start_side=RIGHT".to_string(), - ]); - } - } - args.extend(["--jq".to_string(), ".html_url".to_string()]); - let out = Command::new("gh") - .current_dir(&path) - .args(&args) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns open pull requests for the repo (up to 30). -#[tauri::command] -pub async fn gh_pr_list_open(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let out = Command::new("gh") - .current_dir(&path) - .args([ - "pr", - "list", - "--json", - "number,title,author,headRefName,baseRefName,url", - "--limit", - "30", - ]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - serde_json::from_slice::(&out.stdout).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Edits an existing PR review comment. -#[tauri::command] -pub async fn gh_pr_update_comment( - path: String, - comment_id: u64, - body: String, -) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/comments/{comment_id}"); - let out = Command::new("gh") - .current_dir(&path) - .args([ - "api", - "--method", - "PATCH", - &endpoint, - "-f", - &format!("body={body}"), - ]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Deletes a PR review comment. -#[tauri::command] -pub async fn gh_pr_delete_comment(path: String, comment_id: u64) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/comments/{comment_id}"); - let out = Command::new("gh") - .current_dir(&path) - .args(["api", "--method", "DELETE", &endpoint]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Replies to an existing PR review comment thread. -#[tauri::command] -pub async fn gh_pr_reply_comment( - path: String, - comment_id: u64, - body: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/comments/{comment_id}/replies"); - let out = Command::new("gh") - .current_dir(&path) - .args([ - "api", - "--method", - "POST", - &endpoint, - "-f", - &format!("body={body}"), - "--jq", - ".html_url", - ]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns all inline review comments for a PR as a JSON array. -#[tauri::command] -pub async fn gh_pr_list_comments( - path: String, - pr_number: u64, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments"); - let out = Command::new("gh") - .current_dir(&path) - .args(["api", "--paginate", &endpoint]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - serde_json::from_slice::(&out.stdout).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Submits a pull request review (APPROVE / REQUEST_CHANGES / COMMENT). -#[tauri::command] -pub async fn gh_pr_submit_review( - path: String, - pr_number: u64, - event: String, - body: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let valid_events = ["APPROVE", "REQUEST_CHANGES", "COMMENT"]; - if !valid_events.contains(&event.as_str()) { - return Err(format!("invalid review event: {event}")); - } - let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews"); - let out = Command::new("gh") - .current_dir(&path) - .args([ - "api", - "--method", - "POST", - &endpoint, - "-f", - &format!("event={event}"), - "-f", - &format!("body={body}"), - "--jq", - ".html_url", - ]) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_push(path: String, force_with_lease: Option) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - - let branch = git_output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if branch.is_empty() || branch == "HEAD" { - return Err("cannot push: detached HEAD".into()); - } - - let has_upstream = git_output( - &path, - &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], - ) - .is_ok(); - - let mut cmd = Command::new(&bin); - cmd.arg("-C").arg(&path).arg("push"); - if !has_upstream { - cmd.args(["-u", "origin", &branch]); - } else if force_with_lease.unwrap_or(false) { - cmd.arg("--force-with-lease"); - } - let out = cmd.output().map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_upstream_status(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let branch = current_branch(&path)?; - let upstream = match git_output( - &path, - &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], - ) { - Ok(value) => value.trim().to_string(), - Err(_) => { - return Ok(UpstreamStatus { - branch, - upstream: None, - has_upstream: false, - state: "unpublished".into(), - ahead: 0, - behind: 0, - }) - } - }; - let counts = git_output( - &path, - &["rev-list", "--left-right", "--count", "@{u}...HEAD"], - )?; - let mut parts = counts.split_whitespace(); - let behind = parts - .next() - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let ahead = parts - .next() - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let state = if ahead > 0 && behind > 0 { - "diverged" - } else if behind > 0 { - "behind" - } else if ahead > 0 { - "ahead" - } else { - "synced" - }; - Ok(UpstreamStatus { - branch, - upstream: Some(upstream), - has_upstream: true, - state: state.into(), - ahead, - behind, - }) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_fetch_info(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let raw_path = git_output(&path, &["rev-parse", "--git-path", "FETCH_HEAD"])?; - let fetch_path = Path::new(raw_path.trim()); - let absolute = if fetch_path.is_absolute() { - fetch_path.to_path_buf() - } else { - Path::new(&path).join(fetch_path) - }; - let modified = fs::metadata(absolute) - .and_then(|m| m.modified()) - .ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - Ok(FetchInfo { - fetched_at: modified, - }) - }) - .await - .map_err(|e| e.to_string())? -} - -// Describes the latest automatic backup for the current branch. -#[tauri::command] -pub async fn git_backup_status(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let backup_ref = backup_ref_for(&path)?; - let hash = match git_output(&path, &["rev-parse", "--verify", &backup_ref]) { - Ok(value) => value.trim().to_string(), - Err(_) => { - return Ok(BackupStatus { - available: false, - different: None, - hash: None, - short: None, - subject: None, - }) - } - }; - let head = git_output(&path, &["rev-parse", "HEAD"])? - .trim() - .to_string(); - let subject = git_output(&path, &["log", "-1", "--format=%s", &backup_ref])? - .trim() - .to_string(); - Ok(BackupStatus { - available: true, - different: Some(hash != head), - short: Some(hash.chars().take(7).collect()), - hash: Some(hash), - subject: Some(subject), - }) - }) - .await - .map_err(|e| e.to_string())? -} - -// Lists the bounded automatic backup history, newest first. -// Format: refhashshortsubject. Creation time is encoded in ref. -#[tauri::command] -pub async fn git_backup_list(path: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let branch = current_branch(&path)?; - let prefix = format!("refs/bento/history/{branch}"); - let raw = git_output( - &path, - &[ - "for-each-ref", - "--sort=-refname", - "--format=%(refname)\x1f%(objectname)\x1f%(objectname:short)\x1f%(subject)", - &prefix, - ], - )?; - Ok(raw - .lines() - .filter_map(|line| { - let mut parts = line.split('\x1f'); - let reference = parts.next()?.to_string(); - let hash = parts.next()?.to_string(); - let short = parts.next()?.to_string(); - let subject = parts.next().unwrap_or_default().to_string(); - let created_at = reference.rsplit('/').next()?.parse::().ok()?; - Some(BackupEntry { - reference, - hash, - short, - subject, - created_at, - }) - }) - .collect()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_backup_diff(path: String, target: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let branch = current_branch(&path)?; - let prefix = format!("refs/bento/history/{branch}/"); - if !target.starts_with(&prefix) { - return Err("invalid backup reference".into()); - } - git_output(&path, &["diff", "--no-ext-diff", &target, "HEAD"]) - }) - .await - .map_err(|e| e.to_string())? -} - -// Swaps HEAD with the automatic backup. A clean worktree is required so no -// uncommitted work can be lost; swapping the ref makes the operation reversible. -#[tauri::command] -pub async fn git_restore_backup(path: String, target: Option) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - if !git_output(&path, &["status", "--porcelain"])? - .trim() - .is_empty() - { - return Err("cannot restore backup with uncommitted changes".into()); - } - if resolve_git_dir(&path).join("rebase-merge").exists() { - return Err("cannot restore backup during an active rebase".into()); - } - let backup_ref = backup_ref_for(&path)?; - let branch = current_branch(&path)?; - let history_prefix = format!("refs/bento/history/{branch}/"); - let target_ref = target.unwrap_or_else(|| backup_ref.clone()); - if target_ref != backup_ref && !target_ref.starts_with(&history_prefix) { - return Err("invalid backup reference".into()); - } - let target_hash = git_output(&path, &["rev-parse", "--verify", &target_ref])? - .trim() - .to_string(); - let current = git_output(&path, &["rev-parse", "HEAD"])? - .trim() - .to_string(); - // Preserve the state being left as another history entry. - create_history_backup(&path)?; - git_output(&path, &["update-ref", &backup_ref, ¤t])?; - git_output(&path, &["reset", "--hard", &target_hash]).map(|_| ()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns "\t" matching the format parseAheadBehind expects. -#[tauri::command] -pub async fn git_ahead_behind(path: String, base: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let target = format!("origin/{base}"); - git_output( - &path, - &[ - "rev-list", - "--left-right", - "--count", - &format!("{target}...HEAD"), - ], - ) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_create_pr(path: String, base: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let out = Command::new("gh") - .current_dir(&path) - .args(["pr", "create", "--fill", "--base", &base]) - .output() - .map_err(|e| e.to_string())?; - if out.status.success() { - return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()); - } - // Fallback: return compare URL so the frontend can open it in the browser. - if let Ok(remote) = git_output(&path, &["remote", "get-url", "origin"]) { - let remote = remote.trim().trim_end_matches(".git").to_string(); - let branch = - git_output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default(); - let branch = branch.trim().to_string(); - if !remote.is_empty() && !branch.is_empty() { - return Ok(format!("{remote}/compare/{base}...{branch}?expand=1")); - } - } - Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Resolves the real git directory for both regular repos and worktrees. -// In a worktree, `.git` is a FILE containing "gitdir: "; we read it. -fn resolve_git_dir(path: &str) -> std::path::PathBuf { - let git_path = Path::new(path).join(".git"); - if git_path.is_file() { - if let Ok(content) = fs::read_to_string(&git_path) { - if let Some(gitdir) = content.trim().strip_prefix("gitdir: ") { - return std::path::PathBuf::from(gitdir.trim()); - } - } - } - git_path -} - -// Writes a temp shell script that copies its first argument to our prepared todo file. -// Used as GIT_SEQUENCE_EDITOR so git uses our todo instead of opening $EDITOR. -fn write_sequence_editor_script( - todo_content: &str, -) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { - let pid = std::process::id(); - let todo_path = std::env::temp_dir().join(format!("bento-rebase-todo-{pid}.txt")); - let extension = if cfg!(windows) { "cmd" } else { "sh" }; - let script_path = std::env::temp_dir().join(format!("bento-rebase-editor-{pid}.{extension}")); - - fs::write(&todo_path, todo_content).map_err(|e| e.to_string())?; - #[cfg(windows)] - let script = format!( - "@echo off\r\ncopy /Y \"{}\" \"%~1\" >NUL\r\n", - todo_path.display() - ); - #[cfg(not(windows))] - let script = format!("#!/bin/sh\ncp '{}' \"$1\"\n", todo_path.display()); - fs::write(&script_path, &script).map_err(|e| e.to_string())?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) - .map_err(|e| e.to_string())?; - } - - Ok((todo_path, script_path)) -} - -fn sequence_editor_command(path: &Path, windows: bool) -> String { - let raw = path.to_string_lossy(); - let normalized = if windows { - raw.replace('\\', "/") - } else { - raw.into_owned() - }; - // Git executes GIT_SEQUENCE_EDITOR through a POSIX-style shell, including - // Git for Windows. Single-quote the executable and escape embedded quotes. - format!("'{}'", normalized.replace('\'', "'\"'\"'")) -} - -// Starts an interactive rebase over origin/. `todo_lines` are the rebase instructions -// (e.g. ["pick abc1234 Fix login", "drop def5678 Bad commit"]). -// If git stops at an `edit` step this returns Ok(()) — check git_rebase_status afterwards. -#[tauri::command] -pub async fn git_rebase_start( - path: String, - base: String, - todo_lines: Vec, -) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - if todo_lines.is_empty() { - return Err("nothing to rebase".into()); - } - let target = format!("origin/{base}"); - git_output(&path, &["rev-parse", "--verify", &target])?; - let range = format!("{target}..HEAD"); - let allowed_hashes = git_output(&path, &["rev-list", "--no-merges", &range])?; - for line in &todo_lines { - if line.contains('\n') || line.contains('\r') { - return Err("invalid rebase instruction".into()); - } - let mut parts = line.split_whitespace(); - let action = parts.next().unwrap_or(""); - let hash = parts.next().unwrap_or(""); - if !matches!(action, "pick" | "edit" | "squash" | "fixup" | "drop") - || hash.len() < 7 - || !hash.chars().all(|c| c.is_ascii_hexdigit()) - || !allowed_hashes.lines().any(|allowed| allowed == hash) - { - return Err("rebase instruction contains an invalid action or commit".into()); - } - } - create_history_backup(&path)?; - let todo_content = todo_lines.join("\n") + "\n"; - let (todo_path, script_path) = write_sequence_editor_script(&todo_content)?; - let sequence_editor = sequence_editor_command(&script_path, cfg!(windows)); - - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let out = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("rebase") - .arg("-i") - .arg("--autostash") - .arg(&target) - .env("GIT_SEQUENCE_EDITOR", sequence_editor) - .env("GIT_EDITOR", "true") // suppress editor prompts for squash messages - .output() - .map_err(|e| e.to_string())?; - - let _ = fs::remove_file(&todo_path); - let _ = fs::remove_file(&script_path); - - // Check for pause BEFORE checking exit code: git may exit 0 or non-0 when paused. - let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); - if rebase_dir.exists() { - return Ok(()); - } - - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_rebase_preserve_merges(path: String, base: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let target = format!("origin/{base}"); - git_output(&path, &["rev-parse", "--verify", &target])?; - create_history_backup(&path)?; - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let out = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("rebase") - .arg("--rebase-merges") - .arg("--autostash") - .arg(&target) - .env("GIT_EDITOR", "true") - .output() - .map_err(|e| e.to_string())?; - if resolve_git_dir(&path).join("rebase-merge").exists() { - return Ok("paused".into()); - } - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok("completed".into()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Continues after an `edit` pause. Returns "paused" if git stopped at another edit step. -#[tauri::command] -pub async fn git_rebase_continue(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let out = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("rebase") - .arg("--continue") - .env("GIT_EDITOR", "true") - .output() - .map_err(|e| e.to_string())?; - - // Same pattern as git_rebase_start: check directory before exit code. - let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); - if rebase_dir.exists() { - return Ok("paused".into()); - } - - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_rebase_abort(path: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - git_output(&path, &["rebase", "--abort"]).map(|_| ()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Turns the commit currently paused by an interactive `edit` into worktree -// changes. The user can then create two or more partial commits and continue. -#[tauri::command] -pub async fn git_rebase_split(path: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); - if !rebase_dir.exists() { - return Err("no interactive rebase is active".into()); - } - if !git_output(&path, &["status", "--porcelain"])? - .trim() - .is_empty() - { - return Err("resolve or commit the current worktree changes before splitting".into()); - } - git_output(&path, &["reset", "--mixed", "HEAD^"]).map(|_| ()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Returns JSON with rebase state. Includes `conflicts` array (unmerged files) when paused at a conflict. -#[tauri::command] -pub async fn git_rebase_status(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); - if !rebase_dir.exists() { - return Ok(RebaseStatus { - active: false, - sha: None, - short: None, - subject: None, - body: None, - branch: None, - current: None, - total: None, - conflicts: Vec::new(), - }); - } - // Use HEAD directly — more reliable than stopped-sha (not always written by git). - let sha = git_output(&path, &["rev-parse", "HEAD"]) - .unwrap_or_default() - .trim() - .to_string(); - let short = sha.chars().take(7).collect::(); - let head_name = fs::read_to_string(rebase_dir.join("head-name")) - .unwrap_or_default() - .trim() - .trim_start_matches("refs/heads/") - .to_string(); - let current = fs::read_to_string(rebase_dir.join("msgnum")) - .unwrap_or_default() - .trim() - .parse::() - .unwrap_or(0); - let total = fs::read_to_string(rebase_dir.join("end")) - .unwrap_or_default() - .trim() - .parse::() - .unwrap_or(0); - // Full commit message: subject + body (separated by blank line in git output) - let full_msg = git_output(&path, &["log", "--format=%B", "-1"]) - .unwrap_or_default() - .trim() - .to_string(); - let subject = full_msg.lines().next().unwrap_or("").to_string(); - let body = full_msg.lines().skip(2).collect::>().join("\n"); - - // Detect conflicting files: porcelain status lines where both sides are non-clean (UU, AA, DD, AU, UA, DU, UD). - let status_out = git_output(&path, &["status", "--porcelain"]).unwrap_or_default(); - let conflicts: Vec = status_out - .lines() - .filter(|l| { - l.len() >= 2 && matches!(&l[..2], "UU" | "AA" | "DD" | "AU" | "UA" | "DU" | "UD") - }) - .map(|l| l[3..].trim().to_string()) - .collect(); - - Ok(RebaseStatus { - active: true, - sha: Some(sha), - short: Some(short), - subject: Some(subject), - body: Some(body), - branch: Some(head_name), - current: Some(current), - total: Some(total), - conflicts, - }) - }) - .await - .map_err(|e| e.to_string())? -} - -// Lists files changed in a commit: returns lines of "\t" (M, A, D, R…). -#[tauri::command] -pub async fn git_show_files(path: String, hash: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - git_output( - &path, - &["diff-tree", "--no-commit-id", "-r", "--name-status", &hash], - ) - .map(|raw| { - raw.lines() - .filter_map(|line| { - let mut fields = line.split('\t'); - let status = fields.next()?.to_string(); - let paths = fields.map(str::to_string).collect::>(); - if paths.is_empty() { - None - } else { - Some(CommitFile { status, paths }) - } - }) - .collect() - }) - }) - .await - .map_err(|e| e.to_string())? -} - -// Shows the patch introduced by one commit, optionally limited to one file. -#[tauri::command] -pub async fn git_show_commit_diff( - path: String, - hash: String, - file: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let mut args = vec![ - "show", - "--format=", - "--find-renames", - "--no-ext-diff", - &hash, - "--", - ]; - if let Some(ref file_path) = file { - args.push(file_path); - } - git_output(&path, &args) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn git_show_file(path: String, hash: String, file: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - if hash.len() < 7 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("invalid commit hash".into()); - } - let spec = format!("{hash}:{file}"); - match git_output(&path, &["show", &spec]) { - Ok(content) => Ok(content), - Err(_) => { - // Deleted files only exist in the commit's first parent. - let parent_spec = format!("{hash}^:{file}"); - git_output(&path, &["show", &parent_spec]) - } - } - }) - .await - .map_err(|e| e.to_string())? -} - -// Scores task commits by how often they appear in the selected files' history. -// Format: full-hashscorecomma-separated-files -#[tauri::command] -pub async fn git_recommend_commits( - path: String, - base: String, - files: Vec, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - let range = format!("origin/{base}..HEAD"); - let mut scores = std::collections::HashMap::)>::new(); - for file in files.iter().take(200) { - let history = - git_output(&path, &["log", "--format=%H", &range, "--", file]).unwrap_or_default(); - for hash in history.lines() { - let entry = scores.entry(hash.to_string()).or_insert((0, Vec::new())); - entry.0 += 1; - if !entry.1.contains(file) { - entry.1.push(file.clone()); - } - } - } - let mut rows: Vec<_> = scores.into_iter().collect(); - rows.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); - Ok(rows - .into_iter() - .map(|(hash, (score, files))| CommitRecommendation { hash, score, files }) - .collect()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Attributes the original line ranges touched by an incoming patch to task -// commits using git blame. Same output format as git_recommend_commits. -#[tauri::command] -pub async fn git_blame_recommend( - path: String, - base: String, - patch: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - if !is_safe_branch(&base) { - return Err(format!("unsafe base branch: {base}")); - } - if patch.len() > 16 * 1024 * 1024 { - return Err("patch is too large".into()); - } - let range = format!("origin/{base}..HEAD"); - let allowed: std::collections::HashSet = git_output(&path, &["rev-list", &range])? - .lines() - .map(str::to_string) - .collect(); - let mut current_file = String::new(); - let mut ranges = Vec::<(String, u32, u32)>::new(); - for line in patch.lines() { - if let Some(rest) = line.strip_prefix("diff --git a/") { - current_file = rest.split(" b/").next().unwrap_or("").to_string(); - } else if line.starts_with("@@ -") && !current_file.is_empty() { - let old_spec = line - .split_whitespace() - .nth(1) - .unwrap_or("") - .trim_start_matches('-'); - let mut values = old_spec.split(','); - let start = values - .next() - .and_then(|v| v.parse::().ok()) - .unwrap_or(1) - .max(1); - let count = values - .next() - .and_then(|v| v.parse::().ok()) - .unwrap_or(1) - .max(1); - ranges.push((current_file.clone(), start, start.saturating_add(count - 1))); - } - } - - let mut scores = std::collections::HashMap::)>::new(); - for (file, start, end) in ranges.into_iter().take(500) { - let line_range = format!("{start},{end}"); - let blame = git_output( - &path, - &[ - "blame", - "--line-porcelain", - "-L", - &line_range, - "HEAD", - "--", - &file, - ], - ) - .unwrap_or_default(); - for line in blame.lines() { - let hash = line.split_whitespace().next().unwrap_or(""); - if line.len() >= 41 && hash.len() == 40 && allowed.contains(hash) { - let entry = scores.entry(hash.to_string()).or_insert((0, Vec::new())); - entry.0 += 1; - if !entry.1.contains(&file) { - entry.1.push(file.clone()); - } - } - } - } - let mut rows: Vec<_> = scores.into_iter().collect(); - rows.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); - Ok(rows - .into_iter() - .map(|(hash, (score, files))| CommitRecommendation { hash, score, files }) - .collect()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Resolves a rebase conflict by checking out one side and staging the result. -// `side` must be "ours" or "theirs". -#[tauri::command] -pub async fn git_resolve_conflict(path: String, file: String, side: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let flag = if side == "theirs" { - "--theirs" - } else { - "--ours" - }; - let co = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("checkout") - .arg(flag) - .arg("--") - .arg(&file) - .output() - .map_err(|e| e.to_string())?; - if !co.status.success() { - return Err(String::from_utf8_lossy(&co.stderr).trim().to_string()); - } - let add = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("add") - .arg("--") - .arg(&file) - .output() - .map_err(|e| e.to_string())?; - if !add.status.success() { - return Err(String::from_utf8_lossy(&add.stderr).trim().to_string()); - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -// Stages specific files (used after manually resolving conflicts in an editor). -#[tauri::command] -pub async fn git_add_files( - path: String, - files: Vec, -) -> Result<(), crate::command_error::CommandError> { - tauri::async_runtime::spawn_blocking(move || add_files_blocking(&path, &files)) - .await - .map_err(|e| crate::command_error::CommandError::runtime(e.to_string()))? - .map_err(crate::command_error::CommandError::git) -} - -fn add_files_blocking(path: &str, files: &[String]) -> Result<(), String> { - // Validate using canonical paths, but pass the original relative paths - // to Git. Absolute canonical paths are rejected by Git when the - // worktree itself was reached through a symlink (notably /var -> - // /private/var on macOS), even though the file is inside the worktree. - for file in files { - crate::git_paths::existing_worktree_file(path, file)?; - } - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - for attempt in 0..=30 { - let mut cmd = Command::new(&bin); - cmd.arg("-C").arg(path).arg("add").arg("--"); - for file in files { - cmd.arg(file); - } - let out = cmd.output().map_err(|e| e.to_string())?; - if out.status.success() { - return Ok(()); - } - let error = String::from_utf8_lossy(&out.stderr).trim().to_string(); - let index_is_busy = error.contains("index.lock") && error.contains("File exists"); - if !index_is_busy || attempt == 30 { - return Err(error); - } - // A status/rebase command may briefly own the shared worktree index. - // Never delete its lock: wait for the owner and retry only this known - // transient error. - std::thread::sleep(std::time::Duration::from_millis(100)); - } - unreachable!() -} - -// Reads a file from a worktree — used by the inline conflict resolver. -#[tauri::command] -pub async fn git_read_file( - path: String, - file: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || -> Result { - let safe_path = crate::git_paths::existing_worktree_file(&path, &file)?; - fs::read_to_string(safe_path).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| crate::command_error::CommandError::runtime(e.to_string()))? - .map_err(crate::command_error::CommandError::git) -} - -// Writes resolved content back to a worktree file — used by the inline conflict resolver. -#[tauri::command] -pub async fn git_write_file( - path: String, - file: String, - content: String, -) -> Result<(), crate::command_error::CommandError> { - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { - let safe_path = crate::git_paths::existing_worktree_file(&path, &file)?; - fs::write(safe_path, content).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| crate::command_error::CommandError::runtime(e.to_string()))? - .map_err(crate::command_error::CommandError::git) -} - -// Resets HEAD to `target` (e.g. "origin/main"). -// mode: "soft" | "mixed" (default) | "hard" -#[tauri::command] -pub async fn git_reset(path: String, target: String, mode: Option) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - create_history_backup(&path)?; - let flag = match mode.as_deref().unwrap_or("mixed") { - "soft" => "--soft", - "hard" => "--hard", - _ => "--mixed", - }; - let bin = git_bin().ok_or_else(|| "git not found".to_string())?; - let out = Command::new(&bin) - .arg("-C") - .arg(&path) - .arg("reset") - .arg(flag) - .arg(&target) - .output() - .map_err(|e| e.to_string())?; - if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); - } - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn open_in_editor(path: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - for editor in &["cursor", "code"] { - if let Some(found) = login_shell_output(&format!("command -v {editor}")) { - let bin_path = found.trim().to_string(); - if !bin_path.is_empty() && Command::new(&bin_path).arg(&path).spawn().is_ok() { - return Ok(()); - } - } - } - #[cfg(target_os = "macos")] - Command::new("open") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - #[cfg(target_os = "linux")] - Command::new("xdg-open") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - #[cfg(target_os = "windows")] - Command::new("explorer.exe") - .arg(&path) - .spawn() - .map_err(|e| e.to_string())?; - Ok(()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::{Path, PathBuf}; - - struct TestRepo(PathBuf); - - impl Drop for TestRepo { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - fn repo(name: &str) -> TestRepo { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = - std::env::temp_dir().join(format!("bento-{name}-{}-{stamp}", std::process::id())); - fs::create_dir_all(&path).unwrap(); - run(&path, &["init", "-q"]); - run(&path, &["config", "user.email", "bento-tests@example.com"]); - run(&path, &["config", "user.name", "Bento Tests"]); - TestRepo(path) - } - - fn run(path: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .output() - .unwrap(); - assert!( - out.status.success(), - "git {:?}: {}", - args, - String::from_utf8_lossy(&out.stderr) - ); - String::from_utf8_lossy(&out.stdout).to_string() - } - - fn commit_file(path: &Path, content: &str, message: &str) { - fs::write(path.join("file.txt"), content).unwrap(); - run(path, &["add", "file.txt"]); - run(path, &["commit", "-qm", message]); - } - - #[test] - fn review_branches_include_local_tasks_and_qualified_remotes() { - let branches = parse_review_branches( - "main\nfeat/NIXON-501\n", - "origin/HEAD\norigin/main\nupstream/release\n", - ); - assert_eq!(branches, vec![ - "main", - "feat/NIXON-501", - "origin/main", - "upstream/release", - ]); - } - - #[test] - fn review_worktree_diff_includes_committed_uncommitted_and_untracked_changes() { - let repo = repo("review-worktree-diff"); - commit_file(&repo.0, "base\n", "base"); - let base = run(&repo.0, &["branch", "--show-current"]).trim().to_string(); - run(&repo.0, &["checkout", "-qb", "feat/task"]); - commit_file(&repo.0, "committed\n", "task commit"); - fs::write(repo.0.join("file.txt"), "working\n").unwrap(); - fs::write(repo.0.join("new.txt"), "untracked\n").unwrap(); - - let diff = collect_review_worktree_diff(repo.0.to_str().unwrap(), &base).unwrap(); - assert!(diff.contains("diff --git a/file.txt b/file.txt"), "{diff}"); - assert!(diff.contains("+working"), "{diff}"); - assert!(diff.contains("diff --git a/new.txt b/new.txt"), "{diff}"); - assert!(diff.contains("+untracked"), "{diff}"); - } - - #[test] - fn diff_between_refs_resolves_commit_ids_before_diffing() { - let repo = repo("ref-diff"); - commit_file(&repo.0, "base\n", "base"); - run(&repo.0, &["branch", "origin/base"]); - run(&repo.0, &["checkout", "-qb", "origin/feature"]); - commit_file(&repo.0, "feature\n", "feature"); - - let diff = diff_between_refs(repo.0.to_str().unwrap(), "origin/base", "origin/feature").unwrap(); - assert!(diff.contains("diff --git a/file.txt b/file.txt"), "{diff}"); - assert!(diff.contains("+feature"), "{diff}"); - } - - #[test] - fn stages_validated_relative_worktree_files() { - let repo = repo("add-files"); - fs::write(repo.0.join("file.txt"), "resolved\n").unwrap(); - add_files_blocking(repo.0.to_str().unwrap(), &["file.txt".into()]).unwrap(); - assert_eq!( - run(&repo.0, &["diff", "--cached", "--name-only"]).trim(), - "file.txt" - ); - } - - #[test] - fn waits_for_a_transient_git_index_lock_before_staging() { - let repo = repo("add-files-lock"); - fs::write(repo.0.join("file.txt"), "resolved\n").unwrap(); - let lock = repo.0.join(".git/index.lock"); - fs::write(&lock, "busy").unwrap(); - let release = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(250)); - fs::remove_file(lock).unwrap(); - }); - - add_files_blocking(repo.0.to_str().unwrap(), &["file.txt".into()]).unwrap(); - release.join().unwrap(); - assert_eq!( - run(&repo.0, &["diff", "--cached", "--name-only"]).trim(), - "file.txt" - ); - } - - #[test] - fn history_backup_points_to_pre_operation_head() { - let repo = repo("backup"); - commit_file(&repo.0, "one\n", "first"); - let original = run(&repo.0, &["rev-parse", "HEAD"]); - let backup_ref = create_history_backup(repo.0.to_str().unwrap()).unwrap(); - commit_file(&repo.0, "two\n", "second"); - let saved = run(&repo.0, &["rev-parse", &backup_ref]); - assert_eq!(saved.trim(), original.trim()); - let history = run( - &repo.0, - &["for-each-ref", "--format=%(refname)", "refs/bento/history"], - ); - assert_eq!(history.lines().count(), 1); - } - - #[test] - fn worktree_diff_includes_untracked_files_without_staging_them() { - let repo = repo("untracked"); - commit_file(&repo.0, "base\n", "base"); - fs::write(repo.0.join("new.txt"), "new content\n").unwrap(); - let diff = collect_worktree_diff(repo.0.to_str().unwrap()).unwrap(); - assert!(diff.contains("diff --git a/new.txt b/new.txt")); - assert!(diff.contains("+new content")); - assert_eq!(run(&repo.0, &["status", "--short"]).trim(), "?? new.txt"); - } - - #[test] - fn partial_patch_stages_only_the_selected_hunk() { - let repo = repo("partial"); - let original = (1..=12).map(|n| format!("line {n}\n")).collect::(); - commit_file(&repo.0, &original, "base"); - let changed = original - .replace("line 1\n", "changed one\n") - .replace("line 12\n", "changed twelve\n"); - fs::write(repo.0.join("file.txt"), changed).unwrap(); - let diff = run(&repo.0, &["diff", "--unified=0"]); - let second_hunk = diff - .match_indices("@@") - .nth(2) - .map(|(index, _)| index) - .unwrap(); - let selected = &diff[..second_hunk]; - apply_selected_patch(repo.0.to_str().unwrap(), selected).unwrap(); - let staged = run(&repo.0, &["diff", "--cached"]); - let unstaged = run(&repo.0, &["diff"]); - assert!(staged.contains("changed one")); - assert!(!staged.contains("changed twelve")); - assert!(unstaged.contains("changed twelve")); - } - - #[test] - fn autosquash_integrates_fixup_into_selected_commit() { - let repo = repo("autosquash"); - commit_file(&repo.0, "root\n", "root"); - run(&repo.0, &["branch", "base"]); - commit_file(&repo.0, "target\n", "target commit"); - let target = run(&repo.0, &["rev-parse", "HEAD"]); - fs::write(repo.0.join("other.txt"), "later\n").unwrap(); - run(&repo.0, &["add", "other.txt"]); - run(&repo.0, &["commit", "-qm", "later commit"]); - fs::write(repo.0.join("file.txt"), "target with fix\n").unwrap(); - run(&repo.0, &["add", "file.txt"]); - run(&repo.0, &["commit", &format!("--fixup={}", target.trim())]); - - let out = Command::new("git") - .arg("-C") - .arg(&repo.0) - .args(["rebase", "-i", "--autosquash", "base"]) - .env("GIT_SEQUENCE_EDITOR", "true") - .env("GIT_EDITOR", "true") - .output() - .unwrap(); - assert!( - out.status.success(), - "{}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - run(&repo.0, &["rev-list", "--count", "base..HEAD"]).trim(), - "2" - ); - assert_eq!( - fs::read_to_string(repo.0.join("file.txt")).unwrap(), - "target with fix\n" - ); - assert!(!run(&repo.0, &["log", "--format=%s", "base..HEAD"]).contains("fixup!")); - } - - #[test] - fn force_with_lease_rejects_a_remote_changed_by_someone_else() { - let repo = repo("lease"); - commit_file(&repo.0, "initial\n", "initial"); - let remote = repo.0.join("remote.git"); - let collab = repo.0.join("collab"); - let init = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&remote) - .output() - .unwrap(); - assert!(init.status.success()); - run( - &repo.0, - &["remote", "add", "origin", remote.to_str().unwrap()], - ); - run(&repo.0, &["push", "-u", "origin", "HEAD"]); - - let clone = Command::new("git") - .arg("clone") - .arg("-q") - .arg(&remote) - .arg(&collab) - .output() - .unwrap(); - assert!( - clone.status.success(), - "{}", - String::from_utf8_lossy(&clone.stderr) - ); - run(&collab, &["config", "user.email", "collab@example.com"]); - run(&collab, &["config", "user.name", "Collaborator"]); - fs::write(collab.join("file.txt"), "remote change\n").unwrap(); - run(&collab, &["add", "file.txt"]); - run(&collab, &["commit", "-qm", "remote change"]); - run(&collab, &["push", "-q"]); - - fs::write(repo.0.join("file.txt"), "local rewrite\n").unwrap(); - run(&repo.0, &["add", "file.txt"]); - run(&repo.0, &["commit", "-qm", "local rewrite"]); - let push = Command::new("git") - .arg("-C") - .arg(&repo.0) - .args(["push", "--force-with-lease"]) - .output() - .unwrap(); - assert!( - !push.status.success(), - "force-with-lease unexpectedly overwrote a changed remote" - ); - } - - #[test] - fn conflicted_rebase_can_be_aborted_without_losing_the_original_head() { - let repo = repo("abort"); - commit_file(&repo.0, "shared\n", "root"); - let base_branch = run(&repo.0, &["rev-parse", "--abbrev-ref", "HEAD"]); - run(&repo.0, &["branch", "task"]); - commit_file(&repo.0, "base version\n", "base change"); - run(&repo.0, &["checkout", "-q", "task"]); - commit_file(&repo.0, "task version\n", "task change"); - let original_head = run(&repo.0, &["rev-parse", "HEAD"]); - let rebase = Command::new("git") - .arg("-C") - .arg(&repo.0) - .args(["rebase", base_branch.trim()]) - .output() - .unwrap(); - assert!(!rebase.status.success()); - assert!(resolve_git_dir(repo.0.to_str().unwrap()) - .join("rebase-merge") - .exists()); - run(&repo.0, &["rebase", "--abort"]); - assert_eq!( - run(&repo.0, &["rev-parse", "HEAD"]).trim(), - original_head.trim() - ); - assert_eq!( - fs::read_to_string(repo.0.join("file.txt")).unwrap(), - "task version\n" - ); - } - - #[test] - fn rewrite_preflight_reports_dirty_published_signing_and_hooks() { - let repo = repo("preflight"); - commit_file(&repo.0, "root\n", "root"); - run(&repo.0, &["branch", "-M", "main"]); - run(&repo.0, &["update-ref", "refs/remotes/origin/main", "HEAD"]); - run(&repo.0, &["checkout", "-qb", "task"]); - commit_file(&repo.0, "task\n", "task commit"); - run(&repo.0, &["branch", "published", "HEAD"]); - run(&repo.0, &["branch", "--set-upstream-to=published"]); - run(&repo.0, &["config", "commit.gpgsign", "true"]); - let hooks = resolve_git_dir(repo.0.to_str().unwrap()).join("hooks"); - fs::write(hooks.join("pre-rebase"), "#!/bin/sh\n").unwrap(); - fs::write(repo.0.join("dirty.txt"), "dirty\n").unwrap(); - - let report = tauri::async_runtime::block_on(git_rewrite_preflight( - repo.0.to_string_lossy().to_string(), - "main".into(), - )) - .unwrap(); - assert!(report.dirty); - assert_eq!(report.published_commits, 1); - assert!(report.signing); - assert!(report.hooks.contains(&"pre-rebase".to_string())); - } - - #[test] - fn split_rebase_returns_paused_commit_to_the_worktree() { - let repo = repo("split"); - commit_file(&repo.0, "root\n", "root"); - run(&repo.0, &["branch", "base"]); - commit_file(&repo.0, "changed\n", "change to split"); - let commit = run(&repo.0, &["rev-parse", "HEAD"]); - let todo = format!("edit {} change to split\n", commit.trim()); - let (todo_path, script_path) = write_sequence_editor_script(&todo).unwrap(); - let out = Command::new("git") - .arg("-C") - .arg(&repo.0) - .args(["rebase", "-i", "base"]) - .env("GIT_SEQUENCE_EDITOR", &script_path) - .env("GIT_EDITOR", "true") - .output() - .unwrap(); - let _ = fs::remove_file(todo_path); - let _ = fs::remove_file(script_path); - assert!( - out.status.success(), - "{}", - String::from_utf8_lossy(&out.stderr) - ); - assert!(resolve_git_dir(repo.0.to_str().unwrap()) - .join("rebase-merge") - .exists()); - - tauri::async_runtime::block_on(git_rebase_split(repo.0.to_string_lossy().to_string())) - .unwrap(); - let status = run(&repo.0, &["status", "--short"]); - assert!(status.contains("file.txt")); - assert_eq!( - fs::read_to_string(repo.0.join("file.txt")).unwrap(), - "changed\n" - ); - run(&repo.0, &["rebase", "--abort"]); - } - - #[test] - fn parses_typed_worktrees_and_ignores_bare_entries() { - let raw = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /bare\nHEAD def456\nbare\n"; - let worktrees = parse_worktrees(raw); - assert_eq!(worktrees.len(), 1); - assert_eq!(worktrees[0].path, "/repo"); - assert_eq!(worktrees[0].branch.as_deref(), Some("main")); - assert!(!worktrees[0].bare); - } - - #[test] - fn parses_windows_crlf_worktree_records() { - let raw = "worktree C:\\repo\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\nworktree C:\\repo task\r\nHEAD def456\r\nbranch refs/heads/task/e2e\r\n"; - let worktrees = parse_worktrees(raw); - assert_eq!(worktrees.len(), 2); - assert_eq!(worktrees[0].path, "C:\\repo"); - assert_eq!(worktrees[1].path, "C:\\repo task"); - assert_eq!(worktrees[1].branch.as_deref(), Some("task/e2e")); - } - - #[test] - fn quotes_windows_sequence_editor_for_gits_shell() { - let path = Path::new(r"C:\Users\Runner Admin\Temp\bento-rebase-editor.cmd"); - assert_eq!( - sequence_editor_command(path, true), - "'C:/Users/Runner Admin/Temp/bento-rebase-editor.cmd'" - ); - } - - #[test] - fn parses_typed_status_counts_and_preserves_porcelain() { - let raw = " M a.txt\nM b.txt\nMM c.txt\n?? d.txt\n".to_string(); - let status = parse_status(raw.clone()); - assert_eq!(status.raw, raw); - assert_eq!( - ( - status.staged, - status.unstaged, - status.untracked, - status.total - ), - (2, 2, 1, 4) - ); - } - - #[test] - fn parses_typed_commit_log() { - let commits = parse_commit_log("abcdef\x1fabc\x1fSubject\x1fnow\x1fAda\n".into()); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].hash, "abcdef"); - assert_eq!(commits[0].subject, "Subject"); - assert_eq!(commits[0].author, "Ada"); - } -} diff --git a/src-tauri/src/git/backup.rs b/src-tauri/src/git/backup.rs new file mode 100644 index 0000000..f2d1bbd --- /dev/null +++ b/src-tauri/src/git/backup.rs @@ -0,0 +1,205 @@ +use super::*; + + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct BackupStatus { + available: bool, + different: Option, + hash: Option, + short: Option, + subject: Option, +} + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct BackupEntry { + reference: String, + hash: String, + short: String, + subject: String, + #[ts(type = "number")] + created_at: u64, +} + +pub(super) fn backup_ref_for(path: &str) -> Result { + Ok(format!("refs/bento/backups/{}", current_branch(path)?)) +} + +pub(super) fn create_history_backup(path: &str) -> Result { + let backup_ref = backup_ref_for(path)?; + git_output(path, &["update-ref", &backup_ref, "HEAD"])?; + let branch = current_branch(path)?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis(); + let history_ref = format!("refs/bento/history/{branch}/{stamp}"); + git_output(path, &["update-ref", &history_ref, "HEAD"])?; + + // Keep the history bounded per branch. + let prefix = format!("refs/bento/history/{branch}"); + if let Ok(refs) = git_output( + path, + &[ + "for-each-ref", + "--sort=-refname", + "--format=%(refname)", + &prefix, + ], + ) { + for old_ref in refs.lines().skip(20) { + let _ = git_output(path, &["update-ref", "-d", old_ref]); + } + } + Ok(backup_ref) +} + +// Describes the latest automatic backup for the current branch. +#[tauri::command] +pub async fn git_backup_status(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let backup_ref = backup_ref_for(&path)?; + let hash = match git_output(&path, &["rev-parse", "--verify", &backup_ref]) { + Ok(value) => value.trim().to_string(), + Err(_) => { + return Ok(BackupStatus { + available: false, + different: None, + hash: None, + short: None, + subject: None, + }) + } + }; + let head = git_output(&path, &["rev-parse", "HEAD"])? + .trim() + .to_string(); + let subject = git_output(&path, &["log", "-1", "--format=%s", &backup_ref])? + .trim() + .to_string(); + Ok(BackupStatus { + available: true, + different: Some(hash != head), + short: Some(hash.chars().take(7).collect()), + hash: Some(hash), + subject: Some(subject), + }) + }) + .await + .map_err(|e| e.to_string())? +} + +// Lists the bounded automatic backup history, newest first. +// Format: refhashshortsubject. Creation time is encoded in ref. +#[tauri::command] +pub async fn git_backup_list(path: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let branch = current_branch(&path)?; + let prefix = format!("refs/bento/history/{branch}"); + let raw = git_output( + &path, + &[ + "for-each-ref", + "--sort=-refname", + "--format=%(refname)\x1f%(objectname)\x1f%(objectname:short)\x1f%(subject)", + &prefix, + ], + )?; + Ok(raw + .lines() + .filter_map(|line| { + let mut parts = line.split('\x1f'); + let reference = parts.next()?.to_string(); + let hash = parts.next()?.to_string(); + let short = parts.next()?.to_string(); + let subject = parts.next().unwrap_or_default().to_string(); + let created_at = reference.rsplit('/').next()?.parse::().ok()?; + Some(BackupEntry { + reference, + hash, + short, + subject, + created_at, + }) + }) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_backup_diff(path: String, target: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let branch = current_branch(&path)?; + let prefix = format!("refs/bento/history/{branch}/"); + if !target.starts_with(&prefix) { + return Err("invalid backup reference".into()); + } + git_output(&path, &["diff", "--no-ext-diff", &target, "HEAD"]) + }) + .await + .map_err(|e| e.to_string())? +} + +// Swaps HEAD with the automatic backup. A clean worktree is required so no +// uncommitted work can be lost; swapping the ref makes the operation reversible. +#[tauri::command] +pub async fn git_restore_backup(path: String, target: Option) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + if !git_output(&path, &["status", "--porcelain"])? + .trim() + .is_empty() + { + return Err("cannot restore backup with uncommitted changes".into()); + } + if resolve_git_dir(&path).join("rebase-merge").exists() { + return Err("cannot restore backup during an active rebase".into()); + } + let backup_ref = backup_ref_for(&path)?; + let branch = current_branch(&path)?; + let history_prefix = format!("refs/bento/history/{branch}/"); + let target_ref = target.unwrap_or_else(|| backup_ref.clone()); + if target_ref != backup_ref && !target_ref.starts_with(&history_prefix) { + return Err("invalid backup reference".into()); + } + let target_hash = git_output(&path, &["rev-parse", "--verify", &target_ref])? + .trim() + .to_string(); + let current = git_output(&path, &["rev-parse", "HEAD"])? + .trim() + .to_string(); + // Preserve the state being left as another history entry. + create_history_backup(&path)?; + git_output(&path, &["update-ref", &backup_ref, ¤t])?; + git_output(&path, &["reset", "--hard", &target_hash]).map(|_| ()) + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn history_backup_points_to_pre_operation_head() { + let repo = repo("backup"); + commit_file(&repo.0, "one\n", "first"); + let original = run(&repo.0, &["rev-parse", "HEAD"]); + let backup_ref = create_history_backup(repo.0.to_str().unwrap()).unwrap(); + commit_file(&repo.0, "two\n", "second"); + let saved = run(&repo.0, &["rev-parse", &backup_ref]); + assert_eq!(saved.trim(), original.trim()); + let history = run( + &repo.0, + &["for-each-ref", "--format=%(refname)", "refs/bento/history"], + ); + assert_eq!(history.lines().count(), 1); + } +} diff --git a/src-tauri/src/git/branches.rs b/src-tauri/src/git/branches.rs new file mode 100644 index 0000000..f54aede --- /dev/null +++ b/src-tauri/src/git/branches.rs @@ -0,0 +1,132 @@ +use super::*; + + +#[tauri::command] +pub async fn git_default_branch(repo: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + // Try origin/HEAD first. + if let Ok(out) = git_output( + &repo, + &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + ) { + let branch = out.trim().trim_start_matches("origin/").to_string(); + if !branch.is_empty() { + return Ok(branch); + } + } + // Fall back to checking for `main`, then `master`. + if git_output(&repo, &["rev-parse", "--verify", "main"]).is_ok() { + return Ok("main".into()); + } + Ok("master".into()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_remote_branches(repo: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&repo) { + return Err("not a git repository".into()); + } + let raw = git_output( + &repo, + &[ + "for-each-ref", + "--format=%(refname:short)", + "refs/remotes/origin", + ], + )?; + Ok(raw + .lines() + .filter_map(|line| line.strip_prefix("origin/")) + .filter(|branch| *branch != "HEAD" && is_safe_branch(branch)) + .map(str::to_string) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Lists branches from ALL remotes with full remote/branch format (e.g. "daimoxd/feat/foo"). +#[tauri::command] +pub async fn git_all_remote_branches(repo: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&repo) { + return Err("not a git repository".into()); + } + let raw = git_output( + &repo, + &["for-each-ref", "--format=%(refname:short)", "refs/remotes"], + )?; + Ok(raw + .lines() + .filter(|line| !line.ends_with("/HEAD") && is_safe_branch(line)) + .map(str::to_string) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} + +fn parse_review_branches(local: &str, remote: &str) -> Vec { + let mut branches = Vec::new(); + for branch in local.lines().chain(remote.lines()) { + let branch = branch.trim(); + if branch.is_empty() + || branch == "HEAD" + || branch.ends_with("/HEAD") + || !is_safe_branch(branch) + || branches.iter().any(|existing| existing == branch) + { + continue; + } + branches.push(branch.to_string()); + } + branches +} + +/// Branches available for review: local task/worktree branches first, followed +/// by fully-qualified remote branches such as `origin/main`. + +#[tauri::command] +pub async fn git_review_branches(repo: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&repo) { + return Err("not a git repository".into()); + } + let local = git_output( + &repo, + &["for-each-ref", "--format=%(refname:short)", "refs/heads"], + )?; + let remote = git_output( + &repo, + &["for-each-ref", "--format=%(refname:short)", "refs/remotes"], + )?; + Ok(parse_review_branches(&local, &remote)) + }) + .await + .map_err(|error| error.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn review_branches_include_local_tasks_and_qualified_remotes() { + let branches = parse_review_branches( + "main\nfeat/NIXON-501\n", + "origin/HEAD\norigin/main\nupstream/release\n", + ); + assert_eq!(branches, vec![ + "main", + "feat/NIXON-501", + "origin/main", + "upstream/release", + ]); + } +} diff --git a/src-tauri/src/git/commit.rs b/src-tauri/src/git/commit.rs new file mode 100644 index 0000000..d47d791 --- /dev/null +++ b/src-tauri/src/git/commit.rs @@ -0,0 +1,274 @@ +use super::*; +use super::backup::{backup_ref_for, create_history_backup}; + + +// Validates that a commit message is non-empty (trust boundary: frontend input). +fn is_valid_message(msg: &str) -> bool { + !msg.trim().is_empty() +} + +fn apply_selected_patch(path: &str, patch: &str) -> Result<(), String> { + if patch.trim().is_empty() || !patch.contains("diff --git ") { + return Err("selected patch is empty or invalid".into()); + } + if patch.len() > 16 * 1024 * 1024 { + return Err("selected patch is too large".into()); + } + // Clear the index only; working-tree contents are preserved. This ensures + // unrelated staged paths cannot leak into the partial commit. + git_output(path, &["reset", "--mixed", "HEAD"])?; + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let mut child = Command::new(&bin) + .arg("-C") + .arg(path) + .arg("apply") + .arg("--cached") + .arg("--unidiff-zero") + .arg("--whitespace=nowarn") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + child + .stdin + .as_mut() + .ok_or("could not open git apply stdin")? + .write_all(patch.as_bytes()) + .map_err(|e| e.to_string())?; + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + let _ = git_output(path, &["reset", "--mixed", "HEAD"]); + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) +} + +// `files`: stage only specific paths; if empty/None, stages everything. +// `amend`: if true, amends the last commit. An empty `message` keeps the original message (--no-edit). +#[tauri::command] +pub async fn git_commit( + path: String, + message: String, + amend: Option, + files: Option>, + patch: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let do_amend = amend.unwrap_or(false); + if !do_amend && !is_valid_message(&message) { + return Err("commit message cannot be empty".into()); + } + + let selected = files.as_ref().filter(|items| !items.is_empty()); + if let Some(ref selected_patch) = patch { + apply_selected_patch(&path, selected_patch)?; + } else if let Some(items) = selected { + // Intent-to-add makes new files known to `commit --only`; --only + // then ignores every unrelated path already present in the index. + let mut args = vec!["add", "-N", "--"]; + args.extend(items.iter().map(String::as_str)); + git_output(&path, &args)?; + } else { + git_output(&path, &["add", "-A"])?; + } + + let mut commit_args = vec!["commit"]; + if do_amend { + commit_args.push("--amend"); + } + if is_valid_message(&message) { + commit_args.extend(["-m", &message]); + } else { + commit_args.push("--no-edit"); + } + if patch.is_none() { + if let Some(items) = selected { + commit_args.push("--only"); + commit_args.push("--"); + commit_args.extend(items.iter().map(String::as_str)); + } + } + let result = git_output(&path, &commit_args); + if result.is_err() && patch.is_some() { + let _ = git_output(&path, &["reset", "--mixed", "HEAD"]); + } + result + }) + .await + .map_err(|e| e.to_string())? +} + +// Adds the selected working-tree changes to an existing task commit by creating +// a fixup commit and immediately autosquashing it over origin/. +#[tauri::command] +pub async fn git_fixup( + path: String, + target: String, + base: String, + files: Option>, + patch: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + if target.len() < 7 || !target.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid target commit".into()); + } + + let base_ref = format!("origin/{base}"); + git_output(&path, &["rev-parse", "--verify", &base_ref])?; + let range = format!("{base_ref}..HEAD"); + let branch_commits = git_output(&path, &["rev-list", &range])?; + if !branch_commits.lines().any(|hash| hash == target) { + return Err("target commit is not part of this task branch".into()); + } + + create_history_backup(&path)?; + + let selected = files.as_ref().filter(|items| !items.is_empty()); + if let Some(ref selected_patch) = patch { + apply_selected_patch(&path, selected_patch)?; + } else if let Some(items) = selected { + let mut args = vec!["add", "-N", "--"]; + args.extend(items.iter().map(String::as_str)); + git_output(&path, &args)?; + } else { + git_output(&path, &["add", "-A"])?; + } + + let fixup_arg = format!("--fixup={target}"); + let mut commit_args = vec!["commit", fixup_arg.as_str()]; + if patch.is_none() { + if let Some(items) = selected { + commit_args.push("--only"); + commit_args.push("--"); + commit_args.extend(items.iter().map(String::as_str)); + } + } + if let Err(error) = git_output(&path, &commit_args) { + if patch.is_some() { + let _ = git_output(&path, &["reset", "--mixed", "HEAD"]); + } + return Err(format!( + "{error}\n\nNo se creó el fixup; los cambios siguen en el worktree." + )); + } + + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let out = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("rebase") + .arg("-i") + .arg("--autosquash") + .arg("--autostash") + .arg(&base_ref) + .env("GIT_SEQUENCE_EDITOR", "true") + .env("GIT_EDITOR", "true") + .output() + .map_err(|e| e.to_string())?; + + let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); + if rebase_dir.exists() { + return Ok("paused".into()); + } + if !out.status.success() { + // The fixup commit exists but no recoverable rebase is active. + // Return to the pre-operation commit with --mixed so every file + // change remains available in the worktree. + let backup_ref = backup_ref_for(&path)?; + let _ = git_output(&path, &["reset", "--mixed", &backup_ref]); + return Err(format!( + "{}\n\nEl fixup se revirtió automáticamente y los cambios siguen en el worktree.", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok("completed".into()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_branch_rename(path: String, new_name: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&new_name) { + return Err(format!("unsafe branch name: {new_name}")); + } + git_output(&path, &["branch", "-m", &new_name]).map(|_| ()) + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn partial_patch_stages_only_the_selected_hunk() { + let repo = repo("partial"); + let original = (1..=12).map(|n| format!("line {n}\n")).collect::(); + commit_file(&repo.0, &original, "base"); + let changed = original + .replace("line 1\n", "changed one\n") + .replace("line 12\n", "changed twelve\n"); + fs::write(repo.0.join("file.txt"), changed).unwrap(); + let diff = run(&repo.0, &["diff", "--unified=0"]); + let second_hunk = diff + .match_indices("@@") + .nth(2) + .map(|(index, _)| index) + .unwrap(); + let selected = &diff[..second_hunk]; + apply_selected_patch(repo.0.to_str().unwrap(), selected).unwrap(); + let staged = run(&repo.0, &["diff", "--cached"]); + let unstaged = run(&repo.0, &["diff"]); + assert!(staged.contains("changed one")); + assert!(!staged.contains("changed twelve")); + assert!(unstaged.contains("changed twelve")); + } + + #[test] + fn autosquash_integrates_fixup_into_selected_commit() { + let repo = repo("autosquash"); + commit_file(&repo.0, "root\n", "root"); + run(&repo.0, &["branch", "base"]); + commit_file(&repo.0, "target\n", "target commit"); + let target = run(&repo.0, &["rev-parse", "HEAD"]); + fs::write(repo.0.join("other.txt"), "later\n").unwrap(); + run(&repo.0, &["add", "other.txt"]); + run(&repo.0, &["commit", "-qm", "later commit"]); + fs::write(repo.0.join("file.txt"), "target with fix\n").unwrap(); + run(&repo.0, &["add", "file.txt"]); + run(&repo.0, &["commit", &format!("--fixup={}", target.trim())]); + + let out = Command::new("git") + .arg("-C") + .arg(&repo.0) + .args(["rebase", "-i", "--autosquash", "base"]) + .env("GIT_SEQUENCE_EDITOR", "true") + .env("GIT_EDITOR", "true") + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + run(&repo.0, &["rev-list", "--count", "base..HEAD"]).trim(), + "2" + ); + assert_eq!( + fs::read_to_string(repo.0.join("file.txt")).unwrap(), + "target with fix\n" + ); + assert!(!run(&repo.0, &["log", "--format=%s", "base..HEAD"]).contains("fixup!")); + } +} diff --git a/src-tauri/src/git/edit.rs b/src-tauri/src/git/edit.rs new file mode 100644 index 0000000..1171ea5 --- /dev/null +++ b/src-tauri/src/git/edit.rs @@ -0,0 +1,216 @@ +use super::*; +use super::backup::create_history_backup; + + +// Resolves a rebase conflict by checking out one side and staging the result. +// `side` must be "ours" or "theirs". +#[tauri::command] +pub async fn git_resolve_conflict(path: String, file: String, side: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let flag = if side == "theirs" { + "--theirs" + } else { + "--ours" + }; + let co = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("checkout") + .arg(flag) + .arg("--") + .arg(&file) + .output() + .map_err(|e| e.to_string())?; + if !co.status.success() { + return Err(String::from_utf8_lossy(&co.stderr).trim().to_string()); + } + let add = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("add") + .arg("--") + .arg(&file) + .output() + .map_err(|e| e.to_string())?; + if !add.status.success() { + return Err(String::from_utf8_lossy(&add.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Stages specific files (used after manually resolving conflicts in an editor). +#[tauri::command] +pub async fn git_add_files( + path: String, + files: Vec, +) -> Result<(), crate::command_error::CommandError> { + tauri::async_runtime::spawn_blocking(move || add_files_blocking(&path, &files)) + .await + .map_err(|e| crate::command_error::CommandError::runtime(e.to_string()))? + .map_err(crate::command_error::CommandError::git) +} + +fn add_files_blocking(path: &str, files: &[String]) -> Result<(), String> { + // Validate using canonical paths, but pass the original relative paths + // to Git. Absolute canonical paths are rejected by Git when the + // worktree itself was reached through a symlink (notably /var -> + // /private/var on macOS), even though the file is inside the worktree. + for file in files { + crate::git_paths::existing_worktree_file(path, file)?; + } + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + for attempt in 0..=30 { + let mut cmd = Command::new(&bin); + cmd.arg("-C").arg(path).arg("add").arg("--"); + for file in files { + cmd.arg(file); + } + let out = cmd.output().map_err(|e| e.to_string())?; + if out.status.success() { + return Ok(()); + } + let error = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let index_is_busy = error.contains("index.lock") && error.contains("File exists"); + if !index_is_busy || attempt == 30 { + return Err(error); + } + // A status/rebase command may briefly own the shared worktree index. + // Never delete its lock: wait for the owner and retry only this known + // transient error. + std::thread::sleep(std::time::Duration::from_millis(100)); + } + unreachable!() +} + +// Reads a file from a worktree — used by the inline conflict resolver. +#[tauri::command] +pub async fn git_read_file( + path: String, + file: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let safe_path = crate::git_paths::existing_worktree_file(&path, &file)?; + fs::read_to_string(safe_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| crate::command_error::CommandError::runtime(e.to_string()))? + .map_err(crate::command_error::CommandError::git) +} + +// Writes resolved content back to a worktree file — used by the inline conflict resolver. +#[tauri::command] +pub async fn git_write_file( + path: String, + file: String, + content: String, +) -> Result<(), crate::command_error::CommandError> { + tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let safe_path = crate::git_paths::existing_worktree_file(&path, &file)?; + fs::write(safe_path, content).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| crate::command_error::CommandError::runtime(e.to_string()))? + .map_err(crate::command_error::CommandError::git) +} + +// Resets HEAD to `target` (e.g. "origin/main"). +// mode: "soft" | "mixed" (default) | "hard" +#[tauri::command] +pub async fn git_reset(path: String, target: String, mode: Option) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + create_history_backup(&path)?; + let flag = match mode.as_deref().unwrap_or("mixed") { + "soft" => "--soft", + "hard" => "--hard", + _ => "--mixed", + }; + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let out = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("reset") + .arg(flag) + .arg(&target) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn open_in_editor(path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + for editor in &["cursor", "code"] { + if let Some(found) = login_shell_output(&format!("command -v {editor}")) { + let bin_path = found.trim().to_string(); + if !bin_path.is_empty() && Command::new(&bin_path).arg(&path).spawn().is_ok() { + return Ok(()); + } + } + } + #[cfg(target_os = "macos")] + Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] + Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + #[cfg(target_os = "windows")] + Command::new("explorer.exe") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn stages_validated_relative_worktree_files() { + let repo = repo("add-files"); + fs::write(repo.0.join("file.txt"), "resolved\n").unwrap(); + add_files_blocking(repo.0.to_str().unwrap(), &["file.txt".into()]).unwrap(); + assert_eq!( + run(&repo.0, &["diff", "--cached", "--name-only"]).trim(), + "file.txt" + ); + } + + #[test] + fn waits_for_a_transient_git_index_lock_before_staging() { + let repo = repo("add-files-lock"); + fs::write(repo.0.join("file.txt"), "resolved\n").unwrap(); + let lock = repo.0.join(".git/index.lock"); + fs::write(&lock, "busy").unwrap(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(250)); + fs::remove_file(lock).unwrap(); + }); + + add_files_blocking(repo.0.to_str().unwrap(), &["file.txt".into()]).unwrap(); + release.join().unwrap(); + assert_eq!( + run(&repo.0, &["diff", "--cached", "--name-only"]).trim(), + "file.txt" + ); + } +} diff --git a/src-tauri/src/git/log.rs b/src-tauri/src/git/log.rs new file mode 100644 index 0000000..69853a1 --- /dev/null +++ b/src-tauri/src/git/log.rs @@ -0,0 +1,260 @@ +use super::*; + + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct CommitEntry { + hash: String, + short: String, + subject: String, + date: String, + author: String, +} + +#[derive(serde::Serialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct CommitFile { + status: String, + paths: Vec, +} + +fn parse_commit_log(raw: String) -> Vec { + raw.lines() + .filter_map(|line| { + let mut fields = line.split('\x1f'); + Some(CommitEntry { + hash: fields.next()?.to_string(), + short: fields.next().unwrap_or_default().to_string(), + subject: fields.next().unwrap_or_default().to_string(), + date: fields.next().unwrap_or_default().to_string(), + author: fields.next().unwrap_or_default().to_string(), + }) + }) + .collect() +} + +// Returns newline-separated entries: "\x1f\x1f\x1f\x1f" +#[tauri::command] +pub async fn git_log( + path: String, + limit: u32, + no_merges: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let n = limit.clamp(1, 200).to_string(); + let mut args = vec![ + "log".to_string(), + format!("-{n}"), + "--format=%H\x1f%h\x1f%s\x1f%ad\x1f%an".to_string(), + "--date=relative".to_string(), + ]; + if no_merges.unwrap_or(false) { + args.push("--no-merges".to_string()); + } + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + git_output(&path, &refs).map(parse_commit_log) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_graph(path: String, base: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let base_ref = format!("origin/{base}"); + git_output( + &path, + &[ + "log", + "--graph", + "--decorate", + "--oneline", + "--date-order", + "--boundary", + "-100", + &base_ref, + "HEAD", + ], + ) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns every non-merge commit owned by the task branch, in the same +// oldest-to-newest order used by `git rebase -i origin/`. +#[tauri::command] +pub async fn git_rebase_log(path: String, base: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let target = format!("origin/{base}"); + let range = format!("{target}..HEAD"); + git_output( + &path, + &[ + "log", + "--reverse", + "--no-merges", + "--format=%H\x1f%h\x1f%s\x1f%ad\x1f%an", + "--date=relative", + &range, + ], + ) + .map(parse_commit_log) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_merge_log(path: String, base: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let range = format!("origin/{base}..HEAD"); + git_output( + &path, + &[ + "log", + "--reverse", + "--merges", + "--format=%H\x1f%h\x1f%s\x1f%ad\x1f%an", + "--date=relative", + &range, + ], + ) + .map(parse_commit_log) + }) + .await + .map_err(|e| e.to_string())? +} + +// Diff between any two git refs (e.g. "origin/main" vs "origin/feat/foo"). +#[tauri::command] +pub async fn git_ref_diff(path: String, base: String, target: String) -> Result { + if !is_safe_branch(&base) { + return Err(format!("unsafe base: {base}")); + } + if !is_safe_branch(&target) { + return Err(format!("unsafe target: {target}")); + } + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { + return Err("not a git repository".into()); + } + diff_between_refs(&path, &base, &target) + }) + .await + .map_err(|e| e.to_string())? +} + +// Resolves a git ref to its full SHA. +#[tauri::command] +pub async fn git_rev_parse(path: String, reference: String) -> Result { + if reference.starts_with('-') { + return Err(format!("invalid git reference: {reference}")); + } + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { + return Err("not a git repository".into()); + } + git_output(&path, &["rev-parse", &reference]).map(|s| s.trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Lists files changed in a commit: returns lines of "\t" (M, A, D, R…). +#[tauri::command] +pub async fn git_show_files(path: String, hash: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + git_output( + &path, + &["diff-tree", "--no-commit-id", "-r", "--name-status", &hash], + ) + .map(|raw| { + raw.lines() + .filter_map(|line| { + let mut fields = line.split('\t'); + let status = fields.next()?.to_string(); + let paths = fields.map(str::to_string).collect::>(); + if paths.is_empty() { + None + } else { + Some(CommitFile { status, paths }) + } + }) + .collect() + }) + }) + .await + .map_err(|e| e.to_string())? +} + +// Shows the patch introduced by one commit, optionally limited to one file. +#[tauri::command] +pub async fn git_show_commit_diff( + path: String, + hash: String, + file: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let mut args = vec![ + "show", + "--format=", + "--find-renames", + "--no-ext-diff", + &hash, + "--", + ]; + if let Some(ref file_path) = file { + args.push(file_path); + } + git_output(&path, &args) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_show_file(path: String, hash: String, file: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if hash.len() < 7 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid commit hash".into()); + } + let spec = format!("{hash}:{file}"); + match git_output(&path, &["show", &spec]) { + Ok(content) => Ok(content), + Err(_) => { + // Deleted files only exist in the commit's first parent. + let parent_spec = format!("{hash}^:{file}"); + git_output(&path, &["show", &parent_spec]) + } + } + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn parses_typed_commit_log() { + let commits = parse_commit_log("abcdef\x1fabc\x1fSubject\x1fnow\x1fAda\n".into()); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].hash, "abcdef"); + assert_eq!(commits[0].subject, "Subject"); + assert_eq!(commits[0].author, "Ada"); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs new file mode 100644 index 0000000..39f9a80 --- /dev/null +++ b/src-tauri/src/git/mod.rs @@ -0,0 +1,163 @@ +// Git worktree commands for the parallel tasks panel. +// Follows the same patterns as docker.rs: login-shell PATH resolution, +// spawn_blocking for all blocking I/O, input validation at trust boundaries. + +use std::fs; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +pub(crate) mod worktree; +pub(crate) mod branches; +pub(crate) mod status; +pub(crate) mod backup; +pub(crate) mod commit; +pub(crate) mod log; +pub(crate) mod pr; +pub(crate) mod sync; +pub(crate) mod rebase; +pub(crate) mod recommend; +pub(crate) mod edit; +#[cfg(test)] +pub(crate) mod test_support; + +// macOS GUI apps don't inherit the shell PATH, so `git` may not be on PATH. +fn login_shell_output(cmd: &str) -> Option { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let out = Command::new(shell).arg("-lc").arg(cmd).output().ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).to_string()) +} + +fn git_bin() -> Option { + let on_path = Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if on_path { + return Some("git".into()); + } + let path = login_shell_output("command -v git")?; + let path = path.trim().to_string(); + if path.is_empty() { + None + } else { + Some(path) + } +} + +fn git_output(repo: &str, args: &[&str]) -> Result { + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let out = Command::new(&bin) + .arg("-C") + .arg(repo) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +// Accepts [A-Za-z0-9._/-], rejects `..` and spaces. +fn is_safe_branch(name: &str) -> bool { + !name.is_empty() + && !name.contains("..") + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-')) +} + +fn is_git_repo(path: &str) -> bool { + git_output(path, &["rev-parse", "--git-dir"]).is_ok() +} + +fn current_branch(path: &str) -> Result { + let branch = git_output(path, &["rev-parse", "--abbrev-ref", "HEAD"])? + .trim() + .to_string(); + if branch.is_empty() || branch == "HEAD" || !is_safe_branch(&branch) { + return Err("cannot operate on detached HEAD".into()); + } + Ok(branch) +} + +fn resolve_commit_reference(repo: &str, reference: &str) -> Result { + if !is_safe_branch(reference) { + return Err(format!("unsafe reference: {reference}")); + } + let resolve = |repo: &str, reference: &str| { + let candidates = [ + format!("refs/heads/{reference}"), + format!("refs/remotes/{reference}"), + reference.to_string(), + ]; + for candidate in candidates { + if let Ok(value) = git_output(repo, &["rev-parse", "--verify", &format!("{candidate}^{{commit}}")]) { + return Ok(value.trim().to_string()); + } + } + Err(format!("unknown reference: {reference}")) + }; + + if let Ok(commit) = resolve(repo, reference) { + return Ok(commit); + } + + let _ = git_output(repo, &["fetch", "--all", "--prune"]); + if let Ok(commit) = resolve(repo, reference) { + return Ok(commit); + } + + Err(format!("unknown reference: {reference}")) +} + +fn diff_between_refs(repo: &str, base: &str, target: &str) -> Result { + let base_commit = resolve_commit_reference(repo, base)?; + let target_commit = resolve_commit_reference(repo, target)?; + git_output(repo, &["diff", &format!("{base_commit}...{target_commit}")]) +} + +#[tauri::command] +pub async fn git_current_branch(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || current_branch(&path)) + .await + .map_err(|e| e.to_string())? +} + +// Resolves the real git directory for both regular repos and worktrees. +// In a worktree, `.git` is a FILE containing "gitdir: "; we read it. +fn resolve_git_dir(path: &str) -> std::path::PathBuf { + let git_path = Path::new(path).join(".git"); + if git_path.is_file() { + if let Ok(content) = fs::read_to_string(&git_path) { + if let Some(gitdir) = content.trim().strip_prefix("gitdir: ") { + return std::path::PathBuf::from(gitdir.trim()); + } + } + } + git_path +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn diff_between_refs_resolves_commit_ids_before_diffing() { + let repo = repo("ref-diff"); + commit_file(&repo.0, "base\n", "base"); + run(&repo.0, &["branch", "origin/base"]); + run(&repo.0, &["checkout", "-qb", "origin/feature"]); + commit_file(&repo.0, "feature\n", "feature"); + + let diff = diff_between_refs(repo.0.to_str().unwrap(), "origin/base", "origin/feature").unwrap(); + assert!(diff.contains("diff --git a/file.txt b/file.txt"), "{diff}"); + assert!(diff.contains("+feature"), "{diff}"); + } +} diff --git a/src-tauri/src/git/pr.rs b/src-tauri/src/git/pr.rs new file mode 100644 index 0000000..dc6e88d --- /dev/null +++ b/src-tauri/src/git/pr.rs @@ -0,0 +1,445 @@ +use super::*; + + +#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct PrCheck { + name: Option, + context: Option, + conclusion: Option, + state: Option, + status: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct PrStatus { + state: String, + title: String, + url: String, + #[ts(type = "number")] + number: u64, + base_ref_name: Option, + is_draft: Option, + mergeable: Option, + review_decision: Option, + #[serde(default)] + status_check_rollup: Vec, +} + +// Returns typed PR metadata or null if no PR / gh is unavailable. +#[tauri::command] +pub async fn git_pr_status(path: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let out = Command::new("gh") + .current_dir(&path) + .args(["pr", "view", "--json", "state,title,url,number,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup"]) + .output(); + let Ok(out) = out else { return Ok(None); }; + if !out.status.success() || out.stdout.is_empty() { return Ok(None); } + serde_json::from_slice::(&out.stdout).map(Some).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns PR info for any branch via gh CLI. +#[tauri::command] +pub async fn gh_pr_view_branch( + path: String, + branch: String, +) -> Result, String> { + if !is_safe_branch(&branch) { + return Err(format!("unsafe branch: {branch}")); + } + tauri::async_runtime::spawn_blocking(move || { + let out = Command::new("gh") + .current_dir(&path) + .args([ + "pr", + "view", + &branch, + "--json", + "number,title,url,body,state,mergedAt,statusCheckRollup,reviewDecision", + ]) + .output(); + let Ok(out) = out else { + return Ok(None); + }; + if !out.status.success() || out.stdout.is_empty() { + return Ok(None); + } + serde_json::from_slice::(&out.stdout) + .map(Some) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn gh_pr_diff_number( + path: String, + pr_number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let out = Command::new("gh") + .current_dir(&path) + .args(["pr", "diff", &pr_number.to_string(), "--color=never"]) + .output(); + let Ok(out) = out else { + return Ok(String::new()); + }; + if !out.status.success() { + return Ok(String::new()); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Fetches PR discussion: general comments + review submissions (with body). +#[tauri::command] +pub async fn gh_pr_list_discussion( + path: String, + pr_number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let comments = std::process::Command::new("gh") + .current_dir(&path) + .args([ + "api", + "--paginate", + &format!("repos/{{owner}}/{{repo}}/issues/{}/comments", pr_number), + ]) + .output() + .ok() + .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) + .unwrap_or(serde_json::Value::Array(vec![])); + let reviews = std::process::Command::new("gh") + .current_dir(&path) + .args([ + "api", + &format!("repos/{{owner}}/{{repo}}/pulls/{}/reviews", pr_number), + ]) + .output() + .ok() + .and_then(|o| serde_json::from_slice::(&o.stdout).ok()) + .unwrap_or(serde_json::Value::Array(vec![])); + Ok(serde_json::json!({ "comments": comments, "reviews": reviews })) + }) + .await + .map_err(|e| e.to_string())? +} + +// Posts a comment on the PR and returns its URL. +#[tauri::command] +pub async fn gh_pr_comment(path: String, branch: String, body: String) -> Result { + if !is_safe_branch(&branch) { + return Err(format!("unsafe branch: {branch}")); + } + if body.len() > 65_536 { + return Err("comment body exceeds maximum length".into()); + } + tauri::async_runtime::spawn_blocking(move || { + // Resolve PR number from branch so we can use the REST API (gh pr comment + // does not support --json/--jq, but gh api returns html_url). + let num_out = Command::new("gh") + .current_dir(&path) + .args(["pr", "view", &branch, "--json", "number", "--jq", ".number"]) + .output() + .map_err(|e| e.to_string())?; + if !num_out.status.success() { + return Err(String::from_utf8_lossy(&num_out.stderr).trim().to_string()); + } + let pr_number = String::from_utf8_lossy(&num_out.stdout).trim().to_string(); + let endpoint = format!("repos/{{owner}}/{{repo}}/issues/{pr_number}/comments"); + let out = Command::new("gh") + .current_dir(&path) + .args([ + "api", + "--method", + "POST", + &endpoint, + "-f", + &format!("body={body}"), + "--jq", + ".html_url", + ]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Posts an inline review comment on a specific file+line via gh api. +#[tauri::command] +pub async fn gh_pr_inline_comment( + path: String, + pr_number: u64, + commit_id: String, + file: String, + line: u64, + start_line: Option, + body: String, +) -> Result { + if line == 0 { + return Err("line must be >= 1".into()); + } + if body.len() > 65_536 { + return Err("comment body exceeds maximum length".into()); + } + let is_valid_sha = !commit_id.is_empty() + && commit_id.len() <= 40 + && commit_id.chars().all(|c| c.is_ascii_hexdigit()); + if !is_valid_sha { + return Err(format!("invalid commit SHA: {commit_id}")); + } + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { + return Err("not a git repository".into()); + } + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments"); + let mut args = vec![ + "api".to_string(), + endpoint, + "-f".to_string(), + format!("body={body}"), + "-f".to_string(), + format!("commit_id={commit_id}"), + "-f".to_string(), + format!("path={file}"), + "-F".to_string(), + format!("line={line}"), + "-f".to_string(), + "side=RIGHT".to_string(), + ]; + if let Some(sl) = start_line { + if sl < line { + args.extend([ + "-F".to_string(), + format!("start_line={sl}"), + "-f".to_string(), + "start_side=RIGHT".to_string(), + ]); + } + } + args.extend(["--jq".to_string(), ".html_url".to_string()]); + let out = Command::new("gh") + .current_dir(&path) + .args(&args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns open pull requests for the repo (up to 30). +#[tauri::command] +pub async fn gh_pr_list_open(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let out = Command::new("gh") + .current_dir(&path) + .args([ + "pr", + "list", + "--json", + "number,title,author,headRefName,baseRefName,url", + "--limit", + "30", + ]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + serde_json::from_slice::(&out.stdout).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Edits an existing PR review comment. +#[tauri::command] +pub async fn gh_pr_update_comment( + path: String, + comment_id: u64, + body: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/comments/{comment_id}"); + let out = Command::new("gh") + .current_dir(&path) + .args([ + "api", + "--method", + "PATCH", + &endpoint, + "-f", + &format!("body={body}"), + ]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Deletes a PR review comment. +#[tauri::command] +pub async fn gh_pr_delete_comment(path: String, comment_id: u64) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/comments/{comment_id}"); + let out = Command::new("gh") + .current_dir(&path) + .args(["api", "--method", "DELETE", &endpoint]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Replies to an existing PR review comment thread. +#[tauri::command] +pub async fn gh_pr_reply_comment( + path: String, + comment_id: u64, + body: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/comments/{comment_id}/replies"); + let out = Command::new("gh") + .current_dir(&path) + .args([ + "api", + "--method", + "POST", + &endpoint, + "-f", + &format!("body={body}"), + "--jq", + ".html_url", + ]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns all inline review comments for a PR as a JSON array. +#[tauri::command] +pub async fn gh_pr_list_comments( + path: String, + pr_number: u64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments"); + let out = Command::new("gh") + .current_dir(&path) + .args(["api", "--paginate", &endpoint]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + serde_json::from_slice::(&out.stdout).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Submits a pull request review (APPROVE / REQUEST_CHANGES / COMMENT). +#[tauri::command] +pub async fn gh_pr_submit_review( + path: String, + pr_number: u64, + event: String, + body: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let valid_events = ["APPROVE", "REQUEST_CHANGES", "COMMENT"]; + if !valid_events.contains(&event.as_str()) { + return Err(format!("invalid review event: {event}")); + } + let endpoint = format!("repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews"); + let out = Command::new("gh") + .current_dir(&path) + .args([ + "api", + "--method", + "POST", + &endpoint, + "-f", + &format!("event={event}"), + "-f", + &format!("body={body}"), + "--jq", + ".html_url", + ]) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_create_pr(path: String, base: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let out = Command::new("gh") + .current_dir(&path) + .args(["pr", "create", "--fill", "--base", &base]) + .output() + .map_err(|e| e.to_string())?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()); + } + // Fallback: return compare URL so the frontend can open it in the browser. + if let Ok(remote) = git_output(&path, &["remote", "get-url", "origin"]) { + let remote = remote.trim().trim_end_matches(".git").to_string(); + let branch = + git_output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default(); + let branch = branch.trim().to_string(); + if !remote.is_empty() && !branch.is_empty() { + return Ok(format!("{remote}/compare/{base}...{branch}?expand=1")); + } + } + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/git/rebase.rs b/src-tauri/src/git/rebase.rs new file mode 100644 index 0000000..e7f3cd1 --- /dev/null +++ b/src-tauri/src/git/rebase.rs @@ -0,0 +1,381 @@ +use super::*; +use super::backup::create_history_backup; + + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct RebaseStatus { + active: bool, + sha: Option, + short: Option, + subject: Option, + body: Option, + branch: Option, + current: Option, + total: Option, + conflicts: Vec, +} + +// Writes a temp shell script that copies its first argument to our prepared todo file. +// Used as GIT_SEQUENCE_EDITOR so git uses our todo instead of opening $EDITOR. +fn write_sequence_editor_script( + todo_content: &str, +) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { + let pid = std::process::id(); + let todo_path = std::env::temp_dir().join(format!("bento-rebase-todo-{pid}.txt")); + let extension = if cfg!(windows) { "cmd" } else { "sh" }; + let script_path = std::env::temp_dir().join(format!("bento-rebase-editor-{pid}.{extension}")); + + fs::write(&todo_path, todo_content).map_err(|e| e.to_string())?; + #[cfg(windows)] + let script = format!( + "@echo off\r\ncopy /Y \"{}\" \"%~1\" >NUL\r\n", + todo_path.display() + ); + #[cfg(not(windows))] + let script = format!("#!/bin/sh\ncp '{}' \"$1\"\n", todo_path.display()); + fs::write(&script_path, &script).map_err(|e| e.to_string())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .map_err(|e| e.to_string())?; + } + + Ok((todo_path, script_path)) +} + +fn sequence_editor_command(path: &Path, windows: bool) -> String { + let raw = path.to_string_lossy(); + let normalized = if windows { + raw.replace('\\', "/") + } else { + raw.into_owned() + }; + // Git executes GIT_SEQUENCE_EDITOR through a POSIX-style shell, including + // Git for Windows. Single-quote the executable and escape embedded quotes. + format!("'{}'", normalized.replace('\'', "'\"'\"'")) +} + +// Starts an interactive rebase over origin/. `todo_lines` are the rebase instructions +// (e.g. ["pick abc1234 Fix login", "drop def5678 Bad commit"]). +// If git stops at an `edit` step this returns Ok(()) — check git_rebase_status afterwards. +#[tauri::command] +pub async fn git_rebase_start( + path: String, + base: String, + todo_lines: Vec, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + if todo_lines.is_empty() { + return Err("nothing to rebase".into()); + } + let target = format!("origin/{base}"); + git_output(&path, &["rev-parse", "--verify", &target])?; + let range = format!("{target}..HEAD"); + let allowed_hashes = git_output(&path, &["rev-list", "--no-merges", &range])?; + for line in &todo_lines { + if line.contains('\n') || line.contains('\r') { + return Err("invalid rebase instruction".into()); + } + let mut parts = line.split_whitespace(); + let action = parts.next().unwrap_or(""); + let hash = parts.next().unwrap_or(""); + if !matches!(action, "pick" | "edit" | "squash" | "fixup" | "drop") + || hash.len() < 7 + || !hash.chars().all(|c| c.is_ascii_hexdigit()) + || !allowed_hashes.lines().any(|allowed| allowed == hash) + { + return Err("rebase instruction contains an invalid action or commit".into()); + } + } + create_history_backup(&path)?; + let todo_content = todo_lines.join("\n") + "\n"; + let (todo_path, script_path) = write_sequence_editor_script(&todo_content)?; + let sequence_editor = sequence_editor_command(&script_path, cfg!(windows)); + + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let out = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("rebase") + .arg("-i") + .arg("--autostash") + .arg(&target) + .env("GIT_SEQUENCE_EDITOR", sequence_editor) + .env("GIT_EDITOR", "true") // suppress editor prompts for squash messages + .output() + .map_err(|e| e.to_string())?; + + let _ = fs::remove_file(&todo_path); + let _ = fs::remove_file(&script_path); + + // Check for pause BEFORE checking exit code: git may exit 0 or non-0 when paused. + let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); + if rebase_dir.exists() { + return Ok(()); + } + + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_rebase_preserve_merges(path: String, base: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let target = format!("origin/{base}"); + git_output(&path, &["rev-parse", "--verify", &target])?; + create_history_backup(&path)?; + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let out = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("rebase") + .arg("--rebase-merges") + .arg("--autostash") + .arg(&target) + .env("GIT_EDITOR", "true") + .output() + .map_err(|e| e.to_string())?; + if resolve_git_dir(&path).join("rebase-merge").exists() { + return Ok("paused".into()); + } + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok("completed".into()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Continues after an `edit` pause. Returns "paused" if git stopped at another edit step. +#[tauri::command] +pub async fn git_rebase_continue(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let out = Command::new(&bin) + .arg("-C") + .arg(&path) + .arg("rebase") + .arg("--continue") + .env("GIT_EDITOR", "true") + .output() + .map_err(|e| e.to_string())?; + + // Same pattern as git_rebase_start: check directory before exit code. + let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); + if rebase_dir.exists() { + return Ok("paused".into()); + } + + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_rebase_abort(path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + git_output(&path, &["rebase", "--abort"]).map(|_| ()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Turns the commit currently paused by an interactive `edit` into worktree +// changes. The user can then create two or more partial commits and continue. +#[tauri::command] +pub async fn git_rebase_split(path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); + if !rebase_dir.exists() { + return Err("no interactive rebase is active".into()); + } + if !git_output(&path, &["status", "--porcelain"])? + .trim() + .is_empty() + { + return Err("resolve or commit the current worktree changes before splitting".into()); + } + git_output(&path, &["reset", "--mixed", "HEAD^"]).map(|_| ()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns JSON with rebase state. Includes `conflicts` array (unmerged files) when paused at a conflict. +#[tauri::command] +pub async fn git_rebase_status(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let rebase_dir = resolve_git_dir(&path).join("rebase-merge"); + if !rebase_dir.exists() { + return Ok(RebaseStatus { + active: false, + sha: None, + short: None, + subject: None, + body: None, + branch: None, + current: None, + total: None, + conflicts: Vec::new(), + }); + } + // Use HEAD directly — more reliable than stopped-sha (not always written by git). + let sha = git_output(&path, &["rev-parse", "HEAD"]) + .unwrap_or_default() + .trim() + .to_string(); + let short = sha.chars().take(7).collect::(); + let head_name = fs::read_to_string(rebase_dir.join("head-name")) + .unwrap_or_default() + .trim() + .trim_start_matches("refs/heads/") + .to_string(); + let current = fs::read_to_string(rebase_dir.join("msgnum")) + .unwrap_or_default() + .trim() + .parse::() + .unwrap_or(0); + let total = fs::read_to_string(rebase_dir.join("end")) + .unwrap_or_default() + .trim() + .parse::() + .unwrap_or(0); + // Full commit message: subject + body (separated by blank line in git output) + let full_msg = git_output(&path, &["log", "--format=%B", "-1"]) + .unwrap_or_default() + .trim() + .to_string(); + let subject = full_msg.lines().next().unwrap_or("").to_string(); + let body = full_msg.lines().skip(2).collect::>().join("\n"); + + // Detect conflicting files: porcelain status lines where both sides are non-clean (UU, AA, DD, AU, UA, DU, UD). + let status_out = git_output(&path, &["status", "--porcelain"]).unwrap_or_default(); + let conflicts: Vec = status_out + .lines() + .filter(|l| { + l.len() >= 2 && matches!(&l[..2], "UU" | "AA" | "DD" | "AU" | "UA" | "DU" | "UD") + }) + .map(|l| l[3..].trim().to_string()) + .collect(); + + Ok(RebaseStatus { + active: true, + sha: Some(sha), + short: Some(short), + subject: Some(subject), + body: Some(body), + branch: Some(head_name), + current: Some(current), + total: Some(total), + conflicts, + }) + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn conflicted_rebase_can_be_aborted_without_losing_the_original_head() { + let repo = repo("abort"); + commit_file(&repo.0, "shared\n", "root"); + let base_branch = run(&repo.0, &["rev-parse", "--abbrev-ref", "HEAD"]); + run(&repo.0, &["branch", "task"]); + commit_file(&repo.0, "base version\n", "base change"); + run(&repo.0, &["checkout", "-q", "task"]); + commit_file(&repo.0, "task version\n", "task change"); + let original_head = run(&repo.0, &["rev-parse", "HEAD"]); + let rebase = Command::new("git") + .arg("-C") + .arg(&repo.0) + .args(["rebase", base_branch.trim()]) + .output() + .unwrap(); + assert!(!rebase.status.success()); + assert!(resolve_git_dir(repo.0.to_str().unwrap()) + .join("rebase-merge") + .exists()); + run(&repo.0, &["rebase", "--abort"]); + assert_eq!( + run(&repo.0, &["rev-parse", "HEAD"]).trim(), + original_head.trim() + ); + assert_eq!( + fs::read_to_string(repo.0.join("file.txt")).unwrap(), + "task version\n" + ); + } + + #[test] + fn split_rebase_returns_paused_commit_to_the_worktree() { + let repo = repo("split"); + commit_file(&repo.0, "root\n", "root"); + run(&repo.0, &["branch", "base"]); + commit_file(&repo.0, "changed\n", "change to split"); + let commit = run(&repo.0, &["rev-parse", "HEAD"]); + let todo = format!("edit {} change to split\n", commit.trim()); + let (todo_path, script_path) = write_sequence_editor_script(&todo).unwrap(); + let out = Command::new("git") + .arg("-C") + .arg(&repo.0) + .args(["rebase", "-i", "base"]) + .env("GIT_SEQUENCE_EDITOR", &script_path) + .env("GIT_EDITOR", "true") + .output() + .unwrap(); + let _ = fs::remove_file(todo_path); + let _ = fs::remove_file(script_path); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(resolve_git_dir(repo.0.to_str().unwrap()) + .join("rebase-merge") + .exists()); + + tauri::async_runtime::block_on(git_rebase_split(repo.0.to_string_lossy().to_string())) + .unwrap(); + let status = run(&repo.0, &["status", "--short"]); + assert!(status.contains("file.txt")); + assert_eq!( + fs::read_to_string(repo.0.join("file.txt")).unwrap(), + "changed\n" + ); + run(&repo.0, &["rebase", "--abort"]); + } + + #[test] + fn quotes_windows_sequence_editor_for_gits_shell() { + let path = Path::new(r"C:\Users\Runner Admin\Temp\bento-rebase-editor.cmd"); + assert_eq!( + sequence_editor_command(path, true), + "'C:/Users/Runner Admin/Temp/bento-rebase-editor.cmd'" + ); + } +} diff --git a/src-tauri/src/git/recommend.rs b/src-tauri/src/git/recommend.rs new file mode 100644 index 0000000..30b4d25 --- /dev/null +++ b/src-tauri/src/git/recommend.rs @@ -0,0 +1,130 @@ +use super::*; + + +#[derive(serde::Serialize, ts_rs::TS)] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct CommitRecommendation { + hash: String, + score: u32, + files: Vec, +} + +// Scores task commits by how often they appear in the selected files' history. +// Format: full-hashscorecomma-separated-files +#[tauri::command] +pub async fn git_recommend_commits( + path: String, + base: String, + files: Vec, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let range = format!("origin/{base}..HEAD"); + let mut scores = std::collections::HashMap::)>::new(); + for file in files.iter().take(200) { + let history = + git_output(&path, &["log", "--format=%H", &range, "--", file]).unwrap_or_default(); + for hash in history.lines() { + let entry = scores.entry(hash.to_string()).or_insert((0, Vec::new())); + entry.0 += 1; + if !entry.1.contains(file) { + entry.1.push(file.clone()); + } + } + } + let mut rows: Vec<_> = scores.into_iter().collect(); + rows.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); + Ok(rows + .into_iter() + .map(|(hash, (score, files))| CommitRecommendation { hash, score, files }) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} + +// Attributes the original line ranges touched by an incoming patch to task +// commits using git blame. Same output format as git_recommend_commits. +#[tauri::command] +pub async fn git_blame_recommend( + path: String, + base: String, + patch: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + if patch.len() > 16 * 1024 * 1024 { + return Err("patch is too large".into()); + } + let range = format!("origin/{base}..HEAD"); + let allowed: std::collections::HashSet = git_output(&path, &["rev-list", &range])? + .lines() + .map(str::to_string) + .collect(); + let mut current_file = String::new(); + let mut ranges = Vec::<(String, u32, u32)>::new(); + for line in patch.lines() { + if let Some(rest) = line.strip_prefix("diff --git a/") { + current_file = rest.split(" b/").next().unwrap_or("").to_string(); + } else if line.starts_with("@@ -") && !current_file.is_empty() { + let old_spec = line + .split_whitespace() + .nth(1) + .unwrap_or("") + .trim_start_matches('-'); + let mut values = old_spec.split(','); + let start = values + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1) + .max(1); + let count = values + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1) + .max(1); + ranges.push((current_file.clone(), start, start.saturating_add(count - 1))); + } + } + + let mut scores = std::collections::HashMap::)>::new(); + for (file, start, end) in ranges.into_iter().take(500) { + let line_range = format!("{start},{end}"); + let blame = git_output( + &path, + &[ + "blame", + "--line-porcelain", + "-L", + &line_range, + "HEAD", + "--", + &file, + ], + ) + .unwrap_or_default(); + for line in blame.lines() { + let hash = line.split_whitespace().next().unwrap_or(""); + if line.len() >= 41 && hash.len() == 40 && allowed.contains(hash) { + let entry = scores.entry(hash.to_string()).or_insert((0, Vec::new())); + entry.0 += 1; + if !entry.1.contains(&file) { + entry.1.push(file.clone()); + } + } + } + } + let mut rows: Vec<_> = scores.into_iter().collect(); + rows.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); + Ok(rows + .into_iter() + .map(|(hash, (score, files))| CommitRecommendation { hash, score, files }) + .collect()) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/git/status.rs b/src-tauri/src/git/status.rs new file mode 100644 index 0000000..2db8f3b --- /dev/null +++ b/src-tauri/src/git/status.rs @@ -0,0 +1,296 @@ +use super::*; + + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct GitStatus { + raw: String, + staged: u32, + unstaged: u32, + untracked: u32, + total: u32, +} + +fn parse_status(raw: String) -> GitStatus { + let mut staged = 0; + let mut unstaged = 0; + let mut untracked = 0; + let mut total = 0; + for line in raw.lines().filter(|line| !line.trim().is_empty()) { + total += 1; + let bytes = line.as_bytes(); + let x = bytes.first().copied().unwrap_or(b' '); + let y = bytes.get(1).copied().unwrap_or(b' '); + if x == b'?' && y == b'?' { + untracked += 1; + } else { + if x != b' ' { + staged += 1; + } + if y != b' ' { + unstaged += 1; + } + } + } + GitStatus { + raw, + staged, + unstaged, + untracked, + total, + } +} + +fn append_untracked_diffs(path: &str, combined: &mut String) -> Result<(), String> { + let untracked = git_output(path, &["ls-files", "--others", "--exclude-standard"])?; + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + let null_file = if cfg!(windows) { "NUL" } else { "/dev/null" }; + for file in untracked.lines().filter(|line| !line.is_empty()) { + let out = Command::new(&bin) + .arg("-C") + .arg(path) + .arg("diff") + .arg("--no-index") + .arg("--src-prefix=a/") + .arg("--dst-prefix=b/") + .arg("--") + .arg(null_file) + .arg(file) + .output() + .map_err(|e| e.to_string())?; + if out.status.code() == Some(0) || out.status.code() == Some(1) { + combined.push_str(&String::from_utf8_lossy(&out.stdout)); + } else { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + } + Ok(()) +} + +fn collect_worktree_diff(path: &str) -> Result { + let mut combined = git_output(path, &["diff", "--no-ext-diff", "HEAD"])?; + append_untracked_diffs(path, &mut combined)?; + Ok(combined) +} + +fn collect_review_worktree_diff(path: &str, base: &str) -> Result { + if !is_safe_branch(base) { + return Err(format!("unsafe base: {base}")); + } + let mut combined = git_output(path, &["diff", "--no-ext-diff", base, "--"])?; + append_untracked_diffs(path, &mut combined)?; + Ok(combined) +} + +#[tauri::command] +pub async fn git_status(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_output(&path, &["status", "--porcelain"]).map(parse_status) + }) + .await + .map_err(|e| e.to_string())? +} + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct RewritePreflight { + branch: String, + base: String, + dirty: bool, + operation: String, + upstream: String, + published_commits: u32, + protected_base: bool, + signing: bool, + hooks: Vec, +} + +// Read-only safety report used before rewriting task history. The frontend can +// explain every risk before invoking rebase/fixup instead of discovering it +// after Git has already started the operation. +#[tauri::command] +pub async fn git_rewrite_preflight(path: String, base: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let branch = current_branch(&path)?; + let dirty = !git_output(&path, &["status", "--porcelain"])? + .trim() + .is_empty(); + let git_dir = resolve_git_dir(&path); + let operation = + if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() { + "rebase" + } else if git_dir.join("MERGE_HEAD").exists() { + "merge" + } else if git_dir.join("CHERRY_PICK_HEAD").exists() { + "cherry-pick" + } else if git_dir.join("REVERT_HEAD").exists() { + "revert" + } else { + "" + }; + let upstream = git_output( + &path, + &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + ) + .unwrap_or_default() + .trim() + .to_string(); + let published_commits = if upstream.is_empty() { + 0 + } else { + let range = format!("origin/{base}..@{{u}}"); + git_output(&path, &["rev-list", "--count", &range]) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0) + }; + let hooks = ["pre-rebase", "pre-commit", "commit-msg"] + .iter() + .filter(|name| git_dir.join("hooks").join(name).exists()) + .map(|name| name.to_string()) + .collect::>(); + let signing = git_output(&path, &["config", "--bool", "commit.gpgsign"]) + .map(|value| value.trim() == "true") + .unwrap_or(false); + let protected_base = branch == base || matches!(branch.as_str(), "main" | "master"); + Ok(RewritePreflight { + branch, + base, + dirty, + operation: operation.into(), + upstream, + published_commits, + protected_base, + signing, + hooks, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_diff(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || collect_worktree_diff(&path)) + .await + .map_err(|e| e.to_string())? +} + +// Accumulated diff of all commits on the current branch vs (three-dot range). +#[tauri::command] +pub async fn git_branch_diff(path: String, base: String) -> Result { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { + return Err("not a git repository".into()); + } + // Try local ref first, fall back to origin/ prefix for remote-only branches. + // The original error is preserved so transient failures are not silently swallowed. + match git_output(&path, &["diff", &format!("{base}...HEAD")]) { + Ok(out) => Ok(out), + Err(first_err) => git_output(&path, &["diff", &format!("origin/{base}...HEAD")]) + .map_err(|_| first_err), + } + }) + .await + .map_err(|e| e.to_string())? +} + +/// Diff from `base` to the current worktree, including committed, staged, +/// unstaged and untracked changes. + +#[tauri::command] +pub async fn git_review_worktree_diff(path: String, base: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&path) { + return Err("not a git repository".into()); + } + collect_review_worktree_diff(&path, &base) + }) + .await + .map_err(|error| error.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn review_worktree_diff_includes_committed_uncommitted_and_untracked_changes() { + let repo = repo("review-worktree-diff"); + commit_file(&repo.0, "base\n", "base"); + let base = run(&repo.0, &["branch", "--show-current"]).trim().to_string(); + run(&repo.0, &["checkout", "-qb", "feat/task"]); + commit_file(&repo.0, "committed\n", "task commit"); + fs::write(repo.0.join("file.txt"), "working\n").unwrap(); + fs::write(repo.0.join("new.txt"), "untracked\n").unwrap(); + + let diff = collect_review_worktree_diff(repo.0.to_str().unwrap(), &base).unwrap(); + assert!(diff.contains("diff --git a/file.txt b/file.txt"), "{diff}"); + assert!(diff.contains("+working"), "{diff}"); + assert!(diff.contains("diff --git a/new.txt b/new.txt"), "{diff}"); + assert!(diff.contains("+untracked"), "{diff}"); + } + + #[test] + fn worktree_diff_includes_untracked_files_without_staging_them() { + let repo = repo("untracked"); + commit_file(&repo.0, "base\n", "base"); + fs::write(repo.0.join("new.txt"), "new content\n").unwrap(); + let diff = collect_worktree_diff(repo.0.to_str().unwrap()).unwrap(); + assert!(diff.contains("diff --git a/new.txt b/new.txt")); + assert!(diff.contains("+new content")); + assert_eq!(run(&repo.0, &["status", "--short"]).trim(), "?? new.txt"); + } + + #[test] + fn rewrite_preflight_reports_dirty_published_signing_and_hooks() { + let repo = repo("preflight"); + commit_file(&repo.0, "root\n", "root"); + run(&repo.0, &["branch", "-M", "main"]); + run(&repo.0, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + run(&repo.0, &["checkout", "-qb", "task"]); + commit_file(&repo.0, "task\n", "task commit"); + run(&repo.0, &["branch", "published", "HEAD"]); + run(&repo.0, &["branch", "--set-upstream-to=published"]); + run(&repo.0, &["config", "commit.gpgsign", "true"]); + let hooks = resolve_git_dir(repo.0.to_str().unwrap()).join("hooks"); + fs::write(hooks.join("pre-rebase"), "#!/bin/sh\n").unwrap(); + fs::write(repo.0.join("dirty.txt"), "dirty\n").unwrap(); + + let report = tauri::async_runtime::block_on(git_rewrite_preflight( + repo.0.to_string_lossy().to_string(), + "main".into(), + )) + .unwrap(); + assert!(report.dirty); + assert_eq!(report.published_commits, 1); + assert!(report.signing); + assert!(report.hooks.contains(&"pre-rebase".to_string())); + } + + #[test] + fn parses_typed_status_counts_and_preserves_porcelain() { + let raw = " M a.txt\nM b.txt\nMM c.txt\n?? d.txt\n".to_string(); + let status = parse_status(raw.clone()); + assert_eq!(status.raw, raw); + assert_eq!( + ( + status.staged, + status.unstaged, + status.untracked, + status.total + ), + (2, 2, 1, 4) + ); + } +} diff --git a/src-tauri/src/git/sync.rs b/src-tauri/src/git/sync.rs new file mode 100644 index 0000000..765700f --- /dev/null +++ b/src-tauri/src/git/sync.rs @@ -0,0 +1,266 @@ +use super::*; +use super::backup::create_history_backup; + + +// Sync a worktree against origin/: fetch, then optionally merge or rebase. +// `mode` is one of "fetch", "merge", "rebase". +// `autostash`: stash before merge/rebase and pop after (asked by the user beforehand). +#[tauri::command] +pub async fn git_sync( + path: String, + base: String, + mode: String, + autostash: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let fetched = git_output(&path, &["fetch", "origin"])?; + let target = format!("origin/{base}"); + let do_stash = autostash.unwrap_or(false); + match mode.as_str() { + "fetch" => Ok(if fetched.trim().is_empty() { + "Fetch completado".into() + } else { + fetched + }), + "merge" => { + if do_stash { + git_output(&path, &["stash"])?; + } + let result = git_output(&path, &["merge", &target]); + if do_stash { + let _ = git_output(&path, &["stash", "pop"]); + } + result + } + "rebase" => { + create_history_backup(&path)?; + let extra: &[&str] = if do_stash { &["--autostash"] } else { &[] }; + let mut args = vec!["rebase"]; + args.extend_from_slice(extra); + args.push(&target); + git_output(&path, &args) + } + other => Err(format!("modo desconocido: {other}")), + } + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_push(path: String, force_with_lease: Option) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + + let branch = git_output(&path, &["rev-parse", "--abbrev-ref", "HEAD"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + if branch.is_empty() || branch == "HEAD" { + return Err("cannot push: detached HEAD".into()); + } + + let has_upstream = git_output( + &path, + &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + ) + .is_ok(); + + let mut cmd = Command::new(&bin); + cmd.arg("-C").arg(&path).arg("push"); + if !has_upstream { + cmd.args(["-u", "origin", &branch]); + } else if force_with_lease.unwrap_or(false) { + cmd.arg("--force-with-lease"); + } + let out = cmd.output().map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct UpstreamStatus { + branch: String, + upstream: Option, + has_upstream: bool, + state: String, + ahead: u32, + behind: u32, +} + +#[tauri::command] +pub async fn git_upstream_status(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let branch = current_branch(&path)?; + let upstream = match git_output( + &path, + &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + ) { + Ok(value) => value.trim().to_string(), + Err(_) => { + return Ok(UpstreamStatus { + branch, + upstream: None, + has_upstream: false, + state: "unpublished".into(), + ahead: 0, + behind: 0, + }) + } + }; + let counts = git_output( + &path, + &["rev-list", "--left-right", "--count", "@{u}...HEAD"], + )?; + let mut parts = counts.split_whitespace(); + let behind = parts + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let ahead = parts + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let state = if ahead > 0 && behind > 0 { + "diverged" + } else if behind > 0 { + "behind" + } else if ahead > 0 { + "ahead" + } else { + "synced" + }; + Ok(UpstreamStatus { + branch, + upstream: Some(upstream), + has_upstream: true, + state: state.into(), + ahead, + behind, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct FetchInfo { + #[ts(type = "number")] + fetched_at: u64, +} + +#[tauri::command] +pub async fn git_fetch_info(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let raw_path = git_output(&path, &["rev-parse", "--git-path", "FETCH_HEAD"])?; + let fetch_path = Path::new(raw_path.trim()); + let absolute = if fetch_path.is_absolute() { + fetch_path.to_path_buf() + } else { + Path::new(&path).join(fetch_path) + }; + let modified = fs::metadata(absolute) + .and_then(|m| m.modified()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + Ok(FetchInfo { + fetched_at: modified, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +// Returns "\t" matching the format parseAheadBehind expects. +#[tauri::command] +pub async fn git_ahead_behind(path: String, base: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + let target = format!("origin/{base}"); + git_output( + &path, + &[ + "rev-list", + "--left-right", + "--count", + &format!("{target}...HEAD"), + ], + ) + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn force_with_lease_rejects_a_remote_changed_by_someone_else() { + let repo = repo("lease"); + commit_file(&repo.0, "initial\n", "initial"); + let remote = repo.0.join("remote.git"); + let collab = repo.0.join("collab"); + let init = Command::new("git") + .args(["init", "--bare", "-q"]) + .arg(&remote) + .output() + .unwrap(); + assert!(init.status.success()); + run( + &repo.0, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + run(&repo.0, &["push", "-u", "origin", "HEAD"]); + + let clone = Command::new("git") + .arg("clone") + .arg("-q") + .arg(&remote) + .arg(&collab) + .output() + .unwrap(); + assert!( + clone.status.success(), + "{}", + String::from_utf8_lossy(&clone.stderr) + ); + run(&collab, &["config", "user.email", "collab@example.com"]); + run(&collab, &["config", "user.name", "Collaborator"]); + fs::write(collab.join("file.txt"), "remote change\n").unwrap(); + run(&collab, &["add", "file.txt"]); + run(&collab, &["commit", "-qm", "remote change"]); + run(&collab, &["push", "-q"]); + + fs::write(repo.0.join("file.txt"), "local rewrite\n").unwrap(); + run(&repo.0, &["add", "file.txt"]); + run(&repo.0, &["commit", "-qm", "local rewrite"]); + let push = Command::new("git") + .arg("-C") + .arg(&repo.0) + .args(["push", "--force-with-lease"]) + .output() + .unwrap(); + assert!( + !push.status.success(), + "force-with-lease unexpectedly overwrote a changed remote" + ); + } +} diff --git a/src-tauri/src/git/test_support.rs b/src-tauri/src/git/test_support.rs new file mode 100644 index 0000000..aa42f1c --- /dev/null +++ b/src-tauri/src/git/test_support.rs @@ -0,0 +1,49 @@ +#![cfg(test)] + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::fs; + +pub(crate) struct TestRepo(pub(crate) PathBuf); + +impl Drop for TestRepo { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +pub(crate) fn repo(name: &str) -> TestRepo { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("bento-{name}-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path).unwrap(); + run(&path, &["init", "-q"]); + run(&path, &["config", "user.email", "bento-tests@example.com"]); + run(&path, &["config", "user.name", "Bento Tests"]); + TestRepo(path) +} + +pub(crate) fn run(path: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {:?}: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).to_string() +} + +pub(crate) fn commit_file(path: &Path, content: &str, message: &str) { + fs::write(path.join("file.txt"), content).unwrap(); + run(path, &["add", "file.txt"]); + run(path, &["commit", "-qm", message]); +} diff --git a/src-tauri/src/git/worktree.rs b/src-tauri/src/git/worktree.rs new file mode 100644 index 0000000..4d8772b --- /dev/null +++ b/src-tauri/src/git/worktree.rs @@ -0,0 +1,164 @@ +use super::*; + + +#[derive(serde::Serialize, ts_rs::TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/generated/bindings/")] +pub struct WorktreeInfo { + path: String, + branch: Option, + head: String, + bare: bool, +} + +fn parse_worktrees(raw: &str) -> Vec { + // Git for Windows may emit CRLF even when stdout is captured through a + // pipe. Normalize record separators before splitting porcelain blocks. + raw.replace("\r\n", "\n") + .trim() + .split("\n\n") + .filter_map(|block| { + let mut path = None; + let mut head = None; + let mut branch = None; + let mut bare = false; + for line in block.lines() { + if let Some(value) = line.strip_prefix("worktree ") { + path = Some(value.to_string()); + } + if let Some(value) = line.strip_prefix("HEAD ") { + head = Some(value.to_string()); + } + if let Some(value) = line.strip_prefix("branch refs/heads/") { + branch = Some(value.to_string()); + } + if line == "bare" { + bare = true; + } + } + if bare { + return None; + } + Some(WorktreeInfo { + path: path?, + head: head?, + branch, + bare, + }) + }) + .collect() +} + +#[tauri::command] +pub async fn git_worktree_list(repo: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + // Prune stale worktree refs (folders deleted manually without `git worktree remove`). + let _ = git_output(&repo, &["worktree", "prune"]); + git_output(&repo, &["worktree", "list", "--porcelain"]).map(|raw| parse_worktrees(&raw)) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_worktree_add( + repo: String, + path: String, + branch: String, + base: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + if !is_git_repo(&repo) { + return Err("not a git repository".into()); + } + if !is_safe_branch(&branch) { + return Err(format!("unsafe branch name: {branch}")); + } + if !is_safe_branch(&base) { + return Err(format!("unsafe base branch: {base}")); + } + if Path::new(&path).exists() { + return Err(format!("path already exists: {path}")); + } + git_output(&repo, &["worktree", "add", &path, "-b", &branch, &base])?; + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn git_worktree_remove( + repo: String, + path: String, + force: bool, + branch: Option, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let bin = git_bin().ok_or_else(|| "git not found".to_string())?; + + let try_remove = |extra_force: bool| -> Result<(), String> { + let mut cmd = Command::new(&bin); + cmd.arg("-C").arg(&repo).arg("worktree").arg("remove"); + if force || extra_force { + cmd.arg("--force"); + } + cmd.arg(&path); + let out = cmd.output().map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(()) + }; + + match try_remove(false) { + Ok(()) => {} + Err(e) if e.contains("not a working tree") => { + // The .git file inside the worktree is missing/broken. + // Repair the link so git can locate the metadata, then retry. + let _ = git_output(&repo, &["worktree", "repair", &path]); + try_remove(true)?; + } + Err(e) => return Err(e), + } + + // Delete the branch too — the task is gone, the branch should follow. + if let Some(b) = branch { + if is_safe_branch(&b) { + // -D: force-delete regardless of merge status (user confirmed deletion). + let _ = git_output(&repo, &["branch", "-D", &b]); + } + } + + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_support::*; + + #[test] + fn parses_typed_worktrees_and_ignores_bare_entries() { + let raw = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /bare\nHEAD def456\nbare\n"; + let worktrees = parse_worktrees(raw); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].path, "/repo"); + assert_eq!(worktrees[0].branch.as_deref(), Some("main")); + assert!(!worktrees[0].bare); + } + + #[test] + fn parses_windows_crlf_worktree_records() { + let raw = "worktree C:\\repo\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\nworktree C:\\repo task\r\nHEAD def456\r\nbranch refs/heads/task/e2e\r\n"; + let worktrees = parse_worktrees(raw); + assert_eq!(worktrees.len(), 2); + assert_eq!(worktrees[0].path, "C:\\repo"); + assert_eq!(worktrees[1].path, "C:\\repo task"); + assert_eq!(worktrees[1].branch.as_deref(), Some("task/e2e")); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0bb7be4..59fc08d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -283,35 +283,35 @@ fn main() { db::db_docker_ps, db::db_inspect_env, db::db_check_ports, - db::db_docker_list_mysql, - db::db_docker_list_mongo, - db::db_docker_mysql_tables, - db::db_docker_mysql_rows, - db::db_docker_mysql_pk, - db::db_docker_mysql_update, - db::db_docker_mysql_delete, - db::db_docker_mysql_query, - db::db_docker_mysql_fks, - db::db_docker_mongo_collections, - db::db_docker_mongo_docs, - db::db_docker_mongo_update, - db::db_docker_mongo_delete, - db::db_docker_mongo_query, - db::db_docker_mongo_refs, - db::db_docker_pg_databases, - db::db_docker_pg_tables, - db::db_docker_pg_rows, - db::db_docker_pg_pk, - db::db_docker_pg_update, - db::db_docker_pg_delete, - db::db_docker_pg_query, - db::db_docker_pg_fks, - db::db_docker_redis_dbs, - db::db_docker_redis_keys, - db::db_docker_redis_value, - db::db_docker_redis_set, - db::db_docker_redis_ttl, - db::db_docker_redis_command, + db::mysql::db_docker_list_mysql, + db::mongo::db_docker_list_mongo, + db::mysql::db_docker_mysql_tables, + db::mysql::db_docker_mysql_rows, + db::mysql::db_docker_mysql_pk, + db::mysql::db_docker_mysql_update, + db::mysql::db_docker_mysql_delete, + db::mysql::db_docker_mysql_query, + db::mysql::db_docker_mysql_fks, + db::mongo::db_docker_mongo_collections, + db::mongo::db_docker_mongo_docs, + db::mongo::db_docker_mongo_update, + db::mongo::db_docker_mongo_delete, + db::mongo::db_docker_mongo_query, + db::mongo::db_docker_mongo_refs, + db::postgres::db_docker_pg_databases, + db::postgres::db_docker_pg_tables, + db::postgres::db_docker_pg_rows, + db::postgres::db_docker_pg_pk, + db::postgres::db_docker_pg_update, + db::postgres::db_docker_pg_delete, + db::postgres::db_docker_pg_query, + db::postgres::db_docker_pg_fks, + db::redis::db_docker_redis_dbs, + db::redis::db_docker_redis_keys, + db::redis::db_docker_redis_value, + db::redis::db_docker_redis_set, + db::redis::db_docker_redis_ttl, + db::redis::db_docker_redis_command, jira::jira_accounts_get, jira::jira_account_set, jira::jira_account_delete, @@ -352,76 +352,76 @@ fn main() { docker::docker_logs_follow, docker::docker_logs_stop, docker::docker_exec_argv, - docker::docker_compose_isolate, - docker::devcontainer_recipe_preview, - docker::devcontainer_recipe_create, - docker::devcontainer_recipe_git, - docker::devcontainer_recipe_status, - docker::devcontainer_isolate, - docker::devcontainer_urls, + docker::isolate::docker_compose_isolate, + docker::devcontainer::devcontainer_recipe_preview, + docker::devcontainer::devcontainer_recipe_create, + docker::devcontainer::devcontainer_recipe_git, + docker::devcontainer::devcontainer_recipe_status, + docker::devcontainer::devcontainer_isolate, + docker::devcontainer::devcontainer_urls, docker::docker_compose_up, docker::docker_compose_down, - git::git_worktree_list, - git::git_status, - git::git_rewrite_preflight, - git::git_default_branch, - git::git_remote_branches, - git::git_all_remote_branches, - git::git_review_branches, + git::worktree::git_worktree_list, + git::status::git_status, + git::status::git_rewrite_preflight, + git::branches::git_default_branch, + git::branches::git_remote_branches, + git::branches::git_all_remote_branches, + git::branches::git_review_branches, git::git_current_branch, - git::git_ref_diff, - git::git_rev_parse, - git::gh_pr_view_branch, - git::gh_pr_diff_number, - git::gh_pr_comment, - git::gh_pr_inline_comment, - git::gh_pr_list_open, - git::gh_pr_list_discussion, - git::gh_pr_list_comments, - git::gh_pr_update_comment, - git::gh_pr_delete_comment, - git::gh_pr_reply_comment, - git::gh_pr_submit_review, - git::git_worktree_add, - git::git_worktree_remove, - git::git_sync, - git::git_diff, - git::git_branch_diff, - git::git_review_worktree_diff, - git::git_commit, - git::git_fixup, - git::git_push, - git::git_upstream_status, - git::git_fetch_info, - git::git_backup_status, - git::git_backup_list, - git::git_backup_diff, - git::git_restore_backup, - git::git_ahead_behind, - git::git_create_pr, - git::git_branch_rename, - git::git_log, - git::git_graph, - git::git_rebase_log, - git::git_merge_log, - git::git_pr_status, - git::git_rebase_start, - git::git_rebase_preserve_merges, - git::git_rebase_continue, - git::git_rebase_abort, - git::git_rebase_split, - git::git_rebase_status, - git::git_show_files, - git::git_show_commit_diff, - git::git_show_file, - git::git_recommend_commits, - git::git_blame_recommend, - git::git_resolve_conflict, - git::git_add_files, - git::git_read_file, - git::git_write_file, - git::git_reset, - git::open_in_editor, + git::log::git_ref_diff, + git::log::git_rev_parse, + git::pr::gh_pr_view_branch, + git::pr::gh_pr_diff_number, + git::pr::gh_pr_comment, + git::pr::gh_pr_inline_comment, + git::pr::gh_pr_list_open, + git::pr::gh_pr_list_discussion, + git::pr::gh_pr_list_comments, + git::pr::gh_pr_update_comment, + git::pr::gh_pr_delete_comment, + git::pr::gh_pr_reply_comment, + git::pr::gh_pr_submit_review, + git::worktree::git_worktree_add, + git::worktree::git_worktree_remove, + git::sync::git_sync, + git::status::git_diff, + git::status::git_branch_diff, + git::status::git_review_worktree_diff, + git::commit::git_commit, + git::commit::git_fixup, + git::sync::git_push, + git::sync::git_upstream_status, + git::sync::git_fetch_info, + git::backup::git_backup_status, + git::backup::git_backup_list, + git::backup::git_backup_diff, + git::backup::git_restore_backup, + git::sync::git_ahead_behind, + git::pr::git_create_pr, + git::commit::git_branch_rename, + git::log::git_log, + git::log::git_graph, + git::log::git_rebase_log, + git::log::git_merge_log, + git::pr::git_pr_status, + git::rebase::git_rebase_start, + git::rebase::git_rebase_preserve_merges, + git::rebase::git_rebase_continue, + git::rebase::git_rebase_abort, + git::rebase::git_rebase_split, + git::rebase::git_rebase_status, + git::log::git_show_files, + git::log::git_show_commit_diff, + git::log::git_show_file, + git::recommend::git_recommend_commits, + git::recommend::git_blame_recommend, + git::edit::git_resolve_conflict, + git::edit::git_add_files, + git::edit::git_read_file, + git::edit::git_write_file, + git::edit::git_reset, + git::edit::open_in_editor, docker::docker_compose_logs_follow, docker::docker_compose_logs_stop, ]) diff --git a/src/core/ai/capacityError.ts b/src/core/ai/capacityError.ts new file mode 100644 index 0000000..26396e0 --- /dev/null +++ b/src/core/ai/capacityError.ts @@ -0,0 +1,5 @@ +// A token/rate/usage limit means the current agent can't continue — worth +// switching to a different agent rather than retrying the same one. +export function isCapacityError(message: string): boolean { + return /rate.?limit|too many requests|\b429\b|overloaded|\b529\b|usage limit|quota|out of tokens|token limit|context (?:length|window)|maximum context|prompt is too long|too long/i.test(message) +} diff --git a/src/core/ai/chatHistory.ts b/src/core/ai/chatHistory.ts index 0d97cda..78fdaf1 100644 --- a/src/core/ai/chatHistory.ts +++ b/src/core/ai/chatHistory.ts @@ -88,6 +88,16 @@ export function serializeChatHistory(state: ChatHistoryState): string { return JSON.stringify(state) } +// A review conversation only sends a recent window to the agent, but the first +// assistant message (the review report) carries context that must survive the +// whole conversation — so it stays pinned at the front once history grows past it. +export function pinnedFollowUpHistory(full: ChatMessage[], hasBranch: boolean): ChatMessage[] { + if (!hasBranch || full.length <= 20) return full + const report = full.find(m => m.role === 'assistant') + const recent = full.slice(-19) + return report && !recent.includes(report) ? [report, ...recent] : full.slice(-20) +} + export function techReviewConversationKey(projectPath: string, branch: string): string { const normalizedPath = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '') return `tech-review:${normalizedPath}:${branch.trim() || 'branch'}` 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/core/db/sqlQuote.ts b/src/core/db/sqlQuote.ts new file mode 100644 index 0000000..5fba5c4 --- /dev/null +++ b/src/core/db/sqlQuote.ts @@ -0,0 +1,21 @@ +import type { DbServer } from '../../core/db/dbServer' +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}\`` + +/** + * 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/core/git/commitWorkflow.ts b/src/core/git/commitWorkflow.ts index dd3eb5f..681f753 100644 --- a/src/core/git/commitWorkflow.ts +++ b/src/core/git/commitWorkflow.ts @@ -48,3 +48,32 @@ export function buildSelectedPatch( return [parsed.header + parsed.hunks.filter((_, index) => wanted.has(index)).join('')] }).join('') } + +/** What ranking a commit as a fixup target needs to know about it. */ +export interface FixupCandidate { + /** Incoming files this commit also touched. */ + overlap: string[] + /** How much `git blame` points at this commit for the incoming lines. */ + blame: { score: number } + /** How often this commit shows up in the history of the incoming files. */ + history: { score: number } +} + +// Overlapping files dominate; blame breaks their ties; history breaks blame's. +// The weights keep the three apart without comparing them field by field. +const OVERLAP_WEIGHT = 10000 +const BLAME_WEIGHT = 100 + +const fixupScore = (candidate: FixupCandidate): number => + candidate.overlap.length * OVERLAP_WEIGHT + candidate.blame.score * BLAME_WEIGHT + candidate.history.score + +/** + * Commits ordered by how likely the user meant to fix each one up, best first. + * Candidates that score the same keep the order they came in (newest first). + */ +export function rankFixupCandidates(candidates: T[]): T[] { + return candidates + .map((candidate, originalIndex) => ({ candidate, originalIndex })) + .sort((a, b) => fixupScore(b.candidate) - fixupScore(a.candidate) || a.originalIndex - b.originalIndex) + .map(({ candidate }) => candidate) +} diff --git a/src/core/git/prChecks.ts b/src/core/git/prChecks.ts new file mode 100644 index 0000000..0264c8c --- /dev/null +++ b/src/core/git/prChecks.ts @@ -0,0 +1,44 @@ +/** + * The part of a GitHub check this module reads. Structurally compatible with + * the generated PrCheck binding, without core depending on it. + */ +export interface PrCheckSignals { + conclusion?: string | null + state?: string | null + status?: string | null +} + +export type PrCheckVerdict = 'failed' | 'pending' | 'passed' + +const FAILED = /FAIL|ERROR|CANCEL|TIMED_OUT/i +const PENDING = /PENDING|QUEUED|IN_PROGRESS|EXPECTED/i + +/** + * How one PR check stands. GitHub reports a finished check run in `conclusion`, + * a commit status in `state` and a running check run in `status`, so all three + * are read in that order. Anything unrecognised counts as passed rather than + * blocking the badge. + */ +export function classifyPrCheck(check: PrCheckSignals): PrCheckVerdict { + const signal = check.conclusion ?? check.state ?? check.status ?? '' + if (FAILED.test(signal)) return 'failed' + if (PENDING.test(signal)) return 'pending' + return 'passed' +} + +export interface PrCheckSummary { + failed: number + pending: number + total: number +} + +/** How many of a PR's checks failed or are still running. */ +export function summarisePrChecks(checks: PrCheckSignals[]): PrCheckSummary { + const summary: PrCheckSummary = { failed: 0, pending: 0, total: checks.length } + for (const check of checks) { + const verdict = classifyPrCheck(check) + if (verdict === 'failed') summary.failed++ + else if (verdict === 'pending') summary.pending++ + } + return summary +} diff --git a/src/core/git/rebaseWorkflow.ts b/src/core/git/rebaseWorkflow.ts index 3a25ec6..2b7d9f0 100644 --- a/src/core/git/rebaseWorkflow.ts +++ b/src/core/git/rebaseWorkflow.ts @@ -91,3 +91,26 @@ export async function mapWithConcurrency( await Promise.all(runners) return results } + +/** + * The list after dragging the item at `from` onto the row at `target`, dropping + * it after that row when `after` is set. The index shifts once the dragged item + * is lifted out, so a downward move lands one place earlier than it looks. + */ +export function reorderByDrop(items: T[], from: number, target: number, after: boolean): T[] { + const next = [...items] + const [moved] = next.splice(from, 1) + let at = target + (after ? 1 : 0) + if (from < at) at-- + next.splice(at, 0, moved) + return next +} + +/** The list with two positions exchanged; unchanged if either is out of range. */ +export function swapItems(items: T[], a: number, b: number): T[] { + const isOutOfRange = a < 0 || b < 0 || a >= items.length || b >= items.length + if (isOutOfRange) return [...items] + const next = [...items] + ;[next[a], next[b]] = [next[b], next[a]] + return next +} diff --git a/src/core/git/taskJira.ts b/src/core/git/taskJira.ts index 9f08f5c..c1e7e49 100644 --- a/src/core/git/taskJira.ts +++ b/src/core/git/taskJira.ts @@ -4,12 +4,6 @@ export function extractIssueKey(branch: string | null): string | null { return match?.[0] ?? null } -export function statusCategoryClass(category: string): string { - if (category === 'done') return 'jira-st-done' - if (category === 'indeterminate') return 'jira-st-progress' - return 'jira-st-todo' -} - // Parses the output of `git rev-list --left-right --count origin/...HEAD`. // Format: "\t" where left = behind (commits in base not in HEAD), // right = ahead (commits in HEAD not in base). diff --git a/src/core/git/worktreeList.ts b/src/core/git/worktreeList.ts new file mode 100644 index 0000000..2e957c8 --- /dev/null +++ b/src/core/git/worktreeList.ts @@ -0,0 +1,27 @@ +import type { Worktree } from './worktree' + +/** The worktrees matching a free-text query against their branch or path. */ +export function filterWorktrees(worktrees: Worktree[], query: string): Worktree[] { + const needle = query.trim().toLowerCase() + if (!needle) return worktrees + return worktrees.filter(wt => + (wt.branch ?? '').toLowerCase().includes(needle) || wt.path.toLowerCase().includes(needle)) +} + +/** + * Worktrees bucketed under the repository they belong to, both repos and + * worktrees kept in the order they appear. A worktree with no recorded repo + * falls back to the panel's current one. + */ +export function groupWorktreesByRepo( + worktrees: Worktree[], repoOf: Map, fallbackRepo: string, +): Map { + const byRepo = new Map() + for (const wt of worktrees) { + const repo = repoOf.get(wt.path) ?? fallbackRepo + const bucket = byRepo.get(repo) ?? [] + bucket.push(wt) + byRepo.set(repo, bucket) + } + return byRepo +} diff --git a/src/core/jira/board.ts b/src/core/jira/board.ts index 5e85097..23550d8 100644 --- a/src/core/jira/board.ts +++ b/src/core/jira/board.ts @@ -66,3 +66,10 @@ export function mapToAgileColumns(issues: JiraIssue[], columns: AgileColumn[]): } return result } + +/** The CSS class that colours a status chip by its Jira status category. */ +export function statusCategoryClass(category: string): string { + if (category === 'done') return 'jira-st-done' + if (category === 'indeterminate') return 'jira-st-progress' + return 'jira-st-todo' +} diff --git a/src/core/jira/issueDetail.ts b/src/core/jira/issueDetail.ts new file mode 100644 index 0000000..fc05b8b --- /dev/null +++ b/src/core/jira/issueDetail.ts @@ -0,0 +1,87 @@ +export interface JiraAttachment { + id: string + filename: string + content: string + thumbnail: string + mimeType: string +} + +export interface JiraPullRequest { + title: string + url: string + status: string +} + +export interface IssueDetail { + description: string + /** True when Jira gave us its own HTML instead of raw wiki markup. */ + isRenderedHtml: boolean + attachments: JiraAttachment[] + pullRequests: JiraPullRequest[] + assignee: string + assigneeAvatar: string + reporter: string + reporterAvatar: string + priority: string + sprint: string + fixVersions: string[] + estimate: string +} + +interface RawUser { displayName?: string; avatarUrls?: { '48x48'?: string } } + +interface RawIssueDetail { + renderedFields?: { description?: string } + fields?: { + description?: string + attachment?: Array> + assignee?: RawUser + reporter?: RawUser + priority?: { name?: string } + // Sprints live in a per-instance custom field; 10020 is the common default. + customfield_10020?: Array<{ name?: string }> + fixVersions?: Array<{ name?: string }> + timeoriginalestimate?: number + } +} + +const SECONDS_PER_HOUR = 3600 + +/** An issue's detail fields, with every absent value defaulted rather than undefined. */ +export function parseIssueDetail(json: unknown): IssueDetail { + const issue = (json ?? {}) as RawIssueDetail + const f = issue.fields ?? {} + const renderedDescription = issue.renderedFields?.description + const seconds = f.timeoriginalestimate + + return { + description: renderedDescription ?? f.description ?? '', + isRenderedHtml: Boolean(renderedDescription), + attachments: (f.attachment ?? []).map(a => ({ + id: a.id ?? '', + filename: a.filename ?? '', + content: a.content ?? '', + thumbnail: a.thumbnail ?? '', + mimeType: a.mimeType ?? '', + })), + pullRequests: [], + assignee: f.assignee?.displayName ?? '', + assigneeAvatar: f.assignee?.avatarUrls?.['48x48'] ?? '', + reporter: f.reporter?.displayName ?? '', + reporterAvatar: f.reporter?.avatarUrls?.['48x48'] ?? '', + priority: f.priority?.name ?? '', + sprint: (f.customfield_10020 ?? []).map(s => s.name).filter(Boolean).join(', '), + fixVersions: (f.fixVersions ?? []).map(v => v.name ?? '').filter(Boolean), + estimate: seconds ? `${Math.round(seconds / SECONDS_PER_HOUR)}h` : '', + } +} + +/** The linked pull requests, which only instances with the dev-info panel report. */ +export function parsePullRequests(json: unknown): JiraPullRequest[] { + const dev = (json ?? {}) as { detail?: Array<{ pullRequests?: Array> }> } + return (dev.detail ?? []).flatMap(d => d.pullRequests ?? []).map(pr => ({ + title: pr.title ?? '', + url: pr.url ?? '', + status: pr.status ?? '', + })) +} diff --git a/src/core/jira/transitions.ts b/src/core/jira/transitions.ts new file mode 100644 index 0000000..0ca043e --- /dev/null +++ b/src/core/jira/transitions.ts @@ -0,0 +1,28 @@ +import { boardCategory, type AgileColumn } from './board' + +export interface JiraTransition { + id: string + name: string + to: { id: string; name: string; statusCategory: { key: string } } +} + +/** + * The transition that moves an issue into a board column, tried three ways: + * by the column's name, by the statuses the column holds, and finally by the + * category the column looks like — a column with statuses reads as in-progress, + * an empty or unknown one as to-do. + */ +export function findTransitionForColumn( + transitions: JiraTransition[], targetColumnName: string, columns: AgileColumn[] | null, +): JiraTransition | undefined { + const byName = transitions.find(t => t.to.name === targetColumnName || t.name === targetColumnName) + if (byName) return byName + + const targetColumn = columns?.find(c => c.name === targetColumnName) + const byStatusId = targetColumn && transitions.find(t => targetColumn.statusIds.includes(t.to.id)) + if (byStatusId) return byStatusId + + const columnHoldsStatuses = Boolean(targetColumn?.statusIds[0]) + const wantedCategory = boardCategory(columnHoldsStatuses ? 'indeterminate' : 'new') + return transitions.find(t => boardCategory(t.to.statusCategory.key) === wantedCategory) +} 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/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/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/memoryImportPlan.ts b/src/core/memory/memoryImportPlan.ts new file mode 100644 index 0000000..513d683 --- /dev/null +++ b/src/core/memory/memoryImportPlan.ts @@ -0,0 +1,53 @@ +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[], + updatedAt: string = new Date().toISOString(), +): ImportDecision => { + const payload = candidatePayload(candidate, updatedAt) + 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/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/core/notes/noteGroups.ts b/src/core/notes/noteGroups.ts new file mode 100644 index 0000000..d862d3c --- /dev/null +++ b/src/core/notes/noteGroups.ts @@ -0,0 +1,34 @@ +import type { ParsedNote } from './noteFile' + +export interface NoteEntry { + name: string + note: ParsedNote +} + +export interface NoteGroup { + category: string + items: NoteEntry[] +} + +const matchesQuery = (entry: NoteEntry, query: string): boolean => { + if (!query) return true + const haystack = `${entry.note.title} ${entry.note.category} ${entry.note.tags.join(' ')}` + return haystack.toLowerCase().includes(query) +} + +/** + * Entries matching the search query, bucketed by category (blank ones under + * the placeholder), categories kept in the order they first appear. + */ +export function groupNoteEntries(entries: NoteEntry[], search: string, uncategorizedLabel: string): NoteGroup[] { + const query = search.trim().toLowerCase() + const byCategory = new Map() + for (const entry of entries) { + if (!matchesQuery(entry, query)) continue + const category = entry.note.category.trim() || uncategorizedLabel + const items = byCategory.get(category) ?? [] + items.push(entry) + byCategory.set(category, items) + } + return [...byCategory.entries()].map(([category, items]) => ({ category, items })) +} diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index b5cf170..de1df68 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -6,6 +6,7 @@ import { createTerminalPanel, type TerminalPanelHandle } from '../terminal/Termi import { detectAgentCmd, resolveAgentIdentity } from './detectAgent' import { emitAgentDock, AGENT_ACTIVATE_EVENT, type AgentAttention } from '../../core/terminal/agentDockState' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' +import { buildResumeCmd } from './agentResume' const MAX_AGENTS = 20 @@ -40,34 +41,6 @@ const SESSION_FIND: Record = { const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) -// Builds the exact resume command, verifying the session still exists on disk -// before using --resume to avoid "No conversation found" errors. -async function buildResumeCmd(cmd: string, cwd: string, sessionId?: string): Promise { - if (cmd === 'claude') { - if (sessionId) { - const exists = await invoke('agent_claude_session_exists', { cwd, sessionId }).catch(() => false) - return exists ? `claude --resume ${sessionId}` : 'claude' - } - return 'claude' - } - if (cmd === 'opencode') return sessionId ? `opencode --session ${sessionId}` : 'opencode' - if (cmd === 'codex') { - if (sessionId) { - // Codex only writes the rollout on the first message: a session captured at - // launch but closed before any turn was never saved. Verify it exists before - // resuming, else `codex resume ` fails hard with "No saved session found". - const exists = await invoke('agent_codex_session_exists', { sessionId }).catch(() => false) - if (!exists) return 'codex' - // Clear stale thread-writer lock so codex doesn't reject with - // "already has an active writer" when the previous PTY was killed externally. - await invoke('agent_codex_clear_lock', { sessionId }).catch(() => {}) - return `codex resume ${sessionId}` - } - return 'codex' - } - return cmd -} - interface AgentSlot { num: number diff --git a/src/panels/agents/agentResume.ts b/src/panels/agents/agentResume.ts new file mode 100644 index 0000000..0f6b816 --- /dev/null +++ b/src/panels/agents/agentResume.ts @@ -0,0 +1,28 @@ +import { invoke } from '@tauri-apps/api/core' + +/** + * Builds the exact resume command for one of the known agent CLIs, verifying + * the session still exists on disk before using --resume to avoid + * "No conversation found" errors. Unrecognized commands pass through unchanged. + */ +export async function buildResumeCmd(cmd: string, cwd: string, sessionId?: string): Promise { + if (cmd === 'claude') { + if (!sessionId) return 'claude' + const exists = await invoke('agent_claude_session_exists', { cwd, sessionId }).catch(() => false) + return exists ? `claude --resume ${sessionId}` : 'claude' + } + if (cmd === 'opencode') return sessionId ? `opencode --session ${sessionId}` : 'opencode' + if (cmd === 'codex') { + if (!sessionId) return 'codex' + // Codex only writes the rollout on the first message: a session captured at + // launch but closed before any turn was never saved. Verify it exists before + // resuming, else `codex resume ` fails hard with "No saved session found". + const exists = await invoke('agent_codex_session_exists', { sessionId }).catch(() => false) + if (!exists) return 'codex' + // Clear stale thread-writer lock so codex doesn't reject with + // "already has an active writer" when the previous PTY was killed externally. + await invoke('agent_codex_clear_lock', { sessionId }).catch(() => {}) + return `codex resume ${sessionId}` + } + return cmd +} diff --git a/src/panels/db/DbPanel.ts b/src/panels/db/DbPanel.ts index a19bec8..123ea9f 100644 --- a/src/panels/db/DbPanel.ts +++ b/src/panels/db/DbPanel.ts @@ -1,338 +1,12 @@ 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, type DbKind } 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 { 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 - el.textContent = text - 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' - 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 -} - -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 -} +import { note } from './dbWidgets' +import { detectDocker, detectLocal } from './dbDetect' +import { createDetailHost } from './dbDetailHost' +import { openData } from './dbOpenData' +import { openQuery } from './dbQueryView' +import { createDbTree } from './dbTree' export function createDbPanel(): { element: HTMLElement } { const root = document.createElement('div') @@ -366,1429 +40,19 @@ export function createDbPanel(): { element: HTMLElement } { body.append(cs.element, cs.resizer, detail) root.append(body) - 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' } - } - - // ---- 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 - 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 { - 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(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(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(s, db, name, data, pk, fkColMap, () => openData(s, db, name)) - } - } catch (e) { - showDetail(note(String(e), 'db-detail-error')) - } - } - - // ---- 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 host = createDetailHost(detail) + host.showDetail(note(i18nT('db.selectATableOrCollectionToViewIts'), 'db-detail-hint')) - // ---- 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 - } - - // 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[] = [] - 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') - - // (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 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 actions = document.createElement('div') - actions.className = 'db-query-actions' - actions.append(runBtn, aiBtn, histWrap) - - // 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) - - const resultArea = document.createElement('div') - 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 - } - - 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 => { - 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 - resultArea.replaceChildren(note(i18nT('db.running'), 'db-detail-loading')) - try { - const result = await executeQuery(text) - 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() - } - - 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 - 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') - 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 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') - } - - 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/dbAccess.ts b/src/panels/db/dbAccess.ts new file mode 100644 index 0000000..8d0e1f9 --- /dev/null +++ b/src/panels/db/dbAccess.ts @@ -0,0 +1,57 @@ +import { invoke } from '@tauri-apps/api/core' +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' + +// 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 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/src/panels/db/dbCellRender.ts b/src/panels/db/dbCellRender.ts new file mode 100644 index 0000000..4a012aa --- /dev/null +++ b/src/panels/db/dbCellRender.ts @@ -0,0 +1,209 @@ +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 + +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 copyToClipboard(copyBtn, rawContent) + }) + + const panel = document.createElement('div') + panel.className = 'db-json-panel' + panel.append(copyBtn, contentEl) + cell.append(summaryEl, panel) + td.appendChild(cell) +} 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/src/panels/db/dbDetect.ts b/src/panels/db/dbDetect.ts new file mode 100644 index 0000000..df92ae5 --- /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 '../../core/db/dbEngine' + +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/src/panels/db/dbDocsView.ts b/src/panels/db/dbDocsView.ts new file mode 100644 index 0000000..57580e7 --- /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 '../../core/db/dbEngine' +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/src/panels/db/dbJoinBuilder.ts b/src/panels/db/dbJoinBuilder.ts new file mode 100644 index 0000000..702720b --- /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 '../../core/db/dbEngine' + +// 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/dbOpenData.ts b/src/panels/db/dbOpenData.ts new file mode 100644 index 0000000..f46e96e --- /dev/null +++ b/src/panels/db/dbOpenData.ts @@ -0,0 +1,43 @@ +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, type TableData } from '../../core/db/dbEngine' +import { fetchRelations } 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/dbQueryAi.ts b/src/panels/db/dbQueryAi.ts new file mode 100644 index 0000000..454662e --- /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 '../../core/db/dbEngine' +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/src/panels/db/dbQueryChips.ts b/src/panels/db/dbQueryChips.ts new file mode 100644 index 0000000..7bb0b0e --- /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 '../../core/db/dbEngine' +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/src/panels/db/dbQueryExec.ts b/src/panels/db/dbQueryExec.ts new file mode 100644 index 0000000..92da300 --- /dev/null +++ b/src/panels/db/dbQueryExec.ts @@ -0,0 +1,88 @@ +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 '../../core/db/dbEngine' +import { pgFixIdents } from '../../core/db/pgIdents' +import { note } from './dbWidgets' +import { renderResultTable, preResult } from './dbResultTable' + +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/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/src/panels/db/dbQueryView.ts b/src/panels/db/dbQueryView.ts new file mode 100644 index 0000000..0b61059 --- /dev/null +++ b/src/panels/db/dbQueryView.ts @@ -0,0 +1,88 @@ +import { t as i18nT } from '../../i18n' +import type { DbServer } from '../../core/db/dbServer' +import type { ForeignKey } from './queryBuilders' +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' +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/dbRedisView.ts b/src/panels/db/dbRedisView.ts new file mode 100644 index 0000000..b7a9df5 --- /dev/null +++ b/src/panels/db/dbRedisView.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 { parseStructuredJson } from './jsonValues' +import { target, parseRedisLines } from '../../core/db/dbEngine' +import { prettyJson, highlightJson } from './dbCellRender' +import { note, copyToClipboard } 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 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/dbResultTable.ts b/src/panels/db/dbResultTable.ts new file mode 100644 index 0000000..13ab14d --- /dev/null +++ b/src/panels/db/dbResultTable.ts @@ -0,0 +1,144 @@ +import { t as i18nT } from '../../i18n' +import { icon } from '../../ui/icons' +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' + +// 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/src/panels/db/dbRowEdit.ts b/src/panels/db/dbRowEdit.ts new file mode 100644 index 0000000..35a6f17 --- /dev/null +++ b/src/panels/db/dbRowEdit.ts @@ -0,0 +1,148 @@ +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 '../../core/db/dbEngine' +import { renderCellValue } from './dbCellRender' +import { buildWheres } from './dbWidgets' +import { ident, qualifiedTable } from '../../core/db/sqlQuote' + +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 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 + } + 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/src/panels/db/dbTableGrid.ts b/src/panels/db/dbTableGrid.ts new file mode 100644 index 0000000..91ac92f --- /dev/null +++ b/src/panels/db/dbTableGrid.ts @@ -0,0 +1,238 @@ +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 { 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 '../../core/db/sqlQuote' +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 copyToClipboard(copyBtn2, JSON.stringify(obj, null, 2)) + }) + 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 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(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) }) + 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/src/panels/db/dbTree.ts b/src/panels/db/dbTree.ts new file mode 100644 index 0000000..8c26eb5 --- /dev/null +++ b/src/panels/db/dbTree.ts @@ -0,0 +1,140 @@ +import { t as i18nT } from '../../i18n' +import { LISTABLE, type DbServer } from '../../core/db/dbServer' +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' + +// 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/src/panels/db/dbWidgets.ts b/src/panels/db/dbWidgets.ts new file mode 100644 index 0000000..39da7eb --- /dev/null +++ b/src/panels/db/dbWidgets.ts @@ -0,0 +1,108 @@ +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) +} + +/** 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/src/panels/jira/JiraPanel.ts b/src/panels/jira/JiraPanel.ts index 1d301ba..cac3a3d 100644 --- a/src/panels/jira/JiraPanel.ts +++ b/src/panels/jira/JiraPanel.ts @@ -1,18 +1,16 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import { open as openUrl } from '@tauri-apps/plugin-shell' -import { basicAuth } from '../../core/jira/auth' -import { apiUrl, browseUrl } from '../../core/jira/urls' -import { parseIssues, type JiraIssue } from '../../core/jira/issues' +import type { JiraIssue } from '../../core/jira/issues' import { parseBulkIssues } from '../../core/jira/bulk' import { MY_OPEN_ISSUES } from '../../core/jira/jql' -import { groupByCategory, boardCategory, parseAgileBoards, parseAgileColumns, mapToAgileColumns, type AgileBoard, type AgileColumn } from '../../core/jira/board' -import { jiraWikiToHtml } from '../../core/jira/wikiMarkup' -import { icon } from '../../ui/icons' +import { findTransitionForColumn, type JiraTransition } from '../../core/jira/transitions' +import { showIssueDetail as showIssueDetailView, type JiraIssueDrawerDeps } from './jiraIssueDrawer' +import { groupByCategory, mapToAgileColumns, statusCategoryClass, type AgileBoard, type AgileColumn } from '../../core/jira/board' +import { note, mkBtn, detailHeader, field } from './jiraWidgets' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' +import { createJiraClient, type JiraAccount } from './jiraClient' -interface JiraAccount { id: string; site: string; email: string; token: string } -interface HttpResponse { status: number; body: string } export function createJiraPanel(): { element: HTMLElement } { let accounts: JiraAccount[] = [] @@ -93,143 +91,14 @@ export function createJiraPanel(): { element: HTMLElement } { } } - // ---- API ---- - const api = async (method: string, path: string, body?: unknown): Promise => { - if (!activeAccount) throw new Error('No account selected') - const res = await invoke('http_request', { - method, - url: apiUrl(activeAccount.site, path), - headers: [ - ['Authorization', basicAuth(activeAccount.email, activeAccount.token)], - ['Accept', 'application/json'], - ['Content-Type', 'application/json'], - ], - body: body !== undefined ? JSON.stringify(body) : null, - }) - if (res.status >= 400) throw new Error(`HTTP ${res.status} — ${res.body.slice(0, 300)}`) - return res.body ? JSON.parse(res.body) : null - } - - const searchIssues = async (jql: string): Promise => { - const json = await api('POST', 'api/3/search/jql', { - jql, - fields: ['summary', 'status', 'issuetype', 'assignee'], - maxResults: 50, - }) - return parseIssues(json) - } - - interface IssueDetail { - description: string - isRenderedHtml: boolean - attachments: Array<{ id: string; filename: string; content: string; thumbnail: string; mimeType: string }> - pullRequests: Array<{ title: string; url: string; status: string }> - assignee: string; assigneeAvatar: string - reporter: string; reporterAvatar: string - priority: string - sprint: string - fixVersions: string[] - estimate: string - } - - // Fetch a binary asset (image) with Jira auth and return as a base64 data URL. - const fetchAsDataUrl = (url: string): Promise => - invoke('http_fetch_base64', { - url, - headers: [ - ['Authorization', basicAuth(activeAccount!.email, activeAccount!.token)], - ], - }) - - const fetchIssueDetail = async (key: string): Promise => { - const json = await api('GET', `api/2/issue/${key}?fields=description,attachment,assignee,reporter,priority,customfield_10020,fixVersions,timeoriginalestimate&expand=renderedFields`) as { - renderedFields?: { description?: string } - fields?: { - description?: string - attachment?: Array<{ id?: string; filename?: string; content?: string; mimeType?: string; thumbnail?: string }> - assignee?: { displayName?: string; avatarUrls?: { '48x48'?: string } } - reporter?: { displayName?: string; avatarUrls?: { '48x48'?: string } } - priority?: { name?: string } - customfield_10020?: Array<{ name?: string }> - fixVersions?: Array<{ name?: string }> - timeoriginalestimate?: number - } - } - const f = json?.fields ?? {} - const attachments = (f.attachment ?? []).map(a => ({ - id: a.id ?? '', - filename: a.filename ?? '', - content: a.content ?? '', - thumbnail: a.thumbnail ?? '', - mimeType: a.mimeType ?? '', - })) - let pullRequests: IssueDetail['pullRequests'] = [] - try { - const dev = await api('GET', `dev-info/0.10/issue/detail/${key}?_format=summary`) as { - detail?: Array<{ pullRequests?: Array<{ title?: string; url?: string; status?: string }> }> - } - pullRequests = (dev?.detail ?? []).flatMap(d => d.pullRequests ?? []).map(pr => ({ - title: pr.title ?? '', url: pr.url ?? '', status: pr.status ?? '', - })) - } catch { /* not available on all instances */ } - const secs = f.timeoriginalestimate - const estimate = secs ? `${Math.round(secs / 3600)}h` : '' - const renderedDesc = json?.renderedFields?.description - return { - description: renderedDesc ?? f.description ?? '', - isRenderedHtml: !!renderedDesc, - attachments, - pullRequests, - assignee: f.assignee?.displayName ?? '', - assigneeAvatar: f.assignee?.avatarUrls?.['48x48'] ?? '', - reporter: f.reporter?.displayName ?? '', - reporterAvatar: f.reporter?.avatarUrls?.['48x48'] ?? '', - priority: f.priority?.name ?? '', - sprint: f.customfield_10020?.map(s => s.name).filter(Boolean).join(', ') ?? '', - fixVersions: (f.fixVersions ?? []).map(v => v.name ?? '').filter(Boolean), - estimate, - } - } - - const createIssue = (project: string, type: string, summary: string, description: string, accountId?: string): Promise => { - const fields: Record = { project: { key: project }, issuetype: { name: type }, summary, description } - if (accountId) fields.assignee = { accountId } - return api('POST', 'api/2/issue', { fields }) - } + const jira = createJiraClient(() => activeAccount) + const api = jira.request + const { searchIssues, createIssue, resolveAccountId } = jira - const resolveAccountId = async (email: string): Promise => { - if (!email) return null - const users = await api('GET', `api/2/user/search?query=${encodeURIComponent(email)}`) as Array<{ accountId?: string }> - return Array.isArray(users) && users[0]?.accountId ? users[0].accountId : null - } - - // ---- detail-pane helpers ---- const showDetail = (...nodes: HTMLElement[]): void => { detailPane.replaceChildren(...nodes) } const showHint = (text: string): void => showDetail(note(text, 'jira-detail-hint')) - const detailHeader = (title: string, ...actions: HTMLElement[]): HTMLElement => { - const bar = document.createElement('div') - bar.className = 'jira-header' - const h = document.createElement('span') - h.className = 'jira-title' - h.textContent = title - bar.append(h, ...actions) - return bar - } - - const field = (label: string, value = '', type = 'text'): { row: HTMLElement; input: HTMLInputElement } => { - const row = document.createElement('label') - row.className = 'jira-field' - row.textContent = label - const input = document.createElement('input') - input.className = 'jira-input' - input.type = type - input.value = value - row.appendChild(input) - return { row, input } - } - // ---- config form (shown in detail pane) ---- const showConfig = (existing?: JiraAccount): void => { const siteF = field('Site (https://tuorg.atlassian.net)', existing?.site ?? '') @@ -266,8 +135,6 @@ export function createJiraPanel(): { element: HTMLElement } { // ---- shared helpers ---- - const statusClass = (cat: string): string => - cat === 'done' ? 'jira-st-done' : cat === 'indeterminate' ? 'jira-st-progress' : 'jira-st-todo' let viewMode: 'list' | 'board' = 'board' let lastJql = MY_OPEN_ISSUES @@ -276,32 +143,7 @@ export function createJiraPanel(): { element: HTMLElement } { let selectedBoardId: number | null = null let agileColumns: AgileColumn[] = [] - // ---- Agile board API ---- - const fetchAgileBoards = async (nameFilter = ''): Promise => { - const q = nameFilter ? `&name=${encodeURIComponent(nameFilter)}` : '' - const json = await api('GET', `agile/1.0/board?maxResults=100${q}`) - return parseAgileBoards(json) - } - - const fetchBoardColumns = async (boardId: number): Promise => { - const json = await api('GET', `agile/1.0/board/${boardId}/configuration`) - return parseAgileColumns(json) - } - - // For scrum boards: fetch active sprint issues; for kanban: fetch board issues. - const fetchBoardIssues = async (boardId: number): Promise => { - // Try active sprint first (scrum boards) - try { - const sprintRes = await api('GET', `agile/1.0/board/${boardId}/sprint?state=active&maxResults=1`) as { values?: Array<{ id: number }> } - const sprintId = sprintRes?.values?.[0]?.id - if (sprintId) { - const json = await api('GET', `agile/1.0/sprint/${sprintId}/issue?fields=summary,status,issuetype,assignee&maxResults=100`) - return parseIssues(json) - } - } catch { /* not a scrum board or no active sprint — fall through */ } - const json = await api('GET', `agile/1.0/board/${boardId}/issue?fields=summary,status,issuetype,assignee&maxResults=100`) - return parseIssues(json) - } + const { fetchAgileBoards, fetchBoardColumns, fetchBoardIssues } = jira // ---- issue list / board ---- const showIssues = (jql = lastJql): void => { @@ -446,7 +288,7 @@ export function createJiraPanel(): { element: HTMLElement } { row.innerHTML = `${it.key}` + `` + - `${it.status}` + `${it.status}` row.querySelector('.jira-summary')!.textContent = it.summary row.addEventListener('click', () => showIssueDetail(it)) list.appendChild(row) @@ -602,355 +444,23 @@ export function createJiraPanel(): { element: HTMLElement } { } // Find and execute the right Jira transition to move an issue to a target column. + const jiraDrawerDeps: JiraIssueDrawerDeps = { + jira, getActiveAccount: () => activeAccount, detailPane, + getViewMode: () => viewMode, getSelectedBoardId: () => selectedBoardId, + getAgileColumns: () => agileColumns, setAgileColumns: cols => { agileColumns = cols }, + getCachedIssues: () => cachedIssues, setCachedIssues: issues => { cachedIssues = issues }, + resetAssigneeFilter: () => { activeAssigneeId = '' }, + } + const showIssueDetail = (it: JiraIssue): Promise => showIssueDetailView(jiraDrawerDeps, it) + const doTransitionByColumn = async (issueKey: string, targetColName: string, cols: AgileColumn[] | null): Promise => { - const res = await api('GET', `api/2/issue/${issueKey}/transitions`) as { transitions?: Array<{ id: string; name: string; to: { id: string; name: string; statusCategory: { key: string } } }> } - const transitions = res?.transitions ?? [] - let match = transitions.find(t => t.to.name === targetColName || t.name === targetColName) - if (!match && cols) { - const targetCol = cols.find(c => c.name === targetColName) - match = transitions.find(t => targetCol?.statusIds.includes(t.to.id)) - } - if (!match) match = transitions.find(t => boardCategory(t.to.statusCategory.key) === boardCategory(agileColumns.find(c => c.name === targetColName)?.statusIds[0] ? 'indeterminate' : 'new')) + const res = await api('GET', `api/2/issue/${issueKey}/transitions`) as { transitions?: JiraTransition[] } + const match = findTransitionForColumn(res?.transitions ?? [], targetColName, cols) if (!match) throw new Error(`No hay transición disponible hacia "${targetColName}"`) await api('POST', `api/2/issue/${issueKey}/transitions`, { transition: { id: match.id } }) } // ---- issue detail (drawer — shown on top of the board, board state preserved) ---- - const showIssueDetail = async (it: JiraIssue): Promise => { - const close = (): void => { overlay.remove() } - - const overlay = document.createElement('div') - overlay.className = 'jira-drawer-overlay' - overlay.addEventListener('click', e => { if (e.target === overlay) close() }) - - const drawer = document.createElement('div') - drawer.className = 'jira-drawer' - - const openBtn = mkBtn('globe', 'Abrir en Jira', () => openUrl(browseUrl(activeAccount!.site, it.key)).catch(() => {})) - const closeBtn = mkBtn('x', 'Cerrar', close) - - const meta = document.createElement('div') - meta.className = 'jira-detail-meta' - const key = document.createElement('span') - key.className = 'jira-key' - key.textContent = it.key - const status = document.createElement('span') - status.className = `jira-status ${statusClass(it.statusCategory)}` - status.textContent = it.status - const issueType = document.createElement('span') - issueType.className = 'jira-type' - issueType.textContent = it.type - meta.append(key, status, issueType) - const summary = document.createElement('div') - summary.className = 'jira-detail-summary' - summary.textContent = it.summary - // Two-column layout: description (left) + metadata (right) - const body = document.createElement('div') - body.className = 'jira-detail jira-detail-layout' - - const left = document.createElement('div') - left.className = 'jira-detail-left' - - const right = document.createElement('div') - right.className = 'jira-detail-right' - - const descEl = document.createElement('div') - descEl.className = 'jira-detail-desc jira-wiki-body' - descEl.textContent = 'Cargando…' - left.append(meta, summary, descEl) - - body.append(left, right) - drawer.append(detailHeader('Detalle', openBtn, closeBtn), body) - - fetchIssueDetail(it.key).then(async d => { - // Render description: use Jira's pre-rendered HTML if available, else parse wiki markup - const attachMap = new Map(d.attachments.map(a => [a.filename, a.content])) - if (d.isRenderedHtml) { - descEl.innerHTML = d.description || '(sin descripción)' - // Replace image srcs with authenticated data URLs - descEl.querySelectorAll('img').forEach(img => { - const src = img.getAttribute('src') - if (src) fetchAsDataUrl(src).then(data => { img.src = data }).catch(() => {}) - }) - } else { - descEl.innerHTML = d.description - ? jiraWikiToHtml(d.description, attachMap) - : '(sin descripción)' - } - - // Wire all links to open in browser - descEl.querySelectorAll('a').forEach(a => { - a.addEventListener('click', e => { - e.preventDefault() - const href = a.getAttribute('href') || (a as HTMLElement).dataset.href - if (href && href !== '#') openUrl(href).catch(() => {}) - }) - }) - descEl.querySelectorAll('.jira-wiki-link').forEach(a => { - a.addEventListener('click', e => { - e.preventDefault() - const href = (a as HTMLElement).dataset.href - if (href) openUrl(href).catch(() => {}) - }) - }) - - // Metadata sidebar - const metaItems: Array<[string, string, string?]> = ([ - ['Asignado', d.assignee, d.assigneeAvatar] as [string, string, string?], - ['Informador', d.reporter, d.reporterAvatar] as [string, string, string?], - ['Prioridad', d.priority] as [string, string], - ['Sprint', d.sprint] as [string, string], - ['Estimación', d.estimate] as [string, string], - ...(d.fixVersions.length ? [['Versiones', d.fixVersions.join(', ')] as [string, string]] : []), - ]).filter(([, v]) => v) - - right.replaceChildren() - metaItems.forEach(([label, value, avatar]) => { - const row = document.createElement('div') - row.className = 'jira-meta-row' - if (label === 'Estimación') row.dataset.field = 'estimate' - const lbl = document.createElement('span') - lbl.className = 'jira-meta-label' - lbl.textContent = label.toUpperCase() - const val = document.createElement('span') - val.className = 'jira-meta-value' - if (avatar) { - const img = document.createElement('img') - img.src = avatar - img.className = 'jira-meta-avatar' - img.alt = value - img.onerror = () => img.remove() - val.append(img) - } - val.append(document.createTextNode(value)) - row.append(lbl, val) - right.append(row) - }) - - // Attachments as cards (images show thumbnail) - if (d.attachments.length) { - const attTitle = document.createElement('div') - attTitle.className = 'jira-detail-section-title' - attTitle.textContent = 'Archivos adjuntos' - const attGrid = document.createElement('div') - attGrid.className = 'jira-att-grid' - d.attachments.forEach(a => { - const card = document.createElement('div') - card.className = 'jira-att-card' - const isImg = a.mimeType.startsWith('image/') - const isPdf = a.mimeType === 'application/pdf' - if (isImg) { - const thumb = document.createElement('img') - thumb.className = 'jira-att-thumb' - thumb.alt = a.filename - thumb.addEventListener('click', () => openUrl(a.content).catch(() => {})) - const thumbUrl = a.thumbnail || a.content - fetchAsDataUrl(thumbUrl) - .then(data => { thumb.src = data }) - .catch(() => { thumb.replaceWith(Object.assign(document.createElement('span'), { className: 'jira-att-icon', textContent: '🖼️' })) }) - card.append(thumb) - } else { - const iconEl = document.createElement('span') - iconEl.className = 'jira-att-icon' - iconEl.textContent = isPdf ? '📄' : '📎' - card.append(iconEl) - } - const name = document.createElement('span') - name.className = 'jira-att-name' - name.textContent = a.filename - name.title = a.filename - const dlBtn = document.createElement('button') - dlBtn.className = 'jira-action' - dlBtn.title = 'Abrir / Descargar' - dlBtn.innerHTML = icon('arrow-right') - dlBtn.addEventListener('click', () => openUrl(a.content).catch(() => {})) - card.append(name, dlBtn) - attGrid.append(card) - }) - left.append(attTitle, attGrid) - } - - // Transitions — move card to another status from the detail panel - try { - const res = await api('GET', `api/2/issue/${it.key}/transitions`) as { - transitions?: Array<{ id: string; name: string; to: { name: string } }> - } - const transitions = (res?.transitions ?? []).filter(t => t.to.name !== it.status) - if (transitions.length) { - const trTitle = document.createElement('div') - trTitle.className = 'jira-meta-label' - trTitle.textContent = 'Mover a' - const trList = document.createElement('div') - trList.className = 'jira-transitions' - transitions.forEach(t => { - const btn = document.createElement('button') - btn.className = 'jira-transition-btn' - btn.textContent = t.to.name - btn.addEventListener('click', async () => { - btn.disabled = true - btn.textContent = '…' - try { - await api('POST', `api/2/issue/${it.key}/transitions`, { transition: { id: t.id } }) - it.status = t.to.name - // Refresh board if in board mode - if (viewMode === 'board' && selectedBoardId) { - agileColumns = await fetchBoardColumns(selectedBoardId).catch(() => agileColumns) - const fresh = await fetchBoardIssues(selectedBoardId) - cachedIssues = fresh - activeAssigneeId = '' - } - close() - } catch { btn.disabled = false; btn.textContent = t.to.name } - }) - trList.append(btn) - }) - right.append(trTitle, trList) - } - } catch { /* transitions not available */ } - - // Pull Requests - if (d.pullRequests.length) { - const prTitle = document.createElement('div') - prTitle.className = 'jira-detail-section-title' - prTitle.textContent = 'Pull Requests' - const prList = document.createElement('div') - prList.className = 'jira-detail-prs' - d.pullRequests.forEach(pr => { - const row = document.createElement('a') - row.className = `jira-pr-row jira-pr-${(pr.status || 'open').toLowerCase()}` - row.textContent = pr.title || pr.url - row.title = pr.url - row.addEventListener('click', () => openUrl(pr.url).catch(() => {})) - prList.append(row) - }) - left.append(prTitle, prList) - } - - // ---- Editable estimation in sidebar ---- - const estRow = right.querySelector('.jira-meta-row[data-field="estimate"]') as HTMLElement | null - const makeEstEdit = (): void => { - const estInput = document.createElement('input') - estInput.className = 'jira-input' - estInput.value = d.estimate - estInput.placeholder = '2h, 30m…' - estInput.style.cssText = 'width:100%;margin-top:2px' - const save = document.createElement('button') - save.className = 'jira-primary' - save.style.cssText = 'margin-top:4px;padding:3px 8px;font-size:11px' - save.textContent = 'Guardar' - save.addEventListener('click', async () => { - save.disabled = true - try { - await api('PUT', `api/2/issue/${it.key}`, { update: { timetracking: [{ set: { originalEstimate: estInput.value.trim() } }] } }) - d.estimate = estInput.value.trim() - estRow?.replaceChildren( - Object.assign(document.createElement('span'), { className: 'jira-meta-label', textContent: 'ESTIMACIÓN' }), - Object.assign(document.createElement('span'), { className: 'jira-meta-value' }) - ) - const valEl = estRow?.querySelector('.jira-meta-value') - if (valEl) valEl.textContent = d.estimate - } catch { save.disabled = false } - }) - estRow?.append(estInput, save) - } - if (estRow) { - const valEl = estRow.querySelector('.jira-meta-value') - if (valEl) valEl.addEventListener('click', makeEstEdit) - } - - // ---- Edit description ---- - const editDescBtn = document.createElement('button') - editDescBtn.className = 'jira-action' - editDescBtn.title = 'Editar descripción' - editDescBtn.innerHTML = icon('settings') - editDescBtn.addEventListener('click', () => { - const ta = document.createElement('textarea') - ta.className = 'jira-textarea' - ta.style.cssText = 'min-height:120px;width:100%;box-sizing:border-box' - ta.value = d.description - const saveDesc = document.createElement('button') - saveDesc.className = 'jira-primary' - saveDesc.textContent = 'Guardar' - const cancelDesc = document.createElement('button') - cancelDesc.className = 'jira-transition-btn' - cancelDesc.textContent = 'Cancelar' - const row = document.createElement('div') - row.style.cssText = 'display:flex;gap:6px;margin-top:6px' - row.append(saveDesc, cancelDesc) - descEl.replaceChildren(ta, row) - cancelDesc.addEventListener('click', () => { - descEl.innerHTML = d.isRenderedHtml ? d.description : jiraWikiToHtml(d.description, attachMap) - }) - saveDesc.addEventListener('click', async () => { - saveDesc.disabled = true - try { - await api('PUT', `api/2/issue/${it.key}`, { fields: { description: ta.value } }) - d.description = ta.value - d.isRenderedHtml = false - descEl.innerHTML = jiraWikiToHtml(ta.value, attachMap) - } catch { saveDesc.disabled = false } - }) - }) - drawer.querySelector('.jira-header')?.append(editDescBtn) - - // ---- Comments ---- - const commentTitle = document.createElement('div') - commentTitle.className = 'jira-detail-section-title' - commentTitle.textContent = 'Comentarios' - const commentList = document.createElement('div') - commentList.className = 'jira-comment-list' - commentList.textContent = 'Cargando comentarios…' - - const commentInput = document.createElement('textarea') - commentInput.className = 'jira-textarea' - commentInput.placeholder = 'Escribe un comentario…' - commentInput.style.cssText = 'min-height:70px;width:100%;box-sizing:border-box' - const commentSubmit = document.createElement('button') - commentSubmit.className = 'jira-primary' - commentSubmit.textContent = 'Comentar' - commentSubmit.addEventListener('click', async () => { - const text = commentInput.value.trim() - if (!text) return - commentSubmit.disabled = true - try { - await api('POST', `api/2/issue/${it.key}/comment`, { body: text }) - commentInput.value = '' - const res = await api('GET', `api/2/issue/${it.key}/comment?maxResults=30&orderBy=-created`) as { comments?: Array<{ body: string; author?: { displayName?: string }; created?: string }> } - renderComments(res?.comments ?? []) - } finally { commentSubmit.disabled = false } - }) - - const renderComments = (comments: Array<{ body: string; author?: { displayName?: string }; created?: string }>): void => { - commentList.replaceChildren() - if (!comments.length) { commentList.textContent = 'Sin comentarios.'; return } - comments.forEach(c => { - const item = document.createElement('div') - item.className = 'jira-comment' - const cMeta = document.createElement('div') - cMeta.className = 'jira-comment-meta' - cMeta.textContent = `${c.author?.displayName ?? 'Anónimo'} · ${c.created ? new Date(c.created).toLocaleDateString() : ''}` - const cBody = document.createElement('div') - cBody.className = 'jira-comment-body jira-wiki-body' - cBody.innerHTML = jiraWikiToHtml(c.body ?? '') - item.append(cMeta, cBody) - commentList.append(item) - }) - } - - api('GET', `api/2/issue/${it.key}/comment?maxResults=30&orderBy=-created`) - .then((res: unknown) => { - const r = res as { comments?: Array<{ body: string; author?: { displayName?: string }; created?: string }> } - renderComments(r?.comments ?? []) - }) - .catch(() => { commentList.textContent = 'Error cargando comentarios.' }) - - left.append(commentTitle, commentList, commentInput, commentSubmit) - }).catch(() => { descEl.textContent = '(error cargando descripción)' }) - overlay.append(drawer) - detailPane.append(overlay) - } - - // ---- create ---- const showCreate = (): void => { const project = field('Proyecto (clave, ej. BEN)') const type = field('Tipo', 'Task') @@ -1046,18 +556,3 @@ export function createJiraPanel(): { element: HTMLElement } { return { element: root } } -function note(text: string, cls = 'jira-note'): HTMLElement { - const el = document.createElement('div') - el.className = cls - el.textContent = text - return el -} - -function mkBtn(iconName: string, title: string, onClick: () => void): HTMLButtonElement { - const b = document.createElement('button') - b.className = 'jira-action' - b.title = title - b.innerHTML = icon(iconName) - b.addEventListener('click', onClick) - return b -} diff --git a/src/panels/jira/jiraClient.ts b/src/panels/jira/jiraClient.ts new file mode 100644 index 0000000..7ebc85b --- /dev/null +++ b/src/panels/jira/jiraClient.ts @@ -0,0 +1,115 @@ +import { invoke } from '@tauri-apps/api/core' +import { basicAuth } from '../../core/jira/auth' +import { apiUrl } from '../../core/jira/urls' +import { parseIssues, type JiraIssue } from '../../core/jira/issues' +import { parseAgileBoards, parseAgileColumns, type AgileBoard, type AgileColumn } from '../../core/jira/board' +import { parseIssueDetail, parsePullRequests, type IssueDetail } from '../../core/jira/issueDetail' + +export interface JiraAccount { id: string; site: string; email: string; token: string } + +interface HttpResponse { status: number; body: string } + +const ISSUE_LIST_FIELDS = 'summary,status,issuetype,assignee' +const DETAIL_FIELDS = 'description,attachment,assignee,reporter,priority,customfield_10020,fixVersions,timeoriginalestimate' + +export interface JiraClient { + /** One authenticated REST call against the active account. */ + request: (method: string, path: string, body?: unknown) => Promise + searchIssues: (jql: string) => Promise + fetchIssueDetail: (key: string) => Promise + createIssue: (project: string, type: string, summary: string, description: string, accountId?: string) => Promise + resolveAccountId: (email: string) => Promise + fetchAgileBoards: (nameFilter?: string) => Promise + fetchBoardColumns: (boardId: number) => Promise + fetchBoardIssues: (boardId: number) => Promise + /** A binary asset (image) fetched with Jira auth, as a base64 data URL. */ + fetchAsDataUrl: (url: string) => Promise +} + +/** The Jira REST surface the panel uses, bound to whichever account is active. */ +export function createJiraClient(getAccount: () => JiraAccount | null): JiraClient { + const requireAccount = (): JiraAccount => { + const account = getAccount() + if (!account) throw new Error('No account selected') + return account + } + + const request = async (method: string, path: string, body?: unknown): Promise => { + const account = requireAccount() + const res = await invoke('http_request', { + method, + url: apiUrl(account.site, path), + headers: [ + ['Authorization', basicAuth(account.email, account.token)], + ['Accept', 'application/json'], + ['Content-Type', 'application/json'], + ], + body: body !== undefined ? JSON.stringify(body) : null, + }) + if (res.status >= 400) throw new Error(`HTTP ${res.status} — ${res.body.slice(0, 300)}`) + return res.body ? JSON.parse(res.body) : null + } + + const searchIssues = async (jql: string): Promise => + parseIssues(await request('POST', 'api/3/search/jql', { + jql, + fields: ISSUE_LIST_FIELDS.split(','), + maxResults: 50, + })) + + const fetchIssueDetail = async (key: string): Promise => { + const json = await request('GET', `api/2/issue/${key}?fields=${DETAIL_FIELDS}&expand=renderedFields`) + const detail = parseIssueDetail(json) + // Only instances with the development panel answer this one. + const dev = await request('GET', `dev-info/0.10/issue/detail/${key}?_format=summary`).catch(() => null) + return { ...detail, pullRequests: parsePullRequests(dev) } + } + + const createIssue = ( + project: string, type: string, summary: string, description: string, accountId?: string, + ): Promise => { + const fields: Record = { project: { key: project }, issuetype: { name: type }, summary, description } + if (accountId) fields.assignee = { accountId } + return request('POST', 'api/2/issue', { fields }) + } + + const resolveAccountId = async (email: string): Promise => { + if (!email) return null + const users = await request('GET', `api/2/user/search?query=${encodeURIComponent(email)}`) as Array<{ accountId?: string }> + return Array.isArray(users) && users[0]?.accountId ? users[0].accountId : null + } + + const fetchAgileBoards = async (nameFilter = ''): Promise => { + const q = nameFilter ? `&name=${encodeURIComponent(nameFilter)}` : '' + return parseAgileBoards(await request('GET', `agile/1.0/board?maxResults=100${q}`)) + } + + const fetchBoardColumns = async (boardId: number): Promise => + parseAgileColumns(await request('GET', `agile/1.0/board/${boardId}/configuration`)) + + // Scrum boards show the active sprint; kanban boards have none, so fall back + // to the whole board. + const fetchBoardIssues = async (boardId: number): Promise => { + try { + const sprintRes = await request('GET', `agile/1.0/board/${boardId}/sprint?state=active&maxResults=1`) as { values?: Array<{ id: number }> } + const sprintId = sprintRes?.values?.[0]?.id + if (sprintId) { + return parseIssues(await request('GET', `agile/1.0/sprint/${sprintId}/issue?fields=${ISSUE_LIST_FIELDS}&maxResults=100`)) + } + } catch { /* not a scrum board, or no active sprint */ } + return parseIssues(await request('GET', `agile/1.0/board/${boardId}/issue?fields=${ISSUE_LIST_FIELDS}&maxResults=100`)) + } + + const fetchAsDataUrl = async (url: string): Promise => { + const account = requireAccount() + return invoke('http_fetch_base64', { + url, + headers: [['Authorization', basicAuth(account.email, account.token)]], + }) + } + + return { + request, searchIssues, fetchIssueDetail, createIssue, resolveAccountId, + fetchAgileBoards, fetchBoardColumns, fetchBoardIssues, fetchAsDataUrl, + } +} diff --git a/src/panels/jira/jiraIssueDrawer.ts b/src/panels/jira/jiraIssueDrawer.ts new file mode 100644 index 0000000..78f0a1b --- /dev/null +++ b/src/panels/jira/jiraIssueDrawer.ts @@ -0,0 +1,362 @@ +import { open as openUrl } from '@tauri-apps/plugin-shell' +import { icon } from '../../ui/icons' +import { browseUrl } from '../../core/jira/urls' +import { jiraWikiToHtml } from '../../core/jira/wikiMarkup' +import { statusCategoryClass, type AgileColumn } from '../../core/jira/board' +import type { JiraIssue } from '../../core/jira/issues' +import { mkBtn, detailHeader } from './jiraWidgets' +import type { JiraAccount, JiraClient } from './jiraClient' + +export interface JiraIssueDrawerDeps { + jira: JiraClient + getActiveAccount: () => JiraAccount | null + detailPane: HTMLElement + getViewMode: () => 'list' | 'board' + getSelectedBoardId: () => number | null + getAgileColumns: () => AgileColumn[] + setAgileColumns: (cols: AgileColumn[]) => void + getCachedIssues: () => JiraIssue[] + setCachedIssues: (issues: JiraIssue[]) => void + /** Board mode filters by assignee; a background refresh must not keep a stale one. */ + resetAssigneeFilter: () => void +} + +/** The issue detail drawer: shown over the board/list, its state preserved underneath. */ + export async function showIssueDetail(deps: JiraIssueDrawerDeps, it: JiraIssue): Promise { + const { jira, getActiveAccount, detailPane, getViewMode, getSelectedBoardId, getAgileColumns, setAgileColumns, setCachedIssues, resetAssigneeFilter } = deps + const api = jira.request + const close = (): void => { overlay.remove() } + + const overlay = document.createElement('div') + overlay.className = 'jira-drawer-overlay' + overlay.addEventListener('click', e => { if (e.target === overlay) close() }) + + const drawer = document.createElement('div') + drawer.className = 'jira-drawer' + + const openBtn = mkBtn('globe', 'Abrir en Jira', () => openUrl(browseUrl(getActiveAccount()!.site, it.key)).catch(() => {})) + const closeBtn = mkBtn('x', 'Cerrar', close) + + const meta = document.createElement('div') + meta.className = 'jira-detail-meta' + const key = document.createElement('span') + key.className = 'jira-key' + key.textContent = it.key + const status = document.createElement('span') + status.className = `jira-status ${statusCategoryClass(it.statusCategory)}` + status.textContent = it.status + const issueType = document.createElement('span') + issueType.className = 'jira-type' + issueType.textContent = it.type + meta.append(key, status, issueType) + const summary = document.createElement('div') + summary.className = 'jira-detail-summary' + summary.textContent = it.summary + // Two-column layout: description (left) + metadata (right) + const body = document.createElement('div') + body.className = 'jira-detail jira-detail-layout' + + const left = document.createElement('div') + left.className = 'jira-detail-left' + + const right = document.createElement('div') + right.className = 'jira-detail-right' + + const descEl = document.createElement('div') + descEl.className = 'jira-detail-desc jira-wiki-body' + descEl.textContent = 'Cargando…' + left.append(meta, summary, descEl) + + body.append(left, right) + drawer.append(detailHeader('Detalle', openBtn, closeBtn), body) + + jira.fetchIssueDetail(it.key).then(async d => { + // Render description: use Jira's pre-rendered HTML if available, else parse wiki markup + const attachMap = new Map(d.attachments.map(a => [a.filename, a.content])) + if (d.isRenderedHtml) { + descEl.innerHTML = d.description || '(sin descripción)' + // Replace image srcs with authenticated data URLs + descEl.querySelectorAll('img').forEach(img => { + const src = img.getAttribute('src') + if (src) jira.fetchAsDataUrl(src).then(data => { img.src = data }).catch(() => {}) + }) + } else { + descEl.innerHTML = d.description + ? jiraWikiToHtml(d.description, attachMap) + : '(sin descripción)' + } + + // Wire all links to open in browser + descEl.querySelectorAll('a').forEach(a => { + a.addEventListener('click', e => { + e.preventDefault() + const href = a.getAttribute('href') || (a as HTMLElement).dataset.href + if (href && href !== '#') openUrl(href).catch(() => {}) + }) + }) + descEl.querySelectorAll('.jira-wiki-link').forEach(a => { + a.addEventListener('click', e => { + e.preventDefault() + const href = (a as HTMLElement).dataset.href + if (href) openUrl(href).catch(() => {}) + }) + }) + + // Metadata sidebar + const metaItems: Array<[string, string, string?]> = ([ + ['Asignado', d.assignee, d.assigneeAvatar] as [string, string, string?], + ['Informador', d.reporter, d.reporterAvatar] as [string, string, string?], + ['Prioridad', d.priority] as [string, string], + ['Sprint', d.sprint] as [string, string], + ['Estimación', d.estimate] as [string, string], + ...(d.fixVersions.length ? [['Versiones', d.fixVersions.join(', ')] as [string, string]] : []), + ]).filter(([, v]) => v) + + right.replaceChildren() + metaItems.forEach(([label, value, avatar]) => { + const row = document.createElement('div') + row.className = 'jira-meta-row' + if (label === 'Estimación') row.dataset.field = 'estimate' + const lbl = document.createElement('span') + lbl.className = 'jira-meta-label' + lbl.textContent = label.toUpperCase() + const val = document.createElement('span') + val.className = 'jira-meta-value' + if (avatar) { + const img = document.createElement('img') + img.src = avatar + img.className = 'jira-meta-avatar' + img.alt = value + img.onerror = () => img.remove() + val.append(img) + } + val.append(document.createTextNode(value)) + row.append(lbl, val) + right.append(row) + }) + + // Attachments as cards (images show thumbnail) + if (d.attachments.length) { + const attTitle = document.createElement('div') + attTitle.className = 'jira-detail-section-title' + attTitle.textContent = 'Archivos adjuntos' + const attGrid = document.createElement('div') + attGrid.className = 'jira-att-grid' + d.attachments.forEach(a => { + const card = document.createElement('div') + card.className = 'jira-att-card' + const isImg = a.mimeType.startsWith('image/') + const isPdf = a.mimeType === 'application/pdf' + if (isImg) { + const thumb = document.createElement('img') + thumb.className = 'jira-att-thumb' + thumb.alt = a.filename + thumb.addEventListener('click', () => openUrl(a.content).catch(() => {})) + const thumbUrl = a.thumbnail || a.content + jira.fetchAsDataUrl(thumbUrl) + .then(data => { thumb.src = data }) + .catch(() => { thumb.replaceWith(Object.assign(document.createElement('span'), { className: 'jira-att-icon', textContent: '🖼️' })) }) + card.append(thumb) + } else { + const iconEl = document.createElement('span') + iconEl.className = 'jira-att-icon' + iconEl.textContent = isPdf ? '📄' : '📎' + card.append(iconEl) + } + const name = document.createElement('span') + name.className = 'jira-att-name' + name.textContent = a.filename + name.title = a.filename + const dlBtn = document.createElement('button') + dlBtn.className = 'jira-action' + dlBtn.title = 'Abrir / Descargar' + dlBtn.innerHTML = icon('arrow-right') + dlBtn.addEventListener('click', () => openUrl(a.content).catch(() => {})) + card.append(name, dlBtn) + attGrid.append(card) + }) + left.append(attTitle, attGrid) + } + + // Transitions — move card to another status from the detail panel + try { + const res = await api('GET', `api/2/issue/${it.key}/transitions`) as { + transitions?: Array<{ id: string; name: string; to: { name: string } }> + } + const transitions = (res?.transitions ?? []).filter(t => t.to.name !== it.status) + if (transitions.length) { + const trTitle = document.createElement('div') + trTitle.className = 'jira-meta-label' + trTitle.textContent = 'Mover a' + const trList = document.createElement('div') + trList.className = 'jira-transitions' + transitions.forEach(t => { + const btn = document.createElement('button') + btn.className = 'jira-transition-btn' + btn.textContent = t.to.name + btn.addEventListener('click', async () => { + btn.disabled = true + btn.textContent = '…' + try { + await api('POST', `api/2/issue/${it.key}/transitions`, { transition: { id: t.id } }) + it.status = t.to.name + // Refresh board if in board mode + if (getViewMode() === 'board' && getSelectedBoardId()) { + const boardId = getSelectedBoardId()! + const cols = await jira.fetchBoardColumns(boardId).catch(() => getAgileColumns()) + setAgileColumns(cols) + setCachedIssues(await jira.fetchBoardIssues(boardId)) + resetAssigneeFilter() + } + close() + } catch { btn.disabled = false; btn.textContent = t.to.name } + }) + trList.append(btn) + }) + right.append(trTitle, trList) + } + } catch { /* transitions not available */ } + + // Pull Requests + if (d.pullRequests.length) { + const prTitle = document.createElement('div') + prTitle.className = 'jira-detail-section-title' + prTitle.textContent = 'Pull Requests' + const prList = document.createElement('div') + prList.className = 'jira-detail-prs' + d.pullRequests.forEach(pr => { + const row = document.createElement('a') + row.className = `jira-pr-row jira-pr-${(pr.status || 'open').toLowerCase()}` + row.textContent = pr.title || pr.url + row.title = pr.url + row.addEventListener('click', () => openUrl(pr.url).catch(() => {})) + prList.append(row) + }) + left.append(prTitle, prList) + } + + // ---- Editable estimation in sidebar ---- + const estRow = right.querySelector('.jira-meta-row[data-field="estimate"]') as HTMLElement | null + const makeEstEdit = (): void => { + const estInput = document.createElement('input') + estInput.className = 'jira-input' + estInput.value = d.estimate + estInput.placeholder = '2h, 30m…' + estInput.style.cssText = 'width:100%;margin-top:2px' + const save = document.createElement('button') + save.className = 'jira-primary' + save.style.cssText = 'margin-top:4px;padding:3px 8px;font-size:11px' + save.textContent = 'Guardar' + save.addEventListener('click', async () => { + save.disabled = true + try { + await api('PUT', `api/2/issue/${it.key}`, { update: { timetracking: [{ set: { originalEstimate: estInput.value.trim() } }] } }) + d.estimate = estInput.value.trim() + estRow?.replaceChildren( + Object.assign(document.createElement('span'), { className: 'jira-meta-label', textContent: 'ESTIMACIÓN' }), + Object.assign(document.createElement('span'), { className: 'jira-meta-value' }) + ) + const valEl = estRow?.querySelector('.jira-meta-value') + if (valEl) valEl.textContent = d.estimate + } catch { save.disabled = false } + }) + estRow?.append(estInput, save) + } + if (estRow) { + const valEl = estRow.querySelector('.jira-meta-value') + if (valEl) valEl.addEventListener('click', makeEstEdit) + } + + // ---- Edit description ---- + const editDescBtn = document.createElement('button') + editDescBtn.className = 'jira-action' + editDescBtn.title = 'Editar descripción' + editDescBtn.innerHTML = icon('settings') + editDescBtn.addEventListener('click', () => { + const ta = document.createElement('textarea') + ta.className = 'jira-textarea' + ta.style.cssText = 'min-height:120px;width:100%;box-sizing:border-box' + ta.value = d.description + const saveDesc = document.createElement('button') + saveDesc.className = 'jira-primary' + saveDesc.textContent = 'Guardar' + const cancelDesc = document.createElement('button') + cancelDesc.className = 'jira-transition-btn' + cancelDesc.textContent = 'Cancelar' + const row = document.createElement('div') + row.style.cssText = 'display:flex;gap:6px;margin-top:6px' + row.append(saveDesc, cancelDesc) + descEl.replaceChildren(ta, row) + cancelDesc.addEventListener('click', () => { + descEl.innerHTML = d.isRenderedHtml ? d.description : jiraWikiToHtml(d.description, attachMap) + }) + saveDesc.addEventListener('click', async () => { + saveDesc.disabled = true + try { + await api('PUT', `api/2/issue/${it.key}`, { fields: { description: ta.value } }) + d.description = ta.value + d.isRenderedHtml = false + descEl.innerHTML = jiraWikiToHtml(ta.value, attachMap) + } catch { saveDesc.disabled = false } + }) + }) + drawer.querySelector('.jira-header')?.append(editDescBtn) + + // ---- Comments ---- + const commentTitle = document.createElement('div') + commentTitle.className = 'jira-detail-section-title' + commentTitle.textContent = 'Comentarios' + const commentList = document.createElement('div') + commentList.className = 'jira-comment-list' + commentList.textContent = 'Cargando comentarios…' + + const commentInput = document.createElement('textarea') + commentInput.className = 'jira-textarea' + commentInput.placeholder = 'Escribe un comentario…' + commentInput.style.cssText = 'min-height:70px;width:100%;box-sizing:border-box' + const commentSubmit = document.createElement('button') + commentSubmit.className = 'jira-primary' + commentSubmit.textContent = 'Comentar' + commentSubmit.addEventListener('click', async () => { + const text = commentInput.value.trim() + if (!text) return + commentSubmit.disabled = true + try { + await api('POST', `api/2/issue/${it.key}/comment`, { body: text }) + commentInput.value = '' + const res = await api('GET', `api/2/issue/${it.key}/comment?maxResults=30&orderBy=-created`) as { comments?: Array<{ body: string; author?: { displayName?: string }; created?: string }> } + renderComments(res?.comments ?? []) + } finally { commentSubmit.disabled = false } + }) + + const renderComments = (comments: Array<{ body: string; author?: { displayName?: string }; created?: string }>): void => { + commentList.replaceChildren() + if (!comments.length) { commentList.textContent = 'Sin comentarios.'; return } + comments.forEach(c => { + const item = document.createElement('div') + item.className = 'jira-comment' + const cMeta = document.createElement('div') + cMeta.className = 'jira-comment-meta' + cMeta.textContent = `${c.author?.displayName ?? 'Anónimo'} · ${c.created ? new Date(c.created).toLocaleDateString() : ''}` + const cBody = document.createElement('div') + cBody.className = 'jira-comment-body jira-wiki-body' + cBody.innerHTML = jiraWikiToHtml(c.body ?? '') + item.append(cMeta, cBody) + commentList.append(item) + }) + } + + api('GET', `api/2/issue/${it.key}/comment?maxResults=30&orderBy=-created`) + .then((res: unknown) => { + const r = res as { comments?: Array<{ body: string; author?: { displayName?: string }; created?: string }> } + renderComments(r?.comments ?? []) + }) + .catch(() => { commentList.textContent = 'Error cargando comentarios.' }) + + left.append(commentTitle, commentList, commentInput, commentSubmit) + }).catch(() => { descEl.textContent = '(error cargando descripción)' }) + overlay.append(drawer) + detailPane.append(overlay) + } + + // ---- create ---- diff --git a/src/panels/jira/jiraWidgets.ts b/src/panels/jira/jiraWidgets.ts new file mode 100644 index 0000000..6fbcf88 --- /dev/null +++ b/src/panels/jira/jiraWidgets.ts @@ -0,0 +1,41 @@ +import { icon } from '../../ui/icons' + +export function note(text: string, cls = 'jira-note'): HTMLElement { + const el = document.createElement('div') + el.className = cls + el.textContent = text + return el +} + +export function mkBtn(iconName: string, title: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement('button') + b.className = 'jira-action' + b.title = title + b.innerHTML = icon(iconName) + b.addEventListener('click', onClick) + return b +} + +/** The detail pane header: a title followed by whatever action buttons the caller gives. */ +export function detailHeader(title: string, ...actions: HTMLElement[]): HTMLElement { + const bar = document.createElement('div') + bar.className = 'jira-header' + const h = document.createElement('span') + h.className = 'jira-title' + h.textContent = title + bar.append(h, ...actions) + return bar +} + +/** A labeled text input, used throughout the config/create/bulk forms. */ +export function field(label: string, value = '', type = 'text'): { row: HTMLElement; input: HTMLInputElement } { + const row = document.createElement('label') + row.className = 'jira-field' + row.textContent = label + const input = document.createElement('input') + input.className = 'jira-input' + input.type = type + input.value = value + row.appendChild(input) + return { row, input } +} diff --git a/src/panels/memory/MemoryPanel.ts b/src/panels/memory/MemoryPanel.ts index 9b6c1ba..a33c3a2 100644 --- a/src/panels/memory/MemoryPanel.ts +++ b/src/panels/memory/MemoryPanel.ts @@ -1,110 +1,29 @@ 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 { 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, - findSemanticallyDuplicate, - isArchivedMemory, - mergeMemoryEntries, - normalizeNewMemoryEntry, - toggleMemoryTag, - 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'), -} - -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[] -} +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 } 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' -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 } { + // 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' 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') @@ -170,186 +89,81 @@ 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 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' - - 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 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 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 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 + + const summaryJobsView = createMemorySummaryJobsView({ + currentProject, + setStatus: (message, entry) => setStatus(message, entry), + onRegenerated: async updated => { + if (updated) selectedId = updated.id + await reload() + }, + }) - const saveBtn = document.createElement('button') - saveBtn.className = 'memory-primary' - saveBtn.textContent = i18nT('common.save') + // 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) + }, + }) - form.append(kind, source, titleInput, tags, files, summary, details, saveBtn) - detail.append(detailHead, form) - cs.list.append(controls, summaryJobsPanel, sourcesPanel, 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() - let sources: MemorySource[] = [] - let previewCandidates: ImportedMemoryCandidate[] = [] - let previewSourceId: string | null = null - const previewCandidateState = new Map() - let selectedSourceProject = 'all' + + // 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 @@ -363,52 +177,12 @@ 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 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 + const visibleRows = (): MemoryEntry[] => filterMemoryEntries(entries, { + query: search.value, + kind: kindFilter.value as MemoryKind | 'all', + source: sourceFilter.value, + includeArchived: archivedCheckbox.checked, }) - 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[] => { - 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 selectedRows = (): MemoryEntry[] => entries.filter(entry => selectedIds.has(entry.id)) const targetProjectEntries = async (): Promise => { @@ -416,157 +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 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 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 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))) - } - - 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 @@ -591,342 +214,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() - 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 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++ - } - 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' - 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 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) @@ -942,10 +229,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 => { @@ -962,97 +246,13 @@ 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 - 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) - } - addBtn.addEventListener('click', () => { selectedId = null fillForm() renderList() - titleInput.focus() + detailView.focusTitle() }) - 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) @@ -1068,150 +268,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() - 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++ - } - 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')) @@ -1226,42 +285,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) { @@ -1272,82 +300,7 @@ 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() - syncSourceActions() - syncSourceForm() - syncSourcesCollapsed() - void reloadSources() void reload() - void reloadSummaryJobs() 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/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/src/panels/memory/memoryImportRunner.ts b/src/panels/memory/memoryImportRunner.ts new file mode 100644 index 0000000..ee8a7de --- /dev/null +++ b/src/panels/memory/memoryImportRunner.ts @@ -0,0 +1,53 @@ +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, + /** 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, updatedAt?.(candidate)) + + 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/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/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/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/src/panels/notes/NotesPanel.ts b/src/panels/notes/NotesPanel.ts index 3b05db3..d4853cd 100644 --- a/src/panels/notes/NotesPanel.ts +++ b/src/panels/notes/NotesPanel.ts @@ -2,6 +2,7 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import { askAi } from '../../ui/askAi' import { parseNote, serializeNote, type ParsedNote } from '../../core/notes/noteFile' +import { groupNoteEntries, type NoteGroup } from '../../core/notes/noteGroups' import { noteTitle } from '../../core/notes/noteTitle' import { renderMarkdown } from '../../core/notes/renderMarkdown' import { initUndo, commit, undo, redo, current, type UndoState } from '../../core/notes/undoStack' @@ -192,21 +193,7 @@ export function createNotesPanel() { const displayTitle = (n: ParsedNote): string => n.title.trim() || noteTitle(n.body) - const groups = (): { category: string; items: Entry[] }[] => { - const q = search.value.trim().toLowerCase() - const matches = (e: Entry): boolean => { - if (!q) return true - return `${e.note.title} ${e.note.category} ${e.note.tags.join(' ')}`.toLowerCase().includes(q) - } - const map = new Map() - entries.filter(matches).forEach(e => { - const cat = e.note.category.trim() || i18nT('notes.uncategorized') - const items = map.get(cat) ?? [] - items.push(e) - map.set(cat, items) - }) - return [...map.entries()].map(([category, items]) => ({ category, items })) - } + const groups = (): NoteGroup[] => groupNoteEntries(entries, search.value, i18nT('notes.uncategorized')) const renderList = (): void => { list.innerHTML = '' 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..7dce8ae --- /dev/null +++ b/src/panels/review/ReviewCommentBubble.ts @@ -0,0 +1,183 @@ +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 +} + +// 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') + 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 { 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) + 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 { 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) + 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 { 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) + }) + 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/ReviewDiffView.ts b/src/panels/review/ReviewDiffView.ts new file mode 100644 index 0000000..a9711a5 --- /dev/null +++ b/src/panels/review/ReviewDiffView.ts @@ -0,0 +1,535 @@ +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' +import { buildCommentInputRow } from './ReviewCommentBubble' + +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 +} + +// 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 + + // ── Diff renderer ───────────────────────────────────────────────────────── + const buildFileDiff = (chunk: string, filePath: string): HTMLElement => { + const container = document.createElement('div') + container.dataset.filepath = filePath + const ext = filePath.split('.').pop() ?? '' + const rangeSelector = createLineRangeSelector(container, filePath, state.makeLineForm, anchorWrap => anchorWrap) + + // 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(); rangeSelector.start(capturedLine) + }) + 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) + 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') + 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(); rangeSelector.start(cap) + }) + 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 { 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() + 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 413af9a..dcfd81f 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -1,243 +1,44 @@ 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' import { t as i18nT } from '../../i18n' +import { buildReviewAgentControls } from './ReviewAgentControls' +import { buildReviewCommentBubble, buildReviewLineForm } from './ReviewCommentBubble' +import { buildReviewSidebarLists } from './ReviewSidebarLists' +import { buildReviewDiffView } from './ReviewDiffView' +import { buildReviewDataLoader } from './reviewDataLoader' +import { buildReviewAiRun } from './reviewAiRun' +import type { GhComment, GhPr, SidebarMode, FileTypeFilter, ReviewChangeFile } from './reviewFormat' +import { + resolveReviewFollowUpSession, + buildReviewFileManifest, + buildReviewFileBatches, + describeReviewPrState, + describeReviewNoBranchChanges, + filterReviewPrs, + 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' @@ -262,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 @@ -305,104 +106,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,954 +408,111 @@ 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 - })) - } - 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 ghBranch = (b: string): string => b.replace(/^[^/]+\//, '') + const commentActions = { + repoPath: () => repoPath, + isResolved: (id: number) => resolvedComments.has(id), + setResolved: setCommentResolved, + refresh: async () => { await loadExistingComments(); injectExistingComments() }, + } + const buildCommentBubble = (c: GhComment): HTMLElement => buildReviewCommentBubble(c, commentActions) + + const { renderBranchList, renderPrList, loadPrList } = 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(), + }, + ) // ── 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 - } - - // ── 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 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) + + 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 }, + 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 }, + 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 () => { @@ -1687,31 +557,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) @@ -1719,340 +564,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/ReviewSidebarLists.ts b/src/panels/review/ReviewSidebarLists.ts new file mode 100644 index 0000000..8cc97a2 --- /dev/null +++ b/src/panels/review/ReviewSidebarLists.ts @@ -0,0 +1,152 @@ +import { invoke } from '@tauri-apps/api/core' +import { reviewT } from './i18n' +import { renderReviewPrStateBadge, 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 = 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) + // 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/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..16f1bc0 --- /dev/null +++ b/src/panels/review/reviewDataLoader.ts @@ -0,0 +1,309 @@ +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 { renderReviewPrStateBadge, 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 + 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 + 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 = renderReviewPrStateBadge(pr.state, pr.mergedAt, 'review-pr-state') + if (stateBadge) prMetaEl.append(stateBadge) + + 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/src/panels/review/reviewFormat.ts b/src/panels/review/reviewFormat.ts new file mode 100644 index 0000000..ed28983 --- /dev/null +++ b/src/panels/review/reviewFormat.ts @@ -0,0 +1,234 @@ +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 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 }) + } + 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/src/panels/tasks/PrStatusView.ts b/src/panels/tasks/PrStatusView.ts index 7c8a434..08c87db 100644 --- a/src/panels/tasks/PrStatusView.ts +++ b/src/panels/tasks/PrStatusView.ts @@ -1,5 +1,6 @@ import type { PrStatus } from './gitTypes' import { taskT } from './i18n' +import { classifyPrCheck } from '../../core/git/prChecks' interface PrStatusViewOptions { pr: PrStatus @@ -35,8 +36,9 @@ export function buildPrStatusView({ pr, baseBranch, onBack, onOpen }: PrStatusVi checks.className = 'tasks-backup-list' for (const check of pr.statusCheckRollup ?? []) { const state = check.conclusion ?? check.state ?? check.status ?? 'UNKNOWN' - const failed = /FAIL|ERROR|CANCEL|TIMED_OUT/i.test(state) - const pending = /PENDING|QUEUED|IN_PROGRESS|EXPECTED/i.test(state) + const verdict = classifyPrCheck(check) + const failed = verdict === 'failed' + const pending = verdict === 'pending' const row = document.createElement('div') row.className = `tasks-operation-item tasks-operation-item--${failed ? 'error' : pending ? 'pending' : 'success'}` row.append( diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index 1d8790f..91c6f6c 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -1,135 +1,24 @@ -import { invoke } from '@tauri-apps/api/core' -import { open as openUrl } from '@tauri-apps/plugin-shell' -import { open as pickFolder, confirm as askConfirm } from '@tauri-apps/plugin-dialog' -import { taskBranch, taskPath, type Worktree } from '../../core/git/worktree' -import { showContextMenu } from '../../ui/contextMenu' +import { open as pickFolder } from '@tauri-apps/plugin-dialog' import { icon } from '../../ui/icons' -import { extractIssueKey, statusCategoryClass, parseAheadBehind } from '../../core/git/taskJira' -import { diffFileNames, changedPaths, matchingPaths, buildSelectedPatch } from '../../core/git/commitWorkflow' -import { previewRebase, type RebaseAction, type RebasePlanItem } from '../../core/git/rebaseWorkflow' -import { - fetchIssue, fetchTransitions, applyTransition, browseUrl, loadJiraConfig, - type JiraConfig, type TaskIssue, -} from './taskJiraClient' -import { buildOperationHistoryView } from './OperationHistoryView' -import type { BackupStatus, CommitEntry, PrStatus, RebaseStatus, RewritePreflight, UpstreamStatus } from './gitTypes' -import { buildPrStatusView } from './PrStatusView' import { taskT } from './i18n' -import { buildBackupHistoryView } from './BackupHistoryView' -import { buildConflictResolverView } from './ConflictResolverView' -import { buildChangesFileView } from './ChangesFileView' -import { buildRebasePlanPreview } from './RebasePlanView' -import { buildCommitFileList, fileStateMap, renderPatchHtml } from './TaskCodeView' -import { commitFilesRaw, recommendationMap, taskGit } from './taskGitClient' -import { TaskPanelStore } from './TaskPanelStore' -import { createTaskDockerView, type IsolateResult, type RecipeApplyResult } from './TaskDockerView' -import { buildResetView } from './ResetView' -import { buildGraphView } from './GraphView' -import { buildCommitLogView } from './CommitLogView' -import { buildRebaseMergeWarning } from './RebaseMergeWarningView' -import { buildSyncErrorView } from './TaskAuxiliaryViews' -import { loadTaskData } from './TaskDataLoader' -import { taskProgress } from './taskProgress' -import { buildIncomingChangesView } from './IncomingChangesView' -import { taskRowActions } from './TaskRowActions' -import { TauriAppSettingsRepository } from '../../adapters/TauriAppSettingsRepository' -import type { AppSettings } from '../../ports/AppSettingsRepository' -import { isRunning, parseContainers } from '../../core/docker/containers' +import { createTaskDockerView } from './TaskDockerView' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' -import type { DetailLifecycle } from '../docker/containerDetail' - +import { + createTasksPanelCtx, disposeDetail, setDetailLifecycle, stopDiffRefresh, type TasksPanelCtx, +} from './tasksPanelContext' +import { iconBtn, note, showDetail } from './tasksPanelHelpers' +import { applyFilter } from './tasksListView' +import { showTaskSettings } from './tasksDetailViews' +import { load } from './tasksLifecycle' + +// The panel used to be one closure over ~30 shared mutable variables with +// ~30 nested functions. It is now a plain mutable `TasksPanelCtx` (see +// tasksPanelContext.ts) threaded explicitly through view/lifecycle +// functions split across tasksListView.ts, tasksDetailViews.ts, +// tasksRebaseView.ts and tasksLifecycle.ts. This file only builds the DOM +// scaffold (sidebar, filter, controls) and wires it to those functions. export function createTasksPanel(panelId = 'default'): { element: HTMLElement; dispose: () => void; onVisibilityChange: (visible: boolean) => void } { - const panelStore = new TaskPanelStore(panelId) - const settingsRepository = new TauriAppSettingsRepository() - let appSettings: AppSettings = {} - const settingsReady = settingsRepository.load().then(settings => { appSettings = settings }).catch(() => {}) - let worktrees: Worktree[] = [] - let repoPath = panelStore.repository() - let detailCleanup: () => void = () => {} - let detailPause: () => void = () => {} - let detailResume: () => void = () => {} - let panelVisible = true - const disposeDetail = (): void => { - // Invalidate async work started by the outgoing detail. Stopping an - // interval is not enough when one of its refresh requests is already in - // flight: without a new generation it could finish later and replace the - // newly opened commit/rebase/conflict UI. - detailVersion += 1 - const cleanup = detailCleanup - detailCleanup = () => {} - detailPause = () => {} - detailResume = () => {} - cleanup() - } - const setDetailLifecycle = (lifecycle: DetailLifecycle): void => { - detailCleanup = lifecycle.dispose - detailPause = lifecycle.pause - detailResume = lifecycle.resume - if (!panelVisible) detailPause() - } - // Live agents hub per worktree. Kept alive across detail navigation so the - // running agents/terminals survive switching to changes/history and back; - // disposed only when the worktree is removed or the whole panel closes. - const worktreeTerminals = new Map void; persist: () => void; dispose: () => void }>() - let selectedRow: HTMLElement | null = null - let selectedWorktreePath = panelStore.selected() ?? '' - let selectedRepositoryPath = repoPath - let selectionVersion = 0 - let detailVersion = 0 - let filterText = '' - // Multi-repo: which repo each worktree belongs to, and per-repo collapse state. - const repoOf = new Map() - const baseOf = new Map() - const collapsedRepos = new Set() - let lastStatuses = new Map() - let lastRunningPaths = new Set() - let baseBranch = panelStore.base() - let jiraCfg: JiraConfig | null = null - const issueMap = new Map() - const aheadBehindMap = new Map() - const prStatusMap = new Map() - const backupStatusMap = new Map() - const rebaseStatusMap = new Map() - const upstreamStatusMap = new Map() - let diffRefreshInterval: ReturnType | null = null - - const repositoryFor = (wt: Worktree): string => repoOf.get(wt.path) ?? repoPath - const baseFor = (wt: Worktree): string => baseOf.get(wt.path) ?? baseBranch - - const recordOperation = (wt: Worktree, operation: string, status: 'success' | 'error', detail: string): void => { - panelStore.recordOperation(repositoryFor(wt), wt.branch ?? '(detached)', operation, status, detail) - } - - const selectRow = (row: HTMLElement): void => { - selectedRow?.classList.remove('tasks-row--selected') - selectedRow = row - row.classList.add('tasks-row--selected') - } - - const selectedRepository = (): string => selectedRepositoryPath || repoPath - - // Populated after cs is created; called on every selection/list change. - let refreshMiniItems: () => void = () => {} - - const selectWorktree = (row: HTMLElement, wt: Worktree): number => { - selectRow(row) - selectedWorktreePath = wt.path - selectedRepositoryPath = repositoryFor(wt) - panelStore.setSelected(wt.path) - selectionVersion += 1 - detailVersion += 1 - refreshMiniItems() - return selectionVersion - } - - const isCurrentSelection = (version: number, wt: Worktree): boolean => { - const isSameSelectionVersion = version === selectionVersion - return isSameSelectionVersion && selectedWorktreePath === wt.path - } - - const stopDiffRefresh = (): void => { - if (diffRefreshInterval !== null) { clearInterval(diffRefreshInterval); diffRefreshInterval = null } - } + const ctx = createTasksPanelCtx(panelId) const root = document.createElement('div') root.className = 'tasks-panel' @@ -142,7 +31,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d repoBtn.dataset.testid = 'tasks-select-repository' repoBtn.title = taskT('selectRepo') const updateRepoBtn = (): void => { - const name = repoPath ? repoPath.replace(/\/$/, '').split('/').pop()! : taskT('selectRepoShort') + const name = ctx.repoPath ? ctx.repoPath.replace(/\/$/, '').split('/').pop()! : taskT('selectRepoShort') repoBtn.replaceChildren() const iconSlot = document.createElement('span') iconSlot.innerHTML = icon('folder') @@ -151,25 +40,27 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d repoBtn.append(iconSlot, label) } updateRepoBtn() + ctx.updateRepoBtn = updateRepoBtn repoBtn.addEventListener('click', async () => { - const picked = await pickFolder({ directory: true, defaultPath: repoPath || undefined }).catch(() => null) + const picked = await pickFolder({ directory: true, defaultPath: ctx.repoPath || undefined }).catch(() => null) if (!picked || typeof picked !== 'string') return // Add to the repo list (multi-repo). First pick behaves as before (one repo). - panelStore.addRepository(picked) - load() + ctx.panelStore.addRepository(picked) + void load(ctx) }) + const baseSelect = document.createElement('select') baseSelect.className = 'tasks-base-select' baseSelect.title = taskT('baseBranch') baseSelect.addEventListener('change', () => { - baseBranch = baseSelect.value - panelStore.setBase(baseBranch) - load() + ctx.baseBranch = baseSelect.value + ctx.panelStore.setBase(ctx.baseBranch) + void load(ctx) }) const fetchAgeEl = document.createElement('span') fetchAgeEl.className = 'tasks-fetch-age' - const refreshBtn = iconBtn('refresh', taskT('reload'), () => load()) - const settingsBtn = iconBtn('settings', taskT('taskSettings'), () => { void showTaskSettings() }) + const refreshBtn = iconBtn('refresh', taskT('reload'), () => void load(ctx)) + const settingsBtn = iconBtn('settings', taskT('taskSettings'), () => { void showTaskSettings(ctx) }) settingsBtn.dataset.testid = 'tasks-settings' // ---- layout ---- @@ -192,7 +83,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d type: 'search', placeholder: taskT('filter'), }) - filterInput.addEventListener('input', () => { filterText = filterInput.value; applyFilter() }) + filterInput.addEventListener('input', () => { ctx.filterText = filterInput.value; applyFilter(ctx) }) const listWrap = document.createElement('div') listWrap.className = 'tasks-list-wrap' @@ -251,9 +142,9 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // Remove the principal repo from Bento (list only — never touches the repo on // disk or its worktrees). const removeRepoBtn = iconBtn('x', taskT('removeRepo'), () => { - if (!repoPath) return - panelStore.removeRepository(repoPath) - load() + if (!ctx.repoPath) return + ctx.panelStore.removeRepository(ctx.repoPath) + void load(ctx) }) removeRepoBtn.classList.add('tasks-repo-remove') const repoRow = document.createElement('div') @@ -277,1859 +168,58 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d body.append(cs.element, cs.resizer, detailPane) root.append(body) - refreshMiniItems = (): void => { - cs.setMiniItems(worktrees.map(wt => { - const changes = lastStatuses.get(wt.path) ?? 0 - const hasRunning = lastRunningPaths.has(wt.path) + ctx.root = root + ctx.repoBtn = repoBtn + ctx.removeRepoBtn = removeRepoBtn + ctx.repoRow = repoRow + ctx.baseSelect = baseSelect + ctx.baseRow = baseRow + ctx.fetchAgeEl = fetchAgeEl + ctx.filterInput = filterInput + ctx.listWrap = listWrap + ctx.progressFooter = progressFooter + ctx.createFormWrap = createFormWrap + ctx.detailPane = detailPane + + ctx.refreshMiniItems = (): void => { + cs.setMiniItems(ctx.worktrees.map(wt => { + const changes = ctx.lastStatuses.get(wt.path) ?? 0 + const hasRunning = ctx.lastRunningPaths.has(wt.path) return { label: wt.branch ?? wt.path.replace(/\/$/, '').split('/').pop() ?? wt.path, dot: hasRunning ? 'working' : changes > 0 ? 'blocked' : undefined, - active: wt.path === selectedWorktreePath, + active: wt.path === ctx.selectedWorktreePath, onClick: () => listWrap.querySelector(`[data-path="${CSS.escape(wt.path)}"]`)?.click(), } })) } - const note = (text: string, cls = 'tasks-note'): HTMLElement => - Object.assign(document.createElement('div'), { className: cls, textContent: text }) - - const showDetail = (...nodes: HTMLElement[]): void => { detailPane.replaceChildren(...nodes) } - const buildSubHead = (title: string, goBack: () => void, ...extra: HTMLElement[]): HTMLElement => { - const head = document.createElement('div') - head.className = 'tasks-sub-head' - head.append( - iconBtn('arrow-left', taskT('back'), goBack), - Object.assign(document.createElement('span'), { className: 'tasks-sub-title', textContent: title }), - ...extra, - ) - return head - } - const dockerView = createTaskDockerView({ - showDetail, - resetDetail: () => { stopDiffRefresh(); disposeDetail() }, - setLifecycle: setDetailLifecycle, + ctx.dockerView = createTaskDockerView({ + showDetail: (...nodes) => showDetail(ctx, ...nodes), + resetDetail: () => { stopDiffRefresh(ctx); disposeDetail(ctx) }, + setLifecycle: lifecycle => setDetailLifecycle(ctx, lifecycle), }) - const defaultProjectKey = (repository = repoPath): string => repository.replace(/\/$/, '').split('/').pop() ?? '' - const projectKey = (repository = repoPath): string => panelStore.projectKey() || defaultProjectKey(repository) - - const prepareTaskDevcontainer = async (worktree: Worktree): Promise => { - await settingsReady - appSettings = await settingsRepository.load().catch(() => appSettings) - return dockerView.prepareDevcontainer( - worktree, - appSettings.devcontainerRecipesDir, - projectKey(repositoryFor(worktree)), - panelStore.devcontainerDir() ?? undefined, - path => panelStore.setDevcontainerDir(path), - ) - } - - async function showTaskSettings(): Promise { - stopDiffRefresh() - disposeDetail() - showDetail(note(taskT('loading'), 'db-detail-loading')) - await settingsReady - - const wrap = document.createElement('div') - wrap.className = 'tasks-settings-view' - const title = Object.assign(document.createElement('h3'), { textContent: taskT('taskSettings') }) - const description = note(taskT('recipesDirHint'), 'db-detail-hint') - const recipeProject = projectKey() || taskT('recipesExampleProject') - const projectGuide = note(taskT('addProjectRecipeHint', { project: recipeProject }), 'db-detail-hint') - const recipeExample = Object.assign(document.createElement('pre'), { - className: 'tasks-settings-example', - textContent: `${appSettings.devcontainerRecipesDir || '/ruta/a/bento-recipes'}/${recipeProject}/\n`+ - '├── .env\n' + - '└── .devcontainer/\n' + - ' ├── docker-compose.override.yml\n' + - ' └── bento-postcreate.sh', - }) - const label = Object.assign(document.createElement('label'), { - className: 'tasks-settings-label', - textContent: taskT('recipesDir'), - }) - const row = document.createElement('div') - row.className = 'tasks-settings-row' - const input = Object.assign(document.createElement('input'), { - className: 'tasks-settings-input', - type: 'text', - readOnly: true, - placeholder: taskT('recipesDirEmpty'), - value: appSettings.devcontainerRecipesDir ?? '', - }) - const status = note('', 'tasks-note') - - const keyLabel = Object.assign(document.createElement('label'), { - className: 'tasks-settings-label', - textContent: taskT('projectKey'), - }) - const keyInput = Object.assign(document.createElement('input'), { - className: 'tasks-settings-input', - type: 'text', - value: projectKey(), - placeholder: defaultProjectKey(), - }) - keyInput.addEventListener('change', () => { - panelStore.setProjectKey(keyInput.value === defaultProjectKey() ? '' : keyInput.value) - void showTaskSettings() - }) - keyLabel.appendChild(keyInput) - - const persist = async (directory: string | undefined): Promise => { - appSettings = { ...appSettings, devcontainerRecipesDir: directory || undefined } - input.value = directory ?? '' - status.textContent = taskT('savingSettings') - try { - await settingsRepository.save(appSettings) - status.textContent = taskT('settingsSaved') - } catch (error) { - status.className = 'db-detail-error' - status.textContent = String(error) - } - } - const choose = iconBtn('folder', taskT('chooseRecipesDir'), () => { - void pickFolder({ - directory: true, - defaultPath: appSettings.devcontainerRecipesDir, - }).then(picked => { - if (typeof picked === 'string') void persist(picked) - }).catch(() => {}) - }) - const clear = iconBtn('x', taskT('clearRecipesDir'), () => { void persist(undefined) }) - input.addEventListener('click', () => choose.click()) - row.append(input, choose, clear) - label.append(row) - - const recipeActions = document.createElement('div') - recipeActions.className = 'tasks-compose-controls' - const recipePath = (): string | null => appSettings.devcontainerRecipesDir - ? `${appSettings.devcontainerRecipesDir.replace(/\/$/, '')}/${projectKey()}` - : null - const createRecipe = iconBtn('plus', taskT('createRecipe'), () => { - if (!appSettings.devcontainerRecipesDir) { status.textContent = taskT('selectRecipesDirFirst'); return } - void invoke('devcontainer_recipe_create', { - recipesDir: appSettings.devcontainerRecipesDir, - projectKey: projectKey(), - }).then(path => { - status.className = 'tasks-note' - status.textContent = taskT('recipeCreated', { path }) - }).catch(error => { status.className = 'db-detail-error'; status.textContent = String(error) }) - }) - const openRecipe = iconBtn('folder', taskT('openRecipeFolder'), () => { - const path = recipePath() - if (path) invoke('open_in_editor', { path }).catch(error => { status.textContent = String(error) }) - }) - const gitAction = (action: 'init' | 'status' | 'pull' | 'push' | 'commit'): void => { - if (!appSettings.devcontainerRecipesDir) { status.textContent = taskT('selectRecipesDirFirst'); return } - const message = action === 'commit' ? window.prompt(taskT('recipeCommitMessage')) : null - if (action === 'commit' && !message) return - status.className = 'tasks-note' - status.textContent = taskT('recipeGitRunning', { action }) - void invoke('devcontainer_recipe_git', { - recipesDir: appSettings.devcontainerRecipesDir, - action, - message, - }).then(output => { - status.textContent = output || taskT('recipeGitDone', { action }) - }).catch(error => { status.className = 'db-detail-error'; status.textContent = String(error) }) - } - recipeActions.append( - createRecipe, - openRecipe, - iconBtn('git-branch', taskT('initRecipesGit'), () => gitAction('init')), - iconBtn('list', taskT('recipeGitStatus'), () => gitAction('status')), - iconBtn('download', taskT('recipeGitPull'), () => gitAction('pull')), - iconBtn('arrow-right', taskT('recipeGitPush'), () => gitAction('push')), - iconBtn('check', taskT('recipeGitCommit'), () => gitAction('commit')), - ) - wrap.append(title, description, projectGuide, recipeExample, keyLabel, label, recipeActions, status) - showDetail(wrap) - } - - showDetail(note(taskT('selectTask'), 'db-detail-hint')) - - // ---- list ---- - function renderList(statuses: Map, runningPaths: Set): void { - lastStatuses = statuses - lastRunningPaths = runningPaths - applyFilter() - } - - function progressBar(label: string, v: { done: number; total: number }): HTMLElement { - const pct = v.total ? Math.round((v.done / v.total) * 100) : 0 - const wrap = document.createElement('div') - wrap.className = 'tasks-progress-item' - const head = document.createElement('div') - head.className = 'tasks-progress-head' - head.append( - Object.assign(document.createElement('span'), { className: 'tasks-progress-label', textContent: label }), - Object.assign(document.createElement('span'), { className: 'tasks-progress-stat', textContent: `${v.done}/${v.total} · ${pct}%` }), - ) - const track = document.createElement('div') - track.className = 'tasks-progress-track' - const fill = document.createElement('div') - fill.className = 'tasks-progress-fill' - fill.style.width = `${pct}%` - track.appendChild(fill) - wrap.append(head, track) - return wrap - } - - // Footer progress bars: aggregate health of the repo's worktrees (see taskProgress). - function updateProgress(): void { - const p = taskProgress(worktrees, lastStatuses, aheadBehindMap) - progressFooter.replaceChildren( - progressBar(taskT('tasksClean'), p.clean), - progressBar(taskT('tasksSynced'), p.synced), - ) - } - - function applyFilter(): void { - updateProgress() - const lf = filterText.toLowerCase() - const filtered = filterText - ? worktrees.filter(wt => (wt.branch ?? '').toLowerCase().includes(lf) || wt.path.toLowerCase().includes(lf)) - : worktrees - - listWrap.replaceChildren() - refreshCreateForm() - if (filtered.length === 0) { - listWrap.append(note(worktrees.length === 0 ? taskT('noWorktrees') : taskT('noResults'))) - refreshMiniItems() - return - } - - // Group worktrees by their repo (single repo → one group), preserving order. - const byRepo = new Map() - for (const wt of filtered) { - const repo = repoOf.get(wt.path) ?? repoPath - const bucket = byRepo.get(repo) ?? [] - bucket.push(wt) - byRepo.set(repo, bucket) - } - for (const [repo, wts] of byRepo) listWrap.appendChild(buildProjectGroup(repo, wts)) - refreshMiniItems() - } - - // Collapsible project header (repo name + count) grouping that repo's worktrees. - function buildProjectGroup(repo: string, wts: Worktree[]): HTMLElement { - const repoRoot = repo.replace(/\/$/, '') - const group = document.createElement('div') - group.className = `tasks-project${collapsedRepos.has(repo) ? ' collapsed' : ''}` - const header = document.createElement('div') - header.className = 'tasks-project-header' - const toggle = document.createElement('button') - toggle.type = 'button' - toggle.className = 'tasks-project-toggle' - toggle.setAttribute('aria-expanded', String(!collapsedRepos.has(repo))) - const chevron = document.createElement('span') - chevron.className = 'tasks-project-chevron' - chevron.innerHTML = icon('chevron-down') - toggle.append( - chevron, - Object.assign(document.createElement('span'), { className: 'tasks-project-name', textContent: repoRoot.split('/').pop() ?? repo }), - Object.assign(document.createElement('span'), { className: 'tasks-project-count', textContent: String(wts.length) }), - ) - header.appendChild(toggle) - // Remove this repo from the list (only offered when there's more than one). - if (panelStore.repositories().length > 1) { - const remove = Object.assign(document.createElement('button'), { - type: 'button', className: 'tasks-project-remove', textContent: '×', title: taskT('removeRepo'), - }) - remove.addEventListener('click', e => { e.stopPropagation(); panelStore.removeRepository(repo); load() }) - header.appendChild(remove) - } - toggle.addEventListener('click', () => { - selectedRepositoryPath = repo - if (collapsedRepos.has(repo)) collapsedRepos.delete(repo); else collapsedRepos.add(repo) - group.classList.toggle('collapsed') - toggle.setAttribute('aria-expanded', String(!collapsedRepos.has(repo))) - }) - const list = document.createElement('div') - list.className = 'tasks-list' - wts.forEach(wt => { - const isMain = wt.path.replace(/\/$/, '') === repoRoot - list.appendChild(buildRow(wt, isMain, lastStatuses.get(wt.path) ?? 0, lastRunningPaths.has(wt.path))) - }) - group.append(header, list) - return group - } - - function buildRow(wt: Worktree, isMain: boolean, changes: number, hasRunning: boolean): HTMLElement { - const worktreeBase = baseFor(wt) - const row = document.createElement('div') - row.className = 'tasks-row' - row.dataset.testid = 'tasks-row' - row.dataset.branch = wt.branch ?? '' - row.dataset.path = wt.path - row.tabIndex = 0 - row.setAttribute('role', 'button') - row.setAttribute('aria-label', `${taskT('tasks')}: ${wt.branch ?? ''}, ${taskT('changes', { count: changes })}`) - - const runDot = document.createElement('span') - runDot.className = `tasks-run-dot ${hasRunning ? 'docker-up' : ''}` - runDot.title = hasRunning ? taskT('containersRunning') : taskT('noContainers') - - const issue = issueMap.get(wt.path) ?? null - const ab = aheadBehindMap.get(wt.path) - const pr = prStatusMap.get(wt.path) ?? null - const backup = backupStatusMap.get(wt.path) - const rebase = rebaseStatusMap.get(wt.path) - const upstream = upstreamStatusMap.get(wt.path) - - const branchEl = Object.assign(document.createElement('span'), { - className: 'tasks-branch', - textContent: wt.branch ?? taskT('detached'), - }) - if (isMain) branchEl.title = taskT('mainWorktree') - - const pathEl = Object.assign(document.createElement('span'), { - className: 'tasks-path', - textContent: wt.path.replace(/\/$/, '').split('/').slice(-2).join('/'), - title: wt.path, - }) - - const left = document.createElement('div') - left.className = 'tasks-row-left' - - if (issue) { - const issueEl = document.createElement('div') - issueEl.className = 'tasks-issue-line' - const keyEl = Object.assign(document.createElement('span'), { className: 'tasks-issue-key', textContent: issue.key }) - const sepEl = Object.assign(document.createElement('span'), { className: 'tasks-issue-sep', textContent: ' · ' }) - const summaryEl = Object.assign(document.createElement('span'), { className: 'tasks-issue-summary', textContent: issue.summary }) - const chipEl = Object.assign(document.createElement('span'), { - className: `jira-status ${statusCategoryClass(issue.statusCategory)}`, - textContent: issue.statusName, - }) - issueEl.append(keyEl, sepEl, summaryEl, chipEl) - left.append(issueEl, branchEl, pathEl) - } else { - left.append(branchEl, pathEl) - } - - const badge = Object.assign(document.createElement('span'), { - className: `tasks-badge${changes > 0 ? ' tasks-badge--dirty' : ''}`, - textContent: changes > 0 ? taskT('changes', { count: changes }) : taskT('clean'), - }) - const recipeEl = document.createElement('span') - if (!isMain) { - void invoke('devcontainer_recipe_status', { - worktreePath: wt.path, - devcontainerDir: panelStore.devcontainerDir(), - }).then(recipe => { - if (!recipe || !row.isConnected) return - recipeEl.className = `tasks-recipe-badge${recipe.errors.length ? ' tasks-recipe-badge--error' : ''}` - recipeEl.textContent = taskT('recipeBadge') - recipeEl.title = taskT('recipeBadgeTitle', { - project: recipe.projectKey, - applied: recipe.applied.length, - errors: recipe.errors.length, - date: new Date(recipe.appliedAt * 1000).toLocaleString(), - }) - }).catch(() => {}) - } - - const flashBadge = (text: string, cls: string, ms: number): void => { - const prev = badge.textContent ?? '' - const prevCls = badge.className - badge.textContent = text.split('\n')[0]?.slice(0, 28) ?? '' - badge.className = `tasks-badge ${cls}` - setTimeout(() => { badge.textContent = prev; badge.className = prevCls }, ms) - } - - // Ahead/behind indicator — orange when behind (needs sync) - const abEl = document.createElement('span') - abEl.className = 'tasks-ahead-behind' - if (ab && (ab.ahead > 0 || ab.behind > 0)) { - if (ab.behind > 0) abEl.classList.add('tasks-behind') - const parts: string[] = [] - if (ab.ahead > 0) parts.push(`↑${ab.ahead}`) - if (ab.behind > 0) parts.push(`↓${ab.behind}`) - abEl.textContent = parts.join(' ') - abEl.title = taskT('aheadBehindTitle', { ahead: ab.ahead, behind: ab.behind, branch: worktreeBase }) - } - - // PR status badge - const prEl = document.createElement('span') - if (pr) { - const stateMap: Record = { OPEN: 'tasks-pr-open', DRAFT: 'tasks-pr-draft', MERGED: 'tasks-pr-merged', CLOSED: 'tasks-pr-closed' } - const labelMap: Record = { - OPEN: taskT('openPrShort'), - DRAFT: taskT('draftPrShort'), - MERGED: taskT('mergedPrShort'), - CLOSED: taskT('closedPr'), - } - prEl.className = `tasks-pr-badge ${stateMap[pr.state] ?? ''}` - const checks = pr.statusCheckRollup ?? [] - const failedChecks = checks.filter(check => /FAIL|ERROR|CANCEL|TIMED_OUT/i.test(check.conclusion ?? check.state ?? '')) - const pendingChecks = checks.filter(check => /PENDING|QUEUED|IN_PROGRESS|EXPECTED/i.test(check.status ?? check.state ?? '')) - prEl.textContent = failedChecks.length ? taskT('failedChecks', { count: failedChecks.length }) - : pendingChecks.length ? taskT('pendingChecks', { count: pendingChecks.length }) - : labelMap[pr.state] ?? taskT('openPrShort') - const prSignals = [ - pr.baseRefName ? `base: ${pr.baseRefName}` : '', - pr.mergeable === 'CONFLICTING' ? taskT('baseConflicts') : '', - pr.reviewDecision === 'APPROVED' ? taskT('approved') : pr.reviewDecision === 'CHANGES_REQUESTED' ? taskT('changesRequested') : pr.reviewDecision === 'REVIEW_REQUIRED' ? taskT('reviewPending') : '', - failedChecks.length ? taskT('failingChecks', { count: failedChecks.length }) : pendingChecks.length ? taskT('checksPending', { count: pendingChecks.length }) : checks.length ? taskT('checksPassed') : '', - ].filter(Boolean) - prEl.title = `${pr.title}${prSignals.length ? ` · ${prSignals.join(' · ')}` : ''}` - if (failedChecks.length || pr.mergeable === 'CONFLICTING') prEl.classList.add('tasks-pr-checks-failed') - prEl.addEventListener('click', e => { e.stopPropagation(); openUrl(pr.url).catch(() => {}) }) - } - - const backupEl = document.createElement('span') - if (backup?.available && backup.different) { - backupEl.className = 'tasks-backup-badge' - backupEl.textContent = taskT('backupBadge') - backupEl.title = `${backup.short ?? ''} ${backup.subject ?? ''}`.trim() - } - const rebaseEl = document.createElement('span') - if (rebase?.active) { - rebaseEl.className = 'tasks-rebase-badge' - rebaseEl.textContent = rebase.total - ? taskT('rebaseProgress', { current: rebase.current ?? 0, total: rebase.total }) - : taskT('pausedRebase') - rebaseEl.title = taskT('resumeRebaseHint') - rebaseEl.addEventListener('click', e => { e.stopPropagation(); selectRow(row); showRebasePaused(wt, rebase) }) - } - const upstreamEl = document.createElement('span') - if (upstream?.state === 'diverged') { - upstreamEl.className = 'tasks-upstream-badge tasks-upstream-badge--diverged' - upstreamEl.textContent = taskT('rewrittenHistory') - upstreamEl.title = taskT('localRemoteCommits', { local: upstream.ahead, remote: upstream.behind }) - } else if (upstream?.state === 'behind') { - upstreamEl.className = 'tasks-upstream-badge tasks-upstream-badge--behind' - upstreamEl.textContent = taskT('remoteAhead', { count: upstream.behind }) - } else if (upstream?.state === 'unpublished') { - upstreamEl.className = 'tasks-upstream-badge' - upstreamEl.textContent = taskT('unpublished') - } - - const runSync = async (mode: 'fetch' | 'merge' | 'rebase'): Promise => { - if (mode === 'rebase') { - const preflight = await invoke('git_rewrite_preflight', { path: wt.path, base: worktreeBase }).catch(() => null) - if (preflight?.operation) { - selectRow(row); showSyncError('rebase', taskT('operationInProgress', { operation: preflight.operation }), wt) - return - } - if (preflight?.protectedBase) { - const ok = await askConfirm(taskT('protectedBranchQuestion', { branch: preflight.branch }), { title: taskT('protectedBranchTitle'), kind: 'warning' }) - if (!ok) return - } - } - const needsCleanTree = mode !== 'fetch' - let autostash = false - if (needsCleanTree) { - const hasChanges = (await taskGit.safeStatus(wt.path)).total > 0 - if (hasChanges) { - const doStash = await askConfirm( - taskT('dirtySyncQuestion', { branch: wt.branch ?? '' }), - { title: taskT('syncWithStash'), kind: 'warning' }, - ) - if (!doStash) return - autostash = true - } - } - flashBadge(taskT('syncing'), '', 60000) - try { - const out = await invoke('git_sync', { path: wt.path, base: worktreeBase, mode, autostash }) - recordOperation(wt, mode, 'success', `origin/${worktreeBase}${out.trim() ? ` · ${out.trim()}` : ''}`) - flashBadge(out.trim() || taskT('upToDate'), 'tasks-badge--ok', 3000) - load() - } catch (e) { - recordOperation(wt, mode, 'error', String(e)) - flashBadge(taskT('syncError'), 'tasks-badge--error', 4000) - selectRow(row) - showSyncError(mode, String(e), wt) - } - } - - const openInJira = (): void => { - if (!jiraCfg || !issue) return - openUrl(browseUrl(jiraCfg.site, issue.key)).catch(() => {}) - } - - const changeJiraStatus = async (): Promise => { - if (!jiraCfg || !issue) return - const transitions = await fetchTransitions(issue.key, jiraCfg) - if (transitions.length === 0) return - const r = menuBtn.getBoundingClientRect() - showContextMenu(r.right - 4, r.bottom, transitions.map(t => ({ - label: t.name, - onClick: async () => { - await applyTransition(issue.key, t.id, jiraCfg!).catch(() => {}) - const updated = await fetchIssue(issue.key, jiraCfg!) - issueMap.set(wt.path, updated) - applyFilter() - }, - }))) - } - - const copyBranch = (): void => { navigator.clipboard.writeText(wt.branch ?? '').catch(() => {}) } - - const pushBranch = async (): Promise => { - if (upstream?.state === 'behind') { - const fetch = await askConfirm( - taskT('remoteAheadQuestion', { count: upstream.behind }), - { title: taskT('remoteAheadTitle'), kind: 'warning' }, - ) - if (fetch) runSync('fetch') - return - } - if (upstream?.state === 'diverged') { - const force = await askConfirm( - taskT('divergedQuestion', { upstream: upstream.upstream ?? 'origin', local: upstream.ahead, remote: upstream.behind }), - { title: taskT('rewrittenHistoryTitle'), kind: 'warning' }, - ) - if (!force) return - flashBadge(taskT('pushingLease'), '', 60000) - try { - await invoke('git_push', { path: wt.path, forceWithLease: true }) - recordOperation(wt, 'push --force-with-lease', 'success', upstream.upstream ?? 'origin') - flashBadge(taskT('leaseOk'), 'tasks-badge--ok', 3500) - load() - } catch (e) { - recordOperation(wt, 'push --force-with-lease', 'error', String(e)) - flashBadge(taskT('pushRejected'), 'tasks-badge--error', 4000) - selectRow(row); showSyncError('push --force-with-lease', String(e), wt) - } - return - } - flashBadge(taskT('pushing'), '', 60000) - try { - await invoke('git_push', { path: wt.path }) - recordOperation(wt, 'push', 'success', upstream?.upstream ?? 'origin') - flashBadge(taskT('pushOk'), 'tasks-badge--ok', 3000) - load() - } catch (e) { - const message = String(e) - if (/non-fast-forward|rejected|fetch first/i.test(message)) { - const force = await askConfirm( - taskT('safePushQuestion'), - { title: taskT('safePushTitle'), kind: 'warning' }, - ) - if (force) { - try { - await invoke('git_push', { path: wt.path, forceWithLease: true }) - recordOperation(wt, 'push --force-with-lease', 'success', upstream?.upstream ?? 'origin') - flashBadge(taskT('leaseOk'), 'tasks-badge--ok', 3500) - load() - return - } catch (forceError) { - recordOperation(wt, 'push --force-with-lease', 'error', String(forceError)) - flashBadge(taskT('pushRejected'), 'tasks-badge--error', 4000) - selectRow(row) - showSyncError('push --force-with-lease', String(forceError), wt) - return - } - } - } - recordOperation(wt, 'push', 'error', message) - flashBadge(taskT('pushError'), 'tasks-badge--error', 4000) - selectRow(row) - showSyncError('push', message, wt) - } - } - - const restoreBackup = async (): Promise => { - if (!backup?.available || !backup.different) return - const ok = await askConfirm( - taskT('restoreQuestion', { short: backup.short ?? '', subject: backup.subject ?? '' }), - { title: taskT('undoRewrite'), kind: 'warning' }, - ) - if (!ok) return - try { - await invoke('git_restore_backup', { path: wt.path }) - recordOperation(wt, taskT('restoringBackup'), 'success', backup.short ?? '') - flashBadge(taskT('restoredHistory'), 'tasks-badge--ok', 3500) - await load() - showChanges(wt) - } catch (e) { - recordOperation(wt, taskT('restoringBackup'), 'error', String(e)) - selectRow(row) - showSyncError(taskT('restoringBackup'), String(e), wt) - } - } - - const createPR = async (): Promise => { - flashBadge(taskT('creatingPr'), '', 60000) - try { - const result = await invoke('git_create_pr', { path: wt.path, base: worktreeBase }) - flashBadge(taskT('prCreated'), 'tasks-badge--ok', 3000) - if (result.startsWith('http')) openUrl(result).catch(() => {}) - load() - } catch (e) { - flashBadge(taskT('prCreateError'), 'tasks-badge--error', 4000) - selectRow(row) - showSyncError('PR', String(e), wt) - } - } - - const renameTask = async (): Promise => { - const current = wt.branch ?? '' - - const newName = window.prompt(taskT('renamePrompt', { current }), current) - if (!newName || newName === current) return - try { - await invoke('git_branch_rename', { path: wt.path, newName }) - load() - } catch (e) { - await askConfirm(String(e), { title: taskT('renameError'), kind: 'error' }) - } - } - - const ahead = ab?.ahead ?? 0 - const hasPr = !!pr && (pr.state === 'OPEN' || pr.state === 'DRAFT') - - const menuItems = () => taskRowActions({ - worktree: wt, row, isMain, baseBranch: worktreeBase, ahead, hasPr, issue: !!issue, jiraConfigured: !!jiraCfg, pr, backup, rebase, - selectRow, showRebasePaused, showChanges, showHistory: showCommitLog, showGraph: showCommitGraph, - showInteractiveRebase, showTerminal: showWorktreeTerminal, showPrDetails, showReset: showResetView, - showBackups: showBackupHistory, showOperations: showOperationHistory, - isolateDocker: wt => { void dockerView.isolate(wt) }, - prepareDevcontainer: wt => { if (repoPath) void prepareTaskDevcontainer(wt).then(ok => { if (!ok) showDetail(note(taskT('noDevcontainer'), 'db-detail-hint')) }) }, - runSync, copyBranch, openJira: openInJira, - changeJiraStatus, push: pushBranch, createPr: createPR, restoreBackup, rename: renameTask, - deleteTask: () => deleteWorktree(wt), setBase: branch => { baseBranch = branch; panelStore.setBase(branch) }, reload: load, - }) - - const menuBtn = iconBtn('more', taskT('actions'), () => { - // iconBtn stops propagation, so opening the row menu must explicitly - // count as user interaction. Otherwise a slow startup enrichment can - // still "restore" the saved task after an action (for example Backups) - // has navigated elsewhere and replace that newly opened detail. - selectWorktree(row, wt) - const r = menuBtn.getBoundingClientRect() - showContextMenu(r.right - 4, r.bottom, menuItems()) - }) - menuBtn.dataset.testid = 'tasks-actions' - const actions = document.createElement('div') - actions.className = 'tasks-actions' - actions.appendChild(menuBtn) - - row.addEventListener('click', async () => { - const version = selectWorktree(row, wt) - if (rebase?.active) { showRebasePaused(wt, rebase); return } - // Devcontainer tasks show their URLs (cheap read); anything else shows the diff. - const hasDevcontainerUrls = !isMain && await dockerView.showDevcontainerUrls(wt, panelStore.devcontainerDir() ?? undefined, () => isCurrentSelection(version, wt)) - if (!isCurrentSelection(version, wt)) return - if (hasDevcontainerUrls) return - showChanges(wt) - }) - row.addEventListener('keydown', e => { - if (e.key !== 'Enter' && e.key !== ' ') return - e.preventDefault(); row.click() - }) - row.addEventListener('contextmenu', e => { - e.preventDefault() - selectWorktree(row, wt) - showContextMenu(e.clientX, e.clientY, menuItems()) - }) - // Badges wrap onto their own line under the name/path so the branch name - // always predominates and never gets crowded out. Empty ones are hidden by CSS. - const badges = document.createElement('div') - badges.className = 'tasks-row-badges' - badges.append(abEl, prEl, rebaseEl, upstreamEl, backupEl, recipeEl, badge) - left.appendChild(badges) - - // Row: status dot · name/path/badges column · always-visible actions menu. - row.append(runDot, left, actions) - return row - } - - // Single create form for the whole panel: a repo selector (only when several - // repos are open) + task name. Replaces the per-project forms. - function buildCreateForm(): HTMLElement { - const form = document.createElement('div') - form.className = 'tasks-create' - const repos = panelStore.repositories() - - let repoSelect: HTMLSelectElement | undefined - if (repos.length > 1) { - repoSelect = document.createElement('select') - repoSelect.className = 'tasks-create-repo' - repoSelect.title = taskT('selectRepo') - for (const repo of repos) { - repoSelect.appendChild(Object.assign(document.createElement('option'), { - value: repo, - textContent: repo.replace(/\/$/, '').split('/').pop() ?? repo, - selected: repo === selectedRepository(), - })) - } - repoSelect.addEventListener('change', () => { selectedRepositoryPath = repoSelect!.value }) - } - - const input = Object.assign(document.createElement('input'), { className: 'tasks-name-input', type: 'text', placeholder: taskT('newTask') }) - const submit = (): void => { void createTask(input.value.trim(), repoSelect?.value || selectedRepository()) } - const btn = iconBtn('plus', taskT('createTask'), submit) - input.addEventListener('keydown', e => { if (e.key === 'Enter') submit() }) - - if (repoSelect) form.append(repoSelect, input, btn) - else form.append(input, btn) - return form - } - - // Rebuilds the footer create form so its repo selector reflects the current - // repo list. Called from load()/applyFilter after the repo set may change. - function refreshCreateForm(): void { - createFormWrap.replaceChildren(panelStore.repositories().length > 0 ? buildCreateForm() : document.createDocumentFragment()) - } - - // ---- detail: changes (GitHub-style diff + commit bar) ---- - async function showChanges(wt: Worktree): Promise { - stopDiffRefresh() - disposeDetail() - const requestVersion = ++detailVersion - showDetail(note(taskT('loadingChanges'), 'db-detail-loading')) - try { - const [raw, statusRaw, rebaseStatus] = await Promise.all([ - invoke('git_diff', { path: wt.path }), - taskGit.safeStatus(wt.path), - invoke('git_rebase_status', { path: wt.path }).catch(() => ({ active: false })), - ]) - if (requestVersion !== detailVersion) return - const rebaseActive = rebaseStatus.active - showDetail(buildDiffView(raw, wt, { statusRaw: statusRaw.raw, rebaseActive })) - // Auto-refresh: re-fetch diff every 5 s and update if content changed - let lastSnapshot = `${statusRaw.raw}\0${raw}` - const refreshChanges = async (): Promise => { - const [newRaw, newStatus] = await Promise.all([ - invoke('git_diff', { path: wt.path }).catch(() => null), - taskGit.safeStatus(wt.path), - ]) - if (requestVersion !== detailVersion) return - const snapshot = `${newStatus.raw}\0${newRaw ?? ''}` - if (newRaw !== null && snapshot !== lastSnapshot) { - const draft = detailPane.querySelector('[data-testid="tasks-commit-message"]') - // Replacing the entire diff also replaces the commit controls. Keep - // the current DOM stable while the user (or WebDriver) is editing so - // their text and the button they are about to activate cannot become - // stale underneath them. Once editing ends, the pending snapshot is - // intentionally retried on the next interval. - if (draft && (draft.value.length > 0 || document.activeElement === draft)) return - lastSnapshot = snapshot - showDetail(buildDiffView(newRaw, wt, { statusRaw: newStatus.raw, rebaseActive })) - } - } - const startDiffRefresh = (): void => { - stopDiffRefresh() - if (requestVersion !== detailVersion) return - diffRefreshInterval = setInterval(() => { void refreshChanges() }, 5000) - } - startDiffRefresh() - setDetailLifecycle({ - pause: stopDiffRefresh, - resume: () => { void refreshChanges(); startDiffRefresh() }, - dispose: stopDiffRefresh, - }) - } catch (e) { showDetail(note(String(e), 'db-detail-error')) } - } - - function buildDiffView(raw: string, wt: Worktree, opts: { initMessage?: string; initAmend?: boolean; statusRaw?: string; rebaseActive?: boolean } = {}): HTMLElement { - const wrap = document.createElement('div') - wrap.className = 'tasks-diff' - - // Track which files are checked for partial staging - const checkedFiles = new Set() - const selectedHunks = new Map>() - const fileStates = fileStateMap(opts.statusRaw ?? '') - - // ---- commit bar ---- - const commitBar = document.createElement('div') - commitBar.className = 'tasks-commit-bar' - - const msgInput = Object.assign(document.createElement('input'), { - className: 'tasks-commit-msg', - type: 'text', - placeholder: taskT('commitMessage'), - value: opts.initMessage ?? '', - }) - msgInput.dataset.testid = 'tasks-commit-message' - - const amendToggle = Object.assign(document.createElement('button'), { - className: 'tasks-amend-btn', - title: taskT('amendHint'), - textContent: taskT('amend'), - }) - let doAmend = opts.initAmend ?? false - amendToggle.classList.toggle('tasks-amend-btn--active', doAmend) - if (doAmend) msgInput.placeholder = taskT('keepMessage') - amendToggle.addEventListener('click', () => { - doAmend = !doAmend - amendToggle.classList.toggle('tasks-amend-btn--active', doAmend) - msgInput.placeholder = doAmend ? taskT('keepMessage') : taskT('commitMessage') - commitBtn.textContent = doAmend ? taskT('amendCommit') : taskT('commit') - }) - - const commitBtn = Object.assign(document.createElement('button'), { - className: 'tasks-commit-btn', - textContent: taskT('commit'), - }) - commitBtn.dataset.testid = 'tasks-commit' - const fixupBtn = Object.assign(document.createElement('button'), { - className: 'tasks-amend-btn', - title: taskT('addToPreviousHint'), - textContent: taskT('fixupInto'), - disabled: !raw.trim(), - }) - fixupBtn.addEventListener('click', () => { - const selectedPatch = buildSelectedPatch(raw, checkedFiles, selectedHunks) - showFixupPicker(wt, undefined, selectedPatch || raw, selectedPatch || undefined) - }) - - const showCommitStatus = (text: string, isError = false): void => { - const el = Object.assign(document.createElement('span'), { - className: isError ? 'tasks-commit-err' : 'tasks-commit-ok', - textContent: text, - }) - commitBar.appendChild(el) - setTimeout(() => el.remove(), isError ? 5000 : 3000) - } - - commitBtn.addEventListener('click', async () => { - const msg = msgInput.value.trim() - if (!doAmend && !msg) { msgInput.focus(); return } - commitBtn.disabled = true - amendToggle.disabled = true - fixupBtn.disabled = true - commitBtn.textContent = '…' - try { - const selectedPatch = buildSelectedPatch(raw, checkedFiles, selectedHunks) - await invoke('git_commit', { path: wt.path, message: msg, amend: doAmend || undefined, patch: selectedPatch || undefined }) - recordOperation(wt, doAmend ? 'commit --amend' : 'commit', 'success', msg || taskT('keptMessage')) - const wasAmend = doAmend - msgInput.value = '' - doAmend = false - amendToggle.classList.remove('tasks-amend-btn--active') - commitBtn.textContent = taskT('commit') - const [newRaw, newStatus] = await Promise.all([ - invoke('git_diff', { path: wt.path }), - taskGit.safeStatus(wt.path), - ]) - showDetail(buildDiffView(newRaw, wt, { statusRaw: newStatus.raw, rebaseActive: opts.rebaseActive })) - showCommitStatus(wasAmend ? taskT('commitAmended') : taskT('commitCreated')) - // Update sidebar badge and ahead/behind - lastStatuses.set(wt.path, (await taskGit.safeStatus(wt.path)).total) - const abRaw = await invoke('git_ahead_behind', { path: wt.path, base: baseFor(wt) }).catch(() => '') - aheadBehindMap.set(wt.path, parseAheadBehind(abRaw)) - applyFilter() - } catch (e) { - recordOperation(wt, doAmend ? 'commit --amend' : 'commit', 'error', String(e)) - commitBtn.textContent = doAmend ? taskT('amendCommit') : taskT('commit') - commitBtn.disabled = false - amendToggle.disabled = false - fixupBtn.disabled = false - showCommitStatus(String(e).slice(0, 120), true) - } - }) - - commitBar.append(msgInput, amendToggle, fixupBtn, commitBtn) - - if (opts.rebaseActive) wrap.appendChild(note( - taskT('pausedCommitHint'), - 'tasks-rebase-hint tasks-conflict-warning', - )) - - if (!raw.trim()) { - wrap.append(commitBar, note(taskT('noChanges'), 'db-detail-hint')) - return wrap - } - - const chunks = raw.split(/(?=^diff --git )/m).filter(Boolean) - - for (const chunk of chunks) { - const firstLine = chunk.split('\n')[0] ?? '' - const fileName = firstLine.match(/^diff --git a\/(.+) b\//)?.[1] ?? firstLine - wrap.appendChild(buildChangesFileView({ - chunk, state: fileStates.get(fileName), checkedFiles, selectedHunks, renderPatch: renderPatchHtml, - })) - } - - wrap.appendChild(commitBar) - return wrap - } - - // ---- detail: choose an existing commit for fixup ---- - async function showFixupPicker(wt: Worktree, files: string[] | undefined, incomingDiff: string, selectedPatch?: string): Promise { - stopDiffRefresh() - disposeDetail() - showDetail(note(taskT('loadingCommits'), 'db-detail-loading')) - try { - const worktreeBase = baseFor(wt) - const entries = await taskGit.rebaseLog(wt.path, worktreeBase) - if (entries.length === 0) { - showDetail(note(taskT('noOwnCommits', { base: worktreeBase }), 'db-detail-hint')) - return - } - - const incomingFiles = new Set(files ?? diffFileNames(incomingDiff)) - const [recommendations, blameRecommendations] = await Promise.all([ - taskGit.recommendations(wt.path, worktreeBase, [...incomingFiles]).catch(() => []), - taskGit.blameRecommendations(wt.path, worktreeBase, incomingDiff).catch(() => []), - ]) - const historyScores = recommendationMap(recommendations) - const blameScores = recommendationMap(blameRecommendations) - const enriched = await Promise.all(entries.map(async (entry, originalIndex) => { - const commitFiles = await taskGit.files(wt.path, entry.hash).catch(() => []) - const filesRaw = commitFilesRaw(commitFiles) - const overlap = matchingPaths(incomingFiles, changedPaths(filesRaw)) - const history = historyScores.get(entry.hash) ?? { score: 0, files: [] } - const blame = blameScores.get(entry.hash) ?? { score: 0, files: [] } - return { entry, commitFiles, overlap, history, blame, originalIndex } - })) - enriched.sort((a, b) => - (b.overlap.length * 10000 + b.blame.score * 100 + b.history.score) - - (a.overlap.length * 10000 + a.blame.score * 100 + a.history.score) - || a.originalIndex - b.originalIndex) - - const wrap = document.createElement('div') - wrap.className = 'tasks-fixup-wrap' - wrap.append(buildSubHead(taskT('addChangesTitle'), () => showChanges(wt))) - wrap.appendChild(Object.assign(document.createElement('p'), { - className: 'tasks-rebase-hint', - textContent: selectedPatch - ? taskT('incomingSelection', { count: incomingFiles.size }) - : files?.length - ? taskT('incomingFiles', { count: files.length }) - : taskT('incomingAll'), - })) - wrap.appendChild(buildIncomingChangesView(incomingDiff, files, note)) - - const list = document.createElement('div') - list.className = 'tasks-fixup-list' - for (const { entry, commitFiles, overlap, history, blame } of enriched) { - const item = document.createElement('div') - item.className = `tasks-fixup-item${overlap.length || history.score || blame.score ? ' tasks-fixup-item--match' : ''}` - const header = document.createElement('div') - header.className = 'tasks-fixup-header' - const filesEl = document.createElement('div') - filesEl.className = 'tasks-commit-files hidden' - const expandBtn = iconBtn('chevron-down', taskT('viewCommitCode'), async () => { - const opening = filesEl.classList.contains('hidden') - filesEl.classList.toggle('hidden', !opening) - if (!opening || filesEl.childElementCount > 0) return - filesEl.replaceChildren(...buildCommitFileList( - commitFiles, - file => invoke('git_show_commit_diff', { path: wt.path, hash: entry.hash, file }), - file => invoke('git_show_file', { path: wt.path, hash: entry.hash, file }), - )) - }) - expandBtn.className = 'tasks-expand-btn' - const chooseBtn = Object.assign(document.createElement('button'), { - className: 'tasks-commit-btn', - textContent: taskT('addHere'), - }) - const statusEl = Object.assign(document.createElement('span'), { className: 'tasks-rebase-status-msg' }) - chooseBtn.addEventListener('click', async () => { - const preflight = await invoke('git_rewrite_preflight', { path: wt.path, base: worktreeBase }).catch(() => null) - if (preflight?.operation) { - statusEl.textContent = taskT('operationInProgress', { operation: preflight.operation }) - return - } - const publishedWarning = preflight?.publishedCommits - ? taskT('publishedFixup', { count: preflight.publishedCommits }) : '' - const ok = await askConfirm( - taskT('fixupPreview', { count: incomingFiles.size, target: `${entry.short} ${entry.subject}`, matches: overlap.length ? overlap.join(', ') : taskT('none'), blame: blame.score || taskT('none'), history: history.score ? history.files.join(', ') : taskT('none'), published: publishedWarning }), - { title: taskT('applyFixup'), kind: 'warning' }, - ) - if (!ok) return - list.querySelectorAll('button').forEach(button => { button.disabled = true }) - statusEl.textContent = taskT('fixupRunning') - try { - const result = await invoke('git_fixup', { path: wt.path, target: entry.hash, base: worktreeBase, files, patch: selectedPatch }) - recordOperation(wt, 'fixup + autosquash', 'success', `${entry.short} ${entry.subject}`) - if (result === 'paused') { - showRebasePaused(wt, await invoke('git_rebase_status', { path: wt.path })) - return - } - statusEl.textContent = taskT('changesIntegrated') - setTimeout(() => { showChanges(wt); load() }, 900) - } catch (e) { - recordOperation(wt, 'fixup + autosquash', 'error', String(e)) - statusEl.textContent = String(e).slice(0, 160) - list.querySelectorAll('button').forEach(button => { button.disabled = false }) - } - }) - header.append( - expandBtn, - Object.assign(document.createElement('span'), { className: 'tasks-log-short', textContent: entry.short }), - Object.assign(document.createElement('span'), { className: 'tasks-rebase-subject', textContent: entry.subject }), - ...(overlap.length ? [Object.assign(document.createElement('span'), { - className: 'tasks-fixup-match-badge', - textContent: taskT('recommendedMatch', { count: overlap.length }), - title: overlap.join('\n'), - })] : []), - ...(blame.score ? [Object.assign(document.createElement('span'), { - className: 'tasks-fixup-blame-badge', - textContent: taskT('blameLines', { count: blame.score }), - title: taskT('blameHint', { files: blame.files.join(', ') }), - })] : []), - ...(!overlap.length && !blame.score && history.score ? [Object.assign(document.createElement('span'), { - className: 'tasks-fixup-history-badge', - textContent: taskT('historyScore', { count: history.score }), - title: taskT('historyHint', { files: history.files.join(', ') }), - })] : []), - statusEl, - chooseBtn, - ) - item.append(header, filesEl) - list.appendChild(item) - } - wrap.appendChild(list) - showDetail(wrap) - } catch (e) { - showDetail(note(String(e), 'db-detail-error')) - } - } - - // ---- detail: automatic history backups ---- - async function showBackupHistory(wt: Worktree): Promise { - stopDiffRefresh() - disposeDetail() - showDetail(note(taskT('loadingBackups'), 'db-detail-loading')) - try { - showDetail(await buildBackupHistoryView({ - path: wt.path, branch: wt.branch ?? '', renderPatch: renderPatchHtml, - onBack: () => showChanges(wt), - onRestored: async () => { await load(); showChanges(wt) }, - onOperation: (status, detail) => recordOperation(wt, taskT('restoreBackup'), status, detail), - })) - } catch (e) { showDetail(note(String(e), 'db-detail-error')) } - } - - function showOperationHistory(wt: Worktree): void { - stopDiffRefresh() - disposeDetail() - const branch = wt.branch ?? taskT('detached') - const repository = repositoryFor(wt) - showDetail(buildOperationHistoryView({ - branch, - repository, - entries: panelStore.operations(), - onBack: () => showChanges(wt), - onClear: () => { - panelStore.clearOperations(repository, branch) - showOperationHistory(wt) - }, - })) - } - - // ---- detail: reset commits ---- - function showResetView(wt: Worktree): void { - stopDiffRefresh() - disposeDetail() - showDetail(buildResetView({ - worktree: wt, - baseBranch: baseFor(wt), - buildSubHead, - onBack: () => showChanges(wt), - onComplete: () => { showChanges(wt); load() }, - recordOperation: (operation, status, detail) => recordOperation(wt, operation, status, detail), - })) - } - - // ---- detail: commit log ---- - async function showCommitGraph(wt: Worktree): Promise { - stopDiffRefresh() - disposeDetail() - await buildGraphView({ - worktree: wt, - baseBranch: baseFor(wt), - buildSubHead, - onBack: () => showChanges(wt), - showDetail, - note, - }) - } - - function showPrDetails(wt: Worktree, pr: PrStatus): void { - stopDiffRefresh() - disposeDetail() - showDetail(buildPrStatusView({ - pr, baseBranch: baseFor(wt), - onBack: () => showChanges(wt), - onOpen: () => openUrl(pr.url).catch(() => {}), - })) - } - - async function showCommitLog(wt: Worktree): Promise { - stopDiffRefresh() - disposeDetail() - showDetail(note(taskT('loadingHistory'), 'db-detail-loading')) - try { - const entries = await taskGit.log(wt.path) - buildCommitLogView({ - worktree: wt, - entries, - buildSubHead, - onBack: () => showChanges(wt), - showDetail, - note, - iconBtn, - buildCommitFileList, - loadFiles: (path, hash) => taskGit.files(path, hash).catch(() => []), - }) - } catch (err) { showDetail(note(String(err), 'db-detail-error')) } - } - - // ---- detail: interactive rebase ---- - async function showInteractiveRebase(wt: Worktree): Promise { - stopDiffRefresh() - disposeDetail() - showDetail(note(taskT('loading'), 'db-detail-loading')) - try { - const st = await invoke('git_rebase_status', { path: wt.path }) - if (st.active) { showRebasePaused(wt, st); return } - - const worktreeBase = baseFor(wt) - const [entries, merges] = await Promise.all([ - taskGit.rebaseLog(wt.path, worktreeBase), - taskGit.mergeLog(wt.path, worktreeBase).catch(() => []), - ]) - if (entries.length === 0) { - showDetail(note(taskT('noOwnCommits', { base: worktreeBase }), 'db-detail-hint')) - return - } - if (merges.length) showMergeRebaseWarning(wt, entries, merges) - else showRebaseEditor(wt, entries) - } catch (e) { showDetail(note(String(e), 'db-detail-error')) } - } - - function showMergeRebaseWarning(wt: Worktree, entries: CommitEntry[], merges: CommitEntry[]): void { - buildRebaseMergeWarning({ - worktree: wt, - baseBranch: baseFor(wt), - entries, - merges, - buildSubHead, - onBack: () => showChanges(wt), - showDetail, - showRebaseEditor, - showRebasePaused, - recordOperation: (operation, status, detail) => recordOperation(wt, operation, status, detail), - onComplete: () => { showChanges(wt); load() }, - }) - } - - function showRebaseEditor(wt: Worktree, entries: CommitEntry[]): void { - type RebaseItem = RebasePlanItem & { action: RebaseAction; newMessage: string } - const items: RebaseItem[] = entries.map(e => ({ action: 'pick', hash: e.hash, short: e.short, subject: e.subject, newMessage: '' })) - const ACTIONS: RebaseAction[] = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop'] - let draggedIndex: number | null = null - let dragTarget: { index: number; after: boolean } | null = null - - const wrap = document.createElement('div') - wrap.className = 'tasks-rebase-wrap' - wrap.append(buildSubHead(taskT('interactiveTitle', { branch: wt.branch ?? '', base: baseFor(wt) }), () => showChanges(wt))) - - const hint = Object.assign(document.createElement('p'), { - className: 'tasks-rebase-hint', - textContent: taskT('rebaseOrderHint'), - }) - wrap.appendChild(hint) - - const previewEl = document.createElement('div') - previewEl.className = 'tasks-rebase-preview hidden' - const renderPreview = (): void => { - const content = buildRebasePlanPreview(items) - previewEl.replaceChildren(...content.childNodes) - } - wrap.appendChild(previewEl) - - const list = document.createElement('div') - list.className = 'tasks-rebase-list' - - const renderList = (): void => { - list.replaceChildren() - items.forEach((item, idx) => { - const row = document.createElement('div') - row.className = `tasks-rebase-item${item.action === 'drop' ? ' tasks-rebase-drop' : ''}` - row.dataset.testid = 'tasks-rebase-item' - row.dataset.hash = item.hash - row.tabIndex = 0 - row.setAttribute('role', 'listitem') - row.setAttribute('aria-label', taskT('rebaseItemAria', { - action: item.action, - hash: item.short, - subject: item.subject, - })) - - const dragHandle = Object.assign(document.createElement('button'), { - className: 'tasks-rebase-drag', - textContent: '⠿', - title: taskT('dragCommit'), - }) - dragHandle.setAttribute('aria-label', taskT('moveCommit', { commit: item.short })) - - const clearDragStyles = (): void => { - list.querySelectorAll('.tasks-rebase-item').forEach(el => { - el.classList.remove('tasks-rebase-dragging', 'tasks-rebase-drag-before', 'tasks-rebase-drag-after') - }) - } - - dragHandle.addEventListener('pointerdown', e => { - if (e.button !== 0) return - e.preventDefault() - draggedIndex = idx - dragTarget = null - dragHandle.setPointerCapture(e.pointerId) - row.classList.add('tasks-rebase-dragging') - }) - dragHandle.addEventListener('pointermove', e => { - if (draggedIndex === null || !dragHandle.hasPointerCapture(e.pointerId)) return - e.preventDefault() - const targetEl = document.elementFromPoint(e.clientX, e.clientY)?.closest('.tasks-rebase-item') as HTMLElement | null - if (!targetEl) return - const rows = [...list.querySelectorAll('.tasks-rebase-item')] - const targetIndex = rows.indexOf(targetEl) - clearDragStyles() - row.classList.add('tasks-rebase-dragging') - if (targetIndex < 0 || targetIndex === draggedIndex) { - dragTarget = null - return - } - const rect = targetEl.getBoundingClientRect() - const after = e.clientY > rect.top + rect.height / 2 - dragTarget = { index: targetIndex, after } - targetEl.classList.add(after ? 'tasks-rebase-drag-after' : 'tasks-rebase-drag-before') - }) - const finishPointerDrag = (e: PointerEvent): void => { - if (draggedIndex === null) return - e.preventDefault() - const from = draggedIndex - draggedIndex = null - clearDragStyles() - if (!dragTarget) return - const { index, after } = dragTarget - dragTarget = null - const [moved] = items.splice(from, 1) - let target = index + (after ? 1 : 0) - if (from < target) target-- - items.splice(target, 0, moved) - renderList() - } - dragHandle.addEventListener('pointerup', finishPointerDrag) - dragHandle.addEventListener('pointercancel', finishPointerDrag) - - const select = document.createElement('select') - select.className = 'tasks-rebase-action' - ACTIONS.forEach(a => { - const opt = Object.assign(document.createElement('option'), { value: a, textContent: a }) - opt.selected = a === item.action - select.appendChild(opt) - }) - select.addEventListener('change', () => { - item.action = select.value as RebaseAction - row.className = `tasks-rebase-item${item.action === 'drop' ? ' tasks-rebase-drop' : ''}` - renderList() // re-render to show/hide reword input - }) - - const hashEl = Object.assign(document.createElement('span'), { className: 'tasks-log-short', textContent: item.short }) - - // For reword: show editable input; otherwise show static subject - const contentEl = document.createElement('div') - contentEl.className = 'tasks-rebase-content' - if (item.action === 'reword') { - const msgIn = Object.assign(document.createElement('input'), { - className: 'tasks-rebase-reword-input', - type: 'text', - value: item.newMessage || item.subject, - placeholder: taskT('newCommitTitle'), - }) - msgIn.addEventListener('input', () => { item.newMessage = msgIn.value }) - msgIn.addEventListener('click', e => e.stopPropagation()) - contentEl.appendChild(msgIn) - } else { - contentEl.appendChild(Object.assign(document.createElement('span'), { - className: 'tasks-rebase-subject', - textContent: item.subject, - })) - } - - const upBtn = iconBtn('chevron-up', taskT('moveUp'), () => { - if (idx === 0) return - ;[items[idx - 1], items[idx]] = [items[idx], items[idx - 1]] - renderList() - }) - const downBtn = iconBtn('chevron-down', taskT('moveDown'), () => { - if (idx === items.length - 1) return - ;[items[idx + 1], items[idx]] = [items[idx], items[idx + 1]] - renderList() - }) - upBtn.disabled = idx === 0 - downBtn.disabled = idx === items.length - 1 - - row.addEventListener('keydown', e => { - if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return - e.preventDefault() - const target = e.key === 'ArrowUp' ? idx - 1 : idx + 1 - if (target < 0 || target >= items.length) return - ;[items[target], items[idx]] = [items[idx], items[target]] - renderList() - list.querySelectorAll('.tasks-rebase-item')[target]?.focus() - }) - - const moveEl = document.createElement('div') - moveEl.className = 'tasks-rebase-move' - moveEl.append(upBtn, downBtn) - - // Expand/collapse files changed in this commit - let filesLoaded = false - const filesEl = document.createElement('div') - filesEl.className = 'tasks-commit-files hidden' - const expandBtn = iconBtn('chevron-down', taskT('viewCommitFiles'), async () => { - const isOpen = !filesEl.classList.contains('hidden') - if (isOpen) { filesEl.classList.add('hidden'); expandBtn.title = taskT('viewCommitFiles'); return } - filesEl.classList.remove('hidden'); expandBtn.title = taskT('hideFiles') - if (filesLoaded) return - filesLoaded = true - filesEl.textContent = taskT('loading') - const files = await taskGit.files(wt.path, item.hash).catch(() => []) - filesEl.replaceChildren(...buildCommitFileList( - files, - file => invoke('git_show_commit_diff', { path: wt.path, hash: item.hash, file }), - file => invoke('git_show_file', { path: wt.path, hash: item.hash, file }), - )) - }) - expandBtn.className = 'tasks-expand-btn' - - row.append(dragHandle, select, hashEl, contentEl, moveEl, expandBtn) - row.appendChild(filesEl) - list.appendChild(row) - }) - } - renderList() - wrap.appendChild(list) - - const footer = document.createElement('div') - footer.className = 'tasks-rebase-footer' - const statusEl = Object.assign(document.createElement('span'), { className: 'tasks-rebase-status-msg' }) - const previewBtn = Object.assign(document.createElement('button'), { className: 'tasks-amend-btn', textContent: taskT('simulate') }) - previewBtn.dataset.testid = 'tasks-rebase-preview' - previewBtn.addEventListener('click', () => { - renderPreview() - previewEl.classList.toggle('hidden') - previewBtn.textContent = previewEl.classList.contains('hidden') ? taskT('simulate') : taskT('hideSimulation') - }) - const startBtn = Object.assign(document.createElement('button'), { - className: 'tasks-commit-btn', - textContent: taskT('startRebase'), - }) - startBtn.dataset.testid = 'tasks-rebase-start' - startBtn.addEventListener('click', async () => { - const preview = previewRebase(items) - if (preview.warnings.length) { - statusEl.textContent = preview.warnings.join(' ') - previewEl.classList.remove('hidden'); renderPreview() - return - } - let preflight: RewritePreflight - try { - preflight = await invoke('git_rewrite_preflight', { path: wt.path, base: baseFor(wt) }) - } catch (e) { - statusEl.textContent = taskT('validationError', { error: String(e).slice(0, 100) }) - return - } - if (preflight.operation) { - statusEl.textContent = taskT('operationInProgress', { operation: preflight.operation }) - return - } - const risks = [ - preflight.dirty ? taskT('dirtyRisk') : '', - preflight.publishedCommits ? taskT('publishedRisk', { count: preflight.publishedCommits }) : '', - preflight.protectedBase ? taskT('protectedRisk', { branch: preflight.branch }) : '', - preflight.hooks.length ? taskT('hooksRisk', { hooks: preflight.hooks.join(', ') }) : '', - preflight.signing ? taskT('signingRisk') : '', - ].filter(Boolean) - const confirmed = await askConfirm( - taskT('rebaseQuestion', { result: preview.resultingCommits, combined: preview.combinedCommits, dropped: preview.droppedCommits, risks: risks.length ? `\n\n${risks.join('\n')}` : '' }), - { title: taskT('confirmRebase'), kind: risks.length ? 'warning' : 'info' }, - ) - if (!confirmed) return - startBtn.disabled = true - statusEl.textContent = taskT('running') - // reword → convert to edit in the git todo; the new message is applied in the paused UI - const rewordMessages = new Map(items.filter(i => i.action === 'reword').map(i => [i.hash, i.newMessage || i.subject])) - const todoLines = items.map(i => `${i.action === 'reword' ? 'edit' : i.action} ${i.hash} ${i.subject}`) - try { - await invoke('git_rebase_start', { path: wt.path, base: baseFor(wt), todoLines }) - recordOperation(wt, 'rebase interactivo', 'success', `${items.length} instrucciones sobre origin/${baseFor(wt)}`) - const st = await invoke('git_rebase_status', { path: wt.path }) - if (st.active) { - // If this commit was a reword, pre-fill the message with the new title - const preMsg = rewordMessages.get(st.sha ?? '') ?? st.subject ?? '' - showRebasePaused(wt, { ...st, subject: preMsg }) - return - } - statusEl.textContent = taskT('rebaseComplete') - setTimeout(() => { showChanges(wt); load() }, 1200) - } catch (e) { - recordOperation(wt, 'rebase interactivo', 'error', String(e)) - statusEl.textContent = String(e).slice(0, 120) - startBtn.disabled = false - } - }) - footer.append(statusEl, previewBtn, startBtn) - wrap.appendChild(footer) - showDetail(wrap) - } - - // ---- Inline conflict resolver ---- - function showConflictResolver(wt: Worktree, file: string, onBack: () => void): void { - stopDiffRefresh() - disposeDetail() - showDetail(buildConflictResolverView({ path: wt.path, file, onBack })) - } - - function showRebasePaused(wt: Worktree, st: RebaseStatus): void { - disposeDetail() - const wrap = document.createElement('div') - wrap.className = 'tasks-rebase-paused' - - wrap.append(buildSubHead(taskT('pausedTitle', { branch: wt.branch ?? '' }), () => showChanges(wt))) - - const infoEl = document.createElement('div') - infoEl.className = 'tasks-rebase-paused-info' - infoEl.append( - Object.assign(document.createElement('span'), { - className: 'tasks-rebase-paused-label', - textContent: st.total - ? taskT('rebaseProgressColon', { current: st.current ?? 0, total: st.total }) - : taskT('editing'), - }), - Object.assign(document.createElement('span'), { className: 'tasks-log-short', textContent: st.short ?? '' }), - Object.assign(document.createElement('span'), { className: 'tasks-rebase-subject', textContent: st.subject ?? '' }), - ) - wrap.appendChild(infoEl) - - const actionsEl = document.createElement('div') - actionsEl.className = 'tasks-rebase-paused-actions' - const statusEl = Object.assign(document.createElement('span'), { className: 'tasks-rebase-status-msg' }) - - const abortBtn = Object.assign(document.createElement('button'), { className: 'tasks-amend-btn', textContent: taskT('abortRebase') }) - const editBtn = Object.assign(document.createElement('button'), { - className: 'tasks-amend-btn', - textContent: taskT('editCommit'), - title: taskT('editHint'), - }) - const splitBtn = Object.assign(document.createElement('button'), { - className: 'tasks-amend-btn', - textContent: taskT('splitCommit'), - title: taskT('splitHint'), - }) - const continueBtn = Object.assign(document.createElement('button'), { className: 'tasks-commit-btn', textContent: taskT('continueRebase') }) - - let intervalId = 0 - const stopPolling = (): void => { - clearInterval(intervalId) - intervalId = 0 - } - let resumePolling: () => void - - editBtn.addEventListener('click', () => showChanges(wt)) - splitBtn.addEventListener('click', async () => { - const ok = await askConfirm( - taskT('splitQuestion'), - { title: taskT('splitTitle'), kind: 'warning' }, - ) - if (!ok) return - splitBtn.disabled = true - try { - await invoke('git_rebase_split', { path: wt.path }) - recordOperation(wt, 'dividir commit', 'success', st.short ?? st.subject ?? '') - showChanges(wt) - } catch (e) { - recordOperation(wt, 'dividir commit', 'error', String(e)) - statusEl.textContent = String(e).slice(0, 140) - splitBtn.disabled = false - } - }) - - continueBtn.addEventListener('click', async () => { - continueBtn.disabled = true; abortBtn.disabled = true - statusEl.textContent = taskT('continuing') - clearInterval(intervalId) - try { - const result = await invoke('git_rebase_continue', { path: wt.path }) - if (result === 'paused') { - showRebasePaused(wt, await invoke('git_rebase_status', { path: wt.path })) - } else { - statusEl.textContent = taskT('rebaseComplete') - setTimeout(() => { showChanges(wt); load() }, 1200) - } - } catch (e) { - statusEl.textContent = String(e).slice(0, 120) - continueBtn.disabled = false; abortBtn.disabled = false - } - }) - - abortBtn.addEventListener('click', async () => { - const ok = await askConfirm(taskT('abortQuestion'), { title: taskT('abortRebase'), kind: 'warning' }) - if (!ok) return - await invoke('git_rebase_abort', { path: wt.path }).catch(() => {}) - clearInterval(intervalId) - showChanges(wt); load() - }) - - const conflicts = st.conflicts ?? [] - editBtn.disabled = conflicts.length > 0 - splitBtn.disabled = conflicts.length > 0 - - if (conflicts.length > 0) { - // ---- Conflict resolution mode ---- - const warningEl = Object.assign(document.createElement('p'), { - className: 'tasks-rebase-hint tasks-conflict-warning', - textContent: taskT('conflictWarning', { count: conflicts.length }), - }) - wrap.appendChild(warningEl) - - const conflictList = document.createElement('div') - conflictList.className = 'tasks-conflict-list' - - const resolved = new Set() - - const renderConflicts = (currentConflicts: string[]): void => { - conflictList.replaceChildren() - currentConflicts.forEach(file => { - const isResolved = resolved.has(file) - const row = document.createElement('div') - row.className = `tasks-conflict-row${isResolved ? ' tasks-conflict-resolved' : ''}` - - const fileEl = Object.assign(document.createElement('span'), { - className: 'tasks-conflict-file', - textContent: file, - title: file, - }) - - const btns = document.createElement('div') - btns.className = 'tasks-conflict-btns' - - if (!isResolved) { - const resolveBtn = Object.assign(document.createElement('button'), { className: 'tasks-conflict-btn tasks-conflict-btn-primary', textContent: taskT('resolveHere') }) - resolveBtn.title = taskT('openConflictResolver') - resolveBtn.addEventListener('click', () => { - clearInterval(intervalId) - showConflictResolver(wt, file, () => { - resolved.add(file) - showRebasePaused(wt, st) - }) - }) - - const oursBtn = Object.assign(document.createElement('button'), { className: 'tasks-conflict-btn', textContent: taskT('currentVersion') }) - oursBtn.title = taskT('keepOursHint') - oursBtn.addEventListener('click', async () => { - oursBtn.disabled = true - await invoke('git_resolve_conflict', { path: wt.path, file, side: 'ours' }).catch(e => { statusEl.textContent = String(e); oursBtn.disabled = false }) - resolved.add(file) - renderConflicts(currentConflicts) - }) - - const theirsBtn = Object.assign(document.createElement('button'), { className: 'tasks-conflict-btn', textContent: taskT('appliedCommit') }) - theirsBtn.title = taskT('keepTheirsHint') - theirsBtn.addEventListener('click', async () => { - theirsBtn.disabled = true - await invoke('git_resolve_conflict', { path: wt.path, file, side: 'theirs' }).catch(e => { statusEl.textContent = String(e); theirsBtn.disabled = false }) - resolved.add(file) - renderConflicts(currentConflicts) - }) - - btns.append(resolveBtn, oursBtn, theirsBtn) - } else { - btns.appendChild(Object.assign(document.createElement('span'), { className: 'tasks-conflict-done', textContent: taskT('resolved') })) - } - - row.append(fileEl, btns) - conflictList.appendChild(row) - }) - - // Auto-update Continue button: enabled when all current conflicts are resolved - const allResolved = currentConflicts.every(f => resolved.has(f)) - continueBtn.disabled = !allResolved - } - - renderConflicts(conflicts) - wrap.appendChild(conflictList) - - // Auto-refresh conflict list in case user resolves from terminal - const refreshConflicts = async (): Promise => { - const fresh = await invoke('git_rebase_status', { path: wt.path }).catch(() => null) - if (!fresh) return - if (!fresh.active) { stopPolling(); showChanges(wt); load(); return } - const freshConflicts = fresh.conflicts ?? [] - freshConflicts.forEach(f => { if (!freshConflicts.includes(f)) resolved.delete(f) }) - if (freshConflicts.length === 0) { - stopPolling() - showRebasePaused(wt, fresh) - } else { - renderConflicts(freshConflicts) - } - } - const startPolling = (): void => { - stopPolling() - intervalId = window.setInterval(() => { void refreshConflicts() }, 4000) - } - resumePolling = () => { void refreshConflicts(); startPolling() } - startPolling() - - continueBtn.disabled = conflicts.length > 0 - - } else { - // ---- Normal edit mode (intentional `edit` step) ---- - const hintEl = Object.assign(document.createElement('p'), { - className: 'tasks-rebase-hint', - textContent: taskT('amendPausedHint'), - }) - wrap.appendChild(hintEl) - - const diffWrap = document.createElement('div') - diffWrap.className = 'tasks-rebase-diff' - const refreshDiff = (): void => { - invoke('git_diff', { path: wt.path }).then(raw => { - diffWrap.replaceChildren(buildDiffView(raw, wt, { initAmend: true, initMessage: st.subject ?? '' })) - }).catch(() => {}) - } - refreshDiff() - const startPolling = (): void => { - stopPolling() - intervalId = window.setInterval(refreshDiff, 5000) - } - resumePolling = () => { refreshDiff(); startPolling() } - startPolling() - wrap.appendChild(diffWrap) - } - - setDetailLifecycle({ pause: stopPolling, resume: resumePolling, dispose: stopPolling }) - actionsEl.append(statusEl, abortBtn, editBtn, splitBtn, continueBtn) - wrap.appendChild(actionsEl) - showDetail(wrap) - } - - // ---- detail: worktree terminal ---- - async function showWorktreeTerminal(wt: Worktree): Promise { - stopDiffRefresh() - disposeDetail() - const { createAgentsPanel } = await import('../agents/AgentsPanel') - // Reuse the live hub for this worktree if we already opened it; otherwise - // create one scoped to the worktree (own storage, off the global dock). - let panel = worktreeTerminals.get(wt.path) - if (!panel) { - panel = createAgentsPanel(wt.path, { storageScope: `bento.agents.wt:${wt.path}`, publishToDock: false }) - worktreeTerminals.set(wt.path, panel) - } - const wrap = document.createElement('div') - wrap.className = 'tasks-term-wrap' - const termBody = document.createElement('div') - termBody.className = 'tasks-term-body' - termBody.appendChild(panel.element) - wrap.append(buildSubHead(`Terminal · ${wt.branch ?? ''}`, () => showChanges(wt)), termBody) - showDetail(wrap) - requestAnimationFrame(() => panel.fit()) - // Navigating away only detaches the element (showDetail replaces it); the - // hub stays alive in the cache, so the agents keep running. Persist on leave - // so they're restorable even if the tab closes without a clean dispose. - const livePanel = panel - setDetailLifecycle({ - pause: () => {}, - resume: () => livePanel.fit(), - dispose: () => livePanel.persist(), - }) - } - - // ---- detail: git sync error (with conflict detection + AI explain) ---- - function showSyncError(mode: string, errorText: string, wt: Worktree): void { - stopDiffRefresh() - disposeDetail() - buildSyncErrorView({ mode, errorText, path: wt.path, showDetail, iconButton: iconBtn, status: path => taskGit.status(path) }) - } - - // ---- mutations ---- - async function createTask(name: string, repository = repoPath): Promise { - if (!name || !repository) return - const branch = taskBranch(name) - const path = taskPath(repository, branch.slice('feat/'.length)) - listWrap.replaceChildren(note(taskT('creatingTask'), 'db-detail-loading')) - try { - const base = await invoke('git_default_branch', { repo: repository }) - await invoke('git_worktree_add', { repo: repository, path, branch, base }) - await load() - const wt = worktrees.find(w => w.path === path) - const row = [...listWrap.querySelectorAll('.tasks-row')].find(item => item.dataset.path === path) - if (wt && row) { - selectWorktree(row, wt) - row.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - } - try { - const result = await invoke('docker_compose_isolate', { worktreePath: path }) - if (wt) dockerView.show(result, wt) - } catch (e) { - // No root docker-compose.yml → maybe a devcontainer project (compose under .devcontainer/). - if (String(e) !== 'no-compose') { showDetail(note(String(e), 'db-detail-error')); return } - if (wt) { - const prepared = await prepareTaskDevcontainer(wt) - if (!prepared) showChanges(wt) - } - } - } catch (e) { listWrap.replaceChildren(note(String(e), 'db-detail-error')) } - } - - async function deleteWorktree(wt: Worktree): Promise { - const { total } = await taskGit.safeStatus(wt.path) - const ok = await askConfirm( - total > 0 ? taskT('deleteDirtyQuestion', { branch: wt.branch ?? '', count: total }) : taskT('deleteQuestion', { branch: wt.branch ?? '' }), - { title: taskT('deleteTask'), kind: total > 0 ? 'warning' : 'info' }, - ) - if (!ok) return - try { - await invoke('docker_compose_down', { worktreePath: wt.path }).catch(() => {}) - await invoke('git_worktree_remove', { repo: repositoryFor(wt), path: wt.path, force: total > 0, branch: wt.branch ?? null }) - // Tear down the worktree's live agents hub (its worktree is gone) and drop - // its persisted agents + scrollback so nothing is left orphaned. - worktreeTerminals.get(wt.path)?.dispose() - worktreeTerminals.delete(wt.path) - try { localStorage.removeItem(`bento.agents.wt:${wt.path}.sessions`) } catch { /* ignore */ } - void invoke('agent_history_clear', { scope: `bento.agents.wt:${wt.path}` }).catch(() => {}) - showDetail(note(taskT('selectTask'), 'db-detail-hint')) - await load() - } catch (e) { await askConfirm(String(e), { title: taskT('genericError'), kind: 'error' }) } - } - - // ---- load ---- - async function loadMultiRepo(repos: string[]): Promise { - listWrap.replaceChildren(note(taskT('loading'), 'db-detail-loading')) - repoOf.clear() - baseOf.clear() - ;[issueMap, prStatusMap, backupStatusMap, rebaseStatusMap, upstreamStatusMap].forEach(m => m.clear()) - aheadBehindMap.clear() - try { - const [repoData, containers, config] = await Promise.all([ - Promise.all(repos.map(async repo => ({ - repo, - base: await invoke('git_default_branch', { repo }).catch(() => 'main'), - worktrees: await taskGit.worktrees(repo), - }))), - invoke('docker_list').catch(() => '').then(parseContainers), - loadJiraConfig(), - ]) - jiraCfg = config - worktrees = repoData.flatMap(({ repo, base, worktrees: items }) => { - items.forEach(wt => { - repoOf.set(wt.path, repo) - baseOf.set(wt.path, base) - }) - return items - }) - const statuses = new Map() - await Promise.all(worktrees.map(async wt => { - const base = baseFor(wt) - const key = extractIssueKey(wt.branch ?? null) - const [status, aheadBehind, issue, pr, backup, rebase, upstream] = await Promise.all([ - taskGit.safeStatus(wt.path), - invoke('git_ahead_behind', { path: wt.path, base }).catch(() => ''), - key && jiraCfg ? fetchIssue(key, jiraCfg) : null, - invoke('git_pr_status', { path: wt.path }).catch(() => null), - invoke('git_backup_status', { path: wt.path }).catch(() => ({ available: false, different: null, hash: null, short: null, subject: null })), - invoke('git_rebase_status', { path: wt.path }).catch(() => ({ active: false } as RebaseStatus)), - invoke('git_upstream_status', { path: wt.path }).catch(() => null), - ]) - statuses.set(wt.path, status.total) - aheadBehindMap.set(wt.path, parseAheadBehind(aheadBehind)) - issueMap.set(wt.path, issue) - prStatusMap.set(wt.path, pr) - backupStatusMap.set(wt.path, backup) - rebaseStatusMap.set(wt.path, rebase) - if (upstream) upstreamStatusMap.set(wt.path, upstream) - })) - const runningPaths = new Set(worktrees.filter(wt => { - const directory = wt.path.replace(/\/$/, '').split('/').pop()! - return containers.some(container => isRunning(container) && container.name.startsWith(`${directory}-`)) - }).map(wt => wt.path)) - renderList(statuses, runningPaths) - } catch (error) { - listWrap.replaceChildren(note(String(error), 'db-detail-error')) - } - } - - async function load(): Promise { - const repos = panelStore.repositories() - if (repos.length === 0) { - baseSelect.disabled = true - filterInput.style.display = 'none' - baseRow.style.display = 'none' - repoPath = '' - updateRepoBtn() - removeRepoBtn.style.display = 'none' - refreshCreateForm() - listWrap.replaceChildren(note(taskT('selectRepoHint'))) - return - } - // Protect the principal repo: only offer to remove it when another repo - // remains, so a stray click can never leave Bento with no repositories. - removeRepoBtn.style.display = repos.length > 1 ? '' : 'none' - repoPath = repos[0] - if (!repos.includes(selectedRepositoryPath)) selectedRepositoryPath = repoPath - updateRepoBtn() - if (repos.length > 1) { - filterInput.style.display = '' - baseRow.style.display = 'none' - await loadMultiRepo(repos) - return - } - baseSelect.disabled = false - baseRow.style.display = '' - const selectionVersionAtLoad = selectionVersion - await loadTaskData({ - repoPath, - panelStore, - baseSelect, - filterInput, - listWrap, - fetchAgeEl, - note, - setBaseBranch: value => { baseBranch = value }, - setWorktrees: value => { worktrees = value }, - setJiraConfig: value => { jiraCfg = value }, - maps: { issue: issueMap, aheadBehind: aheadBehindMap, pr: prStatusMap, backup: backupStatusMap, rebase: rebaseStatusMap, upstream: upstreamStatusMap }, - renderList, - shouldRestoreSelection: () => selectionVersion === selectionVersionAtLoad, - selectRow, - showChanges, - showRebasePaused, - }) - repoOf.clear() - baseOf.clear() - worktrees.forEach(w => { - repoOf.set(w.path, repoPath) - baseOf.set(w.path, baseBranch) - }) - } - - function iconBtn(name: string, title: string, onClick: () => void): HTMLButtonElement { - const b = document.createElement('button') - b.className = 'docker-action' - b.title = title - b.innerHTML = icon(name) - b.addEventListener('click', e => { e.stopPropagation(); onClick() }) - return b - } + showDetail(ctx, note(taskT('selectTask'), 'db-detail-hint')) filterInput.style.display = 'none' - load() + void load(ctx) // Dispose all live worktree hubs when the panel/tab closes (persists each). const dispose = (): void => { - stopDiffRefresh() - disposeDetail() - for (const panel of worktreeTerminals.values()) panel.dispose() - worktreeTerminals.clear() + stopDiffRefresh(ctx) + disposeDetail(ctx) + for (const panel of ctx.worktreeTerminals.values()) panel.dispose() + ctx.worktreeTerminals.clear() } return { element: root, dispose, onVisibilityChange: (visible: boolean) => { - panelVisible = visible - if (!visible) detailPause() - else detailResume() + ctx.panelVisible = visible + if (!visible) ctx.detailPause() + else ctx.detailResume() }, } } + +export type { TasksPanelCtx } diff --git a/src/panels/tasks/tasksDetailViews.ts b/src/panels/tasks/tasksDetailViews.ts new file mode 100644 index 0000000..7a483bc --- /dev/null +++ b/src/panels/tasks/tasksDetailViews.ts @@ -0,0 +1,580 @@ +import { invoke } from '@tauri-apps/api/core' +import { open as openUrl } from '@tauri-apps/plugin-shell' +import { open as pickFolder, confirm as askConfirm } from '@tauri-apps/plugin-dialog' +import type { Worktree } from '../../core/git/worktree' +import { diffFileNames, changedPaths, matchingPaths, buildSelectedPatch, rankFixupCandidates } from '../../core/git/commitWorkflow' +import { parseAheadBehind } from '../../core/git/taskJira' +import { buildOperationHistoryView } from './OperationHistoryView' +import type { PrStatus, RebaseStatus, RewritePreflight } from './gitTypes' +import { buildPrStatusView } from './PrStatusView' +import { taskT } from './i18n' +import { buildBackupHistoryView } from './BackupHistoryView' +import { buildChangesFileView } from './ChangesFileView' +import { buildCommitFileList, fileStateMap, renderPatchHtml } from './TaskCodeView' +import { commitFilesRaw, recommendationMap, taskGit } from './taskGitClient' +import { buildResetView } from './ResetView' +import { buildGraphView } from './GraphView' +import { buildCommitLogView } from './CommitLogView' +import { buildSyncErrorView } from './TaskAuxiliaryViews' +import { buildIncomingChangesView } from './IncomingChangesView' +import type { TasksPanelCtx } from './tasksPanelContext' +import { baseFor, disposeDetail, projectKey, defaultProjectKey, recordOperation, repositoryFor, setDetailLifecycle, stopDiffRefresh } from './tasksPanelContext' +import { buildSubHead, iconBtn, note, showDetail } from './tasksPanelHelpers' +import { showRebasePaused } from './tasksRebaseView' +import { load } from './tasksLifecycle' +import { applyFilter } from './tasksListView' + +// ---- detail: task/project settings ---- +export async function showTaskSettings(ctx: TasksPanelCtx): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, note(taskT('loading'), 'db-detail-loading')) + await ctx.settingsReady + + const wrap = document.createElement('div') + wrap.className = 'tasks-settings-view' + const title = Object.assign(document.createElement('h3'), { textContent: taskT('taskSettings') }) + const description = note(taskT('recipesDirHint'), 'db-detail-hint') + const recipeProject = projectKey(ctx) || taskT('recipesExampleProject') + const projectGuide = note(taskT('addProjectRecipeHint', { project: recipeProject }), 'db-detail-hint') + const recipeExample = Object.assign(document.createElement('pre'), { + className: 'tasks-settings-example', + textContent: `${ctx.appSettings.devcontainerRecipesDir || '/ruta/a/bento-recipes'}/${recipeProject}/\n`+ + '├── .env\n' + + '└── .devcontainer/\n' + + ' ├── docker-compose.override.yml\n' + + ' └── bento-postcreate.sh', + }) + const label = Object.assign(document.createElement('label'), { + className: 'tasks-settings-label', + textContent: taskT('recipesDir'), + }) + const row = document.createElement('div') + row.className = 'tasks-settings-row' + const input = Object.assign(document.createElement('input'), { + className: 'tasks-settings-input', + type: 'text', + readOnly: true, + placeholder: taskT('recipesDirEmpty'), + value: ctx.appSettings.devcontainerRecipesDir ?? '', + }) + const status = note('', 'tasks-note') + + const keyLabel = Object.assign(document.createElement('label'), { + className: 'tasks-settings-label', + textContent: taskT('projectKey'), + }) + const keyInput = Object.assign(document.createElement('input'), { + className: 'tasks-settings-input', + type: 'text', + value: projectKey(ctx), + placeholder: defaultProjectKey(ctx.repoPath), + }) + keyInput.addEventListener('change', () => { + ctx.panelStore.setProjectKey(keyInput.value === defaultProjectKey(ctx.repoPath) ? '' : keyInput.value) + void showTaskSettings(ctx) + }) + keyLabel.appendChild(keyInput) + + const persist = async (directory: string | undefined): Promise => { + ctx.appSettings = { ...ctx.appSettings, devcontainerRecipesDir: directory || undefined } + input.value = directory ?? '' + status.textContent = taskT('savingSettings') + try { + await ctx.settingsRepository.save(ctx.appSettings) + status.textContent = taskT('settingsSaved') + } catch (error) { + status.className = 'db-detail-error' + status.textContent = String(error) + } + } + const choose = iconBtn('folder', taskT('chooseRecipesDir'), () => { + void pickFolder({ + directory: true, + defaultPath: ctx.appSettings.devcontainerRecipesDir, + }).then(picked => { + if (typeof picked === 'string') void persist(picked) + }).catch(() => {}) + }) + const clear = iconBtn('x', taskT('clearRecipesDir'), () => { void persist(undefined) }) + input.addEventListener('click', () => choose.click()) + row.append(input, choose, clear) + label.append(row) + + const recipeActions = document.createElement('div') + recipeActions.className = 'tasks-compose-controls' + const recipePath = (): string | null => ctx.appSettings.devcontainerRecipesDir + ? `${ctx.appSettings.devcontainerRecipesDir.replace(/\/$/, '')}/${projectKey(ctx)}` + : null + const createRecipe = iconBtn('plus', taskT('createRecipe'), () => { + if (!ctx.appSettings.devcontainerRecipesDir) { status.textContent = taskT('selectRecipesDirFirst'); return } + void invoke('devcontainer_recipe_create', { + recipesDir: ctx.appSettings.devcontainerRecipesDir, + projectKey: projectKey(ctx), + }).then(path => { + status.className = 'tasks-note' + status.textContent = taskT('recipeCreated', { path }) + }).catch(error => { status.className = 'db-detail-error'; status.textContent = String(error) }) + }) + const openRecipe = iconBtn('folder', taskT('openRecipeFolder'), () => { + const path = recipePath() + if (path) invoke('open_in_editor', { path }).catch(error => { status.textContent = String(error) }) + }) + const gitAction = (action: 'init' | 'status' | 'pull' | 'push' | 'commit'): void => { + if (!ctx.appSettings.devcontainerRecipesDir) { status.textContent = taskT('selectRecipesDirFirst'); return } + const message = action === 'commit' ? window.prompt(taskT('recipeCommitMessage')) : null + if (action === 'commit' && !message) return + status.className = 'tasks-note' + status.textContent = taskT('recipeGitRunning', { action }) + void invoke('devcontainer_recipe_git', { + recipesDir: ctx.appSettings.devcontainerRecipesDir, + action, + message, + }).then(output => { + status.textContent = output || taskT('recipeGitDone', { action }) + }).catch(error => { status.className = 'db-detail-error'; status.textContent = String(error) }) + } + recipeActions.append( + createRecipe, + openRecipe, + iconBtn('git-branch', taskT('initRecipesGit'), () => gitAction('init')), + iconBtn('list', taskT('recipeGitStatus'), () => gitAction('status')), + iconBtn('download', taskT('recipeGitPull'), () => gitAction('pull')), + iconBtn('arrow-right', taskT('recipeGitPush'), () => gitAction('push')), + iconBtn('check', taskT('recipeGitCommit'), () => gitAction('commit')), + ) + wrap.append(title, description, projectGuide, recipeExample, keyLabel, label, recipeActions, status) + showDetail(ctx, wrap) +} + +// ---- detail: changes (GitHub-style diff + commit bar) ---- +export async function showChanges(ctx: TasksPanelCtx, wt: Worktree): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + const requestVersion = ++ctx.detailVersion + showDetail(ctx, note(taskT('loadingChanges'), 'db-detail-loading')) + try { + const [raw, statusRaw, rebaseStatus] = await Promise.all([ + invoke('git_diff', { path: wt.path }), + taskGit.safeStatus(wt.path), + invoke('git_rebase_status', { path: wt.path }).catch(() => ({ active: false })), + ]) + if (requestVersion !== ctx.detailVersion) return + const rebaseActive = rebaseStatus.active + showDetail(ctx, buildDiffView(ctx, raw, wt, { statusRaw: statusRaw.raw, rebaseActive })) + // Auto-refresh: re-fetch diff every 5 s and update if content changed + let lastSnapshot = `${statusRaw.raw}\0${raw}` + const refreshChanges = async (): Promise => { + const [newRaw, newStatus] = await Promise.all([ + invoke('git_diff', { path: wt.path }).catch(() => null), + taskGit.safeStatus(wt.path), + ]) + if (requestVersion !== ctx.detailVersion) return + const snapshot = `${newStatus.raw}\0${newRaw ?? ''}` + if (newRaw !== null && snapshot !== lastSnapshot) { + const draft = ctx.detailPane.querySelector('[data-testid="tasks-commit-message"]') + // Replacing the entire diff also replaces the commit controls. Keep + // the current DOM stable while the user (or WebDriver) is editing so + // their text and the button they are about to activate cannot become + // stale underneath them. Once editing ends, the pending snapshot is + // intentionally retried on the next interval. + if (draft && (draft.value.length > 0 || document.activeElement === draft)) return + lastSnapshot = snapshot + showDetail(ctx, buildDiffView(ctx, newRaw, wt, { statusRaw: newStatus.raw, rebaseActive })) + } + } + const startDiffRefresh = (): void => { + stopDiffRefresh(ctx) + if (requestVersion !== ctx.detailVersion) return + ctx.diffRefreshInterval = setInterval(() => { void refreshChanges() }, 5000) + } + startDiffRefresh() + setDetailLifecycle(ctx, { + pause: () => stopDiffRefresh(ctx), + resume: () => { void refreshChanges(); startDiffRefresh() }, + dispose: () => stopDiffRefresh(ctx), + }) + } catch (e) { showDetail(ctx, note(String(e), 'db-detail-error')) } +} + +export function buildDiffView(ctx: TasksPanelCtx, raw: string, wt: Worktree, opts: { initMessage?: string; initAmend?: boolean; statusRaw?: string; rebaseActive?: boolean } = {}): HTMLElement { + const wrap = document.createElement('div') + wrap.className = 'tasks-diff' + + // Track which files are checked for partial staging + const checkedFiles = new Set() + const selectedHunks = new Map>() + const fileStates = fileStateMap(opts.statusRaw ?? '') + + // ---- commit bar ---- + const commitBar = document.createElement('div') + commitBar.className = 'tasks-commit-bar' + + const msgInput = Object.assign(document.createElement('input'), { + className: 'tasks-commit-msg', + type: 'text', + placeholder: taskT('commitMessage'), + value: opts.initMessage ?? '', + }) + msgInput.dataset.testid = 'tasks-commit-message' + + const amendToggle = Object.assign(document.createElement('button'), { + className: 'tasks-amend-btn', + title: taskT('amendHint'), + textContent: taskT('amend'), + }) + let doAmend = opts.initAmend ?? false + amendToggle.classList.toggle('tasks-amend-btn--active', doAmend) + if (doAmend) msgInput.placeholder = taskT('keepMessage') + amendToggle.addEventListener('click', () => { + doAmend = !doAmend + amendToggle.classList.toggle('tasks-amend-btn--active', doAmend) + msgInput.placeholder = doAmend ? taskT('keepMessage') : taskT('commitMessage') + commitBtn.textContent = doAmend ? taskT('amendCommit') : taskT('commit') + }) + + const commitBtn = Object.assign(document.createElement('button'), { + className: 'tasks-commit-btn', + textContent: taskT('commit'), + }) + commitBtn.dataset.testid = 'tasks-commit' + const fixupBtn = Object.assign(document.createElement('button'), { + className: 'tasks-amend-btn', + title: taskT('addToPreviousHint'), + textContent: taskT('fixupInto'), + disabled: !raw.trim(), + }) + fixupBtn.addEventListener('click', () => { + const selectedPatch = buildSelectedPatch(raw, checkedFiles, selectedHunks) + void showFixupPicker(ctx, wt, undefined, selectedPatch || raw, selectedPatch || undefined) + }) + + const showCommitStatus = (text: string, isError = false): void => { + const el = Object.assign(document.createElement('span'), { + className: isError ? 'tasks-commit-err' : 'tasks-commit-ok', + textContent: text, + }) + commitBar.appendChild(el) + setTimeout(() => el.remove(), isError ? 5000 : 3000) + } + + commitBtn.addEventListener('click', async () => { + const msg = msgInput.value.trim() + if (!doAmend && !msg) { msgInput.focus(); return } + commitBtn.disabled = true + amendToggle.disabled = true + fixupBtn.disabled = true + commitBtn.textContent = '…' + try { + const selectedPatch = buildSelectedPatch(raw, checkedFiles, selectedHunks) + await invoke('git_commit', { path: wt.path, message: msg, amend: doAmend || undefined, patch: selectedPatch || undefined }) + recordOperation(ctx, wt, doAmend ? 'commit --amend' : 'commit', 'success', msg || taskT('keptMessage')) + const wasAmend = doAmend + msgInput.value = '' + doAmend = false + amendToggle.classList.remove('tasks-amend-btn--active') + commitBtn.textContent = taskT('commit') + const [newRaw, newStatus] = await Promise.all([ + invoke('git_diff', { path: wt.path }), + taskGit.safeStatus(wt.path), + ]) + showDetail(ctx, buildDiffView(ctx, newRaw, wt, { statusRaw: newStatus.raw, rebaseActive: opts.rebaseActive })) + showCommitStatus(wasAmend ? taskT('commitAmended') : taskT('commitCreated')) + // Update sidebar badge and ahead/behind + ctx.lastStatuses.set(wt.path, (await taskGit.safeStatus(wt.path)).total) + const abRaw = await invoke('git_ahead_behind', { path: wt.path, base: baseFor(ctx, wt) }).catch(() => '') + ctx.aheadBehindMap.set(wt.path, parseAheadBehind(abRaw)) + applyFilter(ctx) + } catch (e) { + recordOperation(ctx, wt, doAmend ? 'commit --amend' : 'commit', 'error', String(e)) + commitBtn.textContent = doAmend ? taskT('amendCommit') : taskT('commit') + commitBtn.disabled = false + amendToggle.disabled = false + fixupBtn.disabled = false + showCommitStatus(String(e).slice(0, 120), true) + } + }) + + commitBar.append(msgInput, amendToggle, fixupBtn, commitBtn) + + if (opts.rebaseActive) wrap.appendChild(note( + taskT('pausedCommitHint'), + 'tasks-rebase-hint tasks-conflict-warning', + )) + + if (!raw.trim()) { + wrap.append(commitBar, note(taskT('noChanges'), 'db-detail-hint')) + return wrap + } + + const chunks = raw.split(/(?=^diff --git )/m).filter(Boolean) + + for (const chunk of chunks) { + const firstLine = chunk.split('\n')[0] ?? '' + const fileName = firstLine.match(/^diff --git a\/(.+) b\//)?.[1] ?? firstLine + wrap.appendChild(buildChangesFileView({ + chunk, state: fileStates.get(fileName), checkedFiles, selectedHunks, renderPatch: renderPatchHtml, + })) + } + + wrap.appendChild(commitBar) + return wrap +} + +// ---- detail: choose an existing commit for fixup ---- +export async function showFixupPicker(ctx: TasksPanelCtx, wt: Worktree, files: string[] | undefined, incomingDiff: string, selectedPatch?: string): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, note(taskT('loadingCommits'), 'db-detail-loading')) + try { + const worktreeBase = baseFor(ctx, wt) + const entries = await taskGit.rebaseLog(wt.path, worktreeBase) + if (entries.length === 0) { + showDetail(ctx, note(taskT('noOwnCommits', { base: worktreeBase }), 'db-detail-hint')) + return + } + + const incomingFiles = new Set(files ?? diffFileNames(incomingDiff)) + const [recommendations, blameRecommendations] = await Promise.all([ + taskGit.recommendations(wt.path, worktreeBase, [...incomingFiles]).catch(() => []), + taskGit.blameRecommendations(wt.path, worktreeBase, incomingDiff).catch(() => []), + ]) + const historyScores = recommendationMap(recommendations) + const blameScores = recommendationMap(blameRecommendations) + const scored = await Promise.all(entries.map(async entry => { + const commitFiles = await taskGit.files(wt.path, entry.hash).catch(() => []) + const filesRaw = commitFilesRaw(commitFiles) + const overlap = matchingPaths(incomingFiles, changedPaths(filesRaw)) + const history = historyScores.get(entry.hash) ?? { score: 0, files: [] } + const blame = blameScores.get(entry.hash) ?? { score: 0, files: [] } + return { entry, commitFiles, overlap, history, blame } + })) + const enriched = rankFixupCandidates(scored) + + const wrap = document.createElement('div') + wrap.className = 'tasks-fixup-wrap' + wrap.append(buildSubHead(taskT('addChangesTitle'), () => void showChanges(ctx, wt))) + wrap.appendChild(Object.assign(document.createElement('p'), { + className: 'tasks-rebase-hint', + textContent: selectedPatch + ? taskT('incomingSelection', { count: incomingFiles.size }) + : files?.length + ? taskT('incomingFiles', { count: files.length }) + : taskT('incomingAll'), + })) + wrap.appendChild(buildIncomingChangesView(incomingDiff, files, note)) + + const list = document.createElement('div') + list.className = 'tasks-fixup-list' + for (const { entry, commitFiles, overlap, history, blame } of enriched) { + const item = document.createElement('div') + item.className = `tasks-fixup-item${overlap.length || history.score || blame.score ? ' tasks-fixup-item--match' : ''}` + const header = document.createElement('div') + header.className = 'tasks-fixup-header' + const filesEl = document.createElement('div') + filesEl.className = 'tasks-commit-files hidden' + const expandBtn = iconBtn('chevron-down', taskT('viewCommitCode'), async () => { + const opening = filesEl.classList.contains('hidden') + filesEl.classList.toggle('hidden', !opening) + if (!opening || filesEl.childElementCount > 0) return + filesEl.replaceChildren(...buildCommitFileList( + commitFiles, + file => invoke('git_show_commit_diff', { path: wt.path, hash: entry.hash, file }), + file => invoke('git_show_file', { path: wt.path, hash: entry.hash, file }), + )) + }) + expandBtn.className = 'tasks-expand-btn' + const chooseBtn = Object.assign(document.createElement('button'), { + className: 'tasks-commit-btn', + textContent: taskT('addHere'), + }) + const statusEl = Object.assign(document.createElement('span'), { className: 'tasks-rebase-status-msg' }) + chooseBtn.addEventListener('click', async () => { + const preflight = await invoke('git_rewrite_preflight', { path: wt.path, base: worktreeBase }).catch(() => null) + if (preflight?.operation) { + statusEl.textContent = taskT('operationInProgress', { operation: preflight.operation }) + return + } + const publishedWarning = preflight?.publishedCommits + ? taskT('publishedFixup', { count: preflight.publishedCommits }) : '' + const ok = await askConfirm( + taskT('fixupPreview', { count: incomingFiles.size, target: `${entry.short} ${entry.subject}`, matches: overlap.length ? overlap.join(', ') : taskT('none'), blame: blame.score || taskT('none'), history: history.score ? history.files.join(', ') : taskT('none'), published: publishedWarning }), + { title: taskT('applyFixup'), kind: 'warning' }, + ) + if (!ok) return + list.querySelectorAll('button').forEach(button => { button.disabled = true }) + statusEl.textContent = taskT('fixupRunning') + try { + const result = await invoke('git_fixup', { path: wt.path, target: entry.hash, base: worktreeBase, files, patch: selectedPatch }) + recordOperation(ctx, wt, 'fixup + autosquash', 'success', `${entry.short} ${entry.subject}`) + if (result === 'paused') { + showRebasePaused(ctx, wt, await invoke('git_rebase_status', { path: wt.path })) + return + } + statusEl.textContent = taskT('changesIntegrated') + setTimeout(() => { void showChanges(ctx, wt); void load(ctx) }, 900) + } catch (e) { + recordOperation(ctx, wt, 'fixup + autosquash', 'error', String(e)) + statusEl.textContent = String(e).slice(0, 160) + list.querySelectorAll('button').forEach(button => { button.disabled = false }) + } + }) + header.append( + expandBtn, + Object.assign(document.createElement('span'), { className: 'tasks-log-short', textContent: entry.short }), + Object.assign(document.createElement('span'), { className: 'tasks-rebase-subject', textContent: entry.subject }), + ...(overlap.length ? [Object.assign(document.createElement('span'), { + className: 'tasks-fixup-match-badge', + textContent: taskT('recommendedMatch', { count: overlap.length }), + title: overlap.join('\n'), + })] : []), + ...(blame.score ? [Object.assign(document.createElement('span'), { + className: 'tasks-fixup-blame-badge', + textContent: taskT('blameLines', { count: blame.score }), + title: taskT('blameHint', { files: blame.files.join(', ') }), + })] : []), + ...(!overlap.length && !blame.score && history.score ? [Object.assign(document.createElement('span'), { + className: 'tasks-fixup-history-badge', + textContent: taskT('historyScore', { count: history.score }), + title: taskT('historyHint', { files: history.files.join(', ') }), + })] : []), + statusEl, + chooseBtn, + ) + item.append(header, filesEl) + list.appendChild(item) + } + wrap.appendChild(list) + showDetail(ctx, wrap) + } catch (e) { + showDetail(ctx, note(String(e), 'db-detail-error')) + } +} + +// ---- detail: automatic history backups ---- +export async function showBackupHistory(ctx: TasksPanelCtx, wt: Worktree): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, note(taskT('loadingBackups'), 'db-detail-loading')) + try { + showDetail(ctx, await buildBackupHistoryView({ + path: wt.path, branch: wt.branch ?? '', renderPatch: renderPatchHtml, + onBack: () => void showChanges(ctx, wt), + onRestored: async () => { await load(ctx); void showChanges(ctx, wt) }, + onOperation: (status, detail) => recordOperation(ctx, wt, taskT('restoreBackup'), status, detail), + })) + } catch (e) { showDetail(ctx, note(String(e), 'db-detail-error')) } +} + +export function showOperationHistory(ctx: TasksPanelCtx, wt: Worktree): void { + stopDiffRefresh(ctx) + disposeDetail(ctx) + const branch = wt.branch ?? taskT('detached') + const repository = repositoryFor(ctx, wt) + showDetail(ctx, buildOperationHistoryView({ + branch, + repository, + entries: ctx.panelStore.operations(), + onBack: () => void showChanges(ctx, wt), + onClear: () => { + ctx.panelStore.clearOperations(repository, branch) + showOperationHistory(ctx, wt) + }, + })) +} + +// ---- detail: reset commits ---- +export function showResetView(ctx: TasksPanelCtx, wt: Worktree): void { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, buildResetView({ + worktree: wt, + baseBranch: baseFor(ctx, wt), + buildSubHead, + onBack: () => void showChanges(ctx, wt), + onComplete: () => { void showChanges(ctx, wt); void load(ctx) }, + recordOperation: (operation, status, detail) => recordOperation(ctx, wt, operation, status, detail), + })) +} + +// ---- detail: commit log ---- +export async function showCommitGraph(ctx: TasksPanelCtx, wt: Worktree): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + await buildGraphView({ + worktree: wt, + baseBranch: baseFor(ctx, wt), + buildSubHead, + onBack: () => void showChanges(ctx, wt), + showDetail: (...nodes) => showDetail(ctx, ...nodes), + note, + }) +} + +export function showPrDetails(ctx: TasksPanelCtx, wt: Worktree, pr: PrStatus): void { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, buildPrStatusView({ + pr, baseBranch: baseFor(ctx, wt), + onBack: () => void showChanges(ctx, wt), + onOpen: () => openUrl(pr.url).catch(() => {}), + })) +} + +export async function showCommitLog(ctx: TasksPanelCtx, wt: Worktree): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, note(taskT('loadingHistory'), 'db-detail-loading')) + try { + const entries = await taskGit.log(wt.path) + buildCommitLogView({ + worktree: wt, + entries, + buildSubHead, + onBack: () => void showChanges(ctx, wt), + showDetail: (...nodes) => showDetail(ctx, ...nodes), + note, + iconBtn, + buildCommitFileList, + loadFiles: (path, hash) => taskGit.files(path, hash).catch(() => []), + }) + } catch (err) { showDetail(ctx, note(String(err), 'db-detail-error')) } +} + +// ---- detail: worktree terminal ---- +export async function showWorktreeTerminal(ctx: TasksPanelCtx, wt: Worktree): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + const { createAgentsPanel } = await import('../agents/AgentsPanel') + // Reuse the live hub for this worktree if we already opened it; otherwise + // create one scoped to the worktree (own storage, off the global dock). + let panel = ctx.worktreeTerminals.get(wt.path) + if (!panel) { + panel = createAgentsPanel(wt.path, { storageScope: `bento.agents.wt:${wt.path}`, publishToDock: false }) + ctx.worktreeTerminals.set(wt.path, panel) + } + const wrap = document.createElement('div') + wrap.className = 'tasks-term-wrap' + const termBody = document.createElement('div') + termBody.className = 'tasks-term-body' + termBody.appendChild(panel.element) + wrap.append(buildSubHead(`Terminal · ${wt.branch ?? ''}`, () => void showChanges(ctx, wt)), termBody) + showDetail(ctx, wrap) + requestAnimationFrame(() => panel!.fit()) + // Navigating away only detaches the element (showDetail replaces it); the + // hub stays alive in the cache, so the agents keep running. Persist on leave + // so they're restorable even if the tab closes without a clean dispose. + const livePanel = panel + setDetailLifecycle(ctx, { + pause: () => {}, + resume: () => livePanel.fit(), + dispose: () => livePanel.persist(), + }) +} + +// ---- detail: git sync error (with conflict detection + AI explain) ---- +export function showSyncError(ctx: TasksPanelCtx, mode: string, errorText: string, wt: Worktree): void { + stopDiffRefresh(ctx) + disposeDetail(ctx) + buildSyncErrorView({ mode, errorText, path: wt.path, showDetail: (...nodes) => showDetail(ctx, ...nodes), iconButton: iconBtn, status: path => taskGit.status(path) }) +} diff --git a/src/panels/tasks/tasksLifecycle.ts b/src/panels/tasks/tasksLifecycle.ts new file mode 100644 index 0000000..5ee6f40 --- /dev/null +++ b/src/panels/tasks/tasksLifecycle.ts @@ -0,0 +1,178 @@ +import { invoke } from '@tauri-apps/api/core' +import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' +import { taskBranch, taskPath, type Worktree } from '../../core/git/worktree' +import { extractIssueKey, parseAheadBehind } from '../../core/git/taskJira' +import { fetchIssue, loadJiraConfig } from './taskJiraClient' +import type { BackupStatus, PrStatus, RebaseStatus, UpstreamStatus } from './gitTypes' +import { taskT } from './i18n' +import { taskGit } from './taskGitClient' +import type { IsolateResult } from './TaskDockerView' +import { loadTaskData } from './TaskDataLoader' +import { isRunning, parseContainers } from '../../core/docker/containers' +import type { TasksPanelCtx } from './tasksPanelContext' +import { baseFor, prepareTaskDevcontainer, repositoryFor, selectRow, selectWorktree } from './tasksPanelContext' +import { note, showDetail } from './tasksPanelHelpers' +import { renderList, refreshCreateForm } from './tasksListView' +import { showChanges } from './tasksDetailViews' +import { showRebasePaused } from './tasksRebaseView' + +// ---- mutations ---- +export async function createTask(ctx: TasksPanelCtx, name: string, repository = ctx.repoPath): Promise { + if (!name || !repository) return + const branch = taskBranch(name) + const path = taskPath(repository, branch.slice('feat/'.length)) + ctx.listWrap.replaceChildren(note(taskT('creatingTask'), 'db-detail-loading')) + try { + const base = await invoke('git_default_branch', { repo: repository }) + await invoke('git_worktree_add', { repo: repository, path, branch, base }) + await load(ctx) + const wt = ctx.worktrees.find(w => w.path === path) + const row = [...ctx.listWrap.querySelectorAll('.tasks-row')].find(item => item.dataset.path === path) + if (wt && row) { + selectWorktree(ctx, row, wt) + row.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + try { + const result = await invoke('docker_compose_isolate', { worktreePath: path }) + if (wt) ctx.dockerView.show(result, wt) + } catch (e) { + // No root docker-compose.yml → maybe a devcontainer project (compose under .devcontainer/). + if (String(e) !== 'no-compose') { showDetail(ctx, note(String(e), 'db-detail-error')); return } + if (wt) { + const prepared = await prepareTaskDevcontainer(ctx, wt) + if (!prepared) void showChanges(ctx, wt) + } + } + } catch (e) { ctx.listWrap.replaceChildren(note(String(e), 'db-detail-error')) } +} + +export async function deleteWorktree(ctx: TasksPanelCtx, wt: Worktree): Promise { + const { total } = await taskGit.safeStatus(wt.path) + const ok = await askConfirm( + total > 0 ? taskT('deleteDirtyQuestion', { branch: wt.branch ?? '', count: total }) : taskT('deleteQuestion', { branch: wt.branch ?? '' }), + { title: taskT('deleteTask'), kind: total > 0 ? 'warning' : 'info' }, + ) + if (!ok) return + try { + await invoke('docker_compose_down', { worktreePath: wt.path }).catch(() => {}) + await invoke('git_worktree_remove', { repo: repositoryFor(ctx, wt), path: wt.path, force: total > 0, branch: wt.branch ?? null }) + // Tear down the worktree's live agents hub (its worktree is gone) and drop + // its persisted agents + scrollback so nothing is left orphaned. + ctx.worktreeTerminals.get(wt.path)?.dispose() + ctx.worktreeTerminals.delete(wt.path) + try { localStorage.removeItem(`bento.agents.wt:${wt.path}.sessions`) } catch { /* ignore */ } + void invoke('agent_history_clear', { scope: `bento.agents.wt:${wt.path}` }).catch(() => {}) + showDetail(ctx, note(taskT('selectTask'), 'db-detail-hint')) + await load(ctx) + } catch (e) { await askConfirm(String(e), { title: taskT('genericError'), kind: 'error' }) } +} + +// ---- load ---- +export async function loadMultiRepo(ctx: TasksPanelCtx, repos: string[]): Promise { + ctx.listWrap.replaceChildren(note(taskT('loading'), 'db-detail-loading')) + ctx.repoOf.clear() + ctx.baseOf.clear() + ;[ctx.issueMap, ctx.prStatusMap, ctx.backupStatusMap, ctx.rebaseStatusMap, ctx.upstreamStatusMap].forEach(m => m.clear()) + ctx.aheadBehindMap.clear() + try { + const [repoData, containers, config] = await Promise.all([ + Promise.all(repos.map(async repo => ({ + repo, + base: await invoke('git_default_branch', { repo }).catch(() => 'main'), + worktrees: await taskGit.worktrees(repo), + }))), + invoke('docker_list').catch(() => '').then(parseContainers), + loadJiraConfig(), + ]) + ctx.jiraCfg = config + ctx.worktrees = repoData.flatMap(({ repo, base, worktrees: items }) => { + items.forEach(wt => { + ctx.repoOf.set(wt.path, repo) + ctx.baseOf.set(wt.path, base) + }) + return items + }) + const statuses = new Map() + await Promise.all(ctx.worktrees.map(async wt => { + const base = baseFor(ctx, wt) + const key = extractIssueKey(wt.branch ?? null) + const [status, aheadBehind, issue, pr, backup, rebase, upstream] = await Promise.all([ + taskGit.safeStatus(wt.path), + invoke('git_ahead_behind', { path: wt.path, base }).catch(() => ''), + key && ctx.jiraCfg ? fetchIssue(key, ctx.jiraCfg) : null, + invoke('git_pr_status', { path: wt.path }).catch(() => null), + invoke('git_backup_status', { path: wt.path }).catch(() => ({ available: false, different: null, hash: null, short: null, subject: null })), + invoke('git_rebase_status', { path: wt.path }).catch(() => ({ active: false } as RebaseStatus)), + invoke('git_upstream_status', { path: wt.path }).catch(() => null), + ]) + statuses.set(wt.path, status.total) + ctx.aheadBehindMap.set(wt.path, parseAheadBehind(aheadBehind)) + ctx.issueMap.set(wt.path, issue) + ctx.prStatusMap.set(wt.path, pr) + ctx.backupStatusMap.set(wt.path, backup) + ctx.rebaseStatusMap.set(wt.path, rebase) + if (upstream) ctx.upstreamStatusMap.set(wt.path, upstream) + })) + const runningPaths = new Set(ctx.worktrees.filter(wt => { + const directory = wt.path.replace(/\/$/, '').split('/').pop()! + return containers.some(container => isRunning(container) && container.name.startsWith(`${directory}-`)) + }).map(wt => wt.path)) + renderList(ctx, statuses, runningPaths) + } catch (error) { + ctx.listWrap.replaceChildren(note(String(error), 'db-detail-error')) + } +} + +export async function load(ctx: TasksPanelCtx): Promise { + const repos = ctx.panelStore.repositories() + if (repos.length === 0) { + ctx.baseSelect.disabled = true + ctx.filterInput.style.display = 'none' + ctx.baseRow.style.display = 'none' + ctx.repoPath = '' + ctx.updateRepoBtn() + ctx.removeRepoBtn.style.display = 'none' + refreshCreateForm(ctx) + ctx.listWrap.replaceChildren(note(taskT('selectRepoHint'))) + return + } + // Protect the principal repo: only offer to remove it when another repo + // remains, so a stray click can never leave Bento with no repositories. + ctx.removeRepoBtn.style.display = repos.length > 1 ? '' : 'none' + ctx.repoPath = repos[0]! + if (!repos.includes(ctx.selectedRepositoryPath)) ctx.selectedRepositoryPath = ctx.repoPath + ctx.updateRepoBtn() + if (repos.length > 1) { + ctx.filterInput.style.display = '' + ctx.baseRow.style.display = 'none' + await loadMultiRepo(ctx, repos) + return + } + ctx.baseSelect.disabled = false + ctx.baseRow.style.display = '' + const selectionVersionAtLoad = ctx.selectionVersion + await loadTaskData({ + repoPath: ctx.repoPath, + panelStore: ctx.panelStore, + baseSelect: ctx.baseSelect, + filterInput: ctx.filterInput, + listWrap: ctx.listWrap, + fetchAgeEl: ctx.fetchAgeEl, + note, + setBaseBranch: value => { ctx.baseBranch = value }, + setWorktrees: value => { ctx.worktrees = value }, + setJiraConfig: value => { ctx.jiraCfg = value }, + maps: { issue: ctx.issueMap, aheadBehind: ctx.aheadBehindMap, pr: ctx.prStatusMap, backup: ctx.backupStatusMap, rebase: ctx.rebaseStatusMap, upstream: ctx.upstreamStatusMap }, + renderList: (statuses, runningPaths) => renderList(ctx, statuses, runningPaths), + shouldRestoreSelection: () => ctx.selectionVersion === selectionVersionAtLoad, + selectRow: row => selectRow(ctx, row), + showChanges: wt => void showChanges(ctx, wt), + showRebasePaused: (wt, st) => showRebasePaused(ctx, wt, st), + }) + ctx.repoOf.clear() + ctx.baseOf.clear() + ctx.worktrees.forEach(w => { + ctx.repoOf.set(w.path, ctx.repoPath) + ctx.baseOf.set(w.path, ctx.baseBranch) + }) +} diff --git a/src/panels/tasks/tasksListView.ts b/src/panels/tasks/tasksListView.ts new file mode 100644 index 0000000..d299b43 --- /dev/null +++ b/src/panels/tasks/tasksListView.ts @@ -0,0 +1,546 @@ +import { invoke } from '@tauri-apps/api/core' +import { open as openUrl } from '@tauri-apps/plugin-shell' +import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' +import type { Worktree } from '../../core/git/worktree' +import { filterWorktrees, groupWorktreesByRepo } from '../../core/git/worktreeList' +import { summarisePrChecks } from '../../core/git/prChecks' +import { showContextMenu } from '../../ui/contextMenu' +import { icon } from '../../ui/icons' +import { statusCategoryClass } from '../../core/jira/board' +import { fetchIssue, fetchTransitions, applyTransition, browseUrl } from './taskJiraClient' +import type { RecipeApplyResult } from './TaskDockerView' +import type { RewritePreflight } from './gitTypes' +import { taskT } from './i18n' +import { taskGit } from './taskGitClient' +import { taskProgress } from './taskProgress' +import { taskRowActions } from './TaskRowActions' +import type { TasksPanelCtx } from './tasksPanelContext' +import { + baseFor, isCurrentSelection, prepareTaskDevcontainer, recordOperation, + selectRow, selectWorktree, selectedRepository, +} from './tasksPanelContext' +import { iconBtn, note, showDetail } from './tasksPanelHelpers' +import { + showBackupHistory, showCommitLog, showCommitGraph, showChanges, + showOperationHistory, showPrDetails, showResetView, showSyncError, showWorktreeTerminal, +} from './tasksDetailViews' +import { showInteractiveRebase, showRebasePaused } from './tasksRebaseView' +import { createTask, deleteWorktree, load } from './tasksLifecycle' + +// ---- list ---- +export function renderList(ctx: TasksPanelCtx, statuses: Map, runningPaths: Set): void { + ctx.lastStatuses = statuses + ctx.lastRunningPaths = runningPaths + applyFilter(ctx) +} + +export function progressBar(label: string, v: { done: number; total: number }): HTMLElement { + const pct = v.total ? Math.round((v.done / v.total) * 100) : 0 + const wrap = document.createElement('div') + wrap.className = 'tasks-progress-item' + const head = document.createElement('div') + head.className = 'tasks-progress-head' + head.append( + Object.assign(document.createElement('span'), { className: 'tasks-progress-label', textContent: label }), + Object.assign(document.createElement('span'), { className: 'tasks-progress-stat', textContent: `${v.done}/${v.total} · ${pct}%` }), + ) + const track = document.createElement('div') + track.className = 'tasks-progress-track' + const fill = document.createElement('div') + fill.className = 'tasks-progress-fill' + fill.style.width = `${pct}%` + track.appendChild(fill) + wrap.append(head, track) + return wrap +} + +// Footer progress bars: aggregate health of the repo's worktrees (see taskProgress). +export function updateProgress(ctx: TasksPanelCtx): void { + const p = taskProgress(ctx.worktrees, ctx.lastStatuses, ctx.aheadBehindMap) + ctx.progressFooter.replaceChildren( + progressBar(taskT('tasksClean'), p.clean), + progressBar(taskT('tasksSynced'), p.synced), + ) +} + +export function applyFilter(ctx: TasksPanelCtx): void { + updateProgress(ctx) + const filtered = filterWorktrees(ctx.worktrees, ctx.filterText) + + ctx.listWrap.replaceChildren() + refreshCreateForm(ctx) + if (filtered.length === 0) { + ctx.listWrap.append(note(ctx.worktrees.length === 0 ? taskT('noWorktrees') : taskT('noResults'))) + ctx.refreshMiniItems() + return + } + + // Single repo → one group. + const byRepo = groupWorktreesByRepo(filtered, ctx.repoOf, ctx.repoPath) + for (const [repo, wts] of byRepo) ctx.listWrap.appendChild(buildProjectGroup(ctx, repo, wts)) + ctx.refreshMiniItems() +} + +// Collapsible project header (repo name + count) grouping that repo's worktrees. +export function buildProjectGroup(ctx: TasksPanelCtx, repo: string, wts: Worktree[]): HTMLElement { + const repoRoot = repo.replace(/\/$/, '') + const group = document.createElement('div') + group.className = `tasks-project${ctx.collapsedRepos.has(repo) ? ' collapsed' : ''}` + const header = document.createElement('div') + header.className = 'tasks-project-header' + const toggle = document.createElement('button') + toggle.type = 'button' + toggle.className = 'tasks-project-toggle' + toggle.setAttribute('aria-expanded', String(!ctx.collapsedRepos.has(repo))) + const chevron = document.createElement('span') + chevron.className = 'tasks-project-chevron' + chevron.innerHTML = icon('chevron-down') + toggle.append( + chevron, + Object.assign(document.createElement('span'), { className: 'tasks-project-name', textContent: repoRoot.split('/').pop() ?? repo }), + Object.assign(document.createElement('span'), { className: 'tasks-project-count', textContent: String(wts.length) }), + ) + header.appendChild(toggle) + // Remove this repo from the list (only offered when there's more than one). + if (ctx.panelStore.repositories().length > 1) { + const remove = Object.assign(document.createElement('button'), { + type: 'button', className: 'tasks-project-remove', textContent: '×', title: taskT('removeRepo'), + }) + remove.addEventListener('click', e => { e.stopPropagation(); ctx.panelStore.removeRepository(repo); void load(ctx) }) + header.appendChild(remove) + } + toggle.addEventListener('click', () => { + ctx.selectedRepositoryPath = repo + if (ctx.collapsedRepos.has(repo)) ctx.collapsedRepos.delete(repo); else ctx.collapsedRepos.add(repo) + group.classList.toggle('collapsed') + toggle.setAttribute('aria-expanded', String(!ctx.collapsedRepos.has(repo))) + }) + const list = document.createElement('div') + list.className = 'tasks-list' + wts.forEach(wt => { + const isMain = wt.path.replace(/\/$/, '') === repoRoot + list.appendChild(buildRow(ctx, wt, isMain, ctx.lastStatuses.get(wt.path) ?? 0, ctx.lastRunningPaths.has(wt.path))) + }) + group.append(header, list) + return group +} + +export function buildRow(ctx: TasksPanelCtx, wt: Worktree, isMain: boolean, changes: number, hasRunning: boolean): HTMLElement { + const worktreeBase = baseFor(ctx, wt) + const row = document.createElement('div') + row.className = 'tasks-row' + row.dataset.testid = 'tasks-row' + row.dataset.branch = wt.branch ?? '' + row.dataset.path = wt.path + row.tabIndex = 0 + row.setAttribute('role', 'button') + row.setAttribute('aria-label', `${taskT('tasks')}: ${wt.branch ?? ''}, ${taskT('changes', { count: changes })}`) + + const runDot = document.createElement('span') + runDot.className = `tasks-run-dot ${hasRunning ? 'docker-up' : ''}` + runDot.title = hasRunning ? taskT('containersRunning') : taskT('noContainers') + + const issue = ctx.issueMap.get(wt.path) ?? null + const ab = ctx.aheadBehindMap.get(wt.path) + const pr = ctx.prStatusMap.get(wt.path) ?? null + const backup = ctx.backupStatusMap.get(wt.path) + const rebase = ctx.rebaseStatusMap.get(wt.path) + const upstream = ctx.upstreamStatusMap.get(wt.path) + + const branchEl = Object.assign(document.createElement('span'), { + className: 'tasks-branch', + textContent: wt.branch ?? taskT('detached'), + }) + if (isMain) branchEl.title = taskT('mainWorktree') + + const pathEl = Object.assign(document.createElement('span'), { + className: 'tasks-path', + textContent: wt.path.replace(/\/$/, '').split('/').slice(-2).join('/'), + title: wt.path, + }) + + const left = document.createElement('div') + left.className = 'tasks-row-left' + + if (issue) { + const issueEl = document.createElement('div') + issueEl.className = 'tasks-issue-line' + const keyEl = Object.assign(document.createElement('span'), { className: 'tasks-issue-key', textContent: issue.key }) + const sepEl = Object.assign(document.createElement('span'), { className: 'tasks-issue-sep', textContent: ' · ' }) + const summaryEl = Object.assign(document.createElement('span'), { className: 'tasks-issue-summary', textContent: issue.summary }) + const chipEl = Object.assign(document.createElement('span'), { + className: `jira-status ${statusCategoryClass(issue.statusCategory)}`, + textContent: issue.statusName, + }) + issueEl.append(keyEl, sepEl, summaryEl, chipEl) + left.append(issueEl, branchEl, pathEl) + } else { + left.append(branchEl, pathEl) + } + + const badge = Object.assign(document.createElement('span'), { + className: `tasks-badge${changes > 0 ? ' tasks-badge--dirty' : ''}`, + textContent: changes > 0 ? taskT('changes', { count: changes }) : taskT('clean'), + }) + const recipeEl = document.createElement('span') + if (!isMain) { + void invoke('devcontainer_recipe_status', { + worktreePath: wt.path, + devcontainerDir: ctx.panelStore.devcontainerDir(), + }).then(recipe => { + if (!recipe || !row.isConnected) return + recipeEl.className = `tasks-recipe-badge${recipe.errors.length ? ' tasks-recipe-badge--error' : ''}` + recipeEl.textContent = taskT('recipeBadge') + recipeEl.title = taskT('recipeBadgeTitle', { + project: recipe.projectKey, + applied: recipe.applied.length, + errors: recipe.errors.length, + date: new Date(recipe.appliedAt * 1000).toLocaleString(), + }) + }).catch(() => {}) + } + + const flashBadge = (text: string, cls: string, ms: number): void => { + const prev = badge.textContent ?? '' + const prevCls = badge.className + badge.textContent = text.split('\n')[0]?.slice(0, 28) ?? '' + badge.className = `tasks-badge ${cls}` + setTimeout(() => { badge.textContent = prev; badge.className = prevCls }, ms) + } + + // Ahead/behind indicator — orange when behind (needs sync) + const abEl = document.createElement('span') + abEl.className = 'tasks-ahead-behind' + if (ab && (ab.ahead > 0 || ab.behind > 0)) { + if (ab.behind > 0) abEl.classList.add('tasks-behind') + const parts: string[] = [] + if (ab.ahead > 0) parts.push(`↑${ab.ahead}`) + if (ab.behind > 0) parts.push(`↓${ab.behind}`) + abEl.textContent = parts.join(' ') + abEl.title = taskT('aheadBehindTitle', { ahead: ab.ahead, behind: ab.behind, branch: worktreeBase }) + } + + // PR status badge + const prEl = document.createElement('span') + if (pr) { + const stateMap: Record = { OPEN: 'tasks-pr-open', DRAFT: 'tasks-pr-draft', MERGED: 'tasks-pr-merged', CLOSED: 'tasks-pr-closed' } + const labelMap: Record = { + OPEN: taskT('openPrShort'), + DRAFT: taskT('draftPrShort'), + MERGED: taskT('mergedPrShort'), + CLOSED: taskT('closedPr'), + } + prEl.className = `tasks-pr-badge ${stateMap[pr.state] ?? ''}` + const checks = summarisePrChecks(pr.statusCheckRollup ?? []) + prEl.textContent = checks.failed ? taskT('failedChecks', { count: checks.failed }) + : checks.pending ? taskT('pendingChecks', { count: checks.pending }) + : labelMap[pr.state] ?? taskT('openPrShort') + const prSignals = [ + pr.baseRefName ? `base: ${pr.baseRefName}` : '', + pr.mergeable === 'CONFLICTING' ? taskT('baseConflicts') : '', + pr.reviewDecision === 'APPROVED' ? taskT('approved') : pr.reviewDecision === 'CHANGES_REQUESTED' ? taskT('changesRequested') : pr.reviewDecision === 'REVIEW_REQUIRED' ? taskT('reviewPending') : '', + checks.failed ? taskT('failingChecks', { count: checks.failed }) : checks.pending ? taskT('checksPending', { count: checks.pending }) : checks.total ? taskT('checksPassed') : '', + ].filter(Boolean) + prEl.title = `${pr.title}${prSignals.length ? ` · ${prSignals.join(' · ')}` : ''}` + if (checks.failed || pr.mergeable === 'CONFLICTING') prEl.classList.add('tasks-pr-checks-failed') + prEl.addEventListener('click', e => { e.stopPropagation(); openUrl(pr.url).catch(() => {}) }) + } + + const backupEl = document.createElement('span') + if (backup?.available && backup.different) { + backupEl.className = 'tasks-backup-badge' + backupEl.textContent = taskT('backupBadge') + backupEl.title = `${backup.short ?? ''} ${backup.subject ?? ''}`.trim() + } + const rebaseEl = document.createElement('span') + if (rebase?.active) { + rebaseEl.className = 'tasks-rebase-badge' + rebaseEl.textContent = rebase.total + ? taskT('rebaseProgress', { current: rebase.current ?? 0, total: rebase.total }) + : taskT('pausedRebase') + rebaseEl.title = taskT('resumeRebaseHint') + rebaseEl.addEventListener('click', e => { e.stopPropagation(); selectRow(ctx, row); showRebasePaused(ctx, wt, rebase) }) + } + const upstreamEl = document.createElement('span') + if (upstream?.state === 'diverged') { + upstreamEl.className = 'tasks-upstream-badge tasks-upstream-badge--diverged' + upstreamEl.textContent = taskT('rewrittenHistory') + upstreamEl.title = taskT('localRemoteCommits', { local: upstream.ahead, remote: upstream.behind }) + } else if (upstream?.state === 'behind') { + upstreamEl.className = 'tasks-upstream-badge tasks-upstream-badge--behind' + upstreamEl.textContent = taskT('remoteAhead', { count: upstream.behind }) + } else if (upstream?.state === 'unpublished') { + upstreamEl.className = 'tasks-upstream-badge' + upstreamEl.textContent = taskT('unpublished') + } + + const runSync = async (mode: 'fetch' | 'merge' | 'rebase'): Promise => { + if (mode === 'rebase') { + const preflight = await invoke('git_rewrite_preflight', { path: wt.path, base: worktreeBase }).catch(() => null) + if (preflight?.operation) { + selectRow(ctx, row); showSyncError(ctx, 'rebase', taskT('operationInProgress', { operation: preflight.operation }), wt) + return + } + if (preflight?.protectedBase) { + const ok = await askConfirm(taskT('protectedBranchQuestion', { branch: preflight.branch }), { title: taskT('protectedBranchTitle'), kind: 'warning' }) + if (!ok) return + } + } + const needsCleanTree = mode !== 'fetch' + let autostash = false + if (needsCleanTree) { + const hasChanges = (await taskGit.safeStatus(wt.path)).total > 0 + if (hasChanges) { + const doStash = await askConfirm( + taskT('dirtySyncQuestion', { branch: wt.branch ?? '' }), + { title: taskT('syncWithStash'), kind: 'warning' }, + ) + if (!doStash) return + autostash = true + } + } + flashBadge(taskT('syncing'), '', 60000) + try { + const out = await invoke('git_sync', { path: wt.path, base: worktreeBase, mode, autostash }) + recordOperation(ctx, wt, mode, 'success', `origin/${worktreeBase}${out.trim() ? ` · ${out.trim()}` : ''}`) + flashBadge(out.trim() || taskT('upToDate'), 'tasks-badge--ok', 3000) + void load(ctx) + } catch (e) { + recordOperation(ctx, wt, mode, 'error', String(e)) + flashBadge(taskT('syncError'), 'tasks-badge--error', 4000) + selectRow(ctx, row) + showSyncError(ctx, mode, String(e), wt) + } + } + + const openInJira = (): void => { + if (!ctx.jiraCfg || !issue) return + openUrl(browseUrl(ctx.jiraCfg.site, issue.key)).catch(() => {}) + } + + const changeJiraStatus = async (): Promise => { + if (!ctx.jiraCfg || !issue) return + const transitions = await fetchTransitions(issue.key, ctx.jiraCfg) + if (transitions.length === 0) return + const r = menuBtn.getBoundingClientRect() + showContextMenu(r.right - 4, r.bottom, transitions.map(t => ({ + label: t.name, + onClick: async () => { + await applyTransition(issue.key, t.id, ctx.jiraCfg!).catch(() => {}) + const updated = await fetchIssue(issue.key, ctx.jiraCfg!) + ctx.issueMap.set(wt.path, updated) + applyFilter(ctx) + }, + }))) + } + + const copyBranch = (): void => { navigator.clipboard.writeText(wt.branch ?? '').catch(() => {}) } + + const pushBranch = async (): Promise => { + if (upstream?.state === 'behind') { + const fetch = await askConfirm( + taskT('remoteAheadQuestion', { count: upstream.behind }), + { title: taskT('remoteAheadTitle'), kind: 'warning' }, + ) + if (fetch) runSync('fetch') + return + } + if (upstream?.state === 'diverged') { + const force = await askConfirm( + taskT('divergedQuestion', { upstream: upstream.upstream ?? 'origin', local: upstream.ahead, remote: upstream.behind }), + { title: taskT('rewrittenHistoryTitle'), kind: 'warning' }, + ) + if (!force) return + flashBadge(taskT('pushingLease'), '', 60000) + try { + await invoke('git_push', { path: wt.path, forceWithLease: true }) + recordOperation(ctx, wt, 'push --force-with-lease', 'success', upstream.upstream ?? 'origin') + flashBadge(taskT('leaseOk'), 'tasks-badge--ok', 3500) + void load(ctx) + } catch (e) { + recordOperation(ctx, wt, 'push --force-with-lease', 'error', String(e)) + flashBadge(taskT('pushRejected'), 'tasks-badge--error', 4000) + selectRow(ctx, row); showSyncError(ctx, 'push --force-with-lease', String(e), wt) + } + return + } + flashBadge(taskT('pushing'), '', 60000) + try { + await invoke('git_push', { path: wt.path }) + recordOperation(ctx, wt, 'push', 'success', upstream?.upstream ?? 'origin') + flashBadge(taskT('pushOk'), 'tasks-badge--ok', 3000) + void load(ctx) + } catch (e) { + const message = String(e) + if (/non-fast-forward|rejected|fetch first/i.test(message)) { + const force = await askConfirm( + taskT('safePushQuestion'), + { title: taskT('safePushTitle'), kind: 'warning' }, + ) + if (force) { + try { + await invoke('git_push', { path: wt.path, forceWithLease: true }) + recordOperation(ctx, wt, 'push --force-with-lease', 'success', upstream?.upstream ?? 'origin') + flashBadge(taskT('leaseOk'), 'tasks-badge--ok', 3500) + void load(ctx) + return + } catch (forceError) { + recordOperation(ctx, wt, 'push --force-with-lease', 'error', String(forceError)) + flashBadge(taskT('pushRejected'), 'tasks-badge--error', 4000) + selectRow(ctx, row) + showSyncError(ctx, 'push --force-with-lease', String(forceError), wt) + return + } + } + } + recordOperation(ctx, wt, 'push', 'error', message) + flashBadge(taskT('pushError'), 'tasks-badge--error', 4000) + selectRow(ctx, row) + showSyncError(ctx, 'push', message, wt) + } + } + + const restoreBackup = async (): Promise => { + if (!backup?.available || !backup.different) return + const ok = await askConfirm( + taskT('restoreQuestion', { short: backup.short ?? '', subject: backup.subject ?? '' }), + { title: taskT('undoRewrite'), kind: 'warning' }, + ) + if (!ok) return + try { + await invoke('git_restore_backup', { path: wt.path }) + recordOperation(ctx, wt, taskT('restoringBackup'), 'success', backup.short ?? '') + flashBadge(taskT('restoredHistory'), 'tasks-badge--ok', 3500) + await load(ctx) + void showChanges(ctx, wt) + } catch (e) { + recordOperation(ctx, wt, taskT('restoringBackup'), 'error', String(e)) + selectRow(ctx, row) + showSyncError(ctx, taskT('restoringBackup'), String(e), wt) + } + } + + const createPR = async (): Promise => { + flashBadge(taskT('creatingPr'), '', 60000) + try { + const result = await invoke('git_create_pr', { path: wt.path, base: worktreeBase }) + flashBadge(taskT('prCreated'), 'tasks-badge--ok', 3000) + if (result.startsWith('http')) openUrl(result).catch(() => {}) + void load(ctx) + } catch (e) { + flashBadge(taskT('prCreateError'), 'tasks-badge--error', 4000) + selectRow(ctx, row) + showSyncError(ctx, 'PR', String(e), wt) + } + } + + const renameTask = async (): Promise => { + const current = wt.branch ?? '' + + const newName = window.prompt(taskT('renamePrompt', { current }), current) + if (!newName || newName === current) return + try { + await invoke('git_branch_rename', { path: wt.path, newName }) + void load(ctx) + } catch (e) { + await askConfirm(String(e), { title: taskT('renameError'), kind: 'error' }) + } + } + + const ahead = ab?.ahead ?? 0 + const hasPr = !!pr && (pr.state === 'OPEN' || pr.state === 'DRAFT') + + const menuItems = () => taskRowActions({ + worktree: wt, row, isMain, baseBranch: worktreeBase, ahead, hasPr, issue: !!issue, jiraConfigured: !!ctx.jiraCfg, pr, backup, rebase, + selectRow: r => selectRow(ctx, r), showRebasePaused: (worktree, status) => showRebasePaused(ctx, worktree, status), + showChanges: worktree => void showChanges(ctx, worktree), showHistory: worktree => void showCommitLog(ctx, worktree), showGraph: worktree => void showCommitGraph(ctx, worktree), + showInteractiveRebase: worktree => void showInteractiveRebase(ctx, worktree), showTerminal: worktree => void showWorktreeTerminal(ctx, worktree), showPrDetails: (worktree, status) => showPrDetails(ctx, worktree, status), showReset: worktree => showResetView(ctx, worktree), + showBackups: worktree => void showBackupHistory(ctx, worktree), showOperations: worktree => showOperationHistory(ctx, worktree), + isolateDocker: wt => { void ctx.dockerView.isolate(wt) }, + prepareDevcontainer: wt => { if (ctx.repoPath) void prepareTaskDevcontainer(ctx, wt).then(ok => { if (!ok) showDetail(ctx, note(taskT('noDevcontainer'), 'db-detail-hint')) }) }, + runSync, copyBranch, openJira: openInJira, + changeJiraStatus, push: pushBranch, createPr: createPR, restoreBackup, rename: renameTask, + deleteTask: () => void deleteWorktree(ctx, wt), setBase: branch => { ctx.baseBranch = branch; ctx.panelStore.setBase(branch) }, reload: () => void load(ctx), + }) + + const menuBtn = iconBtn('more', taskT('actions'), () => { + // iconBtn stops propagation, so opening the row menu must explicitly + // count as user interaction. Otherwise a slow startup enrichment can + // still "restore" the saved task after an action (for example Backups) + // has navigated elsewhere and replace that newly opened detail. + selectWorktree(ctx, row, wt) + const r = menuBtn.getBoundingClientRect() + showContextMenu(r.right - 4, r.bottom, menuItems()) + }) + menuBtn.dataset.testid = 'tasks-actions' + const actions = document.createElement('div') + actions.className = 'tasks-actions' + actions.appendChild(menuBtn) + + row.addEventListener('click', async () => { + const version = selectWorktree(ctx, row, wt) + if (rebase?.active) { showRebasePaused(ctx, wt, rebase); return } + // Devcontainer tasks show their URLs (cheap read); anything else shows the diff. + const hasDevcontainerUrls = !isMain && await ctx.dockerView.showDevcontainerUrls(wt, ctx.panelStore.devcontainerDir() ?? undefined, () => isCurrentSelection(ctx, version, wt)) + if (!isCurrentSelection(ctx, version, wt)) return + if (hasDevcontainerUrls) return + void showChanges(ctx, wt) + }) + row.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault(); row.click() + }) + row.addEventListener('contextmenu', e => { + e.preventDefault() + selectWorktree(ctx, row, wt) + showContextMenu(e.clientX, e.clientY, menuItems()) + }) + // Badges wrap onto their own line under the name/path so the branch name + // always predominates and never gets crowded out. Empty ones are hidden by CSS. + const badges = document.createElement('div') + badges.className = 'tasks-row-badges' + badges.append(abEl, prEl, rebaseEl, upstreamEl, backupEl, recipeEl, badge) + left.appendChild(badges) + + // Row: status dot · name/path/badges column · always-visible actions menu. + row.append(runDot, left, actions) + return row +} + +// Single create form for the whole panel: a repo selector (only when several +// repos are open) + task name. Replaces the per-project forms. +export function buildCreateForm(ctx: TasksPanelCtx): HTMLElement { + const form = document.createElement('div') + form.className = 'tasks-create' + const repos = ctx.panelStore.repositories() + + let repoSelect: HTMLSelectElement | undefined + if (repos.length > 1) { + repoSelect = document.createElement('select') + repoSelect.className = 'tasks-create-repo' + repoSelect.title = taskT('selectRepo') + for (const repo of repos) { + repoSelect.appendChild(Object.assign(document.createElement('option'), { + value: repo, + textContent: repo.replace(/\/$/, '').split('/').pop() ?? repo, + selected: repo === selectedRepository(ctx), + })) + } + repoSelect.addEventListener('change', () => { ctx.selectedRepositoryPath = repoSelect!.value }) + } + + const input = Object.assign(document.createElement('input'), { className: 'tasks-name-input', type: 'text', placeholder: taskT('newTask') }) + const submit = (): void => { void createTask(ctx, input.value.trim(), repoSelect?.value || selectedRepository(ctx)) } + const btn = iconBtn('plus', taskT('createTask'), submit) + input.addEventListener('keydown', e => { if (e.key === 'Enter') submit() }) + + if (repoSelect) form.append(repoSelect, input, btn) + else form.append(input, btn) + return form +} + +// Rebuilds the footer create form so its repo selector reflects the current +// repo list. Called from load()/applyFilter after the repo set may change. +export function refreshCreateForm(ctx: TasksPanelCtx): void { + ctx.createFormWrap.replaceChildren(ctx.panelStore.repositories().length > 0 ? buildCreateForm(ctx) : document.createDocumentFragment()) +} diff --git a/src/panels/tasks/tasksPanelContext.ts b/src/panels/tasks/tasksPanelContext.ts new file mode 100644 index 0000000..5afa9f3 --- /dev/null +++ b/src/panels/tasks/tasksPanelContext.ts @@ -0,0 +1,221 @@ +import type { Worktree } from '../../core/git/worktree' +import type { AppSettings } from '../../ports/AppSettingsRepository' +import { TauriAppSettingsRepository } from '../../adapters/TauriAppSettingsRepository' +import { TaskPanelStore } from './TaskPanelStore' +import { createTaskDockerView } from './TaskDockerView' +import type { JiraConfig, TaskIssue } from './taskJiraClient' +import type { BackupStatus, PrStatus, RebaseStatus, UpstreamStatus } from './gitTypes' +import type { DetailLifecycle } from '../docker/containerDetail' + +// The former single closure over `createTasksPanel` split into a mutable +// context object so the view/lifecycle functions that used to be nested +// inside it can live in separate files. Every field here was a `let`/`const` +// captured by closure in the original file; passing this object by reference +// preserves the exact same sharing semantics (in particular the +// selectionVersion/detailVersion race-guard comparisons, which only work +// because every reader sees the same live object, not a copy). +export interface TasksPanelCtx { + panelId: string + panelStore: TaskPanelStore + settingsRepository: TauriAppSettingsRepository + appSettings: AppSettings + settingsReady: Promise + + worktrees: Worktree[] + repoPath: string + panelVisible: boolean + selectedRow: HTMLElement | null + selectedWorktreePath: string + selectedRepositoryPath: string + selectionVersion: number + detailVersion: number + filterText: string + repoOf: Map + baseOf: Map + collapsedRepos: Set + lastStatuses: Map + lastRunningPaths: Set + baseBranch: string + jiraCfg: JiraConfig | null + issueMap: Map + aheadBehindMap: Map + prStatusMap: Map + backupStatusMap: Map + rebaseStatusMap: Map + upstreamStatusMap: Map + diffRefreshInterval: ReturnType | null + worktreeTerminals: Map void; persist: () => void; dispose: () => void }> + + detailCleanup: () => void + detailPause: () => void + detailResume: () => void + + root: HTMLElement + repoBtn: HTMLButtonElement + removeRepoBtn: HTMLButtonElement + repoRow: HTMLElement + baseSelect: HTMLSelectElement + baseRow: HTMLElement + fetchAgeEl: HTMLElement + filterInput: HTMLInputElement + listWrap: HTMLElement + progressFooter: HTMLElement + createFormWrap: HTMLElement + detailPane: HTMLElement + dockerView: ReturnType + + // Assigned once the sidebar (`cs`) exists; a no-op until then. Kept as a + // mutable field (not a plain function export) because it closes over `cs`, + // which is built in TasksPanelRuntime.ts's DOM scaffold. + refreshMiniItems: () => void + // Repaints repoBtn's icon+label from ctx.repoPath. Same deferred-assignment + // reason as refreshMiniItems: it closes over the repoBtn element built in + // TasksPanelRuntime.ts's DOM scaffold. + updateRepoBtn: () => void +} + +export function createTasksPanelCtx(panelId: string): TasksPanelCtx { + const panelStore = new TaskPanelStore(panelId) + const settingsRepository = new TauriAppSettingsRepository() + const ctx: TasksPanelCtx = { + panelId, + panelStore, + settingsRepository, + appSettings: {}, + settingsReady: Promise.resolve(), + + worktrees: [], + repoPath: panelStore.repository(), + panelVisible: true, + selectedRow: null, + selectedWorktreePath: panelStore.selected() ?? '', + selectedRepositoryPath: '', + selectionVersion: 0, + detailVersion: 0, + filterText: '', + repoOf: new Map(), + baseOf: new Map(), + collapsedRepos: new Set(), + lastStatuses: new Map(), + lastRunningPaths: new Set(), + baseBranch: panelStore.base(), + jiraCfg: null, + issueMap: new Map(), + aheadBehindMap: new Map(), + prStatusMap: new Map(), + backupStatusMap: new Map(), + rebaseStatusMap: new Map(), + upstreamStatusMap: new Map(), + diffRefreshInterval: null, + worktreeTerminals: new Map(), + + detailCleanup: () => {}, + detailPause: () => {}, + detailResume: () => {}, + + // Placeholders — TasksPanelRuntime.ts overwrites these once the real DOM + // scaffold exists. Typed as non-null here to avoid `| undefined` noise + // through every view function's signature. + root: document.createElement('div'), + repoBtn: document.createElement('button'), + removeRepoBtn: document.createElement('button'), + repoRow: document.createElement('div'), + baseSelect: document.createElement('select'), + baseRow: document.createElement('div'), + fetchAgeEl: document.createElement('span'), + filterInput: document.createElement('input'), + listWrap: document.createElement('div'), + progressFooter: document.createElement('div'), + createFormWrap: document.createElement('div'), + detailPane: document.createElement('div'), + dockerView: null as unknown as ReturnType, + + refreshMiniItems: () => {}, + updateRepoBtn: () => {}, + } + ctx.selectedRepositoryPath = ctx.repoPath + ctx.settingsReady = settingsRepository.load().then(settings => { ctx.appSettings = settings }).catch(() => {}) + return ctx +} + +export function setDetailLifecycle(ctx: TasksPanelCtx, lifecycle: DetailLifecycle): void { + ctx.detailCleanup = lifecycle.dispose + ctx.detailPause = lifecycle.pause + ctx.detailResume = lifecycle.resume + if (!ctx.panelVisible) ctx.detailPause() +} + +export function disposeDetail(ctx: TasksPanelCtx): void { + // Invalidate async work started by the outgoing detail. Stopping an + // interval is not enough when one of its refresh requests is already in + // flight: without a new generation it could finish later and replace the + // newly opened commit/rebase/conflict UI. + ctx.detailVersion += 1 + const cleanup = ctx.detailCleanup + ctx.detailCleanup = () => {} + ctx.detailPause = () => {} + ctx.detailResume = () => {} + cleanup() +} + +export function stopDiffRefresh(ctx: TasksPanelCtx): void { + if (ctx.diffRefreshInterval !== null) { clearInterval(ctx.diffRefreshInterval); ctx.diffRefreshInterval = null } +} + +export function repositoryFor(ctx: TasksPanelCtx, wt: Worktree): string { + return ctx.repoOf.get(wt.path) ?? ctx.repoPath +} + +export function baseFor(ctx: TasksPanelCtx, wt: Worktree): string { + return ctx.baseOf.get(wt.path) ?? ctx.baseBranch +} + +export function recordOperation(ctx: TasksPanelCtx, wt: Worktree, operation: string, status: 'success' | 'error', detail: string): void { + ctx.panelStore.recordOperation(repositoryFor(ctx, wt), wt.branch ?? '(detached)', operation, status, detail) +} + +export function selectRow(ctx: TasksPanelCtx, row: HTMLElement): void { + ctx.selectedRow?.classList.remove('tasks-row--selected') + ctx.selectedRow = row + row.classList.add('tasks-row--selected') +} + +export function selectWorktree(ctx: TasksPanelCtx, row: HTMLElement, wt: Worktree): number { + selectRow(ctx, row) + ctx.selectedWorktreePath = wt.path + ctx.selectedRepositoryPath = repositoryFor(ctx, wt) + ctx.panelStore.setSelected(wt.path) + ctx.selectionVersion += 1 + ctx.detailVersion += 1 + ctx.refreshMiniItems() + return ctx.selectionVersion +} + +export function isCurrentSelection(ctx: TasksPanelCtx, version: number, wt: Worktree): boolean { + const isSameSelectionVersion = version === ctx.selectionVersion + return isSameSelectionVersion && ctx.selectedWorktreePath === wt.path +} + +export function selectedRepository(ctx: TasksPanelCtx): string { + return ctx.selectedRepositoryPath || ctx.repoPath +} + +export function defaultProjectKey(repository: string): string { + return repository.replace(/\/$/, '').split('/').pop() ?? '' +} + +export function projectKey(ctx: TasksPanelCtx, repository = ctx.repoPath): string { + return ctx.panelStore.projectKey() || defaultProjectKey(repository) +} + +export async function prepareTaskDevcontainer(ctx: TasksPanelCtx, worktree: Worktree): Promise { + await ctx.settingsReady + ctx.appSettings = await ctx.settingsRepository.load().catch(() => ctx.appSettings) + return ctx.dockerView.prepareDevcontainer( + worktree, + ctx.appSettings.devcontainerRecipesDir, + projectKey(ctx, repositoryFor(ctx, worktree)), + ctx.panelStore.devcontainerDir() ?? undefined, + path => ctx.panelStore.setDevcontainerDir(path), + ) +} diff --git a/src/panels/tasks/tasksPanelHelpers.ts b/src/panels/tasks/tasksPanelHelpers.ts new file mode 100644 index 0000000..7e23639 --- /dev/null +++ b/src/panels/tasks/tasksPanelHelpers.ts @@ -0,0 +1,31 @@ +import { icon } from '../../ui/icons' +import { taskT } from './i18n' +import type { TasksPanelCtx } from './tasksPanelContext' + +export function note(text: string, cls = 'tasks-note'): HTMLElement { + return Object.assign(document.createElement('div'), { className: cls, textContent: text }) +} + +export function iconBtn(name: string, title: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement('button') + b.className = 'docker-action' + b.title = title + b.innerHTML = icon(name) + b.addEventListener('click', e => { e.stopPropagation(); onClick() }) + return b +} + +export function buildSubHead(title: string, goBack: () => void, ...extra: HTMLElement[]): HTMLElement { + const head = document.createElement('div') + head.className = 'tasks-sub-head' + head.append( + iconBtn('arrow-left', taskT('back'), goBack), + Object.assign(document.createElement('span'), { className: 'tasks-sub-title', textContent: title }), + ...extra, + ) + return head +} + +export function showDetail(ctx: TasksPanelCtx, ...nodes: HTMLElement[]): void { + ctx.detailPane.replaceChildren(...nodes) +} diff --git a/src/panels/tasks/tasksRebaseView.ts b/src/panels/tasks/tasksRebaseView.ts new file mode 100644 index 0000000..235a5b5 --- /dev/null +++ b/src/panels/tasks/tasksRebaseView.ts @@ -0,0 +1,546 @@ +import { invoke } from '@tauri-apps/api/core' +import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' +import type { Worktree } from '../../core/git/worktree' +import { previewRebase, reorderByDrop, swapItems, type RebaseAction, type RebasePlanItem } from '../../core/git/rebaseWorkflow' +import type { CommitEntry, RebaseStatus, RewritePreflight } from './gitTypes' +import { taskT } from './i18n' +import { buildConflictResolverView } from './ConflictResolverView' +import { buildRebasePlanPreview } from './RebasePlanView' +import { buildCommitFileList } from './TaskCodeView' +import { taskGit } from './taskGitClient' +import { buildRebaseMergeWarning } from './RebaseMergeWarningView' +import type { TasksPanelCtx } from './tasksPanelContext' +import { baseFor, disposeDetail, recordOperation, setDetailLifecycle, stopDiffRefresh } from './tasksPanelContext' +import { buildSubHead, iconBtn, showDetail } from './tasksPanelHelpers' +import { buildDiffView, showChanges } from './tasksDetailViews' +import { load } from './tasksLifecycle' +import { note } from './tasksPanelHelpers' + +// ---- detail: interactive rebase ---- +export async function showInteractiveRebase(ctx: TasksPanelCtx, wt: Worktree): Promise { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, note(taskT('loading'), 'db-detail-loading')) + try { + const st = await invoke('git_rebase_status', { path: wt.path }) + if (st.active) { showRebasePaused(ctx, wt, st); return } + + const worktreeBase = baseFor(ctx, wt) + const [entries, merges] = await Promise.all([ + taskGit.rebaseLog(wt.path, worktreeBase), + taskGit.mergeLog(wt.path, worktreeBase).catch(() => []), + ]) + if (entries.length === 0) { + showDetail(ctx, note(taskT('noOwnCommits', { base: worktreeBase }), 'db-detail-hint')) + return + } + if (merges.length) showMergeRebaseWarning(ctx, wt, entries, merges) + else showRebaseEditor(ctx, wt, entries) + } catch (e) { showDetail(ctx, note(String(e), 'db-detail-error')) } +} + +export function showMergeRebaseWarning(ctx: TasksPanelCtx, wt: Worktree, entries: CommitEntry[], merges: CommitEntry[]): void { + buildRebaseMergeWarning({ + worktree: wt, + baseBranch: baseFor(ctx, wt), + entries, + merges, + buildSubHead, + onBack: () => void showChanges(ctx, wt), + showDetail: (...nodes) => showDetail(ctx, ...nodes), + showRebaseEditor: (worktree, items) => showRebaseEditor(ctx, worktree, items), + showRebasePaused: (worktree, status) => showRebasePaused(ctx, worktree, status), + recordOperation: (operation, status, detail) => recordOperation(ctx, wt, operation, status, detail), + onComplete: () => { void showChanges(ctx, wt); void load(ctx) }, + }) +} + +export function showRebaseEditor(ctx: TasksPanelCtx, wt: Worktree, entries: CommitEntry[]): void { + type RebaseItem = RebasePlanItem & { action: RebaseAction; newMessage: string } + let items: RebaseItem[] = entries.map(e => ({ action: 'pick', hash: e.hash, short: e.short, subject: e.subject, newMessage: '' })) + const ACTIONS: RebaseAction[] = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop'] + let draggedIndex: number | null = null + let dragTarget: { index: number; after: boolean } | null = null + + const wrap = document.createElement('div') + wrap.className = 'tasks-rebase-wrap' + wrap.append(buildSubHead(taskT('interactiveTitle', { branch: wt.branch ?? '', base: baseFor(ctx, wt) }), () => void showChanges(ctx, wt))) + + const hint = Object.assign(document.createElement('p'), { + className: 'tasks-rebase-hint', + textContent: taskT('rebaseOrderHint'), + }) + wrap.appendChild(hint) + + const previewEl = document.createElement('div') + previewEl.className = 'tasks-rebase-preview hidden' + const renderPreview = (): void => { + const content = buildRebasePlanPreview(items) + previewEl.replaceChildren(...content.childNodes) + } + wrap.appendChild(previewEl) + + const list = document.createElement('div') + list.className = 'tasks-rebase-list' + + const renderList = (): void => { + list.replaceChildren() + items.forEach((item, idx) => { + const row = document.createElement('div') + row.className = `tasks-rebase-item${item.action === 'drop' ? ' tasks-rebase-drop' : ''}` + row.dataset.testid = 'tasks-rebase-item' + row.dataset.hash = item.hash + row.tabIndex = 0 + row.setAttribute('role', 'listitem') + row.setAttribute('aria-label', taskT('rebaseItemAria', { + action: item.action, + hash: item.short, + subject: item.subject, + })) + + const dragHandle = Object.assign(document.createElement('button'), { + className: 'tasks-rebase-drag', + textContent: '⠿', + title: taskT('dragCommit'), + }) + dragHandle.setAttribute('aria-label', taskT('moveCommit', { commit: item.short })) + + const clearDragStyles = (): void => { + list.querySelectorAll('.tasks-rebase-item').forEach(el => { + el.classList.remove('tasks-rebase-dragging', 'tasks-rebase-drag-before', 'tasks-rebase-drag-after') + }) + } + + dragHandle.addEventListener('pointerdown', e => { + if (e.button !== 0) return + e.preventDefault() + draggedIndex = idx + dragTarget = null + dragHandle.setPointerCapture(e.pointerId) + row.classList.add('tasks-rebase-dragging') + }) + dragHandle.addEventListener('pointermove', e => { + if (draggedIndex === null || !dragHandle.hasPointerCapture(e.pointerId)) return + e.preventDefault() + const targetEl = document.elementFromPoint(e.clientX, e.clientY)?.closest('.tasks-rebase-item') as HTMLElement | null + if (!targetEl) return + const rows = [...list.querySelectorAll('.tasks-rebase-item')] + const targetIndex = rows.indexOf(targetEl) + clearDragStyles() + row.classList.add('tasks-rebase-dragging') + if (targetIndex < 0 || targetIndex === draggedIndex) { + dragTarget = null + return + } + const rect = targetEl.getBoundingClientRect() + const after = e.clientY > rect.top + rect.height / 2 + dragTarget = { index: targetIndex, after } + targetEl.classList.add(after ? 'tasks-rebase-drag-after' : 'tasks-rebase-drag-before') + }) + const finishPointerDrag = (e: PointerEvent): void => { + if (draggedIndex === null) return + e.preventDefault() + const from = draggedIndex + draggedIndex = null + clearDragStyles() + if (!dragTarget) return + const { index, after } = dragTarget + dragTarget = null + items = reorderByDrop(items, from, index, after) + renderList() + } + dragHandle.addEventListener('pointerup', finishPointerDrag) + dragHandle.addEventListener('pointercancel', finishPointerDrag) + + const select = document.createElement('select') + select.className = 'tasks-rebase-action' + ACTIONS.forEach(a => { + const opt = Object.assign(document.createElement('option'), { value: a, textContent: a }) + opt.selected = a === item.action + select.appendChild(opt) + }) + select.addEventListener('change', () => { + item.action = select.value as RebaseAction + row.className = `tasks-rebase-item${item.action === 'drop' ? ' tasks-rebase-drop' : ''}` + renderList() // re-render to show/hide reword input + }) + + const hashEl = Object.assign(document.createElement('span'), { className: 'tasks-log-short', textContent: item.short }) + + // For reword: show editable input; otherwise show static subject + const contentEl = document.createElement('div') + contentEl.className = 'tasks-rebase-content' + if (item.action === 'reword') { + const msgIn = Object.assign(document.createElement('input'), { + className: 'tasks-rebase-reword-input', + type: 'text', + value: item.newMessage || item.subject, + placeholder: taskT('newCommitTitle'), + }) + msgIn.addEventListener('input', () => { item.newMessage = msgIn.value }) + msgIn.addEventListener('click', e => e.stopPropagation()) + contentEl.appendChild(msgIn) + } else { + contentEl.appendChild(Object.assign(document.createElement('span'), { + className: 'tasks-rebase-subject', + textContent: item.subject, + })) + } + + const upBtn = iconBtn('chevron-up', taskT('moveUp'), () => { + if (idx === 0) return + items = swapItems(items, idx - 1, idx) + renderList() + }) + const downBtn = iconBtn('chevron-down', taskT('moveDown'), () => { + if (idx === items.length - 1) return + items = swapItems(items, idx + 1, idx) + renderList() + }) + upBtn.disabled = idx === 0 + downBtn.disabled = idx === items.length - 1 + + row.addEventListener('keydown', e => { + if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return + e.preventDefault() + const target = e.key === 'ArrowUp' ? idx - 1 : idx + 1 + if (target < 0 || target >= items.length) return + items = swapItems(items, target, idx) + renderList() + list.querySelectorAll('.tasks-rebase-item')[target]?.focus() + }) + + const moveEl = document.createElement('div') + moveEl.className = 'tasks-rebase-move' + moveEl.append(upBtn, downBtn) + + // Expand/collapse files changed in this commit + let filesLoaded = false + const filesEl = document.createElement('div') + filesEl.className = 'tasks-commit-files hidden' + const expandBtn = iconBtn('chevron-down', taskT('viewCommitFiles'), async () => { + const isOpen = !filesEl.classList.contains('hidden') + if (isOpen) { filesEl.classList.add('hidden'); expandBtn.title = taskT('viewCommitFiles'); return } + filesEl.classList.remove('hidden'); expandBtn.title = taskT('hideFiles') + if (filesLoaded) return + filesLoaded = true + filesEl.textContent = taskT('loading') + const files = await taskGit.files(wt.path, item.hash).catch(() => []) + filesEl.replaceChildren(...buildCommitFileList( + files, + file => invoke('git_show_commit_diff', { path: wt.path, hash: item.hash, file }), + file => invoke('git_show_file', { path: wt.path, hash: item.hash, file }), + )) + }) + expandBtn.className = 'tasks-expand-btn' + + row.append(dragHandle, select, hashEl, contentEl, moveEl, expandBtn) + row.appendChild(filesEl) + list.appendChild(row) + }) + } + renderList() + wrap.appendChild(list) + + const footer = document.createElement('div') + footer.className = 'tasks-rebase-footer' + const statusEl = Object.assign(document.createElement('span'), { className: 'tasks-rebase-status-msg' }) + const previewBtn = Object.assign(document.createElement('button'), { className: 'tasks-amend-btn', textContent: taskT('simulate') }) + previewBtn.dataset.testid = 'tasks-rebase-preview' + previewBtn.addEventListener('click', () => { + renderPreview() + previewEl.classList.toggle('hidden') + previewBtn.textContent = previewEl.classList.contains('hidden') ? taskT('simulate') : taskT('hideSimulation') + }) + const startBtn = Object.assign(document.createElement('button'), { + className: 'tasks-commit-btn', + textContent: taskT('startRebase'), + }) + startBtn.dataset.testid = 'tasks-rebase-start' + startBtn.addEventListener('click', async () => { + const preview = previewRebase(items) + if (preview.warnings.length) { + statusEl.textContent = preview.warnings.join(' ') + previewEl.classList.remove('hidden'); renderPreview() + return + } + let preflight: RewritePreflight + try { + preflight = await invoke('git_rewrite_preflight', { path: wt.path, base: baseFor(ctx, wt) }) + } catch (e) { + statusEl.textContent = taskT('validationError', { error: String(e).slice(0, 100) }) + return + } + if (preflight.operation) { + statusEl.textContent = taskT('operationInProgress', { operation: preflight.operation }) + return + } + const risks = [ + preflight.dirty ? taskT('dirtyRisk') : '', + preflight.publishedCommits ? taskT('publishedRisk', { count: preflight.publishedCommits }) : '', + preflight.protectedBase ? taskT('protectedRisk', { branch: preflight.branch }) : '', + preflight.hooks.length ? taskT('hooksRisk', { hooks: preflight.hooks.join(', ') }) : '', + preflight.signing ? taskT('signingRisk') : '', + ].filter(Boolean) + const confirmed = await askConfirm( + taskT('rebaseQuestion', { result: preview.resultingCommits, combined: preview.combinedCommits, dropped: preview.droppedCommits, risks: risks.length ? `\n\n${risks.join('\n')}` : '' }), + { title: taskT('confirmRebase'), kind: risks.length ? 'warning' : 'info' }, + ) + if (!confirmed) return + startBtn.disabled = true + statusEl.textContent = taskT('running') + // reword → convert to edit in the git todo; the new message is applied in the paused UI + const rewordMessages = new Map(items.filter(i => i.action === 'reword').map(i => [i.hash, i.newMessage || i.subject])) + const todoLines = items.map(i => `${i.action === 'reword' ? 'edit' : i.action} ${i.hash} ${i.subject}`) + try { + await invoke('git_rebase_start', { path: wt.path, base: baseFor(ctx, wt), todoLines }) + recordOperation(ctx, wt, 'rebase interactivo', 'success', `${items.length} instrucciones sobre origin/${baseFor(ctx, wt)}`) + const st = await invoke('git_rebase_status', { path: wt.path }) + if (st.active) { + // If this commit was a reword, pre-fill the message with the new title + const preMsg = rewordMessages.get(st.sha ?? '') ?? st.subject ?? '' + showRebasePaused(ctx, wt, { ...st, subject: preMsg }) + return + } + statusEl.textContent = taskT('rebaseComplete') + setTimeout(() => { void showChanges(ctx, wt); void load(ctx) }, 1200) + } catch (e) { + recordOperation(ctx, wt, 'rebase interactivo', 'error', String(e)) + statusEl.textContent = String(e).slice(0, 120) + startBtn.disabled = false + } + }) + footer.append(statusEl, previewBtn, startBtn) + wrap.appendChild(footer) + showDetail(ctx, wrap) +} + +// ---- Inline conflict resolver ---- +export function showConflictResolver(ctx: TasksPanelCtx, wt: Worktree, file: string, onBack: () => void): void { + stopDiffRefresh(ctx) + disposeDetail(ctx) + showDetail(ctx, buildConflictResolverView({ path: wt.path, file, onBack })) +} + +export function showRebasePaused(ctx: TasksPanelCtx, wt: Worktree, st: RebaseStatus): void { + disposeDetail(ctx) + const wrap = document.createElement('div') + wrap.className = 'tasks-rebase-paused' + + wrap.append(buildSubHead(taskT('pausedTitle', { branch: wt.branch ?? '' }), () => void showChanges(ctx, wt))) + + const infoEl = document.createElement('div') + infoEl.className = 'tasks-rebase-paused-info' + infoEl.append( + Object.assign(document.createElement('span'), { + className: 'tasks-rebase-paused-label', + textContent: st.total + ? taskT('rebaseProgressColon', { current: st.current ?? 0, total: st.total }) + : taskT('editing'), + }), + Object.assign(document.createElement('span'), { className: 'tasks-log-short', textContent: st.short ?? '' }), + Object.assign(document.createElement('span'), { className: 'tasks-rebase-subject', textContent: st.subject ?? '' }), + ) + wrap.appendChild(infoEl) + + const actionsEl = document.createElement('div') + actionsEl.className = 'tasks-rebase-paused-actions' + const statusEl = Object.assign(document.createElement('span'), { className: 'tasks-rebase-status-msg' }) + + const abortBtn = Object.assign(document.createElement('button'), { className: 'tasks-amend-btn', textContent: taskT('abortRebase') }) + const editBtn = Object.assign(document.createElement('button'), { + className: 'tasks-amend-btn', + textContent: taskT('editCommit'), + title: taskT('editHint'), + }) + const splitBtn = Object.assign(document.createElement('button'), { + className: 'tasks-amend-btn', + textContent: taskT('splitCommit'), + title: taskT('splitHint'), + }) + const continueBtn = Object.assign(document.createElement('button'), { className: 'tasks-commit-btn', textContent: taskT('continueRebase') }) + + let intervalId = 0 + const stopPolling = (): void => { + clearInterval(intervalId) + intervalId = 0 + } + let resumePolling: () => void + + editBtn.addEventListener('click', () => void showChanges(ctx, wt)) + splitBtn.addEventListener('click', async () => { + const ok = await askConfirm( + taskT('splitQuestion'), + { title: taskT('splitTitle'), kind: 'warning' }, + ) + if (!ok) return + splitBtn.disabled = true + try { + await invoke('git_rebase_split', { path: wt.path }) + recordOperation(ctx, wt, 'dividir commit', 'success', st.short ?? st.subject ?? '') + void showChanges(ctx, wt) + } catch (e) { + recordOperation(ctx, wt, 'dividir commit', 'error', String(e)) + statusEl.textContent = String(e).slice(0, 140) + splitBtn.disabled = false + } + }) + + continueBtn.addEventListener('click', async () => { + continueBtn.disabled = true; abortBtn.disabled = true + statusEl.textContent = taskT('continuing') + clearInterval(intervalId) + try { + const result = await invoke('git_rebase_continue', { path: wt.path }) + if (result === 'paused') { + showRebasePaused(ctx, wt, await invoke('git_rebase_status', { path: wt.path })) + } else { + statusEl.textContent = taskT('rebaseComplete') + setTimeout(() => { void showChanges(ctx, wt); void load(ctx) }, 1200) + } + } catch (e) { + statusEl.textContent = String(e).slice(0, 120) + continueBtn.disabled = false; abortBtn.disabled = false + } + }) + + abortBtn.addEventListener('click', async () => { + const ok = await askConfirm(taskT('abortQuestion'), { title: taskT('abortRebase'), kind: 'warning' }) + if (!ok) return + await invoke('git_rebase_abort', { path: wt.path }).catch(() => {}) + clearInterval(intervalId) + void showChanges(ctx, wt); void load(ctx) + }) + + const conflicts = st.conflicts ?? [] + editBtn.disabled = conflicts.length > 0 + splitBtn.disabled = conflicts.length > 0 + + if (conflicts.length > 0) { + // ---- Conflict resolution mode ---- + const warningEl = Object.assign(document.createElement('p'), { + className: 'tasks-rebase-hint tasks-conflict-warning', + textContent: taskT('conflictWarning', { count: conflicts.length }), + }) + wrap.appendChild(warningEl) + + const conflictList = document.createElement('div') + conflictList.className = 'tasks-conflict-list' + + const resolved = new Set() + + const renderConflicts = (currentConflicts: string[]): void => { + conflictList.replaceChildren() + currentConflicts.forEach(file => { + const isResolved = resolved.has(file) + const row = document.createElement('div') + row.className = `tasks-conflict-row${isResolved ? ' tasks-conflict-resolved' : ''}` + + const fileEl = Object.assign(document.createElement('span'), { + className: 'tasks-conflict-file', + textContent: file, + title: file, + }) + + const btns = document.createElement('div') + btns.className = 'tasks-conflict-btns' + + if (!isResolved) { + const resolveBtn = Object.assign(document.createElement('button'), { className: 'tasks-conflict-btn tasks-conflict-btn-primary', textContent: taskT('resolveHere') }) + resolveBtn.title = taskT('openConflictResolver') + resolveBtn.addEventListener('click', () => { + clearInterval(intervalId) + showConflictResolver(ctx, wt, file, () => { + resolved.add(file) + showRebasePaused(ctx, wt, st) + }) + }) + + const oursBtn = Object.assign(document.createElement('button'), { className: 'tasks-conflict-btn', textContent: taskT('currentVersion') }) + oursBtn.title = taskT('keepOursHint') + oursBtn.addEventListener('click', async () => { + oursBtn.disabled = true + await invoke('git_resolve_conflict', { path: wt.path, file, side: 'ours' }).catch(e => { statusEl.textContent = String(e); oursBtn.disabled = false }) + resolved.add(file) + renderConflicts(currentConflicts) + }) + + const theirsBtn = Object.assign(document.createElement('button'), { className: 'tasks-conflict-btn', textContent: taskT('appliedCommit') }) + theirsBtn.title = taskT('keepTheirsHint') + theirsBtn.addEventListener('click', async () => { + theirsBtn.disabled = true + await invoke('git_resolve_conflict', { path: wt.path, file, side: 'theirs' }).catch(e => { statusEl.textContent = String(e); theirsBtn.disabled = false }) + resolved.add(file) + renderConflicts(currentConflicts) + }) + + btns.append(resolveBtn, oursBtn, theirsBtn) + } else { + btns.appendChild(Object.assign(document.createElement('span'), { className: 'tasks-conflict-done', textContent: taskT('resolved') })) + } + + row.append(fileEl, btns) + conflictList.appendChild(row) + }) + + // Auto-update Continue button: enabled when all current conflicts are resolved + const allResolved = currentConflicts.every(f => resolved.has(f)) + continueBtn.disabled = !allResolved + } + + renderConflicts(conflicts) + wrap.appendChild(conflictList) + + // Auto-refresh conflict list in case user resolves from terminal + const refreshConflicts = async (): Promise => { + const fresh = await invoke('git_rebase_status', { path: wt.path }).catch(() => null) + if (!fresh) return + if (!fresh.active) { stopPolling(); void showChanges(ctx, wt); void load(ctx); return } + const freshConflicts = fresh.conflicts ?? [] + freshConflicts.forEach(f => { if (!freshConflicts.includes(f)) resolved.delete(f) }) + if (freshConflicts.length === 0) { + stopPolling() + showRebasePaused(ctx, wt, fresh) + } else { + renderConflicts(freshConflicts) + } + } + const startPolling = (): void => { + stopPolling() + intervalId = window.setInterval(() => { void refreshConflicts() }, 4000) + } + resumePolling = () => { void refreshConflicts(); startPolling() } + startPolling() + + continueBtn.disabled = conflicts.length > 0 + + } else { + // ---- Normal edit mode (intentional `edit` step) ---- + const hintEl = Object.assign(document.createElement('p'), { + className: 'tasks-rebase-hint', + textContent: taskT('amendPausedHint'), + }) + wrap.appendChild(hintEl) + + const diffWrap = document.createElement('div') + diffWrap.className = 'tasks-rebase-diff' + const refreshDiff = (): void => { + invoke('git_diff', { path: wt.path }).then(raw => { + diffWrap.replaceChildren(buildDiffView(ctx, raw, wt, { initAmend: true, initMessage: st.subject ?? '' })) + }).catch(() => {}) + } + refreshDiff() + const startPolling = (): void => { + stopPolling() + intervalId = window.setInterval(refreshDiff, 5000) + } + resumePolling = () => { refreshDiff(); startPolling() } + startPolling() + wrap.appendChild(diffWrap) + } + + setDetailLifecycle(ctx, { pause: stopPolling, resume: resumePolling!, dispose: stopPolling }) + actionsEl.append(statusEl, abortBtn, editBtn, splitBtn, continueBtn) + wrap.appendChild(actionsEl) + showDetail(ctx, wrap) +} diff --git a/src/ui/aiChat.ts b/src/ui/aiChat.ts index e327df5..d938b20 100644 --- a/src/ui/aiChat.ts +++ b/src/ui/aiChat.ts @@ -16,7 +16,8 @@ import type { MemoryRepository } from '../ports/MemoryRepository' import { getActiveProjectPath, setActiveProjectPath } from './activeProject' import { buildMemoryContext, selectMemoryForPrompt } from '../core/memory/aiContext' import { redact, startAgent, resolvePersistedSessionId, buildReviewMessage } from '../core/ai/agentClient' -import { emptyChatHistory, GLOBAL_CHAT_CONVERSATION, parseChatHistory, serializeChatHistory } from '../core/ai/chatHistory' +import { emptyChatHistory, GLOBAL_CHAT_CONVERSATION, parseChatHistory, pinnedFollowUpHistory, serializeChatHistory } from '../core/ai/chatHistory' +import { isCapacityError } from '../core/ai/capacityError' import { getUiZoom, toLayoutPixels } from './zoom' const AI_POSITION_KEY = 'bento.ai.position.v2' @@ -36,12 +37,6 @@ async function verifyResumableSession(agent: AgentType, cwd: string, sessionId: return sessionId } -// A token/rate/usage limit means THIS agent can't continue — worth switching to a -// different agent rather than retrying the same one. -export function isCapacityError(message: string): boolean { - return /rate.?limit|too many requests|\b429\b|overloaded|\b529\b|usage limit|quota|out of tokens|token limit|context (?:length|window)|maximum context|prompt is too long|too long/i.test(message) -} - // Order to fall over through when an agent runs out of capacity (custom excluded: // it needs an explicit executable). The transcript carries the context across. const FAILOVER_AGENTS: AgentType[] = ['claude', 'codex', 'opencode'] @@ -713,13 +708,8 @@ export function createAiChat(memoryRepo: MemoryRepository): HTMLElement { // Always carry the review report as context, even in a long chat: the agent // only sees a recent window, so keep the first assistant message (the report) // pinned at the front when the conversation has grown past it. - const buildFollowUpHistory = (): ChatMessage[] => { - const full = messages.slice(0, -1) - if (!conversationContext?.branch || full.length <= 20) return full - const report = full.find(m => m.role === 'assistant') - const recent = full.slice(-19) - return report && !recent.includes(report) ? [report, ...recent] : full.slice(-20) - } + const buildFollowUpHistory = (): ChatMessage[] => + pinnedFollowUpHistory(messages.slice(0, -1), Boolean(conversationContext?.branch)) let awaitingFirstChunk = true const runAttempt = async (attemptAgent: AgentType, resumeId: string | null): Promise => { awaitingFirstChunk = true diff --git a/tests/core/ai/capacityError.test.ts b/tests/core/ai/capacityError.test.ts new file mode 100644 index 0000000..c9cd1f4 --- /dev/null +++ b/tests/core/ai/capacityError.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isCapacityError } from '../../../src/core/ai/capacityError' + +describe('isCapacityError', () => { + it('flags token / rate / usage limits so we can switch agents', () => { + for (const message of ['rate limit exceeded', 'usage limit reached', '429 Too Many Requests', 'model overloaded', 'quota exceeded', 'prompt is too long', 'maximum context length exceeded']) { + expect(isCapacityError(message)).toBe(true) + } + }) + it('does not flag unrelated failures', () => { + for (const message of ['agent timeout', 'executable not found', 'No conversation found', '']) { + expect(isCapacityError(message)).toBe(false) + } + }) +}) diff --git a/tests/core/ai/chatHistory.test.ts b/tests/core/ai/chatHistory.test.ts index 9638e4c..465f1dc 100644 --- a/tests/core/ai/chatHistory.test.ts +++ b/tests/core/ai/chatHistory.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { GLOBAL_CHAT_CONVERSATION, parseChatHistory, serializeChatHistory, techReviewConversationKey } from '../../../src/core/ai/chatHistory' +import { GLOBAL_CHAT_CONVERSATION, parseChatHistory, pinnedFollowUpHistory, serializeChatHistory, techReviewConversationKey } from '../../../src/core/ai/chatHistory' +import type { ChatMessage } from '../../../src/core/ai/config' describe('chat history', () => { it('migrates the legacy global message array', () => { @@ -51,3 +52,32 @@ describe('chat history', () => { expect(techReviewConversationKey('D:\\work\\repo\\', 'feat/a')).toBe('tech-review:D:/work/repo:feat/a') }) }) + +describe('pinnedFollowUpHistory', () => { + const msg = (n: number): ChatMessage => ({ role: n === 0 ? 'assistant' : 'user', content: `m${n}` }) + + it('returns the full history unchanged when it is short', () => { + const full = [msg(0), msg(1), msg(2)] + expect(pinnedFollowUpHistory(full, true)).toEqual(full) + }) + + it('returns the full history unchanged when there is no branch, regardless of length', () => { + const full = Array.from({ length: 30 }, (_, i) => msg(i)) + expect(pinnedFollowUpHistory(full, false)).toEqual(full) + }) + + it('pins the first assistant message (the review report) when the branch history grows past 20', () => { + const full = Array.from({ length: 30 }, (_, i) => msg(i)) + const result = pinnedFollowUpHistory(full, true) + + expect(result[0]).toEqual(msg(0)) + expect(result.slice(1)).toEqual(full.slice(-19)) + }) + + it('does not duplicate the report when it already falls within the recent window', () => { + const full = [msg(1), ...Array.from({ length: 29 }, (_, i) => msg(i + 10)), msg(0)] + const result = pinnedFollowUpHistory(full, true) + + expect(result).toEqual(full.slice(-20)) + }) +}) 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/core/db/sqlQuote.test.ts b/tests/core/db/sqlQuote.test.ts new file mode 100644 index 0000000..2d6f1b8 --- /dev/null +++ b/tests/core/db/sqlQuote.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +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' } +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/core/git/commitWorkflow.test.ts b/tests/core/git/commitWorkflow.test.ts index 67edc86..52fba62 100644 --- a/tests/core/git/commitWorkflow.test.ts +++ b/tests/core/git/commitWorkflow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildSelectedPatch, changedPaths, diffFileNames, matchingPaths, parseFilePatch } from '../../../src/core/git/commitWorkflow' +import { buildSelectedPatch, changedPaths, diffFileNames, matchingPaths, parseFilePatch, rankFixupCandidates } from '../../../src/core/git/commitWorkflow' describe('commit workflow file matching', () => { it('extracts files from a unified diff', () => { @@ -54,3 +54,62 @@ describe('partial patches', () => { expect(result).toContain('diff --git a/src/a.ts') }) }) + +describe('rankFixupCandidates', () => { + const candidate = (id: string, over: Partial<{ overlap: number; blame: number; history: number }> = {}) => ({ + id, + overlap: Array.from({ length: over.overlap ?? 0 }, (_, i) => `f${i}.ts`), + blame: { score: over.blame ?? 0, files: [] as string[] }, + history: { score: over.history ?? 0, files: [] as string[] }, + }) + + const order = (rows: Array>): string[] => + rankFixupCandidates(rows).map(r => r.id) + + it('puts the commit touching the most of the incoming files first', () => { + expect(order([candidate('a', { overlap: 1 }), candidate('b', { overlap: 3 })])).toEqual(['b', 'a']) + }) + + it('outranks any blame or history score by a single overlapping file', () => { + expect(order([ + candidate('scores', { blame: 99, history: 99 }), + candidate('overlap', { overlap: 1 }), + ])).toEqual(['overlap', 'scores']) + }) + + it('breaks an overlap tie by the blame score', () => { + expect(order([ + candidate('a', { overlap: 1, blame: 1 }), + candidate('b', { overlap: 1, blame: 5 }), + ])).toEqual(['b', 'a']) + }) + + it('outranks history by blame', () => { + expect(order([ + candidate('history', { history: 99 }), + candidate('blame', { blame: 1 }), + ])).toEqual(['blame', 'history']) + }) + + it('falls back to the history score when overlap and blame tie', () => { + expect(order([ + candidate('a', { history: 2 }), + candidate('b', { history: 7 }), + ])).toEqual(['b', 'a']) + }) + + it('keeps the original order for candidates that score the same', () => { + expect(order([candidate('first'), candidate('second'), candidate('third')])) + .toEqual(['first', 'second', 'third']) + }) + + it('does not reorder the array it was given', () => { + const rows = [candidate('a'), candidate('b', { overlap: 2 })] + rankFixupCandidates(rows) + expect(rows.map(r => r.id)).toEqual(['a', 'b']) + }) + + it('ranks nothing into nothing', () => { + expect(rankFixupCandidates([])).toEqual([]) + }) +}) diff --git a/tests/core/git/prChecks.test.ts b/tests/core/git/prChecks.test.ts new file mode 100644 index 0000000..ebea490 --- /dev/null +++ b/tests/core/git/prChecks.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrCheck, summarisePrChecks } from '../../../src/core/git/prChecks' +import type { PrCheck } from '../../../src/panels/tasks/gitTypes' + +const check = (over: Partial = {}): PrCheck => + ({ name: 'build', context: null, conclusion: null, state: null, status: null, ...over } as PrCheck) + +describe('classifyPrCheck', () => { + it('reads a finished check run from its conclusion', () => { + expect(classifyPrCheck(check({ conclusion: 'FAILURE', status: 'COMPLETED' }))).toBe('failed') + expect(classifyPrCheck(check({ conclusion: 'SUCCESS', status: 'COMPLETED' }))).toBe('passed') + }) + + it('treats errors, cancellations and timeouts as failures', () => { + ;['ERROR', 'CANCELLED', 'TIMED_OUT', 'STARTUP_FAILURE'].forEach(conclusion => { + expect(classifyPrCheck(check({ conclusion }))).toBe('failed') + }) + }) + + it('reads a running check run from its status', () => { + expect(classifyPrCheck(check({ status: 'IN_PROGRESS' }))).toBe('pending') + expect(classifyPrCheck(check({ status: 'QUEUED' }))).toBe('pending') + }) + + it('reads a commit status from its state', () => { + expect(classifyPrCheck(check({ state: 'PENDING' }))).toBe('pending') + expect(classifyPrCheck(check({ state: 'FAILURE' }))).toBe('failed') + expect(classifyPrCheck(check({ state: 'SUCCESS' }))).toBe('passed') + }) + + it('prefers the conclusion over the state and status', () => { + expect(classifyPrCheck(check({ conclusion: 'FAILURE', state: 'SUCCESS', status: 'COMPLETED' }))).toBe('failed') + }) + + it('is case-insensitive', () => { + expect(classifyPrCheck(check({ conclusion: 'failure' }))).toBe('failed') + expect(classifyPrCheck(check({ status: 'in_progress' }))).toBe('pending') + }) + + it('counts a check it cannot read as passed, not as a failure', () => { + expect(classifyPrCheck(check())).toBe('passed') + expect(classifyPrCheck(check({ conclusion: 'NEUTRAL' }))).toBe('passed') + }) +}) + +describe('summarisePrChecks', () => { + it('reports nothing for a PR with no checks', () => { + expect(summarisePrChecks([])).toEqual({ failed: 0, pending: 0, total: 0 }) + }) + + it('counts failures, pending and the total', () => { + const summary = summarisePrChecks([ + check({ conclusion: 'FAILURE' }), + check({ status: 'IN_PROGRESS' }), + check({ conclusion: 'SUCCESS' }), + check({ conclusion: 'TIMED_OUT' }), + ]) + expect(summary).toEqual({ failed: 2, pending: 1, total: 4 }) + }) + + it('never counts a check as both failed and pending', () => { + const summary = summarisePrChecks([check({ conclusion: 'FAILURE', status: 'IN_PROGRESS' })]) + expect(summary.failed + summary.pending).toBe(1) + }) +}) diff --git a/tests/core/git/rebaseWorkflow.test.ts b/tests/core/git/rebaseWorkflow.test.ts index d91390d..46ae491 100644 --- a/tests/core/git/rebaseWorkflow.test.ts +++ b/tests/core/git/rebaseWorkflow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { appendOperation, mapWithConcurrency, previewRebase, type RebasePlanItem } from '../../../src/core/git/rebaseWorkflow' +import { appendOperation, mapWithConcurrency, previewRebase, type RebasePlanItem, reorderByDrop, swapItems } from '../../../src/core/git/rebaseWorkflow' const item = (short: string, action: RebasePlanItem['action'] = 'pick'): RebasePlanItem => ({ action, hash: short.padEnd(40, '0'), short, subject: `Commit ${short}`, @@ -40,3 +40,60 @@ describe('mapWithConcurrency', () => { expect(maximum).toBeLessThanOrEqual(2) }) }) + +describe('reorderByDrop', () => { + const list = (): string[] => ['a', 'b', 'c', 'd'] + + it('moves an item down, landing after the drop target', () => { + expect(reorderByDrop(list(), 0, 2, true)).toEqual(['b', 'c', 'a', 'd']) + }) + + it('moves an item down, landing before the drop target', () => { + expect(reorderByDrop(list(), 0, 2, false)).toEqual(['b', 'a', 'c', 'd']) + }) + + it('moves an item up, landing before the drop target', () => { + expect(reorderByDrop(list(), 3, 1, false)).toEqual(['a', 'd', 'b', 'c']) + }) + + it('moves an item up, landing after the drop target', () => { + expect(reorderByDrop(list(), 3, 1, true)).toEqual(['a', 'b', 'd', 'c']) + }) + + it('leaves the list alone when the item lands where it already was', () => { + expect(reorderByDrop(list(), 1, 0, true)).toEqual(list()) + expect(reorderByDrop(list(), 1, 2, false)).toEqual(list()) + }) + + it('handles the ends: first to last and last to first', () => { + expect(reorderByDrop(list(), 0, 3, true)).toEqual(['b', 'c', 'd', 'a']) + expect(reorderByDrop(list(), 3, 0, false)).toEqual(['d', 'a', 'b', 'c']) + }) + + it('does not touch the array it was given', () => { + const original = list() + reorderByDrop(original, 0, 3, true) + expect(original).toEqual(list()) + }) +}) + +describe('swapItems', () => { + it('swaps two positions', () => { + expect(swapItems(['a', 'b', 'c'], 0, 1)).toEqual(['b', 'a', 'c']) + }) + + it('is its own inverse', () => { + expect(swapItems(swapItems(['a', 'b', 'c'], 0, 2), 0, 2)).toEqual(['a', 'b', 'c']) + }) + + it('leaves the list alone for an out-of-range position', () => { + expect(swapItems(['a', 'b'], 0, 2)).toEqual(['a', 'b']) + expect(swapItems(['a', 'b'], -1, 0)).toEqual(['a', 'b']) + }) + + it('does not touch the array it was given', () => { + const original = ['a', 'b'] + swapItems(original, 0, 1) + expect(original).toEqual(['a', 'b']) + }) +}) diff --git a/tests/core/git/taskJira.test.ts b/tests/core/git/taskJira.test.ts index ff6337b..35cf334 100644 --- a/tests/core/git/taskJira.test.ts +++ b/tests/core/git/taskJira.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { extractIssueKey, statusCategoryClass, parseAheadBehind } from '../../../src/core/git/taskJira' +import { extractIssueKey, parseAheadBehind } from '../../../src/core/git/taskJira' describe('extractIssueKey', () => { it('extracts the ticket from a feat branch', () => { @@ -32,21 +32,6 @@ describe('extractIssueKey', () => { }) }) -describe('statusCategoryClass', () => { - it('maps done', () => { - expect(statusCategoryClass('done')).toBe('jira-st-done') - }) - - it('maps in-progress (indeterminate)', () => { - expect(statusCategoryClass('indeterminate')).toBe('jira-st-progress') - }) - - it('maps to-do (new) and anything else', () => { - expect(statusCategoryClass('new')).toBe('jira-st-todo') - expect(statusCategoryClass('')).toBe('jira-st-todo') - }) -}) - describe('parseAheadBehind', () => { it('parses "leftright" from rev-list --left-right --count', () => { // left = behind (commits in base not in HEAD), right = ahead diff --git a/tests/core/git/worktreeList.test.ts b/tests/core/git/worktreeList.test.ts new file mode 100644 index 0000000..997eca9 --- /dev/null +++ b/tests/core/git/worktreeList.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { filterWorktrees, groupWorktreesByRepo } from '../../../src/core/git/worktreeList' +import type { Worktree } from '../../../src/core/git/worktree' + +const wt = (over: Partial = {}): Worktree => + ({ path: '/repo/wt', branch: 'main', head: 'abc', ...over } as Worktree) + +const paths = (rows: Worktree[]): string[] => rows.map(r => r.path) + +const TREES = [ + wt({ path: '/repo/feature-login', branch: 'feat/login' }), + wt({ path: '/repo/fix-redirect', branch: 'fix/redirect' }), + wt({ path: '/other/main', branch: 'main' }), +] + +describe('filterWorktrees', () => { + it('returns everything for an empty query', () => { + expect(filterWorktrees(TREES, '')).toEqual(TREES) + expect(filterWorktrees(TREES, ' ')).toEqual(TREES) + }) + + it('matches the branch name, case-insensitively', () => { + expect(paths(filterWorktrees(TREES, 'LOGIN'))).toEqual(['/repo/feature-login']) + }) + + it('matches the path too', () => { + expect(paths(filterWorktrees(TREES, '/other'))).toEqual(['/other/main']) + }) + + it('keeps a detached worktree out of a branch search instead of throwing', () => { + const detached = [wt({ path: '/repo/detached', branch: undefined })] + expect(filterWorktrees(detached, 'main')).toEqual([]) + expect(paths(filterWorktrees(detached, 'detached'))).toEqual(['/repo/detached']) + }) + + it('can match nothing', () => { + expect(filterWorktrees(TREES, 'nothing here')).toEqual([]) + }) + + it('preserves the original order', () => { + expect(paths(filterWorktrees(TREES, 'e'))).toEqual(paths(TREES.filter(t => + t.path.includes('e') || (t.branch ?? '').includes('e')))) + }) +}) + +describe('groupWorktreesByRepo', () => { + const repoOf = new Map([ + ['/repo/feature-login', '/repo'], + ['/repo/fix-redirect', '/repo'], + ['/other/main', '/other'], + ]) + + it('buckets each worktree under its repo', () => { + const groups = groupWorktreesByRepo(TREES, repoOf, '/fallback') + expect([...groups.keys()]).toEqual(['/repo', '/other']) + expect(paths(groups.get('/repo')!)).toEqual(['/repo/feature-login', '/repo/fix-redirect']) + }) + + it('falls back for a worktree with no recorded repo', () => { + const groups = groupWorktreesByRepo([wt({ path: '/stray' })], new Map(), '/fallback') + expect([...groups.keys()]).toEqual(['/fallback']) + }) + + it('keeps repos in the order they first appear', () => { + const reversed = [TREES[2], TREES[0]] + expect([...groupWorktreesByRepo(reversed, repoOf, '/fallback').keys()]).toEqual(['/other', '/repo']) + }) + + it('keeps the worktree order inside each repo', () => { + const groups = groupWorktreesByRepo([TREES[1], TREES[0]], repoOf, '/fallback') + expect(paths(groups.get('/repo')!)).toEqual(['/repo/fix-redirect', '/repo/feature-login']) + }) + + it('groups nothing into nothing', () => { + expect(groupWorktreesByRepo([], repoOf, '/fallback').size).toBe(0) + }) +}) diff --git a/tests/core/jira/board.test.ts b/tests/core/jira/board.test.ts index bc729ed..4763278 100644 --- a/tests/core/jira/board.test.ts +++ b/tests/core/jira/board.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { boardCategory, groupByCategory, parseAgileBoards, parseAgileColumns, mapToAgileColumns } from '../../../src/core/jira/board' +import { boardCategory, groupByCategory, parseAgileBoards, parseAgileColumns, mapToAgileColumns, statusCategoryClass } from '../../../src/core/jira/board' import type { JiraIssue } from '../../../src/core/jira/issues' const issue = (key: string, statusId: string, statusCategory = ''): JiraIssue => @@ -96,3 +96,18 @@ describe('mapToAgileColumns', () => { expect(map.get('To Do')?.map(i => i.key)).toEqual(['A-1']) }) }) + +describe('statusCategoryClass', () => { + it('maps done', () => { + expect(statusCategoryClass('done')).toBe('jira-st-done') + }) + + it('maps in-progress (indeterminate)', () => { + expect(statusCategoryClass('indeterminate')).toBe('jira-st-progress') + }) + + it('maps to-do (new) and anything else', () => { + expect(statusCategoryClass('new')).toBe('jira-st-todo') + expect(statusCategoryClass('')).toBe('jira-st-todo') + }) +}) diff --git a/tests/core/jira/issueDetail.test.ts b/tests/core/jira/issueDetail.test.ts new file mode 100644 index 0000000..4c6c0ae --- /dev/null +++ b/tests/core/jira/issueDetail.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { parseIssueDetail, parsePullRequests } from '../../../src/core/jira/issueDetail' + +const raw = (fields: Record = {}, rendered?: Record) => + ({ fields, ...(rendered ? { renderedFields: rendered } : {}) }) + +describe('parseIssueDetail defaults', () => { + it('gives every field a safe empty value for an issue with nothing set', () => { + expect(parseIssueDetail(raw())).toEqual({ + description: '', isRenderedHtml: false, attachments: [], pullRequests: [], + assignee: '', assigneeAvatar: '', reporter: '', reporterAvatar: '', + priority: '', sprint: '', fixVersions: [], estimate: '', + }) + }) + + it('survives a response with no fields at all', () => { + expect(parseIssueDetail(null).description).toBe('') + expect(parseIssueDetail({}).attachments).toEqual([]) + }) +}) + +describe('the description', () => { + it('prefers the rendered HTML and says so', () => { + const detail = parseIssueDetail(raw({ description: 'raw text' }, { description: '

    html

    ' })) + expect(detail.description).toBe('

    html

    ') + expect(detail.isRenderedHtml).toBe(true) + }) + + it('falls back to the raw wiki markup', () => { + const detail = parseIssueDetail(raw({ description: 'h1. Title' })) + expect(detail.description).toBe('h1. Title') + expect(detail.isRenderedHtml).toBe(false) + }) + + it('does not treat an empty rendered description as HTML', () => { + expect(parseIssueDetail(raw({ description: 'raw' }, { description: '' })).isRenderedHtml).toBe(false) + }) +}) + +describe('attachments', () => { + it('keeps id, filename, content, thumbnail and type', () => { + const detail = parseIssueDetail(raw({ + attachment: [{ id: '1', filename: 'a.png', content: '/c', thumbnail: '/t', mimeType: 'image/png' }], + })) + expect(detail.attachments).toEqual([ + { id: '1', filename: 'a.png', content: '/c', thumbnail: '/t', mimeType: 'image/png' }, + ]) + }) + + it('fills missing attachment fields with empty strings', () => { + const detail = parseIssueDetail(raw({ attachment: [{}] })) + expect(detail.attachments[0]).toEqual({ id: '', filename: '', content: '', thumbnail: '', mimeType: '' }) + }) +}) + +describe('people and metadata', () => { + it('reads assignee and reporter with their 48px avatars', () => { + const detail = parseIssueDetail(raw({ + assignee: { displayName: 'Ana', avatarUrls: { '48x48': '/ana.png' } }, + reporter: { displayName: 'Bea', avatarUrls: { '48x48': '/bea.png' } }, + })) + expect(detail).toMatchObject({ + assignee: 'Ana', assigneeAvatar: '/ana.png', reporter: 'Bea', reporterAvatar: '/bea.png', + }) + }) + + it('reads the priority name', () => { + expect(parseIssueDetail(raw({ priority: { name: 'High' } })).priority).toBe('High') + }) + + it('joins sprint names and drops the unnamed ones', () => { + expect(parseIssueDetail(raw({ customfield_10020: [{ name: 'S1' }, {}, { name: 'S2' }] })).sprint) + .toBe('S1, S2') + }) + + it('lists fix versions, dropping the unnamed ones', () => { + expect(parseIssueDetail(raw({ fixVersions: [{ name: '1.0' }, {}] })).fixVersions).toEqual(['1.0']) + }) +}) + +describe('the estimate', () => { + it('turns seconds into whole hours', () => { + expect(parseIssueDetail(raw({ timeoriginalestimate: 7200 })).estimate).toBe('2h') + }) + + it('rounds to the nearest hour', () => { + expect(parseIssueDetail(raw({ timeoriginalestimate: 5400 })).estimate).toBe('2h') + expect(parseIssueDetail(raw({ timeoriginalestimate: 5000 })).estimate).toBe('1h') + }) + + it('shows nothing when there is no estimate', () => { + expect(parseIssueDetail(raw({ timeoriginalestimate: 0 })).estimate).toBe('') + expect(parseIssueDetail(raw()).estimate).toBe('') + }) +}) + +describe('parsePullRequests', () => { + it('flattens the pull requests across detail entries', () => { + const prs = parsePullRequests({ + detail: [ + { pullRequests: [{ title: 'One', url: '/1', status: 'OPEN' }] }, + { pullRequests: [{ title: 'Two', url: '/2', status: 'MERGED' }] }, + ], + }) + expect(prs).toEqual([ + { title: 'One', url: '/1', status: 'OPEN' }, + { title: 'Two', url: '/2', status: 'MERGED' }, + ]) + }) + + it('fills missing pull request fields with empty strings', () => { + expect(parsePullRequests({ detail: [{ pullRequests: [{}] }] })) + .toEqual([{ title: '', url: '', status: '' }]) + }) + + it('returns nothing for an instance that does not report them', () => { + expect(parsePullRequests(null)).toEqual([]) + expect(parsePullRequests({})).toEqual([]) + expect(parsePullRequests({ detail: [{}] })).toEqual([]) + }) +}) diff --git a/tests/core/jira/transitions.test.ts b/tests/core/jira/transitions.test.ts new file mode 100644 index 0000000..b4ba19a --- /dev/null +++ b/tests/core/jira/transitions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { findTransitionForColumn, type JiraTransition } from '../../../src/core/jira/transitions' +import type { AgileColumn } from '../../../src/core/jira/board' + +const transition = (id: string, toName: string, toId: string, category = 'new'): JiraTransition => + ({ id, name: toName, to: { id: toId, name: toName, statusCategory: { key: category } } }) + +const column = (name: string, statusIds: string[] = []): AgileColumn => ({ name, statusIds }) + +describe('matching by name', () => { + it('matches the target status name', () => { + const found = findTransitionForColumn([transition('1', 'In Progress', '3')], 'In Progress', null) + expect(found?.id).toBe('1') + }) + + it('matches the transition name when the target status is named differently', () => { + const t: JiraTransition = { id: '2', name: 'Start work', to: { id: '3', name: 'Doing', statusCategory: { key: 'indeterminate' } } } + expect(findTransitionForColumn([t], 'Start work', null)?.id).toBe('2') + }) +}) + +describe('matching by the column status ids', () => { + it('matches a transition whose target status the column holds', () => { + const transitions = [transition('7', 'Doing', '10021', 'indeterminate')] + const cols = [column('In Progress', ['10021'])] + expect(findTransitionForColumn(transitions, 'In Progress', cols)?.id).toBe('7') + }) + + it('ignores columns other than the target one', () => { + const transitions = [transition('7', 'Doing', '10021', 'indeterminate')] + const cols = [column('Done', ['10021']), column('In Progress', [])] + expect(findTransitionForColumn(transitions, 'In Progress', cols)).toBeUndefined() + }) +}) + +describe('falling back to the status category', () => { + it('treats a column that holds statuses as in-progress', () => { + const transitions = [transition('9', 'Whatever', '5', 'indeterminate')] + const cols = [column('Custom', ['999'])] + expect(findTransitionForColumn(transitions, 'Custom', cols)?.id).toBe('9') + }) + + it('treats a column with no statuses as to-do', () => { + const transitions = [transition('9', 'Whatever', '5', 'new')] + expect(findTransitionForColumn(transitions, 'Custom', [column('Custom', [])])?.id).toBe('9') + }) + + it('treats an unknown column as to-do', () => { + const transitions = [transition('9', 'Whatever', '5', 'new')] + expect(findTransitionForColumn(transitions, 'Nowhere', null)?.id).toBe('9') + }) + + it('does not fall back to a done transition', () => { + const transitions = [transition('9', 'Whatever', '5', 'done')] + expect(findTransitionForColumn(transitions, 'Custom', [column('Custom', [])])).toBeUndefined() + }) +}) + +describe('precedence and misses', () => { + it('prefers the name match over the category fallback', () => { + const transitions = [ + transition('fallback', 'Other', '1', 'new'), + transition('byName', 'Target', '2', 'done'), + ] + expect(findTransitionForColumn(transitions, 'Target', null)?.id).toBe('byName') + }) + + it('finds nothing when there are no transitions at all', () => { + expect(findTransitionForColumn([], 'In Progress', null)).toBeUndefined() + }) +}) 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) + }) +}) 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([]) + }) +}) 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) + }) +}) diff --git a/tests/core/memory/memoryImportPlan.test.ts b/tests/core/memory/memoryImportPlan.test.ts new file mode 100644 index 0000000..943611e --- /dev/null +++ b/tests/core/memory/memoryImportPlan.test.ts @@ -0,0 +1,86 @@ +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('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') + 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') + }) +}) diff --git a/tests/core/notes/noteGroups.test.ts b/tests/core/notes/noteGroups.test.ts new file mode 100644 index 0000000..173f26b --- /dev/null +++ b/tests/core/notes/noteGroups.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { groupNoteEntries, type NoteEntry } from '../../../src/core/notes/noteGroups' +import type { ParsedNote } from '../../../src/core/notes/noteFile' + +const note = (over: Partial = {}): ParsedNote => + ({ title: '', category: '', tags: [], body: '', ...over }) + +const entry = (name: string, over: Partial = {}): NoteEntry => + ({ name, note: note(over) }) + +describe('grouping', () => { + it('buckets entries under their category', () => { + const groups = groupNoteEntries([ + entry('a', { category: 'Work' }), + entry('b', { category: 'Personal' }), + entry('c', { category: 'Work' }), + ], '', 'Uncategorized') + expect(groups.map(g => g.category)).toEqual(['Work', 'Personal']) + expect(groups[0].items.map(e => e.name)).toEqual(['a', 'c']) + }) + + it('falls back to the placeholder category for a blank or whitespace one', () => { + const groups = groupNoteEntries([entry('a', { category: ' ' })], '', 'Uncategorized') + expect(groups[0].category).toBe('Uncategorized') + }) + + it('keeps categories in the order they first appear', () => { + const groups = groupNoteEntries([ + entry('a', { category: 'Z' }), + entry('b', { category: 'A' }), + ], '', 'Uncategorized') + expect(groups.map(g => g.category)).toEqual(['Z', 'A']) + }) + + it('groups nothing into nothing', () => { + expect(groupNoteEntries([], '', 'Uncategorized')).toEqual([]) + }) +}) + +describe('filtering by search', () => { + const entries = [ + entry('a', { title: 'Shopping list', category: 'Home', tags: [] }), + entry('b', { title: 'Meeting notes', category: 'Work', tags: ['urgent'] }), + entry('c', { title: 'Untitled', category: 'Home', tags: ['recipe'] }), + ] + + it('keeps everything for an empty query', () => { + const groups = groupNoteEntries(entries, '', 'Uncategorized') + expect(groups.flatMap(g => g.items)).toHaveLength(3) + }) + + it('matches the title, case-insensitively', () => { + const groups = groupNoteEntries(entries, 'SHOPPING', 'Uncategorized') + expect(groups.flatMap(g => g.items).map(e => e.name)).toEqual(['a']) + }) + + it('matches the category', () => { + const groups = groupNoteEntries(entries, 'work', 'Uncategorized') + expect(groups.flatMap(g => g.items).map(e => e.name)).toEqual(['b']) + }) + + it('matches a tag', () => { + const groups = groupNoteEntries(entries, 'recipe', 'Uncategorized') + expect(groups.flatMap(g => g.items).map(e => e.name)).toEqual(['c']) + }) + + it('drops a category left with no matches', () => { + const groups = groupNoteEntries(entries, 'urgent', 'Uncategorized') + expect(groups.map(g => g.category)).toEqual(['Work']) + }) + + it('can match nothing', () => { + expect(groupNoteEntries(entries, 'nothing here', 'Uncategorized')).toEqual([]) + }) +}) diff --git a/tests/panels/agents/agentResume.test.ts b/tests/panels/agents/agentResume.test.ts new file mode 100644 index 0000000..c8e443d --- /dev/null +++ b/tests/panels/agents/agentResume.test.ts @@ -0,0 +1,83 @@ +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 { buildResumeCmd } from '../../../src/panels/agents/agentResume' + +beforeEach(() => { + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +describe('claude', () => { + it('launches plain when there is no session to resume', async () => { + expect(await buildResumeCmd('claude', '/repo')).toBe('claude') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('resumes when the session still exists on disk', async () => { + mocks.invoke.mockResolvedValue(true) + expect(await buildResumeCmd('claude', '/repo', 's1')).toBe('claude --resume s1') + expect(mocks.invoke).toHaveBeenCalledWith('agent_claude_session_exists', { cwd: '/repo', sessionId: 's1' }) + }) + + it('falls back to a plain launch when the session is gone', async () => { + mocks.invoke.mockResolvedValue(false) + expect(await buildResumeCmd('claude', '/repo', 's1')).toBe('claude') + }) + + it('falls back to a plain launch when the check itself fails', async () => { + mocks.invoke.mockRejectedValue(new Error('daemon unreachable')) + expect(await buildResumeCmd('claude', '/repo', 's1')).toBe('claude') + }) +}) + +describe('opencode', () => { + it('resumes without checking whether the session exists', async () => { + expect(await buildResumeCmd('opencode', '/repo', 's1')).toBe('opencode --session s1') + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('launches plain with no session', async () => { + expect(await buildResumeCmd('opencode', '/repo')).toBe('opencode') + }) +}) + +describe('codex', () => { + it('resumes and clears the stale writer lock when the session exists', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => cmd === 'agent_codex_session_exists') + expect(await buildResumeCmd('codex', '/repo', 's1')).toBe('codex resume s1') + expect(mocks.invoke).toHaveBeenCalledWith('agent_codex_clear_lock', { sessionId: 's1' }) + }) + + it('launches plain, and never clears the lock, when the session is gone', async () => { + mocks.invoke.mockResolvedValue(false) + expect(await buildResumeCmd('codex', '/repo', 's1')).toBe('codex') + expect(mocks.invoke).toHaveBeenCalledTimes(1) + }) + + it('does not fail the launch when clearing the lock errors', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'agent_codex_session_exists') return true + throw new Error('lock already gone') + }) + expect(await buildResumeCmd('codex', '/repo', 's1')).toBe('codex resume s1') + }) + + it('launches plain with no session, without checking anything', async () => { + expect(await buildResumeCmd('codex', '/repo')).toBe('codex') + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) + +describe('an unknown command', () => { + it('is returned unchanged, session id or not', async () => { + expect(await buildResumeCmd('aider', '/repo')).toBe('aider') + expect(await buildResumeCmd('aider', '/repo', 's1')).toBe('aider') + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) diff --git a/tests/panels/db/dbAccess.test.ts b/tests/panels/db/dbAccess.test.ts new file mode 100644 index 0000000..24f4558 --- /dev/null +++ b/tests/panels/db/dbAccess.test.ts @@ -0,0 +1,118 @@ +// @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 { 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 } +} + +beforeEach(() => { + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(undefined) +}) + +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([]) + }) +}) 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}') + }) +}) 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) + }) +}) 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: '' }) + } + }) +}) 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) + }) +}) 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/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/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') + }) +}) 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) + }) +}) diff --git a/tests/panels/db/dbQueryExec.test.ts b/tests/panels/db/dbQueryExec.test.ts new file mode 100644 index 0000000..38e0d27 --- /dev/null +++ b/tests/panels/db/dbQueryExec.test.ts @@ -0,0 +1,145 @@ +// @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 { 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('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() + }) +}) 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) + }) +}) 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/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"') + }) +}) 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) + }) +}) 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) + }) +}) 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() + }) +}) 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 new file mode 100644 index 0000000..522e7c2 --- /dev/null +++ b/tests/panels/db/dbWidgets.test.ts @@ -0,0 +1,159 @@ +// @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, copyToClipboard } 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 = '' + // 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('') } + }) + + 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) + }) +}) + +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') + }) +}) diff --git a/tests/panels/jira/jiraClient.test.ts b/tests/panels/jira/jiraClient.test.ts new file mode 100644 index 0000000..572dc63 --- /dev/null +++ b/tests/panels/jira/jiraClient.test.ts @@ -0,0 +1,201 @@ +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 { createJiraClient, type JiraAccount } from '../../../src/panels/jira/jiraClient' + +const account: JiraAccount = { id: 'a1', site: 'acme.atlassian.net', email: 'ana@acme.com', token: 'tok' } + +const ok = (body: unknown): { status: number; body: string } => + ({ status: 200, body: typeof body === 'string' ? body : JSON.stringify(body) }) + +function client(over: { account?: JiraAccount | null } = {}) { + return createJiraClient(() => ('account' in over ? over.account ?? null : account)) +} + +const lastCall = (): { method: string; url: string; headers: string[][]; body: string | null } => + mocks.invoke.mock.calls.at(-1)![1] as never + +beforeEach(() => { + mocks.invoke.mockReset() + mocks.invoke.mockResolvedValue(ok(null)) +}) + +describe('the request itself', () => { + it('refuses to call anything without an account', async () => { + await expect(client({ account: null }).request('GET', 'api/2/myself')).rejects.toThrow() + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('builds the URL from the account site', async () => { + await client().request('GET', 'api/2/myself') + expect(lastCall().url).toContain('acme.atlassian.net') + expect(lastCall().url).toContain('api/2/myself') + }) + + it('sends basic auth and JSON headers', async () => { + await client().request('GET', 'api/2/myself') + const headers = Object.fromEntries(lastCall().headers) + expect(headers.Authorization).toMatch(/^Basic /) + expect(headers.Accept).toBe('application/json') + }) + + it('sends no body for a GET and a JSON body when given one', async () => { + await client().request('GET', 'x') + expect(lastCall().body).toBeNull() + await client().request('POST', 'x', { a: 1 }) + expect(lastCall().body).toBe('{"a":1}') + }) + + it('parses the JSON response, and gives null for an empty one', async () => { + mocks.invoke.mockResolvedValue(ok({ ok: true })) + expect(await client().request('GET', 'x')).toEqual({ ok: true }) + mocks.invoke.mockResolvedValue({ status: 204, body: '' }) + expect(await client().request('GET', 'x')).toBeNull() + }) + + it('turns an error status into a throw carrying the status and body', async () => { + mocks.invoke.mockResolvedValue({ status: 403, body: 'no permission' }) + await expect(client().request('GET', 'x')).rejects.toThrow(/403/) + await expect(client().request('GET', 'x')).rejects.toThrow(/no permission/) + }) +}) + +describe('searchIssues', () => { + it('posts the JQL and parses the issues out', async () => { + mocks.invoke.mockResolvedValue(ok({ + issues: [{ key: 'K-1', fields: { summary: 'One', status: { name: 'To Do', statusCategory: { key: 'new' } } } }], + })) + const issues = await client().searchIssues('assignee = currentUser()') + expect(JSON.parse(lastCall().body!).jql).toBe('assignee = currentUser()') + expect(issues.map(i => i.key)).toEqual(['K-1']) + }) +}) + +describe('fetchIssueDetail', () => { + it('asks for the rendered fields and merges in the pull requests', async () => { + mocks.invoke.mockImplementation(async (_cmd: string, args: unknown) => { + const url = (args as { url: string }).url + if (url.includes('dev-info')) return ok({ detail: [{ pullRequests: [{ title: 'PR', url: '/p', status: 'OPEN' }] }] }) + return ok({ fields: { priority: { name: 'High' } }, renderedFields: { description: '

    hi

    ' } }) + }) + const detail = await client().fetchIssueDetail('K-1') + expect(detail.priority).toBe('High') + expect(detail.isRenderedHtml).toBe(true) + expect(detail.pullRequests).toEqual([{ title: 'PR', url: '/p', status: 'OPEN' }]) + }) + + it('still returns the issue when the instance has no dev-info panel', async () => { + mocks.invoke.mockImplementation(async (_cmd: string, args: unknown) => { + if ((args as { url: string }).url.includes('dev-info')) throw new Error('404') + return ok({ fields: { priority: { name: 'Low' } } }) + }) + const detail = await client().fetchIssueDetail('K-1') + expect(detail.priority).toBe('Low') + expect(detail.pullRequests).toEqual([]) + }) +}) + +describe('createIssue', () => { + it('sends the project, type, summary and description', async () => { + await client().createIssue('KAN', 'Task', 'A summary', 'Some details') + expect(JSON.parse(lastCall().body!).fields).toMatchObject({ + project: { key: 'KAN' }, issuetype: { name: 'Task' }, summary: 'A summary', description: 'Some details', + }) + }) + + it('only sets an assignee when one was given', async () => { + await client().createIssue('KAN', 'Task', 'S', 'D') + expect(JSON.parse(lastCall().body!).fields.assignee).toBeUndefined() + await client().createIssue('KAN', 'Task', 'S', 'D', 'acc-1') + expect(JSON.parse(lastCall().body!).fields.assignee).toEqual({ accountId: 'acc-1' }) + }) +}) + +describe('resolveAccountId', () => { + it('looks the user up by email and returns the first match', async () => { + mocks.invoke.mockResolvedValue(ok([{ accountId: 'acc-9' }])) + expect(await client().resolveAccountId('ana@acme.com')).toBe('acc-9') + expect(lastCall().url).toContain(encodeURIComponent('ana@acme.com')) + }) + + it('answers null without asking when there is no email', async () => { + expect(await client().resolveAccountId('')).toBeNull() + expect(mocks.invoke).not.toHaveBeenCalled() + }) + + it('answers null when nobody matches', async () => { + mocks.invoke.mockResolvedValue(ok([])) + expect(await client().resolveAccountId('nobody@acme.com')).toBeNull() + }) +}) + +describe('agile boards', () => { + it('lists boards, optionally filtered by name', async () => { + mocks.invoke.mockResolvedValue(ok({ values: [{ id: 1, name: 'Board' }] })) + await client().fetchAgileBoards() + expect(lastCall().url).not.toContain('name=') + await client().fetchAgileBoards('My board') + expect(lastCall().url).toContain(`name=${encodeURIComponent('My board')}`) + }) + + it('reads a board’s columns', async () => { + mocks.invoke.mockResolvedValue(ok({ columnConfig: { columns: [{ name: 'To Do', statuses: [{ id: '1' }] }] } })) + const columns = await client().fetchBoardColumns(7) + expect(lastCall().url).toContain('board/7/configuration') + expect(columns.map(c => c.name)).toEqual(['To Do']) + }) +}) + +describe('board issues', () => { + it('prefers the active sprint on a scrum board', async () => { + mocks.invoke.mockImplementation(async (_cmd: string, args: unknown) => { + const url = (args as { url: string }).url + if (url.includes('/sprint?state=active')) return ok({ values: [{ id: 42 }] }) + return ok({ issues: [{ key: 'S-1', fields: { summary: 'x', status: { name: 'To Do', statusCategory: { key: 'new' } } } }] }) + }) + const issues = await client().fetchBoardIssues(7) + expect(lastCall().url).toContain('sprint/42/issue') + expect(issues.map(i => i.key)).toEqual(['S-1']) + }) + + it('falls back to the whole board when there is no active sprint', async () => { + mocks.invoke.mockImplementation(async (_cmd: string, args: unknown) => { + const url = (args as { url: string }).url + if (url.includes('/sprint?state=active')) return ok({ values: [] }) + return ok({ issues: [] }) + }) + await client().fetchBoardIssues(7) + expect(lastCall().url).toContain('board/7/issue') + }) + + it('falls back to the whole board when the sprint lookup fails', async () => { + mocks.invoke.mockImplementation(async (_cmd: string, args: unknown) => { + const url = (args as { url: string }).url + if (url.includes('/sprint?state=active')) throw new Error('not a scrum board') + return ok({ issues: [] }) + }) + await client().fetchBoardIssues(7) + expect(lastCall().url).toContain('board/7/issue') + }) +}) + +describe('fetchAsDataUrl', () => { + it('fetches a binary asset with the account credentials', async () => { + mocks.invoke.mockResolvedValue('data:image/png;base64,AAA') + expect(await client().fetchAsDataUrl('/secure/a.png')).toBe('data:image/png;base64,AAA') + const [cmd, args] = mocks.invoke.mock.calls.at(-1) as [string, { url: string; headers: string[][] }] + expect(cmd).toBe('http_fetch_base64') + expect(args.url).toBe('/secure/a.png') + expect(Object.fromEntries(args.headers).Authorization).toMatch(/^Basic /) + }) + + it('refuses without an account instead of sending empty credentials', async () => { + await expect(client({ account: null }).fetchAsDataUrl('/a.png')).rejects.toThrow() + expect(mocks.invoke).not.toHaveBeenCalled() + }) +}) diff --git a/tests/panels/jira/jiraIssueDrawer.test.ts b/tests/panels/jira/jiraIssueDrawer.test.ts new file mode 100644 index 0000000..d3250a4 --- /dev/null +++ b/tests/panels/jira/jiraIssueDrawer.test.ts @@ -0,0 +1,339 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const mocks = vi.hoisted(() => ({ openUrl: vi.fn(async () => {}) })) +vi.mock('@tauri-apps/plugin-shell', () => ({ open: mocks.openUrl })) + +import { showIssueDetail } from '../../../src/panels/jira/jiraIssueDrawer' +import type { JiraIssue } from '../../../src/core/jira/issues' +import type { IssueDetail } from '../../../src/core/jira/issueDetail' +import type { JiraAccount, JiraClient } from '../../../src/panels/jira/jiraClient' + +const flush = (): Promise => new Promise(r => setTimeout(r, 0)) + +const account: JiraAccount = { id: 'a1', site: 'acme.atlassian.net', email: 'ana@acme.com', token: 'tok' } + +const issue = (over: Partial = {}): JiraIssue => ({ + key: 'K-1', summary: 'A summary', status: 'To Do', statusCategory: 'new', type: 'Task', + assignee: '', assigneeId: '', assigneeAvatar: '', ...over, +} as JiraIssue) + +const detail = (over: Partial = {}): IssueDetail => ({ + description: 'a description', isRenderedHtml: false, attachments: [], pullRequests: [], + assignee: '', assigneeAvatar: '', reporter: '', reporterAvatar: '', + priority: '', sprint: '', fixVersions: [], estimate: '', ...over, +}) + +function jiraClient(over: Partial = {}): JiraClient { + return { + request: vi.fn(async () => ({})), + searchIssues: vi.fn(async () => []), + fetchIssueDetail: vi.fn(async () => detail()), + createIssue: vi.fn(async () => ({})), + resolveAccountId: vi.fn(async () => null), + fetchAgileBoards: vi.fn(async () => []), + fetchBoardColumns: vi.fn(async () => []), + fetchBoardIssues: vi.fn(async () => []), + fetchAsDataUrl: vi.fn(async () => 'data:image/png;base64,AAA'), + ...over, + } +} + +function setup(over: { jira?: Partial; issue?: JiraIssue } = {}) { + const detailPane = document.createElement('div') + document.body.replaceChildren(detailPane) + const jira = jiraClient(over.jira) + const state = { + viewMode: 'board' as 'board' | 'list', + selectedBoardId: null as number | null, + agileColumns: [] as ReturnType extends Promise ? T : never, + cachedIssues: [] as JiraIssue[], + assigneeResets: 0, + } + const promise = showIssueDetail({ + jira, + getActiveAccount: () => account, + detailPane, + getViewMode: () => state.viewMode, + getSelectedBoardId: () => state.selectedBoardId, + getAgileColumns: () => state.agileColumns, + setAgileColumns: cols => { state.agileColumns = cols }, + getCachedIssues: () => state.cachedIssues, + setCachedIssues: issues => { state.cachedIssues = issues }, + resetAssigneeFilter: () => { state.assigneeResets++ }, + }, over.issue ?? issue()) + return { detailPane, jira, state, promise } +} + +const q = (sel: string): T => document.querySelector(sel) as T +const qa = (sel: string): Element[] => [...document.querySelectorAll(sel)] + +beforeEach(() => { + document.body.replaceChildren() + mocks.openUrl.mockReset() + mocks.openUrl.mockResolvedValue(undefined) +}) + +describe('the drawer shell', () => { + it('shows the key, status and type before the detail loads', async () => { + setup({ issue: issue({ key: 'K-9', status: 'Doing', type: 'Bug' }) }) + expect(q('.jira-key').textContent).toBe('K-9') + expect(q('.jira-status').textContent).toBe('Doing') + expect(q('.jira-type').textContent).toBe('Bug') + }) + + it('closes on a click outside the drawer, not inside it', async () => { + const { detailPane, promise } = setup() + await promise + await flush() + q('.jira-drawer-overlay').dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(detailPane.querySelector('.jira-drawer-overlay')).toBeNull() + }) + + it('opens the issue in the browser', async () => { + const { promise } = setup({ issue: issue({ key: 'K-1' }) }) + await promise + await flush() + ;(qa('.jira-header button')[0] as HTMLButtonElement).click() + expect(mocks.openUrl).toHaveBeenCalledWith(expect.stringContaining('K-1')) + }) +}) + +describe('the description', () => { + it('renders Jira-provided HTML as-is', async () => { + const { promise } = setup({ jira: { fetchIssueDetail: async () => detail({ isRenderedHtml: true, description: '

    hi

    ' }) } }) + await promise + await flush() + expect(q('.jira-detail-desc').innerHTML).toBe('

    hi

    ') + }) + + it('parses wiki markup when there is no rendered HTML', async () => { + const { promise } = setup({ jira: { fetchIssueDetail: async () => detail({ description: 'h1. Title' }) } }) + await promise + await flush() + expect(q('.jira-detail-desc h1')).not.toBeNull() + }) + + it('shows a placeholder for an empty description', async () => { + const { promise } = setup({ jira: { fetchIssueDetail: async () => detail({ description: '' }) } }) + await promise + await flush() + expect(q('.jira-detail-desc em')).not.toBeNull() + }) + + it('reports an error instead of hanging when the detail fetch fails', async () => { + const { promise } = setup({ jira: { fetchIssueDetail: async () => { throw new Error('down') } } }) + await promise + await flush() + expect(q('.jira-detail-desc').textContent).toContain('error') + }) +}) + +describe('metadata', () => { + it('lists only the fields that have a value', async () => { + const { promise } = setup({ jira: { fetchIssueDetail: async () => detail({ assignee: 'Ana', priority: '', sprint: '' }) } }) + await promise + await flush() + const labels = qa('.jira-meta-label').map(e => e.textContent) + expect(labels.some(l => l?.includes('ASIGN'))).toBe(true) + expect(labels.some(l => l?.includes('PRIOR'))).toBe(false) + }) + + it('shows the fix versions joined when there are any', async () => { + const { promise } = setup({ jira: { fetchIssueDetail: async () => detail({ fixVersions: ['1.0', '1.1'] }) } }) + await promise + await flush() + const row = qa('.jira-meta-row').find(r => r.textContent?.includes('1.0')) + expect(row?.textContent).toContain('1.1') + }) +}) + +describe('attachments', () => { + it('shows a thumbnail for image attachments', async () => { + const { promise } = setup({ + jira: { fetchIssueDetail: async () => detail({ attachments: [{ id: '1', filename: 'a.png', content: '/a.png', thumbnail: '', mimeType: 'image/png' }] }) }, + }) + await promise + await flush() + expect(q('.jira-att-thumb')).not.toBeNull() + }) + + it('shows a plain icon for a non-image attachment', async () => { + const { promise } = setup({ + jira: { fetchIssueDetail: async () => detail({ attachments: [{ id: '1', filename: 'a.pdf', content: '/a.pdf', thumbnail: '', mimeType: 'application/pdf' }] }) }, + }) + await promise + await flush() + expect(q('.jira-att-icon')).not.toBeNull() + expect(q('.jira-att-thumb')).toBeNull() + }) +}) + +describe('transitions', () => { + it('offers every transition except the one back to the current status', async () => { + const { promise } = setup({ + jira: { + request: vi.fn(async (method: string, path: string) => { + if (path.endsWith('/transitions')) { + return { transitions: [{ id: '1', name: 'x', to: { name: 'To Do' } }, { id: '2', name: 'x', to: { name: 'Done' } }] } + } + return {} + }), + }, + issue: issue({ status: 'To Do' }), + }) + await promise + await flush() + const labels = qa('.jira-transition-btn').map(b => b.textContent) + expect(labels).toEqual(['Done']) + }) + + it('applies the clicked transition and closes the drawer', async () => { + const request = vi.fn(async (method: string, path: string) => { + if (path.endsWith('/transitions') && method === 'GET') return { transitions: [{ id: '2', name: 'x', to: { name: 'Done' } }] } + return {} + }) + const { detailPane, promise } = setup({ jira: { request } }) + await promise + await flush() + q('.jira-transition-btn').click() + await flush() + expect(request).toHaveBeenCalledWith('POST', expect.stringContaining('/transitions'), { transition: { id: '2' } }) + expect(detailPane.querySelector('.jira-drawer-overlay')).toBeNull() + }) + + it('refreshes the board issues after a transition when viewing the board', async () => { + const request = vi.fn(async (_method: string, path: string) => { + if (path.endsWith('/transitions')) return { transitions: [{ id: '2', name: 'x', to: { name: 'Done' } }] } + return {} + }) + const fetchBoardIssues = vi.fn(async () => [issue({ key: 'K-2' })]) + const { state, promise } = setup({ jira: { request, fetchBoardIssues } }) + state.viewMode = 'board' + state.selectedBoardId = 7 + await promise + await flush() + q('.jira-transition-btn').click() + await flush() + expect(fetchBoardIssues).toHaveBeenCalledWith(7) + expect(state.cachedIssues.map(i => i.key)).toEqual(['K-2']) + expect(state.assigneeResets).toBe(1) + }) + + it('re-enables the button when the transition fails', async () => { + const request = vi.fn(async (method: string, path: string) => { + if (path.endsWith('/transitions') && method === 'GET') return { transitions: [{ id: '2', name: 'x', to: { name: 'Done' } }] } + if (method === 'POST') throw new Error('locked') + return {} + }) + const { promise } = setup({ jira: { request } }) + await promise + await flush() + const btn = q('.jira-transition-btn') + btn.click() + await flush() + expect(btn.disabled).toBe(false) + expect(btn.textContent).toBe('Done') + }) +}) + +describe('pull requests', () => { + it('lists each pull request by title', async () => { + const { promise } = setup({ + jira: { fetchIssueDetail: async () => detail({ pullRequests: [{ title: 'Fix bug', url: '/pr/1', status: 'OPEN' }] }) }, + }) + await promise + await flush() + expect(q('.jira-pr-row').textContent).toBe('Fix bug') + }) + + it('opens the pull request in the browser', async () => { + const { promise } = setup({ + jira: { fetchIssueDetail: async () => detail({ pullRequests: [{ title: 'Fix bug', url: '/pr/1', status: 'OPEN' }] }) }, + }) + await promise + await flush() + q('.jira-pr-row').click() + expect(mocks.openUrl).toHaveBeenCalledWith('/pr/1') + }) +}) + +describe('editing the estimate', () => { + it('opens an editor on click and saves through the API', async () => { + const request = vi.fn(async (method: string, path: string) => { + if (path.endsWith('/transitions')) return {} + return {} + }) + const { promise } = setup({ jira: { request, fetchIssueDetail: async () => detail({ estimate: '2h' }) } }) + await promise + await flush() + q('.jira-meta-row[data-field="estimate"] .jira-meta-value').click() + const input = q('.jira-meta-row[data-field="estimate"] input') + input.value = '3h' + q('.jira-meta-row[data-field="estimate"] .jira-primary').click() + await flush() + expect(request).toHaveBeenCalledWith('PUT', expect.stringContaining('K-1'), { + update: { timetracking: [{ set: { originalEstimate: '3h' } }] }, + }) + }) +}) + +describe('editing the description', () => { + it('saves the new text as wiki markup and re-renders it', async () => { + const request = vi.fn(async (method: string, path: string) => { + if (path.endsWith('/transitions')) return {} + return {} + }) + const { promise } = setup({ jira: { request, fetchIssueDetail: async () => detail({ description: 'old' }) } }) + await promise + await flush() + q('.jira-header button:last-child').click() + const ta = q('.jira-detail-desc textarea') + ta.value = 'h1. New' + ;[...document.querySelectorAll('.jira-primary')].find(b => b.textContent === 'Guardar')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flush() + expect(request).toHaveBeenCalledWith('PUT', expect.stringContaining('K-1'), { fields: { description: 'h1. New' } }) + expect(q('.jira-detail-desc h1')).not.toBeNull() + }) +}) + +describe('comments', () => { + it('loads and renders existing comments', async () => { + const request = vi.fn(async (method: string, path: string) => { + if (path.includes('/comment')) return { comments: [{ body: 'Hi', author: { displayName: 'Ana' } }] } + return {} + }) + const { promise } = setup({ jira: { request } }) + await promise + await flush() + expect(q('.jira-comment-body').textContent).toBe('Hi') + expect(q('.jira-comment-meta').textContent).toContain('Ana') + }) + + it('says so when there are none', async () => { + const request = vi.fn(async (method: string, path: string) => (path.includes('/comment') ? { comments: [] } : {})) + const { promise } = setup({ jira: { request } }) + await promise + await flush() + expect(q('.jira-comment-list').textContent).not.toBe('') + }) + + it('posts a new comment and reloads the list', async () => { + let posted = false + const request = vi.fn(async (method: string, path: string) => { + if (path.includes('/comment') && method === 'POST') { posted = true; return {} } + if (path.includes('/comment')) return { comments: posted ? [{ body: 'New one' }] : [] } + return {} + }) + const { promise } = setup({ jira: { request } }) + await promise + await flush() + const commentInput = [...document.querySelectorAll('textarea')].find(t => t.placeholder?.includes('coment')) as HTMLTextAreaElement + commentInput.value = 'New one' + const submit = [...document.querySelectorAll('button')].find(b => b.textContent === 'Comentar') as HTMLButtonElement + submit.click() + await flush() + expect(request).toHaveBeenCalledWith('POST', expect.stringContaining('/comment'), { body: 'New one' }) + expect(commentInput.value).toBe('') + }) +}) diff --git a/tests/panels/jira/jiraWidgets.test.ts b/tests/panels/jira/jiraWidgets.test.ts new file mode 100644 index 0000000..1d63b47 --- /dev/null +++ b/tests/panels/jira/jiraWidgets.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { note, mkBtn, detailHeader, field } from '../../../src/panels/jira/jiraWidgets' + +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('jira-note') + }) + + it('takes an override class', () => { + expect(note('x', 'jira-detail-hint').className).toBe('jira-detail-hint') + }) +}) + +describe('mkBtn', () => { + it('sets the title and runs the handler on click', () => { + const onClick = vi.fn() + const btn = mkBtn('plus', 'Add', onClick) + expect(btn.title).toBe('Add') + btn.click() + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) + +describe('detailHeader', () => { + it('shows the title followed by the given actions', () => { + const action = document.createElement('button') + const bar = detailHeader('Detail', action) + expect(bar.querySelector('.jira-title')!.textContent).toBe('Detail') + expect(bar.lastElementChild).toBe(action) + }) +}) + +describe('field', () => { + it('labels the input and seeds its value', () => { + const { row, input } = field('Project', 'BEN') + expect(row.textContent).toBe('Project') + expect(input.value).toBe('BEN') + expect(row.contains(input)).toBe(true) + }) + + it('defaults to a text input and an empty value', () => { + const { input } = field('Label') + expect(input.type).toBe('text') + expect(input.value).toBe('') + }) +}) 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