Skip to content

Commit dac28de

Browse files
committed
feat: add recent files list and entry point
Add a recent-files model, widget, and home-screen integration so users can quickly reopen recently accessed workbooks.
1 parent 21ac731 commit dac28de

15 files changed

Lines changed: 432 additions & 63 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ edition = "2024"
99
[dependencies]
1010
color-eyre = "0.6.3"
1111
crossterm = "0.29.0"
12+
dirs-next = "2"
1213
ratatui = "0.30.1"
1314
arboard = "3"
1415
replace-homedir = "0.1"

src/app.rs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::time::{Duration, Instant};
1+
use std::{
2+
path::PathBuf,
3+
time::{Duration, Instant},
4+
};
25

36
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
47
use ratatui::{
@@ -10,6 +13,7 @@ use ratatui::{
1013

1114
use crate::{
1215
exit::ExitHandler,
16+
model::{recent::RecentFiles, table_path::resolve_table_path},
1317
screen::{EventResult, FrameState, Screen, ScreenCommand, home::MenuScreen},
1418
theme::Theme,
1519
widget::footer::Footer,
@@ -44,25 +48,33 @@ impl BlinkState {
4448

4549
pub struct App {
4650
theme: Theme,
47-
cwd: String,
51+
cwd: PathBuf,
52+
recent: RecentFiles,
4853
active_screen: Box<dyn Screen>,
4954
exit_handler: ExitHandler,
5055
blink: BlinkState,
5156
footer: Footer,
5257
}
5358

5459
impl App {
55-
pub fn new() -> Self {
56-
let full_cwd = std::env::current_dir()
57-
.map(|p| p.display().to_string())
58-
.unwrap_or_else(|_| String::from("."));
59-
let display_cwd = replace_homedir::replace_homedir(&full_cwd, "~");
60+
pub fn new(initial_file: Option<String>) -> Self {
61+
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
62+
let display_cwd = replace_homedir::replace_homedir(&cwd.display().to_string(), "~");
6063
let theme = Theme::dark();
64+
let mut recent = RecentFiles::load();
65+
let active_screen: Box<dyn Screen> = if let Some(file) = initial_file {
66+
let path = resolve_table_path(&file, &cwd);
67+
recent.add(&path);
68+
Box::new(crate::screen::editor::TableScreen::new(theme, path))
69+
} else {
70+
Box::new(MenuScreen::new(theme, cwd.clone(), recent.items().to_vec()))
71+
};
6172

6273
Self {
6374
theme,
64-
cwd: full_cwd.clone(),
65-
active_screen: Box::new(MenuScreen::new(theme, full_cwd)),
75+
cwd,
76+
recent,
77+
active_screen,
6678
exit_handler: ExitHandler::new(Duration::from_secs(1)),
6779
blink: BlinkState::new(),
6880
footer: Footer::new(display_cwd, APP_VERSION.to_string(), theme),
@@ -151,11 +163,24 @@ impl App {
151163
fn process_cmd(&mut self, cmd: ScreenCommand) {
152164
match cmd {
153165
ScreenCommand::OpenEditor { path } => {
166+
self.recent.add(&path);
154167
self.active_screen =
155168
Box::new(super::screen::editor::TableScreen::new(self.theme, path));
156169
}
170+
ScreenCommand::RemoveRecent { path } => {
171+
self.recent.remove(&path);
172+
self.active_screen = Box::new(MenuScreen::new(
173+
self.theme,
174+
self.cwd.clone(),
175+
self.recent.items().to_vec(),
176+
));
177+
}
157178
ScreenCommand::GoHome => {
158-
self.active_screen = Box::new(MenuScreen::new(self.theme, self.cwd.clone()));
179+
self.active_screen = Box::new(MenuScreen::new(
180+
self.theme,
181+
self.cwd.clone(),
182+
self.recent.items().to_vec(),
183+
));
159184
}
160185
}
161186
}

src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ mod widget;
1212

1313
fn main() -> color_eyre::Result<()> {
1414
color_eyre::install()?;
15-
let mut app = app::App::new();
15+
let initial_file = std::env::args().nth(1);
16+
let mut app = app::App::new(initial_file);
1617
ratatui::run(|terminal| {
1718
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
1819
let result = app.run(terminal);

src/model/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
pub mod cell;
22
pub mod formula;
33
pub mod limits;
4+
pub mod recent;
5+
pub mod table_path;
46
pub mod workbook;

src/model/recent.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::{
3+
fs,
4+
path::{Path, PathBuf},
5+
time::{SystemTime, UNIX_EPOCH},
6+
};
7+
8+
const RECENT_LIMIT: usize = 10;
9+
10+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11+
pub struct RecentFile {
12+
pub path: PathBuf,
13+
pub name: String,
14+
pub opened_at: u64,
15+
}
16+
17+
/// 最近打开文件的数据源。
18+
///
19+
/// App 持有该类型;UI 只读取 `items()` 并通过命令请求修改。
20+
pub struct RecentFiles {
21+
storage_path: PathBuf,
22+
items: Vec<RecentFile>,
23+
}
24+
25+
impl RecentFiles {
26+
pub fn load() -> Self {
27+
let storage_path = recent_json_path();
28+
let items = fs::read_to_string(&storage_path)
29+
.ok()
30+
.and_then(|raw| serde_json::from_str::<Vec<RecentFile>>(&raw).ok())
31+
.map(normalize_items)
32+
.unwrap_or_default();
33+
34+
Self {
35+
storage_path,
36+
items,
37+
}
38+
}
39+
40+
pub fn items(&self) -> &[RecentFile] {
41+
&self.items
42+
}
43+
44+
pub fn add(&mut self, path: impl AsRef<Path>) {
45+
let path = stable_path(path.as_ref());
46+
self.items
47+
.retain(|file| !same_stable_path(&file.path, &path));
48+
self.items.insert(
49+
0,
50+
RecentFile {
51+
name: display_name(&path),
52+
path,
53+
opened_at: now_secs(),
54+
},
55+
);
56+
self.items.truncate(RECENT_LIMIT);
57+
self.save();
58+
}
59+
60+
pub fn remove(&mut self, path: impl AsRef<Path>) {
61+
let path = stable_path(path.as_ref());
62+
self.items
63+
.retain(|file| !same_stable_path(&file.path, &path));
64+
self.save();
65+
}
66+
67+
fn save(&self) {
68+
if let Some(parent) = self.storage_path.parent() {
69+
let _ = fs::create_dir_all(parent);
70+
}
71+
if let Ok(json) = serde_json::to_string_pretty(&self.items) {
72+
let _ = fs::write(&self.storage_path, json);
73+
}
74+
}
75+
}
76+
77+
pub fn recent_json_path() -> PathBuf {
78+
dirs_next::config_dir()
79+
.unwrap_or_else(|| PathBuf::from("."))
80+
.join("mini-excel")
81+
.join("recent.json")
82+
}
83+
84+
fn normalize_items(mut items: Vec<RecentFile>) -> Vec<RecentFile> {
85+
items.sort_by(|a, b| b.opened_at.cmp(&a.opened_at));
86+
87+
let mut unique = Vec::new();
88+
for item in items {
89+
let path = stable_path(&item.path);
90+
if unique.iter().any(|file: &RecentFile| file.path == path) {
91+
continue;
92+
}
93+
unique.push(RecentFile {
94+
name: display_name(&path),
95+
path,
96+
opened_at: item.opened_at,
97+
});
98+
if unique.len() == RECENT_LIMIT {
99+
break;
100+
}
101+
}
102+
unique
103+
}
104+
105+
fn stable_path(path: &Path) -> PathBuf {
106+
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
107+
}
108+
109+
fn same_stable_path(candidate: &Path, stable: &Path) -> bool {
110+
stable_path(candidate) == stable
111+
}
112+
113+
fn display_name(path: &Path) -> String {
114+
path.file_name()
115+
.and_then(|name| name.to_str())
116+
.unwrap_or("untitled.mxlsx")
117+
.to_string()
118+
}
119+
120+
fn now_secs() -> u64 {
121+
SystemTime::now()
122+
.duration_since(UNIX_EPOCH)
123+
.unwrap_or_default()
124+
.as_secs()
125+
}

src/model/table_path.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use std::path::{Path, PathBuf};
2+
3+
const TABLE_EXTENSION: &str = "mxlsx";
4+
5+
pub fn resolve_table_path(input: &str, cwd: &Path) -> PathBuf {
6+
let raw = PathBuf::from(input);
7+
let with_extension = if raw.extension().is_some() {
8+
raw
9+
} else {
10+
raw.with_extension(TABLE_EXTENSION)
11+
};
12+
13+
let absolute = if with_extension.is_absolute() {
14+
with_extension
15+
} else {
16+
cwd.join(with_extension)
17+
};
18+
19+
absolute
20+
}

src/screen/editor/context.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::{
66
screen::ScreenCommand,
77
theme::Theme,
88
};
9+
use std::path::PathBuf;
910

1011
use super::{mode::Selection, viewport::Viewport};
1112

@@ -16,7 +17,7 @@ use super::{mode::Selection, viewport::Viewport};
1617
pub struct TableContext {
1718
pub theme: Theme,
1819
pub viewport: Viewport,
19-
path: String,
20+
path: PathBuf,
2021
wb: Workbook,
2122
selection: Option<Selection>,
2223
copied_region: Option<Selection>,
@@ -35,7 +36,7 @@ pub struct SelectionStats {
3536

3637
impl TableContext {
3738
/// 创建编辑器共享状态。
38-
pub fn new(theme: Theme, path: String, wb: Workbook) -> Self {
39+
pub fn new(theme: Theme, path: PathBuf, wb: Workbook) -> Self {
3940
Self {
4041
theme,
4142
path,

src/screen/editor/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod viewport;
99

1010
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
1111
use ratatui::{Frame, layout::Rect, style::Style, widgets::Block};
12+
use std::path::PathBuf;
1213

1314
use crate::{
1415
model::{
@@ -34,9 +35,9 @@ pub struct TableScreen {
3435
}
3536

3637
impl TableScreen {
37-
pub fn new(theme: Theme, path: String) -> Self {
38+
pub fn new(theme: Theme, path: PathBuf) -> Self {
3839
let wb = Workbook::load(&path).unwrap_or_else(|_| {
39-
let name = std::path::Path::new(&path)
40+
let name = path
4041
.file_stem()
4142
.and_then(|s| s.to_str())
4243
.unwrap_or("untitled")

0 commit comments

Comments
 (0)