Skip to content

Commit 6d801a9

Browse files
committed
feat: add pinned PRs feature
- Add 'pinned' column to SQLite schema for persistence - Add toggle_pin() function to toggle pin state - Add 'p' keybind in list view to toggle pin on selected PR - Show dedicated 'PINNED' section at the top of the list - Show 📌 indicator next to pinned PRs - Pinned PRs are sorted first, then by score/updated - Pin state persists across sessions via SQLite - Update help text and footer with new keybind
1 parent 071a987 commit 6d801a9

3 files changed

Lines changed: 181 additions & 18 deletions

File tree

src/db.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub struct DbPrRow {
2828

2929
pub last_seen_at: Option<i64>,
3030
pub last_opened_at: Option<i64>,
31+
pub pinned: Option<i64>,
3132
}
3233

3334
pub fn now_unix() -> i64 {
@@ -118,6 +119,7 @@ fn migrate_schema(conn: &Connection) -> Result<(), String> {
118119
add_if_missing(conn, &existing, "mergeable", "TEXT")?;
119120
add_if_missing(conn, &existing, "merge_state_status", "TEXT")?;
120121
add_if_missing(conn, &existing, "author_is_viewer", "INTEGER")?;
122+
add_if_missing(conn, &existing, "pinned", "INTEGER")?;
121123

122124
Ok(())
123125
}
@@ -146,7 +148,7 @@ SELECT
146148
pr_key, owner, repo, number, title, url, author, updated_at_unix,
147149
last_commit_sha, last_ci_state, last_review_state,
148150
ci_checks_json, is_draft, mergeable, merge_state_status, author_is_viewer,
149-
last_seen_at, last_opened_at
151+
last_seen_at, last_opened_at, pinned
150152
FROM prs
151153
"#,
152154
)
@@ -180,25 +182,28 @@ FROM prs
180182
author_is_viewer: row.get(15).map_err(|e| format!("Row decode: {e}"))?,
181183
last_seen_at: row.get(16).map_err(|e| format!("Row decode: {e}"))?,
182184
last_opened_at: row.get(17).map_err(|e| format!("Row decode: {e}"))?,
185+
pinned: row.get(18).map_err(|e| format!("Row decode: {e}"))?,
183186
};
184187
out.insert(pr.pr_key.clone(), pr);
185188
}
186189
Ok(out)
187190
}
188191

189192
pub fn upsert_pr(conn: &Connection, pr: &DbPrRow, last_seen_at: i64) -> Result<(), String> {
193+
// Note: pinned is intentionally NOT updated here to preserve user's pin state.
194+
// Use toggle_pin() to change the pinned state.
190195
conn.execute(
191196
r#"
192197
INSERT INTO prs (
193198
pr_key, owner, repo, number, title, url, author, updated_at_unix,
194199
last_commit_sha, last_ci_state, last_review_state,
195200
ci_checks_json, is_draft, mergeable, merge_state_status,
196-
author_is_viewer, last_seen_at, last_opened_at
201+
author_is_viewer, last_seen_at, last_opened_at, pinned
197202
) VALUES (
198203
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
199204
?9, ?10, ?11,
200205
?12, ?13, ?14, ?15,
201-
?16, ?17, ?18
206+
?16, ?17, ?18, ?19
202207
)
203208
ON CONFLICT(pr_key) DO UPDATE SET
204209
owner = excluded.owner,
@@ -236,13 +241,37 @@ ON CONFLICT(pr_key) DO UPDATE SET
236241
pr.merge_state_status,
237242
pr.author_is_viewer,
238243
last_seen_at,
239-
pr.last_opened_at
244+
pr.last_opened_at,
245+
pr.pinned
240246
],
241247
)
242248
.map_err(|e| format!("Failed to upsert pr: {e}"))?;
243249
Ok(())
244250
}
245251

252+
/// Toggle the pinned state of a PR. Returns the new pinned state.
253+
pub fn toggle_pin(conn: &Connection, pr_key: &str) -> Result<bool, String> {
254+
// Get current state
255+
let current: Option<i64> = conn
256+
.query_row(
257+
"SELECT pinned FROM prs WHERE pr_key = ?1",
258+
params![pr_key],
259+
|row| row.get(0),
260+
)
261+
.map_err(|e| format!("Failed to query pin state: {e}"))?;
262+
263+
let new_state = if current.unwrap_or(0) == 0 { 1 } else { 0 };
264+
265+
conn.execute(
266+
"UPDATE prs SET pinned = ?1 WHERE pr_key = ?2",
267+
params![new_state, pr_key],
268+
)
269+
.map_err(|e| format!("Failed to toggle pin: {e}"))?;
270+
271+
Ok(new_state == 1)
272+
}
273+
274+
246275
pub fn delete_prs_not_in(conn: &Connection, keep_pr_keys: &[String]) -> Result<(), String> {
247276
if keep_pr_keys.is_empty() {
248277
conn.execute("DELETE FROM prs", [])

src/refresh.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub struct UiPr {
5959
pub display_status: String,
6060
pub is_new_review_request: bool,
6161
pub is_new_ci_failure: bool,
62+
pub is_pinned: bool,
6263
}
6364

6465
fn parse_ci_state(s: Option<&str>) -> CiState {
@@ -125,6 +126,7 @@ pub fn load_cached(
125126

126127
let is_new_review = false;
127128
let is_new_ci_failure = false;
129+
let is_pinned = row.pinned.unwrap_or(0) != 0;
128130
let score = score_pr(&pr, None, now, is_new_ci_failure);
129131
let category = category_for(&pr, score);
130132
let display_status = status_text(&pr, now, is_new_ci_failure, is_new_review);
@@ -136,12 +138,15 @@ pub fn load_cached(
136138
display_status,
137139
is_new_review_request: is_new_review,
138140
is_new_ci_failure,
141+
is_pinned,
139142
});
140143
}
141144

145+
// Sort: pinned first, then by score desc, then by updated_at desc
142146
out.sort_by(|a, b| {
143-
b.score
144-
.cmp(&a.score)
147+
b.is_pinned
148+
.cmp(&a.is_pinned)
149+
.then_with(|| b.score.cmp(&a.score))
145150
.then_with(|| b.pr.updated_at_unix.cmp(&a.pr.updated_at_unix))
146151
});
147152
Ok(out)
@@ -357,6 +362,7 @@ pub async fn refresh(
357362
let old = existing.get(&pr.pr_key);
358363
let new_review = is_new_review_request(&pr, old);
359364
let new_ci_failure = is_new_ci_failure(&pr, old);
365+
let is_pinned = old.and_then(|r| r.pinned).unwrap_or(0) != 0;
360366

361367
let db_row = DbPrRow {
362368
pr_key: pr.pr_key.clone(),
@@ -377,6 +383,7 @@ pub async fn refresh(
377383
author_is_viewer: Some(viewer_author_to_db(pr.is_viewer_author)),
378384
last_seen_at: Some(now),
379385
last_opened_at: old.and_then(|r| r.last_opened_at),
386+
pinned: old.and_then(|r| r.pinned),
380387
};
381388
upsert_pr(conn, &db_row, now)?;
382389

@@ -391,15 +398,18 @@ pub async fn refresh(
391398
display_status,
392399
is_new_review_request: new_review,
393400
is_new_ci_failure: new_ci_failure,
401+
is_pinned,
394402
});
395403
}
396404

397405
// Keep cache consistent with the current attention set so cached startup doesn't show stale/irrelevant PRs.
398406
delete_prs_not_in(conn, &keep_keys)?;
399407

408+
// Sort: pinned first, then by score desc, then by updated_at desc
400409
out.sort_by(|a, b| {
401-
b.score
402-
.cmp(&a.score)
410+
b.is_pinned
411+
.cmp(&a.is_pinned)
412+
.then_with(|| b.score.cmp(&a.score))
403413
.then_with(|| b.pr.updated_at_unix.cmp(&a.pr.updated_at_unix))
404414
});
405415

@@ -430,6 +440,7 @@ pub fn refresh_demo(
430440
let old = existing.get(&pr.pr_key);
431441
let new_review = is_new_review_request(&pr, old);
432442
let new_ci_failure = is_new_ci_failure(&pr, old);
443+
let is_pinned = old.and_then(|r| r.pinned).unwrap_or(0) != 0;
433444

434445
let db_row = DbPrRow {
435446
pr_key: pr.pr_key.clone(),
@@ -450,6 +461,7 @@ pub fn refresh_demo(
450461
author_is_viewer: Some(viewer_author_to_db(pr.is_viewer_author)),
451462
last_seen_at: Some(now),
452463
last_opened_at: old.and_then(|r| r.last_opened_at),
464+
pinned: old.and_then(|r| r.pinned),
453465
};
454466
upsert_pr(conn, &db_row, now)?;
455467

@@ -464,14 +476,17 @@ pub fn refresh_demo(
464476
display_status,
465477
is_new_review_request: new_review,
466478
is_new_ci_failure: new_ci_failure,
479+
is_pinned,
467480
});
468481
}
469482

470483
delete_prs_not_in(conn, &keep_keys)?;
471484

485+
// Sort: pinned first, then by score desc, then by updated_at desc
472486
out.sort_by(|a, b| {
473-
b.score
474-
.cmp(&a.score)
487+
b.is_pinned
488+
.cmp(&a.is_pinned)
489+
.then_with(|| b.score.cmp(&a.score))
475490
.then_with(|| b.pr.updated_at_unix.cmp(&a.pr.updated_at_unix))
476491
});
477492
Ok(out)
@@ -588,6 +603,7 @@ mod tests {
588603
author_is_viewer: None,
589604
last_seen_at: Some(now - 10),
590605
last_opened_at: None,
606+
pinned: None,
591607
};
592608

593609
assert!(is_new_ci_failure(&pr, Some(&old)));

0 commit comments

Comments
 (0)