use anyhow::Result;
use crate::tui::app::{App, Focus, TaskCopyKind, TaskRefKind};
use crate::tui::overlay::{OverlayRoute, OverlayState};
use crate::tui::platform::copy_to_clipboard;
pub(crate) const ADD_PROJECT_TITLE: &str = "Add project";
pub(crate) const RENAME_PROJECT_TITLE: &str = "Rename project";
pub(crate) const DELETE_PROJECT_TITLE: &str = "Delete project";
pub(crate) const DELETE_TASK_TITLE: &str = "Delete task";
pub(crate) const ADD_LABEL_TITLE: &str = "Add label";
fn task_text_for_copy(title: &str, description: &str, kind: TaskCopyKind) -> String {
match kind {
TaskCopyKind::Title => title.to_string(),
TaskCopyKind::Description => description.to_string(),
TaskCopyKind::TitleAndDescription if description.is_empty() => title.to_string(),
TaskCopyKind::TitleAndDescription => format!("{title}\n\n{description}"),
}
}
fn task_notes_for_copy(notes: &[crate::query::TaskNote]) -> String {
notes
.iter()
.map(|note| note.body.as_str())
.collect::<Vec<_>>()
.join("\n\n")
}
impl App {
pub(super) fn begin_add_project(&mut self) {
self.pending_shortcut.clear();
self.overlay = Some(OverlayState::blank_text_input(
OverlayRoute::AddProject,
ADD_PROJECT_TITLE,
"project name:",
));
}
pub(super) fn begin_add_label(&mut self) {
self.pending_shortcut.clear();
self.overlay = Some(OverlayState::blank_text_input(
OverlayRoute::AddLabel,
ADD_LABEL_TITLE,
"label name:",
));
}
pub(super) fn begin_delete_task(&mut self) {
self.pending_shortcut.clear();
let task_ids = self.marked_task_ids_in_view();
if !task_ids.is_empty() {
let count = task_ids.len();
self.detail_context =
self.detail_context || matches!(self.overlay, Some(OverlayState::Detail { .. }));
self.overlay = Some(OverlayState::confirm(
OverlayRoute::DeleteTaskConfirm,
DELETE_TASK_TITLE,
format!("Delete {count} marked tasks?"),
));
return;
}
let Some(task) = self.store.selected_task(self.widgets.table.selected()) else {
self.set_info("no selected task to edit");
return;
};
self.detail_context =
self.detail_context || matches!(self.overlay, Some(OverlayState::Detail { .. }));
self.overlay = Some(OverlayState::confirm(
OverlayRoute::DeleteTaskConfirm,
DELETE_TASK_TITLE,
format!("Delete {} {}?", task.display_ref, task.task.title),
));
}
pub(super) fn begin_rename_project(&mut self) {
self.pending_shortcut.clear();
let selected = if self.focus == Focus::Sidebar {
self.selected_sidebar_project()
} else {
None
};
let items = self
.store
.existing_project_picker_items(selected.as_deref().unwrap_or_default());
self.open_picker_overlay(
OverlayRoute::RenameProjectPicker,
RENAME_PROJECT_TITLE,
items,
false,
);
}
pub(super) fn begin_delete_project(&mut self) {
self.pending_shortcut.clear();
let selected = if self.focus == Focus::Sidebar {
self.selected_sidebar_project()
} else {
None
};
let items = self
.store
.existing_project_picker_items(selected.as_deref().unwrap_or_default());
self.open_picker_overlay(
OverlayRoute::DeleteProjectPicker,
DELETE_PROJECT_TITLE,
items,
false,
);
}
fn selected_sidebar_project(&self) -> Option<String> {
self.widgets
.sidebar
.selected()
.and_then(|index| self.store.sidebar_entries.get(index))
.and_then(|entry| entry.target.as_ref())
.and_then(|target| match target {
crate::tui::store::SidebarEntryTarget::Scope(
crate::tui::store::TaskScopeTarget::Project(project),
) => Some(project.clone()),
_ => None,
})
}
pub(super) fn copy_selected_ref(&mut self, kind: TaskRefKind) {
let Some(task) = self.store.selected_task(self.widgets.table.selected()) else {
self.set_info("no selected task to copy");
return;
};
let (value, message_ref) = match kind {
TaskRefKind::Short => (task.display_ref.clone(), task.display_ref.clone()),
TaskRefKind::Durable => (task.task.id.to_string(), task.display_ref.clone()),
};
match copy_to_clipboard(&value) {
Ok(()) => self.set_success(format!("copied {message_ref}")),
Err(error) => self.set_error(format!("copy failed: {error}")),
}
}
pub(super) fn copy_selected_task_text(&mut self, kind: TaskCopyKind) {
let Some(task) = self.store.selected_task(self.widgets.table.selected()) else {
self.set_info("no selected task to copy");
return;
};
if kind == TaskCopyKind::Description && task.task.description.is_empty() {
self.set_info("task description is empty");
return;
}
let value = task_text_for_copy(&task.task.title, &task.task.description, kind);
let copied = match kind {
TaskCopyKind::Title => "task title",
TaskCopyKind::Description => "task description",
TaskCopyKind::TitleAndDescription => "task title and description",
};
match copy_to_clipboard(&value) {
Ok(()) => self.set_success(format!("copied {copied}")),
Err(error) => self.set_error(format!("copy failed: {error}")),
}
}
pub(super) fn copy_selected_task_notes(&mut self) {
let Some(task) = self.store.selected_task(self.widgets.table.selected()) else {
self.set_info("no selected task to copy");
return;
};
if task.notes.is_empty() {
self.set_info("task has no notes");
return;
}
match copy_to_clipboard(&task_notes_for_copy(&task.notes)) {
Ok(()) => self.set_success("copied task notes"),
Err(error) => self.set_error(format!("copy failed: {error}")),
}
}
pub(super) fn submit_rename_project_picker(&mut self, values: Vec<String>) {
let Some(project) = self.require_picker_value(values, "no matching project") else {
self.begin_rename_project();
return;
};
self.pending_rename_project = Some(project.clone());
self.overlay = Some(OverlayState::text_input(
OverlayRoute::RenameProjectName,
RENAME_PROJECT_TITLE,
"new project name:",
project,
));
}
pub(super) async fn submit_rename_project(&mut self, value: String) -> Result<()> {
let Some(project) = self.pending_rename_project.clone() else {
self.set_warning("project rename is not active");
return Ok(());
};
match self.store.rename_project(&project, value.clone()).await {
Ok(result) => {
self.pending_rename_project = None;
self.apply_mutation_result(result);
}
Err(error) => {
self.set_error(format!("{error:#}"));
self.overlay = Some(OverlayState::text_input(
OverlayRoute::RenameProjectName,
RENAME_PROJECT_TITLE,
"new project name:",
value,
));
}
}
Ok(())
}
pub(super) fn submit_delete_project_picker(&mut self, values: Vec<String>) {
let Some(project) = self.require_picker_value(values, "no matching project") else {
self.begin_delete_project();
return;
};
self.pending_delete_project = Some(project.clone());
self.overlay = Some(OverlayState::text_input(
OverlayRoute::DeleteProjectNameConfirm,
DELETE_PROJECT_TITLE,
format!("Type {project} to delete project:"),
String::new(),
));
}
pub(super) async fn submit_delete_project_name(&mut self, value: String) -> Result<()> {
let Some(project) = self.pending_delete_project.clone() else {
self.set_warning("project delete confirmation is not active");
return Ok(());
};
if value.trim() != project {
self.set_warning("project name does not match");
self.overlay = Some(OverlayState::text_input(
OverlayRoute::DeleteProjectNameConfirm,
DELETE_PROJECT_TITLE,
format!("Type {project} to delete project:"),
value,
));
return Ok(());
}
self.overlay = Some(OverlayState::confirm(
OverlayRoute::DeleteProjectConfirm,
DELETE_PROJECT_TITLE,
format!("Delete project {project}?"),
));
Ok(())
}
pub(super) async fn submit_delete_project(&mut self) -> Result<()> {
let Some(project) = self.pending_delete_project.take() else {
self.set_warning("project delete confirmation is not active");
return Ok(());
};
match self.store.delete_project(&project).await {
Ok(result) => self.apply_mutation_result(result),
Err(error) => self.set_error(format!("{error:#}")),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_copy_text_preserves_description_formatting() {
let description = "First paragraph.\n\n- one\n - nested\n\n```rust\nfn main() {}\n```\n";
assert_eq!(
task_text_for_copy("Copy me", description, TaskCopyKind::Description),
description
);
assert_eq!(
task_text_for_copy("Copy me", description, TaskCopyKind::TitleAndDescription),
format!("Copy me\n\n{description}")
);
}
#[test]
fn combined_task_copy_omits_separator_for_empty_description() {
assert_eq!(
task_text_for_copy("Title only", "", TaskCopyKind::TitleAndDescription),
"Title only"
);
}
#[test]
fn task_notes_copy_preserves_bodies_and_separates_notes() {
let notes = vec![
crate::query::TaskNote {
body: "First note\n- item".to_string(),
created_at: "2026-07-13T10:00:00Z".to_string(),
},
crate::query::TaskNote {
body: "Second note\n\nParagraph".to_string(),
created_at: "2026-07-13T11:00:00Z".to_string(),
},
];
assert_eq!(
task_notes_for_copy(¬es),
"First note\n- item\n\nSecond note\n\nParagraph"
);
}
}