use crate::command::chat::markdown::markdown_to_lines;
use crate::command::chat::render::theme::Theme;
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::util::fuzzy;
use chrono::{DateTime, Local};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::text::Line;
use ratatui::widgets::ListState;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct NoteItem {
pub path: String,
pub mtime: std::time::SystemTime,
}
impl NoteItem {
pub fn display_name(&self) -> &str {
self.path.rsplit('/').next().unwrap_or(&self.path)
}
pub fn parent_dir(&self) -> Option<&str> {
self.path.rsplit_once('/').map(|(dir, _)| dir)
}
}
pub fn notebook_dir() -> PathBuf {
crate::config::YamlConfig::notebook_dir()
}
fn load_panel_ratio() -> Option<u16> {
YamlConfig::load()
.get_property(section::SETTING, config_key::NOTEBOOK_PANEL_RATIO)
.and_then(|v| v.parse().ok())
}
fn save_panel_ratio(ratio: u16) {
let mut config = YamlConfig::load();
config.set_property(
section::SETTING,
config_key::NOTEBOOK_PANEL_RATIO,
&ratio.to_string(),
);
}
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()
}
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);
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ExpandedDirs(pub std::collections::HashSet<String>);
impl ExpandedDirs {
pub fn new() -> Self {
Self(std::collections::HashSet::new())
}
pub fn is_expanded(&self, dir_path: &str) -> bool {
self.0.contains(dir_path)
}
pub fn toggle(&mut self, dir_path: &str) {
if self.0.contains(dir_path) {
self.0.remove(dir_path);
} else {
self.0.insert(dir_path.to_string());
}
}
}
impl Default for ExpandedDirs {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct FlatEntry {
pub kind: FlatEntryKind,
pub guide: String,
}
#[derive(Debug, Clone)]
pub enum FlatEntryKind {
File { note_index: usize },
Dir {
dir_path: String,
name: String,
file_count: usize,
},
}
pub fn note_file_path(name: &str) -> PathBuf {
notebook_dir().join(format!("{}.md", name))
}
pub fn load_notes() -> Vec<NoteItem> {
let dir = notebook_dir();
let mut notes = Vec::new();
walk_dir_for_notes(&dir, "", &mut notes);
notes.sort_by(|a, b| b.mtime.cmp(&a.mtime));
notes
}
fn walk_dir_for_notes(dir: &std::path::Path, prefix: &str, notes: &mut Vec<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(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) = if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(c) => (c, false),
Err(e) => {
error!("读取笔记失败: {}", e);
return false;
}
}
} else {
(String::new(), true)
};
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), _)) => {
if new_content != content {
match fs::write(&file_path, &new_content) {
Ok(()) => {
info!("笔记已保存: {}", title);
return true;
}
Err(e) => error!("保存笔记失败: {}", e),
}
} else {
info!("内容未变化,跳过保存");
}
}
Ok((None, _)) => info!("已取消编辑"),
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) = if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(c) => (c, false),
Err(e) => {
error!("读取笔记失败: {}", e);
return false;
}
}
} else {
(String::new(), true)
};
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), _)) => {
if new_content != content {
match fs::write(&file_path, &new_content) {
Ok(()) => {
info!("笔记已保存: {}", title);
return true;
}
Err(e) => error!("保存笔记失败: {}", e),
}
} else {
info!("内容未变化,跳过保存");
}
}
Ok((None, _)) => info!("已取消编辑"),
Err(e) => error!("编辑器启动失败: {}", e),
}
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);
}
}
const CMD_POPUP_ITEMS: &[(&str, &str)] = &[
("search", "搜索"),
("rename", "重命名"),
("delete", "删除"),
("mkdir", "新建目录"),
("mv", "移动"),
("open", "打开目录"),
("ratio", "调整比例"),
("help", "帮助"),
];
pub struct NotebookApp {
pub notes: Vec<NoteItem>,
pub state: ListState,
pub mode: AppMode,
pub input: String,
pub cursor_pos: usize,
pub message: Option<String>,
pub search_filter: Option<String>,
pub rename_index: Option<usize>,
pub preview_scroll: u16,
pub preview_content: Option<String>,
pub preview_lines: Vec<Line<'static>>,
pub preview_width: u16,
pub quit_input: String,
pub pending_edit_title: Option<String>,
pub panel_ratio: u16,
pub cmd_popup_selected: usize,
pub cmd_popup_filter: String,
pub expanded_dirs: ExpandedDirs,
pub flat_entries: Vec<FlatEntry>,
pub theme: Theme,
}
#[derive(PartialEq, Clone)]
pub enum AppMode {
Normal,
Preview,
Adding,
Renaming,
Search,
ConfirmDelete,
Help,
CommandPopup,
RatioInput,
Mkdir,
Mv,
}
impl Default for NotebookApp {
fn default() -> Self {
Self::new()
}
}
impl NotebookApp {
pub fn new() -> Self {
let notes = load_notes();
let expanded_dirs = load_expanded_dirs();
let agent_config = load_agent_config();
let theme = Theme::from_name(&agent_config.theme);
let mut app = Self {
notes,
state: ListState::default(),
mode: AppMode::Normal,
input: String::new(),
cursor_pos: 0,
message: None,
search_filter: None,
rename_index: None,
preview_scroll: 0,
preview_content: None,
preview_lines: Vec::new(),
preview_width: 0,
quit_input: String::new(),
pending_edit_title: None,
panel_ratio: load_panel_ratio().unwrap_or(30),
cmd_popup_selected: 0,
cmd_popup_filter: String::new(),
expanded_dirs,
flat_entries: Vec::new(),
theme,
};
app.build_flat_entries();
if !app.flat_entries.is_empty() {
app.state.select(Some(0));
}
app.update_preview();
app
}
pub fn reload(&mut self) {
self.notes = load_notes();
self.build_flat_entries();
let count = self.flat_entries.len();
if count == 0 {
self.state.select(None);
} else if let Some(sel) = self.state.selected()
&& sel >= count
{
self.state.select(Some(count - 1));
}
self.update_preview();
self.message = Some(format!("已刷新,共 {} 篇笔记", self.notes.len()));
}
pub fn filtered_indices(&self) -> Vec<usize> {
self.notes
.iter()
.enumerate()
.filter(|(_, item)| match &self.search_filter {
Some(keyword) => {
if fuzzy::fuzzy_match(&item.path, keyword) {
return true;
}
if let Some(content) = read_note_content(&item.path) {
return fuzzy::fuzzy_match(&content, keyword);
}
false
}
None => true,
})
.map(|(i, _)| i)
.collect()
}
pub fn build_flat_entries(&mut self) {
let filtered: Vec<usize> = self.filtered_indices();
let filtered_set: std::collections::HashSet<usize> = filtered.iter().copied().collect();
let mut dir_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for &idx in &filtered {
if let Some(parent) = self.notes[idx].parent_dir() {
let parts: Vec<&str> = parent.split('/').collect();
let mut acc = String::new();
for part in &parts {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(part);
dir_set.insert(acc.clone());
}
}
}
let mut flat = Vec::new();
build_flat_entries_recursive(
&self.notes,
&filtered_set,
&dir_set,
&self.expanded_dirs,
"",
0,
&mut flat,
);
self.flat_entries = flat;
}
pub fn selected_entry(&self) -> Option<&FlatEntry> {
self.state.selected().and_then(|i| self.flat_entries.get(i))
}
pub fn selected_real_index(&self) -> Option<usize> {
self.state.selected().and_then(|i| {
self.flat_entries
.get(i)
.and_then(|entry| match &entry.kind {
FlatEntryKind::File { note_index } => Some(*note_index),
FlatEntryKind::Dir { .. } => None,
})
})
}
pub fn selected_name(&self) -> Option<String> {
self.selected_real_index()
.map(|idx| self.notes[idx].path.clone())
}
pub fn move_down(&mut self) {
let count = self.flat_entries.len();
if count == 0 {
return;
}
let i = match self.state.selected() {
Some(i) => (i + 1).min(count - 1),
None => 0,
};
self.state.select(Some(i));
self.preview_scroll = 0;
self.update_preview();
}
pub fn move_up(&mut self) {
let count = self.flat_entries.len();
if count == 0 {
return;
}
let i = match self.state.selected() {
Some(i) => i.saturating_sub(1),
None => 0,
};
self.state.select(Some(i));
self.preview_scroll = 0;
self.update_preview();
}
pub fn update_preview(&mut self) {
match self.selected_entry() {
Some(FlatEntry {
kind: FlatEntryKind::File { note_index },
..
}) => {
self.preview_content = read_note_content(&self.notes[*note_index].path);
}
Some(FlatEntry {
kind:
FlatEntryKind::Dir {
dir_path,
file_count,
..
},
..
}) => {
self.preview_content = Some(format!(
"目录: {}\n包含 {} 篇笔记\n\n按 Tab 展开/折叠",
dir_path, file_count
));
}
None => {
self.preview_content = None;
}
}
self.render_preview_lines();
}
pub fn render_preview_with_width(&mut self, width: u16) {
if width != self.preview_width {
self.preview_width = width;
self.render_preview_lines();
}
}
fn render_preview_lines(&mut self) {
let width = if self.preview_width > 0 {
self.preview_width as usize
} else {
80 };
match &self.preview_content {
Some(content) if !content.is_empty() => {
self.preview_lines = markdown_to_lines(content, width, &self.theme.clone());
}
_ => {
self.preview_lines.clear();
}
}
}
pub fn clear_search(&mut self) {
self.search_filter = None;
self.build_flat_entries();
let count = self.flat_entries.len();
if count > 0 {
self.state.select(Some(0));
} else {
self.state.select(None);
}
self.update_preview();
self.message = Some("已清除搜索过滤".to_string());
}
pub fn filtered_cmd_items(&self) -> Vec<(usize, &'static str, &'static str)> {
CMD_POPUP_ITEMS
.iter()
.enumerate()
.filter(|(_, (key, label))| {
if self.cmd_popup_filter.is_empty() {
return true;
}
let f = self.cmd_popup_filter.to_lowercase();
key.to_lowercase().contains(&f) || label.contains(f.as_str())
})
.map(|(i, (k, l))| (i, *k, *l))
.collect()
}
}
fn build_flat_entries_recursive(
notes: &[NoteItem],
filtered_set: &std::collections::HashSet<usize>,
dir_set: &std::collections::BTreeSet<String>,
expanded_dirs: &ExpandedDirs,
prefix: &str,
depth: usize,
flat: &mut Vec<FlatEntry>,
) {
let mut child_dirs: Vec<String> = Vec::new();
for dir_path in dir_set.iter() {
if prefix.is_empty() {
if !dir_path.contains('/') {
child_dirs.push(dir_path.clone());
}
} else if dir_path.starts_with(&format!("{}/", prefix)) {
let rest = &dir_path[prefix.len() + 1..];
if !rest.contains('/') {
child_dirs.push(dir_path.clone());
}
}
}
let mut child_files: Vec<usize> = Vec::new();
for &idx in filtered_set {
let note = ¬es[idx];
let parent = note.parent_dir().unwrap_or("");
if parent == prefix {
child_files.push(idx);
}
}
for dir_path in &child_dirs {
let name = dir_path.rsplit('/').next().unwrap_or(dir_path);
let expanded = expanded_dirs.is_expanded(dir_path);
let file_count = filtered_set
.iter()
.filter(|&&idx| {
notes[idx]
.parent_dir()
.is_some_and(|p| p == *dir_path || p.starts_with(&format!("{}/", dir_path)))
})
.count();
let guide = " ".repeat(depth);
flat.push(FlatEntry {
kind: FlatEntryKind::Dir {
dir_path: dir_path.clone(),
name: name.to_string(),
file_count,
},
guide,
});
if expanded {
build_flat_entries_recursive(
notes,
filtered_set,
dir_set,
expanded_dirs,
dir_path,
depth + 1,
flat,
);
}
}
for &idx in &child_files {
let guide = " ".repeat(depth);
flat.push(FlatEntry {
kind: FlatEntryKind::File { note_index: idx },
guide,
});
}
}
pub fn handle_normal_mode(app: &mut NotebookApp, key: KeyEvent) -> bool {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return true;
}
match key.code {
KeyCode::Char('q') => {
app.quit_input = "q".to_string();
return true;
}
KeyCode::Esc => {
if app.search_filter.is_some() {
app.clear_search();
} else {
return true;
}
}
KeyCode::Char('n') | KeyCode::Down | KeyCode::Char('j') => app.move_down(),
KeyCode::Char('N') | KeyCode::Up | KeyCode::Char('k') => app.move_up(),
KeyCode::Enter | KeyCode::Char('e') => {
if app.selected_name().is_some() {
return false; }
}
KeyCode::Char('a') => {
app.mode = AppMode::Adding;
app.input.clear();
app.cursor_pos = 0;
app.message = None;
}
KeyCode::Char('d') => {
if app.selected_real_index().is_some() {
app.mode = AppMode::ConfirmDelete;
}
}
KeyCode::Char('r') => {
if let Some(idx) = app.selected_real_index() {
app.input = app.notes[idx].path.clone();
app.cursor_pos = app.input.chars().count();
app.rename_index = Some(idx);
app.mode = AppMode::Renaming;
app.message = None;
}
}
KeyCode::Tab => {
if let Some(sel) = app.state.selected()
&& sel < app.flat_entries.len()
&& let FlatEntryKind::Dir { ref dir_path, .. } = app.flat_entries[sel].kind
{
app.expanded_dirs.toggle(dir_path);
save_expanded_dirs(&app.expanded_dirs);
app.build_flat_entries();
if sel >= app.flat_entries.len() {
app.state
.select(Some(app.flat_entries.len().saturating_sub(1)));
}
app.update_preview();
}
}
KeyCode::Char('p') => {
if app.selected_real_index().is_some() {
app.mode = AppMode::Preview;
app.preview_scroll = 0;
}
}
KeyCode::Char('/') => {
app.mode = AppMode::CommandPopup;
app.cmd_popup_filter.clear();
app.cmd_popup_selected = 0;
app.message = None;
}
KeyCode::Char('[') => {
app.panel_ratio = app.panel_ratio.saturating_sub(5).max(15);
app.preview_width = 0;
app.message = Some(format!(
"面板比例: {}:{}",
app.panel_ratio,
100 - app.panel_ratio
));
save_panel_ratio(app.panel_ratio);
}
KeyCode::Char(']') => {
app.panel_ratio = app.panel_ratio.saturating_add(5).min(60);
app.preview_width = 0;
app.message = Some(format!(
"面板比例: {}:{}",
app.panel_ratio,
100 - app.panel_ratio
));
save_panel_ratio(app.panel_ratio);
}
KeyCode::Char('y') => {
if let Some(name) = app.selected_name() {
if copy_to_clipboard(&name) {
app.message = Some(format!("已复制笔记名: {}", name));
} else {
app.message = Some("复制到剪切板失败".to_string());
}
}
}
KeyCode::Char('o') => {
open_in_finder();
}
KeyCode::Char('s') => {
app.reload();
}
KeyCode::Char('?') => {
app.mode = AppMode::Help;
}
_ => {}
}
if key.code != KeyCode::Char('q') {
app.quit_input.clear();
}
false
}
pub fn handle_preview_mode(app: &mut NotebookApp, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('p') | KeyCode::Char('q') => {
app.mode = AppMode::Normal;
}
KeyCode::Down | KeyCode::Char('j') => {
app.preview_scroll = app.preview_scroll.saturating_add(1);
}
KeyCode::Up | KeyCode::Char('k') => {
app.preview_scroll = app.preview_scroll.saturating_sub(1);
}
KeyCode::Char('n') => {
app.move_down();
}
KeyCode::Char('N') => {
app.move_up();
}
_ => {}
}
}
pub fn handle_input_mode(app: &mut NotebookApp, key: KeyEvent) {
let char_count = app.input.chars().count();
match key.code {
KeyCode::Enter => {
match app.mode {
AppMode::Adding => {
let title = app.input.trim().to_string();
if title.is_empty() {
app.message = Some("标题为空,已取消".to_string());
app.mode = AppMode::Normal;
app.input.clear();
return;
}
app.pending_edit_title = Some(title);
app.input.clear();
app.mode = AppMode::Normal;
}
AppMode::Renaming => {
let new_name = app.input.trim().to_string();
if new_name.is_empty() {
app.message = Some("名称为空,已取消".to_string());
app.mode = AppMode::Normal;
app.input.clear();
app.rename_index = None;
return;
}
if let Some(idx) = app.rename_index
&& idx < app.notes.len()
{
let old_name = &app.notes[idx].path;
if old_name == &new_name {
app.message = Some("名称未变化".to_string());
app.mode = AppMode::Normal;
app.input.clear();
app.rename_index = None;
return;
}
let old_path = note_file_path(old_name);
let new_path = note_file_path(&new_name);
if new_path.exists() {
app.message = Some(format!("目标笔记已存在: {}", new_name));
return;
}
if let Some(parent) = new_path.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::rename(&old_path, &new_path) {
Ok(()) => {
cleanup_empty_dirs();
app.message =
Some(format!("已重命名: {} → {}", old_name, new_name));
app.reload();
}
Err(e) => {
app.message = Some(format!("重命名失败: {}", e));
}
}
}
app.mode = AppMode::Normal;
app.input.clear();
app.rename_index = None;
}
AppMode::Mkdir => {
let dir_name = app.input.trim().to_string();
if dir_name.is_empty() {
app.message = Some("目录名为空,已取消".to_string());
app.mode = AppMode::Normal;
app.input.clear();
return;
}
let dir_path = notebook_dir().join(&dir_name);
if dir_path.exists() {
app.message = Some(format!("目录已存在: {}", dir_name));
app.mode = AppMode::Normal;
app.input.clear();
return;
}
match fs::create_dir_all(&dir_path) {
Ok(()) => {
app.expanded_dirs.toggle(&dir_name);
save_expanded_dirs(&app.expanded_dirs);
app.message = Some(format!("已创建目录: {}", dir_name));
app.reload();
}
Err(e) => {
app.message = Some(format!("创建目录失败: {}", e));
}
}
app.mode = AppMode::Normal;
app.input.clear();
}
AppMode::Mv => {
let target = app.input.trim().to_string();
if target.is_empty() {
app.message = Some("目标路径为空,已取消".to_string());
app.mode = AppMode::Normal;
app.input.clear();
return;
}
let current_name = app.selected_name().unwrap_or_default();
if current_name.is_empty() {
app.message = Some("没有选中的笔记".to_string());
app.mode = AppMode::Normal;
app.input.clear();
return;
}
let old_path = note_file_path(¤t_name);
let new_path = note_file_path(&target);
if !old_path.exists() {
app.message = Some(format!("源笔记不存在: {}", current_name));
app.mode = AppMode::Normal;
app.input.clear();
return;
}
if new_path.exists() {
app.message = Some(format!("目标笔记已存在: {}", target));
app.mode = AppMode::Normal;
app.input.clear();
return;
}
if let Some(parent) = new_path.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::rename(&old_path, &new_path) {
Ok(()) => {
cleanup_empty_dirs();
app.message = Some(format!("已移动: {} → {}", current_name, target));
app.reload();
}
Err(e) => {
app.message = Some(format!("移动失败: {}", e));
}
}
app.mode = AppMode::Normal;
app.input.clear();
}
AppMode::Search => {
let keyword = app.input.trim().to_string();
if keyword.is_empty() {
app.clear_search();
app.mode = AppMode::Normal;
} else {
app.search_filter = Some(keyword);
let count = app.filtered_indices().len();
if count > 0 {
app.state.select(Some(0));
} else {
app.state.select(None);
}
app.update_preview();
app.message = Some(format!(
"搜索: {} (匹配 {} 条)",
app.search_filter.as_deref().unwrap_or(""),
count
));
app.mode = AppMode::Normal;
}
app.input.clear();
}
_ => {}
}
}
KeyCode::Esc => {
app.mode = AppMode::Normal;
app.input.clear();
app.cursor_pos = 0;
app.rename_index = None;
app.message = Some("已取消".to_string());
}
KeyCode::Left => {
if app.cursor_pos > 0 {
app.cursor_pos -= 1;
}
}
KeyCode::Right => {
if app.cursor_pos < char_count {
app.cursor_pos += 1;
}
}
KeyCode::Home => {
app.cursor_pos = 0;
}
KeyCode::End => {
app.cursor_pos = char_count;
}
KeyCode::Backspace => {
if app.cursor_pos > 0 {
let start = app
.input
.char_indices()
.nth(app.cursor_pos - 1)
.map(|(i, _)| i)
.unwrap_or(0);
let end = app
.input
.char_indices()
.nth(app.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
app.input.drain(start..end);
app.cursor_pos -= 1;
}
}
KeyCode::Delete => {
if app.cursor_pos < char_count {
let start = app
.input
.char_indices()
.nth(app.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
let end = app
.input
.char_indices()
.nth(app.cursor_pos + 1)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
app.input.drain(start..end);
}
}
KeyCode::Char(c) => {
let byte_idx = app
.input
.char_indices()
.nth(app.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
app.input.insert(byte_idx, c);
app.cursor_pos += 1;
}
_ => {}
}
}
pub fn handle_confirm_delete(app: &mut NotebookApp, key: KeyEvent) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(idx) = app.selected_real_index() {
let name = &app.notes[idx].path;
let path = note_file_path(name);
match fs::remove_file(&path) {
Ok(()) => {
cleanup_empty_dirs();
app.message = Some(format!("已删除: {}", name));
app.reload();
}
Err(e) => {
app.message = Some(format!("删除失败: {}", e));
}
}
}
app.mode = AppMode::Normal;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.mode = AppMode::Normal;
app.message = Some("已取消删除".to_string());
}
_ => {}
}
}
pub fn handle_command_popup_mode(app: &mut NotebookApp, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
app.mode = AppMode::Normal;
app.cmd_popup_filter.clear();
app.message = None;
}
KeyCode::Up | KeyCode::Char('k') => {
let count = app.filtered_cmd_items().len();
if count > 0 {
app.cmd_popup_selected = if app.cmd_popup_selected == 0 {
count - 1
} else {
app.cmd_popup_selected - 1
};
}
}
KeyCode::Down | KeyCode::Char('j') => {
let count = app.filtered_cmd_items().len();
if count > 0 {
app.cmd_popup_selected = (app.cmd_popup_selected + 1) % count;
}
}
KeyCode::Enter => {
let items = app.filtered_cmd_items();
if let Some(&(_orig_idx, cmd_key, _label)) = items.get(app.cmd_popup_selected) {
match cmd_key {
"search" => {
app.mode = AppMode::Search;
app.input.clear();
app.cursor_pos = 0;
app.message = None;
}
"rename" => {
if let Some(idx) = app.selected_real_index() {
app.input = app.notes[idx].path.clone();
app.cursor_pos = app.input.chars().count();
app.rename_index = Some(idx);
app.mode = AppMode::Renaming;
app.message = None;
} else {
app.mode = AppMode::Normal;
app.message = Some("没有选中的笔记".to_string());
}
}
"delete" => {
if app.selected_real_index().is_some() {
app.mode = AppMode::ConfirmDelete;
} else {
app.mode = AppMode::Normal;
app.message = Some("没有选中的笔记".to_string());
}
}
"mkdir" => {
app.mode = AppMode::Mkdir;
app.input.clear();
app.cursor_pos = 0;
app.message = None;
}
"mv" => {
if let Some(name) = app.selected_name() {
app.mode = AppMode::Mv;
app.input = name;
app.cursor_pos = app.input.chars().count();
app.message = None;
} else {
app.mode = AppMode::Normal;
app.message = Some("没有选中的笔记".to_string());
}
}
"open" => {
open_in_finder();
app.mode = AppMode::Normal;
app.message = Some("已打开目录".to_string());
}
"ratio" => {
app.mode = AppMode::RatioInput;
app.input = format!("{}:{}", app.panel_ratio, 100 - app.panel_ratio);
app.cursor_pos = app.input.chars().count();
app.message = None;
}
"help" => {
app.mode = AppMode::Help;
}
_ => {}
}
}
app.cmd_popup_filter.clear();
}
KeyCode::Backspace => {
if app.cmd_popup_filter.is_empty() {
app.mode = AppMode::Normal;
app.message = None;
} else {
app.cmd_popup_filter.pop();
app.cmd_popup_selected = 0;
}
}
KeyCode::Char(c) => {
app.cmd_popup_filter.push(c);
app.cmd_popup_selected = 0;
}
_ => {}
}
}
pub fn handle_ratio_input_mode(app: &mut NotebookApp, key: KeyEvent) {
let char_count = app.input.chars().count();
match key.code {
KeyCode::Enter => {
match parse_ratio(&app.input) {
Some(ratio) => {
app.panel_ratio = ratio;
app.preview_width = 0; app.message = Some(format!("面板比例已设为 {}:{}", ratio, 100 - ratio));
save_panel_ratio(ratio);
}
None => {
app.message = Some("格式错误,请输入如 20:80".to_string());
}
}
app.mode = AppMode::Normal;
app.input.clear();
app.cursor_pos = 0;
}
KeyCode::Esc => {
app.mode = AppMode::Normal;
app.input.clear();
app.cursor_pos = 0;
app.message = Some("已取消".to_string());
}
KeyCode::Left => {
if app.cursor_pos > 0 {
app.cursor_pos -= 1;
}
}
KeyCode::Right => {
if app.cursor_pos < char_count {
app.cursor_pos += 1;
}
}
KeyCode::Home => {
app.cursor_pos = 0;
}
KeyCode::End => {
app.cursor_pos = char_count;
}
KeyCode::Backspace => {
if app.cursor_pos > 0 {
let start = app
.input
.char_indices()
.nth(app.cursor_pos - 1)
.map(|(i, _)| i)
.unwrap_or(0);
let end = app
.input
.char_indices()
.nth(app.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
app.input.drain(start..end);
app.cursor_pos -= 1;
}
}
KeyCode::Delete => {
if app.cursor_pos < char_count {
let start = app
.input
.char_indices()
.nth(app.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
let end = app
.input
.char_indices()
.nth(app.cursor_pos + 1)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
app.input.drain(start..end);
}
}
KeyCode::Char(c) => {
if c.is_ascii_digit() || c == ':' {
let byte_idx = app
.input
.char_indices()
.nth(app.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(app.input.len());
app.input.insert(byte_idx, c);
app.cursor_pos += 1;
}
}
_ => {}
}
}
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))
}
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
.flatten()
.any(|e| e.path().is_file() || e.path().is_dir());
if !has_content {
let _ = fs::remove_dir(&full_path);
}
}
}
}
}
pub fn handle_help_mode(app: &mut NotebookApp, _key: KeyEvent) {
app.mode = AppMode::Normal;
app.message = None;
}