Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Test

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Run tests
run: cargo test
41 changes: 35 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

rem-cli is a TUI (Terminal User Interface) TODO management tool written in Rust. The core concept is that all TODO data is stored locally on the filesystem, ensuring data privacy for sensitive information.
rem-cli is a TUI (Terminal User Interface) TODO management tool written in Rust. The core concept is that all TODO data is stored locally on the filesystem, ensuring data privacy for sensitive information. The binary name is `rem` (invoked via `rem` command).

## Build Commands

```bash
cargo build # Build the project
cargo run # Run the application
cargo run # Run the application (binary: rem)
cargo test # Run all tests
cargo test <name> # Run a specific test
cargo clippy # Run linter
Expand All @@ -20,14 +20,43 @@ cargo fmt # Format code
## Architecture

- **TUI Framework**: ratatui (v0.30.0) with crossterm (v0.29.0) backend
- **Application Pattern**: Event loop with state management in `App` struct
- `main()`: Terminal setup, event loop, cleanup
- `App`: Application state and input handling
- `render()`: UI rendering logic
- **Source Files**:
- `src/main.rs`: Terminal setup/cleanup, event loop, neovim integration
- `src/app.rs`: Application state (`App` struct), input handling, mode management
- `src/render.rs`: UI rendering logic (layout, task lists, preview panel)
- `src/task.rs`: Task data model, filesystem I/O, status management

## Data Storage

Tasks are stored as markdown files under `~/.rem-cli/tasks/` with directory-based status management:

```
~/.rem-cli/tasks/
todo/<uuid>.md
doing/<uuid>.md
done/<uuid>.md
```

- Status is determined by which directory the file resides in (not by frontmatter)
- Frontmatter contains: `id`, `name`, `created_at`, `updated_at` (no `status` field)
- Status changes move the file between directories via `fs::rename`

## Key Patterns

- Terminal enters raw mode and alternate screen on startup
- Event polling with 100ms timeout
- Key events are handled only on `KeyEventKind::Press`
- Clean terminal restoration on exit (disable raw mode, leave alternate screen)
- Two input modes: `Normal` (navigation/actions) and `Editing` (text input for new tasks)
- Done tasks are lazy-loaded on demand (`d` key toggles) to keep startup fast
- Preview panel (right 70%) shows the selected task's markdown content, updated on cursor movement
- Neovim integration: Enter key temporarily exits TUI, opens task file in nvim, then restores TUI
- `open_file: Option<PathBuf>` is used as a message-passing mechanism between `App` (state) and `main` (terminal control)
- `--version` / `-V` flag prints version and exits without entering TUI

## CI/CD

- GitHub Actions workflow (`.github/workflows/release.yml`) builds release binaries on tag push (`v*`)
- Targets: macOS (aarch64, x86_64), Linux (x86_64, aarch64)
- Release artifacts are uploaded to GitHub Releases via `softprops/action-gh-release`
- Distributed via Homebrew tap (`tttol/tap`)
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rem-cli"
version = "0.2.0"
version = "0.5.0"
edition = "2024"

[[bin]]
Expand Down
File renamed without changes.
16 changes: 16 additions & 0 deletions docs/plan/integrationtest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Integration Test Scenario
## 概要
Integration Test(以下ITと呼ぶ)を実装したい。実装方針やシナリオをこのファイルで定義する。
## シナリオ
### 1.remからタスクを追加しmdファイルが新しく生成されること
- 操作:remを起動しaを押下、任意のタスク名を入力しEnterを押下
- 期待値:mdファイルが新しく作成されていること
### 2.remからステータスを変更するとmdファイルが特定のディレクトリに移動すること
- 操作:すでに作成されたタスクに対してnまたはNを押下する
- 期待値:mdファイルがステータスに応じたディレクトリに移動していること
## 実装方針
- tests/ディレクトリを作成してその中にITのテストファイルを作成すること
- テストファイル名はintegration_test.rsとすること
- 各シナリオごとに関数を作成し、シナリオ名は関数名で表現すること
- ITでは原則publicな関数・APIのみコールし、private関数を直接コールすることはしない。public関数を経由してprivate関数がコールされることはOK。

41 changes: 40 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub enum Mode {
Editing,
}

/// Application state and core logic for the TUI.
pub struct App {
pub should_quit: bool,
pub input_mode: Mode,
Expand All @@ -22,6 +23,10 @@ pub struct App {
}

impl App {
/// Creates a new `App` instance.
///
/// Loads TODO and DOING tasks from the filesystem.
/// DONE tasks are not loaded at startup (lazy-loaded via `toggle_done`).
pub fn new() -> Self {
let mut tasks = Task::load_todo().unwrap_or_default();
tasks.extend(Task::load_doing().unwrap_or_default());
Expand All @@ -42,6 +47,7 @@ impl App {
}
}

/// Dispatches a key event to the appropriate handler based on the current input mode.
pub fn handle_key_event(&mut self, key_code: KeyCode) {
match self.input_mode {
Mode::Normal => match key_code {
Expand All @@ -53,6 +59,7 @@ impl App {
KeyCode::Char('j') | KeyCode::Down => self.select_next(),
KeyCode::Char('k') | KeyCode::Up => self.select_previous(),
KeyCode::Char('n') => self.forward_status(),
KeyCode::Char('N') => self.backward_status(),
KeyCode::Char('d') => self.toggle_done(),
KeyCode::Enter => self.open_task(),
_ => {}
Expand All @@ -76,6 +83,7 @@ impl App {
}
}

/// Moves the cursor to the next task in the list.
fn select_next(&mut self) {
if self.tasks.is_empty() {
return;
Expand All @@ -87,6 +95,7 @@ impl App {
self.update_preview();
}

/// Moves the cursor to the previous task in the list.
fn select_previous(&mut self) {
if self.tasks.is_empty() {
return;
Expand All @@ -98,19 +107,27 @@ impl App {
self.update_preview();
}

/// Sets the selected task's file path to `open_file` for neovim to open.
///
/// The actual neovim invocation is handled in the main event loop (`main.rs`),
/// since terminal control must be managed there.
fn open_task(&mut self) {
if let Some(index) = self.selected_index {
self.open_file = Some(self.tasks[index].file_path());
}
}

/// Reads the selected task's markdown file and updates the preview content.
pub fn update_preview(&mut self) {
self.preview_content = match self.selected_index {
Some(index) => fs::read_to_string(self.tasks[index].file_path()).unwrap_or_default(),
None => String::new(),
};
}

/// Advances the selected task's status: TODO -> DOING -> DONE.
///
/// Does nothing if the task is already DONE.
fn forward_status(&mut self) {
if let Some(index) = self.selected_index {
let next_status = match self.tasks[index].status {
Expand All @@ -124,6 +141,25 @@ impl App {
}
}

/// Reverts the selected task's status: DONE -> DOING -> TODO.
///
/// Does nothing if the task is already TODO.
fn backward_status(&mut self) {
if let Some(index) = self.selected_index {
let next_status = match self.tasks[index].status {
TaskStatus::Todo => return,
TaskStatus::Doing => TaskStatus::Todo,
TaskStatus::Done => TaskStatus::Doing,
};
self.tasks[index].update_status(next_status);
self.tasks = Task::sort(self.tasks.clone());
self.update_preview();
}
}

/// Creates a new task from the input buffer and saves it to the filesystem.
///
/// Clears the input buffer and returns to Normal mode after completion.
fn add_task(&mut self) {
if !self.input_buffer.is_empty() {
let new_task = Task::new(self.input_buffer.clone());
Expand All @@ -139,14 +175,17 @@ impl App {
self.update_preview();
}

/// Toggles the visibility of DONE tasks.
///
/// When enabled, loads DONE tasks from the filesystem and appends them to the task list.
/// When disabled, removes all DONE tasks from the in-memory list.
fn toggle_done(&mut self) {
if self.done_loaded {
self.tasks.retain(|t| t.status != TaskStatus::Done);
self.done_loaded = false;
} else {
if let Ok(done_tasks) = Task::load_done() {
self.tasks.extend(done_tasks);
// self.tasks.sort_by(|a, b| a.created_at.cmp(&b.created_at));
}
self.done_loaded = true;
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod app;
pub mod render;
pub mod task;
15 changes: 8 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
mod app;
mod render;
mod task;

use std::io;
use std::process::Command;
use crossterm::{
Expand All @@ -10,9 +6,14 @@ use crossterm::{
ExecutableCommand,
};
use ratatui::prelude::*;

use crate::app::App;

use rem_cli::app::App;
use rem_cli::render;

/// Entry point for the rem TUI application.
///
/// Handles `--version` / `-V` flags, sets up the terminal (raw mode, alternate screen),
/// runs the event loop, and restores the terminal on exit.
/// When `app.open_file` is set, temporarily exits the TUI to open the file in neovim.
fn main() -> io::Result<()> {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("rem {}", env!("CARGO_PKG_VERSION"));
Expand Down
22 changes: 20 additions & 2 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ use ratatui::{
use crate::app::{App, Mode};
use crate::task::TaskStatus;

/// Renders the entire TUI layout.
///
/// Layout structure:
/// - Left 30%: Task list panels (TODO, DOING, DONE)
/// - Right 70%: Preview panel showing the selected task's markdown content
/// - Bottom: Input field (Editing mode) or keybinding help (Normal mode)
///
/// The DONE panel is minimized to a border-only row when `done_loaded` is false.
pub fn render(frame: &mut Frame, app: &App) {
let outer = if app.input_mode == Mode::Editing {
Layout::vertical([
Expand Down Expand Up @@ -60,8 +68,13 @@ pub fn render(frame: &mut Frame, app: &App) {
ListItem::new(t.name.as_str())
})
.collect();
let border_style = if selected_in_group.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default()
};
let list = List::new(items)
.block(Block::default().title(*title).borders(Borders::ALL))
.block(Block::default().title(*title).borders(Borders::ALL).border_style(border_style))
.highlight_style(Style::default().bg(Color::DarkGray));
let mut state = ListState::default();
state.select(selected_in_group);
Expand All @@ -80,8 +93,13 @@ pub fn render(frame: &mut Frame, app: &App) {
ListItem::new(t.name.as_str())
})
.collect();
let border_style = if selected_in_group.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default()
};
let list = List::new(items)
.block(Block::default().title(" DONE ").borders(Borders::ALL))
.block(Block::default().title(" DONE ").borders(Borders::ALL).border_style(border_style))
.highlight_style(Style::default().bg(Color::DarkGray));
let mut state = ListState::default();
state.select(selected_in_group);
Expand Down
Loading