Skip to main content

oxicode/tui_vt/issues_panel/
mod.rs

1//! State, rendering, and input handling for the `/issue` TUI panel.
2//! See docs/superpowers/specs/2026-08-27-tui-issues-panel-design.md.
3
4use std::path::Path;
5
6use oxicode_sdk::{FileIssueStore, IssueFilter, IssuePatch, Priority, Status, liveness};
7mod filter_parse;
8// Consumed by `input::FilterInput` Enter handler in Task 6.
9pub(crate) use filter_parse::parse_issue_filter;
10mod store_handle;
11
12pub(crate) use store_handle::get_or_open_store;
13
14mod form;
15// Task 8: `cycle_priority` is wired into the `Left/Right` cycling on the
16// Priority field; `parse_labels` translates the comma-separated `labels_input`
17// String into `Vec<String>` at submit time.
18pub(crate) use form::{cycle_priority, parse_labels};
19
20mod input;
21mod render;
22
23pub(crate) use input::handle_issues_panel_key;
24pub(crate) use render::render_issues_panel;
25
26// `status_filter`: `Some(Status::Open)` is the default view (open issues
27// only); `None` = All (no status constraint). The `f` key in the panel
28// toggles between the two. We hand-write `Default` so the derived form's
29// `Option::default()` (= `None` = All) does NOT shadow the intended
30// Open-on-first-open behavior.
31#[derive(Debug)]
32pub(crate) struct IssuesPanelState {
33    pub mode: IssuesPanelMode,
34    pub status_filter: Option<Status>,
35    pub extra_filter: IssueFilter,
36    pub rows: Vec<IssueRow>,
37    pub selected: usize,
38    pub pending: bool,
39    pub error: Option<String>,
40    /// Body text for the current Detail view, populated synchronously via
41    /// `FileIssueStore::read(id)` on List→Detail transition. `None` while
42    /// the read hasn't happened yet (or failed) — render layer shows a
43    /// "(loading…)" placeholder in that case.
44    pub detail_body_cache: Option<String>,
45}
46
47impl Default for IssuesPanelState {
48    fn default() -> Self {
49        Self {
50            mode: IssuesPanelMode::default(),
51            status_filter: Some(Status::Open),
52            extra_filter: IssueFilter::default(),
53            rows: Vec::new(),
54            selected: 0,
55            pending: false,
56            error: None,
57            detail_body_cache: None,
58        }
59    }
60}
61
62#[derive(Debug, Default)]
63pub(crate) enum IssuesPanelMode {
64    #[default]
65    List,
66    Detail {
67        id: u32,
68        scroll: usize,
69    },
70    Form(Box<IssueFormState>),
71    FilterInput(String),
72}
73
74#[derive(Clone, Debug)]
75pub(crate) struct IssueRow {
76    pub id: u32,
77    pub title: String,
78    pub status: Status,
79    pub priority: Priority,
80    pub labels: Vec<String>,
81    pub assignee_badge: Option<AssigneeBadge>,
82    /// Snapshot of `IssueMeta` timestamps at `refresh()` time (design §5
83    /// Detail meta header). Carried read-only; never written back.
84    pub created_at: chrono::DateTime<chrono::Utc>,
85    pub updated_at: chrono::DateTime<chrono::Utc>,
86    pub closed_at: Option<chrono::DateTime<chrono::Utc>>,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub(crate) enum AssigneeBadge {
91    Live(String),
92    Stale(String),
93}
94
95#[derive(Debug)]
96pub(crate) struct IssueFormState {
97    pub editing_id: Option<u32>,
98    pub content_hash: Option<String>,
99    pub title: String,
100    pub priority: Priority,
101    pub labels_input: String,
102    pub body: oxicode_textarea::TextArea,
103    pub focus: FormField,
104}
105
106impl Default for IssueFormState {
107    fn default() -> Self {
108        Self {
109            editing_id: None,
110            content_hash: None,
111            title: String::new(),
112            priority: Priority::default(),
113            labels_input: String::new(),
114            body: oxicode_textarea::TextArea::new(),
115            focus: FormField::Title,
116        }
117    }
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
121pub(crate) enum FormField {
122    #[default]
123    Title,
124    Priority,
125    Labels,
126    Body,
127}
128
129impl IssuesPanelState {
130    pub fn refresh(&mut self, store: &FileIssueStore, issues_dir: &Path) {
131        let filter = self.effective_filter();
132        let issues = match store.list(&filter) {
133            Ok(v) => v,
134            Err(e) => {
135                self.error = Some(e.to_string());
136                self.rows.clear();
137                return;
138            }
139        };
140        self.rows = issues
141            .into_iter()
142            .map(|issue| IssueRow {
143                id: issue.meta.id,
144                title: issue.meta.title,
145                status: issue.meta.status,
146                priority: issue.meta.priority,
147                labels: issue.meta.labels,
148                assignee_badge: issue.meta.assigned_to.map(|a| {
149                    if liveness::is_session_alive(issues_dir, &a.session) {
150                        AssigneeBadge::Live(a.session)
151                    } else {
152                        AssigneeBadge::Stale(a.session)
153                    }
154                }),
155                created_at: issue.meta.created_at,
156                updated_at: issue.meta.updated_at,
157                closed_at: issue.meta.closed_at,
158            })
159            .collect();
160        self.selected = self.selected.min(self.rows.len().saturating_sub(1));
161    }
162
163    /// Union of the status toggle and the `/` filter-modal fields (design §4).
164    /// `None` in `status_filter` flows through as `None` here, matching the
165    /// design's "All" view (no status constraint).
166    fn effective_filter(&self) -> oxicode_sdk::IssueFilter {
167        oxicode_sdk::IssueFilter {
168            status: self.status_filter,
169            ..self.extra_filter.clone()
170        }
171    }
172}
173
174/// Requests the panel's synchronous key-handling code cannot satisfy itself
175/// (CAS-guarded async store writes). Sent over a dedicated channel into
176/// `run_event_loop`'s `select!` — kept out of `oxicode_vtui::InlineEvent` so
177/// the framework crate stays free of `oxicode-*` dependencies.
178#[derive(Clone, Debug)]
179pub(crate) enum IssueActionRequest {
180    Close {
181        id: u32,
182        caller: String,
183        hash: Option<String>,
184    },
185    Reopen {
186        id: u32,
187        hash: Option<String>,
188    },
189    ApplyPatch {
190        id: u32,
191        patch: IssuePatch,
192        caller: Option<String>,
193        hash: Option<String>,
194    },
195}
196
197/// Receive an action from the panel's dedicated mpsc channel and resolve it
198/// with a real CAS-guarded store write.
199///
200/// Runs the mutation on a spawned task so the event loop never blocks on
201/// filesystem I/O. The `parking_lot` guard is dropped before the `.await`
202/// and re-acquired after completion (AGENTS.md pitfall: never hold it across
203/// an await). On completion the panel's `pending` flag clears, the outcome
204/// lands in `panel.error`, and the row list refreshes regardless — a failed
205/// write may still reflect a concurrent change made by another session.
206pub(crate) fn dispatch_action(
207    req: IssueActionRequest,
208    state: std::sync::Arc<parking_lot::Mutex<crate::tui_vt::main_loop::RenderState>>,
209) {
210    let store = { state.lock().issue_store.clone() };
211    let Some(store) = store else {
212        let mut s = state.lock();
213        if let Some(panel) = s.issues_panel.as_mut() {
214            panel.pending = false;
215            panel.error = Some("issue store not initialized".into());
216        }
217        return;
218    };
219    tokio::spawn(async move {
220        let result = match req {
221            IssueActionRequest::Close { id, caller, hash } => {
222                // Mirror the CLI's `oxicode issue close` flow
223                // (`oxicode-cli/src/cli/commands/issue.rs:84-103`):
224                //   1. `start` claims ownership (fails `Assigned` for a
225                //      live OTHER session — surfaced, not closed).
226                //   2. Re-read for a fresh hash.
227                //   3. `close` requires the assignee — now satisfied.
228                // The whole dance lives inside `cas_retry`'s closure so a
229                // `Conflict` from any step re-runs the sequence; `start`
230                // is idempotent for the same caller, so a retry after a
231                // close-side conflict is safe.
232                oxicode_sdk::cas_retry(&store, id, hash, |h| {
233                    let store = store.clone();
234                    let caller = caller.clone();
235                    async move {
236                        // `h: Option<String>` matches `store.start`'s
237                        // `expected_hash: Option<String>` directly — do
238                        // NOT wrap in `Some()`.
239                        store.start(id, &caller, h).await?;
240                        let (_, fresh_hash) = store.read(id)?;
241                        store.close(id, &caller, Some(fresh_hash)).await
242                    }
243                })
244                .await
245            }
246            IssueActionRequest::Reopen { id, hash } => {
247                oxicode_sdk::cas_retry(&store, id, hash, |h| {
248                    let store = store.clone();
249                    async move { store.reopen(id, h).await }
250                })
251                .await
252            }
253            IssueActionRequest::ApplyPatch {
254                id,
255                patch,
256                caller,
257                hash,
258            } => {
259                oxicode_sdk::cas_retry(&store, id, hash, |h| {
260                    let store = store.clone();
261                    let patch = patch.clone();
262                    let caller = caller.clone();
263                    async move { store.apply_patch(id, patch, caller, h).await }
264                })
265                .await
266            }
267        };
268
269        let mut s = state.lock();
270        if let Some(panel) = s.issues_panel.as_mut() {
271            panel.pending = false;
272            match result {
273                Ok(_) => panel.error = None,
274                Err(e) => panel.error = Some(e.to_string()),
275            }
276        }
277        // Refresh regardless of outcome — a failed write may still reflect
278        // a concurrent change made by another session.
279        let store2 = s.issue_store.clone();
280        if let (Some(store2), Some(panel)) = (store2, s.issues_panel.as_mut()) {
281            panel.refresh(&store2, &store2.issues_dir());
282        }
283    });
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    #[test]
290    fn default_state_opens_in_list_mode_with_open_filter() {
291        let state = IssuesPanelState::default();
292        assert!(matches!(state.mode, IssuesPanelMode::List));
293        assert_eq!(state.status_filter, Some(Status::Open));
294        assert!(!state.pending);
295        assert!(state.error.is_none());
296        assert!(state.rows.is_empty());
297    }
298}
299
300#[cfg(test)]
301mod refresh_tests {
302    use super::*;
303    use oxicode_sdk::{FileIssueStore, Priority};
304
305    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
306        let tmp = tempfile::tempdir().unwrap();
307        let store = FileIssueStore::open(tmp.path().to_path_buf()).unwrap();
308        (tmp, store)
309    }
310
311    #[test]
312    fn refresh_populates_rows_sorted_by_recency() {
313        let (tmp, store) = tmp_store();
314        store
315            .create("first".into(), "body".into(), Priority::Low, vec![], None)
316            .unwrap();
317        store
318            .create("second".into(), "body".into(), Priority::High, vec![], None)
319            .unwrap();
320
321        let mut panel = IssuesPanelState::default();
322        panel.refresh(&store, tmp.path());
323
324        assert_eq!(panel.rows.len(), 2);
325        // FileIssueStore::list sorts by updated_at desc — "second" was
326        // created after "first" so it sorts first.
327        assert_eq!(panel.rows[0].title, "second");
328        assert_eq!(panel.rows[1].title, "first");
329    }
330
331    #[test]
332    fn refresh_marks_unassigned_issues_with_no_badge() {
333        let (tmp, store) = tmp_store();
334        store
335            .create("solo".into(), "body".into(), Priority::Medium, vec![], None)
336            .unwrap();
337        let mut panel = IssuesPanelState::default();
338        panel.refresh(&store, tmp.path());
339        assert!(panel.rows[0].assignee_badge.is_none());
340    }
341}
342
343/// Task 9: real dispatch (`IssueActionRequest` → CAS-guarded store writes).
344/// Both requests below carry the SAME (stale-after-the-first-write) hash so
345/// the second one must flow through `cas_retry`'s re-read-and-retry path.
346#[cfg(test)]
347mod dispatch_tests {
348    use super::*;
349    use oxicode_sdk::{FileIssueStore, IssuePatch, Priority};
350    use std::sync::Arc;
351
352    #[tokio::test]
353    async fn concurrent_apply_patch_via_dispatch_action_both_eventually_succeed() {
354        let tmp = tempfile::tempdir().unwrap();
355        let store = Arc::new(FileIssueStore::open(tmp.path().to_path_buf()).unwrap());
356        let issue = store
357            .create("t".into(), "b".into(), Priority::Low, vec![], None)
358            .unwrap();
359        let (_issue, hash) = store.read(issue.meta.id).unwrap();
360
361        let state = Arc::new(parking_lot::Mutex::new(
362            crate::tui_vt::main_loop::RenderState {
363                issue_store: Some(store.clone()),
364                issues_panel: Some(IssuesPanelState::default()),
365                ..Default::default()
366            },
367        ));
368
369        // Both requests carry the SAME (now-stale-after-the-first-write) hash,
370        // forcing the second one through cas_retry's re-read-and-retry path.
371        dispatch_action(
372            IssueActionRequest::ApplyPatch {
373                id: issue.meta.id,
374                patch: IssuePatch {
375                    title: Some("first".into()),
376                    ..Default::default()
377                },
378                caller: None,
379                hash: Some(hash.clone()),
380            },
381            state.clone(),
382        );
383        dispatch_action(
384            IssueActionRequest::ApplyPatch {
385                id: issue.meta.id,
386                patch: IssuePatch {
387                    priority: Some(Priority::High),
388                    ..Default::default()
389                },
390                caller: None,
391                hash: Some(hash),
392            },
393            state.clone(),
394        );
395
396        // Give both spawned tasks a chance to run.
397        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
398
399        let (final_issue, _) = store.read(issue.meta.id).unwrap();
400        assert_eq!(final_issue.meta.title, "first");
401        assert_eq!(final_issue.meta.priority, Priority::High);
402        let s = state.lock();
403        assert!(s.issues_panel.as_ref().unwrap().error.is_none());
404    }
405
406    /// F1 regression: closing an UNASSIGNED issue via the panel's
407    /// `IssueActionRequest::Close` path used to fail with `NotAssigned`
408    /// because `store.close` requires the caller to hold the assignment.
409    /// The fixed dispatch does `start` → re-read → `close` (mirroring
410    /// the CLI), so an unassigned issue is now claimed-then-closed in
411    /// one CAS-guarded sequence.
412    #[tokio::test]
413    async fn dispatch_action_close_unassigned_issue_claims_then_closes() {
414        let tmp = tempfile::tempdir().unwrap();
415        let store = Arc::new(FileIssueStore::open(tmp.path().to_path_buf()).unwrap());
416        let issue = store
417            .create("t".into(), "b".into(), Priority::Low, vec![], None)
418            .unwrap();
419        // Sanity: the issue is unassigned at creation.
420        assert!(issue.meta.assigned_to.is_none());
421
422        let state = Arc::new(parking_lot::Mutex::new(
423            crate::tui_vt::main_loop::RenderState {
424                issue_store: Some(store.clone()),
425                issues_panel: Some(IssuesPanelState::default()),
426                ..Default::default()
427            },
428        ));
429
430        dispatch_action(
431            IssueActionRequest::Close {
432                id: issue.meta.id,
433                caller: "tui-ownership".into(),
434                hash: None,
435            },
436            state.clone(),
437        );
438
439        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
440
441        let (closed, _) = store.read(issue.meta.id).unwrap();
442        assert_eq!(closed.meta.status, Status::Closed);
443        let s = state.lock();
444        let panel = s.issues_panel.as_ref().unwrap();
445        assert!(!panel.pending, "pending should clear on completion");
446        assert!(
447            panel.error.is_none(),
448            "close must not error: {:?}",
449            panel.error
450        );
451    }
452}