collective

Browse / github.com/storytold/designcraft / patch

Links panel: file list, file information and Show in Folder

Links panel: names and paths are cut off, and there is no way to show a linked file's folder ### What happens The Links panel is hard to use with real files: - Each row squeezes the name, a second line with the folder path, the status and "Page · PPI" into a narrow grid, so names and paths are cut to a few letters ("chapatisun-…", "/home/user…"). - The selected link's full path is shown as plain text under the list, and the action buttons (Relink…, Go To, Update, Embed, Relink to Folder…) wrap onto two rows. - There is no way to open the folder of a linked file. To find a modified or missing file you have to copy the path by hand. ### Steps 1. Place two or three images or PDFs fro

Status
open
Score
0.00
Adopters
0 other operators
Author
PiMinusMax (imported from pull request #1531, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+788 −130 in 8 files
Review
pending
Shared
2026-10-28
Id
688345c7b3139ba44f1f0d4b

How it went

No other operator has reported using it yet.

Diff

raw · apps/designcraft/src/main.rs apps/designcraft/src/reveal.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/properties.rs crates/ui-egui/src/widgets.rs

diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs--- a/apps/designcraft/src/main.rs+++ b/apps/designcraft/src/main.rs@@ -22,6 +22,7 @@ mod logging; #[cfg(target_os = "macos")] mod native_menu; mod profile;+mod reveal;  use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services};@@ -370,6 +371,7 @@ fn services() -> Services {         read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),         write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),         clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),+        reveal: Some(Box::new(reveal::reveal)),         ..Default::default()     } }diff --git a/apps/designcraft/src/reveal.rs b/apps/designcraft/src/reveal.rsnew file mode 100644--- /dev/null+++ b/apps/designcraft/src/reveal.rs@@ -0,0 +1,149 @@+//! Links panel › Show in Folder: open a linked file's folder in the system file manager, with the+//! file selected where the platform can (Finder, Explorer, and Linux file managers that implement+//! the freedesktop `org.freedesktop.FileManager1` interface: Files, Dolphin, Nemo, Caja, Thunar …).++use std::path::Path;+use std::process::{Command, Stdio};++/// Show `path` in the file manager. A missing file still opens its folder, so a moved or renamed+/// link can be looked for; a missing folder is an error.+pub fn reveal(path: &str) -> Result<(), String> {+    let file = Path::new(path);+    let folder = file.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| format!("“{path}” has no folder"))?;+    if !folder.is_dir() {+        return Err(format!("The folder “{}” doesn't exist", folder.display()));+    }+    show(file, folder, file.is_file())+}++/// Start `cmd` without a console and reap it on a helper thread, so no zombie stays behind.+#[cfg(any(target_os = "macos", windows))]+fn spawn(cmd: &mut Command) -> Result<(), String> {+    let mut child = cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().map_err(|e| e.to_string())?;+    std::thread::spawn(move || child.wait());+    Ok(())+}++#[cfg(target_os = "macos")]+fn show(file: &Path, folder: &Path, exists: bool) -> Result<(), String> {+    // A missing file's folder opens in Finder by name: a plain `open` would launch a folder+    // that is an app bundle instead of showing it.+    if exists { spawn(Command::new("open").arg("-R").arg(file)) } else { spawn(Command::new("open").arg("-a").arg("Finder").arg(folder)) }+}++#[cfg(windows)]+fn show(file: &Path, folder: &Path, exists: bool) -> Result<(), String> {+    use std::os::windows::process::CommandExt;+    if exists {+        // Explorer reads `/select,` and the quoted path as one argument; Rust's quoting would split it.+        spawn(Command::new("explorer").raw_arg(format!("/select,\"{}\"", file.display())))+    } else {+        spawn(Command::new("explorer").arg(folder))+    }+}++#[cfg(all(unix, not(target_os = "macos")))]+fn show(file: &Path, folder: &Path, exists: bool) -> Result<(), String> {+    use std::os::unix::ffi::OsStrExt;+    // The D-Bus call waits up to five seconds for the file manager's answer, so it runs off the+    // UI thread and can't report back. What can be known up front is reported here: with none+    // of the programs it would use installed, Show in Folder can't work at all.+    let dbus = exists && (on_path("gdbus") || on_path("dbus-send"));+    if !dbus && !on_path("xdg-open") {+        return Err("Can't open the folder: xdg-open isn't installed".into());+    }+    let uri = file_uri(file.as_os_str().as_bytes());+    let folder = folder.to_path_buf();+    std::thread::Builder::new()+        .name("reveal".into())+        .spawn(move || {+            if dbus && show_items(&uri) {+                return;+            }+            match Command::new("xdg-open").arg(&folder).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status() {+                Ok(s) if s.success() => {}+                Ok(s) => log::warn!("Show in Folder: xdg-open {}: {s}", folder.display()),+                Err(e) => log::warn!("Show in Folder: xdg-open {}: {e}", folder.display()),+            }+        })+        .map(|_| ())+        .map_err(|e| e.to_string())+}++/// Whether `program` is a file in one of the `PATH` folders, where [`Command`] looks for it.+#[cfg(all(unix, not(target_os = "macos")))]+fn on_path(program: &str) -> bool {+    std::env::var_os("PATH").is_some_and(|dirs| std::env::split_paths(&dirs).any(|d| d.join(program).is_file()))+}++/// Ask the session's file manager to open the folder with `uri` selected; false if none answered.+#[cfg(all(unix, not(target_os = "macos")))]+fn show_items(uri: &str) -> bool {+    let quiet = |cmd: &mut Command| cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success());+    quiet(Command::new("gdbus").args([+        "call",+        "--session",+        "--timeout",+        "5",+        "--dest",+        "org.freedesktop.FileManager1",+        "--object-path",+        "/org/freedesktop/FileManager1",+        "--method",+        "org.freedesktop.FileManager1.ShowItems",+        &format!("['{uri}']"),+        "",+    ])) || quiet(Command::new("dbus-send").args([+        "--session",+        "--print-reply",+        "--reply-timeout=5000",+        "--dest=org.freedesktop.FileManager1",+        "/org/freedesktop/FileManager1",+        "org.freedesktop.FileManager1.ShowItems",+        &format!("array:string:{uri}"),+        "string:",+    ]))+}++/// A `file://` URI for an absolute path: every byte outside the unreserved set and `/` is+/// percent-encoded, so the URI holds no quote or comma that would end the D-Bus argument early.+#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]+fn file_uri(path: &[u8]) -> String {+    let mut uri = String::from("file://");+    for &b in path {+        if b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'.' | b'_' | b'~') {+            uri.push(char::from(b));+        } else {+            uri.push_str(&format!("%{b:02X}"));+        }+    }+    uri+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn file_uri_escapes_spaces_quotes_commas_and_umlauts() {+        assert_eq!(+            file_uri("/home/me/Bilder/Nachshooting Vollauflösung (2).jpg".as_bytes()),+            "file:///home/me/Bilder/Nachshooting%20Vollaufl%C3%B6sung%20%282%29.jpg"+        );+        assert_eq!(file_uri(b"/a/it's,x.pdf"), "file:///a/it%27s%2Cx.pdf");+    }++    #[cfg(all(unix, not(target_os = "macos")))]+    #[test]+    fn programs_are_looked_up_on_the_path() {+        assert!(on_path("sh"));+        assert!(!on_path("designcraft-no-such-program"));+    }++    #[test]+    fn a_missing_folder_is_an_error_not_a_launch() {+        let err = reveal("/no/such/folder/for/designcraft/photo.jpg").unwrap_err();+        assert!(err.contains("/no/such/folder/for/designcraft"), "{err}");+        assert!(reveal("photo.jpg").is_err(), "a bare name has no folder");+    }+}diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -4711,6 +4711,50 @@ const TABLE: &[(&str, [&str; 5])] = &[             "按字母顺序排列条目",         ],     ),+    // Links panel.+    ("Show in Folder", ["Im Ordner zeigen", "Afficher dans le dossier", "Mostrar en la carpeta", "フォルダーに表示", "在文件夹中显示"]),+    ("Copy Path", ["Pfad kopieren", "Copier le chemin", "Copiar ruta", "パスをコピー", "复制路径"]),+    ("Path copied", ["Pfad kopiert", "Chemin copié", "Ruta copiada", "パスをコピーしました", "已复制路径"]),+    ("{count} files", ["{count} Dateien", "{count} fichiers", "{count} archivos", "{count} 個のファイル", "{count} 个文件"]),+    ("{count} modified", ["{count} geändert", "{count} modifié(s)", "{count} modificado(s)", "{count} 個が変更済み", "{count} 个已修改"]),+    ("{count} missing", ["{count} fehlend", "{count} manquant(s)", "{count} no encontrado(s)", "{count} 個が見つかりません", "{count} 个缺失"]),+    ("Name or folder", ["Name oder Ordner", "Nom ou dossier", "Nombre o carpeta", "名前またはフォルダー", "名称或文件夹"]),+    (+        "No links match.",+        ["Keine passende Verknüpfung.", "Aucun lien ne correspond.", "Ningún vínculo coincide.", "一致するリンクはありません。", "没有匹配的链接。"],+    ),+    ("File Information", ["Dateiinformationen", "Informations sur le fichier", "Información del archivo", "ファイル情報", "文件信息"]),+    (+        "Missing: moved, renamed or deleted",+        [+            "Fehlt: verschoben, umbenannt oder gelöscht",+            "Manquant : déplacé, renommé ou supprimé",+            "No encontrado: movido, renombrado o eliminado",+            "見つかりません:移動、名前変更、または削除されました",+            "缺失:已移动、重命名或删除",+        ],+    ),+    (+        "Changed since it was placed",+        ["Geändert seit dem Platzieren", "Modifié depuis l'importation", "Modificado desde que se colocó", "配置後に変更されました", "置入后已更改"],+    ),+    (+        "Embedded in the document",+        ["Im Dokument eingebettet", "Incorporé au document", "Incrustado en el documento", "ドキュメントに埋め込み済み", "已嵌入文档"],+    ),+    ("Up to date", ["Aktuell", "À jour", "Actualizado", "最新", "最新"]),+    (+        "Edited in the layout",+        ["Im Layout bearbeitet", "Modifié dans la mise en page", "Editado en el diseño", "レイアウトで編集済み", "已在版面中编辑"],+    ),+    ("Effective PPI", ["Effektive ppi", "ppi effectif", "ppp efectivos", "有効 ppi", "有效 ppi"]),+    ("low for print", ["zu niedrig für Druck", "faible pour l'impression", "baja para impresión", "印刷には低すぎます", "打印分辨率偏低"]),+    ("Pixels", ["Pixel", "Pixels", "Píxeles", "ピクセル", "像素"]),+    ("More", ["Mehr", "Plus", "Más", "その他", "更多"]),+    (+        "Update All Links",+        ["Alle Verknüpfungen aktualisieren", "Mettre à jour tous les liens", "Actualizar todos los vínculos", "すべてのリンクを更新", "更新所有链接"],+    ), ];  /// Interface direction; independent of document binding and paragraph direction.@@ -5159,6 +5203,27 @@ mod tests {         // Docking chrome labels reach `tr` through craft-ui's localizer.         keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english));         // UI labels passed to `tr` that no other catalog has yet.+        // The Links panel's labels and status texts.+        keys.extend([+            "Changed since it was placed",+            "Copy Path",+            "Edited in the layout",+            "Effective PPI",+            "Embedded in the document",+            "File Information",+            "Missing: moved, renamed or deleted",+            "More",+            "Name or folder",+            "No links match.",+            "Path copied",+            "Pixels",+            "Up to date",+            "Update All Links",+            "low for print",+            "{count} files",+            "{count} missing",+            "{count} modified",+        ]);         keys.extend([             "(any)",             "(unchanged)",diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs--- a/crates/ui-egui/src/icons.rs+++ b/crates/ui-egui/src/icons.rs@@ -451,6 +451,50 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {             pen.closed(&[(3.0, 9.0), (10.0, 3.0), (17.0, 9.0), (17.0, 17.0), (3.0, 17.0)]);             pen.rect(8.0, 11.0, 12.0, 17.0);         }+        // Links panel: Show in Folder, Copy Path, Go To, the more-actions menu and a warning.+        "folder-open" => {+            pen.line(&[(3.0, 15.5), (3.0, 4.5), (8.0, 4.5), (9.5, 6.5), (15.0, 6.5), (15.0, 9.0)]);+            pen.closed(&[(3.0, 15.5), (5.5, 9.0), (18.0, 9.0), (15.5, 15.5)]);+        }+        // Two chain links on the diagonal (Links panel: the source path, Relink).+        "chain" => {+            let stadium = |cx: f32, cy: f32| -> Vec<(f32, f32)> {+                let (half, r) = (2.6_f32, 2.6_f32);+                let (ux, uy) = (std::f32::consts::FRAC_1_SQRT_2, -std::f32::consts::FRAC_1_SQRT_2);+                let mut pts = Vec::with_capacity(26);+                for (end, from) in [(1.0_f32, -std::f32::consts::FRAC_PI_2), (-1.0, std::f32::consts::FRAC_PI_2)] {+                    for i in 0..=12 {+                        let a = from + i as f32 / 12.0 * std::f32::consts::PI;+                        let (lx, ly) = (end * half + r * a.cos(), r * a.sin());+                        pts.push((cx + lx * ux - ly * uy, cy + lx * uy + ly * ux));+                    }+                }+                pts+            };+            pen.closed(&stadium(7.4, 12.6));+            pen.closed(&stadium(12.6, 7.4));+        }+        "copy" => {+            pen.rect(7.0, 7.0, 16.0, 17.0);+            pen.line(&[(4.0, 13.0), (4.0, 3.0), (13.0, 3.0)]);+        }+        "go-to" => {+            pen.circle(10.0, 10.0, 5.5);+            pen.fcircle(10.0, 10.0, 1.6);+            for (a, b) in [((10.0, 1.5), (10.0, 4.5)), ((10.0, 15.5), (10.0, 18.5)), ((1.5, 10.0), (4.5, 10.0)), ((15.5, 10.0), (18.5, 10.0))] {+                pen.line(&[a, b]);+            }+        }+        "more" => {+            for x in [5.0, 10.0, 15.0] {+                pen.fcircle(x, 10.0, 1.4);+            }+        }+        "warning" => {+            pen.closed(&[(10.0, 3.0), (18.0, 17.0), (2.0, 17.0)]);+            pen.line(&[(10.0, 8.0), (10.0, 12.5)]);+            pen.fcircle(10.0, 14.8, 0.9);+        }         "search" => {             pen.circle(8.5, 8.5, 5.0);             pen.line(&[(12.5, 12.5), (17.0, 17.0)]);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -68,6 +68,9 @@ pub type OpenAsyncFn = Box<dyn FnMut(ImportRequest)>; /// Starts a browser download; an error means no file was handed to the user. pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>; pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>;+/// Shows a file in the system file manager (its folder opened, the file selected where the+/// platform can); an error says why nothing was shown.+pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>; /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>; @@ -92,6 +95,8 @@ pub struct Services {     /// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the     /// paste keys, which a native menu bar takes first).     pub clipboard_text: Option<ClipboardFn>,+    /// Show a linked file in the file manager (Links panel › Show in Folder). Unset on the web.+    pub reveal: Option<RevealFn>, }  /// Closed panels, each with where it goes back, in craft-ui's `Workspace::hidden` shapediff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -40,6 +40,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[         "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects",     ),     ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"),+    (+        "links.reveal",+        "Show in Folder",+        None,+        "{asset}|{story} — open the folder of a linked file in the system file manager, with the file selected (desktop only)",+    ),     ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"),     ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),     ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),@@ -1253,6 +1259,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value             Ok(Value::Null)         }         "app.exportText" => export_bytes(app, p, "rtf", "file.exportText"),+        "links.reveal" => crate::panels::properties::reveal_link(app, p),         "app.packageDialog" => {             let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default();             match app.pick_save(&name) {@@ -3498,6 +3505,8 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str, cjk /// dialog to ask for it, or without one they reset a setting. Each stays in the menu, panel or /// dialog that gives it the value (the workspace menus, Keyboard Shortcuts, Preferences…). const NOT_SEARCHED: &[&str] = &[+    // The link to show: the Links panel names it.+    "links.reveal",     // The workspace to switch to or delete.     "window.workspace",     "window.deleteWorkspace",diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs--- a/crates/ui-egui/src/panels/properties.rs+++ b/crates/ui-egui/src/panels/properties.rs@@ -2612,8 +2612,142 @@ pub fn align_panel(app: &mut DesignApp, ui: &mut egui::Ui) {     }); } -/// Links: status (missing / modified / embedded), page and effective resolution of every placed-/// graphic and linked text file; Relink, Go To Link, Update Link, Embed Link for the chosen row.+/// What a Links panel row, its context menu, the action bar or the more-actions menu asked for;+/// run after the list is drawn.+enum LinkAction {+    GoTo,+    Update,+    UpdateAll,+    Relink,+    RelinkFolder,+    Reveal,+    CopyPath,+    Embed,+}++const LINK_ROW_H: f32 = 30.0;+const LINK_PAGE_W: f32 = 44.0;+const LINK_STATUS_W: f32 = 48.0;++/// The id a Links row is chosen by: asset and story ids come from one counter.+fn link_id(r: &Value) -> u64 {+    r["asset"].as_u64().or_else(|| r["story"].as_u64()).unwrap_or(0)+}++/// The file type badge of a row ("PDF", "JPG", "TXT"): the extension, at most four letters.+fn link_kind(name: &str) -> String {+    let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default();+    let ext = if ext == "JPEG" { "JPG".to_string() } else { ext };+    if ext.is_empty() { "FILE".into() } else { ext.chars().take(4).collect() }+}++/// The colour of a link status (missing red, modified/unchecked amber, the rest dim).+fn link_status_color(status: &str, t: &Tokens) -> egui::Color32 {+    match status {+        "missing" => egui::Color32::from_rgb(0xe5, 0x4b, 0x4b),+        "modified" | "tooLarge" => egui::Color32::from_rgb(0xe8, 0xb3, 0x2c),+        _ => t.text_dim,+    }+}++/// Paint a row's status mark in `r`: a warning, a broken link, an embedded box, or a dash.+fn paint_link_status(p: &egui::Painter, r: egui::Rect, status: &str, t: &Tokens) {+    let c = link_status_color(status, t);+    let icon = egui::Rect::from_center_size(r.center(), vec2(15.0, 15.0));+    match status {+        "modified" | "tooLarge" => crate::icons::paint(p, icon, "warning", c),+        "missing" => crate::icons::paint(p, icon, "link-broken", c),+        "embedded" => {+            let b = egui::Rect::from_center_size(r.center(), vec2(10.0, 10.0));+            p.rect_stroke(b, 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside);+            p.rect_filled(b.shrink(3.0), 0.0, c);+        }+        _ => {+            p.line_segment([r.center() - vec2(5.0, 0.0), r.center() + vec2(5.0, 0.0)], egui::Stroke::new(1.0, c));+        }+    }+}++/// The page a link is placed on, `+n` for its other uses, a dash when unused.+fn link_page(r: &Value) -> String {+    let uses = r["uses"].as_array().map(Vec::as_slice).unwrap_or_default();+    match uses.first() {+        Some(u) => {+            let page = u["page"].as_str().unwrap_or("PB");+            if uses.len() > 1 { format!("{page} +{}", uses.len() - 1) } else { page.to_string() }+        }+        None => "—".into(),+    }+}++/// One Links row: type badge, name, page and status; selected rows carry an accent edge.+fn link_row(ui: &mut egui::Ui, r: &Value, selected: bool, t: &Tokens) -> egui::Response {+    let name = r["name"].as_str().unwrap_or("");+    let status = r["status"].as_str().unwrap_or("");+    let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), LINK_ROW_H), Sense::click());+    // Named by the file, for screen readers and UI tests.+    resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, name));+    let p = ui.painter_at(rect);+    if selected {+        p.rect_filled(rect, 0.0, t.row_selected);+        p.rect_filled(egui::Rect::from_min_size(rect.min, vec2(2.0, rect.height())), 0.0, t.accent);+    } else if resp.hovered() {+        p.rect_filled(rect, 0.0, t.hover);+    }+    p.line_segment([rect.left_bottom(), rect.right_bottom()], egui::Stroke::new(1.0, t.divider));+    let text = if selected { t.text_strong } else { t.text };+    // Type badge.+    let badge = egui::Rect::from_min_size(egui::pos2(rect.min.x + 8.0, rect.center().y - 8.0), vec2(26.0, 16.0));+    p.rect_stroke(badge, 2.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);+    p.text(badge.center(), egui::Align2::CENTER_CENTER, link_kind(name), crate::theme::semibold(8.0), t.text_dim);+    // Name, truncated to what the page and status columns leave.+    let status_x = rect.max.x - LINK_STATUS_W;+    let page_x = status_x - LINK_PAGE_W;+    let name_w = (page_x - badge.max.x - 16.0).max(20.0);+    let galley = egui::WidgetText::from(egui::RichText::new(name).size(12.5)).into_galley(+        ui,+        Some(egui::TextWrapMode::Truncate),+        name_w,+        egui::TextStyle::Body,+    );+    p.galley(egui::pos2(badge.max.x + 10.0, rect.center().y - galley.size().y / 2.0), galley, text);+    let page = egui::Rect::from_min_max(egui::pos2(page_x, rect.min.y), egui::pos2(status_x, rect.max.y));+    p.text(page.center(), egui::Align2::CENTER_CENTER, link_page(r), egui::FontId::proportional(12.0), text);+    let status_rect = egui::Rect::from_min_max(egui::pos2(status_x, rect.min.y), rect.max);+    paint_link_status(&p, status_rect, status, t);+    resp+}++/// A small caps-style section caption ("FILE INFORMATION").+fn link_caption(ui: &mut egui::Ui, text: &str, t: &Tokens) {+    ui.add(egui::Label::new(egui::RichText::new(text.to_uppercase()).font(crate::theme::semibold(10.0)).color(t.text_dim).extra_letter_spacing(0.6)));+}++/// The more-actions and right-click menu of a link.+fn link_menu(ui: &mut egui::Ui, lang: &str, has_path: bool, is_text: bool, can_reveal: bool, action: &mut Option<LinkAction>) {+    let tr = |s: &'static str| crate::i18n::tr(lang, s);+    let mut item = |ui: &mut egui::Ui, enabled: bool, label: &str, a: LinkAction| {+        if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {+            *action = Some(a);+            ui.close();+        }+    };+    item(ui, true, tr("Go To"), LinkAction::GoTo);+    item(ui, true, tr("Update"), LinkAction::Update);+    item(ui, true, tr("Update All Links"), LinkAction::UpdateAll);+    ui.separator();+    item(ui, true, tr("Relink…"), LinkAction::Relink);+    item(ui, true, tr("Relink to Folder…"), LinkAction::RelinkFolder);+    ui.separator();+    item(ui, has_path && can_reveal, tr("Show in Folder"), LinkAction::Reveal);+    item(ui, has_path, tr("Copy Path"), LinkAction::CopyPath);+    ui.separator();+    item(ui, has_path, tr(if is_text { "Unlink" } else { "Embed" }), LinkAction::Embed);+}++/// Links: every placed graphic and linked text file with its type, page and status; the chosen+/// link's file information (status, pages, effective resolution, source path) and its actions:+/// Go To, Update, Relink, Show in Folder, Copy Path, Embed and Relink to Folder. pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) {     // Linked stories (Place and Link).     let stories = app.session.execute("story.links", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();@@ -2646,6 +2780,8 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) {     }     let Some(st) = app.session.active() else { return };     let t = Tokens::get(ui.ctx());+    let lang = app.ui.language.clone();+    let tr = |s: &'static str| crate::i18n::tr(&lang, s);     // The list checks files on disk: refresh it at most twice a second.     let key = egui::Id::unique("links_panel");     let now = ui.input(|i| i.time);@@ -2660,145 +2796,252 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) {         }     };     if rows.is_empty() {-        crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No placed graphics.")).color(t.text_dim));+        crate::rtl::label(ui, egui::RichText::new(tr("No placed graphics.")).color(t.text_dim));         return;     }-    let sel_key = egui::Id::unique("links_panel_sel");-    let mut chosen: Option<u64> = ui.data(|d| d.get_temp(sel_key));-    // Leave room for Status and Page/PPI instead of widening the grid for long source paths.-    let name_width = (ui.available_width() - 160.0).max(50.0);-    egui::Grid::new("links_grid").num_columns(3).striped(true).spacing([10.0, 4.0]).show(ui, |ui| {-        crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Name")).color(t.text_dim).size(11.0));-        crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Status")).color(t.text_dim).size(11.0));-        crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page · PPI")).color(t.text_dim).size(11.0));-        ui.end_row();-        for r in &rows {-            // Asset and story ids come from one counter: a row is chosen by either.-            let aid = r["asset"].as_u64().or_else(|| r["story"].as_u64()).unwrap_or(0);-            let name = r["name"].as_str().unwrap_or("");-            let response = ui-                .vertical(|ui| {-                    ui.set_width(name_width);-                    let name = ui.add(egui::Button::selectable(chosen == Some(aid), name).truncate());-                    match r["path"].as_str().filter(|path| !path.is_empty()) {-                        Some(path) => name.union(-                            ui.add(-                                egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0))-                                    .truncate()-                                    .sense(Sense::click())-                                    .show_tooltip_when_elided(false),-                            )-                            .on_hover_text(path),-                        ),-                        None => name,-                    }-                })-                .inner;-            if response.clicked() {-                chosen = Some(aid);-            }-            let (txt, col) = match r["status"].as_str().unwrap_or("") {-                "missing" => ("\u{26A0} Missing", egui::Color32::from_rgb(0xe5, 0x4b, 0x4b)),-                "modified" => ("\u{25B2} Modified", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)),-                "embedded" => ("Embedded", t.text_dim),-                "tooLarge" => ("Too large to check", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)),-                _ => ("OK", t.text_dim),-            };-            let status = crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0));-            // Why a link isn't followed (a path on another computer, a device).-            if let Some(why) = r["refused"].as_str() {-                status.on_hover_text(why);++    // Summary: how many files, how many need attention; the search field filters by name or path.+    let filter_key = egui::Id::unique("links_panel_filter");+    let (mut searching, mut filter): (bool, String) = ui.data(|d| d.get_temp(filter_key)).unwrap_or_default();+    let count = |s: &str| rows.iter().filter(|r| r["status"] == s).count();+    let (modified, missing) = (count("modified"), count("missing"));+    ui.horizontal(|ui| {+        ui.set_min_height(24.0);+        ui.label(egui::RichText::new(tr("{count} files").replace("{count}", &rows.len().to_string())).size(12.0).color(t.text));+        for (n, icon, text, status) in [(modified, "warning", "{count} modified", "modified"), (missing, "link-broken", "{count} missing", "missing")]+        {+            if n > 0 {+                ui.add_space(6.0);+                let c = link_status_color(status, &t);+                let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());+                crate::icons::paint(ui.painter(), r, icon, c);+                ui.label(egui::RichText::new(tr(text).replace("{count}", &n.to_string())).size(12.0).color(c));             }-            let uses = r["uses"].as_array().cloned().unwrap_or_default();-            let info = match uses.first() {-                Some(u) => {-                    let page = u["page"].as_str().unwrap_or("PB");-                    let ppi = u["ppi"].as_f64().map(|p| format!(" · {p:.0} ppi")).unwrap_or_default();-                    let more = if uses.len() > 1 { format!(" (+{})", uses.len() - 1) } else { String::new() };-                    format!("{page}{ppi}{more}")+        }+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+            if widgets::icon_toggle(ui, "search", searching, tr("Search")).clicked() {+                searching = !searching;+                if !searching {+                    filter.clear();                 }-                None => crate::i18n::tr(&app.ui.language, "unused").into(),+            }+        });+    });+    if searching {+        let edit = ui.add(egui::TextEdit::singleline(&mut filter).hint_text(tr("Name or folder")).desired_width(f32::INFINITY));+        if !ui.memory(|m| m.has_focus(edit.id)) && filter.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {+            searching = false;+        }+    }+    ui.data_mut(|d| d.insert_temp(filter_key, (searching, filter.clone())));+    let needle = filter.to_lowercase();+    let shown: Vec<&Value> = rows+        .iter()+        .filter(|r| needle.is_empty() || [&r["name"], &r["path"]].iter().any(|v| v.as_str().is_some_and(|s| s.to_lowercase().contains(&needle))))+        .collect();++    // The chosen link: the last one clicked, else the first.+    let sel_key = egui::Id::unique("links_panel_sel");+    let chosen: Option<u64> = ui.data(|d| d.get_temp(sel_key));+    let mut chosen = chosen.filter(|c| rows.iter().any(|r| link_id(r) == *c)).or_else(|| rows.first().map(link_id));+    let mut action: Option<LinkAction> = None;+    let can_reveal = app.services.reveal.is_some();++    // Column headings.+    ui.add_space(2.0);+    let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover());+    let head_font = egui::FontId::proportional(11.0);+    let status_x = head.max.x - LINK_STATUS_W;+    ui.painter().text(egui::pos2(head.min.x + 8.0, head.center().y), egui::Align2::LEFT_CENTER, tr("File"), head_font.clone(), t.text_dim);+    ui.painter().text(+        egui::pos2(status_x - LINK_PAGE_W / 2.0, head.center().y),+        egui::Align2::CENTER_CENTER,+        tr("Page"),+        head_font.clone(),+        t.text_dim,+    );+    ui.painter().text(egui::pos2(status_x + LINK_STATUS_W / 2.0, head.center().y), egui::Align2::CENTER_CENTER, tr("Status"), head_font, t.text_dim);+    ui.painter().line_segment([head.left_bottom(), head.right_bottom()], egui::Stroke::new(1.0, t.divider));++    egui::ScrollArea::vertical().id_salt("links_rows").max_height(LINK_ROW_H * 6.5).auto_shrink([false, true]).show(ui, |ui| {+        ui.spacing_mut().item_spacing.y = 0.0;+        if shown.is_empty() {+            ui.add_space(6.0);+            ui.label(egui::RichText::new(tr("No links match.")).size(11.5).color(t.text_dim));+        }+        for r in &shown {+            let id = link_id(r);+            let resp = link_row(ui, r, chosen == Some(id), &t);+            let resp = match r["path"].as_str().filter(|p| !p.is_empty()) {+                Some(path) => resp.on_hover_text(path),+                None => resp,             };-            // Low effective resolution for print is flagged like the Preflight check.-            let low = uses.first().and_then(|u| u["ppi"].as_f64()).is_some_and(|p| p < 150.0);-            crate::rtl::label(-                ui,-                egui::RichText::new(info).size(11.0).color(if low { egui::Color32::from_rgb(0xe8, 0xb3, 0x2c) } else { t.text_dim }),-            );-            ui.end_row();+            if resp.clicked() || resp.secondary_clicked() {+                chosen = Some(id);+            }+            if resp.double_clicked() {+                action = Some(LinkAction::GoTo);+            }+            let has_path = r["path"].as_str().is_some_and(|p| !p.is_empty());+            let is_text = r["story"].as_u64().is_some();+            egui::Popup::context_menu(&resp).show(|ui| link_menu(ui, &lang, has_path, is_text, can_reveal, &mut action));         }     });     if let Some(c) = chosen {         ui.data_mut(|d| d.insert_temp(sel_key, c));     }-    ui.add_space(6.0);-    if let Some(path) = rows-        .iter()-        .find(|r| r["asset"].as_u64().or_else(|| r["story"].as_u64()) == chosen)-        .and_then(|r| r["path"].as_str())-        .filter(|path| !path.is_empty())-    {-        // A selected link stays distinguishable even if two rows truncate to the same prefix.-        ui.add(egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0)).wrap());-        ui.add_space(6.0);+    let Some(aid) = chosen else { return };+    let Some(row) = rows.iter().find(|r| link_id(r) == aid).cloned() else { return };++    // File information.+    let name = row["name"].as_str().unwrap_or("");+    let status = row["status"].as_str().unwrap_or("");+    let path = row["path"].as_str().filter(|p| !p.is_empty()).map(str::to_string);+    // A linked text file is addressed by its story.+    let is_text = row["story"].as_u64().is_some();+    ui.add_space(10.0);+    link_caption(ui, tr("File Information"), &t);+    ui.add_space(4.0);+    ui.add(egui::Label::new(egui::RichText::new(name).font(crate::theme::semibold(13.0)).color(t.text_strong)).wrap());+    ui.add_space(2.0);+    let status_text = match status {+        "missing" => tr("Missing: moved, renamed or deleted"),+        "modified" => tr("Changed since it was placed"),+        "embedded" => tr("Embedded in the document"),+        "tooLarge" => tr("Too large to check"),+        _ => tr("Up to date"),+    };+    ui.horizontal(|ui| {+        let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());+        paint_link_status(ui.painter(), r, status, &t);+        ui.label(egui::RichText::new(status_text).size(12.0).color(link_status_color(status, &t)));+    });+    // Why a link isn't followed (a path on another computer, a device).+    if let Some(why) = row["refused"].as_str() {+        ui.add(egui::Label::new(egui::RichText::new(why).size(11.0).color(t.text_dim)).wrap());     }-    ui.horizontal_wrapped(|ui| {-        let Some(aid) = chosen else {-            crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Choose a link.")).color(t.text_dim).size(11.0));-            return;-        };-        // A linked text file is addressed by its story.-        let is_text = rows.iter().any(|r| r["story"].as_u64() == Some(aid));-        let target = if is_text { json!({"story": aid}) } else { json!({"asset": aid}) };-        let with = |k: &str, v: Value| {-            let mut p = target.clone();-            p[k] = v;-            p+    if is_text && row["edited"].as_bool() == Some(true) {+        ui.label(egui::RichText::new(tr("Edited in the layout")).size(11.0).color(t.text_dim));+    }+    let uses = row["uses"].as_array().cloned().unwrap_or_default();+    let pages: Vec<&str> = uses.iter().map(|u| u["page"].as_str().unwrap_or("PB")).collect();+    let ppi = uses.first().and_then(|u| u["ppi"].as_f64());+    let pixels = row["pixels"].as_array().and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?)));+    ui.add_space(4.0);+    egui::Grid::new("links_info").num_columns(2).spacing([12.0, 2.0]).show(ui, |ui| {+        let cap = |ui: &mut egui::Ui, s: &str| {+            ui.label(egui::RichText::new(s).size(11.0).color(t.text_dim));         };-        if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked()-            && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink"))-        {-            let _ = app.run("links.relink", with("path", json!(path)));+        cap(ui, tr("Page"));+        ui.label(egui::RichText::new(if pages.is_empty() { tr("unused").to_string() } else { pages.join(", ") }).size(11.0));+        ui.end_row();+        if let Some(ppi) = ppi {+            cap(ui, tr("Effective PPI"));+            // Low effective resolution for print is flagged like the Preflight check.+            let low = ppi < 150.0;+            let text = if low { format!("{ppi:.0} · {}", tr("low for print")) } else { format!("{ppi:.0}") };+            ui.label(egui::RichText::new(text).size(11.0).color(if low { link_status_color("modified", &t) } else { t.text }));+            ui.end_row();         }-        if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Go To"))).clicked() {-            let _ = app.run("links.goTo", target.clone());+        if let Some((w, h)) = pixels {+            cap(ui, tr("Pixels"));+            ui.label(egui::RichText::new(format!("{w} × {h}")).size(11.0));+            ui.end_row();         }-        let edited = rows.iter().any(|r| r["story"].as_u64() == Some(aid) && r["edited"].as_bool() == Some(true));-        let update = ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update")));-        let update = if edited {-            update.on_hover_ui(|ui| {-                crate::rtl::label(-                    ui,-                    crate::i18n::tr(&app.ui.language, "This story was edited in the layout: you'll be asked before those edits are replaced"),-                );-            })+    });+    if let Some(path) = &path {+        ui.add_space(6.0);+        ui.horizontal_top(|ui| {+            let (r, _) = ui.allocate_exact_size(vec2(14.0, 16.0), Sense::hover());+            crate::icons::paint(ui.painter(), r, "chain", t.text_dim);+            // A selected link stays distinguishable even if two rows truncate to the same prefix.+            let w = (ui.available_width() - 2.0 * 27.0).max(40.0);+            ui.allocate_ui(vec2(w, 16.0), |ui| {+                ui.add(egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0)).wrap().selectable(true));+            });+            ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {+                ui.add_enabled_ui(can_reveal, |ui| {+                    if widgets::icon_toggle(ui, "folder-open", false, tr("Show in Folder")).clicked() {+                        action = Some(LinkAction::Reveal);+                    }+                });+                if widgets::icon_toggle(ui, "copy", false, tr("Copy Path")).clicked() {+                    action = Some(LinkAction::CopyPath);+                }+            });+        });+    }++    // Actions.+    ui.add_space(8.0);+    widgets::rule(ui);+    ui.add_space(8.0);+    ui.horizontal(|ui| {+        if widgets::icon_text_button(ui, "go-to", tr("Go To")).clicked() {+            action = Some(LinkAction::GoTo);+        }+        let update = widgets::icon_text_button(ui, "rotate-clockwise", tr("Update"));+        let update = if is_text && row["edited"].as_bool() == Some(true) {+            update.on_hover_text(tr("This story was edited in the layout: you'll be asked before those edits are replaced"))         } else {             update         };         if update.clicked() {-            update_links(app, target.clone());-        }-        if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if is_text { "Unlink" } else { "Embed" }))).clicked() {-            let _ = app.run("links.embed", target.clone());+            action = Some(LinkAction::Update);         }+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+            let more = widgets::icon_frame_button(ui, "more", tr("More"));+            egui::Popup::menu(&more).show(|ui| link_menu(ui, &lang, path.is_some(), is_text, can_reveal, &mut action));+            ui.add_enabled_ui(can_reveal && path.is_some(), |ui| {+                if widgets::icon_frame_button(ui, "folder-open", tr("Show in Folder")).clicked() {+                    action = Some(LinkAction::Reveal);+                }+            });+            if widgets::icon_frame_button(ui, "chain", tr("Relink…")).clicked() {+                action = Some(LinkAction::Relink);+            }+        });     });-    ui.horizontal(|ui| {++    let Some(action) = action else { return };+    let target = if is_text { json!({"story": aid}) } else { json!({"asset": aid}) };+    match action {+        LinkAction::GoTo => {+            let _ = app.run("links.goTo", target);+        }+        LinkAction::Update => update_links(app, target),+        LinkAction::UpdateAll => update_links(app, json!({})),+        LinkAction::Relink => {+            if let Some(file) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) {+                let mut p = target;+                p["path"] = json!(file);+                let _ = app.run("links.relink", p);+            }+        }         // Choose any file in the folder: missing links are looked up there by name.-        if ui-            .button(crate::i18n::tr(&app.ui.language, "Relink to Folder…"))-            .on_hover_ui(|ui| {-                crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pick a file in the folder holding the moved graphics"));-            })-            .clicked()-            && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink"))-            && let Some(dir) = std::path::Path::new(&path).parent()-        {-            let r = app.run("links.relinkFolder", json!({"dir": dir.to_string_lossy()}));-            if let Ok(v) = r {+        LinkAction::RelinkFolder => {+            if let Some(file) = app.services.pick_open.as_mut().and_then(|pick| pick("relink"))+                && let Some(dir) = std::path::Path::new(&file).parent()+                && let Ok(v) = app.run("links.relinkFolder", json!({"dir": dir.to_string_lossy()}))+            {                 app.status(crate::i18n::tr(&app.ui.language, "Relinked {count}").replace("{count}", &v["relinked"].to_string()));             }         }-    });+        LinkAction::Reveal => {+            if let Err(e) = app.run("links.reveal", target) {+                app.status(e);+            }+        }+        LinkAction::CopyPath => {+            if let Some(path) = path {+                ui.ctx().copy_text(path);+                app.status(crate::i18n::tr(&app.ui.language, "Path copied").to_string());+            }+        }+        LinkAction::Embed => {+            let _ = app.run("links.embed", target);+        }+    } }  /// Update Link from the Links panel or the menu (`{asset}`, `{story}`, or `{}` for all): asks@@ -2821,6 +3064,27 @@ pub fn update_links(app: &mut DesignApp, target: Value) {     } } +/// Show in Folder (`{asset}` or `{story}`): the linked file in the system file manager.+pub(crate) fn reveal_link(app: &mut DesignApp, p: &Value) -> Result<Value, String> {+    let asset = p.get("asset").and_then(Value::as_u64);+    let story = p.get("story").and_then(Value::as_u64);+    if asset.is_none() && story.is_none() {+        return Err("give the link's asset or story".into());+    }+    let rows = app.session.execute("links.list", &json!({})).map_err(|e| e.to_string())?;+    let row = rows+        .as_array()+        .and_then(|rows| rows.iter().find(|r| (asset.is_some() && r["asset"].as_u64() == asset) || (story.is_some() && r["story"].as_u64() == story)))+        .ok_or("no such link")?;+    let path = row["path"].as_str().filter(|path| !path.is_empty()).ok_or("this graphic is embedded: it has no file")?.to_string();+    // A relative link names a file next to the document: the file manager needs the full path.+    let document = app.session.doc().ok().and_then(|d| d.path.clone());+    let file = designcraft_engine::io::document_path(&path, document.as_deref().map(std::path::Path::new)).map_err(|e| e.to_string())?;+    let reveal = app.services.reveal.as_mut().ok_or("Show in Folder isn't available here")?;+    reveal(&file.to_string_lossy())?;+    Ok(json!({"path": path}))+}+ /// Run `links.update`; its first warning goes to the status bar. pub(crate) fn run_update_links(app: &mut DesignApp, target: Value) -> Result<Value, String> {     let r = app.run("links.update", target)?;@@ -3347,27 +3611,50 @@ mod tests {         let _ = h.get_by_label("Edit Page");     } +    fn click(h: &mut Harness<'static, DesignApp>, at: egui::Pos2) {+        h.hover_at(at);+        h.drag_at(at);+        h.drop_at(at);+        h.run_steps(1);+    }++    /// The Links panel's rows, top to bottom (each is named by its file).+    fn link_rows(h: &Harness<'static, DesignApp>, name: &str) -> Vec<egui::Rect> {+        let mut rows: Vec<egui::Rect> = h.get_all_by_label(name).map(|n| n.rect()).filter(|r| (r.height() - LINK_ROW_H).abs() < 0.5).collect();+        rows.sort_by(|a, b| a.top().total_cmp(&b.top()));+        rows+    }+     #[test]     fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() {         let first = "/project/assets/studio-a/photo.svg";         let second = "/project/assets/studio-b/photo.svg";         let (app, items, assets) = linked_graphics(&[Some(first), Some(second), None]);         let mut h = links_harness(app, 320.0);-        assert_eq!(h.get_all_by_label("photo.svg").count(), 3);-        assert!(h.query_by_label(first).is_some(), "the first source path is visible in the row");-        h.get_by_label(second).click();+        let rows = link_rows(&h, "photo.svg");+        assert_eq!(rows.len(), 3, "one row per graphic");+        // The first link is chosen at first: File Information shows its source.+        assert!(h.query_by_label(first).is_some());+        h.get_by_label("2 missing");+        click(&mut h, rows[1].center());         h.run_steps(3);+        assert!(h.query_by_label(second).is_some(), "the chosen link's source path is shown");+        assert!(h.query_by_label(first).is_none());         h.get_by_label("Go To").click();         h.run_steps(3);         assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[1]]);+        h.get_by_label("More").click();+        h.run_steps(3);         h.get_by_label("Embed").click();         h.run_steps(3);         assert!(h.state().session.active().unwrap().doc.assets[&assets[1]].link.is_none());         assert_eq!(h.state().session.active().unwrap().doc.assets[&assets[0]].link.as_deref(), Some(first));         assert!(h.query_by_label(second).is_none(), "an embedded graphic no longer shows a source path");-        assert_eq!(h.get_all_by_label("Embedded").count(), 2);-        assert_eq!(h.get_all_by_label("photo.svg").count(), 3);-        h.get_by_label(first).click();+        h.get_by_label("Embedded in the document");+        h.get_by_label("1 missing");+        let rows = link_rows(&h, "photo.svg");+        assert_eq!(rows.len(), 3);+        click(&mut h, rows[0].center());         h.run_steps(3);         h.get_by_label("Go To").click();         h.run_steps(3);@@ -3378,14 +3665,84 @@ mod tests {     fn links_panel_keeps_long_source_paths_inside_a_narrow_panel() {         let path = "/project/assets/studio-a/very-long-folder-name/another-long-folder-name/photo.svg";         let (app, _, _) = linked_graphics(&[Some(path)]);-        let mut h = links_harness(app, 240.0);-        let source = h.get_by_label(path).rect();-        assert!(source.width() < 150.0, "the source is truncated to its column: {source:?}");-        assert!(source.right() <= 240.0, "the source stays inside the narrow panel: {source:?}");-        assert!(h.get_by_label("Page · PPI").rect().right() <= 240.0);-        h.get_by_label(path).click();+        let h = links_harness(app, 240.0);+        for row in link_rows(&h, "photo.svg") {+            assert!(row.right() <= 240.0, "the row stays inside the narrow panel: {row:?}");+        }+        assert!(h.get_by_label("Go To").rect().right() <= 240.0);+        assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(text) if text.galley.job.text == path && !text.galley.elided && text.visual_bounding_rect().right() <= 240.0)), "the chosen link's path is painted in full and wraps inside the panel");+    }++    /// Show in Folder hands the chosen link's file to the host (the file manager); an embedded+    /// graphic has no file, and without the host service (the web) it's not offered.+    #[test]+    fn show_in_folder_hands_the_linked_file_to_the_host() {+        let path = "/project/assets/photo.svg";+        let (mut app, _, assets) = linked_graphics(&[Some(path), None]);+        let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));+        let log = shown.clone();+        app.services.reveal = Some(Box::new(move |p: &str| {+            log.lock().unwrap().push(p.to_string());+            Ok(())+        }));+        assert_eq!(app.run("links.reveal", json!({"asset": assets[0].0})).unwrap()["path"], path);+        assert!(app.run("links.reveal", json!({"asset": assets[1].0})).unwrap_err().contains("embedded"));+        assert!(app.run("links.reveal", json!({})).is_err(), "a link must be named");+        let mut h = links_harness(app, 320.0);+        let buttons: Vec<egui::Rect> = h.get_all_by_label("Show in Folder").map(|n| n.rect()).collect();+        assert_eq!(buttons.len(), 2, "beside the path and in the action bar");+        click(&mut h, buttons[0].center());+        h.run_steps(3);+        assert_eq!(*shown.lock().unwrap(), vec![path.to_string(), path.to_string()]);+        h.state_mut().services.reveal = None;+        assert!(h.state_mut().run("links.reveal", json!({"asset": assets[0].0})).unwrap_err().contains("isn't available"));+    }++    /// A relative link is resolved against the document's file, as reading it is: the host gets+    /// the full path, the panel keeps the stored one.+    #[test]+    fn show_in_folder_resolves_a_relative_link_against_the_document() {+        let (mut app, _, assets) = linked_graphics(&[Some("assets/photo.svg")]);+        app.session.doc_mut().unwrap().path = Some("/project/brochure.designcraft".into());+        let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));+        let log = shown.clone();+        app.services.reveal = Some(Box::new(move |p: &str| {+            log.lock().unwrap().push(p.to_string());+            Ok(())+        }));+        assert_eq!(app.run("links.reveal", json!({"asset": assets[0].0})).unwrap()["path"], "assets/photo.svg");+        assert_eq!(*shown.lock().unwrap(), vec!["/project/assets/photo.svg".to_string()]);+        // Without a document file there is nothing to resolve it against.+        app.session.doc_mut().unwrap().path = None;+        assert!(app.run("links.reveal", json!({"asset": assets[0].0})).is_err());+        assert_eq!(shown.lock().unwrap().len(), 1, "nothing handed to the host");+    }++    #[test]+    fn copy_path_puts_the_source_on_the_clipboard() {+        let path = "/project/assets/photo.svg";+        let (app, _, _) = linked_graphics(&[Some(path)]);+        let mut h = links_harness(app, 320.0);+        h.get_by_label("Copy Path").click();+        h.step();+        assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == path)));+    }++    #[test]+    fn the_search_field_filters_links_by_name_or_folder() {+        let (app, _, _) = linked_graphics(&[Some("/a/studio-a/photo.svg"), Some("/a/studio-b/photo.svg")]);+        let mut h = links_harness(app, 320.0);+        assert_eq!(link_rows(&h, "photo.svg").len(), 2);+        h.get_by_label("Search").click();+        h.run_steps(3);+        h.get_by_role(egui::accesskit::Role::TextInput).focus();+        h.run_steps(1);+        h.event(egui::Event::Text("studio-b".into()));+        h.run_steps(3);+        assert_eq!(link_rows(&h, "photo.svg").len(), 1, "only the link in studio-b");+        h.event(egui::Event::Text("zzz".into()));         h.run_steps(3);-        assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(text) if text.galley.job.text == path && !text.galley.elided && text.visual_bounding_rect().right() <= 240.0)), "the selected path is painted in full and wraps inside the panel");+        h.get_by_label("No links match.");     }      #[test]diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs--- a/crates/ui-egui/src/widgets.rs+++ b/crates/ui-egui/src/widgets.rs@@ -605,6 +605,33 @@ pub fn swatch_colors(doc: &designcraft_doc::Document, name: &str, tint: f32) -> }  /// A 21 pt icon toggle (active = `#303030` well with a 1 pt rim).+/// A push button with a painted icon before its text (the Links panel's Go To and Update).+pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str) -> Response {+    let t = Tokens::get(ui.ctx());+    let enabled = ui.is_enabled();+    let color = if enabled { t.text } else { t.text_disabled };+    let galley = ui.painter().layout_no_wrap(text.to_owned(), egui::FontId::proportional(12.0), color);+    let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 26.0), Sense::click());+    let visuals = ui.style().interact(&resp);+    ui.painter().rect(r, f32::from(t.radius), visuals.weak_bg_fill, visuals.bg_stroke, StrokeKind::Inside);+    crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 15.0, r.center().y), vec2(14.0, 14.0)), icon, color);+    ui.painter().galley(pos2(r.min.x + 27.0, r.center().y - galley.size().y / 2.0), galley, color);+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, text));+    resp+}++/// A framed square push button with a painted icon, named and explained by `tip`.+pub fn icon_frame_button(ui: &mut Ui, icon: &str, tip: &str) -> Response {+    let t = Tokens::get(ui.ctx());+    let enabled = ui.is_enabled();+    let (r, resp) = ui.allocate_exact_size(vec2(28.0, 26.0), Sense::click());+    let visuals = ui.style().interact(&resp);+    ui.painter().rect(r, f32::from(t.radius), visuals.weak_bg_fill, visuals.bg_stroke, StrokeKind::Inside);+    crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(15.0, 15.0)), icon, if enabled { t.text } else { t.text_disabled });+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));+    resp.on_hover_text(tip)+}+ pub fn icon_toggle(ui: &mut Ui, icon: &str, on: bool, tip: &str) -> Response {     icon_toggle_sized(ui, icon, on, tip, vec2(23.0, 21.0)) }