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.rs
new 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` shape
diff --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))
 }
