tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl App {
    /// The relationship under the detail pane's cursor, if any.
    pub(in crate::app) fn selected_relationship(
        &self,
    ) -> Option<&crate::relationships::Relationship> {
        self.detail
            .as_ref()
            .and_then(|d| d.relationships.get(d.rel_cursor))
    }

    /// The **stable id** of the selected relationship, which is what `tix link edit`
    /// and `tix link remove` name.
    ///
    /// Taken from the id rather than from `(from, to)`, because the id survives a
    /// re-kind: `2 related 1` and `1 blocks 2` are different edges that can share an
    /// endpoint pair, and addressing one by its endpoints would be ambiguous.
    pub(in crate::app) fn selected_relationship_id(&self) -> Option<String> {
        self.selected_relationship().map(|r| r.id.clone())
    }

    /// Move the relationship cursor by `delta`, clamped to the list.
    pub(in crate::app) fn move_rel_cursor(&mut self, delta: isize) {
        let Some(d) = self.detail.as_mut() else {
            return;
        };
        if d.relationships.is_empty() {
            return;
        }
        let last = d.relationships.len() as isize - 1;
        let next = (d.rel_cursor as isize + delta).clamp(0, last);
        d.rel_cursor = next as usize;
    }

    /// The two-step create/edit relationship prompt (#1359).
    ///
    /// Shares one handler for `LinkCreate` and `LinkEdit` because the two differ only
    /// in what they do at the end: a create needs both a target and a kind, an edit
    /// keeps the target it already has unless the user changes it. Sharing it means
    /// the two cannot drift in how they read a selector or report a bad kind โ€” which
    /// is the class of bug `surface-parity.md` ยง2 is about.
    pub(in crate::app) fn handle_link_prompt_key(&mut self, key: KeyEvent) -> Result<Action> {
        match key.code {
            KeyCode::Esc => {
                // Cancel the whole flow, not just this step: `Esc` in step 2 returns
                // to the detail pane rather than to step 1, because a two-step
                // cancel that needs two presses is a trap.
                self.mode = Mode::Detail;
                self.status_msg = Some("relationship: cancelled".to_string());
                Ok(Action::None)
            }
            KeyCode::Enter => {
                let prompt = match self.mode.clone() {
                    Mode::LinkCreate { buffer, step, .. } => Some((buffer, step)),
                    Mode::LinkEdit { buffer, step, .. } => Some((buffer, step)),
                    _ => None,
                };
                match prompt {
                    Some((buffer, step)) => self.submit_link_prompt(buffer, step),
                    None => Ok(Action::None),
                }
            }
            KeyCode::Backspace => {
                if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
                    buffer.pop();
                } else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
                    buffer.pop();
                }
                Ok(Action::None)
            }
            KeyCode::Char(c) => {
                if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
                    buffer.push(c);
                } else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
                    buffer.push(c);
                }
                Ok(Action::None)
            }
            _ => Ok(Action::None),
        }
    }

    /// The parent prompt's keys (tix #1384): printable text, backspace, Enter
    /// submits, Esc cancels.
    ///
    /// The mode carries only a buffer, so this is the single-line text rule and
    /// nothing else โ€” the same shape `handle_comment_key` has for its own prompt.
    pub(in crate::app) fn handle_parent_prompt_key(&mut self, key: KeyEvent) -> Result<Action> {
        match key.code {
            KeyCode::Esc => {
                self.mode = Mode::Detail;
                self.status_msg = Some("parent: cancelled".to_string());
                Ok(Action::None)
            }
            KeyCode::Enter => {
                let buffer = match &self.mode {
                    Mode::ParentPrompt { buffer } => buffer.clone(),
                    _ => String::new(),
                };
                self.submit_parent_prompt(buffer)
            }
            KeyCode::Backspace => {
                if let Mode::ParentPrompt { buffer } = &mut self.mode {
                    buffer.pop();
                }
                Ok(Action::None)
            }
            KeyCode::Char(c) => {
                if let Mode::ParentPrompt { buffer } = &mut self.mode {
                    buffer.push(c);
                }
                Ok(Action::None)
            }
            _ => Ok(Action::None),
        }
    }

    /// Apply the parent prompt: an empty answer clears, any other answer sets or
    /// repoints, always through the shared hierarchy service (tix #1384).
    ///
    /// `Store::set_parent` is set-or-repoint, so one call covers both "give it a
    /// parent" and "change its parent"; the unchanged case is the service's own
    /// no-op, which writes nothing and appends no History event. Selecting the
    /// verb here would be a second place for the TUI and the CLI to disagree about
    /// what re-running a set means.
    pub(in crate::app) fn submit_parent_prompt(&mut self, buffer: String) -> Result<Action> {
        let Some(number) = self.detail.as_ref().map(|d| d.issue.number) else {
            self.mode = Mode::Detail;
            return Ok(Action::None);
        };
        let child = number.to_string();
        let trimmed = buffer.trim().to_string();
        let outcome = if trimmed.is_empty() {
            self.store.clear_parent(&child)
        } else {
            self.store.set_parent(&child, &trimmed)
        };
        match outcome {
            Ok(outcome) => {
                self.status_msg = Some(match (outcome.changed, outcome.parent) {
                    (false, _) => "parent: unchanged".to_string(),
                    (true, Some(parent)) => format!("parent set to #{parent}"),
                    (true, None) => "parent cleared".to_string(),
                });
                self.mode = Mode::Detail;
                self.refresh_detail()?;
                Ok(Action::None)
            }
            Err(e) => {
                // The service's own message (an unknown selector, a self-parent, a
                // cycle) and stay in the prompt so the answer can be corrected โ€”
                // the same refusal shape the link prompt uses.
                self.status_msg = Some(e.to_string());
                Ok(Action::None)
            }
        }
    }

    /// One prompt step: resolve a target, or apply the kind.
    pub(in crate::app) fn submit_link_prompt(
        &mut self,
        buffer: String,
        step: LinkStep,
    ) -> Result<Action> {
        let trimmed = buffer.trim().to_string();
        let subject = self.detail.as_ref().map(|d| d.issue.number).unwrap_or(0);
        let (is_edit, index) = match &self.mode {
            Mode::LinkEdit { index, .. } => (true, *index),
            _ => (false, 0),
        };

        match step {
            LinkStep::Target => {
                if trimmed.is_empty() {
                    self.status_msg = Some("relationship: cancelled (no issue given)".into());
                    self.mode = Mode::Detail;
                    return Ok(Action::None);
                }
                // **The CLI's own resolver**, so `#4`, `4` and a title fragment all
                // work and an unresolvable selector produces the CLI's message. A
                // TUI-specific resolver would be a second place for the two surfaces
                // to disagree about what a selector means.
                let selector = match crate::commands::parse_selector(&self.store, &trimmed) {
                    Ok(selector) => selector,
                    Err(e) => {
                        self.status_msg = Some(e.to_string());
                        return Ok(Action::None);
                    }
                };
                let target = match self.store.get(selector) {
                    Ok(issue) => issue.number,
                    Err(e) => {
                        self.status_msg = Some(e.to_string());
                        return Ok(Action::None);
                    }
                };
                if target == subject {
                    // The service rejects a self-link; saying so here is friendlier
                    // and the service's check still stands as the guarantee.
                    self.status_msg =
                        Some("relationship: an issue cannot be related to itself".into());
                    return Ok(Action::None);
                }
                self.mode = if is_edit {
                    Mode::LinkEdit {
                        index,
                        buffer: String::new(),
                        step: LinkStep::Kind,
                        target: Some(target),
                        kind: None,
                    }
                } else {
                    Mode::LinkCreate {
                        buffer: String::new(),
                        step: LinkStep::Kind,
                        target: Some(target),
                    }
                };
                Ok(Action::None)
            }
            LinkStep::Kind => {
                // **Required** (C-001). An untypeable relationship must never be
                // creatable by omission, so there is no default kind and an empty
                // answer is a refusal carrying the CLI's own message โ€” which names
                // the valid set rather than just failing.
                let kind = match crate::commands::link::parse_kind(&trimmed) {
                    Ok(kind) => kind,
                    Err(e) => {
                        self.status_msg = Some(e.to_string());
                        return Ok(Action::None);
                    }
                };
                if is_edit {
                    self.apply_relationship_edit(kind)
                } else {
                    self.apply_relationship_create(subject, kind)
                }
            }
        }
    }

    /// `tix link <subject> <target> --type <kind>`, through the service.
    ///
    /// The **shared** message (`link::link_message`), so the TUI reports the same
    /// sentence as the CLI and MCP for the same edge.
    pub(in crate::app) fn apply_relationship_create(
        &mut self,
        subject: u32,
        kind: crate::relationships::RelationKind,
    ) -> Result<Action> {
        let Some(target) = (match &self.mode {
            Mode::LinkCreate { target, .. } => *target,
            _ => None,
        }) else {
            // Unreachable: `Kind` is only entered from a resolved `Target` step. Left
            // as a refusal rather than an `expect` so a request handler never panics.
            self.status_msg = Some("relationship: no target resolved".into());
            self.mode = Mode::Detail;
            return Ok(Action::None);
        };
        // `Store::link` takes the same **selector strings** the CLI takes, so the
        // numbers resolved in the prompt go through the one resolver rather than a
        // second path that happens to agree today.
        match self
            .store
            .link(&subject.to_string(), &target.to_string(), kind)
        {
            Ok(outcome) => {
                self.status_msg = Some(crate::commands::link::link_message(&outcome));
                self.mode = Mode::Detail;
                self.refresh_detail()?;
            }
            Err(e) => {
                self.status_msg = Some(e.to_string());
                self.mode = Mode::Detail;
            }
        }
        Ok(Action::None)
    }

    /// `tix link edit <id> --to <target> --type <kind>`, through the service.
    ///
    /// Reached by the **stable id**, not by an endpoint pair: `2 related 1` and
    /// `1 blocks 2` can share a pair, so an endpoint-addressed edit would be
    /// ambiguous. The id survives a re-kind, which is the whole reason an edit
    /// exists rather than a remove-then-create pair.
    pub(in crate::app) fn apply_relationship_edit(
        &mut self,
        kind: crate::relationships::RelationKind,
    ) -> Result<Action> {
        // The index is read from the mode rather than passed in, so there is one
        // source for "which edge" instead of a parameter and a field that can
        // disagree. (It did disagree in the first draft: the parameter was carried
        // and never used, and the field was read instead.)
        let (id, to) = match &self.mode {
            Mode::LinkEdit { index, target, .. } => (
                self.detail
                    .as_ref()
                    .and_then(|d| d.relationships.get(*index))
                    .map(|r| r.id.clone()),
                *target,
            ),
            _ => (None, None),
        };
        let Some(id) = id else {
            self.status_msg = Some("relationship: nothing selected to edit".into());
            self.mode = Mode::Detail;
            return Ok(Action::None);
        };
        match self.store.edit_relation(&id, to, Some(kind)) {
            Ok(outcome) => {
                self.status_msg = Some(crate::commands::link::edit_message(&outcome));
                self.mode = Mode::Detail;
                self.refresh_detail()?;
            }
            Err(e) => {
                self.status_msg = Some(e.to_string());
                self.mode = Mode::Detail;
            }
        }
        Ok(Action::None)
    }

    pub(in crate::app) fn handle_remove_relationship_key(
        &mut self,
        key: KeyEvent,
    ) -> Result<Action> {
        match key.code {
            KeyCode::Char('y') | KeyCode::Char('Y') => {
                let index = match &self.mode {
                    Mode::ConfirmRemoveRelationship { index } => *index,
                    _ => return Ok(Action::None),
                };
                let id = match self
                    .detail
                    .as_ref()
                    .and_then(|d| d.relationships.get(index))
                {
                    Some(rel) => rel.id.clone(),
                    None => {
                        self.mode = Mode::Detail;
                        return Ok(Action::None);
                    }
                };
                match self.store.remove_relation(&id) {
                    Ok(outcome) => {
                        // **The shared wording** โ€” `link::remove_message` โ€” so the TUI
                        // and the CLI cannot drift on what a removal reports.
                        self.status_msg = Some(crate::commands::link::remove_message(&outcome));
                        self.mode = Mode::Detail;
                        self.refresh_detail()?;
                    }
                    Err(e) => {
                        self.status_msg = Some(e.to_string());
                        self.mode = Mode::Detail;
                    }
                }
                Ok(Action::None)
            }
            _ => {
                self.mode = Mode::Detail;
                self.status_msg = Some("relationship: removal cancelled".to_string());
                Ok(Action::None)
            }
        }
    }
}