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;
}