├── .envrc ├── crates ├── kanban.json ├── kanban-persistence │ ├── src │ │ ├── watch │ │ │ └── mod.rs │ │ ├── serialization │ │ │ ├── mod.rs │ │ │ └── json_serializer.rs │ │ ├── migration │ │ │ ├── mod.rs │ │ │ └── v1_to_v2.rs │ │ ├── conflict │ │ │ ├── mod.rs │ │ │ ├── resolver.rs │ │ │ └── detector.rs │ │ ├── store │ │ │ ├── mod.rs │ │ │ └── atomic_writer.rs │ │ ├── lib.rs │ │ └── traits.rs │ └── Cargo.toml ├── kanban-core │ ├── src │ │ ├── result.rs │ │ ├── lib.rs │ │ ├── logging.rs │ │ ├── error.rs │ │ ├── traits.rs │ │ └── config.rs │ ├── Cargo.toml │ └── README.md ├── kanban-tui │ ├── src │ │ ├── theme │ │ │ ├── mod.rs │ │ │ ├── colors.rs │ │ │ └── styles.rs │ │ ├── export │ │ │ ├── mod.rs │ │ │ ├── models.rs │ │ │ └── exporter.rs │ │ ├── services │ │ │ ├── mod.rs │ │ │ ├── filter.rs │ │ │ └── card_filter_service.rs │ │ ├── clipboard.rs │ │ ├── handlers │ │ │ └── mod.rs │ │ ├── components │ │ │ ├── mod.rs │ │ │ ├── panel.rs │ │ │ ├── detail_view.rs │ │ │ ├── popup.rs │ │ │ ├── list.rs │ │ │ ├── selection_list.rs │ │ │ └── card_list_item.rs │ │ ├── lib.rs │ │ ├── board_context.rs │ │ ├── dialog.rs │ │ ├── selection.rs │ │ ├── input.rs │ │ ├── events.rs │ │ ├── filters.rs │ │ ├── keybindings │ │ │ ├── mod.rs │ │ │ ├── card_detail.rs │ │ │ ├── sprint_detail.rs │ │ │ └── registry.rs │ │ ├── state │ │ │ └── snapshot.rs │ │ ├── editor.rs │ │ ├── task_list.rs │ │ ├── view_strategy.rs │ │ ├── search.rs │ │ └── markdown_renderer.rs │ ├── Cargo.toml │ ├── tests │ │ ├── markdown_renderer.rs │ │ └── import_failure_safety.rs │ └── README.md ├── kanban-cli │ ├── src │ │ ├── handlers │ │ │ ├── mod.rs │ │ │ ├── export.rs │ │ │ ├── board.rs │ │ │ ├── column.rs │ │ │ └── sprint.rs │ │ ├── output.rs │ │ └── main.rs │ ├── build.rs │ └── Cargo.toml ├── kanban-domain │ ├── src │ │ ├── task_list_view.rs │ │ ├── tag.rs │ │ ├── lib.rs │ │ ├── sprint_log.rs │ │ ├── deleted_card.rs │ │ ├── archived_card.rs │ │ ├── commands │ │ │ ├── mod.rs │ │ │ ├── column_commands.rs │ │ │ ├── board_commands.rs │ │ │ └── sprint_commands.rs │ │ ├── column.rs │ │ ├── field_update.rs │ │ └── operations.rs │ └── Cargo.toml └── kanban-mcp │ ├── Cargo.toml │ ├── default.nix │ └── src │ ├── main.rs │ └── tools_trait.rs ├── demo.gif ├── .changeset ├── kan-129-include-commit-hash-in-v.md ├── kan-139-if-no-sprints-cant-scroll-to-column-settings.md ├── kan-148-archiving-deleting-cards-is-broken.md ├── kan-140-filter-out-completed-sprints-from-assign-list.md ├── kan-142-updating-fields-jumps-the-user-back-to-board-2.md ├── kan-144-kanban-view-switches-column-on-the-second-to-last-item.md ├── kan-145-we-broke-the-file-watcher-having-a-conflict-with-one-instance.md ├── kan-143-gg-g-works-poorly.md ├── kan-111-sprint-binding-help-is-wrong.md ├── kan-141-scrolling-up-from-column-options-lands-the-cursor-on-the-first-sprint-in-the-list.md ├── kan-152-dont-include-description-of-card-for-get-cards.md ├── kan-132-urgent-migrations.md ├── kan-118-unfilter-tasks-list-on-completed-sprint.md ├── kan-150-file-path-to-a-non-existant-file-crashes-the-app.md ├── kan-196-make-help-menu-items-selectable-and-activateable.md ├── kan-133-scrolling-doesnt-work-in-grouped-by-columns-list.md ├── kan-146-kanban-mcp.md ├── kan-30-vim-motions.md ├── kan-33-add-binding.md ├── kan-93-dialogs-always-return-to-main-when-opened.md ├── kan-20-remove-a-card.md ├── kan-147-multiselecting-and-assigning-cards-causes-write-race-condition.md ├── kan-130-three-card-list-components-to-become-one.md ├── kan-151-kanban-cli.md ├── kan-15-progressive-saving-detect-changes-to-current-json.md └── kan-55-scroll-in-cards-list.md ├── .mcp.json ├── .gitignore ├── default.nix ├── scripts ├── publish-crates.sh ├── aggregate-changesets.sh ├── create-changeset.sh ├── bump-version.sh ├── validate-release.sh └── update-changelog.sh ├── .github └── workflows │ ├── aggregate-changesets.yml │ └── release.yml ├── shell.nix ├── Cargo.toml ├── flake.nix └── flake.lock /.envrc: -------------------------------------------------------------------------------- 1 | use_flake 2 | -------------------------------------------------------------------------------- /crates/kanban.json: -------------------------------------------------------------------------------- 1 | { 2 | "boards": [] 3 | } -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fulsomenko/kanban/HEAD/demo.gif -------------------------------------------------------------------------------- /crates/kanban-persistence/src/watch/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file_watcher; 2 | 3 | pub use file_watcher::FileWatcher; 4 | -------------------------------------------------------------------------------- /crates/kanban-core/src/result.rs: -------------------------------------------------------------------------------- 1 | use crate::error::KanbanError; 2 | 3 | pub type KanbanResult = Result; 4 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/theme/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod colors; 2 | pub mod styles; 3 | 4 | pub use colors::*; 5 | pub use styles::*; 6 | -------------------------------------------------------------------------------- /crates/kanban-persistence/src/serialization/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod json_serializer; 2 | 3 | pub use json_serializer::JsonSerializer; 4 | -------------------------------------------------------------------------------- /crates/kanban-cli/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod board; 2 | pub mod card; 3 | pub mod column; 4 | pub mod export; 5 | pub mod sprint; 6 | -------------------------------------------------------------------------------- /.changeset/kan-129-include-commit-hash-in-v.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - feat(cli): include git commit hash in version output 6 | -------------------------------------------------------------------------------- /crates/kanban-persistence/src/migration/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod migrator; 2 | pub mod v1_to_v2; 3 | 4 | pub use migrator::Migrator; 5 | pub use v1_to_v2::V1ToV2Migration; 6 | -------------------------------------------------------------------------------- /.changeset/kan-139-if-no-sprints-cant-scroll-to-column-settings.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix(tui): skip empty sprints section when navigating board details 6 | -------------------------------------------------------------------------------- /.changeset/kan-148-archiving-deleting-cards-is-broken.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: batch card archive and delete operations in animation completion 6 | 7 | -------------------------------------------------------------------------------- /crates/kanban-persistence/src/conflict/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod detector; 2 | pub mod resolver; 3 | 4 | pub use detector::FileMetadata; 5 | pub use resolver::LastWriteWinsResolver; 6 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/export/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod exporter; 2 | pub mod importer; 3 | pub mod models; 4 | 5 | pub use exporter::*; 6 | pub use importer::*; 7 | pub use models::*; 8 | -------------------------------------------------------------------------------- /.changeset/kan-140-filter-out-completed-sprints-from-assign-list.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: filter out completed and cancelled sprints from assign list 6 | 7 | -------------------------------------------------------------------------------- /.changeset/kan-142-updating-fields-jumps-the-user-back-to-board-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: preserve navigation mode during auto-reload from external changes 6 | 7 | -------------------------------------------------------------------------------- /.changeset/kan-144-kanban-view-switches-column-on-the-second-to-last-item.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: prevent premature column switching in handle_navigation_down 6 | 7 | -------------------------------------------------------------------------------- /.changeset/kan-145-we-broke-the-file-watcher-having-a-conflict-with-one-instance.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: Centralize file watcher pause/resume in StateManager 6 | 7 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod card_filter_service; 2 | pub mod filter; 3 | pub mod sort; 4 | 5 | pub use card_filter_service::*; 6 | pub use filter::*; 7 | pub use sort::*; 8 | -------------------------------------------------------------------------------- /.changeset/kan-143-gg-g-works-poorly.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - chore: cargo fmt 6 | - fix(tui): fix gg/G vim navigation in grouped-by-column view 7 | - chore: remove wip file 8 | -------------------------------------------------------------------------------- /crates/kanban-persistence/src/store/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod atomic_writer; 2 | pub mod json_file_store; 3 | 4 | pub use atomic_writer::AtomicWriter; 5 | pub use json_file_store::{JsonEnvelope, JsonFileStore}; 6 | -------------------------------------------------------------------------------- /.changeset/kan-111-sprint-binding-help-is-wrong.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - refactor: integrate card list into keybinding registry 6 | - refactor: unify keybinding management for footer and help popup 7 | -------------------------------------------------------------------------------- /.changeset/kan-141-scrolling-up-from-column-options-lands-the-cursor-on-the-first-sprint-in-the-list.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: navigate to last sprint when scrolling up from columns in board settings 6 | 7 | -------------------------------------------------------------------------------- /.changeset/kan-152-dont-include-description-of-card-for-get-cards.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - feat(mcp): omit description and sprint_logs from card list responses 6 | - feat(cli): include git commit hash in version output (#132) 7 | 8 | -------------------------------------------------------------------------------- /.changeset/kan-132-urgent-migrations.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - migration: add reconciliation of branch_prefix and sprint_prefix to migrate old boards 6 | - migration: add serde default to support migration to archived cards board 7 | 8 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/clipboard.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | pub fn copy_to_clipboard(text: &str) -> io::Result<()> { 4 | arboard::Clipboard::new() 5 | .and_then(|mut clipboard| clipboard.set_text(text)) 6 | .map_err(io::Error::other) 7 | } 8 | -------------------------------------------------------------------------------- /.mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "git": { 4 | "command": "nix", 5 | "args": ["run", ".#mcp-server-git"] 6 | }, 7 | "kanban": { 8 | "command": "nix", 9 | "args": ["run", ".#kanban-mcp", "--", "kanban.json"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.changeset/kan-118-unfilter-tasks-list-on-completed-sprint.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | Remove filtering of cards from completed sprints 6 | 7 | - fix: remove auto-hiding of completed sprint cards from app methods 8 | - fix: remove auto-hiding of completed sprint cards from view strategies 9 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod board_handlers; 2 | pub mod card_handlers; 3 | pub mod column_handlers; 4 | pub mod detail_view_handlers; 5 | pub mod dialog_handlers; 6 | pub mod filter_handlers; 7 | pub mod navigation_handlers; 8 | pub mod popup_handlers; 9 | pub mod sprint_handlers; 10 | -------------------------------------------------------------------------------- /crates/kanban-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod error; 3 | pub mod logging; 4 | pub mod result; 5 | pub mod traits; 6 | 7 | pub use config::AppConfig; 8 | pub use error::KanbanError; 9 | pub use logging::{LogEntry, Loggable}; 10 | pub use result::KanbanResult; 11 | pub use traits::Editable; 12 | -------------------------------------------------------------------------------- /.changeset/kan-150-file-path-to-a-non-existant-file-crashes-the-app.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - feat: Add migration verification and automatic backup cleanup 6 | - fix: Add instance ID check to file watcher to prevent false positives 7 | - fix: Remove redundant version fields from PersistenceMetadata 8 | 9 | -------------------------------------------------------------------------------- /crates/kanban-persistence/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod conflict; 2 | pub mod migration; 3 | pub mod serialization; 4 | pub mod store; 5 | pub mod traits; 6 | pub mod watch; 7 | 8 | pub use conflict::*; 9 | pub use migration::*; 10 | pub use serialization::*; 11 | pub use store::*; 12 | pub use traits::*; 13 | pub use watch::*; 14 | -------------------------------------------------------------------------------- /.changeset/kan-196-make-help-menu-items-selectable-and-activateable.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: help menu keybinding matching for special keys and / 6 | - fix: implement missing action handlers for help menu 7 | - refactor: couple keybindings with actions 8 | - feat: add visual selection to help popup 9 | - feat: add generic list component 10 | 11 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/task_list_view.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 4 | pub enum TaskListView { 5 | Flat, 6 | GroupedByColumn, 7 | ColumnView, 8 | } 9 | 10 | impl Default for TaskListView { 11 | fn default() -> Self { 12 | Self::Flat 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/kan-133-scrolling-doesnt-work-in-grouped-by-columns-list.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - feat: Synchronize navigation viewport with grouped view column headers 6 | - feat: Implement unified scrolling rendering for grouped view 7 | - feat: Wire up VirtualUnifiedLayout for grouped view mode 8 | - feat: Add VirtualUnifiedLayout for unified card scrolling in grouped view 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | /target 3 | **/*.rs.bk 4 | *.pdb 5 | 6 | # Database 7 | .pgdata/ 8 | *.db 9 | *.sqlite 10 | 11 | # IDE 12 | .vscode/ 13 | .idea/ 14 | *.swp 15 | *.swo 16 | *~ 17 | .direnv 18 | 19 | # OS 20 | .DS_Store 21 | Thumbs.db 22 | 23 | # Nix 24 | result 25 | result-* 26 | out 27 | 28 | # Environment 29 | .env 30 | .env.local 31 | 32 | # Logs 33 | *.log 34 | logfile 35 | 36 | # Build artifacts 37 | artifacts 38 | changed.txt 39 | BUILD 40 | coverage 41 | -------------------------------------------------------------------------------- /.changeset/kan-146-kanban-mcp.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - feat: add kanban-mcp server 6 | - feat(mcp): add McpTools trait for compile-time parity with KanbanOperations 7 | - docs(mcp): add subprocess architecture documentation and Nix wrapper 8 | - feat(mcp): add CLI executor for subprocess-based operations 9 | - feat(mcp): enhance card operations and add delete/archive functionality 10 | - feat: add kanban-mcp: Model Context Protocol server implementation 11 | 12 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod card_list_item; 2 | pub mod detail_view; 3 | pub mod generic_list; 4 | pub mod list; 5 | pub mod page; 6 | pub mod panel; 7 | pub mod popup; 8 | pub mod selection_dialog; 9 | pub mod selection_list; 10 | 11 | pub use card_list_item::*; 12 | pub use detail_view::*; 13 | pub use generic_list::*; 14 | pub use list::*; 15 | pub use page::*; 16 | pub use panel::*; 17 | pub use popup::*; 18 | pub use selection_dialog::*; 19 | pub use selection_list::*; 20 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/tag.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use uuid::Uuid; 3 | 4 | pub type TagId = Uuid; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct Tag { 8 | pub id: TagId, 9 | pub name: String, 10 | pub color: String, 11 | } 12 | 13 | impl Tag { 14 | pub fn new(name: String, color: String) -> Self { 15 | Self { 16 | id: Uuid::new_v4(), 17 | name, 18 | color, 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/export/models.rs: -------------------------------------------------------------------------------- 1 | use kanban_domain::{ArchivedCard, Board, Card, Column, Sprint}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize)] 5 | pub struct BoardExport { 6 | pub board: Board, 7 | pub columns: Vec, 8 | pub cards: Vec, 9 | pub sprints: Vec, 10 | #[serde(default)] 11 | pub archived_cards: Vec, 12 | } 13 | 14 | #[derive(Serialize, Deserialize)] 15 | pub struct AllBoardsExport { 16 | pub boards: Vec, 17 | } 18 | -------------------------------------------------------------------------------- /crates/kanban-core/src/logging.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct LogEntry { 6 | pub timestamp: DateTime, 7 | pub message: String, 8 | } 9 | 10 | impl LogEntry { 11 | pub fn new(message: String) -> Self { 12 | Self { 13 | timestamp: Utc::now(), 14 | message, 15 | } 16 | } 17 | } 18 | 19 | pub trait Loggable { 20 | fn add_log(&mut self, message: String); 21 | fn get_logs(&self) -> &[LogEntry]; 22 | } 23 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , rustPlatform 3 | }: 4 | 5 | let 6 | cargoToml = lib.importTOML ./Cargo.toml; 7 | in 8 | rustPlatform.buildRustPackage { 9 | inherit (cargoToml.workspace.package) version; 10 | pname = "kanban"; 11 | 12 | src = lib.cleanSource ./.; 13 | 14 | cargoLock = { 15 | lockFile = ./Cargo.lock; 16 | }; 17 | 18 | meta = { 19 | inherit (cargoToml.workspace.package) description homepage; 20 | license = lib.licenses.asl20; 21 | maintainers = with lib.maintainers; [ fulsomenko ]; 22 | mainProgram = "kanban"; 23 | platforms = lib.platforms.all; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod board_context; 3 | pub mod card_list; 4 | pub mod card_list_component; 5 | pub mod clipboard; 6 | pub mod components; 7 | pub mod dialog; 8 | pub mod editor; 9 | pub mod events; 10 | pub mod export; 11 | pub mod filters; 12 | pub mod handlers; 13 | pub mod input; 14 | pub mod keybindings; 15 | pub mod layout_strategy; 16 | pub mod markdown_renderer; 17 | pub mod render_strategy; 18 | pub mod search; 19 | pub mod selection; 20 | pub mod services; 21 | pub mod state; 22 | pub mod theme; 23 | pub mod tui_context; 24 | pub mod ui; 25 | pub mod view_strategy; 26 | 27 | pub use app::App; 28 | -------------------------------------------------------------------------------- /.changeset/kan-30-vim-motions.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | Jumping cards 6 | 7 | - fix: jump by actual visible cards count from render_info, not cards_to_show 8 | - feat: add vim jump motions to normal mode keybinding display 9 | - feat: add vim jump motions to card list keybinding display 10 | - feat: wire up vim jump motions to keybinding handlers 11 | - feat: add jump motion handlers 12 | - feat: add jump methods to CardList 13 | - feat: add jump_to_first and jump_to_last methods to SelectionState 14 | - feat: add jump action variants to KeybindingAction enum 15 | - feat: add pending_key field to App struct for multi-key sequences 16 | -------------------------------------------------------------------------------- /.changeset/kan-33-add-binding.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | Add help dialogue for keybindings. 6 | 7 | - feat: implement Help popup rendering with context-aware keybindings 8 | - feat: add global ? key handler for help across all modes 9 | - refactor: make CardFocus and BoardFocus Copy 10 | - feat: add Help app mode with context preservation 11 | - feat: create keybinding registry to route contexts 12 | - feat: implement keybinding providers for all contexts 13 | - feat: create keybindings module with traits and data structures 14 | - refactor: add keybindings module to lib 15 | - ci: automatically sync develop with master after release (#90) 16 | -------------------------------------------------------------------------------- /scripts/publish-crates.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | CRATES=( 5 | "crates/kanban-core" 6 | "crates/kanban-domain" 7 | "crates/kanban-tui" 8 | "crates/kanban-cli" 9 | ) 10 | 11 | echo "🚀 Publishing crates to crates.io..." 12 | echo "" 13 | 14 | echo "Running pre-publish validation..." 15 | validate-release 16 | echo "" 17 | 18 | echo "Publishing crates in dependency order..." 19 | for crate in "${CRATES[@]}"; do 20 | echo "📦 Publishing $crate..." 21 | cd "$crate" 22 | cargo publish --allow-dirty 23 | cd - > /dev/null 24 | sleep 10 25 | done 26 | 27 | echo "" 28 | echo "✅ All crates published successfully!" 29 | -------------------------------------------------------------------------------- /.changeset/kan-93-dialogs-always-return-to-main-when-opened.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | Refactored dialog mode handling to use nested AppMode::Dialog(DialogMode) enum 6 | for type-safe dialog management. Dialogs now correctly display their parent 7 | view in the background instead of hardcoded destinations. 8 | 9 | - Added DialogMode enum with all 23 dialog variants 10 | - Simplified is_dialog_mode() to matches!(self.mode, AppMode::Dialog(_)) 11 | - Added get_base_mode() to determine parent view from mode_stack 12 | - Two-phase rendering: base view first, then dialog overlay 13 | - Converted all push_mode(AppMode::X) calls to open_dialog(DialogMode::X) 14 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/services/filter.rs: -------------------------------------------------------------------------------- 1 | use kanban_domain::{Card, Column}; 2 | use uuid::Uuid; 3 | 4 | pub trait CardFilter { 5 | fn matches(&self, card: &Card) -> bool; 6 | } 7 | 8 | pub struct BoardFilter<'a> { 9 | board_id: Uuid, 10 | columns: &'a [Column], 11 | } 12 | 13 | impl<'a> BoardFilter<'a> { 14 | pub fn new(board_id: Uuid, columns: &'a [Column]) -> Self { 15 | Self { board_id, columns } 16 | } 17 | } 18 | 19 | impl CardFilter for BoardFilter<'_> { 20 | fn matches(&self, card: &Card) -> bool { 21 | self.columns 22 | .iter() 23 | .any(|col| col.id == card.column_id && col.board_id == self.board_id) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /crates/kanban-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kanban-core" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | description = "Core traits, errors, and result types for the kanban project management tool" 10 | keywords = ["kanban", "project-management", "tui", "core", "traits"] 11 | categories = ["command-line-utilities", "development-tools"] 12 | 13 | [dependencies] 14 | anyhow.workspace = true 15 | thiserror.workspace = true 16 | serde.workspace = true 17 | uuid.workspace = true 18 | chrono.workspace = true 19 | async-trait.workspace = true 20 | toml = "0.8" 21 | dirs = "5.0" 22 | -------------------------------------------------------------------------------- /.github/workflows/aggregate-changesets.yml: -------------------------------------------------------------------------------- 1 | name: Aggregate Changesets 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - develop 7 | types: 8 | - closed 9 | 10 | concurrency: 11 | group: aggregate-changesets 12 | cancel-in-progress: false 13 | 14 | jobs: 15 | aggregate: 16 | name: Aggregate Changesets 17 | runs-on: ubuntu-latest 18 | if: github.event.pull_request.merged == true 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | ref: develop 24 | 25 | - name: Aggregate changesets 26 | run: | 27 | bash scripts/aggregate-changesets.sh 28 | echo "✅ Changesets validated and aggregated" 29 | -------------------------------------------------------------------------------- /crates/kanban-cli/build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | println!("cargo::rerun-if-changed=../../.git/HEAD"); 5 | println!("cargo::rerun-if-changed=../../.git/refs/heads/"); 6 | 7 | let commit_hash = Command::new("git") 8 | .args(["rev-parse", "HEAD"]) 9 | .output() 10 | .ok() 11 | .and_then(|output| { 12 | if output.status.success() { 13 | String::from_utf8(output.stdout).ok() 14 | } else { 15 | None 16 | } 17 | }) 18 | .map(|s| s.trim().to_string()) 19 | .unwrap_or_else(|| "unknown".to_string()); 20 | 21 | println!("cargo::rustc-env=GIT_COMMIT_HASH={}", commit_hash); 22 | } 23 | -------------------------------------------------------------------------------- /crates/kanban-cli/src/handlers/export.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::{ExportArgs, ImportArgs}; 2 | use crate::context::CliContext; 3 | use crate::output; 4 | use kanban_domain::KanbanOperations; 5 | 6 | pub async fn handle_export(ctx: &CliContext, args: ExportArgs) -> anyhow::Result<()> { 7 | let json = ctx.export_board(args.board_id)?; 8 | println!("{}", json); 9 | Ok(()) 10 | } 11 | 12 | pub async fn handle_import(ctx: &mut CliContext, args: ImportArgs) -> anyhow::Result<()> { 13 | let data = std::fs::read_to_string(&args.file) 14 | .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", args.file, e))?; 15 | let board = ctx.import_board(&data)?; 16 | ctx.save().await?; 17 | output::output_success(&board); 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /.changeset/kan-20-remove-a-card.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - chore: simplify archived cards view keybindings 6 | - refactor: rename delete to archive, permanent delete to delete 7 | - refactor: consolidate keybinding providers into CardListProvider 8 | - feat: add animation state infrastructure and types 9 | - feat: add yellow border for deleted cards view visual distinction 10 | - feat: add card deletion from detail view 11 | - fix: card lookup in DeletedCardsView mode 12 | - feat: add deleted cards UI rendering 13 | - feat: add keybindings for card deletion 14 | - feat: implement card deletion with position compacting 15 | - feat: add DeletedCardsView mode to App 16 | - feat: add deleted_cards persistence 17 | - feat: add DeletedCard domain model 18 | 19 | -------------------------------------------------------------------------------- /.changeset/kan-147-multiselecting-and-assigning-cards-causes-write-race-condition.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: batch card creation with optional status update 6 | - fix: batch card movements with conditional status updates 7 | - fix: batch sprint activation and completion with board updates 8 | - fix: batch column position swaps 9 | - fix: batch card unassignment from sprint 10 | - fix: batch card completion toggles 11 | - fix: batch card moves when deleting column 12 | - fix: batch default column creation to prevent conflict dialog on new board 13 | - refactor: use batch command execution in sprint assignment handlers 14 | - feat: add execute_commands_batch for race-free command execution 15 | - fix: enhance AssignCardToSprint to handle sprint log transitions 16 | 17 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {}, rustToolchain ? pkgs.rustc }: 2 | 3 | let 4 | changeset = pkgs.writeShellScriptBin "changeset" '' 5 | ${builtins.readFile ./scripts/create-changeset.sh} 6 | ''; 7 | in 8 | 9 | pkgs.mkShell { 10 | name = "kanban-rust-shell"; 11 | 12 | buildInputs = with pkgs; [ 13 | # Rust toolchain 14 | rustToolchain 15 | cargo-watch 16 | cargo-edit 17 | cargo-audit 18 | cargo-tarpaulin 19 | 20 | # Development utilities 21 | bacon 22 | changeset 23 | 24 | asciinema_3 25 | asciinema-agg 26 | ]; 27 | 28 | shellHook = '' 29 | export RUST_BACKTRACE=1 30 | echo "Kanban Development Environment" 31 | echo "📦 Cargo: $(cargo --version)" 32 | echo "🦀 Rustc: $(rustc --version)" 33 | ''; 34 | } 35 | 36 | -------------------------------------------------------------------------------- /crates/kanban-core/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum KanbanError { 5 | #[error("Connection error: {0}")] 6 | Connection(String), 7 | 8 | #[error("Not found: {0}")] 9 | NotFound(String), 10 | 11 | #[error("Validation error: {0}")] 12 | Validation(String), 13 | 14 | #[error("IO error: {0}")] 15 | Io(#[from] std::io::Error), 16 | 17 | #[error("Serialization error: {0}")] 18 | Serialization(String), 19 | 20 | #[error("Internal error: {0}")] 21 | Internal(String), 22 | 23 | #[error("File conflict: {path} was modified by another instance")] 24 | ConflictDetected { 25 | path: String, 26 | #[source] 27 | source: Option>, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /crates/kanban-domain/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kanban-domain" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | description = "Domain models and business logic for the kanban project management tool" 10 | keywords = ["kanban", "project-management", "domain", "models", "business-logic"] 11 | categories = ["command-line-utilities", "development-tools"] 12 | 13 | [dependencies] 14 | kanban-core = { path = "../kanban-core", version = "^0.1" } 15 | anyhow.workspace = true 16 | thiserror.workspace = true 17 | serde.workspace = true 18 | serde_json.workspace = true 19 | uuid.workspace = true 20 | chrono.workspace = true 21 | async-trait.workspace = true 22 | 23 | [dev-dependencies] 24 | mockall.workspace = true 25 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod archived_card; 2 | pub mod board; 3 | pub mod card; 4 | pub mod column; 5 | pub mod commands; 6 | pub mod editable; 7 | pub mod field_update; 8 | pub mod operations; 9 | pub mod sprint; 10 | pub mod sprint_log; 11 | pub mod tag; 12 | pub mod task_list_view; 13 | 14 | pub use archived_card::ArchivedCard; 15 | pub use board::{Board, BoardId, BoardUpdate, SortField, SortOrder}; 16 | pub use card::{Card, CardId, CardPriority, CardStatus, CardSummary, CardUpdate}; 17 | pub use column::{Column, ColumnId, ColumnUpdate}; 18 | pub use editable::{BoardSettingsDto, CardMetadataDto}; 19 | pub use field_update::FieldUpdate; 20 | pub use operations::{CardFilter, KanbanOperations}; 21 | pub use sprint::{Sprint, SprintId, SprintStatus, SprintUpdate}; 22 | pub use sprint_log::SprintLog; 23 | pub use tag::{Tag, TagId}; 24 | pub use task_list_view::TaskListView; 25 | -------------------------------------------------------------------------------- /.changeset/kan-130-three-card-list-components-to-become-one.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | A refactoring. 6 | 7 | - refactor: simplify navigation handlers to work with unified view strategy 8 | - refactor: simplify card handlers to work with unified view strategy 9 | - refactor: update app initialization to use UnifiedViewStrategy 10 | - refactor: simplify render_tasks to use unified view strategy 11 | - refactor: introduce UnifiedViewStrategy to compose layout and render strategies 12 | - refactor: create render strategy abstraction for card list rendering 13 | - refactor: create layout strategy abstraction for card list management 14 | - refactor: extract card filtering and sorting logic into card_filter_service 15 | - KAN-118/unfilter-tasks-list-on-completed-sprint (#93) 16 | - KAN-111/sprint-binding-help-is-wrong (#92) 17 | - KAN-33: Add Help mode with context-aware keybindings (#91) 18 | - ci: automatically sync develop with master after release (#90) 19 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/sprint_log.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub struct SprintLog { 7 | pub sprint_id: Uuid, 8 | pub sprint_number: u32, 9 | pub sprint_name: Option, 10 | pub started_at: DateTime, 11 | pub ended_at: Option>, 12 | pub status: String, 13 | } 14 | 15 | impl SprintLog { 16 | pub fn new( 17 | sprint_id: Uuid, 18 | sprint_number: u32, 19 | sprint_name: Option, 20 | status: String, 21 | ) -> Self { 22 | Self { 23 | sprint_id, 24 | sprint_number, 25 | sprint_name, 26 | started_at: Utc::now(), 27 | ended_at: None, 28 | status, 29 | } 30 | } 31 | 32 | pub fn end_sprint(&mut self) { 33 | self.ended_at = Some(Utc::now()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /crates/kanban-core/src/traits.rs: -------------------------------------------------------------------------------- 1 | use crate::KanbanResult; 2 | use async_trait::async_trait; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[async_trait] 6 | pub trait Repository { 7 | async fn find_by_id(&self, id: Id) -> KanbanResult>; 8 | async fn find_all(&self) -> KanbanResult>; 9 | async fn save(&self, entity: &T) -> KanbanResult; 10 | async fn delete(&self, id: Id) -> KanbanResult<()>; 11 | } 12 | 13 | #[async_trait] 14 | pub trait Service { 15 | async fn get(&self, id: Id) -> KanbanResult; 16 | async fn list(&self) -> KanbanResult>; 17 | async fn create(&self, entity: T) -> KanbanResult; 18 | async fn update(&self, id: Id, entity: T) -> KanbanResult; 19 | async fn delete(&self, id: Id) -> KanbanResult<()>; 20 | } 21 | 22 | pub trait Editable: Serialize + for<'de> Deserialize<'de> + Sized { 23 | fn from_entity(entity: &T) -> Self; 24 | fn apply_to(self, entity: &mut T); 25 | } 26 | -------------------------------------------------------------------------------- /crates/kanban-mcp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kanban-mcp" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | description = "Model Context Protocol server for the kanban project management tool" 10 | keywords = ["kanban", "mcp", "model-context-protocol", "project-management"] 11 | categories = ["command-line-utilities"] 12 | 13 | [[bin]] 14 | name = "kanban-mcp" 15 | path = "src/main.rs" 16 | 17 | [dependencies] 18 | # MCP SDK 19 | rmcp = { version = "0.11", features = ["server", "transport-io"] } 20 | schemars = "1.0" 21 | 22 | # Async runtime 23 | tokio.workspace = true 24 | async-trait.workspace = true 25 | 26 | # Serialization 27 | serde.workspace = true 28 | serde_json.workspace = true 29 | 30 | # Error handling (for main.rs only) 31 | anyhow.workspace = true 32 | 33 | # Logging 34 | tracing.workspace = true 35 | tracing-subscriber.workspace = true 36 | -------------------------------------------------------------------------------- /crates/kanban-mcp/default.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , rustPlatform 3 | , makeWrapper 4 | , kanban 5 | }: 6 | 7 | let 8 | cargoToml = lib.importTOML ../../Cargo.toml; 9 | in 10 | rustPlatform.buildRustPackage { 11 | inherit (cargoToml.workspace.package) version; 12 | pname = "kanban-mcp"; 13 | 14 | src = lib.cleanSource ../..; 15 | 16 | cargoLock = { 17 | lockFile = ../../Cargo.lock; 18 | }; 19 | 20 | nativeBuildInputs = [ makeWrapper ]; 21 | 22 | # Only build the kanban-mcp binary 23 | cargoBuildFlags = [ "--package" "kanban-mcp" ]; 24 | cargoTestFlags = [ "--package" "kanban-mcp" ]; 25 | 26 | # Wrap the binary to include kanban CLI in PATH 27 | postInstall = '' 28 | wrapProgram $out/bin/kanban-mcp \ 29 | --prefix PATH : ${lib.makeBinPath [ kanban ]} 30 | ''; 31 | 32 | meta = { 33 | inherit (cargoToml.workspace.package) description homepage; 34 | license = lib.licenses.asl20; 35 | maintainers = with lib.maintainers; [ fulsomenko ]; 36 | mainProgram = "kanban-mcp"; 37 | platforms = lib.platforms.all; 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /.changeset/kan-151-kanban-cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix(tui): restoring restoring cards 6 | - fix(cli): restoring to a non existing column 7 | - docs: add CLI quick start section to root README 8 | - docs: update CLI README with command documentation 9 | - fix: use get_selected_card_in_context for points dialog 10 | - feat: add TuiContext struct with KanbanOperations implementation 11 | - feat: implement KanbanOperations trait for TUI App 12 | - test: update CLI tests for positional ID arguments 13 | - feat: make ID positional argument for single-resource commands 14 | - fix: return descriptive errors for invalid priority and status values 15 | - feat: add API version to CLI output and document never type 16 | - feat: simplify CLI file argument and add shell completions 17 | - fix: CLI context bugs and improve error messages 18 | - fix: Support positional file argument for TUI mode 19 | - test: Add comprehensive integration tests for CLI 20 | - feat: Implement full CLI with subcommand interface 21 | - feat: Add KanbanOperations trait for TUI/CLI feature parity 22 | 23 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/deleted_card.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::{card::Card, column::ColumnId}; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct DeletedCard { 8 | pub card: Card, 9 | pub deleted_at: DateTime, 10 | pub original_column_id: ColumnId, 11 | pub original_position: i32, 12 | } 13 | 14 | impl DeletedCard { 15 | pub fn new(card: Card, original_column_id: ColumnId, original_position: i32) -> Self { 16 | Self { 17 | card, 18 | deleted_at: Utc::now(), 19 | original_column_id, 20 | original_position, 21 | } 22 | } 23 | 24 | pub fn into_card(self) -> Card { 25 | self.card 26 | } 27 | 28 | pub fn card_ref(&self) -> &Card { 29 | &self.card 30 | } 31 | 32 | pub fn card_mut(&mut self) -> &mut Card { 33 | &mut self.card 34 | } 35 | } 36 | 37 | impl From for Card { 38 | fn from(deleted_card: DeletedCard) -> Self { 39 | deleted_card.card 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /crates/kanban-tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kanban-tui" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | description = "Terminal user interface for the kanban project management tool" 10 | keywords = ["kanban", "tui", "terminal", "ratatui", "project-management"] 11 | categories = ["command-line-utilities"] 12 | 13 | [dependencies] 14 | kanban-core = { path = "../kanban-core", version = "^0.1" } 15 | kanban-domain = { path = "../kanban-domain", version = "^0.1" } 16 | kanban-persistence = { path = "../kanban-persistence", version = "^0.1" } 17 | ratatui.workspace = true 18 | crossterm.workspace = true 19 | anyhow.workspace = true 20 | thiserror.workspace = true 21 | tokio.workspace = true 22 | serde.workspace = true 23 | serde_json.workspace = true 24 | uuid.workspace = true 25 | chrono.workspace = true 26 | async-trait.workspace = true 27 | tracing.workspace = true 28 | arboard = "3.4" 29 | pulldown-cmark = "0.13" 30 | 31 | [dev-dependencies] 32 | tempfile = "3.13" 33 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/archived_card.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::{card::Card, column::ColumnId}; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct ArchivedCard { 8 | pub card: Card, 9 | pub archived_at: DateTime, 10 | pub original_column_id: ColumnId, 11 | pub original_position: i32, 12 | } 13 | 14 | impl ArchivedCard { 15 | pub fn new(card: Card, original_column_id: ColumnId, original_position: i32) -> Self { 16 | Self { 17 | card, 18 | archived_at: Utc::now(), 19 | original_column_id, 20 | original_position, 21 | } 22 | } 23 | 24 | pub fn into_card(self) -> Card { 25 | self.card 26 | } 27 | 28 | pub fn card_ref(&self) -> &Card { 29 | &self.card 30 | } 31 | 32 | pub fn card_mut(&mut self) -> &mut Card { 33 | &mut self.card 34 | } 35 | } 36 | 37 | impl From for Card { 38 | fn from(archived_card: ArchivedCard) -> Self { 39 | archived_card.card 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.changeset/kan-15-progressive-saving-detect-changes-to-current-json.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - feat(persistence): create kanban-persistence crate structure 6 | - feat(state): create Command trait and StateManager 7 | - feat(domain): add CreateBoard command 8 | - feat(domain): add active_sprint_id field to BoardUpdate 9 | - feat(state): add debouncing to StateManager::save_if_needed() 10 | - feat(persistence): add automatic V1→V2 migration on load 11 | - feat(core,persistence): add conflict detection for multi-instance saves 12 | - feat(persistence): detect file conflicts before save 13 | - feat(state): propagate conflict errors in StateManager 14 | - feat(tui): Implement conflict resolution dialog and event loop integration 15 | - feat(tui): Integrate FileWatcher with App event loop 16 | - feat(state): Add view refresh tracking to StateManager 17 | - feat(tui): Add ExternalChangeDetected dialog 18 | - feat(tui): add user-visible error display banner 19 | - feat(app): prevent quit with pending saves 20 | - feat(app): add save completion receiver to App struct 21 | - feat(state): add bidirectional save completion channel 22 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use kanban_core::KanbanResult; 2 | 3 | pub mod board_commands; 4 | pub mod card_commands; 5 | pub mod column_commands; 6 | pub mod sprint_commands; 7 | 8 | pub use board_commands::*; 9 | pub use card_commands::*; 10 | pub use column_commands::*; 11 | pub use sprint_commands::*; 12 | 13 | /// Trait for domain commands that mutate state 14 | /// Commands represent intent and can be executed, queued, and persisted 15 | pub trait Command: Send + Sync { 16 | /// Execute this command, mutating the domain state 17 | fn execute(&self, context: &mut CommandContext) -> KanbanResult<()>; 18 | 19 | /// Human-readable description of what this command does 20 | fn description(&self) -> String; 21 | } 22 | 23 | /// Context passed to commands for mutation 24 | /// Contains references to all domain aggregates 25 | pub struct CommandContext<'a> { 26 | pub boards: &'a mut Vec, 27 | pub columns: &'a mut Vec, 28 | pub cards: &'a mut Vec, 29 | pub sprints: &'a mut Vec, 30 | pub archived_cards: &'a mut Vec, 31 | } 32 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/board_context.rs: -------------------------------------------------------------------------------- 1 | use kanban_domain::{Board, Sprint}; 2 | 3 | /// Get the active sprint's card prefix override if one exists. 4 | /// Returns the sprint's card_prefix if the board has an active sprint 5 | /// that has a card_prefix override set. 6 | pub fn get_active_sprint_card_prefix_override<'a>( 7 | board: &'a Board, 8 | sprints: &'a [Sprint], 9 | ) -> Option<&'a str> { 10 | board.active_sprint_id.and_then(|sprint_id| { 11 | sprints 12 | .iter() 13 | .find(|s| s.id == sprint_id) 14 | .and_then(|sprint| sprint.card_prefix.as_deref()) 15 | }) 16 | } 17 | 18 | /// Get the active sprint's sprint prefix override if one exists. 19 | /// Returns the sprint's prefix if the board has an active sprint 20 | /// that has a sprint prefix override set. 21 | pub fn get_active_sprint_prefix_override<'a>( 22 | board: &'a Board, 23 | sprints: &'a [Sprint], 24 | ) -> Option<&'a str> { 25 | board.active_sprint_id.and_then(|sprint_id| { 26 | sprints 27 | .iter() 28 | .find(|s| s.id == sprint_id) 29 | .and_then(|sprint| sprint.prefix.as_deref()) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /crates/kanban-persistence/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kanban-persistence" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | description = "Persistence layer for the kanban project management tool with progressive saving and multi-instance support" 10 | keywords = ["kanban", "persistence", "storage", "file-watching"] 11 | categories = ["command-line-utilities", "development-tools"] 12 | 13 | [dependencies] 14 | kanban-core = { path = "../kanban-core", version = "^0.1" } 15 | kanban-domain = { path = "../kanban-domain", version = "^0.1" } 16 | 17 | # Core async and error handling 18 | tokio.workspace = true 19 | async-trait.workspace = true 20 | thiserror.workspace = true 21 | anyhow.workspace = true 22 | 23 | # Serialization 24 | serde.workspace = true 25 | serde_json.workspace = true 26 | 27 | # IDs and time 28 | uuid.workspace = true 29 | chrono.workspace = true 30 | 31 | # File operations 32 | notify.workspace = true 33 | tempfile.workspace = true 34 | 35 | # Logging 36 | tracing.workspace = true 37 | 38 | [dev-dependencies] 39 | mockall.workspace = true 40 | -------------------------------------------------------------------------------- /crates/kanban-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kanban-cli" 3 | version.workspace = true 4 | edition.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | homepage.workspace = true 9 | description = "Command-line interface for the kanban project management tool" 10 | keywords = ["kanban", "cli", "terminal", "project-management", "productivity"] 11 | categories = ["command-line-utilities"] 12 | 13 | [[bin]] 14 | name = "kanban" 15 | path = "src/main.rs" 16 | 17 | [dependencies] 18 | kanban-core = { path = "../kanban-core", version = "^0.1" } 19 | kanban-domain = { path = "../kanban-domain", version = "^0.1" } 20 | kanban-persistence = { path = "../kanban-persistence", version = "^0.1" } 21 | kanban-tui = { path = "../kanban-tui", version = "^0.1" } 22 | clap.workspace = true 23 | clap_complete.workspace = true 24 | tokio.workspace = true 25 | anyhow.workspace = true 26 | tracing.workspace = true 27 | tracing-subscriber.workspace = true 28 | uuid.workspace = true 29 | chrono.workspace = true 30 | serde.workspace = true 31 | serde_json.workspace = true 32 | 33 | [dev-dependencies] 34 | tempfile.workspace = true 35 | assert_cmd.workspace = true 36 | predicates.workspace = true 37 | -------------------------------------------------------------------------------- /.changeset/kan-55-scroll-in-cards-list.md: -------------------------------------------------------------------------------- 1 | --- 2 | bump: patch 3 | --- 4 | 5 | - fix: ensure forward progress when viewport shrinks during down navigation 6 | - fix: correct viewport height calculation across all renderers 7 | - feat: add viewport calculation infrastructure to CardList 8 | - fix: allow scrolling down to show the final card 9 | - feat: update navigation to account for scroll indicator space 10 | - feat: add scroll indicators showing tasks above and below viewport 11 | - feat: use actual viewport_height instead of hardcoded value 12 | - feat: calculate and update viewport_height during rendering 13 | - feat: add viewport_height tracking to App 14 | - fix: eliminate selector jitter by moving selection with scroll 15 | - refactor: remove preemptive ensure_selected_visible calls 16 | - refactor: update CardListComponent navigate methods for viewport awareness 17 | - refactor: implement scroll-on-boundary logic in navigate methods 18 | - feat: wire up automatic scroll adjustment on navigation 19 | - feat: implement scroll-aware rendering for sprint detail panels 20 | - feat: implement scroll-aware rendering in all card list views 21 | - feat: expose scroll management in CardListComponent 22 | - feat: add scroll offset tracking to CardList 23 | -------------------------------------------------------------------------------- /scripts/aggregate-changesets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Aggregate all changesets in .changeset/ and determine the highest bump type 5 | # Outputs key=value format for GitHub Actions 6 | # Exit 0 if changesets found, exit 1 if none 7 | 8 | CHANGESET_DIR=".changeset" 9 | 10 | # Find all changeset files (excluding README.md) 11 | CHANGESETS=$(find "$CHANGESET_DIR" -maxdepth 1 -name "*.md" ! -name "README.md" 2>/dev/null | sort || true) 12 | 13 | if [ -z "$CHANGESETS" ]; then 14 | echo "No changesets found" 15 | exit 1 16 | fi 17 | 18 | # Extract all bump types 19 | BUMP_TYPES="" 20 | for changeset in $CHANGESETS; do 21 | bump=$(grep -A1 "^---$" "$changeset" | grep "^bump:" | cut -d' ' -f2 | tr -d '\r\n' || echo "patch") 22 | BUMP_TYPES="$BUMP_TYPES $bump" 23 | done 24 | 25 | # Determine highest priority bump (major > minor > patch) 26 | HIGHEST_BUMP="patch" 27 | if echo "$BUMP_TYPES" | grep -q "major"; then 28 | HIGHEST_BUMP="major" 29 | elif echo "$BUMP_TYPES" | grep -q "minor"; then 30 | HIGHEST_BUMP="minor" 31 | fi 32 | 33 | # Output in GitHub Actions format 34 | if [ -n "${GITHUB_OUTPUT:-}" ]; then 35 | echo "bump_type=$HIGHEST_BUMP" >> "$GITHUB_OUTPUT" 36 | else 37 | echo "bump_type=$HIGHEST_BUMP" 38 | fi 39 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/theme/colors.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | 3 | pub const FOCUSED_BORDER: Color = Color::Cyan; 4 | pub const UNFOCUSED_BORDER: Color = Color::White; 5 | pub const SELECTED_BG: Color = Color::Blue; 6 | 7 | pub const ACTIVE_ITEM: Color = Color::Green; 8 | pub const DONE_TEXT: Color = Color::DarkGray; 9 | pub const NORMAL_TEXT: Color = Color::White; 10 | pub const LABEL_TEXT: Color = Color::DarkGray; 11 | pub const HIGHLIGHT_TEXT: Color = Color::Yellow; 12 | 13 | pub const PRIORITY_CRITICAL: Color = Color::Red; 14 | pub const PRIORITY_HIGH: Color = Color::LightRed; 15 | pub const PRIORITY_MEDIUM: Color = Color::Yellow; 16 | pub const PRIORITY_LOW: Color = Color::White; 17 | 18 | pub const POINTS_1: Color = Color::Cyan; 19 | pub const POINTS_2: Color = Color::Green; 20 | pub const POINTS_3: Color = Color::Yellow; 21 | pub const POINTS_4: Color = Color::LightMagenta; 22 | pub const POINTS_5: Color = Color::Red; 23 | 24 | pub const STATUS_ACTIVE: Color = Color::Green; 25 | pub const STATUS_PLANNING: Color = Color::Yellow; 26 | pub const STATUS_COMPLETED: Color = Color::Gray; 27 | pub const STATUS_CANCELLED: Color = Color::Red; 28 | 29 | pub const POPUP_BG: Color = Color::Black; 30 | pub const ERROR_COLOR: Color = Color::Red; 31 | 32 | pub const FLASH_DELETE: Color = Color::Red; 33 | pub const FLASH_RESTORE: Color = Color::Blue; 34 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/dialog.rs: -------------------------------------------------------------------------------- 1 | use crate::input::InputState; 2 | use crossterm::event::KeyCode; 3 | 4 | pub fn handle_dialog_input( 5 | input: &mut InputState, 6 | key_code: KeyCode, 7 | allow_empty: bool, 8 | ) -> DialogAction { 9 | match key_code { 10 | KeyCode::Esc => DialogAction::Cancel, 11 | KeyCode::Enter => { 12 | if allow_empty || !input.is_empty() { 13 | DialogAction::Confirm 14 | } else { 15 | DialogAction::None 16 | } 17 | } 18 | KeyCode::Char(c) => { 19 | input.insert_char(c); 20 | DialogAction::None 21 | } 22 | KeyCode::Backspace => { 23 | input.backspace(); 24 | DialogAction::None 25 | } 26 | KeyCode::Delete => { 27 | input.delete(); 28 | DialogAction::None 29 | } 30 | KeyCode::Left => { 31 | input.move_left(); 32 | DialogAction::None 33 | } 34 | KeyCode::Right => { 35 | input.move_right(); 36 | DialogAction::None 37 | } 38 | KeyCode::Home => { 39 | input.move_home(); 40 | DialogAction::None 41 | } 42 | KeyCode::End => { 43 | input.move_end(); 44 | DialogAction::None 45 | } 46 | _ => DialogAction::None, 47 | } 48 | } 49 | 50 | pub enum DialogAction { 51 | None, 52 | Cancel, 53 | Confirm, 54 | } 55 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "crates/kanban-core", 5 | "crates/kanban-domain", 6 | "crates/kanban-persistence", 7 | "crates/kanban-tui", 8 | "crates/kanban-cli", 9 | "crates/kanban-mcp", 10 | ] 11 | 12 | [workspace.package] 13 | version = "0.1.13" 14 | edition = "2021" 15 | authors = ["Max Emil Yoon Blomstervall"] 16 | license = "Apache-2.0" 17 | repository = "https://github.com/fulsomenko/kanban" 18 | homepage = "https://github.com/fulsomenko/kanban" 19 | description = "A terminal-based project management solution" 20 | 21 | [workspace.dependencies] 22 | # Core async runtime 23 | tokio = { version = "1.42", features = ["full"] } 24 | async-trait = "0.1" 25 | futures = "0.3" 26 | futures-util = "0.3" 27 | 28 | # Error handling 29 | anyhow = "1.0" 30 | thiserror = "2.0" 31 | 32 | # Serialization 33 | serde = { version = "1.0", features = ["derive"] } 34 | serde_json = "1.0" 35 | 36 | # UUID and time 37 | uuid = { version = "1.11", features = ["v4", "serde"] } 38 | chrono = { version = "0.4", features = ["serde"] } 39 | 40 | # TUI framework 41 | ratatui = "0.29" 42 | crossterm = "0.28" 43 | 44 | # CLI framework 45 | clap = { version = "4.5", features = ["derive", "env"] } 46 | clap_complete = "4.5" 47 | 48 | # Logging 49 | tracing = "0.1" 50 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 51 | 52 | # Testing 53 | mockall = "0.13" 54 | assert_cmd = "2.0" 55 | predicates = "3.0" 56 | 57 | # File watching and atomic operations 58 | notify = "6.1" 59 | tempfile = "3.8" 60 | 61 | [profile.release] 62 | opt-level = 3 63 | lto = true 64 | codegen-units = 1 65 | strip = true 66 | 67 | -------------------------------------------------------------------------------- /crates/kanban-cli/src/output.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | #[derive(Serialize)] 4 | pub struct CliResponse { 5 | pub success: bool, 6 | pub api_version: &'static str, 7 | #[serde(skip_serializing_if = "Option::is_none")] 8 | pub data: Option, 9 | #[serde(skip_serializing_if = "Option::is_none")] 10 | pub error: Option, 11 | } 12 | 13 | #[derive(Serialize)] 14 | pub struct ListResponse { 15 | pub items: Vec, 16 | pub count: usize, 17 | } 18 | 19 | pub fn output_success(data: T) { 20 | let response = CliResponse { 21 | success: true, 22 | api_version: env!("CARGO_PKG_VERSION"), 23 | data: Some(data), 24 | error: None, 25 | }; 26 | println!("{}", serde_json::to_string(&response).unwrap()); 27 | } 28 | 29 | pub fn output_list(items: Vec) { 30 | let count = items.len(); 31 | let list = ListResponse { items, count }; 32 | output_success(list); 33 | } 34 | 35 | /// Outputs an error response to stderr and returns an error for proper propagation. 36 | /// 37 | /// Returns an `anyhow::Error` to allow callers to handle the error appropriately 38 | /// and enable proper cleanup. The CLI's main function handles the exit code. 39 | pub fn output_error(message: &str) -> anyhow::Result<()> { 40 | let response: CliResponse<()> = CliResponse { 41 | success: false, 42 | api_version: env!("CARGO_PKG_VERSION"), 43 | data: None, 44 | error: Some(message.to_string()), 45 | }; 46 | eprintln!("{}", serde_json::to_string(&response).unwrap()); 47 | anyhow::bail!("{}", message) 48 | } 49 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/selection.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone)] 2 | pub struct SelectionState { 3 | selected_index: Option, 4 | } 5 | 6 | impl SelectionState { 7 | pub fn new() -> Self { 8 | Self { 9 | selected_index: None, 10 | } 11 | } 12 | 13 | pub fn get(&self) -> Option { 14 | self.selected_index 15 | } 16 | 17 | pub fn set(&mut self, index: Option) { 18 | self.selected_index = index; 19 | } 20 | 21 | pub fn clear(&mut self) { 22 | self.selected_index = None; 23 | } 24 | 25 | pub fn next(&mut self, max_count: usize) { 26 | if max_count == 0 { 27 | return; 28 | } 29 | self.selected_index = Some(match self.selected_index { 30 | Some(idx) => (idx + 1).min(max_count - 1), 31 | None => 0, 32 | }); 33 | } 34 | 35 | pub fn prev(&mut self) { 36 | self.selected_index = Some(match self.selected_index { 37 | Some(idx) => idx.saturating_sub(1), 38 | None => 0, 39 | }); 40 | } 41 | 42 | pub fn auto_select_first_if_empty(&mut self, has_items: bool) { 43 | if self.selected_index.is_none() && has_items { 44 | self.selected_index = Some(0); 45 | } 46 | } 47 | 48 | pub fn jump_to_first(&mut self) { 49 | self.selected_index = Some(0); 50 | } 51 | 52 | pub fn jump_to_last(&mut self, len: usize) { 53 | if len > 0 { 54 | self.selected_index = Some(len - 1); 55 | } 56 | } 57 | } 58 | 59 | impl Default for SelectionState { 60 | fn default() -> Self { 61 | Self::new() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/kanban-domain/src/commands/column_commands.rs: -------------------------------------------------------------------------------- 1 | use super::{Command, CommandContext}; 2 | use crate::ColumnUpdate; 3 | use kanban_core::KanbanResult; 4 | use uuid::Uuid; 5 | 6 | /// Update column properties (name, position, wip_limit) 7 | pub struct UpdateColumn { 8 | pub column_id: Uuid, 9 | pub updates: ColumnUpdate, 10 | } 11 | 12 | impl Command for UpdateColumn { 13 | fn execute(&self, context: &mut CommandContext) -> KanbanResult<()> { 14 | if let Some(column) = context.columns.iter_mut().find(|c| c.id == self.column_id) { 15 | column.update(self.updates.clone()); 16 | } 17 | Ok(()) 18 | } 19 | 20 | fn description(&self) -> String { 21 | "Update column".to_string() 22 | } 23 | } 24 | 25 | /// Create a new column 26 | pub struct CreateColumn { 27 | pub board_id: Uuid, 28 | pub name: String, 29 | pub position: i32, 30 | } 31 | 32 | impl Command for CreateColumn { 33 | fn execute(&self, context: &mut CommandContext) -> KanbanResult<()> { 34 | let column = crate::Column::new(self.board_id, self.name.clone(), self.position); 35 | context.columns.push(column); 36 | Ok(()) 37 | } 38 | 39 | fn description(&self) -> String { 40 | format!("Create column: '{}'", self.name) 41 | } 42 | } 43 | 44 | /// Delete a column 45 | pub struct DeleteColumn { 46 | pub column_id: Uuid, 47 | } 48 | 49 | impl Command for DeleteColumn { 50 | fn execute(&self, context: &mut CommandContext) -> KanbanResult<()> { 51 | context.columns.retain(|c| c.id != self.column_id); 52 | Ok(()) 53 | } 54 | 55 | fn description(&self) -> String { 56 | format!("Delete column {}", self.column_id) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/kanban-tui/tests/markdown_renderer.rs: -------------------------------------------------------------------------------- 1 | use kanban_tui::markdown_renderer::render_markdown; 2 | 3 | #[test] 4 | fn test_plain_text() { 5 | let text = "This is plain text"; 6 | let lines = render_markdown(text); 7 | assert!(!lines.is_empty()); 8 | } 9 | 10 | #[test] 11 | fn test_bold_text() { 12 | let text = "This is **bold** text"; 13 | let lines = render_markdown(text); 14 | assert!(!lines.is_empty()); 15 | } 16 | 17 | #[test] 18 | fn test_italic_text() { 19 | let text = "This is *italic* text"; 20 | let lines = render_markdown(text); 21 | assert!(!lines.is_empty()); 22 | } 23 | 24 | #[test] 25 | fn test_code_block() { 26 | let text = "```rust\nfn main() {}\n```"; 27 | let lines = render_markdown(text); 28 | assert!(!lines.is_empty()); 29 | } 30 | 31 | #[test] 32 | fn test_multiple_paragraphs() { 33 | let text = "First paragraph\n\nSecond paragraph"; 34 | let lines = render_markdown(text); 35 | assert!(lines.len() >= 3); 36 | } 37 | 38 | #[test] 39 | fn test_inline_code() { 40 | let text = "Use `fn main()` to start"; 41 | let lines = render_markdown(text); 42 | assert!(!lines.is_empty()); 43 | } 44 | 45 | #[test] 46 | fn test_empty_text() { 47 | let text = ""; 48 | let lines = render_markdown(text); 49 | assert!(lines.is_empty() || lines.iter().all(|line| line.spans.is_empty())); 50 | } 51 | 52 | #[test] 53 | fn test_code_block_with_language() { 54 | let text = "```python\nprint('hello')\n```"; 55 | let lines = render_markdown(text); 56 | assert!(!lines.is_empty()); 57 | } 58 | 59 | #[test] 60 | fn test_mixed_formatting() { 61 | let text = "This is **bold with `code`** and *italic* text"; 62 | let lines = render_markdown(text); 63 | assert!(!lines.is_empty()); 64 | } 65 | -------------------------------------------------------------------------------- /scripts/create-changeset.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | BRANCH=$(git branch --show-current) 5 | 6 | if [ "$BRANCH" = "master" ] || [ "$BRANCH" = "main" ]; then 7 | echo "Error: Cannot create changeset on master/main branch" 8 | exit 1 9 | fi 10 | 11 | # Determine bump type and description based on arguments 12 | BUMP_TYPE="patch" 13 | DESCRIPTION="" 14 | 15 | if [ $# -gt 0 ]; then 16 | # Check if first argument is a valid bump type 17 | if [[ "$1" =~ ^(patch|minor|major)$ ]]; then 18 | BUMP_TYPE="$1" 19 | DESCRIPTION="${2:-}" 20 | else 21 | # First argument is treated as description, bump type defaults to patch 22 | DESCRIPTION="$1" 23 | fi 24 | fi 25 | 26 | if [ -z "$DESCRIPTION" ]; then 27 | BASE_BRANCH="${BASE_BRANCH:-master}" 28 | COMMITS=$(git log --oneline "$BASE_BRANCH"..HEAD --pretty=format:"- %s") 29 | 30 | if [ -z "$COMMITS" ]; then 31 | echo "Error: No commits found and no description provided" 32 | echo "Usage: $0 [patch|minor|major] \"Description of changes\"" 33 | exit 1 34 | fi 35 | 36 | DESCRIPTION="$COMMITS" 37 | echo "Auto-generated description from commits:" 38 | echo "$DESCRIPTION" 39 | echo "" 40 | fi 41 | 42 | SANITIZED_BRANCH=$(echo "$BRANCH" | tr '/' '-' | tr '[:upper:]' '[:lower:]') 43 | 44 | # Extract issue ID (kan-XX) from branch name if present 45 | if [[ "$SANITIZED_BRANCH" =~ ^(kan-[0-9]+) ]]; then 46 | ISSUE_ID="${BASH_REMATCH[1]}" 47 | CHANGESET_FILE=".changeset/${ISSUE_ID}-${SANITIZED_BRANCH#${ISSUE_ID}-}.md" 48 | else 49 | CHANGESET_FILE=".changeset/${SANITIZED_BRANCH}.md" 50 | fi 51 | 52 | mkdir -p .changeset 53 | 54 | cat > "$CHANGESET_FILE" < Self { 8 | Self { 9 | buffer: String::new(), 10 | cursor: 0, 11 | } 12 | } 13 | 14 | pub fn insert_char(&mut self, c: char) { 15 | self.buffer.insert(self.cursor, c); 16 | self.cursor += 1; 17 | } 18 | 19 | pub fn backspace(&mut self) { 20 | if self.cursor > 0 { 21 | self.cursor -= 1; 22 | self.buffer.remove(self.cursor); 23 | } 24 | } 25 | 26 | pub fn delete(&mut self) { 27 | if self.cursor < self.buffer.len() { 28 | self.buffer.remove(self.cursor); 29 | } 30 | } 31 | 32 | pub fn move_left(&mut self) { 33 | if self.cursor > 0 { 34 | self.cursor -= 1; 35 | } 36 | } 37 | 38 | pub fn move_right(&mut self) { 39 | if self.cursor < self.buffer.len() { 40 | self.cursor += 1; 41 | } 42 | } 43 | 44 | pub fn move_home(&mut self) { 45 | self.cursor = 0; 46 | } 47 | 48 | pub fn move_end(&mut self) { 49 | self.cursor = self.buffer.len(); 50 | } 51 | 52 | pub fn clear(&mut self) { 53 | self.buffer.clear(); 54 | self.cursor = 0; 55 | } 56 | 57 | pub fn set(&mut self, text: String) { 58 | self.buffer = text; 59 | self.cursor = self.buffer.len(); 60 | } 61 | 62 | pub fn is_empty(&self) -> bool { 63 | self.buffer.is_empty() 64 | } 65 | 66 | pub fn as_str(&self) -> &str { 67 | &self.buffer 68 | } 69 | 70 | pub fn cursor_pos(&self) -> usize { 71 | self.cursor 72 | } 73 | } 74 | 75 | impl Default for InputState { 76 | fn default() -> Self { 77 | Self::new() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/events.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent}; 2 | use std::time::Duration; 3 | use tokio::sync::mpsc; 4 | 5 | #[derive(Debug, Clone)] 6 | pub enum Event { 7 | Key(KeyEvent), 8 | Tick, 9 | } 10 | 11 | pub struct EventHandler { 12 | rx: mpsc::UnboundedReceiver, 13 | shutdown_tx: mpsc::UnboundedSender<()>, 14 | } 15 | 16 | impl Default for EventHandler { 17 | fn default() -> Self { 18 | Self::new() 19 | } 20 | } 21 | 22 | impl EventHandler { 23 | pub fn new() -> Self { 24 | let (tx, rx) = mpsc::unbounded_channel(); 25 | let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel(); 26 | 27 | tokio::spawn(async move { 28 | loop { 29 | tokio::select! { 30 | _ = shutdown_rx.recv() => { 31 | break; 32 | } 33 | _ = tokio::time::sleep(Duration::from_millis(16)) => { 34 | if event::poll(Duration::from_millis(0)).unwrap_or(false) { 35 | if let Ok(CrosstermEvent::Key(key)) = event::read() { 36 | if tx.send(Event::Key(key)).is_err() { 37 | break; 38 | } 39 | } 40 | } else if tx.send(Event::Tick).is_err() { 41 | break; 42 | } 43 | } 44 | } 45 | } 46 | }); 47 | 48 | Self { rx, shutdown_tx } 49 | } 50 | 51 | pub async fn next(&mut self) -> Option { 52 | self.rx.recv().await 53 | } 54 | 55 | pub fn stop(&self) { 56 | let _ = self.shutdown_tx.send(()); 57 | } 58 | } 59 | 60 | pub fn should_quit(key: &KeyEvent) -> bool { 61 | matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) 62 | } 63 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/filters.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | #[derive(Debug, Clone, PartialEq)] 4 | pub enum FilterDialogSection { 5 | Sprints, 6 | DateRange, 7 | Tags, 8 | } 9 | 10 | #[derive(Debug, Clone, Default)] 11 | pub struct CardFilters { 12 | pub show_unassigned_sprints: bool, 13 | pub selected_sprint_ids: HashSet, 14 | pub date_from: Option, 15 | pub date_to: Option, 16 | pub selected_tags: HashSet, 17 | } 18 | 19 | #[derive(Debug, Clone)] 20 | pub struct FilterDialogState { 21 | pub current_section: FilterDialogSection, 22 | pub section_index: usize, 23 | pub item_selection: usize, 24 | pub filters: CardFilters, 25 | } 26 | 27 | impl FilterDialogState { 28 | pub fn new(filters: CardFilters) -> Self { 29 | Self { 30 | current_section: FilterDialogSection::Sprints, 31 | section_index: 0, 32 | item_selection: 0, 33 | filters, 34 | } 35 | } 36 | 37 | pub fn next_section(&mut self) { 38 | self.section_index = (self.section_index + 1) % 3; 39 | self.item_selection = 0; 40 | self.current_section = match self.section_index { 41 | 0 => FilterDialogSection::Sprints, 42 | 1 => FilterDialogSection::DateRange, 43 | 2 => FilterDialogSection::Tags, 44 | _ => FilterDialogSection::Sprints, 45 | }; 46 | } 47 | 48 | pub fn prev_section(&mut self) { 49 | self.section_index = if self.section_index == 0 { 50 | 2 51 | } else { 52 | self.section_index - 1 53 | }; 54 | self.item_selection = 0; 55 | self.current_section = match self.section_index { 56 | 0 => FilterDialogSection::Sprints, 57 | 1 => FilterDialogSection::DateRange, 58 | 2 => FilterDialogSection::Tags, 59 | _ => FilterDialogSection::Sprints, 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/kanban-core/src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] 5 | pub struct AppConfig { 6 | #[serde(default)] 7 | pub default_branch_prefix: Option, 8 | } 9 | 10 | impl AppConfig { 11 | pub fn config_path() -> Option { 12 | #[cfg(target_os = "macos")] 13 | { 14 | dirs::home_dir().map(|home| home.join(".config/kanban/config.toml")) 15 | } 16 | #[cfg(target_os = "linux")] 17 | { 18 | dirs::config_dir().map(|config| config.join("kanban/config.toml")) 19 | } 20 | #[cfg(target_os = "windows")] 21 | { 22 | dirs::config_dir().map(|config| config.join("kanban\\config.toml")) 23 | } 24 | #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] 25 | { 26 | None 27 | } 28 | } 29 | 30 | pub fn load() -> Self { 31 | if let Some(config_path) = Self::config_path() { 32 | if config_path.exists() { 33 | if let Ok(content) = std::fs::read_to_string(&config_path) { 34 | if let Ok(config) = toml::from_str(&content) { 35 | return config; 36 | } 37 | } 38 | } 39 | } 40 | Self::default() 41 | } 42 | 43 | pub fn effective_default_sprint_prefix(&self) -> &str { 44 | self.default_branch_prefix.as_deref().unwrap_or("sprint") 45 | } 46 | 47 | pub fn effective_default_card_prefix(&self) -> &str { 48 | self.default_branch_prefix.as_deref().unwrap_or("task") 49 | } 50 | 51 | #[deprecated( 52 | since = "0.1.10", 53 | note = "use effective_default_sprint_prefix or effective_default_card_prefix instead" 54 | )] 55 | pub fn effective_default_prefix(&self) -> &str { 56 | self.effective_default_card_prefix() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /crates/kanban-persistence/src/serialization/json_serializer.rs: -------------------------------------------------------------------------------- 1 | use crate::traits::Serializer; 2 | use kanban_core::KanbanResult; 3 | 4 | /// JSON serializer for domain models 5 | pub struct JsonSerializer; 6 | 7 | impl Serializer 8 | for JsonSerializer 9 | { 10 | fn serialize(&self, data: &T) -> KanbanResult> { 11 | let json = serde_json::to_vec_pretty(data) 12 | .map_err(|e| kanban_core::KanbanError::Serialization(e.to_string()))?; 13 | Ok(json) 14 | } 15 | 16 | fn deserialize(&self, bytes: &[u8]) -> KanbanResult { 17 | let data = serde_json::from_slice(bytes) 18 | .map_err(|e| kanban_core::KanbanError::Serialization(e.to_string()))?; 19 | Ok(data) 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::*; 26 | use serde::{Deserialize, Serialize}; 27 | 28 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 29 | struct TestData { 30 | name: String, 31 | value: i32, 32 | } 33 | 34 | #[test] 35 | fn test_serialize_deserialize() { 36 | let serializer = JsonSerializer; 37 | let data = TestData { 38 | name: "test".to_string(), 39 | value: 42, 40 | }; 41 | 42 | let serialized = serializer.serialize(&data).unwrap(); 43 | let deserialized: TestData = serde_json::from_slice(&serialized).unwrap(); 44 | 45 | assert_eq!(data, deserialized); 46 | } 47 | 48 | #[test] 49 | fn test_pretty_print() { 50 | let serializer = JsonSerializer; 51 | let data = TestData { 52 | name: "test".to_string(), 53 | value: 42, 54 | }; 55 | 56 | let serialized = serializer.serialize(&data).unwrap(); 57 | let json_str = String::from_utf8(serialized).unwrap(); 58 | 59 | // Pretty printed JSON should be readable 60 | assert!(json_str.contains("name")); 61 | assert!(json_str.contains("value")); 62 | assert!(json_str.contains('\n')); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /crates/kanban-tui/src/components/panel.rs: -------------------------------------------------------------------------------- 1 | use crate::theme::{focused_border, unfocused_border}; 2 | use ratatui::{ 3 | layout::Rect, 4 | style::Style, 5 | widgets::{Block, Borders, Paragraph}, 6 | Frame, 7 | }; 8 | 9 | pub struct PanelConfig<'a> { 10 | pub title: &'a str, 11 | pub focused_title: &'a str, 12 | pub is_focused: bool, 13 | pub custom_border_style: Option