app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← Commit history
v0.3.3: in-app folder browser replaces native dialog (fixes Windows Browse-files wedge; universal Win/mac/Linux; path textbox + copy-paste); Windows PowerShell via -EncodedCommand (folder pick + library scans no longer echo-not-run); non-blocking picker; concurrent server + serialized bridge calls; curl --fail fixes render-folder false-positive; render-warning bar; multi-EDA tags; fills-only PCB transparency; 3D cap/center + fullscreen fix; controls-in-box; activity chips; tag recolors
2 files changed
+334−67
src/main.rs+210−38@@ -158,16 +158,23 @@ pub fn run(port: u16) -> Result<()> { println!("OK: Adom Project Manager on http://localhost:{port}"); println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`."); println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");+ // Handle each request on its own thread. Many endpoints block on the desktop+ // bridge (folder listing, library scans, renders); a single-threaded loop would+ // serialize them, so e.g. the folder browser could wait tens of seconds behind+ // the Add-Project library scan. All shared state is Mutex-guarded, so concurrent+ // handlers are safe. for mut req in server.incoming_requests() {- let method = req.method().clone();- let url = req.url().to_string();- let mut body = String::new();- if matches!(method, Method::Post) {- // /api/apply bodies are small JSON — cap far above any real payload.- let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);- }- let resp = route(&method, &url, &body);- let _ = req.respond(resp);+ std::thread::spawn(move || {+ let method = req.method().clone();+ let url = req.url().to_string();+ let mut body = String::new();+ if matches!(method, Method::Post) {+ // /api/apply bodies are small JSON — cap far above any real payload.+ let _ = req.as_reader().take(1024 * 1024).read_to_string(&mut body);+ }+ let resp = route(&method, &url, &body);+ let _ = req.respond(resp);+ }); } Ok(()) }@@ -180,6 +187,10 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { (Method::Get, "/api/env") => json_resp(&api_env().to_string()), (Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()), (Method::Get, "/api/pick-folder") => json_resp(&pick_folder().to_string()),+ (Method::Get, p) if p.starts_with("/api/pick-folder-result") => {+ let tok = url.split_once("?f=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();+ json_resp(&pick_folder_result(&tok).to_string())+ } (Method::Get, p) if p.starts_with("/api/project-diff") => { let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default(); json_resp(&project_diff(¶m("board"), ¶m("dest")).to_string())@@ -197,6 +208,10 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { let prefix = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default(); json_resp(&suggest_path(&prefix).to_string()) }+ (Method::Get, p) if p.starts_with("/api/list-dirs") => {+ let pp = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();+ json_resp(&list_dirs(&pp).to_string())+ } (Method::Get, "/") => html(INDEX_HTML), (Method::Get, "/api/components") => { // ?refresh=1 busts the server-side cache so newly-pushed components appear@@ -290,7 +305,11 @@ fn wiki_token() -> Option<String> { } fn curl(url: &str) -> Option<Vec<u8>> {- let mut args: Vec<String> = vec!["-s".into(), "-L".into(), "--max-time".into(), "12".into()];+ // `--fail`: on an HTTP error (e.g. a missing file's 404) the wiki returns a JSON+ // error body; without --fail curl hands us those bytes and callers mistake the+ // error object for real content (a 404 manifest read as a valid render/ folder).+ // With --fail curl exits non-zero and emits nothing, so we correctly return None.+ let mut args: Vec<String> = vec!["-s".into(), "-f".into(), "-L".into(), "--max-time".into(), "12".into()]; // authenticate wiki requests so private/org pages are included (issue #120) if url.contains("wiki.adom.inc") { if let Some(tok) = wiki_token() {@@ -360,7 +379,7 @@ fn list_components() -> Value { }); 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 eda_map: std::collections::HashMap<String, Vec<&'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) })@@ -466,33 +485,38 @@ fn board_deps(slug: &str) -> Value { /// The board's EDA toolchain, inferred from the files on its page: /// "kicad" | "altium" | "fusion" | "".-fn board_eda(slug: &str) -> &'static str {+/// Every EDA toolchain whose files are present on the board — a board can carry+/// more than one (e.g. a KiCad PCB alongside a Fusion/Eagle `.brd` + `.lbr`), so+/// this returns ALL matches, in a stable order, not just the first.+fn board_eda(slug: &str) -> Vec<&'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)) });+ let mut out = Vec::new(); 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 {- ""+ out.push("kicad");+ }+ if has(&[".pcbdoc", ".schdoc", ".prjpcb", ".intlib", ".pcblib", ".schlib"]) {+ out.push("altium");+ }+ if has(&[".fbrd", ".fsch", ".brd", ".sch", ".f3d", ".fusion", ".lbr"]) {+ out.push("fusion"); }+ out } -/// `board_eda` cached across requests. The EDA type never changes for a given+/// `board_eda` cached across requests. The EDA set 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 {+static EDA_CACHE: Mutex<Option<std::collections::HashMap<String, Vec<&'static str>>>> = Mutex::new(None);+fn board_eda_cached(slug: &str) -> Vec<&'static str> { { let mut g = EDA_CACHE.lock().unwrap();- if let Some(&e) = g.get_or_insert_with(Default::default).get(slug) {- return e;+ if let Some(e) = g.get_or_insert_with(Default::default).get(slug) {+ return e.clone(); } } let e = board_eda(slug);- EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e);+ EDA_CACHE.lock().unwrap().get_or_insert_with(Default::default).insert(slug.to_string(), e.clone()); e } @@ -1012,13 +1036,115 @@ fn urldecode(s: &str) -> String { /// or cancels (empty path = cancelled / no dialog available). fn pick_folder() -> Value { let (os, _) = machine();- let cmd = match os.as_str() {- "windows" => "powershell -NoProfile -STA -Command \"Add-Type -AssemblyName System.Windows.Forms; $f=New-Object System.Windows.Forms.FolderBrowserDialog; if($f.ShowDialog() -eq 'OK'){[Console]::Out.Write($f.SelectedPath)}\"".to_string(),- "macos" => "osascript -e 'POSIX path of (choose folder)' 2>/dev/null".to_string(),- _ => "zenity --file-selection --directory 2>/dev/null".to_string(),+ let id = unique_id();+ // A native folder dialog is MODAL and blocks until the user picks/cancels. If we+ // ran it synchronously through the bridge, the bridge's shell_execute would wait+ // for it and WEDGE the whole AD command channel. So launch it DETACHED, have it+ // write the chosen path (or __CANCELLED__) to a temp file, and let the frontend+ // poll /api/pick-folder-result. shell_execute returns instantly; AD stays free.+ // The token is just the id; each side rebuilds the temp path itself (on Windows+ // inside PowerShell via GetTempPath, since cmd mangles %TEMP%/forward-slash paths).+ let launch = match os.as_str() {+ "windows" => {+ // inner: the TopMost dialog (owner form pulls it to the foreground), writing the result+ let inner = format!(+ "$ProgressPreference='SilentlyContinue'\n\+ Add-Type -AssemblyName System.Windows.Forms\n\+ $o=New-Object System.Windows.Forms.Form\n\+ $o.TopMost=$true;$o.ShowInTaskbar=$false;$o.Opacity=0\n\+ $o.Show();$o.Activate()\n\+ $d=New-Object System.Windows.Forms.FolderBrowserDialog\n\+ $d.Description='Select the project folder'\n\+ $r=$d.ShowDialog($o)\n\+ $o.Close()\n\+ $out=if($r -eq [System.Windows.Forms.DialogResult]::OK){{$d.SelectedPath}}else{{'__CANCELLED__'}}\n\+ $p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'\n\+ Set-Content -LiteralPath $p -Value $out -NoNewline -Encoding UTF8");+ let inner_b64 = b64(&utf16le(&inner));+ // outer: fire the inner off detached, then exit immediately so shell_execute+ // returns. The inner carries -STA (needed for the dialog); the OUTER must NOT+ // (an -STA outer adds ~7s of startup here). Base64-encoded so cmd/PS don't echo it.+ let outer = format!("Start-Process -WindowStyle Hidden powershell -ArgumentList @('-NoProfile','-STA','-EncodedCommand','{inner_b64}')");+ format!("powershell -NoProfile -EncodedCommand {}", b64(&utf16le(&outer)))+ }+ "macos" => format!("(osascript -e 'POSIX path of (choose folder)' > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),+ _ => format!("(zenity --file-selection --directory > '/tmp/apm_pick_{id}.txt' 2>/dev/null || echo __CANCELLED__ > '/tmp/apm_pick_{id}.txt') &"),+ };+ let _ = ad_shell_out(&launch);+ json!({ "token": id })+}++/// GET /api/pick-folder-result?f=<id> — poll the detached folder dialog: no file+/// yet = still open (`pending`); `__CANCELLED__`/empty = cancelled; else the path.+/// Reads (and deletes) the temp file so a stale result can't linger.+fn pick_folder_result(token: &str) -> Value {+ // the token is our own generated id — keep only its safe characters+ let id: String = token.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_').collect();+ if id.is_empty() {+ return json!({ "cancelled": true });+ }+ let (os, _) = machine();+ let content = if os == "windows" {+ // read+delete entirely inside PowerShell (cmd can't read the forward-slash %TEMP% path)+ ad_run(&ps_command(&format!(+ "$p=Join-Path ([System.IO.Path]::GetTempPath()) 'apm_pick_{id}.txt'; \+ if(Test-Path -LiteralPath $p){{[Console]::Out.Write((Get-Content -LiteralPath $p -Raw)); Remove-Item -LiteralPath $p -Force}}else{{[Console]::Out.Write('__PENDING__')}}")))+ } else {+ let tmp = format!("/tmp/apm_pick_{id}.txt");+ let c = ad_run(&format!("cat '{tmp}' 2>/dev/null"));+ if c.is_some() {+ let _ = ad_shell_out(&format!("rm -f '{tmp}'"));+ }+ c.or_else(|| Some("__PENDING__".into())) };- let path = ad_run(&cmd).unwrap_or_default().trim().to_string();- json!({ "path": path, "cancelled": path.is_empty() })+ match content.as_deref().map(str::trim) {+ None | Some("__PENDING__") => json!({ "pending": true }),+ Some("") | Some("__CANCELLED__") => json!({ "cancelled": true }),+ Some(path) => json!({ "path": path }),+ }+}++/// GET /api/list-dirs?p=<abspath> — the sub-folders of a directory on the user's+/// machine (each with its full path) plus the parent, powering the in-app folder+/// browser. Empty p starts at the home directory. This replaces the native OS+/// folder dialog (which can't render from a detached bridge process and can't be+/// run modally without wedging the bridge), and — unlike a browser file input — it+/// yields the real absolute path, which delivery needs.+fn list_dirs(path: &str) -> Value {+ let (os, home) = machine();+ let base = if path.trim().is_empty() { home } else { path.to_string() };+ if os == "windows" {+ let esc = base.replace('\'', "''");+ let script = format!(+ "$ErrorActionPreference='SilentlyContinue'; \+ $i=Get-Item -LiteralPath '{esc}'; \+ if(-not $i){{[Console]::Out.Write('@@ERR@@'); return}}; \+ $full=$i.FullName; $par=''; if($i.Parent){{$par=$i.Parent.FullName}}; \+ $names=Get-ChildItem -LiteralPath $full -Directory -Force | ForEach-Object {{ $_.Name + [char]9 + $_.FullName }} | Sort-Object; \+ [Console]::Out.Write($full + [char]10 + $par + [char]10 + '@@' + [char]10 + ($names -join [char]10))");+ let out = ad_run(&ps_command(&script)).unwrap_or_default().replace('\r', "");+ if out.is_empty() || out.contains("@@ERR@@") {+ return json!({ "path": base, "parent": "", "dirs": [] });+ }+ let (head, rest) = out.split_once("\n@@\n").unwrap_or((out.as_str(), ""));+ let mut hl = head.splitn(2, '\n');+ let full = hl.next().unwrap_or(&base).to_string();+ let par = hl.next().unwrap_or("").to_string();+ let dirs: Vec<Value> = rest.split('\n').filter(|l| !l.trim().is_empty())+ .filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))+ .collect();+ json!({ "path": full, "parent": par, "dirs": dirs })+ } else {+ let q = sh_quote(&base);+ let full = ad_run(&format!("cd {q} 2>/dev/null && pwd")).unwrap_or_else(|| base.clone());+ let fq = sh_quote(&full);+ let par = ad_run(&format!("dirname {fq}")).filter(|p| *p != full).unwrap_or_default();+ let out = ad_run(&format!("for d in {fq}/*/ ; do [ -d \"$d\" ] && printf '%s\\t%s\\n' \"$(basename \"$d\")\" \"${{d%/}}\" ; done 2>/dev/null")).unwrap_or_default();+ let dirs: Vec<Value> = out.lines()+ .filter_map(|l| l.split_once('\t').map(|(n, p)| json!({ "name": n.trim(), "path": p.trim() })))+ .collect();+ json!({ "path": full, "parent": par, "dirs": dirs })+ } } /// GET /api/suggest-path?p=<prefix> — existing directories on the user's machine@@ -1188,7 +1314,13 @@ fn ad_shell(cmd: &str) { /// Run a shell command on the user's machine and return its captured reply /// (whole adom-desktop stdout); None if the bridge call itself failed.+// The desktop bridge (AD) processes shell commands one at a time. Firing several+// at once (now that the HTTP server is multi-threaded) makes concurrent filesystem+// searches thrash the disk and each balloon 5-10x. Serialize them here so every+// bridge call stays fast; the server still handles local/cached requests in parallel.+static BRIDGE_LOCK: Mutex<()> = Mutex::new(()); fn ad_shell_out(cmd: &str) -> Option<String> {+ let _guard = BRIDGE_LOCK.lock().unwrap(); let args = json!({"command": cmd}).to_string(); let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?; if !out.status.success() {@@ -1197,6 +1329,41 @@ fn ad_shell_out(cmd: &str) -> Option<String> { Some(String::from_utf8_lossy(&out.stdout).to_string()) } +/// Base64 (standard alphabet, padded).+fn b64(data: &[u8]) -> String {+ const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for c in data.chunks(3) {+ let (b0, b1, b2) = (c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0));+ out.push(T[(b0 >> 2) as usize] as char);+ out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);+ out.push(if c.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' });+ out.push(if c.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' });+ }+ out+}++fn utf16le(s: &str) -> Vec<u8> {+ s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()+}++/// Wrap a PowerShell script as a `-EncodedCommand` invocation. The AD Windows+/// shell is cmd; passing `powershell -Command "…"` lets the double quotes survive+/// to PowerShell, which then treats the script as a string literal and ECHOES it+/// instead of running it. Base64 UTF-16LE has no quotes/spaces to mangle, so the+/// script runs verbatim regardless of the intervening cmd/JSON quoting.+fn ps_command(script: &str) -> String {+ format!("powershell -NoProfile -STA -EncodedCommand {}", b64(&utf16le(script)))+}++/// A process-unique id (millis + counter) for temp filenames.+fn unique_id() -> String {+ static C: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+ let n = C.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+ let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);+ format!("{t}_{n}")+}+ /// Board media rendered by the hosted service-kicad container (KiCad 10) and /// cached per board. Tuple: (schematic sheets `[(name, svg)]`, PCB svg, board GLB). type BoardMedia = (Vec<(String, String)>, Option<String>, Option<Vec<u8>>);@@ -1375,7 +1542,10 @@ fn render_board_media(slug: &str) -> BoardMedia { /// Manager serves them directly — no service-kicad, no wait — instead of /// rendering live. Regenerated on every board publish (`--render-board`). fn stored_render(slug: &str) -> Option<Value> {- wiki_bytes(slug, "render/manifest.json").and_then(|b| serde_json::from_slice::<Value>(&b).ok())+ wiki_bytes(slug, "render/manifest.json")+ .and_then(|b| serde_json::from_slice::<Value>(&b).ok())+ // must actually look like a manifest, not e.g. a 404 error object+ .filter(|m| m.get("v").is_some() || m.get("glb").is_some() || m.get("sheets").is_some()) } /// {rendering, has_3d, has_sch, has_pcb}. With pre-rendered assets it returns@@ -1388,18 +1558,20 @@ fn board_media(slug: &str) -> Value { let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) .unwrap_or_default();- return json!({"rendering": false, "is_kicad": true, "stored": true, "sheets": sheets,+ return json!({"rendering": false, "is_kicad": true, "stored": true, "has_render": true, "sheets": sheets, "has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false), "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false)}); }+ // no stored render/ folder from here on — the board falls back to slow live+ // rendering (has_render:false), which the UI flags as an error state. if let Some((sheets, pcb, glb)) = cached_board(slug) { let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();- return json!({"rendering": false, "is_kicad": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});+ return json!({"rendering": false, "is_kicad": true, "has_render": false, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()}); } // only KiCad boards (with a .kicad_pcb / .kicad_sch) can be rendered let is_kicad = list_page_files(slug).iter().any(|n| n.ends_with(".kicad_pcb") || n.ends_with(".kicad_sch")); if !is_kicad {- return json!({"rendering": false, "is_kicad": false, "sheets": [], "has_3d": false, "has_pcb": false});+ return json!({"rendering": false, "is_kicad": false, "has_render": false, "sheets": [], "has_3d": false, "has_pcb": false}); } let start = { let mut g = BOARD_RENDERING.lock().unwrap();@@ -1416,7 +1588,7 @@ fn board_media(slug: &str) -> Value { } }); }- json!({"rendering": true, "is_kicad": true, "sheets": [], "has_3d": false, "has_pcb": false})+ json!({"rendering": true, "is_kicad": true, "has_render": false, "sheets": [], "has_3d": false, "has_pcb": false}) } /// Does a file exist on the user's machine? None = couldn't determine@@ -1583,7 +1755,7 @@ fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String, let (os, _) = machine(); let out = if os == "windows" { let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");- ad_run(&format!("powershell -NoProfile -Command \"Select-String -Path {list} -Pattern '\\(symbol \\\"([^\\\"]+)\\\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}\""))+ ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '\\(symbol \"([^\"]+)\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}"))) } else { let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" "); ad_run(&format!("grep -HoE '\\(symbol \"[^\"]+\"' {list} 2>/dev/null"))@@ -1620,7 +1792,7 @@ fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections:: let pat = pats.join("|"); let out = if os == "windows" { let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");- ad_run(&format!("powershell -NoProfile -Command \"Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}\""))+ ad_run(&ps_command(&format!("$ProgressPreference='SilentlyContinue'; Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}"))) } else { let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" "); ad_run(&format!("grep -HioE '({pat})' {list} 2>/dev/null"))
src/manage.html+124−29@@ -67,6 +67,22 @@ #modal-dest .destrow{display:flex;gap:8px;align-items:center} #modal-dest .browsebtn{flex:2;text-align:left;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px;font:12px ui-monospace,monospace;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #modal-dest .browsebtn:hover{border-color:var(--acc)}+ .fb-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}+ .fb-panel{width:min(560px,92vw);max-height:80vh;background:#12151c;border:1px solid var(--edge);border-radius:12px;display:flex;flex-direction:column;overflow:hidden}+ .fb-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--edge)}+ .fb-title{font-weight:600}+ .fb-x{background:none;border:none;color:var(--mut);font-size:16px;cursor:pointer;line-height:1}+ .fb-path{display:block;width:100%;box-sizing:border-box;padding:9px 16px;font:12px ui-monospace,monospace;color:var(--acc);background:#0d0f14;border:none;border-bottom:1px solid var(--edge);outline:none}+ .fb-path:focus{border-bottom-color:var(--acc)}+ .fb-list{overflow-y:auto;flex:1;min-height:48px;max-height:52vh;padding:6px 0}+ .fb-row:last-child{border-bottom:none}+ .fb-row{padding:9px 16px;cursor:pointer;font-size:13px;border-bottom:1px solid #171b22;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .fb-row:hover{background:#1e222b}+ .fb-up{color:var(--acc)}+ .fb-empty,.fb-loading{padding:16px;color:var(--mut);font-size:13px;text-align:center}+ .fb-foot{display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--edge)}+ .fb-foot button{border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;border:1px solid var(--edge);background:#0d0f14;color:var(--ink)}+ .fb-pick{background:var(--acc)!important;border-color:var(--acc)!important;color:#04121f!important;font-weight:600} #modal-dest #destname{flex:1} #modal-dest .destsep{color:var(--mut);font:14px ui-monospace,monospace;flex:0 0 auto} .libsel{background:#262c36;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:3px 6px;font-size:12px;max-width:100%}@@ -108,19 +124,27 @@ #view.split #component-view{flex:0 0 50%;display:block;border-top:3px solid var(--acc)} #hint{color:var(--mut);text-align:center;margin-top:20vh} /* board overview (top of the right pane) */+ .render-warn{background:#3a1214;border-bottom:1px solid #7f1d1d;color:#fca5a5;padding:11px 20px;font-size:13px;line-height:1.45;font-weight:500}+ .render-warn b{color:#fecaca} .bo-head{padding:16px 20px 12px;border-bottom:1px solid var(--edge)} .bo-head .title{margin:0;font-size:20px;font-weight:700} .bo-author{color:var(--mut);font-size:13px;margin-top:3px} .bo-3d-wrap{position:relative;margin:16px 20px;padding:0}- .bo-3d-wrap .bo-3d{margin:0}+ .bo-3d-wrap .bo-3d{margin:0 auto} .bo-fs-abs{position:absolute;top:0;right:0;z-index:3} .bo-3d-wrap .bo-fs-abs{top:10px;right:10px}- .bo-view.maxed .bo-3d{flex:1;height:auto;aspect-ratio:auto}- .bo-3d{aspect-ratio:1;margin:16px 20px;background:#161b22;border:1px solid var(--edge);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden}+ .bo-view.maxed .bo-3d{flex:1;height:auto;aspect-ratio:auto;max-width:none;width:auto;margin:0;align-self:stretch}+ /* cap the 3D at a comfortable size and center it (with side gutters) instead of+ stretching to the full panel width on wide screens */+ .bo-3d{position:relative;aspect-ratio:1;max-width:560px;margin:16px auto;background:#161b22;border:1px solid var(--edge);border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden}+ /* controls live INSIDE each view box: fullscreen at top-left, sheet dropdown at top-right */+ .bo-boxinner{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden}+ .boxfs{position:absolute;top:8px;left:8px;z-index:4}+ .boxsheet{position:absolute;top:8px;right:8px;z-index:4;max-width:55%} .bo-3d model-viewer{width:100%;height:100%;aspect-ratio:auto} .bo-views{display:flex;gap:14px;padding:0 20px 20px} .bo-view{flex:1;min-width:0;position:relative}- .bo-view-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;min-height:24px;padding-right:34px}+ .bo-view-hd{display:flex;align-items:center;gap:8px;margin-bottom:6px;min-height:24px} .bo-view h3{margin:0;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em} .bo-sheet{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;max-width:62%} .bo-sheet:focus{outline:none;border-color:var(--acc)}@@ -294,10 +318,53 @@ function renderBoards(){ 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={};+// a board can carry more than one toolchain (e.g. KiCad + Fusion) — render one tag each.+function edaTags(list){return (list||[]).map(e=>`<span class="tag eda ${e}">${EDA_LABEL[e]||''}</span>`).join('');} 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||'');});+ const fill=list=>document.querySelectorAll('.eda[data-eda="'+CSS.escape(slug)+'"]').forEach(el=>{el.outerHTML=edaTags(list);}); 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(()=>{});+ fetch('api/board-eda/'+encodeURIComponent(slug)).then(r=>r.json()).then(d=>{edaCache[slug]=d.eda||[];fill(edaCache[slug]);}).catch(()=>{});+}+// In-app folder browser: navigates the user's filesystem via the bridge and calls+// onPick(absolutePath) with the real path when the user selects a folder.+function openFolderBrowser(start,onPick){+ let cur=start||'';+ const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');+ const ov=document.createElement('div'); ov.className='fb-overlay';+ ov.innerHTML=`<div class="fb-panel">+ <div class="fb-head"><span class="fb-title">Choose a folder</span><button class="fb-x" title="cancel">✕</button></div>+ <input class="fb-path" id="fb-path" spellcheck="false" autocomplete="off" placeholder="type or paste a path, press Enter to go">+ <div class="fb-list" id="fb-list"><div class="fb-loading">loading…</div></div>+ <div class="fb-foot"><button class="fb-cancel">Cancel</button><button class="fb-pick">Select this folder</button></div>+ </div>`;+ document.body.appendChild(ov);+ const close=()=>ov.remove();+ ov.querySelector('.fb-x').onclick=close;+ ov.querySelector('.fb-cancel').onclick=close;+ ov.querySelector('.fb-pick').onclick=()=>{ if(cur){onPick(cur);} close(); };+ ov.onclick=e=>{ if(e.target===ov)close(); };+ const listEl=ov.querySelector('#fb-list'), pathEl=ov.querySelector('#fb-path');+ // type/paste a path + Enter to jump straight there+ pathEl.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); load(pathEl.value.trim()); } });+ async function load(p){+ listEl.innerHTML='<div class="fb-loading">loading…</div>';+ let d;+ try{+ // use the listing prefetched when the modal opened, so the first open is instant+ // even while the modal's library scan is still running on the bridge+ if((!p||p==='') && fbPrefetch){ d=await fbPrefetch; fbPrefetch=null; }+ if(!d) d=await fetch('api/list-dirs?p='+encodeURIComponent(p||'')).then(r=>r.json());+ }+ catch(e){ listEl.innerHTML='<div class="fb-empty">couldn’t read that folder</div>'; return; }+ cur=d.path||p||''; pathEl.value=cur;+ let html='';+ if(d.parent) html+=`<div class="fb-row fb-up" data-p="${esc(d.parent)}">⬆ ..</div>`;+ (d.dirs||[]).forEach(x=>{ html+=`<div class="fb-row" data-p="${esc(x.path)}">📁 ${esc(x.name)}</div>`; });+ if(!(d.dirs||[]).length) html+='<div class="fb-empty">(no sub-folders)</div>';+ listEl.innerHTML=html;+ listEl.querySelectorAll('.fb-row').forEach(r=>r.onclick=()=>load(r.dataset.p));+ }+ load(cur); } function boardRow(c){ const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;@@ -305,8 +372,8 @@ function boardRow(c){ 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>`;+ const edaKnown=Array.isArray(c.eda);+ const edaSpan=edaKnown?edaTags(c.eda):`<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);@@ -354,10 +421,10 @@ function renderBoardOverview(slug){ document.getElementById('component-view').innerHTML=''; document.getElementById('board-overview').innerHTML=` <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-view bo-3d-wrap"><div class="bo-3d"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo3d"><div class="bo-na">loading 3D…</div></div></div></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>- <div class="bo-view"><button class="bo-fs bo-fs-abs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody" id="bo-pcb"><div class="bo-na">rendering…</div></div></div>+ <div class="bo-view"><div class="bo-view-hd"><h3>Schematic</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><select id="bo-sheet" class="bo-sheet boxsheet" style="display:none"></select><div class="bo-boxinner" id="bo-sch"><div class="bo-na">rendering…</div></div></div></div>+ <div class="bo-view"><div class="bo-view-hd"><h3>PCB</h3></div><div class="bo-viewbody"><button class="bo-fs boxfs" title="fullscreen" onclick="toggleFs(this)">⛶</button><div class="bo-boxinner" id="bo-pcb"><div class="bo-na">rendering…</div></div></div></div> </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);@@ -391,6 +458,21 @@ function fillBoardInfo(slug,log){ const a=d.activity||[]; if(a.length&&lg)lg.innerHTML=a.map(commitHtml).join(''); }).catch(()=>{}); }+// Red error bar at the top when a board/molecule has no stored render/ folder — it+// can't load properly (falls back to slow live rendering) until it's re-published.+function showRenderWarning(slug,m){+ if(boardSlug!==slug)return;+ const ov=document.getElementById('board-overview'); if(!ov)return;+ let bar=ov.querySelector('.render-warn');+ const missing=m&&m.is_kicad!==false&&m.has_render===false;+ if(!missing){ if(bar)bar.remove(); return; }+ const kind=boardKind(bySlug[slug]||{});+ if(!bar){+ bar=document.createElement('div'); bar.className='render-warn';+ ov.insertBefore(bar,ov.firstChild);+ }+ bar.innerHTML=`⚠ This ${kind} is missing a render folder, so its 3D, PCB and schematic may not load properly. Reupload the ${kind} with the <b>publish-board</b> skill to regenerate the render assets.`;+} async function loadBoardMedia(slug){ // service-kicad renders in the background (a board GLB can take ~a minute) — // poll until it's done, keeping the "rendering…" placeholders meanwhile.@@ -398,9 +480,11 @@ async function loadBoardMedia(slug){ for(let i=0;i<60 && m.rendering;i++){ try{ m=await fetch('api/board-media/'+slug).then(r=>r.json()); }catch(e){ m={}; break; } if(boardSlug!==slug)return; // navigated away+ showRenderWarning(slug,m); // flag a missing render/ folder as soon as we know if(m.rendering)await new Promise(r=>setTimeout(r,2000)); } if(boardSlug!==slug)return;+ showRenderWarning(slug,m); const d3=document.getElementById('bo3d'),sch=document.getElementById('bo-sch'),pcb=document.getElementById('bo-pcb'); if(m.is_kicad===false){ const na='<div class="bo-na">board type not supported</div>';@@ -435,21 +519,27 @@ function mountSvg(box,svgText,opt){ svg.removeAttribute('width');svg.removeAttribute('height'); svg.style.width='100%';svg.style.height='100%';svg.style.display='block'; if(opt.invert)svg.style.filter='invert(1) hue-rotate(180deg)';- // dim the copper fills so lower layers show through (see all 4 copper layers);- // keep silkscreen/edge fully opaque so they stay readable+ // dim ONLY the copper zone pours (the large filled ground/power planes) so lower+ // layers show through; pads, vias, traces and silk stay fully opaque. if(opt.dimFills){- const keep=new Set(['#f2eda1','#d0d2cd']); // silk + edge stay opaque (markings); ALL copper dims+ // drill holes first (unchanged): via (<0.5mm) → gold, larger holes → background svg.querySelectorAll('[style*="fill"]').forEach(el=>{ const m=(el.getAttribute('style')||'').match(/fill:\s*(#[0-9a-fA-F]{6})/);- if(!m)return;- const c=m[1].toLowerCase();- if(c==='#ffffff'){- // drill holes: diameter < 0.5mm = via → gold; larger (TH pads, mounting holes) → background.- // ([r] not "circle": the parsed SVG is namespaced, so type selectors don't match)- 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 all copper (top/bottom/inner)+ if(!m||m[1].toLowerCase()!=='#ffffff')return;+ // ([r] not "circle": the parsed SVG is namespaced, so type selectors don't match)+ 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';+ });+ // A pour is a big FILLED path (huge 'd'); pads are tiny paths/circles and traces+ // are stroke-only (computed fill:none). Silk/edge fills are skipped by color so a+ // filled silk logo stays readable.+ const skip=new Set(['rgb(242, 237, 161)','rgb(208, 210, 205)','rgb(255, 255, 255)']); // silk, edge, white+ svg.querySelectorAll('path').forEach(el=>{+ if((el.getAttribute('d')||'').length < 2000) return; // pads / short segments → opaque+ const f=getComputedStyle(el).fill; // resolves fill inherited from a parent <g>+ if(!f||f==='none'||f==='rgba(0, 0, 0, 0)'||skip.has(f)) return; // traces / edge / silk → opaque+ el.style.fillOpacity=opt.dimFills; // copper pour → translucent }); } // remove the full-page paper rect (crop to the content on the box background)@@ -673,7 +763,11 @@ function openAddLib(){ modalGo=()=>doApply(curPlan,'kicad',false); showModal(); }+let fbPrefetch=null; function openAddProj(){+ // prefetch the home folder listing FIRST (before the modal's library scan hogs the+ // bridge) so clicking "Browse files" opens the folder browser instantly+ fbPrefetch=fetch('api/list-dirs?p=').then(r=>r.json()).catch(()=>null); const slugs=curDeps.slice(); const projName=((bySlug[boardSlug]||{}).title||boardSlug).replace(/[^A-Za-z0-9_-]+/g,'_'); let mode='personal';@@ -700,12 +794,13 @@ function openAddProj(){ }; // run the check for the current mode (drives its status bar + coloring) const runCheck=()=>{ if(mode==='project'){ inLib={}; checkProjectDiff(boardSlug,projPath(),draw); } else { projDiff={}; checkInLib(slugs,draw); } };- browse.onclick=async()=>{ const old=browse.dataset.path||'Browse files'; browse.textContent='Opening…'; browse.disabled=true;- try{ const r=await fetch('api/pick-folder').then(r=>r.json());- if(r.path){ chosenDir=r.path; browse.dataset.path=r.path; browse.textContent=r.path; browse.title=r.path; if(mode==='project')runCheck(); }- else browse.textContent=old;- }catch(e){ browse.textContent=old; }- browse.disabled=false; };+ // An in-app folder browser (native OS dialogs can't render from the detached+ // bridge process, and a browser file input can't yield the absolute path delivery+ // needs). Navigates the user's filesystem over the bridge; the path is real.+ browse.onclick=()=>openFolderBrowser(browse.dataset.path||'', path=>{+ chosenDir=path; browse.dataset.path=path; browse.textContent=path; browse.title=path;+ if(mode==='project')runCheck();+ }); selOpt('mode','mode',v=>{mode=v;runCheck();}); document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project'; inLib={}; projDiff={}; draw(); runCheck(); showModal();