use crate::command::chat::storage::load_agent_config;
use crate::config::YamlConfig;
use crate::constants::{config_key, section, shell};
use crate::error;
use crate::info;
use crate::theme::Theme;
use chrono::{DateTime, Local};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use super::types::ExpandedDirs;
pub fn notebook_dir() -> PathBuf {
YamlConfig::notebook_dir()
}
pub fn note_file_path(name: &str) -> PathBuf {
notebook_dir().join(format!("{}.md", name))
}
pub fn load_panel_ratio() -> Option<u16> {
YamlConfig::load()
.get_property(section::SETTING, config_key::NOTEBOOK_PANEL_RATIO)
.and_then(|v| v.parse().ok())
}
pub fn save_panel_ratio(ratio: u16) {
let mut config = YamlConfig::load();
config.set_property(
section::SETTING,
config_key::NOTEBOOK_PANEL_RATIO,
&ratio.to_string(),
);
}
pub fn load_expanded_dirs() -> ExpandedDirs {
YamlConfig::load()
.get_property(section::SETTING, config_key::NOTEBOOK_EXPANDED_DIRS)
.and_then(|v| serde_json::from_str(v).ok())
.unwrap_or_default()
}
pub fn save_expanded_dirs(dirs: &ExpandedDirs) {
if let Ok(json) = serde_json::to_string(dirs) {
let mut config = YamlConfig::load();
config.set_property(section::SETTING, config_key::NOTEBOOK_EXPANDED_DIRS, &json);
}
}
pub fn load_notes() -> Vec<super::types::NoteItem> {
let dir = notebook_dir();
let mut notes = Vec::new();
walk_dir_for_notes(&dir, "", &mut notes);
notes.sort_by_key(|b| std::cmp::Reverse(b.mtime));
notes
}
fn walk_dir_for_notes(
dir: &std::path::Path,
prefix: &str,
notes: &mut Vec<super::types::NoteItem>,
) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
if dir_name.starts_with('.') {
continue;
}
let sub_prefix = if prefix.is_empty() {
dir_name.to_string()
} else {
format!("{}/{}", prefix, dir_name)
};
walk_dir_for_notes(&path, &sub_prefix, notes);
} else if path.extension().is_some_and(|ext| ext == "md") {
let stem = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let note_path = if prefix.is_empty() {
stem
} else {
format!("{}/{}", prefix, stem)
};
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(std::time::UNIX_EPOCH);
notes.push(super::types::NoteItem {
path: note_path,
mtime,
});
}
}
}
}
pub fn list_dirs() -> Vec<String> {
let dir = notebook_dir();
let mut dirs = Vec::new();
walk_dir_for_dirs(&dir, "", &mut dirs);
dirs.sort();
dirs
}
fn walk_dir_for_dirs(dir: &std::path::Path, prefix: &str, dirs: &mut Vec<String>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
if dir_name.starts_with('.') {
continue;
}
let dir_path = if prefix.is_empty() {
dir_name.to_string()
} else {
format!("{}/{}", prefix, dir_name)
};
dirs.push(dir_path.clone());
walk_dir_for_dirs(&path, &dir_path, dirs);
}
}
}
}
pub fn read_note_content(name: &str) -> Option<String> {
let path = note_file_path(name);
fs::read_to_string(path).ok()
}
pub fn format_time(time: std::time::SystemTime) -> String {
let dt: DateTime<Local> = time.into();
dt.format("%Y-%m-%d %H:%M").to_string()
}
pub fn edit_note_on_terminal(
title: &str,
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
) -> bool {
let file_path = note_file_path(title);
let (content, is_new) = read_note_content_or_new(&file_path);
let editor_title = if is_new {
format!("{} (新笔记)", title)
} else {
title.to_string()
};
let theme = Theme::from_name(&load_agent_config().theme);
match crate::tui::editor_markdown::open_markdown_editor_on_terminal(
terminal,
&editor_title,
&content,
&theme,
) {
Ok((Some(new_content), _)) => save_if_changed(&file_path, title, &content, &new_content),
Ok((None, _)) => {
info!("已取消编辑");
false
}
Err(e) => {
error!("编辑器启动失败: {}", e);
false
}
}
}
pub fn edit_note_with_editor(title: &str) -> bool {
let file_path = note_file_path(title);
let (content, is_new) = read_note_content_or_new(&file_path);
let editor_title = if is_new {
format!("{} (新笔记)", title)
} else {
title.to_string()
};
let theme = Theme::from_name(&load_agent_config().theme);
match crate::tui::editor_markdown::open_markdown_editor(&editor_title, &content, &theme) {
Ok((Some(new_content), _)) => save_if_changed(&file_path, title, &content, &new_content),
Ok((None, _)) => {
info!("已取消编辑");
false
}
Err(e) => {
error!("编辑器启动失败: {}", e);
false
}
}
}
fn read_note_content_or_new(path: &std::path::Path) -> (String, bool) {
if path.exists() {
match fs::read_to_string(path) {
Ok(c) => (c, false),
Err(e) => {
error!("读取笔记失败: {}", e);
(String::new(), true)
}
}
} else {
(String::new(), true)
}
}
fn save_if_changed(
file_path: &std::path::Path,
title: &str,
old_content: &str,
new_content: &str,
) -> bool {
if new_content != old_content {
match fs::write(file_path, new_content) {
Ok(()) => {
info!("笔记已保存: {}", title);
return true;
}
Err(e) => error!("保存笔记失败: {}", e),
}
} else {
info!("内容未变化,跳过保存");
}
false
}
pub fn copy_to_clipboard(content: &str) -> bool {
use std::io::Write;
use std::process::{Command, Stdio};
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("pbcopy", vec![])
} else if cfg!(target_os = "linux") {
if Command::new("which")
.arg("xclip")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
("xclip", vec!["-selection", "clipboard"])
} else {
("xsel", vec!["--clipboard", "--input"])
}
} else {
return false;
};
let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
match child {
Ok(mut child) => {
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write_all(content.as_bytes());
}
child.wait().map(|s| s.success()).unwrap_or(false)
}
Err(_) => false,
}
}
pub fn open_in_finder() {
let dir = notebook_dir();
let path = dir.to_string_lossy().to_string();
let os = std::env::consts::OS;
let result = if os == shell::MACOS_OS {
Command::new("open").arg(&path).status()
} else if os == shell::WINDOWS_OS {
Command::new(shell::WINDOWS_CMD)
.args([shell::WINDOWS_CMD_FLAG, "start", "", &path])
.status()
} else {
Command::new("xdg-open").arg(&path).status()
};
if let Err(e) = result {
error!("打开目录失败: {}", e);
}
}
pub fn cleanup_empty_dirs() {
let dir = notebook_dir();
let all_dirs = list_dirs();
let mut sorted_dirs: Vec<String> = all_dirs;
sorted_dirs.sort_by_key(|b| std::cmp::Reverse(b.matches('/').count()));
for dir_path in &sorted_dirs {
let full_path = dir.join(dir_path);
if full_path.is_dir() {
if let Ok(entries) = fs::read_dir(&full_path) {
let has_content = entries
.flat_map(|e| e.ok())
.any(|e| e.path().is_file() || e.path().is_dir());
if !has_content {
let _ = fs::remove_dir(&full_path);
}
}
}
}
}
pub fn parse_ratio(input: &str) -> Option<u16> {
let parts: Vec<&str> = input.split(':').collect();
if parts.len() != 2 {
return None;
}
let left: u16 = parts[0].parse().ok()?;
let right: u16 = parts[1].parse().ok()?;
if left == 0 || right == 0 {
return None;
}
let pct = left * 100 / (left + right);
Some(pct.clamp(15, 60))
}