@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Painéis por projeto: consumo, armazenamento seletivo e comparação de visões."""
|
|
2
|
+
STYLE=r'''
|
|
3
|
+
.insights-controls{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:16px 0}.insights-controls label{font-size:12px;margin:0 0 4px}.insights-controls select{max-width:100%;min-height:40px}.insights-table-wrap{max-height:340px;overflow:auto;margin:16px 0}.insights-table{width:100%;border-collapse:collapse;font-size:13px}.insights-table th,.insights-table td{text-align:left;vertical-align:top;padding:10px 9px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}.insights-table th{color:var(--muted);font-weight:600}.insights-table td:first-child,.insights-table th:first-child{padding-left:0}.insights-table td button{font-size:12px;min-height:30px;padding:4px 8px}.insights-note{color:var(--muted);font-size:13px;overflow-wrap:anywhere}.insights-summary{display:grid;grid-template-columns:1fr 1fr;gap:0 20px;margin:14px 0}.insights-summary div{padding:10px 0;border-top:1px solid var(--border)}.insights-summary small{display:block}.insights-summary strong{font-size:15px}.cleanup-preview{border:1px solid var(--border);border-radius:8px;padding:16px;margin:16px 0}.comparison-diff{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.65 Consolas,monospace;max-height:430px;overflow:auto}.comparison-diff .added{color:var(--good)}.comparison-diff .removed{color:var(--bad)}.comparison-item{width:100%;text-align:left;margin:4px 0}.insights-note:empty{display:none}.insights-panel select{min-width:0}.insights-views{display:grid;grid-template-columns:1fr 1fr;gap:16px}.insights-views select{width:100%}
|
|
4
|
+
@media(max-width:700px){.insights-summary,.insights-views{grid-template-columns:1fr}.insights-table{min-width:490px}.insights-controls>div{max-width:100%}}
|
|
5
|
+
'''
|
|
6
|
+
HTML=r'''
|
|
7
|
+
<details class="insights-panel" id="usage-panel"><summary>Usage and reuse by project and view</summary>
|
|
8
|
+
<div class="insights-controls"><div><label for="usage-days">Period</label><select id="usage-days"><option value="7">7 days</option><option value="30" selected>30 days</option><option value="90">90 days</option><option value="366">1 year</option></select></div><div><label for="usage-view">View</label><select id="usage-view"><option value="">All</option></select></div><button id="usage-load">Refresh usage</button></div><p id="usage-message" class="insights-note">Local counts and usage reported by the gateway. No query text is saved in this telemetry.</p><div id="usage-summary" class="insights-summary"></div><div id="usage-table" class="insights-table-wrap"></div><div id="usage-indexing" class="insights-table-wrap"></div></details>
|
|
9
|
+
<details class="insights-panel" id="storage-panel"><summary>Disk space and view cleanup</summary><div class="insights-controls"><button id="storage-load">Refresh inventory</button></div><p class="insights-note">The active view and pinned views are protected. Cleanup is manual and removes only Smart Tool indexes and cache.</p><div id="storage-summary" class="insights-summary"></div><div id="storage-table" class="insights-table-wrap"></div><label class="check-label"><input id="cleanup-cache" type="checkbox" checked>Also clear vectors no longer used by the stored generations</label><div class="actions"><button id="cleanup-preview" disabled>Preview selected cleanup</button></div><div id="cleanup-plan" class="cleanup-preview" hidden></div><button id="cleanup-apply" class="danger" hidden>Confirm removal of this preview</button><p id="storage-message" class="insights-note"></p></details>
|
|
10
|
+
<details class="insights-panel" id="compare-panel"><summary>Compare two stored views</summary><p class="insights-note">The comparison uses the index content, with no checkout and no new embeddings. Scope differences can also show up as added or removed files.</p><button id="compare-load-views">Load views</button><div class="insights-views"><div><label for="compare-left">View A · base</label><select id="compare-left"></select></div><div><label for="compare-right">View B · compare with A</label><select id="compare-right"></select></div></div><label class="check-label"><input id="compare-relations" type="checkbox" checked>Also compare resolved references and static calls</label><div class="actions"><button id="compare-run" disabled>Compare A → B</button></div><p id="compare-message" class="insights-note"></p><div id="compare-summary" class="insights-summary"></div><div id="compare-files" class="insights-table-wrap"></div><div id="compare-graph"></div><h3 id="compare-file-title"></h3><pre id="compare-diff" class="comparison-diff"></pre></details>
|
|
11
|
+
'''
|
|
12
|
+
SCRIPT=r'''
|
|
13
|
+
window.SmartInsights=(()=>{
|
|
14
|
+
const $i=id=>document.getElementById(id);let sequence=0,storage=null,plan=null,comparison=null,compareRequest=0,diffRequest=0;
|
|
15
|
+
const count=n=>Number(n||0).toLocaleString('en-US'),size=n=>n>=1048576?(n/1048576).toLocaleString('en-US',{maximumFractionDigits:2})+' MiB':(n/1024).toLocaleString('en-US',{maximumFractionDigits:1})+' KiB';
|
|
16
|
+
const phaseNames={index_embedding:'Indexing',query_embedding:'Query',embedding_probe:'Model check',scope:'Scope',rerank:'Rerank',other:'Other step'};
|
|
17
|
+
function summary(host,rows){host.innerHTML=rows.map(([title,value,note])=>`<div><small>${esc(title)}</small><strong>${esc(value)}</strong>${note?'<small>'+esc(note)+'</small>':''}</div>`).join('')}
|
|
18
|
+
function table(headers,rows){return '<table class="insights-table"><thead><tr>'+headers.map(x=>'<th>'+esc(x)+'</th>').join('')+'</tr></thead><tbody>'+rows.map(row=>'<tr>'+row.map(value=>'<td>'+value+'</td>').join('')+'</tr>').join('')+'</tbody></table>'}
|
|
19
|
+
async function run(button,task){const seq=sequence,key=selected;button.disabled=true;try{await task(key,()=>seq===sequence&&key===selected)}catch(e){if(seq===sequence)message(e.message,true)}finally{if(seq===sequence)button.disabled=false}}
|
|
20
|
+
function clearPlan(){plan=null;$i('cleanup-plan').hidden=true;$i('cleanup-apply').hidden=true;$i('cleanup-preview').disabled=!document.querySelector('#storage-table input[data-storage]:checked')}
|
|
21
|
+
function reset(){sequence++;storage=null;comparison=null;plan=null;for(const id of ['usage-summary','usage-table','usage-indexing','storage-summary','storage-table','storage-message','compare-summary','compare-files','compare-graph','compare-file-title','compare-diff','compare-message'])$i(id).replaceChildren();$i('usage-view').replaceChildren(new Option('All',''));$i('compare-left').replaceChildren();$i('compare-right').replaceChildren();$i('compare-run').disabled=true;clearPlan();for(const id of ['usage-load','storage-load','compare-load-views'])$i(id).disabled=false;$i('usage-message').textContent='Refresh the usage of this project. The telemetry stores only counts and metadata.'}
|
|
22
|
+
async function loadUsage(key,valid){const requestedDays=$i('usage-days').value,requestedView=$i('usage-view').value;const data=await api({action:'usage',project_id:key,days:Number($i('usage-days').value),...($i('usage-view').value?{view_id:$i('usage-view').value}:{})});if(!valid()||requestedDays!==$i('usage-days').value||requestedView!==$i('usage-view').value)return;const t=data.totals,old=$i('usage-view').value,views=new Map();for(const row of [...data.daily,...data.indexing])views.set(row.view_id,row.view_label);if(!old)$i('usage-view').replaceChildren(new Option('All',''),...Array.from(views,([id,label])=>new Option(label||id,id)));summary($i('usage-summary'),[['Call attempts',count(t.attempts),count(t.errors)+' with errors'],['Reported tokens',t.token_reports?count((t.prompt_tokens||0)+(t.output_tokens||0)):'Not reported',count(t.token_reports)+' of '+count(t.attempts)+' calls with data'],['Cost reported by the gateway',t.reported_usd==null?'Not reported':new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:6}).format(t.reported_usd),count(t.price_reports)+' calls with a cost; not the full bill'],['Chunks sent / cache',count(data.indexing.reduce((s,r)=>s+r.embedded_chunks,0))+' / '+count(data.indexing.reduce((s,r)=>s+r.reused_chunks,0)),'Unchanged files are counted separately']]);$i('usage-message').textContent='Loaded period: '+data.days+' days (UTC). '+data.notes.join(' ');$i('usage-table').innerHTML=table(['Date UTC','View / step','Model','Attempts','Reported tokens','Measured characters'],data.daily.slice(0,200).map(r=>[esc(r.day),esc(r.view_label)+'<br>'+esc(phaseNames[r.purpose]||r.purpose),esc(r.model),count(r.attempts),r.token_reports?count(r.prompt_tokens+r.output_tokens):'Not reported',count(r.input_characters)]));$i('usage-indexing').innerHTML=table(['View','Indexing jobs','Chunks sent','Cache reuse','Unchanged files'],data.indexing.map(r=>[esc(r.view_label),count(r.jobs),count(r.embedded_chunks),count(r.reused_chunks),count(r.unchanged_files)]))}
|
|
23
|
+
function renderStorage(data){storage=data;clearPlan();summary($i('storage-summary'),[['SQLite indexes',size(data.total_bytes)],['Vector cache',size(data.cache_bytes)],['Auxiliary generations',size(data.auxiliary_bytes)],['Stored views',count(data.views.length),'Active and pinned views are protected']]);$i('storage-table').innerHTML=table(['Clear','View','Space','Protection'],data.views.map(v=>[`<input type="checkbox" data-storage="${esc(v.storage_id)}" aria-label="Select ${esc(v.label)} for cleanup" ${v.protected?'disabled':''}>`,esc(v.label)+'<br><small>'+esc(v.commit?.slice(0,10)||'Commit not recorded')+'</small>',size(v.bytes),esc(v.protected_reason||'Can be selected')+`<br><button data-pin="${esc(v.storage_id)}" data-pinned="${v.pinned?'1':'0'}">${v.pinned?'Unpin':'Pin'}</button>`]));$i('storage-message').textContent=data.views.length?'Choose the old views and check the preview before removing.':'No stored views in this project.';clearPlan()}
|
|
24
|
+
async function loadStorage(key,valid){const data=await api({action:'storage',project_id:key});if(valid())renderStorage(data)}
|
|
25
|
+
$i('usage-load').onclick=()=>run($i('usage-load'),loadUsage);$i('usage-days').onchange=()=>run($i('usage-load'),loadUsage);$i('usage-view').onchange=()=>run($i('usage-load'),loadUsage);$i('storage-load').onclick=()=>run($i('storage-load'),loadStorage);
|
|
26
|
+
$i('storage-table').onchange=clearPlan;$i('cleanup-cache').onchange=clearPlan;
|
|
27
|
+
$i('storage-table').onclick=e=>{const button=e.target.closest('[data-pin]');if(button)run(button,async(key,valid)=>{const data=await api({action:'pin_view',project_id:key,storage_id:button.dataset.pin,pinned:button.dataset.pinned!=='1'});if(valid())renderStorage(data)})};
|
|
28
|
+
$i('cleanup-preview').onclick=()=>run($i('cleanup-preview'),async(key,valid)=>{const ids=[...document.querySelectorAll('#storage-table input[data-storage]:checked')].map(n=>n.dataset.storage);const data=await api({action:'cleanup_preview',project_id:key,storage_ids:ids,clear_cache:$i('cleanup-cache').checked});if(!valid())return;plan=data;$i('cleanup-plan').hidden=false;$i('cleanup-plan').innerHTML='<h3>Removal preview</h3><ul>'+data.views.map(v=>'<li>'+esc(v.label)+'</li>').join('')+'</ul><p>'+size(data.index_bytes)+' in indexes; '+count(data.unused_vectors)+' unreferenced vectors ('+size(data.vector_bytes)+' of vector content).</p><p class="insights-note">'+esc(data.note||'The final cache size depends on SQLite compaction.')+'</p><p>The project files will be kept.</p>';$i('cleanup-apply').hidden=false});
|
|
29
|
+
$i('cleanup-apply').onclick=()=>{if(!plan)return;if(!confirm('Remove the views in this preview: '+plan.views.map(v=>v.label).join(', ')+'? The project files will be kept.'))return;run($i('cleanup-apply'),async(key,valid)=>{const data=await api({action:'cleanup_commit',project_id:key,plan_id:plan.plan_id,confirm:true});if(!valid())return;renderStorage(data.storage);$i('storage-message').textContent=data.removed_views+' view(s) removed; '+data.removed_vectors+' vectors removed. '+data.warnings.join(' ');window.SmartMaps?.reset();await refresh()})};
|
|
30
|
+
async function loadViews(key,valid){compareRequest++;diffRequest++;comparison=null;for(const id of ['compare-summary','compare-files','compare-graph','compare-file-title','compare-diff'])$i(id).replaceChildren();const data=await api({action:'storage',project_id:key});if(!valid())return;const options=data.views.map(v=>[v.storage_id,v.label+(v.current?' (active)':'')]);$i('compare-left').replaceChildren(...options.map(([id,label])=>new Option(label,id)));$i('compare-right').replaceChildren(...options.map(([id,label])=>new Option(label,id)));if(options.length>1)$i('compare-right').selectedIndex=1;$i('compare-run').disabled=options.length<2;$i('compare-message').textContent=options.length<2?'Two indexed views are needed. Run searches on the branches you want to store them.':'Differences will be shown from A to B.'}
|
|
31
|
+
$i('compare-load-views').onclick=()=>run($i('compare-load-views'),loadViews);
|
|
32
|
+
for(const id of ['compare-left','compare-right','compare-relations'])$i(id).onchange=()=>{compareRequest++;diffRequest++;comparison=null;for(const host of ['compare-summary','compare-files','compare-graph','compare-file-title','compare-diff'])$i(host).replaceChildren();$i('compare-run').disabled=!$i('compare-left').value||!$i('compare-right').value||$i('compare-left').value===$i('compare-right').value;$i('compare-message').textContent='Selection changed. Run the comparison from A to B.'};
|
|
33
|
+
function fileButtons(data){const rows=[];for(const kind of ['added','removed','changed'])for(const path of data.files[kind])rows.push([kind==='added'?'Added in B':kind==='removed'?'Missing in B':'Changed',path]);for(const item of data.files.renamed)rows.push(['Rename detected',item.from+' → '+item.to]);return table(['Change','File'],rows.slice(0,1000).map(([kind,path])=>[esc(kind),kind==='Rename detected'?esc(path):`<button data-compare-file="${esc(path)}">${esc(path)}</button>`]))}
|
|
34
|
+
$i('compare-run').onclick=()=>run($i('compare-run'),async(key,valid)=>{const request=++compareRequest;diffRequest++;const left=$i('compare-left').value,right=$i('compare-right').value;if(!left||!right||left===right)throw Error('Choose two different views.');$i('compare-message').textContent='Comparing the stored snapshots…';const data=await api({action:'compare',project_id:key,left_view:left,right_view:right,relations:$i('compare-relations').checked});if(!valid()||request!==compareRequest)return;comparison={left,right};summary($i('compare-summary'),[['Added in B',count(data.files.added.length)],['Missing in B',count(data.files.removed.length)],['Changed',count(data.files.changed.length)],['Renamed / identical',count(data.files.renamed.length)+' / '+count(data.unchanged)]]);$i('compare-files').innerHTML=fileButtons(data);$i('compare-message').textContent=data.notes.join(' ')+(data.analysis_partial?' Some files/relations could not be analyzed.':'');$i('compare-file-title').textContent='';$i('compare-diff').textContent='';$i('compare-graph').innerHTML=data.relations?Object.entries(data.relations).map(([kind,changes])=>'<details><summary>'+esc(kind==='calls'?'Calls':'References between files')+' · +'+changes.added_count+' / −'+changes.removed_count+'</summary><div class="insights-table-wrap">'+table(['Change','Source → target'],[...changes.added.map(x=>['Added',esc(x[0])+' → '+esc(x[1])]),...changes.removed.map(x=>['Removed',esc(x[0])+' → '+esc(x[1])])])+'</div></details>').join(''):''});
|
|
35
|
+
$i('compare-files').onclick=e=>{const button=e.target.closest('[data-compare-file]');if(!button||!comparison)return;const chosen={...comparison},request=++diffRequest;run(button,async(key,valid)=>{const data=await api({action:'compare',project_id:key,left_view:chosen.left,right_view:chosen.right,file_path:button.dataset.compareFile,relations:false});if(!valid()||request!==diffRequest||comparison?.left!==chosen.left||comparison?.right!==chosen.right)return;$i('compare-file-title').textContent=button.dataset.compareFile+(button.dataset.compareFile.toLowerCase().endsWith('.docx')?' · extracted text':'');$i('compare-diff').innerHTML=data.diff_error?esc(data.diff_error):(data.diff||'The indexed text is identical; metadata/hashes may have changed.').split('\n').map(line=>'<span class="'+(line.startsWith('+')?'added':line.startsWith('-')?'removed':'')+'">'+esc(line)+'</span>').join('\n');if(data.diff_truncated)$i('compare-message').textContent+=' Diff limited to 1,000 lines / 128,000 characters.'})};
|
|
36
|
+
return {reset};
|
|
37
|
+
})();
|
|
38
|
+
'''
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Interface dos três mapas reais por projeto; sem bibliotecas de UI externas."""
|
|
2
|
+
|
|
3
|
+
STYLE = r'''
|
|
4
|
+
.maps{--map-blue-fill:#bfdbfe;--map-blue-stroke:#1d4ed8;--map-blue-ink:#172554;--map-teal-fill:#99f6e4;--map-teal-stroke:#0f766e;--map-teal-ink:#042f2e;--map-violet-fill:#ddd6fe;--map-violet-stroke:#6d28d9;--map-violet-ink:#2e1065;--map-amber-fill:#fde68a;--map-amber-stroke:#a16207;--map-amber-ink:#422006;--map-rose-fill:#fecdd3;--map-rose-stroke:#be123c;--map-rose-ink:#4c0519;--map-sky-fill:#bae6fd;--map-sky-stroke:#0369a1;--map-sky-ink:#082f49;--map-orange-fill:#fed7aa;--map-orange-stroke:#c2410c;--map-orange-ink:#431407;--map-green-fill:#bbf7d0;--map-green-stroke:#15803d;--map-green-ink:#052e16}
|
|
5
|
+
@media(prefers-color-scheme:dark){.maps{--map-blue-fill:#1e3a8a;--map-blue-stroke:#93c5fd;--map-blue-ink:#eff6ff;--map-teal-fill:#134e4a;--map-teal-stroke:#5eead4;--map-teal-ink:#f0fdfa;--map-violet-fill:#4c1d95;--map-violet-stroke:#c4b5fd;--map-violet-ink:#f5f3ff;--map-amber-fill:#713f12;--map-amber-stroke:#fcd34d;--map-amber-ink:#fffbeb;--map-rose-fill:#881337;--map-rose-stroke:#fda4af;--map-rose-ink:#fff1f2;--map-sky-fill:#0c4a6e;--map-sky-stroke:#7dd3fc;--map-sky-ink:#f0f9ff;--map-orange-fill:#7c2d12;--map-orange-stroke:#fdba74;--map-orange-ink:#fff7ed;--map-green-fill:#14532d;--map-green-stroke:#86efac;--map-green-ink:#f0fdf4}}
|
|
6
|
+
.maps-color-legend{padding:10px 20px;display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap;max-height:115px;overflow:auto;font-size:12px;color:var(--muted);border-top:1px solid var(--border)}.maps-color-legend>span{display:inline-flex;align-items:center;gap:6px;max-width:100%;overflow-wrap:anywhere}.maps-swatch{display:inline-block;width:13px;height:13px;flex:0 0 13px;border-radius:3px;background:var(--swatch-fill);border:1px solid var(--swatch-stroke)}.maps-node.pending circle,.maps-node.pending rect,.maps-node.missing circle,.maps-node.missing rect{stroke-dasharray:6 3}
|
|
7
|
+
.maps-inspector{max-height:none!important;overflow:visible!important;background:var(--surface)}
|
|
8
|
+
.maps{margin:18px 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden}.maps>summary{padding:18px 20px}.maps[open]>summary{border-bottom:1px solid var(--border)}.maps-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:16px 20px 0}.maps-tabs button[aria-selected=true]{background:var(--selected);border-color:var(--accent)}.maps-toolbar{padding:14px 20px;display:flex;gap:10px;align-items:end;flex-wrap:wrap}.maps-toolbar label{font-size:12px;margin:0 0 4px}.maps-toolbar input,.maps-toolbar select{min-height:40px;max-width:100%;margin:0}.maps-search{flex:1;min-width:150px}.maps-view{max-width:280px}.maps-status{padding:0 20px;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.maps-workspace{display:grid;grid-template-columns:minmax(0,1fr) 245px;border-block:1px solid var(--border);margin-top:14px}.maps-canvas{min-width:0;background:var(--bg)}.maps-tools{display:flex;gap:6px;padding:10px 14px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border)}.maps-tools button{font-size:12px;padding:6px 10px;min-height:36px}.maps-tools span{margin-left:auto;font-size:12px;color:var(--muted)}#maps-svg{display:block;width:100%;height:480px;touch-action:none;user-select:none;cursor:grab}#maps-svg:active{cursor:grabbing}.maps-edge{stroke:var(--edge-color,var(--border));stroke-width:1.6;fill:none;opacity:.85}.maps-edge.active{stroke:var(--edge-color,var(--accent));stroke-width:2.8;opacity:1}.maps-node{cursor:pointer}.maps-node circle,.maps-node rect{fill:var(--node-fill,var(--surface));stroke:var(--node-stroke,var(--muted));stroke-width:2}.maps-node text{fill:var(--ink);font:12px 'Segoe UI',sans-serif;pointer-events:none}.maps-node .on-node,.maps-node .secondary{fill:var(--node-ink,var(--ink))}.maps-node .secondary{font-size:11px}.maps-node.active circle,.maps-node.active rect{fill:var(--node-fill,var(--selected));stroke:var(--ink)!important;stroke-width:4}.maps-node:focus-visible{outline:none}.maps-node:focus-visible circle,.maps-node:focus-visible rect{stroke:var(--ink)!important;stroke-width:4}.maps-folder-label{fill:var(--muted);font:600 12px 'Segoe UI',sans-serif}.maps-inspector{border-left:1px solid var(--border);padding:18px;min-width:0}.maps-inspector h3{overflow-wrap:anywhere}.maps-inspector code{display:block;overflow-wrap:anywhere;color:var(--muted);margin:8px 0}.maps-inspector p{font-size:13px}.maps-relations{padding:0;list-style:none;max-height:180px;overflow:auto}.maps-relations button{padding:5px 0;min-height:28px;border:0;font-size:12px;text-align:left;color:var(--accent);overflow-wrap:anywhere}.maps-relations button:hover{text-decoration:underline;background:transparent}.maps-bottom{padding:12px 20px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}.maps-legend{display:flex;gap:14px;flex-wrap:wrap}.maps-legend span{display:flex;gap:6px;align-items:center}.maps-dot{width:9px;height:9px;border-radius:50%;background:var(--good)}.maps-dot.pending{background:var(--warn)}.maps-dot.stored{background:var(--muted)}#map-files{padding:0 20px;max-height:220px;overflow:auto}#map-chunks{padding:0 20px 14px}#map-chunks pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.65 Consolas,monospace}#maps-diagnostics{margin:0 20px}#maps-diagnostic-list{max-height:260px;overflow:auto;font-size:13px;overflow-wrap:anywhere}.maps:fullscreen{background:var(--surface);border:0;border-radius:0;overflow:auto;padding:12px;margin:0}.maps:fullscreen #maps-svg{height:calc(100vh - 350px);min-height:400px}.maps:fullscreen .maps-workspace{grid-template-columns:minmax(0,1fr) 300px}.maps-placeholder{padding:30px 20px;font-size:13px;color:var(--muted)}#maps-symbol-picker{max-width:100%;width:100%;margin:8px 0}
|
|
9
|
+
@media(max-width:1050px){.maps-workspace{grid-template-columns:1fr}.maps-inspector{border-left:0;border-top:1px solid var(--border)}#maps-svg{height:460px}.maps-view{max-width:100%;flex:1}}
|
|
10
|
+
@media(max-width:600px){.maps-tabs,.maps-toolbar{padding-inline:12px}.maps-toolbar>div{max-width:100%}.maps-view{width:100%;flex-basis:100%}.maps-toolbar select{width:100%}.maps-tabs button{flex:1;padding:8px;font-size:13px}#maps-svg{height:420px}.maps:fullscreen .maps-workspace{grid-template-columns:1fr}.maps-bottom{padding-inline:12px}#map-files,#map-chunks{padding-inline:12px}}
|
|
11
|
+
'''
|
|
12
|
+
|
|
13
|
+
HTML = r'''
|
|
14
|
+
<details id="map-details" class="maps">
|
|
15
|
+
<summary>Project maps and stored views</summary>
|
|
16
|
+
<div class="maps-tabs" role="tablist" aria-label="Project maps">
|
|
17
|
+
<button type="button" role="tab" data-map-mode="network" id="map-tab-network" aria-controls="maps-panel" aria-selected="true">A · Files</button>
|
|
18
|
+
<button type="button" role="tab" data-map-mode="coverage" id="map-tab-coverage" aria-controls="maps-panel" aria-selected="false" tabindex="-1">B · Coverage</button>
|
|
19
|
+
<button type="button" role="tab" data-map-mode="calls" id="map-tab-calls" aria-controls="maps-panel" aria-selected="false" tabindex="-1">C · Functions</button>
|
|
20
|
+
</div>
|
|
21
|
+
<div id="maps-panel" role="tabpanel" aria-labelledby="map-tab-network">
|
|
22
|
+
<div class="maps-toolbar">
|
|
23
|
+
<div class="maps-view"><label for="map-view">Stored view</label><select id="map-view"><option value="">Current view or available legacy</option></select></div>
|
|
24
|
+
<div class="maps-search"><label for="maps-search">Find file or function</label><input id="maps-search" type="text" placeholder="Name or path" autocomplete="off"></div>
|
|
25
|
+
<div><label for="maps-folder">Folder</label><select id="maps-folder"><option value="">All</option></select></div>
|
|
26
|
+
<div><label for="maps-color-mode">Color by</label><select id="maps-color-mode"><option value="module">Module / folder</option><option value="status">Index status</option></select></div>
|
|
27
|
+
<button type="button" id="load-map">Load maps</button>
|
|
28
|
+
</div>
|
|
29
|
+
<p class="maps-status" id="map-info" role="status" aria-live="polite">Local analysis of already indexed content, with no new embeddings.</p>
|
|
30
|
+
<p class="maps-status" id="maps-description">Arrows link the importing file to its resolved internal dependency.</p>
|
|
31
|
+
<div id="maps-color-legend" class="maps-color-legend" aria-label="Color legend"></div>
|
|
32
|
+
<div class="maps-workspace">
|
|
33
|
+
<div class="maps-canvas" id="map-graph"><div class="maps-tools"><button id="maps-minus" aria-label="Zoom out">−</button><button id="maps-plus" aria-label="Zoom in">+</button><button id="maps-fit">Fit</button><button id="maps-all">Show all</button><button id="maps-expand">Full screen</button><button id="maps-new-window">New window</button><a id="maps-popup-link" hidden target="_blank" rel="noopener noreferrer">Open map separately</a><label for="maps-limit">Nodes</label><select id="maps-limit" aria-label="Visible node limit"><option value="60">60</option><option value="120" selected>120</option><option value="250">250</option></select><span id="maps-count"></span></div>
|
|
34
|
+
<svg id="maps-svg" viewBox="0 0 1000 650" role="group" aria-label="Interactive project map"><defs><marker id="maps-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse"><path d="M0 0 L8 4 L0 8" fill="var(--accent)"></path></marker></defs><g id="maps-world"></g></svg>
|
|
35
|
+
<div id="maps-empty" class="maps-placeholder">Load a view to explore the three maps.</div></div>
|
|
36
|
+
<aside class="maps-inspector"><h3 id="maps-selected">Select an element</h3><code id="maps-path"></code><p id="maps-state"></p><p id="maps-facts"></p><label for="maps-symbol-picker">Open element by name</label><select id="maps-symbol-picker"><option value="">Select…</option></select><h3 id="maps-relation-title">Relations</h3><ul id="maps-relations" class="maps-relations"></ul><button id="maps-read" disabled>View file chunks</button></aside>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="maps-bottom"><span id="maps-color-note">Color by module/folder · dashed border means changed or missing.</span><span>Drag nodes or the background; zoom in to read the details.</span></div>
|
|
39
|
+
<details id="maps-diagnostics"><summary>Analysis coverage and unresolved relations</summary><div id="maps-diagnostic-list"></div></details>
|
|
40
|
+
<div id="map-files"></div><div id="map-chunks"></div>
|
|
41
|
+
</div></details>
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
SCRIPT = r'''
|
|
45
|
+
window.SmartMaps=(()=>{
|
|
46
|
+
const byId=id=>document.getElementById(id),ns='http://www.w3.org/2000/svg';
|
|
47
|
+
let data=null,project=null,mode='network',selectedNode=null,focus=false,positions=new Map(),zoom=1,panX=0,panY=0,drag=null,requestId=0,aborter=null;
|
|
48
|
+
let currentNodes=[],currentEdges=[],shown=[],shownEdges=[],globalLookup=new Map();
|
|
49
|
+
const description={network:'Arrows represent resolved local references: imports, Java types, templates, styles and document links. External targets are listed in the details.',coverage:'Area proportional to the lines stored in the index. Grouped by folder. The map covers the files present in this view.',calls:'Arrows represent resolved static calls between functions, methods and classes. They do not represent an execution trace.'};
|
|
50
|
+
const statusLabel={indexed:'Local metadata checked',pending:'File changed since indexing',missing:'File not found in the current directory',stored:'Content stored; local state not compared'};
|
|
51
|
+
const palette=['blue','teal','violet','amber','rose','sky','orange','green'];
|
|
52
|
+
const statePalette={indexed:'green',pending:'amber',missing:'rose',stored:'blue'};
|
|
53
|
+
function moduleKey(node){const file=node.file||node,path=file.path||node.path||'',folder=file.group||'(root)';if(folder==='(root)')return path.split('/').pop()||'(root)';const parts=folder.split('/');return parts.slice(0,parts[0]==='src'?2:1).join('/')}
|
|
54
|
+
function moduleColor(key){let hash=2166136261;for(const ch of key)hash=Math.imul(hash^ch.codePointAt(0),16777619);return palette[(hash>>>0)%palette.length]}
|
|
55
|
+
function paint(node){return byId('maps-color-mode').value==='status'?(statePalette[node.file?.status||node.status]||'blue'):moduleColor(moduleKey(node))}
|
|
56
|
+
function decorateNode(group,node){const shade=paint(node);for(const role of ['fill','stroke','ink'])group.style.setProperty('--node-'+role,`var(--map-${shade}-${role})`);group.classList.add(node.file?.status||node.status||'stored')}
|
|
57
|
+
function edgeElement(tag,attrs,edge){const shade=paint(globalLookup.get(edge.source)||{}),node=element(tag,{...attrs,'marker-end':`url(#maps-arrow-${shade})`});node.style.setProperty('--edge-color',`var(--map-${shade}-stroke)`);return node}
|
|
58
|
+
function renderLegend(){const host=byId('maps-color-legend'),byState=byId('maps-color-mode').value==='status',groups=new Map();host.replaceChildren();const intro=document.createElement('strong');intro.textContent=byState?'Index status:':'Modules / folders:';host.append(intro);for(const node of shown){const key=byState?(node.file?.status||node.status||'stored'):moduleKey(node);if(!groups.has(key))groups.set(key,{count:0,shade:paint(node)});groups.get(key).count++}for(const [key,item] of [...groups].sort((a,b)=>a[0].localeCompare(b[0]))){const label=document.createElement('span'),swatch=document.createElement('i');swatch.className='maps-swatch';swatch.setAttribute('aria-hidden','true');swatch.style.setProperty('--swatch-fill',`var(--map-${item.shade}-fill)`);swatch.style.setProperty('--swatch-stroke',`var(--map-${item.shade}-stroke)`);label.append(swatch,document.createTextNode((byState?statusLabel[key]:key)+' ('+item.count+')'));host.append(label)}byId('maps-color-note').textContent=byState?'Fill by status · dashed border means changed or missing.':'Fill by module/folder · dashed border means changed or missing.'}
|
|
59
|
+
function element(tag,attrs={},text){const node=document.createElementNS(ns,tag);for(const [k,v] of Object.entries(attrs))node.setAttribute(k,String(v));if(text!==undefined)node.textContent=text;return node}
|
|
60
|
+
function plainList(host,rows){host.replaceChildren();for(const text of rows){const p=document.createElement('p');p.textContent=text;host.append(p)}}
|
|
61
|
+
function clearSelection(){byId('maps-selected').textContent='Select an element';for(const id of ['maps-path','maps-state','maps-facts'])byId(id).textContent='';byId('maps-relation-title').textContent=mode==='calls'?'Static calls':'File dependencies';byId('maps-relations').replaceChildren();byId('maps-read').disabled=true}
|
|
62
|
+
function reset(){++requestId;aborter?.abort();data=null;project=null;selectedNode=null;positions.clear();currentNodes=[];currentEdges=[];shown=[];shownEdges=[];globalLookup.clear();byId('maps-world').replaceChildren();byId('maps-color-legend').replaceChildren();byId('map-files').replaceChildren();byId('map-chunks').replaceChildren();byId('maps-diagnostic-list').replaceChildren();byId('map-view').replaceChildren(new Option('Current view or available legacy',''));byId('maps-folder').replaceChildren(new Option('All',''));byId('maps-symbol-picker').replaceChildren(new Option('Select…',''));byId('maps-search').value='';byId('map-info').textContent='Load the maps of this project. The analysis is local and creates no embeddings.';byId('maps-count').textContent='';byId('maps-empty').hidden=false;byId('maps-empty').textContent='Load a view to explore the three maps.';byId('load-map').disabled=false;byId('maps-popup-link').hidden=true;byId('maps-popup-link').removeAttribute('href');clearSelection()}
|
|
63
|
+
async function load(view,restore=null){const key=selected,id=++requestId;aborter?.abort();aborter=new AbortController();byId('load-map').disabled=true;byId('map-info').textContent='Reading the snapshot and analyzing imports and calls…';try{const response=await fetch('/setup/projects',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':token},body:JSON.stringify({action:'graph',project_id:key,...(view?{view_id:view}:{})}),signal:aborter.signal});const result=await response.json();if(!response.ok)throw Error(result.error||'Could not load the maps.');if(id!==requestId||key!==selected)return;data=result;project=key;selectedNode=null;focus=false;positions.clear();byId('map-view').replaceChildren(...data.views.map(v=>new Option(v.label+(v.current?' (active)':'')+' · '+v.files+' files',v.view_id)));if(data.selected)byId('map-view').value=data.selected.view_id;byId('map-info').textContent=data.selected?`${data.selected.label} · ${data.counts.files} files · ${data.counts.parsed_files} analyzed. ${data.selected.current?'Indexed content; local changes may be pending.':'Stored snapshot. Current view: '+(data.current_view?.label||'not identified')+'.'}`:data.message;byId('maps-folder').replaceChildren(new Option('All',''),...Array.from(new Set(data.files.map(f=>f.group))).sort().map(f=>new Option(f,f)));byId('map-chunks').replaceChildren();renderDiagnostics();if(restore){byId('maps-search').value=restore.q||'';if([...byId('maps-folder').options].some(o=>o.value===restore.folder))byId('maps-folder').value=restore.folder;if(['60','120','250'].includes(restore.limit))byId('maps-limit').value=restore.limit;if(['module','status'].includes(restore.colors))byId('maps-color-mode').value=restore.colors}refreshGraph(true);if(restore?.node&&globalLookup.has(restore.node)){selectedNode=restore.node;focus=restore.focus==='1';refreshGraph(true)}}catch(e){if(e.name!=='AbortError'&&id===requestId){byId('map-info').textContent=e.message+' Use Load maps to try again.';message(e.message,true)}}finally{if(id===requestId)byId('load-map').disabled=false}}
|
|
64
|
+
function renderDiagnostics(){const c=data.counts||{},lines=[`Local analysis of Java, Angular, Python, JavaScript/TypeScript, HTML/CSS and Markdown links. TXT and DOCX show up in coverage and in text search. ${c.parsed_files||0} of ${c.files||0} files were analyzed. Other languages and incomplete snapshots remain available in coverage.`,`${c.resolved_imports||0} resolved internal references; ${c.external_imports||0} external references; ${c.calls||0} call relations. Unresolved targets are a limited sample (${c.unresolved_calls||0} records in the analysis).`];if(data.truncated)lines.push('The analysis limits were reached. Filter by folder to explore the available part.');lines.push(...(data.diagnostics||[]).slice(0,60).map(d=>d.path+': '+d.reason));lines.push(...(data.unresolved||[]).slice(0,40).map(u=>u.path+':'+u.line+' · '+u.expression+' — '+u.reason));plainList(byId('maps-diagnostic-list'),lines)}
|
|
65
|
+
function prepare(){const files=new Map((data?.files||[]).map(f=>[f.path,f]));currentNodes=mode==='calls'?(data?.symbols||[]).map(s=>({...s,file:files.get(s.path),label:s.name==='callback'?'callback :'+s.start_line:s.name})): (data?.files||[]).map(f=>({...f,file:f,label:/^(index|styles|__init__)\./.test(f.path.split('/').pop())?f.path.split('/').slice(-2).join('/'):f.path.split('/').pop()}));globalLookup=new Map(currentNodes.map(n=>[n.id,n]));currentEdges=(mode==='calls'?data?.calls:data?.dependencies)||[];currentEdges=currentEdges.filter(e=>e.target&&globalLookup.has(e.source)&&globalLookup.has(e.target));}
|
|
66
|
+
function chooseVisible(){const term=byId('maps-search').value.trim().toLocaleLowerCase(),folder=byId('maps-folder').value,limit=Number(byId('maps-limit').value);const degree=new Map();for(const e of currentEdges){degree.set(e.source,(degree.get(e.source)||0)+1);degree.set(e.target,(degree.get(e.target)||0)+1)}let list=currentNodes.filter(n=>(!folder||n.file?.group===folder)&&(!term||(n.path+' '+n.label).toLocaleLowerCase().includes(term)));if(focus&&selectedNode){const near=new Set([selectedNode]);for(const e of currentEdges){if(e.source===selectedNode)near.add(e.target);if(e.target===selectedNode)near.add(e.source)}list=currentNodes.filter(n=>near.has(n.id))}list.sort((a,b)=>mode==='coverage'?(b.lines||0)-(a.lines||0):((degree.get(b.id)||0)-(degree.get(a.id)||0)||a.path.localeCompare(b.path)));if(selectedNode&&globalLookup.has(selectedNode)&&list.some(n=>n.id===selectedNode))list=[globalLookup.get(selectedNode),...list.filter(n=>n.id!==selectedNode)];shown=list.slice(0,limit);const ids=new Set(shown.map(n=>n.id));shownEdges=currentEdges.filter(e=>ids.has(e.source)&&ids.has(e.target)).slice(0,1000);byId('maps-count').textContent=mode==='coverage'?`${shown.length} of ${list.length} files · area by lines`:`${shown.length} of ${list.length} nodes · ${shownEdges.length} visible relations${focus?' · selected neighborhood':''}`;byId('maps-empty').hidden=!!shown.length;byId('maps-empty').textContent=mode==='calls'?'No resolved functions in this selection. Check the diagnostics or choose another folder/view.':'No files match the filter.';byId('maps-symbol-picker').replaceChildren(new Option('Select…',''),...list.slice(0,1000).map(n=>new Option(n.label+' · '+n.path,n.id)));if(selectedNode)byId('maps-symbol-picker').value=selectedNode;}
|
|
67
|
+
function layout(){const groups=new Map();for(const n of shown){const pathGroup=n.file?.group||'(root)',parts=pathGroup.split('/'),g=parts.slice(0,parts[0]==='src'?2:1).join('/');if(!groups.has(g))groups.set(g,[]);groups.get(g).push(n)}const sorted=[...groups].sort((a,b)=>b[1].length-a[1].length);const cols=Math.max(1,Math.ceil(Math.sqrt(sorted.length*1.4)));let i=0;for(const [group,nodes] of sorted){const step=mode==='calls'?580:440,cx=210+(i%cols)*step,cy=210+Math.floor(i/cols)*step;nodes.forEach((n,j)=>{if(!positions.has(n.id)){const ring=Math.floor(Math.sqrt(j)),angle=j*2.399963,rad=nodes.length===1?0:mode==='calls'?90+ring*105:50+ring*65;positions.set(n.id,{x:cx+Math.cos(angle)*rad,y:cy+Math.sin(angle)*rad})}});i++}}
|
|
68
|
+
function bounds(){const points=shown.map(n=>positions.get(n.id)).filter(Boolean);if(!points.length)return {x:0,y:0,w:1000,h:650};const xs=points.map(p=>p.x),ys=points.map(p=>p.y);return {x:Math.min(...xs)-100,y:Math.min(...ys)-60,w:Math.max(...xs)-Math.min(...xs)+200,h:Math.max(...ys)-Math.min(...ys)+120}}
|
|
69
|
+
function transform(){byId('maps-world').setAttribute('transform',`translate(${panX} ${panY}) scale(${zoom})`)}
|
|
70
|
+
function fit(){if(mode==='coverage'){zoom=1;panX=panY=0}else{const b=bounds();zoom=Math.min(2.4,900/b.w,560/b.h);panX=500-(b.x+b.w/2)*zoom;panY=325-(b.y+b.h/2)*zoom}transform()}
|
|
71
|
+
function color(node){return ['pending','missing'].includes(node.file?.status)?'var(--warn)':node.file?.status==='indexed'?'var(--good)':'var(--muted)'}
|
|
72
|
+
function wire(group,node){decorateNode(group,node);group.dataset.graphNode=node.id;group.setAttribute('role','button');group.setAttribute('tabindex','0');group.setAttribute('aria-label',node.label+' — '+node.path);group.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();selectNode(node.id,true)}})}
|
|
73
|
+
function render(){renderLegend();const world=byId('maps-world');world.replaceChildren();if(mode==='coverage'){coverage(world);transform();return}for(const edge of shownEdges){const a=positions.get(edge.source),b=positions.get(edge.target);if(!a||!b)continue;if(edge.source===edge.target){const r=mode==='calls'?70:35;world.append(edgeElement('path',{d:`M${a.x+r/2} ${a.y-20} C${a.x+r*2} ${a.y-r*2},${a.x-r*2} ${a.y-r*2},${a.x-r/2} ${a.y-20}`,class:'maps-edge'+(edge.source===selectedNode?' active':''),'marker-end':'url(#maps-arrow)'},edge));continue}const dx=b.x-a.x,dy=b.y-a.y,len=Math.hypot(dx,dy)||1,r=mode==='calls'?Math.min(65/(Math.abs(dx)/len||.0001),20/(Math.abs(dy)/len||.0001))+8:22+Math.min(12,Math.sqrt(globalLookup.get(edge.target)?.chunks||1));world.append(edgeElement('line',{x1:a.x,y1:a.y,x2:b.x-dx/len*r,y2:b.y-dy/len*r,class:'maps-edge'+([edge.source,edge.target].includes(selectedNode)?' active':''),'marker-end':'url(#maps-arrow)'},edge))}for(const n of shown){const p=positions.get(n.id),g=element('g',{class:'maps-node'+(n.id===selectedNode?' active':'')});if(mode==='calls'){g.append(element('rect',{x:p.x-65,y:p.y-20,width:130,height:40,rx:6,stroke:color(n)}));g.append(element('text',{x:p.x,y:p.y+4,'text-anchor':'middle',class:'on-node'},n.label.length>18?n.label.slice(0,16)+'…':n.label))}else{g.append(element('circle',{cx:p.x,cy:p.y,r:14+Math.min(12,Math.sqrt(n.chunks||1)),stroke:color(n),'stroke-dasharray':['pending','missing'].includes(n.status)?'4 3':'none'}));g.append(element('text',{x:p.x,y:p.y+4,'text-anchor':'middle',class:'on-node'},String(n.chunks||0)));g.append(element('text',{x:p.x,y:p.y+39,'text-anchor':'middle'},n.label.length>24?n.label.slice(0,22)+'…':n.label))}g.append(element('title',{},n.path+' · '+n.label));wire(g,n);world.append(g)}transform()}
|
|
74
|
+
function partition(items,x,y,w,h,weight,paint){if(!items.length)return;if(items.length===1){paint(items[0],x,y,w,h);return}const total=items.reduce((s,n)=>s+weight(n),0);let sum=0,cut=1;for(let i=0;i<items.length-1;i++){sum+=weight(items[i]);cut=i+1;if(sum>=total/2)break}const r=sum/total;if(w>=h){partition(items.slice(0,cut),x,y,w*r,h,weight,paint);partition(items.slice(cut),x+w*r,y,w*(1-r),h,weight,paint)}else{partition(items.slice(0,cut),x,y,w,h*r,weight,paint);partition(items.slice(cut),x,y+h*r,w,h*(1-r),weight,paint)}}
|
|
75
|
+
function coverage(world){const groups=new Map();for(const n of shown){const key=n.file?.group||'(root)';if(!groups.has(key))groups.set(key,[]);groups.get(key).push(n)}const weight=n=>Math.max(n.lines||n.file?.lines||1,1);partition([...groups],10,10,980,620,g=>g[1].reduce((s,n)=>s+weight(n),0),(entry,x,y,w,h)=>{world.append(element('rect',{x:x+2,y:y+2,width:Math.max(w-4,1),height:Math.max(h-4,1),fill:'var(--surface)',stroke:'var(--border)',rx:5}));const title=entry[0];if(w>60&&h>35)world.append(element('text',{x:x+10,y:y+18,class:'maps-folder-label'},title.length>Math.floor(w/7)?title.slice(0,Math.floor(w/7)-2)+'…':title));partition(entry[1],x+5,y+25,Math.max(w-10,1),Math.max(h-30,1),weight,(n,fx,fy,fw,fh)=>{const g=element('g',{class:'maps-node'+(n.id===selectedNode?' active':'')});g.append(element('rect',{x:fx+2,y:fy+2,width:Math.max(fw-4,1),height:Math.max(fh-4,1),rx:3,stroke:color(n),'stroke-dasharray':['pending','missing'].includes(n.status)?'4 3':'none'}));if(fw>50&&fh>30){const max=Math.max(4,Math.floor((fw-16)/7));g.append(element('text',{x:fx+8,y:fy+20,class:'on-node'},n.label.length>max?n.label.slice(0,max-1)+'…':n.label));if(fh>50&&fw>100){const detail=`${n.lines} lines · ${n.chunks} chunks`;g.append(element('text',{x:fx+8,y:fy+39,class:'secondary'},detail.length>max?detail.slice(0,max-1)+'…':detail))}}g.append(element('title',{},n.path));wire(g,n);world.append(g)})})}
|
|
76
|
+
function inspector(){const n=globalLookup.get(selectedNode);if(!n){clearSelection();return}byId('maps-selected').textContent=n.label;byId('maps-path').textContent=n.path;byId('maps-state').textContent=statusLabel[n.file?.status]||'Indexed snapshot';byId('maps-facts').textContent=mode==='calls'?`${n.kind} · lines ${n.start_line}–${n.end_line} · ${n.language}`:`${n.lines||0} ${n.file?.location_kind==='extracted_text_line'?'extracted lines':'lines'} · ${n.chunks||0} chunks · ${n.size==null||n.size<0?'size not recorded':n.size+' bytes'}`;byId('maps-read').disabled=false;byId('maps-relations').replaceChildren();byId('maps-relation-title').textContent=mode==='calls'?'Static calls':'File dependencies';const edges=currentEdges.filter(e=>e.source===selectedNode||e.target===selectedNode);for(const e of edges.slice(0,40)){const id=e.source===selectedNode?e.target:e.source,node=globalLookup.get(id),li=document.createElement('li'),button=document.createElement('button');button.textContent=(e.source===selectedNode?'→ ':'← ')+(node?.label||id)+(e.count>1?' ('+e.count+' sites)':'');button.onclick=()=>selectNode(id);li.append(button);byId('maps-relations').append(li)}if(mode!=='calls')for(const e of (data.dependencies||[]).filter(e=>e.source===n.path&&!e.target).slice(0,15)){const li=document.createElement('li');li.textContent=e.specifier+' · '+(e.resolution==='external'?'external':'unresolved');byId('maps-relations').append(li)}if(!byId('maps-relations').children.length){const li=document.createElement('li');li.textContent='No resolved internal relations in this selection.';byId('maps-relations').append(li)}}
|
|
77
|
+
function renderFiles(){const term=byId('maps-search').value.trim().toLocaleLowerCase(),folder=byId('maps-folder').value;const files=(data?.files||[]).filter(f=>(!folder||f.group===folder)&&(!term||f.path.toLocaleLowerCase().includes(term))).slice(0,150);byId('map-files').innerHTML='<p class="muted">Files in the selection · up to 150 items. Filter by path to find others.</p>'+files.map(f=>`<button class="project-item" data-file="${esc(f.path)}"><strong>${esc(f.path)}</strong><small>${f.chunks} chunks · ${esc(statusLabel[f.status]||'Snapshot')}</small></button>`).join('')}
|
|
78
|
+
function publishWindowState(){if(document.body.classList.contains('map-window')&&data?.selected){const state=windowState();history.replaceState(null,'','/maps#'+state.toString())}}
|
|
79
|
+
function refreshGraph(reframe=false){prepare();chooseVisible();if(reframe)positions.clear();layout();render();if(reframe)fit();inspector();renderFiles();byId('maps-description').textContent=description[mode];publishWindowState()}
|
|
80
|
+
function selectNode(id,keyboard=false){selectedNode=id;focus=mode!=='coverage';refreshGraph(true);if(keyboard)byId('maps-world').querySelector('[data-graph-node="'+CSS.escape(id)+'"]')?.focus()}
|
|
81
|
+
async function readFile(path){if(!data?.selected||project!==selected)return;const key=selected,view=data.selected.view_id,request=requestId;try{const detail=await api({action:'inspect',project_id:key,view_id:view,file_path:path});if(key!==selected||request!==requestId)return;byId('map-chunks').innerHTML='<h3>'+esc(path)+'</h3><p class="muted">Text of the stored view; up to 100 chunks. May differ from the current file.</p>'+detail.chunks.map(c=>`<details><summary>${c.location_kind==='extracted_text_line'?'Extracted text lines':'Lines'} ${c.start_line}–${c.end_line} · ${c.dimensions} dimensions</summary><pre>${esc(c.text)}</pre></details>`).join('');byId('map-chunks').scrollIntoView({block:'nearest',behavior:'auto'})}catch(e){message(e.message,true)}}
|
|
82
|
+
function switchMode(value){mode=value;focus=false;selectedNode=null;positions.clear();for(const b of document.querySelectorAll('[data-map-mode]')){const active=b.dataset.mapMode===mode;b.setAttribute('aria-selected',String(active));b.tabIndex=active?0:-1}byId('maps-panel').setAttribute('aria-labelledby','map-tab-'+mode);if(data)refreshGraph(true);else byId('maps-description').textContent=description[mode];publishWindowState()}
|
|
83
|
+
document.querySelectorAll('[data-map-mode]').forEach(b=>{b.onclick=()=>switchMode(b.dataset.mapMode);b.onkeydown=e=>{if(['ArrowLeft','ArrowRight','Home','End'].includes(e.key)){e.preventDefault();const tabs=[...document.querySelectorAll('[data-map-mode]')],i=tabs.indexOf(b),j=e.key==='Home'?0:e.key==='End'?2:(i+(e.key==='ArrowRight'?1:2))%3;tabs[j].focus();switchMode(tabs[j].dataset.mapMode)}}});
|
|
84
|
+
byId('load-map').onclick=()=>load(byId('map-view').value||undefined);byId('map-view').onchange=()=>load(byId('map-view').value);byId('maps-search').oninput=()=>{focus=false;selectedNode=null;refreshGraph(true)};byId('maps-folder').onchange=()=>{focus=false;selectedNode=null;positions.clear();refreshGraph(true)};byId('maps-limit').onchange=()=>refreshGraph(true);byId('maps-all').onclick=()=>{focus=false;selectedNode=null;refreshGraph(true)};byId('maps-symbol-picker').onchange=()=>{if(byId('maps-symbol-picker').value)selectNode(byId('maps-symbol-picker').value)};byId('maps-read').onclick=()=>{const n=globalLookup.get(selectedNode);if(n)readFile(n.path)};byId('map-files').onclick=e=>{const b=e.target.closest('[data-file]');if(b)readFile(b.dataset.file)};
|
|
85
|
+
function point(e){const p=byId('maps-svg').createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(byId('maps-svg').getScreenCTM().inverse())}
|
|
86
|
+
function scale(factor,p={x:500,y:325}){const next=Math.min(8,Math.max(.15,zoom*factor));panX=p.x-(p.x-panX)*next/zoom;panY=p.y-(p.y-panY)*next/zoom;zoom=next;transform()}
|
|
87
|
+
byId('maps-plus').onclick=()=>scale(1.3);byId('maps-minus').onclick=()=>scale(1/1.3);byId('maps-fit').onclick=fit;byId('maps-expand').onclick=async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await byId('map-details').requestFullscreen()}catch{message('The browser did not open full screen; the map is still available on the page.',true)}};
|
|
88
|
+
const svg=byId('maps-svg');for(const shade of palette){const marker=element('marker',{id:'maps-arrow-'+shade,markerWidth:8,markerHeight:8,refX:7,refY:4,orient:'auto',markerUnits:'userSpaceOnUse'});marker.append(element('path',{d:'M0 0 L8 4 L0 8',fill:`var(--map-${shade}-stroke)`}));svg.querySelector('defs').append(marker)}svg.addEventListener('wheel',e=>{e.preventDefault();scale(e.deltaY<0?1.1:1/1.1,point(e))},{passive:false});svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const p=point(e),node=e.target.closest('[data-graph-node]');drag={id:node?.dataset.graphNode||null,start:p,last:p,moved:false};svg.setPointerCapture(e.pointerId)});svg.addEventListener('pointermove',e=>{if(!drag)return;const p=point(e),dx=p.x-drag.last.x,dy=p.y-drag.last.y;if(Math.abs(p.x-drag.start.x)+Math.abs(p.y-drag.start.y)>5)drag.moved=true;if(drag.moved){if(drag.id&&mode!=='coverage'){const pos=positions.get(drag.id);if(pos){pos.x+=dx/zoom;pos.y+=dy/zoom;render()}}else{panX+=dx;panY+=dy;transform()}}drag.last=p});svg.addEventListener('pointerup',e=>{const previous=drag;drag=null;if(previous&&!previous.moved&&previous.id)selectNode(previous.id);if(svg.hasPointerCapture(e.pointerId))svg.releasePointerCapture(e.pointerId)});svg.addEventListener('pointercancel',()=>drag=null);
|
|
89
|
+
function windowState(){return new URLSearchParams({project:selected,view:data?.selected?.view_id||byId('map-view').value||'',mode,q:byId('maps-search').value,folder:byId('maps-folder').value,limit:byId('maps-limit').value,colors:byId('maps-color-mode').value,node:selectedNode||'',focus:focus?'1':'0'})}
|
|
90
|
+
function openWindow(){const url=new URL('/maps',location.origin);url.hash=windowState().toString();byId('maps-popup-link').hidden=true;const popup=window.open('','_blank','popup,width=1440,height=960,resizable=yes,scrollbars=yes');if(popup){popup.opener=null;popup.location.replace(url.href)}else{const link=byId('maps-popup-link');link.href=url.href;link.hidden=false;message('The browser blocked the window. Use the Open map separately link.',true)}}
|
|
91
|
+
byId('maps-new-window').onclick=openWindow;
|
|
92
|
+
byId('maps-color-mode').onchange=()=>{render();publishWindowState()};
|
|
93
|
+
async function restore(state){if(['network','coverage','calls'].includes(state.mode))switchMode(state.mode);await load(state.view||undefined,state)}
|
|
94
|
+
return {reset,load,restore,openWindow};
|
|
95
|
+
})();
|
|
96
|
+
'''
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Eventos Windows + reconciliação: fila apenas para projetos habilitados pelo usuário."""
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import config
|
|
7
|
+
import directory_watch
|
|
8
|
+
import indexer
|
|
9
|
+
import local_embedder
|
|
10
|
+
import index_scope
|
|
11
|
+
import project_store
|
|
12
|
+
import index_views
|
|
13
|
+
|
|
14
|
+
DEBOUNCE_S = 5
|
|
15
|
+
RECONCILE_S = 180
|
|
16
|
+
FULL_CHECK_S = 86400
|
|
17
|
+
MAX_EVENT_PATHS = 1000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def relevant_path(path):
|
|
21
|
+
parts = path.replace("\\", "/").strip("/").split("/")
|
|
22
|
+
return bool(parts and all(part not in ("", ".", "..") and not part.startswith(".")
|
|
23
|
+
and not index_scope.always_excluded(part) for part in parts))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def snapshot(root, scope):
|
|
27
|
+
result = {}
|
|
28
|
+
for rel in index_scope.resolve_included_files(root, scope):
|
|
29
|
+
stat, _reason = indexer._stat_candidate(root, rel)
|
|
30
|
+
if stat is not None:
|
|
31
|
+
result[rel] = stat
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def index_model(current=None):
|
|
37
|
+
return local_embedder.resolve(config.load_config().get("embedding_model"), current)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def needs_model_rebuild(meta, configured_model):
|
|
41
|
+
return meta.get("model_id") != configured_model or not meta.get("exists")
|
|
42
|
+
|
|
43
|
+
class ProjectMonitor:
|
|
44
|
+
def __init__(self, enqueue, watcher_factory=directory_watch.DirectoryWatcher):
|
|
45
|
+
self.enqueue = enqueue
|
|
46
|
+
self.watcher_factory = watcher_factory
|
|
47
|
+
self._watchers = {}
|
|
48
|
+
self._retry_watch = {}
|
|
49
|
+
self._checked = {}
|
|
50
|
+
self._git_seen = {}
|
|
51
|
+
self._git_checked = {}
|
|
52
|
+
self._pending = {}
|
|
53
|
+
self._lock = threading.RLock()
|
|
54
|
+
self._stop = threading.Event()
|
|
55
|
+
self._thread = None
|
|
56
|
+
|
|
57
|
+
def start(self):
|
|
58
|
+
self._thread = threading.Thread(target=self._run, name="project-monitor", daemon=True)
|
|
59
|
+
self._thread.start()
|
|
60
|
+
|
|
61
|
+
def stop(self):
|
|
62
|
+
self._stop.set()
|
|
63
|
+
if self._thread:
|
|
64
|
+
self._thread.join(timeout=3)
|
|
65
|
+
with self._lock:
|
|
66
|
+
watchers = list(self._watchers.values())
|
|
67
|
+
self._watchers.clear()
|
|
68
|
+
for watcher in watchers:
|
|
69
|
+
watcher.stop()
|
|
70
|
+
|
|
71
|
+
def refresh(self):
|
|
72
|
+
self._checked.clear()
|
|
73
|
+
|
|
74
|
+
def events(self, project_id, rows, overflow=False):
|
|
75
|
+
project = project_store.get(project_id)
|
|
76
|
+
if not project or project.get("paused") or not project.get("watch") or not project.get("enabled"):
|
|
77
|
+
return
|
|
78
|
+
paths = {row["path"] for row in rows if relevant_path(row.get("path", ""))}
|
|
79
|
+
if not paths and not overflow:
|
|
80
|
+
return
|
|
81
|
+
# Um diretório existente também recebe MODIFIED ao mudar um arquivo filho.
|
|
82
|
+
# Só uma estrutura nova ou documentação da raiz exige reavaliar o escopo.
|
|
83
|
+
scope = index_scope.load_scope(project["root"]) or {}
|
|
84
|
+
known_dirs = {p.casefold() for p in scope.get("structure", [])}
|
|
85
|
+
docs = {name.casefold() for name in index_scope._ROOT_DOC_NAMES}
|
|
86
|
+
force_scope = any(path.casefold() in docs for path in paths) or any(
|
|
87
|
+
row.get("action") in (1, 5) and row.get("path") in paths
|
|
88
|
+
and row["path"].casefold() not in known_dirs
|
|
89
|
+
and os.path.isdir(os.path.join(project["root"], row["path"])) for row in rows)
|
|
90
|
+
force_scope = force_scope and not scope.get("manual", False)
|
|
91
|
+
with self._lock:
|
|
92
|
+
first = project_id not in self._pending
|
|
93
|
+
state = self._pending.setdefault(project_id, {"paths": set(), "full": False, "force_scope": False, "revision": 0})
|
|
94
|
+
state["revision"] = state.get("revision", 0) + 1
|
|
95
|
+
state["paths"].update(paths)
|
|
96
|
+
state["last"] = time.monotonic()
|
|
97
|
+
state["full"] |= overflow or len(state["paths"]) > MAX_EVENT_PATHS
|
|
98
|
+
state["force_scope"] |= force_scope
|
|
99
|
+
if state["full"]:
|
|
100
|
+
state["paths"].clear()
|
|
101
|
+
project_store.mark_dirty(project_id, "Changes detected by the monitor.")
|
|
102
|
+
project_store.record_changes(project_id, paths, full=overflow, scope=force_scope)
|
|
103
|
+
|
|
104
|
+
def _watch_error(self, project_id, message):
|
|
105
|
+
project_store.update(project_id, watch_backend="reconciliation", watch_error=message[:300])
|
|
106
|
+
self._retry_watch[project_id] = time.monotonic() + RECONCILE_S
|
|
107
|
+
self.events(project_id, [], overflow=True)
|
|
108
|
+
|
|
109
|
+
def _sync_watchers(self, projects, now):
|
|
110
|
+
wanted = {p["id"]: p for p in projects if p.get("watch") and p.get("enabled")
|
|
111
|
+
and not p.get("paused") and os.path.isdir(p["root"])}
|
|
112
|
+
for key in list(self._watchers):
|
|
113
|
+
if key not in wanted:
|
|
114
|
+
watcher = self._watchers.pop(key)
|
|
115
|
+
watcher.stop()
|
|
116
|
+
project_store.update(key, watch_backend="off")
|
|
117
|
+
for key, project in wanted.items():
|
|
118
|
+
current = self._watchers.get(key)
|
|
119
|
+
if current and current.alive:
|
|
120
|
+
continue
|
|
121
|
+
if now < self._retry_watch.get(key, 0):
|
|
122
|
+
continue
|
|
123
|
+
watcher = self.watcher_factory(
|
|
124
|
+
project["root"], lambda rows, overflow, k=key: self.events(k, rows, overflow),
|
|
125
|
+
lambda error, k=key: self._watch_error(k, error),
|
|
126
|
+
)
|
|
127
|
+
self._watchers[key] = watcher
|
|
128
|
+
project_store.update(key, watch_backend="windows_events", watch_error="")
|
|
129
|
+
watcher.start()
|
|
130
|
+
|
|
131
|
+
def tick(self):
|
|
132
|
+
now = time.monotonic()
|
|
133
|
+
projects = project_store.all_projects()
|
|
134
|
+
self._sync_watchers(projects, now)
|
|
135
|
+
for project in projects:
|
|
136
|
+
key = project["id"]
|
|
137
|
+
if not project.get("watch") or not project.get("enabled") or project.get("paused"):
|
|
138
|
+
continue
|
|
139
|
+
if not os.path.isdir(project["root"]):
|
|
140
|
+
project_store.update(key, status="unavailable", last_error="The folder is not accessible.")
|
|
141
|
+
continue
|
|
142
|
+
if any(job.get("status") == "pending" for job in project_store.jobs(key, limit=50)):
|
|
143
|
+
continue # Não planejar outra geração com metadados de uma ainda em curso.
|
|
144
|
+
if now - self._git_checked.get(key, -5) >= 5:
|
|
145
|
+
view = index_views.describe(project['root'], fresh=True)
|
|
146
|
+
signature = (view['view_id'], view.get('commit'))
|
|
147
|
+
if key in self._git_seen and signature != self._git_seen[key]:
|
|
148
|
+
self.events(key, [], overflow=True)
|
|
149
|
+
self._git_seen[key] = signature
|
|
150
|
+
self._git_checked[key] = now
|
|
151
|
+
if now - self._checked.get(key, -RECONCILE_S) >= RECONCILE_S:
|
|
152
|
+
self._checked[key] = now
|
|
153
|
+
scope = index_scope.load_scope(project["root"])
|
|
154
|
+
meta = indexer.info(project["root"])
|
|
155
|
+
rescope = scope is None or index_scope.needs_rescan(project["root"], scope)
|
|
156
|
+
changed = rescope
|
|
157
|
+
if scope is not None:
|
|
158
|
+
changed |= snapshot(project["root"], scope) != indexer.manifest_snapshot(project["root"])
|
|
159
|
+
full = time.time() - meta.get("full_scan_at", 0) >= FULL_CHECK_S
|
|
160
|
+
changed |= needs_model_rebuild(meta, index_model(meta.get("model_id")))
|
|
161
|
+
project_store.update(key, last_checked=time.time())
|
|
162
|
+
if changed or full or project.get("dirty_seq", 0) > project.get("indexed_seq", 0):
|
|
163
|
+
with self._lock:
|
|
164
|
+
first = key not in self._pending
|
|
165
|
+
state = self._pending.setdefault(key, {"paths": set(), "last": now - DEBOUNCE_S,
|
|
166
|
+
"full": False, "force_scope": False, "revision": 0})
|
|
167
|
+
state["full"] |= full
|
|
168
|
+
state["force_scope"] |= rescope
|
|
169
|
+
if first:
|
|
170
|
+
project_store.mark_dirty(key, "Reconciliation found a pending update.")
|
|
171
|
+
project_store.record_changes(key, full=full, scope=rescope)
|
|
172
|
+
# Monitoramento local é independente do gasto com o modelo. O padrão
|
|
173
|
+
# somente registra pendência; a próxima busca executa o diff e embeddings.
|
|
174
|
+
if project.get('update_mode', 'on_search') != 'eager':
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._pending.pop(key, None)
|
|
177
|
+
continue
|
|
178
|
+
with self._lock:
|
|
179
|
+
pending = self._pending.get(key)
|
|
180
|
+
if not pending or now - pending["last"] < DEBOUNCE_S:
|
|
181
|
+
continue
|
|
182
|
+
options = {"changed_paths": sorted(pending["paths"]), "full_check": pending["full"],
|
|
183
|
+
"force_scope": pending["force_scope"]}
|
|
184
|
+
revision = pending.get("revision", 0)
|
|
185
|
+
current = project_store.get(key)
|
|
186
|
+
if time.time() < current.get("next_retry_at", 0):
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
self.enqueue(key, **options)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
project_store.update(key, status="error", last_error=str(exc)[:300], next_retry_at=time.time() + 60)
|
|
192
|
+
continue
|
|
193
|
+
with self._lock:
|
|
194
|
+
# Um evento recebido durante o submit continua pendente para a próxima geração.
|
|
195
|
+
if self._pending.get(key) is pending and pending.get("revision", 0) == revision:
|
|
196
|
+
self._pending.pop(key, None)
|
|
197
|
+
|
|
198
|
+
def _run(self):
|
|
199
|
+
while not self._stop.is_set():
|
|
200
|
+
try:
|
|
201
|
+
self.tick()
|
|
202
|
+
except Exception:
|
|
203
|
+
pass # Falha opcional do monitor não derruba o daemon MCP.
|
|
204
|
+
self._stop.wait(1)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Fila justa entre raízes; jobs aguardando a mesma pasta não ocupam workers."""
|
|
2
|
+
import collections
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
import project_identity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProjectScheduler:
|
|
10
|
+
def __init__(self, workers=3):
|
|
11
|
+
self.workers = workers
|
|
12
|
+
self._pool = concurrent.futures.ThreadPoolExecutor(max_workers=workers, thread_name_prefix="project")
|
|
13
|
+
self._lock = threading.RLock()
|
|
14
|
+
self._queues = collections.OrderedDict()
|
|
15
|
+
self._running = set()
|
|
16
|
+
self._closed = False
|
|
17
|
+
|
|
18
|
+
def submit(self, root, function, *args):
|
|
19
|
+
key = project_identity.canonical_root(root)
|
|
20
|
+
future = concurrent.futures.Future()
|
|
21
|
+
with self._lock:
|
|
22
|
+
if self._closed:
|
|
23
|
+
raise RuntimeError("Project queue closed.")
|
|
24
|
+
self._queues.setdefault(key, collections.deque()).append((future, function, args))
|
|
25
|
+
self._dispatch()
|
|
26
|
+
return future
|
|
27
|
+
|
|
28
|
+
def _dispatch(self):
|
|
29
|
+
while len(self._running) < self.workers:
|
|
30
|
+
key = next((root for root in self._queues if root not in self._running), None)
|
|
31
|
+
if key is None:
|
|
32
|
+
break
|
|
33
|
+
task = self._queues[key].popleft()
|
|
34
|
+
if not self._queues[key]:
|
|
35
|
+
self._queues.pop(key)
|
|
36
|
+
else:
|
|
37
|
+
self._queues.move_to_end(key)
|
|
38
|
+
self._running.add(key)
|
|
39
|
+
self._pool.submit(self._run, key, task)
|
|
40
|
+
|
|
41
|
+
def _run(self, key, task):
|
|
42
|
+
future, function, args = task
|
|
43
|
+
try:
|
|
44
|
+
if future.set_running_or_notify_cancel():
|
|
45
|
+
try:
|
|
46
|
+
future.set_result(function(*args))
|
|
47
|
+
except BaseException as exc:
|
|
48
|
+
future.set_exception(exc)
|
|
49
|
+
finally:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._running.discard(key)
|
|
52
|
+
self._dispatch()
|
|
53
|
+
|
|
54
|
+
def shutdown(self, wait=True):
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._closed = True
|
|
57
|
+
for queue in self._queues.values():
|
|
58
|
+
for future, _fn, _args in queue:
|
|
59
|
+
future.cancel()
|
|
60
|
+
self._queues.clear()
|
|
61
|
+
self._pool.shutdown(wait=wait, cancel_futures=True)
|