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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Configurable cache location** - New `[storage] data_dir` key sets the directory holding the local SQLite cache (`terminalist.db`). Omit it for the platform default; a leading `~` is expanded. See `docs/CONFIGURATION.md` — note that the database holds your API token in plaintext, so it should not live in a shared or cloud-synced directory
- **Working automatic sync** - `[sync] auto_sync_interval_minutes` now actually schedules background syncs. It was previously accepted, documented, and ignored: nothing ever read it, so the app only synced at startup or when you pressed `r`

### Changed
- **Persistent local cache** - The database is now kept across launches instead of being deleted and rebuilt at every startup, and each sync reconciles it against Todoist (insert/update/delete) rather than rewriting it. Startup no longer waits on a full re-download, and an existing cache from an older version is adopted in place rather than duplicated
- **Non-blocking startup and sync** - The app paints your cached tasks immediately and syncs in the background, so navigation works while a sync runs. The blocking centered "Loading data" overlay is replaced by a small status toast in the corner of the task list: it shows progress, auto-hides a few seconds after a successful sync, and stays up after a failure until you press a key
- **Faster sync** - Projects, tasks, labels, and sections are fetched concurrently rather than one after another
- **Completed and deleted tasks leave the cache sooner** - A task you complete or delete is now removed from the local cache at the next sync, rather than lingering until the next launch. The window in which such a task is still visible locally is now at most one auto-sync interval

## [0.5.0] - 2026-03-25

### Added
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

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

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ dirs = "6.0"
log = "0.4"
fern = "0.7"
once_cell = "1.21"
uuid = { version = "1.23", features = ["v4", "serde"] }
uuid = { version = "1.23", features = ["v4", "v5", "serde"] }

[dev-dependencies]
tempfile = "3"
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ A terminal application for interacting with Todoist, built in Rust with a modern
## Features

- **Interactive TUI Interface** - Beautiful terminal user interface with ratatui
- **Local Data Caching** - Fast, responsive UI with in-memory SQLite storage
- **Local Data Caching** - Fast, responsive UI backed by a persistent SQLite
cache that survives restarts
- **Smart Sync** - Automatic sync on startup and manual refresh with 'r'
- **Project Management** - Browse projects with hierarchical display
- **Task Management** - View, navigate, complete, and create tasks
Expand Down Expand Up @@ -128,8 +129,10 @@ Essential keyboard shortcuts to get started:
## How It Works

Terminalist uses a smart sync mechanism:
- **Fast Startup**: In-memory SQLite database for instant loading
- **Auto Sync**: Syncs with Todoist on startup and every 5 minutes
- **Fast Startup**: A file-backed SQLite cache persists across launches, so
the UI paints cached data immediately while a sync runs in the background
- **Auto Sync**: Syncs with Todoist on startup and every
`auto_sync_interval_minutes` (5 minutes by default; set to `0` to disable)
- **Manual Sync**: Press `r` to force refresh from Todoist
- **Real-time Updates**: Create, modify, and delete tasks/projects immediately

Expand Down
56 changes: 45 additions & 11 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ src/
├── main.rs # Main application entry point
├── lib.rs # Library exports
├── config.rs # Configuration management
├── constants.rs # Shared UI text and other constants
├── theme.rs # Semantic color theme configuration
├── todoist.rs # Todoist API models & display structs
├── sync.rs # Sync service with API integration
├── storage.rs # Storage initialization
├── storage.rs # Persistent SQLite cache initialization
├── sync/ # Sync service with API integration
│ ├── mod.rs # SyncService and shared sync logic
│ ├── storage.rs # Reconciliation (upsert + delete-missing)
│ ├── projects.rs
│ ├── sections.rs
│ ├── tasks.rs
│ └── labels.rs
├── entities/ # Sea-ORM domain entities
│ ├── backend.rs # Backend entity (Todoist, etc.)
│ ├── label.rs
Expand All @@ -31,43 +39,69 @@ src/
│ ├── factory.rs
│ ├── todoist.rs # Todoist backend implementation
│ └── mod.rs
├── backend_registry.rs # Backend registry system
├── backend_registry.rs # Backend registry (derives a stable UUID)
├── icons.rs # Icon service for terminal compatibility
├── logger.rs # Debug logging system
├── utils/ # Utility modules
│ ├── mod.rs
│ └── datetime.rs # Date/time utilities
└── ui/ # Modern Component-Based Architecture
├── mod.rs
├── app_component.rs # Main application orchestrator
├── renderer.rs # Modern rendering system
├── layout.rs # Layout calculations
├── core/ # Core architecture components
│ ├── actions.rs # Action system for component communication
│ ├── component.rs # Component trait and lifecycle
│ ├── context.rs # App context
│ ├── event_handler.rs # Event processing system
│ └── task_manager.rs # Background async task management
│ ├── task_manager.rs # Background async task management
│ └── mod.rs
└── components/ # UI Components
├── badge.rs
├── dialog_component.rs # Unified modal dialog system
├── dialogs/ # Per-entity dialog content
├── sidebar_component.rs # Project/label navigation
├── sidebar_item_component.rs
├── scrollbar_helper.rs
├── sync_toast.rs # Non-blocking sync status toast
├── task_list_component.rs # Task management and display
└── task_list_item_component.rs
├── task_list_item_component.rs
└── mod.rs
```

## Data Management

### Local Storage
- Data is cached locally in a **file-backed SQLite database**
- Database is recreated from scratch on each startup by syncing with the backend
- Data is cached locally in a **file-backed SQLite database** that persists
across launches — it is opened (or created) rather than deleted and rebuilt
- A backend row's UUID is **derived** from `(backend_type, name)` via
`Uuid::new_v5` when the row is new, and **adopted** from the existing row
when one is already there — including the random UUID written by versions
predating the derived scheme. Either way a relaunch resolves to the same
row, so the cache keyed to it is never orphaned or duplicated
- Sync **reconciles** the cache instead of replacing it: each entity type is
upserted on `(backend_uuid, remote_id)`, then any local row whose
`remote_id` the remote no longer returned is deleted. Local UUIDs stay
stable across syncs. An **empty** fetch is the one exception: it is treated
as "nothing to reconcile" rather than "the remote has nothing", so an
empty-but-successful response cannot blank the cache
- Uses Sea-ORM for type-safe database operations
- Repository pattern provides clean data access layer
- UUID-based primary keys for robust entity management

### Sync Behavior
- **First Run**: Automatically syncs all data from Todoist
- **Startup**: Loads local data instantly, then syncs in background if data is older than 5 minutes
- **Manual Sync**: Press `r` to force refresh from Todoist API
- **Sync Indicators**: Sync progress is shown during operations
- **Startup**: Cached data is loaded and painted immediately, and a sync
with the backend starts in the background without blocking the UI
- **Concurrent Fetch**: Projects, tasks, labels, and sections are fetched
from the backend concurrently (`tokio::join!`) rather than one after
another
- **Auto Sync**: After the first sync of the session, a background sync
fires again once `auto_sync_interval_minutes` has elapsed since the last
one; set it to `0` to disable auto-sync entirely
- **Manual Sync**: Press `r` to force refresh from Todoist API at any time
- **Sync Indicators**: A toast in the lower-right corner of the task list
shows syncing/success/failure status without blocking interaction

### Data Types
- **Backends**: Abstract backend entity supporting multiple task management services (Todoist, etc.)
Expand Down
28 changes: 27 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ show_project_colors = false # Show project colors
[logging]
enabled = false # Enable logging to file

[storage]
# Directory holding the local SQLite cache. Unset = platform default:
# Linux ~/.local/share/terminalist
# macOS ~/Library/Application Support/terminalist
# Windows %APPDATA%\terminalist
# data_dir = "/path/to/dir"

[theme]
accent = "Yellow" # Selection highlight color for the sidebar's currently-selected entry
success = "Green" # Completed-task checkmark icon, save/create dialog actions
Expand Down Expand Up @@ -72,7 +79,8 @@ visual language, so they're always red/orange/blue/white regardless of your `[th
### Sync Configuration

- **auto_sync_interval_minutes**: How often to automatically sync with Todoist
- Set to `0` to disable automatic syncing (manual sync only with `r` key)
- Set to `0` to disable automatic syncing entirely
- Manual sync with the `r` key always works, regardless of this setting

### Display Configuration

Expand All @@ -87,6 +95,24 @@ visual language, so they're always red/orange/blue/white regardless of your `[th

- **enabled**: Enable debug logging to file for troubleshooting

### Storage Configuration

- **data_dir**: Directory holding the local SQLite cache (`terminalist.db`). Omit the key to use
the platform default: `~/.local/share/terminalist` on Linux, `~/Library/Application Support/terminalist`
on macOS, `%APPDATA%\terminalist` on Windows.
- A leading `~` is expanded to your home directory. Relative paths resolve against the directory
you launched Terminalist from.
- The directory is created if it does not exist. If it cannot be created or written,
Terminalist exits with an error rather than silently falling back to the default location —
a wrong path must not send writes to your real database.
- The database persists across launches: it is opened (or created) on
startup rather than deleted, and each sync reconciles it with Todoist
instead of rebuilding it from scratch.
- **The database contains your Todoist API token in plaintext.** Terminalist creates the file
with owner-only permissions (`0600`) on Unix, but do not point `data_dir` at a shared,
world-readable, or cloud-synced directory (Dropbox, iCloud Drive, Syncthing, a network
share) — that would copy your token wherever the directory syncs.

### Theme Configuration

Each field accepts either a named color (`"Black"`, `"Red"`, `"Green"`, `"Yellow"`, `"Blue"`, `"Magenta"`,
Expand Down
18 changes: 6 additions & 12 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,9 @@ This is a fully-featured TUI application for Todoist. You can extend it by:

## Dependencies

This project uses the following Rust crates (see `Cargo.toml` for exact versions):

- `todoist-api = "0.3.0"` - Unofficial Todoist API client
- `ratatui = "0.29"` - Terminal UI framework
- `crossterm = "0.29"` - Cross-platform terminal handling
- `tokio = "1.x"` - Async runtime
- `sqlx = "0.8"` - Database toolkit with SQLite support
- `serde` - Serialization/deserialization
- `chrono = "0.4"` - Date and time handling
- `anyhow = "1.0"` - Error handling
- `toml = "0.8"` - Configuration file parsing
- `dirs = "5.0"` - Platform-specific directory paths
See [`Cargo.toml`](../Cargo.toml) for the full, exact list of dependencies
and their versions — notably `ratatui` for the TUI, `sea-orm` (with the
`sqlx-sqlite` backend) for the SQLite cache, `todoist-api` for the Todoist
client, `tokio` for async, and `toml`/`dirs` for configuration and platform
paths. A version list copied here would go stale again immediately, so this
guide doesn't duplicate one.
41 changes: 37 additions & 4 deletions src/backend_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use anyhow::Result;
use log::{error, info};
use sea_orm::{ActiveValue, IntoActiveModel};
use sea_orm::{ActiveValue, EntityTrait, IntoActiveModel};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
Expand All @@ -32,6 +32,19 @@ pub struct BackendRegistry {
backends: Arc<Mutex<BackendMap>>,
}

/// Namespace for deriving stable backend UUIDs (a fixed, arbitrary v4 UUID).
const BACKEND_NAMESPACE: Uuid = Uuid::from_u128(0x6b1c_2f0e_9d47_4a35_8f21_5c7e_3a9b_0d64);

/// Derive the UUID for a backend from its type and name.
///
/// Deterministic: the same pair yields the same UUID on every launch. The local cache keys
/// every project, task, label, and section to this value, so a random UUID per launch would
/// silently duplicate the entire dataset against a persistent database.
#[must_use]
pub fn derive_backend_uuid(backend_type: &str, name: &str) -> Uuid {
Uuid::new_v5(&BACKEND_NAMESPACE, format!("{backend_type}:{name}").as_bytes())
}

impl BackendRegistry {
/// Create a new backend registry.
///
Expand Down Expand Up @@ -155,10 +168,24 @@ impl BackendRegistry {
credentials: String,
settings: String,
) -> Result<Uuid> {
use sea_orm::sea_query::OnConflict;

// Validate by creating instance first
let backend_instance = factory::create_backend(&backend_type, &credentials)?;

let uuid = Uuid::new_v4();
let storage = self.storage.lock().await;

// Reuse the row a previous version wrote, whatever UUID it has, so the cache that hangs
// off that `backend_uuid` stays attached to this backend. Releases predating the derived
// UUID wrote a random v4 here; inserting the derived v5 instead would both violate
// `idx_backends_type_name` (the app would fail to start) and, if that constraint were
// widened rather than respected, strand every cached project/task under a dead
// `backend_uuid` — which the repositories' unfiltered `get_all` would then render as a
// fully duplicated task list.
let uuid = match BackendRepository::get_by_type_and_name(&storage.conn, &backend_type, &name).await? {
Some(existing) => existing.uuid,
None => derive_backend_uuid(&backend_type, &name),
};

let backend_model = backend::ActiveModel {
uuid: ActiveValue::Set(uuid),
Expand All @@ -169,8 +196,14 @@ impl BackendRegistry {
settings: ActiveValue::Set(settings),
};

let storage = self.storage.lock().await;
BackendRepository::create(&storage.conn, backend_model).await?;
backend::Entity::insert(backend_model)
.on_conflict(
OnConflict::column(backend::Column::Uuid)
.update_columns([backend::Column::Name, backend::Column::Credentials, backend::Column::Settings])
.to_owned(),
)
.exec(&storage.conn)
.await?;

// Add to in-memory cache
let mut backends = self.backends.lock().await;
Expand Down
Loading