From 59506d9b4aebb60c2f0ecfd874364b3ae61ae370 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:59:49 +0200 Subject: [PATCH] =?UTF-8?q?fix(home):=20list=20recent=20projects=20on=20Ho?= =?UTF-8?q?me=20and=20in=20File=20=E2=96=B8=20Open=20Recent=20(fixes=20#14?= =?UTF-8?q?61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/id.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 ++ crates/ui-egui/src/menus.rs | 34 +++++++++++++++++ crates/ui-egui/src/panels/import_mode.rs | 47 +++++++++++++++++++++++- crates/ui-egui/tests/menus_ui.rs | 38 +++++++++++++++++++ 12 files changed, 141 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 21c0be07..25ec27b9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -248,6 +248,8 @@ OMF… OMF… Offline File… Offline-Datei… Open Project… Projekt öffnen… + Open Recent Zuletzt geöffnete Dateien + Clear Recent Files Zuletzt geöffnete Dateien leeren OpenTimelineIO… OpenTimelineIO… Optical Flow Optischer Fluss Overwrite Überschreiben diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 25a23480..c67b68f6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -230,6 +230,9 @@ Normalize Mix Track… Normalizar pista de mezcla… Offline File… Archivo sin conexión… Open Project… Abrir proyecto… + Open Recent Archivos recientes + Clear Recent Files Vaciar archivos recientes + Recent Recientes Optical Flow Flujo óptico Overwrite Sobrescribir Paste Pegar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4385c0dd..dd975670 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -232,6 +232,9 @@ Normalize Mix Track… Normaliser la piste de mixage… Offline File… Fichier hors ligne… Open Project… Ouvrir un projet… + Open Recent Ouvrir un fichier récent + Clear Recent Files Effacer la liste des fichiers récents + Recent Récents Optical Flow Flux optique Overwrite Écraser Paste Coller diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1c44965f..19d2eb60 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -237,6 +237,9 @@ Normalize Mix Track… Normalisasi Trek Mix… Offline File… Berkas Offline… Open Project… Buka Proyek… + Open Recent Terakhir Dibuka + Clear Recent Files Hapus Daftar Berkas Terakhir + Recent Terbaru Optical Flow Aliran Optik Overwrite Timpa Paste Tempel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 13673e6c..4d2431c2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -231,6 +231,9 @@ Normalize Mix Track… ミックストラックをノーマライズ… Offline File… オフラインファイル… Open Project… プロジェクトを開く… + Open Recent 最近使ったファイルを開く + Clear Recent Files 最近使ったファイルを消去 + Recent 最近 Optical Flow オプティカルフロー Overwrite 上書き Paste 貼り付け diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 17498760..9ace0c51 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -234,6 +234,9 @@ Normalize Mix Track… Normalizar faixa de mixagem… Offline File… Arquivo offline… Open Project… Abrir projeto… + Open Recent Abrir recentes + Clear Recent Files Limpar arquivos recentes + Recent Recentes Optical Flow Fluxo óptico Overwrite Sobrescrever Paste Colar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 747ffde2..8ffaf83a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -221,6 +221,8 @@ Normalize Mix Track… Нормализовать мастер-дорожку… Offline File… Автономный файл… Open Project… Открыть проект… + Open Recent Открыть недавние + Clear Recent Files Очистить список недавних файлов Optical Flow Оптический поток Overwrite Перезаписать Paste Вставить diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3ce73588..f8bbe117 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -222,6 +222,8 @@ Normalize Mix Track… Нормалізувати доріжку міксу… Offline File… Автономний файл… Open Project… Відкрити проєкт… + Open Recent Нещодавні файли + Clear Recent Files Очистити список нещодавніх файлів Optical Flow Оптичний потік Overwrite Перезаписати Paste Вставити diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 7459caa1..5756871b 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -225,6 +225,9 @@ Normalize Mix Track… 标准化混音轨道… Offline File… 脱机文件… Open Project… 打开项目… + Open Recent 打开最近文件 + Clear Recent Files 清除最近文件 + Recent 最近使用 Optical Flow 光流 Overwrite 覆盖 Paste 粘贴 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 04d79ac3..191b924c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -550,6 +550,17 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: crate::panels::settings::set_theme(app, ctx, k); return Ok(Value::Null); } + // File ▸ Open Recent + if let Some(i) = id.strip_prefix("file.openRecent.") { + let path = i.parse::().ok().and_then(|i| app.session.prefs.general.recent_projects.get(i).cloned()).ok_or("no such recent project")?; + return invoke(app, ctx, "file.open", json!({"path": path})); + } + if id == "file.clearRecent" { + let mut next = app.session.prefs.clone(); + next.general.recent_projects.clear(); + app.session.set_prefs(next).map_err(|e| e.to_string())?; + return Ok(Value::Null); + } // File dialogs for commands that need a path. if (id == "file.import" && params.get("paths").is_none() && params.get("path").is_none()) || (id == "file.importImageSequence" && params.get("path").is_none()) @@ -682,9 +693,31 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { } } crate::panels::workspaces::menu(app, &mut v); + open_recent(&mut v, &app.session, |n| app.ui.language.tr(n).to_string()); v } +/// File ▸ Open Recent, after Open Project…: the recent projects (General ▸ `recentProjects`), +/// newest first, then Clear Recent Files. Clear is always there, so the submenu exists (in the +/// native menu too, built once at launch) before the first project is saved. +fn open_recent(items: &mut Vec, session: &filmcraft_engine::Session, tr: impl Fn(&str) -> String) { + let Some(at) = items.iter().position(|it| it.id == "file.open") else { return }; + let path: Vec = vec!["File".into(), "Open Recent".into()]; + let recent = &session.prefs.general.recent_projects; + let mut sub: Vec = recent + .iter() + .enumerate() + .map(|(i, p)| MenuItem { id: format!("file.openRecent.{i}"), label: recent_name(p), path: path.clone(), shortcut: None, enabled: true, checked: None }) + .collect(); + sub.push(MenuItem { id: "file.clearRecent".into(), label: tr("Clear Recent Files"), path, shortcut: None, enabled: !recent.is_empty(), checked: None }); + items.splice(at + 1..at + 1, sub); +} + +/// A recent project as listed in File ▸ Open Recent and on Home: its file name. +pub fn recent_name(path: &str) -> String { + std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()) +} + /// The menu bar as it ships, without a running app: English labels, default preferences, the /// built-in workspaces, nothing checked. Deterministic, so `cargo xtask parity` can measure it /// (`examples/menu_tree.rs` prints it as JSON). @@ -692,6 +725,7 @@ pub fn default_menu_items() -> Vec { let session = filmcraft_engine::Session::default(); let mut v = menu_items_for(&session); crate::panels::workspaces::default_menu(&mut v, &session); + open_recent(&mut v, &session, str::to_string); v } diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index ef68c0aa..2af12cad 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -1,4 +1,5 @@ -//! Import mode (header "Import"): large browser cards for importing media and demo footage. +//! Import mode (header "Import", also Home): large browser cards for importing media and demo footage, +//! and the recent projects. use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -43,8 +44,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.mode = crate::state::Mode::Edit; } } + let y = recent(app, ui, rect, 90.0 + 150.0 + 36.0); // Community and project links. - let y = 90.0 + 150.0 + 36.0; // ArtCraft wordmark (first-party trademark, docs/brand/), then the section title. let logo = crate::brand::paint_wordmark(ui, rect.min + vec2(24.0, y), 15.0, app.ui.dark); let tx = logo.map_or(rect.min.x + 24.0, |r| r.max.x + 12.0); @@ -72,3 +73,45 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { x = r.max.x + 12.0; } } + +/// Recent projects listed on Home, as in File ▸ Open Recent. +const RECENT_ROWS: usize = 6; +const RECENT_ROW_H: f32 = 28.0; + +/// Home's Recent list at `y`: the recent projects (newest first, file name with its folder beside +/// it), a click opens one. Returns where the next section starts. +fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32 { + let paths: Vec = app.session.prefs.general.recent_projects.iter().take(RECENT_ROWS).cloned().collect(); + if paths.is_empty() { + return y; + } + let t = app.tokens; + ui.painter().text(rect.min + vec2(24.0, y), Align2::LEFT_CENTER, tl!("Recent"), Tokens::semibold(15.0), t.text); + let mut open = None; + for (i, path) in paths.iter().enumerate() { + let row = + Rect::from_min_size(rect.min + vec2(16.0, y + 16.0 + i as f32 * RECENT_ROW_H), vec2(560.0_f32.min(rect.width() - 32.0).max(0.0), RECENT_ROW_H)); + let name = crate::menus::recent_name(path); + let resp = ui.interact(row, egui::Id::new(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str()); + app.auto.add(&format!("import.recent.{i}"), row, &name); + if resp.hovered() { + ui.painter().rect_filled(row, 4.0, t.hover); + } + let p = ui.painter().with_clip_rect(row); + crate::icons::paint(&p, Rect::from_center_size(pos2(row.min.x + 16.0, row.center().y), vec2(14.0, 14.0)), crate::icons::Icon::Sequence, t.text_dim); + let name_r = p.text(pos2(row.min.x + 32.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(13.0), t.text); + if let Some(folder) = std::path::Path::new(path).parent() { + p.text(pos2(name_r.max.x + 12.0, row.center().y), Align2::LEFT_CENTER, folder.to_string_lossy(), Tokens::ui(11.5), t.text_dim); + } + if resp.clicked() { + open = Some(path.clone()); + } + } + if let Some(path) = open { + let ctx = ui.ctx().clone(); + if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() { + app.ui.mode = crate::state::Mode::Edit; + } + } + y + 16.0 + paths.len() as f32 * RECENT_ROW_H + 36.0 +} diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/menus_ui.rs index b3354b2d..0cdc81c3 100644 --- a/crates/ui-egui/tests/menus_ui.rs +++ b/crates/ui-egui/tests/menus_ui.rs @@ -322,3 +322,41 @@ fn reveal_in_finder_opens_an_imported_clip() { assert_eq!(d.app().ui.extras.opened.last().map(String::as_str), Some(path.to_string_lossy().as_ref())); let _ = std::fs::remove_dir_all(&dir); } + +/// A project saved for the first time is listed at the top of Home's Recent list and of File ▸ +/// Open Recent, both of which reopen it; Clear Recent Files empties the list (#1461). +#[test] +fn saved_projects_are_listed_in_home_and_open_recent() { + let dir = std::env::temp_dir().join(format!("fc-ui-recent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("first cut.fcproj").to_string_lossy().to_string(); + let b = dir.join("second cut.fcproj").to_string_lossy().to_string(); + let mut d = Driver::new(); + d.exec("file.saveAs", json!({"path": a})); + d.exec("file.newProject", json!({"name": "Second"})); + d.exec("file.saveAs", json!({"path": b})); + let menus = d.ok("ui.menu.list", json!({})); + let recent: Vec<&Value> = menus.as_array().unwrap().iter().filter(|m| m["path"] == json!(["File", "Open Recent"])).collect(); + let labels: Vec<&str> = recent.iter().map(|m| m["label"].as_str().unwrap()).collect(); + assert_eq!(labels, ["second cut.fcproj", "first cut.fcproj", "Clear Recent Files"]); + assert_eq!(recent[0]["id"], "file.openRecent.0"); + // File ▸ Open Recent reopens the older one, which moves to the top + d.menu("file.openRecent.1"); + assert_eq!(d.app().session.path.as_deref(), Some(a.as_str())); + assert_eq!(d.app().session.prefs.general.recent_projects, [a.clone(), b.clone()]); + // Home lists them too, newest first, and a click opens one + d.click("header.home"); + let rows = d.ok("ui.elements", json!({"prefix": "import.recent."})); + let rows: Vec<&str> = rows.as_array().unwrap().iter().map(|e| e["label"].as_str().unwrap()).collect(); + assert_eq!(rows, ["first cut.fcproj", "second cut.fcproj"]); + d.click("import.recent.1"); + assert_eq!(d.app().session.path.as_deref(), Some(b.as_str())); + // Clear Recent Files empties both lists + d.menu("file.clearRecent"); + assert!(d.app().session.prefs.general.recent_projects.is_empty()); + let menus = d.ok("ui.menu.list", json!({})); + let clear = menus.as_array().unwrap().iter().find(|m| m["id"] == "file.clearRecent").cloned().expect("Clear Recent Files"); + assert_eq!(clear["enabled"], false); + assert!(!menus.as_array().unwrap().iter().any(|m| m["id"].as_str().is_some_and(|id| id.starts_with("file.openRecent.")))); + let _ = std::fs::remove_dir_all(&dir); +}