Merge pull request #1471 from marcoarnulfo/fix/recent-projects

fix(home): list recent projects on Home and in File ▸ Open Recent
This commit is contained in:
Brandon Thomas committed 2026-10-13 00:03:43 -04:00
commit 7bc739bfae
12 files changed
+141 -2

No files matched your search

+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 45.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 3 and column 95.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 83.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 14.
+3
View File
@@ -231,6 +231,9 @@
Normalize Mix Track… ミックストラックをノーマライズ…
Offline File… オフラインファイル…
Open Project… プロジェクトを開く…
Open Recent 最近使ったファイルを開く
Clear Recent Files 最近使ったファイルを消去
Recent 最近
Optical Flow オプティカルフロー
Overwrite 上書き
Paste 貼り付け
Can't render this file because it contains an unexpected character in line 11 and column 59.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 4.
+2
View File
@@ -221,6 +221,8 @@
Normalize Mix Track… Нормализовать мастер-дорожку…
Offline File… Автономный файл…
Open Project… Открыть проект…
Open Recent Открыть недавние
Clear Recent Files Очистить список недавних файлов
Optical Flow Оптический поток
Overwrite Перезаписать
Paste Вставить
1 # Russian (ru) catalog. Columns separated by TAB: context <TAB> source <TAB> translation
221
222
223
224
225
226
227
228
+2
View File
@@ -222,6 +222,8 @@
Normalize Mix Track… Нормалізувати доріжку міксу…
Offline File… Автономний файл…
Open Project… Відкрити проєкт…
Open Recent Нещодавні файли
Clear Recent Files Очистити список нещодавніх файлів
Optical Flow Оптичний потік
Overwrite Перезаписати
Paste Вставити
1 # Ukrainian (uk) catalog. Columns separated by TAB: context <TAB> source <TAB> translation
222
223
224
225
226
227
228
229
+3
View File
@@ -225,6 +225,9 @@
Normalize Mix Track… 标准化混音轨道…
Offline File… 脱机文件…
Open Project… 打开项目…
Open Recent 打开最近文件
Clear Recent Files 清除最近文件
Recent 最近使用
Optical Flow 光流
Overwrite 覆盖
Paste 粘贴
Can't render this file because it contains an unexpected character in line 3 and column 95.
+34
View File
@@ -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::<usize>().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<MenuItem> {
}
}
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<MenuItem>, 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<String> = vec!["File".into(), "Open Recent".into()];
let recent = &session.prefs.general.recent_projects;
let mut sub: Vec<MenuItem> = 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<MenuItem> {
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
}
+45 -2
View File
@@ -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<String> = 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
}
+38
View File
@@ -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);
}