From 8fb54c14ba0defd23c94e01c94da5d25500713e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 21:39:46 +0000 Subject: [PATCH 01/33] Put Code cards on the canvas and confirm recent deletes Code view now opens selected and connected files as cards on the infinite canvas, linked by the existing graph lines, instead of a fixed right-hand split capped at four. Recents delete requires a second Confirm? click so a misclick does not drop a cached analysis. Co-authored-by: Braedon Saunders --- README.md | 4 +- index.html | 361 +++++++++++++++++++++++++------------ tests/code-canvas.test.mjs | 64 ++++++- 3 files changed, 303 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index d847236..4a856fd 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Paste URL / Select Files -> See Architecture -> Make Better Decisions ## Features ### Interactive Dependency Graph -See how your files connect at a glance. Click any node to highlight its dependencies. Drag, zoom, and explore. The **Code** view keeps that map and shows the selected file plus its connected files beside it. +See how your files connect at a glance. Click any node to highlight its dependencies. Drag, zoom, and explore. The **Code** view keeps that map and opens the selected file plus its connected files as cards on the canvas. ### Blast Radius Analysis *"If I change this file, what breaks?"* — CodeFlow answers this instantly. Select any file and see exactly how many files would be affected by changes. @@ -257,7 +257,7 @@ strings and Pascal comment forms; unsupported language features fall back gracef | **Layer** | Color by architectural layer (UI, Services, Utils, etc.) | | **Churn** | Color by commit frequency (hot spots) | | **Blast** | Color by impact when a file is selected | -| **Code** | Same graph, with file source and cross-file symbol highlights beside it | +| **Code** | Same graph, with selected and connected files as cards on the canvas | --- diff --git a/index.html b/index.html index 9fa9e89..0768d25 100644 --- a/index.html +++ b/index.html @@ -454,16 +454,12 @@ .light .syn-tag{color:#e45649} .light .syn-attr{color:#986801} .light .syn-punct{color:#383a42} -.code-split{display:flex;width:100%;height:100%;min-width:0;min-height:0} -.code-split-graph{flex:1 1 48%;min-width:180px;position:relative;overflow:hidden} -.code-split-graph svg{width:100%;height:100%;display:block} -.code-split-resize{width:8px;flex-shrink:0;cursor:col-resize;background:var(--border);position:relative;z-index:20;touch-action:none} -.code-split-resize:hover,.code-split-resize.active{background:var(--acc)} -.code-split-pane{flex:1 1 52%;min-width:220px;max-width:70%;display:flex;flex-direction:column;background:var(--bg1);border-left:1px solid var(--border);min-height:0} -.code-split-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0} -.code-split-title{font-size:10px;font-weight:600;color:var(--t2);text-transform:uppercase;letter-spacing:0.6px} -.code-split-hint{font-size:9px;color:var(--t3)} -.code-sym-row{display:flex;flex-wrap:wrap;gap:4px;padding:6px 10px;border-bottom:1px solid var(--border2);flex-shrink:0;max-height:72px;overflow:auto} +.code-canvas{position:relative;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden} +.code-canvas svg{width:100%;height:100%;display:block} +.code-canvas-cards{position:absolute;inset:0;pointer-events:none;transform-origin:0 0;z-index:4} +.code-canvas-hud{position:absolute;top:52px;left:50%;transform:translateX(-50%);z-index:51;display:flex;flex-direction:column;align-items:center;gap:6px;max-width:min(720px,calc(100% - 160px));pointer-events:none} +.code-canvas-hint{font-size:10px;color:var(--t3);background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:6px 10px;text-align:center;line-height:1.4} +.code-sym-row{display:flex;flex-wrap:wrap;gap:4px;padding:6px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg1);max-height:72px;overflow:auto;pointer-events:auto;justify-content:center} .code-sym-chip{font-size:9px;padding:3px 7px;border-radius:999px;border:1px solid var(--border);background:var(--bg0);color:var(--t2);cursor:pointer;font-family:inherit} .code-sym-chip:hover{border-color:var(--acc);color:var(--acc)} .code-sym-chip.active{background:var(--accbg);border-color:var(--acc);color:var(--acc)} @@ -471,20 +467,16 @@ .code-sym-chip.import{box-shadow:inset 3px 0 0 #c678dd} .code-sym-chip.export{box-shadow:inset 3px 0 0 #98c379} .code-sym-chip.var{box-shadow:inset 3px 0 0 #e06c75} -.code-file-tabs{display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border2);overflow-x:auto;flex-shrink:0} -.code-file-tab{font-size:9px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg0);color:var(--t2);cursor:pointer;white-space:nowrap;font-family:inherit} -.code-file-tab:hover{color:var(--t0);border-color:var(--acc)} -.code-file-tab.active{background:var(--accbg);border-color:var(--acc);color:var(--acc)} -.code-file-tab.connected{opacity:0.95} -.code-cards{flex:1;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:8px;min-height:0} -.code-card{border:1px solid var(--border);border-radius:8px;background:var(--bg0);overflow:hidden;display:flex;flex-direction:column;min-height:120px} -.code-card.primary{flex:1 1 55%;min-height:180px} -.code-card.linked{flex:0 1 28%;min-height:110px;max-height:240px} -.code-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:var(--bg2);border-bottom:1px solid var(--border2);cursor:pointer} -.code-card-name{font-size:11px;font-weight:600;color:var(--t0)} -.code-card-path{font-size:9px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.code-card-body{flex:1;overflow:auto;min-height:0} +.code-card{position:absolute;width:320px;height:220px;border:1px solid var(--border);border-radius:8px;background:var(--bg0);overflow:hidden;display:flex;flex-direction:column;pointer-events:auto;box-shadow:0 10px 28px rgba(0,0,0,0.28);visibility:hidden;user-select:none} +.code-card.primary{width:380px;height:280px;border-color:var(--acc);box-shadow:0 12px 32px rgba(0,0,0,0.34),0 0 0 1px var(--acc)} +.code-card.linked{opacity:0.98} +.code-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:var(--bg2);border-bottom:1px solid var(--border2);cursor:grab;touch-action:none;flex-shrink:0} +.code-card-head:active{cursor:grabbing} +.code-card-name{font-size:11px;font-weight:600;color:var(--t0);transform-origin:left center;display:inline-block;white-space:nowrap} +.code-card-path{font-size:9px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px} +.code-card-body{flex:1;overflow:auto;min-height:0;touch-action:pan-y;user-select:text} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} +.has-code-card .nc,.has-code-card .node-label{opacity:0!important;pointer-events:none} .sym-mark{border-radius:3px;padding:0 1px;cursor:pointer} .sym-mark.fn{background:rgba(97,175,239,0.16)} .sym-mark.import{background:rgba(198,120,221,0.16)} @@ -502,16 +494,14 @@ .recent-mini-btn{font-size:8px;padding:3px 6px;border-radius:4px;border:1px solid var(--border);background:var(--bg2);color:var(--t2);cursor:pointer;font-family:inherit} .recent-mini-btn:hover{border-color:var(--acc);color:var(--acc)} .recent-mini-btn.danger:hover{border-color:var(--red);color:var(--red)} +.recent-mini-btn.danger.armed{background:var(--red);border-color:var(--red);color:#fff} .recent-empty{font-size:10px;color:var(--t3);line-height:1.4;padding:4px 0} .stale-banner{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;background:var(--accbg);border:1px solid var(--acc);border-radius:6px;margin:0 0 8px;font-size:10px;color:var(--t1)} .github-hint{font-size:9px;color:var(--t3);line-height:1.45;margin-top:6px;max-width:420px} .github-hint button{background:none;border:none;color:var(--acc);cursor:pointer;font-family:inherit;font-size:9px;padding:0;text-decoration:underline} .cli-chip{padding:4px 8px;background:var(--accbg);border:1px solid var(--acc);border-radius:6px;font-size:9px;color:var(--acc)} @media(max-width:980px){ -.code-split{flex-direction:column} -.code-split-graph{flex:1 1 42%;min-height:180px} -.code-split-resize{width:100%;height:8px;cursor:row-resize} -.code-split-pane{flex:1 1 58%;max-width:none;min-height:200px;border-left:0;border-top:1px solid var(--border)} +.code-canvas-hud{top:56px;max-width:calc(100% - 24px)} } .auth-select{padding:6px 10px;background:var(--bg0);border:1px solid var(--border);border-radius:6px;color:var(--t0);font-family:inherit;font-size:10px;cursor:pointer;min-width:100px;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b95' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:28px} .auth-select:focus{outline:none;border-color:var(--acc)} @@ -686,7 +676,7 @@ * 4. Shared analysis pipeline plus inline Web Worker fallback. * 5. React UI, local folder/ZIP ingestion, visualization, and modals. */ -const{useState,useEffect,useRef,useMemo,useCallback}=React; +const{useState,useEffect,useLayoutEffect,useRef,useMemo,useCallback}=React; const COLORS=['#4d9fff','#a78bfa','#22d3ee','#00ff9d','#ff9f43','#ec4899','#ff5f5f','#84cc16']; const LAYER_COLORS={ui:'#4d9fff',components:'#22d3ee',services:'#a78bfa',utils:'#00ff9d',data:'#ff9f43',config:'#ec4899',test:'#f59e0b',modules:'#a78bfa',forms:'#22d3ee',classes:'#ff9f43',note:'#c084fc'}; const IGNORE=new Set(['node_modules','.git','vendor','dist','build','out','coverage','.next','.nuxt','.cache','.parcel-cache','.turbo','.vercel','.local','.artifacts','.playwright-cli','playwright-report','test-results','.claude','.codex','.idea','.vscode','.pnpm-store','.yarn','tmp','temp','target','bin','obj','__pycache__','.venv','venv','env','.env','.tox','.mypy_cache','.pytest_cache','.ruff_cache','__pypackages__','.eggs','__macosx']); @@ -5733,7 +5723,6 @@ function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){ if(!data||!data.files)return[]; - var max=limit==null?4:limit; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; var byPath=Object.create(null); filtered.forEach(function(f){byPath[f.path]=f;}); @@ -5745,8 +5734,80 @@ } if(selectedPath)add(selectedPath); getConnectedFilePaths(selectedPath,data.connections).forEach(add); - if(!ordered.length&&filtered.length)add(filtered[0].path); - return ordered.slice(0,max); + if(limit==null||!isFinite(Number(limit)))return ordered; + return ordered.slice(0,Math.max(0,Number(limit))); +} + +var CODE_CARD_WIDTH=320; +var CODE_CARD_HEIGHT=220; +var CODE_CARD_PRIMARY_WIDTH=380; +var CODE_CARD_PRIMARY_HEIGHT=280; + +function codeCardSize(isPrimary){ + return isPrimary + ?{width:CODE_CARD_PRIMARY_WIDTH,height:CODE_CARD_PRIMARY_HEIGHT} + :{width:CODE_CARD_WIDTH,height:CODE_CARD_HEIGHT}; +} + +function codeCardCollisionRadius(isCard){ + if(!isCard)return 20; + var size=codeCardSize(false); + return Math.round(Math.hypot(size.width,size.height)/2)+18; +} + +function liveCodeCollideRadius(node,isCard){ + if(isCard)return codeCardCollisionRadius(true); + return Math.max(8,Math.min(24,5+((node&&node.fnCount)||0)*0.8))+12; +} + +function codeCanvasTransformStyle(transform){ + var t=transform||{}; + var k=Number(t.k); + if(!isFinite(k)||k<=0)k=1; + var x=Number(t.x); + if(!isFinite(x))x=0; + var y=Number(t.y); + if(!isFinite(y))y=0; + return 'translate('+x+'px,'+y+'px) scale('+k+')'; +} + +function codeCardAnchorStyle(node,size){ + size=size||codeCardSize(false); + if(!node||!isFinite(node.x)||!isFinite(node.y))return{visibility:'hidden',left:'0px',top:'0px'}; + return{ + visibility:'visible', + left:(node.x-size.width/2)+'px', + top:(node.y-size.height/2)+'px' + }; +} + +function applyCodeCardLayout(layer,nodesById,transform,primaryPath){ + if(!layer)return{placed:0,titleScale:1}; + layer.style.transform=codeCanvasTransformStyle(transform); + var k=transform&&isFinite(Number(transform.k))?Number(transform.k):1; + var titleScale=readableLabelScale(k); + var cards=layer.querySelectorAll?layer.querySelectorAll('[data-code-card]'):[]; + var placed=0; + Array.prototype.forEach.call(cards,function(card){ + var path=card.getAttribute('data-code-card'); + var size=codeCardSize(path===primaryPath); + var style=codeCardAnchorStyle(nodesById&&nodesById[path],size); + card.style.visibility=style.visibility; + card.style.left=style.left; + card.style.top=style.top; + card.style.width=size.width+'px'; + card.style.height=size.height+'px'; + var title=card.querySelector?card.querySelector('.code-card-name'):null; + if(title)title.style.transform='scale('+titleScale+')'; + if(style.visibility==='visible')placed++; + }); + return{placed:placed,titleScale:titleScale}; +} + +function armRecentDelete(armedId,clickedId){ + clickedId=clickedId||null; + if(clickedId&&armedId===clickedId)return{confirm:true,armedId:null}; + return{confirm:false,armedId:clickedId}; } function extractFileSymbols(file,connections){ @@ -5950,29 +6011,6 @@ return copy; } -function codeSplitIsStacked(wrap){ - if(!wrap||typeof getComputedStyle!=='function')return false; - try{ - return getComputedStyle(wrap).flexDirection==='column'; - }catch(e){ - return false; - } -} - -function codeSplitPanePercent(rect,point,stacked){ - if(!rect||!point)return 42; - var next; - if(stacked){ - var height=rect.height||1; - next=((rect.bottom-point.clientY)/height)*100; - }else{ - var width=rect.width||1; - next=((rect.right-point.clientX)/width)*100; - } - if(!isFinite(next))return 42; - return Math.max(28,Math.min(72,next)); -} - function openAnalysisCacheDb(){ if(typeof indexedDB==='undefined')return Promise.reject(new Error('IndexedDB is not available')); return new Promise(function(resolve,reject){ @@ -6507,7 +6545,7 @@ var _recents=useState([]),recentAnalyses=_recents[0],setRecentAnalyses=_recents[1]; var _cachedId=useState(null),cachedFromId=_cachedId[0],setCachedFromId=_cachedId[1]; var _activeSym=useState(null),activeSymbol=_activeSym[0],setActiveSymbol=_activeSym[1]; - var _codeWidth=useState(52),codePanePercent=_codeWidth[0],setCodePanePercent=_codeWidth[1]; + var _pendingDel=useState(null),pendingRecentDelete=_pendingDel[0],setPendingRecentDelete=_pendingDel[1]; var _cli=useState(null),cliStatus=_cli[0],setCliStatus=_cli[1]; var _cliDirty=useState([]),cliDirty=_cliDirty[0],setCliDirty=_cliDirty[1]; var isMobile=viewportWidth<=980; @@ -6529,6 +6567,12 @@ var nodesRef=useRef(null); var linksRef=useRef(null); var selectFileRef=useRef(null); + var codeCardsLayerRef=useRef(null); + var codeCardPathsRef=useRef(new Set()); + var codeZoomTransformRef=useRef({k:1,x:0,y:0}); + var graphNodesByIdRef=useRef(Object.create(null)); + var selectedPathRef=useRef(null); + var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); var zipArchiveRef=useRef(null); var zipFileRef=useRef(null); @@ -6668,6 +6712,10 @@ confirmResolverRef.current(false); confirmResolverRef.current=null; } + if(pendingRecentDeleteTimerRef.current){ + clearTimeout(pendingRecentDeleteTimerRef.current); + pendingRecentDeleteTimerRef.current=null; + } }; },[]); @@ -6683,6 +6731,18 @@ return function(){document.removeEventListener('keydown',onKeyDown);}; },[confirmDialog]); + useEffect(function(){ + if(!pendingRecentDelete)return; + function onKeyDown(e){ + if(e.key==='Escape'){ + e.preventDefault(); + clearPendingRecentDelete(); + } + } + document.addEventListener('keydown',onKeyDown); + return function(){document.removeEventListener('keydown',onKeyDown);}; + },[pendingRecentDelete]); + useEffect(function(){ var params=new URLSearchParams(window.location.search); var repo=params.get('repo'); @@ -6792,6 +6852,7 @@ } function loadRecentAnalysis(id){ + clearPendingRecentDelete(); return getRecentAnalysis(id).then(function(record){ if(!record){showNotification('That analysis is no longer cached.','warning');refreshRecentList();return null;} applyCachedAnalysis(record); @@ -6806,14 +6867,34 @@ }).catch(function(){showNotification('Could not open cached analysis.','error');}); } - function removeRecentAnalysis(id,e){ - if(e)e.stopPropagation(); + function clearPendingRecentDelete(){ + if(pendingRecentDeleteTimerRef.current){ + clearTimeout(pendingRecentDeleteTimerRef.current); + pendingRecentDeleteTimerRef.current=null; + } + setPendingRecentDelete(null); + } + + function removeRecentAnalysis(id){ deleteRecentAnalysis(id).then(function(){ if(cachedFromId===id)setCachedFromId(null); refreshRecentList(); }).catch(function(){}); } + function requestRecentDelete(id,e){ + if(e)e.stopPropagation(); + var next=armRecentDelete(pendingRecentDelete,id); + if(next.confirm){ + clearPendingRecentDelete(); + removeRecentAnalysis(id); + return; + } + setPendingRecentDelete(next.armedId); + if(pendingRecentDeleteTimerRef.current)clearTimeout(pendingRecentDeleteTimerRef.current); + pendingRecentDeleteTimerRef.current=setTimeout(function(){setPendingRecentDelete(null);},4000); + } + function startGithubZipDownload(owner,repo){ var url=githubZipDownloadUrl(owner,repo); var a=document.createElement('a'); @@ -7913,8 +7994,35 @@ var codeViewFiles=useMemo(function(){ if(!data)return[]; - return collectVisibleCodeFiles(selected&&selected.path,data,folderFilter,4); + return collectVisibleCodeFiles(selected&&selected.path,data,folderFilter); },[data,selected,folderFilter]); + selectedPathRef.current=selected&&selected.path; + codeCardPathsRef.current=new Set(codeViewFiles.map(function(file){return file.path;})); + + function syncCodeCards(){ + applyCodeCardLayout(codeCardsLayerRef.current,graphNodesByIdRef.current,codeZoomTransformRef.current,selectedPathRef.current); + } + + function refreshCodeCardForces(){ + var sim=simRef.current; + if(!sim)return; + var collide=sim.force('collision'); + if(collide&&typeof collide.radius==='function'){ + collide.radius(function(d){return liveCodeCollideRadius(d,codeCardPathsRef.current.has(d.id));}); + } + var linkForce=sim.force('link'); + if(linkForce&&typeof linkForce.distance==='function'){ + linkForce.distance(function(d){ + var src=typeof d.source==='object'?d.source.id:d.source; + var tgt=typeof d.target==='object'?d.target.id:d.target; + var extra=(codeCardPathsRef.current.has(src)||codeCardPathsRef.current.has(tgt))?240:0; + return (graphConfig.linkDist||70)+extra; + }); + } + if(nodesRef.current){ + nodesRef.current.classed('has-code-card',function(d){return codeCardPathsRef.current.has(d.id);}); + } + } var codeViewSymbols=useMemo(function(){ return collectCrossFileSymbols(codeViewFiles,data?data.connections:[]); @@ -7968,6 +8076,9 @@ var centers={}; folders.forEach(function(f,i){centers[f]={x:(i%cols+1)*cw,y:(Math.floor(i/cols)+1)*ch};}); var keepReadable=graphConfig.vizType==='code'; + graphNodesByIdRef.current=Object.create(null); + nodes.forEach(function(n){graphNodesByIdRef.current[n.id]=n;}); + function collideR(d){return liveCodeCollideRadius(d,keepReadable&&codeCardPathsRef.current.has(d.id));} function applyReadableLabels(k){ if(!keepReadable)return; var s=readableLabelScale(k); @@ -7976,7 +8087,9 @@ } var zoom=d3.zoom().scaleExtent([keepReadable?0.08:0.2,5]).on('zoom',function(e){ container.attr('transform',e.transform); + codeZoomTransformRef.current=e.transform; applyReadableLabels(e.transform.k); + if(keepReadable)syncCodeCards(); }); svg.call(zoom); zoomRef.current=zoom; @@ -7990,7 +8103,7 @@ if(graphConfig.viewMode==='force'){ sim.force('link',d3.forceLink(links).id(function(d){return d.id;}).distance(graphConfig.linkDist).strength(0.3)) .force('charge',d3.forceManyBody().strength(-graphConfig.spacing).distanceMax(400)) - .force('collision',d3.forceCollide().radius(function(d){return getR(d)+12;})) + .force('collision',d3.forceCollide().radius(collideR)) .force('x',d3.forceX(function(d){return centers[d.folder]?centers[d.folder].x:w/2;}).strength(0.15)) .force('y',d3.forceY(function(d){return centers[d.folder]?centers[d.folder].y:h/2;}).strength(0.15)); }else if(graphConfig.viewMode==='radial'){ @@ -7998,7 +8111,7 @@ nodes.forEach(function(n,i){n.angle=i/nodes.length*2*Math.PI;n.targetX=w/2+Math.cos(n.angle)*r;n.targetY=h/2+Math.sin(n.angle)*r;}); sim.force('link',d3.forceLink(links).id(function(d){return d.id;}).distance(graphConfig.linkDist*0.5).strength(0.05)) .force('charge',d3.forceManyBody().strength(-graphConfig.spacing*0.3)) - .force('collision',d3.forceCollide().radius(function(d){return getR(d)+8;})) + .force('collision',d3.forceCollide().radius(collideR)) .force('x',d3.forceX(function(d){return d.targetX;}).strength(0.8)) .force('y',d3.forceY(function(d){return d.targetY;}).strength(0.8)); }else if(graphConfig.viewMode==='hierarchical'){ @@ -8009,7 +8122,7 @@ sortedLayers.forEach(function(l,li){var g=layerGroups[l];var colW=w/(sortedLayers.length+1);g.forEach(function(n,ni){n.targetX=(li+1)*colW;n.targetY=(ni+1)*h/(g.length+1);});}); sim.force('link',d3.forceLink(links).id(function(d){return d.id;}).distance(graphConfig.linkDist).strength(0.1)) .force('charge',d3.forceManyBody().strength(-graphConfig.spacing*0.5).distanceMax(200)) - .force('collision',d3.forceCollide().radius(function(d){return getR(d)+10;})) + .force('collision',d3.forceCollide().radius(collideR)) .force('x',d3.forceX(function(d){return d.targetX||w/2;}).strength(0.9)) .force('y',d3.forceY(function(d){return d.targetY||h/2;}).strength(0.3)); }else if(graphConfig.viewMode==='grid'){ @@ -8018,7 +8131,7 @@ var cellH=h/(Math.ceil(nodes.length/gridCols)+1); nodes.forEach(function(n,i){n.targetX=(i%gridCols+1)*cellW;n.targetY=(Math.floor(i/gridCols)+1)*cellH;}); sim.force('link',d3.forceLink(links).id(function(d){return d.id;}).distance(graphConfig.linkDist*1.5).strength(0.02)) - .force('collision',d3.forceCollide().radius(function(d){return getR(d)+15;})) + .force('collision',d3.forceCollide().radius(collideR)) .force('x',d3.forceX(function(d){return d.targetX;}).strength(1)) .force('y',d3.forceY(function(d){return d.targetY;}).strength(1)); }else if(graphConfig.viewMode==='metro'){ @@ -8038,7 +8151,7 @@ }); nodes.filter(function(n){return!n.targetX;}).forEach(function(n,i){n.targetX=80+i*50;n.targetY=h-80;n.metroLine=roots.length;}); sim.force('link',d3.forceLink(links).id(function(d){return d.id;}).distance(graphConfig.linkDist).strength(0.05)) - .force('collision',d3.forceCollide().radius(function(d){return getR(d)+12;})) + .force('collision',d3.forceCollide().radius(collideR)) .force('x',d3.forceX(function(d){return d.targetX||w/2;}).strength(0.95)) .force('y',d3.forceY(function(d){return d.targetY||h/2;}).strength(0.95)); } @@ -8054,7 +8167,8 @@ nodesRef.current=node; node.call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.1).restart();d.fx=d.x;d.fy=d.y;}).on('drag',function(e,d){d.fx=e.x;d.fy=e.y;}).on('end',function(e,d){if(!e.active)sim.alphaTarget(0);d.fx=null;d.fy=null;})); node.on('click',function(e,d){e.stopPropagation();if(selectFileRef.current)selectFileRef.current(d.id);}); - node.on('mouseenter',function(e,d){var r=svgRef.current.getBoundingClientRect();setTooltip({x:e.clientX-r.left+10,y:e.clientY-r.top,title:d.name,content:d.fnCount+' functions\n'+d.layer+' layer\n'+d.churn+' recent commits'});}).on('mouseleave',function(){setTooltip(null);}); + node.classed('has-code-card',function(d){return keepReadable&&codeCardPathsRef.current.has(d.id);}); + node.on('mouseenter',function(e,d){if(keepReadable&&codeCardPathsRef.current.has(d.id))return;var r=svgRef.current.getBoundingClientRect();setTooltip({x:e.clientX-r.left+10,y:e.clientY-r.top,title:d.name,content:d.fnCount+' functions\n'+d.layer+' layer\n'+d.churn+' recent commits'});}).on('mouseleave',function(){setTooltip(null);}); svg.on('click',function(e){if(e.target===svgRef.current){setSelected(null);setBlastRadius(null);link.attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-opacity',0.4);node.selectAll('.nc').attr('opacity',1).attr('fill',getC);}}); node.append('circle').attr('class','nc').attr('r',getR).attr('fill',getC).attr('stroke',function(d){var c=d3.color(getC(d));return c?c.brighter(0.3):'#fff';}).attr('stroke-width',1.5); // Hide labels for large graphs to reduce DOM overhead. Code view keeps titles readable. @@ -8069,14 +8183,19 @@ folders.forEach(function(f){ var fn=nodesByFolder[f]; if(!fn||fn.length<1)return; - var pad=30,pts=[]; - fn.forEach(function(n){if(n.x&&n.y)pts.push([n.x-pad,n.y-pad],[n.x+pad,n.y-pad],[n.x-pad,n.y+pad],[n.x+pad,n.y+pad]);}); + var pts=[]; + fn.forEach(function(n){ + if(!n.x||!n.y)return; + var pad=(keepReadable&&codeCardPathsRef.current.has(n.id))?codeCardCollisionRadius(true):30; + pts.push([n.x-pad,n.y-pad],[n.x+pad,n.y-pad],[n.x-pad,n.y+pad],[n.x+pad,n.y+pad]); + }); if(pts.length<3)return; var hull=d3.polygonHull(pts); if(hull){ var color=colorMap[f]||COLORS[folders.indexOf(f)%COLORS.length]; hullLayer.append('path').attr('d','M'+hull.join('L')+'Z').attr('fill',color).attr('fill-opacity',0.04).attr('stroke',color).attr('stroke-width',2).attr('stroke-opacity',0.25).attr('rx',8); - var cx=d3.mean(fn,function(n){return n.x;}),cy=d3.min(fn,function(n){return n.y;})-pad-8; + var hullPad=fn.some(function(n){return keepReadable&&codeCardPathsRef.current.has(n.id);})?codeCardCollisionRadius(true):30; + var cx=d3.mean(fn,function(n){return n.x;}),cy=d3.min(fn,function(n){return n.y;})-hullPad-8; hullLayer.append('text').attr('class','hull-label').attr('x',cx).attr('y',cy).attr('text-anchor','middle').attr('fill',color).attr('font-size',keepReadable?'11px':'10px').attr('font-family','JetBrains Mono').attr('font-weight','600').attr('opacity',0.85).text(f||'root'); } }); @@ -8091,14 +8210,29 @@ link.attr('d',function(d){return'M'+d.source.x+','+d.source.y+'L'+d.target.x+','+d.target.y;}); } node.attr('transform',function(d){return'translate('+d.x+','+d.y+')';}); + if(keepReadable){ + nodes.forEach(function(n){graphNodesByIdRef.current[n.id]=n;}); + syncCodeCards(); + } tickCount++; if(tickCount%hullInterval===0)updateHulls(); }); node.selectAll('text').attr('opacity',(keepReadable||graphConfig.showLabels)?1:0); }catch(e){console.error('Force graph error:',e);svg.selectAll('*').remove();svg.append('text').attr('x',20).attr('y',30).attr('fill','var(--t3)').text('Graph rendering error: '+e.message);} + if(keepReadable){ + refreshCodeCardForces(); + syncCodeCards(); + } return function(){if(simRef.current)simRef.current.stop();}; },[data,colorMap,colorMode,theme,folderFilter,graphConfig]); + useLayoutEffect(function(){ + if(graphConfig.vizType!=='code')return; + refreshCodeCardForces(); + if(simRef.current)simRef.current.alpha(codeCardPathsRef.current.size?0.28:0.12).restart(); + syncCodeCards(); + },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist]); + // 3D Force Graph Hook useEffect(function(){ if(!data||!graph3dRef.current||graphConfig.vizType!=='graph3d')return; @@ -8997,6 +9131,9 @@ } function computeGraphFitTransform(paddingSlack){ paddingSlack=paddingSlack==null?100:paddingSlack; + if(graphConfig.vizType==='code'&&codeCardPathsRef.current&&codeCardPathsRef.current.size){ + paddingSlack=Math.max(paddingSlack,codeCardCollisionRadius(true)*2); + } if(!zoomRef.current||!svgRef.current||!simRef.current)return null; var nodes=simRef.current.nodes(); if(!nodes.length)return null; @@ -9525,13 +9662,46 @@ ), React.createElement('div',{className:'recent-item-actions'}, React.createElement('button',{className:'recent-mini-btn',title:'Re-analyze',onClick:function(e){e.stopPropagation();reanalyzeRecent(item.id);}},'Re-analyze'), - React.createElement('button',{className:'recent-mini-btn danger',title:'Delete cached analysis',onClick:function(e){removeRecentAnalysis(item.id,e);}},'Delete') + React.createElement('button',{className:'recent-mini-btn danger'+(pendingRecentDelete===item.id?' armed':''),title:pendingRecentDelete===item.id?'Click again to delete':'Delete cached analysis',onClick:function(e){requestRecentDelete(item.id,e);}},pendingRecentDelete===item.id?'Confirm?':'Delete') ) ); }) ):React.createElement('div',{className:'recent-empty'},'Analyses you run are cached here so the same tree is not fetched twice.') ); } + function beginCodeCardDrag(e,file){ + if(e.pointerType==='mouse'&&e.button!==0)return; + e.stopPropagation(); + var node=graphNodesByIdRef.current[file.path]; + var sim=simRef.current; + var transform=codeZoomTransformRef.current||{k:1,x:0,y:0}; + var k=Number(transform.k); + if(!isFinite(k)||k<=0)k=1; + if(!node||!sim){ + selectFile(file.path); + return; + } + var startX=e.clientX,startY=e.clientY,originX=node.x,originY=node.y,moved=false; + node.fx=node.x;node.fy=node.y; + sim.alphaTarget(0.18).restart(); + function onMove(ev){ + var dx=(ev.clientX-startX)/k,dy=(ev.clientY-startY)/k; + if(Math.abs(dx)+Math.abs(dy)>3)moved=true; + node.fx=originX+dx;node.fy=originY+dy; + syncCodeCards(); + } + function onUp(){ + window.removeEventListener('pointermove',onMove); + window.removeEventListener('pointerup',onUp); + window.removeEventListener('pointercancel',onUp); + sim.alphaTarget(0); + node.fx=null;node.fy=null; + if(!moved)selectFile(file.path); + } + window.addEventListener('pointermove',onMove); + window.addEventListener('pointerup',onUp); + window.addEventListener('pointercancel',onUp); + } function renderCodeFileCard(file,isPrimary){ if(!file)return null; var sourceState=fileSourceDisplayState(file,canReadLiveFileSource()); @@ -9552,8 +9722,8 @@ :(sourceState==='loading'?'Loading source...':'Reopen this project to read source'); body=React.createElement('div',{className:'empty-desc',style:{padding:'12px'}},message); } - return React.createElement('div',{key:file.path,className:'code-card'+(isPrimary?' primary':' linked'),onClick:function(){if(selected&&selected.path!==file.path)selectFile(file.path);}}, - React.createElement('div',{className:'code-card-head',onClick:function(e){e.stopPropagation();selectFile(file.path);}}, + return React.createElement('div',{key:file.path,'data-code-card':file.path,className:'code-card'+(isPrimary?' primary':' linked'),onClick:function(){if(selected&&selected.path!==file.path)selectFile(file.path);}}, + React.createElement('div',{className:'code-card-head',onPointerDown:function(e){beginCodeCardDrag(e,file);}}, React.createElement('div',null, React.createElement('div',{className:'code-card-name'},file.name), React.createElement('div',{className:'code-card-path'},file.path) @@ -9571,55 +9741,18 @@ ); } function renderCodeView(){ - var primary=codeViewFiles[0]||null; - var linked=codeViewFiles.slice(1); - return React.createElement('div',{className:'code-split'}, - React.createElement('div',{className:'code-split-graph',style:{flexBasis:(100-codePanePercent)+'%'}}, - React.createElement('svg',{ref:svgRef}) + var primaryPath=selected&&selected.path; + return React.createElement('div',{className:'code-canvas'}, + React.createElement('svg',{ref:svgRef}), + React.createElement('div',{className:'code-canvas-cards',ref:codeCardsLayerRef}, + codeViewFiles.map(function(file){return renderCodeFileCard(file,file.path===primaryPath);}) ), - React.createElement('div',{className:'code-split-resize',onPointerDown:function(e){ - if(e.pointerType==='mouse'&&e.button!==0)return; - e.preventDefault(); - var handle=e.currentTarget; - var wrap=handle.parentNode; - var stacked=codeSplitIsStacked(wrap); - handle.classList.add('active'); - try{if(handle.setPointerCapture)handle.setPointerCapture(e.pointerId);}catch(err){} - function onMove(ev){ - if(!wrap)return; - setCodePanePercent(codeSplitPanePercent(wrap.getBoundingClientRect(),ev,stacked)); - } - function onUp(){ - handle.classList.remove('active'); - window.removeEventListener('pointermove',onMove); - window.removeEventListener('pointerup',onUp); - window.removeEventListener('pointercancel',onUp); - } - window.addEventListener('pointermove',onMove); - window.addEventListener('pointerup',onUp); - window.addEventListener('pointercancel',onUp); - }}), - React.createElement('div',{className:'code-split-pane',style:{flexBasis:codePanePercent+'%'}}, - React.createElement('div',{className:'code-split-head'}, - React.createElement('div',{className:'code-split-title'},'Code'), - React.createElement('div',{className:'code-split-hint'},selected?'Click a symbol to highlight it across these files':'Click a node to read it next to the graph') - ), + React.createElement('div',{className:'code-canvas-hud'}, + React.createElement('div',{className:'code-canvas-hint'},selected?'Click a symbol to highlight it across these cards':'Click a node to open its file as a card on the canvas. Connected files open with it.'), codeViewSymbols.length>0&&React.createElement('div',{className:'code-sym-row'}, codeViewSymbols.slice(0,24).map(function(sym){ return React.createElement('button',{key:sym.name,className:'code-sym-chip '+sym.kind+(activeSymbol===sym.name?' active':''),onClick:function(){setActiveSymbol(function(prev){return prev===sym.name?null:sym.name;});}},sym.name); }) - ), - React.createElement('div',{className:'code-file-tabs'}, - codeViewFiles.map(function(file,idx){ - return React.createElement('button',{key:file.path,className:'code-file-tab'+(primary&&file.path===primary.path?' active':'')+(idx>0?' connected':''),onClick:function(){selectFile(file.path);}},file.name); - }) - ), - React.createElement('div',{className:'code-cards'}, - primary?renderCodeFileCard(primary,true):React.createElement('div',{className:'empty-state'}, - React.createElement('div',{className:'empty-title'},'Select a file'), - React.createElement('div',{className:'empty-desc'},'Click a node on the map to read its code and connected files.') - ), - linked.map(function(file){return renderCodeFileCard(file,false);}) ) ) ); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index a428f98..9589ce5 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -73,10 +73,57 @@ test('visible code files prefer the selection and its neighbors', () => { ], connections: [{ source: 'a.js', target: 'b.js', fn: 'a' }] }; - const visible = context.collectVisibleCodeFiles('a.js', data, 'src', 4); + const visible = context.collectVisibleCodeFiles('a.js', data, 'src'); assert.deepEqual(J(visible).map((f) => f.path), ['a.js', 'b.js']); }); +test('visible code files are not capped at four', () => { + const files = ['a.js', 'b.js', 'c.js', 'd.js', 'e.js', 'f.js'].map((name) => ({ + path: name, + folder: 'src', + name, + content: '', + functions: [] + })); + const connections = files.slice(1).map((file) => ({ source: 'a.js', target: file.path, fn: 'a' })); + const visible = context.collectVisibleCodeFiles('a.js', { files, connections }, 'src'); + assert.deepEqual(J(visible).map((f) => f.path), ['a.js', 'b.js', 'c.js', 'd.js', 'e.js', 'f.js']); + assert.equal(context.collectVisibleCodeFiles(null, { files, connections }, 'src').length, 0); +}); + +test('code cards sit on the canvas transform, not a split pane', () => { + assert.deepEqual(J(context.codeCardSize(false)), { width: 320, height: 220 }); + assert.ok(context.codeCardCollisionRadius(true) > 100); + assert.equal(context.codeCanvasTransformStyle({ k: 0.5, x: 10, y: 20 }), 'translate(10px,20px) scale(0.5)'); + assert.deepEqual(J(context.codeCardAnchorStyle({ x: 400, y: 300 }, { width: 320, height: 220 })), { + visibility: 'visible', + left: '240px', + top: '190px' + }); + const title = { style: {} }; + const card = { + getAttribute(name) { return name === 'data-code-card' ? 'a.js' : null; }, + style: {}, + querySelector() { return title; } + }; + const layer = { + style: {}, + querySelectorAll() { return [card]; } + }; + const placed = context.applyCodeCardLayout(layer, { 'a.js': { x: 400, y: 300 } }, { k: 0.5, x: 12, y: 8 }, 'a.js'); + assert.equal(placed.placed, 1); + assert.equal(placed.titleScale, 2); + assert.equal(layer.style.transform, 'translate(12px,8px) scale(0.5)'); + assert.equal(card.style.left, '210px'); + assert.equal(title.style.transform, 'scale(2)'); +}); + +test('recent delete requires a second confirm click', () => { + assert.deepEqual(J(context.armRecentDelete(null, 'github:owner/repo')), { confirm: false, armedId: 'github:owner/repo' }); + assert.deepEqual(J(context.armRecentDelete('github:owner/repo', 'github:owner/repo')), { confirm: true, armedId: null }); + assert.deepEqual(J(context.armRecentDelete('github:owner/repo', 'zip:other')), { confirm: false, armedId: 'zip:other' }); +}); + test('cache keys and records stay stable', () => { assert.equal(context.analysisCacheKey('github', 'braedonsaunders/codeflow'), 'github:braedonsaunders/codeflow'); const record = context.buildRecentAnalysisRecord({ @@ -228,13 +275,6 @@ test('empty source files count as loaded after hydration', () => { assert.equal(context.fileSourceDisplayState(merged.files[0], true), 'ready'); }); -test('code split percent uses the stacked axis', () => { - const rect = { width: 1000, height: 800, right: 1000, bottom: 800 }; - assert.equal(context.codeSplitPanePercent(rect, { clientX: 400, clientY: 200 }, false), 60); - assert.equal(context.codeSplitPanePercent(rect, { clientX: 400, clientY: 200 }, true), 72); - assert.equal(context.codeSplitPanePercent(rect, { clientX: 900, clientY: 600 }, true), 28); -}); - test('empty code cards always render from an array of lines', () => { assert.deepEqual(J(context.asCodeLines('')), ['']); assert.deepEqual(J(context.asCodeLines(null)), ['']); @@ -254,10 +294,15 @@ test('HTML attribute sanitizer encodes quotes before they reach data-sym', () => test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /value:'code'/); assert.match(htmlSource, /function renderCodeView\(/); - assert.match(htmlSource, /className:'code-split'/); + assert.match(htmlSource, /className:'code-canvas'/); + assert.doesNotMatch(htmlSource, /className:'code-split'/); + assert.match(htmlSource, /data-code-card/); + assert.match(htmlSource, /applyCodeCardLayout/); assert.match(htmlSource, /vizType==='code'/); assert.match(htmlSource, /readableLabelScale/); assert.match(htmlSource, /listRecentAnalyses/); + assert.match(htmlSource, /armRecentDelete/); + assert.match(htmlSource, /Confirm\?/); assert.match(htmlSource, /__codeflow\/status/); assert.match(htmlSource, /analyzeFromCli\(false,status\)/); assert.match(htmlSource, /retainedFolderMatchesRecord/); @@ -269,7 +314,6 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /zipArchiveCacheMeta/); assert.match(htmlSource, /compactAnalysisForCache/); assert.match(htmlSource, /fileHasLoadedSource/); - assert.match(htmlSource, /codeSplitPanePercent/); assert.match(htmlSource, /onPointerDown/); assert.match(htmlSource, /__codeflow\/file\?path=/); assert.match(htmlSource, /The folder picker is faster when the API is rate-limited/); From 5fc2dcee02a1af6b4963d0ee17f64cb87d854fef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 21:40:31 +0000 Subject: [PATCH 02/33] Keep dragged Code cards in place and reset canvas zoom Cards no longer snap back after a drag, and a remounted Code view starts from identity zoom so overlay cards are not offset. Co-authored-by: Braedon Saunders --- index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 0768d25..0a1f62f 100644 --- a/index.html +++ b/index.html @@ -8076,6 +8076,7 @@ var centers={}; folders.forEach(function(f,i){centers[f]={x:(i%cols+1)*cw,y:(Math.floor(i/cols)+1)*ch};}); var keepReadable=graphConfig.vizType==='code'; + codeZoomTransformRef.current={k:1,x:0,y:0}; graphNodesByIdRef.current=Object.create(null); nodes.forEach(function(n){graphNodesByIdRef.current[n.id]=n;}); function collideR(d){return liveCodeCollideRadius(d,keepReadable&&codeCardPathsRef.current.has(d.id));} @@ -9695,8 +9696,8 @@ window.removeEventListener('pointerup',onUp); window.removeEventListener('pointercancel',onUp); sim.alphaTarget(0); - node.fx=null;node.fy=null; - if(!moved)selectFile(file.path); + if(moved){node.fx=node.x;node.fy=node.y;} + else{node.fx=null;node.fy=null;selectFile(file.path);} } window.addEventListener('pointermove',onMove); window.addEventListener('pointerup',onUp); From 8e1f44c377e65650d9c0ae53c632679242fb8866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:09:03 +0000 Subject: [PATCH 03/33] Cap Code cards and ignore the click after a drag High-degree files now open at most 12 cards, preferring neighbors that already have source, so a cached GitHub reopen does not fire one getFile per edge. A leftover click after dragging a card is consumed so repositioning does not change the selection. Co-authored-by: Braedon Saunders --- index.html | 48 ++++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 26 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 0a1f62f..bc9ac09 100644 --- a/index.html +++ b/index.html @@ -5721,6 +5721,8 @@ return Array.from(linked); } +var CODE_CARD_MAX=12; + function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -5733,9 +5735,26 @@ ordered.push(file); } if(selectedPath)add(selectedPath); - getConnectedFilePaths(selectedPath,data.connections).forEach(add); - if(limit==null||!isFinite(Number(limit)))return ordered; - return ordered.slice(0,Math.max(0,Number(limit))); + getConnectedFilePaths(selectedPath,data.connections).slice().sort(function(a,b){ + var loadedA=fileHasLoadedSource(byPath[a])?0:1; + var loadedB=fileHasLoadedSource(byPath[b])?0:1; + return loadedA-loadedB; + }).forEach(add); + var max=limit==null?CODE_CARD_MAX:Number(limit); + if(!isFinite(max))return ordered; + return ordered.slice(0,Math.max(0,max)); +} + +function countVisibleCodeFiles(selectedPath,data,folderFilter){ + return collectVisibleCodeFiles(selectedPath,data,folderFilter,Infinity).length; +} + +function noteCodeCardPointerEnd(moved){ + return {select:!moved,ignoreNextClick:!!moved}; +} + +function consumeCodeCardClick(ignoreNextClick){ + return {ignore:!!ignoreNextClick,ignoreNextClick:false}; } var CODE_CARD_WIDTH=320; @@ -6572,6 +6591,7 @@ var codeZoomTransformRef=useRef({k:1,x:0,y:0}); var graphNodesByIdRef=useRef(Object.create(null)); var selectedPathRef=useRef(null); + var codeCardIgnoreClickRef=useRef(false); var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); var zipArchiveRef=useRef(null); @@ -7996,6 +8016,10 @@ if(!data)return[]; return collectVisibleCodeFiles(selected&&selected.path,data,folderFilter); },[data,selected,folderFilter]); + var codeViewFileTotal=useMemo(function(){ + if(!data)return 0; + return countVisibleCodeFiles(selected&&selected.path,data,folderFilter); + },[data,selected,folderFilter]); selectedPathRef.current=selected&&selected.path; codeCardPathsRef.current=new Set(codeViewFiles.map(function(file){return file.path;})); @@ -9696,8 +9720,13 @@ window.removeEventListener('pointerup',onUp); window.removeEventListener('pointercancel',onUp); sim.alphaTarget(0); - if(moved){node.fx=node.x;node.fy=node.y;} - else{node.fx=null;node.fy=null;selectFile(file.path);} + var end=noteCodeCardPointerEnd(moved); + if(end.ignoreNextClick){ + codeCardIgnoreClickRef.current=true; + setTimeout(function(){codeCardIgnoreClickRef.current=false;},400); + } + if(end.select){node.fx=null;node.fy=null;selectFile(file.path);} + else{node.fx=node.x;node.fy=node.y;} } window.addEventListener('pointermove',onMove); window.addEventListener('pointerup',onUp); @@ -9723,7 +9752,12 @@ :(sourceState==='loading'?'Loading source...':'Reopen this project to read source'); body=React.createElement('div',{className:'empty-desc',style:{padding:'12px'}},message); } - return React.createElement('div',{key:file.path,'data-code-card':file.path,className:'code-card'+(isPrimary?' primary':' linked'),onClick:function(){if(selected&&selected.path!==file.path)selectFile(file.path);}}, + return React.createElement('div',{key:file.path,'data-code-card':file.path,className:'code-card'+(isPrimary?' primary':' linked'),onClick:function(e){ + var taken=consumeCodeCardClick(codeCardIgnoreClickRef.current); + codeCardIgnoreClickRef.current=taken.ignoreNextClick; + if(taken.ignore){e.stopPropagation();return;} + if(selected&&selected.path!==file.path)selectFile(file.path); + }}, React.createElement('div',{className:'code-card-head',onPointerDown:function(e){beginCodeCardDrag(e,file);}}, React.createElement('div',null, React.createElement('div',{className:'code-card-name'},file.name), @@ -9749,7 +9783,7 @@ codeViewFiles.map(function(file){return renderCodeFileCard(file,file.path===primaryPath);}) ), React.createElement('div',{className:'code-canvas-hud'}, - React.createElement('div',{className:'code-canvas-hint'},selected?'Click a symbol to highlight it across these cards':'Click a node to open its file as a card on the canvas. Connected files open with it.'), + React.createElement('div',{className:'code-canvas-hint'},selected?(codeViewFileTotal>codeViewFiles.length?'Showing '+codeViewFiles.length+' of '+codeViewFileTotal+' connected files. Click a symbol to highlight it across these cards.':'Click a symbol to highlight it across these cards'):'Click a node to open its file as a card on the canvas. Connected files open with it.'), codeViewSymbols.length>0&&React.createElement('div',{className:'code-sym-row'}, codeViewSymbols.slice(0,24).map(function(sym){ return React.createElement('button',{key:sym.name,className:'code-sym-chip '+sym.kind+(activeSymbol===sym.name?' active':''),onClick:function(){setActiveSymbol(function(prev){return prev===sym.name?null:sym.name;});}},sym.name); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 9589ce5..b5be954 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -91,6 +91,30 @@ test('visible code files are not capped at four', () => { assert.equal(context.collectVisibleCodeFiles(null, { files, connections }, 'src').length, 0); }); +test('high-degree neighborhoods stay within the card cap', () => { + const files = Array.from({ length: 30 }, (_, i) => ({ + path: i === 0 ? 'hub.js' : 'n' + i + '.js', + folder: 'src', + name: i === 0 ? 'hub.js' : 'n' + i + '.js', + functions: [] + })); + files[20].content = 'export const kept = 1;\n'; + const connections = files.slice(1).map((file) => ({ source: 'hub.js', target: file.path, fn: 'hub' })); + const visible = context.collectVisibleCodeFiles('hub.js', { files, connections }, 'src'); + assert.equal(visible.length, context.CODE_CARD_MAX); + assert.equal(visible[0].path, 'hub.js'); + assert.ok(visible.some((file) => file.path === 'n20.js')); + assert.equal(context.countVisibleCodeFiles('hub.js', { files, connections }, 'src'), 30); + assert.ok(context.countVisibleCodeFiles('hub.js', { files, connections }, 'src') > visible.length); +}); + +test('a drag does not select the card on the leftover click', () => { + assert.deepEqual(J(context.noteCodeCardPointerEnd(true)), { select: false, ignoreNextClick: true }); + assert.deepEqual(J(context.noteCodeCardPointerEnd(false)), { select: true, ignoreNextClick: false }); + assert.deepEqual(J(context.consumeCodeCardClick(true)), { ignore: true, ignoreNextClick: false }); + assert.deepEqual(J(context.consumeCodeCardClick(false)), { ignore: false, ignoreNextClick: false }); +}); + test('code cards sit on the canvas transform, not a split pane', () => { assert.deepEqual(J(context.codeCardSize(false)), { width: 320, height: 220 }); assert.ok(context.codeCardCollisionRadius(true) > 100); @@ -298,6 +322,8 @@ test('index.html ships a working Code view, not a stub', () => { assert.doesNotMatch(htmlSource, /className:'code-split'/); assert.match(htmlSource, /data-code-card/); assert.match(htmlSource, /applyCodeCardLayout/); + assert.match(htmlSource, /CODE_CARD_MAX/); + assert.match(htmlSource, /consumeCodeCardClick/); assert.match(htmlSource, /vizType==='code'/); assert.match(htmlSource, /readableLabelScale/); assert.match(htmlSource, /listRecentAnalyses/); From f470afdcf38573a8c3fb20d313d65f3be1a8ad5d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:13:49 +0000 Subject: [PATCH 04/33] Size, align, and list Code cards the way the canvas needs Cards grow to the full file so the canvas does the scrolling. Opened files auto-align in directory groups using the same parent folder frames the graph already draws. Symbols sit in a vertical list. Click-to-open connected files stays the default; other nodes fade instead of becoming more cards. Co-authored-by: Braedon Saunders --- README.md | 4 +- index.html | 231 ++++++++++++++++++++++++++++++------- tests/code-canvas.test.mjs | 51 +++++++- 3 files changed, 239 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 4a856fd..f2bec9b 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Paste URL / Select Files -> See Architecture -> Make Better Decisions ## Features ### Interactive Dependency Graph -See how your files connect at a glance. Click any node to highlight its dependencies. Drag, zoom, and explore. The **Code** view keeps that map and opens the selected file plus its connected files as cards on the canvas. +See how your files connect at a glance. Click any node to highlight its dependencies. Drag, zoom, and explore. The **Code** view keeps that map and opens the selected file plus its connected files as full-file cards on the canvas, grouped by directory. ### Blast Radius Analysis *"If I change this file, what breaks?"* — CodeFlow answers this instantly. Select any file and see exactly how many files would be affected by changes. @@ -257,7 +257,7 @@ strings and Pascal comment forms; unsupported language features fall back gracef | **Layer** | Color by architectural layer (UI, Services, Utils, etc.) | | **Churn** | Color by commit frequency (hot spots) | | **Blast** | Color by impact when a file is selected | -| **Code** | Same graph, with selected and connected files as cards on the canvas | +| **Code** | Same graph, with selected and connected files as full-file cards grouped by directory | --- diff --git a/index.html b/index.html index bc9ac09..2008c17 100644 --- a/index.html +++ b/index.html @@ -457,26 +457,29 @@ .code-canvas{position:relative;width:100%;height:100%;min-width:0;min-height:0;overflow:hidden} .code-canvas svg{width:100%;height:100%;display:block} .code-canvas-cards{position:absolute;inset:0;pointer-events:none;transform-origin:0 0;z-index:4} -.code-canvas-hud{position:absolute;top:52px;left:50%;transform:translateX(-50%);z-index:51;display:flex;flex-direction:column;align-items:center;gap:6px;max-width:min(720px,calc(100% - 160px));pointer-events:none} -.code-canvas-hint{font-size:10px;color:var(--t3);background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:6px 10px;text-align:center;line-height:1.4} -.code-sym-row{display:flex;flex-wrap:wrap;gap:4px;padding:6px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg1);max-height:72px;overflow:auto;pointer-events:auto;justify-content:center} -.code-sym-chip{font-size:9px;padding:3px 7px;border-radius:999px;border:1px solid var(--border);background:var(--bg0);color:var(--t2);cursor:pointer;font-family:inherit} +.code-canvas-hud{position:absolute;top:56px;left:12px;z-index:51;display:flex;flex-direction:column;align-items:stretch;gap:6px;width:168px;max-width:calc(100% - 24px);pointer-events:none} +.code-canvas-hint{font-size:10px;color:var(--t3);background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:6px 8px;line-height:1.4} +.code-sym-list{display:flex;flex-direction:column;gap:2px;padding:6px;border:1px solid var(--border);border-radius:8px;background:var(--bg1);max-height:min(420px,calc(100% - 140px));overflow-x:hidden;overflow-y:auto;pointer-events:auto} +.code-sym-chip{display:block;width:100%;box-sizing:border-box;font-size:9px;padding:4px 7px;border-radius:6px;border:1px solid var(--border);background:var(--bg0);color:var(--t2);cursor:pointer;font-family:inherit;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .code-sym-chip:hover{border-color:var(--acc);color:var(--acc)} .code-sym-chip.active{background:var(--accbg);border-color:var(--acc);color:var(--acc)} .code-sym-chip.fn{box-shadow:inset 3px 0 0 #61afef} .code-sym-chip.import{box-shadow:inset 3px 0 0 #c678dd} .code-sym-chip.export{box-shadow:inset 3px 0 0 #98c379} .code-sym-chip.var{box-shadow:inset 3px 0 0 #e06c75} -.code-card{position:absolute;width:320px;height:220px;border:1px solid var(--border);border-radius:8px;background:var(--bg0);overflow:hidden;display:flex;flex-direction:column;pointer-events:auto;box-shadow:0 10px 28px rgba(0,0,0,0.28);visibility:hidden;user-select:none} -.code-card.primary{width:380px;height:280px;border-color:var(--acc);box-shadow:0 12px 32px rgba(0,0,0,0.34),0 0 0 1px var(--acc)} +.code-card{position:absolute;border:1px solid var(--border);border-radius:8px;background:var(--bg0);overflow:hidden;display:flex;flex-direction:column;pointer-events:auto;box-shadow:0 10px 28px rgba(0,0,0,0.28);visibility:hidden;user-select:none} +.code-card.primary{border-color:var(--acc);box-shadow:0 12px 32px rgba(0,0,0,0.34),0 0 0 1px var(--acc)} .code-card.linked{opacity:0.98} .code-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:var(--bg2);border-bottom:1px solid var(--border2);cursor:grab;touch-action:none;flex-shrink:0} .code-card-head:active{cursor:grabbing} .code-card-name{font-size:11px;font-weight:600;color:var(--t0);transform-origin:left center;display:inline-block;white-space:nowrap} .code-card-path{font-size:9px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px} -.code-card-body{flex:1;overflow:auto;min-height:0;touch-action:pan-y;user-select:text} +.code-card-body{flex:1;overflow:hidden;min-height:0;user-select:text} +.code-card.clipped .code-card-body{overflow:auto;touch-action:pan-y} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} .has-code-card .nc,.has-code-card .node-label{opacity:0!important;pointer-events:none} +.code-faded .nc{opacity:0.18!important} +.code-faded .node-label{opacity:0.35!important} .sym-mark{border-radius:3px;padding:0 1px;cursor:pointer} .sym-mark.fn{background:rgba(97,175,239,0.16)} .sym-mark.import{background:rgba(198,120,221,0.16)} @@ -501,7 +504,7 @@ .github-hint button{background:none;border:none;color:var(--acc);cursor:pointer;font-family:inherit;font-size:9px;padding:0;text-decoration:underline} .cli-chip{padding:4px 8px;background:var(--accbg);border:1px solid var(--acc);border-radius:6px;font-size:9px;color:var(--acc)} @media(max-width:980px){ -.code-canvas-hud{top:56px;max-width:calc(100% - 24px)} +.code-canvas-hud{top:56px;left:10px;width:140px} } .auth-select{padding:6px 10px;background:var(--bg0);border:1px solid var(--border);border-radius:6px;color:var(--t0);font-family:inherit;font-size:10px;cursor:pointer;min-width:100px;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b95' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:28px} .auth-select:focus{outline:none;border-color:var(--acc)} @@ -5757,28 +5760,114 @@ return {ignore:!!ignoreNextClick,ignoreNextClick:false}; } -var CODE_CARD_WIDTH=320; -var CODE_CARD_HEIGHT=220; -var CODE_CARD_PRIMARY_WIDTH=380; -var CODE_CARD_PRIMARY_HEIGHT=280; +var CODE_CARD_MIN_WIDTH=320; +var CODE_CARD_MAX_WIDTH=720; +var CODE_CARD_MIN_HEIGHT=160; +var CODE_CARD_MAX_HEIGHT=1840; +var CODE_CARD_LINE_HEIGHT=19; +var CODE_CARD_CHAR_WIDTH=7; +var CODE_CARD_HEAD_HEIGHT=42; +var CODE_CARD_BODY_PAD=16; +var CODE_CARD_GUTTER=72; -function codeCardSize(isPrimary){ - return isPrimary - ?{width:CODE_CARD_PRIMARY_WIDTH,height:CODE_CARD_PRIMARY_HEIGHT} - :{width:CODE_CARD_WIDTH,height:CODE_CARD_HEIGHT}; +function codeCardContentMetrics(file){ + var content=file&&typeof file.content==='string'?file.content:''; + var lines=content?content.split('\n'):['']; + var maxLineChars=0; + for(var i=0;imaxLineChars)maxLineChars=n; + } + return{lines:Math.max(1,lines.length),maxLineChars:maxLineChars}; +} + +function codeCardSize(file){ + var metrics=codeCardContentMetrics(file); + var width=Math.max(CODE_CARD_MIN_WIDTH,Math.min(CODE_CARD_MAX_WIDTH,CODE_CARD_GUTTER+metrics.maxLineChars*CODE_CARD_CHAR_WIDTH)); + var naturalHeight=CODE_CARD_HEAD_HEIGHT+CODE_CARD_BODY_PAD+metrics.lines*CODE_CARD_LINE_HEIGHT; + var height=Math.max(CODE_CARD_MIN_HEIGHT,Math.min(CODE_CARD_MAX_HEIGHT,naturalHeight)); + return{width:width,height:height,clipped:naturalHeight>CODE_CARD_MAX_HEIGHT}; } -function codeCardCollisionRadius(isCard){ - if(!isCard)return 20; - var size=codeCardSize(false); +function codeCardCollisionRadius(size){ + size=size||codeCardSize(null); return Math.round(Math.hypot(size.width,size.height)/2)+18; } -function liveCodeCollideRadius(node,isCard){ - if(isCard)return codeCardCollisionRadius(true); +function liveCodeCollideRadius(node,size){ + if(size)return codeCardCollisionRadius(size); return Math.max(8,Math.min(24,5+((node&&node.fnCount)||0)*0.8))+12; } +function layoutCodeCardsByFolder(files,sizesByPath,options){ + options=options||{}; + var gapX=options.gapX==null?56:options.gapX; + var gapY=options.gapY==null?48:options.gapY; + var groupGapX=options.groupGapX==null?96:options.groupGapX; + var groupGapY=options.groupGapY==null?120:options.groupGapY; + var originX=options.originX==null?80:options.originX; + var originY=options.originY==null?80:options.originY; + var maxRowWidth=options.maxRowWidth==null?2400:options.maxRowWidth; + var groups=Object.create(null); + var folderOrder=[]; + (files||[]).forEach(function(file){ + var folder=file&&file.folder?file.folder:'root'; + if(!groups[folder]){groups[folder]=[];folderOrder.push(folder);} + groups[folder].push(file); + }); + var placed=Object.create(null); + var gx=originX,gy=originY,rowH=0; + folderOrder.forEach(function(folder){ + var items=groups[folder]; + var cols=Math.max(1,Math.ceil(Math.sqrt(items.length))); + var colWidths=[],rowHeights=[]; + items.forEach(function(file,i){ + var size=(sizesByPath&&sizesByPath[file.path])||codeCardSize(file); + var col=i%cols,row=Math.floor(i/cols); + colWidths[col]=Math.max(colWidths[col]||0,size.width); + rowHeights[row]=Math.max(rowHeights[row]||0,size.height); + }); + var colX=[],x=0; + colWidths.forEach(function(w){colX.push(x);x+=w+gapX;}); + var rowY=[],y=36; + rowHeights.forEach(function(h){rowY.push(y);y+=h+gapY;}); + var groupW=Math.max(0,x-gapX); + var groupH=Math.max(0,y-gapY); + if(gx>originX&&gx+groupW>originX+maxRowWidth){ + gx=originX; + gy+=rowH+groupGapY; + rowH=0; + } + items.forEach(function(file,i){ + var size=(sizesByPath&&sizesByPath[file.path])||codeCardSize(file); + var col=i%cols,row=Math.floor(i/cols); + placed[file.path]={ + x:gx+colX[col]+size.width/2, + y:gy+rowY[row]+size.height/2, + folder:folder + }; + }); + gx+=groupW+groupGapX; + rowH=Math.max(rowH,groupH); + }); + return placed; +} + +function codeFolderCardBounds(nodes,sizesByPath,pad){ + pad=pad==null?24:pad; + var minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; + (nodes||[]).forEach(function(node){ + if(!node||!isFinite(node.x)||!isFinite(node.y))return; + var size=(sizesByPath&&sizesByPath[node.id])||codeCardSize(null); + minX=Math.min(minX,node.x-size.width/2); + minY=Math.min(minY,node.y-size.height/2); + maxX=Math.max(maxX,node.x+size.width/2); + maxY=Math.max(maxY,node.y+size.height/2); + }); + if(!isFinite(minX))return null; + return{x:minX-pad,y:minY-pad-22,width:maxX-minX+pad*2,height:maxY-minY+pad*2+22}; +} + function codeCanvasTransformStyle(transform){ var t=transform||{}; var k=Number(t.k); @@ -5791,7 +5880,7 @@ } function codeCardAnchorStyle(node,size){ - size=size||codeCardSize(false); + size=size||codeCardSize(null); if(!node||!isFinite(node.x)||!isFinite(node.y))return{visibility:'hidden',left:'0px',top:'0px'}; return{ visibility:'visible', @@ -5800,7 +5889,7 @@ }; } -function applyCodeCardLayout(layer,nodesById,transform,primaryPath){ +function applyCodeCardLayout(layer,nodesById,transform,sizesByPath){ if(!layer)return{placed:0,titleScale:1}; layer.style.transform=codeCanvasTransformStyle(transform); var k=transform&&isFinite(Number(transform.k))?Number(transform.k):1; @@ -5809,13 +5898,17 @@ var placed=0; Array.prototype.forEach.call(cards,function(card){ var path=card.getAttribute('data-code-card'); - var size=codeCardSize(path===primaryPath); + var size=(sizesByPath&&sizesByPath[path])||codeCardSize(null); var style=codeCardAnchorStyle(nodesById&&nodesById[path],size); card.style.visibility=style.visibility; card.style.left=style.left; card.style.top=style.top; card.style.width=size.width+'px'; card.style.height=size.height+'px'; + if(card.classList){ + if(size.clipped&&card.classList.add)card.classList.add('clipped'); + else if(card.classList.remove)card.classList.remove('clipped'); + } var title=card.querySelector?card.querySelector('.code-card-name'):null; if(title)title.style.transform='scale('+titleScale+')'; if(style.visibility==='visible')placed++; @@ -6591,6 +6684,9 @@ var codeZoomTransformRef=useRef({k:1,x:0,y:0}); var graphNodesByIdRef=useRef(Object.create(null)); var selectedPathRef=useRef(null); + var codeCardSizesRef=useRef(Object.create(null)); + var codeCardUserPinnedRef=useRef(new Set()); + var codeCardLayoutKeyRef=useRef(''); var codeCardIgnoreClickRef=useRef(false); var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); @@ -8023,8 +8119,31 @@ selectedPathRef.current=selected&&selected.path; codeCardPathsRef.current=new Set(codeViewFiles.map(function(file){return file.path;})); + function currentCodeCardSizes(){ + var sizes=Object.create(null); + codeViewFiles.forEach(function(file){sizes[file.path]=codeCardSize(file);}); + codeCardSizesRef.current=sizes; + return sizes; + } + function syncCodeCards(){ - applyCodeCardLayout(codeCardsLayerRef.current,graphNodesByIdRef.current,codeZoomTransformRef.current,selectedPathRef.current); + applyCodeCardLayout(codeCardsLayerRef.current,graphNodesByIdRef.current,codeZoomTransformRef.current,codeCardSizesRef.current); + } + + function pinCodeCardsToFolderLayout(){ + var sim=simRef.current; + if(!sim)return; + var sizes=currentCodeCardSizes(); + var layout=layoutCodeCardsByFolder(codeViewFiles,sizes); + sim.nodes().forEach(function(node){ + var pos=layout[node.id]; + if(!pos){ + if(!codeCardUserPinnedRef.current.has(node.id)){node.fx=null;node.fy=null;} + return; + } + if(codeCardUserPinnedRef.current.has(node.id))return; + node.fx=pos.x;node.fy=pos.y;node.x=pos.x;node.y=pos.y; + }); } function refreshCodeCardForces(){ @@ -8032,7 +8151,7 @@ if(!sim)return; var collide=sim.force('collision'); if(collide&&typeof collide.radius==='function'){ - collide.radius(function(d){return liveCodeCollideRadius(d,codeCardPathsRef.current.has(d.id));}); + collide.radius(function(d){return liveCodeCollideRadius(d,codeCardPathsRef.current.has(d.id)?codeCardSizesRef.current[d.id]:null);}); } var linkForce=sim.force('link'); if(linkForce&&typeof linkForce.distance==='function'){ @@ -8045,6 +8164,7 @@ } if(nodesRef.current){ nodesRef.current.classed('has-code-card',function(d){return codeCardPathsRef.current.has(d.id);}); + nodesRef.current.classed('code-faded',function(d){return codeCardPathsRef.current.size>0&&!codeCardPathsRef.current.has(d.id);}); } } @@ -8103,7 +8223,7 @@ codeZoomTransformRef.current={k:1,x:0,y:0}; graphNodesByIdRef.current=Object.create(null); nodes.forEach(function(n){graphNodesByIdRef.current[n.id]=n;}); - function collideR(d){return liveCodeCollideRadius(d,keepReadable&&codeCardPathsRef.current.has(d.id));} + function collideR(d){return liveCodeCollideRadius(d,keepReadable&&codeCardPathsRef.current.has(d.id)?codeCardSizesRef.current[d.id]:null);} function applyReadableLabels(k){ if(!keepReadable)return; var s=readableLabelScale(k); @@ -8193,6 +8313,7 @@ node.call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.1).restart();d.fx=d.x;d.fy=d.y;}).on('drag',function(e,d){d.fx=e.x;d.fy=e.y;}).on('end',function(e,d){if(!e.active)sim.alphaTarget(0);d.fx=null;d.fy=null;})); node.on('click',function(e,d){e.stopPropagation();if(selectFileRef.current)selectFileRef.current(d.id);}); node.classed('has-code-card',function(d){return keepReadable&&codeCardPathsRef.current.has(d.id);}); + node.classed('code-faded',function(d){return keepReadable&&codeCardPathsRef.current.size>0&&!codeCardPathsRef.current.has(d.id);}); node.on('mouseenter',function(e,d){if(keepReadable&&codeCardPathsRef.current.has(d.id))return;var r=svgRef.current.getBoundingClientRect();setTooltip({x:e.clientX-r.left+10,y:e.clientY-r.top,title:d.name,content:d.fnCount+' functions\n'+d.layer+' layer\n'+d.churn+' recent commits'});}).on('mouseleave',function(){setTooltip(null);}); svg.on('click',function(e){if(e.target===svgRef.current){setSelected(null);setBlastRadius(null);link.attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-opacity',0.4);node.selectAll('.nc').attr('opacity',1).attr('fill',getC);}}); node.append('circle').attr('class','nc').attr('r',getR).attr('fill',getC).attr('stroke',function(d){var c=d3.color(getC(d));return c?c.brighter(0.3):'#fff';}).attr('stroke-width',1.5); @@ -8208,19 +8329,26 @@ folders.forEach(function(f){ var fn=nodesByFolder[f]; if(!fn||fn.length<1)return; + var cardNodes=keepReadable?fn.filter(function(n){return codeCardPathsRef.current.has(n.id);}):[]; + var color=colorMap[f]||COLORS[folders.indexOf(f)%COLORS.length]; + if(cardNodes.length){ + var bounds=codeFolderCardBounds(cardNodes,codeCardSizesRef.current,28); + if(bounds){ + hullLayer.append('rect').attr('x',bounds.x).attr('y',bounds.y).attr('width',bounds.width).attr('height',bounds.height).attr('rx',14).attr('fill',color).attr('fill-opacity',0.06).attr('stroke',color).attr('stroke-width',2).attr('stroke-opacity',0.35); + hullLayer.append('text').attr('class','hull-label').attr('x',bounds.x+12).attr('y',bounds.y+16).attr('text-anchor','start').attr('fill',color).attr('font-size',keepReadable?'12px':'10px').attr('font-family','JetBrains Mono').attr('font-weight','600').attr('opacity',0.9).text(f||'root'); + } + return; + } var pts=[]; fn.forEach(function(n){ if(!n.x||!n.y)return; - var pad=(keepReadable&&codeCardPathsRef.current.has(n.id))?codeCardCollisionRadius(true):30; - pts.push([n.x-pad,n.y-pad],[n.x+pad,n.y-pad],[n.x-pad,n.y+pad],[n.x+pad,n.y+pad]); + pts.push([n.x-30,n.y-30],[n.x+30,n.y-30],[n.x-30,n.y+30],[n.x+30,n.y+30]); }); if(pts.length<3)return; var hull=d3.polygonHull(pts); if(hull){ - var color=colorMap[f]||COLORS[folders.indexOf(f)%COLORS.length]; hullLayer.append('path').attr('d','M'+hull.join('L')+'Z').attr('fill',color).attr('fill-opacity',0.04).attr('stroke',color).attr('stroke-width',2).attr('stroke-opacity',0.25).attr('rx',8); - var hullPad=fn.some(function(n){return keepReadable&&codeCardPathsRef.current.has(n.id);})?codeCardCollisionRadius(true):30; - var cx=d3.mean(fn,function(n){return n.x;}),cy=d3.min(fn,function(n){return n.y;})-hullPad-8; + var cx=d3.mean(fn,function(n){return n.x;}),cy=d3.min(fn,function(n){return n.y;})-38; hullLayer.append('text').attr('class','hull-label').attr('x',cx).attr('y',cy).attr('text-anchor','middle').attr('fill',color).attr('font-size',keepReadable?'11px':'10px').attr('font-family','JetBrains Mono').attr('font-weight','600').attr('opacity',0.85).text(f||'root'); } }); @@ -8245,6 +8373,8 @@ node.selectAll('text').attr('opacity',(keepReadable||graphConfig.showLabels)?1:0); }catch(e){console.error('Force graph error:',e);svg.selectAll('*').remove();svg.append('text').attr('x',20).attr('y',30).attr('fill','var(--t3)').text('Graph rendering error: '+e.message);} if(keepReadable){ + currentCodeCardSizes(); + pinCodeCardsToFolderLayout(); refreshCodeCardForces(); syncCodeCards(); } @@ -8253,8 +8383,14 @@ useLayoutEffect(function(){ if(graphConfig.vizType!=='code')return; + var layoutKey=codeViewFiles.map(function(file){return file.path;}).join('|'); + if(layoutKey!==codeCardLayoutKeyRef.current){ + codeCardLayoutKeyRef.current=layoutKey; + codeCardUserPinnedRef.current=new Set(); + } + currentCodeCardSizes(); + pinCodeCardsToFolderLayout(); refreshCodeCardForces(); - if(simRef.current)simRef.current.alpha(codeCardPathsRef.current.size?0.28:0.12).restart(); syncCodeCards(); },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist]); @@ -9157,7 +9293,11 @@ function computeGraphFitTransform(paddingSlack){ paddingSlack=paddingSlack==null?100:paddingSlack; if(graphConfig.vizType==='code'&&codeCardPathsRef.current&&codeCardPathsRef.current.size){ - paddingSlack=Math.max(paddingSlack,codeCardCollisionRadius(true)*2); + var maxR=120; + Object.keys(codeCardSizesRef.current||{}).forEach(function(path){ + maxR=Math.max(maxR,codeCardCollisionRadius(codeCardSizesRef.current[path])); + }); + paddingSlack=Math.max(paddingSlack,maxR*2); } if(!zoomRef.current||!svgRef.current||!simRef.current)return null; var nodes=simRef.current.nodes(); @@ -9711,7 +9851,10 @@ sim.alphaTarget(0.18).restart(); function onMove(ev){ var dx=(ev.clientX-startX)/k,dy=(ev.clientY-startY)/k; - if(Math.abs(dx)+Math.abs(dy)>3)moved=true; + if(Math.abs(dx)+Math.abs(dy)>3){ + moved=true; + codeCardUserPinnedRef.current.add(file.path); + } node.fx=originX+dx;node.fy=originY+dy; syncCodeCards(); } @@ -9725,8 +9868,11 @@ codeCardIgnoreClickRef.current=true; setTimeout(function(){codeCardIgnoreClickRef.current=false;},400); } - if(end.select){node.fx=null;node.fy=null;selectFile(file.path);} - else{node.fx=node.x;node.fy=node.y;} + if(moved){ + codeCardUserPinnedRef.current.add(file.path); + node.fx=node.x;node.fy=node.y; + } + if(end.select)selectFile(file.path); } window.addEventListener('pointermove',onMove); window.addEventListener('pointerup',onUp); @@ -9752,7 +9898,8 @@ :(sourceState==='loading'?'Loading source...':'Reopen this project to read source'); body=React.createElement('div',{className:'empty-desc',style:{padding:'12px'}},message); } - return React.createElement('div',{key:file.path,'data-code-card':file.path,className:'code-card'+(isPrimary?' primary':' linked'),onClick:function(e){ + var cardSize=codeCardSize(file); + return React.createElement('div',{key:file.path,'data-code-card':file.path,className:'code-card'+(isPrimary?' primary':' linked')+(cardSize.clipped?' clipped':''),onClick:function(e){ var taken=consumeCodeCardClick(codeCardIgnoreClickRef.current); codeCardIgnoreClickRef.current=taken.ignoreNextClick; if(taken.ignore){e.stopPropagation();return;} @@ -9783,10 +9930,10 @@ codeViewFiles.map(function(file){return renderCodeFileCard(file,file.path===primaryPath);}) ), React.createElement('div',{className:'code-canvas-hud'}, - React.createElement('div',{className:'code-canvas-hint'},selected?(codeViewFileTotal>codeViewFiles.length?'Showing '+codeViewFiles.length+' of '+codeViewFileTotal+' connected files. Click a symbol to highlight it across these cards.':'Click a symbol to highlight it across these cards'):'Click a node to open its file as a card on the canvas. Connected files open with it.'), - codeViewSymbols.length>0&&React.createElement('div',{className:'code-sym-row'}, - codeViewSymbols.slice(0,24).map(function(sym){ - return React.createElement('button',{key:sym.name,className:'code-sym-chip '+sym.kind+(activeSymbol===sym.name?' active':''),onClick:function(){setActiveSymbol(function(prev){return prev===sym.name?null:sym.name;});}},sym.name); + React.createElement('div',{className:'code-canvas-hint'},selected?(codeViewFileTotal>codeViewFiles.length?'Showing '+codeViewFiles.length+' of '+codeViewFileTotal+' connected files.':'Click a symbol to highlight it across these cards.'):'Click a node to open connected files. Canvas zoom pans the full file.'), + codeViewSymbols.length>0&&React.createElement('div',{className:'code-sym-list'}, + codeViewSymbols.map(function(sym){ + return React.createElement('button',{key:sym.name,className:'code-sym-chip '+sym.kind+(activeSymbol===sym.name?' active':''),title:sym.name,onClick:function(){setActiveSymbol(function(prev){return prev===sym.name?null:sym.name;});}},sym.name); }) ) ) diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index b5be954..2111f5d 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -115,9 +115,49 @@ test('a drag does not select the card on the leftover click', () => { assert.deepEqual(J(context.consumeCodeCardClick(false)), { ignore: false, ignoreNextClick: false }); }); +test('code cards size to the file instead of a fixed pane', () => { + const empty = context.codeCardSize(null); + assert.equal(empty.width, context.CODE_CARD_MIN_WIDTH); + assert.equal(empty.height, context.CODE_CARD_MIN_HEIGHT); + assert.equal(empty.clipped, false); + const short = context.codeCardSize({ content: 'const x = 1;\n' }); + const tall = context.codeCardSize({ content: Array(40).fill('const value = 1;').join('\n') }); + assert.ok(tall.height > short.height); + const huge = context.codeCardSize({ content: Array(400).fill('x'.repeat(120)).join('\n') }); + assert.equal(huge.width, context.CODE_CARD_MAX_WIDTH); + assert.equal(huge.height, context.CODE_CARD_MAX_HEIGHT); + assert.equal(huge.clipped, true); +}); + +test('opened code cards auto-align by directory', () => { + const files = [ + { path: 'src/a.js', folder: 'src' }, + { path: 'src/b.js', folder: 'src' }, + { path: 'lib/c.js', folder: 'lib' } + ]; + const sizes = { + 'src/a.js': { width: 320, height: 200 }, + 'src/b.js': { width: 320, height: 200 }, + 'lib/c.js': { width: 320, height: 200 } + }; + const layout = context.layoutCodeCardsByFolder(files, sizes, { originX: 0, originY: 0, gapX: 40, groupGapX: 80 }); + assert.ok(layout['src/a.js']); + assert.ok(layout['src/b.js']); + assert.ok(layout['lib/c.js']); + assert.equal(layout['src/a.js'].folder, 'src'); + assert.equal(layout['lib/c.js'].folder, 'lib'); + assert.ok(Math.abs(layout['src/a.js'].x - layout['src/b.js'].x) >= 320 || Math.abs(layout['src/a.js'].y - layout['src/b.js'].y) >= 200); + assert.ok(layout['lib/c.js'].x !== layout['src/a.js'].x); + const bounds = context.codeFolderCardBounds([ + { id: 'src/a.js', x: layout['src/a.js'].x, y: layout['src/a.js'].y }, + { id: 'src/b.js', x: layout['src/b.js'].x, y: layout['src/b.js'].y } + ], sizes, 10); + assert.ok(bounds.width >= 320); + assert.ok(bounds.height >= 200); +}); + test('code cards sit on the canvas transform, not a split pane', () => { - assert.deepEqual(J(context.codeCardSize(false)), { width: 320, height: 220 }); - assert.ok(context.codeCardCollisionRadius(true) > 100); + assert.ok(context.codeCardCollisionRadius({ width: 320, height: 220 }) > 100); assert.equal(context.codeCanvasTransformStyle({ k: 0.5, x: 10, y: 20 }), 'translate(10px,20px) scale(0.5)'); assert.deepEqual(J(context.codeCardAnchorStyle({ x: 400, y: 300 }, { width: 320, height: 220 })), { visibility: 'visible', @@ -128,13 +168,15 @@ test('code cards sit on the canvas transform, not a split pane', () => { const card = { getAttribute(name) { return name === 'data-code-card' ? 'a.js' : null; }, style: {}, + classList: { add() {}, remove() {} }, querySelector() { return title; } }; const layer = { style: {}, querySelectorAll() { return [card]; } }; - const placed = context.applyCodeCardLayout(layer, { 'a.js': { x: 400, y: 300 } }, { k: 0.5, x: 12, y: 8 }, 'a.js'); + const sizes = { 'a.js': { width: 380, height: 280 } }; + const placed = context.applyCodeCardLayout(layer, { 'a.js': { x: 400, y: 300 } }, { k: 0.5, x: 12, y: 8 }, sizes); assert.equal(placed.placed, 1); assert.equal(placed.titleScale, 2); assert.equal(layer.style.transform, 'translate(12px,8px) scale(0.5)'); @@ -322,6 +364,9 @@ test('index.html ships a working Code view, not a stub', () => { assert.doesNotMatch(htmlSource, /className:'code-split'/); assert.match(htmlSource, /data-code-card/); assert.match(htmlSource, /applyCodeCardLayout/); + assert.match(htmlSource, /layoutCodeCardsByFolder/); + assert.match(htmlSource, /className:'code-sym-list'/); + assert.doesNotMatch(htmlSource, /className:'code-sym-row'/); assert.match(htmlSource, /CODE_CARD_MAX/); assert.match(htmlSource, /consumeCodeCardClick/); assert.match(htmlSource, /vizType==='code'/); From 2d23d58638659974ed687c4e605d2f0e1d3b8f87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:26:35 +0000 Subject: [PATCH 05/33] Show Code cards as soon as the view opens Code no longer waits for a click, which made it look like Graph. It now seeds the most-connected file plus neighbors immediately, keeps that set if the canvas is clicked, and fits the cards in view. Co-authored-by: Braedon Saunders --- index.html | 44 +++++++++++++++++++++++++++++++++++--- tests/code-canvas.test.mjs | 21 +++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 2008c17..ecf86d6 100644 --- a/index.html +++ b/index.html @@ -5726,6 +5726,28 @@ var CODE_CARD_MAX=12; +function defaultCodeViewSeed(data,folderFilter){ + if(!data||!data.files||!data.files.length)return null; + var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; + if(!filtered.length)return null; + var byPath=Object.create(null); + filtered.forEach(function(f){byPath[f.path]=f;}); + var counts=Object.create(null); + (data.connections||[]).forEach(function(c){ + var src=typeof c.source==='object'?c.source.id:c.source; + var tgt=typeof c.target==='object'?c.target.id:c.target; + if(byPath[src])counts[src]=(counts[src]||0)+1; + if(byPath[tgt])counts[tgt]=(counts[tgt]||0)+1; + }); + var best=filtered[0]; + var bestN=counts[best.path]||0; + filtered.forEach(function(file){ + var n=counts[file.path]||0; + if(n>bestN){best=file;bestN=n;} + }); + return best.path; +} + function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -5737,6 +5759,7 @@ if(!file||ordered.some(function(f){return f.path===path;}))return; ordered.push(file); } + if(!selectedPath)selectedPath=defaultCodeViewSeed(data,folderFilter); if(selectedPath)add(selectedPath); getConnectedFilePaths(selectedPath,data.connections).slice().sort(function(a,b){ var loadedA=fileHasLoadedSource(byPath[a])?0:1; @@ -8112,6 +8135,11 @@ if(!data)return[]; return collectVisibleCodeFiles(selected&&selected.path,data,folderFilter); },[data,selected,folderFilter]); + useEffect(function(){ + if(graphConfig.vizType!=='code'||!data||selected)return; + var seed=defaultCodeViewSeed(data,folderFilter); + if(seed&&selectFileRef.current)selectFileRef.current(seed); + },[graphConfig.vizType,data,folderFilter,selected]); var codeViewFileTotal=useMemo(function(){ if(!data)return 0; return countVisibleCodeFiles(selected&&selected.path,data,folderFilter); @@ -8315,7 +8343,7 @@ node.classed('has-code-card',function(d){return keepReadable&&codeCardPathsRef.current.has(d.id);}); node.classed('code-faded',function(d){return keepReadable&&codeCardPathsRef.current.size>0&&!codeCardPathsRef.current.has(d.id);}); node.on('mouseenter',function(e,d){if(keepReadable&&codeCardPathsRef.current.has(d.id))return;var r=svgRef.current.getBoundingClientRect();setTooltip({x:e.clientX-r.left+10,y:e.clientY-r.top,title:d.name,content:d.fnCount+' functions\n'+d.layer+' layer\n'+d.churn+' recent commits'});}).on('mouseleave',function(){setTooltip(null);}); - svg.on('click',function(e){if(e.target===svgRef.current){setSelected(null);setBlastRadius(null);link.attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-opacity',0.4);node.selectAll('.nc').attr('opacity',1).attr('fill',getC);}}); + svg.on('click',function(e){if(e.target===svgRef.current){if(keepReadable)return;setSelected(null);setBlastRadius(null);link.attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-opacity',0.4);node.selectAll('.nc').attr('opacity',1).attr('fill',getC);}}); node.append('circle').attr('class','nc').attr('r',getR).attr('fill',getC).attr('stroke',function(d){var c=d3.color(getC(d));return c?c.brighter(0.3):'#fff';}).attr('stroke-width',1.5); // Hide labels for large graphs to reduce DOM overhead. Code view keeps titles readable. if(keepReadable||!isLargeGraph||graphConfig.showLabels){ @@ -8377,13 +8405,19 @@ pinCodeCardsToFolderLayout(); refreshCodeCardForces(); syncCodeCards(); + var fit=computeGraphFitTransform(); + if(fit)svg.call(zoom.transform,fit); } return function(){if(simRef.current)simRef.current.stop();}; },[data,colorMap,colorMode,theme,folderFilter,graphConfig]); useLayoutEffect(function(){ - if(graphConfig.vizType!=='code')return; + if(graphConfig.vizType!=='code'){ + codeCardLayoutKeyRef.current=''; + return; + } var layoutKey=codeViewFiles.map(function(file){return file.path;}).join('|'); + var firstOpen=!codeCardLayoutKeyRef.current; if(layoutKey!==codeCardLayoutKeyRef.current){ codeCardLayoutKeyRef.current=layoutKey; codeCardUserPinnedRef.current=new Set(); @@ -8392,6 +8426,10 @@ pinCodeCardsToFolderLayout(); refreshCodeCardForces(); syncCodeCards(); + if(firstOpen&&codeViewFiles.length&&zoomRef.current&&svgRef.current){ + var fit=computeGraphFitTransform(); + if(fit)d3.select(svgRef.current).call(zoomRef.current.transform,fit); + } },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist]); // 3D Force Graph Hook @@ -9930,7 +9968,7 @@ codeViewFiles.map(function(file){return renderCodeFileCard(file,file.path===primaryPath);}) ), React.createElement('div',{className:'code-canvas-hud'}, - React.createElement('div',{className:'code-canvas-hint'},selected?(codeViewFileTotal>codeViewFiles.length?'Showing '+codeViewFiles.length+' of '+codeViewFileTotal+' connected files.':'Click a symbol to highlight it across these cards.'):'Click a node to open connected files. Canvas zoom pans the full file.'), + React.createElement('div',{className:'code-canvas-hint'},codeViewFiles.length?(codeViewFileTotal>codeViewFiles.length?'Showing '+codeViewFiles.length+' of '+codeViewFileTotal+' connected files.':'File cards on the canvas. Click another node to shift the set.'):'No files to open as cards.'), codeViewSymbols.length>0&&React.createElement('div',{className:'code-sym-list'}, codeViewSymbols.map(function(sym){ return React.createElement('button',{key:sym.name,className:'code-sym-chip '+sym.kind+(activeSymbol===sym.name?' active':''),title:sym.name,onClick:function(){setActiveSymbol(function(prev){return prev===sym.name?null:sym.name;});}},sym.name); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 2111f5d..16c8dcd 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -88,7 +88,25 @@ test('visible code files are not capped at four', () => { const connections = files.slice(1).map((file) => ({ source: 'a.js', target: file.path, fn: 'a' })); const visible = context.collectVisibleCodeFiles('a.js', { files, connections }, 'src'); assert.deepEqual(J(visible).map((f) => f.path), ['a.js', 'b.js', 'c.js', 'd.js', 'e.js', 'f.js']); - assert.equal(context.collectVisibleCodeFiles(null, { files, connections }, 'src').length, 0); + const seeded = context.collectVisibleCodeFiles(null, { files, connections }, 'src'); + assert.equal(seeded[0].path, 'a.js'); + assert.equal(seeded.length, 6); +}); + +test('Code view seeds cards without waiting for a click', () => { + const files = [ + { path: 'leaf.js', folder: 'src', name: 'leaf.js', functions: [] }, + { path: 'hub.js', folder: 'src', name: 'hub.js', functions: [] }, + { path: 'other.js', folder: 'lib', name: 'other.js', functions: [] } + ]; + const connections = [ + { source: 'hub.js', target: 'leaf.js', fn: 'h' }, + { source: 'hub.js', target: 'other.js', fn: 'h' } + ]; + assert.equal(context.defaultCodeViewSeed({ files, connections }, 'src'), 'hub.js'); + const visible = context.collectVisibleCodeFiles(null, { files, connections }, 'src'); + assert.deepEqual(J(visible).map((f) => f.path), ['hub.js', 'leaf.js']); + assert.equal(context.defaultCodeViewSeed({ files: [], connections: [] }, null), null); }); test('high-degree neighborhoods stay within the card cap', () => { @@ -365,6 +383,7 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /data-code-card/); assert.match(htmlSource, /applyCodeCardLayout/); assert.match(htmlSource, /layoutCodeCardsByFolder/); + assert.match(htmlSource, /defaultCodeViewSeed/); assert.match(htmlSource, /className:'code-sym-list'/); assert.doesNotMatch(htmlSource, /className:'code-sym-row'/); assert.match(htmlSource, /CODE_CARD_MAX/); From f7a9179ce5f8ff076fc5d37c93710ccb0bfa131a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:30:39 +0000 Subject: [PATCH 06/33] Move Color By onto the graph and tab the left rail Color By is a small canvas control on Graph/3D Graph instead of a tall sidebar block. The left rail is now full-height Overview, Files, and Recents tabs so the file tree can use the whole column. Co-authored-by: Braedon Saunders --- index.html | 180 ++++++++++++++++++++++--------------- tests/code-canvas.test.mjs | 9 ++ 2 files changed, 118 insertions(+), 71 deletions(-) diff --git a/index.html b/index.html index ecf86d6..a3793f2 100644 --- a/index.html +++ b/index.html @@ -69,9 +69,21 @@ .resize-handle:hover{background:var(--acc);opacity:0.3} .sidebar-section{padding:12px;border-bottom:1px solid var(--border)} .sidebar-title{font-size:9px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px} -.sidebar-scroll{flex:1;overflow-y:auto;padding:8px} +.sidebar-scroll{flex:1;overflow-y:auto;padding:8px;min-height:0} .sidebar-scroll::-webkit-scrollbar{width:6px} .sidebar-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} +.sidebar-tabs{display:flex;flex-shrink:0;border-bottom:1px solid var(--border);background:var(--bg2)} +.sidebar-tab{flex:1;padding:9px 4px;background:transparent;border:none;border-bottom:2px solid transparent;color:var(--t3);font-family:inherit;font-size:9px;font-weight:600;letter-spacing:0.4px;text-transform:uppercase;cursor:pointer} +.sidebar-tab:hover{color:var(--t1)} +.sidebar-tab.active{color:var(--acc);border-bottom-color:var(--acc)} +.sidebar-pane{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden} +.sidebar-filter{flex-shrink:0;padding:8px 10px;border-bottom:1px solid var(--border)} +.color-by{position:absolute;top:12px;right:12px;z-index:51;display:flex;align-items:center;gap:2px;background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:3px 3px 3px 6px} +.color-by-label{font-size:8px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:0.4px;margin-right:2px;white-space:nowrap} +.color-by-btn{padding:3px 7px;background:transparent;border:1px solid transparent;border-radius:5px;font-size:9px;font-family:inherit;color:var(--t2);cursor:pointer} +.color-by-btn:hover{color:var(--t0)} +.color-by-btn.active{background:var(--accbg);border-color:var(--acc);color:var(--acc)} +.legend.with-color-by{top:50px} .view-modes{display:flex;flex-direction:column;gap:2px} .view-mode{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:11px;color:var(--t1)} .view-mode:hover{background:var(--hover)} @@ -568,7 +580,9 @@ .canvas-toolbar{top:68px;left:12px;right:12px;gap:8px;overflow-x:auto;padding-bottom:2px} .tool-btn{width:40px;height:40px;font-size:15px;flex:0 0 auto} .graph-config{top:120px;left:12px;right:12px;width:auto;max-width:none} +.color-by{top:auto;bottom:118px;right:12px} .legend{top:auto;bottom:68px;right:12px;max-width:calc(100vw - 24px)} +.legend.with-color-by{top:auto;bottom:68px} .canvas-info{left:12px;right:12px;bottom:12px;overflow-x:auto;padding-bottom:calc(2px + env(safe-area-inset-bottom))} .info-chip{white-space:nowrap;flex:0 0 auto} .export-options,.pr-grid,.pr-impact-grid,.unused-summary{grid-template-columns:1fr} @@ -595,7 +609,9 @@ .health-score{align-items:flex-start} .canvas-toolbar{top:64px} .graph-config{top:116px} +.color-by{bottom:122px} .legend{bottom:72px;right:12px} +.legend.with-color-by{bottom:72px} .panel-tabs{position:sticky;top:0;z-index:2} .file-preview-modal{width:100vw;height:100dvh;border-radius:0;border:none} .file-preview-header{border-radius:0;padding:12px} @@ -651,7 +667,9 @@ .graph-config{top:52px;left:10px;right:10px;width:auto;max-width:none} .architecture-view{padding:0} .architecture-shell{min-width:0;padding:0} +.color-by{top:auto;bottom:118px;right:10px} .legend{top:auto;bottom:72px;right:10px;max-width:calc(100vw - 20px)} +.legend.with-color-by{top:auto;bottom:72px} .canvas-info{left:10px;right:10px;bottom:10px;overflow-x:auto;padding-bottom:0} .info-chip{white-space:nowrap;flex:0 0 auto} } @@ -661,7 +679,9 @@ .mobile-secondary-row .auth-select{width:106px} .mobile-secondary-row .top-btn,.mobile-secondary-row .refresh-btn,.mobile-secondary-row .reset-btn{padding:0 9px} .canvas-toolbar{bottom:70px} +.color-by{bottom:116px} .legend{bottom:70px} +.legend.with-color-by{bottom:70px} } @@ -6643,6 +6663,7 @@ var _j=useState(null),selected=_j[0],setSelected=_j[1]; var _k=useState(new Set([''])),expandedPaths=_k[0],setExpandedPaths=_k[1]; var _l=useState(new Set(['blast','fns'])),expandedCards=_l[0],setExpandedCards=_l[1]; + var _leftRail=useState('recents'),leftTab=_leftRail[0],setLeftTab=_leftRail[1]; var _m=useState('details'),rightTab=_m[0],setRightTab=_m[1]; var _m2=useState(null),drillDown=_m2[0],setDrillDown=_m2[1];// {type:'issue'|'pattern'|'security'|'suggestion'|'duplicate', data:...} var _n=useState(null),blastRadius=_n[0],setBlastRadius=_n[1]; @@ -8140,6 +8161,12 @@ var seed=defaultCodeViewSeed(data,folderFilter); if(seed&&selectFileRef.current)selectFileRef.current(seed); },[graphConfig.vizType,data,folderFilter,selected]); + var hadAnalysisRef=useRef(false); + useEffect(function(){ + if(data&&!hadAnalysisRef.current)setLeftTab('files'); + if(!data)setLeftTab('recents'); + hadAnalysisRef.current=!!data; + },[data]); var codeViewFileTotal=useMemo(function(){ if(!data)return 0; return countVisibleCodeFiles(selected&&selected.path,data,folderFilter); @@ -9853,10 +9880,9 @@ function resetAnalysis(){setData(null);setSelected(null);setBlastRadius(null);setOwnership(null);setRepoInfo(null);setRepoUrl('');setPrData(null);setFolderFilter(null);setLocalDirHandle(null);setLocalSourceKind(null);setArchitectureIncludeTests(false);setArchitectureIncludeBuildOutput(false);setCachedFromId(null);setActiveSymbol(null);setCliDirty([]);localFolderKeyRef.current=null;localFolderSelectionRef.current=null;localFilesRef.current=null;zipKeyRef.current=null;zipArchiveRef.current=null;zipFileRef.current=null;window.history.replaceState({},'',window.location.pathname);} function filterByFolder(path){setFolderFilter(function(prev){return prev===path?null:path;});} function renderRecentsList(){ - return React.createElement('div',{className:'sidebar-section'}, - React.createElement('div',{className:'sidebar-title'},iconLabel('clock','Recent analyses')), + return React.createElement('div',{className:'sidebar-scroll'}, recentAnalyses.length?React.createElement('div',{className:'recent-list'}, - recentAnalyses.slice(0,8).map(function(item){ + recentAnalyses.map(function(item){ var active=cachedFromId===item.id||(data&¤tAnalysisSource()&&analysisCacheKey(currentAnalysisSource().sourceType,currentAnalysisSource().sourceKey)===item.id); return React.createElement('div',{key:item.id,className:'recent-item'+(active?' active':''),onClick:function(){loadRecentAnalysis(item.id);}}, React.createElement('div',{className:'recent-item-main'}, @@ -9872,6 +9898,72 @@ ):React.createElement('div',{className:'recent-empty'},'Analyses you run are cached here so the same tree is not fetched twice.') ); } + function renderColorByControl(){ + if(graphConfig.vizType!=='graph'&&graphConfig.vizType!=='graph3d')return null; + return React.createElement('div',{className:'color-by',role:'group','aria-label':'Color graph by'}, + React.createElement('span',{className:'color-by-label'},'Color'), + React.createElement('button',{type:'button',className:'color-by-btn'+(colorMode==='folder'?' active':''),onClick:function(){setColorMode('folder');}},'Folder'), + React.createElement('button',{type:'button',className:'color-by-btn'+(colorMode==='layer'?' active':''),onClick:function(){setColorMode('layer');}},'Layer'), + React.createElement('button',{type:'button',className:'color-by-btn'+(colorMode==='churn'?' active':''),onClick:function(){setColorMode('churn');}},'Churn') + ); + } + function renderSidebarEmpty(title,desc){ + return React.createElement('div',{className:'empty-state'}, + React.createElement(Icon,{name:'search',size:'xxl',className:'empty-icon'}), + React.createElement('div',{className:'empty-title'},title), + React.createElement('div',{className:'empty-desc'},desc) + ); + } + function renderOverviewPane(){ + if(!data)return renderSidebarEmpty('No Repository','Enter a GitHub URL, open a folder, or load a ZIP archive'); + return React.createElement('div',{className:'sidebar-scroll'}, + React.createElement('div',{className:'health-score',style:{marginBottom:10}}, + React.createElement(HealthRing,{score:health.score,grade:health.grade}), + React.createElement('div',{className:'health-info'}, + React.createElement('div',{className:'health-grade',style:{color:health.score>=80?'var(--green)':health.score>=60?'var(--orange)':'var(--red)'}},health.score,'/100'), + React.createElement('div',{className:'health-label'},'Health Score') + ) + ), + (cachedFromId||cliDirty.length>0)&&React.createElement('div',{className:'stale-banner'}, + React.createElement('span',null,cliDirty.length?'Watched files changed. Re-analyze to refresh.':'Cached analysis. Re-analyze to refresh.'), + React.createElement('button',{className:'top-btn primary',onClick:refreshAnalysis,disabled:loading},'Re-analyze') + ), + React.createElement('div',{className:'stats-grid'}, + React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},data.stats.files),React.createElement('div',{className:'stat-label'},'Files')), + React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},data.stats.functions),React.createElement('div',{className:'stat-label'},'Functions')), + React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},data.stats.connections),React.createElement('div',{className:'stat-label'},'Links')), + React.createElement('div',{className:'stat-card'+(data.stats.dead>10?' warn':''),style:{cursor:data.stats.dead>0?'pointer':'default'},onClick:function(){if(data.stats.dead>0)setShowUnused(true);}},React.createElement('div',{className:'stat-value'},data.stats.dead),React.createElement('div',{className:'stat-label'},'Unused')) + ), + React.createElement('div',{className:'loc-stat'}, + React.createElement('div',{className:'loc-value'},data.stats.loc?data.stats.loc.toLocaleString():'0'), + React.createElement('div',{className:'loc-label'},'Lines of Code') + ), + data.stats.languages&&data.stats.languages.length>0&&React.createElement(React.Fragment,null, + React.createElement('div',{className:'lang-bar'}, + data.stats.languages.slice(0,6).map(function(l,i){return React.createElement('div',{key:l.ext,className:'lang-bar-segment',style:{width:l.pct+'%',background:COLORS[i%COLORS.length]}});}) + ), + React.createElement('div',{className:'lang-legend'}, + data.stats.languages.slice(0,6).map(function(l,i){return React.createElement('div',{key:l.ext,className:'lang-item'}, + React.createElement('div',{className:'lang-dot',style:{background:COLORS[i%COLORS.length]}}), + React.createElement('span',null,l.ext,' ',l.pct,'%') + );}) + ) + ) + ); + } + function renderFilesPane(){ + if(!data)return renderSidebarEmpty('No Files','Analyze a repository to browse its folder tree'); + return React.createElement(React.Fragment,null, + folderFilter&&React.createElement('div',{className:'sidebar-filter'}, + React.createElement('button',{className:'top-btn',style:{width:'100%'},onClick:function(){setFolderFilter(null);}}, + React.createElement(Icon,{name:'close',size:'s'}), + ' Clear Filter: ', + folderFilter + ) + ), + React.createElement('div',{className:'sidebar-scroll'},React.createElement(TreeNode,{node:data.tree,selected:selected,onSelect:selectFile,expanded:expandedPaths,toggle:togglePath,filterFolder:filterByFolder,activeFilter:folderFilter})) + ); + } function beginCodeCardDrag(e,file){ if(e.pointerType==='mouse'&&e.button!==0)return; e.stopPropagation(); @@ -10216,8 +10308,8 @@ React.createElement('div',{className:'sidebar'+(isMobile&&mobilePanel==='explorer'?' mobile-visible':''),style:{width:isMobile?'100vw':sidebarWidth}}, isMobile&&React.createElement('div',{className:'mobile-panel-header'}, React.createElement('div',{className:'mobile-panel-meta'}, - React.createElement('div',{className:'mobile-panel-title'},'Explorer'), - React.createElement('div',{className:'mobile-panel-subtitle'},data?(folderFilter?'Filtered by '+folderFilter:data.files.length+' files ready to browse'):'Analyze a repo or open a folder') + React.createElement('div',{className:'mobile-panel-title'},leftTab==='overview'?'Overview':leftTab==='files'?'Files':'Recents'), + React.createElement('div',{className:'mobile-panel-subtitle'},leftTab==='recents'?(recentAnalyses.length?recentAnalyses.length+' cached analyses':'No cached analyses'):data?(folderFilter?'Filtered by '+folderFilter:data.files.length+' files ready to browse'):'Analyze a repo or open a folder') ), React.createElement('button',{className:'mobile-panel-close',type:'button','aria-label':'Close explorer panel',onClick:function(){setMobilePanel(null);}}, React.createElement(Icon,{name:'close',size:'m'}) @@ -10230,70 +10322,15 @@ function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);} document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp); }}), - data?React.createElement(React.Fragment,null, - React.createElement('div',{className:'sidebar-section'}, - React.createElement('div',{className:'health-score'}, - React.createElement(HealthRing,{score:health.score,grade:health.grade}), - React.createElement('div',{className:'health-info'}, - React.createElement('div',{className:'health-grade',style:{color:health.score>=80?'var(--green)':health.score>=60?'var(--orange)':'var(--red)'}},health.score,'/100'), - React.createElement('div',{className:'health-label'},'Health Score') - ) - ) - ), - React.createElement('div',{className:'sidebar-section'}, - React.createElement('div',{className:'sidebar-title'},'Color By'), - React.createElement('div',{className:'view-modes'}, - React.createElement('div',{className:'view-mode'+(colorMode==='folder'?' active':''),onClick:function(){setColorMode('folder');}},React.createElement(Icon,{name:'folder',size:'m',className:'view-mode-icon'}),'Folder'), - React.createElement('div',{className:'view-mode'+(colorMode==='layer'?' active':''),onClick:function(){setColorMode('layer');}},React.createElement(Icon,{name:'layers',size:'m',className:'view-mode-icon'}),'Layer'), - React.createElement('div',{className:'view-mode'+(colorMode==='churn'?' active':''),onClick:function(){setColorMode('churn');}},React.createElement(Icon,{name:'activity',size:'m',className:'view-mode-icon'}),'Churn') - ) - ), - (cachedFromId||cliDirty.length>0)&&React.createElement('div',{className:'sidebar-section'}, - React.createElement('div',{className:'stale-banner'}, - React.createElement('span',null,cliDirty.length?'Watched files changed. Re-analyze to refresh.':'Cached analysis. Re-analyze to refresh.'), - React.createElement('button',{className:'top-btn primary',onClick:refreshAnalysis,disabled:loading},'Re-analyze') - ) - ), - renderRecentsList(), - React.createElement('div',{className:'sidebar-section'}, - React.createElement('div',{className:'stats-grid'}, - React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},data.stats.files),React.createElement('div',{className:'stat-label'},'Files')), - React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},data.stats.functions),React.createElement('div',{className:'stat-label'},'Functions')), - React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},data.stats.connections),React.createElement('div',{className:'stat-label'},'Links')), - React.createElement('div',{className:'stat-card'+(data.stats.dead>10?' warn':''),style:{cursor:data.stats.dead>0?'pointer':'default'},onClick:function(){if(data.stats.dead>0)setShowUnused(true);}},React.createElement('div',{className:'stat-value'},data.stats.dead),React.createElement('div',{className:'stat-label'},'Unused')) - ), - React.createElement('div',{className:'loc-stat'}, - React.createElement('div',{className:'loc-value'},data.stats.loc?data.stats.loc.toLocaleString():'0'), - React.createElement('div',{className:'loc-label'},'Lines of Code') - ), - data.stats.languages&&data.stats.languages.length>0&&React.createElement(React.Fragment,null, - React.createElement('div',{className:'lang-bar'}, - data.stats.languages.slice(0,6).map(function(l,i){return React.createElement('div',{key:l.ext,className:'lang-bar-segment',style:{width:l.pct+'%',background:COLORS[i%COLORS.length]}});}) - ), - React.createElement('div',{className:'lang-legend'}, - data.stats.languages.slice(0,6).map(function(l,i){return React.createElement('div',{key:l.ext,className:'lang-item'}, - React.createElement('div',{className:'lang-dot',style:{background:COLORS[i%COLORS.length]}}), - React.createElement('span',null,l.ext,' ',l.pct,'%') - );}) - ) - ) - ), - React.createElement('div',{className:'sidebar-section',style:{paddingBottom:8}}, - React.createElement('div',{className:'sidebar-title'},'Explorer'), - folderFilter&&React.createElement('button',{className:'top-btn',style:{width:'100%',marginTop:8},onClick:function(){setFolderFilter(null);}}, - React.createElement(Icon,{name:'close',size:'s'}), - ' Clear Filter: ', - folderFilter - ) - ), - React.createElement('div',{className:'sidebar-scroll'},React.createElement(TreeNode,{node:data.tree,selected:selected,onSelect:selectFile,expanded:expandedPaths,toggle:togglePath,filterFolder:filterByFolder,activeFilter:folderFilter})) - ):React.createElement(React.Fragment,null, - renderRecentsList(), - React.createElement('div',{className:'empty-state'}, - React.createElement(Icon,{name:'search',size:'xxl',className:'empty-icon'}), - React.createElement('div',{className:'empty-title'},'No Repository'), - React.createElement('div',{className:'empty-desc'},'Enter a GitHub URL, open a folder, or load a ZIP archive') - ) + React.createElement('div',{className:'sidebar-tabs',role:'tablist','aria-label':'Left rail'}, + React.createElement('button',{type:'button',role:'tab','aria-selected':leftTab==='overview',className:'sidebar-tab'+(leftTab==='overview'?' active':''),onClick:function(){setLeftTab('overview');}},'Overview'), + React.createElement('button',{type:'button',role:'tab','aria-selected':leftTab==='files',className:'sidebar-tab'+(leftTab==='files'?' active':''),onClick:function(){setLeftTab('files');}},'Files'), + React.createElement('button',{type:'button',role:'tab','aria-selected':leftTab==='recents',className:'sidebar-tab'+(leftTab==='recents'?' active':''),onClick:function(){setLeftTab('recents');}},'Recents') + ), + React.createElement('div',{className:'sidebar-pane'}, + leftTab==='overview'&&renderOverviewPane(), + leftTab==='files'&&renderFilesPane(), + leftTab==='recents'&&renderRecentsList() ) ), React.createElement('div',{className:'canvas-area'}, @@ -10394,7 +10431,8 @@ blastRadius.fnsUsed>0?' • '+blastRadius.fnsUsed+' fns used':'' ) ), - graphConfig.vizType!=='architecture'&&graphConfig.vizType!=='code'&&React.createElement('div',{className:'legend'+(legendCollapsed?' collapsed':'')}, + renderColorByControl(), + graphConfig.vizType!=='architecture'&&graphConfig.vizType!=='code'&&React.createElement('div',{className:'legend'+(legendCollapsed?' collapsed':'')+((graphConfig.vizType==='graph'||graphConfig.vizType==='graph3d')?' with-color-by':'')}, React.createElement('div',{className:'legend-header',onClick:function(){setLegendCollapsed(!legendCollapsed);}}, React.createElement('div',{className:'legend-title',style:{margin:0}},colorMode==='folder'?'Folders':colorMode==='layer'?'Layers':'Churn'), React.createElement('span',{className:'legend-toggle'},'▼') diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 16c8dcd..9d12924 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -407,4 +407,13 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /onPointerDown/); assert.match(htmlSource, /__codeflow\/file\?path=/); assert.match(htmlSource, /The folder picker is faster when the API is rate-limited/); + assert.match(htmlSource, /className:'sidebar-tabs'/); + assert.match(htmlSource, /className:'sidebar-tab'/); + assert.match(htmlSource, /setLeftTab\('overview'\)/); + assert.match(htmlSource, /setLeftTab\('files'\)/); + assert.match(htmlSource, /setLeftTab\('recents'\)/); + assert.match(htmlSource, /function renderColorByControl\(/); + assert.match(htmlSource, /className:'color-by'/); + assert.doesNotMatch(htmlSource, /sidebar-title'\},'Color By'/); + assert.doesNotMatch(htmlSource, /sidebar-title'\},'Explorer'/); }); From 6334bd90d64d7bc5612c97737cb88908b7517a4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:31:47 +0000 Subject: [PATCH 07/33] Default the left rail to Overview after analyze First paint and a newly loaded analysis open Overview. Files and Recents stay one click away; last tab is not persisted across visits. Co-authored-by: Braedon Saunders --- index.html | 5 ++--- tests/code-canvas.test.mjs | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index a3793f2..06e694a 100644 --- a/index.html +++ b/index.html @@ -6663,7 +6663,7 @@ var _j=useState(null),selected=_j[0],setSelected=_j[1]; var _k=useState(new Set([''])),expandedPaths=_k[0],setExpandedPaths=_k[1]; var _l=useState(new Set(['blast','fns'])),expandedCards=_l[0],setExpandedCards=_l[1]; - var _leftRail=useState('recents'),leftTab=_leftRail[0],setLeftTab=_leftRail[1]; + var _leftRail=useState('overview'),leftTab=_leftRail[0],setLeftTab=_leftRail[1]; var _m=useState('details'),rightTab=_m[0],setRightTab=_m[1]; var _m2=useState(null),drillDown=_m2[0],setDrillDown=_m2[1];// {type:'issue'|'pattern'|'security'|'suggestion'|'duplicate', data:...} var _n=useState(null),blastRadius=_n[0],setBlastRadius=_n[1]; @@ -8163,8 +8163,7 @@ },[graphConfig.vizType,data,folderFilter,selected]); var hadAnalysisRef=useRef(false); useEffect(function(){ - if(data&&!hadAnalysisRef.current)setLeftTab('files'); - if(!data)setLeftTab('recents'); + if(data&&!hadAnalysisRef.current)setLeftTab('overview'); hadAnalysisRef.current=!!data; },[data]); var codeViewFileTotal=useMemo(function(){ diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 9d12924..250ba71 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -409,9 +409,11 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /The folder picker is faster when the API is rate-limited/); assert.match(htmlSource, /className:'sidebar-tabs'/); assert.match(htmlSource, /className:'sidebar-tab'/); + assert.match(htmlSource, /useState\('overview'\),leftTab=/); assert.match(htmlSource, /setLeftTab\('overview'\)/); assert.match(htmlSource, /setLeftTab\('files'\)/); assert.match(htmlSource, /setLeftTab\('recents'\)/); + assert.doesNotMatch(htmlSource, /if\(data&&!hadAnalysisRef\.current\)setLeftTab\('files'\)/); assert.match(htmlSource, /function renderColorByControl\(/); assert.match(htmlSource, /className:'color-by'/); assert.doesNotMatch(htmlSource, /sidebar-title'\},'Color By'/); From b45fdd6daa6e503674d91f28c9530728a8545ab8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:37:04 +0000 Subject: [PATCH 08/33] Stop Code view from stealing the camera Auto-fit now runs at most once when entering Code. Opening files, hydrating source, or resizing cards no longer refits or regrids the canvas. The user pan/zoom stays put and cards keep their readable size. Co-authored-by: Braedon Saunders --- index.html | 160 ++++++++++++++++++++++++++++++------- tests/code-canvas.test.mjs | 34 ++++++++ 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/index.html b/index.html index 06e694a..a292519 100644 --- a/index.html +++ b/index.html @@ -5932,6 +5932,66 @@ }; } +var CODE_VIEW_MIN_FIT_SCALE=0.4; +var CODE_VIEW_MAX_FIT_SCALE=1.15; + +function graphStructureKey(data,folderFilter){ + if(!data||!data.files)return ''; + var paths=data.files.map(function(file){return file.path;}).join('\n'); + return String(folderFilter||'')+'\n'+paths+'\n'+(data.connections?data.connections.length:0); +} + +function preserveGraphNodeState(nodes,prevById){ + (nodes||[]).forEach(function(node){ + var prev=prevById&&prevById[node.id]; + if(!prev)return; + if(isFinite(prev.x))node.x=prev.x; + if(isFinite(prev.y))node.y=prev.y; + if(isFinite(prev.vx))node.vx=prev.vx; + if(isFinite(prev.vy))node.vy=prev.vy; + if(prev.fx!=null&&isFinite(prev.fx))node.fx=prev.fx; + if(prev.fy!=null&&isFinite(prev.fy))node.fy=prev.fy; + }); + return nodes; +} + +function snapshotZoomTransform(transform){ + var t=transform||{}; + var k=Number(t.k); + if(!isFinite(k)||k<=0)k=1; + var x=Number(t.x); + if(!isFinite(x))x=0; + var y=Number(t.y); + if(!isFinite(y))y=0; + return {k:k,x:x,y:y}; +} + +function shouldFitCodeCamera(cameraReady,vizType){ + return vizType==='code'&&!cameraReady; +} + +function clampCodeViewFitScale(scale){ + var value=Number(scale); + if(!isFinite(value)||value<=0)return CODE_VIEW_MIN_FIT_SCALE; + if(valueCODE_VIEW_MAX_FIT_SCALE)return CODE_VIEW_MAX_FIT_SCALE; + return value; +} + +function codeCardFitBounds(nodes,sizesByPath,cardPaths){ + var minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; + (nodes||[]).forEach(function(node){ + if(!node||!cardPaths||!cardPaths.has(node.id)||!isFinite(node.x)||!isFinite(node.y))return; + var size=(sizesByPath&&sizesByPath[node.id])||codeCardSize(null); + minX=Math.min(minX,node.x-size.width/2); + minY=Math.min(minY,node.y-size.height/2); + maxX=Math.max(maxX,node.x+size.width/2); + maxY=Math.max(maxY,node.y+size.height/2); + }); + if(!isFinite(minX))return null; + return {minX:minX,minY:minY,maxX:maxX,maxY:maxY,cx:(minX+maxX)/2,cy:(minY+maxY)/2}; +} + function applyCodeCardLayout(layer,nodesById,transform,sizesByPath){ if(!layer)return{placed:0,titleScale:1}; layer.style.transform=codeCanvasTransformStyle(transform); @@ -6731,6 +6791,8 @@ var codeCardSizesRef=useRef(Object.create(null)); var codeCardUserPinnedRef=useRef(new Set()); var codeCardLayoutKeyRef=useRef(''); + var codeViewCameraReadyRef=useRef(false); + var codeViewSceneRef=useRef(''); var codeCardIgnoreClickRef=useRef(false); var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); @@ -8152,6 +8214,19 @@ return m; },[data,colorMode]); + var graphRebuildKey=useMemo(function(){ + return [ + graphStructureKey(data,folderFilter), + colorMode, + theme, + graphConfig.vizType, + graphConfig.viewMode, + graphConfig.spacing, + graphConfig.linkDist, + graphConfig.showLabels, + graphConfig.curvedLinks + ].join('\0'); + },[data,folderFilter,colorMode,theme,graphConfig]); var codeViewFiles=useMemo(function(){ if(!data)return[]; return collectVisibleCodeFiles(selected&&selected.path,data,folderFilter); @@ -8184,7 +8259,7 @@ applyCodeCardLayout(codeCardsLayerRef.current,graphNodesByIdRef.current,codeZoomTransformRef.current,codeCardSizesRef.current); } - function pinCodeCardsToFolderLayout(){ + function pinCodeCardsToFolderLayout(onlyUnplaced){ var sim=simRef.current; if(!sim)return; var sizes=currentCodeCardSizes(); @@ -8192,13 +8267,24 @@ sim.nodes().forEach(function(node){ var pos=layout[node.id]; if(!pos){ - if(!codeCardUserPinnedRef.current.has(node.id)){node.fx=null;node.fy=null;} + if(!onlyUnplaced&&!codeCardUserPinnedRef.current.has(node.id)){node.fx=null;node.fy=null;} return; } if(codeCardUserPinnedRef.current.has(node.id))return; + if(onlyUnplaced&&isFinite(node.fx)&&isFinite(node.fy))return; node.fx=pos.x;node.fy=pos.y;node.x=pos.x;node.y=pos.y; }); } + function freezeNewCodeCardsInPlace(){ + var sim=simRef.current; + if(!sim)return; + sim.nodes().forEach(function(node){ + if(!codeCardPathsRef.current.has(node.id))return; + if(isFinite(node.fx)&&isFinite(node.fy))return; + if(!isFinite(node.x)||!isFinite(node.y))return; + node.fx=node.x;node.fy=node.y; + }); + } function refreshCodeCardForces(){ var sim=simRef.current; @@ -8274,7 +8360,21 @@ var centers={}; folders.forEach(function(f,i){centers[f]={x:(i%cols+1)*cw,y:(Math.floor(i/cols)+1)*ch};}); var keepReadable=graphConfig.vizType==='code'; - codeZoomTransformRef.current={k:1,x:0,y:0}; + var sceneKey=(folderFilter||'')+'|'+graphConfig.vizType; + if(sceneKey!==codeViewSceneRef.current){ + codeViewSceneRef.current=sceneKey; + codeViewCameraReadyRef.current=false; + } + var savedZoom=snapshotZoomTransform(codeZoomTransformRef.current); + var prevNodesById=Object.create(null); + if(simRef.current){ + simRef.current.nodes().forEach(function(n){if(n&&n.id)prevNodesById[n.id]=n;}); + } + if(keepReadable&&codeViewCameraReadyRef.current)preserveGraphNodeState(nodes,prevNodesById); + if(!keepReadable){ + codeZoomTransformRef.current={k:1,x:0,y:0}; + codeViewCameraReadyRef.current=false; + } graphNodesByIdRef.current=Object.create(null); nodes.forEach(function(n){graphNodesByIdRef.current[n.id]=n;}); function collideR(d){return liveCodeCollideRadius(d,keepReadable&&codeCardPathsRef.current.has(d.id)?codeCardSizesRef.current[d.id]:null);} @@ -8364,7 +8464,7 @@ linksRef.current=link; var node=nodeLayer.selectAll('g').data(nodes).join('g').style('cursor','pointer'); nodesRef.current=node; - node.call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.1).restart();d.fx=d.x;d.fy=d.y;}).on('drag',function(e,d){d.fx=e.x;d.fy=e.y;}).on('end',function(e,d){if(!e.active)sim.alphaTarget(0);d.fx=null;d.fy=null;})); + node.call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.1).restart();d.fx=d.x;d.fy=d.y;}).on('drag',function(e,d){d.fx=e.x;d.fy=e.y;}).on('end',function(e,d){if(!e.active)sim.alphaTarget(0);if(keepReadable){d.fx=d.x;d.fy=d.y;codeCardUserPinnedRef.current.add(d.id);}else{d.fx=null;d.fy=null;}})); node.on('click',function(e,d){e.stopPropagation();if(selectFileRef.current)selectFileRef.current(d.id);}); node.classed('has-code-card',function(d){return keepReadable&&codeCardPathsRef.current.has(d.id);}); node.classed('code-faded',function(d){return keepReadable&&codeCardPathsRef.current.size>0&&!codeCardPathsRef.current.has(d.id);}); @@ -8425,37 +8525,39 @@ if(tickCount%hullInterval===0)updateHulls(); }); node.selectAll('text').attr('opacity',(keepReadable||graphConfig.showLabels)?1:0); - }catch(e){console.error('Force graph error:',e);svg.selectAll('*').remove();svg.append('text').attr('x',20).attr('y',30).attr('fill','var(--t3)').text('Graph rendering error: '+e.message);} if(keepReadable){ currentCodeCardSizes(); - pinCodeCardsToFolderLayout(); + if(!codeViewCameraReadyRef.current)pinCodeCardsToFolderLayout(); + else freezeNewCodeCardsInPlace(); refreshCodeCardForces(); syncCodeCards(); - var fit=computeGraphFitTransform(); - if(fit)svg.call(zoom.transform,fit); + updateHulls(); + if(shouldFitCodeCamera(codeViewCameraReadyRef.current,graphConfig.vizType)){ + var fit=computeGraphFitTransform(); + if(fit)svg.call(zoom.transform,fit); + codeViewCameraReadyRef.current=true; + }else{ + svg.call(zoom.transform,d3.zoomIdentity.translate(savedZoom.x,savedZoom.y).scale(savedZoom.k)); + } + sim.alpha(0); } + }catch(e){console.error('Force graph error:',e);svg.selectAll('*').remove();svg.append('text').attr('x',20).attr('y',30).attr('fill','var(--t3)').text('Graph rendering error: '+e.message);} return function(){if(simRef.current)simRef.current.stop();}; - },[data,colorMap,colorMode,theme,folderFilter,graphConfig]); + },[graphRebuildKey]); useLayoutEffect(function(){ if(graphConfig.vizType!=='code'){ codeCardLayoutKeyRef.current=''; + codeViewCameraReadyRef.current=false; return; } var layoutKey=codeViewFiles.map(function(file){return file.path;}).join('|'); - var firstOpen=!codeCardLayoutKeyRef.current; - if(layoutKey!==codeCardLayoutKeyRef.current){ - codeCardLayoutKeyRef.current=layoutKey; - codeCardUserPinnedRef.current=new Set(); - } + if(layoutKey!==codeCardLayoutKeyRef.current)codeCardLayoutKeyRef.current=layoutKey; currentCodeCardSizes(); - pinCodeCardsToFolderLayout(); + if(!codeViewCameraReadyRef.current)pinCodeCardsToFolderLayout(); + else freezeNewCodeCardsInPlace(); refreshCodeCardForces(); syncCodeCards(); - if(firstOpen&&codeViewFiles.length&&zoomRef.current&&svgRef.current){ - var fit=computeGraphFitTransform(); - if(fit)d3.select(svgRef.current).call(zoomRef.current.transform,fit); - } },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist]); // 3D Force Graph Hook @@ -9356,20 +9458,22 @@ } function computeGraphFitTransform(paddingSlack){ paddingSlack=paddingSlack==null?100:paddingSlack; - if(graphConfig.vizType==='code'&&codeCardPathsRef.current&&codeCardPathsRef.current.size){ - var maxR=120; - Object.keys(codeCardSizesRef.current||{}).forEach(function(path){ - maxR=Math.max(maxR,codeCardCollisionRadius(codeCardSizesRef.current[path])); - }); - paddingSlack=Math.max(paddingSlack,maxR*2); - } if(!zoomRef.current||!svgRef.current||!simRef.current)return null; var nodes=simRef.current.nodes(); if(!nodes.length)return null; - var xs=nodes.map(function(n){return n.x;}),ys=nodes.map(function(n){return n.y;}); - var minX=Math.min.apply(null,xs),maxX=Math.max.apply(null,xs),minY=Math.min.apply(null,ys),maxY=Math.max.apply(null,ys); var w=svgRef.current.clientWidth,h=svgRef.current.clientHeight; if(w<1||h<1)return null; + if(graphConfig.vizType==='code'&&codeCardPathsRef.current&&codeCardPathsRef.current.size){ + var cardBounds=codeCardFitBounds(nodes,codeCardSizesRef.current,codeCardPathsRef.current); + if(cardBounds){ + var cardW=Math.max(1,cardBounds.maxX-cardBounds.minX+80); + var cardH=Math.max(1,cardBounds.maxY-cardBounds.minY+80); + var cardScale=clampCodeViewFitScale(0.88/Math.max(cardW/w,cardH/h)); + return d3.zoomIdentity.translate(w/2-cardScale*cardBounds.cx,h/2-cardScale*cardBounds.cy).scale(cardScale); + } + } + var xs=nodes.map(function(n){return n.x;}),ys=nodes.map(function(n){return n.y;}); + var minX=Math.min.apply(null,xs),maxX=Math.max.apply(null,xs),minY=Math.min.apply(null,ys),maxY=Math.max.apply(null,ys); var scale=0.8/Math.max((maxX-minX+paddingSlack)/w,(maxY-minY+paddingSlack)/h); return d3.zoomIdentity.translate(w/2-scale*(minX+maxX)/2,h/2-scale*(minY+maxY)/2).scale(Math.min(scale,2)); } diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 250ba71..a6c7ea0 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -174,6 +174,36 @@ test('opened code cards auto-align by directory', () => { assert.ok(bounds.height >= 200); }); +test('Code camera fits only when it has not been armed yet', () => { + assert.equal(context.shouldFitCodeCamera(false, 'code'), true); + assert.equal(context.shouldFitCodeCamera(true, 'code'), false); + assert.equal(context.shouldFitCodeCamera(false, 'graph'), false); + assert.equal(context.clampCodeViewFitScale(0.05), context.CODE_VIEW_MIN_FIT_SCALE); + assert.equal(context.clampCodeViewFitScale(8), context.CODE_VIEW_MAX_FIT_SCALE); + assert.equal(context.clampCodeViewFitScale(0.7), 0.7); +}); + +test('hydrating file contents does not change the graph structure key', () => { + const before = { + files: [{ path: 'a.js' }, { path: 'b.js' }], + connections: [{ source: 'a.js', target: 'b.js' }] + }; + const after = { + files: [{ path: 'a.js', content: 'export function a(){}' }, { path: 'b.js', content: 'import { a } from "./a.js"' }], + connections: [{ source: 'a.js', target: 'b.js' }] + }; + assert.equal(context.graphStructureKey(before, null), context.graphStructureKey(after, null)); + assert.notEqual(context.graphStructureKey(before, null), context.graphStructureKey(before, 'src')); +}); + +test('preserved graph nodes keep the user camera positions', () => { + const nodes = [{ id: 'a.js', x: 0, y: 0 }, { id: 'b.js', x: 1, y: 1 }]; + context.preserveGraphNodeState(nodes, { 'a.js': { x: 40, y: 80, fx: 40, fy: 80 } }); + assert.equal(nodes[0].x, 40); + assert.equal(nodes[0].fx, 40); + assert.equal(nodes[1].x, 1); +}); + test('code cards sit on the canvas transform, not a split pane', () => { assert.ok(context.codeCardCollisionRadius({ width: 320, height: 220 }) > 100); assert.equal(context.codeCanvasTransformStyle({ k: 0.5, x: 10, y: 20 }), 'translate(10px,20px) scale(0.5)'); @@ -384,6 +414,10 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /applyCodeCardLayout/); assert.match(htmlSource, /layoutCodeCardsByFolder/); assert.match(htmlSource, /defaultCodeViewSeed/); + assert.match(htmlSource, /shouldFitCodeCamera/); + assert.match(htmlSource, /codeViewCameraReadyRef/); + assert.match(htmlSource, /graphRebuildKey/); + assert.doesNotMatch(htmlSource, /if\(firstOpen&&codeViewFiles\.length/); assert.match(htmlSource, /className:'code-sym-list'/); assert.doesNotMatch(htmlSource, /className:'code-sym-row'/); assert.match(htmlSource, /CODE_CARD_MAX/); From 8df3439662778f208340943b0c9696948e6907ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:43:28 +0000 Subject: [PATCH 09/33] Hide Code-card nodes and keep the leftover graph usable Open files now replace their circle nodes instead of burying them. Remaining nodes stay Graph-sized, are pushed out from under cards, and are no longer faded to nearly invisible. Co-authored-by: Braedon Saunders --- index.html | 88 +++++++++++++++++++++++++++++++++----- tests/code-canvas.test.mjs | 25 +++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index a292519..6dd9ca3 100644 --- a/index.html +++ b/index.html @@ -489,9 +489,8 @@ .code-card-body{flex:1;overflow:hidden;min-height:0;user-select:text} .code-card.clipped .code-card-body{overflow:auto;touch-action:pan-y} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} -.has-code-card .nc,.has-code-card .node-label{opacity:0!important;pointer-events:none} -.code-faded .nc{opacity:0.18!important} -.code-faded .node-label{opacity:0.35!important} +.has-code-card{display:none!important} +.has-code-card .nc,.has-code-card .node-label{pointer-events:none} .sym-mark{border-radius:3px;padding:0 1px;cursor:pointer} .sym-mark.fn{background:rgba(97,175,239,0.16)} .sym-mark.import{background:rgba(198,120,221,0.16)} @@ -5715,6 +5714,45 @@ return Math.min(8,1/zoom); } +function nodeReplacedByCard(path,cardPaths){ + return !!(cardPaths&&cardPaths.has(path)); +} + +function unburyNodesFromCards(nodes,cardPaths,sizesByPath,pad){ + pad=pad==null?36:pad; + var list=nodes||[]; + var cards=[]; + list.forEach(function(node){ + if(!node||!cardPaths||!cardPaths.has(node.id)||!isFinite(node.x)||!isFinite(node.y))return; + cards.push(node); + }); + if(!cards.length)return list; + var pass; + for(pass=0;pass<4;pass++){ + var moved=false; + list.forEach(function(node){ + if(!node||(cardPaths&&cardPaths.has(node.id))||!isFinite(node.x)||!isFinite(node.y))return; + cards.forEach(function(card){ + var size=(sizesByPath&&sizesByPath[card.id])||codeCardSize(null); + var hw=size.width/2+pad,hh=size.height/2+pad; + var dx=node.x-card.x,dy=node.y-card.y; + if(Math.abs(dx)>hw||Math.abs(dy)>hh)return; + var left=hw+dx,right=hw-dx,top=hh+dy,bottom=hh-dy; + var min=Math.min(left,right,top,bottom); + if(min===left)node.x=card.x-hw; + else if(min===right)node.x=card.x+hw; + else if(min===top)node.y=card.y-hh; + else node.y=card.y+hh; + if(node.fx!=null)node.fx=node.x; + if(node.fy!=null)node.fy=node.y; + moved=true; + }); + }); + if(!moved)break; + } + return list; +} + function githubZipDownloadUrl(owner,repo){ return 'https://github.com/'+encodeURIComponent(owner)+'/'+encodeURIComponent(repo)+'/archive/HEAD.zip'; } @@ -8285,6 +8323,22 @@ node.fx=node.x;node.fy=node.y; }); } + function placeRemainingCodeNodes(){ + var sim=simRef.current; + if(!sim)return; + unburyNodesFromCards(sim.nodes(),codeCardPathsRef.current,codeCardSizesRef.current); + refreshCodeCardForces(); + if(nodesRef.current){ + nodesRef.current.attr('transform',function(d){return'translate('+d.x+','+d.y+')';}); + } + if(linksRef.current){ + if(graphConfig.curvedLinks){ + linksRef.current.attr('d',function(d){var dx=d.target.x-d.source.x,dy=d.target.y-d.source.y,dr=Math.sqrt(dx*dx+dy*dy);return'M'+d.source.x+','+d.source.y+'A'+dr+','+dr+' 0 0,1 '+d.target.x+','+d.target.y;}); + }else{ + linksRef.current.attr('d',function(d){return'M'+d.source.x+','+d.source.y+'L'+d.target.x+','+d.target.y;}); + } + } + } function refreshCodeCardForces(){ var sim=simRef.current; @@ -8303,8 +8357,12 @@ }); } if(nodesRef.current){ - nodesRef.current.classed('has-code-card',function(d){return codeCardPathsRef.current.has(d.id);}); - nodesRef.current.classed('code-faded',function(d){return codeCardPathsRef.current.size>0&&!codeCardPathsRef.current.has(d.id);}); + var markScale=readableLabelScale((codeZoomTransformRef.current||{}).k); + nodesRef.current.classed('has-code-card',function(d){return nodeReplacedByCard(d.id,codeCardPathsRef.current);}); + nodesRef.current.classed('code-faded',false); + nodesRef.current.attr('display',function(d){return nodeReplacedByCard(d.id,codeCardPathsRef.current)?'none':null;}); + nodesRef.current.selectAll('circle.nc').attr('transform',function(d){return nodeReplacedByCard(d.id,codeCardPathsRef.current)?'':'scale('+markScale+')';}); + nodesRef.current.selectAll('text.node-label').attr('transform',function(d){return nodeReplacedByCard(d.id,codeCardPathsRef.current)?'':'scale('+markScale+')';}); } } @@ -8381,7 +8439,16 @@ function applyReadableLabels(k){ if(!keepReadable)return; var s=readableLabelScale(k); - nodeLayer.selectAll('text.node-label').attr('transform','scale('+s+')'); + nodeLayer.selectAll('g').each(function(d){ + var hidden=nodeReplacedByCard(d&&d.id,codeCardPathsRef.current); + var sel=d3.select(this); + sel.classed('has-code-card',hidden); + sel.classed('code-faded',false); + sel.attr('display',hidden?'none':null); + if(hidden)return; + sel.select('circle.nc').attr('transform','scale('+s+')'); + sel.select('text.node-label').attr('transform','scale('+s+')'); + }); hullLayer.selectAll('text.hull-label').attr('font-size',(11*s)+'px'); } var zoom=d3.zoom().scaleExtent([keepReadable?0.08:0.2,5]).on('zoom',function(e){ @@ -8466,8 +8533,8 @@ nodesRef.current=node; node.call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.1).restart();d.fx=d.x;d.fy=d.y;}).on('drag',function(e,d){d.fx=e.x;d.fy=e.y;}).on('end',function(e,d){if(!e.active)sim.alphaTarget(0);if(keepReadable){d.fx=d.x;d.fy=d.y;codeCardUserPinnedRef.current.add(d.id);}else{d.fx=null;d.fy=null;}})); node.on('click',function(e,d){e.stopPropagation();if(selectFileRef.current)selectFileRef.current(d.id);}); - node.classed('has-code-card',function(d){return keepReadable&&codeCardPathsRef.current.has(d.id);}); - node.classed('code-faded',function(d){return keepReadable&&codeCardPathsRef.current.size>0&&!codeCardPathsRef.current.has(d.id);}); + node.classed('has-code-card',function(d){return keepReadable&&nodeReplacedByCard(d.id,codeCardPathsRef.current);}); + node.attr('display',function(d){return keepReadable&&nodeReplacedByCard(d.id,codeCardPathsRef.current)?'none':null;}); node.on('mouseenter',function(e,d){if(keepReadable&&codeCardPathsRef.current.has(d.id))return;var r=svgRef.current.getBoundingClientRect();setTooltip({x:e.clientX-r.left+10,y:e.clientY-r.top,title:d.name,content:d.fnCount+' functions\n'+d.layer+' layer\n'+d.churn+' recent commits'});}).on('mouseleave',function(){setTooltip(null);}); svg.on('click',function(e){if(e.target===svgRef.current){if(keepReadable)return;setSelected(null);setBlastRadius(null);link.attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-opacity',0.4);node.selectAll('.nc').attr('opacity',1).attr('fill',getC);}}); node.append('circle').attr('class','nc').attr('r',getR).attr('fill',getC).attr('stroke',function(d){var c=d3.color(getC(d));return c?c.brighter(0.3):'#fff';}).attr('stroke-width',1.5); @@ -8529,8 +8596,9 @@ currentCodeCardSizes(); if(!codeViewCameraReadyRef.current)pinCodeCardsToFolderLayout(); else freezeNewCodeCardsInPlace(); - refreshCodeCardForces(); + placeRemainingCodeNodes(); syncCodeCards(); + applyReadableLabels((codeZoomTransformRef.current&&codeZoomTransformRef.current.k)||1); updateHulls(); if(shouldFitCodeCamera(codeViewCameraReadyRef.current,graphConfig.vizType)){ var fit=computeGraphFitTransform(); @@ -8556,7 +8624,7 @@ currentCodeCardSizes(); if(!codeViewCameraReadyRef.current)pinCodeCardsToFolderLayout(); else freezeNewCodeCardsInPlace(); - refreshCodeCardForces(); + placeRemainingCodeNodes(); syncCodeCards(); },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist]); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index a6c7ea0..b7da332 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -25,6 +25,27 @@ test('readable labels grow only when zoomed out', () => { assert.ok(context.readableLabelScale(0.1) > 2); }); +test('open Code cards replace their nodes', () => { + const cards = new Set(['src/app.js']); + assert.equal(context.nodeReplacedByCard('src/app.js', cards), true); + assert.equal(context.nodeReplacedByCard('src/math.js', cards), false); + assert.equal(context.nodeReplacedByCard('src/app.js', null), false); +}); + +test('remaining Code nodes are pushed out from under cards', () => { + const cards = new Set(['src/app.js']); + const sizes = { 'src/app.js': { width: 400, height: 200 } }; + const nodes = [ + { id: 'src/app.js', x: 100, y: 100 }, + { id: 'src/math.js', x: 110, y: 105 } + ]; + context.unburyNodesFromCards(nodes, cards, sizes, 20); + assert.equal(nodes[0].x, 100); + assert.equal(nodes[0].y, 100); + const buried = Math.abs(nodes[1].x - 100) <= 220 && Math.abs(nodes[1].y - 100) <= 120; + assert.equal(buried, false); +}); + test('connected files include both directions', () => { const paths = context.getConnectedFilePaths('src/app.js', [ { source: 'src/math.js', target: 'src/app.js', fn: 'add' }, @@ -417,6 +438,10 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /shouldFitCodeCamera/); assert.match(htmlSource, /codeViewCameraReadyRef/); assert.match(htmlSource, /graphRebuildKey/); + assert.match(htmlSource, /nodeReplacedByCard/); + assert.match(htmlSource, /unburyNodesFromCards/); + assert.match(htmlSource, /\.has-code-card\{display:none/); + assert.doesNotMatch(htmlSource, /\.code-faded \.nc\{opacity:0\.18/); assert.doesNotMatch(htmlSource, /if\(firstOpen&&codeViewFiles\.length/); assert.match(htmlSource, /className:'code-sym-list'/); assert.doesNotMatch(htmlSource, /className:'code-sym-row'/); From c9d2b806c7d14545da7e8fbbfbef4345b21fc806 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:43:59 +0000 Subject: [PATCH 10/33] Push leftover Code nodes fully outside card bounds Co-authored-by: Braedon Saunders --- index.html | 2 +- tests/code-canvas.test.mjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 6dd9ca3..7cfe04d 100644 --- a/index.html +++ b/index.html @@ -5736,7 +5736,7 @@ var size=(sizesByPath&&sizesByPath[card.id])||codeCardSize(null); var hw=size.width/2+pad,hh=size.height/2+pad; var dx=node.x-card.x,dy=node.y-card.y; - if(Math.abs(dx)>hw||Math.abs(dy)>hh)return; + if(Math.abs(dx)>=hw||Math.abs(dy)>=hh)return; var left=hw+dx,right=hw-dx,top=hh+dy,bottom=hh-dy; var min=Math.min(left,right,top,bottom); if(min===left)node.x=card.x-hw; diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index b7da332..d7fbd62 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -42,8 +42,9 @@ test('remaining Code nodes are pushed out from under cards', () => { context.unburyNodesFromCards(nodes, cards, sizes, 20); assert.equal(nodes[0].x, 100); assert.equal(nodes[0].y, 100); - const buried = Math.abs(nodes[1].x - 100) <= 220 && Math.abs(nodes[1].y - 100) <= 120; + const buried = Math.abs(nodes[1].x - 100) < 220 && Math.abs(nodes[1].y - 100) < 120; assert.equal(buried, false); + assert.ok(Math.abs(nodes[1].x - 100) >= 220 || Math.abs(nodes[1].y - 100) >= 120); }); test('connected files include both directions', () => { From 7a1c34ff4f18f75fe3e81ea93e3b063546ccc43b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 00:44:58 +0000 Subject: [PATCH 11/33] Keep right-rail subtab labels on one baseline Panel tabs are a nowrap flex row with a shared height. Count pills sit inline and no longer wrap or shift FILE / PATTERNS / SECURITY / ACTIONS off the same line. Co-authored-by: Braedon Saunders --- index.html | 13 ++++++++----- tests/code-canvas.test.mjs | 3 +++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 7cfe04d..da7ae73 100644 --- a/index.html +++ b/index.html @@ -138,10 +138,13 @@ .legend-color{width:12px;height:12px;border-radius:3px;flex-shrink:0} .right-panel{width:360px;min-width:280px;max-width:500px;background:var(--bg1);border-left:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;position:relative;min-height:0} .right-panel .resize-handle{right:auto;left:-4px} -.panel-tabs{display:flex;border-bottom:1px solid var(--border);background:var(--bg2)} -.panel-tab{flex:1;padding:8px;background:transparent;border:none;border-bottom:2px solid transparent;color:var(--t3);font-family:inherit;font-size:9px;font-weight:600;cursor:pointer} +.panel-tabs{display:flex;flex-wrap:nowrap;align-items:stretch;border-bottom:1px solid var(--border);background:var(--bg2)} +.panel-tab{flex:1 1 0;min-width:0;display:inline-flex;flex-direction:row;flex-wrap:nowrap;align-items:center;justify-content:center;gap:4px;height:34px;padding:0 4px;line-height:16px;white-space:nowrap;overflow:hidden;background:transparent;border:none;border-bottom:2px solid transparent;color:var(--t3);font-family:inherit;font-size:9px;font-weight:600;cursor:pointer} .panel-tab:hover{color:var(--t1)} .panel-tab.active{color:var(--acc);border-bottom-color:var(--acc)} +.panel-tab .icon{flex-shrink:0} +.panel-tab-pill{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;height:14px;min-width:14px;padding:0 5px;margin:0;border-radius:8px;font-size:8px;font-weight:600;line-height:14px;background:var(--bg3);color:var(--t2)} +.panel-tab-pill.alert{background:var(--red);color:#fff} .panel-content{flex:1;overflow-y:auto;padding:12px} .panel-content::-webkit-scrollbar{width:6px} .panel-content::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} @@ -10638,9 +10641,9 @@ data?React.createElement(React.Fragment,null, React.createElement('div',{className:'panel-tabs'}, React.createElement('button',{className:'panel-tab'+(rightTab==='details'?' active':''),onClick:function(){setRightTab('details');setDrillDown(null);}},selected?iconLabel('file','FILE'):(graphConfig.vizType==='architecture'?iconLabel('layers','ARCH'):iconLabel('search','ISSUES'))), - React.createElement('button',{className:'panel-tab'+(rightTab==='patterns'?' active':''),onClick:function(){setRightTab('patterns');setDrillDown(null);}},iconLabel('puzzle','PATTERNS'),' ',React.createElement('span',{className:'badge badge-default'},data.patterns.length)), - React.createElement('button',{className:'panel-tab'+(rightTab==='security'?' active':''),onClick:function(){setRightTab('security');setDrillDown(null);}},iconLabel('security','SECURITY'),data.stats.security>0&&React.createElement('span',{className:'view-mode-badge',style:{marginLeft:4}},data.stats.security)), - React.createElement('button',{className:'panel-tab'+(rightTab==='suggestions'?' active':''),onClick:function(){setRightTab('suggestions');setDrillDown(null);}},iconLabel('action','ACTIONS'),data.suggestions&&data.suggestions.length>0&&React.createElement('span',{className:'view-mode-badge',style:{marginLeft:4}},data.suggestions.length)) + React.createElement('button',{className:'panel-tab'+(rightTab==='patterns'?' active':''),onClick:function(){setRightTab('patterns');setDrillDown(null);}},iconLabel('puzzle','PATTERNS'),React.createElement('span',{className:'panel-tab-pill'},data.patterns.length)), + React.createElement('button',{className:'panel-tab'+(rightTab==='security'?' active':''),onClick:function(){setRightTab('security');setDrillDown(null);}},iconLabel('security','SECURITY'),data.stats.security>0&&React.createElement('span',{className:'panel-tab-pill alert'},data.stats.security)), + React.createElement('button',{className:'panel-tab'+(rightTab==='suggestions'?' active':''),onClick:function(){setRightTab('suggestions');setDrillDown(null);}},iconLabel('action','ACTIONS'),data.suggestions&&data.suggestions.length>0&&React.createElement('span',{className:'panel-tab-pill alert'},data.suggestions.length)) ), React.createElement('div',{className:'panel-content'}, rightTab==='details'&&(selected?React.createElement(React.Fragment,null, diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index d7fbd62..6c8ab3e 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -467,6 +467,9 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /onPointerDown/); assert.match(htmlSource, /__codeflow\/file\?path=/); assert.match(htmlSource, /The folder picker is faster when the API is rate-limited/); + assert.match(htmlSource, /className:'panel-tab-pill'/); + assert.match(htmlSource, /\.panel-tab\{[^}]*flex-wrap:nowrap/); + assert.match(htmlSource, /\.panel-tabs\{[^}]*flex-wrap:nowrap/); assert.match(htmlSource, /className:'sidebar-tabs'/); assert.match(htmlSource, /className:'sidebar-tab'/); assert.match(htmlSource, /useState\('overview'\),leftTab=/); From 719de10b4feac7a116247f3729ca058ee6a39e6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 01:00:09 +0000 Subject: [PATCH 12/33] Stop Code view from reshuffling cards on click Match codecanvas: accumulate opened files, append each card to its folder column, and keep existing cards pinned. Selection is overlay only. Wheel pans; Ctrl+wheel zooms about the cursor. Co-authored-by: Braedon Saunders --- index.html | 439 +++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 91 +++++++- 2 files changed, 458 insertions(+), 72 deletions(-) diff --git a/index.html b/index.html index da7ae73..c2e3942 100644 --- a/index.html +++ b/index.html @@ -482,7 +482,9 @@ .code-sym-chip.import{box-shadow:inset 3px 0 0 #c678dd} .code-sym-chip.export{box-shadow:inset 3px 0 0 #98c379} .code-sym-chip.var{box-shadow:inset 3px 0 0 #e06c75} -.code-card{position:absolute;border:1px solid var(--border);border-radius:8px;background:var(--bg0);overflow:hidden;display:flex;flex-direction:column;pointer-events:auto;box-shadow:0 10px 28px rgba(0,0,0,0.28);visibility:hidden;user-select:none} +.code-card{position:absolute;border:1px solid var(--border);border-radius:8px;background:var(--bg0);overflow:visible;display:flex;flex-direction:column;pointer-events:auto;box-shadow:0 10px 28px rgba(0,0,0,0.28);visibility:hidden;user-select:none} +.code-line-pill{position:absolute;left:100%;margin-left:8px;transform:translateY(-50%);z-index:2;font-size:8px;line-height:14px;padding:0 6px;border-radius:8px;border:1px solid var(--border);background:var(--bg1);color:var(--t1);white-space:nowrap;cursor:pointer;font-family:inherit} +.code-line-pill:hover,.code-line-pill.active{border-color:var(--acc);color:var(--acc);background:var(--accbg)} .code-card.primary{border-color:var(--acc);box-shadow:0 12px 32px rgba(0,0,0,0.34),0 0 0 1px var(--acc)} .code-card.linked{opacity:0.98} .code-card-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:var(--bg2);border-bottom:1px solid var(--border2);cursor:grab;touch-action:none;flex-shrink:0} @@ -492,6 +494,11 @@ .code-card-body{flex:1;overflow:hidden;min-height:0;user-select:text} .code-card.clipped .code-card-body{overflow:auto;touch-action:pan-y} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} +.code-card.code-far .code-card-body{overflow:hidden} +.code-card.code-far .file-preview-linenum{display:none} +.code-card.code-far .file-preview-line{min-height:3px;height:3px;overflow:hidden} +.code-card.code-far .file-preview-text{font-size:3px;line-height:3px;letter-spacing:-0.5px} +.code-card.code-far .code-line-pill{display:none} .has-code-card{display:none!important} .has-code-card .nc,.has-code-card .node-label{pointer-events:none} .sym-mark{border-radius:3px;padding:0 1px;cursor:pointer} @@ -5865,9 +5872,11 @@ return{lines:Math.max(1,lines.length),maxLineChars:maxLineChars}; } +var CODE_CARD_WIDTH=440; + function codeCardSize(file){ var metrics=codeCardContentMetrics(file); - var width=Math.max(CODE_CARD_MIN_WIDTH,Math.min(CODE_CARD_MAX_WIDTH,CODE_CARD_GUTTER+metrics.maxLineChars*CODE_CARD_CHAR_WIDTH)); + var width=CODE_CARD_WIDTH; var naturalHeight=CODE_CARD_HEAD_HEIGHT+CODE_CARD_BODY_PAD+metrics.lines*CODE_CARD_LINE_HEIGHT; var height=Math.max(CODE_CARD_MIN_HEIGHT,Math.min(CODE_CARD_MAX_HEIGHT,naturalHeight)); return{width:width,height:height,clipped:naturalHeight>CODE_CARD_MAX_HEIGHT}; @@ -5937,6 +5946,150 @@ return placed; } +function openCodeCardPaths(prev,path,limit){ + var list=(prev||[]).slice(); + if(!path)return list; + if(list.indexOf(path)>=0)return list; + var max=limit==null?CODE_CARD_MAX:Number(limit); + if(isFinite(max)&&list.length>=max)return list; + list.push(path); + return list; +} + +function filesForOpenedCodePaths(paths,data,folderFilter){ + if(!data||!data.files)return[]; + var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; + var byPath=Object.create(null); + filtered.forEach(function(file){byPath[file.path]=file;}); + return(paths||[]).map(function(path){return byPath[path];}).filter(Boolean); +} + +function appendCodeCardPlacement(placements,file,size,options){ + options=options||{}; + var gapX=options.gapX==null?88:options.gapX; + var gapY=options.gapY==null?36:options.gapY; + var originX=options.originX==null?80:options.originX; + var originY=options.originY==null?72:options.originY; + var next=Object.assign(Object.create(null),placements||{}); + if(!file||!file.path)return next; + size=size||codeCardSize(file); + var folder=file.folder||'root'; + var prev=next[file.path]; + if(prev){ + next[file.path]={ + left:prev.left, + top:prev.top, + x:prev.left+size.width/2, + y:prev.top+size.height/2, + width:size.width, + height:size.height, + folder:prev.folder||folder + }; + return next; + } + var same=[]; + var folderLeft=null; + Object.keys(next).forEach(function(id){ + var item=next[id]; + if(!item||item.folder!==folder)return; + same.push(item); + folderLeft=folderLeft==null?item.left:Math.min(folderLeft,item.left); + }); + var left,top; + if(!same.length){ + var maxRight=originX; + Object.keys(next).forEach(function(id){ + var item=next[id]; + if(!item)return; + maxRight=Math.max(maxRight,item.left+item.width+gapX); + }); + left=Object.keys(next).length?maxRight:originX; + top=originY; + }else{ + left=folderLeft; + var maxBottom=originY; + same.forEach(function(item){maxBottom=Math.max(maxBottom,item.top+item.height+gapY);}); + top=maxBottom; + } + next[file.path]={ + left:left, + top:top, + x:left+size.width/2, + y:top+size.height/2, + width:size.width, + height:size.height, + folder:folder + }; + return next; +} + +function codeCardSymbolLine(file,name){ + if(!file||!name)return null; + var fns=file.functions||[]; + var i; + for(i=0;i3){ moved=true; codeCardUserPinnedRef.current.add(file.path); } - node.fx=originX+dx;node.fy=originY+dy; + node.x=node.fx=originX+dx; + node.y=node.fy=originY+dy; + writePlacement(); syncCodeCards(); + redrawGraphLinksAndNodes(); } function onUp(){ window.removeEventListener('pointermove',onMove); window.removeEventListener('pointerup',onUp); window.removeEventListener('pointercancel',onUp); - sim.alphaTarget(0); var end=noteCodeCardPointerEnd(moved); if(end.ignoreNextClick){ codeCardIgnoreClickRef.current=true; @@ -10174,7 +10461,8 @@ } if(moved){ codeCardUserPinnedRef.current.add(file.path); - node.fx=node.x;node.fy=node.y; + node.x=node.fx;node.y=node.fy; + writePlacement(); } if(end.select)selectFile(file.path); } @@ -10203,18 +10491,19 @@ body=React.createElement('div',{className:'empty-desc',style:{padding:'12px'}},message); } var cardSize=codeCardSize(file); + var pills=isPrimary?codeCardSymbolPills(file,data?data.connections:[]):[]; return React.createElement('div',{key:file.path,'data-code-card':file.path,className:'code-card'+(isPrimary?' primary':' linked')+(cardSize.clipped?' clipped':''),onClick:function(e){ var taken=consumeCodeCardClick(codeCardIgnoreClickRef.current); codeCardIgnoreClickRef.current=taken.ignoreNextClick; if(taken.ignore){e.stopPropagation();return;} - if(selected&&selected.path!==file.path)selectFile(file.path); + if(!selected||selected.path!==file.path)selectFile(file.path); }}, React.createElement('div',{className:'code-card-head',onPointerDown:function(e){beginCodeCardDrag(e,file);}}, React.createElement('div',null, React.createElement('div',{className:'code-card-name'},file.name), React.createElement('div',{className:'code-card-path'},file.path) ), - React.createElement('span',{className:'badge badge-default'},isPrimary?'selected':'linked') + React.createElement('span',{className:'badge badge-default'},isPrimary?'selected':'open') ), React.createElement('div',{className:'code-card-body',onClick:function(e){ var mark=e.target.closest?e.target.closest('[data-sym]'):null; @@ -10223,18 +10512,30 @@ var name=mark.getAttribute('data-sym'); setActiveSymbol(function(prev){return prev===name?null:name;}); } - }},body) + }},body), + pills.map(function(pill){ + return React.createElement('button',{ + key:pill.name+':'+pill.line, + className:'code-line-pill '+pill.kind+(activeSymbol===pill.name?' active':''), + style:{top:pill.top+'px'}, + title:pill.name, + onClick:function(e){ + e.stopPropagation(); + setActiveSymbol(function(prev){return prev===pill.name?null:pill.name;}); + } + },pill.name); + }) ); } function renderCodeView(){ var primaryPath=selected&&selected.path; - return React.createElement('div',{className:'code-canvas'}, + return React.createElement('div',{className:'code-canvas',ref:codeCanvasRef}, React.createElement('svg',{ref:svgRef}), React.createElement('div',{className:'code-canvas-cards',ref:codeCardsLayerRef}, codeViewFiles.map(function(file){return renderCodeFileCard(file,file.path===primaryPath);}) ), React.createElement('div',{className:'code-canvas-hud'}, - React.createElement('div',{className:'code-canvas-hint'},codeViewFiles.length?(codeViewFileTotal>codeViewFiles.length?'Showing '+codeViewFiles.length+' of '+codeViewFileTotal+' connected files.':'File cards on the canvas. Click another node to shift the set.'):'No files to open as cards.'), + React.createElement('div',{className:'code-canvas-hint'},codeViewFiles.length?'Open files from leftover nodes or the Files tree. Open cards stay put. Wheel pans · Ctrl+wheel zooms.':'No files to open as cards.'), codeViewSymbols.length>0&&React.createElement('div',{className:'code-sym-list'}, codeViewSymbols.map(function(sym){ return React.createElement('button',{key:sym.name,className:'code-sym-chip '+sym.kind+(activeSymbol===sym.name?' active':''),title:sym.name,onClick:function(){setActiveSymbol(function(prev){return prev===sym.name?null:sym.name;});}},sym.name); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 6c8ab3e..265ffc9 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -155,20 +155,96 @@ test('a drag does not select the card on the leftover click', () => { assert.deepEqual(J(context.consumeCodeCardClick(false)), { ignore: false, ignoreNextClick: false }); }); -test('code cards size to the file instead of a fixed pane', () => { +test('code cards use a uniform width and grow with line count', () => { const empty = context.codeCardSize(null); - assert.equal(empty.width, context.CODE_CARD_MIN_WIDTH); + assert.equal(empty.width, context.CODE_CARD_WIDTH); assert.equal(empty.height, context.CODE_CARD_MIN_HEIGHT); assert.equal(empty.clipped, false); const short = context.codeCardSize({ content: 'const x = 1;\n' }); const tall = context.codeCardSize({ content: Array(40).fill('const value = 1;').join('\n') }); + assert.equal(short.width, context.CODE_CARD_WIDTH); + assert.equal(tall.width, context.CODE_CARD_WIDTH); assert.ok(tall.height > short.height); const huge = context.codeCardSize({ content: Array(400).fill('x'.repeat(120)).join('\n') }); - assert.equal(huge.width, context.CODE_CARD_MAX_WIDTH); + assert.equal(huge.width, context.CODE_CARD_WIDTH); assert.equal(huge.height, context.CODE_CARD_MAX_HEIGHT); assert.equal(huge.clipped, true); }); +test('opened code paths append without reshuffling the set', () => { + assert.deepEqual(J(context.openCodeCardPaths(['a.js'], 'b.js')), ['a.js', 'b.js']); + assert.deepEqual(J(context.openCodeCardPaths(['a.js', 'b.js'], 'a.js')), ['a.js', 'b.js']); + const capped = Array.from({ length: context.CODE_CARD_MAX }, (_, i) => 'f' + i + '.js'); + assert.deepEqual(J(context.openCodeCardPaths(capped, 'extra.js')), capped); +}); + +test('opened code files ignore the current selection', () => { + const data = { + files: [ + { path: 'a.js', folder: 'src' }, + { path: 'b.js', folder: 'src' }, + { path: 'c.js', folder: 'lib' } + ] + }; + const opened = context.filesForOpenedCodePaths(['a.js', 'c.js'], data, null); + assert.deepEqual(J(opened).map((f) => f.path), ['a.js', 'c.js']); + const still = context.filesForOpenedCodePaths(['a.js', 'c.js'], data, null); + assert.deepEqual(J(still).map((f) => f.path), ['a.js', 'c.js']); +}); + +test('appending a code card keeps existing cards in place', () => { + const a = { path: 'src/a.js', folder: 'src' }; + const b = { path: 'src/b.js', folder: 'src' }; + const c = { path: 'lib/c.js', folder: 'lib' }; + const size = { width: 440, height: 200 }; + const opts = { originX: 80, originY: 72, gapX: 88, gapY: 36 }; + let placed = context.appendCodeCardPlacement({}, a, size, opts); + const first = J(placed['src/a.js']); + placed = context.appendCodeCardPlacement(placed, b, size, opts); + assert.deepEqual(J(placed['src/a.js']), first); + assert.equal(placed['src/b.js'].left, first.left); + assert.ok(placed['src/b.js'].top >= first.top + first.height); + const beforeLib = J(placed['src/a.js']); + const beforeB = J(placed['src/b.js']); + placed = context.appendCodeCardPlacement(placed, c, size, opts); + assert.deepEqual(J(placed['src/a.js']), beforeLib); + assert.deepEqual(J(placed['src/b.js']), beforeB); + assert.ok(placed['lib/c.js'].left >= beforeLib.left + beforeLib.width); + assert.equal(placed['lib/c.js'].top, opts.originY); + const grown = context.appendCodeCardPlacement(placed, a, { width: 440, height: 400 }, opts); + assert.equal(grown['src/a.js'].left, first.left); + assert.equal(grown['src/a.js'].top, first.top); + assert.equal(grown['src/b.js'].top, beforeB.top); +}); + +test('line-level code edges use bezier anchors, not card centers', () => { + const file = { + path: 'src/a.js', + content: 'export function shared(){}\n', + functions: [{ name: 'shared', line: 1, isExported: true }] + }; + assert.equal(context.codeCardSymbolLine(file, 'shared'), 1); + const d = context.codeEdgeBezier(0, 10, 200, 40); + assert.match(d, /^M0,10C/); + assert.match(d, / 200,40$/); + const src = { id: 'src/a.js', x: 220, y: 200 }; + const tgt = { id: 'src/b.js', x: 800, y: 240 }; + const sizes = { + 'src/a.js': { width: 440, height: 200 }, + 'src/b.js': { width: 440, height: 200 } + }; + const files = { + 'src/a.js': file, + 'src/b.js': { path: 'src/b.js', content: 'import { shared } from "./a.js";\n', functions: [] } + }; + const cards = new Set(['src/a.js', 'src/b.js']); + const path = context.codeCardLinkPath({ source: src, target: tgt, fn: 'shared' }, sizes, files, cards); + assert.match(path, /^M/); + assert.ok(!path.includes(String(src.x) + ',' + String(src.y))); + assert.equal(context.codeViewWheelAction({ ctrlKey: true }), 'zoom'); + assert.equal(context.codeViewWheelAction({}), 'pan'); +}); + test('opened code cards auto-align by directory', () => { const files = [ { path: 'src/a.js', folder: 'src' }, @@ -435,6 +511,12 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /data-code-card/); assert.match(htmlSource, /applyCodeCardLayout/); assert.match(htmlSource, /layoutCodeCardsByFolder/); + assert.match(htmlSource, /appendCodeCardPlacement/); + assert.match(htmlSource, /openCodeCardPaths/); + assert.match(htmlSource, /filesForOpenedCodePaths/); + assert.match(htmlSource, /applyOpenedCardPlacements/); + assert.match(htmlSource, /codeCardLinkPath/); + assert.match(htmlSource, /code-line-pill/); assert.match(htmlSource, /defaultCodeViewSeed/); assert.match(htmlSource, /shouldFitCodeCamera/); assert.match(htmlSource, /codeViewCameraReadyRef/); @@ -447,7 +529,10 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /className:'code-sym-list'/); assert.doesNotMatch(htmlSource, /className:'code-sym-row'/); assert.match(htmlSource, /CODE_CARD_MAX/); + assert.match(htmlSource, /CODE_CARD_WIDTH/); assert.match(htmlSource, /consumeCodeCardClick/); + assert.match(htmlSource, /Open cards stay put/); + assert.doesNotMatch(htmlSource, /shift the set/); assert.match(htmlSource, /vizType==='code'/); assert.match(htmlSource, /readableLabelScale/); assert.match(htmlSource, /listRecentAnalyses/); From 2a64cca82672243607ceda5b30d313a80b10ba39 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 01:10:42 +0000 Subject: [PATCH 13/33] Fix Codex P1/P2s for Code view graph identity Rebuild when connection endpoints or weights change, reset the Code scene on a new analysis, seed cards if the selection is outside the folder filter, and allow horizontal scroll when a card is width-capped. Co-authored-by: Braedon Saunders --- index.html | 49 ++++++++++++++++++++++++++++++-------- tests/code-canvas.test.mjs | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index c2e3942..f83cee2 100644 --- a/index.html +++ b/index.html @@ -492,7 +492,7 @@ .code-card-name{font-size:11px;font-weight:600;color:var(--t0);transform-origin:left center;display:inline-block;white-space:nowrap} .code-card-path{font-size:9px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px} .code-card-body{flex:1;overflow:hidden;min-height:0;user-select:text} -.code-card.clipped .code-card-body{overflow:auto;touch-action:pan-y} +.code-card.clipped .code-card-body{overflow:auto;touch-action:pan-x pan-y} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} .code-card.code-far .code-card-body{overflow:hidden} .code-card.code-far .file-preview-linenum{display:none} @@ -5827,7 +5827,7 @@ if(!file||ordered.some(function(f){return f.path===path;}))return; ordered.push(file); } - if(!selectedPath)selectedPath=defaultCodeViewSeed(data,folderFilter); + if(!selectedPath||!byPath[selectedPath])selectedPath=defaultCodeViewSeed(data,folderFilter); if(selectedPath)add(selectedPath); getConnectedFilePaths(selectedPath,data.connections).slice().sort(function(a,b){ var loadedA=fileHasLoadedSource(byPath[a])?0:1; @@ -5874,12 +5874,18 @@ var CODE_CARD_WIDTH=440; +function codeCardNaturalWidth(metrics){ + metrics=metrics||{maxLineChars:0}; + return CODE_CARD_GUTTER+CODE_CARD_BODY_PAD+(metrics.maxLineChars||0)*CODE_CARD_CHAR_WIDTH; +} + function codeCardSize(file){ var metrics=codeCardContentMetrics(file); var width=CODE_CARD_WIDTH; + var naturalWidth=codeCardNaturalWidth(metrics); var naturalHeight=CODE_CARD_HEAD_HEIGHT+CODE_CARD_BODY_PAD+metrics.lines*CODE_CARD_LINE_HEIGHT; var height=Math.max(CODE_CARD_MIN_HEIGHT,Math.min(CODE_CARD_MAX_HEIGHT,naturalHeight)); - return{width:width,height:height,clipped:naturalHeight>CODE_CARD_MAX_HEIGHT}; + return{width:width,height:height,clipped:naturalHeight>CODE_CARD_MAX_HEIGHT||naturalWidth>width}; } function codeCardCollisionRadius(size){ @@ -6129,10 +6135,22 @@ var CODE_VIEW_MIN_FIT_SCALE=0.4; var CODE_VIEW_MAX_FIT_SCALE=1.15; +function connectionIdentity(connection){ + var src=connection&&(typeof connection.source==='object'?connection.source.id:connection.source); + var tgt=connection&&(typeof connection.target==='object'?connection.target.id:connection.target); + var count=connection&&connection.count!=null?connection.count:1; + return String(src||'')+'\t'+String(tgt||'')+'\t'+String((connection&&connection.fn)||'')+'\t'+String(count); +} + function graphStructureKey(data,folderFilter){ if(!data||!data.files)return ''; var paths=data.files.map(function(file){return file.path;}).join('\n'); - return String(folderFilter||'')+'\n'+paths+'\n'+(data.connections?data.connections.length:0); + var connections=(data.connections||[]).map(connectionIdentity).sort().join('\n'); + return String(folderFilter||'')+'\n'+paths+'\n'+connections; +} + +function codeViewSceneKey(data,folderFilter,vizType){ + return graphStructureKey(data,folderFilter)+'|'+String(vizType||''); } function preserveGraphNodeState(nodes,prevById){ @@ -8451,11 +8469,12 @@ var codeViewFiles=useMemo(function(){ if(!data)return[]; var paths=openedCodePaths; - if((!paths||!paths.length)&&graphConfig.vizType==='code'){ + var files=filesForOpenedCodePaths(paths,data,folderFilter); + if((!files.length)&&graphConfig.vizType==='code'){ var seed=defaultCodeViewSeed(data,folderFilter); - if(seed)paths=[seed]; + if(seed)files=filesForOpenedCodePaths([seed],data,folderFilter); } - return filesForOpenedCodePaths(paths,data,folderFilter); + return files; },[data,openedCodePaths,folderFilter,graphConfig.vizType]); useEffect(function(){ var scene=graphStructureKey(data,folderFilter); @@ -8565,11 +8584,21 @@ }); var keep=Object.create(null); codeViewFiles.forEach(function(file){keep[file.path]=true;}); - Object.keys(placements).forEach(function(path){if(!keep[path])delete placements[path];}); + var departed=Object.create(null); + Object.keys(placements).forEach(function(path){ + if(keep[path])return; + departed[path]=true; + delete placements[path]; + }); codeCardPlacementRef.current=placements; sim.nodes().forEach(function(node){ var pos=placements[node.id]; - if(!pos)return; + if(!pos){ + if(departed[node.id]&&!codeCardUserPinnedRef.current.has(node.id)){ + node.fx=null;node.fy=null; + } + return; + } if(codeCardUserPinnedRef.current.has(node.id)&&isFinite(node.fx)&&isFinite(node.fy)){ node.x=node.fx;node.y=node.fy; return; @@ -8670,7 +8699,7 @@ var centers={}; folders.forEach(function(f,i){centers[f]={x:(i%cols+1)*cw,y:(Math.floor(i/cols)+1)*ch};}); var keepReadable=graphConfig.vizType==='code'; - var sceneKey=(folderFilter||'')+'|'+graphConfig.vizType; + var sceneKey=codeViewSceneKey(data,folderFilter,graphConfig.vizType); if(sceneKey!==codeViewSceneRef.current){ codeViewSceneRef.current=sceneKey; codeViewCameraReadyRef.current=false; diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 265ffc9..4d11033 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -115,6 +115,24 @@ test('visible code files are not capped at four', () => { assert.equal(seeded.length, 6); }); +test('Code view seeds the filtered folder when the selection is outside it', () => { + const data = { + files: [ + { path: 'src/hub.js', folder: 'src', name: 'hub.js', functions: [] }, + { path: 'src/leaf.js', folder: 'src', name: 'leaf.js', functions: [] }, + { path: 'lib/out.js', folder: 'lib', name: 'out.js', functions: [] } + ], + connections: [ + { source: 'src/hub.js', target: 'src/leaf.js', fn: 'h' }, + { source: 'lib/out.js', target: 'src/hub.js', fn: 'o' } + ] + }; + const visible = context.collectVisibleCodeFiles('lib/out.js', data, 'src'); + assert.equal(visible[0].path, 'src/hub.js'); + assert.ok(visible.every((file) => file.folder === 'src')); + assert.equal(visible.some((file) => file.path === 'lib/out.js'), false); +}); + test('Code view seeds cards without waiting for a click', () => { const files = [ { path: 'leaf.js', folder: 'src', name: 'leaf.js', functions: [] }, @@ -169,6 +187,10 @@ test('code cards use a uniform width and grow with line count', () => { assert.equal(huge.width, context.CODE_CARD_WIDTH); assert.equal(huge.height, context.CODE_CARD_MAX_HEIGHT); assert.equal(huge.clipped, true); + const wide = context.codeCardSize({ content: 'x'.repeat(200) }); + assert.equal(wide.width, context.CODE_CARD_WIDTH); + assert.ok(wide.height < context.CODE_CARD_MAX_HEIGHT); + assert.equal(wide.clipped, true); }); test('opened code paths append without reshuffling the set', () => { @@ -292,6 +314,24 @@ test('hydrating file contents does not change the graph structure key', () => { }; assert.equal(context.graphStructureKey(before, null), context.graphStructureKey(after, null)); assert.notEqual(context.graphStructureKey(before, null), context.graphStructureKey(before, 'src')); + const swapped = { + files: [{ path: 'a.js' }, { path: 'b.js' }], + connections: [{ source: 'b.js', target: 'a.js' }] + }; + const heavier = { + files: [{ path: 'a.js' }, { path: 'b.js' }], + connections: [{ source: 'a.js', target: 'b.js', count: 4 }] + }; + assert.notEqual(context.graphStructureKey(before, null), context.graphStructureKey(swapped, null)); + assert.notEqual(context.graphStructureKey(before, null), context.graphStructureKey(heavier, null)); + assert.equal( + context.codeViewSceneKey(before, null, 'code'), + context.codeViewSceneKey(after, null, 'code') + ); + assert.notEqual( + context.codeViewSceneKey(before, null, 'code'), + context.codeViewSceneKey(swapped, null, 'code') + ); }); test('preserved graph nodes keep the user camera positions', () => { @@ -521,6 +561,10 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /shouldFitCodeCamera/); assert.match(htmlSource, /codeViewCameraReadyRef/); assert.match(htmlSource, /graphRebuildKey/); + assert.match(htmlSource, /connectionIdentity/); + assert.match(htmlSource, /codeViewSceneKey/); + assert.match(htmlSource, /naturalWidth>width/); + assert.match(htmlSource, /!byPath\[selectedPath\]/); assert.match(htmlSource, /nodeReplacedByCard/); assert.match(htmlSource, /unburyNodesFromCards/); assert.match(htmlSource, /\.has-code-card\{display:none/); From 1959a1117e9c7750fd7fabcd8b46a499dd950049 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:06:16 +0000 Subject: [PATCH 14/33] Keep Code graph keys and the 12-card cap honest Rebuild when folder, layer, churn, or function count changes, still ignoring hydrated source. Rejected 13th opens no longer select or fly. Co-authored-by: Braedon Saunders --- index.html | 23 +++++++++++++++++++---- tests/code-canvas.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index f83cee2..d86051d 100644 --- a/index.html +++ b/index.html @@ -5962,6 +5962,14 @@ return list; } +function resolveOpenCodeCard(prev,path,limit){ + var before=prev||[]; + var next=openCodeCardPaths(before,path,limit); + var already=!!(path&&before.indexOf(path)>=0); + var inserted=!!(path&&!already&&next.indexOf(path)>=0); + return{paths:next,already:already,inserted:inserted,opened:already||inserted}; +} + function filesForOpenedCodePaths(paths,data,folderFilter){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -6142,11 +6150,17 @@ return String(src||'')+'\t'+String(tgt||'')+'\t'+String((connection&&connection.fn)||'')+'\t'+String(count); } +function fileGraphIdentity(file){ + if(!file)return ''; + var fnCount=file.functions&&file.functions.length?file.functions.length:0; + return [file.path||'',file.name||'',file.folder||'',file.layer||'',file.churn||0,fnCount].join('\t'); +} + function graphStructureKey(data,folderFilter){ if(!data||!data.files)return ''; - var paths=data.files.map(function(file){return file.path;}).join('\n'); + var files=data.files.map(fileGraphIdentity).join('\n'); var connections=(data.connections||[]).map(connectionIdentity).sort().join('\n'); - return String(folderFilter||'')+'\n'+paths+'\n'+connections; + return String(folderFilter||'')+'\n'+files+'\n'+connections; } function codeViewSceneKey(data,folderFilter,vizType){ @@ -8270,9 +8284,10 @@ },[data,repoInfo,localSourceKind,isMobile,graphConfig.vizType]); selectFileRef.current=selectFile; function openCodeFile(path){ - var isNew=openedCodePaths.indexOf(path)<0; + var resolved=resolveOpenCodeCard(openedCodePaths,path); + if(!resolved.opened)return; setOpenedCodePaths(function(prev){return openCodeCardPaths(prev,path);}); - if(isNew)pendingFlyToRef.current=path; + if(resolved.inserted)pendingFlyToRef.current=path; selectFile(path); } openCodeFileRef.current=openCodeFile; diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 4d11033..a620dcd 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -198,6 +198,17 @@ test('opened code paths append without reshuffling the set', () => { assert.deepEqual(J(context.openCodeCardPaths(['a.js', 'b.js'], 'a.js')), ['a.js', 'b.js']); const capped = Array.from({ length: context.CODE_CARD_MAX }, (_, i) => 'f' + i + '.js'); assert.deepEqual(J(context.openCodeCardPaths(capped, 'extra.js')), capped); + const rejected = context.resolveOpenCodeCard(capped, 'extra.js'); + assert.equal(rejected.opened, false); + assert.equal(rejected.inserted, false); + assert.deepEqual(J(rejected.paths), capped); + const already = context.resolveOpenCodeCard(['a.js', 'b.js'], 'a.js'); + assert.equal(already.opened, true); + assert.equal(already.inserted, false); + const added = context.resolveOpenCodeCard(['a.js'], 'b.js'); + assert.equal(added.opened, true); + assert.equal(added.inserted, true); + assert.deepEqual(J(added.paths), ['a.js', 'b.js']); }); test('opened code files ignore the current selection', () => { @@ -332,6 +343,19 @@ test('hydrating file contents does not change the graph structure key', () => { context.codeViewSceneKey(before, null, 'code'), context.codeViewSceneKey(swapped, null, 'code') ); + const moved = { + files: [{ path: 'a.js', folder: 'src', layer: 'utils', churn: 0, functions: [] }, { path: 'b.js' }], + connections: [{ source: 'a.js', target: 'b.js' }] + }; + const restyled = { + files: [{ path: 'a.js', folder: 'lib', layer: 'ui', churn: 3, functions: [{ name: 'a' }] }, { path: 'b.js' }], + connections: [{ source: 'a.js', target: 'b.js' }] + }; + assert.notEqual(context.graphStructureKey(moved, null), context.graphStructureKey(restyled, null)); + assert.equal( + context.fileGraphIdentity({ path: 'a.js', content: 'x', folder: 'src', layer: 'utils', functions: [] }), + context.fileGraphIdentity({ path: 'a.js', content: 'y', folder: 'src', layer: 'utils', functions: [] }) + ); }); test('preserved graph nodes keep the user camera positions', () => { @@ -562,6 +586,8 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /codeViewCameraReadyRef/); assert.match(htmlSource, /graphRebuildKey/); assert.match(htmlSource, /connectionIdentity/); + assert.match(htmlSource, /fileGraphIdentity/); + assert.match(htmlSource, /resolveOpenCodeCard/); assert.match(htmlSource, /codeViewSceneKey/); assert.match(htmlSource, /naturalWidth>width/); assert.match(htmlSource, /!byPath\[selectedPath\]/); From 9d2cf01aa5938b7ae86c903e59317da57b57a1d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 12:15:27 +0000 Subject: [PATCH 15/33] Fix Code card overlap, duplicate fetches, and wheel pan Reflow unpinned cards in a folder when a hydrated card grows. Reuse in-flight source reads instead of restarting the whole set. Divide wheel pan by zoom so a flick moves the same screen distance. Co-authored-by: Braedon Saunders --- index.html | 85 ++++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 38 ++++++++++++++++- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index d86051d..85c1214 100644 --- a/index.html +++ b/index.html @@ -5999,7 +5999,7 @@ height:size.height, folder:prev.folder||folder }; - return next; + return reflowUnpinnedCodeCards(next,options.pinnedPaths,options); } var same=[]; var folderLeft=null; @@ -6034,6 +6034,51 @@ height:size.height, folder:folder }; + return reflowUnpinnedCodeCards(next,options.pinnedPaths,options); +} + +function codePathIsPinned(pinnedPaths,path){ + if(!path||!pinnedPaths)return false; + if(typeof pinnedPaths.has==='function')return pinnedPaths.has(path); + return !!pinnedPaths[path]; +} + +function reflowUnpinnedCodeCards(placements,pinnedPaths,options){ + options=options||{}; + var gapY=options.gapY==null?36:options.gapY; + var originY=options.originY==null?72:options.originY; + var next=placements||Object.create(null); + var byFolder=Object.create(null); + Object.keys(next).forEach(function(path){ + var item=next[path]; + if(!item)return; + var folder=item.folder||'root'; + if(!byFolder[folder])byFolder[folder]=[]; + byFolder[folder].push(path); + }); + Object.keys(byFolder).forEach(function(folder){ + var paths=byFolder[folder]; + paths.sort(function(a,b){ + var dy=(next[a].top||0)-(next[b].top||0); + if(dy)return dy; + return ab?1:0; + }); + var cursor=originY; + paths.forEach(function(path){ + var item=Object.assign({},next[path]); + next[path]=item; + if(codePathIsPinned(pinnedPaths,path)){ + item.x=item.left+item.width/2; + item.y=item.top+item.height/2; + cursor=Math.max(cursor,item.top+item.height+gapY); + return; + } + item.top=cursor; + item.x=item.left+item.width/2; + item.y=item.top+item.height/2; + cursor=item.top+item.height+gapY; + }); + }); return next; } @@ -6078,6 +6123,19 @@ return 'pan'; } +function codeViewWheelPanDelta(deltaX,deltaY,scale){ + var k=Number(scale); + if(!isFinite(k)||k<=0)k=1; + return{x:-(Number(deltaX)||0)/k,y:-(Number(deltaY)||0)/k}; +} + +function nextCodeSourceReads(neededPaths,inFlight){ + inFlight=inFlight||Object.create(null); + return(neededPaths||[]).filter(function(path){ + return !!path&&!inFlight[path]; + }); +} + function codeCardAnchorY(node,size,line){ size=size||codeCardSize(null); var y=line?codeCardLineY(size,line):CODE_CARD_HEAD_HEIGHT+(size.height-CODE_CARD_HEAD_HEIGHT)/2; @@ -7039,6 +7097,7 @@ var openCodeFileRef=useRef(null); var codeFilesByPathRef=useRef(Object.create(null)); var codeCanvasRef=useRef(null); + var codeSourceInFlightRef=useRef(Object.create(null)); var _openedCards=useState([]),openedCodePaths=_openedCards[0],setOpenedCodePaths=_openedCards[1]; var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); @@ -8496,6 +8555,7 @@ if(scene===openedSceneRef.current)return; openedSceneRef.current=scene; codeCardPlacementRef.current=Object.create(null); + codeSourceInFlightRef.current=Object.create(null); setOpenedCodePaths([]); pendingFlyToRef.current=null; },[data,folderFilter]); @@ -8522,7 +8582,8 @@ zoomRef.current.scaleBy(svg,factor,[e.clientX-rect.left,e.clientY-rect.top]); return; } - zoomRef.current.translateBy(svg,-e.deltaX,-e.deltaY); + var pan=codeViewWheelPanDelta(e.deltaX,e.deltaY,snapshotZoomTransform(codeZoomTransformRef.current).k); + zoomRef.current.translateBy(svg,pan.x,pan.y); } el.addEventListener('wheel',onWheel,{passive:false}); return function(){el.removeEventListener('wheel',onWheel);}; @@ -8595,7 +8656,9 @@ return; } } - placements=appendCodeCardPlacement(placements,file,sizes[file.path]||codeCardSize(file)); + placements=appendCodeCardPlacement(placements,file,sizes[file.path]||codeCardSize(file),{ + pinnedPaths:codeCardUserPinnedRef.current + }); }); var keep=Object.create(null); codeViewFiles.forEach(function(file){keep[file.path]=true;}); @@ -8605,6 +8668,7 @@ departed[path]=true; delete placements[path]; }); + placements=reflowUnpinnedCodeCards(placements,codeCardUserPinnedRef.current); codeCardPlacementRef.current=placements; sim.nodes().forEach(function(node){ var pos=placements[node.id]; @@ -8668,16 +8732,13 @@ useEffect(function(){ var missing=filesNeedingSource(codeViewFiles); if(!missing.length||!canReadLiveFileSource())return; - var cancelled=false; - Promise.all(missing.map(function(file){ - return readLiveFileSource(file.path).then(function(content){ - return {path:file.path,content:content}; - }); - })).then(function(updates){ - if(cancelled)return; - rememberHydratedSources(updates.filter(function(update){return update&&typeof update.content==='string';})); + var inflight=codeSourceInFlightRef.current; + nextCodeSourceReads(missing.map(function(file){return file.path;}),inflight).forEach(function(path){ + inflight[path]=true; + readLiveFileSource(path).then(function(content){ + if(typeof content==='string')rememberHydratedSources([{path:path,content:content}]); + }).then(function(){delete inflight[path];},function(){delete inflight[path];}); }); - return function(){cancelled=true;}; },[codeViewFiles,localSourceKind,cliStatus,repoInfo,localDirHandle]); useEffect(function(){ diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index a620dcd..c702401 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -247,7 +247,39 @@ test('appending a code card keeps existing cards in place', () => { const grown = context.appendCodeCardPlacement(placed, a, { width: 440, height: 400 }, opts); assert.equal(grown['src/a.js'].left, first.left); assert.equal(grown['src/a.js'].top, first.top); - assert.equal(grown['src/b.js'].top, beforeB.top); + assert.ok(grown['src/b.js'].top >= grown['src/a.js'].top + grown['src/a.js'].height); + assert.ok(grown['src/b.js'].top > beforeB.top); + const pinned = context.appendCodeCardPlacement(placed, a, { width: 440, height: 400 }, Object.assign({ pinnedPaths: { 'src/b.js': true } }, opts)); + assert.equal(pinned['src/b.js'].top, beforeB.top); +}); + +test('hydration height growth reflows unpinned cards in the same folder', () => { + const a = { path: 'src/a.js', folder: 'src' }; + const b = { path: 'src/b.js', folder: 'src' }; + const opts = { originY: 72, gapY: 36 }; + let placed = context.appendCodeCardPlacement({}, a, { width: 440, height: 160 }, opts); + placed = context.appendCodeCardPlacement(placed, b, { width: 440, height: 160 }, opts); + const beforeB = placed['src/b.js'].top; + placed = context.reflowUnpinnedCodeCards( + context.appendCodeCardPlacement(placed, a, { width: 440, height: 400 }, opts), + null, + opts + ); + assert.equal(placed['src/a.js'].top, 72); + assert.equal(placed['src/b.js'].top, 72 + 400 + 36); + assert.ok(placed['src/b.js'].top > beforeB); +}); + +test('source reads skip paths that are already in flight', () => { + assert.deepEqual(J(context.nextCodeSourceReads(['a.js', 'b.js', 'a.js'], { 'a.js': true })), ['b.js']); + assert.deepEqual(J(context.nextCodeSourceReads(['a.js'], { 'a.js': true })), []); +}); + +test('wheel pan deltas stay screen-pixel based across zoom', () => { + assert.deepEqual(J(context.codeViewWheelPanDelta(40, 80, 1)), { x: -40, y: -80 }); + assert.deepEqual(J(context.codeViewWheelPanDelta(40, 80, 2)), { x: -20, y: -40 }); + assert.deepEqual(J(context.codeViewWheelPanDelta(40, 80, 0.5)), { x: -80, y: -160 }); + assert.equal(context.codeViewWheelAction({}), 'pan'); }); test('line-level code edges use bezier anchors, not card centers', () => { @@ -576,6 +608,10 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /applyCodeCardLayout/); assert.match(htmlSource, /layoutCodeCardsByFolder/); assert.match(htmlSource, /appendCodeCardPlacement/); + assert.match(htmlSource, /reflowUnpinnedCodeCards/); + assert.match(htmlSource, /nextCodeSourceReads/); + assert.match(htmlSource, /codeViewWheelPanDelta/); + assert.match(htmlSource, /codeSourceInFlightRef/); assert.match(htmlSource, /openCodeCardPaths/); assert.match(htmlSource, /filesForOpenedCodePaths/); assert.match(htmlSource, /applyOpenedCardPlacements/); From a1a14be3414cb125bb31b3f2c3c9c3012a73fe23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 14:05:41 +0000 Subject: [PATCH 16/33] Drop stale hydration and keep Code pills on the scrolled line Stamp each source read with the analysis identity and ignore responses after a repo switch. Symbol pills follow the card body scroll and hide when their line is clipped away. Co-authored-by: Braedon Saunders --- index.html | 46 ++++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 26 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 85c1214..5855a00 100644 --- a/index.html +++ b/index.html @@ -6225,6 +6225,17 @@ return graphStructureKey(data,folderFilter)+'|'+String(vizType||''); } +function analysisHydrationId(source,data,folderFilter){ + source=source||{}; + return [source.sourceType||'',source.sourceKey||'',graphStructureKey(data,folderFilter)].join('\0'); +} + +function hydratedSourceIsCurrent(update,currentId){ + if(!update||!update.path||typeof update.content!=='string')return false; + if(update.hydrationId==null||currentId==null)return true; + return update.hydrationId===currentId; +} + function preserveGraphNodeState(nodes,prevById){ (nodes||[]).forEach(function(node){ var prev=prevById&&prevById[node.id]; @@ -6377,6 +6388,15 @@ }).filter(Boolean); } +function codeCardPillViewTop(lineTop,scrollTop,cardHeight,headHeight){ + var y=Number(lineTop)-(Number(scrollTop)||0); + var min=headHeight==null?CODE_CARD_HEAD_HEIGHT:Number(headHeight); + var max=Number(cardHeight); + if(!isFinite(y)||!isFinite(min)||!isFinite(max))return null; + if(ymax)return null; + return y; +} + function collectCrossFileSymbols(files,connections){ var byName=Object.create(null); (files||[]).forEach(function(file){ @@ -6475,11 +6495,12 @@ return (files||[]).filter(analysisFileNeedsSource); } -function mergeHydratedFileSources(data,updates){ +function mergeHydratedFileSources(data,updates,currentId){ if(!data||!data.files||!updates||!updates.length)return data; var byPath=Object.create(null); updates.forEach(function(update){ - if(update&&update.path&&typeof update.content==='string')byPath[update.path]=update.content; + if(!hydratedSourceIsCurrent(update,currentId))return; + byPath[update.path]=update.content; }); var changed=false; var files=data.files.map(function(file){ @@ -7098,7 +7119,9 @@ var codeFilesByPathRef=useRef(Object.create(null)); var codeCanvasRef=useRef(null); var codeSourceInFlightRef=useRef(Object.create(null)); + var analysisHydrationIdRef=useRef(''); var _openedCards=useState([]),openedCodePaths=_openedCards[0],setOpenedCodePaths=_openedCards[1]; + var _pillScroll=useState(0),codePillScroll=_pillScroll[0],setCodePillScroll=_pillScroll[1]; var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); var zipArchiveRef=useRef(null); @@ -7304,6 +7327,7 @@ if(parsed)return{sourceType:'github',sourceKey:githubCacheSourceKey(parsed.owner,parsed.repo,activeExcludePatterns),title:parsed.owner+'/'+parsed.repo,repoUrl:parsed.owner+'/'+parsed.repo,localSourceKind:null}; return null; } + analysisHydrationIdRef.current=analysisHydrationId(currentAnalysisSource(),data,folderFilter); function refreshRecentList(){ listRecentAnalyses().then(function(rows){ @@ -8478,7 +8502,7 @@ function rememberHydratedSources(updates){ if(!updates||!updates.length)return; - setData(function(prev){return mergeHydratedFileSources(prev,updates);}); + setData(function(prev){return mergeHydratedFileSources(prev,updates,analysisHydrationIdRef.current);}); } // Open file preview @@ -8495,10 +8519,12 @@ setFilePreview({path:path,filename:filename,content:existingFile.content,line:line||null,loading:false,error:null}); return; } + var previewId=analysisHydrationIdRef.current; readLiveFileSource(path).then(function(content){ + if(analysisHydrationIdRef.current!==previewId)return; if(typeof content==='string'){ setFilePreview({path:path,filename:filename,content:content,line:line||null,loading:false,error:null}); - rememberHydratedSources([{path:path,content:content}]); + rememberHydratedSources([{path:path,content:content,hydrationId:previewId}]); return; } setFilePreview({path:path,filename:filename,content:null,line:line||null,loading:false,error:canReadLiveFileSource()?'Could not load file content':'Reopen this project to preview source'}); @@ -8588,6 +8614,9 @@ el.addEventListener('wheel',onWheel,{passive:false}); return function(){el.removeEventListener('wheel',onWheel);}; },[graphConfig.vizType]); + useEffect(function(){ + setCodePillScroll(0); + },[selected&&selected.path]); var hadAnalysisRef=useRef(false); useEffect(function(){ if(data&&!hadAnalysisRef.current)setLeftTab('overview'); @@ -8733,10 +8762,11 @@ var missing=filesNeedingSource(codeViewFiles); if(!missing.length||!canReadLiveFileSource())return; var inflight=codeSourceInFlightRef.current; + var hydrationId=analysisHydrationIdRef.current; nextCodeSourceReads(missing.map(function(file){return file.path;}),inflight).forEach(function(path){ inflight[path]=true; readLiveFileSource(path).then(function(content){ - if(typeof content==='string')rememberHydratedSources([{path:path,content:content}]); + if(typeof content==='string')rememberHydratedSources([{path:path,content:content,hydrationId:hydrationId}]); }).then(function(){delete inflight[path];},function(){delete inflight[path];}); }); },[codeViewFiles,localSourceKind,cliStatus,repoInfo,localDirHandle]); @@ -10617,12 +10647,14 @@ var name=mark.getAttribute('data-sym'); setActiveSymbol(function(prev){return prev===name?null:name;}); } - }},body), + },onScroll:isPrimary&&cardSize.clipped?function(e){setCodePillScroll(e.currentTarget.scrollTop);}:undefined},body), pills.map(function(pill){ + var top=codeCardPillViewTop(pill.top,isPrimary?codePillScroll:0,cardSize.height,CODE_CARD_HEAD_HEIGHT); + if(top==null)return null; return React.createElement('button',{ key:pill.name+':'+pill.line, className:'code-line-pill '+pill.kind+(activeSymbol===pill.name?' active':''), - style:{top:pill.top+'px'}, + style:{top:top+'px'}, title:pill.name, onClick:function(e){ e.stopPropagation(); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index c702401..3ffeb92 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -583,6 +583,29 @@ test('empty source files count as loaded after hydration', () => { assert.equal(context.fileSourceDisplayState(merged.files[0], true), 'ready'); }); +test('obsolete hydration results do not replace a new analysis file', () => { + const repoA = { sourceType: 'github', sourceKey: 'owner/alpha' }; + const repoB = { sourceType: 'github', sourceKey: 'owner/beta' }; + const data = { files: [{ path: 'src/index.js', name: 'index.js' }], connections: [] }; + const idA = context.analysisHydrationId(repoA, data, null); + const idB = context.analysisHydrationId(repoB, data, null); + assert.notEqual(idA, idB); + assert.equal(context.hydratedSourceIsCurrent({ path: 'src/index.js', content: 'A', hydrationId: idA }, idB), false); + const rejected = context.mergeHydratedFileSources(data, [{ path: 'src/index.js', content: 'from-alpha', hydrationId: idA }], idB); + assert.equal(Object.prototype.hasOwnProperty.call(rejected.files[0], 'content'), false); + const accepted = context.mergeHydratedFileSources(data, [{ path: 'src/index.js', content: 'from-beta', hydrationId: idB }], idB); + assert.equal(accepted.files[0].content, 'from-beta'); +}); + +test('symbol pills track scroll and hide when clipped away', () => { + const visible = context.codeCardPillViewTop(80, 0, 400, 42); + assert.equal(visible, 80); + assert.equal(context.codeCardPillViewTop(80, 20, 400, 42), 60); + assert.equal(context.codeCardPillViewTop(2000, 0, 1840, 42), null); + assert.equal(context.codeCardPillViewTop(2000, 1600, 1840, 42), 400); + assert.equal(context.codeCardPillViewTop(30, 0, 400, 42), null); +}); + test('empty code cards always render from an array of lines', () => { assert.deepEqual(J(context.asCodeLines('')), ['']); assert.deepEqual(J(context.asCodeLines(null)), ['']); @@ -612,6 +635,9 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /nextCodeSourceReads/); assert.match(htmlSource, /codeViewWheelPanDelta/); assert.match(htmlSource, /codeSourceInFlightRef/); + assert.match(htmlSource, /analysisHydrationId/); + assert.match(htmlSource, /hydratedSourceIsCurrent/); + assert.match(htmlSource, /codeCardPillViewTop/); assert.match(htmlSource, /openCodeCardPaths/); assert.match(htmlSource, /filesForOpenedCodePaths/); assert.match(htmlSource, /applyOpenedCardPlacements/); From 6529c869f5346f4874c609a4679e544544300cb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 20:31:36 +0000 Subject: [PATCH 17/33] Sync pill scroll on primary change and let folder frames pass clicks Read the live card body scrollTop when a clipped linked card becomes primary, and treat folder hulls as canvas background so empty-frame clicks can deselect. Co-authored-by: Braedon Saunders --- index.html | 33 +++++++++++++++++++++---- tests/code-canvas.test.mjs | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 5855a00..d3c2169 100644 --- a/index.html +++ b/index.html @@ -6397,6 +6397,25 @@ return y; } +function readCodeCardBodyScroll(layer,path){ + if(!layer||!path)return 0; + var cards=layer.querySelectorAll?layer.querySelectorAll('[data-code-card]'):[]; + for(var i=0;i { assert.equal(context.codeCardPillViewTop(30, 0, 400, 42), null); }); +test('selection reads the live body scrollTop of a previously scrolled card', () => { + const layer = { + querySelectorAll(sel) { + if (sel !== '[data-code-card]') return []; + return [ + { + getAttribute(name) { return name === 'data-code-card' ? 'src/other.js' : null; }, + querySelector() { return { scrollTop: 999 }; } + }, + { + getAttribute(name) { return name === 'data-code-card' ? 'src/app.js' : null; }, + querySelector(sel) { return sel === '.code-card-body' ? { scrollTop: 240 } : null; } + } + ]; + } + }; + assert.equal(context.readCodeCardBodyScroll(layer, 'src/app.js'), 240); + assert.equal(context.readCodeCardBodyScroll(layer, 'src/missing.js'), 0); + assert.equal(context.readCodeCardBodyScroll(null, 'src/app.js'), 0); + assert.equal(context.readCodeCardBodyScroll(layer, ''), 0); +}); + +test('folder frames count as canvas background for deselect', () => { + const svg = { id: 'svg' }; + const hull = { + getAttribute(name) { return name === 'data-code-bg' ? '1' : null; }, + closest(sel) { return sel === '[data-code-bg="1"]' ? this : null; } + }; + const hullChild = { + getAttribute() { return null; }, + closest(sel) { return sel === '[data-code-bg="1"]' ? hull : null; } + }; + const node = { + getAttribute() { return null; }, + closest() { return null; } + }; + assert.equal(context.isCodeCanvasDeselectTarget(svg, svg), true); + assert.equal(context.isCodeCanvasDeselectTarget(hull, svg), true); + assert.equal(context.isCodeCanvasDeselectTarget(hullChild, svg), true); + assert.equal(context.isCodeCanvasDeselectTarget(node, svg), false); + assert.equal(context.isCodeCanvasDeselectTarget(null, svg), false); + assert.equal(context.isCodeCanvasDeselectTarget(svg, null), false); +}); + test('empty code cards always render from an array of lines', () => { assert.deepEqual(J(context.asCodeLines('')), ['']); assert.deepEqual(J(context.asCodeLines(null)), ['']); @@ -638,6 +682,12 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /analysisHydrationId/); assert.match(htmlSource, /hydratedSourceIsCurrent/); assert.match(htmlSource, /codeCardPillViewTop/); + assert.match(htmlSource, /readCodeCardBodyScroll/); + assert.match(htmlSource, /readCodeCardBodyScroll\(codeCardsLayerRef\.current/); + assert.match(htmlSource, /isCodeCanvasDeselectTarget/); + assert.match(htmlSource, /isCodeCanvasDeselectTarget\(e\.target/); + assert.match(htmlSource, /data-code-bg/); + assert.match(htmlSource, /attr\('pointer-events','none'\)/); assert.match(htmlSource, /openCodeCardPaths/); assert.match(htmlSource, /filesForOpenedCodePaths/); assert.match(htmlSource, /applyOpenedCardPlacements/); From 3d56efe863f48f06e2aa4a10510cc685a1f3ba57 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 22:04:05 +0000 Subject: [PATCH 18/33] Seed Code from the current selection and anchor mixed card edges Open the in-filter selected file when entering Code, and attach leftover-node links at the card boundary instead of hiding them under the card center. Co-authored-by: Braedon Saunders --- index.html | 43 +++++++++++++++++++++++++++++--------- tests/code-canvas.test.mjs | 21 +++++++++++++++++-- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/index.html b/index.html index d3c2169..c8582c5 100644 --- a/index.html +++ b/index.html @@ -5816,6 +5816,18 @@ return best.path; } +function codeViewSeedPath(selectedPath,data,folderFilter){ + if(!data||!data.files||!data.files.length)return null; + if(selectedPath){ + var selected=null; + for(var i=0;i { { source: 'hub.js', target: 'leaf.js', fn: 'h' }, { source: 'hub.js', target: 'other.js', fn: 'h' } ]; - assert.equal(context.defaultCodeViewSeed({ files, connections }, 'src'), 'hub.js'); - const visible = context.collectVisibleCodeFiles(null, { files, connections }, 'src'); + const data = { files, connections }; + assert.equal(context.defaultCodeViewSeed(data, 'src'), 'hub.js'); + assert.equal(context.codeViewSeedPath(null, data, 'src'), 'hub.js'); + assert.equal(context.codeViewSeedPath('leaf.js', data, 'src'), 'leaf.js'); + assert.equal(context.codeViewSeedPath('other.js', data, 'src'), 'hub.js'); + const visible = context.collectVisibleCodeFiles(null, data, 'src'); assert.deepEqual(J(visible).map((f) => f.path), ['hub.js', 'leaf.js']); assert.equal(context.defaultCodeViewSeed({ files: [], connections: [] }, null), null); + assert.equal(context.codeViewSeedPath('leaf.js', { files: [], connections: [] }, null), null); }); test('high-degree neighborhoods stay within the card cap', () => { @@ -306,6 +311,15 @@ test('line-level code edges use bezier anchors, not card centers', () => { const path = context.codeCardLinkPath({ source: src, target: tgt, fn: 'shared' }, sizes, files, cards); assert.match(path, /^M/); assert.ok(!path.includes(String(src.x) + ',' + String(src.y))); + const leftover = { id: 'src/c.js', x: 1100, y: 260 }; + const fromCard = context.codeCardLinkPath({ source: src, target: leftover, fn: 'shared' }, sizes, files, new Set(['src/a.js'])); + assert.match(fromCard, /^M440,/); + assert.match(fromCard, / 1100,260$/); + assert.ok(!fromCard.includes('220,200')); + const toCard = context.codeCardLinkPath({ source: leftover, target: tgt, fn: 'shared' }, sizes, files, new Set(['src/b.js'])); + assert.match(toCard, /^M1100,260C/); + assert.ok(!toCard.includes('800,240')); + assert.equal(context.codeCardLinkPath({ source: src, target: leftover, fn: 'shared' }, sizes, files, new Set()), null); assert.equal(context.codeViewWheelAction({ ctrlKey: true }), 'zoom'); assert.equal(context.codeViewWheelAction({}), 'pan'); }); @@ -692,8 +706,11 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /filesForOpenedCodePaths/); assert.match(htmlSource, /applyOpenedCardPlacements/); assert.match(htmlSource, /codeCardLinkPath/); + assert.match(htmlSource, /codeCardLinkEndpoint/); assert.match(htmlSource, /code-line-pill/); assert.match(htmlSource, /defaultCodeViewSeed/); + assert.match(htmlSource, /codeViewSeedPath/); + assert.match(htmlSource, /codeViewSeedPath\(selected&&selected\.path/); assert.match(htmlSource, /shouldFitCodeCamera/); assert.match(htmlSource, /codeViewCameraReadyRef/); assert.match(htmlSource, /graphRebuildKey/); From 89260b98d63f0e184c075f470987037c16a179db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 00:05:00 +0000 Subject: [PATCH 19/33] Open the current selection when returning to Code and orient reverse edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-entering Code now appends the in-filter selection without dropping older cards, and Bézier controls follow the sign of the target so leftward dependencies keep a leftward tangent. Co-authored-by: Braedon Saunders --- index.html | 32 ++++++++++++++++++++++++++----- tests/code-canvas.test.mjs | 39 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index c8582c5..167fa7a 100644 --- a/index.html +++ b/index.html @@ -5828,6 +5828,12 @@ return defaultCodeViewSeed(data,folderFilter); } +function shouldSeedOpenedCodeCards(inCode,sessionActive,openedPaths){ + if(!inCode)return false; + if(!sessionActive)return true; + return !(openedPaths&&openedPaths.length); +} + function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -5982,6 +5988,13 @@ return{paths:next,already:already,inserted:inserted,opened:already||inserted}; } +function ensureCodeViewOpenedPaths(openedPaths,selectedPath,data,folderFilter){ + var seed=codeViewSeedPath(selectedPath,data,folderFilter); + if(!seed)return{paths:openedPaths||[],seed:null,opened:false,inserted:false}; + var resolved=resolveOpenCodeCard(openedPaths,seed); + return{paths:resolved.paths,seed:seed,opened:resolved.opened,inserted:resolved.inserted}; +} + function filesForOpenedCodePaths(paths,data,folderFilter){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -6118,7 +6131,8 @@ } function codeEdgeBezier(x1,y1,x2,y2){ - var dx=Math.max(80,Math.abs(x2-x1)*0.45); + var span=Math.max(80,Math.abs(x2-x1)*0.45); + var dx=(x2 { assert.equal(context.codeViewSeedPath('leaf.js', { files: [], connections: [] }, null), null); }); +test('returning to Code opens the current selection without dropping old cards', () => { + const data = { + files: [ + { path: 'leaf.js', folder: 'src', name: 'leaf.js', functions: [] }, + { path: 'hub.js', folder: 'src', name: 'hub.js', functions: [] }, + { path: 'other.js', folder: 'src', name: 'other.js', functions: [] } + ], + connections: [ + { source: 'hub.js', target: 'leaf.js', fn: 'h' }, + { source: 'hub.js', target: 'other.js', fn: 'h' } + ] + }; + assert.equal(context.shouldSeedOpenedCodeCards(false, false, ['hub.js']), false); + assert.equal(context.shouldSeedOpenedCodeCards(true, false, ['hub.js']), true); + assert.equal(context.shouldSeedOpenedCodeCards(true, true, ['hub.js']), false); + assert.equal(context.shouldSeedOpenedCodeCards(true, true, []), true); + const reenter = context.ensureCodeViewOpenedPaths(['hub.js'], 'leaf.js', data, 'src'); + assert.deepEqual(J(reenter.paths), ['hub.js', 'leaf.js']); + assert.equal(reenter.seed, 'leaf.js'); + assert.equal(reenter.inserted, true); + assert.equal(reenter.opened, true); + const already = context.ensureCodeViewOpenedPaths(['hub.js', 'leaf.js'], 'leaf.js', data, 'src'); + assert.deepEqual(J(already.paths), ['hub.js', 'leaf.js']); + assert.equal(already.inserted, false); + assert.equal(already.opened, true); + const empty = context.ensureCodeViewOpenedPaths([], null, data, 'src'); + assert.deepEqual(J(empty.paths), ['hub.js']); + assert.equal(empty.seed, 'hub.js'); +}); + test('high-degree neighborhoods stay within the card cap', () => { const files = Array.from({ length: 30 }, (_, i) => ({ path: i === 0 ? 'hub.js' : 'n' + i + '.js', @@ -295,8 +325,9 @@ test('line-level code edges use bezier anchors, not card centers', () => { }; assert.equal(context.codeCardSymbolLine(file, 'shared'), 1); const d = context.codeEdgeBezier(0, 10, 200, 40); - assert.match(d, /^M0,10C/); - assert.match(d, / 200,40$/); + assert.equal(d, 'M0,10C90,10 110,40 200,40'); + const reverse = context.codeEdgeBezier(200, 10, 0, 40); + assert.equal(reverse, 'M200,10C110,10 90,40 0,40'); const src = { id: 'src/a.js', x: 220, y: 200 }; const tgt = { id: 'src/b.js', x: 800, y: 240 }; const sizes = { @@ -711,6 +742,10 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /defaultCodeViewSeed/); assert.match(htmlSource, /codeViewSeedPath/); assert.match(htmlSource, /codeViewSeedPath\(selected&&selected\.path/); + assert.match(htmlSource, /shouldSeedOpenedCodeCards/); + assert.match(htmlSource, /ensureCodeViewOpenedPaths/); + assert.match(htmlSource, /codeViewSessionRef/); + assert.match(htmlSource, /x2 Date: Wed, 19 Aug 2026 02:08:06 +0000 Subject: [PATCH 20/33] Reset Code scene by analysis identity, drag in screen pixels, and evict at the cap Clear in-flight source reads when the analysis/source identity changes, measure card-drag activation in client pixels, and replace the oldest card when re-entering Code at the 12-card limit. Co-authored-by: Braedon Saunders --- index.html | 61 +++++++++++++++++++++++++------------- tests/code-canvas.test.mjs | 40 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 167fa7a..d0415f9 100644 --- a/index.html +++ b/index.html @@ -5869,6 +5869,22 @@ return {ignore:!!ignoreNextClick,ignoreNextClick:false}; } +function codeCardDragDelta(clientX,clientY,startX,startY,scale,threshold){ + var screenX=(Number(clientX)||0)-(Number(startX)||0); + var screenY=(Number(clientY)||0)-(Number(startY)||0); + var k=Number(scale); + if(!isFinite(k)||k<=0)k=1; + var limit=threshold==null?3:Number(threshold); + if(!isFinite(limit))limit=3; + return{ + x:screenX/k, + y:screenY/k, + screenX:screenX, + screenY:screenY, + moved:Math.abs(screenX)+Math.abs(screenY)>limit + }; +} + var CODE_CARD_MIN_WIDTH=320; var CODE_CARD_MAX_WIDTH=720; var CODE_CARD_MIN_HEIGHT=160; @@ -5970,19 +5986,22 @@ return placed; } -function openCodeCardPaths(prev,path,limit){ +function openCodeCardPaths(prev,path,limit,replace){ var list=(prev||[]).slice(); if(!path)return list; if(list.indexOf(path)>=0)return list; var max=limit==null?CODE_CARD_MAX:Number(limit); - if(isFinite(max)&&list.length>=max)return list; + if(isFinite(max)&&list.length>=max){ + if(!replace)return list; + list=list.slice(Math.max(0,list.length-max+1)); + } list.push(path); return list; } -function resolveOpenCodeCard(prev,path,limit){ +function resolveOpenCodeCard(prev,path,limit,replace){ var before=prev||[]; - var next=openCodeCardPaths(before,path,limit); + var next=openCodeCardPaths(before,path,limit,replace); var already=!!(path&&before.indexOf(path)>=0); var inserted=!!(path&&!already&&next.indexOf(path)>=0); return{paths:next,already:already,inserted:inserted,opened:already||inserted}; @@ -5991,7 +6010,7 @@ function ensureCodeViewOpenedPaths(openedPaths,selectedPath,data,folderFilter){ var seed=codeViewSeedPath(selectedPath,data,folderFilter); if(!seed)return{paths:openedPaths||[],seed:null,opened:false,inserted:false}; - var resolved=resolveOpenCodeCard(openedPaths,seed); + var resolved=resolveOpenCodeCard(openedPaths,seed,null,true); return{paths:resolved.paths,seed:seed,opened:resolved.opened,inserted:resolved.inserted}; } @@ -6258,8 +6277,8 @@ return String(folderFilter||'')+'\n'+files+'\n'+connections; } -function codeViewSceneKey(data,folderFilter,vizType){ - return graphStructureKey(data,folderFilter)+'|'+String(vizType||''); +function codeViewSceneKey(data,folderFilter,vizType,source){ + return analysisHydrationId(source,data,folderFilter)+'|'+String(vizType||''); } function analysisHydrationId(source,data,folderFilter){ @@ -7384,7 +7403,8 @@ if(parsed)return{sourceType:'github',sourceKey:githubCacheSourceKey(parsed.owner,parsed.repo,activeExcludePatterns),title:parsed.owner+'/'+parsed.repo,repoUrl:parsed.owner+'/'+parsed.repo,localSourceKind:null}; return null; } - analysisHydrationIdRef.current=analysisHydrationId(currentAnalysisSource(),data,folderFilter); + var currentHydrationId=analysisHydrationId(currentAnalysisSource(),data,folderFilter); + analysisHydrationIdRef.current=currentHydrationId; function refreshRecentList(){ listRecentAnalyses().then(function(rows){ @@ -8423,10 +8443,10 @@ } },[data,repoInfo,localSourceKind,isMobile,graphConfig.vizType]); selectFileRef.current=selectFile; - function openCodeFile(path){ - var resolved=resolveOpenCodeCard(openedCodePaths,path); + function openCodeFile(path,replace){ + var resolved=resolveOpenCodeCard(openedCodePaths,path,null,replace); if(!resolved.opened)return; - setOpenedCodePaths(function(prev){return openCodeCardPaths(prev,path);}); + setOpenedCodePaths(function(prev){return openCodeCardPaths(prev,path,null,replace);}); if(resolved.inserted)pendingFlyToRef.current=path; selectFile(path); } @@ -8634,14 +8654,13 @@ return files; },[data,openedCodePaths,folderFilter,graphConfig.vizType,selected&&selected.path]); useEffect(function(){ - var scene=graphStructureKey(data,folderFilter); - if(scene===openedSceneRef.current)return; - openedSceneRef.current=scene; + if(currentHydrationId===openedSceneRef.current)return; + openedSceneRef.current=currentHydrationId; codeCardPlacementRef.current=Object.create(null); codeSourceInFlightRef.current=Object.create(null); setOpenedCodePaths([]); pendingFlyToRef.current=null; - },[data,folderFilter]); + },[currentHydrationId]); useEffect(function(){ var inCode=graphConfig.vizType==='code'&&!!data; if(!inCode){ @@ -8655,7 +8674,7 @@ codeViewSessionRef.current=true; var seed=codeViewSeedPath(selected&&selected.path,data,folderFilter); if(!seed)return; - if(openCodeFileRef.current)openCodeFileRef.current(seed); + if(openCodeFileRef.current)openCodeFileRef.current(seed,true); },[graphConfig.vizType,data,folderFilter,openedCodePaths.length,selected&&selected.path]); useEffect(function(){ var el=codeCanvasRef.current; @@ -8873,7 +8892,7 @@ var centers={}; folders.forEach(function(f,i){centers[f]={x:(i%cols+1)*cw,y:(Math.floor(i/cols)+1)*ch};}); var keepReadable=graphConfig.vizType==='code'; - var sceneKey=codeViewSceneKey(data,folderFilter,graphConfig.vizType); + var sceneKey=codeViewSceneKey(data,folderFilter,graphConfig.vizType,currentAnalysisSource()); if(sceneKey!==codeViewSceneRef.current){ codeViewSceneRef.current=sceneKey; codeViewCameraReadyRef.current=false; @@ -10642,13 +10661,13 @@ }; } function onMove(ev){ - var dx=(ev.clientX-startX)/k,dy=(ev.clientY-startY)/k; - if(Math.abs(dx)+Math.abs(dy)>3){ + var delta=codeCardDragDelta(ev.clientX,ev.clientY,startX,startY,k,3); + if(delta.moved){ moved=true; codeCardUserPinnedRef.current.add(file.path); } - node.x=node.fx=originX+dx; - node.y=node.fy=originY+dy; + node.x=node.fx=originX+delta.x; + node.y=node.fy=originY+delta.y; writePlacement(); syncCodeCards(); redrawGraphLinksAndNodes(); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 023e922..881235d 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -182,6 +182,14 @@ test('returning to Code opens the current selection without dropping old cards', const empty = context.ensureCodeViewOpenedPaths([], null, data, 'src'); assert.deepEqual(J(empty.paths), ['hub.js']); assert.equal(empty.seed, 'hub.js'); + const capped = Array.from({ length: context.CODE_CARD_MAX }, (_, i) => 'f' + i + '.js'); + const replaced = context.ensureCodeViewOpenedPaths(capped, 'leaf.js', data, 'src'); + assert.equal(replaced.opened, true); + assert.equal(replaced.inserted, true); + assert.equal(replaced.paths.length, context.CODE_CARD_MAX); + assert.equal(replaced.paths[replaced.paths.length - 1], 'leaf.js'); + assert.equal(replaced.paths.indexOf('f0.js'), -1); + assert.equal(replaced.paths.indexOf('f1.js'), 0); }); test('high-degree neighborhoods stay within the card cap', () => { @@ -208,6 +216,18 @@ test('a drag does not select the card on the leftover click', () => { assert.deepEqual(J(context.consumeCodeCardClick(false)), { ignore: false, ignoreNextClick: false }); }); +test('card drag activation uses screen pixels, not zoomed graph units', () => { + const far = context.codeCardDragDelta(101, 100, 100, 100, 0.08, 3); + assert.equal(far.moved, false); + assert.ok(Math.abs(far.x) > 3); + const near = context.codeCardDragDelta(104, 100, 100, 100, 5, 3); + assert.equal(near.moved, true); + assert.ok(Math.abs(near.x) < 3); + const still = context.codeCardDragDelta(100, 100, 100, 100, 1, 3); + assert.equal(still.moved, false); + assert.equal(still.x, 0); +}); + test('code cards use a uniform width and grow with line count', () => { const empty = context.codeCardSize(null); assert.equal(empty.width, context.CODE_CARD_WIDTH); @@ -244,6 +264,14 @@ test('opened code paths append without reshuffling the set', () => { assert.equal(added.opened, true); assert.equal(added.inserted, true); assert.deepEqual(J(added.paths), ['a.js', 'b.js']); + const evicted = context.openCodeCardPaths(capped, 'extra.js', null, true); + assert.equal(evicted.length, context.CODE_CARD_MAX); + assert.equal(evicted[0], 'f1.js'); + assert.equal(evicted[evicted.length - 1], 'extra.js'); + const replaced = context.resolveOpenCodeCard(capped, 'extra.js', null, true); + assert.equal(replaced.opened, true); + assert.equal(replaced.inserted, true); + assert.deepEqual(J(replaced.paths), evicted); }); test('opened code files ignore the current selection', () => { @@ -420,6 +448,14 @@ test('hydrating file contents does not change the graph structure key', () => { context.codeViewSceneKey(before, null, 'code'), context.codeViewSceneKey(swapped, null, 'code') ); + assert.notEqual( + context.codeViewSceneKey(before, null, 'code', { sourceType: 'github', sourceKey: 'owner/alpha' }), + context.codeViewSceneKey(before, null, 'code', { sourceType: 'github', sourceKey: 'owner/beta' }) + ); + assert.notEqual( + context.analysisHydrationId({ sourceType: 'github', sourceKey: 'owner/alpha' }, before, null), + context.analysisHydrationId({ sourceType: 'github', sourceKey: 'owner/beta' }, before, null) + ); const moved = { files: [{ path: 'a.js', folder: 'src', layer: 'utils', churn: 0, functions: [] }, { path: 'b.js' }], connections: [{ source: 'a.js', target: 'b.js' }] @@ -725,7 +761,11 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /codeViewWheelPanDelta/); assert.match(htmlSource, /codeSourceInFlightRef/); assert.match(htmlSource, /analysisHydrationId/); + assert.match(htmlSource, /currentHydrationId/); + assert.match(htmlSource, /openedSceneRef\.current=currentHydrationId/); assert.match(htmlSource, /hydratedSourceIsCurrent/); + assert.match(htmlSource, /codeCardDragDelta/); + assert.match(htmlSource, /openCodeFileRef\.current\(seed,true\)/); assert.match(htmlSource, /codeCardPillViewTop/); assert.match(htmlSource, /readCodeCardBodyScroll/); assert.match(htmlSource, /readCodeCardBodyScroll\(codeCardsLayerRef\.current/); From b2d95cdf4e93108286b85e048f6d7951627f1c13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 02:28:04 +0000 Subject: [PATCH 21/33] Open insight-panel files as Code cards and rebuild when the source changes Route blast-radius, connection, caller, and drill-down file links through goToFile so Code view opens a card, and include analysisHydrationId in graphRebuildKey so same-shaped forks reset camera and pins. Co-authored-by: Braedon Saunders --- index.html | 43 +++++++++++++++++++++++--------------- tests/code-canvas.test.mjs | 8 +++++++ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/index.html b/index.html index d0415f9..c0f6c48 100644 --- a/index.html +++ b/index.html @@ -5834,6 +5834,10 @@ return !(openedPaths&&openedPaths.length); } +function codeFileNavOpensCard(vizType){ + return vizType==='code'; +} + function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -8451,6 +8455,10 @@ selectFile(path); } openCodeFileRef.current=openCodeFile; + function goToFile(path){ + if(codeFileNavOpensCard(graphConfig.vizType))openCodeFile(path); + else selectFile(path); + } function updateGraphHighlight(path,blast){ if(!nodesRef.current||!linksRef.current)return; @@ -8632,6 +8640,7 @@ var graphRebuildKey=useMemo(function(){ return [ + currentHydrationId, graphStructureKey(data,folderFilter), colorMode, theme, @@ -8642,7 +8651,7 @@ graphConfig.showLabels, graphConfig.curvedLinks ].join('\0'); - },[data,folderFilter,colorMode,theme,graphConfig]); + },[currentHydrationId,data,folderFilter,colorMode,theme,graphConfig]); var codeViewFiles=useMemo(function(){ if(!data)return[]; var paths=openedCodePaths; @@ -10631,7 +10640,7 @@ folderFilter ) ), - React.createElement('div',{className:'sidebar-scroll'},React.createElement(TreeNode,{node:data.tree,selected:selected,onSelect:function(path){if(graphConfig.vizType==='code')openCodeFile(path);else selectFile(path);},expanded:expandedPaths,toggle:togglePath,filterFolder:filterByFolder,activeFilter:folderFilter})) + React.createElement('div',{className:'sidebar-scroll'},React.createElement(TreeNode,{node:data.tree,selected:selected,onSelect:goToFile},expanded:expandedPaths,toggle:togglePath,filterFolder:filterByFolder,activeFilter:folderFilter})) ); } function beginCodeCardDrag(e,file){ @@ -11210,12 +11219,12 @@ ), blastRadius.affected.length>0&&React.createElement('div',{className:'blast-detail'}, React.createElement('div',{style:{fontSize:9,fontWeight:600,marginBottom:6}},'Files that import from this:'), - blastRadius.affected.slice(0,8).map(function(path){return React.createElement('div',{key:path,className:'blast-file',onClick:function(){selectFile(path);}},React.createElement(Icon,{name:'file',size:'s'}),' ',path.split('/').pop());}), + blastRadius.affected.slice(0,8).map(function(path){return React.createElement('div',{key:path,className:'blast-file',onClick:function(){goToFile(path);}},React.createElement(Icon,{name:'file',size:'s'}),' ',path.split('/').pop());}), blastRadius.affected.length>8&&React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginTop:4}},'+',blastRadius.affected.length-8,' more') ), (blastRadius.dependencies||[]).length>0&&React.createElement('div',{className:'blast-detail',style:{marginTop:8}}, React.createElement('div',{style:{fontSize:9,fontWeight:600,marginBottom:6,color:'var(--orange)'}},'Dependencies (risk if these change):'), - blastRadius.dependencies.slice(0,5).map(function(path){return React.createElement('div',{key:path,className:'blast-file',onClick:function(){selectFile(path);}},React.createElement(Icon,{name:'file',size:'s'}),' ',path.split('/').pop());}), + blastRadius.dependencies.slice(0,5).map(function(path){return React.createElement('div',{key:path,className:'blast-file',onClick:function(){goToFile(path);}},React.createElement(Icon,{name:'file',size:'s'}),' ',path.split('/').pop());}), blastRadius.dependencies.length>5&&React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginTop:4}},'+',blastRadius.dependencies.length-5,' more') ) ) @@ -11257,7 +11266,7 @@ React.createElement('span',{className:'conn-fn-name'},fn.name,'()'), React.createElement('span',{className:'conn-fn-count'},fn.count,'×') );}), - React.createElement('div',{className:'conn-goto',onClick:function(){selectFile(conn.file);}},'→ View ',conn.file.split('/').pop()) + React.createElement('div',{className:'conn-goto',onClick:function(){goToFile(conn.file);}},'→ View ',conn.file.split('/').pop()) ) ); }), @@ -11279,7 +11288,7 @@ React.createElement('span',{className:'conn-fn-name'},fn.name,'()'), React.createElement('span',{className:'conn-fn-count'},fn.count,'×') );}), - React.createElement('div',{className:'conn-goto',onClick:function(){selectFile(conn.file);}},'→ View ',conn.file.split('/').pop()) + React.createElement('div',{className:'conn-goto',onClick:function(){goToFile(conn.file);}},'→ View ',conn.file.split('/').pop()) ) ); }), @@ -11322,7 +11331,7 @@ fn.code&&React.createElement('div',{className:'fn-code'},fn.code), st&&st.callers&&st.callers.length>0&&React.createElement('div',{className:'fn-callers'}, React.createElement('div',{className:'fn-callers-title'},'External callers:'), - st.callers.slice(0,8).map(function(c,i){return React.createElement('div',{key:i,className:'fn-caller',onClick:function(){selectFile(c.file);}}, + st.callers.slice(0,8).map(function(c,i){return React.createElement('div',{key:i,className:'fn-caller',onClick:function(){goToFile(c.file);}}, React.createElement(Icon,{name:'file',size:'s'}), React.createElement('span',null,c.name), React.createElement('span',{style:{marginLeft:'auto',color:'var(--t3)'}},c.count,'×') @@ -11648,7 +11657,7 @@ React.createElement('div',{style:{fontWeight:600,fontSize:11}},item.name), item.file&&React.createElement('div',{style:{display:'flex',gap:6}}, React.createElement('button',{className:'view-file-btn',onClick:function(e){e.stopPropagation();openFilePreview(item.file,item.line);}},iconLabel('eye','View')), - React.createElement('button',{style:{fontSize:9,padding:'4px 8px',background:'var(--acc)',color:'var(--bg0)',border:'none',borderRadius:4,cursor:'pointer'},onClick:function(e){e.stopPropagation();selectFile(item.file);setDrillDown(null);}},'Go to file →') + React.createElement('button',{style:{fontSize:9,padding:'4px 8px',background:'var(--acc)',color:'var(--bg0)',border:'none',borderRadius:4,cursor:'pointer'},onClick:function(e){e.stopPropagation();goToFile(item.file);setDrillDown(null);}},'Go to file →') ) ), item.file&&React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace'}},item.file,item.line?' : '+item.line:''), @@ -11664,10 +11673,10 @@ item.files&&React.createElement('div',{style:{marginTop:8}}, React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginBottom:4}},'Locations:'), item.files.map(function(f,k){return React.createElement('div',{key:k,style:{fontSize:9,color:'var(--t2)',padding:'4px 8px',background:'var(--bg2)',borderRadius:4,marginBottom:4,display:'flex',justifyContent:'space-between',alignItems:'center'}}, - React.createElement('span',{style:{fontFamily:'monospace',cursor:'pointer',flex:1},onClick:function(){selectFile(f.file||f);setDrillDown(null);}},typeof f==='string'?f.split('/').pop():(f.file||'').split('/').pop(),f.line?' :'+f.line:''), + React.createElement('span',{style:{fontFamily:'monospace',cursor:'pointer',flex:1},onClick:function(){goToFile(f.file||f);setDrillDown(null);}},typeof f==='string'?f.split('/').pop():(f.file||'').split('/').pop(),f.line?' :'+f.line:''), React.createElement('div',{style:{display:'flex',gap:4}}, React.createElement('button',{className:'view-file-btn',onClick:function(e){e.stopPropagation();openFilePreview(f.file||f,f.line);}},React.createElement(Icon,{name:'eye',size:'s'})), - React.createElement('span',{style:{color:'var(--acc)',cursor:'pointer'},onClick:function(){selectFile(f.file||f);setDrillDown(null);}},'→') + React.createElement('span',{style:{color:'var(--acc)',cursor:'pointer'},onClick:function(){goToFile(f.file||f);setDrillDown(null);}},'→') ) );}) ) @@ -11688,10 +11697,10 @@ React.createElement('div',{style:{fontSize:12,fontWeight:600,marginBottom:12}},'All Files (',drillDown.data.files.length,')'), drillDown.data.files.map(function(f,j){return React.createElement('div',{key:j,style:getAccentBlockStyle('rgba(0,255,157,0.28)','rgba(0,255,157,0.08)',{padding:12,marginBottom:8})}, React.createElement('div',{style:{display:'flex',justifyContent:'space-between',alignItems:'center'}}, - React.createElement('div',{style:{fontWeight:600,fontSize:11,cursor:'pointer'},onClick:function(){selectFile(f.path);setDrillDown(null);}},f.name), + React.createElement('div',{style:{fontWeight:600,fontSize:11,cursor:'pointer'},onClick:function(){goToFile(f.path);setDrillDown(null);}},f.name), React.createElement('button',{className:'view-file-btn',onClick:function(e){e.stopPropagation();openFilePreview(f.path);}},iconLabel('eye','View')) ), - React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace',cursor:'pointer'},onClick:function(){selectFile(f.path);setDrillDown(null);}},f.path), + React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace',cursor:'pointer'},onClick:function(){goToFile(f.path);setDrillDown(null);}},f.path), f.fns&&React.createElement('div',{style:{fontSize:10,color:'var(--orange)',marginTop:4}},f.fns,' functions'), f.lines&&React.createElement('div',{style:{fontSize:10,color:'var(--purple)',marginTop:4}},f.lines,' lines') );}) @@ -11711,10 +11720,10 @@ React.createElement('div',{style:{fontSize:12,fontWeight:600,marginBottom:12}},'Location'), React.createElement('div',{style:{background:'var(--bg0)',padding:12,borderRadius:8,marginBottom:16}}, React.createElement('div',{style:{display:'flex',justifyContent:'space-between',alignItems:'center'}}, - React.createElement('div',{style:{fontWeight:600,fontSize:11,cursor:'pointer'},onClick:function(){selectFile(drillDown.data.path);setDrillDown(null);}},drillDown.data.file), + React.createElement('div',{style:{fontWeight:600,fontSize:11,cursor:'pointer'},onClick:function(){goToFile(drillDown.data.path);setDrillDown(null);}},drillDown.data.file), React.createElement('button',{className:'view-file-btn',onClick:function(e){e.stopPropagation();openFilePreview(drillDown.data.path,drillDown.data.line);}},iconLabel('eye','View')) ), - React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace',cursor:'pointer'},onClick:function(){selectFile(drillDown.data.path);setDrillDown(null);}},drillDown.data.path), + React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace',cursor:'pointer'},onClick:function(){goToFile(drillDown.data.path);setDrillDown(null);}},drillDown.data.path), drillDown.data.line&&React.createElement('div',{style:{fontSize:10,color:'var(--orange)',marginTop:4}},'Line ',drillDown.data.line) ), drillDown.data.code&&React.createElement(React.Fragment,null, @@ -11740,10 +11749,10 @@ ? getAccentBlockStyle('rgba(167,139,250,0.34)','rgba(167,139,250,0.08)',{padding:12,marginBottom:8}) : getAccentBlockStyle('rgba(255,159,67,0.34)','rgba(255,159,67,0.08)',{padding:12,marginBottom:8})}, React.createElement('div',{style:{display:'flex',justifyContent:'space-between',alignItems:'center'}}, - React.createElement('div',{style:{fontWeight:600,fontSize:11,cursor:'pointer'},onClick:function(){selectFile(f.file);setDrillDown(null);}},f.name||drillDown.data.name), + React.createElement('div',{style:{fontWeight:600,fontSize:11,cursor:'pointer'},onClick:function(){goToFile(f.file);setDrillDown(null);}},f.name||drillDown.data.name), React.createElement('button',{className:'view-file-btn',onClick:function(e){e.stopPropagation();openFilePreview(f.file,f.line);}},iconLabel('eye','View')) ), - React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace',cursor:'pointer'},onClick:function(){selectFile(f.file);setDrillDown(null);}},f.file), + React.createElement('div',{style:{fontSize:10,color:'var(--t3)',marginTop:4,fontFamily:'monospace',cursor:'pointer'},onClick:function(){goToFile(f.file);setDrillDown(null);}},f.file), f.line&&React.createElement('div',{style:{fontSize:10,color:'var(--orange)',marginTop:4}},'Line ',f.line) );}), React.createElement('div',{style:{fontSize:12,fontWeight:600,marginBottom:12,marginTop:16}},'Suggested Action'), @@ -11839,7 +11848,7 @@ React.createElement('div',{className:'unused-fn-path'}, React.createElement('span',null,React.createElement(Icon,{name:'folder',size:'s'}),' ',fn.folder||'root'), React.createElement('span',null,'→'), - React.createElement('span',{className:'unused-fn-file',onClick:function(e){e.stopPropagation();selectFile(fn.file);setShowUnused(false);}},fn.file.split('/').pop()) + React.createElement('span',{className:'unused-fn-file',onClick:function(e){e.stopPropagation();goToFile(fn.file);setShowUnused(false);}},fn.file.split('/').pop()) ) ), React.createElement('div',{className:'unused-fn-meta'}, diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 881235d..711de0b 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -152,6 +152,9 @@ test('Code view seeds cards without waiting for a click', () => { assert.deepEqual(J(visible).map((f) => f.path), ['hub.js', 'leaf.js']); assert.equal(context.defaultCodeViewSeed({ files: [], connections: [] }, null), null); assert.equal(context.codeViewSeedPath('leaf.js', { files: [], connections: [] }, null), null); + assert.equal(context.codeFileNavOpensCard('code'), true); + assert.equal(context.codeFileNavOpensCard('graph'), false); + assert.equal(context.codeFileNavOpensCard(null), false); }); test('returning to Code opens the current selection without dropping old cards', () => { @@ -782,6 +785,11 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /defaultCodeViewSeed/); assert.match(htmlSource, /codeViewSeedPath/); assert.match(htmlSource, /codeViewSeedPath\(selected&&selected\.path/); + assert.match(htmlSource, /codeFileNavOpensCard/); + assert.match(htmlSource, /function goToFile\(/); + assert.match(htmlSource, /className:'blast-file',onClick:function\(\)\{goToFile\(path\);\}/); + assert.match(htmlSource, /className:'conn-goto',onClick:function\(\)\{goToFile\(conn\.file\);\}/); + assert.match(htmlSource, /currentHydrationId,/); assert.match(htmlSource, /shouldSeedOpenedCodeCards/); assert.match(htmlSource, /ensureCodeViewOpenedPaths/); assert.match(htmlSource, /codeViewSessionRef/); From 05ad20c11a970368ab8c441d14e955f6187c1d48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 12:07:00 +0000 Subject: [PATCH 22/33] Show a retry state for failed source reads and free the card cap from filtered-out files Failed live reads no longer leave cards on Loading source. Hidden cards no longer consume the 12-card cap, and folder hulls follow dragged cards. Co-authored-by: Braedon Saunders --- index.html | 124 +++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 48 ++++++++++++++ 2 files changed, 154 insertions(+), 18 deletions(-) diff --git a/index.html b/index.html index c0f6c48..18ea5e3 100644 --- a/index.html +++ b/index.html @@ -492,6 +492,9 @@ .code-card-name{font-size:11px;font-weight:600;color:var(--t0);transform-origin:left center;display:inline-block;white-space:nowrap} .code-card-path{font-size:9px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px} .code-card-body{flex:1;overflow:hidden;min-height:0;user-select:text} +.code-card-source-status{padding:12px;display:flex;flex-direction:column;align-items:flex-start;gap:10px} +.code-card-retry{background:transparent;border:1px solid var(--border);color:var(--t2);padding:4px 8px;border-radius:4px;font-size:10px;cursor:pointer} +.code-card-retry:hover{border-color:var(--acc);color:var(--acc)} .code-card.clipped .code-card-body{overflow:auto;touch-action:pan-x pan-y} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} .code-card.code-far .code-card-body{overflow:hidden} @@ -5990,22 +5993,59 @@ return placed; } -function openCodeCardPaths(prev,path,limit,replace){ +function pathIsFlagged(map,path){ + if(!path||!map)return false; + if(typeof map.has==='function')return map.has(path); + return !!map[path]; +} + +function hiddenOpenedCodePaths(paths,data,folderFilter){ + var hidden=Object.create(null); + if(!folderFilter)return hidden; + var visible=Object.create(null); + filesForOpenedCodePaths(paths,data,folderFilter).forEach(function(file){ + if(file&&file.path)visible[file.path]=true; + }); + (paths||[]).forEach(function(path){ + if(path&&!visible[path])hidden[path]=true; + }); + return hidden; +} + +function evictHiddenCodeCards(list,hidden,count){ + var need=Number(count); + if(!isFinite(need)||need<=0)return (list||[]).slice(); + var removed=0; + return (list||[]).filter(function(path){ + if(removed>=need)return true; + if(pathIsFlagged(hidden,path)){ + removed++; + return false; + } + return true; + }); +} + +function openCodeCardPaths(prev,path,limit,replace,hidden){ var list=(prev||[]).slice(); if(!path)return list; if(list.indexOf(path)>=0)return list; var max=limit==null?CODE_CARD_MAX:Number(limit); if(isFinite(max)&&list.length>=max){ - if(!replace)return list; - list=list.slice(Math.max(0,list.length-max+1)); + var need=list.length-max+1; + if(hidden)list=evictHiddenCodeCards(list,hidden,need); + if(list.length>=max){ + if(!replace)return list; + list=list.slice(Math.max(0,list.length-max+1)); + } } list.push(path); return list; } -function resolveOpenCodeCard(prev,path,limit,replace){ +function resolveOpenCodeCard(prev,path,limit,replace,hidden){ var before=prev||[]; - var next=openCodeCardPaths(before,path,limit,replace); + var next=openCodeCardPaths(before,path,limit,replace,hidden); var already=!!(path&&before.indexOf(path)>=0); var inserted=!!(path&&!already&&next.indexOf(path)>=0); return{paths:next,already:already,inserted:inserted,opened:already||inserted}; @@ -6014,7 +6054,7 @@ function ensureCodeViewOpenedPaths(openedPaths,selectedPath,data,folderFilter){ var seed=codeViewSeedPath(selectedPath,data,folderFilter); if(!seed)return{paths:openedPaths||[],seed:null,opened:false,inserted:false}; - var resolved=resolveOpenCodeCard(openedPaths,seed,null,true); + var resolved=resolveOpenCodeCard(openedPaths,seed,null,true,hiddenOpenedCodePaths(openedPaths,data,folderFilter)); return{paths:resolved.paths,seed:seed,opened:resolved.opened,inserted:resolved.inserted}; } @@ -6178,10 +6218,10 @@ return{x:-(Number(deltaX)||0)/k,y:-(Number(deltaY)||0)/k}; } -function nextCodeSourceReads(neededPaths,inFlight){ +function nextCodeSourceReads(neededPaths,inFlight,failed){ inFlight=inFlight||Object.create(null); return(neededPaths||[]).filter(function(path){ - return !!path&&!inFlight[path]; + return !!path&&!inFlight[path]&&!pathIsFlagged(failed,path); }); } @@ -6563,10 +6603,23 @@ return !!(file&&!file.analysisSkipped&&!fileHasLoadedSource(file)); } -function fileSourceDisplayState(file,canFetch){ +function recordCodeSourceFailure(prev,path){ + var next=Object.assign(Object.create(null),prev||{}); + if(path)next[path]=true; + return next; +} + +function clearCodeSourceFailure(prev,path){ + var next=Object.assign(Object.create(null),prev||{}); + if(path)delete next[path]; + return next; +} + +function fileSourceDisplayState(file,canFetch,failed){ if(!file)return 'empty'; if(file.analysisSkipped)return 'skipped'; if(fileHasLoadedSource(file))return 'ready'; + if(canFetch&&pathIsFlagged(failed,file.path))return 'failed'; return canFetch?'loading':'unavailable'; } @@ -7199,8 +7252,10 @@ var codeFilesByPathRef=useRef(Object.create(null)); var codeCanvasRef=useRef(null); var codeSourceInFlightRef=useRef(Object.create(null)); + var updateHullsRef=useRef(null); var analysisHydrationIdRef=useRef(''); var _openedCards=useState([]),openedCodePaths=_openedCards[0],setOpenedCodePaths=_openedCards[1]; + var _sourceFailed=useState(Object.create(null)),codeSourceFailed=_sourceFailed[0],setCodeSourceFailed=_sourceFailed[1]; var _pillScroll=useState(0),codePillScroll=_pillScroll[0],setCodePillScroll=_pillScroll[1]; var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); @@ -8448,12 +8503,18 @@ },[data,repoInfo,localSourceKind,isMobile,graphConfig.vizType]); selectFileRef.current=selectFile; function openCodeFile(path,replace){ - var resolved=resolveOpenCodeCard(openedCodePaths,path,null,replace); + var hidden=hiddenOpenedCodePaths(openedCodePaths,data,folderFilter); + var resolved=resolveOpenCodeCard(openedCodePaths,path,null,replace,hidden); if(!resolved.opened)return; - setOpenedCodePaths(function(prev){return openCodeCardPaths(prev,path,null,replace);}); + setOpenedCodePaths(function(prev){return openCodeCardPaths(prev,path,null,replace,hiddenOpenedCodePaths(prev,data,folderFilter));}); if(resolved.inserted)pendingFlyToRef.current=path; selectFile(path); } + function retryCodeSource(path){ + if(!path)return; + delete codeSourceInFlightRef.current[path]; + setCodeSourceFailed(function(prev){return clearCodeSourceFailure(prev,path);}); + } openCodeFileRef.current=openCodeFile; function goToFile(path){ if(codeFileNavOpensCard(graphConfig.vizType))openCodeFile(path); @@ -8667,6 +8728,7 @@ openedSceneRef.current=currentHydrationId; codeCardPlacementRef.current=Object.create(null); codeSourceInFlightRef.current=Object.create(null); + setCodeSourceFailed(Object.create(null)); setOpenedCodePaths([]); pendingFlyToRef.current=null; },[currentHydrationId]); @@ -8859,13 +8921,21 @@ if(!missing.length||!canReadLiveFileSource())return; var inflight=codeSourceInFlightRef.current; var hydrationId=analysisHydrationIdRef.current; - nextCodeSourceReads(missing.map(function(file){return file.path;}),inflight).forEach(function(path){ + nextCodeSourceReads(missing.map(function(file){return file.path;}),inflight,codeSourceFailed).forEach(function(path){ inflight[path]=true; readLiveFileSource(path).then(function(content){ - if(typeof content==='string')rememberHydratedSources([{path:path,content:content,hydrationId:hydrationId}]); - }).then(function(){delete inflight[path];},function(){delete inflight[path];}); + if(typeof content==='string'){ + rememberHydratedSources([{path:path,content:content,hydrationId:hydrationId}]); + setCodeSourceFailed(function(prev){return clearCodeSourceFailure(prev,path);}); + return; + } + setCodeSourceFailed(function(prev){return recordCodeSourceFailure(prev,path);}); + }).then(function(){delete inflight[path];},function(){ + delete inflight[path]; + setCodeSourceFailed(function(prev){return recordCodeSourceFailure(prev,path);}); + }); }); - },[codeViewFiles,localSourceKind,cliStatus,repoInfo,localDirHandle]); + },[codeViewFiles,localSourceKind,cliStatus,repoInfo,localDirHandle,codeSourceFailed]); useEffect(function(){ if(!data||!svgRef.current)return; @@ -9026,7 +9096,10 @@ if(!e.active)sim.alphaTarget(0.1).restart(); }).on('drag',function(e,d){ d.fx=e.x;d.fy=e.y;d.x=e.x;d.y=e.y; - if(keepReadable)redrawGraphLinksAndNodes(); + if(keepReadable){ + redrawGraphLinksAndNodes(); + if(updateHullsRef.current)updateHullsRef.current(); + } }).on('end',function(e,d){ if(!keepReadable){ if(!e.active)sim.alphaTarget(0); @@ -9035,6 +9108,7 @@ } d.fx=d.x;d.fy=d.y; if(codeCardPathsRef.current.has(d.id))codeCardUserPinnedRef.current.add(d.id); + if(updateHullsRef.current)updateHullsRef.current(); })); node.on('click',function(e,d){ e.stopPropagation(); @@ -9119,8 +9193,9 @@ } sim.alpha(0); } + updateHullsRef.current=updateHulls; }catch(e){console.error('Force graph error:',e);svg.selectAll('*').remove();svg.append('text').attr('x',20).attr('y',30).attr('fill','var(--t3)').text('Graph rendering error: '+e.message);} - return function(){if(simRef.current)simRef.current.stop();}; + return function(){if(simRef.current)simRef.current.stop();updateHullsRef.current=null;}; },[graphRebuildKey]); useLayoutEffect(function(){ @@ -9134,6 +9209,7 @@ applyOpenedCardPlacements(); placeRemainingCodeNodes(); syncCodeCards(); + if(updateHullsRef.current)updateHullsRef.current(); },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist]); // 3D Force Graph Hook @@ -10680,6 +10756,7 @@ writePlacement(); syncCodeCards(); redrawGraphLinksAndNodes(); + if(updateHullsRef.current)updateHullsRef.current(); } function onUp(){ window.removeEventListener('pointermove',onMove); @@ -10694,6 +10771,9 @@ codeCardUserPinnedRef.current.add(file.path); node.x=node.fx;node.y=node.fy; writePlacement(); + syncCodeCards(); + redrawGraphLinksAndNodes(); + if(updateHullsRef.current)updateHullsRef.current(); } if(end.select)selectFile(file.path); } @@ -10703,7 +10783,7 @@ } function renderCodeFileCard(file,isPrimary){ if(!file)return null; - var sourceState=fileSourceDisplayState(file,canReadLiveFileSource()); + var sourceState=fileSourceDisplayState(file,canReadLiveFileSource(),codeSourceFailed); var body; if(sourceState==='ready'){ var lines=asCodeLines(highlightSyntax(file.content||'',file.name)); @@ -10715,6 +10795,14 @@ ); }) ); + }else if(sourceState==='failed'){ + body=React.createElement('div',{className:'code-card-source-status'}, + React.createElement('div',{className:'empty-desc'},'Source unavailable'), + React.createElement('button',{className:'code-card-retry',type:'button',onClick:function(e){ + e.stopPropagation(); + retryCodeSource(file.path); + }},'Retry') + ); }else{ var message=sourceState==='skipped' ?(file.analysisSkipped==='oversized'?'Skipped during analysis (file too large)':'File was not fetched during analysis') diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 711de0b..add2d46 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -341,6 +341,43 @@ test('source reads skip paths that are already in flight', () => { assert.deepEqual(J(context.nextCodeSourceReads(['a.js'], { 'a.js': true })), []); }); +test('failed source reads leave a retryable state instead of loading forever', () => { + const file = { path: 'src/a.js', name: 'a.js' }; + assert.equal(context.fileSourceDisplayState(file, true), 'loading'); + const failed = context.recordCodeSourceFailure(null, 'src/a.js'); + assert.equal(context.fileSourceDisplayState(file, true, failed), 'failed'); + assert.equal(context.fileSourceDisplayState(file, false, failed), 'unavailable'); + assert.deepEqual(J(context.nextCodeSourceReads(['src/a.js', 'src/b.js'], {}, failed)), ['src/b.js']); + const cleared = context.clearCodeSourceFailure(failed, 'src/a.js'); + assert.equal(context.fileSourceDisplayState(file, true, cleared), 'loading'); + assert.deepEqual(J(context.nextCodeSourceReads(['src/a.js'], {}, cleared)), ['src/a.js']); +}); + +test('filtered-out cards do not consume the open-card cap', () => { + const files = Array.from({ length: context.CODE_CARD_MAX }, (_, i) => ({ + path: 'src/f' + i + '.js', + folder: 'src', + name: 'f' + i + '.js' + })); + files.push({ path: 'lib/new.js', folder: 'lib', name: 'new.js' }); + files.push({ path: 'src/extra.js', folder: 'src', name: 'extra.js' }); + const data = { files }; + const capped = files.slice(0, context.CODE_CARD_MAX).map((file) => file.path); + const hidden = context.hiddenOpenedCodePaths(capped, data, 'lib'); + assert.equal(Object.keys(hidden).length, context.CODE_CARD_MAX); + const opened = context.resolveOpenCodeCard(capped, 'lib/new.js', null, false, hidden); + assert.equal(opened.opened, true); + assert.equal(opened.inserted, true); + assert.equal(opened.paths.length, context.CODE_CARD_MAX); + assert.equal(opened.paths[opened.paths.length - 1], 'lib/new.js'); + assert.equal(opened.paths.indexOf('src/f0.js'), -1); + const visibleHidden = context.hiddenOpenedCodePaths(capped, data, 'src'); + assert.equal(Object.keys(visibleHidden).length, 0); + const refused = context.resolveOpenCodeCard(capped, 'src/extra.js', null, false, visibleHidden); + assert.equal(refused.opened, false); + assert.deepEqual(J(refused.paths), capped); +}); + test('wheel pan deltas stay screen-pixel based across zoom', () => { assert.deepEqual(J(context.codeViewWheelPanDelta(40, 80, 1)), { x: -40, y: -80 }); assert.deepEqual(J(context.codeViewWheelPanDelta(40, 80, 2)), { x: -20, y: -40 }); @@ -411,6 +448,10 @@ test('opened code cards auto-align by directory', () => { ], sizes, 10); assert.ok(bounds.width >= 320); assert.ok(bounds.height >= 200); + const before = context.codeFolderCardBounds([{ id: 'src/a.js', x: 220, y: 100 }], sizes, 10); + const after = context.codeFolderCardBounds([{ id: 'src/a.js', x: 800, y: 400 }], sizes, 10); + assert.ok(after.x > before.x); + assert.ok(after.y > before.y); }); test('Code camera fits only when it has not been armed yet', () => { @@ -654,6 +695,7 @@ test('hydrated sources stay in memory and are not treated as cached source', () assert.equal(context.fileSourceDisplayState(merged.files[0], true), 'ready'); assert.equal(context.fileSourceDisplayState({ path: 'big.js', analysisSkipped: 'oversized' }, false), 'skipped'); assert.equal(context.fileSourceDisplayState({ path: 'src/a.js' }, false), 'unavailable'); + assert.equal(context.fileSourceDisplayState(cached.files[0], true, { 'src/a.js': true }), 'failed'); }); test('empty source files count as loaded after hydration', () => { @@ -761,6 +803,12 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /appendCodeCardPlacement/); assert.match(htmlSource, /reflowUnpinnedCodeCards/); assert.match(htmlSource, /nextCodeSourceReads/); + assert.match(htmlSource, /recordCodeSourceFailure/); + assert.match(htmlSource, /retryCodeSource/); + assert.match(htmlSource, /sourceState==='failed'/); + assert.match(htmlSource, /hiddenOpenedCodePaths/); + assert.match(htmlSource, /evictHiddenCodeCards/); + assert.match(htmlSource, /updateHullsRef\.current/); assert.match(htmlSource, /codeViewWheelPanDelta/); assert.match(htmlSource, /codeSourceInFlightRef/); assert.match(htmlSource, /analysisHydrationId/); From 6b18414174ea24eb63852725e1e8113566049dc7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 14:05:16 +0000 Subject: [PATCH 23/33] Ignore stale source failures and keep opened cards across folder filters Failed reads now check the captured hydration id before recording. The open-card reset keys on analysis identity only; folder filter stays a separate scene key. Co-authored-by: Braedon Saunders --- index.html | 42 ++++++++++++++++++++++++++++---------- tests/code-canvas.test.mjs | 30 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 18ea5e3..565005a 100644 --- a/index.html +++ b/index.html @@ -6314,26 +6314,36 @@ return [file.path||'',file.name||'',file.folder||'',file.layer||'',file.churn||0,fnCount].join('\t'); } -function graphStructureKey(data,folderFilter){ +function analysisGraphKey(data){ if(!data||!data.files)return ''; var files=data.files.map(fileGraphIdentity).join('\n'); var connections=(data.connections||[]).map(connectionIdentity).sort().join('\n'); - return String(folderFilter||'')+'\n'+files+'\n'+connections; + return files+'\n'+connections; +} + +function graphStructureKey(data,folderFilter){ + var graph=analysisGraphKey(data); + if(!graph)return ''; + return String(folderFilter||'')+'\n'+graph; } function codeViewSceneKey(data,folderFilter,vizType,source){ - return analysisHydrationId(source,data,folderFilter)+'|'+String(vizType||''); + return analysisHydrationId(source,data)+'|'+String(folderFilter||'')+'|'+String(vizType||''); } -function analysisHydrationId(source,data,folderFilter){ +function analysisHydrationId(source,data){ source=source||{}; - return [source.sourceType||'',source.sourceKey||'',graphStructureKey(data,folderFilter)].join('\0'); + return [source.sourceType||'',source.sourceKey||'',analysisGraphKey(data)].join('\0'); +} + +function hydrationRequestIsCurrent(hydrationId,currentId){ + if(hydrationId==null||currentId==null)return true; + return hydrationId===currentId; } function hydratedSourceIsCurrent(update,currentId){ if(!update||!update.path||typeof update.content!=='string')return false; - if(update.hydrationId==null||currentId==null)return true; - return update.hydrationId===currentId; + return hydrationRequestIsCurrent(update.hydrationId,currentId); } function preserveGraphNodeState(nodes,prevById){ @@ -6615,6 +6625,16 @@ return next; } +function recordCodeSourceFailureIfCurrent(prev,path,hydrationId,currentId){ + if(!hydrationRequestIsCurrent(hydrationId,currentId))return prev||Object.create(null); + return recordCodeSourceFailure(prev,path); +} + +function clearCodeSourceFailureIfCurrent(prev,path,hydrationId,currentId){ + if(!hydrationRequestIsCurrent(hydrationId,currentId))return prev||Object.create(null); + return clearCodeSourceFailure(prev,path); +} + function fileSourceDisplayState(file,canFetch,failed){ if(!file)return 'empty'; if(file.analysisSkipped)return 'skipped'; @@ -7462,7 +7482,7 @@ if(parsed)return{sourceType:'github',sourceKey:githubCacheSourceKey(parsed.owner,parsed.repo,activeExcludePatterns),title:parsed.owner+'/'+parsed.repo,repoUrl:parsed.owner+'/'+parsed.repo,localSourceKind:null}; return null; } - var currentHydrationId=analysisHydrationId(currentAnalysisSource(),data,folderFilter); + var currentHydrationId=analysisHydrationId(currentAnalysisSource(),data); analysisHydrationIdRef.current=currentHydrationId; function refreshRecentList(){ @@ -8926,13 +8946,13 @@ readLiveFileSource(path).then(function(content){ if(typeof content==='string'){ rememberHydratedSources([{path:path,content:content,hydrationId:hydrationId}]); - setCodeSourceFailed(function(prev){return clearCodeSourceFailure(prev,path);}); + setCodeSourceFailed(function(prev){return clearCodeSourceFailureIfCurrent(prev,path,hydrationId,analysisHydrationIdRef.current);}); return; } - setCodeSourceFailed(function(prev){return recordCodeSourceFailure(prev,path);}); + setCodeSourceFailed(function(prev){return recordCodeSourceFailureIfCurrent(prev,path,hydrationId,analysisHydrationIdRef.current);}); }).then(function(){delete inflight[path];},function(){ delete inflight[path]; - setCodeSourceFailed(function(prev){return recordCodeSourceFailure(prev,path);}); + setCodeSourceFailed(function(prev){return recordCodeSourceFailureIfCurrent(prev,path,hydrationId,analysisHydrationIdRef.current);}); }); }); },[codeViewFiles,localSourceKind,cliStatus,repoInfo,localDirHandle,codeSourceFailed]); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index add2d46..30b9e28 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -500,6 +500,13 @@ test('hydrating file contents does not change the graph structure key', () => { context.analysisHydrationId({ sourceType: 'github', sourceKey: 'owner/alpha' }, before, null), context.analysisHydrationId({ sourceType: 'github', sourceKey: 'owner/beta' }, before, null) ); + const source = { sourceType: 'github', sourceKey: 'owner/repo' }; + assert.equal(context.analysisHydrationId(source, before, null), context.analysisHydrationId(source, before, 'src')); + assert.equal(context.analysisGraphKey(before), context.analysisGraphKey(after)); + assert.notEqual( + context.codeViewSceneKey(before, null, 'code', source), + context.codeViewSceneKey(before, 'src', 'code', source) + ); const moved = { files: [{ path: 'a.js', folder: 'src', layer: 'utils', churn: 0, functions: [] }, { path: 'b.js' }], connections: [{ source: 'a.js', target: 'b.js' }] @@ -723,6 +730,24 @@ test('obsolete hydration results do not replace a new analysis file', () => { assert.equal(accepted.files[0].content, 'from-beta'); }); +test('obsolete source failures do not mark a new analysis path as failed', () => { + const repoA = { sourceType: 'github', sourceKey: 'owner/alpha' }; + const repoB = { sourceType: 'github', sourceKey: 'owner/beta' }; + const data = { files: [{ path: 'src/index.js', name: 'index.js' }], connections: [] }; + const idA = context.analysisHydrationId(repoA, data); + const idB = context.analysisHydrationId(repoB, data); + assert.equal(context.hydrationRequestIsCurrent(idA, idB), false); + assert.equal(context.hydrationRequestIsCurrent(idB, idB), true); + const stale = context.recordCodeSourceFailureIfCurrent(null, 'src/index.js', idA, idB); + assert.equal(Object.keys(stale).length, 0); + const current = context.recordCodeSourceFailureIfCurrent(null, 'src/index.js', idB, idB); + assert.equal(current['src/index.js'], true); + const kept = context.clearCodeSourceFailureIfCurrent(current, 'src/index.js', idA, idB); + assert.equal(kept['src/index.js'], true); + const cleared = context.clearCodeSourceFailureIfCurrent(current, 'src/index.js', idB, idB); + assert.equal(Object.prototype.hasOwnProperty.call(cleared, 'src/index.js'), false); +}); + test('symbol pills track scroll and hide when clipped away', () => { const visible = context.codeCardPillViewTop(80, 0, 400, 42); assert.equal(visible, 80); @@ -804,6 +829,11 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /reflowUnpinnedCodeCards/); assert.match(htmlSource, /nextCodeSourceReads/); assert.match(htmlSource, /recordCodeSourceFailure/); + assert.match(htmlSource, /recordCodeSourceFailureIfCurrent/); + assert.match(htmlSource, /hydrationRequestIsCurrent/); + assert.match(htmlSource, /analysisGraphKey/); + assert.match(htmlSource, /analysisHydrationId\(currentAnalysisSource\(\),data\)/); + assert.doesNotMatch(htmlSource, /analysisHydrationId\(currentAnalysisSource\(\),data,folderFilter\)/); assert.match(htmlSource, /retryCodeSource/); assert.match(htmlSource, /sourceState==='failed'/); assert.match(htmlSource, /hiddenOpenedCodePaths/); From 73a0065518cc269e1c7f5ed084073c0586e00fe4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 16:08:04 +0000 Subject: [PATCH 24/33] Key hydration to the loaded analysis, keep symbol-list wheel native, and memoize graph identity CLI-hosted GitHub analyses no longer share one hydration id. The symbol list can scroll, and file/edge serialization no longer runs on tooltip-only renders. Co-authored-by: Braedon Saunders --- index.html | 48 +++++++++++++++++++++++++++++---- tests/code-canvas.test.mjs | 55 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 565005a..c556c38 100644 --- a/index.html +++ b/index.html @@ -6331,9 +6331,23 @@ return analysisHydrationId(source,data)+'|'+String(folderFilter||'')+'|'+String(vizType||''); } -function analysisHydrationId(source,data){ +function analysisHydrationIdFromParts(source,graphKey){ source=source||{}; - return [source.sourceType||'',source.sourceKey||'',analysisGraphKey(data)].join('\0'); + return [source.sourceType||'',source.sourceKey||'',graphKey||''].join('\0'); +} + +function analysisHydrationId(source,data){ + return analysisHydrationIdFromParts(source,analysisGraphKey(data)); +} + +function loadedAnalysisSourceIdentity(options){ + options=options||{}; + if(options.localSourceKind==='folder')return{sourceType:'folder',sourceKey:options.folderKey||'local-folder'}; + if(options.localSourceKind==='zip')return{sourceType:'zip',sourceKey:options.zipKey||'zip'}; + if(options.localSourceKind==='cli')return{sourceType:'cli',sourceKey:options.cliRoot||'cli'}; + if(options.githubOwner&&options.githubRepo)return{sourceType:'github',sourceKey:options.githubKey||(options.githubOwner+'/'+options.githubRepo)}; + if(options.cliOk)return{sourceType:'cli',sourceKey:options.cliRoot||'cli'}; + return null; } function hydrationRequestIsCurrent(hydrationId,currentId){ @@ -6526,6 +6540,11 @@ return !!(target.closest&&target.closest('[data-code-bg="1"]')); } +function isCodeCanvasNativeScrollTarget(target){ + if(!target||!target.closest)return false; + return !!(target.closest('.code-card.clipped .code-card-body')||target.closest('.code-sym-list')); +} + function collectCrossFileSymbols(files,connections){ var byName=Object.create(null); (files||[]).forEach(function(file){ @@ -7474,15 +7493,34 @@ } function currentAnalysisSource(){ - if(cliStatus&&cliStatus.ok)return{sourceType:'cli',sourceKey:cliStatus.root||'cli',title:cliStatus.name||'Local watch',repoUrl:'',localSourceKind:'cli'}; if(localSourceKind==='folder')return{sourceType:'folder',sourceKey:(repoInfo&&(repoInfo.folderKey||repoInfo.name))||'local-folder',title:(repoInfo&&repoInfo.name)||'Local Folder',repoUrl:'',localSourceKind:'folder'}; if(localSourceKind==='zip')return{sourceType:'zip',sourceKey:(repoInfo&&(repoInfo.zipKey||repoInfo.name))||'zip',title:(repoInfo&&repoInfo.name)||'ZIP Archive',repoUrl:'',localSourceKind:'zip'}; + if(localSourceKind==='cli')return{sourceType:'cli',sourceKey:(repoInfo&&repoInfo.cliRoot)||(cliStatus&&cliStatus.root)||'cli',title:(cliStatus&&cliStatus.name)||(repoInfo&&repoInfo.name)||'Local watch',repoUrl:'',localSourceKind:'cli'}; if(repoInfo&&repoInfo.owner&&repoInfo.repo&&repoInfo.owner!=='local')return{sourceType:'github',sourceKey:githubCacheSourceKey(repoInfo.owner,repoInfo.repo,activeExcludePatterns),title:repoInfo.owner+'/'+repoInfo.repo,repoUrl:repoInfo.owner+'/'+repoInfo.repo,localSourceKind:null}; var parsed=parseUrl(repoUrl); if(parsed)return{sourceType:'github',sourceKey:githubCacheSourceKey(parsed.owner,parsed.repo,activeExcludePatterns),title:parsed.owner+'/'+parsed.repo,repoUrl:parsed.owner+'/'+parsed.repo,localSourceKind:null}; + if(cliStatus&&cliStatus.ok)return{sourceType:'cli',sourceKey:cliStatus.root||'cli',title:cliStatus.name||'Local watch',repoUrl:'',localSourceKind:'cli'}; return null; } - var currentHydrationId=analysisHydrationId(currentAnalysisSource(),data); + var analysisGraphIdentity=useMemo(function(){return analysisGraphKey(data);},[data]); + var loadedSourceIdentity=useMemo(function(){ + var parsed=parseUrl(repoUrl); + var githubOwner=repoInfo&&repoInfo.owner&&repoInfo.owner!=='local'?repoInfo.owner:(parsed&&parsed.owner); + var githubRepo=repoInfo&&repoInfo.owner&&repoInfo.owner!=='local'?repoInfo.repo:(parsed&&parsed.repo); + return loadedAnalysisSourceIdentity({ + localSourceKind:localSourceKind, + folderKey:repoInfo&&(repoInfo.folderKey||repoInfo.name), + zipKey:repoInfo&&(repoInfo.zipKey||repoInfo.name), + cliRoot:(repoInfo&&repoInfo.cliRoot)||(cliStatus&&cliStatus.root), + cliOk:!!(cliStatus&&cliStatus.ok), + githubOwner:githubOwner, + githubRepo:githubRepo, + githubKey:githubOwner&&githubRepo?githubCacheSourceKey(githubOwner,githubRepo,activeExcludePatterns):null + }); + },[localSourceKind,repoInfo,repoUrl,activeExcludePatterns,cliStatus]); + var currentHydrationId=useMemo(function(){ + return analysisHydrationIdFromParts(loadedSourceIdentity,analysisGraphIdentity); + },[loadedSourceIdentity,analysisGraphIdentity]); analysisHydrationIdRef.current=currentHydrationId; function refreshRecentList(){ @@ -8772,7 +8810,7 @@ if(!el||graphConfig.vizType!=='code')return; function onWheel(e){ if(!zoomRef.current||!svgRef.current)return; - if(e.target&&e.target.closest&&e.target.closest('.code-card.clipped .code-card-body'))return; + if(isCodeCanvasNativeScrollTarget(e.target))return; var action=codeViewWheelAction(e); e.preventDefault(); var svg=d3.select(svgRef.current); diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 30b9e28..7db1a1f 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -779,6 +779,53 @@ test('selection reads the live body scrollTop of a previously scrolled card', () assert.equal(context.readCodeCardBodyScroll(layer, ''), 0); }); +test('hydration IDs follow the loaded analysis, not a live CLI session', () => { + const data = { files: [{ path: 'src/index.js', name: 'index.js' }], connections: [] }; + const graph = context.analysisGraphKey(data); + const watchingGithub = { + localSourceKind: null, + cliOk: true, + cliRoot: '/watch', + githubOwner: 'owner', + githubRepo: 'alpha', + githubKey: 'owner/alpha' + }; + const alpha = context.loadedAnalysisSourceIdentity(watchingGithub); + const beta = context.loadedAnalysisSourceIdentity(Object.assign({}, watchingGithub, { + githubRepo: 'beta', + githubKey: 'owner/beta' + })); + assert.equal(alpha.sourceType, 'github'); + assert.equal(alpha.sourceKey, 'owner/alpha'); + assert.equal(beta.sourceKey, 'owner/beta'); + assert.notEqual( + context.analysisHydrationIdFromParts(alpha, graph), + context.analysisHydrationIdFromParts(beta, graph) + ); + const cli = context.loadedAnalysisSourceIdentity({ + localSourceKind: 'cli', + cliOk: true, + cliRoot: '/watch', + githubOwner: 'owner', + githubRepo: 'alpha', + githubKey: 'owner/alpha' + }); + assert.equal(cli.sourceType, 'cli'); + assert.equal(cli.sourceKey, '/watch'); +}); + +test('symbol list wheel stays native instead of panning the canvas', () => { + const list = { closest(sel) { return sel === '.code-sym-list' ? this : null; } }; + const chip = { closest(sel) { return sel === '.code-sym-list' ? list : null; } }; + const body = { closest(sel) { return sel === '.code-card.clipped .code-card-body' ? this : null; } }; + const canvas = { closest() { return null; } }; + assert.equal(context.isCodeCanvasNativeScrollTarget(list), true); + assert.equal(context.isCodeCanvasNativeScrollTarget(chip), true); + assert.equal(context.isCodeCanvasNativeScrollTarget(body), true); + assert.equal(context.isCodeCanvasNativeScrollTarget(canvas), false); + assert.equal(context.isCodeCanvasNativeScrollTarget(null), false); +}); + test('folder frames count as canvas background for deselect', () => { const svg = { id: 'svg' }; const hull = { @@ -832,8 +879,12 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /recordCodeSourceFailureIfCurrent/); assert.match(htmlSource, /hydrationRequestIsCurrent/); assert.match(htmlSource, /analysisGraphKey/); - assert.match(htmlSource, /analysisHydrationId\(currentAnalysisSource\(\),data\)/); - assert.doesNotMatch(htmlSource, /analysisHydrationId\(currentAnalysisSource\(\),data,folderFilter\)/); + assert.match(htmlSource, /analysisGraphIdentity/); + assert.match(htmlSource, /analysisHydrationIdFromParts/); + assert.match(htmlSource, /loadedAnalysisSourceIdentity/); + assert.match(htmlSource, /isCodeCanvasNativeScrollTarget/); + assert.match(htmlSource, /isCodeCanvasNativeScrollTarget\(e\.target\)/); + assert.doesNotMatch(htmlSource, /analysisHydrationId\(currentAnalysisSource\(\),data\)/); assert.match(htmlSource, /retryCodeSource/); assert.match(htmlSource, /sourceState==='failed'/); assert.match(htmlSource, /hiddenOpenedCodePaths/); From 9f1521e9e783515f486b3918ec15d81683267066 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 18:07:32 +0000 Subject: [PATCH 25/33] Route same-column Code card links through top and bottom edges Equal-x stacked cards no longer share a right-edge bezier whose target control sits under the card. Vertical pairs now anchor on facing top/bottom edges so the arrow approaches the target. Co-authored-by: Braedon Saunders --- index.html | 27 +++++++++++++++++++++++---- tests/code-canvas.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index c556c38..facec96 100644 --- a/index.html +++ b/index.html @@ -6193,8 +6193,20 @@ return CODE_CARD_HEAD_HEIGHT+8+(n-0.5)*CODE_CARD_LINE_HEIGHT; } +function codeLinkPrefersVertical(src,tgt){ + if(!src||!tgt||!isFinite(src.x)||!isFinite(tgt.x))return false; + return Math.abs(src.x-tgt.x)<1; +} + function codeEdgeBezier(x1,y1,x2,y2){ - var span=Math.max(80,Math.abs(x2-x1)*0.45); + var dxAbs=Math.abs(x2-x1); + var dyAbs=Math.abs(y2-y1); + if(dxAbs<1&&dyAbs>0){ + var vspan=Math.max(80,dyAbs*0.45); + var dy=(y2 { assert.equal(context.codeViewWheelAction({}), 'pan'); }); +test('same-folder stacked cards route links through top and bottom edges', () => { + const upper = { id: 'src/a.js', x: 300, y: 200 }; + const lower = { id: 'src/b.js', x: 300, y: 500 }; + const sizes = { + 'src/a.js': { width: 440, height: 200 }, + 'src/b.js': { width: 440, height: 200 } + }; + const files = { + 'src/a.js': { path: 'src/a.js', content: 'export function shared(){}\n', functions: [{ name: 'shared', line: 1 }] }, + 'src/b.js': { path: 'src/b.js', content: 'import { shared } from "./a.js";\n', functions: [] } + }; + const cards = new Set(['src/a.js', 'src/b.js']); + assert.equal(context.codeLinkPrefersVertical(upper, lower), true); + assert.equal(context.codeLinkPrefersVertical({ x: 220, y: 200 }, { x: 800, y: 240 }), false); + const stacked = context.codeEdgeBezier(300, 300, 300, 400); + assert.equal(stacked, 'M300,300C300,380 300,320 300,400'); + const path = context.codeCardLinkPath({ source: upper, target: lower, fn: 'shared' }, sizes, files, cards); + assert.equal(path, 'M300,300C300,380 300,320 300,400'); + const reverse = context.codeCardLinkPath({ source: lower, target: upper, fn: 'shared' }, sizes, files, cards); + assert.equal(reverse, 'M300,400C300,320 300,380 300,300'); + assert.ok(!path.includes('520,')); + assert.ok(!reverse.includes('80,')); +}); + test('opened code cards auto-align by directory', () => { const files = [ { path: 'src/a.js', folder: 'src' }, @@ -910,6 +934,7 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /applyOpenedCardPlacements/); assert.match(htmlSource, /codeCardLinkPath/); assert.match(htmlSource, /codeCardLinkEndpoint/); + assert.match(htmlSource, /codeLinkPrefersVertical/); assert.match(htmlSource, /code-line-pill/); assert.match(htmlSource, /defaultCodeViewSeed/); assert.match(htmlSource, /codeViewSeedPath/); From d64664abe38a80e0b4168c9327264c67fbf70d1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 20:06:06 +0000 Subject: [PATCH 26/33] Reveal out-of-filter Code targets and pin hydration to the loaded exclude set Insight-panel links that land outside the folder filter now clear it (and can replace at the cap) so the card renders. GitHub hydration identity uses data.excludePatterns until a new analysis completes. Co-authored-by: Braedon Saunders --- index.html | 45 ++++++++++++++++---- tests/code-canvas.test.mjs | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index facec96..7f4c432 100644 --- a/index.html +++ b/index.html @@ -5706,6 +5706,11 @@ return excl?base+'|excl:'+excl:base; } +function githubSourceKeyForLoadedAnalysis(owner,repo,data,pendingPatterns){ + var patterns=data&&data.excludePatterns!=null?data.excludePatterns:pendingPatterns; + return githubCacheSourceKey(owner,repo,patterns); +} + function cachedAnalysisMatchesExcludes(record,patterns){ if(!record)return false; var wanted=normalizeExcludeKey(patterns); @@ -5819,6 +5824,26 @@ return best.path; } +function fileMatchesFolderFilter(file,folderFilter){ + if(!folderFilter)return true; + if(!file)return false; + return file.folder===folderFilter||(!!file.folder&&file.folder.startsWith(folderFilter+'/')); +} + +function pathMatchesFolderFilter(path,data,folderFilter){ + if(!folderFilter)return true; + if(!data||!data.files||!path)return false; + for(var i=0;i