diff --git a/dist/assets/index-DP_ri0XS.js b/dist/assets/index-4lR4GVU8.js similarity index 98% rename from dist/assets/index-DP_ri0XS.js rename to dist/assets/index-4lR4GVU8.js index 443f370..456c4f1 100644 --- a/dist/assets/index-DP_ri0XS.js +++ b/dist/assets/index-4lR4GVU8.js @@ -1891,4 +1891,4 @@ XID_Start XIDS`.split(/\s/).map(e=>[Fl(e),e])),Su=new Map([[`s`,J(383)],[J(383), color: var(--diffs-fg) !important; } [data-review-comment] [data-gutter-utility-slot] { display: none !important; } - `,e.appendChild(t)}function qm(e,t){let n=e.querySelector(`diffs-container`)?.shadowRoot;if(!n)return;let r=n.querySelector(`pre`);if(!r)return;Km(n);for(let e of n.querySelectorAll(`[data-review-comment]`))e.removeAttribute(`data-review-comment`);if(t.length===0)return;let i=e=>t.some(t=>e>=t.start&&e<=t.end);for(let e of Array.from(r.children)){if(!(e instanceof HTMLElement)||!e.hasAttribute(`data-code`)||e.hasAttribute(`data-deletions`))continue;let t=e.querySelector(`[data-gutter]`),n=e.querySelector(`[data-content]`);if(!(!t||!n))for(let e=0;e{o.current?.focus()},[]);let s=()=>{let t=i.trim();!t||n||e(t)};return(0,j.jsxs)(`div`,{className:`comment-form`,onClick:e=>e.stopPropagation(),children:[(0,j.jsx)(`textarea`,{ref:o,value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),s()),e.key===`Escape`&&t()},placeholder:`Leave a review comment... (Cmd+Enter to submit)`,rows:3,disabled:n}),(0,j.jsxs)(`div`,{className:`comment-form-actions`,children:[r&&(0,j.jsx)(`div`,{className:`comment-form-error`,children:r}),(0,j.jsx)(Jm,{onClick:t,disabled:n,children:`Cancel`}),(0,j.jsx)(Jm,{variant:`primary`,disabled:!i.trim()||n,onClick:s,children:`Comment`})]})]})}var Xm=20,Zm=!1,Qm=(0,A.memo)(function({fileDiff:e,diffStyle:t,lineAnnotations:n,renderAnnotation:r,onGutterUtilityClick:i,commentsEnabled:a}){return(0,j.jsx)(`div`,{ref:(0,A.useCallback)(n=>{Zm||!n||requestAnimationFrame(()=>{let r=t===`split`?e.splitLineCount:e.unifiedLineCount;r>0&&n.offsetHeight>0&&(Xm=n.offsetHeight/r,Zm=!0)})},[e,t]),children:(0,j.jsx)(wm,{fileDiff:e,options:{theme:`github-dark-default`,diffStyle:t,diffIndicators:`classic`,hunkSeparators:`line-info-basic`,overflow:`wrap`,disableFileHeader:!0,enableGutterUtility:a,onGutterUtilityClick:i},lineAnnotations:n,renderAnnotation:r})})},(e,t)=>e.fileDiff===t.fileDiff&&e.diffStyle===t.diffStyle&&e.lineAnnotations===t.lineAnnotations);function $m({fileDiff:e,isViewed:t,isStale:n,diffStyle:r,collapsed:i,composing:a,focused:o,onToggleCollapse:s,onToggleViewed:c,onStartComment:l,onSubmitComment:u,onResolveComment:d,onCancelComment:f,commentsEnabled:p,submitting:m,submitError:h}){let{additions:g,deletions:_}=zm(e),v=e.name,y=(0,A.useMemo)(()=>{if(!p)return[];let t=Rm(e,v);return a&&t.push({side:`additions`,lineNumber:a.line,metadata:{type:`composing`,file:v}}),t},[e,v,a,p]),b=(0,A.useCallback)(e=>{let t=e.metadata;return t.type===`review`?(0,j.jsx)(`div`,{className:`review-annotation`,children:(0,j.jsx)(Jm,{onClick:()=>d(t.startLine),children:`Resolve comment`})}):t.type===`composing`?(0,j.jsx)(Ym,{onSubmit:t=>u(e.lineNumber,t),onCancel:f,submitting:m,error:h}):null},[d,u,f,m,h]),x=(0,A.useCallback)(e=>{!p||e.side!==`additions`||y.some(t=>t.metadata.type===`review`&&e.start>=t.metadata.startLine&&e.start<=t.metadata.endLine)||l(e.start)},[l,p,y]),S=(0,A.useRef)(null),[C,w]=(0,A.useState)(!1);(0,A.useEffect)(()=>{let e=S.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0]?.isIntersecting&&(w(!0),t.disconnect())},{rootMargin:`2000px`});return t.observe(e),()=>t.disconnect()},[]);let ee=C&&!i;(0,A.useEffect)(()=>{if(!ee)return;let e=S.current;if(!e)return;let t=y.flatMap(e=>e.metadata.type===`review`?[{start:e.metadata.startLine,end:e.metadata.endLine}]:[]),n=0,r=null,i=0,a=()=>qm(e,t),o=()=>{let t=(e.querySelector(`diffs-container`)?.shadowRoot)?.querySelector(`pre`);if(!t){i++<60&&(n=requestAnimationFrame(o));return}a(),r=new MutationObserver(a),r.observe(t,{childList:!0,subtree:!0})};return n=requestAnimationFrame(o),()=>{cancelAnimationFrame(n),r?.disconnect()}},[y,ee,r,e]);let T=(r===`split`?e.splitLineCount:e.unifiedLineCount)*Xm;return(0,j.jsxs)(`div`,{ref:S,className:`file-card`+(o?` focused`:``),children:[(0,j.jsxs)(`div`,{className:`file-header`,onClick:s,children:[(0,j.jsx)(`span`,{className:`collapse-chevron`+(i?` collapsed`:``),children:`▶`}),(0,j.jsx)(`span`,{className:`file-header-name`,children:v}),(0,j.jsxs)(`span`,{className:`file-header-stats`,children:[g>0&&(0,j.jsxs)(`span`,{className:`stat-add`,children:[`+`,g]}),_>0&&(0,j.jsxs)(`span`,{className:`stat-del`,children:[`-`,_]})]}),(0,j.jsxs)(`button`,{type:`button`,className:`viewed-button`+(n?` stale`:``)+(t?` checked`:``),onClick:e=>{e.stopPropagation(),c()},children:[(0,j.jsx)(`input`,{type:`checkbox`,className:`viewed-checkbox`+(n?` stale`:``),checked:t,readOnly:!0,tabIndex:-1}),n?`Changed`:`Viewed`]})]}),ee?(0,j.jsx)(Qm,{fileDiff:e,diffStyle:r,lineAnnotations:y,renderAnnotation:b,onGutterUtilityClick:x,commentsEnabled:p}):!i&&(0,j.jsx)(`div`,{style:{height:T}})]})}function eh({fileHashes:e,viewed:t}){let n=Object.keys(e).length,r=Object.entries(e).filter(([e,n])=>t[e]===n).length;return n===0?null:(0,j.jsxs)(`div`,{className:`progress-bar`,children:[(0,j.jsxs)(`span`,{children:[r,`/`,n,` files viewed`]}),(0,j.jsx)(`div`,{className:`progress-track`,children:(0,j.jsx)(`div`,{className:`progress-fill`,style:{width:`${r/n*100}%`}})})]})}function th({onClose:e,children:t}){let n=(0,A.useRef)(null);return(0,A.useEffect)(()=>n.current?.showModal(),[]),(0,j.jsx)(`dialog`,{ref:n,className:`help-modal`,onClose:e,onClick:e=>{e.target===e.currentTarget&&n.current?.close()},children:t})}var nh=[[`j / k`,`Next / previous line`],[`n / p`,`Next / previous file`],[`v`,`Toggle viewed`],[`e / E`,`Toggle collapse file / all files`],[`s`,`Toggle split mode (responsive / unified)`],[`c`,`Comment on line`],[`Esc`,`Close / cancel`],[`?`,`Toggle this help`]];function rh({onClose:e}){return(0,j.jsxs)(th,{onClose:e,children:[(0,j.jsx)(`h3`,{children:`Keyboard Shortcuts`}),(0,j.jsx)(`table`,{children:(0,j.jsx)(`tbody`,{children:nh.map(([e,t])=>(0,j.jsxs)(`tr`,{children:[(0,j.jsx)(`td`,{children:e.split(` / `).map((e,t)=>(0,j.jsxs)(`span`,{children:[t>0&&` / `,(0,j.jsx)(`kbd`,{children:e})]},e))}),(0,j.jsx)(`td`,{children:t})]},e))})})]})}function ih({onClose:e,vcs:t}){return(0,j.jsxs)(th,{onClose:e,children:[(0,j.jsxs)(`p`,{children:[`Comments work by inserting lines into the code. This can only work if the`,` `,t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`revset includes `,(0,j.jsx)(`code`,{children:`@`})]}):`commit range includes the working copy`,`. Otherwise, the inserted lines will land in`,` `,t===`jj`?(0,j.jsx)(`code`,{children:`@`}):`the working copy`,` but not in the diff being viewed.`]}),t===`jj`?(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with the`,` `,(0,j.jsx)(`code`,{children:`-f`}),` flag to get a diff that starts at some revision and ends at`,` `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with `,(0,j.jsx)(`code`,{children:`-f`}),` `,`only (no `,(0,j.jsx)(`code`,{children:`-t`}),`), so the diff goes from a commit to the working tree.`]})]})}function ah({onLearnMore:e,vcs:t}){return(0,j.jsxs)(`div`,{className:`comments-disabled-banner`,children:[t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`Comments are disabled because the revset does not include `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):`Comments are disabled because the commit range does not end at the working copy.`,` `,(0,j.jsx)(`a`,{href:`#`,onClick:t=>{t.preventDefault(),e()},children:`Learn more`})]})}var oh=[`responsive`,`unified`];function sh(){let e=Im(),[t,n]=(0,A.useState)(`responsive`),r=t===`responsive`&&e?`split`:`unified`,{toast:i,showToast:a}=Lm(),[o,s]=(0,A.useState)({}),[c,l]=(0,A.useState)(null),u=(0,A.useRef)(!1),{data:d,error:f,isLoading:p}=ct({queryKey:[`diff`],queryFn:()=>Pm(`/api/diff`)});(0,A.useEffect)(()=>{d?.revset&&(document.title=`skepsis | ${d.revset}`)},[d?.revset]),(0,A.useEffect)(()=>{if(u.current||!d?.fileHashes)return;u.current=!0;let e={};for(let[t,n]of Object.entries(d.fileHashes))d.viewed[t]===n&&(e[t]=!0);s(e)},[d]);let m=qe(),h=lt({mutationFn:({file:e,hash:t,mark:n})=>Pm(`/api/viewed`,{method:n?`POST`:`DELETE`,body:{file:e,hash:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),g=lt({mutationFn:({file:e,afterLine:t,text:n})=>Pm(`/api/comment`,{method:`POST`,body:{file:e,afterLine:t,text:n}}),onSuccess:()=>l(null),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),_=lt({mutationFn:({file:e,line:t})=>Pm(`/api/comment`,{method:`DELETE`,body:{file:e,line:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),v=d?.patch,y=(0,A.useMemo)(()=>v?qp(v).flatMap(e=>e.files):[],[v]),b=(0,A.useMemo)(()=>{let e={...d?.viewed};if(h.isPending&&h.variables){let{file:t,hash:n,mark:r}=h.variables;r?e[t]=n:delete e[t]}return e},[d,h.isPending,h.variables]),[x,S]=(0,A.useState)(0),[C,w]=(0,A.useState)(0),[ee,T]=(0,A.useState)(!1),[te,ne]=(0,A.useState)(!1),re=(0,A.useRef)(null),ie=(0,A.useRef)(0),ae=(0,A.useRef)({focusedFileIdx:0,cursorIdx:0,showHelp:!1,composing:null,files:[],collapsed:{},fileHashes:{},viewed:{},markMutate:h.mutate});ae.current={focusedFileIdx:x,cursorIdx:C,showHelp:ee,composing:c,files:y,collapsed:o,fileHashes:d?.fileHashes??{},viewed:b,markMutate:h.mutate},(0,A.useLayoutEffect)(()=>{Wm();let e=re.current;re.current=null,e===`file`&&document.querySelectorAll(`.file-card`)[x]?.scrollIntoView({block:`start`});let t=y[x];if(!t||(o[t.name]??!1))return;let n=Bm(x),r=n[Math.min(C,Math.max(n.length-1,0))];if(!r)return;let i=r.gutterEl.getRootNode();i instanceof ShadowRoot&&Gm(i),r.gutterEl.setAttribute(`data-selected-line`,`single`),r.contentEl.setAttribute(`data-selected-line`,`single`),e===`line`&&r.contentEl.scrollIntoView({block:`nearest`})},[x,C,v,c,o,y]),(0,A.useEffect)(()=>{function e(e){if(e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLInputElement||e.ctrlKey||e.metaKey||e.altKey)return;let t=ae.current;switch(e.key){case`?`:e.preventDefault(),T(e=>!e);break;case`Escape`:t.showHelp?(e.preventDefault(),T(!1)):t.composing&&(e.preventDefault(),l(null));break;case`n`:case`p`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=e.key===`n`?1:-1,r=Math.max(0,Math.min(t.focusedFileIdx+n,t.files.length-1));r!==t.focusedFileIdx&&(ie.current=Date.now(),S(r),w(0),re.current=`file`);break}case`j`:case`k`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx]?.name;if(!n||(t.collapsed[n]??!1))break;let r=Bm(t.focusedFileIdx);if(r.length===0)break;let i=t.cursorIdx,a=r[Math.min(i,r.length-1)];if(a&&!Um(a.contentEl,t.focusedFileIdx)){let e=Hm(t.focusedFileIdx);e>=0&&(i=e)}let o=Math.max(0,Math.min(i+(e.key===`j`?1:-1),r.length-1));o!==t.cursorIdx&&(ie.current=Date.now(),w(o),re.current=`line`);break}case`v`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;let r=t.fileHashes[n.name];if(!r)break;let i=t.viewed[n.name]!==r;s(e=>({...e,[n.name]:i})),t.markMutate({file:n.name,hash:r,mark:i});break}case`e`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;ie.current=Date.now(),s(e=>({...e,[n.name]:!(e[n.name]??!1)}));break}case`E`:{if(t.showHelp||t.composing)break;e.preventDefault(),ie.current=Date.now();let n=t.files.some(e=>!(t.collapsed[e.name]??!1));s(Object.fromEntries(t.files.map(e=>[e.name,n])));break}case`s`:if(t.showHelp||t.composing)break;e.preventDefault(),n(e=>{let t=oh[(oh.indexOf(e)+1)%oh.length];return a((0,j.jsxs)(j.Fragment,{children:[`Split mode: `,(0,j.jsx)(`code`,{children:t})]})),t});break;case`c`:{if(!d?.commentsEnabled||t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n||(t.collapsed[n.name]??!1))break;let r=Bm(t.focusedFileIdx)[t.cursorIdx];if(!r||r.lineType===`change-deletion`)break;l({file:n.name,line:r.lineNumber});break}}}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[a,d?.commentsEnabled]);let oe=(0,A.useRef)(new Set);if((0,A.useEffect)(()=>{oe.current.clear();let e=new IntersectionObserver(e=>{for(let t of e){let e=Number(t.target.dataset.fileIdx);t.isIntersecting?oe.current.add(e):oe.current.delete(e)}if(Date.now()-ie.current<150||oe.current.size===0)return;let t=Math.min(...oe.current),n=ae.current;if(t!==n.focusedFileIdx){S(t);let e=n.files[t];if(e&&!(n.collapsed[e.name]??!1)){let e=Hm(t);e>=0&&w(e)}}},{threshold:0});return document.querySelectorAll(`.file-card`).forEach((t,n)=>{t.dataset.fileIdx=String(n),e.observe(t)}),()=>e.disconnect()},[y]),p||!d)return(0,j.jsx)(`div`,{className:`empty-diff`,children:`Loading...`});if(f)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:String(f)});if(d.error)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:d.error});if(!v)return(0,j.jsxs)(`div`,{className:`empty-diff`,children:[(0,j.jsx)(`code`,{children:d.revset}),` is empty`]});let{fileHashes:se}=d;return(0,j.jsxs)(j.Fragment,{children:[!d.commentsEnabled&&(0,j.jsx)(ah,{vcs:d.vcs,onLearnMore:()=>ne(!0)}),(0,j.jsxs)(`div`,{className:`diff-container`,children:[(0,j.jsx)(eh,{fileHashes:se,viewed:b}),ee&&(0,j.jsx)(rh,{onClose:()=>T(!1)}),te&&(0,j.jsx)(ih,{vcs:d.vcs,onClose:()=>ne(!1)}),i&&(0,j.jsx)(`div`,{className:`toast`,children:i.content},i.key),y.map((e,t)=>{let n=e.name,i=se[n],a=b[n],u=i!=null&&a===i,f=a!=null&&i!=null&&a!==i,p=o[n]??!1;return(0,j.jsx)($m,{fileDiff:e,isViewed:u,isStale:f,diffStyle:r,collapsed:p,composing:c?.file===n?{line:c.line}:null,focused:t===x,onToggleCollapse:()=>s(e=>({...e,[n]:!p})),onToggleViewed:()=>{if(i){let e=!u;s(t=>({...t,[n]:e})),h.mutate({file:n,hash:i,mark:e})}},onStartComment:e=>{g.reset(),l({file:n,line:e})},onSubmitComment:(e,t)=>g.mutate({file:n,afterLine:e,text:t}),onResolveComment:e=>_.mutate({file:n,line:e}),onCancelComment:()=>l(null),commentsEnabled:d.commentsEnabled,submitting:g.isPending&&g.variables?.file===n,submitError:g.variables?.file===n&&g.error?g.error.message:null},n??t)})]})]})}function ch(){return(0,j.jsx)(Je,{client:Nm,children:(0,j.jsx)(sh,{})})}(0,u.createRoot)(document.getElementById(`root`)).render((0,j.jsx)(ch,{})); \ No newline at end of file + `,e.appendChild(t)}function qm(e,t){let n=e.querySelector(`diffs-container`)?.shadowRoot;if(!n)return;let r=n.querySelector(`pre`);if(!r)return;Km(n);for(let e of n.querySelectorAll(`[data-review-comment]`))e.removeAttribute(`data-review-comment`);if(t.length===0)return;let i=e=>t.some(t=>e>=t.start&&e<=t.end);for(let e of Array.from(r.children)){if(!(e instanceof HTMLElement)||!e.hasAttribute(`data-code`)||e.hasAttribute(`data-deletions`))continue;let t=e.querySelector(`[data-gutter]`),n=e.querySelector(`[data-content]`);if(!(!t||!n))for(let e=0;e{o.current?.focus()},[]);let s=()=>{let t=i.trim();!t||n||e(t)};return(0,j.jsxs)(`div`,{className:`comment-form`,onClick:e=>e.stopPropagation(),children:[(0,j.jsx)(`textarea`,{ref:o,value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),s()),e.key===`Escape`&&t()},placeholder:`Leave a review comment... (Cmd+Enter to submit)`,rows:3,disabled:n}),(0,j.jsxs)(`div`,{className:`comment-form-actions`,children:[r&&(0,j.jsx)(`div`,{className:`comment-form-error`,children:r}),(0,j.jsx)(Jm,{onClick:t,disabled:n,children:`Cancel`}),(0,j.jsx)(Jm,{variant:`primary`,disabled:!i.trim()||n,onClick:s,children:`Comment`})]})]})}var Xm=20,Zm=!1,Qm=(0,A.memo)(function({fileDiff:e,diffStyle:t,lineAnnotations:n,renderAnnotation:r,onGutterUtilityClick:i,commentsEnabled:a}){return(0,j.jsx)(`div`,{ref:(0,A.useCallback)(n=>{Zm||!n||requestAnimationFrame(()=>{let r=t===`split`?e.splitLineCount:e.unifiedLineCount;r>0&&n.offsetHeight>0&&(Xm=n.offsetHeight/r,Zm=!0)})},[e,t]),children:(0,j.jsx)(wm,{fileDiff:e,options:{theme:`github-dark-default`,diffStyle:t,diffIndicators:`classic`,hunkSeparators:`line-info-basic`,overflow:`wrap`,disableFileHeader:!0,enableGutterUtility:a,onGutterUtilityClick:i},lineAnnotations:n,renderAnnotation:r})})},(e,t)=>e.fileDiff===t.fileDiff&&e.diffStyle===t.diffStyle&&e.lineAnnotations===t.lineAnnotations);function $m({attrs:e}){if(!e)return null;let t=[{key:`generated`,label:`Generated`},{key:`vendored`,label:`Vendored`},{key:`documentation`,label:`Documentation`},{key:`binary`,label:`Binary`}].filter(t=>e[t.key]);return t.length===0?null:(0,j.jsx)(j.Fragment,{children:t.map(e=>(0,j.jsx)(`span`,{className:`attr-badge attr-badge-${e.key}`,children:e.label},e.key))})}function eh({fileDiff:e,attrs:t,isViewed:n,isStale:r,diffStyle:i,collapsed:a,composing:o,focused:s,onToggleCollapse:c,onToggleViewed:l,onStartComment:u,onSubmitComment:d,onResolveComment:f,onCancelComment:p,commentsEnabled:m,submitting:h,submitError:g}){let{additions:_,deletions:v}=zm(e),y=e.name,b=(0,A.useMemo)(()=>{if(!m)return[];let t=Rm(e,y);return o&&t.push({side:`additions`,lineNumber:o.line,metadata:{type:`composing`,file:y}}),t},[e,y,o,m]),x=(0,A.useCallback)(e=>{let t=e.metadata;return t.type===`review`?(0,j.jsx)(`div`,{className:`review-annotation`,children:(0,j.jsx)(Jm,{onClick:()=>f(t.startLine),children:`Resolve comment`})}):t.type===`composing`?(0,j.jsx)(Ym,{onSubmit:t=>d(e.lineNumber,t),onCancel:p,submitting:h,error:g}):null},[f,d,p,h,g]),S=(0,A.useCallback)(e=>{!m||e.side!==`additions`||b.some(t=>t.metadata.type===`review`&&e.start>=t.metadata.startLine&&e.start<=t.metadata.endLine)||u(e.start)},[u,m,b]),C=(0,A.useRef)(null),[w,ee]=(0,A.useState)(!1);(0,A.useEffect)(()=>{let e=C.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0]?.isIntersecting&&(ee(!0),t.disconnect())},{rootMargin:`2000px`});return t.observe(e),()=>t.disconnect()},[]);let T=w&&!a;(0,A.useEffect)(()=>{if(!T)return;let e=C.current;if(!e)return;let t=b.flatMap(e=>e.metadata.type===`review`?[{start:e.metadata.startLine,end:e.metadata.endLine}]:[]),n=0,r=null,i=0,a=()=>qm(e,t),o=()=>{let t=(e.querySelector(`diffs-container`)?.shadowRoot)?.querySelector(`pre`);if(!t){i++<60&&(n=requestAnimationFrame(o));return}a(),r=new MutationObserver(a),r.observe(t,{childList:!0,subtree:!0})};return n=requestAnimationFrame(o),()=>{cancelAnimationFrame(n),r?.disconnect()}},[b,T,i,e]);let te=(i===`split`?e.splitLineCount:e.unifiedLineCount)*Xm;return(0,j.jsxs)(`div`,{ref:C,className:`file-card`+(s?` focused`:``),children:[(0,j.jsxs)(`div`,{className:`file-header`,onClick:c,children:[(0,j.jsx)(`span`,{className:`collapse-chevron`+(a?` collapsed`:``),children:`▶`}),(0,j.jsx)(`span`,{className:`file-header-name`,children:y}),(0,j.jsxs)(`span`,{className:`file-header-stats`,children:[_>0&&(0,j.jsxs)(`span`,{className:`stat-add`,children:[`+`,_]}),v>0&&(0,j.jsxs)(`span`,{className:`stat-del`,children:[`-`,v]})]}),(0,j.jsx)($m,{attrs:t}),(0,j.jsxs)(`button`,{type:`button`,className:`viewed-button`+(r?` stale`:``)+(n?` checked`:``),onClick:e=>{e.stopPropagation(),l()},children:[(0,j.jsx)(`input`,{type:`checkbox`,className:`viewed-checkbox`+(r?` stale`:``),checked:n,readOnly:!0,tabIndex:-1}),r?`Changed`:`Viewed`]})]}),T?(0,j.jsx)(Qm,{fileDiff:e,diffStyle:i,lineAnnotations:b,renderAnnotation:x,onGutterUtilityClick:S,commentsEnabled:m}):!a&&(0,j.jsx)(`div`,{style:{height:te}})]})}function th(e){return e?e.generated||e.vendored||e.binary:!1}function nh({fileHashes:e,viewed:t,attrs:n}){let r=Object.entries(e).filter(([e])=>!th(n[e])),i=r.length,a=r.filter(([e,n])=>t[e]===n).length;return i===0?null:(0,j.jsxs)(`div`,{className:`progress-bar`,children:[(0,j.jsxs)(`span`,{children:[a,`/`,i,` files viewed`]}),(0,j.jsx)(`div`,{className:`progress-track`,children:(0,j.jsx)(`div`,{className:`progress-fill`,style:{width:`${a/i*100}%`}})})]})}function rh({onClose:e,children:t}){let n=(0,A.useRef)(null);return(0,A.useEffect)(()=>n.current?.showModal(),[]),(0,j.jsx)(`dialog`,{ref:n,className:`help-modal`,onClose:e,onClick:e=>{e.target===e.currentTarget&&n.current?.close()},children:t})}var ih=[[`j / k`,`Next / previous line`],[`n / p`,`Next / previous file`],[`v`,`Toggle viewed`],[`e / E`,`Toggle collapse file / all files`],[`s`,`Toggle split mode (responsive / unified)`],[`c`,`Comment on line`],[`Esc`,`Close / cancel`],[`?`,`Toggle this help`]];function ah({onClose:e}){return(0,j.jsxs)(rh,{onClose:e,children:[(0,j.jsx)(`h3`,{children:`Keyboard Shortcuts`}),(0,j.jsx)(`table`,{children:(0,j.jsx)(`tbody`,{children:ih.map(([e,t])=>(0,j.jsxs)(`tr`,{children:[(0,j.jsx)(`td`,{children:e.split(` / `).map((e,t)=>(0,j.jsxs)(`span`,{children:[t>0&&` / `,(0,j.jsx)(`kbd`,{children:e})]},e))}),(0,j.jsx)(`td`,{children:t})]},e))})})]})}function oh({onClose:e,vcs:t}){return(0,j.jsxs)(rh,{onClose:e,children:[(0,j.jsxs)(`p`,{children:[`Comments work by inserting lines into the code. This can only work if the`,` `,t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`revset includes `,(0,j.jsx)(`code`,{children:`@`})]}):`commit range includes the working copy`,`. Otherwise, the inserted lines will land in`,` `,t===`jj`?(0,j.jsx)(`code`,{children:`@`}):`the working copy`,` but not in the diff being viewed.`]}),t===`jj`?(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with the`,` `,(0,j.jsx)(`code`,{children:`-f`}),` flag to get a diff that starts at some revision and ends at`,` `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with `,(0,j.jsx)(`code`,{children:`-f`}),` `,`only (no `,(0,j.jsx)(`code`,{children:`-t`}),`), so the diff goes from a commit to the working tree.`]})]})}function sh({onLearnMore:e,vcs:t}){return(0,j.jsxs)(`div`,{className:`comments-disabled-banner`,children:[t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`Comments are disabled because the revset does not include `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):`Comments are disabled because the commit range does not end at the working copy.`,` `,(0,j.jsx)(`a`,{href:`#`,onClick:t=>{t.preventDefault(),e()},children:`Learn more`})]})}var ch=[`responsive`,`unified`];function lh(){let e=Im(),[t,n]=(0,A.useState)(`responsive`),r=t===`responsive`&&e?`split`:`unified`,{toast:i,showToast:a}=Lm(),[o,s]=(0,A.useState)({}),[c,l]=(0,A.useState)(null),u=(0,A.useRef)(!1),{data:d,error:f,isLoading:p}=ct({queryKey:[`diff`],queryFn:()=>Pm(`/api/diff`)});(0,A.useEffect)(()=>{d?.revset&&(document.title=`skepsis | ${d.revset}`)},[d?.revset]),(0,A.useEffect)(()=>{if(u.current||!d?.fileHashes)return;u.current=!0;let e={};for(let[t,n]of Object.entries(d.fileHashes))(d.viewed[t]===n||th(d.attrs[t]))&&(e[t]=!0);s(e)},[d]);let m=qe(),h=lt({mutationFn:({file:e,hash:t,mark:n})=>Pm(`/api/viewed`,{method:n?`POST`:`DELETE`,body:{file:e,hash:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),g=lt({mutationFn:({file:e,afterLine:t,text:n})=>Pm(`/api/comment`,{method:`POST`,body:{file:e,afterLine:t,text:n}}),onSuccess:()=>l(null),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),_=lt({mutationFn:({file:e,line:t})=>Pm(`/api/comment`,{method:`DELETE`,body:{file:e,line:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),v=d?.patch,y=(0,A.useMemo)(()=>v?qp(v).flatMap(e=>e.files):[],[v]),b=(0,A.useMemo)(()=>{let e={...d?.viewed};if(h.isPending&&h.variables){let{file:t,hash:n,mark:r}=h.variables;r?e[t]=n:delete e[t]}return e},[d,h.isPending,h.variables]),[x,S]=(0,A.useState)(0),[C,w]=(0,A.useState)(0),[ee,T]=(0,A.useState)(!1),[te,ne]=(0,A.useState)(!1),re=(0,A.useRef)(null),ie=(0,A.useRef)(0),ae=(0,A.useRef)({focusedFileIdx:0,cursorIdx:0,showHelp:!1,composing:null,files:[],collapsed:{},fileHashes:{},viewed:{},markMutate:h.mutate});ae.current={focusedFileIdx:x,cursorIdx:C,showHelp:ee,composing:c,files:y,collapsed:o,fileHashes:d?.fileHashes??{},viewed:b,markMutate:h.mutate},(0,A.useLayoutEffect)(()=>{Wm();let e=re.current;re.current=null,e===`file`&&document.querySelectorAll(`.file-card`)[x]?.scrollIntoView({block:`start`});let t=y[x];if(!t||(o[t.name]??!1))return;let n=Bm(x),r=n[Math.min(C,Math.max(n.length-1,0))];if(!r)return;let i=r.gutterEl.getRootNode();i instanceof ShadowRoot&&Gm(i),r.gutterEl.setAttribute(`data-selected-line`,`single`),r.contentEl.setAttribute(`data-selected-line`,`single`),e===`line`&&r.contentEl.scrollIntoView({block:`nearest`})},[x,C,v,c,o,y]),(0,A.useEffect)(()=>{function e(e){if(e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLInputElement||e.ctrlKey||e.metaKey||e.altKey)return;let t=ae.current;switch(e.key){case`?`:e.preventDefault(),T(e=>!e);break;case`Escape`:t.showHelp?(e.preventDefault(),T(!1)):t.composing&&(e.preventDefault(),l(null));break;case`n`:case`p`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=e.key===`n`?1:-1,r=Math.max(0,Math.min(t.focusedFileIdx+n,t.files.length-1));r!==t.focusedFileIdx&&(ie.current=Date.now(),S(r),w(0),re.current=`file`);break}case`j`:case`k`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx]?.name;if(!n||(t.collapsed[n]??!1))break;let r=Bm(t.focusedFileIdx);if(r.length===0)break;let i=t.cursorIdx,a=r[Math.min(i,r.length-1)];if(a&&!Um(a.contentEl,t.focusedFileIdx)){let e=Hm(t.focusedFileIdx);e>=0&&(i=e)}let o=Math.max(0,Math.min(i+(e.key===`j`?1:-1),r.length-1));o!==t.cursorIdx&&(ie.current=Date.now(),w(o),re.current=`line`);break}case`v`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;let r=t.fileHashes[n.name];if(!r)break;let i=t.viewed[n.name]!==r;s(e=>({...e,[n.name]:i})),t.markMutate({file:n.name,hash:r,mark:i});break}case`e`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;ie.current=Date.now(),s(e=>({...e,[n.name]:!(e[n.name]??!1)}));break}case`E`:{if(t.showHelp||t.composing)break;e.preventDefault(),ie.current=Date.now();let n=t.files.some(e=>!(t.collapsed[e.name]??!1));s(Object.fromEntries(t.files.map(e=>[e.name,n])));break}case`s`:if(t.showHelp||t.composing)break;e.preventDefault(),n(e=>{let t=ch[(ch.indexOf(e)+1)%ch.length];return a((0,j.jsxs)(j.Fragment,{children:[`Split mode: `,(0,j.jsx)(`code`,{children:t})]})),t});break;case`c`:{if(!d?.commentsEnabled||t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n||(t.collapsed[n.name]??!1))break;let r=Bm(t.focusedFileIdx)[t.cursorIdx];if(!r||r.lineType===`change-deletion`)break;l({file:n.name,line:r.lineNumber});break}}}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[a,d?.commentsEnabled]);let oe=(0,A.useRef)(new Set);if((0,A.useEffect)(()=>{oe.current.clear();let e=new IntersectionObserver(e=>{for(let t of e){let e=Number(t.target.dataset.fileIdx);t.isIntersecting?oe.current.add(e):oe.current.delete(e)}if(Date.now()-ie.current<150||oe.current.size===0)return;let t=Math.min(...oe.current),n=ae.current;if(t!==n.focusedFileIdx){S(t);let e=n.files[t];if(e&&!(n.collapsed[e.name]??!1)){let e=Hm(t);e>=0&&w(e)}}},{threshold:0});return document.querySelectorAll(`.file-card`).forEach((t,n)=>{t.dataset.fileIdx=String(n),e.observe(t)}),()=>e.disconnect()},[y]),p||!d)return(0,j.jsx)(`div`,{className:`empty-diff`,children:`Loading...`});if(f)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:String(f)});if(d.error)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:d.error});if(!v)return(0,j.jsxs)(`div`,{className:`empty-diff`,children:[(0,j.jsx)(`code`,{children:d.revset}),` is empty`]});let{fileHashes:se}=d;return(0,j.jsxs)(j.Fragment,{children:[!d.commentsEnabled&&(0,j.jsx)(sh,{vcs:d.vcs,onLearnMore:()=>ne(!0)}),(0,j.jsxs)(`div`,{className:`diff-container`,children:[(0,j.jsx)(nh,{fileHashes:se,viewed:b,attrs:d.attrs}),ee&&(0,j.jsx)(ah,{onClose:()=>T(!1)}),te&&(0,j.jsx)(oh,{vcs:d.vcs,onClose:()=>ne(!1)}),i&&(0,j.jsx)(`div`,{className:`toast`,children:i.content},i.key),y.map((e,t)=>{let n=e.name,i=se[n],a=b[n],u=i!=null&&a===i,f=a!=null&&i!=null&&a!==i,p=o[n]??!1,m=c?.file===n?{line:c.line}:null;return(0,j.jsx)(eh,{fileDiff:e,attrs:d.attrs[n],isViewed:u,isStale:f,diffStyle:r,collapsed:p,composing:m,focused:t===x,onToggleCollapse:()=>s(e=>({...e,[n]:!p})),onToggleViewed:()=>{if(i){let e=!u;s(t=>({...t,[n]:e})),h.mutate({file:n,hash:i,mark:e})}},onStartComment:e=>{g.reset(),l({file:n,line:e})},onSubmitComment:(e,t)=>g.mutate({file:n,afterLine:e,text:t}),onResolveComment:e=>_.mutate({file:n,line:e}),onCancelComment:()=>l(null),commentsEnabled:d.commentsEnabled,submitting:g.isPending&&g.variables?.file===n,submitError:g.variables?.file===n&&g.error?g.error.message:null},n??t)})]})]})}function uh(){return(0,j.jsx)(Je,{client:Nm,children:(0,j.jsx)(lh,{})})}(0,u.createRoot)(document.getElementById(`root`)).render((0,j.jsx)(uh,{})); \ No newline at end of file diff --git a/dist/assets/index-BMNMHQlP.css b/dist/assets/index-BMNMHQlP.css new file mode 100644 index 0000000..2c73957 --- /dev/null +++ b/dist/assets/index-BMNMHQlP.css @@ -0,0 +1 @@ +body{color:#c9d1d9;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#151b23;margin:0;font-family:system-ui,-apple-system,sans-serif}.diff-container{flex-direction:column;gap:16px;max-width:1800px;margin:0 auto;padding:16px 24px;display:flex}.file-card{--diffs-font-size:13px;--diffs-font-family:monospace;--diffs-bg-separator-override:#1c2333;--diffs-modified-color-override:#1f6feb;border:1px solid #30363d;border-radius:8px;overflow:clip}.file-header{z-index:10;cursor:pointer;-webkit-user-select:none;user-select:none;background:#161b22;border-bottom:1px solid #30363d;align-items:center;gap:12px;padding:8px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex;position:sticky;top:0}.file-card:has(>.file-header:only-child)>.file-header{border-bottom:none}.collapse-chevron{color:#8b949e;flex-shrink:0;font-size:10px;transition:transform .15s;transform:rotate(90deg)}.collapse-chevron.collapsed{transform:rotate(0)}.file-header-name{color:#e6edf3;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-weight:600;overflow:hidden}.file-header-stats{flex-shrink:0;gap:6px;font-family:monospace;font-size:12px;display:flex}.stat-add{color:#3fb950}.stat-del{color:#f85149}.attr-badge{color:#8b949e;text-transform:uppercase;letter-spacing:.04em;background:#161b22;border:1px solid #30363d;border-radius:10px;align-self:center;padding:1px 6px;font-family:-apple-system,system-ui,sans-serif;font-size:10px}.viewed-button{color:#8b949e;cursor:pointer;-webkit-user-select:none;user-select:none;background:#21262d;border:1px solid #30363d;border-radius:6px;flex-shrink:0;align-items:center;gap:6px;padding:4px 10px 4px 6px;font-family:inherit;font-size:12px;font-weight:500;display:flex}.viewed-button:hover{background:#292e36;border-color:#484f58}.viewed-button.checked{color:#58a6ff}.viewed-button.stale{color:#d29922}.viewed-checkbox{appearance:none;cursor:pointer;pointer-events:none;background:0 0;border:1px solid #484f58;border-radius:3px;flex-shrink:0;width:14px;height:14px;position:relative}.viewed-checkbox:checked{background:#1f6feb;border-color:#1f6feb}.viewed-checkbox:checked:after{content:"";border:2px solid #fff;border-width:0 2px 2px 0;width:5px;height:8px;position:absolute;top:0;left:3px;transform:rotate(45deg)}.viewed-checkbox.stale{border-color:#d29922}.viewed-checkbox.stale:checked{background:#9e6a03;border-color:#9e6a03}.progress-bar{color:#8b949e;align-items:center;gap:10px;padding:8px 0;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex}.progress-track{background:#21262d;border-radius:2px;flex:1;height:4px;overflow:hidden}.progress-fill{background:#238636;border-radius:2px;height:100%;transition:width .2s}.add-comment-button{color:#fff;cursor:pointer;background:#1f6feb;border:none;border-radius:50%;justify-content:center;align-items:center;width:22px;height:22px;padding:0;font-size:16px;font-weight:600;line-height:1;display:flex}.add-comment-button:hover{background:#388bfd}.comment-form{background:#1c2128;border-top:1px solid #30363d;padding:8px 12px}.comment-form textarea{color:#e6edf3;resize:vertical;box-sizing:border-box;background:#0d1117;border:1px solid #30363d;border-radius:6px;width:100%;min-height:60px;padding:8px;font-family:system-ui,-apple-system,sans-serif;font-size:14px}.comment-form textarea:focus{border-color:#1f6feb;outline:none}.comment-form-actions{justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;display:flex}.comment-form-error{color:#f85149;margin-right:auto;font-family:system-ui,-apple-system,sans-serif;font-size:13px}.btn{cursor:pointer;border-radius:6px;padding:6px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:14px;font-weight:500}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-secondary{color:#e6edf3;background:#262c34;border:1px solid #484f58}.btn-secondary:hover:not(:disabled){background:#2d333b;border-color:#6e7681}.btn-primary{color:#fff;background:#238636;border:1px solid #0000}.btn-primary:hover:not(:disabled){background:#2ea043}.review-annotation{background:#1c2128;border-top:1px solid #30363d;justify-content:flex-end;padding:4px 12px;display:flex}.file-card.focused{border-color:#1f6feb}.file-card.focused>.file-header{border-bottom-color:#1f6feb}.help-modal{color:#e6edf3;background:#161b22;border:1px solid #30363d;border-radius:10px;min-width:320px;max-width:400px;padding:20px;font-family:system-ui,-apple-system,sans-serif}.help-modal p{margin:0 0 12px;font-size:14px}.help-modal p:last-of-type{margin:0}.help-modal:focus{outline:none}.help-modal::backdrop{background:#00000080}.help-modal h3{margin:0 0 16px;font-size:15px;font-weight:600}.help-modal table{border-spacing:0;width:100%}.help-modal td{padding:5px 0;font-size:13px}.help-modal td:first-child{white-space:nowrap;color:#8b949e;padding-right:24px}.toast{color:#e6edf3;z-index:200;pointer-events:none;background:#21262d;border:1px solid #484f58;border-radius:8px;padding:12px 20px;font-family:system-ui,-apple-system,sans-serif;font-size:16px;font-weight:500;animation:1.5s forwards toast-fade;position:fixed;bottom:24px;left:24px}@keyframes toast-fade{0%,60%{opacity:1}to{opacity:0}}.comments-disabled-banner{color:oklch(70% .14 260);text-align:center;background:oklch(22% .05 260);border-bottom:1px solid oklch(35% .07 260);padding:8px 16px;font-size:13px}.comments-disabled-banner a{color:oklch(78% .14 260);cursor:pointer;text-decoration:underline}.empty-diff{padding:20px;font-size:16px}.empty-diff code{color:#e6edf3;background:#30363d;border-radius:3px;padding:2px 4px;font-size:14px}.help-modal kbd{color:#e6edf3;text-align:center;background:#21262d;border:1px solid #30363d;border-radius:4px;min-width:18px;padding:2px 6px;font-family:monospace;font-size:12px;display:inline-block} diff --git a/dist/assets/index-rQG9O9IS.css b/dist/assets/index-rQG9O9IS.css deleted file mode 100644 index 9c0f150..0000000 --- a/dist/assets/index-rQG9O9IS.css +++ /dev/null @@ -1 +0,0 @@ -body{color:#c9d1d9;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#151b23;margin:0;font-family:system-ui,-apple-system,sans-serif}.diff-container{flex-direction:column;gap:16px;max-width:1800px;margin:0 auto;padding:16px 24px;display:flex}.file-card{--diffs-font-size:13px;--diffs-font-family:monospace;--diffs-bg-separator-override:#1c2333;--diffs-modified-color-override:#1f6feb;border:1px solid #30363d;border-radius:8px;overflow:clip}.file-header{z-index:10;cursor:pointer;-webkit-user-select:none;user-select:none;background:#161b22;border-bottom:1px solid #30363d;align-items:center;gap:12px;padding:8px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex;position:sticky;top:0}.file-card:has(>.file-header:only-child)>.file-header{border-bottom:none}.collapse-chevron{color:#8b949e;flex-shrink:0;font-size:10px;transition:transform .15s;transform:rotate(90deg)}.collapse-chevron.collapsed{transform:rotate(0)}.file-header-name{color:#e6edf3;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-weight:600;overflow:hidden}.file-header-stats{flex-shrink:0;gap:6px;font-family:monospace;font-size:12px;display:flex}.stat-add{color:#3fb950}.stat-del{color:#f85149}.viewed-button{color:#8b949e;cursor:pointer;-webkit-user-select:none;user-select:none;background:#21262d;border:1px solid #30363d;border-radius:6px;flex-shrink:0;align-items:center;gap:6px;padding:4px 10px 4px 6px;font-family:inherit;font-size:12px;font-weight:500;display:flex}.viewed-button:hover{background:#292e36;border-color:#484f58}.viewed-button.checked{color:#58a6ff}.viewed-button.stale{color:#d29922}.viewed-checkbox{appearance:none;cursor:pointer;pointer-events:none;background:0 0;border:1px solid #484f58;border-radius:3px;flex-shrink:0;width:14px;height:14px;position:relative}.viewed-checkbox:checked{background:#1f6feb;border-color:#1f6feb}.viewed-checkbox:checked:after{content:"";border:2px solid #fff;border-width:0 2px 2px 0;width:5px;height:8px;position:absolute;top:0;left:3px;transform:rotate(45deg)}.viewed-checkbox.stale{border-color:#d29922}.viewed-checkbox.stale:checked{background:#9e6a03;border-color:#9e6a03}.progress-bar{color:#8b949e;align-items:center;gap:10px;padding:8px 0;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex}.progress-track{background:#21262d;border-radius:2px;flex:1;height:4px;overflow:hidden}.progress-fill{background:#238636;border-radius:2px;height:100%;transition:width .2s}.add-comment-button{color:#fff;cursor:pointer;background:#1f6feb;border:none;border-radius:50%;justify-content:center;align-items:center;width:22px;height:22px;padding:0;font-size:16px;font-weight:600;line-height:1;display:flex}.add-comment-button:hover{background:#388bfd}.comment-form{background:#1c2128;border-top:1px solid #30363d;padding:8px 12px}.comment-form textarea{color:#e6edf3;resize:vertical;box-sizing:border-box;background:#0d1117;border:1px solid #30363d;border-radius:6px;width:100%;min-height:60px;padding:8px;font-family:system-ui,-apple-system,sans-serif;font-size:14px}.comment-form textarea:focus{border-color:#1f6feb;outline:none}.comment-form-actions{justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;display:flex}.comment-form-error{color:#f85149;margin-right:auto;font-family:system-ui,-apple-system,sans-serif;font-size:13px}.btn{cursor:pointer;border-radius:6px;padding:6px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:14px;font-weight:500}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-secondary{color:#e6edf3;background:#262c34;border:1px solid #484f58}.btn-secondary:hover:not(:disabled){background:#2d333b;border-color:#6e7681}.btn-primary{color:#fff;background:#238636;border:1px solid #0000}.btn-primary:hover:not(:disabled){background:#2ea043}.review-annotation{background:#1c2128;border-top:1px solid #30363d;justify-content:flex-end;padding:4px 12px;display:flex}.file-card.focused{border-color:#1f6feb}.file-card.focused>.file-header{border-bottom-color:#1f6feb}.help-modal{color:#e6edf3;background:#161b22;border:1px solid #30363d;border-radius:10px;min-width:320px;max-width:400px;padding:20px;font-family:system-ui,-apple-system,sans-serif}.help-modal p{margin:0 0 12px;font-size:14px}.help-modal p:last-of-type{margin:0}.help-modal:focus{outline:none}.help-modal::backdrop{background:#00000080}.help-modal h3{margin:0 0 16px;font-size:15px;font-weight:600}.help-modal table{border-spacing:0;width:100%}.help-modal td{padding:5px 0;font-size:13px}.help-modal td:first-child{white-space:nowrap;color:#8b949e;padding-right:24px}.toast{color:#e6edf3;z-index:200;pointer-events:none;background:#21262d;border:1px solid #484f58;border-radius:8px;padding:12px 20px;font-family:system-ui,-apple-system,sans-serif;font-size:16px;font-weight:500;animation:1.5s forwards toast-fade;position:fixed;bottom:24px;left:24px}@keyframes toast-fade{0%,60%{opacity:1}to{opacity:0}}.comments-disabled-banner{color:oklch(70% .14 260);text-align:center;background:oklch(22% .05 260);border-bottom:1px solid oklch(35% .07 260);padding:8px 16px;font-size:13px}.comments-disabled-banner a{color:oklch(78% .14 260);cursor:pointer;text-decoration:underline}.empty-diff{padding:20px;font-size:16px}.empty-diff code{color:#e6edf3;background:#30363d;border-radius:3px;padding:2px 4px;font-size:14px}.help-modal kbd{color:#e6edf3;text-align:center;background:#21262d;border:1px solid #30363d;border-radius:4px;min-width:18px;padding:2px 6px;font-family:monospace;font-size:12px;display:inline-block} diff --git a/dist/index.html b/dist/index.html index 6cfceb1..44b6103 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,9 +12,9 @@ skepsis - + - +
diff --git a/package-lock.json b/package-lock.json index 8f443d0..296c050 100644 --- a/package-lock.json +++ b/package-lock.json @@ -697,9 +697,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -717,9 +714,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -737,9 +731,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +748,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -777,9 +765,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -797,9 +782,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +799,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -837,9 +816,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1044,9 +1020,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1064,9 +1037,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1084,9 +1054,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1104,9 +1071,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1124,9 +1088,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1144,9 +1105,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1164,9 +1122,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1184,9 +1139,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1384,9 +1336,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1404,9 +1353,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1424,9 +1370,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1444,9 +1387,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1464,9 +1404,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1484,9 +1421,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2281,9 +2215,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2305,9 +2236,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2329,9 +2257,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2353,9 +2278,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/server/attrs.test.ts b/server/attrs.test.ts new file mode 100644 index 0000000..b1fd256 --- /dev/null +++ b/server/attrs.test.ts @@ -0,0 +1,155 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { mergeAttrs, parseCheckAttrOutput } from './attrs.ts' + +describe('parseCheckAttrOutput', () => { + it('returns empty object for empty input', () => { + expect(parseCheckAttrOutput('')).toEqual({}) + }) + + it('parses a generated file as an explicit set', () => { + const out = [ + 'pnpm-lock.yaml: linguist-generated: set', + 'pnpm-lock.yaml: linguist-vendored: unspecified', + 'pnpm-lock.yaml: linguist-documentation: unspecified', + 'pnpm-lock.yaml: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({ + 'pnpm-lock.yaml': { generated: true }, + }) + }) + + it('drops files where nothing was set or unset', () => { + const out = [ + 'src/foo.ts: linguist-generated: unspecified', + 'src/foo.ts: linguist-vendored: unspecified', + 'src/foo.ts: linguist-documentation: unspecified', + 'src/foo.ts: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({}) + }) + + it('records `unset` as an explicit false', () => { + const out = [ + 'README.md: linguist-generated: unset', + 'README.md: linguist-vendored: unspecified', + 'README.md: linguist-documentation: set', + 'README.md: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({ + 'README.md': { generated: false, documentation: true }, + }) + }) + + it('parses multiple files', () => { + const out = [ + 'pnpm-lock.yaml: linguist-generated: set', + 'pnpm-lock.yaml: linguist-vendored: unspecified', + 'pnpm-lock.yaml: linguist-documentation: unspecified', + 'pnpm-lock.yaml: binary: unspecified', + 'third-party/lib.js: linguist-generated: unspecified', + 'third-party/lib.js: linguist-vendored: set', + 'third-party/lib.js: linguist-documentation: unspecified', + 'third-party/lib.js: binary: unspecified', + 'logo.png: linguist-generated: unspecified', + 'logo.png: linguist-vendored: unspecified', + 'logo.png: linguist-documentation: unspecified', + 'logo.png: binary: set', + '', + ].join('\n') + const result = parseCheckAttrOutput(out) + expect(result['pnpm-lock.yaml']?.generated).toBe(true) + expect(result['third-party/lib.js']?.vendored).toBe(true) + expect(result['logo.png']?.binary).toBe(true) + expect(Object.keys(result).length).toBe(3) + }) + + it('handles paths containing colons', () => { + // Unusual but legal — git doesn't quote these, so the parser must split + // from the right. + const out = [ + 'weird: file: linguist-generated: set', + 'weird: file: linguist-vendored: unspecified', + 'weird: file: linguist-documentation: unspecified', + 'weird: file: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)['weird: file']?.generated).toBe(true) + }) + + it('ignores unrelated attribute lines', () => { + const out = [ + 'foo.ts: unrelated-attr: set', + 'foo.ts: linguist-generated: set', + 'foo.ts: linguist-vendored: unspecified', + 'foo.ts: linguist-documentation: unspecified', + 'foo.ts: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({ + 'foo.ts': { generated: true }, + }) + }) +}) + +describe('mergeAttrs', () => { + it('returns {} for no layers', () => { + expect(mergeAttrs([])).toEqual({}) + }) + + it('returns {} when nothing ends up set', () => { + expect(mergeAttrs([{ 'foo.ts': { generated: false } }])).toEqual({}) + }) + + it('materializes a single layer to full FileAttrs', () => { + expect(mergeAttrs([{ 'foo.ts': { generated: true } }])).toEqual({ + 'foo.ts': { + generated: true, + vendored: false, + documentation: false, + binary: false, + }, + }) + }) + + it('lets a higher-priority layer override a lower one (per attribute)', () => { + // check-attr says linguist-generated: unset, even though path rules say + // it's generated. Expect the explicit unset to win. + const checkAttr = { 'weird.js': { generated: false } } + const pathRules = { 'weird.js': { generated: true } } + expect(mergeAttrs([checkAttr, pathRules])).toEqual({}) + }) + + it('falls through to the next layer when the first is silent on an attr', () => { + // check-attr sets linguist-documentation, says nothing about generated. + // path rules say the file is generated. Expect both to apply. + const checkAttr = { 'foo.md': { documentation: true } } + const pathRules = { 'foo.md': { generated: true } } + expect(mergeAttrs([checkAttr, pathRules])).toEqual({ + 'foo.md': { + generated: true, + vendored: false, + documentation: true, + binary: false, + }, + }) + }) + + it('unions paths across layers', () => { + const a = { 'a.ts': { generated: true } } + const b = { 'b.ts': { vendored: true } } + const merged = mergeAttrs([a, b]) + expect(merged['a.ts']?.generated).toBe(true) + expect(merged['b.ts']?.vendored).toBe(true) + }) +}) diff --git a/server/attrs.ts b/server/attrs.ts new file mode 100644 index 0000000..97cb22a --- /dev/null +++ b/server/attrs.ts @@ -0,0 +1,213 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Per-file attributes that affect how skepsis displays a file in the diff. + * Mirrors GitHub Linguist: `linguist-generated`, `linguist-vendored`, + * `linguist-documentation`, and `binary`. Sources (highest priority first): + * + * 1. `.gitattributes` via `git check-attr` — explicit `set`/`unset`. + * 2. Linguist's built-in path rules (`linguist/pathRules.ts`). + * + * Generated/vendored/binary files are auto-collapsed by the frontend; + * documentation files get a badge but stay expanded. + */ + +import { spawn } from 'node:child_process' +import type { FileAttrs, FileHashes } from '../shared/types.ts' +import { classifyByPaths } from './linguist/pathRules.ts' +import { classifyByContent } from './linguist/contentRules.ts' +import { readBlobSummaries } from './linguist/contentReader.ts' + +/** + * A `Partial` tracks which attributes a layer has decided on. + * A missing key means "no opinion" — the next layer gets a turn. + */ +export type PartialAttrMap = Record> + +const TRACKED_ATTRS = [ + 'linguist-generated', + 'linguist-vendored', + 'linguist-documentation', + 'binary', +] as const + +type TrackedAttr = (typeof TRACKED_ATTRS)[number] + +/** + * Resolve the full attribute map for a diff by combining (highest priority + * first): + * + * 1. `.gitattributes` via `git check-attr`. + * 2. Linguist's built-in path rules. + * 3. Linguist's built-in content rules (reads blob contents via + * `git cat-file`; only runs where `generated` isn't already decided). + * + * Files without any set attribute are omitted from the result. + */ +export async function resolveFileAttrs( + fileHashes: FileHashes, + cwd: string, +): Promise> { + const files = Object.keys(fileHashes) + if (files.length === 0) return {} + + const [gitAttrs, pathAttrs] = await Promise.all([ + getGitAttrs(files, cwd), + Promise.resolve(classifyByPaths(files)), + ]) + + // Only read content for files whose `generated` status isn't already + // decided. check-attr `unset` wins — we respect the user's override and + // skip content checks. + const needsContent: FileHashes = {} + for (const [path, hash] of Object.entries(fileHashes)) { + const git = gitAttrs[path]?.generated + const byPath = pathAttrs[path]?.generated + if (git === undefined && byPath !== true) needsContent[path] = hash + } + + const contentAttrs: PartialAttrMap = {} + if (Object.keys(needsContent).length > 0) { + const summaries = await readBlobSummaries(needsContent, cwd) + for (const [path, summary] of Object.entries(summaries)) { + const attrs = classifyByContent(path, summary) + if (Object.keys(attrs).length > 0) contentAttrs[path] = attrs + } + } + + return mergeAttrs([gitAttrs, pathAttrs, contentAttrs]) +} + +/** + * Fetch `.gitattributes` attributes for the given file paths. Presence of + * a key in the inner `Partial` means check-attr returned + * `set` or `unset` for that attribute; `unspecified` is represented as a + * missing key so lower-priority layers can contribute. + * + * Resolves to `{}` if `git check-attr` is unavailable (e.g., pure-jj repo + * with no `.git/`). + */ +export async function getGitAttrs(files: string[], cwd: string): Promise { + if (files.length === 0) return {} + + let stdout: string + try { + stdout = await runCheckAttr(files, cwd) + } catch { + return {} + } + return parseCheckAttrOutput(stdout) +} + +function runCheckAttr(files: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('git', ['check-attr', '--stdin', ...TRACKED_ATTRS], { + cwd, + }) + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + proc.stdout.on('data', (d: Buffer) => stdoutChunks.push(d)) + proc.stderr.on('data', (d: Buffer) => stderrChunks.push(d)) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdoutChunks).toString()) + } else { + reject( + new Error( + `git check-attr exited ${code}: ${Buffer.concat(stderrChunks).toString()}`, + ), + ) + } + }) + proc.stdin.end(files.join('\n') + '\n') + }) +} + +const ATTR_TO_FIELD: Record = { + 'linguist-generated': 'generated', + 'linguist-vendored': 'vendored', + 'linguist-documentation': 'documentation', + binary: 'binary', +} + +/** + * Parse `git check-attr --stdin` output. Format per line: + * : : + * where value is `set`, `unset`, `unspecified`, or a literal string. + * `unspecified` is dropped so lower-priority sources can contribute. + */ +export function parseCheckAttrOutput(stdout: string): PartialAttrMap { + const result: PartialAttrMap = {} + + for (const line of stdout.split('\n')) { + if (!line) continue + + // Parse "path: attr: value" from the right, since paths may contain colons. + const lastColon = line.lastIndexOf(': ') + if (lastColon === -1) continue + const value = line.slice(lastColon + 2) + const pathAndAttr = line.slice(0, lastColon) + + const midColon = pathAndAttr.lastIndexOf(': ') + if (midColon === -1) continue + const path = pathAndAttr.slice(0, midColon) + const attr = pathAndAttr.slice(midColon + 2) + + if (!isTrackedAttr(attr)) continue + if (value !== 'set' && value !== 'unset') continue + + const attrs = (result[path] ??= {}) + attrs[ATTR_TO_FIELD[attr]] = value === 'set' + } + + return result +} + +/** + * Merge attribute layers in priority order (first wins per attribute). + * Materializes to full `FileAttrs` objects and drops entries where + * nothing ended up set. + */ +export function mergeAttrs(layers: PartialAttrMap[]): Record { + const paths = new Set() + for (const layer of layers) { + for (const path of Object.keys(layer)) paths.add(path) + } + + const result: Record = {} + const fields: (keyof FileAttrs)[] = ['generated', 'vendored', 'documentation', 'binary'] + + for (const path of paths) { + const out: FileAttrs = { + generated: false, + vendored: false, + documentation: false, + binary: false, + } + for (const field of fields) { + for (const layer of layers) { + const v = layer[path]?.[field] + if (v !== undefined) { + out[field] = v + break + } + } + } + if (out.generated || out.vendored || out.documentation || out.binary) { + result[path] = out + } + } + + return result +} + +function isTrackedAttr(s: string): s is TrackedAttr { + return (TRACKED_ATTRS as readonly string[]).includes(s) +} diff --git a/server/linguist/contentReader.test.ts b/server/linguist/contentReader.test.ts new file mode 100644 index 0000000..0373854 --- /dev/null +++ b/server/linguist/contentReader.test.ts @@ -0,0 +1,63 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { parseCatFileBatch } from './contentReader.ts' + +function concat(...parts: (string | Buffer)[]): Buffer { + return Buffer.concat( + parts.map((p) => (typeof p === 'string' ? Buffer.from(p, 'utf-8') : p)), + ) +} + +describe('parseCatFileBatch', () => { + it('returns empty map for empty input', () => { + expect(parseCatFileBatch(Buffer.alloc(0))).toEqual(new Map()) + }) + + it('parses a single blob', () => { + const content = 'hello world\n' + const size = Buffer.byteLength(content, 'utf-8') + const buf = concat(`aabbccdd blob ${size}\n`, content, '\n') + const result = parseCatFileBatch(buf) + expect(result.get('aabbccdd')).toBe(content) + expect(result.size).toBe(1) + }) + + it('skips missing objects', () => { + const content = 'hi\n' + const size = Buffer.byteLength(content, 'utf-8') + const buf = concat('deadbeef missing\n', `cafebabe blob ${size}\n`, content, '\n') + const result = parseCatFileBatch(buf) + expect(result.size).toBe(1) + expect(result.get('cafebabe')).toBe(content) + }) + + it('parses multiple blobs in a row', () => { + const a = 'one\n' + const b = 'two lines\nhere\n' + const buf = concat( + `aaaaaaaa blob ${Buffer.byteLength(a, 'utf-8')}\n`, + a, + '\n', + `bbbbbbbb blob ${Buffer.byteLength(b, 'utf-8')}\n`, + b, + '\n', + ) + const result = parseCatFileBatch(buf) + expect(result.get('aaaaaaaa')).toBe(a) + expect(result.get('bbbbbbbb')).toBe(b) + }) + + it('handles content that embeds newlines', () => { + const content = 'line1\nline2\nline3' + const size = Buffer.byteLength(content, 'utf-8') + const buf = concat(`abc123 blob ${size}\n`, content, '\n') + expect(parseCatFileBatch(buf).get('abc123')).toBe(content) + }) +}) diff --git a/server/linguist/contentReader.ts b/server/linguist/contentReader.ts new file mode 100644 index 0000000..f53a965 --- /dev/null +++ b/server/linguist/contentReader.ts @@ -0,0 +1,113 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Read blob contents from the git object store in one batched subprocess, + * then summarize each blob as line arrays for the content-based Linguist + * rules. + * + * We use the blob object IDs that skepsis already extracts from the diff + * (the "newObjectId" side). Deleted files (all-zero hash) are skipped, + * and blobs that git reports as missing are silently omitted — the + * caller treats them as "no content signal." + */ + +import { spawn } from 'node:child_process' + +export interface BlobSummary { + /** All lines of the blob (split on \n — trailing empty string preserved). */ + lines: string[] +} + +export async function readBlobSummaries( + blobIdsByPath: Record, + cwd: string, +): Promise> { + const uniqueIds = new Set() + for (const id of Object.values(blobIdsByPath)) { + if (id && !/^0+$/.test(id)) uniqueIds.add(id) + } + if (uniqueIds.size === 0) return {} + + let blobs: Map + try { + blobs = await readBlobs([...uniqueIds], cwd) + } catch { + return {} + } + + const result: Record = {} + for (const [path, id] of Object.entries(blobIdsByPath)) { + const content = blobs.get(id) + if (content === undefined) continue + result[path] = { lines: content.split('\n') } + } + return result +} + +function readBlobs(blobIds: string[], cwd: string): Promise> { + return new Promise((resolve, reject) => { + const proc = spawn('git', ['cat-file', '--batch', '--buffer'], { cwd }) + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + proc.stdout.on('data', (d: Buffer) => stdoutChunks.push(d)) + proc.stderr.on('data', (d: Buffer) => stderrChunks.push(d)) + proc.on('error', reject) + proc.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `git cat-file exited ${code}: ${Buffer.concat(stderrChunks).toString()}`, + ), + ) + return + } + try { + resolve(parseCatFileBatch(Buffer.concat(stdoutChunks))) + } catch (e) { + reject(e) + } + }) + proc.stdin.end(blobIds.join('\n') + '\n') + }) +} + +/** + * Parse `git cat-file --batch` output. For each requested object id, git + * emits either: + * missing\n + * or: + * \n + * \n + * + * Exported for tests. + */ +export function parseCatFileBatch(buf: Buffer): Map { + const result = new Map() + const LF = 0x0a + let i = 0 + while (i < buf.length) { + const nl = buf.indexOf(LF, i) + if (nl === -1) break + const header = buf.slice(i, nl).toString('utf-8') + i = nl + 1 + const parts = header.split(' ') + const sha = parts[0] + if (!sha) break + if (parts[1] === 'missing') continue + const size = Number(parts[2]) + if (!Number.isFinite(size) || size < 0) break + if (i + size > buf.length) break + const content = buf.slice(i, i + size).toString('utf-8') + result.set(sha, content) + i += size + // Skip the trailing LF after the content, if present. + if (buf[i] === LF) i += 1 + } + return result +} diff --git a/server/linguist/contentRules.test.ts b/server/linguist/contentRules.test.ts new file mode 100644 index 0000000..410ef2d --- /dev/null +++ b/server/linguist/contentRules.test.ts @@ -0,0 +1,303 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { classifyByContent } from './contentRules.ts' +import type { BlobSummary } from './contentReader.ts' + +function summary(content: string): BlobSummary { + return { lines: content.split('\n') } +} + +function expectGenerated(path: string, content: string): void { + expect(classifyByContent(path, summary(content))).toEqual({ + generated: true, + }) +} + +function expectNotGenerated(path: string, content: string): void { + expect(classifyByContent(path, summary(content))).toEqual({}) +} + +describe('classifyByContent — minified', () => { + it('flags .js when avg line length > 110', () => { + const content = 'x'.repeat(500) + '\n' + 'y'.repeat(500) + expectGenerated('app.js', content) + }) + + it("doesn't flag short .js files", () => { + expectNotGenerated('src/app.js', 'const a = 1\nconst b = 2\n') + }) + + it("doesn't flag .ts even when long", () => { + // Minification detection is only for .js/.css per Linguist. + const content = 'x'.repeat(500) + '\n' + 'y'.repeat(500) + expectNotGenerated('app.ts', content) + }) +}) + +describe('classifyByContent — source map references and files', () => { + it('flags .js with trailing sourceMappingURL comment', () => { + expectGenerated('bundle.js', 'const x = 1\n//# sourceMappingURL=bundle.js.map\n') + }) + + it('flags .map files by name', () => { + expectGenerated('bundle.js.map', '{"version":3,"sources":["foo.js"]}') + expectGenerated('styles.css.map', '{"version":3}') + }) + + it('flags .map files by revision 2 header', () => { + expectGenerated('foo.map', '{"version":3,"sources":[]}') + }) + + it("doesn't flag random .map files without the header", () => { + expectNotGenerated('world.map', 'just some map data\n') + }) +}) + +describe('classifyByContent — compiled CoffeeScript', () => { + it('flags .js with "// Generated by" comment on the first line', () => { + expectGenerated('app.js', '// Generated by CoffeeScript 1.12.7\nconst x = 1') + }) + + it('flags via structural pattern + score threshold', () => { + const lines = [ + '(function() {', + ' var _fn, _i, _len, _ref, _results;', + ' var __bind = function() {};', + '}).call(this);', + '', + ] + expectGenerated('app.js', lines.join('\n')) + }) + + it("doesn't flag structurally-similar non-Coffee code", () => { + const lines = ['(function() {', ' const x = 1', '}).call(this);', ''] + expectNotGenerated('app.js', lines.join('\n')) + }) +}) + +describe('classifyByContent — generated .NET docfile', () => { + it('flags .xml with //', () => { + const content = [ + '', + '', + ' Foo', + ' ', + '', + '', + ].join('\n') + expectGenerated('Foo.xml', content) + }) +}) + +describe('classifyByContent — PEG.js parser', () => { + it('flags .js with PEG.js banner in first 5 lines', () => { + expectGenerated( + 'parser.js', + '/*\n * Generated by PEG.js 0.10.0.\n */\nmodule.exports = ...\n', + ) + }) +}) + +describe('classifyByContent — PostScript', () => { + it('flags .ps with ImageMagick Creator line', () => { + expectGenerated( + 'graph.ps', + '%!PS-Adobe-3.0\n%%Creator: ImageMagick 7.1.0\n%%Title: foo\n', + ) + }) + + it('flags .ps with eexec marker', () => { + expectGenerated('font.ps', 'currentfile eexec\nAABBCC\n') + }) +}) + +describe('classifyByContent — protobuf variants', () => { + it('flags .proto files from go-to-protobuf', () => { + expectGenerated( + 'foo.proto', + '// This file was autogenerated by go-to-protobuf. DO NOT EDIT.\nsyntax = "proto3";\n', + ) + }) + + it('flags protoc-generated C++/Java/Python', () => { + const banner = '// Generated by the protocol buffer compiler. DO NOT EDIT!\n' + expectGenerated('foo.pb.cc', banner + 'code;\n') + expectGenerated('Foo.java', banner + 'class Foo {}\n') + expectGenerated('foo_pb2.py', banner + 'print(1)\n') + }) + + it('flags protoc-generated JS at line 5', () => { + const content = [ + '// source: foo.proto', + '/**', + ' * @fileoverview', + ' * @enhanceable', + ' * @suppress {missingRequire}', + '// GENERATED CODE -- DO NOT EDIT!', + 'var foo = ...;', + ].join('\n') + expectGenerated('foo_pb.js', content) + }) + + it('flags ts-proto .ts on line 0', () => { + expectGenerated( + 'foo.ts', + '// Code generated by protoc-gen-ts_proto. DO NOT EDIT.\n// versions:\n// protoc-gen-ts_proto v1\n// source: hello.proto\n\nexport interface Foo {}\n', + ) + }) +}) + +describe('classifyByContent — Apache Thrift', () => { + it('flags files with Thrift banner in first 6 lines', () => { + const banner = '/**\n * Autogenerated by Thrift Compiler (0.15.0)\n *\n */\n' + expectGenerated('foo.java', banner + 'class Foo {}\n') + }) +}) + +describe('classifyByContent — JNI header', () => { + it('flags JNI-generated headers', () => { + const content = + '/* DO NOT EDIT THIS FILE - it is machine generated */\n#include \n/* Header for class com_Example */\n' + expectGenerated('com_Example.h', content) + }) +}) + +describe('classifyByContent — VCR cassette', () => { + it('flags .yml with VCR tail', () => { + const content = ['http_interactions: []', 'recorded_with: VCR 6.0.0', ''].join('\n') + expectGenerated('cassette.yml', content) + }) +}) + +describe('classifyByContent — Cython', () => { + it('flags .c generated by Cython', () => { + expectGenerated( + 'foo.c', + '/* Generated by Cython 0.29.21 */\n#define PY_SSIZE_T_CLEAN\n', + ) + }) +}) + +describe('classifyByContent — Unity3D / Racc / JFlex / GrammarKit / roxygen2', () => { + it('Unity3D .meta', () => { + expectGenerated('foo.meta', 'fileFormatVersion: 2\nguid: abc\n') + }) + it('Racc .rb', () => { + expectGenerated( + 'parser.rb', + '# frozen_string_literal: true\nrequire "racc"\n# This file is automatically generated by Racc 1.6.0\nmodule Parser\nend\n', + ) + }) + it('JFlex .java', () => { + expectGenerated( + 'Lexer.java', + '/* The following code was generated by JFlex 1.8.2 */\npublic class Lexer {}\n', + ) + }) + it('GrammarKit .java', () => { + expectGenerated( + 'Parser.java', + '// This is a generated file. Not intended for manual editing.\npublic class Parser {}\n', + ) + }) + it('roxygen2 .Rd', () => { + expectGenerated( + 'foo.Rd', + '% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/foo.R\n', + ) + }) +}) + +describe('classifyByContent — Go', () => { + it('flags .go with "Code generated ..." comment', () => { + expectGenerated( + 'zz_generated.go', + '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage foo\n', + ) + }) +}) + +describe('classifyByContent — gRPC C++', () => { + it('flags generated gRPC C++ headers', () => { + expectGenerated( + 'foo.grpc.pb.h', + '// Generated by the gRPC C++ plugin.\n// If you make any local changes they will be lost\n', + ) + }) +}) + +describe('classifyByContent — Dart', () => { + it('flags Dart with "GENERATED CODE - DO NOT MODIFY"', () => { + expectGenerated( + 'foo.g.dart', + 'part of foo;\n// GENERATED CODE - DO NOT MODIFY\n\npart foo {}\n', + ) + }) +}) + +describe('classifyByContent — Haxe output', () => { + it('flags Haxe output JS', () => { + expectGenerated('foo.js', '// Generated by Haxe 4.2.5\n(function() {})\n') + }) +}) + +describe('classifyByContent — generated HTML', () => { + it('flags pkgdown-generated HTML', () => { + expectGenerated( + 'index.html', + '\n\n\n', + ) + }) + it('flags Doxygen-generated HTML', () => { + const content = + '\n\n\n\n\n' + expectGenerated('class.html', content) + }) + it('flags groff-generated HTML via ', () => { + const content = + '\n\n\n\n' + expectGenerated('man.html', content) + }) +}) + +describe('classifyByContent — Sorbet RBI from Tapioca', () => { + it('flags typed:-prefixed RBI with Tapioca banner', () => { + const content = [ + '# typed: true', + '', + '# DO NOT EDIT MANUALLY', + '# This is an autogenerated file from Tapioca.', + '', + 'module Foo; end', + ].join('\n') + expectGenerated('foo.rbi', content) + }) + it("doesn't flag plain .rbi without banner", () => { + const content = '# typed: true\n\nmodule Foo; end\n' + expectNotGenerated('foo.rbi', content) + }) +}) + +describe('classifyByContent — MySQL view definition', () => { + it('flags .frm with TYPE=VIEW', () => { + expectGenerated('my_view.frm', 'TYPE=VIEW\nquery=SELECT 1\n') + }) +}) + +describe('classifyByContent — no match', () => { + it.each([ + ['src/App.tsx', 'import React from "react"\nexport const App = () => null\n'], + ['cli.ts', 'import { main } from "./main.ts"\nmain()\n'], + ['server/main.ts', 'export function startServer() {}\n'], + ['README.md', '# Title\n\nSome content.\n'], + ])('%s returns no attributes', (path, content) => { + expectNotGenerated(path, content) + }) +}) diff --git a/server/linguist/contentRules.ts b/server/linguist/contentRules.ts new file mode 100644 index 0000000..a0a6b8e --- /dev/null +++ b/server/linguist/contentRules.ts @@ -0,0 +1,475 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Linguist's content-based "generated" rules — the ones that need to + * inspect file contents (first/last lines, line counts, etc.) rather + * than filenames. + * + * Ported from: + * github-linguist/linguist @ 537297cdae3ab05f8d5dd1c03627a5bd73707b19 + * - lib/linguist/generated.rb + * + * Every rule only sets `generated` — Linguist's vendored and + * documentation checks are path-based and live in `pathRules.ts`. + */ + +import type { FileAttrs } from '../../shared/types.ts' +import type { BlobSummary } from './contentReader.ts' + +export function classifyByContent(path: string, summary: BlobSummary): Partial { + return isGenerated(path, summary) ? { generated: true } : {} +} + +function isGenerated(path: string, s: BlobSummary): boolean { + return ( + minified(path, s) || + hasSourceMapRef(path, s) || + isSourceMap(path, s) || + compiledCoffeescript(path, s) || + generatedNetDocfile(path, s) || + generatedParser(path, s) || + generatedPostscript(path, s) || + generatedGo(path, s) || + generatedProtocolBufferFromGo(path, s) || + generatedProtocolBuffer(path, s) || + generatedJavascriptProtocolBuffer(path, s) || + generatedTypescriptProtocolBuffer(path, s) || + generatedApacheThrift(path, s) || + generatedJniHeader(path, s) || + vcrCassette(path, s) || + generatedAntlr(path, s) || + compiledCythonFile(path, s) || + generatedModule(path, s) || + generatedUnity3dMeta(path, s) || + generatedRacc(path, s) || + generatedJflex(path, s) || + generatedGrammarkit(path, s) || + generatedRoxygen2(path, s) || + generatedJison(path, s) || + generatedGrpcCpp(path, s) || + generatedDart(path, s) || + generatedPerlPpportHeader(path, s) || + generatedGamemakerstudio(path, s) || + generatedGimp(path, s) || + generatedVisualstudio6(path, s) || + generatedHaxe(path, s) || + generatedHtml(path, s) || + generatedJooq(path, s) || + generatedSorbetRbi(path, s) || + generatedMysqlViewDefinitionFormat(path, s) + ) +} + +// --- Path helpers --- + +function extname(path: string): string { + const slash = path.lastIndexOf('/') + const base = slash === -1 ? path : path.slice(slash + 1) + const dot = base.lastIndexOf('.') + return dot === -1 ? '' : base.slice(dot) +} + +function basename(path: string): string { + const slash = path.lastIndexOf('/') + return slash === -1 ? path : path.slice(slash + 1) +} + +// --- Rule implementations (preserving Ruby source order where practical) --- + +const MAYBE_MINIFIED = new Set(['.js', '.css']) + +function maybeMinified(path: string): boolean { + return MAYBE_MINIFIED.has(extname(path).toLowerCase()) +} + +// minified_files? +function minified(path: string, s: BlobSummary): boolean { + if (!maybeMinified(path)) return false + if (s.lines.length === 0) return false + const total = s.lines.reduce((acc, l) => acc + l.length, 0) + return total / s.lines.length > 110 +} + +// has_source_map? +function hasSourceMapRef(path: string, s: BlobSummary): boolean { + if (!maybeMinified(path)) return false + const tail = s.lines.slice(-2) + // eslint-disable-next-line unicorn/prefer-string-starts-ends-with + return tail.some((l) => /^\/[*/][#@] source(?:Mapping)?URL|sourceURL=/.test(l)) +} + +// source_map? +function isSourceMap(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.map') return false + if (/(\.css|\.js)\.map$/i.test(path)) return true + const first = s.lines[0] ?? '' + // eslint-disable-next-line unicorn/prefer-string-starts-ends-with + return /^{"version":\d+,/.test(first) || first.startsWith('/** Begin line maps. **/{') +} + +// compiled_coffeescript? +function compiledCoffeescript(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + const first = s.lines[0] ?? '' + if (first.startsWith('// Generated by ')) return true + if (s.lines.length < 3) return false + if (first !== '(function() {') return false + if (s.lines[s.lines.length - 2] !== '}).call(this);') return false + if (s.lines[s.lines.length - 1] !== '') return false + + let score = 0 + for (const line of s.lines) { + if (!/var /.test(line)) continue + score += countMatches(line, /_fn|_i|_len|_ref|_results/g) + score += 3 * countMatches(line, /__bind|__extends|__hasProp|__indexOf|__slice/g) + } + return score >= 3 +} + +function countMatches(s: string, re: RegExp): number { + return (s.match(re) ?? []).length +} + +// generated_net_docfile? +function generatedNetDocfile(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.xml') return false + if (s.lines.length <= 3) return false + return ( + (s.lines[1]?.includes('') ?? false) && + (s.lines[2]?.includes('') ?? false) && + (s.lines[s.lines.length - 2]?.includes('') ?? false) + ) +} + +// generated_parser? (PEG.js) +function generatedParser(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + const head = s.lines.slice(0, 5).join('') + return /^(?:[^/]|\/[^*])*\/\*(?:[^*]|\*[^/])*Generated by PEG\.js/.test(head) +} + +// generated_postscript? +function generatedPostscript(path: string, s: BlobSummary): boolean { + if (!['.ps', '.eps', '.pfa'].includes(extname(path))) return false + + // Full content check for the two hex-stream markers. + const joined = s.lines.join('\n') + if (/^\s*(?:currentfile eexec\s+|\/sfnts\s+\[\s<)/m.test(joined)) return true + + const head = s.lines.slice(0, 10) + const creator = head.find((l) => l.startsWith('%%Creator: ')) + if (!creator) return false + + if ( + /[0-9]|draw|mpage|ImageMagick|inkscape|MATLAB/.test(creator) || + /PCBNEW|pnmtops|\(Unknown\)|Serif Affinity|Filterimage -tops/.test(creator) + ) { + return true + } + + // EAGLE check + if (creator.includes('EAGLE')) { + return s.lines.slice(0, 5).some((l) => l.startsWith('%%Title: EAGLE Drawing ')) + } + return false +} + +// generated_go? +function generatedGo(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.go') return false + if (s.lines.length <= 1) return false + return s.lines.slice(0, 40).some((l) => /^\/\/ Code generated .*/.test(l)) +} + +// generated_protocol_buffer_from_go? +function generatedProtocolBufferFromGo(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.proto') return false + if (s.lines.length <= 1) return false + return s.lines + .slice(0, 20) + .some((l) => l.includes('This file was autogenerated by go-to-protobuf')) +} + +const PROTOBUF_EXTS = new Set(['.py', '.java', '.h', '.cc', '.cpp', '.m', '.rb', '.php']) + +// generated_protocol_buffer? +function generatedProtocolBuffer(path: string, s: BlobSummary): boolean { + if (!PROTOBUF_EXTS.has(extname(path))) return false + if (s.lines.length <= 1) return false + return s.lines + .slice(0, 3) + .some((l) => l.includes('Generated by the protocol buffer compiler. DO NOT EDIT!')) +} + +// generated_javascript_protocol_buffer? +function generatedJavascriptProtocolBuffer(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + if (s.lines.length <= 6) return false + return s.lines[5]?.includes('GENERATED CODE -- DO NOT EDIT!') ?? false +} + +// generated_typescript_protocol_buffer? +function generatedTypescriptProtocolBuffer(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.ts') return false + if (s.lines.length <= 4) return false + return ( + s.lines[0]?.includes('Code generated by protoc-gen-ts_proto. DO NOT EDIT.') ?? false + ) +} + +const APACHE_THRIFT_EXTS = new Set([ + '.rb', + '.py', + '.go', + '.js', + '.m', + '.java', + '.h', + '.cc', + '.cpp', + '.php', +]) + +// generated_apache_thrift? +function generatedApacheThrift(path: string, s: BlobSummary): boolean { + if (!APACHE_THRIFT_EXTS.has(extname(path))) return false + return s.lines.slice(0, 6).some((l) => l.includes('Autogenerated by Thrift Compiler')) +} + +// generated_jni_header? +function generatedJniHeader(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.h') return false + if (s.lines.length <= 2) return false + return ( + (s.lines[0]?.includes('/* DO NOT EDIT THIS FILE - it is machine generated */') ?? + false) && + (s.lines[1]?.includes('#include ') ?? false) + ) +} + +// vcr_cassette? +function vcrCassette(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.yml') return false + if (s.lines.length <= 2) return false + return s.lines[s.lines.length - 2]?.includes('recorded_with: VCR') ?? false +} + +// generated_antlr? +function generatedAntlr(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.g') return false + if (s.lines.length <= 2) return false + return s.lines[1]?.includes('generated by Xtest') ?? false +} + +// compiled_cython_file? +function compiledCythonFile(path: string, s: BlobSummary): boolean { + if (!['.c', '.cpp'].includes(extname(path))) return false + if (s.lines.length <= 1) return false + return s.lines[0]?.includes('Generated by Cython') ?? false +} + +// generated_module? +function generatedModule(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.mod') return false + if (s.lines.length <= 1) return false + const first = s.lines[0] ?? '' + return first.includes('PCBNEW-LibModule-V') || first.includes("GFORTRAN module version '") +} + +// generated_unity3d_meta? +function generatedUnity3dMeta(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.meta') return false + if (s.lines.length <= 1) return false + return s.lines[0]?.includes('fileFormatVersion: ') ?? false +} + +// generated_racc? +function generatedRacc(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.rb') return false + if (s.lines.length <= 2) return false + return s.lines[2]?.startsWith('# This file is automatically generated by Racc') ?? false +} + +// generated_jflex? +function generatedJflex(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.java') return false + if (s.lines.length <= 1) return false + return s.lines[0]?.startsWith('/* The following code was generated by JFlex ') ?? false +} + +// generated_grammarkit? +function generatedGrammarkit(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.java') return false + if (s.lines.length <= 1) return false + return ( + s.lines[0]?.startsWith( + '// This is a generated file. Not intended for manual editing.', + ) ?? false + ) +} + +// generated_roxygen2? +function generatedRoxygen2(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.Rd') return false + if (s.lines.length <= 1) return false + return s.lines[0]?.includes('% Generated by roxygen2: do not edit by hand') ?? false +} + +// generated_jison? +function generatedJison(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + if (s.lines.length <= 1) return false + const first = s.lines[0] ?? '' + return ( + first.startsWith('/* parser generated by jison ') || + first.startsWith('/* generated by jison-lex ') + ) +} + +const GRPC_CPP_EXTS = new Set(['.cpp', '.hpp', '.h', '.cc']) + +// generated_grpc_cpp? +function generatedGrpcCpp(path: string, s: BlobSummary): boolean { + if (!GRPC_CPP_EXTS.has(extname(path))) return false + if (s.lines.length <= 1) return false + return s.lines[0]?.startsWith('// Generated by the gRPC') ?? false +} + +// generated_dart? +function generatedDart(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.dart') return false + if (s.lines.length <= 1) return false + return s.lines.slice(0, 3).some((l) => /generated code\W{2,3}do not modify/i.test(l)) +} + +// generated_perl_ppport_header? +function generatedPerlPpportHeader(path: string, s: BlobSummary): boolean { + if (!basename(path).endsWith('ppport.h')) return false + if (s.lines.length <= 10) return false + return s.lines[8]?.includes('Automatically created by Devel::PPPort') ?? false +} + +// generated_gamemakerstudio? +function generatedGamemakerstudio(path: string, s: BlobSummary): boolean { + if (!['.yy', '.yyp'].includes(extname(path))) return false + if (s.lines.length <= 3) return false + const joined = s.lines.slice(0, 3).join('') + return /^\s*[{[]/.test(joined) || /^\d\.\d\.\d.+\|\{/.test(s.lines[0] ?? '') +} + +// generated_gimp? +function generatedGimp(path: string, s: BlobSummary): boolean { + if (!['.c', '.h'].includes(extname(path))) return false + if (s.lines.length === 0) return false + const first = s.lines[0] ?? '' + return ( + /^\/\* GIMP [a-zA-Z0-9\- ]+ C-Source image dump \(.+?\.c\) \*\//.test(first) || + /^\/\* GIMP header image file format \([a-zA-Z0-9\- ]+\): .+?\.h \*\//.test(first) + ) +} + +// generated_visualstudio6? +function generatedVisualstudio6(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.dsp') return false + return s.lines + .slice(0, 3) + .some((l) => l.includes('# Microsoft Developer Studio Generated Build File')) +} + +const HAXE_EXTS = new Set(['.js', '.py', '.lua', '.cpp', '.h', '.java', '.cs', '.php']) + +// generated_haxe? +function generatedHaxe(path: string, s: BlobSummary): boolean { + if (!HAXE_EXTS.has(extname(path))) return false + return s.lines.slice(0, 3).some((l) => l.includes('Generated by Haxe')) +} + +// generated_html? +function generatedHtml(path: string, s: BlobSummary): boolean { + if (!['.html', '.htm', '.xhtml'].includes(extname(path).toLowerCase())) { + return false + } + if (s.lines.length <= 1) return false + + const head = s.lines.slice(0, 31) + + // pkgdown marker (first 2 lines) + if ( + head + .slice(0, 2) + .some((l) => //.test(l)) + ) { + return true + } + // Mandoc marker (line 2) + if ( + s.lines.length > 2 && + s.lines[2]?.startsWith('/i.test(l))) { + return true + } + + // style + const joined = head.join(' ') + const metaMatches = [...joined.matchAll(/]+)>/gi)] + if (metaMatches.length === 0) return false + return metaMatches.some((m) => { + const attrs = extractHtmlMeta(m[1] ?? '') + if ((attrs['name'] ?? '').toLowerCase() !== 'generator') return false + const cv = [attrs['content'], attrs['value']].filter((v): v is string => !!v) + return cv.some((v) => + /^\s*(?:org\s+mode|j?latex2html|groff|makeinfo|texi2html|ronn)\b/i.test(v), + ) + }) +} + +function extractHtmlMeta(attrStr: string): Record { + const result: Record = {} + const cleaned = attrStr.replace(/\/\s*$/, '').trim() + const re = /(?<=^|\s)(name|content|value)\s*=\s*("[^"]+"|'[^']+'|[^\s"']+)/gi + for (const m of cleaned.matchAll(re)) { + const key = m[1]!.toLowerCase() + let val = m[2]! + val = val.replace(/^["']|["']$/g, '') + result[key] = val + } + return result +} + +// generated_jooq? +function generatedJooq(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.java') return false + return s.lines.slice(0, 2).some((l) => l.includes('This file is generated by jOOQ.')) +} + +// generated_sorbet_rbi? (Tapioca) +function generatedSorbetRbi(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.rbi') return false + if (s.lines.length < 5) return false + if (!(s.lines[0] ?? '').startsWith('# typed:')) return false + // Ruby's implementation checks for Tapioca's standard header in the next + // few lines. See the source; we accept any of the three Tapioca variants. + return s.lines + .slice(1, 5) + .some((l) => + /DO NOT EDIT MANUALLY|This is an autogenerated file|DO NOT EDIT: This file is automatically generated/i.test( + l, + ), + ) +} + +// generated_mysql_view_definition_format? +function generatedMysqlViewDefinitionFormat(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.frm') return false + return s.lines[0]?.includes('TYPE=VIEW') ?? false +} diff --git a/server/linguist/pathRules.test.ts b/server/linguist/pathRules.test.ts new file mode 100644 index 0000000..61f2a45 --- /dev/null +++ b/server/linguist/pathRules.test.ts @@ -0,0 +1,216 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { classifyByPath, classifyByPaths } from './pathRules.ts' + +describe('classifyByPath — generated', () => { + it.each([ + // xcode_file? (extension-based) + ['MainMenu.nib'], + ['App.xcworkspace/contents.xcworkspacedata'], + ['UserInterfaceState.xcuserstate'], + // intellij_file? + ['.idea/workspace.xml'], + ['sub/.idea/misc.xml'], + // cocoapods? + ['Pods/Alamofire/Source/Alamofire.swift'], + ['app/Pods/FBSDK/README.md'], + // carthage_build? + ['Carthage/Build/iOS/Alamofire.framework/Info.plist'], + // node_modules? + ['node_modules/react/index.js'], + // go_lock? + ['Gopkg.lock'], + ['glide.lock'], + // package_resolved? + ['Package.resolved'], + // lock files + ['poetry.lock'], + ['pdm.lock'], + ['uv.lock'], + ['pixi.lock'], + ['deno.lock'], + ['esy.lock'], + ['my.esy.lock'], + // npm_shrinkwrap_or_package_lock? + ['npm-shrinkwrap.json'], + ['package-lock.json'], + // pnpm_lock? + ['pnpm-lock.yaml'], + // bun_lock? + ['bun.lock'], + ['bun.lockb'], + ['sub/bun.lock'], + // generated_yarn_plugnplay? + ['.pnp.js'], + ['.pnp.cjs'], + ['sub/.pnp.loader.mjs'], + // godeps? + ['Godeps/foo.go'], + // composer_lock? + ['composer.lock'], + // cargo + ['Cargo.lock'], + ['sub/Cargo.lock'], + ['Cargo.toml.orig'], + // flake_lock? + ['flake.lock'], + ['sub/flake.lock'], + // bazel_lock? + ['MODULE.bazel.lock'], + // pipenv_lock? + ['Pipfile.lock'], + // terraform_lock? + ['.terraform.lock.hcl'], + ['sub/.terraform.lock.hcl'], + // generated_graphql_relay? + ['src/__generated__/Query.graphql.ts'], + // generated_net_designer_file? (case insensitive) + ['Form1.Designer.cs'], + ['form1.designer.vb'], + // generated_net_specflow_feature_file? + ['Features/Login.feature.cs'], + // generated_pascal_tlb? + ['UnitName_TLB.pas'], + // gradle_wrapper? + ['gradlew'], + ['sub/gradlew.bat'], + // maven_wrapper? + ['mvnw'], + ['mvnw.cmd'], + // htmlcov? + ['htmlcov/index.html'], + ])('%s is generated', (path) => { + expect(classifyByPath(path).generated).toBe(true) + }) + + it('respects the go_vendor? heuristic', () => { + // go_vendor? matches import paths under vendor/. + expect(classifyByPath('vendor/github.com/pkg/errors/errors.go').generated).toBe(true) + // But not plain `vendor/` entries that don't look like domains. + expect(classifyByPath('vendor/foo').generated).toBeUndefined() + }) + + it('flags generated_by_zephir? only with the right extensions', () => { + expect(classifyByPath('src/foo.zep.c').generated).toBe(true) + expect(classifyByPath('src/foo.zep.h').generated).toBe(true) + expect(classifyByPath('src/foo.zep.php').generated).toBe(true) + expect(classifyByPath('src/foo.zep').generated).toBeUndefined() + }) +}) + +describe('classifyByPath — vendored', () => { + it.each([ + ['cache/something.dat'], + ['Dependencies/foo.c'], + ['dist/bundle.js'], + ['deps/openssl/openssl.c'], + ['configure'], + ['node_modules/pkg/file.js'], + ['.yarn/releases/yarn.cjs'], + ['bower_components/jquery/jquery.js'], + ['third-party/lib.js'], + ['3rd-party/lib.js'], + ['vendor/foo.js'], + ['vendors/lib.js'], + ['extern/lib.js'], + ['externals/lib.js'], + ['app.min.js'], + ['app.min.css'], + ['style-min.js'], + ['bootstrap.css'], + ['bootstrap.min.css'], + ['font-awesome.css'], + ['jquery.js'], + ['jquery-3.6.0.js'], + ['d3.js'], + ['d3.v5.min.js'], + ['react.js'], + ['react-dom.js'], + ['Vagrantfile'], + ['Jenkinsfile'], + ['.DS_Store'], + ['.github/workflows/ci.yml'], + ['.vscode/settings.json'], + ['.gitignore'], + ['.gitattributes'], + ['gradlew'], + ['types/node.d.ts'], + ['tests/fixtures/input.txt'], + ['specs/fixtures/case.json'], + ])('%s is vendored', (path) => { + expect(classifyByPath(path).vendored).toBe(true) + }) +}) + +describe('classifyByPath — documentation', () => { + it.each([ + ['docs/README.md'], + ['Docs/index.html'], + ['doc/manual.pdf'], + ['Doc/overview.md'], + ['Documentation/api.md'], + ['pkg/Documentation/api.md'], + ['man/cmd.1'], + ['examples/basic.py'], + ['demo/sample.html'], + ['demos/sample.html'], + ['samples/sample.py'], + ['inst/doc/notes.md'], + ['CITATION'], + ['CITATION.cff'], + ['CITATIONS.md'], + ['CHANGELOG'], + ['CHANGELOG.md'], + ['CONTRIBUTING.md'], + ['COPYING'], + ['INSTALL.md'], + ['LICENSE'], + ['LICENSE.md'], + ['LICENCE'], + ['README'], + ['README.md'], + ['readme.txt'], + ])('%s is documentation', (path) => { + expect(classifyByPath(path).documentation).toBe(true) + }) +}) + +describe('classifyByPath — no match', () => { + it.each([ + ['src/App.tsx'], + ['server/main.ts'], + ['cli.ts'], + ['index.html'], + ['package.json'], + ])('%s returns no attributes', (path) => { + expect(classifyByPath(path)).toEqual({}) + }) +}) + +describe('classifyByPaths', () => { + it('returns only files that matched a rule', () => { + const result = classifyByPaths([ + 'src/App.tsx', + 'pnpm-lock.yaml', + 'README.md', + 'node_modules/lib.js', + ]) + expect(Object.keys(result).toSorted()).toEqual([ + 'README.md', + 'node_modules/lib.js', + 'pnpm-lock.yaml', + ]) + expect(result['pnpm-lock.yaml']?.generated).toBe(true) + expect(result['README.md']?.documentation).toBe(true) + // node_modules matches both generated and vendored in Linguist. + expect(result['node_modules/lib.js']?.generated).toBe(true) + expect(result['node_modules/lib.js']?.vendored).toBe(true) + }) +}) diff --git a/server/linguist/pathRules.ts b/server/linguist/pathRules.ts new file mode 100644 index 0000000..35e1c52 --- /dev/null +++ b/server/linguist/pathRules.ts @@ -0,0 +1,360 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Linguist's path-based rules for generated, vendored, and documentation + * files — the ones that can be decided from the filename alone, without + * reading the file's content. + * + * Ported from: + * github-linguist/linguist @ 537297cdae3ab05f8d5dd1c03627a5bd73707b19 + * - lib/linguist/generated.rb (name-based checks) + * - lib/linguist/vendor.yml + * - lib/linguist/documentation.yml + * + * Content-based Linguist rules (source maps, protobuf, etc.) live in + * `contentRules.ts`. + */ + +import type { FileAttrs } from '../../shared/types.ts' + +// --- Generated (path-based subset of generated.rb) --- + +const GENERATED_EXTENSIONS = new Set([ + // xcode_file? + '.nib', + '.xcworkspacedata', + '.xcuserstate', +]) + +const GENERATED_PATH_PATTERNS: string[] = [ + // intellij_file? + '(?:^|/)\\.idea/', + // cocoapods? + '(^Pods|/Pods)/', + // carthage_build? + '(^|/)Carthage/Build/', + // node_modules? + 'node_modules/', + // go_vendor? + 'vendor/((?!-)[-0-9A-Za-z]+(? new RegExp(p)), + ...GENERATED_PATH_PATTERNS_CI.map((p) => new RegExp(p, 'i')), +] + +function hasGeneratedExtension(path: string): boolean { + const lastSlash = path.lastIndexOf('/') + const base = lastSlash === -1 ? path : path.slice(lastSlash + 1) + const dot = base.lastIndexOf('.') + if (dot === -1) return false + return GENERATED_EXTENSIONS.has(base.slice(dot)) +} + +// --- Vendor (vendor.yml, 168 patterns) --- + +const VENDOR_PATTERNS: string[] = [ + '(^|/)cache/', + '^[Dd]ependencies/', + '(^|/)dist/', + '^deps/', + '(^|/)configure$', + '(^|/)config\\.guess$', + '(^|/)config\\.sub$', + '(^|/)aclocal\\.m4', + '(^|/)libtool\\.m4', + '(^|/)ltoptions\\.m4', + '(^|/)ltsugar\\.m4', + '(^|/)ltversion\\.m4', + '(^|/)lt~obsolete\\.m4', + '(^|/)dotnet-install\\.(ps1|sh)$', + '(^|/)cpplint\\.py', + '(^|/)node_modules/', + '(^|/)\\.yarn/releases/', + '(^|/)\\.yarn/plugins/', + '(^|/)\\.yarn/sdks/', + '(^|/)\\.yarn/versions/', + '(^|/)\\.yarn/unplugged/', + '(^|/)_esy$', + '(^|/)bower_components/', + '^rebar$', + '(^|/)erlang\\.mk', + '(^|/)Godeps/_workspace/', + '(^|/)testdata/', + '(^|/)\\.indent\\.pro', + '(\\.|-)min\\.(js|css)$', + '([^\\s]*)import\\.(css|less|scss|styl)$', + '(^|/)bootstrap([^/.]*)(\\..*)?\\.(js|css|less|scss|styl)$', + '(^|/)custom\\.bootstrap([^\\s]*)(js|css|less|scss|styl)$', + '(^|/)font-?awesome\\.(css|less|scss|styl)$', + '(^|/)font-?awesome/.*\\.(css|less|scss|styl)$', + '(^|/)foundation\\.(css|less|scss|styl)$', + '(^|/)normalize\\.(css|less|scss|styl)$', + '(^|/)skeleton\\.(css|less|scss|styl)$', + '(^|/)[Bb]ourbon/.*\\.(css|less|scss|styl)$', + '(^|/)animate\\.(css|less|scss|styl)$', + '(^|/)materialize\\.(css|less|scss|styl|js)$', + '(^|/)select2/.*\\.(css|scss|js)$', + '(^|/)bulma\\.(css|sass|scss)$', + '(3rd|[Tt]hird)[-_]?[Pp]arty/', + '(^|/)vendors?/', + '(^|/)[Ee]xtern(als?)?/', + '(^|/)[Vv]+endor/', + '^debian/', + '(^|/)run\\.n$', + '(^|/)bootstrap-datepicker/', + '(^|/)jquery([^.]*)\\.js$', + '(^|/)jquery\\-\\d\\.\\d+(\\.\\d+)?\\.js$', + '(^|/)jquery\\-ui(\\-\\d\\.\\d+(\\.\\d+)?)?(\\.\\w+)?\\.(js|css)$', + '(^|/)jquery\\.(ui|effects)\\.([^.]*)\\.(js|css)$', + '(^|/)jquery\\.fn\\.gantt\\.js', + '(^|/)jquery\\.fancybox\\.(js|css)', + '(^|/)fuelux\\.js', + '(^|/)jquery\\.fileupload(-\\w+)?\\.js$', + '(^|/)jquery\\.dataTables\\.js', + '(^|/)bootbox\\.js', + '(^|/)pdf\\.worker\\.js', + '(^|/)slick\\.\\w+.js$', + '(^|/)Leaflet\\.Coordinates-\\d+\\.\\d+\\.\\d+\\.src\\.js$', + '(^|/)leaflet\\.draw-src\\.js', + '(^|/)leaflet\\.draw\\.css', + '(^|/)Control\\.FullScreen\\.css', + '(^|/)Control\\.FullScreen\\.js', + '(^|/)leaflet\\.spin\\.js', + '(^|/)wicket-leaflet\\.js', + '(^|/)\\.sublime-project', + '(^|/)\\.sublime-workspace', + '(^|/)\\.vscode/', + '(^|/)prototype(.*)\\.js$', + '(^|/)effects\\.js$', + '(^|/)controls\\.js$', + '(^|/)dragdrop\\.js$', + '(.*?)\\.d\\.ts$', + '(^|/)mootools([^.]*)\\d+\\.\\d+.\\d+([^.]*)\\.js$', + '(^|/)dojo\\.js$', + '(^|/)MochiKit\\.js$', + '(^|/)yahoo-([^.]*)\\.js$', + '(^|/)yui([^.]*)\\.js$', + '(^|/)ckeditor\\.js$', + '(^|/)tiny_mce([^.]*)\\.js$', + '(^|/)tiny_mce/(langs|plugins|themes|utils)', + '(^|/)ace-builds/', + '(^|/)fontello(.*?)\\.css$', + '(^|/)MathJax/', + '(^|/)Chart\\.js$', + '(^|/)[Cc]ode[Mm]irror/(\\d+\\.\\d+/)?(lib|mode|theme|addon|keymap|demo)', + '(^|/)shBrush([^.]*)\\.js$', + '(^|/)shCore\\.js$', + '(^|/)shLegacy\\.js$', + '(^|/)angular([^.]*)\\.js$', + '(^|/)d3(\\.v\\d+)?([^.]*)\\.js$', + '(^|/)react(-[^.]*)?\\.js$', + '(^|/)flow-typed/.*\\.js$', + '(^|/)modernizr\\-\\d\\.\\d+(\\.\\d+)?\\.js$', + '(^|/)modernizr\\.custom\\.\\d+\\.js$', + '(^|/)knockout-(\\d+\\.){3}(debug\\.)?js$', + '(^|/)docs?/_?(build|themes?|templates?|static)/', + '(^|/)admin_media/', + '(^|/)env/', + '(^|/)fabfile\\.py$', + '(^|/)waf$', + '(^|/)\\.osx$', + '\\.xctemplate/', + '\\.imageset/', + '(^|/)Carthage/', + '(^|/)Sparkle/', + '(^|/)Crashlytics\\.framework/', + '(^|/)Fabric\\.framework/', + '(^|/)BuddyBuildSDK\\.framework/', + '(^|/)Realm\\.framework', + '(^|/)RealmSwift\\.framework', + '(^|/)\\.gitattributes$', + '(^|/)\\.gitignore$', + '(^|/)\\.gitmodules$', + '(^|/)gradlew$', + '(^|/)gradlew\\.bat$', + '(^|/)gradle/wrapper/', + '(^|/)mvnw$', + '(^|/)mvnw\\.cmd$', + '(^|/)\\.mvn/wrapper/', + '-vsdoc\\.js$', + '\\.intellisense\\.js$', + '(^|/)jquery([^.]*)\\.validate(\\.unobtrusive)?\\.js$', + '(^|/)jquery([^.]*)\\.unobtrusive\\-ajax\\.js$', + '(^|/)[Mm]icrosoft([Mm]vc)?([Aa]jax|[Vv]alidation)(\\.debug)?\\.js$', + '(^|/)[Pp]ackages/.+\\.\\d+/', + '(^|/)extjs/.*?\\.js$', + '(^|/)extjs/.*?\\.xml$', + '(^|/)extjs/.*?\\.txt$', + '(^|/)extjs/.*?\\.html$', + '(^|/)extjs/.*?\\.properties$', + '(^|/)extjs/\\.sencha/', + '(^|/)extjs/docs/', + '(^|/)extjs/builds/', + '(^|/)extjs/cmd/', + '(^|/)extjs/examples/', + '(^|/)extjs/locale/', + '(^|/)extjs/packages/', + '(^|/)extjs/plugins/', + '(^|/)extjs/resources/', + '(^|/)extjs/src/', + '(^|/)extjs/welcome/', + '(^|/)html5shiv\\.js$', + '(^|/)[Tt]ests?/fixtures/', + '(^|/)[Ss]pecs?/fixtures/', + '(^|/)cordova([^.]*)\\.js$', + '(^|/)cordova\\-\\d\\.\\d(\\.\\d)?\\.js$', + '(^|/)foundation(\\..*)?\\.js$', + '(^|/)Vagrantfile$', + '(^|/)\\.[Dd][Ss]_[Ss]tore$', + '(^|/)inst/extdata/', + '(^|/)octicons\\.css', + '(^|/)sprockets-octicons\\.scss', + '(^|/)activator$', + '(^|/)activator\\.bat$', + '(^|/)proguard\\.pro$', + '(^|/)proguard-rules\\.pro$', + '(^|/)puphpet/', + '(^|/)\\.google_apis/', + '(^|/)Jenkinsfile$', + '(^|/)\\.gitpod\\.Dockerfile$', + '(^|/)\\.github/', + '(^|/)\\.obsidian/', + '(^|/)\\.teamcity/', + '(^|/)xvba_modules/', +] + +const VENDOR_REGEXES: RegExp[] = VENDOR_PATTERNS.map((p) => new RegExp(p)) + +// --- Documentation (documentation.yml) --- + +const DOCUMENTATION_PATTERNS: string[] = [ + '^[Dd]ocs?/', + '(^|/)[Dd]ocumentation/', + '(^|/)[Gg]roovydoc/', + '(^|/)[Jj]avadoc/', + '^[Mm]an/', + '^[Ee]xamples/', + '^[Dd]emos?/', + '(^|/)inst/doc/', + '(^|/)CITATION(\\.cff|(S)?(\\.(bib|md))?)$', + '(^|/)CHANGE(S|LOG)?(\\.|$)', + '(^|/)CONTRIBUTING(\\.|$)', + '(^|/)COPYING(\\.|$)', + '(^|/)INSTALL(\\.|$)', + '(^|/)LICEN[CS]E(\\.|$)', + '(^|/)[Ll]icen[cs]e(\\.|$)', + '(^|/)README(\\.|$)', + '(^|/)[Rr]eadme(\\.|$)', + '^[Ss]amples?/', +] + +const DOCUMENTATION_REGEXES: RegExp[] = DOCUMENTATION_PATTERNS.map((p) => new RegExp(p)) + +// --- Public API --- + +/** + * Classify a file path using Linguist's built-in path rules. Returns only + * the attributes that the rules explicitly set; a file that doesn't match + * any rule returns `{}`. + * + * The caller is responsible for merging this with other attribute sources + * (e.g., `.gitattributes`). + */ +export function classifyByPath(path: string): Partial { + const result: Partial = {} + if (hasGeneratedExtension(path) || GENERATED_REGEXES.some((r) => r.test(path))) { + result.generated = true + } + if (VENDOR_REGEXES.some((r) => r.test(path))) { + result.vendored = true + } + if (DOCUMENTATION_REGEXES.some((r) => r.test(path))) { + result.documentation = true + } + return result +} + +/** Apply `classifyByPath` to many files, dropping entries with no matches. */ +export function classifyByPaths(paths: string[]): Record> { + const result: Record> = {} + for (const path of paths) { + const attrs = classifyByPath(path) + if (Object.keys(attrs).length > 0) result[path] = attrs + } + return result +} diff --git a/server/main.ts b/server/main.ts index a47a547..8dea0a0 100644 --- a/server/main.ts +++ b/server/main.ts @@ -14,6 +14,7 @@ import { join, relative } from 'node:path' import { readFile } from 'node:fs/promises' import type { DiffArgs } from '../shared/types.ts' import { getDiff, validateDiffArgs } from './diff.ts' +import { resolveFileAttrs } from './attrs.ts' import { loadViewed, markViewed, unmarkViewed } from './viewed.ts' import { insertComment, removeComment } from './comment.ts' import { @@ -36,7 +37,10 @@ export async function startServer(opts: { app.get('/api/diff', async (c) => { const { patch, fileHashes } = await getDiff(diffSource) - const viewed = await loadViewed(cwd, fileHashes) + const [viewed, attrs] = await Promise.all([ + loadViewed(cwd, fileHashes), + resolveFileAttrs(fileHashes, cwd), + ]) const fileSuffix = diffSource.files.length > 0 ? ` -- ${diffSource.files.join(' ')}` : '' return c.json({ @@ -49,6 +53,7 @@ export async function startServer(opts: { commentsEnabled: diffSource.commentsEnabled, fileHashes, viewed, + attrs, } satisfies DiffResponse) }) diff --git a/shared/types.ts b/shared/types.ts index 690150f..5eedec5 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -49,6 +49,18 @@ export type DiffArgs = ({ vcs: 'jj'; args: string[] } | { vcs: 'git'; args: stri export type ViewedMap = Record export type FileHashes = Record +/** + * Per-file attributes (from `.gitattributes` and Linguist built-in rules). + * Generated/vendored/binary cause auto-collapse in the UI; documentation + * only produces a badge. + */ +export interface FileAttrs { + generated: boolean + vendored: boolean + documentation: boolean + binary: boolean +} + export interface DiffResponse { patch: string revset: string @@ -56,6 +68,8 @@ export interface DiffResponse { commentsEnabled: boolean fileHashes: FileHashes viewed: ViewedMap + /** Only includes files with at least one attribute set. */ + attrs: Record error?: string } diff --git a/src/App.tsx b/src/App.tsx index 3003134..ccbcce2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,7 +26,13 @@ import { import { parsePatchFiles } from '@pierre/diffs' import { FileDiff } from '@pierre/diffs/react' import type { DiffLineAnnotation, FileDiffMetadata } from '@pierre/diffs' -import type { DiffResponse, ErrorResponse, ViewedMap, FileHashes } from '../shared/types.ts' +import type { + DiffResponse, + ErrorResponse, + FileAttrs, + FileHashes, + ViewedMap, +} from '../shared/types.ts' import { REVIEW_CLOSE_PATTERN, REVIEW_OPEN_PATTERN } from '../shared/reviewComments.ts' const queryClient = new QueryClient() @@ -61,7 +67,10 @@ function useIsWide(): boolean { } function useToast(duration = 1500) { - const [toast, setToast] = useState<{ content: React.ReactNode; key: number } | null>(null) + const [toast, setToast] = useState<{ + content: React.ReactNode + key: number + } | null>(null) const timer = useRef>(undefined) const key = useRef(0) const showToast = useCallback( @@ -418,8 +427,30 @@ const MemoizedFileDiff = memo( // --- FileCard --- +function AttrBadges({ attrs }: { attrs: FileAttrs | undefined }) { + if (!attrs) return null + const labels: { key: keyof FileAttrs; label: string }[] = [ + { key: 'generated', label: 'Generated' }, + { key: 'vendored', label: 'Vendored' }, + { key: 'documentation', label: 'Documentation' }, + { key: 'binary', label: 'Binary' }, + ] + const active = labels.filter((l) => attrs[l.key]) + if (active.length === 0) return null + return ( + <> + {active.map((l) => ( + + {l.label} + + ))} + + ) +} + function FileCard({ fileDiff, + attrs, isViewed, isStale, diffStyle, @@ -437,6 +468,7 @@ function FileCard({ submitError, }: { fileDiff: FileDiffMetadata + attrs: FileAttrs | undefined isViewed: boolean isStale: boolean diffStyle: 'split' | 'unified' @@ -588,6 +620,7 @@ function FileCard({ {additions > 0 && +{additions}} {deletions > 0 && -{deletions}} +