1use std::path::Path;
5
6use oxicode_sdk::{FileIssueStore, IssueFilter, IssuePatch, Priority, Status, liveness};
7mod filter_parse;
8pub(crate) use filter_parse::parse_issue_filter;
10mod store_handle;
11
12pub(crate) use store_handle::get_or_open_store;
13
14mod form;
15pub(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#[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 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 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 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#[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
197pub(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 oxicode_sdk::cas_retry(&store, id, hash, |h| {
233 let store = store.clone();
234 let caller = caller.clone();
235 async move {
236 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 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 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#[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 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 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 #[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 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}