Skip to content

Commit 525c6ff

Browse files
program247365claude
andcommitted
feat(history): delete a played song with d + [y]/[n] confirm
Add a keyboard shortcut to remove a row from the played-songs history, in both the landing browser and the in-playback `p` overlay. Press `d` to arm, then a centered [y]/[n] modal confirms before deleting from the SQLite history DB. The confirmation is a `pending_delete` state flag on HistoryPanelState rather than a blocking prompt, so the playback overlay keeps ticking the visualizer, audio, and media-key events underneath. Routing sends `y` to confirm and any other key to cancel, so a stray keypress can never delete. Reuses the existing Storage::delete_by_replay_target. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb33bc7 commit 525c6ff

2 files changed

Lines changed: 218 additions & 3 deletions

File tree

src/play_loop.rs

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ fn browse_history_session(
107107
selected: 0,
108108
sort_field: HistorySortField::TimePlayed,
109109
descending: true,
110+
pending_delete: false,
110111
};
111112
refresh_history_panel(&mut panel, &storage)?;
112113

@@ -148,6 +149,25 @@ fn browse_history_session(
148149
refresh_history_panel(&mut panel, &storage)?;
149150
}
150151
}
152+
KeyCommand::HistoryDelete => {
153+
if !panel.rows.is_empty() {
154+
panel.pending_delete = true;
155+
}
156+
}
157+
KeyCommand::HistoryDeleteConfirm => {
158+
if let Some(row) = panel.rows.get(panel.selected) {
159+
let replay_target = row.replay_target.clone();
160+
storage
161+
.lock()
162+
.unwrap()
163+
.delete_by_replay_target(&replay_target)?;
164+
}
165+
panel.pending_delete = false;
166+
refresh_history_panel(&mut panel, &storage)?;
167+
}
168+
KeyCommand::HistoryDeleteCancel => {
169+
panel.pending_delete = false;
170+
}
151171
KeyCommand::HistoryReplay => {
152172
if let Some(row) = panel.rows.get(panel.selected) {
153173
let target = row.replay_target.clone();
@@ -676,6 +696,37 @@ fn dispatch_command(
676696
*needs_render = true;
677697
Ok(None)
678698
}
699+
KeyCommand::HistoryDelete => {
700+
if let Some(panel) = state.history_panel.as_mut() {
701+
if !panel.rows.is_empty() {
702+
panel.pending_delete = true;
703+
*needs_render = true;
704+
}
705+
}
706+
Ok(None)
707+
}
708+
KeyCommand::HistoryDeleteConfirm => {
709+
if let Some(panel) = state.history_panel.as_mut() {
710+
if let Some(row) = panel.rows.get(panel.selected) {
711+
let replay_target = row.replay_target.clone();
712+
storage
713+
.lock()
714+
.unwrap()
715+
.delete_by_replay_target(&replay_target)?;
716+
}
717+
panel.pending_delete = false;
718+
refresh_history_panel(panel, storage)?;
719+
*needs_render = true;
720+
}
721+
Ok(None)
722+
}
723+
KeyCommand::HistoryDeleteCancel => {
724+
if let Some(panel) = state.history_panel.as_mut() {
725+
panel.pending_delete = false;
726+
*needs_render = true;
727+
}
728+
Ok(None)
729+
}
679730
KeyCommand::None => Ok(None),
680731
}
681732
}
@@ -813,10 +864,19 @@ pub(crate) enum KeyCommand {
813864
HistoryReverse,
814865
HistoryReplay,
815866
HistoryToggleFavorite,
867+
HistoryDelete,
868+
HistoryDeleteConfirm,
869+
HistoryDeleteCancel,
816870
}
817871

818872
fn handle_key_event(key: KeyEvent, state: &AppState) -> KeyCommand {
819-
if state.history_panel.is_some() {
873+
if let Some(panel) = &state.history_panel {
874+
if panel.pending_delete {
875+
return match key.code {
876+
KeyCode::Char('y') | KeyCode::Char('Y') => KeyCommand::HistoryDeleteConfirm,
877+
_ => KeyCommand::HistoryDeleteCancel,
878+
};
879+
}
820880
match (key.code, key.modifiers) {
821881
(KeyCode::Esc, _) | (KeyCode::Char('p'), _) => KeyCommand::ToggleHistory,
822882
(KeyCode::Char('j'), _) => KeyCommand::HistoryNext,
@@ -825,6 +885,7 @@ fn handle_key_event(key: KeyEvent, state: &AppState) -> KeyCommand {
825885
(KeyCode::Char('h'), _) => KeyCommand::HistorySortPrev,
826886
(KeyCode::Char('r'), _) => KeyCommand::HistoryReverse,
827887
(KeyCode::Char('s'), _) => KeyCommand::HistoryToggleFavorite,
888+
(KeyCode::Char('d'), _) if !panel.rows.is_empty() => KeyCommand::HistoryDelete,
828889
(KeyCode::Enter, _) => KeyCommand::HistoryReplay,
829890
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
830891
KeyCommand::Quit
@@ -853,13 +914,20 @@ fn handle_key_event(key: KeyEvent, state: &AppState) -> KeyCommand {
853914
}
854915

855916
fn handle_history_browser_key_event(key: KeyEvent, panel: &HistoryPanelState) -> KeyCommand {
917+
if panel.pending_delete {
918+
return match key.code {
919+
KeyCode::Char('y') | KeyCode::Char('Y') => KeyCommand::HistoryDeleteConfirm,
920+
_ => KeyCommand::HistoryDeleteCancel,
921+
};
922+
}
856923
match (key.code, key.modifiers) {
857924
(KeyCode::Char('j'), _) => KeyCommand::HistoryNext,
858925
(KeyCode::Char('k'), _) => KeyCommand::HistoryPrev,
859926
(KeyCode::Char('l'), _) => KeyCommand::HistorySortNext,
860927
(KeyCode::Char('h'), _) => KeyCommand::HistorySortPrev,
861928
(KeyCode::Char('r'), _) => KeyCommand::HistoryReverse,
862929
(KeyCode::Char('s'), _) if !panel.rows.is_empty() => KeyCommand::HistoryToggleFavorite,
930+
(KeyCode::Char('d'), _) if !panel.rows.is_empty() => KeyCommand::HistoryDelete,
863931
(KeyCode::Enter, _) if !panel.rows.is_empty() => KeyCommand::HistoryReplay,
864932
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => KeyCommand::Quit,
865933
_ => KeyCommand::None,
@@ -877,6 +945,7 @@ fn toggle_history_panel(state: &mut AppState, storage: &SharedStorage) -> Result
877945
selected: 0,
878946
sort_field: HistorySortField::TimePlayed,
879947
descending: true,
948+
pending_delete: false,
880949
};
881950
refresh_history_panel(&mut panel, storage)?;
882951
state.history_panel = Some(panel);
@@ -1767,6 +1836,7 @@ mod tests {
17671836
selected: 0,
17681837
sort_field: HistorySortField::TimePlayed,
17691838
descending: true,
1839+
pending_delete: false,
17701840
});
17711841

17721842
assert_eq!(
@@ -1825,6 +1895,7 @@ mod tests {
18251895
selected: 0,
18261896
sort_field: HistorySortField::TimePlayed,
18271897
descending: true,
1898+
pending_delete: false,
18281899
};
18291900

18301901
assert_eq!(
@@ -1850,6 +1921,7 @@ mod tests {
18501921
selected: 0,
18511922
sort_field: HistorySortField::TimePlayed,
18521923
descending: true,
1924+
pending_delete: false,
18531925
};
18541926

18551927
assert_eq!(
@@ -1860,4 +1932,82 @@ mod tests {
18601932
KeyCommand::None
18611933
);
18621934
}
1935+
1936+
fn panel_with_one_row(pending_delete: bool) -> HistoryPanelState {
1937+
HistoryPanelState {
1938+
rows: vec![HistoryRow {
1939+
track_key: "a".into(),
1940+
replay_target: "a".into(),
1941+
title: "A".into(),
1942+
platform: "Local".into(),
1943+
is_favorite: false,
1944+
play_count: 1,
1945+
total_play_seconds: 10,
1946+
first_played_at: 0,
1947+
last_played_at: 0,
1948+
last_played_computer: String::new(),
1949+
}],
1950+
selected: 0,
1951+
sort_field: HistorySortField::TimePlayed,
1952+
descending: true,
1953+
pending_delete,
1954+
}
1955+
}
1956+
1957+
#[test]
1958+
fn history_browser_arms_delete_with_d() {
1959+
let panel = panel_with_one_row(false);
1960+
assert_eq!(
1961+
handle_history_browser_key_event(
1962+
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
1963+
&panel
1964+
),
1965+
KeyCommand::HistoryDelete
1966+
);
1967+
}
1968+
1969+
#[test]
1970+
fn history_browser_ignores_delete_when_empty() {
1971+
let panel = HistoryPanelState {
1972+
rows: Vec::new(),
1973+
selected: 0,
1974+
sort_field: HistorySortField::TimePlayed,
1975+
descending: true,
1976+
pending_delete: false,
1977+
};
1978+
assert_eq!(
1979+
handle_history_browser_key_event(
1980+
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
1981+
&panel
1982+
),
1983+
KeyCommand::None
1984+
);
1985+
}
1986+
1987+
#[test]
1988+
fn pending_delete_routes_y_to_confirm_and_other_to_cancel() {
1989+
let panel = panel_with_one_row(true);
1990+
assert_eq!(
1991+
handle_history_browser_key_event(
1992+
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1993+
&panel
1994+
),
1995+
KeyCommand::HistoryDeleteConfirm
1996+
);
1997+
// Any non-`y` key cancels — including keys that normally do something.
1998+
assert_eq!(
1999+
handle_history_browser_key_event(
2000+
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
2001+
&panel
2002+
),
2003+
KeyCommand::HistoryDeleteCancel
2004+
);
2005+
assert_eq!(
2006+
handle_history_browser_key_event(
2007+
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
2008+
&panel
2009+
),
2010+
KeyCommand::HistoryDeleteCancel
2011+
);
2012+
}
18632013
}

src/tui.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ pub struct HistoryPanelState {
7575
pub selected: usize,
7676
pub sort_field: HistorySortField,
7777
pub descending: bool,
78+
/// When true, the selected row is awaiting a delete confirmation ([y]/[n]).
79+
pub pending_delete: bool,
7880
}
7981

8082
#[derive(Clone)]
@@ -1231,9 +1233,9 @@ fn draw_history_table(
12311233
if panel.descending { "↓" } else { "↑" }
12321234
);
12331235
let controls = if area == frame.area() {
1234-
" • j/k move h/l sort r reverse s star enter replay q quit"
1236+
" • j/k move h/l sort r reverse s star d delete enter replay q quit"
12351237
} else {
1236-
" • j/k move h/l sort r reverse s star enter replay p/esc close"
1238+
" • j/k move h/l sort r reverse s star d delete enter replay p/esc close"
12371239
};
12381240
let header = vec![Line::from(vec![
12391241
Span::styled(
@@ -1322,6 +1324,68 @@ fn draw_history_table(
13221324
)])]),
13231325
chunks[2],
13241326
);
1327+
1328+
if panel.pending_delete {
1329+
if let Some(row) = panel.rows.get(panel.selected) {
1330+
draw_delete_confirm(frame, area, &row.title);
1331+
}
1332+
}
1333+
}
1334+
1335+
/// Renders the delete-confirmation modal centered over the history panel. The
1336+
/// caller arms this via `HistoryPanelState::pending_delete`; `y` confirms and
1337+
/// any other key cancels (handled in the key router, not here).
1338+
fn draw_delete_confirm(frame: &mut ratatui::Frame, area: ratatui::layout::Rect, title: &str) {
1339+
let modal = centered_area(area, 60, 9);
1340+
frame.render_widget(Clear, modal);
1341+
let truncated = truncate_str(title, 54);
1342+
let lines = vec![
1343+
Line::from(vec![Span::styled(
1344+
"Remove this track from your history?",
1345+
Style::default()
1346+
.fg(Color::Rgb(255, 180, 80))
1347+
.add_modifier(Modifier::BOLD),
1348+
)]),
1349+
Line::from(""),
1350+
Line::from(vec![Span::styled(
1351+
truncated,
1352+
Style::default()
1353+
.fg(Color::Rgb(230, 230, 240))
1354+
.add_modifier(Modifier::BOLD),
1355+
)]),
1356+
Line::from(""),
1357+
Line::from(vec![
1358+
Span::styled(
1359+
"[y]",
1360+
Style::default()
1361+
.fg(Color::Rgb(255, 120, 100))
1362+
.add_modifier(Modifier::BOLD),
1363+
),
1364+
Span::styled(" delete ", Style::default().fg(Color::Rgb(150, 150, 170))),
1365+
Span::styled(
1366+
"[n]",
1367+
Style::default()
1368+
.fg(Color::Rgb(120, 200, 140))
1369+
.add_modifier(Modifier::BOLD),
1370+
),
1371+
Span::styled(" keep", Style::default().fg(Color::Rgb(150, 150, 170))),
1372+
]),
1373+
];
1374+
let block = Block::default()
1375+
.borders(Borders::ALL)
1376+
.title(" confirm delete ")
1377+
.style(Style::default().fg(Color::Rgb(170, 90, 90)));
1378+
frame.render_widget(Paragraph::new(lines).block(block), modal);
1379+
}
1380+
1381+
/// Truncates a string to `max` characters, appending an ellipsis when cut.
1382+
fn truncate_str(s: &str, max: usize) -> String {
1383+
if s.chars().count() <= max {
1384+
s.to_string()
1385+
} else {
1386+
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
1387+
format!("{kept}…")
1388+
}
13251389
}
13261390

13271391
fn centered_rect(
@@ -1429,6 +1493,7 @@ mod tests {
14291493
selected: 0,
14301494
sort_field: HistorySortField::TimePlayed,
14311495
descending: true,
1496+
pending_delete: false,
14321497
};
14331498
let warning = sample_warning();
14341499
terminal

0 commit comments

Comments
 (0)