Skip to content
Open
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
77 changes: 77 additions & 0 deletions docs/tui-functional-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Terminal UI (TUI) Functional Requirements - MVP

## Overview

This document outlines the MVP functional requirements for the interactive terminal user interface for the `devlog` command-line tool. The TUI will be activated when users run `devlog list`, providing an interactive browsing experience for journal entries.

---

## Core Functional Requirements

### 1. Navigation

- **Arrow Keys** (`↑`, `↓`, `←`, `→`) - Navigate between entries and pages
- **Vim Keys** (`h`, `j`, `k`, `l`) - Alternative navigation (left, down, up, right)

### 2. Entry Actions

- **Enter** - View full content of selected entry
- **e** - Edit selected entry in $EDITOR
- **n** - Create new entry
- **q** - Quit application

### 3. Search and Help

- **/** or **s** - Search/filter entries (not-implemented placeholder for now)
- **h** or **?** - Show help with all keybindings

### 4. Pagination Support

- **Page Up/Down** or **Left/Right arrows** - Navigate between pages when there are many entries
- **Status bar** - Show current page and total entries (e.g., "Page 2/5 - Entry 15/47")

### 5. Entry Information Display

- **Entry list** - Show entry ID (date), first line of content, and basic metadata
- **Selection highlight** - Clear visual indication of currently selected entry

### 6. Error Handling

- **Confirmation prompts** - For destructive operations
- **Error messages** - Clear feedback when operations fail
- **Graceful fallback** - If TUI fails, fall back to simple list output

---

## Technical Implementation Notes

### TUI Framework

- Use `ratatui` (modern Rust TUI framework) with `crossterm` for terminal handling

### Entry Display Format

```
┌─ DevLog Entries ───────────────────────────────────────────┐
│ > 20240910 Fixed pagination bug in user service │
│ 20240909 Team meeting notes - Q4 planning │
│ 20240908 Implemented user authentication flow │
│ 20240907 Debugging database connection issues │
└─ Page 1/3 - Entry 1/25 ──────── Press 'h' for help ────────┘
```

### Help Display

```
┌─ Help ─────────────────────────────────────────────────────┐
│ Navigation: │
│ ↑/k - Up ↓/j - Down ←/h - Left →/l - Right │
│ Page Up/Down - Navigate pages │
│ │
│ Actions: │
│ Enter - View entry e - Edit n - New q - Quit │
│ / or s - Search (coming soon) h/? - Help │
│ │
│ Press any key to continue... │
└────────────────────────────────────────────────────────────┘
```
48 changes: 48 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub enum Commands {
#[arg(value_name = "YYYYMMDD")]
id: String,
},
/// List all entries
List,
}

impl Cli {
Expand All @@ -57,6 +59,9 @@ impl Cli {
Commands::Show { id } => {
Self::handle_show_command(id, &storage)?;
}
Commands::List => {
Self::handle_list_command(&storage)?;
}
}

Ok(())
Expand Down Expand Up @@ -160,6 +165,49 @@ impl Cli {
Ok(())
}

/// Handle the list subcommand
fn handle_list_command(storage: &EntryStorage) -> Result<(), Box<dyn std::error::Error>> {
let entry_ids = storage.list_entry_ids()?;

if entry_ids.is_empty() {
println!("No entries found. Create one with 'devlog new'");
return Ok(());
}

println!();
println!("DevLog Entries");
println!("══════════════");

for entry_id in &entry_ids {
// Load the entry to get its content
if let Some(entry) = Entry::load(entry_id, storage)? {
let state = entry.current_state();

// Get the first line of content, truncated to ~60 characters
let first_line = state.content.lines().next().unwrap_or("(empty)").trim();

let display_content = if first_line.len() > 60 {
format!("{}...", &first_line[..57])
} else if state.content.lines().count() > 1 {
format!("{}...", first_line)
} else {
first_line.to_string()
};

println!(" {} {}", entry_id, display_content);
}
}

println!("══════════════");
println!("Total: {} entries", entry_ids.len());
println!();
println!("Commands:");
println!(" devlog edit --id YYYYMMDD Edit an entry");
println!(" devlog new Create a new entry");

Ok(())
}

/// Display entry in default human-readable format
fn display_default_format(entry: &Entry) {
let state = entry.current_state();
Expand Down
35 changes: 35 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ impl EntryStorage {
Ok(Self { base_dir })
}

/// List all entry IDs sorted in descending order (newest first)
pub fn list_entry_ids(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let entries_dir = self.base_dir.join("entries");

if !entries_dir.exists() {
return Ok(Vec::new());
}

let mut entry_ids = Vec::new();

for entry in fs::read_dir(entries_dir)? {
// Entry is Result<DirEntry, Error>, not DirEntry
// Each individual file/directory read operation could fail due to permission, corrupted filesystem, etc.
let entry = entry?;
let path = entry.path();

// Only process .md files
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
// file_stem() returns the filename without its extension
// this method can return None for paths like `/` or `..`
// `to_str()` can return None if the fielname contains invalid UTF-8 characters
if let Some(file_stem) = path.file_stem() {
if let Some(entry_id) = file_stem.to_str() {
entry_ids.push(entry_id.to_string());
}
}
}
}

// Sort in descending order
entry_ids.sort_by(|a, b| b.cmp(a));

Ok(entry_ids)
}

/// Get the event file path for a given date
fn events_path(&self, date: &str) -> PathBuf {
self.base_dir.join("events").join(format!("{}.jsonl", date))
Expand Down