Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions crates/edit/src/bin/edit/draw_filepicker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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));
Expand All @@ -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))
{
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 10 additions & 3 deletions crates/edit/src/bin/edit/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,16 @@ fn handle_args(state: &mut State) -> apperr::Result<bool> {
}

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);
}
}

Expand Down
132 changes: 128 additions & 4 deletions crates/edit/src/bin/edit/settings.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -12,6 +13,7 @@ use crate::apperr;
pub struct Settings {
pub path: PathBuf,
pub file_associations: Vec<(String, &'static Language)>,
pub recent_files: Vec<PathBuf>,
}

struct SettingsCell(SemiRefCell<Settings>);
Expand All @@ -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> {
Expand Down Expand Up @@ -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<PathBuf> {
Expand Down Expand Up @@ -117,3 +215,29 @@ fn config_dir() -> Option<PathBuf> {
.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"));
}
}
10 changes: 10 additions & 0 deletions i18n/edit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "حفظ"
Expand Down