fix(tickets): matching exact du #ref et curseur de pagination opaque/stable pour ticket_list (#20)

Épurement de la dette de ticket_list sur deux axes :

Recherche texte — matching exact du numéro/#ref via parse_issue_search_ref,
court-circuité avant load_issue, sans matching par substring numérique
(« 1 » ne remonte plus « 12 », « 123 »…).

Pagination — curseur opaque et stable anchor-based au lieu d'un offset fragile :
token v1.<base64url-no-pad-json> encodant le tri + l'ancre {number, sortKey},
reprise strictement après l'ancre. Curseur legacy / invalide / de version
inconnue / avec sort divergent rejeté par une erreur explicite « Invalid
cursor ». Le DTO cursor reste String → non-breaking côté UI.

Fichiers : infrastructure/src/issues.rs, app-tauri/src/tickets.rs,
app-tauri/Cargo.toml (+ base64 0.22). Tests : issue_store_text_filter,
ticket_list_ 7/7 (anchor-based + rejets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:20:40 +02:00
parent 83e4df7626
commit bd5d8d864a
5 changed files with 401 additions and 35 deletions

View File

@ -254,6 +254,9 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
if let Some(number) = parse_issue_search_ref(text) {
return row.issue_ref.number() == number;
}
row.title
.to_ascii_lowercase()
.contains(&text.to_ascii_lowercase())
@ -262,6 +265,17 @@ fn filter_matches(row: &IssueIndexEntry, filter: &IssueListFilter) -> bool {
}
}
fn parse_issue_search_ref(needle: &str) -> Option<IssueNumber> {
let trimmed = needle.trim();
let raw = trimmed.strip_prefix('#').unwrap_or(trimmed);
if raw.is_empty() || !raw.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
raw.parse::<u64>()
.ok()
.and_then(|number| IssueNumber::new(number).ok())
}
#[async_trait]
impl IssueStore for FsIssueStore {
async fn create(&self, root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> {
@ -309,7 +323,9 @@ impl IssueStore for FsIssueStore {
.is_some_and(|text| !text.trim().is_empty())
{
// Text search may need description/carnet, so fall back to source files.
let needle = filter.text.as_ref().unwrap().trim().to_ascii_lowercase();
let raw_needle = filter.text.as_ref().unwrap().trim();
let ref_needle = parse_issue_search_ref(raw_needle);
let needle = raw_needle.to_ascii_lowercase();
let base_filter = IssueListFilter {
text: None,
..filter
@ -319,6 +335,12 @@ impl IssueStore for FsIssueStore {
if !filter_matches(&row, &base_filter) {
continue;
}
if let Some(number) = ref_needle {
if row.issue_ref.number() == number {
out.push(row);
}
continue;
}
if row.title.to_ascii_lowercase().contains(&needle) {
out.push(row);
continue;