From 8ae82dda4741f18fd28f32295587bd33b5338037 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 18:14:23 +0000 Subject: [PATCH 1/3] Add user-named Code-view groups on top of directory hulls Let Code view create, rename, and delete named parent hulls, add or remove open cards, and persist those groups per analysis session. Named membership wins over directory auto-groups; dragging a card out drops it, and group drags move members without rebuilding hulls on pointermove. Co-authored-by: Braedon Saunders --- index.html | 520 +++++++++++++++++++++++++++++++++++-- tests/code-canvas.test.mjs | 133 +++++++++- 2 files changed, 625 insertions(+), 28 deletions(-) diff --git a/index.html b/index.html index 2a8aceb1..4382f0bb 100644 --- a/index.html +++ b/index.html @@ -525,8 +525,17 @@ .code-card.wrap .file-preview-line{align-items:flex-start;height:auto} .code-card.wrap .file-preview-text{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;min-width:0} .code-card-body .file-preview-code{padding:8px 0;font-size:11px} -.color-by.code-view-prefs{flex-direction:column;align-items:stretch;gap:2px;padding:3px 4px;max-width:min(280px,calc(100% - 24px))} -.code-view-prefs-row{display:flex;flex-direction:row;align-items:center;gap:2px} +.color-by.code-view-prefs{flex-direction:column;align-items:stretch;gap:2px;padding:3px 4px;max-width:min(320px,calc(100% - 24px))} +.code-view-prefs-row{display:flex;flex-direction:row;align-items:center;gap:2px;flex-wrap:wrap} +.code-named-group-list{display:flex;flex-wrap:wrap;gap:2px;max-width:100%} +.code-named-group-chip{display:inline-flex;align-items:center;gap:2px;max-width:100%} +.code-named-group-chip .color-by-btn{max-width:140px;overflow:hidden;text-overflow:ellipsis} +.code-card-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0} +.code-card-group{flex-shrink:0;display:flex;align-items:center} +.code-card-group-select{max-width:110px;font-size:8px;padding:2px 4px;border-radius:5px;border:1px solid var(--border);background:var(--bg0);color:var(--t2);font-family:inherit;cursor:pointer} +.code-card-group-select:focus{outline:none;border-color:var(--acc);color:var(--acc)} +.named-group-hull{cursor:grab} +.named-group-hull:active{cursor:grabbing} .code-card-resize{position:absolute;z-index:3;background:transparent} .code-card-resize-e{top:14px;right:-4px;width:10px;bottom:14px;cursor:ew-resize} .code-card-resize-s{left:14px;right:14px;bottom:-4px;height:10px;cursor:ns-resize} @@ -5746,6 +5755,175 @@ return writeUiPrefs(undefined,prefs); } +var NAMED_GROUPS_STORAGE_KEY='codeflow-code-named-groups'; +var NAMED_GROUP_NAME_MAX=32; +var NAMED_GROUP_COLORS=['#a78bfa','#22d3ee','#ff9f43','#ec4899','#c084fc','#f59e0b']; + +function namedGroupsSessionKey(sourceType,sourceKey){ + return analysisCacheKey(sourceType,sourceKey); +} + +function normalizeNamedGroupName(name){ + var label=String(name==null?'':name).replace(/\s+/g,' ').trim(); + if(!label)return ''; + if(label.length>NAMED_GROUP_NAME_MAX)label=label.slice(0,NAMED_GROUP_NAME_MAX); + return label; +} + +function newNamedGroupId(now,rand){ + now=now==null?Date.now():Number(now)||0; + var suffix=rand!=null?String(rand):Math.random().toString(36).slice(2,8); + return 'ng-'+now.toString(36)+'-'+suffix; +} + +function normalizeNamedGroup(group){ + if(!group||typeof group!=='object')return null; + var name=normalizeNamedGroupName(group.name); + if(!name)return null; + var id=String(group.id||'').trim(); + if(!id)return null; + var paths=[]; + var seen=Object.create(null); + (group.paths||[]).forEach(function(path){ + path=String(path||'').trim(); + if(!path||seen[path])return; + seen[path]=true; + paths.push(path); + }); + return{id:id,name:name,paths:paths}; +} + +function normalizeNamedGroups(groups){ + var out=[]; + var seen=Object.create(null); + (groups||[]).forEach(function(group){ + var next=normalizeNamedGroup(group); + if(!next||seen[next.id])return; + seen[next.id]=true; + out.push(next); + }); + return out; +} + +function namedGroupById(groups,id){ + id=String(id||''); + var found=null; + normalizeNamedGroups(groups).some(function(group){ + if(group.id===id){found=group;return true;} + return false; + }); + return found; +} + +function namedGroupIdForPath(groups,path){ + path=String(path||''); + if(!path)return null; + var id=null; + normalizeNamedGroups(groups).some(function(group){ + if(group.paths.indexOf(path)>=0){id=group.id;return true;} + return false; + }); + return id; +} + +function namedGroupColor(id,palette){ + palette=palette&&palette.length?palette:NAMED_GROUP_COLORS; + var hash=0; + String(id||'').split('').forEach(function(ch){hash=(hash*31+ch.charCodeAt(0))>>>0;}); + return palette[hash%palette.length]; +} + +function createNamedGroup(groups,name,id){ + var next=normalizeNamedGroups(groups); + var label=normalizeNamedGroupName(name); + if(!label)return{groups:next,group:null}; + var group={id:id||newNamedGroupId(),name:label,paths:[]}; + next.push(group); + return{groups:next,group:group}; +} + +function renameNamedGroup(groups,id,name){ + var label=normalizeNamedGroupName(name); + return normalizeNamedGroups(groups).map(function(group){ + if(group.id!==id)return group; + return{id:group.id,name:label||group.name,paths:group.paths.slice()}; + }); +} + +function deleteNamedGroup(groups,id){ + return normalizeNamedGroups(groups).filter(function(group){return group.id!==id;}); +} + +function addCardToNamedGroup(groups,groupId,path){ + path=String(path||'').trim(); + if(!path)return normalizeNamedGroups(groups); + return normalizeNamedGroups(groups).map(function(group){ + var paths=group.paths.filter(function(item){return item!==path;}); + if(group.id===groupId)paths.push(path); + return{id:group.id,name:group.name,paths:paths}; + }); +} + +function removeCardFromNamedGroup(groups,groupId,path){ + path=String(path||''); + return normalizeNamedGroups(groups).map(function(group){ + if(group.id!==groupId)return group; + return{id:group.id,name:group.name,paths:group.paths.filter(function(item){return item!==path;})}; + }); +} + +function setCardNamedGroup(groups,path,groupId){ + if(!groupId){ + var current=namedGroupIdForPath(groups,path); + return current?removeCardFromNamedGroup(groups,current,path):normalizeNamedGroups(groups); + } + return addCardToNamedGroup(groups,groupId,path); +} + +function promptNamedGroupName(host,current){ + try{ + if(!host||typeof host.prompt!=='function')return null; + var raw=host.prompt('Name this group',current||''); + if(raw==null)return null; + return normalizeNamedGroupName(raw); + }catch(e){ + return null; + } +} + +function readNamedGroupsStore(storage){ + try{ + var store=resolveUiPrefsStorage(storage); + if(!store)return{}; + var raw=store.getItem(NAMED_GROUPS_STORAGE_KEY); + if(!raw)return{}; + var parsed=JSON.parse(raw); + return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:{}; + }catch(e){ + return{}; + } +} + +function readNamedGroupsForSession(storage,sessionKey){ + if(!sessionKey)return[]; + var bucket=readNamedGroupsStore(storage)[sessionKey]; + if(Array.isArray(bucket))return normalizeNamedGroups(bucket); + if(bucket&&Array.isArray(bucket.groups))return normalizeNamedGroups(bucket.groups); + return[]; +} + +function writeNamedGroupsForSession(storage,sessionKey,groups){ + var next=normalizeNamedGroups(groups); + if(!sessionKey)return next; + var store=readNamedGroupsStore(storage); + store[sessionKey]=next; + try{ + var target=resolveUiPrefsStorage(storage); + if(target)target.setItem(NAMED_GROUPS_STORAGE_KEY,JSON.stringify(store)); + }catch(e){} + return next; +} + function graphLinkBaseWidth(count){ return Math.max(1,Math.min(2,Math.sqrt(count||1)*0.3)); } @@ -7688,6 +7866,90 @@ return unionPaddedBoxes(boxes,pad==null?40:pad,22); } +function partitionCodeViewHullMembers(nodes,cardPaths,groups){ + var named=Object.create(null); + normalizeNamedGroups(groups).forEach(function(group){ + named[group.id]={group:group,cards:[]}; + }); + var folders=Object.create(null); + var membership=Object.create(null); + (nodes||[]).forEach(function(node){ + if(!node)return; + var isCard=!!(cardPaths&&cardPaths.has&&cardPaths.has(node.id)); + var groupId=isCard?namedGroupIdForPath(groups,node.id):null; + if(groupId&&named[groupId]){ + named[groupId].cards.push(node); + membership[node.id]=groupId; + return; + } + var folder=node.folder||'root'; + if(!folders[folder])folders[folder]={cards:[],leftover:[]}; + if(isCard)folders[folder].cards.push(node); + else folders[folder].leftover.push(node); + }); + return{named:named,folders:folders,membership:membership}; +} + +function namedGroupHullBounds(cardNodes,sizesByPath,pad,boxesByPath){ + return codeFolderCardBounds(cardNodes,sizesByPath,pad==null?28:pad,boxesByPath); +} + +function namedGroupHullsFromMembers(named,sizesByPath,pad,boxesByPath){ + var out=[]; + Object.keys(named||{}).forEach(function(id){ + var entry=named[id]; + if(!entry||!entry.cards||!entry.cards.length)return; + var bounds=namedGroupHullBounds(entry.cards,sizesByPath,pad,boxesByPath); + if(!bounds)return; + out.push({ + id:id, + name:entry.group&&entry.group.name||id, + color:namedGroupColor(id), + x:bounds.x, + y:bounds.y, + width:bounds.width, + height:bounds.height + }); + }); + return out; +} + +function namedGroupDropRemovesCard(cardBox,remainingBoxes,pad){ + if(!cardBox)return false; + if(!remainingBoxes||!remainingBoxes.length)return false; + var hull=unionPaddedBoxes(remainingBoxes,pad==null?28:pad,22); + if(!hull)return false; + var cx=cardBox.x+cardBox.width/2; + var cy=cardBox.y+cardBox.height/2; + return cxhull.x+hull.width||cy>hull.y+hull.height; +} + +function translateNamedGroupCards(nodes,paths,dx,dy){ + dx=Number(dx)||0; + dy=Number(dy)||0; + if(!dx&&!dy)return[]; + var want=Object.create(null); + (paths||[]).forEach(function(path){if(path)want[path]=true;}); + var moved=[]; + (nodes||[]).forEach(function(node){ + if(!node||!want[node.id])return; + if(isFinite(node.x))node.x+=dx; + if(isFinite(node.y))node.y+=dy; + node.fx=node.x; + node.fy=node.y; + moved.push(node); + }); + return moved; +} + +function applyNamedGroupHullFrame(root,groupId,dx,dy){ + if(!root||!groupId)return false; + var el=root.querySelector?root.querySelector('[data-named-group="'+String(groupId).replace(/"/g,'')+'"]'):null; + if(!el||!el.setAttribute)return false; + el.setAttribute('transform','translate('+(Number(dx)||0)+','+(Number(dy)||0)+')'); + return true; +} + function codeCanvasTransformStyle(transform){ var t=transform||{}; var k=Number(t.k); @@ -7830,10 +8092,12 @@ return minimapWorldFromBoxes(boxes,pad); } -function collectMinimapContent(nodes,sizesByPath,cardPaths,colorOf,pad){ +function collectMinimapContent(nodes,sizesByPath,cardPaths,colorOf,pad,namedGroups){ var marks=[]; var boxes=[]; var folders=Object.create(null); + var named=Object.create(null); + var grouped=namedGroups&&namedGroups.length?partitionCodeViewHullMembers(nodes,cardPaths,namedGroups):null; (nodes||[]).forEach(function(node){ var xy=liveGraphNodeXY(node); if(!xy)return; @@ -7849,16 +8113,27 @@ box={x:xy.x-16,y:xy.y-16,width:32,height:32}; marks.push({kind:'node',x:xy.x,y:xy.y,color:color,folder:folder}); } - if(!folders[folder])folders[folder]={boxes:[],color:color}; - folders[folder].boxes.push(box); + var namedId=grouped&&grouped.membership[node.id]; + if(namedId){ + if(!named[namedId])named[namedId]={boxes:[],color:namedGroupColor(namedId)}; + named[namedId].boxes.push(box); + if(color)named[namedId].color=namedGroupColor(namedId); + }else{ + if(!folders[folder])folders[folder]={boxes:[],color:color}; + folders[folder].boxes.push(box); + if(color)folders[folder].color=color; + } boxes.push(box); - if(color)folders[folder].color=color; }); var hulls=[]; Object.keys(folders).forEach(function(folder){ var union=unionPaddedBoxes(folders[folder].boxes,18,0); if(union)hulls.push({folder:folder,color:folders[folder].color,x:union.x,y:union.y,width:union.width,height:union.height}); }); + Object.keys(named).forEach(function(id){ + var union=unionPaddedBoxes(named[id].boxes,18,0); + if(union)hulls.push({id:id,kind:'named',color:named[id].color,x:union.x,y:union.y,width:union.width,height:union.height}); + }); return{marks:marks,hulls:hulls,world:minimapWorldFromBoxes(boxes,pad)}; } @@ -9057,6 +9332,10 @@ var codeCanvasRef=useRef(null); var codeSourceInFlightRef=useRef(Object.create(null)); var updateHullsRef=useRef(null); + var beginNamedGroupDragRef=useRef(null); + var namedCodeGroupsRef=useRef([]); + var namedGroupsSessionRef=useRef(''); + var armedNamedGroupIdRef=useRef(null); var drawMinimapRef=useRef(null); var minimapHostRef=useRef(null); var minimapCanvasRef=useRef(null); @@ -9084,6 +9363,10 @@ var _codeExpand=useState(false),codeViewExpand=_codeExpand[0],setCodeViewExpand=_codeExpand[1]; var _codeWrap=useState(true),codeViewWrap=_codeWrap[0],setCodeViewWrap=_codeWrap[1]; var _codeGate=useState(readUiPrefs().codeViewRootGate),codeViewRootGate=_codeGate[0],setCodeViewRootGate=_codeGate[1]; + var _namedGroups=useState([]),namedCodeGroups=_namedGroups[0],setNamedCodeGroups=_namedGroups[1]; + var _armedNamedGroup=useState(null),armedNamedGroupId=_armedNamedGroup[0],setArmedNamedGroupId=_armedNamedGroup[1]; + namedCodeGroupsRef.current=namedCodeGroups; + armedNamedGroupIdRef.current=armedNamedGroupId; var _lineThick=useState(readUiPrefs().lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; var lineThicknessRef=useRef(lineThickness); lineThicknessRef.current=lineThickness; @@ -9313,6 +9596,18 @@ return analysisHydrationIdFromParts(loadedSourceIdentity,analysisGraphIdentity); },[loadedSourceIdentity,analysisGraphIdentity]); analysisHydrationIdRef.current=currentHydrationId; + var namedGroupsSession=useMemo(function(){ + if(!loadedSourceIdentity)return ''; + return namedGroupsSessionKey(loadedSourceIdentity.sourceType,loadedSourceIdentity.sourceKey); + },[loadedSourceIdentity&&loadedSourceIdentity.sourceType,loadedSourceIdentity&&loadedSourceIdentity.sourceKey]); + useEffect(function(){ + if(namedGroupsSession===namedGroupsSessionRef.current)return; + namedGroupsSessionRef.current=namedGroupsSession; + var restored=namedGroupsSession?readNamedGroupsForSession(undefined,namedGroupsSession):[]; + namedCodeGroupsRef.current=restored; + setNamedCodeGroups(restored); + setArmedNamedGroupId(null); + },[namedGroupsSession]); function refreshRecentList(){ listRecentAnalyses().then(function(rows){ @@ -10827,6 +11122,56 @@ var next=persistUiPrefs({codeViewRootGate:value}).codeViewRootGate; setCodeViewRootGate(next); } + function commitNamedGroups(next){ + var groups=normalizeNamedGroups(next); + namedCodeGroupsRef.current=groups; + setNamedCodeGroups(groups); + if(namedGroupsSessionRef.current)writeNamedGroupsForSession(undefined,namedGroupsSessionRef.current,groups); + if(updateHullsRef.current)updateHullsRef.current(); + if(drawMinimapRef.current)drawMinimapRef.current(); + return groups; + } + function requestNamedGroupName(current){ + return promptNamedGroupName(typeof window!=='undefined'?window:null,current||''); + } + function createCodeNamedGroup(){ + var name=requestNamedGroupName(''); + if(name==null)return; + if(!name){ + showNotification('Group name cannot be empty','warning'); + return; + } + var made=createNamedGroup(namedCodeGroupsRef.current,name); + if(!made.group)return; + var next=made.groups; + if(selected&&selected.path&&codeCardPathsRef.current.has(selected.path)){ + next=addCardToNamedGroup(next,made.group.id,selected.path); + } + commitNamedGroups(next); + setArmedNamedGroupId(null); + } + function renameCodeNamedGroup(id){ + var group=namedGroupById(namedCodeGroupsRef.current,id); + if(!group)return; + var name=requestNamedGroupName(group.name); + if(name==null)return; + if(!name){ + showNotification('Group name cannot be empty','warning'); + return; + } + commitNamedGroups(renameNamedGroup(namedCodeGroupsRef.current,id,name)); + setArmedNamedGroupId(null); + } + function deleteCodeNamedGroup(id){ + var armed=armRecentDelete(armedNamedGroupId,id); + setArmedNamedGroupId(armed.armedId); + if(!armed.confirm)return; + commitNamedGroups(deleteNamedGroup(namedCodeGroupsRef.current,id)); + } + function assignCardNamedGroup(path,groupId){ + if(!path)return; + commitNamedGroups(setCardNamedGroup(namedCodeGroupsRef.current,path,groupId||null)); + } function applyLinkThickness(){ if(vizUsesForceLinkParticles(graphConfig.vizType))applyForceLinkVisuals(); else if(linksRef.current)linksRef.current.attr('stroke-width',function(d){return graphLinkStrokeWidth(d.count,lineThicknessRef.current);}); @@ -10851,7 +11196,7 @@ var viewH=svg.clientHeight||600; var nodes=simRef.current?simRef.current.nodes():[]; var overlay=minimapCardInputs(graphConfig.vizType,codeCardSizesRef.current,codeCardPathsRef.current); - var content=collectMinimapContent(nodes,overlay.sizesByPath,overlay.cardPaths,getNodeColor); + var content=collectMinimapContent(nodes,overlay.sizesByPath,overlay.cardPaths,getNodeColor,undefined,graphConfig.vizType==='code'?namedCodeGroupsRef.current:null); if(!content.world||!content.world.width){ minimapModelRef.current=null; clearCanvasMinimap(canvas); @@ -11324,13 +11669,22 @@ } function updateHulls(){ hullLayer.selectAll('*').remove(); - var grouped=liveNodesByFolder(); + var live=(simRef.current&&simRef.current.nodes())||nodes; var boxesByPath=keepReadable?readCodeCardWorldBoxes(codeCardsLayerRef.current):null; + var part=keepReadable?partitionCodeViewHullMembers(live,codeCardPathsRef.current,namedCodeGroupsRef.current):null; + var grouped=part?part.folders:null; + if(!grouped){ + var fallback=liveNodesByFolder(); + grouped=Object.create(null); + Object.keys(fallback).forEach(function(folder){ + grouped[folder]={cards:[],leftover:fallback[folder]}; + }); + } Object.keys(grouped).forEach(function(f){ - var fn=grouped[f]; - if(!fn||fn.length<1)return; - var cardNodes=keepReadable?fn.filter(function(n){return codeCardPathsRef.current.has(n.id);}):[]; - var leftover=keepReadable?fn.filter(function(n){return !codeCardPathsRef.current.has(n.id);}):fn; + var bucket=grouped[f]||{}; + var cardNodes=keepReadable?bucket.cards||[]:[]; + var leftover=keepReadable?bucket.leftover||[]:bucket.leftover||[]; + if(!cardNodes.length&&!leftover.length)return; var color=colorMap[f]||COLORS[folders.indexOf(f)%COLORS.length]; if(cardNodes.length){ var bounds=codeFolderHullBounds(cardNodes,leftover,codeCardSizesRef.current,28,boxesByPath); @@ -11354,6 +11708,20 @@ 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'); } }); + if(part){ + namedGroupHullsFromMembers(part.named,codeCardSizesRef.current,28,boxesByPath).forEach(function(namedHull){ + var color=namedHull.color; + var g=hullLayer.append('g').attr('class','named-group-hull').attr('data-named-group',namedHull.id).style('pointer-events','all').style('cursor','grab'); + g.append('rect').attr('x',namedHull.x).attr('y',namedHull.y).attr('width',namedHull.width).attr('height',namedHull.height).attr('rx',14).attr('fill',color).attr('fill-opacity',0.08).attr('stroke',color).attr('stroke-width',2).attr('stroke-opacity',0.55).attr('stroke-dasharray','6 4'); + g.append('text').attr('class','hull-label').attr('x',namedHull.x+12).attr('y',namedHull.y+16).attr('text-anchor','start').attr('fill',color).attr('font-size','12px').attr('font-family','JetBrains Mono').attr('font-weight','600').attr('opacity',0.95).text(namedHull.name); + g.on('pointerdown',function(event){ + if(event.button!=null&&event.button!==0)return; + event.preventDefault(); + event.stopPropagation(); + if(beginNamedGroupDragRef.current)beginNamedGroupDragRef.current(event,namedHull.id); + }); + }); + } } // Throttle hull updates for large graphs (every N ticks instead of every tick) var hullInterval=isLargeGraph?5:1; @@ -11406,7 +11774,7 @@ syncCodeCards(); if(updateHullsRef.current)updateHullsRef.current(); if(drawMinimapRef.current)drawMinimapRef.current(); - },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath]); + },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath,namedCodeGroups]); useEffect(function(){ return subscribePrefersReducedMotion(function(){ @@ -12910,6 +13278,18 @@ CODE_VIEW_ROOT_GATE_STEPS.map(function(n){ return React.createElement('button',{key:n,type:'button',className:'color-by-btn'+(codeViewRootGate===n?' active':''),'aria-pressed':codeViewRootGate===n?'true':'false',title:'Skip Code cards when this many files sit at the project root',onClick:function(){persistCodeViewRootGate(n);}},String(n)); }) + ), + React.createElement('div',{className:'code-view-prefs-row',role:'group','aria-label':'Named groups'}, + React.createElement('span',{className:'color-by-label',title:'Create a parent hull and drop open cards into it'},'Groups'), + React.createElement('button',{type:'button',className:'color-by-btn',onClick:createCodeNamedGroup},'New Group'), + React.createElement('div',{className:'code-named-group-list'}, + namedCodeGroups.map(function(group){ + return React.createElement('div',{key:group.id,className:'code-named-group-chip'}, + React.createElement('button',{type:'button',className:'color-by-btn',title:'Rename '+group.name,onClick:function(){renameCodeNamedGroup(group.id);}},group.name), + React.createElement('button',{type:'button',className:'color-by-btn'+(armedNamedGroupId===group.id?' active':''),title:armedNamedGroupId===group.id?'Click again to delete':'Delete '+group.name,onClick:function(){deleteCodeNamedGroup(group.id);}},armedNamedGroupId===group.id?'Confirm?':'×') + ); + }) + ) ) ); } @@ -12970,6 +13350,75 @@ 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 writeCodeCardPlacement(file,node){ + if(!file||!node)return; + var size=codeCardSizesRef.current[file.path]||codeCardSize(file,currentCodeCardPrefs()); + var prev=codeCardPlacementRef.current[file.path]||{}; + codeCardPlacementRef.current[file.path]={ + left:node.fx-size.width/2, + top:node.fy-size.height/2, + x:node.fx, + y:node.fy, + width:size.width, + height:size.height, + folder:file.folder||prev.folder||'root' + }; + } + function beginNamedGroupDrag(e,groupId){ + if(e.pointerType==='mouse'&&e.button!==0)return; + var group=namedGroupById(namedCodeGroupsRef.current,groupId); + if(!group||!group.paths.length)return; + var transform=codeZoomTransformRef.current||{k:1,x:0,y:0}; + var k=Number(transform.k); + if(!isFinite(k)||k<=0)k=1; + var startX=e.clientX,startY=e.clientY,lastX=0,lastY=0,moved=false; + var nodes=(simRef.current&&simRef.current.nodes())||[]; + var paths=group.paths.slice(); + function onMove(ev){ + var delta=codeCardDragDelta(ev.clientX,ev.clientY,startX,startY,k,3); + var dx=delta.x-lastX,dy=delta.y-lastY; + lastX=delta.x;lastY=delta.y; + if(delta.moved)moved=true; + var movedNodes=translateNamedGroupCards(nodes,paths,dx,dy); + movedNodes.forEach(function(node){ + codeCardUserPinnedRef.current.add(node.id); + writeCodeCardPlacement({path:node.id,folder:node.folder},node); + applyCodeCardDragFrame(codeCardsLayerRef.current,node.id,node,codeCardSizesRef.current[node.id]); + }); + applyNamedGroupHullFrame(svgRef.current,groupId,lastX,lastY); + if(codeViewDragRefresh('move')){ + syncCodeCards(); + redrawGraphLinksAndNodes(); + if(updateHullsRef.current)updateHullsRef.current(); + }else{ + redrawMovedGraphNodes(movedNodes); + } + if(drawMinimapRef.current)drawMinimapRef.current(); + } + function onUp(){ + window.removeEventListener('pointermove',onMove); + window.removeEventListener('pointerup',onUp); + window.removeEventListener('pointercancel',onUp); + if(!moved){ + renameCodeNamedGroup(groupId); + return; + } + paths.forEach(function(path){codeCardUserPinnedRef.current.add(path);}); + if(codeViewDragRefresh('release')){ + settleCodeViewAfterDrag(nodes,codeCardPathsRef.current,codeCardSizesRef.current,null,{ + boxesByPath:readCodeCardWorldBoxes(codeCardsLayerRef.current) + }); + } + syncCodeCards(); + redrawGraphLinksAndNodes(); + if(updateHullsRef.current)updateHullsRef.current(); + if(drawMinimapRef.current)drawMinimapRef.current(); + } + window.addEventListener('pointermove',onMove); + window.addEventListener('pointerup',onUp); + window.addEventListener('pointercancel',onUp); + } + beginNamedGroupDragRef.current=beginNamedGroupDrag; function beginCodeCardDrag(e,file){ if(e.pointerType==='mouse'&&e.button!==0)return; e.stopPropagation(); @@ -12986,17 +13435,7 @@ codeCardStackRef.current=raiseCodeCardStack(codeCardStackRef.current,file.path); applyCodeCardStackOrder(codeCardsLayerRef.current,codeCardStackRef.current); function writePlacement(){ - var size=codeCardSizesRef.current[file.path]||codeCardSize(file,currentCodeCardPrefs()); - var prev=codeCardPlacementRef.current[file.path]||{}; - codeCardPlacementRef.current[file.path]={ - left:node.fx-size.width/2, - top:node.fy-size.height/2, - x:node.fx, - y:node.fy, - width:size.width, - height:size.height, - folder:file.folder||prev.folder||'root' - }; + writeCodeCardPlacement(file,node); } function onMove(ev){ var delta=codeCardDragDelta(ev.clientX,ev.clientY,startX,startY,k,3); @@ -13032,6 +13471,20 @@ } if(moved){ codeCardUserPinnedRef.current.add(file.path); + var groups=namedCodeGroupsRef.current; + var gid=namedGroupIdForPath(groups,file.path); + if(gid){ + var live=(simRef.current&&simRef.current.nodes())||[]; + var otherBoxes=[]; + live.forEach(function(other){ + if(!other||other.id===file.path||namedGroupIdForPath(groups,other.id)!==gid)return; + var box=cardWorldBox(other,codeCardSizesRef.current,null); + if(box)otherBoxes.push(box); + }); + if(namedGroupDropRemovesCard(cardWorldBox(node,codeCardSizesRef.current,null),otherBoxes)){ + commitNamedGroups(removeCardFromNamedGroup(groups,gid,file.path)); + } + } if(codeViewDragRefresh('release')){ settleCodeViewAfterDrag((simRef.current&&simRef.current.nodes())||[],codeCardPathsRef.current,codeCardSizesRef.current,file.path,{ boxesByPath:readCodeCardWorldBoxes(codeCardsLayerRef.current) @@ -13193,7 +13646,22 @@ 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':'open') + React.createElement('div',{className:'code-card-head-actions'}, + namedCodeGroups.length?React.createElement('label',{className:'code-card-group',onPointerDown:function(e){e.stopPropagation();},onClick:function(e){e.stopPropagation();}}, + React.createElement('select',{ + className:'code-card-group-select', + 'aria-label':'Named group', + value:namedGroupIdForPath(namedCodeGroups,file.path)||'', + onChange:function(e){assignCardNamedGroup(file.path,e.target.value);} + }, + React.createElement('option',{value:''},'No group'), + namedCodeGroups.map(function(group){ + return React.createElement('option',{key:group.id,value:group.id},group.name); + }) + ) + ):null, + 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; @@ -13235,7 +13703,7 @@ React.createElement('div',{className:'empty-desc'},codeViewRootGateMessage(rootCount,codeViewRootGate)) ), React.createElement('div',{className:'code-canvas-hud'}, - React.createElement('div',{className:'code-canvas-hint'},codeRootGateActive?codeViewRootGateMessage(rootCount,codeViewRootGate):(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.')), + React.createElement('div',{className:'code-canvas-hint'},codeRootGateActive?codeViewRootGateMessage(rootCount,codeViewRootGate):(codeViewFiles.length?'Open files from leftover nodes or the Files tree. Open cards stay put. New Group wraps selected cards. 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 243c3580..13cf2255 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1893,7 +1893,7 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /codeCardSizeForDiff\(file,cardPrefs,diffRows\)/); assert.match(htmlSource, /applyCodeCardUserSize\(codeCardSizeForDiff\(file,currentCodeCardPrefs\(\),codeCardDiffRows\(file,cliLiveByPath\[file\.path\]\)\),nextSize\)/); assert.match(htmlSource, /applyCodeCardResizeFrame\(findCodeCardElement\(codeCardsLayerRef\.current,file\.path\),codeCardSizesRef\.current\[file\.path\]\)/); - assert.match(htmlSource, /\[codeViewFiles,graphConfig\.vizType,graphConfig\.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath\]/); + assert.match(htmlSource, /\[codeViewFiles,graphConfig\.vizType,graphConfig\.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath,namedCodeGroups\]/); assert.match(htmlSource, /codeCardSymbolPills\(file,data\?data\.connections:\[\],cardPrefs,diffRows\)/); assert.match(htmlSource, /codeColorBlockSections\(file,data\?data\.connections:\[\],cardPrefs,diffRows\)/); assert.match(htmlSource, /cliDiffEpochRef\.current=bumpCliWatchDiffEpoch\(cliDiffEpochRef\.current\)/); @@ -1937,6 +1937,18 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /className:'code-view-prefs-row'/); assert.match(htmlSource, /Load Files/); assert.match(htmlSource, /persistCodeViewRootGate\(n\)/); + assert.match(htmlSource, /'aria-label':'Named groups'/); + assert.match(htmlSource, /New Group/); + assert.match(htmlSource, /function createCodeNamedGroup\(/); + assert.match(htmlSource, /function renameCodeNamedGroup\(/); + assert.match(htmlSource, /function deleteCodeNamedGroup\(/); + assert.match(htmlSource, /function assignCardNamedGroup\(/); + assert.match(htmlSource, /function beginNamedGroupDrag\(/); + assert.match(htmlSource, /function partitionCodeViewHullMembers\(/); + assert.match(htmlSource, /function namedGroupDropRemovesCard\(/); + assert.match(htmlSource, /code-card-group-select/); + assert.match(htmlSource, /data-named-group/); + assert.match(htmlSource, /stroke-dasharray','6 4'/); assert.doesNotMatch(htmlSource, /className:'code-view-gate-select'/); assert.doesNotMatch(htmlSource, /code-view-prefs[\s\S]{0,400}createElement\('select'/); assert.match(htmlSource, /Pick a folder or file/); @@ -1981,7 +1993,7 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /readCodeCardWorldBoxes\(codeCardsLayerRef\.current\)/); assert.match(htmlSource, /codeFolderHullBounds\(cardNodes,leftover/); assert.match(htmlSource, /if\(updateHullsRef\.current\)updateHullsRef\.current\(\)/); - assert.match(htmlSource, /codeViewExpand,codeViewWrap,cliLiveByPath/); + assert.match(htmlSource, /codeViewExpand,codeViewWrap,cliLiveByPath,namedCodeGroups/); assert.match(htmlSource, /cardSize\.expand\?' expand'/); assert.match(htmlSource, /cardSize\.wrap\?' wrap'/); assert.match(htmlSource, /\.code-card\.wrap \.file-preview-text/); @@ -2436,3 +2448,120 @@ test('UI prefs keep the default when localStorage access throws', () => { delete context.window; } }); + +test('named groups create, rename, delete, and keep exclusive card membership', () => { + assert.equal(context.normalizeNamedGroupName(' Auth flow '), 'Auth flow'); + assert.equal(context.normalizeNamedGroupName('x'.repeat(40)).length, context.NAMED_GROUP_NAME_MAX); + assert.equal(context.normalizeNamedGroupName(' '), ''); + const empty = context.createNamedGroup([], ' '); + assert.equal(empty.group, null); + const made = context.createNamedGroup([], 'Auth', 'g-auth'); + assert.equal(made.group.id, 'g-auth'); + assert.equal(made.group.paths.length, 0); + let groups = context.addCardToNamedGroup(made.groups, 'g-auth', 'src/a.js'); + groups = context.addCardToNamedGroup(groups, 'g-auth', 'src/b.js'); + groups = context.createNamedGroup(groups, 'UI', 'g-ui').groups; + groups = context.addCardToNamedGroup(groups, 'g-ui', 'src/a.js'); + assert.deepEqual(J(context.namedGroupById(groups, 'g-auth').paths), ['src/b.js']); + assert.deepEqual(J(context.namedGroupById(groups, 'g-ui').paths), ['src/a.js']); + assert.equal(context.namedGroupIdForPath(groups, 'src/a.js'), 'g-ui'); + groups = context.setCardNamedGroup(groups, 'src/a.js', ''); + assert.equal(context.namedGroupIdForPath(groups, 'src/a.js'), null); + groups = context.renameNamedGroup(groups, 'g-auth', 'Session'); + assert.equal(context.namedGroupById(groups, 'g-auth').name, 'Session'); + groups = context.deleteNamedGroup(groups, 'g-ui'); + assert.equal(context.namedGroupById(groups, 'g-ui'), null); + assert.equal(context.promptNamedGroupName({ prompt: () => ' Core ' }, ''), 'Core'); + assert.equal(context.promptNamedGroupName({ prompt: () => null }, 'Auth'), null); +}); + +test('named-group hulls win over directory hulls and drop only when dragged out', () => { + const groups = [ + { id: 'g1', name: 'Auth', paths: ['src/a.js', 'lib/c.js'] } + ]; + const nodes = [ + { id: 'src/a.js', folder: 'src', x: 200, y: 200 }, + { id: 'src/b.js', folder: 'src', x: 800, y: 200 }, + { id: 'lib/c.js', folder: 'lib', x: 200, y: 700 }, + { id: 'src/left.js', folder: 'src', x: 1200, y: 200 } + ]; + const cards = new Set(['src/a.js', 'src/b.js', 'lib/c.js']); + const part = context.partitionCodeViewHullMembers(nodes, cards, groups); + assert.equal(part.named.g1.cards.map((n) => n.id).sort().join(','), 'lib/c.js,src/a.js'); + assert.equal(part.folders.src.cards.length, 1); + assert.equal(part.folders.src.cards[0].id, 'src/b.js'); + assert.equal(part.folders.lib, undefined); + assert.equal(part.folders.src.leftover[0].id, 'src/left.js'); + const sizes = { + 'src/a.js': { width: 200, height: 120 }, + 'src/b.js': { width: 200, height: 120 }, + 'lib/c.js': { width: 200, height: 120 } + }; + const namedHulls = context.namedGroupHullsFromMembers(part.named, sizes, 10); + assert.equal(namedHulls.length, 1); + assert.equal(namedHulls[0].name, 'Auth'); + assert.ok(namedHulls[0].height > 400); + const dirHulls = context.codeFolderHullsByFolder(part.folders, sizes, 10); + assert.ok(dirHulls.src); + assert.equal(dirHulls.lib == null, true); + const stay = context.namedGroupDropRemovesCard( + { x: 160, y: 150, width: 200, height: 120 }, + [{ x: 100, y: 140, width: 200, height: 120 }], + 10 + ); + assert.equal(stay, false); + const leave = context.namedGroupDropRemovesCard( + { x: 1400, y: 900, width: 200, height: 120 }, + [{ x: 100, y: 140, width: 200, height: 120 }], + 10 + ); + assert.equal(leave, true); + assert.equal(context.namedGroupDropRemovesCard({ x: 10, y: 10, width: 40, height: 40 }, [], 10), false); + const moved = context.translateNamedGroupCards(nodes, ['src/a.js', 'lib/c.js'], 40, -15); + assert.equal(moved.length, 2); + assert.equal(nodes[0].x, 240); + assert.equal(nodes[2].y, 685); + assert.equal(nodes[1].x, 800); + const hullEl = { setAttribute(name, value) { this[name] = value; } }; + const root = { querySelector: () => hullEl }; + assert.equal(context.applyNamedGroupHullFrame(root, 'g1', 12, 8), true); + assert.equal(hullEl.transform, 'translate(12,8)'); +}); + +test('named groups persist per analysis session in localStorage', () => { + const storage = memoryStorage(); + const key = context.namedGroupsSessionKey('github', 'owner/repo'); + assert.equal(key, 'github:owner/repo'); + const written = context.writeNamedGroupsForSession(storage, key, [ + { id: 'g1', name: ' Auth ', paths: ['src/a.js', 'src/a.js', ''] } + ]); + assert.equal(written[0].name, 'Auth'); + assert.deepEqual(J(written[0].paths), ['src/a.js']); + assert.deepEqual(J(context.readNamedGroupsForSession(storage, key)), J(written)); + assert.deepEqual(J(context.readNamedGroupsForSession(storage, 'github:other/repo')), []); + assert.deepEqual(J(context.readNamedGroupsForSession(storage, '')), []); + storage.setItem(context.NAMED_GROUPS_STORAGE_KEY, '{not-json'); + assert.deepEqual(J(context.readNamedGroupsForSession(storage, key)), []); + context.window = throwingLocalStorageWindow(); + try { + assert.doesNotThrow(() => context.readNamedGroupsForSession(undefined, key)); + assert.deepEqual(J(context.writeNamedGroupsForSession(undefined, key, [{ id: 'g2', name: 'UI', paths: [] }])), [ + { id: 'g2', name: 'UI', paths: [] } + ]); + } finally { + delete context.window; + } + const content = context.collectMinimapContent( + [ + { id: 'src/a.js', folder: 'src', x: 0, y: 0 }, + { id: 'src/b.js', folder: 'src', x: 400, y: 0 } + ], + { 'src/a.js': { width: 200, height: 100 }, 'src/b.js': { width: 200, height: 100 } }, + new Set(['src/a.js', 'src/b.js']), + () => '#4d9fff', + 0, + [{ id: 'g1', name: 'Auth', paths: ['src/a.js'] }] + ); + assert.equal(content.hulls.some((h) => h.kind === 'named'), true); + assert.equal(content.hulls.some((h) => h.folder === 'src'), true); +}); From 871509f858dd429f01e5f845c5aae7f890905987 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 02:13:09 +0000 Subject: [PATCH 2/3] Replace named-group prompts with in-app dialog and viewport placement New Group and Rename now use a Codeflow modal instead of window.prompt. Empty and off-screen groups are created at the current canvas viewport so they are visible immediately, including a placeholder hull on the minimap. Co-authored-by: Braedon Saunders --- index.html | 255 +++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 74 ++++++++++- 2 files changed, 287 insertions(+), 42 deletions(-) diff --git a/index.html b/index.html index 4382f0bb..9d264cfd 100644 --- a/index.html +++ b/index.html @@ -536,6 +536,7 @@ .code-card-group-select:focus{outline:none;border-color:var(--acc);color:var(--acc)} .named-group-hull{cursor:grab} .named-group-hull:active{cursor:grabbing} +.named-group-name-modal{max-width:400px} .code-card-resize{position:absolute;z-index:3;background:transparent} .code-card-resize-e{top:14px;right:-4px;width:10px;bottom:14px;cursor:ew-resize} .code-card-resize-s{left:14px;right:14px;bottom:-4px;height:10px;cursor:ns-resize} @@ -5757,6 +5758,8 @@ var NAMED_GROUPS_STORAGE_KEY='codeflow-code-named-groups'; var NAMED_GROUP_NAME_MAX=32; +var NAMED_GROUP_PLACEHOLDER_W=280; +var NAMED_GROUP_PLACEHOLDER_H=160; var NAMED_GROUP_COLORS=['#a78bfa','#22d3ee','#ff9f43','#ec4899','#c084fc','#f59e0b']; function namedGroupsSessionKey(sourceType,sourceKey){ @@ -5776,6 +5779,21 @@ return 'ng-'+now.toString(36)+'-'+suffix; } +function namedGroupPoint(value){ + if(!value||typeof value!=='object')return null; + var x=Number(value.x),y=Number(value.y); + if(!isFinite(x)||!isFinite(y))return null; + return{x:x,y:y}; +} + +function namedGroupRecord(group,patch){ + var next={id:group.id,name:group.name,paths:(patch&&patch.paths?patch.paths:group.paths).slice()}; + if(patch&&patch.name!=null)next.name=patch.name; + var origin=namedGroupPoint(patch)||namedGroupPoint(group); + if(origin){next.x=origin.x;next.y=origin.y;} + return next; +} + function normalizeNamedGroup(group){ if(!group||typeof group!=='object')return null; var name=normalizeNamedGroupName(group.name); @@ -5790,7 +5808,7 @@ seen[path]=true; paths.push(path); }); - return{id:id,name:name,paths:paths}; + return namedGroupRecord({id:id,name:name,paths:paths},namedGroupPoint(group)); } function normalizeNamedGroups(groups){ @@ -5833,11 +5851,11 @@ return palette[hash%palette.length]; } -function createNamedGroup(groups,name,id){ +function createNamedGroup(groups,name,id,origin){ var next=normalizeNamedGroups(groups); var label=normalizeNamedGroupName(name); if(!label)return{groups:next,group:null}; - var group={id:id||newNamedGroupId(),name:label,paths:[]}; + var group=namedGroupRecord({id:id||newNamedGroupId(),name:label,paths:[]},namedGroupPoint(origin)); next.push(group); return{groups:next,group:group}; } @@ -5846,7 +5864,7 @@ var label=normalizeNamedGroupName(name); return normalizeNamedGroups(groups).map(function(group){ if(group.id!==id)return group; - return{id:group.id,name:label||group.name,paths:group.paths.slice()}; + return namedGroupRecord(group,{name:label||group.name}); }); } @@ -5860,7 +5878,7 @@ return normalizeNamedGroups(groups).map(function(group){ var paths=group.paths.filter(function(item){return item!==path;}); if(group.id===groupId)paths.push(path); - return{id:group.id,name:group.name,paths:paths}; + return namedGroupRecord(group,{paths:paths}); }); } @@ -5868,7 +5886,7 @@ path=String(path||''); return normalizeNamedGroups(groups).map(function(group){ if(group.id!==groupId)return group; - return{id:group.id,name:group.name,paths:group.paths.filter(function(item){return item!==path;})}; + return namedGroupRecord(group,{paths:group.paths.filter(function(item){return item!==path;})}); }); } @@ -5880,15 +5898,63 @@ return addCardToNamedGroup(groups,groupId,path); } -function promptNamedGroupName(host,current){ - try{ - if(!host||typeof host.prompt!=='function')return null; - var raw=host.prompt('Name this group',current||''); - if(raw==null)return null; - return normalizeNamedGroupName(raw); - }catch(e){ - return null; - } +function setNamedGroupOrigin(groups,id,origin){ + var point=namedGroupPoint(origin); + return normalizeNamedGroups(groups).map(function(group){ + if(group.id!==id||!point)return group; + return namedGroupRecord(group,point); + }); +} + +function namedGroupViewportCenter(transform,viewW,viewH){ + var view=viewportWorldRect(transform,viewW,viewH); + return{x:view.x+view.width/2,y:view.y+view.height/2}; +} + +function namedGroupPlaceholderBounds(origin,width,height){ + var w=Number(width); + var h=Number(height); + if(!isFinite(w)||w<=0)w=NAMED_GROUP_PLACEHOLDER_W; + if(!isFinite(h)||h<=0)h=NAMED_GROUP_PLACEHOLDER_H; + var point=namedGroupPoint(origin); + if(!point)return null; + return{x:point.x-w/2,y:point.y-h/2,width:w,height:h}; +} + +function namedGroupBoxIntersectsViewport(box,viewport){ + if(!box||!viewport)return false; + var x=Number(box.x),y=Number(box.y),w=Number(box.width),h=Number(box.height); + if(!isFinite(x)||!isFinite(y)||!isFinite(w)||!isFinite(h))return false; + return xviewport.x&&yviewport.y; +} + +function namedGroupNeedsViewportPlacement(memberBoxes,viewport){ + if(!viewport)return true; + var boxes=memberBoxes||[]; + if(!boxes.length)return true; + return !boxes.some(function(box){return namedGroupBoxIntersectsViewport(box,viewport);}); +} + +function namedGroupMemberCentroid(nodes,paths){ + var want=Object.create(null); + (paths||[]).forEach(function(path){if(path)want[path]=true;}); + var sx=0,sy=0,n=0; + (nodes||[]).forEach(function(node){ + if(!node||!want[node.id])return; + var x=Number(node.x),y=Number(node.y); + if(!isFinite(x)||!isFinite(y))return; + sx+=x;sy+=y;n++; + }); + if(!n)return null; + return{x:sx/n,y:sy/n}; +} + +function placeNodesAtNamedGroupOrigin(nodes,paths,origin){ + var point=namedGroupPoint(origin); + if(!point)return[]; + var center=namedGroupMemberCentroid(nodes,paths); + if(!center)return[]; + return translateNamedGroupCards(nodes,paths,point.x-center.x,point.y-center.y); } function readNamedGroupsStore(storage){ @@ -7898,8 +7964,11 @@ var out=[]; Object.keys(named||{}).forEach(function(id){ var entry=named[id]; - if(!entry||!entry.cards||!entry.cards.length)return; - var bounds=namedGroupHullBounds(entry.cards,sizesByPath,pad,boxesByPath); + if(!entry)return; + var hasCards=!!(entry.cards&&entry.cards.length); + var bounds=hasCards + ?namedGroupHullBounds(entry.cards,sizesByPath,pad,boxesByPath) + :unionPaddedBoxes([namedGroupPlaceholderBounds(entry.group)],pad==null?28:pad,22); if(!bounds)return; out.push({ id:id, @@ -7908,7 +7977,8 @@ x:bounds.x, y:bounds.y, width:bounds.width, - height:bounds.height + height:bounds.height, + placeholder:!hasCards }); }); return out; @@ -8134,6 +8204,15 @@ var union=unionPaddedBoxes(named[id].boxes,18,0); if(union)hulls.push({id:id,kind:'named',color:named[id].color,x:union.x,y:union.y,width:union.width,height:union.height}); }); + normalizeNamedGroups(namedGroups).forEach(function(group){ + if(named[group.id]&&named[group.id].boxes&&named[group.id].boxes.length)return; + var placeholder=namedGroupPlaceholderBounds(group); + if(!placeholder)return; + var union=unionPaddedBoxes([placeholder],18,0); + if(!union)return; + hulls.push({id:group.id,kind:'named',color:namedGroupColor(group.id),x:union.x,y:union.y,width:union.width,height:union.height,placeholder:true}); + boxes.push(placeholder); + }); return{marks:marks,hulls:hulls,world:minimapWorldFromBoxes(boxes,pad)}; } @@ -9336,6 +9415,7 @@ var namedCodeGroupsRef=useRef([]); var namedGroupsSessionRef=useRef(''); var armedNamedGroupIdRef=useRef(null); + var namedGroupNameInputRef=useRef(null); var drawMinimapRef=useRef(null); var minimapHostRef=useRef(null); var minimapCanvasRef=useRef(null); @@ -9365,6 +9445,7 @@ var _codeGate=useState(readUiPrefs().codeViewRootGate),codeViewRootGate=_codeGate[0],setCodeViewRootGate=_codeGate[1]; var _namedGroups=useState([]),namedCodeGroups=_namedGroups[0],setNamedCodeGroups=_namedGroups[1]; var _armedNamedGroup=useState(null),armedNamedGroupId=_armedNamedGroup[0],setArmedNamedGroupId=_armedNamedGroup[1]; + var _namedGroupName=useState(null),namedGroupNameDialog=_namedGroupName[0],setNamedGroupNameDialog=_namedGroupName[1]; namedCodeGroupsRef.current=namedCodeGroups; armedNamedGroupIdRef.current=armedNamedGroupId; var _lineThick=useState(readUiPrefs().lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; @@ -9529,6 +9610,23 @@ return function(){document.removeEventListener('keydown',onKeyDown);}; },[confirmDialog]); + useEffect(function(){ + if(!namedGroupNameDialog)return; + var input=namedGroupNameInputRef.current; + if(input&&typeof input.focus==='function'){ + input.focus(); + if(typeof input.select==='function')input.select(); + } + function onKeyDown(e){ + if(e.key==='Escape'){ + e.preventDefault(); + setNamedGroupNameDialog(null); + } + } + document.addEventListener('keydown',onKeyDown); + return function(){document.removeEventListener('keydown',onKeyDown);}; + },[!!namedGroupNameDialog]); + useEffect(function(){ if(!pendingRecentDelete)return; function onKeyDown(e){ @@ -11131,36 +11229,80 @@ if(drawMinimapRef.current)drawMinimapRef.current(); return groups; } - function requestNamedGroupName(current){ - return promptNamedGroupName(typeof window!=='undefined'?window:null,current||''); + function closeNamedGroupNameDialog(){ + setNamedGroupNameDialog(null); + } + function namedGroupViewportSize(){ + var svg=svgRef.current; + return{ + width:svg&&svg.clientWidth?svg.clientWidth:800, + height:svg&&svg.clientHeight?svg.clientHeight:600 + }; + } + function currentNamedGroupOrigin(){ + var size=namedGroupViewportSize(); + return namedGroupViewportCenter(codeZoomTransformRef.current||{k:1,x:0,y:0},size.width,size.height); + } + function currentNamedGroupViewport(){ + var size=namedGroupViewportSize(); + return viewportWorldRect(codeZoomTransformRef.current||{k:1,x:0,y:0},size.width,size.height); + } + function placeNamedGroupInViewport(paths,origin){ + var list=paths||[]; + if(!list.length)return; + var live=(simRef.current&&simRef.current.nodes())||[]; + var boxes=[]; + live.forEach(function(node){ + if(!node||list.indexOf(node.id)<0)return; + var box=codeFolderMemberBox(node,codeCardSizesRef.current,null,0); + if(box)boxes.push(box); + }); + if(!namedGroupNeedsViewportPlacement(boxes,currentNamedGroupViewport()))return; + placeNodesAtNamedGroupOrigin(live,list,origin).forEach(function(node){ + codeCardUserPinnedRef.current.add(node.id); + writeCodeCardPlacement({path:node.id,folder:node.folder},node); + }); + syncCodeCards(); + redrawGraphLinksAndNodes(); } function createCodeNamedGroup(){ - var name=requestNamedGroupName(''); - if(name==null)return; - if(!name){ - showNotification('Group name cannot be empty','warning'); - return; - } - var made=createNamedGroup(namedCodeGroupsRef.current,name); - if(!made.group)return; - var next=made.groups; - if(selected&&selected.path&&codeCardPathsRef.current.has(selected.path)){ - next=addCardToNamedGroup(next,made.group.id,selected.path); - } - commitNamedGroups(next); - setArmedNamedGroupId(null); + setNamedGroupNameDialog({mode:'create',name:''}); } function renameCodeNamedGroup(id){ var group=namedGroupById(namedCodeGroupsRef.current,id); if(!group)return; - var name=requestNamedGroupName(group.name); - if(name==null)return; + setNamedGroupNameDialog({mode:'rename',groupId:id,name:group.name}); + } + function submitNamedGroupNameDialog(){ + var dialog=namedGroupNameDialog; + if(!dialog)return; + var name=normalizeNamedGroupName(dialog.name); if(!name){ showNotification('Group name cannot be empty','warning'); return; } - commitNamedGroups(renameNamedGroup(namedCodeGroupsRef.current,id,name)); + if(dialog.mode==='rename'){ + commitNamedGroups(renameNamedGroup(namedCodeGroupsRef.current,dialog.groupId,name)); + setArmedNamedGroupId(null); + closeNamedGroupNameDialog(); + return; + } + var origin=currentNamedGroupOrigin(); + var made=createNamedGroup(namedCodeGroupsRef.current,name,undefined,origin); + if(!made.group)return; + var next=made.groups; + var memberPath=selected&&selected.path&&codeCardPathsRef.current.has(selected.path)?selected.path:null; + if(memberPath){ + next=addCardToNamedGroup(next,made.group.id,memberPath); + placeNamedGroupInViewport([memberPath],origin); + var node=graphNodesByIdRef.current[memberPath]; + if(node&&isFinite(node.x)&&isFinite(node.y)){ + next=setNamedGroupOrigin(next,made.group.id,{x:node.x,y:node.y}); + } + } + commitNamedGroups(next); setArmedNamedGroupId(null); + closeNamedGroupNameDialog(); } function deleteCodeNamedGroup(id){ var armed=armRecentDelete(armedNamedGroupId,id); @@ -13367,13 +13509,14 @@ function beginNamedGroupDrag(e,groupId){ if(e.pointerType==='mouse'&&e.button!==0)return; var group=namedGroupById(namedCodeGroupsRef.current,groupId); - if(!group||!group.paths.length)return; + if(!group)return; var transform=codeZoomTransformRef.current||{k:1,x:0,y:0}; var k=Number(transform.k); if(!isFinite(k)||k<=0)k=1; var startX=e.clientX,startY=e.clientY,lastX=0,lastY=0,moved=false; var nodes=(simRef.current&&simRef.current.nodes())||[]; var paths=group.paths.slice(); + var origin=namedGroupPoint(group)||namedGroupMemberCentroid(nodes,paths)||{x:0,y:0}; function onMove(ev){ var delta=codeCardDragDelta(ev.clientX,ev.clientY,startX,startY,k,3); var dx=delta.x-lastX,dy=delta.y-lastY; @@ -13404,6 +13547,7 @@ return; } paths.forEach(function(path){codeCardUserPinnedRef.current.add(path);}); + commitNamedGroups(setNamedGroupOrigin(namedCodeGroupsRef.current,groupId,{x:origin.x+lastX,y:origin.y+lastY})); if(codeViewDragRefresh('release')){ settleCodeViewAfterDrag(nodes,codeCardPathsRef.current,codeCardSizesRef.current,null,{ boxesByPath:readCodeCardWorldBoxes(codeCardsLayerRef.current) @@ -14827,6 +14971,41 @@ ) ) ), + namedGroupNameDialog&&React.createElement('div',{className:'modal-overlay',style:{zIndex:1200},onClick:closeNamedGroupNameDialog}, + React.createElement('div',{className:'modal named-group-name-modal',onClick:function(e){e.stopPropagation();}}, + React.createElement('div',{className:'modal-header'}, + React.createElement('div',{className:'modal-title'},iconLabel('layers',namedGroupNameDialog.mode==='rename'?'Rename Group':'New Group','m')), + React.createElement('button',{className:'modal-close',onClick:closeNamedGroupNameDialog,'aria-label':'Close'},'×') + ), + React.createElement('div',{className:'modal-body'}, + React.createElement('div',{className:'form-group',style:{marginBottom:0}}, + React.createElement('label',{className:'form-label',htmlFor:'named-group-name-input'},'Name this group'), + React.createElement('input',{ + id:'named-group-name-input', + ref:namedGroupNameInputRef, + className:'form-input', + type:'text', + 'aria-label':'Group name', + placeholder:'Auth, UI, Core…', + value:namedGroupNameDialog.name, + maxLength:NAMED_GROUP_NAME_MAX, + autoFocus:true, + onChange:function(e){setNamedGroupNameDialog(Object.assign({},namedGroupNameDialog,{name:e.target.value}));}, + onKeyDown:function(e){ + if(e.key==='Enter'){ + e.preventDefault(); + submitNamedGroupNameDialog(); + } + } + }) + ) + ), + React.createElement('div',{className:'modal-footer'}, + React.createElement('button',{className:'top-btn',onClick:closeNamedGroupNameDialog},'Cancel'), + React.createElement('button',{className:'top-btn primary',onClick:submitNamedGroupNameDialog},namedGroupNameDialog.mode==='rename'?'Rename':'Create') + ) + ) + ), confirmDialog&&(function(){ var tone=getDialogTone(confirmDialog.tone); return React.createElement('div',{className:'modal-overlay',style:{zIndex:1200},onClick:function(){closeConfirmDialog(false);}}, diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 13cf2255..8e6534f4 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1946,9 +1946,20 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /function beginNamedGroupDrag\(/); assert.match(htmlSource, /function partitionCodeViewHullMembers\(/); assert.match(htmlSource, /function namedGroupDropRemovesCard\(/); + assert.match(htmlSource, /function submitNamedGroupNameDialog\(/); + assert.match(htmlSource, /function closeNamedGroupNameDialog\(/); + assert.match(htmlSource, /function namedGroupViewportCenter\(/); + assert.match(htmlSource, /function namedGroupPlaceholderBounds\(/); + assert.match(htmlSource, /namedGroupNameDialog/); + assert.match(htmlSource, /named-group-name-modal/); + assert.match(htmlSource, /Name this group/); assert.match(htmlSource, /code-card-group-select/); assert.match(htmlSource, /data-named-group/); assert.match(htmlSource, /stroke-dasharray','6 4'/); + assert.doesNotMatch(htmlSource, /function promptNamedGroupName\(/); + assert.doesNotMatch(htmlSource, /function requestNamedGroupName\(/); + assert.doesNotMatch(htmlSource, /host\.prompt/); + assert.doesNotMatch(htmlSource, /window\.prompt/); assert.doesNotMatch(htmlSource, /className:'code-view-gate-select'/); assert.doesNotMatch(htmlSource, /code-view-prefs[\s\S]{0,400}createElement\('select'/); assert.match(htmlSource, /Pick a folder or file/); @@ -2455,9 +2466,11 @@ test('named groups create, rename, delete, and keep exclusive card membership', assert.equal(context.normalizeNamedGroupName(' '), ''); const empty = context.createNamedGroup([], ' '); assert.equal(empty.group, null); - const made = context.createNamedGroup([], 'Auth', 'g-auth'); + const made = context.createNamedGroup([], 'Auth', 'g-auth', { x: 50, y: 80 }); assert.equal(made.group.id, 'g-auth'); assert.equal(made.group.paths.length, 0); + assert.equal(made.group.x, 50); + assert.equal(made.group.y, 80); let groups = context.addCardToNamedGroup(made.groups, 'g-auth', 'src/a.js'); groups = context.addCardToNamedGroup(groups, 'g-auth', 'src/b.js'); groups = context.createNamedGroup(groups, 'UI', 'g-ui').groups; @@ -2465,14 +2478,65 @@ test('named groups create, rename, delete, and keep exclusive card membership', assert.deepEqual(J(context.namedGroupById(groups, 'g-auth').paths), ['src/b.js']); assert.deepEqual(J(context.namedGroupById(groups, 'g-ui').paths), ['src/a.js']); assert.equal(context.namedGroupIdForPath(groups, 'src/a.js'), 'g-ui'); + assert.equal(context.namedGroupById(groups, 'g-auth').x, 50); + assert.equal(context.namedGroupById(groups, 'g-auth').y, 80); groups = context.setCardNamedGroup(groups, 'src/a.js', ''); assert.equal(context.namedGroupIdForPath(groups, 'src/a.js'), null); groups = context.renameNamedGroup(groups, 'g-auth', 'Session'); assert.equal(context.namedGroupById(groups, 'g-auth').name, 'Session'); + assert.equal(context.namedGroupById(groups, 'g-auth').x, 50); + groups = context.setNamedGroupOrigin(groups, 'g-auth', { x: 12, y: -4 }); + assert.equal(context.namedGroupById(groups, 'g-auth').x, 12); + assert.equal(context.namedGroupById(groups, 'g-auth').y, -4); groups = context.deleteNamedGroup(groups, 'g-ui'); assert.equal(context.namedGroupById(groups, 'g-ui'), null); - assert.equal(context.promptNamedGroupName({ prompt: () => ' Core ' }, ''), 'Core'); - assert.equal(context.promptNamedGroupName({ prompt: () => null }, 'Auth'), null); +}); + +test('named groups use an in-app name dialog and land in the current viewport', () => { + assert.equal(typeof context.promptNamedGroupName, 'undefined'); + const center = context.namedGroupViewportCenter({ k: 2, x: -200, y: -100 }, 400, 200); + assert.equal(center.x, 200); + assert.equal(center.y, 100); + const bounds = context.namedGroupPlaceholderBounds({ x: 10, y: 20 }, 280, 160); + assert.equal(bounds.x, -130); + assert.equal(bounds.y, -60); + assert.equal(bounds.width, 280); + assert.equal(bounds.height, 160); + assert.equal(context.namedGroupPlaceholderBounds({}, 280, 160), null); + const viewport = { x: 0, y: 0, width: 800, height: 600 }; + assert.equal(context.namedGroupNeedsViewportPlacement([], viewport), true); + assert.equal(context.namedGroupNeedsViewportPlacement([{ x: 100, y: 100, width: 200, height: 120 }], viewport), false); + assert.equal(context.namedGroupNeedsViewportPlacement([{ x: 2000, y: 2000, width: 200, height: 120 }], viewport), true); + const nodes = [ + { id: 'src/a.js', x: 0, y: 0 }, + { id: 'src/b.js', x: 100, y: 0 } + ]; + const moved = context.placeNodesAtNamedGroupOrigin(nodes, ['src/a.js', 'src/b.js'], { x: 500, y: 40 }); + assert.equal(moved.length, 2); + assert.equal(nodes[0].x, 450); + assert.equal(nodes[1].x, 550); + assert.equal(nodes[0].y, 40); + assert.equal(nodes[1].y, 40); + const emptyNamed = context.partitionCodeViewHullMembers( + [], + new Set(), + [{ id: 'g-empty', name: 'Core', paths: [], x: 300, y: 150 }] + ); + const emptyHulls = context.namedGroupHullsFromMembers(emptyNamed.named, {}, 10); + assert.equal(emptyHulls.length, 1); + assert.equal(emptyHulls[0].placeholder, true); + assert.equal(emptyHulls[0].name, 'Core'); + assert.ok(emptyHulls[0].x < 300 && emptyHulls[0].x + emptyHulls[0].width > 300); + assert.ok(emptyHulls[0].y < 150 && emptyHulls[0].y + emptyHulls[0].height > 150); + const content = context.collectMinimapContent( + [], + {}, + new Set(), + () => '#4d9fff', + 0, + [{ id: 'g-empty', name: 'Core', paths: [], x: 300, y: 150 }] + ); + assert.equal(content.hulls.some((h) => h.kind === 'named' && h.id === 'g-empty'), true); }); test('named-group hulls win over directory hulls and drop only when dragged out', () => { @@ -2533,10 +2597,12 @@ test('named groups persist per analysis session in localStorage', () => { const key = context.namedGroupsSessionKey('github', 'owner/repo'); assert.equal(key, 'github:owner/repo'); const written = context.writeNamedGroupsForSession(storage, key, [ - { id: 'g1', name: ' Auth ', paths: ['src/a.js', 'src/a.js', ''] } + { id: 'g1', name: ' Auth ', paths: ['src/a.js', 'src/a.js', ''], x: 120, y: -40 } ]); assert.equal(written[0].name, 'Auth'); assert.deepEqual(J(written[0].paths), ['src/a.js']); + assert.equal(written[0].x, 120); + assert.equal(written[0].y, -40); assert.deepEqual(J(context.readNamedGroupsForSession(storage, key)), J(written)); assert.deepEqual(J(context.readNamedGroupsForSession(storage, 'github:other/repo')), []); assert.deepEqual(J(context.readNamedGroupsForSession(storage, '')), []); From 2b7611c7446b233f60e705fe2afe7be7778d6ec4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 00:11:02 +0000 Subject: [PATCH 3/3] Ungroup a card when it is dragged outside the named hull The last (or only) member could never leave: drop-out required other cards, so the hull just followed the file. Use the hull at drag start as the leave target, and stop membership changes from re-running the Code-view layout effect so the group does not rebuild on every move. Co-authored-by: Braedon Saunders --- index.html | 17 ++++++++++++----- tests/code-canvas.test.mjs | 10 ++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 9d264cfd..c0978348 100644 --- a/index.html +++ b/index.html @@ -7984,10 +7984,15 @@ return out; } -function namedGroupDropRemovesCard(cardBox,remainingBoxes,pad){ +function namedGroupLeaveHull(remainingBoxes,pad,startBoxes){ + var boxes=remainingBoxes&&remainingBoxes.length?remainingBoxes:startBoxes; + if(!boxes||!boxes.length)return null; + return unionPaddedBoxes(boxes,pad==null?28:pad,22); +} + +function namedGroupDropRemovesCard(cardBox,remainingBoxes,pad,startBoxes){ if(!cardBox)return false; - if(!remainingBoxes||!remainingBoxes.length)return false; - var hull=unionPaddedBoxes(remainingBoxes,pad==null?28:pad,22); + var hull=namedGroupLeaveHull(remainingBoxes,pad,startBoxes); if(!hull)return false; var cx=cardBox.x+cardBox.width/2; var cy=cardBox.y+cardBox.height/2; @@ -11916,7 +11921,7 @@ syncCodeCards(); if(updateHullsRef.current)updateHullsRef.current(); if(drawMinimapRef.current)drawMinimapRef.current(); - },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath,namedCodeGroups]); + },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath]); useEffect(function(){ return subscribePrefersReducedMotion(function(){ @@ -13575,6 +13580,8 @@ return; } var startX=e.clientX,startY=e.clientY,originX=node.x,originY=node.y,moved=false,lastX=node.x,lastY=node.y; + var startBox=cardWorldBox(node,codeCardSizesRef.current,null); + var startMemberBoxes=startBox?[startBox]:[]; node.fx=node.x;node.fy=node.y; codeCardStackRef.current=raiseCodeCardStack(codeCardStackRef.current,file.path); applyCodeCardStackOrder(codeCardsLayerRef.current,codeCardStackRef.current); @@ -13625,7 +13632,7 @@ var box=cardWorldBox(other,codeCardSizesRef.current,null); if(box)otherBoxes.push(box); }); - if(namedGroupDropRemovesCard(cardWorldBox(node,codeCardSizesRef.current,null),otherBoxes)){ + if(namedGroupDropRemovesCard(cardWorldBox(node,codeCardSizesRef.current,null),otherBoxes,28,startMemberBoxes)){ commitNamedGroups(removeCardFromNamedGroup(groups,gid,file.path)); } } diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 8e6534f4..c742f9e3 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1893,7 +1893,7 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /codeCardSizeForDiff\(file,cardPrefs,diffRows\)/); assert.match(htmlSource, /applyCodeCardUserSize\(codeCardSizeForDiff\(file,currentCodeCardPrefs\(\),codeCardDiffRows\(file,cliLiveByPath\[file\.path\]\)\),nextSize\)/); assert.match(htmlSource, /applyCodeCardResizeFrame\(findCodeCardElement\(codeCardsLayerRef\.current,file\.path\),codeCardSizesRef\.current\[file\.path\]\)/); - assert.match(htmlSource, /\[codeViewFiles,graphConfig\.vizType,graphConfig\.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath,namedCodeGroups\]/); + assert.match(htmlSource, /\[codeViewFiles,graphConfig\.vizType,graphConfig\.linkDist,codeViewExpand,codeViewWrap,cliLiveByPath\]/); assert.match(htmlSource, /codeCardSymbolPills\(file,data\?data\.connections:\[\],cardPrefs,diffRows\)/); assert.match(htmlSource, /codeColorBlockSections\(file,data\?data\.connections:\[\],cardPrefs,diffRows\)/); assert.match(htmlSource, /cliDiffEpochRef\.current=bumpCliWatchDiffEpoch\(cliDiffEpochRef\.current\)/); @@ -1946,6 +1946,8 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /function beginNamedGroupDrag\(/); assert.match(htmlSource, /function partitionCodeViewHullMembers\(/); assert.match(htmlSource, /function namedGroupDropRemovesCard\(/); + assert.match(htmlSource, /function namedGroupLeaveHull\(/); + assert.match(htmlSource, /namedGroupDropRemovesCard\(cardWorldBox\(node,codeCardSizesRef\.current,null\),otherBoxes,28,startMemberBoxes\)/); assert.match(htmlSource, /function submitNamedGroupNameDialog\(/); assert.match(htmlSource, /function closeNamedGroupNameDialog\(/); assert.match(htmlSource, /function namedGroupViewportCenter\(/); @@ -2004,7 +2006,7 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /readCodeCardWorldBoxes\(codeCardsLayerRef\.current\)/); assert.match(htmlSource, /codeFolderHullBounds\(cardNodes,leftover/); assert.match(htmlSource, /if\(updateHullsRef\.current\)updateHullsRef\.current\(\)/); - assert.match(htmlSource, /codeViewExpand,codeViewWrap,cliLiveByPath,namedCodeGroups/); + assert.match(htmlSource, /codeViewExpand,codeViewWrap,cliLiveByPath\]/); assert.match(htmlSource, /cardSize\.expand\?' expand'/); assert.match(htmlSource, /cardSize\.wrap\?' wrap'/); assert.match(htmlSource, /\.code-card\.wrap \.file-preview-text/); @@ -2581,6 +2583,10 @@ test('named-group hulls win over directory hulls and drop only when dragged out' ); assert.equal(leave, true); assert.equal(context.namedGroupDropRemovesCard({ x: 10, y: 10, width: 40, height: 40 }, [], 10), false); + const lastStart = [{ x: 10, y: 10, width: 40, height: 40 }]; + assert.equal(context.namedGroupDropRemovesCard({ x: 10, y: 10, width: 40, height: 40 }, [], 10, lastStart), false); + assert.equal(context.namedGroupDropRemovesCard({ x: 400, y: 10, width: 40, height: 40 }, [], 10, lastStart), true); + assert.equal(context.namedGroupLeaveHull([], 10, lastStart).width > 40, true); const moved = context.translateNamedGroupCards(nodes, ['src/a.js', 'lib/c.js'], 40, -15); assert.equal(moved.length, 2); assert.equal(nodes[0].x, 240);