Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion android/core/src/test/resources/json-fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,8 @@
"message": "Removed 3 tracks",
"actionLabel": "Undo",
"actionCommand": "{\"type\":\"undo\"}",
"durationMs": 5000
"durationMs": 5000,
"undoEntryId": null
}
}
},
Expand Down
1 change: 1 addition & 0 deletions crates/hocket-android/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ mod tests {
action_label: Some("Undo".into()),
action_command: Some(serde_json::to_string(&Command::Undo).unwrap()),
duration_ms: 5000,
undo_entry_id: None,
}
}),
);
Expand Down
7 changes: 7 additions & 0 deletions crates/hocket-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,10 @@ pub struct UndoEntry {
pub at: EpochMs,
/// e.g. "undid 487 of 500, 13 changed elsewhere"
pub note: Option<String>,
/// The action threw away the queue (played a new context, cleared it,
/// restored a saved queue), so undoing it is worth offering loudly.
#[serde(default)]
pub replaces_queue: bool,
}

#[typeshare]
Expand All @@ -1292,6 +1296,9 @@ pub struct Toast {
/// Command to dispatch when the button is pressed, JSON-encoded [`Command`].
pub action_command: Option<String>,
pub duration_ms: Ms,
/// Set on "Undid …" / "Redid …" toasts: the [`UndoEntry`] they report on.
#[serde(default)]
pub undo_entry_id: Option<String>,
}

// ---------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions crates/hocket-core/src/core/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,16 @@ impl Actor {
}

pub(crate) fn toast(&mut self, message: impl Into<String>, action: Option<(String, Command)>) {
self.toast_for(message, action, None);
}

/// A toast reporting on an undo entry (the "Undid …" / "Redid …" pair).
pub(crate) fn toast_for(
&mut self,
message: impl Into<String>,
action: Option<(String, Command)>,
undo_entry_id: Option<String>,
) {
let (action_label, action_command) = match action {
Some((l, c)) => (Some(l), serde_json::to_string(&c).ok()),
None => (None, None),
Expand All @@ -1089,6 +1099,7 @@ impl Actor {
action_label,
action_command,
duration_ms: 5_000,
undo_entry_id,
},
});
}
Expand Down
2 changes: 1 addition & 1 deletion crates/hocket-core/src/core/handlers/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ impl Actor {
} else {
("Redo".to_string(), Command::Redo)
};
self.toast(message, Some(action));
self.toast_for(message, Some(action), Some(entry_id.to_string()));
self.emit(Event::UndoChanged {
state: self.undo.state(),
});
Expand Down
6 changes: 5 additions & 1 deletion crates/hocket-core/src/core/handlers/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,11 @@ impl Actor {
("Redo".to_string(), Command::Redo)
};
if result.tier == UndoTier::SessionState {
self.toast(format!("{verb} {label}"), Some(action));
self.toast_for(
format!("{verb} {label}"),
Some(action),
Some(result.entry_id.clone()),
);
}
self.emit(Event::UndoChanged {
state: self.undo.state(),
Expand Down
9 changes: 9 additions & 0 deletions crates/hocket-core/src/undo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,19 @@ impl Entry {
device_id: self.device_id.clone(),
at: self.at,
note: self.note.clone(),
replaces_queue: replaces_queue(&self.kind),
}
}
}

/// Undo kinds whose action replaces or empties the whole queue.
pub fn replaces_queue(kind: &str) -> bool {
matches!(
kind,
"playContext" | "playTracks" | "clearQueue" | "restoreSavedQueue"
)
}

/// What the actor performs after `undo` / `redo`.
#[derive(Debug, Clone, PartialEq)]
pub struct UndoResult {
Expand Down
7 changes: 4 additions & 3 deletions desktop/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,12 @@ test.describe("axe: no violations anywhere", () => {
await expect(page.getByTestId("dialog-trackInfo").locator("dl")).toBeVisible();
await expectNoViolations(page, `${theme} track info dialog`);
await page.keyboard.press("Escape");
// A toast with an Undo action.
await page.getByTestId("shuffle").click();
// A toast with an Undo action (only queue-replacing actions get one).
await page.evaluate(() => window.hocket.dispatch({ type: "clearQueue" }));
await expect(page.getByTestId("toast").first()).toBeVisible();
await expectNoViolations(page, `${theme} toast`);
await page.getByTestId("shuffle").click();
await page.evaluate(() => window.hocket.dispatch({ type: "undo" }));
await expect(page.getByTestId("queue-row-current")).toHaveCount(1);
}
});

Expand Down
4 changes: 2 additions & 2 deletions desktop/e2e/backup.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Config backup round trip: an export with passwords resolved by main is
// enough to restore a second, fresh install straight from the setup screen.
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { completeSetup, expect, launchFake, stubDialogs, test } from "./fixtures";
Expand All @@ -24,7 +24,7 @@ test.describe("config backup", () => {
await stubDialogs(app, file);
await page.getByTestId("export-secrets").check();
await page.getByTestId("export-config").click();
await expect(page.getByTestId("toast").last()).toContainText("Configuration exported");
await expect.poll(() => existsSync(file)).toBe(true);
const doc = JSON.parse(readFileSync(file, "utf8")) as { secrets?: Record<string, string>; servers: { id: string; url: string }[] };
expect(doc.servers[0]?.url).toBe("https://music.example.org");
// Main resolved the keystore reference to the password entered at setup.
Expand Down
4 changes: 2 additions & 2 deletions desktop/e2e/motion-contrast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ test.describe("motion, contrast and zoom", () => {
await page.keyboard.press("ArrowDown");
await expectNoViolations(page, `${theme} accent ${accent}: menu`);
await page.keyboard.press("Escape");
// A toast's action is the accent on the inverse surface.
await page.getByTestId("shuffle").click();
// A toast's action is the accent on the inverse surface (replaying the album offers Undo).
await page.getByTestId("album-play").click();
await expect(page.getByTestId("toast-action").first()).toBeVisible();
await expectNoViolations(page, `${theme} accent ${accent}: toast`);
await page.getByTestId("shuffle").click();
Expand Down
8 changes: 4 additions & 4 deletions desktop/e2e/native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,13 @@ test.describe("real core against a fake Navidrome", () => {
// Rate the current track from the album table; the outbox reaches the server; undo reverts.
const row = page.getByTestId("track-row").first();
await row.locator(".stars .star").nth(3).click();
const toast = page.getByTestId("toast").last();
await expect(toast.getByTestId("toast-action")).toHaveText("Undo", { timeout: 10_000 });
await expect(row.locator(".stars .star.on")).toHaveCount(4, { timeout: 10_000 });
await expect.poll(() => server.callsTo("setRating").length, { timeout: 15_000 }).toBeGreaterThan(0);
await toast.getByTestId("toast-action").click();
// Rating leaves the queue alone, so no toast: undo from the keyboard.
await expect(page.getByTestId("toast")).toHaveCount(0);
await page.getByTestId("content").click();
await page.keyboard.press("Control+z");
await expect(row.locator(".stars .star.on")).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByTestId("toast").last()).toContainText("Undid");

// Lyrics from getLyricsBySongId render (line tier) in the right panel.
await expect(page.getByTestId("lyrics-view")).toBeVisible({ timeout: 20_000 });
Expand Down
18 changes: 12 additions & 6 deletions desktop/e2e/undo.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import { completeSetup, expect, playFirstAlbum, test } from "./fixtures";

test.describe("undo", () => {
test("a queue mutation shows a toast with the single Undo action and Ctrl+Z undoes it", async ({ hocket }) => {
test("replacing the queue shows a toast with the single Undo action; other mutations stay quiet", async ({ hocket }) => {
const { page } = hocket;
await completeSetup(page);
await playFirstAlbum(page);
await expect(page.getByTestId("queue-row-current")).toHaveCount(1);
await page.getByTestId("shuffle").click();
const toast = page.getByTestId("toast").last();
await expect(toast).toContainText("Shuffle on");
await expect(toast).toContainText("Play");
await expect(toast.getByTestId("toast-action")).toHaveText("Undo");
await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "true");
await toast.getByTestId("toast-action").click();
await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("queue-row-current")).toHaveCount(0);
await expect(page.getByTestId("toast").last()).toContainText("Undone");
// Keyboard redo/undo.
await page.getByTestId("toast-action").last().click();
await expect(page.getByTestId("queue-row-current")).toHaveCount(1);
// Shuffle leaves the queue's contents alone: no toast, but still undoable.
await page.getByTestId("shuffle").click();
await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("toasts")).not.toContainText("Shuffle");
// Keyboard undo/redo.
await page.getByTestId("content").click();
await page.keyboard.press("Control+z");
await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "false");
await page.keyboard.press("Control+Shift+z");
await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Control+z");
Expand Down
37 changes: 19 additions & 18 deletions desktop/src/main/fake-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ interface UndoRecord {
id: string;
label: string;
at: number;
/** Like the core's `replacesQueue`: the action threw the queue away. */
replacesQueue: boolean;
undo: () => string | undefined;
redo: () => void;
selection?: ActionTarget;
Expand Down Expand Up @@ -341,7 +343,7 @@ export class FakeCore implements CoreHandle {
this.withUndo("Clear queue", () => {
this.session = { ...this.session, context: undefined, order: [], cursor: 0, current: undefined, insertions: [], history: [] };
this.stop();
});
}, true);
return;
case "clearInsertions":
this.withUndo("Clear playing next", () => {
Expand Down Expand Up @@ -766,7 +768,7 @@ export class FakeCore implements CoreHandle {
if (!silent && username.toLowerCase() === "wrong") {
// Like the core: a failed probe is Error + toast only; no ServersChanged, no job, no problem.
this.later(600, () => {
this.toast("Couldn't reach the server: authentication failed: Wrong username or password", false);
this.toast("Couldn't reach the server: authentication failed: Wrong username or password");
this.emit({ type: "error", data: { kind: "auth", message: "server probe failed", detail: "authentication failed: Wrong username or password" } });
});
return;
Expand Down Expand Up @@ -900,7 +902,7 @@ export class FakeCore implements CoreHandle {
this.restoreSession(before);
this.seek(outgoingPos);
return undefined;
}, () => this.playContext(resolved, startIndex, shuffle, false));
}, () => this.playContext(resolved, startIndex, shuffle, false), undefined, true);
this.emitAll();
}

Expand All @@ -919,7 +921,6 @@ export class FakeCore implements CoreHandle {
}
}
});
this.toast(next ? "Playing next" : "Added to queue", true);
}

private jumpTo(key: string): void {
Expand Down Expand Up @@ -1356,7 +1357,7 @@ export class FakeCore implements CoreHandle {
this.pushUndo(`Restore ${q.label}`, () => {
this.restoreSession(before);
return undefined;
}, () => this.restoreSavedQueue(id));
}, () => this.restoreSavedQueue(id), undefined, true);
this.emitAll();
}

Expand Down Expand Up @@ -1384,7 +1385,7 @@ export class FakeCore implements CoreHandle {
this.emitAll();
}

private withUndo(label: string, mutate: () => void): void {
private withUndo(label: string, mutate: () => void, replacesQueue = false): void {
const before = this.captureSession();
const selection = clone(this.selection);
mutate();
Expand All @@ -1393,19 +1394,19 @@ export class FakeCore implements CoreHandle {
this.pushUndo(label, () => {
this.restoreSession(before);
return undefined;
}, () => this.restoreSession(after), selection);
}, () => this.restoreSession(after), selection, replacesQueue);
this.emitAll();
}

private pushUndo(label: string, undo: () => string | undefined, redo: () => void, selection?: ActionTarget): void {
private pushUndo(label: string, undo: () => string | undefined, redo: () => void, selection?: ActionTarget, replacesQueue = false): void {
const now = Date.now();
const top = this.undoStack[this.undoStack.length - 1];
// Coalesce rapid repeats of the same label (dragging a rating, holding a key).
if (top && top.label === label && now - top.at < 800) {
if (top && top.label === label && top.replacesQueue === replacesQueue && now - top.at < 800) {
top.redo = redo;
top.at = now;
} else {
this.undoStack.push({ id: newId("undo"), label, at: now, undo, redo, selection });
this.undoStack.push({ id: newId("undo"), label, at: now, replacesQueue, undo, redo, selection });
if (this.undoStack.length > 200) this.undoStack.shift();
}
this.redoStack = [];
Expand All @@ -1419,7 +1420,7 @@ export class FakeCore implements CoreHandle {
if (rec.selection) this.selection = rec.selection;
this.redoStack.push(rec);
this.emitUndo();
this.emit({ type: "toast", data: { toast: { id: newId("toast"), message: note ? `Undone: ${rec.label} (${note})` : `Undone: ${rec.label}`, actionLabel: "Redo", actionCommand: JSON.stringify({ type: "redo" } satisfies Command), durationMs: 5000 } } });
this.emit({ type: "toast", data: { toast: { id: newId("toast"), message: note ? `Undone: ${rec.label} (${note})` : `Undone: ${rec.label}`, actionLabel: "Redo", actionCommand: JSON.stringify({ type: "redo" } satisfies Command), durationMs: 5000, undoEntryId: rec.id } } });
}

private redo(): void {
Expand All @@ -1428,18 +1429,18 @@ export class FakeCore implements CoreHandle {
rec.redo();
this.undoStack.push(rec);
this.emitUndo();
this.emit({ type: "toast", data: { toast: { id: newId("toast"), message: `Redone: ${rec.label}`, actionLabel: "Undo", actionCommand: JSON.stringify({ type: "undo" } satisfies Command), durationMs: 5000 } } });
this.emit({ type: "toast", data: { toast: { id: newId("toast"), message: `Redone: ${rec.label}`, actionLabel: "Undo", actionCommand: JSON.stringify({ type: "undo" } satisfies Command), durationMs: 5000, undoEntryId: rec.id } } });
}

private undoState(): UndoState {
const top = this.undoStack[this.undoStack.length - 1];
const rtop = this.redoStack[this.redoStack.length - 1];
const history: UndoEntry[] = [...this.undoStack].reverse().slice(0, 20).map((u) => ({ id: u.id, label: u.label, deviceId: this.config.deviceId, at: u.at, note: undefined }));
const history: UndoEntry[] = [...this.undoStack].reverse().slice(0, 20).map((u) => ({ id: u.id, label: u.label, deviceId: this.config.deviceId, at: u.at, note: undefined, replacesQueue: u.replacesQueue }));
return { canUndo: !!top, undoLabel: top?.label, canRedo: !!rtop, redoLabel: rtop?.label, history };
}

private toast(message: string, undoable: boolean): void {
const toast: Toast = { id: newId("toast"), message, actionLabel: undoable ? "Undo" : undefined, actionCommand: undoable ? JSON.stringify({ type: "undo" } satisfies Command) : undefined, durationMs: 4000 };
private toast(message: string): void {
const toast: Toast = { id: newId("toast"), message, actionLabel: undefined, actionCommand: undefined, durationMs: 4000 };
this.emit({ type: "toast", data: { toast } });
}

Expand Down Expand Up @@ -1538,7 +1539,7 @@ export class FakeCore implements CoreHandle {
trackIds: [...trackIds],
});
this.libraryChanged("playlists", [id]);
this.toast(`Created playlist ${name}`, false);
this.toast(`Created playlist ${name}`);
}

private playlistAdd(playlistId: string, trackIds: string[], atIndex: number | undefined): void {
Expand Down Expand Up @@ -1880,7 +1881,7 @@ export class FakeCore implements CoreHandle {
this.emit({ type: "filtersChanged", data: { filters: this.filters } });
this.emit({ type: "shortcutsChanged", data: { shortcuts: this.shortcuts() } });
this.emit({ type: "audioSettingsChanged", data: { settings: this.audio } });
this.toast("Configuration imported", false);
this.toast("Configuration imported");
} catch (err) {
this.emit({ type: "error", data: { kind: "storage", message: "Couldn't import the configuration document", detail: String(err) } });
}
Expand All @@ -1897,7 +1898,7 @@ export class FakeCore implements CoreHandle {
this.emit({ type: "devicesChanged", data: { devices: this.devices } });
this.emitTransport();
this.emitMediaSession();
this.toast(`Playing on ${target.name}`, false);
this.toast(`Playing on ${target.name}`);
}

private resumeHere(): void {
Expand Down
5 changes: 1 addition & 4 deletions desktop/src/renderer/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,7 @@ export async function executeAction(rawId: string, target: ActionTarget = { type
return;
case "copyDiagnostics": {
const r = await b.query({ type: "diagnostics" });
if (r.type === "text") {
b.clipboard.writeText(r.data);
app.applyEvent({ type: "toast", data: { toast: { id: `diag-${Date.now()}`, message: t("settings.diagnosticsCopied"), actionLabel: undefined, actionCommand: undefined, durationMs: 3000 } } });
}
if (r.type === "text") b.clipboard.writeText(r.data);
return;
}
case "goToAlbum": {
Expand Down
Loading
Loading