From 03dab421678300641d4e273519625b222104f5a4 Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Tue, 10 Mar 2026 15:54:45 +0800 Subject: [PATCH] feat: add sheet creation command --- CHANGELOG.md | 15 +++- README.md | 8 +- README_zh.md | 8 +- src/actions/command.rs | 5 +- src/actions/mod.rs | 2 +- src/actions/sheet.rs | 12 ++- src/actions/types.rs | 1 + src/app/sheet.rs | 81 +++++++++++++++++++ src/app/ui.rs | 1 + src/app/undo_manager.rs | 165 ++++++++++++++++++++++++++------------- src/commands/executor.rs | 3 + src/excel/sheet.rs | 13 +++ src/excel/workbook.rs | 160 ++++++++++++++++++++++++++++++++++++- src/ui/render.rs | 7 +- 14 files changed, 410 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9d1b5..d18971e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-03-10 + +### Added + +- Added `:addsheet ` to create a new sheet after the current sheet +- Added undo and redo support for sheet creation + +### Fixed + +- Load all lazy-loaded sheets before saving to avoid writing unloaded sheets as blank +- Count sheet name length by characters so non-ASCII names such as Chinese sheet names are validated correctly + ## [0.3.0] - 2025-05-07 ### Added @@ -74,7 +86,8 @@ This is the initial release of excel-cli, a lightweight terminal-based Excel vie - Copy, cut, and paste functionality with `y`, `d`, and `p` keys - Support for pipe operator when exporting to JSON -[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/fuhan666/excel-cli/releases/tag/v0.4.0 [0.3.0]: https://github.com/fuhan666/excel-cli/releases/tag/v0.3.0 [0.2.0]: https://github.com/fuhan666/excel-cli/releases/tag/v0.2.0 [0.1.1]: https://github.com/fuhan666/excel-cli/releases/tag/v0.1.1 diff --git a/README.md b/README.md index 0004f32..c51440b 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ English | [中文](README_zh.md) ## Features - Browse and navigate Excel worksheets with Vim-like hotkeys -- Switch between sheets in multi-sheet workbooks +- Create, switch, and delete sheets in multi-sheet workbooks - Edit cell contents directly in the terminal - Export data to JSON format -- Delete rows, columns, and sheets +- Delete rows and columns - Search functionality with highlighting - Command mode for advanced operations @@ -105,7 +105,7 @@ The application has a simple and intuitive interface: - `y`: Copy current cell content - `d`: Cut current cell content - `p`: Paste clipboard content to current cell -- `u`: Undo the last operation (edit, row/column/sheet deletion) +- `u`: Undo the last operation (edit, row/column changes, sheet creation/deletion) - `Ctrl+r`: Redo the last undone operation - `/`: Start forward search - `?`: Start backward search @@ -218,6 +218,7 @@ The JSON files are saved in the same directory as the original Excel file. ### Sheet Management Commands +- `:addsheet [name]` - Add a new sheet after the current sheet - `:sheet [name/number]` - Switch to sheet by name or index (1-based) - `:delsheet` - Delete the current sheet @@ -241,6 +242,7 @@ Excel-CLI uses a non-destructive approach to file saving: - When you save a file (using `:w`, `:wq`, or `:x`), the application checks if any changes have been made - If no changes have been made, no new file is created, and a "No changes to save" message is displayed +- If lazy loading is enabled, all unloaded sheets are loaded before saving so the workbook content is preserved - If changes have been made, a new file is created with a timestamp in the filename, following the format `original_filename_YYYYMMDD_HHMMSS.xlsx` - The new file is created without any styling - The original file is never modified diff --git a/README_zh.md b/README_zh.md index a6e7dd9..22f8145 100644 --- a/README_zh.md +++ b/README_zh.md @@ -7,10 +7,10 @@ ## 功能特点 - 使用类 Vim 快捷键浏览 Excel 工作表 -- 在多工作表的 Excel 文件中切换工作表 +- 在多工作表的 Excel 文件中创建、切换和删除工作表 - 直接在终端中编辑单元格内容 - 将数据导出为 JSON 格式 -- 删除行、列和工作表 +- 删除行和列 - 搜索并高亮显示结果 - 支持高级操作的命令模式 @@ -105,7 +105,7 @@ excel-cli path/to/your/file.xlsx -j > data.json # (示例)将JSON输出保 - `y`:复制当前单元格内容 - `d`:剪切当前单元格内容 - `p`:将剪贴板内容粘贴到当前单元格 -- `u`:撤销上一次操作(编辑、行/列/工作表删除) +- `u`:撤销上一次操作(编辑、行列变更、工作表创建/删除) - `Ctrl+r`:重做上一次被撤销的操作 - `/`:开始向前搜索 - `?`:开始向后搜索 @@ -218,6 +218,7 @@ JSON 文件保存在原始 Excel 文件所在的目录中。 ### 工作表管理命令 +- `:addsheet [名称]` - 在当前工作表后新增一个工作表 - `:sheet [名称/编号]` - 按名称或索引切换工作表(基于 1 的索引) - `:delsheet` - 删除当前工作表 @@ -241,6 +242,7 @@ Excel-CLI 使用非破坏性的文件保存方法: - 当您保存文件(使用`:w`,`:wq`或`:x`)时,应用程序会检查是否进行了任何更改 - 如果没有进行更改,则不会创建新文件,并显示"No changes to save"消息 +- 如果启用了懒加载,保存前会先加载尚未读取的工作表,以保留完整工作簿内容 - 如果进行了更改,则会创建一个文件名中带有时间戳的新文件,格式为`original_filename_YYYYMMDD_HHMMSS.xlsx` - 创建的新文件不带任何样式 - 原始文件永远不会被修改 diff --git a/src/actions/command.rs b/src/actions/command.rs index b27e907..ca4b22c 100644 --- a/src/actions/command.rs +++ b/src/actions/command.rs @@ -1,4 +1,5 @@ -use super::types::{ActionCommand, ActionType}; +use super::types::ActionCommand; +use super::{ActionType, Command}; impl ActionCommand { // Returns the action type of this command @@ -13,7 +14,7 @@ impl ActionCommand { ActionCommand::MultiRow(_) => ActionType::DeleteMultiRows, ActionCommand::Column(_) => ActionType::DeleteColumn, ActionCommand::MultiColumn(_) => ActionType::DeleteMultiColumns, - ActionCommand::Sheet(_) => ActionType::DeleteSheet, + ActionCommand::Sheet(action) => action.action_type(), } } } diff --git a/src/actions/mod.rs b/src/actions/mod.rs index 0d760a4..c3c4302 100644 --- a/src/actions/mod.rs +++ b/src/actions/mod.rs @@ -10,5 +10,5 @@ pub use cell::CellAction; pub use column::{ColumnAction, MultiColumnAction}; pub use history::UndoHistory; pub use row::{MultiRowAction, RowAction}; -pub use sheet::SheetAction; +pub use sheet::{SheetAction, SheetOperation}; pub use types::{ActionCommand, ActionExecutor, ActionType, Command}; diff --git a/src/actions/sheet.rs b/src/actions/sheet.rs index 15611d2..d1c12a2 100644 --- a/src/actions/sheet.rs +++ b/src/actions/sheet.rs @@ -2,12 +2,19 @@ use super::{ActionType, Command}; use crate::excel::Sheet; use anyhow::Result; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SheetOperation { + Create, + Delete, +} + #[derive(Clone)] pub struct SheetAction { pub sheet_index: usize, pub sheet_name: String, pub sheet_data: Sheet, pub column_widths: Vec, + pub operation: SheetOperation, } impl Command for SheetAction { @@ -20,6 +27,9 @@ impl Command for SheetAction { } fn action_type(&self) -> ActionType { - ActionType::DeleteSheet + match self.operation { + SheetOperation::Create => ActionType::CreateSheet, + SheetOperation::Delete => ActionType::DeleteSheet, + } } } diff --git a/src/actions/types.rs b/src/actions/types.rs index 9bccdb7..61ab7fa 100644 --- a/src/actions/types.rs +++ b/src/actions/types.rs @@ -3,6 +3,7 @@ pub enum ActionType { Edit, Cut, Paste, + CreateSheet, DeleteRow, DeleteColumn, DeleteSheet, diff --git a/src/app/sheet.rs b/src/app/sheet.rs index 0890d57..92bb2fb 100644 --- a/src/app/sheet.rs +++ b/src/app/sheet.rs @@ -1,5 +1,6 @@ use crate::actions::{ ActionCommand, ColumnAction, MultiColumnAction, MultiRowAction, RowAction, SheetAction, + SheetOperation, }; use crate::app::AppState; use crate::utils::index_to_col_name; @@ -150,6 +151,56 @@ impl AppState<'_> { self.add_notification(format!("Sheet '{name_or_index}' not found")); } + pub fn create_sheet(&mut self, name: &str) { + let insert_index = self.workbook.get_current_sheet_index() + 1; + + match self.workbook.add_sheet(name, insert_index) { + Ok(sheet_name) => { + let default_width = 15; + let max_cols = self + .workbook + .get_sheet_by_index(insert_index) + .map(|sheet| sheet.max_cols) + .unwrap_or(1); + + self.sheet_column_widths + .insert(sheet_name.clone(), vec![default_width; max_cols + 1]); + self.sheet_cell_positions.insert( + sheet_name.clone(), + crate::app::CellPosition { + selected: (1, 1), + view: (1, 1), + }, + ); + + if let Err(e) = self.switch_sheet_by_index(insert_index) { + self.sheet_column_widths.remove(&sheet_name); + self.sheet_cell_positions.remove(&sheet_name); + let _ = self.workbook.delete_sheet_at_index(insert_index); + self.add_notification(format!("Failed to switch to new sheet: {e}")); + return; + } + + self.notification_messages.pop(); + + let sheet_action = SheetAction { + sheet_index: insert_index, + sheet_name: sheet_name.clone(), + sheet_data: self.workbook.get_current_sheet().clone(), + column_widths: self.column_widths.clone(), + operation: SheetOperation::Create, + }; + + self.undo_history.push(ActionCommand::Sheet(sheet_action)); + self.input_mode = crate::app::InputMode::Normal; + self.add_notification(format!("Created sheet: {sheet_name}")); + } + Err(e) => { + self.add_notification(format!("Failed to add sheet: {e}")); + } + } + } + pub fn delete_current_sheet(&mut self) { let current_sheet_name = self.workbook.get_current_sheet_name(); let sheet_index = self.workbook.get_current_sheet_index(); @@ -166,6 +217,7 @@ impl AppState<'_> { sheet_name: current_sheet_name.clone(), sheet_data, column_widths, + operation: SheetOperation::Delete, }; self.undo_history.push(ActionCommand::Sheet(sheet_action)); @@ -210,6 +262,7 @@ impl AppState<'_> { // Clear search results as they're specific to the previous sheet self.search_results.clear(); self.current_search_idx = None; + self.update_row_number_width(); // Check if the new current sheet is loaded when using lazy loading if self.workbook.is_lazy_loading() && !is_new_sheet_loaded { @@ -722,3 +775,31 @@ impl AppState<'_> { } } } + +#[cfg(test)] +mod tests { + use crate::app::AppState; + use crate::excel::{Sheet, Workbook}; + use std::path::PathBuf; + + #[test] + fn create_sheet_can_be_undone_and_redone() { + let workbook = Workbook::from_sheets_for_test(vec![Sheet::blank("Sheet1".to_string())]); + let mut app = AppState::new(workbook, PathBuf::from("test.xlsx")).unwrap(); + + app.create_sheet("Report"); + assert_eq!(app.workbook.get_sheet_names(), vec!["Sheet1", "Report"]); + assert_eq!(app.workbook.get_current_sheet_name(), "Report"); + assert!(app.workbook.is_modified()); + + app.undo().unwrap(); + assert_eq!(app.workbook.get_sheet_names(), vec!["Sheet1"]); + assert_eq!(app.workbook.get_current_sheet_name(), "Sheet1"); + assert!(!app.workbook.is_modified()); + + app.redo().unwrap(); + assert_eq!(app.workbook.get_sheet_names(), vec!["Sheet1", "Report"]); + assert_eq!(app.workbook.get_current_sheet_name(), "Report"); + assert!(app.workbook.is_modified()); + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index f4075c6..ab83574 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -54,6 +54,7 @@ impl AppState<'_> { h=horizontal (default), v=vertical\n\ [rows]=number of header rows (default: 1)\n\n\ SHEET OPERATIONS:\n\ + :addsheet [name] - Add a new sheet after the current sheet\n\ :delsheet - Delete the current sheet\n\n\ UI ADJUSTMENTS:\n\ +/= - Increase info panel height\n\ diff --git a/src/app/undo_manager.rs b/src/app/undo_manager.rs index 395e599..5561182 100644 --- a/src/app/undo_manager.rs +++ b/src/app/undo_manager.rs @@ -1,6 +1,6 @@ use crate::actions::{ ActionCommand, ActionExecutor, ActionType, CellAction, ColumnAction, MultiColumnAction, - MultiRowAction, RowAction, SheetAction, + MultiRowAction, RowAction, SheetAction, SheetOperation, }; use crate::app::AppState; use crate::utils::index_to_col_name; @@ -271,61 +271,31 @@ impl AppState<'_> { } fn apply_sheet_action(&mut self, sheet_action: &SheetAction, is_undo: bool) -> Result<()> { - if is_undo { - let sheet_index = sheet_action.sheet_index; - - if let Err(e) = self - .workbook - .insert_sheet_at_index(sheet_action.sheet_data.clone(), sheet_index) - { - self.add_notification(format!( - "Failed to restore sheet {}: {}", - sheet_action.sheet_name, e - )); - return Ok(()); + match (sheet_action.operation, is_undo) { + (SheetOperation::Delete, true) => { + self.restore_sheet_from_action( + sheet_action, + format!("Undid sheet {} deletion", sheet_action.sheet_name), + ); } - - self.sheet_column_widths.insert( - sheet_action.sheet_name.clone(), - sheet_action.column_widths.clone(), - ); - - // Initialize cell position for the restored sheet with default values - self.sheet_cell_positions.insert( - sheet_action.sheet_name.clone(), - crate::app::CellPosition { - selected: (1, 1), - view: (1, 1), - }, - ); - - if let Err(e) = self.switch_sheet_by_index(sheet_index) { - self.add_notification(format!( - "Restored sheet {} but couldn't switch to it: {}", - sheet_action.sheet_name, e - )); - } else { - self.add_notification(format!("Undid sheet {} deletion", sheet_action.sheet_name)); + (SheetOperation::Delete, false) => { + self.delete_sheet_from_action( + sheet_action, + format!("Redid deletion of sheet {}", sheet_action.sheet_name), + ); } - } else { - if let Err(e) = self.switch_sheet_by_index(sheet_action.sheet_index) { - self.add_notification(format!( - "Cannot switch to sheet {} to delete it: {}", - sheet_action.sheet_name, e - )); - return Ok(()); + (SheetOperation::Create, true) => { + self.delete_sheet_from_action( + sheet_action, + format!("Undid creation of sheet {}", sheet_action.sheet_name), + ); } - - if let Err(e) = self.workbook.delete_current_sheet() { - self.add_notification(format!("Failed to delete sheet: {e}")); - return Ok(()); + (SheetOperation::Create, false) => { + self.restore_sheet_from_action( + sheet_action, + format!("Redid creation of sheet {}", sheet_action.sheet_name), + ); } - - self.cleanup_after_sheet_deletion(&sheet_action.sheet_name); - self.add_notification(format!( - "Redid deletion of sheet {}", - sheet_action.sheet_name - )); } Ok(()) @@ -370,6 +340,73 @@ impl AppState<'_> { self.search_results.clear(); self.current_search_idx = None; + self.update_row_number_width(); + + let new_sheet_index = self.workbook.get_current_sheet_index(); + if self.workbook.is_lazy_loading() && !self.workbook.is_sheet_loaded(new_sheet_index) { + self.input_mode = crate::app::InputMode::LazyLoading; + } else { + self.input_mode = crate::app::InputMode::Normal; + } + } + + fn restore_sheet_from_action(&mut self, sheet_action: &SheetAction, notification: String) { + let sheet_index = sheet_action.sheet_index; + + if let Err(e) = self + .workbook + .insert_sheet_at_index(sheet_action.sheet_data.clone(), sheet_index) + { + self.add_notification(format!( + "Failed to restore sheet {}: {}", + sheet_action.sheet_name, e + )); + return; + } + + self.sheet_column_widths.insert( + sheet_action.sheet_name.clone(), + sheet_action.column_widths.clone(), + ); + + self.sheet_cell_positions.insert( + sheet_action.sheet_name.clone(), + crate::app::CellPosition { + selected: (1, 1), + view: (1, 1), + }, + ); + + if let Err(e) = self.switch_sheet_by_index(sheet_index) { + self.add_notification(format!( + "Restored sheet {} but couldn't switch to it: {}", + sheet_action.sheet_name, e + )); + return; + } + + self.notification_messages.pop(); + self.add_notification(notification); + } + + fn delete_sheet_from_action(&mut self, sheet_action: &SheetAction, notification: String) { + if let Err(e) = self.switch_sheet_by_index(sheet_action.sheet_index) { + self.add_notification(format!( + "Cannot switch to sheet {} to delete it: {}", + sheet_action.sheet_name, e + )); + return; + } + + self.notification_messages.pop(); + + if let Err(e) = self.workbook.delete_current_sheet() { + self.add_notification(format!("Failed to delete sheet: {e}")); + return; + } + + self.cleanup_after_sheet_deletion(&sheet_action.sheet_name); + self.add_notification(notification); } fn apply_multi_row_action( @@ -602,8 +639,28 @@ impl ActionExecutor for AppState<'_> { } fn execute_sheet_action(&mut self, action: &SheetAction) -> Result<()> { - self.switch_sheet_by_index(action.sheet_index)?; - self.workbook.delete_current_sheet() + match action.operation { + SheetOperation::Create => { + self.workbook + .insert_sheet_at_index(action.sheet_data.clone(), action.sheet_index)?; + self.sheet_column_widths + .insert(action.sheet_name.clone(), action.column_widths.clone()); + self.sheet_cell_positions.insert( + action.sheet_name.clone(), + crate::app::CellPosition { + selected: (1, 1), + view: (1, 1), + }, + ); + self.switch_sheet_by_index(action.sheet_index) + } + SheetOperation::Delete => { + self.switch_sheet_by_index(action.sheet_index)?; + self.workbook.delete_current_sheet()?; + self.cleanup_after_sheet_deletion(&action.sheet_name); + Ok(()) + } + } } fn execute_multi_row_action(&mut self, action: &MultiRowAction) -> Result<()> { diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 1742bd0..a1bd699 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -53,12 +53,15 @@ impl AppState<'_> { "nohlsearch" | "noh" => self.disable_search_highlight(), "help" => self.show_help(), "delsheet" => self.delete_current_sheet(), + "addsheet" => self.add_notification("Usage: :addsheet ".to_string()), _ => { // Handle commands with parameters if command.starts_with("cw ") { self.handle_column_width_command(&command); } else if command.starts_with("ej") { self.handle_json_export_command(&command); + } else if let Some(sheet_name) = command.strip_prefix("addsheet ") { + self.create_sheet(sheet_name.trim()); } else if command.starts_with("sheet ") { let sheet_name = command.strip_prefix("sheet ").unwrap().trim(); self.switch_to_sheet(sheet_name); diff --git a/src/excel/sheet.rs b/src/excel/sheet.rs index 57f32f4..1ec34ff 100644 --- a/src/excel/sheet.rs +++ b/src/excel/sheet.rs @@ -8,3 +8,16 @@ pub struct Sheet { pub max_cols: usize, pub is_loaded: bool, } + +impl Sheet { + #[must_use] + pub fn blank(name: String) -> Self { + Self { + name, + data: vec![vec![Cell::empty(); 2]; 2], + max_rows: 1, + max_cols: 1, + is_loaded: true, + } + } +} diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 92c47fc..834dec5 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -359,17 +359,35 @@ impl Workbook { Ok(()) } + pub fn add_sheet(&mut self, name: &str, index: usize) -> Result { + let sheet_name = name.trim(); + + self.validate_sheet_name(sheet_name)?; + self.insert_sheet_at_index(Sheet::blank(sheet_name.to_string()), index)?; + + Ok(sheet_name.to_string()) + } + pub fn delete_current_sheet(&mut self) -> Result<()> { + self.delete_sheet_at_index(self.current_sheet_index) + } + + pub fn delete_sheet_at_index(&mut self, index: usize) -> Result<()> { // Prevent deleting the last sheet if self.sheets.len() <= 1 { anyhow::bail!("Cannot delete the last sheet"); } - self.sheets.remove(self.current_sheet_index); + if index >= self.sheets.len() { + anyhow::bail!("Sheet index out of range"); + } + + self.sheets.remove(index); self.is_modified = true; - // Adjust current_sheet_index - if self.current_sheet_index >= self.sheets.len() { + if index < self.current_sheet_index { + self.current_sheet_index = self.current_sheet_index.saturating_sub(1); + } else if self.current_sheet_index >= self.sheets.len() { self.current_sheet_index = self.sheets.len() - 1; } @@ -554,6 +572,8 @@ impl Workbook { return Ok(()); } + self.ensure_all_sheets_loaded()?; + // Create a new workbook with rust_xlsxwriter let mut workbook = XlsxWorkbook::new(); @@ -653,6 +673,11 @@ impl Workbook { self.sheets.len() ); } + + if index <= self.current_sheet_index { + self.current_sheet_index += 1; + } + self.sheets.insert(index, sheet); self.is_modified = true; Ok(()) @@ -695,4 +720,133 @@ impl Workbook { sheet.max_rows = actual_max_row.max(1); } + + fn ensure_all_sheets_loaded(&mut self) -> Result<()> { + if !self.lazy_loading { + return Ok(()); + } + + let pending_sheets: Vec<(usize, String)> = self + .sheets + .iter() + .enumerate() + .filter(|(_, sheet)| !sheet.is_loaded) + .map(|(index, sheet)| (index, sheet.name.clone())) + .collect(); + + for (index, name) in pending_sheets { + self.ensure_sheet_loaded(index, &name)?; + } + + Ok(()) + } + + fn validate_sheet_name(&self, name: &str) -> Result<()> { + if name.is_empty() { + anyhow::bail!("Sheet name cannot be empty"); + } + + if name.chars().count() > 31 { + anyhow::bail!("Sheet name cannot exceed 31 characters"); + } + + if name.starts_with('\'') || name.ends_with('\'') { + anyhow::bail!("Sheet name cannot start or end with apostrophes"); + } + + if name + .chars() + .any(|c| matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\')) + { + anyhow::bail!("Sheet name cannot contain any of these characters: [ ] : * ? / \\"); + } + + if self + .sheets + .iter() + .any(|sheet| sheet.name.eq_ignore_ascii_case(name)) + { + anyhow::bail!("Sheet '{}' already exists", name); + } + + Ok(()) + } + + #[cfg(test)] + pub(crate) fn from_sheets_for_test(sheets: Vec) -> Self { + let loaded_sheets = (0..sheets.len()).collect(); + + Self { + sheets, + current_sheet_index: 0, + file_path: "test.xlsx".to_string(), + is_modified: false, + calamine_workbook: CalamineWorkbook::None, + lazy_loading: false, + loaded_sheets, + } + } +} + +#[cfg(test)] +mod tests { + use super::Workbook; + use crate::excel::Sheet; + + fn blank_sheet(name: &str) -> Sheet { + Sheet::blank(name.to_string()) + } + + #[test] + fn adds_blank_sheet_after_current_sheet() { + let mut workbook = + Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]); + + let sheet_name = workbook.add_sheet("Added", 1).unwrap(); + + assert_eq!(sheet_name, "Added"); + assert_eq!( + workbook.get_sheet_names(), + vec!["Sheet1", "Added", "Sheet2"] + ); + + let added_sheet = workbook.get_sheet_by_index(1).unwrap(); + assert_eq!(added_sheet.name, "Added"); + assert_eq!(added_sheet.max_rows, 1); + assert_eq!(added_sheet.max_cols, 1); + assert!(added_sheet.is_loaded); + assert_eq!(added_sheet.data.len(), 2); + assert_eq!(added_sheet.data[1].len(), 2); + } + + #[test] + fn rejects_duplicate_sheet_names_case_insensitively() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]); + + let error = workbook.add_sheet("summary", 1).unwrap_err().to_string(); + + assert!(error.contains("already exists")); + } + + #[test] + fn rejects_invalid_sheet_names() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); + + assert!(workbook.add_sheet("", 1).is_err()); + assert!(workbook.add_sheet("Bad/Name", 1).is_err()); + assert!(workbook.add_sheet("'quoted", 1).is_err()); + assert!(workbook + .add_sheet("this-sheet-name-is-definitely-too-long", 1) + .is_err()); + } + + #[test] + fn counts_sheet_name_length_by_characters() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); + let valid_name = "表".repeat(31); + let invalid_name = "表".repeat(32); + + assert!(workbook.add_sheet(&valid_name, 1).is_ok()); + assert!(workbook.add_sheet(&invalid_name, 2).is_err()); + } } diff --git a/src/ui/render.rs b/src/ui/render.rs index 2f4935d..8445c88 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -304,7 +304,7 @@ fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { } // Parse command input and identify keywords and parameters for highlighting -fn parse_command(input: &str) -> Vec { +fn parse_command(input: &str) -> Vec> { if input.is_empty() { return vec![Span::raw("")]; } @@ -322,10 +322,11 @@ fn parse_command(input: &str) -> Vec { "nohlsearch", "noh", "help", + "addsheet", "delsheet", ]; - let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc"]; + let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"]; let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"]; @@ -567,7 +568,7 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { InputMode::LazyLoading => { // Show a status message for lazy loading mode let status_widget = Paragraph::new( - "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :delsheet to delete current sheet, :q to quit, :q! to quit without saving", + "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :addsheet to add a sheet, :delsheet to delete current sheet, :q to quit, :q! to quit without saving", ) .style(Style::default().fg(Color::LightYellow)) .alignment(ratatui::layout::Alignment::Left);