M7: recent files as thumbnail cards on the home screen (#249)

The Recents section shows recent files as cards: the picture each file
carries (thumbnail.png in .figcraft and .fig, an image file itself) fitted
on its first page's colour, a divider, and a footer with the file's icon,
name and "Edited ... ago" (or "File not found"); the full path on hover.
Pictures are read off the frame's thread, only for cards in view, cached
by path and modification time, capped in bytes and pixels; missing,
damaged or oversized ones give a placeholder. They fade in, and cards
show a skeleton until their file has been looked at.

A sort menu (Alphabetical, Date created, Last modified; either order) and
a grid/list toggle sit above them. The list is a Name, Last modified and
Created table whose headers sort. View (per section) and sort are kept in
the preferences and set with ui.set {homeRecentsView, homeRecentsSort,
homeRecentsOrder}. A click selects, a double-click or Enter opens, arrow
keys move the selection; a right-click offers Open, Show in folder and
Remove from recents (file.openRecent {remove}).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
rcoenen
2026-10-14 12:00:20 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 bflatastic
parent b34c5de2ab
commit bcedfeba10
21 changed files with 1982 additions and 24 deletions
+4 -1
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.openRecent",
"Open recent",
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
"{path? | index? (into the list), clear? (forget them all), remove? (a path: forget that one)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
false,
open_recent
),
@@ -625,6 +625,9 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
}
if let Some(gone) = string(p, "remove") {
s.prefs.recent_files.retain(|r| *r != gone);
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
+11
View File
@@ -591,6 +591,14 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub home_views: BTreeMap<String, String>,
/// What the home screen's recent files are ordered by: `"modified"`, `"name"` or `"created"`.
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
}
impl Default for Prefs {
@@ -616,6 +624,9 @@ impl Default for Prefs {
recent_files: Vec::new(),
online_files: Vec::new(),
getting_started_dismissed: false,
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
}
}
}
+13
View File
@@ -33,6 +33,19 @@ fn open_recent_lists_opens_prunes_and_clears() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_recent_file_is_forgotten_and_nothing_opens() {
let mut s = Session::empty();
s.prefs.recent_files = vec!["/tmp/a.figcraft".into(), "/tmp/b.fig".into(), "/tmp/c.svg".into()];
let list = s.execute("file.openRecent", &json!({"remove": "/tmp/b.fig"})).unwrap();
assert_eq!(s.prefs.recent_files, vec!["/tmp/a.figcraft".to_string(), "/tmp/c.svg".to_string()]);
assert_eq!(list["files"].as_array().map(Vec::len), Some(2), "the list as it is now");
assert!(s.docs.is_empty(), "nothing opens");
// A path that isn't in the list changes nothing.
s.execute("file.openRecent", &json!({"remove": "/tmp/zzz.fig"})).unwrap();
assert_eq!(s.prefs.recent_files.len(), 2);
}
#[test]
fn recent_files_are_capped() {
let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id()));