From 0d66ecf499cb385a8b95339c5735ba4678d2ad98 Mon Sep 17 00:00:00 2001 From: LiuYinCarl Date: Mon, 19 Jan 2026 22:07:54 +0800 Subject: [PATCH 1/3] add detail view and create/finish time for task --- Cargo.lock | 206 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/main.rs | 18 +++++ src/task.rs | 35 +++++++-- src/view.rs | 111 +++++++++++++++++++++++++++- 5 files changed, 360 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a1e253..69d5d5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -20,6 +20,15 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -30,6 +39,7 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" name = "basilk" version = "0.2.1" dependencies = [ + "chrono", "dirs", "ratatui", "serde", @@ -44,6 +54,12 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "cassowary" version = "0.3.0" @@ -59,12 +75,35 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "compact_str" version = "0.7.1" @@ -78,6 +117,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossterm" version = "0.27.0" @@ -136,6 +181,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + [[package]] name = "getrandom" version = "0.2.15" @@ -163,6 +214,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.5.0" @@ -188,6 +263,16 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.155" @@ -247,6 +332,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -406,6 +500,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.17" @@ -596,6 +696,51 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -618,6 +763,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 0c06b5f..a94f8ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ default-run = "basilk" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = "0.4.43" dirs = "5.0.1" ratatui = "0.27.0" serde = { version = "1.0.204", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 26f79ff..6f067dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ pub enum ViewMode { ChangePriorityTask, AddTask, DeleteTask, + ViewTaskDetails, InfoMigration, } @@ -281,6 +282,13 @@ impl App { App::change_view(self, ViewMode::DeleteTask); } + Char('v') => { + if items.is_empty() { + continue; + } + + App::change_view(self, ViewMode::ViewTaskDetails); + } Down | Tab | Char('j') => { self.next(&items); } @@ -380,6 +388,11 @@ impl App { } _ => {} }, + ViewMode::ViewTaskDetails => match key.code { + _ => { + App::change_view(self, ViewMode::ViewTasks); + } + }, ViewMode::InfoMigration => match key.code { _ => { @@ -454,6 +467,10 @@ impl App { View::show_select_task_priority_modal(self, priority_items, f, area) } + if self.view_mode == ViewMode::ViewTaskDetails { + View::show_task_details_modal(self, f, area) + } + if self.config.ui.show_help { View::show_footer_helper(self, f, footer_area) } @@ -502,6 +519,7 @@ impl App { ViewMode::ChangePriorityTask => return &mut self.selected_priority_task_index, ViewMode::AddTask => return &mut self.selected_task_index, ViewMode::DeleteTask => return &mut self.selected_task_index, + ViewMode::ViewTaskDetails => return &mut self.selected_task_index, ViewMode::InfoMigration => return &mut self.selected_project_index, }; diff --git a/src/task.rs b/src/task.rs index 37bf5dd..8cf6f68 100644 --- a/src/task.rs +++ b/src/task.rs @@ -4,6 +4,7 @@ use ratatui::{ widgets::ListItem, }; use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::{json::Json, util::Util, App}; @@ -12,6 +13,10 @@ pub struct Task { pub title: String, pub status: String, pub priority: u8, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub completed_at: Option, } pub const TASK_STATUS_DONE: &str = "Done"; @@ -28,7 +33,7 @@ const TASK_STATUSES_SORT_ORDER: [&'static str; 3] = pub const TASK_PRIORITIES: [u8; 4] = [1, 2, 3, 0]; impl Task { - fn get_status_color(status: &String) -> ratatui::prelude::Color { + pub fn get_status_color(status: &String) -> ratatui::prelude::Color { match status.as_str() { TASK_STATUS_DONE => return Color::LightGreen, TASK_STATUS_ON_GOING => return Color::Yellow, @@ -37,6 +42,13 @@ impl Task { } } + fn current_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + pub fn load_statues_items(items: &mut Vec) { items.clear(); @@ -73,6 +85,8 @@ impl Task { title: "".to_string(), status: "".to_string(), priority: 0, + created_at: None, + completed_at: None, }) .clone() .title; @@ -150,6 +164,8 @@ impl Task { title: value.to_string(), status: TASK_STATUS_UP_NEXT.to_string(), priority: 0, + created_at: Some(Self::current_timestamp()), + completed_at: None, }; let mut internal_projects = app.projects.clone(); @@ -176,14 +192,19 @@ impl Task { let mut internal_projects = app.projects.clone(); let status = value.to_string(); - internal_projects[app.selected_project_index.selected().unwrap()].tasks - [app.selected_task_index.selected().unwrap()] - .status = status.clone(); + let task = &mut internal_projects[app.selected_project_index.selected().unwrap()].tasks + [app.selected_task_index.selected().unwrap()]; + + task.status = status.clone(); if status == TASK_STATUS_DONE { - internal_projects[app.selected_project_index.selected().unwrap()].tasks - [app.selected_task_index.selected().unwrap()] - .priority = 0 + task.priority = 0; + if task.completed_at.is_none() { + task.completed_at = Some(Self::current_timestamp()); + } + } else { + // If changing from Done to another status, clear completion time + task.completed_at = None; } Json::write(internal_projects); diff --git a/src/view.rs b/src/view.rs index 9ccebbd..20bc6a6 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,11 +1,12 @@ use ratatui::{ layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Text}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, widgets::{Block, Clear, HighlightSpacing, List, ListItem, Paragraph, Wrap}, Frame, }; use tui_input::Input; +use chrono::{DateTime, Local}; use crate::{project::Project, task::Task, ui::Ui, util::Util, App, ViewMode}; @@ -111,6 +112,109 @@ impl View { } } + pub fn show_task_details_modal(app: &mut App, f: &mut Frame, area: Rect) { + let task = Task::get_current(app); + + let format_timestamp = |timestamp: Option| -> String { + timestamp + .and_then(|ts| DateTime::from_timestamp(ts as i64, 0)) + .map(|dt| { + let local_dt: DateTime = dt.into(); + local_dt.format("%Y-%m-%d %H:%M:%S %z").to_string() + }) + .unwrap_or_else(|| "N/A".to_string()) + }; + + let format_duration = |start: Option, end: Option| -> String { + match (start, end) { + (Some(s), Some(e)) => { + if e > s { + let duration_secs = e - s; + let days = duration_secs / 86400; + let hours = (duration_secs % 86400) / 3600; + let minutes = (duration_secs % 3600) / 60; + let seconds = duration_secs % 60; + + if days > 0 { + format!("{}d {}h {}m {}s", days, hours, minutes, seconds) + } else if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + } + } else { + "Invalid time range".to_string() + } + } + _ => "Task not completed".to_string(), + } + }; + + let priority_text = if task.priority == 0 { + "None".to_string() + } else { + format!("{} ({})", task.priority, Util::get_priority_indicator(task.priority)) + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Task: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(&task.title), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Color::Cyan)), + Span::styled(&task.status, Style::default().fg(Task::get_status_color(&task.status))), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled("Priority: ", Style::default().fg(Color::Cyan)), + Span::styled(priority_text, Style::default().fg(Color::Red)), + ]), + Line::raw(""), + ]; + + // Add creation time if available + if task.created_at.is_some() { + lines.push(Line::from(vec![ + Span::styled("Created: ", Style::default().fg(Color::Cyan)), + Span::raw(format_timestamp(task.created_at)), + ])); + lines.push(Line::raw("")); + } + + // Add completion time if available + if task.completed_at.is_some() { + lines.push(Line::from(vec![ + Span::styled("Completed: ", Style::default().fg(Color::Cyan)), + Span::raw(format_timestamp(task.completed_at)), + ])); + lines.push(Line::raw("")); + } + + // Add time consumed if both timestamps are available + if task.created_at.is_some() { + lines.push(Line::from(vec![ + Span::styled("Time Consumed: ", Style::default().fg(Color::Cyan)), + Span::raw(format_duration(task.created_at, task.completed_at)), + ])); + lines.push(Line::raw("")); + } + + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled("Press any key to close", Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)), + ])); + + let widget = Paragraph::new(Text::from(lines)) + .alignment(Alignment::Left) + .block(Block::bordered().title(" Task Details ")); + + Ui::create_modal(f, 60, 18, area, widget) + } + pub fn show_footer_helper(app: &mut App, f: &mut Frame, area: Rect) { let help_string = match app.view_mode { ViewMode::ViewProjects => { @@ -121,13 +225,14 @@ impl View { ViewMode::DeleteProject => " confirm - cancel", ViewMode::ViewTasks => { - " next/prev - go to projects - change status -

change priority - new - rename - delete - quit" + " next/prev - go to projects - change status -

change priority - new - rename - details - delete - quit" } ViewMode::RenameTask => " confirm - cancel", ViewMode::ChangeStatusTask => " next/prev - confirm - cancel", ViewMode::ChangePriorityTask => " next/prev - confirm - cancel", ViewMode::AddTask => " confirm - cancel", ViewMode::DeleteTask => " confirm - cancel", + ViewMode::ViewTaskDetails => " close", ViewMode::InfoMigration => "" }; From 0bdf4043c44e26bfda193380ff41d8e9d95ae8b4 Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Sat, 24 Jan 2026 12:20:19 +0800 Subject: [PATCH 2/3] add task note and agent support --- AGENTS.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 40 ++++++++++++++++++++++++++++++++ src/migration.rs | 53 +++++++++++++------------------------------ src/task.rs | 15 ++++++++++++ src/view.rs | 13 ++++++++++- 5 files changed, 142 insertions(+), 38 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3f02d36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Basilk Agent Guide + +Basilk is a TUI-based kanban task manager written in Rust using `ratatui`. + +## Essential Commands + +- **Build**: `cargo build` +- **Run**: `cargo run` +- **Test**: `cargo test` (Note: Currently the project has no automated tests in `src/`) +- **Lint**: `cargo fmt --all -- --check` (used in CI) +- **Format**: `cargo fmt` + +## Project Structure + +- `src/main.rs`: Entry point, terminal initialization, main event loop, and app state management. +- `src/app.rs` (Wait, I saw `App` in `main.rs`, let me check if there is a separate file or it's all in `main.rs`): App struct and core logic are in `main.rs`. +- `src/cli.rs`: Simple CLI argument handling (e.g., `--version`). +- `src/config.rs`: Configuration management (TOML format). +- `src/json.rs`: Data persistence layer (JSON format). +- `src/migration.rs`: JSON data schema migrations. +- `src/project.rs`: Project data model and logic. +- `src/task.rs`: Task data model, status/priority constants, and logic. +- `src/ui.rs`: UI utility functions for creating modals and layouts. +- `src/view.rs`: Higher-level UI rendering logic (rendering specific views/modals). +- `src/util.rs`: Miscellaneous utility functions. + +## Code Patterns + +### App State Management +The `App` struct in `main.rs` manages the application state, including selected indices for projects and tasks, the current `ViewMode`, and loaded data. + +### View Modes +`ViewMode` enum in `main.rs` defines the different screens and states (e.g., `ViewProjects`, `AddTask`, `ViewTasks`). + +### Data Persistence +- Data is stored in JSON files named after a version hash (e.g., `911fc.json`) in the user's config directory. +- `Json::read()` and `Json::write()` handle loading and saving the entire project list. +- Migrations are handled in `migration.rs` by mapping version hashes to transformation functions. + +### TUI Logic +- Uses `ratatui` with `crossterm` backend. +- `App::render` in `main.rs` delegates rendering to `View` methods in `view.rs`. +- Modals are created using `Ui` helper methods in `ui.rs`. + +## Conventions + +- **Naming**: Standard Rust naming conventions (CamelCase for types, snake_case for functions/variables). +- **Static Constants**: Used for task statuses (`TASK_STATUS_DONE`, etc.) and priorities. +- **Error Handling**: Uses `Box` in `main` and `Result` elsewhere. `unwrap()` is frequently used in data operations. + +## Gotchas + +- **Input Handling**: `tui-input` is used for text fields. Note that the event loop in `main.rs` filters for `KeyEventKind::Press` to avoid double-processing on Windows. +- **Data Loading**: `Project::reload` and `Task::reload` read the entire JSON file from disk. Changes are written back to disk immediately after most operations (create, rename, delete, change status/priority). +- **Sorting**: Tasks are sorted by status and priority during `Task::load_items`. +- **Migrations**: If you change the data schema (e.g., in `Project` or `Task` structs), you **must** add a new migration in `migration.rs` and update `JSON_VERSIONS`. + +## Configuration +Stored in `config.toml` in the config directory. Currently only supports `ui.show_help`. diff --git a/src/main.rs b/src/main.rs index 6f067dd..6689804 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,7 @@ pub enum ViewMode { AddTask, DeleteTask, ViewTaskDetails, + EditTaskNote, InfoMigration, } @@ -289,6 +290,17 @@ impl App { App::change_view(self, ViewMode::ViewTaskDetails); } + Char('e') => { + if items.is_empty() { + continue; + } + + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); + + App::change_view(self, ViewMode::EditTaskNote); + } Down | Tab | Char('j') => { self.next(&items); } @@ -389,10 +401,33 @@ impl App { _ => {} }, ViewMode::ViewTaskDetails => match key.code { + Char('e') => { + input = input + .clone() + .with_value(Task::get_current(self).note.clone()); + + App::change_view(self, ViewMode::EditTaskNote); + } _ => { App::change_view(self, ViewMode::ViewTasks); } }, + ViewMode::EditTaskNote => match key.code { + Enter => { + Task::update_note(self, &mut items, input.value()); + input.reset(); + + App::change_view(self, ViewMode::ViewTaskDetails); + } + Esc => { + input.reset(); + + App::change_view(self, ViewMode::ViewTaskDetails); + } + _ => { + input.handle_event(&Event::Key(key)); + } + }, ViewMode::InfoMigration => match key.code { _ => { @@ -455,6 +490,10 @@ impl App { View::show_rename_item_modal(f, area, input) } + if self.view_mode == ViewMode::EditTaskNote { + View::show_edit_note_modal(f, area, input) + } + if self.view_mode == ViewMode::DeleteTask || self.view_mode == ViewMode::DeleteProject { View::show_delete_item_modal(self, f, area) } @@ -520,6 +559,7 @@ impl App { ViewMode::AddTask => return &mut self.selected_task_index, ViewMode::DeleteTask => return &mut self.selected_task_index, ViewMode::ViewTaskDetails => return &mut self.selected_task_index, + ViewMode::EditTaskNote => return &mut self.selected_task_index, ViewMode::InfoMigration => return &mut self.selected_project_index, }; diff --git a/src/migration.rs b/src/migration.rs index 4a83687..2fdd8ff 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,10 +1,10 @@ use serde_json::{ - json, to_string, Map, + json, to_string, Value::{self}, }; -// sha of 0.1.0 0.2.0 -pub static JSON_VERSIONS: [&str; 2] = ["6ad96", "911fc"]; +// sha of 0.1.0 0.2.0 0.2.2 +pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; pub struct Migration; @@ -13,7 +13,8 @@ impl Migration { // Mapper between json version and the relative migration let mapper: Vec<(&str, String)> = vec![ ("6ad96", "".to_string()), - ("911fc", Migration::add_priority(original_json)), + ("911fc", Migration::add_priority(original_json.clone())), + ("a4e1b", Migration::add_note(original_json)), ]; // The start index where the migration are picked @@ -33,39 +34,17 @@ impl Migration { } // Migrations - fn add_priority(original_json: Vec) -> String { - let mut internal_json = original_json.clone(); - - let new_json: Vec> = internal_json - .iter_mut() - .map(|p| { - // Get all tasks from each project and convert into "Map" type from serde - // in order to do some operations with the json - // Vec = Array ; Map = Object - let mut tasks = serde_json::from_value::>>( - p.get("tasks").unwrap().clone(), - ) - .unwrap(); - - // Add to each task a new key value (i.e. {priority: 0}) - tasks.iter_mut().for_each(|t| { - // Entry and or_insert methods are used for add a new key - // cf. https://docs.rs/serde_json/latest/serde_json/map/enum.Entry.html#method.or_insert - t.entry("priority").or_insert(json!(0)); - }); - - // Convert "p" into the "Map" type from serde in order to do some operations with the json - let mut project = serde_json::from_value::>(p.clone()).unwrap(); - - // Replace the "tasks" key with the new one - // Insert method is used for replace a new with a new value - // cf. https://docs.rs/serde_json/latest/serde_json/map/struct.Map.html#method.insert - project.insert("tasks".to_string(), json!(tasks)).unwrap(); - - return project; - }) - .collect(); + fn add_priority(mut json: Vec) -> String { + for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { + t.as_object_mut().unwrap().insert("priority".to_string(), json!(0)); + } + to_string(&json).unwrap() + } - return to_string(&new_json).unwrap(); + fn add_note(mut json: Vec) -> String { + for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { + t.as_object_mut().unwrap().insert("note".to_string(), json!("")); + } + to_string(&json).unwrap() } } diff --git a/src/task.rs b/src/task.rs index 8cf6f68..115c6a5 100644 --- a/src/task.rs +++ b/src/task.rs @@ -17,6 +17,8 @@ pub struct Task { pub created_at: Option, #[serde(default)] pub completed_at: Option, + #[serde(default)] + pub note: String, } pub const TASK_STATUS_DONE: &str = "Done"; @@ -87,6 +89,7 @@ impl Task { priority: 0, created_at: None, completed_at: None, + note: "".to_string(), }) .clone() .title; @@ -166,6 +169,7 @@ impl Task { priority: 0, created_at: Some(Self::current_timestamp()), completed_at: None, + note: "".to_string(), }; let mut internal_projects = app.projects.clone(); @@ -188,6 +192,17 @@ impl Task { Task::reload(app, items) } + pub fn update_note(app: &mut App, items: &mut Vec, value: &str) { + let mut internal_projects = app.projects.clone(); + + internal_projects[app.selected_project_index.selected().unwrap()].tasks + [app.selected_task_index.selected().unwrap()] + .note = value.to_string(); + + Json::write(internal_projects); + Task::reload(app, items) + } + pub fn change_status(app: &mut App, items: &mut Vec, value: &str) { let mut internal_projects = app.projects.clone(); let status = value.to_string(); diff --git a/src/view.rs b/src/view.rs index 20bc6a6..e3b308f 100644 --- a/src/view.rs +++ b/src/view.rs @@ -32,6 +32,10 @@ impl View { Ui::create_input_modal("Rename", f, area, input) } + pub fn show_edit_note_modal(f: &mut Frame, area: Rect, input: &Input) { + Ui::create_input_modal("Note", f, area, input) + } + pub fn show_delete_item_modal(app: &mut App, f: &mut Frame, area: Rect) { let title = match app.view_mode { ViewMode::DeleteTask => &Task::get_current(app).title, @@ -174,6 +178,11 @@ impl View { Span::styled(priority_text, Style::default().fg(Color::Red)), ]), Line::raw(""), + Line::from(vec![ + Span::styled("Note: ", Style::default().fg(Color::Cyan)), + Span::raw(&task.note), + ]), + Line::raw(""), ]; // Add creation time if available @@ -210,6 +219,7 @@ impl View { let widget = Paragraph::new(Text::from(lines)) .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) .block(Block::bordered().title(" Task Details ")); Ui::create_modal(f, 60, 18, area, widget) @@ -232,7 +242,8 @@ impl View { ViewMode::ChangePriorityTask => " next/prev - confirm - cancel", ViewMode::AddTask => " confirm - cancel", ViewMode::DeleteTask => " confirm - cancel", - ViewMode::ViewTaskDetails => " close", + ViewMode::ViewTaskDetails => " edit note - close", + ViewMode::EditTaskNote => " confirm - cancel", ViewMode::InfoMigration => "" }; From 619f63f4b05aeb3315c2f89586c59f97ee478d7e Mon Sep 17 00:00:00 2001 From: LiuYinCarl <1427518212@qq.coom> Date: Sat, 24 Jan 2026 12:32:40 +0800 Subject: [PATCH 3/3] use fixed data file --- src/json.rs | 141 +++++++++++++++++++++++++++++------------------ src/main.rs | 6 +- src/migration.rs | 51 +++++++++-------- 3 files changed, 118 insertions(+), 80 deletions(-) diff --git a/src/json.rs b/src/json.rs index ae4a369..4711435 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,12 +1,12 @@ use std::{ error::Error, - fs::{self, File}, - io::Write, - path::{Path, PathBuf}, + fs, + path::PathBuf, sync::Mutex, }; -use serde_json::{from_str, to_string, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::{from_str, to_string}; use crate::{ migration::{Migration, JSON_VERSIONS}, @@ -16,8 +16,15 @@ use crate::{ pub struct Json; static DIR_CONFIG_NAME: &str = env!("CARGO_PKG_NAME"); +static DATA_FILE_NAME: &str = "basilk_data.json"; static VERSION: Mutex = Mutex::new(String::new()); +#[derive(Serialize, Deserialize)] +struct DataWrapper { + version: String, + data: Vec, +} + impl Json { pub fn get_dir_path() -> PathBuf { let mut path = dirs::config_dir().unwrap(); @@ -26,6 +33,14 @@ impl Json { return path; } + fn get_data_path() -> PathBuf { + let mut path = PathBuf::new(); + path.push(Json::get_dir_path().as_path()); + path.push(DATA_FILE_NAME); + + return path; + } + fn get_json_path(version: String) -> PathBuf { let mut path = PathBuf::new(); path.push(Json::get_dir_path().as_path()); @@ -37,78 +52,96 @@ impl Json { pub fn check() -> Result> { fs::create_dir_all(Json::get_dir_path())?; - // Create the state to save the json version let mut version_state = VERSION.lock().unwrap(); + let data_path = Json::get_data_path(); + + if data_path.is_file() { + let json_raw = fs::read_to_string(&data_path)?; + if json_raw.trim().is_empty() { + let last_json_version = JSON_VERSIONS.last().unwrap(); + version_state.clear(); + version_state.push_str(last_json_version); + drop(version_state); + Json::write(vec![]); + return Ok(false); + } + let wrapper: DataWrapper = from_str(&json_raw)?; + version_state.clear(); + version_state.push_str(&wrapper.version); - // Pick the version from the internal file - let mut json_version_from_file: Vec<&str> = JSON_VERSIONS - .into_iter() - .filter(|version| Path::new(&Json::get_json_path(version.to_string())).is_file()) - .collect(); - - // If the file doesn't exist create a new one with the last version - if json_version_from_file.is_empty() { - let last_json_version = JSON_VERSIONS.last().unwrap(); - let path = Json::get_json_path(last_json_version.to_string()); + let migrations = Migration::get_migrations(&wrapper.version, wrapper.data); - let mut file = File::create(path).unwrap(); - let _ = file.write_all(b"[]"); + if migrations.is_empty() { + return Ok(false); + } - json_version_from_file = vec![last_json_version]; - version_state.push_str(json_version_from_file[0]); + for (version, migration_data) in migrations.iter() { + version_state.clear(); + version_state.push_str(version); + Json::write_internal(&data_path, version_state.to_string(), migration_data.clone()); + } - return Ok(false); + return Ok(true); } - // Save into the internal state the last json version - version_state.push_str(json_version_from_file[0]); - - // Read the internal file - let path = Json::get_json_path(json_version_from_file[0].to_string()); - let json_raw = fs::read_to_string(&path).unwrap(); - let json = from_str::>(&json_raw).unwrap(); - - if json.is_empty() { - return Ok(false); - } - - // Load all migrations - let migrations = Migration::get_migrations(json_version_from_file[0], json); + // Migration from old versioned files + let json_version_from_file: Vec<&str> = JSON_VERSIONS + .into_iter() + .filter(|version| { + let p = Json::get_json_path(version.to_string()); + p.is_file() + }) + .collect(); - if migrations.is_empty() { + if json_version_from_file.is_empty() { + let last_json_version = JSON_VERSIONS.last().unwrap(); + version_state.clear(); + version_state.push_str(last_json_version); + let projects: Vec = vec![]; + let wrapper = DataWrapper { version: last_json_version.to_string(), data: projects }; + fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); return Ok(false); } - // Loop thru all migrations and apply them! - for (version, migration) in migrations.iter() { - let path = Json::get_json_path(version_state.to_string()); - let new_path = Json::get_json_path(version.to_string()); + let old_version = json_version_from_file[0]; + let old_path = Json::get_json_path(old_version.to_string()); + let json_raw = fs::read_to_string(&old_path)?; + let data = from_str::>(&json_raw)?; - let new_json = migration; + version_state.clear(); + version_state.push_str(old_version); + let wrapper = DataWrapper { version: old_version.to_string(), data }; + fs::write(&data_path, to_string(&wrapper).unwrap()).unwrap(); - fs::write(&path, new_json).unwrap(); - fs::rename(&path, new_path)?; + // Optionally delete old file + let _ = fs::remove_file(old_path); - // Save into the internal state the json version of the last migration applied - version_state.clear(); - version_state.push_str(&version) - } - - Ok(true) + // Re-run check to apply any further migrations + drop(version_state); + return Json::check(); } pub fn read() -> Vec { - let version = VERSION.lock().unwrap().to_string(); - let path = Json::get_json_path(version); - + let path = Json::get_data_path(); let json = fs::read_to_string(path).unwrap(); - return from_str::>(&json).unwrap(); + let wrapper: DataWrapper = from_str(&json).unwrap(); + + let mut version_state = VERSION.lock().unwrap(); + version_state.clear(); + version_state.push_str(&wrapper.version); + + return wrapper.data; } pub fn write(projects: Vec) { let version = VERSION.lock().unwrap().to_string(); - let path = Json::get_json_path(version); + let path = Json::get_data_path(); + + Json::write_internal(&path, version, projects); + } - fs::write(path, to_string(&projects).unwrap()).unwrap(); + fn write_internal(path: &PathBuf, version: String, data: Vec) { + let wrapper = DataWrapper { version, data }; + fs::write(path, to_string(&wrapper).unwrap()).unwrap(); } } diff --git a/src/main.rs b/src/main.rs index 6689804..9c9c368 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,12 +80,12 @@ fn restore_terminal() -> Result<(), Box> { fn main() -> Result<(), Box> { Cli::read(); - // setup terminal - let terminal = init_terminal()?; - // Check the version of the json file let were_applied_migrations = Json::check()?; + // setup terminal + let terminal = init_terminal()?; + // create app and run it App::setup().run(terminal, were_applied_migrations)?; diff --git a/src/migration.rs b/src/migration.rs index 2fdd8ff..77072ff 100644 --- a/src/migration.rs +++ b/src/migration.rs @@ -1,7 +1,4 @@ -use serde_json::{ - json, to_string, - Value::{self}, -}; +use crate::project::Project; // sha of 0.1.0 0.2.0 0.2.2 pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; @@ -9,42 +6,50 @@ pub static JSON_VERSIONS: [&str; 3] = ["6ad96", "911fc", "a4e1b"]; pub struct Migration; impl Migration { - pub fn get_migrations(version: &str, original_json: Vec) -> Vec<(&str, String)> { + pub fn get_migrations(version: &str, original_json: Vec) -> Vec<(&str, Vec)> { // Mapper between json version and the relative migration - let mapper: Vec<(&str, String)> = vec![ - ("6ad96", "".to_string()), - ("911fc", Migration::add_priority(original_json.clone())), - ("a4e1b", Migration::add_note(original_json)), + let mapper: Vec<(&str, fn(Vec) -> Vec)> = vec![ + ("6ad96", |data| data), + ("911fc", Migration::add_priority), + ("a4e1b", Migration::add_note), ]; // The start index where the migration are picked let start_index = mapper - .clone() - .into_iter() - .position(|(key, _val)| key == version); + .iter() + .position(|(key, _val)| *key == version); if start_index.is_none() { return vec![]; } - let all_migrations: Vec<(&str, String)> = mapper.into_iter().collect(); + let mut results = vec![]; + let mut current_data = original_json; - // Slice for pick only the useful migration - return all_migrations[(start_index.unwrap() + 1)..].to_vec(); + for (v, migration_fn) in mapper.into_iter().skip(start_index.unwrap() + 1) { + current_data = migration_fn(current_data); + results.push((v, current_data.clone())); + } + + return results; } // Migrations - fn add_priority(mut json: Vec) -> String { - for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { - t.as_object_mut().unwrap().insert("priority".to_string(), json!(0)); + fn add_priority(mut data: Vec) -> Vec { + for p in data.iter_mut() { + for t in p.tasks.iter_mut() { + t.priority = 0; + } } - to_string(&json).unwrap() + data } - fn add_note(mut json: Vec) -> String { - for t in json.iter_mut().flat_map(|p| p.get_mut("tasks")).flat_map(|t| t.as_array_mut()).flatten() { - t.as_object_mut().unwrap().insert("note".to_string(), json!("")); + fn add_note(mut data: Vec) -> Vec { + for p in data.iter_mut() { + for t in p.tasks.iter_mut() { + t.note = "".to_string(); + } } - to_string(&json).unwrap() + data } }