← Commit history

v0.3.2: tags below slug + recolor (molecule gold, board blue, KiCad dark blue, Altium gold, Fusion orange); parallel-warmed+cached EDA (no per-row trickle); activity chips; board/molecule+EDA tags; transparent PCB fills; back-button race fix

barrett-land ·050b01e542 ·3mo ago ·parent 975f9d2
2 files changed +161−7
src/main.rs+107
@@ -213,6 +213,14 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {             let slug = &p["/api/board/".len()..];             json_resp(&board_deps(slug).to_string())         }+        (Method::Get, p) if p.starts_with("/api/board-activity/") => {+            let slug = &p["/api/board-activity/".len()..];+            json_resp(&board_activity(slug).to_string())+        }+        (Method::Get, p) if p.starts_with("/api/board-eda/") => {+            let slug = &p["/api/board-eda/".len()..];+            json_resp(&json!({"eda": board_eda_cached(slug)}).to_string())+        }         (Method::Get, p) if p.starts_with("/api/board-media/") => {             let slug = &p["/api/board-media/".len()..];             json_resp(&board_media(slug).to_string())@@ -343,6 +351,27 @@ fn list_components() -> Value {             items.push(json!({"slug": s, "title": up(s), "brief": ""}));         }     }+    // Fold the EDA type into every BOARD item up front, computed concurrently.+    // The server is single-threaded, so lazy per-row `/api/board-eda` fetches would+    // otherwise serialize N wiki file-listings (KiCad tags trickling in one by one).+    let board_slugs: Vec<String> = items.iter().filter_map(|it| {+        let is_board = it.get("tags").and_then(|t| t.as_array()).is_some_and(|a| {+            a.iter().any(|x| matches!(x.as_str().unwrap_or("").to_lowercase().as_str(), "board" | "molecule"))+        });+        if is_board { it.get("slug").and_then(|s| s.as_str()).map(String::from) } else { None }+    }).collect();+    let eda_map: std::collections::HashMap<String, &'static str> = std::thread::scope(|sc| {+        let handles: Vec<_> = board_slugs.iter().map(|s| {+            let s = s.clone();+            sc.spawn(move || { let e = board_eda_cached(&s); (s, e) })+        }).collect();+        handles.into_iter().filter_map(|h| h.join().ok()).collect()+    });+    for it in items.iter_mut() {+        if let Some(e) = it.get("slug").and_then(|s| s.as_str()).and_then(|s| eda_map.get(s)) {+            it["eda"] = json!(e);+        }+    }     let (me, orgs) = whoami();     json!({ "status": "ok", "components": items, "me": me, "orgs": orgs,         "hints": ["click a component to open its viewer"] })@@ -435,6 +464,84 @@ fn board_deps(slug: &str) -> Value {         "hints": ["board → component library → components (the library's dependencies)"] }) } +/// The board's EDA toolchain, inferred from the files on its page:+/// "kicad" | "altium" | "fusion" | "".+fn board_eda(slug: &str) -> &'static str {+    let files = list_page_files(slug);+    let has = |exts: &[&str]| files.iter().any(|n| { let l = n.to_lowercase(); exts.iter().any(|e| l.ends_with(e)) });+    if has(&[".kicad_pcb", ".kicad_sch", ".kicad_pro"]) {+        "kicad"+    } else if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {+        "altium"+    } else if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion"]) {+        "fusion"+    } else {+        ""+    }+}++/// `board_eda` cached across requests. The EDA type never changes for a given+/// board within a session, and the raw call costs a wiki file-listing round-trip;+/// caching turns the left-list's per-row EDA into a one-time cost.+static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, &'static str>>> = Mutex::new(None);+fn board_eda_cached(slug: &str) -> &'static str {+    {+        let mut g = EDA_CACHE.lock().unwrap();+        if let Some(&e) = g.get_or_insert_with(Default::default).get(slug) {+            return e;+        }+    }+    let e = board_eda(slug);+    EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e);+    e+}++/// The board's commit history enriched with what each commit changed: components+/// added/removed (footprint reference designators on the PCB) and whether the PCB+/// or schematic changed. Fetches each commit's .kicad_pcb + .kicad_sch via ?ref=.+fn board_activity(slug: &str) -> Value {+    let log: Vec<Value> = curl(&format!("{WIKI}/api/v1/pages/{slug}/log"))+        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+        .and_then(|v| v.get("log").and_then(|l| l.as_array().cloned()))+        .unwrap_or_default();+    if log.is_empty() {+        return json!({ "activity": [] });+    }+    let files = list_page_files(slug);+    let pcb = files.iter().find(|n| n.ends_with(".kicad_pcb")).cloned();+    let sch = files.iter().find(|n| n.ends_with(".kicad_sch")).cloned();+    let at = |file: &Option<String>, hash: &str| -> Option<String> {+        file.as_ref().and_then(|f| curl(&format!("{WIKI}/api/v1/pages/{slug}/files/{f}?ref={hash}")).and_then(|b| String::from_utf8(b).ok()))+    };+    // component reference designators placed on the PCB+    let refs = |pcb: &str| -> std::collections::HashSet<String> {+        pcb.split("(property \"Reference\" \"").skip(1)+            .filter_map(|c| c.split('"').next())+            .filter(|r| !r.is_empty() && *r != "REF**")+            .map(String::from)+            .collect()+    };+    let hashes: Vec<String> = log.iter().filter_map(|c| c.get("hash").and_then(|h| h.as_str()).map(String::from)).collect();+    let pcbs: Vec<Option<String>> = hashes.iter().map(|h| at(&pcb, h)).collect();+    let schs: Vec<Option<String>> = hashes.iter().map(|h| at(&sch, h)).collect();+    let mut out = vec![];+    for i in 0..log.len() {+        let mut e = log[i].clone();+        // compare against the previous (older) commit — the next entry in the log+        if i + 1 < log.len() {+            e["pcb_changed"] = json!(pcbs[i] != pcbs[i + 1]);+            e["sch_changed"] = json!(schs[i] != schs[i + 1]);+            if let (Some(n), Some(o)) = (&pcbs[i], &pcbs[i + 1]) {+                let (rn, ro) = (refs(n), refs(o));+                e["comp_added"] = json!(rn.difference(&ro).count());+                e["comp_removed"] = json!(ro.difference(&rn).count());+            }+        }+        out.push(e);+    }+    json!({ "activity": out })+}+ /// List the file names on a page (robust to any MPN→filename convention — the /// slug can't be uppercased to the filename when the MPN has `/`, `.`, etc.). fn list_page_files(slug: &str) -> Vec<String> {
src/manage.html+54−7
@@ -19,6 +19,15 @@   #crumb a:hover{text-decoration:underline}   #crumb .cactive{color:var(--ink);font-weight:600}   .row .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:1px}+  .tag{display:inline-block;font-size:10px;background:#243049;color:var(--acc);border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:2px}+  .row .row-tags{margin-top:4px}+  .row .row-tags .tag{margin-left:0;margin-right:6px;vertical-align:0}+  .tag.kind.board{background:#16324f;color:#79b8ff}+  .tag.kind.molecule{background:#3d3416;color:#f5c518}+  .tag.eda:empty{display:none}+  .tag.eda.kicad{background:#0b2036;color:#5c9ce6}+  .tag.eda.altium{background:#3d3416;color:#f5c518}+  .tag.eda.fusion{background:#452510;color:#ff8c33}   .cathead{padding:7px 16px 5px;font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--acc);background:#12151c;border-bottom:1px solid var(--edge);font-weight:700;position:sticky;top:0;z-index:1}   .tabs{display:flex;border-bottom:1px solid var(--edge)}   .tabs .tab{flex:1;background:none;border:none;border-bottom:2px solid transparent;color:var(--mut);padding:10px 4px;font-size:12px;cursor:pointer;white-space:nowrap}@@ -130,6 +139,10 @@   .bo-log .commit:last-child{border-bottom:none}   .commit-msg{font-size:13px;color:var(--ink)}   .commit-meta{font-size:11.5px;color:var(--mut);margin-top:2px}+  .commit-chips{display:flex;gap:5px;flex-wrap:wrap;margin:3px 0}+  .chg{font-size:10px;padding:1px 7px;border-radius:4px;background:#21262e;color:var(--mut);white-space:nowrap}+  .chg.add{color:#4ade80}+  .chg.rm{color:#f87171}   .commit-hash{font-family:ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:4px;padding:0 5px}   .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}   .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}@@ -278,11 +291,25 @@ function renderBoards(){ } // A board row. Boards you've starred on the wiki show a ★ and pin to the top of // their list (starred status comes from the wiki API; there's no toggle here).+const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function boardKind(c){return (c.tags||[]).map(x=>(''+x).toLowerCase()).includes('molecule')?'molecule':'board';}+let edaCache={};+function loadEda(slug){+  const fill=eda=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.textContent=EDA_LABEL[eda]||'';el.className='tag eda '+(eda||'');});+  if(edaCache[slug]!==undefined){fill(edaCache[slug]);return;}+  fetch('api/board-eda/'+encodeURIComponent(slug)).then(r=>r.json()).then(d=>{edaCache[slug]=d.eda||'';fill(edaCache[slug]);}).catch(()=>{});+} function boardRow(c){   const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;   const star=c.starred?`<span class="star" title="starred on the wiki">★</span>`:'';-  d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}<span class="tag">board</span></div><div class="sub">${c.slug}</div>`;+  const kind=boardKind(c);+  // eda arrives with the list (computed server-side in parallel); only fall back+  // to the per-row fetch when it's absent.+  const edaKnown=c.eda!==undefined;+  const edaSpan=edaKnown?`<span class="tag eda ${c.eda}">${EDA_LABEL[c.eda]||''}</span>`:`<span class="tag eda" data-eda="${c.slug}"></span>`;+  d.innerHTML=`<div class="mpn">${star}${c.title||c.slug}</div><div class="sub">${c.slug}</div><div class="row-tags"><span class="tag kind ${kind}">${kind}</span>${edaSpan}</div>`;   d.onclick=()=>openBoard(c.slug);+  if(!edaKnown)loadEda(c.slug);   return d; } function sortBoards(list){@@ -311,8 +338,11 @@ async function openBoard(slug){   document.getElementById('crumb').innerHTML=`<a href="#" onclick="backToBoards();return false">← Boards</a> / <span class="cactive">${title}</span>`;   renderBoardOverview(slug);   rowsEl().innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading dependencies…</div>';-  try{ const r=await fetch('api/board/'+slug).then(r=>r.json()); curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }-  catch(e){ curDeps=[]; }+  try{ const r=await fetch('api/board/'+slug).then(r=>r.json());+    if(boardSlug!==slug)return; // navigated back/away while loading — don't paint stale parts+    curDeps=(r.deps||[]).map(d=>d.slug); fillBoardInfo(slug,r.log); }+  catch(e){ if(boardSlug!==slug)return; curDeps=[]; }+  if(boardSlug!==slug)return;   renderDeps(); } // The board overview (top of the right pane): name + author, a 3D view of the@@ -323,7 +353,7 @@ function renderBoardOverview(slug){   view.classList.remove('split');   document.getElementById('component-view').innerHTML='';   document.getElementById('board-overview').innerHTML=`-    <div class="bo-head"><h2 class="title">${c.title||slug}</h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div></div>+    <div class="bo-head"><h2 class="title">${c.title||slug}<span class="tag kind ${boardKind(c)}">${boardKind(c)}</span><span class="tag eda" data-eda="${slug}"></span></h2><div class="bo-author">by ${c.owner||'unknown'}${c.org?` · ${c.org}`:''}</div></div>     <div class="bo-view bo-3d-wrap"><div class="bo-3d" id="bo3d"><div class="bo-na">loading 3D…</div></div><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button></div>     <div class="bo-views">       <div class="bo-view"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>Schematic</h3><select id="bo-sheet" class="bo-sheet" style="display:none"></select></div><div class="bo-viewbody" id="bo-sch"><div class="bo-na">rendering…</div></div></div>@@ -331,6 +361,7 @@ function renderBoardOverview(slug){     </div>     <div class="bo-section"><h3>Activity</h3><div class="bo-log" id="bo-log"><div class="bo-na" style="text-align:left;padding:8px 0">loading…</div></div></div>`;   loadBoardMedia(slug);+  loadEda(slug); } function esc(s){return (s||'').replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[m]));} function timeago(ts){@@ -339,10 +370,26 @@ function timeago(ts){   const h=Math.floor(m/60);if(h<24)return h+'h ago';const d=Math.floor(h/24);if(d<30)return d+'d ago';   const mo=Math.floor(d/30);if(mo<12)return mo+'mo ago';return Math.floor(mo/12)+'y ago'; }+function commitHtml(c){+  const chips=[];+  if(c.comp_added)chips.push(`<span class="chg add">+${c.comp_added} component${c.comp_added>1?'s':''}</span>`);+  if(c.comp_removed)chips.push(`<span class="chg rm">−${c.comp_removed} component${c.comp_removed>1?'s':''}</span>`);+  if(c.pcb_changed)chips.push(`<span class="chg">PCB changed</span>`);+  if(c.sch_changed)chips.push(`<span class="chg">schematic changed</span>`);+  const chipsHtml=chips.length?`<div class="commit-chips">${chips.join('')}</div>`:'';+  return `<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div>${chipsHtml}<div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`;+} function fillBoardInfo(slug,log){   if(boardSlug!==slug)return;   const lg=document.getElementById('bo-log');-  if(lg)lg.innerHTML=(log&&log.length)?log.map(c=>`<div class="commit"><div class="commit-msg">${esc(c.message||'')}</div><div class="commit-meta">${esc(c.author||'')} · ${timeago(c.timestamp)} · <span class="commit-hash">${esc((c.hash||'').slice(0,7))}</span></div></div>`).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+  if(!lg)return;+  lg.innerHTML=(log&&log.length)?log.map(commitHtml).join(''):'<div class="bo-na" style="text-align:left">no commits</div>';+  // enrich each entry with what changed (components +/-, PCB/schematic) — lazily,+  // since it re-fetches each commit's PCB/schematic to diff them+  fetch('api/board-activity/'+slug).then(r=>r.json()).then(d=>{+    if(boardSlug!==slug)return;+    const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join('');+  }).catch(()=>{}); } async function loadBoardMedia(slug){   // service-kicad renders in the background (a board GLB can take ~a minute) —@@ -391,7 +438,7 @@ function mountSvg(box,svgText,opt){   // dim the copper fills so lower layers show through (see all 4 copper layers);   // keep silkscreen/edge fully opaque so they stay readable   if(opt.dimFills){-    const keep=new Set(['#f2eda1','#d0d2cd','#c83434']); // silk, edge, F.Cu → stay opaque+    const keep=new Set(['#f2eda1','#d0d2cd']); // silk + edge stay opaque (markings); ALL copper dims     svg.querySelectorAll('[style*="fill"]').forEach(el=>{       const m=(el.getAttribute('style')||'').match(/fill:\s*(#[0-9a-fA-F]{6})/);       if(!m)return;@@ -402,7 +449,7 @@ function mountSvg(box,svgText,opt){         const circles=el.querySelectorAll('[r]');         if(circles.length) circles.forEach(ci=>{ ci.style.fill = (parseFloat(ci.getAttribute('r')||'0')*2 < 0.5) ? '#e3b72e' : '#0d0f14'; });         else el.style.fill='#0d0f14';-      } else if(!keep.has(c)){ el.style.fillOpacity=opt.dimFills; } // dim inner/bottom copper+      } else if(!keep.has(c)){ el.style.fillOpacity=opt.dimFills; } // dim all copper (top/bottom/inner)     });   }   // remove the full-page paper rect (crop to the content on the box background)