diff --git a/index.html b/index.html index 244c3a5..3de2cce 100644 --- a/index.html +++ b/index.html @@ -225,6 +225,11 @@ .empty-icon{margin-bottom:16px;opacity:0.3;color:var(--t2)} .empty-title{font-size:16px;font-weight:600;color:var(--t1);margin-bottom:8px} .empty-desc{font-size:12px;color:var(--t3);max-width:300px} +.code-root-gate{position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:40px;pointer-events:none;background:color-mix(in srgb,var(--bg0) 70%,transparent)} +.code-root-gate .empty-title{font-size:14px} +.code-root-gate .empty-desc{font-size:11px;max-width:320px;line-height:1.45;white-space:pre-wrap} +.code-view-gate-select{background:var(--bg0);border:1px solid var(--border);border-radius:5px;color:var(--t1);font-family:inherit;font-size:9px;padding:2px 4px;margin-left:2px;cursor:pointer} +.code-view-gate-select:focus{outline:none;border-color:var(--acc)} .loading{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%} .spinner{width:40px;height:40px;border:3px solid var(--border);border-top-color:var(--acc);border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:16px} @keyframes spin{to{transform:rotate(360deg)}} @@ -507,7 +512,7 @@ .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:row;align-items:center;gap:2px;padding:3px 3px 3px 6px} +.color-by.code-view-prefs{flex-direction:row;flex-wrap:wrap;align-items:center;gap:2px;padding:3px 3px 3px 6px;max-width:min(360px,calc(100% - 24px))} .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} @@ -5632,6 +5637,8 @@ var LINE_THICKNESS_MIN=1; var LINE_THICKNESS_MAX=6; var LINE_THICKNESS_DEFAULT=1; +var CODE_VIEW_ROOT_GATE_DEFAULT=50; +var CODE_VIEW_ROOT_GATE_STEPS=[25,50,75,100]; function clampLineThickness(value){ var n=Number(value); @@ -5642,14 +5649,29 @@ return n; } +function clampCodeViewRootGate(value){ + var n=Number(value); + if(!isFinite(n))return CODE_VIEW_ROOT_GATE_DEFAULT; + n=Math.round(n); + var best=CODE_VIEW_ROOT_GATE_STEPS[0]; + var bestD=Math.abs(n-best); + for(var i=1;i=clampCodeViewRootGate(threshold); +} + +function codeViewRootGateMessage(count,threshold){ + return'This project root has '+count+' files (gate is '+clampCodeViewRootGate(threshold)+'). Pick a folder or file in the Files tree before opening Code cards.'; +} + function codeFileNavOpensCard(vizType){ return vizType==='code'; } @@ -7705,6 +7771,7 @@ var _pillScroll=useState(0),codePillScroll=_pillScroll[0],setCodePillScroll=_pillScroll[1]; 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 _lineThick=useState(readUiPrefs().lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; var lineThicknessRef=useRef(lineThickness); lineThicknessRef.current=lineThickness; @@ -9187,8 +9254,11 @@ graphConfig.curvedLinks ].join('\0'); },[currentHydrationId,data,folderFilter,colorMode,theme,graphConfig]); + var codeRootGateActive=useMemo(function(){ + return graphConfig.vizType==='code'&&codeViewRootGateActive(data,folderFilter,selected&&selected.path,openedCodePaths,codeViewRootGate); + },[data,folderFilter,openedCodePaths,selected&&selected.path,codeViewRootGate,graphConfig.vizType]); var codeViewFiles=useMemo(function(){ - if(!data)return[]; + if(!data||codeRootGateActive)return[]; var paths=openedCodePaths; var files=filesForOpenedCodePaths(paths,data,folderFilter); if((!files.length)&&graphConfig.vizType==='code'){ @@ -9196,7 +9266,7 @@ if(seed)files=filesForOpenedCodePaths([seed],data,folderFilter); } return files; - },[data,openedCodePaths,folderFilter,graphConfig.vizType,selected&&selected.path]); + },[data,openedCodePaths,folderFilter,graphConfig.vizType,selected&&selected.path,codeRootGateActive]); useEffect(function(){ if(currentHydrationId===openedSceneRef.current)return; openedSceneRef.current=currentHydrationId; @@ -9212,7 +9282,7 @@ codeViewSessionRef.current=false; return; } - if(!shouldSeedOpenedCodeCards(true,codeViewSessionRef.current,openedCodePaths)){ + if(!shouldSeedOpenedCodeCards(true,codeViewSessionRef.current,openedCodePaths,codeRootGateActive)){ codeViewSessionRef.current=true; return; } @@ -9220,7 +9290,7 @@ var seed=codeViewSeedPath(selected&&selected.path,data,folderFilter); if(!seed)return; if(openCodeFileRef.current)openCodeFileRef.current(seed,true); - },[graphConfig.vizType,data,folderFilter,openedCodePaths.length,selected&&selected.path]); + },[graphConfig.vizType,data,folderFilter,openedCodePaths.length,selected&&selected.path,codeRootGateActive]); useEffect(function(){ var el=codeCanvasRef.current; if(!el||graphConfig.vizType!=='code')return; @@ -9297,6 +9367,10 @@ var next=persistUiPrefs({lineThickness:value}).lineThickness; setLineThickness(next); } + function persistCodeViewRootGate(value){ + var next=persistUiPrefs({codeViewRootGate:value}).codeViewRootGate; + setCodeViewRootGate(next); + } function applyLinkThickness(){ var thickness=lineThicknessRef.current; if(linksRef.current){ @@ -11188,7 +11262,11 @@ if(graphConfig.vizType!=='code')return null; return React.createElement('div',{className:'color-by code-view-prefs',role:'toolbar','aria-label':'Code view options'}, React.createElement('button',{type:'button',className:'color-by-btn'+(codeViewExpand?' active':''),'aria-pressed':codeViewExpand?'true':'false',onClick:function(){setCodeViewExpand(!codeViewExpand);}},'Expand All'), - React.createElement('button',{type:'button',className:'color-by-btn'+(codeViewWrap?' active':''),'aria-pressed':codeViewWrap?'true':'false',onClick:function(){setCodeViewWrap(!codeViewWrap);}},'Wrap Text') + React.createElement('button',{type:'button',className:'color-by-btn'+(codeViewWrap?' active':''),'aria-pressed':codeViewWrap?'true':'false',onClick:function(){setCodeViewWrap(!codeViewWrap);}},'Wrap Text'), + React.createElement('span',{className:'color-by-label',title:'Skip Code cards when this many files sit at the project root'},'Root'), + React.createElement('select',{className:'code-view-gate-select','aria-label':'Root file gate',title:'Skip Code cards when this many files sit at the project root',value:String(codeViewRootGate),onChange:function(e){persistCodeViewRootGate(e.target.value);}}, + CODE_VIEW_ROOT_GATE_STEPS.map(function(n){return React.createElement('option',{key:n,value:String(n)},String(n));}) + ) ); } function renderSidebarEmpty(title,desc){ @@ -11438,13 +11516,18 @@ } function renderCodeView(){ var primaryPath=selected&&selected.path; + var rootCount=countCodeViewRootFiles(data); 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);}) ), + codeRootGateActive&&React.createElement('div',{className:'code-root-gate',role:'status'}, + React.createElement('div',{className:'empty-title'},'Pick a folder or file'), + React.createElement('div',{className:'empty-desc'},codeViewRootGateMessage(rootCount,codeViewRootGate)) + ), React.createElement('div',{className:'code-canvas-hud'}, - 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.'), + 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.')), 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 885c50e..451e38b 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -115,6 +115,62 @@ test('visible code files are not capped at four', () => { assert.equal(seeded.length, 6); }); +test('root file count ignores nested files and unwraps a single zip prefix', () => { + const flat = { + files: [ + { path: 'a.js', folder: 'root' }, + { path: 'b.js', folder: 'root' }, + { path: 'src/c.js', folder: 'src' } + ] + }; + assert.equal(context.codeViewAnalysisRootFolder(flat), 'root'); + assert.equal(context.countCodeViewRootFiles(flat), 2); + assert.equal(context.isCodeViewRootFile(flat.files[0], 'root'), true); + assert.equal(context.isCodeViewRootFile(flat.files[2], 'root'), false); + const wrapped = { + files: [ + { path: 'repo/a.js', folder: 'repo' }, + { path: 'repo/b.js', folder: 'repo' }, + { path: 'repo/src/c.js', folder: 'repo/src' } + ] + }; + assert.equal(context.codeViewAnalysisRootFolder(wrapped), 'repo'); + assert.equal(context.countCodeViewRootFiles(wrapped), 2); + const nested = { + files: [ + { path: 'src/a.js', folder: 'src' }, + { path: 'lib/b.js', folder: 'lib' } + ] + }; + assert.equal(context.codeViewAnalysisRootFolder(nested), 'root'); + assert.equal(context.countCodeViewRootFiles(nested), 0); + assert.equal(context.countCodeViewRootFiles({ files: [] }), 0); +}); + +test('Code root gate waits for a folder or file when the root is crowded', () => { + const files = Array.from({ length: 60 }, (_, i) => ({ + path: i < 50 ? 'f' + i + '.js' : 'src/n' + i + '.js', + folder: i < 50 ? 'root' : 'src', + name: i < 50 ? 'f' + i + '.js' : 'n' + i + '.js' + })); + const data = { files }; + assert.equal(context.countCodeViewRootFiles(data), 50); + assert.equal(context.clampCodeViewRootGate(undefined), 50); + assert.equal(context.clampCodeViewRootGate(40), 50); + assert.equal(context.clampCodeViewRootGate(70), 75); + assert.equal(context.codeViewRootGateActive(data, null, null, [], 50), true); + assert.equal(context.codeViewRootGateActive(data, 'src', null, [], 50), false); + assert.equal(context.codeViewRootGateActive(data, null, 'f0.js', [], 50), false); + assert.equal(context.codeViewRootGateActive(data, null, null, ['f0.js'], 50), false); + assert.equal(context.codeViewRootGateActive(data, null, null, [], 75), false); + assert.equal(context.codeViewRootGateActive(data, null, null, [], 25), true); + assert.equal(context.shouldSeedOpenedCodeCards(true, false, [], true), false); + assert.equal(context.shouldSeedOpenedCodeCards(true, false, [], false), true); + assert.match(context.codeViewRootGateMessage(50, 50), /Pick a folder or file/); + const small = { files: files.slice(0, 10) }; + assert.equal(context.codeViewRootGateActive(small, null, null, [], 50), false); +}); + test('Code view seeds the filtered folder when the selection is outside it', () => { const data = { files: [ @@ -1248,6 +1304,21 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /setCodeViewWrap\(!codeViewWrap\)/); assert.match(htmlSource, /'aria-pressed':codeViewExpand/); assert.match(htmlSource, /'aria-pressed':codeViewWrap/); + assert.match(htmlSource, /CODE_VIEW_ROOT_GATE_DEFAULT/); + assert.match(htmlSource, /CODE_VIEW_ROOT_GATE_STEPS/); + assert.match(htmlSource, /function countCodeViewRootFiles\(/); + assert.match(htmlSource, /function codeViewRootGateActive\(/); + assert.match(htmlSource, /function codeViewRootGateMessage\(/); + assert.match(htmlSource, /function persistCodeViewRootGate\(/); + assert.match(htmlSource, /className:'code-root-gate'/); + assert.match(htmlSource, /className:'code-view-gate-select'/); + assert.match(htmlSource, /Pick a folder or file/); + assert.match(htmlSource, /codeRootGateActive/); + assert.match(htmlSource, /shouldSeedOpenedCodeCards\(true,codeViewSessionRef\.current,openedCodePaths,codeRootGateActive\)/); + assert.match(htmlSource, /if\(!data\|\|codeRootGateActive\)return\[\]/); + assert.match(htmlSource, /persistCodeViewRootGate\(e\.target\.value\)/); + assert.match(htmlSource, /useState\(readUiPrefs\(\)\.codeViewRootGate\)/); + assert.match(htmlSource, /persistUiPrefs\(\{codeViewRootGate:value\}\)/); assert.match(htmlSource, /normalizeCodeCardPrefs/); assert.match(htmlSource, /codeCardWrapColumns/); assert.match(htmlSource, /liveGraphNodeXY/); @@ -1343,20 +1414,29 @@ test('line thickness defaults match current graph edges and stay in range', () = test('UI prefs persist line thickness in localStorage', () => { const storage = memoryStorage(); assert.equal(context.readUiPrefs(storage).lineThickness, 1); + assert.equal(context.readUiPrefs(storage).codeViewRootGate, 50); assert.equal(context.readUiPrefs(null).lineThickness, 1); const written = context.writeUiPrefs(storage, { lineThickness: 5 }); assert.equal(written.lineThickness, 5); + assert.equal(written.codeViewRootGate, 50); assert.equal(context.readUiPrefs(storage).lineThickness, 5); assert.equal(context.writeUiPrefs(storage, { lineThickness: 99 }).lineThickness, 6); + const gated = context.writeUiPrefs(storage, { codeViewRootGate: 75 }); + assert.equal(gated.codeViewRootGate, 75); + assert.equal(context.writeUiPrefs(storage, { codeViewRootGate: 40 }).codeViewRootGate, 50); storage.setItem(context.UI_PREFS_STORAGE_KEY, '{not-json'); assert.equal(context.readUiPrefs(storage).lineThickness, 1); + assert.equal(context.readUiPrefs(storage).codeViewRootGate, 50); const other = memoryStorage({ [context.UI_PREFS_STORAGE_KEY]: JSON.stringify({ lineThickness: 2, extra: true }) }); const merged = context.writeUiPrefs(other, { lineThickness: 3 }); assert.equal(merged.lineThickness, 3); + assert.equal(merged.codeViewRootGate, 50); context.window = { localStorage: storage }; try { assert.equal(context.persistUiPrefs({ lineThickness: 4 }).lineThickness, 4); assert.equal(context.readUiPrefs().lineThickness, 4); + assert.equal(context.persistUiPrefs({ codeViewRootGate: 25 }).codeViewRootGate, 25); + assert.equal(context.readUiPrefs().codeViewRootGate, 25); } finally { delete context.window; }