use std::collections::{HashMap, HashSet};
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
use crossterm::event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::text::Line;
use regex::Regex;
use crate::io::lock::FileLock;
use crate::io::project_io::{self, discover_project, load_project};
use crate::io::watcher::{FileEvent, FrameWatcher};
use crate::model::{Metadata, Project, SectionKind, Task, TaskState, Track};
use crate::parse::{parse_inbox, parse_track};
use super::input;
use super::render;
use super::theme::Theme;
use super::undo::{Operation, UndoStack};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum View {
Track(usize),
Tracks,
Board,
Inbox,
Recent,
Detail { track_id: String, task_id: String },
Search,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardColumn {
Ready,
InProgress,
Done,
}
impl BoardColumn {
pub fn index(self) -> usize {
match self {
BoardColumn::Ready => 0,
BoardColumn::InProgress => 1,
BoardColumn::Done => 2,
}
}
pub fn from_index(i: usize) -> Self {
match i {
0 => BoardColumn::Ready,
1 => BoardColumn::InProgress,
_ => BoardColumn::Done,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardMode {
Cc,
All,
}
#[derive(Debug, Clone)]
pub enum BoardItem {
TrackHeader {
track_name: String,
},
Task {
track_id: String,
task_id: String,
title: String,
id_display: String,
state: TaskState,
tags: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub struct BoardState {
pub focus_column: BoardColumn,
pub cursor: [usize; 3],
pub scroll: [usize; 3],
pub mode: BoardMode,
pub visible_columns: usize,
pub column_pins: Vec<BoardColumnPin>,
}
#[derive(Debug, Clone)]
pub struct BoardColumnPin {
pub track_id: String,
pub task_id: String,
pub pinned_state: TaskState,
pub deadline: std::time::Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetailRegion {
Title,
Tags,
Added,
Deps,
Spec,
Refs,
Note,
Subtasks,
}
impl DetailRegion {
pub fn is_editable(self) -> bool {
!matches!(self, DetailRegion::Added | DetailRegion::Subtasks)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchResultKind {
Track { track_idx: usize, track_id: String },
Inbox { item_index: usize },
Archive { track_id: String },
}
#[derive(Debug, Clone)]
pub struct MatchAnnotation {
pub field: crate::ops::search::MatchField,
pub snippet: String,
}
#[derive(Debug, Clone)]
pub struct SearchResultItem {
pub kind: SearchResultKind,
pub task_id: String,
pub title: String,
pub state: Option<TaskState>,
pub tags: Vec<String>,
pub annotations: Vec<MatchAnnotation>,
pub title_matches: bool,
pub id_matches: bool,
}
#[derive(Debug, Clone)]
pub struct SearchResults {
pub query: String,
pub regex: Regex,
pub items: Vec<SearchResultItem>,
pub groups: Vec<(usize, String, usize)>,
pub cursor: usize,
pub scroll_offset: usize,
pub return_view: View,
}
#[derive(Debug, Clone, Default)]
pub struct EditHistory {
entries: Vec<(String, usize, usize)>,
position: usize,
}
impl EditHistory {
pub fn new(initial_buffer: &str, cursor_pos: usize, cursor_line: usize) -> Self {
EditHistory {
entries: vec![(initial_buffer.to_string(), cursor_pos, cursor_line)],
position: 0,
}
}
pub fn snapshot(&mut self, buffer: &str, cursor_pos: usize, cursor_line: usize) {
if let Some(last) = self.entries.get_mut(self.position)
&& last.0 == buffer
{
last.1 = cursor_pos;
last.2 = cursor_line;
return;
}
self.entries.truncate(self.position + 1);
self.entries
.push((buffer.to_string(), cursor_pos, cursor_line));
self.position = self.entries.len() - 1;
}
pub fn undo(&mut self) -> Option<(&str, usize, usize)> {
if self.position > 0 {
self.position -= 1;
let (buf, pos, line) = &self.entries[self.position];
Some((buf, *pos, *line))
} else {
None
}
}
pub fn redo(&mut self) -> Option<(&str, usize, usize)> {
if self.position + 1 < self.entries.len() {
self.position += 1;
let (buf, pos, line) = &self.entries[self.position];
Some((buf, *pos, *line))
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
Tag,
TaskId,
FilePath,
JumpTaskId,
}
#[derive(Debug, Clone)]
pub struct AutocompleteState {
pub kind: AutocompleteKind,
pub candidates: Vec<String>,
pub filtered: Vec<String>,
pub selected: usize,
pub visible: bool,
}
impl AutocompleteState {
pub fn new(kind: AutocompleteKind, candidates: Vec<String>) -> Self {
let filtered = candidates.clone();
AutocompleteState {
kind,
candidates,
filtered,
selected: 0,
visible: true,
}
}
pub fn word_start_in_buffer(&self, buffer: &str) -> usize {
match self.kind {
AutocompleteKind::Tag => {
buffer.rfind(' ').map(|i| i + 1).unwrap_or(0)
}
AutocompleteKind::TaskId => {
buffer
.rfind(|c: char| c == ',' || c.is_whitespace())
.map(|i| {
let rest = &buffer[i + 1..];
let trimmed = rest.len() - rest.trim_start().len();
i + 1 + trimmed
})
.unwrap_or(0)
}
AutocompleteKind::FilePath => {
buffer.rfind(' ').map(|i| i + 1).unwrap_or(0)
}
AutocompleteKind::JumpTaskId => {
0
}
}
}
pub fn filter(&mut self, input: &str) {
let query = input.to_lowercase();
self.filtered = self
.candidates
.iter()
.filter(|c| c.to_lowercase().contains(&query))
.cloned()
.collect();
if self.selected >= self.filtered.len() {
self.selected = 0;
}
}
pub fn move_up(&mut self) {
if !self.filtered.is_empty() {
if self.selected == 0 {
self.selected = self.filtered.len() - 1;
} else {
self.selected -= 1;
}
}
}
pub fn move_down(&mut self) {
if !self.filtered.is_empty() {
self.selected = (self.selected + 1) % self.filtered.len();
}
}
pub fn selected_entry(&self) -> Option<&str> {
self.filtered.get(self.selected).map(|s| s.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReturnView {
Track(usize),
Recent,
Board,
}
#[derive(Debug, Clone)]
pub struct DetailState {
pub region: DetailRegion,
pub scroll_offset: usize,
pub regions: Vec<DetailRegion>,
pub return_view: ReturnView,
pub editing: bool,
pub edit_buffer: String,
pub edit_cursor_line: usize,
pub edit_cursor_col: usize,
pub edit_original: String,
pub subtask_cursor: usize,
pub flat_subtask_ids: Vec<String>,
pub multiline_selection_anchor: Option<(usize, usize)>,
pub note_h_scroll: usize,
pub sticky_col: Option<usize>,
pub total_lines: usize,
pub note_view_line: Option<usize>,
pub note_header_line: Option<usize>,
pub note_content_end: usize,
pub regions_populated: Vec<bool>,
}
#[derive(Debug, Clone)]
pub enum TriageStep {
SelectTrack,
SelectPosition { track_id: String },
}
#[derive(Debug, Clone)]
pub enum TriageSource {
Inbox { index: usize },
CrossTrackMove {
source_track_id: String,
task_id: String,
},
BulkCrossTrackMove { source_track_id: String },
}
#[derive(Debug, Clone)]
pub struct TriageState {
pub source: TriageSource,
pub step: TriageStep,
pub popup_anchor: Option<(u16, u16)>,
pub position_cursor: u8,
}
#[derive(Debug, Clone)]
pub struct ConfirmState {
pub message: String,
pub action: ConfirmAction,
}
#[derive(Debug, Clone)]
pub enum ConfirmAction {
DeleteInboxItem { index: usize },
ArchiveTrack { track_id: String },
DeleteTrack { track_id: String },
DeleteTask { track_id: String, task_id: String },
BulkDeleteTasks { task_ids: Vec<(String, String)> },
PruneRecovery,
UnarchiveTrack { track_id: String },
ImportTasks { track_id: String, file_path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingMoveKind {
ToDone,
ToBacklog,
ToParked,
FromParked,
}
#[derive(Debug, Clone)]
pub struct PendingMove {
pub kind: PendingMoveKind,
pub track_id: String,
pub task_id: String,
pub deadline: Instant,
pub old_state: Option<TaskState>,
}
#[derive(Debug, Clone)]
pub struct PendingSubtaskHide {
pub track_id: String,
pub task_id: String,
pub deadline: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateFilter {
Active,
Todo,
Blocked,
Parked,
Ready,
}
impl StateFilter {
pub fn label(self) -> &'static str {
match self {
StateFilter::Active => "active",
StateFilter::Todo => "todo",
StateFilter::Blocked => "blocked",
StateFilter::Parked => "parked",
StateFilter::Ready => "ready",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FilterState {
pub state_filter: Option<StateFilter>,
pub tag_filter: Option<String>,
}
impl FilterState {
pub fn is_active(&self) -> bool {
self.state_filter.is_some() || self.tag_filter.is_some()
}
pub fn clear_all(&mut self) {
self.state_filter = None;
self.tag_filter = None;
}
pub fn clear_state(&mut self) {
self.state_filter = None;
}
}
#[derive(Debug, Clone)]
pub enum RepeatableAction {
CycleState,
SetState(TaskState),
TagEdit {
adds: Vec<String>,
removes: Vec<String>,
},
DepEdit {
adds: Vec<String>,
removes: Vec<String>,
},
ToggleCcTag,
EnterEdit(RepeatEditRegion),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepeatEditRegion {
Title,
Tags,
Deps,
Refs,
Note,
}
#[derive(Debug, Clone)]
pub enum DepPopupEntry {
SectionHeader { label: &'static str },
Task {
task_id: String,
title: String,
state: Option<TaskState>,
track_id: Option<String>,
depth: usize,
has_children: bool,
is_expanded: bool,
is_circular: bool,
is_dangling: bool,
is_upstream: bool,
},
Nothing,
}
#[derive(Debug, Clone)]
pub struct DepPopupState {
pub root_task_id: String,
pub root_track_id: String,
pub entries: Vec<DepPopupEntry>,
pub cursor: usize,
pub scroll_offset: usize,
pub expanded: HashSet<String>,
pub visited: HashSet<String>,
pub inverse_deps: HashMap<String, Vec<String>>,
}
pub const TAG_COLOR_PALETTE: &[(&str, &str)] = &[
("red", "#FF4444"),
("yellow", "#FFD700"),
("green", "#44FF88"),
("cyan", "#44DDFF"),
("blue", "#4488FF"),
("purple", "#CC66FF"),
("pink", "#FB4196"),
("white", "#FFFFFF"),
("dim", "#5A5580"),
("text", "#A09BFE"),
];
#[derive(Debug, Clone)]
pub struct TagColorPopupState {
pub tags: Vec<(String, Option<String>)>,
pub cursor: usize,
pub scroll_offset: usize,
pub picker_open: bool,
pub picker_cursor: usize,
}
#[derive(Debug, Clone)]
pub struct PrefixRenameState {
pub track_id: String,
pub track_name: String,
pub old_prefix: String,
pub new_prefix: String,
pub confirming: bool,
pub task_id_count: usize,
pub dep_ref_count: usize,
pub affected_track_count: usize,
pub validation_error: String,
}
#[derive(Debug, Clone)]
pub struct ProjectPickerState {
pub entries: Vec<crate::io::registry::ProjectEntry>,
pub cursor: usize,
pub scroll_offset: usize,
pub sort_alpha: bool,
pub current_project_path: Option<String>,
pub confirm_remove: Option<usize>,
}
impl ProjectPickerState {
pub fn new(
mut entries: Vec<crate::io::registry::ProjectEntry>,
current_path: Option<String>,
) -> Self {
entries.sort_by(|a, b| {
let ta = a.last_accessed_tui.unwrap_or_default();
let tb = b.last_accessed_tui.unwrap_or_default();
tb.cmp(&ta)
});
Self {
entries,
cursor: 0,
scroll_offset: 0,
sort_alpha: false,
current_project_path: current_path,
confirm_remove: None,
}
}
pub fn move_up(&mut self) {
if !self.entries.is_empty() {
if self.cursor == 0 {
self.cursor = self.entries.len() - 1;
} else {
self.cursor -= 1;
}
}
self.confirm_remove = None;
}
pub fn move_down(&mut self) {
if !self.entries.is_empty() {
self.cursor = (self.cursor + 1) % self.entries.len();
}
self.confirm_remove = None;
}
pub fn selected_entry(&self) -> Option<&crate::io::registry::ProjectEntry> {
self.entries.get(self.cursor)
}
pub fn toggle_sort(&mut self) {
self.sort_alpha = !self.sort_alpha;
if self.sort_alpha {
self.entries
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
} else {
self.entries.sort_by(|a, b| {
let ta = a.last_accessed_tui.unwrap_or_default();
let tb = b.last_accessed_tui.unwrap_or_default();
tb.cmp(&ta)
});
}
self.cursor = 0;
self.scroll_offset = 0;
self.confirm_remove = None;
}
pub fn remove_selected(&mut self) {
if self.entries.is_empty() {
return;
}
if self.confirm_remove == Some(self.cursor) {
let entry = &self.entries[self.cursor];
crate::io::registry::remove_by_path(&entry.path);
self.entries.remove(self.cursor);
if self.cursor >= self.entries.len() && self.cursor > 0 {
self.cursor -= 1;
}
self.confirm_remove = None;
} else {
self.confirm_remove = Some(self.cursor);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Navigate,
Search,
Edit,
Move,
Triage,
Confirm,
Select,
Command,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditTarget {
NewTask {
task_id: String,
track_id: String,
parent_id: Option<String>,
},
ExistingTitle {
task_id: String,
track_id: String,
original_title: String,
},
ExistingTags {
task_id: String,
track_id: String,
original_tags: String,
},
NewInboxItem {
index: usize,
},
ExistingInboxTitle {
index: usize,
original_title: String,
},
ExistingInboxTags { index: usize, original_tags: String },
NewTrackName,
ExistingTrackName {
track_id: String,
original_name: String,
},
FilterTag,
BulkTags,
BulkDeps,
JumpTo,
ExistingPrefix {
track_id: String,
original_prefix: String,
},
ImportFilePath { track_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveState {
Task {
track_id: String,
task_id: String,
original_parent_id: Option<String>,
original_section: SectionKind,
original_sibling_index: usize,
original_depth: usize,
force_expanded: HashSet<String>,
},
Track {
track_id: String,
original_index: usize,
},
InboxItem { original_index: usize },
BulkTask {
track_id: String,
removed_tasks: Vec<(usize, Task)>,
insert_pos: usize,
},
}
#[derive(Debug, Clone, Default)]
pub struct TrackViewState {
pub cursor: usize,
pub scroll_offset: usize,
pub expanded: HashSet<String>,
}
#[derive(Debug, Clone)]
pub enum FlatItem {
Task {
section: SectionKind,
path: Vec<usize>,
depth: usize,
has_children: bool,
is_expanded: bool,
is_last_sibling: bool,
ancestor_last: Vec<bool>,
is_context: bool,
},
ParkedSeparator,
BulkMoveStandin { count: usize },
DoneSummary {
depth: usize,
done_count: usize,
total_count: usize,
ancestor_last: Vec<bool>,
},
}
pub struct App {
pub project: Project,
pub view: View,
pub mode: Mode,
pub should_quit: bool,
pub watcher_needs_restart: bool,
pub theme: Theme,
pub active_track_ids: Vec<String>,
pub track_states: HashMap<String, TrackViewState>,
pub tracks_cursor: usize,
pub tracks_name_col_min: usize,
pub inbox_cursor: usize,
pub recent_cursor: usize,
pub inbox_scroll: usize,
pub inbox_note_index: Option<usize>,
pub inbox_note_editor_scroll: usize,
pub recent_scroll: usize,
pub show_help: bool,
pub help_scroll: usize,
pub search_input: String,
pub last_search: Option<String>,
pub search_match_idx: usize,
pub search_history: Vec<String>,
pub search_history_index: Option<usize>,
pub search_draft: String,
pub search_wrap_message: Option<String>,
pub search_match_count: Option<usize>,
pub search_zero_confirmed: bool,
pub quit_pending: bool,
pub status_message: Option<String>,
pub status_is_error: bool,
pub esc_streak: u8,
pub edit_buffer: String,
pub edit_cursor: usize,
pub edit_target: Option<EditTarget>,
pub pre_edit_cursor: Option<usize>,
pub move_state: Option<MoveState>,
pub undo_stack: UndoStack,
pub pending_reload_paths: Vec<PathBuf>,
pub conflict_text: Option<String>,
pub last_save_at: Option<Instant>,
pub track_mtimes: HashMap<String, SystemTime>,
pub detail_state: Option<DetailState>,
pub detail_stack: Vec<(String, String)>,
pub autocomplete: Option<AutocompleteState>,
pub autocomplete_anchor: Option<(u16, u16)>,
pub edit_history: Option<EditHistory>,
pub edit_selection_anchor: Option<usize>,
pub edit_is_fresh: bool,
pub new_track_insert_pos: Option<usize>,
pub triage_state: Option<TriageState>,
pub confirm_state: Option<ConfirmState>,
pub flash_state: Option<TaskState>,
pub flash_task_id: Option<String>,
pub flash_task_ids: HashSet<String>,
pub flash_track_id: Option<String>,
pub flash_detail_region: Option<DetailRegion>,
pub flash_started: Option<Instant>,
pub pending_moves: Vec<PendingMove>,
pub pending_subtask_hides: Vec<PendingSubtaskHide>,
pub recent_expanded: HashSet<String>,
pub filter_state: FilterState,
pub filter_pending: bool,
pub selection: HashSet<String>,
pub range_anchor: Option<usize>,
pub last_action: Option<RepeatableAction>,
pub command_palette: Option<super::command_actions::CommandPaletteState>,
pub dep_popup: Option<DepPopupState>,
pub tag_color_popup: Option<TagColorPopupState>,
pub prefix_rename: Option<PrefixRenameState>,
pub project_picker: Option<ProjectPickerState>,
pub key_debug: bool,
pub last_key_event: Option<String>,
pub kitty_enabled: bool,
pub edit_h_scroll: usize,
pub last_edit_available_width: u16,
pub tab_scroll: usize,
pub show_startup_hints: bool,
pub note_wrap: bool,
pub recovery_message: Option<String>,
pub recovery_message_at: Option<Instant>,
pub show_recovery_log: bool,
pub recovery_log_scroll: usize,
pub recovery_log_lines: Vec<String>,
pub recovery_log_wrapped_count: usize,
pub recovery_log_line_offsets: Vec<usize>,
pub show_results_overlay: bool,
pub results_overlay_title: String,
pub results_overlay_lines: Vec<Line<'static>>,
pub results_overlay_scroll: usize,
pub project_search_results: Option<SearchResults>,
pub project_search_history: Vec<String>,
pub project_search_input: String,
pub project_search_history_index: Option<usize>,
pub project_search_draft: String,
pub project_search_active: bool,
pub board_state: BoardState,
}
impl App {
pub fn new(project: Project) -> Self {
let active_track_ids: Vec<String> = project
.config
.tracks
.iter()
.filter(|t| t.state == "active")
.map(|t| t.id.clone())
.collect();
let theme = Theme::from_config(&project.config.ui);
let note_wrap = project.config.ui.note_wrap;
let initial_view = if active_track_ids.is_empty() {
View::Tracks
} else {
View::Track(0)
};
let mut track_mtimes = HashMap::new();
for tc in &project.config.tracks {
let path = project.frame_dir.join(&tc.file);
if let Ok(meta) = std::fs::metadata(&path)
&& let Ok(mtime) = meta.modified()
{
track_mtimes.insert(tc.id.clone(), mtime);
}
}
let mut track_states = HashMap::new();
for track_id in &active_track_ids {
let mut state = TrackViewState::default();
if let Some(track) = Self::find_track_in_project(&project, track_id) {
let backlog = track.backlog();
if let Some(first) = backlog.first() {
let key = task_expand_key(first, SectionKind::Backlog, &[0]);
state.expanded.insert(key);
}
}
track_states.insert(track_id.clone(), state);
}
App {
project,
view: initial_view,
mode: Mode::Navigate,
should_quit: false,
watcher_needs_restart: false,
theme,
active_track_ids,
track_states,
tracks_cursor: 0,
tracks_name_col_min: 0,
inbox_cursor: 0,
recent_cursor: 0,
inbox_scroll: 0,
inbox_note_index: None,
inbox_note_editor_scroll: 0,
recent_scroll: 0,
show_help: false,
help_scroll: 0,
search_input: String::new(),
last_search: None,
search_match_idx: 0,
search_history: Vec::new(),
search_history_index: None,
search_draft: String::new(),
search_wrap_message: None,
search_match_count: None,
search_zero_confirmed: false,
quit_pending: false,
status_message: None,
status_is_error: false,
esc_streak: 0,
edit_buffer: String::new(),
edit_cursor: 0,
edit_target: None,
pre_edit_cursor: None,
move_state: None,
undo_stack: UndoStack::new(),
pending_reload_paths: Vec::new(),
conflict_text: None,
last_save_at: None,
track_mtimes,
detail_state: None,
detail_stack: Vec::new(),
autocomplete: None,
autocomplete_anchor: None,
edit_history: None,
edit_selection_anchor: None,
edit_is_fresh: false,
new_track_insert_pos: None,
triage_state: None,
confirm_state: None,
flash_state: None,
flash_task_id: None,
flash_task_ids: HashSet::new(),
flash_track_id: None,
flash_detail_region: None,
flash_started: None,
pending_moves: Vec::new(),
pending_subtask_hides: Vec::new(),
recent_expanded: HashSet::new(),
filter_state: FilterState::default(),
filter_pending: false,
selection: HashSet::new(),
range_anchor: None,
last_action: None,
command_palette: None,
dep_popup: None,
tag_color_popup: None,
prefix_rename: None,
project_picker: None,
key_debug: false,
last_key_event: None,
kitty_enabled: false,
edit_h_scroll: 0,
last_edit_available_width: 0,
tab_scroll: 0,
show_startup_hints: true,
note_wrap,
recovery_message: None,
recovery_message_at: None,
show_recovery_log: false,
recovery_log_scroll: 0,
recovery_log_lines: Vec::new(),
recovery_log_wrapped_count: 0,
recovery_log_line_offsets: Vec::new(),
show_results_overlay: false,
results_overlay_title: String::new(),
results_overlay_lines: Vec::new(),
results_overlay_scroll: 0,
project_search_results: None,
project_search_history: Vec::new(),
project_search_input: String::new(),
project_search_history_index: None,
project_search_draft: String::new(),
project_search_active: false,
board_state: BoardState {
focus_column: BoardColumn::Ready,
cursor: [0; 3],
scroll: [0; 3],
mode: BoardMode::Cc,
visible_columns: 3,
column_pins: Vec::new(),
},
}
}
pub fn find_track_in_project<'a>(project: &'a Project, track_id: &str) -> Option<&'a Track> {
project
.tracks
.iter()
.find(|(id, _)| id == track_id)
.map(|(_, track)| track)
}
pub fn track_name<'a>(&'a self, track_id: &'a str) -> &'a str {
self.project
.config
.tracks
.iter()
.find(|t| t.id == track_id)
.map(|t| t.name.as_str())
.unwrap_or(track_id)
}
pub fn inbox_count(&self) -> usize {
self.project
.inbox
.as_ref()
.map_or(0, |inbox| inbox.items.len())
}
pub fn build_board_columns(&self) -> [Vec<BoardItem>; 3] {
let cc_mode = self.board_state.mode == BoardMode::Cc;
let tag_filter = self.filter_state.tag_filter.as_deref();
let done_days = self.project.config.ui.board_done_days;
let mut ready: Vec<BoardItem> = Vec::new();
let mut in_progress: Vec<BoardItem> = Vec::new();
let mut done_items: Vec<(String, BoardItem)> = Vec::new();
for track_id in &self.active_track_ids {
let track = match Self::find_track_in_project(&self.project, track_id) {
Some(t) => t,
None => continue,
};
let track_name = self.track_name(track_id).to_string();
let prefix = self
.project
.config
.ids
.prefixes
.get(track_id.as_str())
.cloned()
.unwrap_or_default();
let mut has_ready = false;
let mut has_active = false;
for task in track.backlog() {
let task_id = match &task.id {
Some(id) => id.clone(),
None => continue,
};
let id_display = if prefix.is_empty() {
task_id.clone()
} else {
format!("{}-{}", prefix, task_id)
};
if let Some(tf) = tag_filter
&& !task.tags.iter().any(|t| t == tf)
{
continue;
}
let pin = self
.board_state
.column_pins
.iter()
.find(|p| p.track_id == *track_id && p.task_id == task_id);
let pending_move = self
.pending_moves
.iter()
.find(|pm| pm.track_id == *track_id && pm.task_id == task_id);
let effective_state = if let Some(p) = pin {
p.pinned_state
} else {
match pending_move {
Some(pm)
if matches!(
pm.kind,
PendingMoveKind::ToDone | PendingMoveKind::ToParked
) =>
{
pm.old_state.unwrap_or(task.state)
}
_ => task.state,
}
};
match effective_state {
TaskState::Todo => {
if pin.is_none() && pending_move.is_none() && !self.all_deps_resolved(task)
{
continue;
}
if cc_mode && !task.tags.iter().any(|t| t == "cc") {
continue;
}
if !has_ready {
ready.push(BoardItem::TrackHeader {
track_name: track_name.clone(),
});
has_ready = true;
}
ready.push(BoardItem::Task {
track_id: track_id.clone(),
task_id: task_id.clone(),
title: task.title.clone(),
id_display,
state: task.state,
tags: task.tags.clone(),
});
}
TaskState::Active => {
if cc_mode && !task.tags.iter().any(|t| t == "cc") {
continue;
}
if !has_active {
in_progress.push(BoardItem::TrackHeader {
track_name: track_name.clone(),
});
has_active = true;
}
in_progress.push(BoardItem::Task {
track_id: track_id.clone(),
task_id: task_id.clone(),
title: task.title.clone(),
id_display,
state: task.state,
tags: task.tags.clone(),
});
}
_ => {}
}
}
if done_days > 0 {
for task in track.section_tasks(SectionKind::Done) {
let task_id = match &task.id {
Some(id) => id.clone(),
None => continue,
};
let pending_reopen = self.pending_moves.iter().any(|pm| {
pm.kind == PendingMoveKind::ToBacklog
&& pm.track_id == *track_id
&& pm.task_id == task_id
});
if task.state != TaskState::Done && !pending_reopen {
continue;
}
if let Some(tf) = tag_filter
&& !task.tags.iter().any(|t| t == tf)
{
continue;
}
if cc_mode && !task.tags.iter().any(|t| t == "cc" || t == "cc-added") {
continue;
}
let resolved_date = task.metadata.iter().find_map(|m| {
if let Metadata::Resolved(d) = m {
Some(d.clone())
} else {
None
}
});
let resolved_str = match &resolved_date {
Some(d) => d.clone(),
None => continue,
};
if !self.is_within_done_days(&resolved_str, done_days) {
continue;
}
let id_display = if prefix.is_empty() {
task_id.clone()
} else {
format!("{}-{}", prefix, task_id)
};
done_items.push((
resolved_str,
BoardItem::Task {
track_id: track_id.clone(),
task_id,
title: task.title.clone(),
id_display,
state: task.state,
tags: task.tags.clone(),
},
));
}
}
}
done_items.sort_by(|a, b| b.0.cmp(&a.0));
let done: Vec<BoardItem> = done_items.into_iter().map(|(_, item)| item).collect();
[ready, in_progress, done]
}
fn all_deps_resolved(&self, task: &Task) -> bool {
for meta in &task.metadata {
if let Metadata::Dep(deps) = meta {
for dep_id in deps {
let mut found_done = false;
for (_, track) in &self.project.tracks {
if let Some(dep_task) =
crate::ops::task_ops::find_task_in_track(track, dep_id)
{
if dep_task.state == TaskState::Done {
found_done = true;
}
break;
}
}
if !found_done {
return false;
}
}
}
}
true
}
fn is_within_done_days(&self, date_str: &str, days: u32) -> bool {
let resolved = match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return false,
};
let today = chrono::Local::now().date_naive();
let cutoff = today - chrono::Duration::days(i64::from(days));
resolved >= cutoff
}
pub fn board_cursor_task_id(&self) -> Option<(String, String)> {
let col_idx = self.board_state.focus_column.index();
let columns = self.build_board_columns();
let column = &columns[col_idx];
let cursor = self.board_state.cursor[col_idx];
match column.get(cursor) {
Some(BoardItem::Task {
track_id, task_id, ..
}) => Some((track_id.clone(), task_id.clone())),
_ => None,
}
}
pub fn board_task_count(&self, columns: &[Vec<BoardItem>], col: BoardColumn) -> usize {
columns[col.index()]
.iter()
.filter(|item| matches!(item, BoardItem::Task { .. }))
.count()
}
pub fn edit_selection_range(&self) -> Option<(usize, usize)> {
let anchor = self.edit_selection_anchor?;
let cursor = self.edit_cursor;
Some((anchor.min(cursor), anchor.max(cursor)))
}
pub fn delete_selection(&mut self) -> bool {
if let Some((start, end)) = self.edit_selection_range()
&& start != end
{
self.edit_buffer.drain(start..end);
self.edit_cursor = start;
self.edit_selection_anchor = None;
return true;
}
self.edit_selection_anchor = None;
false
}
pub fn get_selection_text(&self) -> Option<String> {
let (start, end) = self.edit_selection_range()?;
if start == end {
return None;
}
Some(self.edit_buffer[start..end].to_string())
}
pub fn toggle_note_wrap(&mut self) {
self.note_wrap = !self.note_wrap;
}
pub fn flash_task(&mut self, task_id: &str) {
self.flash_task_id = Some(task_id.to_string());
self.flash_task_ids.clear();
self.flash_track_id = None;
self.flash_detail_region = None;
self.flash_started = Some(Instant::now());
}
pub fn flash_tasks(&mut self, task_ids: HashSet<String>) {
self.flash_task_id = None;
self.flash_task_ids = task_ids;
self.flash_track_id = None;
self.flash_started = Some(Instant::now());
}
pub fn flash_track(&mut self, track_id: &str) {
self.flash_track_id = Some(track_id.to_string());
self.flash_task_id = None;
self.flash_task_ids.clear();
self.flash_started = Some(Instant::now());
}
pub fn is_flashing(&self, task_id: &str) -> bool {
if let Some(started) = self.flash_started {
if started.elapsed() >= Duration::from_millis(300) {
return false;
}
if self.flash_task_id.as_deref() == Some(task_id) {
return true;
}
if self.flash_task_ids.contains(task_id) {
return true;
}
}
false
}
pub fn is_track_flashing(&self, track_id: &str) -> bool {
if let (Some(flash_id), Some(started)) = (&self.flash_track_id, self.flash_started) {
flash_id == track_id && started.elapsed() < Duration::from_millis(300)
} else {
false
}
}
pub fn clear_expired_flash(&mut self) {
if let Some(started) = self.flash_started
&& started.elapsed() >= Duration::from_millis(300)
{
self.flash_state = None;
self.flash_task_id = None;
self.flash_task_ids.clear();
self.flash_track_id = None;
self.flash_detail_region = None;
self.flash_started = None;
}
}
pub fn has_pending_move(&self, track_id: &str, task_id: &str) -> bool {
self.pending_moves
.iter()
.any(|pm| pm.track_id == track_id && pm.task_id == task_id)
}
pub fn cancel_pending_move(&mut self, track_id: &str, task_id: &str) -> Option<PendingMove> {
let idx = self
.pending_moves
.iter()
.position(|pm| pm.track_id == track_id && pm.task_id == task_id)?;
Some(self.pending_moves.remove(idx))
}
fn execute_pending_move(&mut self, pm: &PendingMove) -> Option<String> {
use crate::ops::task_ops::move_task_between_sections;
let track = self.find_track_mut(&pm.track_id)?;
match pm.kind {
PendingMoveKind::ToDone => {
let source_index = move_task_between_sections(
track,
&pm.task_id,
SectionKind::Backlog,
SectionKind::Done,
)?;
self.undo_stack.push(Operation::SectionMove {
track_id: pm.track_id.clone(),
task_id: pm.task_id.clone(),
from_section: SectionKind::Backlog,
to_section: SectionKind::Done,
from_index: source_index,
});
Some(pm.track_id.clone())
}
PendingMoveKind::ToBacklog => {
move_task_between_sections(
track,
&pm.task_id,
SectionKind::Done,
SectionKind::Backlog,
)?;
let track = self.find_track_mut(&pm.track_id)?;
let task = crate::ops::task_ops::find_task_mut_in_track(track, &pm.task_id)?;
task.metadata.retain(|m| m.key() != "resolved");
task.mark_dirty();
Some(pm.track_id.clone())
}
PendingMoveKind::ToParked => {
let source_index = move_task_between_sections(
track,
&pm.task_id,
SectionKind::Backlog,
SectionKind::Parked,
)?;
self.undo_stack.push(Operation::SectionMove {
track_id: pm.track_id.clone(),
task_id: pm.task_id.clone(),
from_section: SectionKind::Backlog,
to_section: SectionKind::Parked,
from_index: source_index,
});
Some(pm.track_id.clone())
}
PendingMoveKind::FromParked => {
move_task_between_sections(
track,
&pm.task_id,
SectionKind::Parked,
SectionKind::Backlog,
)?;
Some(pm.track_id.clone())
}
}
}
pub fn cancel_pending_subtask_hide(&mut self, track_id: &str, task_id: &str) {
self.pending_subtask_hides
.retain(|ph| ph.track_id != track_id || ph.task_id != task_id);
}
pub fn flush_expired_subtask_hides(&mut self) {
let now = Instant::now();
self.pending_subtask_hides.retain(|ph| now < ph.deadline);
}
pub fn reset_pending_subtask_hide_deadlines(&mut self) {
let new_deadline = Instant::now() + std::time::Duration::from_secs(5);
for ph in &mut self.pending_subtask_hides {
ph.deadline = new_deadline;
}
}
pub fn reset_pending_move_deadlines(&mut self) {
let new_deadline = Instant::now() + std::time::Duration::from_secs(5);
for pm in &mut self.pending_moves {
pm.deadline = new_deadline;
}
}
pub fn flush_expired_pending_moves(&mut self) -> Vec<String> {
let now = Instant::now();
let expired: Vec<PendingMove> = self
.pending_moves
.iter()
.filter(|pm| now >= pm.deadline)
.cloned()
.collect();
self.pending_moves.retain(|pm| now < pm.deadline);
let expiring_pins: Vec<String> = self
.board_state
.column_pins
.iter()
.filter(|p| now >= p.deadline)
.map(|p| p.task_id.clone())
.collect();
self.board_state.column_pins.retain(|p| now < p.deadline);
if !expiring_pins.is_empty() {
let ids: std::collections::HashSet<String> = expiring_pins.into_iter().collect();
self.flash_tasks(ids);
}
let moving_task_ids: std::collections::HashSet<String> = expired
.iter()
.filter(|pm| matches!(pm.kind, PendingMoveKind::ToBacklog))
.map(|pm| pm.task_id.clone())
.collect();
let mut modified = Vec::new();
for pm in &expired {
if let Some(tid) = self.execute_pending_move(pm)
&& !modified.contains(&tid)
{
modified.push(tid);
}
}
if !moving_task_ids.is_empty() {
self.flash_tasks(moving_task_ids);
}
modified
}
pub fn flush_all_pending_moves(&mut self) -> Vec<String> {
let all: Vec<PendingMove> = std::mem::take(&mut self.pending_moves);
self.board_state.column_pins.clear();
let mut modified = Vec::new();
for pm in &all {
if let Some(tid) = self.execute_pending_move(pm)
&& !modified.contains(&tid)
{
modified.push(tid);
}
}
modified
}
pub fn open_tag_color_popup(&mut self) {
let tag_names = self.collect_all_tags();
let tags: Vec<(String, Option<String>)> = tag_names
.into_iter()
.map(|tag| {
let hex = self
.project
.config
.ui
.tag_colors
.get(&tag)
.cloned()
.or_else(|| {
self.theme.tag_colors.get(&tag).and_then(|color| {
if let ratatui::style::Color::Rgb(r, g, b) = color {
Some(format!("#{:02X}{:02X}{:02X}", r, g, b))
} else {
None
}
})
});
(tag, hex)
})
.collect();
self.tag_color_popup = Some(TagColorPopupState {
tags,
cursor: 0,
scroll_offset: 0,
picker_open: false,
picker_cursor: 0,
});
}
pub fn collect_all_tags(&self) -> Vec<String> {
let mut tags: HashSet<String> = HashSet::new();
for key in self.project.config.ui.tag_colors.keys() {
tags.insert(key.clone());
}
for key in self.theme.tag_colors.keys() {
tags.insert(key.clone());
}
for tag in &self.project.config.ui.default_tags {
tags.insert(tag.clone());
}
for (_, track) in &self.project.tracks {
Self::collect_tags_from_tasks(track.backlog(), &mut tags);
Self::collect_tags_from_tasks(track.parked(), &mut tags);
Self::collect_tags_from_tasks(track.done(), &mut tags);
}
if let Some(inbox) = &self.project.inbox {
for item in &inbox.items {
for tag in &item.tags {
tags.insert(tag.clone());
}
}
}
let mut sorted: Vec<String> = tags.into_iter().collect();
sorted.sort();
sorted
}
fn collect_tags_from_tasks(tasks: &[Task], tags: &mut HashSet<String>) {
for task in tasks {
for tag in &task.tags {
tags.insert(tag.clone());
}
Self::collect_tags_from_tasks(&task.subtasks, tags);
}
}
pub fn collect_all_task_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
for (_, track) in &self.project.tracks {
Self::collect_ids_from_tasks(track.backlog(), &mut ids);
Self::collect_ids_from_tasks(track.parked(), &mut ids);
Self::collect_ids_from_tasks(track.done(), &mut ids);
}
ids.sort();
ids
}
fn collect_ids_from_tasks(tasks: &[Task], ids: &mut Vec<String>) {
for task in tasks {
if let Some(ref id) = task.id {
ids.push(id.clone());
}
Self::collect_ids_from_tasks(&task.subtasks, ids);
}
}
pub fn collect_active_track_task_ids(&self) -> Vec<String> {
let mut entries: Vec<String> = Vec::new();
for track_id in &self.active_track_ids {
if let Some(track) = Self::find_track_in_project(&self.project, track_id) {
Self::collect_id_title_from_tasks(track.backlog(), &mut entries);
Self::collect_id_title_from_tasks(track.parked(), &mut entries);
Self::collect_id_title_from_tasks(track.done(), &mut entries);
}
}
entries.sort();
entries
}
fn collect_id_title_from_tasks(tasks: &[Task], entries: &mut Vec<String>) {
for task in tasks {
if let Some(ref id) = task.id {
entries.push(format!("{} {}", id, task.title));
}
Self::collect_id_title_from_tasks(&task.subtasks, entries);
}
}
pub fn collect_file_paths(&self) -> Vec<String> {
let mut paths: Vec<String> = Vec::new();
let frame_dir = &self.project.frame_dir;
let project_root = frame_dir.parent().unwrap_or(frame_dir);
let extensions = &self.project.config.ui.ref_extensions;
let ref_paths = &self.project.config.ui.ref_paths;
if ref_paths.is_empty() {
Self::walk_dir_for_paths(project_root, project_root, &mut paths, 3, extensions);
} else {
for rp in ref_paths {
let dir = project_root.join(rp);
if dir.is_dir() {
Self::walk_dir_for_paths(project_root, &dir, &mut paths, 3, extensions);
}
}
}
paths.sort();
paths
}
fn walk_dir_for_paths(
base: &std::path::Path,
dir: &std::path::Path,
paths: &mut Vec<String>,
max_depth: usize,
extensions: &[String],
) {
if max_depth == 0 {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') || name == "node_modules" || name == "target" {
continue;
}
if path.is_dir() {
Self::walk_dir_for_paths(base, &path, paths, max_depth - 1, extensions);
} else if path.is_file() {
if !extensions.is_empty() {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
continue;
}
}
if let Ok(rel) = path.strip_prefix(base) {
paths.push(rel.to_string_lossy().to_string());
}
}
}
}
pub fn active_search_re(&self) -> Option<Regex> {
let pattern = match &self.mode {
Mode::Search if !self.search_input.is_empty() => self.search_input.as_str(),
Mode::Navigate => self.last_search.as_deref()?,
_ => return None,
};
Regex::new(&format!("(?i){}", pattern))
.or_else(|_| Regex::new(&format!("(?i){}", regex::escape(pattern))))
.ok()
}
pub fn current_track_id(&self) -> Option<&str> {
match &self.view {
View::Track(idx) => self.active_track_ids.get(*idx).map(|s| s.as_str()),
_ => None,
}
}
pub fn current_track(&self) -> Option<&Track> {
let track_id = self.current_track_id()?;
Self::find_track_in_project(&self.project, track_id)
}
pub fn get_track_state(&mut self, track_id: &str) -> &mut TrackViewState {
if !self.track_states.contains_key(track_id) {
self.track_states
.insert(track_id.to_string(), TrackViewState::default());
}
self.track_states.get_mut(track_id).unwrap()
}
pub fn find_task_track_id(&self, task_id: &str) -> Option<String> {
for track_id in &self.active_track_ids {
if let Some(track) = Self::find_track_in_project(&self.project, track_id)
&& crate::ops::task_ops::find_task_in_track(track, task_id).is_some()
{
return Some(track_id.clone());
}
}
None
}
pub fn jump_to_task(&mut self, task_id: &str) -> bool {
let target_track_id = match self.find_task_track_id(task_id) {
Some(id) => id,
None => return false,
};
let track_idx = match self
.active_track_ids
.iter()
.position(|id| id == &target_track_id)
{
Some(idx) => idx,
None => return false,
};
self.close_detail_fully();
self.view = View::Track(track_idx);
self.expand_parent_chain(&target_track_id, task_id);
let flat_items = self.build_flat_items(&target_track_id);
let track = match Self::find_track_in_project(&self.project, &target_track_id) {
Some(t) => t,
None => return false,
};
for (i, item) in flat_items.iter().enumerate() {
if let FlatItem::Task { section, path, .. } = item
&& let Some(task) = resolve_task_from_flat(track, *section, path)
&& task.id.as_deref() == Some(task_id)
{
let state = self.get_track_state(&target_track_id);
state.cursor = i;
return true;
}
}
false
}
fn expand_parent_chain(&mut self, track_id: &str, task_id: &str) {
let parts: Vec<&str> = task_id.split('.').collect();
if parts.len() <= 1 {
return; }
let mut ancestors_to_expand = Vec::new();
if let Some(track) = Self::find_track_in_project(&self.project, track_id) {
for i in 1..parts.len() {
let ancestor_id = parts[..i].join(".");
if crate::ops::task_ops::find_task_in_track(track, &ancestor_id).is_some() {
ancestors_to_expand.push(ancestor_id);
}
}
}
let state = self.get_track_state(track_id);
for ancestor_id in ancestors_to_expand {
state.expanded.insert(ancestor_id);
}
}
pub fn build_dep_index(project: &Project) -> HashMap<String, Vec<String>> {
let mut index: HashMap<String, Vec<String>> = HashMap::new();
for (_, track) in &project.tracks {
for node in &track.nodes {
if let crate::model::TrackNode::Section { tasks, .. } = node {
Self::collect_deps_recursive(tasks, &mut index);
}
}
}
index
}
fn collect_deps_recursive(tasks: &[Task], index: &mut HashMap<String, Vec<String>>) {
for task in tasks {
if let Some(task_id) = &task.id {
for m in &task.metadata {
if let Metadata::Dep(deps) = m {
for dep_id in deps {
index
.entry(dep_id.clone())
.or_default()
.push(task_id.clone());
}
}
}
}
Self::collect_deps_recursive(&task.subtasks, index);
}
}
pub fn open_dep_popup(&mut self, track_id: &str, task_id: &str) {
let inverse_deps = Self::build_dep_index(&self.project);
let mut state = DepPopupState {
root_task_id: task_id.to_string(),
root_track_id: track_id.to_string(),
entries: Vec::new(),
cursor: 0,
scroll_offset: 0,
expanded: HashSet::new(),
visited: HashSet::new(),
inverse_deps,
};
self.rebuild_dep_popup_entries(&mut state);
state.cursor = state
.entries
.iter()
.position(|e| matches!(e, DepPopupEntry::Task { .. }))
.unwrap_or(0);
self.dep_popup = Some(state);
}
pub fn rebuild_dep_popup_entries(&self, state: &mut DepPopupState) {
let task_id = state.root_task_id.clone();
state.entries.clear();
let mut upstream_ids: Vec<String> = Vec::new();
for (_, track) in &self.project.tracks {
if let Some(task) = crate::ops::task_ops::find_task_in_track(track, &task_id) {
for m in &task.metadata {
if let Metadata::Dep(deps) = m {
upstream_ids.extend(deps.iter().cloned());
}
}
break;
}
}
let downstream_ids: Vec<String> = state
.inverse_deps
.get(&task_id)
.cloned()
.unwrap_or_default();
let auto_expand_upstream = upstream_ids.len() <= 2;
let auto_expand_downstream = downstream_ids.len() <= 2;
if state.expanded.is_empty() {
if auto_expand_upstream {
for id in &upstream_ids {
state.expanded.insert(format!("up:{}", id));
}
}
if auto_expand_downstream {
for id in &downstream_ids {
state.expanded.insert(format!("down:{}", id));
}
}
}
state.entries.push(DepPopupEntry::SectionHeader {
label: "Blocked by",
});
if upstream_ids.is_empty() {
state.entries.push(DepPopupEntry::Nothing);
} else {
for dep_id in &upstream_ids {
let mut visited = HashSet::new();
visited.insert(task_id.to_string());
self.add_dep_entry(state, dep_id, 0, true, &mut visited);
}
}
state
.entries
.push(DepPopupEntry::SectionHeader { label: "Blocking" });
if downstream_ids.is_empty() {
state.entries.push(DepPopupEntry::Nothing);
} else {
for dep_id in &downstream_ids {
let mut visited = HashSet::new();
visited.insert(task_id.to_string());
self.add_dep_entry(state, dep_id, 0, false, &mut visited);
}
}
}
fn add_dep_entry(
&self,
state: &mut DepPopupState,
dep_id: &str,
depth: usize,
is_upstream: bool,
visited: &mut HashSet<String>,
) {
if visited.contains(dep_id) {
state.entries.push(DepPopupEntry::Task {
task_id: dep_id.to_string(),
title: String::new(),
state: None,
track_id: None,
depth,
has_children: false,
is_expanded: false,
is_circular: true,
is_dangling: false,
is_upstream,
});
return;
}
let mut found_task: Option<(&str, &Task)> = None;
for (tid, track) in &self.project.tracks {
if let Some(task) = crate::ops::task_ops::find_task_in_track(track, dep_id) {
found_task = Some((tid.as_str(), task));
break;
}
}
if let Some((found_track_id, task)) = found_task {
let children_ids = if is_upstream {
let mut ids = Vec::new();
for m in &task.metadata {
if let Metadata::Dep(deps) = m {
ids.extend(deps.iter().cloned());
}
}
ids
} else {
state.inverse_deps.get(dep_id).cloned().unwrap_or_default()
};
let has_children = !children_ids.is_empty();
let expand_key = format!("{}:{}", if is_upstream { "up" } else { "down" }, dep_id);
let is_expanded = state.expanded.contains(&expand_key);
state.entries.push(DepPopupEntry::Task {
task_id: dep_id.to_string(),
title: task.title.clone(),
state: Some(task.state),
track_id: Some(found_track_id.to_string()),
depth,
has_children,
is_expanded,
is_circular: false,
is_dangling: false,
is_upstream,
});
if is_expanded && has_children {
visited.insert(dep_id.to_string());
for child_id in &children_ids {
self.add_dep_entry(state, child_id, depth + 1, is_upstream, visited);
}
visited.remove(dep_id);
}
} else {
state.entries.push(DepPopupEntry::Task {
task_id: dep_id.to_string(),
title: String::new(),
state: None,
track_id: None,
depth,
has_children: false,
is_expanded: false,
is_circular: false,
is_dangling: true,
is_upstream,
});
}
}
pub fn track_prefix(&self, track_id: &str) -> Option<&str> {
self.project
.config
.ids
.prefixes
.get(track_id)
.map(|s| s.as_str())
}
pub fn track_file(&self, track_id: &str) -> Option<&str> {
self.project
.config
.tracks
.iter()
.find(|tc| tc.id == track_id)
.map(|tc| tc.file.as_str())
}
pub fn find_track_mut(&mut self, track_id: &str) -> Option<&mut Track> {
self.project
.tracks
.iter_mut()
.find(|(id, _)| id == track_id)
.map(|(_, track)| track)
}
pub fn read_track_from_disk(&mut self, track_id: &str) -> Option<Track> {
let file = self.track_file(track_id)?;
let path = self.project.frame_dir.join(file);
let meta = std::fs::metadata(&path).ok()?;
let text = std::fs::read_to_string(&path).ok()?;
if let Ok(mtime) = meta.modified() {
self.track_mtimes.insert(track_id.to_string(), mtime);
}
Some(parse_track(&text))
}
pub fn replace_track(&mut self, track_id: &str, new_track: Track) {
if let Some(entry) = self
.project
.tracks
.iter_mut()
.find(|(id, _)| id == track_id)
{
entry.1 = new_track;
}
}
pub fn track_changed_on_disk(&self, track_id: &str) -> bool {
let file = match self.track_file(track_id) {
Some(f) => f,
None => return false,
};
let path = self.project.frame_dir.join(file);
let disk_mtime = match std::fs::metadata(&path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => return false,
};
match self.track_mtimes.get(track_id) {
Some(known) => disk_mtime > *known,
None => true, }
}
pub fn show_save_error(&mut self, result: Result<(), Box<dyn std::error::Error>>) {
if let Err(e) = result {
self.status_message = Some(format!("Save error: {}", e));
}
}
pub fn save_inbox(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let inbox = self.project.inbox.as_ref().ok_or("no inbox loaded")?;
let _lock = FileLock::acquire_default(&self.project.frame_dir)?;
project_io::save_inbox(&self.project.frame_dir, inbox)?;
self.last_save_at = Some(Instant::now());
Ok(())
}
pub fn save_track(&mut self, track_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let file = self
.track_file(track_id)
.ok_or("track not found")?
.to_string();
let track =
Self::find_track_in_project(&self.project, track_id).ok_or("track not found")?;
let _lock = FileLock::acquire_default(&self.project.frame_dir)?;
project_io::save_track(&self.project.frame_dir, &file, track)?;
self.last_save_at = Some(Instant::now());
let path = self.project.frame_dir.join(&file);
if let Ok(mtime) = std::fs::metadata(&path).and_then(|m| m.modified()) {
self.track_mtimes.insert(track_id.to_string(), mtime);
}
Ok(())
}
pub fn save_track_logged(&mut self, track_id: &str) {
if let Err(e) = self.save_track(track_id) {
crate::io::recovery::log_recovery(
&self.project.frame_dir,
crate::io::recovery::RecoveryEntry {
timestamp: chrono::Utc::now(),
category: crate::io::recovery::RecoveryCategory::Write,
description: format!("track save failed: {}", track_id),
fields: vec![("Error".to_string(), e.to_string())],
body: String::new(),
},
);
self.recovery_message = Some(format!("Save failed for {}: {}", track_id, e));
self.recovery_message_at = Some(Instant::now());
}
}
pub fn save_inbox_logged(&mut self) {
if let Err(e) = self.save_inbox() {
crate::io::recovery::log_recovery(
&self.project.frame_dir,
crate::io::recovery::RecoveryEntry {
timestamp: chrono::Utc::now(),
category: crate::io::recovery::RecoveryCategory::Write,
description: "inbox save failed".to_string(),
fields: vec![("Error".to_string(), e.to_string())],
body: String::new(),
},
);
self.recovery_message = Some(format!("Inbox save failed: {}", e));
self.recovery_message_at = Some(Instant::now());
}
}
pub fn cursor_task_id(&self) -> Option<(String, String, SectionKind)> {
let track_id = self.current_track_id()?.to_string();
let flat_items = self.build_flat_items(&track_id);
let cursor = self.track_states.get(&track_id).map_or(0, |s| s.cursor);
let item = flat_items.get(cursor)?;
if let FlatItem::Task { section, path, .. } = item {
let track = Self::find_track_in_project(&self.project, &track_id)?;
let task = resolve_task_from_flat(track, *section, path)?;
let task_id = task.id.clone()?;
Some((track_id, task_id, *section))
} else {
None
}
}
pub fn reload_changed_files(&mut self, paths: &[std::path::PathBuf]) -> Option<String> {
let mut edited_task_conflict = None;
let editing_task_id = match &self.edit_target {
Some(EditTarget::NewTask { task_id, .. })
| Some(EditTarget::ExistingTitle { task_id, .. })
| Some(EditTarget::ExistingTags { task_id, .. }) => Some(task_id.clone()),
_ => None,
};
let editing_track_id = match &self.edit_target {
Some(EditTarget::NewTask { track_id, .. })
| Some(EditTarget::ExistingTitle { track_id, .. })
| Some(EditTarget::ExistingTags { track_id, .. }) => Some(track_id.clone()),
_ => None,
};
for path in paths {
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
let rel_path = path
.strip_prefix(&self.project.frame_dir)
.ok()
.and_then(|p| p.to_str())
.map(|s| s.to_string());
if is_inbox_path(&file_name, rel_path.as_deref()) {
if let Ok(text) = std::fs::read_to_string(path) {
let (inbox, dropped) = parse_inbox(&text);
if !dropped.is_empty() {
crate::io::recovery::log_recovery(
&self.project.frame_dir,
crate::io::recovery::RecoveryEntry {
timestamp: chrono::Utc::now(),
category: crate::io::recovery::RecoveryCategory::Parser,
description: "dropped lines".to_string(),
fields: vec![("Source".to_string(), "inbox.md".to_string())],
body: dropped.join("\n"),
},
);
}
self.project.inbox = Some(inbox);
}
continue;
}
if file_name == "project.toml" {
continue;
}
if let Some((track_id, _track_file)) =
resolve_track_for_path(&self.project.config.tracks, &file_name, rel_path.as_deref())
&& let Ok(text) = std::fs::read_to_string(path)
{
let new_track = parse_track(&text);
if editing_track_id.as_deref() == Some(&track_id)
&& let Some(ref edit_task_id) = editing_task_id
{
if let Some(old_track) = Self::find_track_in_project(&self.project, &track_id) {
let old_task =
crate::ops::task_ops::find_task_in_track(old_track, edit_task_id);
let new_task =
crate::ops::task_ops::find_task_in_track(&new_track, edit_task_id);
match (old_task, new_task) {
(Some(old), Some(new)) if old.title != new.title => {
edited_task_conflict = Some(edit_task_id.clone());
}
(Some(_), None) => {
edited_task_conflict = Some(edit_task_id.clone());
}
_ => {}
}
}
}
if let Some(entry) = self
.project
.tracks
.iter_mut()
.find(|(id, _)| id == &track_id)
{
entry.1 = new_track;
}
if let Ok(mtime) = std::fs::metadata(path).and_then(|m| m.modified()) {
self.track_mtimes.insert(track_id, mtime);
}
}
}
let modified_tracks = crate::ops::clean::ensure_ids_and_dates(&mut self.project);
for track_id in &modified_tracks {
let _ = self.save_track(track_id);
}
self.undo_stack.push_sync_marker();
edited_task_conflict
}
pub fn build_detail_regions(task: &Task) -> Vec<DetailRegion> {
use crate::model::Metadata;
let mut regions = vec![DetailRegion::Title];
regions.push(DetailRegion::Tags);
if task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)))
{
regions.push(DetailRegion::Added);
}
regions.push(DetailRegion::Deps);
regions.push(DetailRegion::Spec);
regions.push(DetailRegion::Refs);
regions.push(DetailRegion::Note);
if !task.subtasks.is_empty() {
regions.push(DetailRegion::Subtasks);
}
regions
}
pub fn is_detail_region_populated(task: &Task, region: DetailRegion) -> bool {
use crate::model::Metadata;
match region {
DetailRegion::Title => true,
DetailRegion::Tags => !task.tags.is_empty(),
DetailRegion::Added => true, DetailRegion::Subtasks => true, DetailRegion::Deps => task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Dep(v) if !v.is_empty())),
DetailRegion::Spec => task.metadata.iter().any(|m| matches!(m, Metadata::Spec(_))),
DetailRegion::Refs => task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Ref(v) if !v.is_empty())),
DetailRegion::Note => task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Note(s) if !s.is_empty())),
}
}
pub fn close_detail_fully(&mut self) {
self.detail_state = None;
self.detail_stack.clear();
}
pub fn open_detail(&mut self, track_id: String, task_id: String) {
let return_view = if let View::Detail {
track_id: ref cur_track,
task_id: ref cur_task,
} = self.view
{
self.detail_stack
.push((cur_track.clone(), cur_task.clone()));
self.detail_state
.as_ref()
.map(|ds| ds.return_view.clone())
.unwrap_or(ReturnView::Track(0))
} else {
match &self.view {
View::Track(idx) => ReturnView::Track(*idx),
View::Recent => ReturnView::Recent,
View::Board => ReturnView::Board,
_ => ReturnView::Track(0),
}
};
let regions = if let Some(track) = Self::find_track_in_project(&self.project, &track_id) {
if let Some(task) = crate::ops::task_ops::find_task_in_track(track, &task_id) {
Self::build_detail_regions(task)
} else {
vec![DetailRegion::Title]
}
} else {
vec![DetailRegion::Title]
};
let initial_region = regions.first().copied().unwrap_or(DetailRegion::Title);
self.detail_state = Some(DetailState {
region: initial_region,
scroll_offset: 0,
regions,
return_view,
editing: false,
edit_buffer: String::new(),
edit_cursor_line: 0,
edit_cursor_col: 0,
edit_original: String::new(),
subtask_cursor: 0,
flat_subtask_ids: Vec::new(),
multiline_selection_anchor: None,
note_h_scroll: 0,
sticky_col: None,
total_lines: 0,
note_view_line: None,
note_header_line: None,
note_content_end: 0,
regions_populated: Vec::new(),
});
self.view = View::Detail { track_id, task_id };
}
pub fn build_flat_items(&self, track_id: &str) -> Vec<FlatItem> {
let track = match Self::find_track_in_project(&self.project, track_id) {
Some(t) => t,
None => return Vec::new(),
};
let state = self.track_states.get(track_id);
let expanded = state.map(|s| &s.expanded);
let now = Instant::now();
let grace_ids: HashSet<String> = self
.pending_subtask_hides
.iter()
.filter(|ph| ph.track_id == track_id && now < ph.deadline)
.map(|ph| ph.task_id.clone())
.collect();
let mut items = Vec::new();
let backlog = track.backlog();
flatten_tasks(
backlog,
SectionKind::Backlog,
0,
&mut items,
expanded,
&[],
&grace_ids,
);
let parked = track.parked();
if !parked.is_empty() {
items.push(FlatItem::ParkedSeparator);
flatten_tasks(
parked,
SectionKind::Parked,
0,
&mut items,
expanded,
&[],
&grace_ids,
);
}
if self.filter_state.is_active() {
apply_filter(&mut items, track, &self.filter_state, &self.project);
}
items
}
}
pub fn resolve_task_from_flat<'a>(
track: &'a Track,
section: SectionKind,
path: &[usize],
) -> Option<&'a Task> {
let tasks = track.section_tasks(section);
if path.is_empty() {
return None;
}
let mut current = tasks.get(path[0])?;
for &idx in &path[1..] {
current = current.subtasks.get(idx)?;
}
Some(current)
}
pub fn flatten_subtask_ids(task: &Task) -> Vec<String> {
let mut ids = Vec::new();
flatten_subtask_ids_inner(&task.subtasks, &mut ids);
ids
}
fn flatten_subtask_ids_inner(tasks: &[Task], ids: &mut Vec<String>) {
for task in tasks {
if let Some(ref id) = task.id {
ids.push(id.clone());
}
flatten_subtask_ids_inner(&task.subtasks, ids);
}
}
pub fn task_expand_key(task: &Task, section: SectionKind, path: &[usize]) -> String {
if let Some(id) = &task.id {
id.clone()
} else {
let section_str = match section {
SectionKind::Backlog => "b",
SectionKind::Parked => "p",
SectionKind::Done => "d",
};
format!(
"_{}_{}",
section_str,
path.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("_")
)
}
}
fn flatten_tasks(
tasks: &[Task],
section: SectionKind,
depth: usize,
items: &mut Vec<FlatItem>,
expanded: Option<&HashSet<String>>,
ancestor_last: &[bool],
grace_ids: &HashSet<String>,
) {
flatten_tasks_inner(
tasks,
section,
depth,
items,
expanded,
ancestor_last,
&[],
grace_ids,
);
}
#[allow(clippy::too_many_arguments)]
fn flatten_tasks_inner(
tasks: &[Task],
section: SectionKind,
depth: usize,
items: &mut Vec<FlatItem>,
expanded: Option<&HashSet<String>>,
ancestor_last: &[bool],
parent_path: &[usize],
grace_ids: &HashSet<String>,
) {
let count = tasks.len();
if depth > 0 {
let total_count = count;
let mut visible_indices: Vec<usize> = Vec::new();
let mut done_count = 0usize;
for (i, task) in tasks.iter().enumerate() {
let is_done = task.state == TaskState::Done;
if is_done {
done_count += 1;
let in_grace = task.id.as_ref().is_some_and(|id| grace_ids.contains(id));
if in_grace {
visible_indices.push(i);
}
} else {
visible_indices.push(i);
}
}
let hidden_count = done_count.saturating_sub(
tasks
.iter()
.filter(|t| {
t.state == TaskState::Done
&& t.id.as_ref().is_some_and(|id| grace_ids.contains(id))
})
.count(),
);
if hidden_count > 0 {
items.push(FlatItem::DoneSummary {
depth,
done_count,
total_count,
ancestor_last: ancestor_last.to_vec(),
});
}
let visible_count = visible_indices.len();
for (vi, &real_idx) in visible_indices.iter().enumerate() {
let task = &tasks[real_idx];
let is_last = vi == visible_count - 1;
let has_children = !task.subtasks.is_empty();
let mut path = parent_path.to_vec();
path.push(real_idx);
let key = task_expand_key(task, section, &path);
let is_expanded = has_children && expanded.is_some_and(|set| set.contains(&key));
items.push(FlatItem::Task {
section,
path: path.clone(),
depth,
has_children,
is_expanded,
is_last_sibling: is_last,
ancestor_last: ancestor_last.to_vec(),
is_context: false,
});
if is_expanded {
let mut new_ancestor_last = ancestor_last.to_vec();
new_ancestor_last.push(is_last);
flatten_tasks_inner(
&task.subtasks,
section,
depth + 1,
items,
expanded,
&new_ancestor_last,
&path,
grace_ids,
);
}
}
} else {
for (i, task) in tasks.iter().enumerate() {
let is_last = i == count - 1;
let has_children = !task.subtasks.is_empty();
let mut path = parent_path.to_vec();
path.push(i);
let key = task_expand_key(task, section, &path);
let is_expanded = has_children && expanded.is_some_and(|set| set.contains(&key));
items.push(FlatItem::Task {
section,
path: path.clone(),
depth,
has_children,
is_expanded,
is_last_sibling: is_last,
ancestor_last: ancestor_last.to_vec(),
is_context: false,
});
if is_expanded {
let mut new_ancestor_last = ancestor_last.to_vec();
new_ancestor_last.push(is_last);
flatten_tasks_inner(
&task.subtasks,
section,
depth + 1,
items,
expanded,
&new_ancestor_last,
&path,
grace_ids,
);
}
}
}
}
fn task_matches_filter(task: &Task, filter: &FilterState, project: &Project) -> bool {
if let Some(sf) = &filter.state_filter {
let state_ok = match sf {
StateFilter::Active => task.state == TaskState::Active,
StateFilter::Todo => task.state == TaskState::Todo,
StateFilter::Blocked => task.state == TaskState::Blocked,
StateFilter::Parked => task.state == TaskState::Parked,
StateFilter::Ready => {
(task.state == TaskState::Todo || task.state == TaskState::Active)
&& !has_unresolved_deps(task, project)
}
};
if !state_ok {
return false;
}
}
if let Some(ref tag) = filter.tag_filter
&& !task.tags.iter().any(|t| t == tag)
{
return false;
}
true
}
fn has_unresolved_deps(task: &Task, project: &Project) -> bool {
use crate::ops::task_ops;
for m in &task.metadata {
if let Metadata::Dep(deps) = m {
for dep_id in deps {
for (_, track) in &project.tracks {
if let Some(dep_task) = task_ops::find_task_in_track(track, dep_id)
&& dep_task.state != TaskState::Done
{
return true;
}
}
}
}
}
false
}
fn has_matching_descendant(task: &Task, filter: &FilterState, project: &Project) -> bool {
for sub in &task.subtasks {
if task_matches_filter(sub, filter, project) {
return true;
}
if has_matching_descendant(sub, filter, project) {
return true;
}
}
false
}
fn apply_filter(items: &mut Vec<FlatItem>, track: &Track, filter: &FilterState, project: &Project) {
let mut keep = vec![false; items.len()];
let mut context = vec![false; items.len()];
for (i, item) in items.iter().enumerate() {
if let FlatItem::Task { section, path, .. } = item
&& let Some(task) = resolve_task_from_flat(track, *section, path)
{
if task_matches_filter(task, filter, project) {
keep[i] = true;
mark_ancestors_kept(items, i, &mut keep, &mut context);
} else if has_matching_descendant(task, filter, project) {
keep[i] = true;
context[i] = true;
}
}
}
for i in 0..items.len() {
if let FlatItem::DoneSummary { depth, .. } = &items[i] {
let summary_depth = *depth;
for j in (0..i).rev() {
if let FlatItem::Task { depth: d, .. } = &items[j]
&& *d == summary_depth.saturating_sub(1)
{
keep[i] = keep[j];
break;
}
}
}
}
for (i, item) in items.iter().enumerate() {
if matches!(item, FlatItem::ParkedSeparator) {
let has_parked = items[i + 1..].iter().enumerate().any(|(j, fi)| {
matches!(
fi,
FlatItem::Task {
section: SectionKind::Parked,
..
}
) && keep[i + 1 + j]
});
keep[i] = has_parked;
}
}
let mut idx = 0;
items.retain_mut(|item| {
let retained = keep[idx];
if retained
&& let FlatItem::Task {
is_context: ctx, ..
} = item
{
*ctx = context[idx];
}
idx += 1;
retained
});
}
fn mark_ancestors_kept(
items: &[FlatItem],
child_idx: usize,
keep: &mut [bool],
context: &mut [bool],
) {
if let FlatItem::Task { path, section, .. } = &items[child_idx] {
if path.len() <= 1 {
return; }
let child_section = *section;
for ancestor_len in 1..path.len() {
let ancestor_path = &path[..ancestor_len];
for (j, item) in items[..child_idx].iter().enumerate().rev() {
if let FlatItem::Task {
path: p,
section: s,
..
} = item
&& *s == child_section
&& p.as_slice() == ancestor_path
{
if !keep[j] {
keep[j] = true;
context[j] = true;
}
break;
}
}
}
}
}
pub fn restore_ui_state(app: &mut App) {
use crate::io::state::read_ui_state;
let ui_state = match read_ui_state(&app.project.frame_dir) {
Some(s) => s,
None => return,
};
match ui_state.view.as_str() {
"tracks" => app.view = View::Tracks,
"board" => app.view = View::Board,
"inbox" => app.view = View::Inbox,
"recent" => app.view = View::Recent,
"track" => {
if let Some(idx) = app
.active_track_ids
.iter()
.position(|id| id == &ui_state.active_track)
{
app.view = View::Track(idx);
}
}
_ => {}
}
if let Some(mode_str) = &ui_state.board_mode {
app.board_state.mode = match mode_str.as_str() {
"all" => BoardMode::All,
_ => BoardMode::Cc,
};
}
if let Some(col) = ui_state.board_focus_column {
app.board_state.focus_column = BoardColumn::from_index(col);
}
for (track_id, track_ui) in &ui_state.tracks {
let state = app.get_track_state(track_id);
state.cursor = track_ui.cursor;
state.scroll_offset = track_ui.scroll_offset;
state.expanded = track_ui.expanded.clone();
}
app.last_search = ui_state.last_search;
app.search_history = ui_state.search_history;
app.project_search_history = ui_state.project_search_history;
if let Some(wrap_override) = ui_state.note_wrap_override {
app.note_wrap = wrap_override;
}
}
pub fn save_ui_state(app: &App) {
use crate::io::state::{TrackUiState, UiState, write_ui_state};
let view_to_save = if app.view == View::Search {
app.project_search_results
.as_ref()
.map(|sr| sr.return_view.clone())
.unwrap_or(View::Recent)
} else {
app.view.clone()
};
let (view_str, active_track) = match &view_to_save {
View::Track(idx) => (
"track".to_string(),
app.active_track_ids.get(*idx).cloned().unwrap_or_default(),
),
View::Detail { track_id, .. } => ("track".to_string(), track_id.clone()),
View::Tracks => ("tracks".to_string(), String::new()),
View::Board => ("board".to_string(), String::new()),
View::Inbox => ("inbox".to_string(), String::new()),
View::Recent => ("recent".to_string(), String::new()),
View::Search => ("recent".to_string(), String::new()),
};
let mut tracks = HashMap::new();
for (track_id, state) in &app.track_states {
tracks.insert(
track_id.clone(),
TrackUiState {
cursor: state.cursor,
expanded: state.expanded.clone(),
scroll_offset: state.scroll_offset,
},
);
}
let note_wrap_override = if app.note_wrap != app.project.config.ui.note_wrap {
Some(app.note_wrap)
} else {
None
};
let board_mode = Some(match app.board_state.mode {
BoardMode::Cc => "cc".to_string(),
BoardMode::All => "all".to_string(),
});
let ui_state = UiState {
view: view_str,
active_track,
tracks,
last_search: app.last_search.clone(),
search_history: app.search_history.clone(),
note_wrap_override,
project_search_history: app.project_search_history.clone(),
board_mode,
board_focus_column: Some(app.board_state.focus_column.index()),
};
let _ = write_ui_state(&app.project.frame_dir, &ui_state);
}
pub fn set_window_title(name: &str) {
let _ = write!(io::stdout(), "\x1b]0;frame · {}\x07", name);
let _ = io::stdout().flush();
}
pub fn clear_window_title() {
let _ = write!(io::stdout(), "\x1b]0;\x07");
let _ = io::stdout().flush();
}
pub fn run(project_dir_override: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let start_dir = match project_dir_override {
Some(dir) => std::fs::canonicalize(dir)
.map_err(|e| format!("cannot resolve -C path '{}': {}", dir, e))?,
None => std::env::current_dir()?,
};
let root = match discover_project(&start_dir) {
Ok(root) => root,
Err(_) => {
return run_project_picker();
}
};
let mut project = load_project(&root)?;
let modified_tracks = crate::ops::clean::ensure_ids_and_dates(&mut project);
if !modified_tracks.is_empty() {
let _lock = FileLock::acquire_default(&project.frame_dir)?;
for track_id in &modified_tracks {
if let Some(tc) = project.config.tracks.iter().find(|tc| tc.id == *track_id) {
let file = &tc.file;
if let Some(track) = project
.tracks
.iter()
.find(|(id, _)| id == track_id)
.map(|(_, t)| t)
{
let _ = project_io::save_track(&project.frame_dir, file, track);
}
}
}
}
crate::io::registry::register_project(&project.config.project.name, &project.root);
crate::io::registry::touch_tui(&project.root);
let mut app = App::new(project);
restore_ui_state(&mut app);
let watcher = FrameWatcher::start(&app.project.frame_dir).ok();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let kitty_setting = app.project.config.ui.kitty_keyboard.unwrap_or(true);
let kitty_enabled = if kitty_setting {
execute!(
stdout,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
)
)
.is_ok()
} else {
false
};
let _ = execute!(stdout, EnableBracketedPaste);
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = write!(io::stdout(), "\x1b]0;\x07");
let _ = io::stdout().flush();
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), DisableBracketedPaste);
let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic_info);
}));
app.kitty_enabled = kitty_enabled;
set_window_title(&app.project.config.project.name);
let result = run_event_loop(&mut terminal, &mut app, watcher);
save_ui_state(&app);
clear_window_title();
disable_raw_mode()?;
let _ = execute!(terminal.backend_mut(), DisableBracketedPaste);
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_project_picker() -> Result<(), Box<dyn std::error::Error>> {
let reg = crate::io::registry::read_registry();
if reg.projects.is_empty() {
println!("No projects registered.");
println!();
println!("Run `fr init` in a project directory to get started,");
println!("or `fr projects add <path>` to register an existing project.");
return Ok(());
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic_info);
}));
let mut picker = ProjectPickerState::new(reg.projects, None);
let theme = super::theme::Theme::default();
let selected_path = loop {
terminal.draw(|frame| {
let area = frame.area();
frame.render_widget(
ratatui::widgets::Block::default()
.style(ratatui::style::Style::default().bg(theme.background)),
area,
);
render::project_picker::render_project_picker_standalone(frame, &picker, &theme, area);
})?;
if crossterm::event::poll(Duration::from_millis(250))?
&& let crossterm::event::Event::Key(key) = crossterm::event::read()?
&& (key.kind == crossterm::event::KeyEventKind::Press
|| (key.kind == crossterm::event::KeyEventKind::Repeat
&& matches!(
key.code,
crossterm::event::KeyCode::Up
| crossterm::event::KeyCode::Down
| crossterm::event::KeyCode::Char('j')
| crossterm::event::KeyCode::Char('k')
)))
{
use crossterm::event::{KeyCode, KeyModifiers};
match (key.modifiers, key.code) {
(_, KeyCode::Char('q')) | (_, KeyCode::Esc) => break None,
(_, KeyCode::Up) | (_, KeyCode::Char('k')) => picker.move_up(),
(_, KeyCode::Down) | (_, KeyCode::Char('j')) => picker.move_down(),
(_, KeyCode::Enter) => {
if let Some(entry) = picker.selected_entry() {
break Some(entry.path.clone());
}
}
(KeyModifiers::SHIFT, KeyCode::Char('X'))
| (KeyModifiers::NONE, KeyCode::Char('X')) => {
picker.remove_selected();
}
(_, KeyCode::Char('s')) => picker.toggle_sort(),
_ => {}
}
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Some(path) = selected_path {
let root_path = std::path::PathBuf::from(&path);
if !root_path.join("frame").exists() {
return Err(format!("project not found at {}", path).into());
}
crate::io::registry::touch_tui(&root_path);
return run(Some(&path));
}
Ok(())
}
fn format_key_debug(key: &crossterm::event::KeyEvent) -> String {
use crossterm::event::KeyModifiers;
let code = format!("{:?}", key.code);
let mut mods = Vec::new();
if key.modifiers.contains(KeyModifiers::CONTROL) {
mods.push("CTRL");
}
if key.modifiers.contains(KeyModifiers::ALT) {
mods.push("ALT");
}
if key.modifiers.contains(KeyModifiers::SHIFT) {
mods.push("SHIFT");
}
if key.modifiers.contains(KeyModifiers::SUPER) {
mods.push("SUPER");
}
if key.modifiers.contains(KeyModifiers::HYPER) {
mods.push("HYPER");
}
if key.modifiers.contains(KeyModifiers::META) {
mods.push("META");
}
let mod_str = if mods.is_empty() {
"NONE".to_string()
} else {
mods.join("|")
};
format!("{} mod={} state={:?}", code, mod_str, key.state)
}
fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
mut watcher: Option<FrameWatcher>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut save_counter = 0u32;
loop {
if app.watcher_needs_restart {
app.watcher_needs_restart = false;
watcher = FrameWatcher::start(&app.project.frame_dir).ok();
}
app.clear_expired_flash();
if app.mode == Mode::Navigate
&& (!app.pending_moves.is_empty() || !app.board_state.column_pins.is_empty())
{
let modified = app.flush_expired_pending_moves();
for tid in &modified {
app.save_track_logged(tid);
}
}
if !app.pending_subtask_hides.is_empty() {
app.flush_expired_subtask_hides();
}
terminal.draw(|frame| render::render(frame, app))?;
if let Some(w) = watcher.as_ref() {
let events = w.poll();
if !events.is_empty() {
let mut all_paths = Vec::new();
for evt in events {
match evt {
FileEvent::Changed(paths) => all_paths.extend(paths),
}
}
all_paths.sort();
all_paths.dedup();
let is_self_write = app
.last_save_at
.is_some_and(|t| t.elapsed() < Duration::from_secs(1));
if is_self_write {
app.last_save_at = None; } else if !all_paths.is_empty() {
if matches!(
app.mode,
Mode::Edit | Mode::Move | Mode::Triage | Mode::Confirm | Mode::Command
) {
app.pending_reload_paths.extend(all_paths);
} else {
handle_external_reload(app, &all_paths);
}
}
}
}
if event::poll(Duration::from_millis(250))? {
let old_view = app.view.clone();
let evt = event::read()?;
let handled = match evt {
Event::Key(key)
if key.kind == KeyEventKind::Press
|| (key.kind == KeyEventKind::Repeat
&& is_repeatable_key(&app.mode, &key)) =>
{
if app.key_debug {
app.last_key_event = Some(format_key_debug(&key));
}
input::handle_key(app, key);
true
}
Event::Paste(text) => {
input::handle_paste(app, &text);
true
}
_ => false,
};
if handled {
if !app.pending_moves.is_empty() {
app.reset_pending_move_deadlines();
}
if !app.pending_subtask_hides.is_empty() {
app.reset_pending_subtask_hide_deadlines();
}
if app.view != old_view && !app.pending_moves.is_empty() {
let modified = app.flush_all_pending_moves();
for tid in &modified {
app.save_track_logged(tid);
}
}
if app.view != old_view {
app.pending_subtask_hides.clear();
}
if !app.pending_reload_paths.is_empty() && app.mode == Mode::Navigate {
let paths = std::mem::take(&mut app.pending_reload_paths);
handle_pending_reload(app, &paths);
}
save_counter += 1;
if save_counter >= 5 {
save_ui_state(app);
save_counter = 0;
}
}
}
if app.should_quit {
let modified = app.flush_all_pending_moves();
for tid in &modified {
app.save_track_logged(tid);
}
break;
}
}
Ok(())
}
fn is_repeatable_key(mode: &Mode, key: &crossterm::event::KeyEvent) -> bool {
use crossterm::event::KeyCode;
match mode {
Mode::Edit | Mode::Search | Mode::Triage | Mode::Command => true,
_ => matches!(
key.code,
KeyCode::Up
| KeyCode::Down
| KeyCode::Left
| KeyCode::Right
| KeyCode::PageUp
| KeyCode::PageDown
| KeyCode::Home
| KeyCode::End
| KeyCode::Tab
| KeyCode::BackTab
| KeyCode::Char('j')
| KeyCode::Char('k')
| KeyCode::Char('h')
| KeyCode::Char('l')
),
}
}
fn handle_external_reload(app: &mut App, paths: &[std::path::PathBuf]) {
let affected_track_ids: HashSet<String> = paths
.iter()
.filter_map(|p| {
let file_name = p.file_name()?.to_str()?;
let rel = p
.strip_prefix(&app.project.frame_dir)
.ok()
.and_then(|r| r.to_str());
resolve_track_for_path(&app.project.config.tracks, file_name, rel).map(|(id, _)| id)
})
.collect();
app.pending_subtask_hides
.retain(|ph| !affected_track_ids.contains(&ph.track_id));
let conflict_task = app.reload_changed_files(paths);
if conflict_task.is_some() {
if !app.edit_buffer.is_empty() {
app.conflict_text = Some(app.edit_buffer.clone());
}
app.mode = Mode::Navigate;
app.edit_target = None;
app.edit_buffer.clear();
app.edit_cursor = 0;
}
run_auto_clean(app);
}
fn handle_pending_reload(app: &mut App, paths: &[PathBuf]) {
let mut deduped: Vec<PathBuf> = Vec::new();
for p in paths {
if !deduped.contains(p) {
deduped.push(p.clone());
}
}
app.reload_changed_files(&deduped);
run_auto_clean(app);
}
fn run_auto_clean(app: &mut App) {
use crate::ops::clean::clean_project;
let result = clean_project(&mut app.project);
let has_changes = !result.ids_assigned.is_empty()
|| !result.dates_assigned.is_empty()
|| !result.duplicates_resolved.is_empty()
|| !result.sections_reconciled.is_empty()
|| !result.tasks_archived.is_empty();
if has_changes {
let mut affected_tracks: std::collections::HashSet<String> =
std::collections::HashSet::new();
for id_a in &result.ids_assigned {
affected_tracks.insert(id_a.track_id.clone());
}
for date_a in &result.dates_assigned {
affected_tracks.insert(date_a.track_id.clone());
}
for dup in &result.duplicates_resolved {
affected_tracks.insert(dup.track_id.clone());
}
for rec in &result.sections_reconciled {
affected_tracks.insert(rec.track_id.clone());
}
for arc in &result.tasks_archived {
affected_tracks.insert(arc.track_id.clone());
}
for track_id in &affected_tracks {
let _ = app.save_track(track_id);
}
app.undo_stack.push(crate::tui::undo::Operation::SyncMarker);
let count = result.ids_assigned.len()
+ result.dates_assigned.len()
+ result.duplicates_resolved.len()
+ result.sections_reconciled.len()
+ result.tasks_archived.len();
app.status_message = Some(format!(
"Auto-cleaned: {} fix{}",
count,
if count == 1 { "" } else { "es" }
));
}
}
fn is_inbox_path(file_name: &str, rel_path: Option<&str>) -> bool {
file_name == "inbox.md" && rel_path.is_none_or(|r| r == "inbox.md")
}
fn resolve_track_for_path(
tracks: &[crate::model::config::TrackConfig],
file_name: &str,
rel_path: Option<&str>,
) -> Option<(String, String)> {
tracks
.iter()
.find(|tc| {
if let Some(rel) = rel_path {
tc.file == rel
} else {
tc.file == file_name || tc.file.ends_with(&format!("/{}", file_name))
}
})
.map(|tc| (tc.id.clone(), tc.file.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::config::TrackConfig;
fn sample_tracks() -> Vec<TrackConfig> {
vec![
TrackConfig {
id: "main".to_string(),
name: "Main".to_string(),
state: "active".to_string(),
file: "tracks/main.md".to_string(),
},
TrackConfig {
id: "research".to_string(),
name: "Research".to_string(),
state: "active".to_string(),
file: "tracks/research.md".to_string(),
},
]
}
#[test]
fn inbox_top_level_matches() {
assert!(is_inbox_path("inbox.md", Some("inbox.md")));
}
#[test]
fn inbox_archive_does_not_match() {
assert!(!is_inbox_path("inbox.md", Some("archive/inbox.md")));
}
#[test]
fn inbox_no_rel_path_matches() {
assert!(is_inbox_path("inbox.md", None));
}
#[test]
fn non_inbox_does_not_match() {
assert!(!is_inbox_path("main.md", Some("tracks/main.md")));
}
#[test]
fn track_file_matches_by_rel_path() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "main.md", Some("tracks/main.md"));
assert_eq!(
result,
Some(("main".to_string(), "tracks/main.md".to_string()))
);
}
#[test]
fn archive_file_does_not_match_track() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "main.md", Some("archive/main.md"));
assert_eq!(result, None);
}
#[test]
fn archive_tracks_subdir_does_not_match() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "main.md", Some("archive/_tracks/main.md"));
assert_eq!(result, None);
}
#[test]
fn different_track_name_in_archive() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "research.md", Some("archive/research.md"));
assert_eq!(result, None);
}
#[test]
fn correct_track_file_matches() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "research.md", Some("tracks/research.md"));
assert_eq!(
result,
Some(("research".to_string(), "tracks/research.md".to_string()))
);
}
#[test]
fn fallback_filename_matching_when_no_rel_path() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "main.md", None);
assert_eq!(
result,
Some(("main".to_string(), "tracks/main.md".to_string()))
);
}
#[test]
fn unrelated_md_file_does_not_match() {
let tracks = sample_tracks();
let result = resolve_track_for_path(&tracks, "notes.md", Some("notes.md"));
assert_eq!(result, None);
}
#[test]
fn flat_track_config_matches_exactly() {
let tracks = vec![TrackConfig {
id: "main".to_string(),
name: "Main".to_string(),
state: "active".to_string(),
file: "main.md".to_string(),
}];
let result = resolve_track_for_path(&tracks, "main.md", Some("main.md"));
assert_eq!(result, Some(("main".to_string(), "main.md".to_string())));
}
#[test]
fn flat_config_archive_does_not_match() {
let tracks = vec![TrackConfig {
id: "main".to_string(),
name: "Main".to_string(),
state: "active".to_string(),
file: "main.md".to_string(),
}];
let result = resolve_track_for_path(&tracks, "main.md", Some("archive/main.md"));
assert_eq!(result, None);
}
}