tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
#[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.bulk_action_targets = None;
                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 trimmed = buffer.trim().to_string();
        if let Some(targets) = self.bulk_action_targets.clone() {
            let mut updated = 0usize;
            let mut unchanged = 0usize;
            let mut errors = Vec::new();
            for number in targets {
                let child = number.to_string();
                let outcome = if trimmed.is_empty() {
                    self.store.clear_parent(&child)
                } else {
                    self.store.set_parent(&child, &trimmed)
                };
                match outcome {
                    Ok(outcome) if outcome.changed => updated += 1,
                    Ok(_) => unchanged += 1,
                    Err(error) => errors.push((number, error.to_string())),
                }
            }
            if updated == 0 && unchanged == 0 {
                // Nothing was written (a bad selector, a self-parent, a cycle).
                // Stay in the prompt so the answer can be corrected, exactly as the
                // single-issue path does.
                self.status_msg = errors.first().map(|(_, error)| error.clone());
                return Ok(Action::None);
            }
            self.bulk_action_targets = None;
            self.mode = Mode::Detail;
            self.refresh_detail()?;
            let verb = if trimmed.is_empty() {
                "parent cleared on"
            } else {
                "parent set on"
            };
            let suffix = if updated == 1 { "" } else { "s" };
            let mut message = format!("{verb} {updated} issue{suffix}");
            if unchanged > 0 {
                message.push_str(&format!("; {unchanged} unchanged"));
            }
            if let Some((number, error)) = errors.first() {
                message.push_str(&format!("; {} refused (#{number}: {error})", errors.len()));
            }
            self.status_msg = Some(message);
            return Ok(Action::None);
        }
        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 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)
            }
        }
    }
}