diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json
--- a/crates/ui-egui/locales/cs-formats.json
+++ b/crates/ui-egui/locales/cs-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "Soubor {name} se nepodařilo přečíst: {e}",
   "Added {n} photo{}": "Přidáno fotografií: {n}{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Kam má jít katalog Lightroomu „{catalog}“?",
-  "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Sloučit s otevřenou knihovnou „{library}“ (fotografie: {count}{:.0})",
+  "Copied {n} file paths": "Zkopírované cesty k souborům: {n}"
 }
diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json
--- a/crates/ui-egui/locales/cs.json
+++ b/crates/ui-egui/locales/cs.json
@@ -2408,5 +2408,20 @@
   "Import dropped files without review": "Importovat přetažené soubory bez kontroly",
   "An import is running": "Import už běží",
   "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí",
-  "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky"
+  "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky",
+  "Open in Compare": "Otevřít v porovnání",
+  "Open in Survey": "Otevřít v přehledu",
+  "Edit Settings": "Nastavení úprav",
+  "Rotate & Flip": "Otočit a převrátit",
+  "Copy File Path": "Kopírovat cestu k souboru",
+  "Copied the file path": "Cesta k souboru zkopírována",
+  "The selected photos have no file on disk": "Vybrané fotografie nemají soubor na disku",
+  "Select 2 or more photos": "Vyberte 2 nebo více fotografií",
+  "Copy edit settings first": "Nejprve zkopírujte nastavení úprav",
+  "Copy metadata first": "Nejprve zkopírujte metadata",
+  "This version of LightCraft can't open the file manager": "Tato verze LightCraftu neumí otevřít správce souborů",
+  "This photo has no file on disk": "Tato fotografie nemá soubor na disku",
+  "This photo is not in a folder of the library": "Tato fotografie není ve složce knihovny",
+  "Opening other programs needs the desktop app": "Otevírání jiných programů vyžaduje desktopovou aplikaci",
+  "Only a photo file LightCraft reads can be opened this way": "Takto lze otevřít jen soubor fotografie, který LightCraft umí načíst"
 }
diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json
--- a/crates/ui-egui/locales/de-formats.json
+++ b/crates/ui-egui/locales/de-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "{name} konnte nicht gelesen werden: {e}",
   "Added {n} photo{}": "{n} Foto{} hinzugefügt",
   "Where should the Lightroom catalog “{catalog}” go?": "Wohin soll der Lightroom-Katalog „{catalog}“?",
-  "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})"
+  "Merge into the open library “{library}” ({count} photo{})": "Mit der geöffneten Bibliothek „{library}“ zusammenführen ({count} Foto{})",
+  "Copied {n} file paths": "{n} Dateipfade kopiert"
 }
diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json
--- a/crates/ui-egui/locales/de.json
+++ b/crates/ui-egui/locales/de.json
@@ -3440,5 +3440,20 @@
   "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren",
   "An import is running": "Ein Import läuft",
   "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist",
-  "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst"
+  "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst",
+  "Open in Compare": "In Vergleich öffnen",
+  "Open in Survey": "In Übersicht öffnen",
+  "Edit Settings": "Bearbeitungseinstellungen",
+  "Rotate & Flip": "Drehen und spiegeln",
+  "Copy File Path": "Dateipfad kopieren",
+  "Copied the file path": "Dateipfad kopiert",
+  "The selected photos have no file on disk": "Die ausgewählten Fotos haben keine Datei auf dem Datenträger",
+  "Select 2 or more photos": "Wähle 2 oder mehr Fotos aus",
+  "Copy edit settings first": "Kopiere zuerst Bearbeitungseinstellungen",
+  "Copy metadata first": "Kopiere zuerst Metadaten",
+  "This version of LightCraft can't open the file manager": "Diese Version von LightCraft kann den Dateimanager nicht öffnen",
+  "This photo has no file on disk": "Dieses Foto hat keine Datei auf dem Datenträger",
+  "This photo is not in a folder of the library": "Dieses Foto liegt in keinem Ordner der Bibliothek",
+  "Opening other programs needs the desktop app": "Andere Programme lassen sich nur in der Desktop-App öffnen",
+  "Only a photo file LightCraft reads can be opened this way": "So lässt sich nur eine Fotodatei öffnen, die LightCraft lesen kann"
 }
diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json
--- a/crates/ui-egui/locales/es-formats.json
+++ b/crates/ui-egui/locales/es-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "No se pudo leer {name}: {e}",
   "Added {n} photo{}": "{n} foto{0} añadida{0}",
   "Where should the Lightroom catalog “{catalog}” go?": "¿Dónde debe ir el catálogo de Lightroom “{catalog}”?",
-  "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})"
+  "Merge into the open library “{library}” ({count} photo{})": "Combinar con la biblioteca abierta “{library}” ({count} foto{})",
+  "Copied {n} file paths": "{n} rutas de archivo copiadas"
 }
diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json
--- a/crates/ui-egui/locales/es.json
+++ b/crates/ui-egui/locales/es.json
@@ -2358,5 +2358,20 @@
   "Import dropped files without review": "Importar los archivos soltados sin revisión",
   "An import is running": "Hay una importación en curso",
   "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine",
-  "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos"
+  "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos",
+  "Open in Compare": "Abrir en Comparar",
+  "Open in Survey": "Abrir en Encuesta",
+  "Edit Settings": "Ajustes de edición",
+  "Rotate & Flip": "Rotar y voltear",
+  "Copy File Path": "Copiar ruta del archivo",
+  "Copied the file path": "Ruta del archivo copiada",
+  "The selected photos have no file on disk": "Las fotos seleccionadas no tienen archivo en el disco",
+  "Select 2 or more photos": "Selecciona 2 o más fotos",
+  "Copy edit settings first": "Copia primero los ajustes de edición",
+  "Copy metadata first": "Copia primero los metadatos",
+  "This version of LightCraft can't open the file manager": "Esta versión de LightCraft no puede abrir el gestor de archivos",
+  "This photo has no file on disk": "Esta foto no tiene archivo en el disco",
+  "This photo is not in a folder of the library": "Esta foto no está en una carpeta de la biblioteca",
+  "Opening other programs needs the desktop app": "Abrir otros programas requiere la aplicación de escritorio",
+  "Only a photo file LightCraft reads can be opened this way": "Solo se puede abrir así un archivo de foto que LightCraft lea"
 }
diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json
--- a/crates/ui-egui/locales/fi-formats.json
+++ b/crates/ui-egui/locales/fi-formats.json
@@ -305,5 +305,6 @@
   "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}",
   "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",
-  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})",
+  "Copied {n} file paths": "Kopioituja tiedostopolkuja: {n}"
 }
diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json
--- a/crates/ui-egui/locales/fi.json
+++ b/crates/ui-egui/locales/fi.json
@@ -2327,5 +2327,20 @@
   "Drop to open the face model": "Pudota avataksesi kasvomallin",
   "Dropped Files": "Pudotetut tiedostot",
   "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",
-  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"
+  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",
+  "Open in Compare": "Avaa Vertaa-näkymässä",
+  "Open in Survey": "Avaa Vertailu-näkymässä",
+  "Edit Settings": "Muokkausasetukset",
+  "Rotate & Flip": "Kierrä ja käännä",
+  "Copy File Path": "Kopioi tiedostopolku",
+  "Copied the file path": "Tiedostopolku kopioitu",
+  "The selected photos have no file on disk": "Valituilla kuvilla ei ole tiedostoa levyllä",
+  "Select 2 or more photos": "Valitse vähintään 2 kuvaa",
+  "Copy edit settings first": "Kopioi ensin muokkausasetukset",
+  "Copy metadata first": "Kopioi ensin metatiedot",
+  "This version of LightCraft can't open the file manager": "Tämä LightCraftin versio ei voi avata tiedostonhallintaa",
+  "This photo has no file on disk": "Tällä kuvalla ei ole tiedostoa levyllä",
+  "This photo is not in a folder of the library": "Tämä kuva ei ole kirjaston kansiossa",
+  "Opening other programs needs the desktop app": "Muiden ohjelmien avaaminen vaatii työpöytäsovelluksen",
+  "Only a photo file LightCraft reads can be opened this way": "Näin voi avata vain kuvatiedoston, jonka LightCraft lukee"
 }
diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json
--- a/crates/ui-egui/locales/fr-formats.json
+++ b/crates/ui-egui/locales/fr-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "Impossible de lire {name} : {e}",
   "Added {n} photo{}": "{n} photo{} ajoutée(s)",
   "Where should the Lightroom catalog “{catalog}” go?": "Où importer le catalogue Lightroom « {catalog} » ?",
-  "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})"
+  "Merge into the open library “{library}” ({count} photo{})": "Fusionner avec la bibliothèque ouverte « {library} » ({count} photo{})",
+  "Copied {n} file paths": "{n} chemins de fichier copiés"
 }
diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json
--- a/crates/ui-egui/locales/fr.json
+++ b/crates/ui-egui/locales/fr.json
@@ -2390,5 +2390,20 @@
   "Checking recent libraries": "Vérification des bibliothèques récentes",
   "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte",
   "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages",
-  "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :"
+  "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :",
+  "Open in Compare": "Ouvrir dans Comparer",
+  "Open in Survey": "Ouvrir dans Vue multiple",
+  "Edit Settings": "Réglages de retouche",
+  "Rotate & Flip": "Pivoter et retourner",
+  "Copy File Path": "Copier le chemin du fichier",
+  "Copied the file path": "Chemin du fichier copié",
+  "The selected photos have no file on disk": "Les photos sélectionnées n’ont pas de fichier sur le disque",
+  "Select 2 or more photos": "Sélectionnez 2 photos ou plus",
+  "Copy edit settings first": "Copiez d’abord des réglages de retouche",
+  "Copy metadata first": "Copiez d’abord des métadonnées",
+  "This version of LightCraft can't open the file manager": "Cette version de LightCraft ne peut pas ouvrir le gestionnaire de fichiers",
+  "This photo has no file on disk": "Cette photo n’a pas de fichier sur le disque",
+  "This photo is not in a folder of the library": "Cette photo n’est dans aucun dossier de la bibliothèque",
+  "Opening other programs needs the desktop app": "Ouvrir d’autres programmes nécessite l’application de bureau",
+  "Only a photo file LightCraft reads can be opened this way": "Seul un fichier photo que LightCraft sait lire peut être ouvert ainsi"
 }
diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json
--- a/crates/ui-egui/locales/id-formats.json
+++ b/crates/ui-egui/locales/id-formats.json
@@ -305,5 +305,6 @@
   "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",
   "Inside: {names}": "Di dalam: {names}",
   "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",
-  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})",
+  "Copied {n} file paths": "{n} jalur file disalin"
 }
diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json
--- a/crates/ui-egui/locales/id.json
+++ b/crates/ui-egui/locales/id.json
@@ -2339,5 +2339,20 @@
   "Drop to open the face model": "Jatuhkan untuk membuka model wajah",
   "Dropped Files": "File yang dijatuhkan",
   "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",
-  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"
+  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",
+  "Open in Compare": "Buka di Bandingkan",
+  "Open in Survey": "Buka di Survei",
+  "Edit Settings": "Pengaturan edit",
+  "Rotate & Flip": "Putar dan balik",
+  "Copy File Path": "Salin jalur file",
+  "Copied the file path": "Jalur file disalin",
+  "The selected photos have no file on disk": "Foto yang dipilih tidak memiliki file di disk",
+  "Select 2 or more photos": "Pilih 2 foto atau lebih",
+  "Copy edit settings first": "Salin pengaturan edit terlebih dahulu",
+  "Copy metadata first": "Salin metadata terlebih dahulu",
+  "This version of LightCraft can't open the file manager": "Versi LightCraft ini tidak dapat membuka pengelola file",
+  "This photo has no file on disk": "Foto ini tidak memiliki file di disk",
+  "This photo is not in a folder of the library": "Foto ini tidak berada di folder pustaka",
+  "Opening other programs needs the desktop app": "Membuka program lain memerlukan aplikasi desktop",
+  "Only a photo file LightCraft reads can be opened this way": "Hanya file foto yang dibaca LightCraft yang dapat dibuka dengan cara ini"
 }
diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json
--- a/crates/ui-egui/locales/it-formats.json
+++ b/crates/ui-egui/locales/it-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "Impossibile leggere {name}: {e}",
   "Added {n} photo{}": "Foto aggiunte: {n}{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Dove importare il catalogo Lightroom “{catalog}”?",
-  "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Unisci alla libreria aperta “{library}” (foto: {count}{:.0})",
+  "Copied {n} file paths": "Percorsi dei file copiati: {n}"
 }
diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json
--- a/crates/ui-egui/locales/it.json
+++ b/crates/ui-egui/locales/it.json
@@ -2361,5 +2361,20 @@
   "Import dropped files without review": "Importa i file rilasciati senza revisione",
   "An import is running": "Un'importazione è in corso",
   "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita",
-  "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto"
+  "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto",
+  "Open in Compare": "Apri in Confronta",
+  "Open in Survey": "Apri in Panoramica",
+  "Edit Settings": "Impostazioni di modifica",
+  "Rotate & Flip": "Ruota e capovolgi",
+  "Copy File Path": "Copia percorso del file",
+  "Copied the file path": "Percorso del file copiato",
+  "The selected photos have no file on disk": "Le foto selezionate non hanno un file sul disco",
+  "Select 2 or more photos": "Seleziona 2 o più foto",
+  "Copy edit settings first": "Copia prima le impostazioni di modifica",
+  "Copy metadata first": "Copia prima i metadati",
+  "This version of LightCraft can't open the file manager": "Questa versione di LightCraft non può aprire il gestore file",
+  "This photo has no file on disk": "Questa foto non ha un file sul disco",
+  "This photo is not in a folder of the library": "Questa foto non è in una cartella della libreria",
+  "Opening other programs needs the desktop app": "Aprire altri programmi richiede l’app desktop",
+  "Only a photo file LightCraft reads can be opened this way": "Così si può aprire solo un file foto che LightCraft legge"
 }
diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json
--- a/crates/ui-egui/locales/ja-formats.json
+++ b/crates/ui-egui/locales/ja-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "{name} を読み取れませんでした: {e}",
   "Added {n} photo{}": "{n}枚を追加しました{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Lightroom カタログ「{catalog}」をどこに読み込みますか？",
-  "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合（写真{count}枚）{:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "開いているライブラリ「{library}」に統合（写真{count}枚）{:.0}",
+  "Copied {n} file paths": "{n} 件のファイルパスをコピーしました"
 }
diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json
--- a/crates/ui-egui/locales/ja.json
+++ b/crates/ui-egui/locales/ja.json
@@ -2356,5 +2356,20 @@
   "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む",
   "An import is running": "読み込み中です",
   "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",
-  "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください"
+  "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください",
+  "Open in Compare": "比較表示で開く",
+  "Open in Survey": "一覧比較で開く",
+  "Edit Settings": "編集設定",
+  "Rotate & Flip": "回転と反転",
+  "Copy File Path": "ファイルパスをコピー",
+  "Copied the file path": "ファイルパスをコピーしました",
+  "The selected photos have no file on disk": "選択した写真にはディスク上のファイルがありません",
+  "Select 2 or more photos": "2 枚以上の写真を選択してください",
+  "Copy edit settings first": "先に編集設定をコピーしてください",
+  "Copy metadata first": "先にメタデータをコピーしてください",
+  "This version of LightCraft can't open the file manager": "このバージョンの LightCraft はファイルマネージャーを開けません",
+  "This photo has no file on disk": "この写真にはディスク上のファイルがありません",
+  "This photo is not in a folder of the library": "この写真はライブラリのフォルダにありません",
+  "Opening other programs needs the desktop app": "ほかのプログラムを開くにはデスクトップ版が必要です",
+  "Only a photo file LightCraft reads can be opened this way": "この方法で開けるのは LightCraft が読み込める写真ファイルだけです"
 }
diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json
--- a/crates/ui-egui/locales/ka-formats.json
+++ b/crates/ui-egui/locales/ka-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "ვერ წაიკითხა {name}: {e}",
   "Added {n} photo{}": "დაემატა ფოტო: {n}{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "სად გადავიტანოთ Lightroom-ის კატალოგი „{catalog}“?",
-  "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "გაერთიანება ღია ბიბლიოთეკასთან „{library}“ (ფოტოები: {count}){:.0}",
+  "Copied {n} file paths": "დაკოპირებული ფაილის მისამართები: {n}"
 }
diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json
--- a/crates/ui-egui/locales/ka.json
+++ b/crates/ui-egui/locales/ka.json
@@ -2734,5 +2734,20 @@
   "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე",
   "An import is running": "იმპორტი მიმდინარეობს",
   "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",
-  "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი"
+  "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი",
+  "Open in Compare": "შედარებაში გახსნა",
+  "Open in Survey": "მიმოხილვაში გახსნა",
+  "Edit Settings": "რედაქტირების პარამეტრები",
+  "Rotate & Flip": "შემობრუნება და არეკვლა",
+  "Copy File Path": "ფაილის მისამართის კოპირება",
+  "Copied the file path": "ფაილის მისამართი დაკოპირდა",
+  "The selected photos have no file on disk": "არჩეულ ფოტოებს დისკზე ფაილი არ აქვთ",
+  "Select 2 or more photos": "აირჩიეთ 2 ან მეტი ფოტო",
+  "Copy edit settings first": "ჯერ დააკოპირეთ რედაქტირების პარამეტრები",
+  "Copy metadata first": "ჯერ დააკოპირეთ მეტამონაცემები",
+  "This version of LightCraft can't open the file manager": "LightCraft-ის ამ ვერსიას ფაილების მენეჯერის გახსნა არ შეუძლია",
+  "This photo has no file on disk": "ამ ფოტოს დისკზე ფაილი არ აქვს",
+  "This photo is not in a folder of the library": "ეს ფოტო ბიბლიოთეკის საქაღალდეში არ არის",
+  "Opening other programs needs the desktop app": "სხვა პროგრამების გასახსნელად საჭიროა სამუშაო მაგიდის აპი",
+  "Only a photo file LightCraft reads can be opened this way": "ასე მხოლოდ ისეთი ფოტოს ფაილი იხსნება, რომელსაც LightCraft კითხულობს"
 }
diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json
--- a/crates/ui-egui/locales/pl-formats.json
+++ b/crates/ui-egui/locales/pl-formats.json
@@ -305,5 +305,6 @@
   "Added {n} photo{}": "Dodano zdjęcia: {n}{:.0}",
   "Inside: {names}": "Wewnątrz: {names}",
   "Where should the Lightroom catalog “{catalog}” go?": "Dokąd zaimportować katalog Lightroom „{catalog}”?",
-  "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "Scal z otwartą biblioteką „{library}” (zdjęcia: {count}){:.0}",
+  "Copied {n} file paths": "Skopiowane ścieżki plików: {n}"
 }
diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json
--- a/crates/ui-egui/locales/pl.json
+++ b/crates/ui-egui/locales/pl.json
@@ -2283,5 +2283,20 @@
   "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu",
   "An import is running": "Import jest w toku",
   "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy",
-  "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia"
+  "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia",
+  "Open in Compare": "Otwórz w widoku porównania",
+  "Open in Survey": "Otwórz w widoku przeglądu",
+  "Edit Settings": "Ustawienia edycji",
+  "Rotate & Flip": "Obróć i odbij",
+  "Copy File Path": "Kopiuj ścieżkę pliku",
+  "Copied the file path": "Skopiowano ścieżkę pliku",
+  "The selected photos have no file on disk": "Wybrane zdjęcia nie mają pliku na dysku",
+  "Select 2 or more photos": "Wybierz co najmniej 2 zdjęcia",
+  "Copy edit settings first": "Najpierw skopiuj ustawienia edycji",
+  "Copy metadata first": "Najpierw skopiuj metadane",
+  "This version of LightCraft can't open the file manager": "Ta wersja LightCraft nie może otworzyć menedżera plików",
+  "This photo has no file on disk": "To zdjęcie nie ma pliku na dysku",
+  "This photo is not in a folder of the library": "To zdjęcie nie jest w folderze biblioteki",
+  "Opening other programs needs the desktop app": "Otwieranie innych programów wymaga aplikacji na komputer",
+  "Only a photo file LightCraft reads can be opened this way": "W ten sposób można otworzyć tylko plik zdjęcia, który LightCraft odczytuje"
 }
diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json
--- a/crates/ui-egui/locales/pt-br-formats.json
+++ b/crates/ui-egui/locales/pt-br-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "Não foi possível ler {name}: {e}",
   "Added {n} photo{}": "{n} foto{0} adicionada{0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Para onde vai o catálogo do Lightroom “{catalog}”?",
-  "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})"
+  "Merge into the open library “{library}” ({count} photo{})": "Mesclar na biblioteca aberta “{library}” ({count} foto{})",
+  "Copied {n} file paths": "{n} caminhos de arquivo copiados"
 }
diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json
--- a/crates/ui-egui/locales/pt-br.json
+++ b/crates/ui-egui/locales/pt-br.json
@@ -2202,5 +2202,20 @@
   "Import dropped files without review": "Importar arquivos soltos sem revisão",
   "An import is running": "Uma importação está em andamento",
   "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar",
-  "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos"
+  "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos",
+  "Open in Compare": "Abrir em Comparar",
+  "Open in Survey": "Abrir em Inspeção",
+  "Edit Settings": "Ajustes de edição",
+  "Rotate & Flip": "Girar e espelhar",
+  "Copy File Path": "Copiar caminho do arquivo",
+  "Copied the file path": "Caminho do arquivo copiado",
+  "The selected photos have no file on disk": "As fotos selecionadas não têm arquivo no disco",
+  "Select 2 or more photos": "Selecione 2 ou mais fotos",
+  "Copy edit settings first": "Copie primeiro os ajustes de edição",
+  "Copy metadata first": "Copie primeiro os metadados",
+  "This version of LightCraft can't open the file manager": "Esta versão do LightCraft não pode abrir o gerenciador de arquivos",
+  "This photo has no file on disk": "Esta foto não tem arquivo no disco",
+  "This photo is not in a folder of the library": "Esta foto não está em uma pasta da biblioteca",
+  "Opening other programs needs the desktop app": "Abrir outros programas requer o app para computador",
+  "Only a photo file LightCraft reads can be opened this way": "Só é possível abrir assim um arquivo de foto que o LightCraft lê"
 }
diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json
--- a/crates/ui-egui/locales/ru-formats.json
+++ b/crates/ui-egui/locales/ru-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "Не удалось прочитать {name}: {e}",
   "Added {n} photo{}": "Добавлено фото: {n}{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Куда импортировать каталог Lightroom «{catalog}»?",
-  "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "Объединить с открытой библиотекой «{library}» (фото: {count}){:.0}",
+  "Copied {n} file paths": "Скопировано путей к файлам: {n}"
 }
diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json
--- a/crates/ui-egui/locales/ru.json
+++ b/crates/ui-egui/locales/ru.json
@@ -2349,5 +2349,20 @@
   "Import dropped files without review": "Импортировать перетащенные файлы без проверки",
   "An import is running": "Импорт уже выполняется",
   "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится",
-  "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото"
+  "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото",
+  "Open in Compare": "Открыть в режиме «Сравнение»",
+  "Open in Survey": "Открыть в режиме «Обзор»",
+  "Edit Settings": "Правки",
+  "Rotate & Flip": "Поворот и отражение",
+  "Copy File Path": "Копировать путь к файлу",
+  "Copied the file path": "Путь к файлу скопирован",
+  "The selected photos have no file on disk": "У выбранных фото нет файла на диске",
+  "Select 2 or more photos": "Выберите 2 или больше фото",
+  "Copy edit settings first": "Сначала скопируйте правки",
+  "Copy metadata first": "Сначала скопируйте метаданные",
+  "This version of LightCraft can't open the file manager": "Эта версия LightCraft не может открыть файловый менеджер",
+  "This photo has no file on disk": "У этого фото нет файла на диске",
+  "This photo is not in a folder of the library": "Это фото не находится в папке библиотеки",
+  "Opening other programs needs the desktop app": "Открывать другие программы можно только в настольном приложении",
+  "Only a photo file LightCraft reads can be opened this way": "Так можно открыть только файл фото, который читает LightCraft"
 }
diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json
--- a/crates/ui-egui/locales/sv-formats.json
+++ b/crates/ui-egui/locales/sv-formats.json
@@ -305,5 +305,6 @@
   "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}",
   "Inside: {names}": "Inuti: {names}",
   "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",
-  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})",
+  "Copied {n} file paths": "Kopierade filsökvägar: {n}"
 }
diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json
--- a/crates/ui-egui/locales/sv.json
+++ b/crates/ui-egui/locales/sv.json
@@ -2429,5 +2429,20 @@
   "Drop to open the face model": "Släpp för att öppna ansiktsmodellen",
   "Dropped Files": "Släppta filer",
   "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",
-  "Import dropped files without review": "Importera släppta filer utan granskning"
+  "Import dropped files without review": "Importera släppta filer utan granskning",
+  "Open in Compare": "Öppna i Jämför",
+  "Open in Survey": "Öppna i Översikt",
+  "Edit Settings": "Redigeringsinställningar",
+  "Rotate & Flip": "Rotera och vänd",
+  "Copy File Path": "Kopiera filsökväg",
+  "Copied the file path": "Filsökvägen har kopierats",
+  "The selected photos have no file on disk": "De valda fotona har ingen fil på disken",
+  "Select 2 or more photos": "Välj 2 eller fler foton",
+  "Copy edit settings first": "Kopiera redigeringsinställningar först",
+  "Copy metadata first": "Kopiera metadata först",
+  "This version of LightCraft can't open the file manager": "Den här versionen av LightCraft kan inte öppna filhanteraren",
+  "This photo has no file on disk": "Det här fotot har ingen fil på disken",
+  "This photo is not in a folder of the library": "Det här fotot ligger inte i en mapp i biblioteket",
+  "Opening other programs needs the desktop app": "Att öppna andra program kräver skrivbordsappen",
+  "Only a photo file LightCraft reads can be opened this way": "Bara en fotofil som LightCraft läser kan öppnas så här"
 }
diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json
--- a/crates/ui-egui/locales/uk-formats.json
+++ b/crates/ui-egui/locales/uk-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "Не вдалося прочитати {name}: {e}",
   "Added {n} photo{}": "Додано фото: {n}{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Куди імпортувати каталог Lightroom «{catalog}»?",
-  "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "Об’єднати з відкритою бібліотекою «{library}» (фото: {count}){:.0}",
+  "Copied {n} file paths": "Скопійовано шляхів до файлів: {n}"
 }
diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json
--- a/crates/ui-egui/locales/uk.json
+++ b/crates/ui-egui/locales/uk.json
@@ -2354,5 +2354,20 @@
   "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду",
   "An import is running": "Імпорт уже виконується",
   "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться",
-  "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото"
+  "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото",
+  "Open in Compare": "Відкрити в порівнянні",
+  "Open in Survey": "Відкрити в огляді",
+  "Edit Settings": "Налаштування редагування",
+  "Rotate & Flip": "Поворот і віддзеркалення",
+  "Copy File Path": "Копіювати шлях до файлу",
+  "Copied the file path": "Шлях до файлу скопійовано",
+  "The selected photos have no file on disk": "Вибрані фото не мають файлу на диску",
+  "Select 2 or more photos": "Виберіть 2 або більше фото",
+  "Copy edit settings first": "Спершу скопіюйте налаштування редагування",
+  "Copy metadata first": "Спершу скопіюйте метадані",
+  "This version of LightCraft can't open the file manager": "Ця версія LightCraft не може відкрити файловий менеджер",
+  "This photo has no file on disk": "Це фото не має файлу на диску",
+  "This photo is not in a folder of the library": "Це фото не в теці бібліотеки",
+  "Opening other programs needs the desktop app": "Відкривати інші програми можна лише в застосунку для комп’ютера",
+  "Only a photo file LightCraft reads can be opened this way": "Так можна відкрити лише файл фото, який читає LightCraft"
 }
diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json
--- a/crates/ui-egui/locales/vi-formats.json
+++ b/crates/ui-egui/locales/vi-formats.json
@@ -305,5 +305,6 @@
   "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}",
   "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",
-  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"
+  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})",
+  "Copied {n} file paths": "Đã sao chép {n} đường dẫn tệp"
 }
diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json
--- a/crates/ui-egui/locales/vi.json
+++ b/crates/ui-egui/locales/vi.json
@@ -2235,5 +2235,20 @@
   "Drop to open the face model": "Thả để mở mô hình khuôn mặt",
   "Dropped Files": "Tệp đã thả",
   "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",
-  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"
+  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại",
+  "Open in Compare": "Mở so sánh",
+  "Open in Survey": "Mở khảo sát",
+  "Edit Settings": "Cài đặt chỉnh sửa",
+  "Rotate & Flip": "Xoay và lật",
+  "Copy File Path": "Sao chép đường dẫn tệp",
+  "Copied the file path": "Đã sao chép đường dẫn tệp",
+  "The selected photos have no file on disk": "Các ảnh đã chọn không có tệp trên đĩa",
+  "Select 2 or more photos": "Chọn từ 2 ảnh trở lên",
+  "Copy edit settings first": "Hãy sao chép cài đặt chỉnh sửa trước",
+  "Copy metadata first": "Hãy sao chép siêu dữ liệu trước",
+  "This version of LightCraft can't open the file manager": "Phiên bản LightCraft này không mở được trình quản lý tệp",
+  "This photo has no file on disk": "Ảnh này không có tệp trên đĩa",
+  "This photo is not in a folder of the library": "Ảnh này không nằm trong thư mục của thư viện",
+  "Opening other programs needs the desktop app": "Mở chương trình khác cần ứng dụng máy tính",
+  "Only a photo file LightCraft reads can be opened this way": "Chỉ mở được theo cách này tệp ảnh mà LightCraft đọc được"
 }
diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json
--- a/crates/ui-egui/locales/zh-hans-formats.json
+++ b/crates/ui-egui/locales/zh-hans-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "无法读取 {name}：{e}",
   "Added {n} photo{}": "已添加 {n} 张照片{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目录“{catalog}”要导入到哪里？",
-  "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”（{count} 张照片）{:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "合并到当前打开的图库“{library}”（{count} 张照片）{:.0}",
+  "Copied {n} file paths": "已复制 {n} 个文件路径"
 }
diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json
--- a/crates/ui-egui/locales/zh-hans.json
+++ b/crates/ui-egui/locales/zh-hans.json
@@ -2200,5 +2200,20 @@
   "Import dropped files without review": "不经检查直接导入拖入的文件",
   "An import is running": "正在导入",
   "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片",
-  "Finish Lightroom catalog import before adding photos": "添加照片前，请先完成 Lightroom 目录的导入"
+  "Finish Lightroom catalog import before adding photos": "添加照片前，请先完成 Lightroom 目录的导入",
+  "Open in Compare": "在比较视图中打开",
+  "Open in Survey": "在筛选视图中打开",
+  "Edit Settings": "编辑设置",
+  "Rotate & Flip": "旋转与翻转",
+  "Copy File Path": "复制文件路径",
+  "Copied the file path": "已复制文件路径",
+  "The selected photos have no file on disk": "所选照片在磁盘上没有文件",
+  "Select 2 or more photos": "请选择 2 张或更多照片",
+  "Copy edit settings first": "请先复制编辑设置",
+  "Copy metadata first": "请先复制元数据",
+  "This version of LightCraft can't open the file manager": "此版本的 LightCraft 无法打开文件管理器",
+  "This photo has no file on disk": "这张照片在磁盘上没有文件",
+  "This photo is not in a folder of the library": "这张照片不在图库的文件夹中",
+  "Opening other programs needs the desktop app": "打开其他程序需要桌面版",
+  "Only a photo file LightCraft reads can be opened this way": "只有 LightCraft 能读取的照片文件才能这样打开"
 }
diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json
--- a/crates/ui-egui/locales/zh-hant-formats.json
+++ b/crates/ui-egui/locales/zh-hant-formats.json
@@ -305,5 +305,6 @@
   "Could not read {name}: {e}": "無法讀取 {name}：{e}",
   "Added {n} photo{}": "已加入 {n} 張照片{:.0}",
   "Where should the Lightroom catalog “{catalog}” go?": "Lightroom 目錄「{catalog}」要匯入到哪裡？",
-  "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」（{count} 張照片）{:.0}"
+  "Merge into the open library “{library}” ({count} photo{})": "合併到目前開啟的照片庫「{library}」（{count} 張照片）{:.0}",
+  "Copied {n} file paths": "已複製 {n} 個檔案路徑"
 }
diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json
--- a/crates/ui-egui/locales/zh-hant.json
+++ b/crates/ui-egui/locales/zh-hant.json
@@ -2157,5 +2157,20 @@
   "Import dropped files without review": "不經檢查直接匯入拖放的檔案",
   "An import is running": "正在匯入",
   "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片",
-  "Finish Lightroom catalog import before adding photos": "新增照片前，請先完成 Lightroom 目錄的匯入"
+  "Finish Lightroom catalog import before adding photos": "新增照片前，請先完成 Lightroom 目錄的匯入",
+  "Open in Compare": "在比較檢視中開啟",
+  "Open in Survey": "在總覽中開啟",
+  "Edit Settings": "編輯設定",
+  "Rotate & Flip": "旋轉與翻轉",
+  "Copy File Path": "複製檔案路徑",
+  "Copied the file path": "已複製檔案路徑",
+  "The selected photos have no file on disk": "所選照片在磁碟上沒有檔案",
+  "Select 2 or more photos": "請選取 2 張或更多照片",
+  "Copy edit settings first": "請先複製編輯設定",
+  "Copy metadata first": "請先複製中繼資料",
+  "This version of LightCraft can't open the file manager": "此版本的 LightCraft 無法開啟檔案管理員",
+  "This photo has no file on disk": "這張照片在磁碟上沒有檔案",
+  "This photo is not in a folder of the library": "這張照片不在照片庫的資料夾中",
+  "Opening other programs needs the desktop app": "開啟其他程式需要桌面版 App",
+  "Only a photo file LightCraft reads can be opened this way": "只有 LightCraft 能讀取的照片檔案才能這樣開啟"
 }
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
@@ -32,6 +32,7 @@ pub mod menus;
 pub mod merge;
 mod model_setup;
 pub mod panels;
+pub mod photo_menu;
 pub mod pick;
 pub mod print_view;
 pub mod progress;
diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs
--- a/crates/ui-egui/src/menubar.rs
+++ b/crates/ui-egui/src/menubar.rs
@@ -45,7 +45,7 @@ impl MenuNode {
 
 /// User-defined names in parameterized menus are data, not message keys.
 pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str {
-    if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet")
+    if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet" | "preset.apply")
         || (id == "app.export" && params.get("preset").is_some())
         || (id == "app.openLibrary" && params.get("path").is_some())
         || (id == "app.recentLibraries" && params.get("remove").is_some())
@@ -379,7 +379,7 @@ pub(crate) fn in_normal_album(app: &LightcraftApp) -> bool {
     }
 }
 
-fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {
+pub(crate) fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode {
     MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked }
 }
 
@@ -809,7 +809,7 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>>
 }
 
 /// Resolve a live menu row, including contextual keys and borrowed grid-cycle hints.
-fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {
+pub(crate) fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {
     // Shift+P picks and advances in Library; don't advertise it on Presets in those views.
     let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() };
     // in an album the Delete key removes from the album (issue #1805): the menu says so
@@ -824,7 +824,7 @@ fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode {
 }
 
 /// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus.
-fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {
+pub(crate) fn tidy(v: Vec<MenuNode>) -> Vec<MenuNode> {
     let mut out: Vec<MenuNode> = Vec::with_capacity(v.len());
     for n in v {
         let n = match n {
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
@@ -282,6 +282,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
     ("app.export", "Export Now", None, ""),
     ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
     ("app.showInFolders", "Show in Folders", None, "Photo"),
+    // the selected photos' file paths, one per line, on the clipboard
+    ("photo.copyPath", "Copy File Path", None, ""),
     ("app.openSamModelFolder", "Show in File Manager", None, ""),
     ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
     ("dialog.labelNames", "Edit Color Label Names…", None, ""),
@@ -1874,7 +1876,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
             r
         }
         "photo.locate" => {
-            let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
+            // `id`: the photo whose file is missing (a photo's menu names it), else the active one
+            let id = match photo_param(p, "photo.locate") {
+                Ok(id) => id.or(app.session.active()),
+                Err(e) => return Some(Err(e)),
+            };
+            let Some(id) = id else { return Some(Err("no photo selected".into())) };
+            if app.session.catalog.photo(id).is_none() {
+                return Some(Err(format!("photo.locate: no photo {}", id.0)));
+            }
             let path = match p.get("path").and_then(Value::as_str) {
                 Some(x) => Some(x.to_string()),
                 None => {
@@ -2263,8 +2273,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
             return Some(r);
         }
         "app.export" => crate::control::export_active(app, p),
-        "app.showInFinder" => show_in_finder(app),
-        "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)),
+        "app.showInFinder" => photo_param(p, id).and_then(|photo| show_in_finder(app, photo)),
+        "app.showInFolders" => photo_param(p, id).and_then(|photo| show_in_folders(app, photo)),
+        "photo.copyPath" => copy_file_paths(app, &ctx),
         "app.openLogFolder" => open_log_folder(app),
         "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => {
             let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE);
@@ -2311,15 +2322,16 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
                     _ => false,
                 })
         }
-        "app.showInFinder" => {
-            app.services.reveal.is_some()
-                && app
-                    .session
-                    .active()
-                    .and_then(|id| app.session.catalog.photo(id))
-                    .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
-        }
+        "app.showInFinder" => show_in_finder_enabled(app, None),
         "app.showInFolders" => show_in_folders_enabled(app, None),
+        // (asked every frame a menu is open: reads the selection in place, copies no path)
+        "photo.copyPath" => {
+            let on_disk = |id: &lightcraft_catalog::PhotoId| {
+                matches!(app.session.catalog.photo(*id).map(|p| &p.source), Some(lightcraft_engine::catalog::Source::File { .. }))
+            };
+            let sel = &app.session.selection;
+            if sel.ids.is_empty() { sel.active.as_ref().is_some_and(on_disk) } else { sel.ids.iter().any(on_disk) }
+        }
         "app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(),
         "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
         "file.exportMaskEffects" => !app.session.mask_effects.is_empty(),
@@ -2437,12 +2449,61 @@ fn open_log_folder(app: &mut LightcraftApp) -> Result<Value, String> {
     Ok(json!({"path": path}))
 }
 
-/// Reveal the active photo's original in the system file manager.
-fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
-    let id = app.session.active().ok_or("no photo selected")?;
+/// The selected photos' originals, each once (a virtual copy shares its original's file);
+/// generated demo photos and other sources have none.
+fn selected_file_paths(app: &LightcraftApp) -> Vec<String> {
+    let mut seen = std::collections::HashSet::new();
+    app.session
+        .selected()
+        .into_iter()
+        .filter_map(|id| match app.session.catalog.photo(id).map(|p| &p.source) {
+            Some(lightcraft_engine::catalog::Source::File { path }) => Some(path.clone()),
+            _ => None,
+        })
+        .filter(|path| seen.insert(path.clone()))
+        .collect()
+}
+
+/// `photo.copyPath`: the selected photos' file paths, one per line, on the clipboard.
+fn copy_file_paths(app: &mut LightcraftApp, ctx: &egui::Context) -> Result<Value, String> {
+    let paths = selected_file_paths(app);
+    if paths.is_empty() {
+        return Err(crate::i18n::tr("The selected photos have no file on disk").to_string());
+    }
+    ctx.copy_text(paths.join("\n"));
+    let n = paths.len();
+    let text = if n == 1 { crate::i18n::tr("Copied the file path").to_string() } else { crate::i18n::tr_format!("Copied {n} file paths", n = n) };
+    app.toast(ctx, text);
+    Ok(json!({"paths": paths}))
+}
+
+/// Can Show in Finder reveal `id` (`None`: the active photo)? Needs a file manager and a photo
+/// with a file on disk.
+pub(crate) fn show_in_finder_enabled(app: &LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> bool {
+    app.services.reveal.is_some()
+        && id
+            .or(app.session.active())
+            .and_then(|id| app.session.catalog.photo(id))
+            .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
+}
+
+/// A command's optional `id` (a photo): none or null means the active photo; anything else must
+/// be a photo id, as the engine's commands require, so a mistyped one isn't quietly the active
+/// photo.
+fn photo_param(p: &Value, cmd: &str) -> Result<Option<lightcraft_catalog::PhotoId>, String> {
+    match p.get("id") {
+        None | Some(Value::Null) => Ok(None),
+        Some(v) => v.as_u64().map(|n| Some(lightcraft_catalog::PhotoId(n))).ok_or_else(|| format!("{cmd}: `id` must be a photo id, not {v}")),
+    }
+}
+
+/// Reveal `id`'s original (`None`: the active photo's) in the system file manager.
+fn show_in_finder(app: &mut LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Result<Value, String> {
+    let id = id.or(app.session.active()).ok_or("no photo selected")?;
     let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) {
         Some(lightcraft_engine::catalog::Source::File { path }) => path,
-        _ => return Err("this photo has no file (demo scene)".into()),
+        Some(_) => return Err("this photo has no file (demo scene)".into()),
+        None => return Err(format!("app.showInFinder: no photo {}", id.0)),
     };
     let reveal = app.services.reveal.as_mut().ok_or("not available here")?;
     reveal(&path)?;
@@ -2472,6 +2533,11 @@ pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option<lightcraft_catalo
 /// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the
 /// section and the folders above it open, and it scrolls there (`reveal_folder`).
 fn show_in_folders(app: &mut LightcraftApp, id: Option<lightcraft_catalog::PhotoId>) -> Result<Value, String> {
+    if let Some(id) = id
+        && app.session.catalog.photo(id).is_none()
+    {
+        return Err(format!("app.showInFolders: no photo {}", id.0));
+    }
     let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?;
     let tree = app.caches.folder_tree(&app.session.catalog);
     // the row's own spelling (forward slashes): what its click hands `library.source`, and its id
diff --git a/crates/ui-egui/src/photo_menu.rs b/crates/ui-egui/src/photo_menu.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/photo_menu.rs
@@ -0,0 +1,323 @@
+//! The photo context menu: right-click a thumbnail in the grid or the filmstrip, the loupe or a
+//! Compare / Survey photo. One menu everywhere, built from the menu bar's model
+//! ([`crate::menubar::menu_bar`]), so its rows have the Photo menu's labels ("Delete 3 Photos"),
+//! shortcuts, check marks and enabled state. The rows are grouped by what they do (see the
+//! photo, describe it, organize it, develop it, its file, where it is, export, delete), with the
+//! long tails in submenus (Edit Settings, Metadata, Rotate & Flip). Rows that only make sense
+//! somewhere are left out elsewhere: Open in Detail in Detail, Compare and Survey with one photo,
+//! the album rows outside an album, Locate Missing File… for a file that is there.
+//!
+//! This module builds the rows ([`nodes`]) and says why an off row is off ([`disabled_reason`]);
+//! the right-click menus of the grid, the filmstrip, the loupe and Compare / Survey draw them in
+//! the change that follows (parity row MENU-CTX-GRID).
+
+use lightcraft_catalog::{Flag, PhotoId};
+use lightcraft_engine::cmd::CommandInfo;
+use serde_json::{Value, json};
+
+use crate::LightcraftApp;
+use crate::menubar::{self, MenuNode};
+use crate::menus::MenuEntry;
+use crate::state::ViewMode;
+
+/// The first item for `id` in `nodes`, submenus included.
+fn find_item<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> {
+    nodes.iter().find_map(|n| match n {
+        MenuNode::Item { id: i, .. } if i == id => Some(n),
+        MenuNode::Submenu { children, .. } => find_item(children, id),
+        _ => None,
+    })
+}
+
+/// Any registered command as a row, with or without a place in the menu bar.
+fn registered(app: &LightcraftApp, infos: &[CommandInfo], id: &str) -> Option<MenuNode> {
+    let keymap = &app.ui.settings.keymap;
+    let e = match crate::menus::ui_commands().find(|c| c.0 == id) {
+        Some((cid, label, sc, _)) => MenuEntry {
+            id: cid.to_string(),
+            label: label.to_string(),
+            menu: Vec::new(),
+            shortcut: crate::shortcuts::binding(keymap, cid, *sc).map(str::to_string),
+            enabled: crate::menus::ui_enabled(app, cid),
+        },
+        None => {
+            let c = infos.iter().find(|c| c.id == id)?;
+            MenuEntry {
+                id: c.id.into(),
+                label: c.label.into(),
+                menu: Vec::new(),
+                shortcut: crate::shortcuts::shortcut_of(keymap, c.id).map(str::to_string),
+                enabled: c.enabled,
+            }
+        }
+    };
+    Some(menubar::node(app, &e))
+}
+
+/// `n` with its label, parameters, enabled or checked state changed.
+fn with(n: Option<MenuNode>, f: impl FnOnce(&mut String, &mut Value, &mut bool, &mut Option<bool>)) -> Option<MenuNode> {
+    let mut n = n?;
+    if let MenuNode::Item { label, params, enabled, checked, .. } = &mut n {
+        f(label, params, enabled, checked);
+    }
+    Some(n)
+}
+
+/// The rating, flag and label rows checked by `photo`'s values (the menu bar checks the active
+/// photo's, which a right-click on one of several selected photos may leave another). In Compare
+/// and Survey a click there rates the active photo only (`compare::target_active`), so these rows
+/// name `photo`: the one checked is the one changed.
+fn recheck(app: &LightcraftApp, photo: PhotoId, mut sub: MenuNode) -> MenuNode {
+    let culling = crate::panels::compare::culling(app);
+    let Some(p) = app.session.catalog.photo(photo) else { return sub };
+    if let MenuNode::Submenu { children, .. } = &mut sub {
+        for c in children {
+            let MenuNode::Item { id, params, checked, .. } = c else { continue };
+            let on = match id.as_str() {
+                "photo.rate" => params["rating"].as_u64().map(|r| r == u64::from(p.rating)),
+                "photo.flag" => params["flag"].as_str().map(|f| match p.flag {
+                    Flag::Pick => f == "pick",
+                    Flag::Reject => f == "reject",
+                    Flag::None => f == "none",
+                }),
+                "photo.label" => params["label"].as_str().map(|l| match p.label {
+                    Some(c) => format!("{c:?}").to_lowercase() == l,
+                    None => l == "none",
+                }),
+                _ => None,
+            };
+            if on.is_some() {
+                *checked = on;
+                if culling {
+                    params["ids"] = json!([photo.0]);
+                }
+            }
+        }
+    }
+    sub
+}
+
+/// Edit Settings ▸ Apply Preset: one submenu per preset group, in the Presets panel's order
+/// (Favorites first, then the groups by name). Labels are in the UI language already (a user's
+/// group and preset names as written): whatever draws them must not translate them again
+/// ([`menubar::display_item_label`] leaves `preset.apply` rows alone).
+fn presets(app: &LightcraftApp, any: bool) -> MenuNode {
+    let list = &app.session.presets;
+    let row = |p: &lightcraft_develop::Preset| {
+        menubar::item("preset.apply", json!({"id": p.id}), crate::i18n::builtin_label(&p.name, p.builtin), None, any, None)
+    };
+    let mut by_name: std::collections::BTreeMap<&str, (bool, Vec<MenuNode>)> = std::collections::BTreeMap::new();
+    for p in list.iter() {
+        let g = by_name.entry(&p.group).or_default();
+        g.0 |= p.builtin;
+        g.1.push(row(p));
+    }
+    let starred: Vec<MenuNode> = list.iter().filter(|p| p.favorite).map(row).collect();
+    let favorites = (!starred.is_empty()).then(|| MenuNode::Submenu { label: crate::i18n::tr("Favorites").into(), children: starred });
+    let groups = by_name
+        .into_iter()
+        .map(|(name, (builtin, children))| MenuNode::Submenu { label: crate::i18n::builtin_label(name, builtin).into(), children });
+    MenuNode::Submenu { label: "Apply Preset".into(), children: favorites.into_iter().chain(groups).collect() }
+}
+
+/// The menu for `photo` (the right-click selects it unless it is one of the selected photos).
+pub fn nodes(app: &LightcraftApp, photo: PhotoId) -> Vec<MenuNode> {
+    let any = !app.session.selected().is_empty();
+    nodes_with(app, photo, &app.session.commands(), presets(app, any))
+}
+
+/// [`nodes`], with `infos` ([`lightcraft_engine::Session::commands`]) and Apply Preset at hand.
+fn nodes_with(app: &LightcraftApp, photo: PhotoId, infos: &[CommandInfo], presets: MenuNode) -> Vec<MenuNode> {
+    let bar = menubar::menu_bar(app);
+    let menu = |title: &str| bar.iter().find(|(t, _)| t == title).map(|(_, n)| n.as_slice()).unwrap_or_default();
+    // a menu's own row before one of the same command inside a submenu (Photo ▸ Add to Album holds
+    // a hint row for Add to My Photos)
+    let top = |id: &str| bar.iter().find_map(|(_, n)| n.iter().find(|x| matches!(x, MenuNode::Item { id: i, .. } if i == id))).cloned();
+    let row = |id: &str| top(id).or_else(|| bar.iter().find_map(|(_, n)| find_item(n, id)).cloned()).or_else(|| registered(app, infos, id));
+    let sub = |title: &str, label: &str| menu(title).iter().find(|n| matches!(n, MenuNode::Submenu { label: l, .. } if l == label)).cloned();
+    let submenu =
+        |label: &str, children: Vec<Option<MenuNode>>| MenuNode::Submenu { label: label.into(), children: children.into_iter().flatten().collect() };
+    let selected = app.session.selected();
+    let several = selected.len() > 1;
+    let view = app.ui.view;
+    let sep = || Some(MenuNode::Separator);
+    let open = |id: &str, label: &'static str| {
+        with(row(id), |l, _, _, c| {
+            *l = label.into();
+            *c = None;
+        })
+    };
+    let mut v: Vec<Option<MenuNode>> = Vec::new();
+
+    // see it
+    if view != ViewMode::Detail {
+        v.push(open("view.detail", "Open in Detail"));
+    }
+    if several && view != ViewMode::Compare {
+        v.push(open("view.compare", "Open in Compare"));
+    }
+    if several && view != ViewMode::Survey {
+        v.push(open("view.survey", "Open in Survey"));
+    }
+    v.push(sub("Photo", "Edit In"));
+    v.push(sep());
+
+    // describe it
+    for s in ["Set Rating", "Set Flag", "Set Color Label"] {
+        v.push(sub("Photo", s).map(|s| recheck(app, photo, s)));
+    }
+    v.push(row("panel.addKeywords"));
+    let copied_meta = app.session.meta_clipboard.is_some();
+    v.push(Some(submenu(
+        "Metadata",
+        vec![
+            row("photo.copyMetadata"),
+            with(row("photo.pasteMetadata"), |_, _, e, _| *e &= copied_meta),
+            sub("Photo", "Metadata Preset"),
+            sep(),
+            row("dialog.captureTime"),
+            row("photo.tagFromTracklog"),
+            row("metadata.reverseGeocode"),
+            sep(),
+            row("photo.saveMetadataToFile"),
+            row("photo.readMetadataFromFile"),
+            sep(),
+            row("dialog.allMetadata"),
+            row("faces.detect"),
+        ],
+    )));
+    v.push(sep());
+
+    // organize it
+    // (offered when the right-clicked photo is Local: a folder being browsed shows Local photos
+    // alone, so a mixed selection is rare, and Photo ▸ Add to Album says the rest)
+    if app.session.catalog.photo(photo).is_some_and(|p| p.local) {
+        v.push(row("photo.addToLibrary"));
+    }
+    v.push(row("album.toggleTarget"));
+    if crate::panels::grid::local_only(app, &selected) {
+        // albums hold library photos (issue #684): the row is off and says to add them first
+        v.push(Some(menubar::item("album.addPhotos", Value::Null, crate::i18n::tr("Add to Album"), None, false, None)));
+    } else {
+        v.push(sub("Photo", "Add to Album"));
+    }
+    if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source
+        && menubar::in_normal_album(app)
+        && !menubar::selection_deleted(app)
+    {
+        v.push(row("album.removePhotos"));
+        v.push(with(row("album.setCover"), |_, p, _, _| *p = json!({"id": aid.0, "photo": photo.0})));
+    }
+    v.push(sub("Photo", "Stack"));
+    v.push(with(row("library.findSimilar"), |_, p, _, _| *p = json!({"id": photo.0})));
+    v.push(sep());
+
+    // develop it
+    let copied = app.session.clipboard.is_some();
+    v.push(Some(submenu(
+        "Edit Settings",
+        vec![
+            row("develop.copy"),
+            row("dialog.copySettings"),
+            with(row("develop.paste"), |_, _, e, _| *e &= copied),
+            with(row("dialog.pasteSettings"), |_, _, e, _| *e &= copied),
+            row("develop.pastePrevious"),
+            row("develop.sync"),
+            row("develop.matchExposure"),
+            sep(),
+            row("develop.auto"),
+            row("develop.treatment"),
+            Some(presets),
+            row("dialog.createPreset"),
+            sep(),
+            with(row("photo.setReference"), |_, p, e, _| {
+                *p = json!({"id": photo.0});
+                *e &= crate::panels::compare::live(app, photo);
+            }),
+            row("develop.reset"),
+        ],
+    )));
+    v.push(row("photo.virtualCopy"));
+    v.push(row("version.create"));
+    v.push(sub("Photo", "Photo Merge"));
+    v.push(Some(submenu(
+        "Rotate & Flip",
+        vec![row("photo.rotateLeft"), row("photo.rotateRight"), sep(), row("photo.flipHorizontal"), row("photo.flipVertical")],
+    )));
+    v.push(sep());
+
+    // its file
+    v.push(row("dialog.rename"));
+    if app.services.pick_folder.is_some() || app.services.picker.is_some() {
+        v.push(with(row("photo.moveToFolder"), |l, _, _, _| *l = "Move to Folder…".into()));
+    }
+    v.push(row("photo.duplicate"));
+    v.push(row("photo.convertToDng"));
+    v.push(row("photo.reload"));
+    v.push(sep());
+
+    // where it is: the original moved or its drive is gone (a cached answer, checked off the UI thread)
+    let missing = matches!(app.session.catalog.photo(photo).map(|p| &p.source), Some(lightcraft_catalog::Source::File { path }) if app.session.media.availability.is_offline(path));
+    if missing {
+        v.push(with(row("photo.locate"), |_, p, _, _| *p = json!({"id": photo.0})));
+    }
+    v.push(with(row("app.showInFinder"), |_, p, e, _| {
+        *p = json!({"id": photo.0});
+        *e = crate::menus::show_in_finder_enabled(app, Some(photo));
+    }));
+    v.push(with(row("app.showInFolders"), |_, p, e, _| {
+        *p = json!({"id": photo.0});
+        *e = crate::menus::show_in_folders_enabled(app, Some(photo));
+    }));
+    v.push(row("photo.copyPath"));
+    v.push(sep());
+
+    // export it
+    v.push(row("dialog.export"));
+    let mut export = vec![row("app.exportPrevious"), sep()];
+    if let Some(MenuNode::Submenu { children, .. }) = sub("File", "Export with Preset") {
+        // (its Custom… is the Export… row above)
+        export.extend(children.into_iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "dialog.export")).map(Some));
+    }
+    v.push(Some(submenu("Export with Preset", export)));
+    v.push(sep());
+
+    // delete it (Restore and Delete Permanently in Recently Deleted: the menu bar's choice)
+    for id in ["photo.delete", "photo.restore", "photo.deletePermanently"] {
+        v.push(menu("Photo").iter().find(|n| matches!(n, MenuNode::Item { id: i, .. } if i == id)).cloned());
+    }
+    menubar::tidy(v.into_iter().flatten().collect())
+}
+
+/// Why a row that is off is off; `infos` is [`lightcraft_engine::Session::commands`]. Asked only
+/// about a row that is off: for some ids the answer doesn't depend on the row being off.
+fn reason(app: &LightcraftApp, infos: &[CommandInfo], id: &str, params: &Value) -> Option<String> {
+    let why = match id {
+        _ if (id.starts_with("dialog.merge") || id.starts_with("merge.")) && app.session.selected().len() < 2 => "Select 2 or more photos",
+        "develop.paste" | "dialog.pasteSettings" if app.session.clipboard.is_none() => "Copy edit settings first",
+        "photo.pasteMetadata" if app.session.meta_clipboard.is_none() => "Copy metadata first",
+        "album.addPhotos" if params.is_null() => return Some(crate::panels::grid::albums_hint()),
+        "photo.copyPath" => "The selected photos have no file on disk",
+        "app.showInFinder" if app.services.reveal.is_none() => "This version of LightCraft can't open the file manager",
+        "app.showInFinder" => "This photo has no file on disk",
+        "app.showInFolders" => "This photo is not in a folder of the library",
+        "photo.editInPhotoCraft" | "photo.editInExternal" | "photo.openOriginalInExternal" if app.services.open_with.is_none() => {
+            "Opening other programs needs the desktop app"
+        }
+        "photo.openOriginalInExternal" => "Only a photo file LightCraft reads can be opened this way",
+        "photo.setReference" => "Restore the photo to use it as the reference",
+        _ => return infos.iter().find(|c| c.id == id).and_then(|c| c.disabled_reason.clone()),
+    };
+    Some(crate::i18n::tr(why).to_string())
+}
+
+/// Why a row is off (the text shown while the pointer rests on a row that is off). Asked only
+/// about a row that is off: for some ids (Copy File Path, Show in Folders, Set as Reference) the
+/// answer is the same whether or not it is.
+pub fn disabled_reason(app: &LightcraftApp, id: &str, params: &Value) -> Option<String> {
+    reason(app, &app.session.commands(), id, params)
+}
+
+#[cfg(test)]
+#[path = "photo_menu_tests.rs"]
+mod tests;
diff --git a/crates/ui-egui/src/photo_menu_tests.rs b/crates/ui-egui/src/photo_menu_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/photo_menu_tests.rs
@@ -0,0 +1,503 @@
+//! Tests of the photo menu's model ([`super::nodes`], [`super::disabled_reason`]): what it offers, how it
+//! groups it, and that it matches the menu bar.
+
+use super::*;
+use crate::state::ViewMode;
+use serde_json::json;
+
+fn app() -> LightcraftApp {
+    LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default())
+}
+
+fn photos(a: &mut LightcraftApp) -> Vec<PhotoId> {
+    a.session.visible_cloned()
+}
+
+fn select(a: &mut LightcraftApp, ids: &[PhotoId]) {
+    a.run("library.select", json!({"ids": ids.iter().map(|p| p.0).collect::<Vec<_>>()})).unwrap();
+}
+
+/// Every item, submenus included, with the submenu labels it sits under.
+fn items(nodes: &[MenuNode]) -> Vec<(Vec<String>, MenuNode)> {
+    fn walk(n: &[MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<String>, MenuNode)>) {
+        for x in n {
+            match x {
+                MenuNode::Submenu { label, children } => {
+                    path.push(label.clone());
+                    walk(children, path, out);
+                    path.pop();
+                }
+                MenuNode::Item { .. } => out.push((path.clone(), x.clone())),
+                MenuNode::Separator => {}
+            }
+        }
+    }
+    let mut out = Vec::new();
+    walk(nodes, &mut Vec::new(), &mut out);
+    out
+}
+
+fn find<'a>(nodes: &'a [(Vec<String>, MenuNode)], id: &str, params: &Value) -> Option<&'a MenuNode> {
+    nodes.iter().map(|(_, n)| n).find(|n| matches!(n, MenuNode::Item { id: i, params: p, .. } if i == id && (params.is_null() || p == params)))
+}
+
+fn has(a: &LightcraftApp, photo: PhotoId, id: &str) -> bool {
+    find(&items(&nodes(a, photo)), id, &Value::Null).is_some()
+}
+
+/// What the Photo menu can do to the selected photos, the right-click menu can do too (a mode
+/// like Auto Advance and library-wide tools aside), so a new Photo command can't be left out.
+#[test]
+fn the_photo_menu_actions_are_all_offered() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    let bar = crate::menubar::menu_bar(&a);
+    let menu = &bar.iter().find(|(t, _)| t == "Photo").expect("Photo menu").1;
+    let ours = items(&nodes(&a, p));
+    // a mode, library-wide tools, and Add to My Photos (offered only for Local photos)
+    let not_for_a_photo =
+        ["view.autoAdvance", "dialog.cull", "photo.readFocusPoints", "develop.updateProcess", "metadata.downloadPlaceNames", "photo.addToLibrary"];
+    let missing: Vec<String> = items(menu)
+        .into_iter()
+        .filter_map(|(_, n)| match n {
+            MenuNode::Item { id, params, .. } if !not_for_a_photo.contains(&id.as_str()) && find(&ours, &id, &params).is_none() => {
+                Some(MenuNode::key(&id, &params))
+            }
+            _ => None,
+        })
+        .collect();
+    assert!(missing.is_empty(), "the Photo menu has these and the right-click menu doesn't: {missing:?}");
+}
+
+/// The everyday actions a photo library's menu has beyond the Photo menu: metadata, edit
+/// settings, the target album, Compare and the file's path.
+#[test]
+fn copy_paste_sync_and_the_target_album_are_offered() {
+    let mut a = app();
+    let v = photos(&mut a);
+    select(&mut a, &[v[0], v[1]]);
+    let ours = items(&nodes(&a, v[0]));
+    for id in [
+        "photo.copyMetadata",
+        "photo.pasteMetadata",
+        "develop.copy",
+        "develop.paste",
+        "dialog.pasteSettings",
+        "develop.sync",
+        "develop.auto",
+        "album.toggleTarget",
+        "view.compare",
+        "view.survey",
+        "photo.duplicate",
+        "photo.convertToDng",
+        "photo.flipHorizontal",
+        "dialog.captureTime",
+        "photo.copyPath",
+        "app.exportPrevious",
+    ] {
+        assert!(find(&ours, id, &Value::Null).is_some(), "{id} is missing");
+    }
+    // presets, by group
+    let preset = a.session.presets.first().expect("demo presets").id.clone();
+    assert!(find(&ours, "preset.apply", &json!({"id": preset})).is_some(), "Apply Preset lists the presets");
+}
+
+/// Groups stay short and the long tails sit in submenus: Edit Settings and Metadata.
+#[test]
+fn actions_are_grouped_with_the_long_tails_in_submenus() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    let n = nodes(&a, p);
+    let ours = items(&n);
+    let under = |id: &str| ours.iter().find(|(_, x)| matches!(x, MenuNode::Item { id: i, .. } if i == id)).map(|(path, _)| path.clone());
+    assert_eq!(under("develop.copy"), Some(vec!["Edit Settings".to_string()]));
+    assert_eq!(under("develop.reset"), Some(vec!["Edit Settings".to_string()]));
+    assert_eq!(under("photo.copyMetadata"), Some(vec!["Metadata".to_string()]));
+    assert_eq!(under("dialog.captureTime"), Some(vec!["Metadata".to_string()]));
+    assert_eq!(under("photo.flipVertical"), Some(vec!["Rotate & Flip".to_string()]));
+    let mut group = 0;
+    for x in &n {
+        if *x == MenuNode::Separator {
+            group = 0;
+        } else {
+            group += 1;
+            assert!(group <= 6, "a group of more than 6 rows: {n:?}");
+        }
+    }
+    // Delete stands alone at the end
+    assert!(matches!(n.last(), Some(MenuNode::Item { id, .. }) if id == "photo.delete"), "{:?}", n.last());
+    assert_eq!(n.get(n.len().saturating_sub(2)), Some(&MenuNode::Separator));
+}
+
+/// The labels, shortcuts and enabled state are the menu bar's (one model): "Delete 3 Photos".
+#[test]
+fn rows_have_the_menu_bars_labels_and_shortcuts() {
+    let mut a = app();
+    let v = photos(&mut a);
+    select(&mut a, &v[..3]);
+    let ours = items(&nodes(&a, v[0]));
+    let bar = crate::menubar::menu_bar(&a);
+    // the Photo menu's rows first (its Add to Album ▸ New Album… has no shortcut, File's has)
+    let theirs: Vec<_> =
+        bar.iter().filter(|(t, _)| t == "Photo").chain(bar.iter().filter(|(t, _)| t != "Photo")).flat_map(|(_, n)| items(n)).collect();
+    let mut compared = 0;
+    for (_, n) in &ours {
+        let MenuNode::Item { id, params, label, shortcut, .. } = n else { continue };
+        if id.starts_with("view.") {
+            continue; // "Open in Detail" rather than the View menu's "Detail View"
+        }
+        if let Some(MenuNode::Item { label: l, shortcut: s, .. }) = find(&theirs, id, params) {
+            assert_eq!((label, shortcut), (l, s), "{id}");
+            compared += 1;
+        }
+    }
+    assert!(compared > 20, "only {compared} rows compared");
+    let delete = find(&ours, "photo.delete", &Value::Null).expect("delete");
+    assert!(matches!(delete, MenuNode::Item { label, shortcut: Some(_), .. } if label == "Delete 3 Photos"), "{delete:?}");
+}
+
+/// The rating, flag and label submenus check the right-clicked photo's values, also when it
+/// isn't the active one of a selection.
+#[test]
+fn rating_flag_and_label_check_the_right_clicked_photo() {
+    let mut a = app();
+    let v = photos(&mut a);
+    let (active, clicked) = (v[0], v[1]);
+    a.run("photo.rate", json!({"ids": [clicked.0], "rating": 4})).unwrap();
+    a.run("photo.flag", json!({"ids": [clicked.0], "flag": "pick"})).unwrap();
+    a.run("photo.label", json!({"ids": [clicked.0], "label": "green"})).unwrap();
+    a.run("photo.rate", json!({"ids": [active.0], "rating": 1})).unwrap();
+    select(&mut a, &[active, clicked]);
+    assert_eq!(a.session.active(), Some(active));
+    let ours = items(&nodes(&a, clicked));
+    let checked = |id: &str, params: Value| match find(&ours, id, &params) {
+        Some(MenuNode::Item { checked, .. }) => *checked,
+        other => panic!("{id} {params}: {other:?}"),
+    };
+    assert_eq!(checked("photo.rate", json!({"rating": 4})), Some(true));
+    assert_eq!(checked("photo.rate", json!({"rating": 1})), Some(false));
+    assert_eq!(checked("photo.flag", json!({"flag": "pick"})), Some(true));
+    assert_eq!(checked("photo.label", json!({"label": "green"})), Some(true));
+    assert_eq!(checked("photo.label", json!({"label": "none"})), Some(false));
+}
+
+/// Only what makes sense where the menu opened: no "Open in Detail" in Detail, Compare and
+/// Survey once several photos are chosen.
+#[test]
+fn view_rows_follow_the_view_and_the_selection() {
+    let mut a = app();
+    let v = photos(&mut a);
+    select(&mut a, &[v[0]]);
+    a.ui.view = ViewMode::PhotoGrid;
+    assert!(has(&a, v[0], "view.detail"));
+    assert!(!has(&a, v[0], "view.compare"), "Compare needs two photos");
+    a.ui.view = ViewMode::Detail;
+    assert!(!has(&a, v[0], "view.detail"), "already in Detail (the filmstrip)");
+    select(&mut a, &[v[0], v[1]]);
+    assert!(has(&a, v[0], "view.compare") && has(&a, v[0], "view.survey"));
+    a.ui.view = ViewMode::Compare;
+    assert!(!has(&a, v[0], "view.compare"), "already comparing");
+    let detail = find(&items(&nodes(&a, v[0])), "view.detail", &Value::Null).cloned();
+    assert!(matches!(&detail, Some(MenuNode::Item { label, checked: None, .. }) if label == "Open in Detail"), "{detail:?}");
+}
+
+/// A row that is off says why, where the reason isn't obvious.
+#[test]
+fn rows_that_are_off_say_why() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    let ours = items(&nodes(&a, p));
+    for id in ["dialog.mergeHdr", "develop.paste", "photo.copyPath"] {
+        assert!(matches!(find(&ours, id, &Value::Null), Some(MenuNode::Item { enabled: false, .. })), "{id} should be off here");
+        assert!(disabled_reason(&a, id, &Value::Null).is_some_and(|r| !r.is_empty()), "{id} says nothing");
+    }
+    assert_eq!(disabled_reason(&a, "dialog.mergeHdr", &Value::Null).as_deref(), Some("Select 2 or more photos"));
+}
+
+/// A photo in Recently Deleted gets Restore and Delete Permanently instead of Delete.
+#[test]
+fn a_deleted_photo_offers_restore() {
+    let mut a = app();
+    a.ui.settings.confirm_delete = false;
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    a.run("photo.delete", json!({})).unwrap();
+    select(&mut a, &[p]);
+    assert!(has(&a, p, "photo.restore") && has(&a, p, "photo.deletePermanently"));
+    assert!(!has(&a, p, "photo.delete"));
+}
+
+/// Give a photo a file on disk (the path is only read, never opened).
+fn on_disk(a: &mut LightcraftApp, id: PhotoId, path: &str) {
+    let name = path.rsplit('/').next().unwrap_or(path).to_string();
+    a.session
+        .catalog
+        .apply(lightcraft_catalog::Op::SetFile { id, file_name: name, source: lightcraft_catalog::Source::File { path: path.into() } })
+        .unwrap();
+}
+
+/// The rating, flag and label rows with their parameters.
+fn culling_rows(a: &LightcraftApp, photo: PhotoId) -> Vec<Value> {
+    items(&nodes(a, photo))
+        .into_iter()
+        .filter_map(|(_, n)| match n {
+            MenuNode::Item { id, params, .. } if matches!(id.as_str(), "photo.rate" | "photo.flag" | "photo.label") => Some(params),
+            _ => None,
+        })
+        .collect()
+}
+
+/// In Compare and Survey a rating, flag or label row names the right-clicked photo (a click
+/// there would otherwise change only the active one); elsewhere it changes the selection.
+#[test]
+fn culling_rows_name_the_photo_in_compare_and_survey() {
+    let mut a = app();
+    let v = photos(&mut a);
+    select(&mut a, &[v[0], v[1]]);
+    let rows = culling_rows(&a, v[1]);
+    assert!(!rows.is_empty() && rows.iter().all(|p| p.get("ids").is_none()), "in the grid: {rows:?}");
+    for view in [ViewMode::Compare, ViewMode::Survey] {
+        a.ui.view = view;
+        let rows = culling_rows(&a, v[1]);
+        assert!(!rows.is_empty() && rows.iter().all(|p| p["ids"] == json!([v[1].0])), "{view:?}: {rows:?}");
+    }
+}
+
+/// Add to My Photos is offered for a Local photo only; a selection of Local photos gets an Add to
+/// Album row that is off and says to add them first.
+#[test]
+fn local_photos_are_offered_add_to_my_photos() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    assert!(!has(&a, p, "photo.addToLibrary"));
+    a.session.catalog.apply(lightcraft_catalog::Op::SetLocal { id: p, local: true }).unwrap();
+    let ours = items(&nodes(&a, p));
+    assert!(matches!(find(&ours, "photo.addToLibrary", &Value::Null), Some(MenuNode::Item { enabled: true, .. })));
+    assert!(matches!(find(&ours, "album.addPhotos", &Value::Null), Some(MenuNode::Item { enabled: false, .. })));
+    assert_eq!(disabled_reason(&a, "album.addPhotos", &Value::Null), Some(crate::panels::grid::albums_hint()));
+}
+
+/// Inside an album, Remove from Album and Set as Album Cover (of that album, with this photo);
+/// outside one, neither.
+#[test]
+fn album_rows_only_inside_an_album() {
+    let mut a = app();
+    let v = photos(&mut a);
+    select(&mut a, &[v[2]]);
+    assert!(!has(&a, v[2], "album.removePhotos") && !has(&a, v[2], "album.setCover"));
+    let album = a.session.execute("album.create", &json!({"name": "Kept"})).unwrap()["id"].as_u64().unwrap();
+    a.session.execute("album.addPhotos", &json!({"id": album, "ids": [v[2].0]})).unwrap();
+    a.run("library.source", json!({"kind": "album", "id": album})).unwrap();
+    select(&mut a, &[v[2]]);
+    assert!(has(&a, v[2], "album.removePhotos"));
+    let ours = items(&nodes(&a, v[2]));
+    assert!(find(&ours, "album.setCover", &json!({"id": album, "photo": v[2].0})).is_some(), "the cover row names the album and the photo");
+}
+
+/// Locate Missing File… only for a photo whose original is known to be gone.
+#[test]
+fn locate_only_for_a_missing_original() {
+    let mut a = app();
+    let v = photos(&mut a);
+    on_disk(&mut a, v[0], "/pics/here.jpg");
+    on_disk(&mut a, v[1], "/pics/gone.jpg");
+    a.session.media.availability.set_probe(std::sync::Arc::new(|path: &str| path != "/pics/gone.jpg"));
+    select(&mut a, &[v[0]]);
+    assert!(!has(&a, v[0], "photo.locate"));
+    select(&mut a, &[v[1]]);
+    assert!(has(&a, v[1], "photo.locate"));
+}
+
+/// The rows about one photo carry it: Find Similar, Show in Folders and Set as Reference ask for
+/// the right-clicked photo, not the active one.
+#[test]
+fn photo_rows_carry_the_right_clicked_photo() {
+    let mut a = app();
+    let v = photos(&mut a);
+    select(&mut a, &[v[0], v[3]]);
+    assert_eq!(a.session.active(), Some(v[0]));
+    let ours = items(&nodes(&a, v[3]));
+    for id in ["library.findSimilar", "app.showInFolders", "photo.setReference"] {
+        assert!(find(&ours, id, &json!({"id": v[3].0})).is_some(), "{id} names the right-clicked photo");
+    }
+}
+
+/// The menu row of a selected photo that isn't the active one, with its own parameters.
+fn row_of(a: &LightcraftApp, photo: PhotoId, id: &str) -> (Value, bool) {
+    match find(&items(&nodes(a, photo)), id, &Value::Null) {
+        Some(MenuNode::Item { params, enabled, .. }) => (params.clone(), *enabled),
+        other => panic!("no {id} row: {other:?}"),
+    }
+}
+
+/// Right-clicking a selected photo that isn't the active one, Show in Finder is on for that
+/// photo's file (the active photo has none) and reveals it; a mistyped `id` is refused, not taken
+/// for the active photo.
+#[test]
+fn show_in_finder_reveals_the_right_clicked_photo() {
+    let mut a = app();
+    let v = photos(&mut a);
+    on_disk(&mut a, v[1], "/pics/clicked.jpg");
+    let shown = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
+    let s = shown.clone();
+    a.services.reveal = Some(Box::new(move |p: &str| {
+        s.lock().unwrap().push(p.to_string());
+        Ok(())
+    }));
+    select(&mut a, &[v[0], v[1]]);
+    assert_eq!(a.session.active(), Some(v[0]));
+    assert!(!crate::menus::ui_enabled(&a, "app.showInFinder"), "the menu bar's is off: the active photo has no file");
+    let (finder, on) = row_of(&a, v[1], "app.showInFinder");
+    assert!(on, "the right-clicked photo's row is on");
+    a.run("app.showInFinder", finder).unwrap();
+    assert_eq!(shown.lock().unwrap().as_slice(), ["/pics/clicked.jpg"]);
+    // a mistyped id is refused, not taken for the active photo; an unknown one says so
+    on_disk(&mut a, v[0], "/pics/active.jpg");
+    let e = a.run("app.showInFinder", json!({"id": "5"})).unwrap_err();
+    assert!(e.contains("must be a photo id"), "{e}");
+    let e = a.run("app.showInFinder", json!({"id": u64::MAX})).unwrap_err();
+    assert!(e.contains("no photo"), "{e}");
+    assert_eq!(shown.lock().unwrap().as_slice(), ["/pics/clicked.jpg"], "nothing else was revealed");
+}
+
+/// Right-clicking a selected photo that isn't the active one, Locate Missing File… relinks that
+/// photo, and leaves the active one alone.
+#[test]
+fn locate_relinks_the_right_clicked_photo() {
+    let mut a = app();
+    let v = photos(&mut a);
+    let dir = std::env::temp_dir().join(format!("lc-photo-menu-locate-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let found = dir.join("found.jpg");
+    std::fs::write(&found, b"not read").unwrap();
+    on_disk(&mut a, v[0], "/pics/active.jpg");
+    on_disk(&mut a, v[1], "/pics/gone.jpg");
+    a.session.media.availability.set_probe(std::sync::Arc::new(|path: &str| path != "/pics/gone.jpg"));
+    select(&mut a, &[v[0], v[1]]);
+    assert_eq!(a.session.active(), Some(v[0]));
+    let (mut locate, _) = row_of(&a, v[1], "photo.locate");
+    locate["path"] = json!(found.to_string_lossy());
+    a.run("photo.locate", locate).unwrap();
+    let path = |a: &LightcraftApp, id: PhotoId| match a.session.catalog.photo(id).map(|p| &p.source) {
+        Some(lightcraft_catalog::Source::File { path }) => path.clone(),
+        _ => String::new(),
+    };
+    assert_eq!(path(&a, v[1]), found.to_string_lossy(), "the right-clicked photo is relinked");
+    assert_eq!(path(&a, v[0]), "/pics/active.jpg", "the active photo is left alone");
+    // an unknown or mistyped id is refused before any file dialog opens
+    a.services.pick_files = Some(Box::new(|| panic!("no file dialog for a photo that doesn't exist")));
+    let e = a.run("photo.locate", json!({"id": u64::MAX})).unwrap_err();
+    assert!(e.contains("no photo"), "{e}");
+    let e = a.run("photo.locate", json!({"id": u64::MAX, "path": found.to_string_lossy()})).unwrap_err();
+    assert!(e.contains("no photo"), "{e}");
+    let e = a.run("photo.locate", json!({"id": "5", "path": found.to_string_lossy()})).unwrap_err();
+    assert!(e.contains("must be a photo id"), "{e}");
+    assert_eq!(path(&a, v[0]), "/pics/active.jpg", "still left alone");
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// Set as Reference Photo is off for a photo in Recently Deleted, and says why.
+#[test]
+fn a_deleted_photo_cant_be_the_reference() {
+    let mut a = app();
+    a.ui.settings.confirm_delete = false;
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    a.run("photo.delete", json!({})).unwrap();
+    select(&mut a, &[p]);
+    let ours = items(&nodes(&a, p));
+    assert!(matches!(find(&ours, "photo.setReference", &Value::Null), Some(MenuNode::Item { enabled: false, .. })));
+    assert_eq!(disabled_reason(&a, "photo.setReference", &json!({"id": p.0})).as_deref(), Some("Restore the photo to use it as the reference"));
+}
+
+/// Export with Preset holds Export with Previous and the presets, not a second Export… (the row
+/// above it is that).
+#[test]
+fn export_with_preset_has_no_second_export() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    let ours = items(&nodes(&a, p));
+    let export: Vec<&MenuNode> = ours.iter().filter(|(path, _)| path.iter().any(|s| s == "Export with Preset")).map(|(_, n)| n).collect();
+    assert!(export.iter().any(|n| matches!(n, MenuNode::Item { id, .. } if id == "app.exportPrevious")));
+    assert!(!export.iter().any(|n| matches!(n, MenuNode::Item { id, .. } if id == "dialog.export")));
+    assert_eq!(ours.iter().filter(|(_, n)| matches!(n, MenuNode::Item { id, .. } if id == "dialog.export")).count(), 1);
+}
+
+/// Apply Preset lists the groups as the Presets panel does: Favorites first, then by name.
+#[test]
+fn apply_preset_groups_follow_the_presets_panel() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    let star = a.session.presets.iter().position(|x| x.builtin).expect("a stock preset");
+    a.session.presets[star].favorite = true;
+    let starred = a.session.presets[star].id.clone();
+    let mut groups: Vec<String> = a.session.presets.iter().map(|x| x.group.clone()).collect();
+    groups.sort_unstable();
+    groups.dedup();
+    let apply = nodes(&a, p)
+        .into_iter()
+        .find_map(|n| match n {
+            MenuNode::Submenu { label, children } if label == "Edit Settings" => children.into_iter().find_map(|c| match c {
+                MenuNode::Submenu { label, children } if label == "Apply Preset" => Some(children),
+                _ => None,
+            }),
+            _ => None,
+        })
+        .expect("Edit Settings ▸ Apply Preset");
+    let labels: Vec<&str> = apply.iter().filter_map(|n| if let MenuNode::Submenu { label, .. } = n { Some(label.as_str()) } else { None }).collect();
+    let mut want = vec!["Favorites"];
+    want.extend(groups.iter().map(String::as_str));
+    assert_eq!(labels, want);
+    let MenuNode::Submenu { children, .. } = &apply[0] else { panic!("Favorites") };
+    assert!(children.iter().any(|c| matches!(c, MenuNode::Item { params, .. } if params["id"] == json!(starred))));
+}
+
+/// The reasons of rows that need the desktop app or a file say so.
+#[test]
+fn host_and_file_reasons() {
+    let mut a = app();
+    let p = photos(&mut a)[0];
+    select(&mut a, &[p]);
+    assert!(a.services.reveal.is_none() && a.services.open_with.is_none(), "a test app has neither");
+    let why = |id: &str| disabled_reason(&a, id, &json!({"id": p.0}));
+    assert_eq!(why("app.showInFinder").as_deref(), Some("This version of LightCraft can't open the file manager"));
+    assert_eq!(why("photo.openOriginalInExternal").as_deref(), Some("Opening other programs needs the desktop app"));
+    assert_eq!(why("app.showInFolders").as_deref(), Some("This photo is not in a folder of the library"));
+}
+
+/// Copy File Path puts each selected photo's path on the clipboard once (a virtual copy shares its
+/// original's file), one per line, and says how many.
+#[test]
+fn copy_file_path_copies_each_file_once() {
+    let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
+    let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);
+    let v = h.app.session.visible_cloned();
+    on_disk(&mut h.app, v[0], "/pics/a.jpg");
+    on_disk(&mut h.app, v[1], "/pics/b.jpg");
+    let ask = |h: &mut crate::headless::Headless, ids: Vec<u64>| {
+        h.app.session.execute("library.select", &json!({"ids": ids})).unwrap();
+        let r = h.request("engine.execute", json!({"command": "photo.copyPath"}), std::time::Duration::from_secs(20));
+        h.step();
+        h.step();
+        r
+    };
+    // (a frame first: commands run outside a frame copy through the context it last drew)
+    h.settle(std::time::Duration::from_secs(120));
+    let r = ask(&mut h, vec![v[0].0]);
+    assert_eq!(r["result"]["paths"], json!(["/pics/a.jpg"]), "{r}");
+    assert_eq!(h.app.copied.as_deref(), Some("/pics/a.jpg"));
+    assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Copied the file path"));
+    h.app.session.execute("library.select", &json!({"ids": [v[0].0]})).unwrap();
+    let copy = h.app.session.execute("photo.virtualCopy", &json!({})).unwrap();
+    let copy = copy["ids"][0].as_u64().or_else(|| copy["id"].as_u64()).expect("the virtual copy's id");
+    let r = ask(&mut h, vec![v[0].0, copy, v[1].0]);
+    assert_eq!(r["result"]["paths"], json!(["/pics/a.jpg", "/pics/b.jpg"]), "{r}");
+    assert_eq!(h.app.copied.as_deref(), Some("/pics/a.jpg\n/pics/b.jpg"));
+    assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Copied 2 file paths"));
+}
diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs
--- a/crates/ui-egui/src/tests_panels.rs
+++ b/crates/ui-egui/src/tests_panels.rs
@@ -1736,6 +1736,13 @@ fn show_in_folders_reveals_the_photo_asked_for_and_follows_to_its_folder() {
     assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home"));
     assert_eq!(h.app.session.active(), Some(c), "the view of that folder shows the photo asked for");
     assert!(crate::menus::show_in_folders_enabled(&h.app, Some(c)));
+    // a mistyped id is refused (read loosely, it would quietly mean the active photo and
+    // succeed); an unknown one says so
+    let e = h.app.run("app.showInFolders", json!({"id": a.0.to_string()})).unwrap_err();
+    assert!(e.contains("must be a photo id"), "{e}");
+    let e = h.app.run("app.showInFolders", json!({"id": u64::MAX})).unwrap_err();
+    assert!(e.contains("no photo"), "{e}");
+    assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home"), "still the folder asked for");
 }
 
 /// A folder deeper than the tree goes has no row: the entry is disabled rather than failing.
