feat(app): clear all events from the Event Center (red trash icon)
Adds Cmd::ClearEvents + Evt::EventsCleared: the daemon drops the persistent event log (keeping next_id monotonic), persists, and broadcasts so every client empties its list. A red trash icon next to 'Mark all read' triggers it; disabled when the list is empty. Threaded through proto, the daemon handler, the Tauri bridge, and socketBridge. Includes an EventLog::clear test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -430,6 +430,11 @@ pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, S
|
|||||||
data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
|
data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_events(state: BridgeState<'_>) -> Result<Value, String> {
|
||||||
|
data_of(state.request(Cmd::ClearEvents).await.map_err(|e| e.to_string())?)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
|
pub async fn health(state: BridgeState<'_>) -> Result<Value, String> {
|
||||||
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
|
data_of(state.request(Cmd::Health).await.map_err(|e| e.to_string())?)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub fn run() {
|
|||||||
bridge::set_zoom,
|
bridge::set_zoom,
|
||||||
bridge::event_log,
|
bridge::event_log,
|
||||||
bridge::mark_read,
|
bridge::mark_read,
|
||||||
|
bridge::clear_events,
|
||||||
bridge::health,
|
bridge::health,
|
||||||
bridge::get_config,
|
bridge::get_config,
|
||||||
bridge::set_config,
|
bridge::set_config,
|
||||||
|
|||||||
+4
-1
@@ -9,7 +9,7 @@ import { Settings } from "./Settings";
|
|||||||
import { EventCenter } from "./EventCenter";
|
import { EventCenter } from "./EventCenter";
|
||||||
import { maybeNotify } from "./notify";
|
import { maybeNotify } from "./notify";
|
||||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
||||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
|
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
|
||||||
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
|
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
|
||||||
import { leafIds } from "./layoutTypes";
|
import { leafIds } from "./layoutTypes";
|
||||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||||
@@ -95,6 +95,8 @@ export function App() {
|
|||||||
} else if (evt.evt === "events_read") {
|
} else if (evt.evt === "events_read") {
|
||||||
const ids = new Set(evt.data.ids);
|
const ids = new Set(evt.data.ids);
|
||||||
setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e)));
|
setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e)));
|
||||||
|
} else if (evt.evt === "events_cleared") {
|
||||||
|
setEvents([]);
|
||||||
} else if (evt.evt === "state") {
|
} else if (evt.evt === "state") {
|
||||||
setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
|
setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -177,6 +179,7 @@ export function App() {
|
|||||||
<EventCenter
|
<EventCenter
|
||||||
events={events}
|
events={events}
|
||||||
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
|
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
|
||||||
|
onClear={() => { void clearEvents(); }}
|
||||||
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
|
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-3
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Check, Hourglass, X, Power, Send, MessageSquare } from "lucide-react";
|
import { Check, Hourglass, X, Power, Send, MessageSquare, Trash2 } from "lucide-react";
|
||||||
import { COLORS, FONT } from "./theme";
|
import { COLORS, FONT } from "./theme";
|
||||||
import type { EventRecord } from "./socketBridge";
|
import type { EventRecord } from "./socketBridge";
|
||||||
|
|
||||||
@@ -26,10 +26,11 @@ function rel(ts: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EventCenter({
|
export function EventCenter({
|
||||||
events, onMarkAllRead, onSelect,
|
events, onMarkAllRead, onClear, onSelect,
|
||||||
}: {
|
}: {
|
||||||
events: EventRecord[];
|
events: EventRecord[];
|
||||||
onMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
|
onClear: () => void;
|
||||||
onSelect: (surfaceId: string, id: number) => void;
|
onSelect: (surfaceId: string, id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("all");
|
const [tab, setTab] = useState<Tab>("all");
|
||||||
@@ -41,7 +42,14 @@ export function EventCenter({
|
|||||||
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
|
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
|
||||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
||||||
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
|
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer", marginRight: 10 }}>Mark all read</span>
|
||||||
|
<span
|
||||||
|
title="Clear all events"
|
||||||
|
onClick={() => { if (events.length) onClear(); }}
|
||||||
|
style={{ display: "flex", cursor: events.length ? "pointer" : "default", opacity: events.length ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} color={COLORS.stError} aria-label="Clear all" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export async function markEventsRead(target: MarkReadTarget): Promise<void> {
|
|||||||
await invoke("mark_read", { target });
|
await invoke("mark_read", { target });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearEvents(): Promise<void> {
|
||||||
|
await invoke("clear_events");
|
||||||
|
}
|
||||||
|
|
||||||
export type DaemonEvt =
|
export type DaemonEvt =
|
||||||
| { evt: "exit"; data: { surface_id: string; code: number } }
|
| { evt: "exit"; data: { surface_id: string; code: number } }
|
||||||
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
||||||
@@ -96,7 +100,8 @@ export type DaemonEvt =
|
|||||||
| { evt: "groups_changed"; data: unknown }
|
| { evt: "groups_changed"; data: unknown }
|
||||||
| { evt: "config_changed"; data: { config: ConfigView } }
|
| { evt: "config_changed"; data: { config: ConfigView } }
|
||||||
| { evt: "event"; data: { record: EventRecord } }
|
| { evt: "event"; data: { record: EventRecord } }
|
||||||
| { evt: "events_read"; data: { ids: number[] } };
|
| { evt: "events_read"; data: { ids: number[] } }
|
||||||
|
| { evt: "events_cleared"; data: unknown };
|
||||||
|
|
||||||
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
||||||
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ pub enum Cmd {
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
},
|
},
|
||||||
MarkRead { target: MarkReadTarget },
|
MarkRead { target: MarkReadTarget },
|
||||||
|
ClearEvents,
|
||||||
SetZoom {
|
SetZoom {
|
||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -163,6 +164,7 @@ pub enum Evt {
|
|||||||
State { surface_id: SurfaceId, state: SurfaceState },
|
State { surface_id: SurfaceId, state: SurfaceState },
|
||||||
Event { record: EventRecord },
|
Event { record: EventRecord },
|
||||||
EventsRead { ids: Vec<u64> },
|
EventsRead { ids: Vec<u64> },
|
||||||
|
EventsCleared,
|
||||||
ConfigChanged { config: crate::config_view::ConfigView },
|
ConfigChanged { config: crate::config_view::ConfigView },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ impl EventLog {
|
|||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop all records. `next_id` stays monotonic so ids are never reused.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.records.clear();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unread_count(&self) -> u32 {
|
pub fn unread_count(&self) -> u32 {
|
||||||
self.records.iter().filter(|r| !r.read).count() as u32
|
self.records.iter().filter(|r| !r.read).count() as u32
|
||||||
}
|
}
|
||||||
@@ -164,6 +169,18 @@ mod tests {
|
|||||||
assert!(log.mark_read(&MarkReadTarget::All).is_empty());
|
assert!(log.mark_read(&MarkReadTarget::All).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_drops_records_but_keeps_next_id() {
|
||||||
|
let mut log = EventLog::new(10);
|
||||||
|
rec(&mut log, "s_1", EventKind::Done);
|
||||||
|
rec(&mut log, "s_2", EventKind::Done);
|
||||||
|
log.clear();
|
||||||
|
assert_eq!(log.recent(None).len(), 0);
|
||||||
|
assert_eq!(log.unread_count(), 0);
|
||||||
|
// ids continue from where they were — no reuse after a clear.
|
||||||
|
assert_eq!(rec(&mut log, "s_3", EventKind::Done).id, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_restore_preserves_next_id_and_records() {
|
fn snapshot_restore_preserves_next_id_and_records() {
|
||||||
let mut log = EventLog::new(10);
|
let mut log = EventLog::new(10);
|
||||||
|
|||||||
@@ -648,6 +648,13 @@ async fn handle_request(
|
|||||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Cmd::ClearEvents => {
|
||||||
|
event_log.clear();
|
||||||
|
event_persister.mark_dirty(event_log.snapshot());
|
||||||
|
broadcast_evt(clients, &Envelope::Evt(Evt::EventsCleared));
|
||||||
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Shutdown => {
|
Cmd::Shutdown => {
|
||||||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user