From b1eb199e7bea7742f632179d61a1132409535b7d Mon Sep 17 00:00:00 2001 From: "KOSMOS, Tzushih.K" <48860861+kisaraki@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:50:23 +0800 Subject: [PATCH] feat: show recent files in open dialog --- crates/edit/src/bin/edit/draw_filepicker.rs | 63 +++++++++- crates/edit/src/bin/edit/main.rs | 13 +- crates/edit/src/bin/edit/settings.rs | 132 +++++++++++++++++++- i18n/edit.toml | 10 ++ 4 files changed, 207 insertions(+), 11 deletions(-) diff --git a/crates/edit/src/bin/edit/draw_filepicker.rs b/crates/edit/src/bin/edit/draw_filepicker.rs index e71b7f81ec3..d3355528b5a 100644 --- a/crates/edit/src/bin/edit/draw_filepicker.rs +++ b/crates/edit/src/bin/edit/draw_filepicker.rs @@ -14,6 +14,7 @@ use stdext::arena::scratch_arena; use stdext::collections::BVec; use crate::localization::*; +use crate::settings::Settings; use crate::state::*; pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { @@ -27,9 +28,21 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } } + // EN: Only Open receives a lower MRU pane; Save As keeps the original layout. + // 中文:僅「開啟舊檔」加入下方最近檔案欄;另存新檔維持原配置。 + let opening_file = state.wants_file_picker == StateFilePicker::Open; + let recent_files = + if opening_file { Settings::borrow().recent_files.clone() } else { Vec::new() }; let width = (ctx.size().width - 20).max(10); - let height = (ctx.size().height - 10).max(10); + let height = if opening_file { + (ctx.size().height - 4).max(12) + } else { + (ctx.size().height - 10).max(10) + }; + let recent_pane_height = if opening_file { (height / 3).clamp(1, 5) } else { 0 }; + let recent_reserved_rows = if opening_file { recent_pane_height + 3 } else { 0 }; let mut doit = None; + let mut recent_open = None; let mut done = false; ctx.modal_begin( @@ -144,7 +157,7 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { // -1 for the label (top) // -1 for the label (bottom) // -1 for the editline (bottom) - height: height - 3, + height: (height - 3 - recent_reserved_rows).max(3), }, ); ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 1, 4)); @@ -170,6 +183,40 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } ctx.scrollarea_end(); + if opening_file { + // EN: Every absolute path is a clickable entry in the lower pane. + // 中文:每一筆絕對路徑都是下方窗格中可點選開啟的項目。 + ctx.label("recent-files-label", loc(LocId::FileOpenRecentFiles)); + ctx.attr_padding(Rect::three(1, 1, 0)); + ctx.scrollarea_begin( + "recent-files-pane", + Size { width: 0, height: recent_pane_height }, + ); + ctx.attr_border(); + ctx.attr_focus_well(); + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 1, 4)); + { + if recent_files.is_empty() { + ctx.label("recent-files-empty", loc(LocId::FileOpenRecentEmpty)); + ctx.attr_padding(Rect::two(0, 1)); + } else { + for recent in &recent_files { + let display = recent.to_string_lossy(); + if ctx.button( + "recent-file", + &display, + ButtonStyle::default().bracketed(false), + ) { + recent_open = Some(recent.clone()); + } + ctx.attr_overflow(Overflow::TruncateMiddle); + ctx.attr_padding(Rect::two(0, 1)); + } + } + } + ctx.scrollarea_end(); + } + if contains_focus && (ctx.consume_shortcut(vk::BACK) || ctx.consume_shortcut(kbmod::ALT | vk::UP)) { @@ -188,6 +235,10 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { state.file_picker_overwrite_warning = doit.take(); } } + + if let Some(path) = recent_open.take() { + doit = Some(path); + } } if ctx.modal_end() { done = true; @@ -241,15 +292,19 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } if let Some(path) = doit { - let res = if state.wants_file_picker == StateFilePicker::Open { + let opening_file = state.wants_file_picker == StateFilePicker::Open; + let res = if opening_file { state.documents.add_file_path(&path).map(|_| ()) } else if let Some(doc) = state.documents.active_mut() { - doc.save(Some(path)) + doc.save(Some(path.clone())) } else { Ok(()) }; match res { Ok(..) => { + if opening_file && let Err(err) = Settings::record_recent_file(&path) { + error_log_add(ctx, state, err); + } ctx.needs_rerender(); done = true; } diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 27ae7fab6c0..25531a6db5f 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -289,9 +289,16 @@ fn handle_args(state: &mut State) -> apperr::Result { } for (p, goto) in &paths { - let doc = state.documents.add_file_path(p)?; - if let Some(goto) = goto { - doc.cursor_move_to_goto(*goto); + { + let doc = state.documents.add_file_path(p)?; + if let Some(goto) = goto { + doc.cursor_move_to_goto(*goto); + } + } + // EN: Command-line and shell opens participate in the same recent-file list. + // 中文:命令列與系統殼層開啟的檔案也納入同一份最近檔案清單。 + if let Err(err) = Settings::record_recent_file(p) { + state.add_error(err); } } diff --git a/crates/edit/src/bin/edit/settings.rs b/crates/edit/src/bin/edit/settings.rs index e29b0970a9b..93c78be8d2b 100644 --- a/crates/edit/src/bin/edit/settings.rs +++ b/crates/edit/src/bin/edit/settings.rs @@ -1,9 +1,10 @@ -use std::path::PathBuf; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; use edit::buffer::TextBuffer; use edit::cell::{Ref, SemiRefCell}; -use edit::json; use edit::lsh::{LANGUAGES, Language}; +use edit::{json, path as edit_path}; use stdext::arena::{read_to_string, scratch_arena}; use stdext::arena_format; @@ -12,6 +13,7 @@ use crate::apperr; pub struct Settings { pub path: PathBuf, pub file_associations: Vec<(String, &'static Language)>, + pub recent_files: Vec, } struct SettingsCell(SemiRefCell); @@ -22,13 +24,14 @@ impl Settings { /// Fills the given settings.json text buffer with some initial contents for convenience. pub fn bootstrap(tb: &mut TextBuffer) { tb.set_crlf(false); - tb.write_raw(b"{\n}\n"); + let contents = Self::borrow().to_json(); + tb.write_raw(contents.as_bytes()); tb.cursor_move_to_logical(Default::default()); tb.mark_as_clean(); } const fn new() -> Self { - Settings { path: PathBuf::new(), file_associations: Vec::new() } + Settings { path: PathBuf::new(), file_associations: Vec::new(), recent_files: Vec::new() } } pub fn borrow() -> Ref<'static, Settings> { @@ -82,8 +85,103 @@ impl Settings { } } + // EN: Persist at most five unique absolute paths, newest first. + // 中文:最近開啟檔案最多保存五筆不重複的絕對路徑,最新項目在前。 + if let Some(value) = root.get("files.recent") { + let Some(values) = value.as_array() else { + return Err(apperr::Error::SettingsInvalid("files.recent")); + }; + for value in values.iter().take(5) { + let Some(value) = value.as_str() else { + return Err(apperr::Error::SettingsInvalid("files.recent")); + }; + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(apperr::Error::SettingsInvalid("files.recent")); + } + let path = edit_path::normalize(&path); + if !self.recent_files.contains(&path) { + self.recent_files.push(path); + } + } + } + Ok(()) } + + /// EN: Moves a successfully opened file to the front of the recent-file list. + /// 中文:將成功開啟的檔案移至最近檔案清單最前方。 + pub fn record_recent_file(path: &Path) -> apperr::Result<()> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let absolute = edit_path::normalize(&absolute); + let settings = &mut *SETTINGS.0.borrow_mut(); + settings.remember_recent_file(absolute); + settings.save() + } + + fn remember_recent_file(&mut self, path: PathBuf) { + self.recent_files.retain(|recent| recent != &path); + self.recent_files.insert(0, path); + self.recent_files.truncate(5); + } + + fn save(&self) -> apperr::Result<()> { + if self.path.as_os_str().is_empty() { + return Ok(()); + } + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&self.path, self.to_json())?; + Ok(()) + } + + fn to_json(&self) -> String { + // EN: Serialize generated settings with LF on every supported platform. + // 中文:自行序列化設定,確保所有支援平台皆固定使用 LF。 + let mut contents = String::from("{\n \"files.associations\": {"); + for (index, (pattern, language)) in self.file_associations.iter().enumerate() { + contents.push_str(if index == 0 { "\n " } else { ",\n " }); + write_json_string(&mut contents, pattern); + contents.push_str(": "); + write_json_string(&mut contents, language.id); + } + if !self.file_associations.is_empty() { + contents.push_str("\n "); + } + contents.push_str("},\n \"files.recent\": ["); + for (index, path) in self.recent_files.iter().enumerate() { + contents.push_str(if index == 0 { "\n " } else { ",\n " }); + write_json_string(&mut contents, &path.to_string_lossy()); + } + if !self.recent_files.is_empty() { + contents.push_str("\n "); + } + contents.push_str("]\n}\n"); + contents + } +} + +fn write_json_string(output: &mut String, value: &str) { + output.push('"'); + for ch in value.chars() { + match ch { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{08}' => output.push_str("\\b"), + '\u{0c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + '\0'..='\u{1f}' => _ = write!(output, "\\u{:04x}", ch as u32), + _ => output.push(ch), + } + } + output.push('"'); } fn settings_json_path() -> Option { @@ -117,3 +215,29 @@ fn config_dir() -> Option { .map(|p| push(p, "msedit")) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recent_files_keep_the_newest_unique_five_paths() { + let mut settings = Settings::new(); + for name in ["one", "two", "three", "four", "five", "six", "four"] { + settings.remember_recent_file(PathBuf::from(name)); + } + assert_eq!( + settings.recent_files, + ["four", "six", "five", "three", "two"].map(PathBuf::from) + ); + } + + #[test] + fn recent_file_json_uses_lf() { + let mut settings = Settings::new(); + settings.recent_files.push(PathBuf::from("C:/notes/readme.md")); + let json = settings.to_json(); + assert!(json.contains("\"files.recent\"")); + assert!(!json.contains("\r\n")); + } +} diff --git a/i18n/edit.toml b/i18n/edit.toml index 11039636308..23f68968b2d 100644 --- a/i18n/edit.toml +++ b/i18n/edit.toml @@ -359,6 +359,16 @@ vi = "Mở tệp…" zh-hans = "打开文件…" zh-hant = "開啟舊檔…" +[FileOpenRecentFiles] +en = "Recent files (last 5; click to open)" +zh-hans = "最近打开的文件(最多5笔,单击打开)" +zh-hant = "最近開啟的檔案(最多5筆,單擊開啟)" + +[FileOpenRecentEmpty] +en = "No recently opened files" +zh-hans = "没有最近打开的文件" +zh-hant = "沒有最近開啟的檔案" + [FileSave] en = "Save" ar = "حفظ"