Detailed changes
@@ -116,6 +116,10 @@ pub enum Command {
/// Set task type (e.g. task, bug, feature)
#[arg(long = "type")]
task_type: Option<String>,
+
+ /// Set parent task ID (pass empty string "" to clear)
+ #[arg(long)]
+ parent: Option<String>,
},
/// Mark task(s) as closed
@@ -95,10 +95,16 @@ pub fn dispatch(cli: &Cli) -> Result<()> {
title,
desc,
task_type,
+ parent,
} => {
let root = require_root()?;
let pri = priority.as_deref().map(Priority::parse).transpose()?;
let eff = effort.as_deref().map(Effort::parse).transpose()?;
+ // --parent "" means clear; --parent <id> means set; absent means don't change.
+ let parent_opt =
+ parent
+ .as_ref()
+ .map(|p| if p.is_empty() { None } else { Some(p.as_str()) });
update::run(
&root,
id,
@@ -109,6 +115,7 @@ pub fn dispatch(cli: &Cli) -> Result<()> {
title: title.as_deref(),
desc: desc.as_deref(),
task_type: task_type.as_deref(),
+ parent: parent_opt,
json: cli.json,
},
)
@@ -13,6 +13,7 @@ pub struct Opts<'a> {
pub title: Option<&'a str>,
pub desc: Option<&'a str>,
pub task_type: Option<&'a str>,
+ pub parent: Option<Option<&'a str>>,
pub json: bool,
}
@@ -32,6 +33,7 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
&& opts.title.is_none()
&& opts.desc.is_none()
&& opts.task_type.is_none()
+ && opts.parent.is_none()
{
let interactive = std::env::var("TD_FORCE_EDITOR").is_ok()
|| std::io::IsTerminal::is_terminal(&std::io::stdin());
@@ -66,6 +68,12 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
(opts.title, opts.desc)
};
+ let resolved_parent = match opts.parent {
+ Some(Some(id)) => Some(Some(db::resolve_task_id(&store, id, false)?)),
+ Some(None) => Some(None),
+ None => None,
+ };
+
let task = ops::update_task(
&store,
&task_id,
@@ -76,6 +84,7 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
title: title_override.map(String::from),
description: desc_override.map(String::from),
task_type: opts.task_type.map(String::from),
+ parent: resolved_parent,
},
)?;
@@ -61,6 +61,13 @@ pub(in crate::cmd::webui) struct UpdateForm {
effort: Option<String>,
#[serde(default)]
task_type: Option<String>,
+ /// Present and non-empty = set parent; present and empty = clear parent;
+ /// absent = don't change. The form uses `_parent_present` as a sentinel
+ /// so we can distinguish "field not submitted" from "submitted empty".
+ #[serde(default)]
+ parent: Option<String>,
+ #[serde(default)]
+ _parent_present: Option<String>,
#[serde(default)]
redirect: Option<String>,
}
@@ -200,6 +207,18 @@ pub(in crate::cmd::webui) async fn update_handler(
title: form.title.filter(|s| !s.is_empty()),
description: form.description,
task_type: form.task_type.filter(|s| !s.is_empty()),
+ // Only interpret `parent` when the sentinel field proves the
+ // edit form was the one submitted (not a status-change form).
+ parent: if form._parent_present.is_some() {
+ match form.parent.as_deref() {
+ Some(p) if !p.is_empty() => {
+ Some(Some(db::resolve_task_id(&store, p, false)?))
+ }
+ _ => Some(None), // clear parent
+ }
+ } else {
+ None // field not submitted — don't change
+ },
},
)?;
@@ -81,6 +81,7 @@ pub(in crate::cmd::webui) async fn task_handler(
created_at: task.created_at.clone(),
updated_at_display: friendly_date(&task.updated_at),
updated_at: task.updated_at.clone(),
+ parent_id: task.parent.as_ref().map(|p| p.short()).unwrap_or_default(),
labels: task.labels.clone(),
logs: task
.logs
@@ -18,6 +18,8 @@ pub(in crate::cmd::webui) struct TaskView {
pub(in crate::cmd::webui) created_at_display: String,
pub(in crate::cmd::webui) updated_at: String,
pub(in crate::cmd::webui) updated_at_display: String,
+ /// Parent task ID (short form) for display, empty string if none.
+ pub(in crate::cmd::webui) parent_id: String,
pub(in crate::cmd::webui) labels: Vec<String>,
pub(in crate::cmd::webui) logs: Vec<LogView>,
}
@@ -77,6 +77,9 @@ pub fn create_task(store: &Store, opts: CreateOpts) -> Result<Task> {
/// Input for updating an existing task. All fields are optional; only
/// populated fields are written.
+///
+/// `parent` uses a nested Option: `None` means "don't change", `Some(None)`
+/// means "clear the parent", `Some(Some(id))` means "set a new parent".
pub struct UpdateOpts {
pub status: Option<Status>,
pub priority: Option<Priority>,
@@ -84,12 +87,27 @@ pub struct UpdateOpts {
pub title: Option<String>,
pub description: Option<String>,
pub task_type: Option<String>,
+ pub parent: Option<Option<TaskId>>,
}
/// Update task fields and return the refreshed task.
pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<Task> {
let ts = now_utc();
+ // Validate parent change before persisting to avoid writing a cycle.
+ if let Some(Some(ref new_parent)) = opts.parent {
+ if new_parent == task_id {
+ bail!("a task cannot be its own parent");
+ }
+ if parent_would_cycle(store, task_id, new_parent)? {
+ bail!(
+ "reparenting {} under {} would create a cycle",
+ task_id.short(),
+ new_parent.short()
+ );
+ }
+ }
+
store.apply_and_persist(|doc| {
let tasks = doc.get_map("tasks");
let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
@@ -112,6 +130,11 @@ pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<
if let Some(ref tt) = opts.task_type {
task.insert("type", tt.as_str())?;
}
+ match opts.parent {
+ Some(Some(ref p)) => task.insert("parent", p.as_str())?,
+ Some(None) => task.insert("parent", "")?,
+ None => {}
+ }
task.insert("updated_at", ts.clone())?;
Ok(())
})?;
@@ -317,6 +340,33 @@ pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result<Del
})
}
+/// Walk the parent chain from `new_parent` upward. If we reach `child`,
+/// making `child` a subtask of `new_parent` would create a cycle.
+fn parent_would_cycle(store: &Store, child: &TaskId, new_parent: &TaskId) -> Result<bool> {
+ let tasks = store.list_tasks_unfiltered()?;
+ let parent_of: HashMap<String, String> = tasks
+ .iter()
+ .filter_map(|t| {
+ t.parent
+ .as_ref()
+ .map(|p| (t.id.as_str().to_string(), p.as_str().to_string()))
+ })
+ .collect();
+
+ let mut cur = new_parent.as_str().to_string();
+ let mut seen = HashSet::new();
+ while let Some(p) = parent_of.get(&cur) {
+ if p == child.as_str() {
+ return Ok(true);
+ }
+ if !seen.insert(p.clone()) {
+ break; // existing cycle in data — don't loop forever
+ }
+ cur = p.clone();
+ }
+ Ok(false)
+}
+
fn would_cycle(store: &Store, child: &TaskId, parent: &TaskId) -> Result<bool> {
let tasks = store.list_tasks_unfiltered()?;
let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
@@ -223,6 +223,11 @@
</select>
</div>
</div>
+ <label data-field>
+ Parent task ID <small class="text-light">(leave empty to clear)</small>
+ <input type="text" name="parent" value="{{ task.parent_id }}" placeholder="td-XXXXXXX" aria-label="Parent task ID">
+ </label>
+ <input type="hidden" name="_parent_present" value="1">
</div>
<footer>
<button type="button" commandfor="dlg-edit-task" command="close" class="outline">Cancel</button>