From 3ac274b30140a39ec9e5ae9b35fead26f2986851 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 16 Oct 2026 09:32:43 -0300 Subject: [PATCH 1/4] feat(welcome): show a ready card with history stats on launch and keep the help on top when reopened --- app/src-tauri/capabilities/default.json | 3 +- app/src-tauri/src/lib.rs | 5 +- app/src-tauri/src/looks.rs | 2 +- app/src-tauri/src/ready.rs | 103 ++++++++++++++ app/src-tauri/src/ready_test.rs | 33 +++++ app/src-tauri/src/welcome.rs | 5 + app/src/index.css | 165 +++++++++++++++++++++++ app/src/locales.ts | 22 +++ app/src/main.tsx | 9 +- app/src/tests/ready.test.tsx | 76 +++++++++++ app/src/tests/setup.ts | 4 + app/src/ui/Ready.tsx | 152 +++++++++++++++++++++ crates/cp-store/src/lib.rs | 4 +- crates/cp-store/src/store.rs | 9 ++ crates/cp-store/src/store_glance.rs | 28 ++++ crates/cp-store/src/store_glance_test.rs | 38 ++++++ 16 files changed, 652 insertions(+), 6 deletions(-) create mode 100644 app/src-tauri/src/ready.rs create mode 100644 app/src-tauri/src/ready_test.rs create mode 100644 app/src/tests/ready.test.tsx create mode 100644 app/src/ui/Ready.tsx create mode 100644 crates/cp-store/src/store_glance.rs create mode 100644 crates/cp-store/src/store_glance_test.rs diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index c653017a..f0115efa 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -4,7 +4,8 @@ "description": "Lo que la ventana de ajustes necesita y nada más", "windows": [ "main", - "welcome" + "welcome", + "ready" ], "permissions": [ "core:default", diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 692e4597..b9fef529 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod links; mod looks; mod note; mod panel; +mod ready; mod report; mod reviving; mod settings; @@ -62,7 +63,9 @@ pub fn run() { relabel, welcome::greeting, welcome::tour, - welcome::open_settings + welcome::open_settings, + ready::glance, + ready::show_panel ]) .manage(update::Installing::default()) .manage(backup::Crossing::default()) diff --git a/app/src-tauri/src/looks.rs b/app/src-tauri/src/looks.rs index 5e21bd2a..a8193be8 100644 --- a/app/src-tauri/src/looks.rs +++ b/app/src-tauri/src/looks.rs @@ -3,7 +3,7 @@ use tauri::{AppHandle, Manager, Runtime, Theme, WebviewWindowBuilder}; const LIGHT: Color = Color(0xF7, 0xF8, 0xFB, 0xFF); const DARK: Color = Color(0x1A, 0x1D, 0x2B, 0xFF); -const WINDOWS: [&str; 2] = ["main", "welcome"]; +const WINDOWS: [&str; 3] = ["main", "welcome", "ready"]; fn chosen() -> Option { match crate::settings::settings().map(|kept| kept.theme) { diff --git a/app/src-tauri/src/ready.rs b/app/src-tauri/src/ready.rs new file mode 100644 index 00000000..7a88422a --- /dev/null +++ b/app/src-tauri/src/ready.rs @@ -0,0 +1,103 @@ +use tauri::{ + AppHandle, Manager, PhysicalPosition, PhysicalRect, Runtime, WebviewUrl, WebviewWindowBuilder, +}; + +pub const LABEL: &str = "ready"; + +const WIDE: f64 = 360.0; +const TALL: f64 = 184.0; +const MARGIN: f64 = 12.0; + +#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct Glance { + items: i64, + today: i64, + pinned: i64, + bytes: i64, +} + +pub fn corner(area: PhysicalRect, scale: f64, top: bool) -> PhysicalPosition { + let wide = (WIDE * scale).round() as i32; + let tall = (TALL * scale).round() as i32; + let margin = (MARGIN * scale).round() as i32; + let right = area.position.x + area.size.width as i32; + let x = right - wide - margin; + let y = if top { + area.position.y + margin + } else { + area.position.y + area.size.height as i32 - tall - margin + }; + PhysicalPosition::new(x, y) +} + +pub fn open(app: &AppHandle) { + if app.get_webview_window(LABEL).is_some() { + return; + } + let built = crate::looks::dressed(WebviewWindowBuilder::new( + app, + LABEL, + WebviewUrl::App("index.html#ready".into()), + )) + .title("CoPanda") + .inner_size(WIDE, TALL) + .resizable(false) + .maximizable(false) + .minimizable(false) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .focused(false) + .visible(false) + .build(); + let window = match built { + Ok(window) => window, + Err(why) => { + crate::note::note(&format!("the ready card would not open: {why}")); + return; + } + }; + if let Ok(Some(screen)) = app.primary_monitor() { + let at = corner( + *screen.work_area(), + screen.scale_factor(), + cfg!(target_os = "macos"), + ); + let _ = window.set_position(at); + } + let _ = window.show(); +} + +pub fn close(app: &AppHandle) { + if let Some(window) = app.get_webview_window(LABEL) { + let _ = window.close(); + } +} + +#[tauri::command(async)] +pub fn glance(since: i64) -> Result { + let at = crate::settings::folder() + .ok_or_else(crate::settings::nowhere)? + .join("history.db"); + if !at.exists() { + return Ok(Glance::default()); + } + let store = cp_store::Store::open(&at).map_err(|why| why.to_string())?; + let seen = store.glance(since).map_err(|why| why.to_string())?; + Ok(Glance { + items: seen.items, + today: seen.today, + pinned: seen.pinned, + bytes: seen.bytes, + }) +} + +#[tauri::command] +pub async fn show_panel(app: AppHandle) { + close(&app); + crate::panel::show(&app); +} + +#[cfg(test)] +#[path = "ready_test.rs"] +mod tests; diff --git a/app/src-tauri/src/ready_test.rs b/app/src-tauri/src/ready_test.rs new file mode 100644 index 00000000..2ed0964e --- /dev/null +++ b/app/src-tauri/src/ready_test.rs @@ -0,0 +1,33 @@ +use super::corner; +use tauri::{PhysicalPosition, PhysicalRect, PhysicalSize}; + +fn screen(x: i32, y: i32, width: u32, height: u32) -> PhysicalRect { + PhysicalRect { + position: PhysicalPosition::new(x, y), + size: PhysicalSize::new(width, height), + } +} + +#[test] +fn on_windows_it_sits_over_the_tray() { + let at = corner(screen(0, 0, 1920, 1032), 1.0, false); + assert_eq!(at, PhysicalPosition::new(1920 - 360 - 12, 1032 - 184 - 12)); +} + +#[test] +fn on_a_mac_it_hangs_under_the_menu_bar() { + let at = corner(screen(0, 25, 1440, 875), 1.0, true); + assert_eq!(at, PhysicalPosition::new(1440 - 360 - 12, 25 + 12)); +} + +#[test] +fn a_taskbar_on_top_still_leaves_it_inside_the_free_space() { + let at = corner(screen(0, 48, 1920, 1032), 1.0, false); + assert_eq!(at.y + 184 + 12, 48 + 1032); +} + +#[test] +fn it_grows_with_the_screen_scale() { + let at = corner(screen(0, 0, 3840, 2088), 2.0, false); + assert_eq!(at, PhysicalPosition::new(3840 - 720 - 24, 2088 - 368 - 24)); +} diff --git a/app/src-tauri/src/welcome.rs b/app/src-tauri/src/welcome.rs index 999b1186..f438a95f 100644 --- a/app/src-tauri/src/welcome.rs +++ b/app/src-tauri/src/welcome.rs @@ -106,6 +106,7 @@ pub fn raise(app: &AppHandle, fresh: bool) { if behind(welcomed.as_deref(), &running) { mark(app); } + crate::ready::open(app); } fn mark(app: &AppHandle) { @@ -187,7 +188,11 @@ pub fn reopened(app: &AppHandle) { if app.get_webview_window(LABEL).is_none() { hold(app, Greeting::Keys); } + crate::ready::close(app); open(app); + if let Some(window) = app.get_webview_window(LABEL) { + let _ = window.set_always_on_top(true); + } } #[tauri::command] diff --git a/app/src/index.css b/app/src/index.css index 38a0deac..9c406c2b 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -1854,3 +1854,168 @@ button:disabled { display: flex; justify-content: flex-end; } + +.ready { + height: 100%; + display: flex; + flex-direction: column; + background: var(--card); + color: var(--ink); + overflow: hidden; + user-select: none; +} + +.ready-top { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 10px 10px 14px; +} + +.ready-mark { + width: 40px; + height: 36px; +} + +.ready-who { + display: flex; + flex-direction: column; + gap: 2px; + margin-right: auto; +} + +.ready-who b { + font-size: var(--fs-lead); +} + +.ready-who span { + display: flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: var(--fs-small); +} + +.ready-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--good); + box-shadow: 0 0 0 3px var(--good-soft); +} + +.ready-knob { + align-self: flex-start; + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--r-inset); + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.ready-knob:hover { + background: var(--tile); + color: var(--ink); +} + +.ready-knob svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ready-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin: 0; + padding: 0 14px; +} + +.ready-stats div { + background: var(--tile); + border-radius: var(--r-inset); + padding: 8px 4px; + text-align: center; +} + +.ready-stats dt { + font-size: 16px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.ready-stats dd { + margin: 0; + color: var(--muted); + font-size: var(--fs-caption); +} + +.ready-empty { + margin: 0; + padding: 0 14px; + color: var(--muted); + line-height: 1.45; +} + +.ready-foot { + display: flex; + align-items: center; + gap: 6px; + margin-top: auto; + padding: 0 14px 12px; + color: var(--muted); + font-size: var(--fs-small); +} + +.ready-cap { + display: flex; + align-items: center; + gap: 6px; +} + +.ready-cap kbd { + font: var(--fs-caption) var(--mono); + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--hair); + border-radius: 5px; + padding: 2px 6px; +} + +.ready-show { + margin-left: auto; +} + +.ready-bar { + height: 3px; + background: var(--tile); +} + +.ready-bar i { + display: block; + height: 100%; + background: var(--accent); + animation: ready-runs linear forwards; +} + +.ready.held .ready-bar i { + animation-play-state: paused; +} + +@keyframes ready-runs { + from { + width: 100%; + } + to { + width: 0; + } +} diff --git a/app/src/locales.ts b/app/src/locales.ts index 9d784f76..b1b90b87 100644 --- a/app/src/locales.ts +++ b/app/src/locales.ts @@ -48,6 +48,17 @@ const ES = { chromeMinimise: "Minimizar", chromeClose: "Cerrar", + readyTitle: "CoPanda está listo", + readyWatching: "Cuidando tu portapapeles", + readySaved: "guardados", + readyToday: "hoy", + readyPinned: "fijados", + readyOnDisk: "en disco", + readyEmpty: "Todavía no hay nada guardado. Copia algo y aparecerá aquí.", + readyOpenWith: "Ábrelo con", + readyShow: "Ver historial", + readySettings: "Ajustes", + bandTongue: "Idioma", bandDoes: "Comportamiento", tongue: "Idioma", @@ -388,6 +399,17 @@ const EN: Record = { chromeMinimise: "Minimise", chromeClose: "Close", + readyTitle: "CoPanda is ready", + readyWatching: "Looking after your clipboard", + readySaved: "saved", + readyToday: "today", + readyPinned: "pinned", + readyOnDisk: "on disk", + readyEmpty: "Nothing saved yet. Copy something and it will show up here.", + readyOpenWith: "Open it with", + readyShow: "Show history", + readySettings: "Settings", + bandTongue: "Language", bandDoes: "Behaviour", tongue: "Language", diff --git a/app/src/main.tsx b/app/src/main.tsx index 18d7fb91..8fb325e6 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -2,9 +2,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { onMac, reachable } from "./core"; +import Ready from "./ui/Ready"; import Welcome from "./ui/Welcome"; import "./index.css"; +const PAGES: Record React.JSX.Element | null> = { + "#welcome": Welcome, + "#ready": Ready, +}; +const Page = PAGES[window.location.hash] ?? App; + const root = document.getElementById("root"); if (root) { @@ -14,7 +21,7 @@ if (root) { } ReactDOM.createRoot(root).render( - {window.location.hash === "#welcome" ? : } + , ); } diff --git a/app/src/tests/ready.test.tsx b/app/src/tests/ready.test.tsx new file mode 100644 index 00000000..a276d5bc --- /dev/null +++ b/app/src/tests/ready.test.tsx @@ -0,0 +1,76 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { adopt } from "../locales"; +import Ready, { LASTS_MS, midnight } from "../ui/Ready"; +import { scene, theWindow } from "./setup"; + +async function asked() { + const { invoke } = await import("@tauri-apps/api/core"); + return vi.mocked(invoke); +} + +describe("the ready card", () => { + afterEach(() => { + vi.useRealTimers(); + theWindow.close.mockClear(); + }); + + it("tells what the history holds", async () => { + adopt("es"); + scene.glance = { items: 12480, today: 37, pinned: 18, bytes: 84 * 1024 ** 2 }; + render(); + expect(await screen.findByText("CoPanda está listo")).toBeInTheDocument(); + expect(screen.getByText("12.480")).toBeInTheDocument(); + expect(screen.getByText("37")).toBeInTheDocument(); + expect(screen.getByText("18")).toBeInTheDocument(); + expect(screen.getByText("84 MB")).toBeInTheDocument(); + expect(screen.getByText("Ctrl")).toBeInTheDocument(); + }); + + it("invites to copy something when the history is empty", async () => { + adopt("es"); + render(); + expect(await screen.findByText(/Todavía no hay nada guardado/)).toBeInTheDocument(); + expect(screen.queryByText("guardados")).toBeNull(); + }); + + it("counts today from local midnight", async () => { + adopt("es"); + render(); + await screen.findByText("CoPanda está listo"); + expect(await asked()).toHaveBeenCalledWith("glance", { since: midnight() }); + }); + + it("opens the history or the settings and goes away", async () => { + adopt("es"); + const who = userEvent.setup(); + render(); + await who.click(await screen.findByRole("button", { name: "Ver historial" })); + expect(await asked()).toHaveBeenCalledWith("show_panel"); + await who.click(screen.getByRole("button", { name: "Ajustes" })); + expect(await asked()).toHaveBeenCalledWith("open_settings", { rail: "general" }); + expect(theWindow.close).toHaveBeenCalled(); + }); + + it("leaves on its own, but waits while the mouse is over it", async () => { + adopt("es"); + vi.useFakeTimers({ shouldAdvanceTime: true }); + render(); + await screen.findByText("CoPanda está listo"); + const card = document.documentElement; + fireEvent.mouseEnter(card); + act(() => vi.advanceTimersByTime(LASTS_MS * 2)); + expect(theWindow.close).not.toHaveBeenCalled(); + fireEvent.mouseLeave(card); + act(() => vi.advanceTimersByTime(LASTS_MS)); + expect(theWindow.close).toHaveBeenCalled(); + }); +}); + +describe("midnight", () => { + it("is the start of the local day", () => { + const at = new Date(2026, 9, 16, 15, 42, 7); + expect(midnight(at)).toBe(new Date(2026, 9, 16).getTime()); + }); +}); diff --git a/app/src/tests/setup.ts b/app/src/tests/setup.ts index 2d9bde93..efe7d1ff 100644 --- a/app/src/tests/setup.ts +++ b/app/src/tests/setup.ts @@ -8,6 +8,7 @@ const asked = vi.hoisted(() => ({ locale: "es", trust: { offered: false, pastes: false, secureInput: false, clipboard: "allowed" }, usedBytes: 0, + glance: { items: 0, today: 0, pinned: 0, bytes: 0 }, })); vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -143,6 +144,8 @@ vi.mock("@tauri-apps/api/core", () => ({ return Promise.resolve(); } if (what === "greeting") return Promise.resolve(asked.greeting); + if (what === "glance") return Promise.resolve({ ...asked.glance }); + if (what === "show_panel") return Promise.resolve(null); if (what === "open_settings" || what === "tour") return Promise.resolve(null); return Promise.reject(new Error(`sin simular: ${what}`)); }), @@ -170,4 +173,5 @@ afterEach(() => { asked.locale = "es"; asked.trust = { offered: false, pastes: false, secureInput: false, clipboard: "allowed" }; asked.usedBytes = 0; + asked.glance = { items: 0, today: 0, pinned: 0, bytes: 0 }; }); diff --git a/app/src/ui/Ready.tsx b/app/src/ui/Ready.tsx new file mode 100644 index 00000000..08090559 --- /dev/null +++ b/app/src/ui/Ready.tsx @@ -0,0 +1,152 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect, useRef, useState } from "react"; +import { caps, onMac, useKept } from "../core"; +import { inEnglish, sized, t } from "../locales"; +import panda from "../panda.svg"; + +export const LASTS_MS = 6000; + +export type Glance = { items: number; today: number; pinned: number; bytes: number }; + +export function midnight(now = new Date()): number { + const day = new Date(now); + day.setHours(0, 0, 0, 0); + return day.getTime(); +} + +function leave() { + void getCurrentWindow().close(); +} + +function counted(value: number) { + return value.toLocaleString(inEnglish() ? "en" : "es"); +} + +export default function Ready() { + const { kept } = useKept(); + const [glance, setGlance] = useState(null); + const [held, setHeld] = useState(false); + const left = useRef(LASTS_MS); + const mac = onMac(); + + useEffect(() => { + invoke("glance", { since: midnight() }) + .then(setGlance) + .catch(() => setGlance({ items: 0, today: 0, pinned: 0, bytes: 0 })); + const heard = listen("panel-shown", leave); + return () => { + void heard.then((drop) => drop()); + }; + }, []); + + useEffect(() => { + const page = document.documentElement; + const hold = () => setHeld(true); + const release = () => setHeld(false); + page.addEventListener("mouseenter", hold); + page.addEventListener("mouseleave", release); + return () => { + page.removeEventListener("mouseenter", hold); + page.removeEventListener("mouseleave", release); + }; + }, []); + + useEffect(() => { + if (held || glance === null) { + return; + } + const started = Date.now(); + const timer = setTimeout(leave, left.current); + return () => { + clearTimeout(timer); + left.current -= Date.now() - started; + }; + }, [held, glance]); + + if (!kept || !glance) { + return null; + } + + return ( +
+
+ +
+ {t("readyTitle")} + + + {t("readyWatching")} + +
+ + +
+ {glance.items > 0 ? ( +
+ {( + [ + [counted(glance.items), t("readySaved")], + [counted(glance.today), t("readyToday")], + [counted(glance.pinned), t("readyPinned")], + [sized(glance.bytes), t("readyOnDisk")], + ] as const + ).map(([value, label]) => ( +
+
{value}
+
{label}
+
+ ))} +
+ ) : ( +

{t("readyEmpty")}

+ )} +
+ {t("readyOpenWith")} + {caps(kept.shortcut, mac).map((one, index) => ( + + {index > 0 && !mac && +} + {one} + + ))} + +
+
+ +
+
+ ); +} diff --git a/crates/cp-store/src/lib.rs b/crates/cp-store/src/lib.rs index 7049e5c6..d4c45100 100644 --- a/crates/cp-store/src/lib.rs +++ b/crates/cp-store/src/lib.rs @@ -16,8 +16,8 @@ pub use recover::{Opened, open_or_set_aside}; pub use schema::SCHEMA_VERSION; pub use store::restrict; pub use store::{ - A_DAY, AppCount, Broken, Cursor, Facet, Filter, FoundIn, Listed, More, Order, PREVIEW_CHARS, - PREVIEW_UP_TO, Page, Policy, Restricted, Snippet, Store, Swept, Usage, + A_DAY, AppCount, Broken, Cursor, Facet, Filter, FoundIn, Glance, Listed, More, Order, + PREVIEW_CHARS, PREVIEW_UP_TO, Page, Policy, Restricted, Snippet, Store, Swept, Usage, }; #[derive(Debug, thiserror::Error)] diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 2ac04a71..c8672a33 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -1431,6 +1431,11 @@ mod upkeep; pub use upkeep::{A_DAY, Policy, Swept}; +#[path = "store_glance.rs"] +mod glance; + +pub use glance::Glance; + #[cfg(test)] #[path = "store_test.rs"] mod tests; @@ -1446,3 +1451,7 @@ mod listing; #[cfg(test)] #[path = "store_housekeeping_test.rs"] mod housekeeping; + +#[cfg(test)] +#[path = "store_glance_test.rs"] +mod glancing; diff --git a/crates/cp-store/src/store_glance.rs b/crates/cp-store/src/store_glance.rs new file mode 100644 index 00000000..8aede7c2 --- /dev/null +++ b/crates/cp-store/src/store_glance.rs @@ -0,0 +1,28 @@ +use super::Store; +use crate::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Glance { + pub items: i64, + pub today: i64, + pub pinned: i64, + pub bytes: i64, +} + +impl Store { + pub fn glance(&self, since: i64) -> Result { + let usage = self.usage()?; + let (today, pinned): (i64, i64) = self.db.query_row( + "SELECT COALESCE(SUM(modified_at >= ?1), 0), COALESCE(SUM(pinned), 0) + FROM items WHERE deleted_at IS NULL", + [since], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok(Glance { + items: usage.items, + today, + pinned, + bytes: usage.bytes, + }) + } +} diff --git a/crates/cp-store/src/store_glance_test.rs b/crates/cp-store/src/store_glance_test.rs new file mode 100644 index 00000000..35965146 --- /dev/null +++ b/crates/cp-store/src/store_glance_test.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn an_empty_history_is_all_zeros() { + let store = Store::in_memory().expect("store"); + assert_eq!(store.glance(0).expect("glance"), Glance::default()); +} + +#[test] +fn today_counts_what_was_copied_or_copied_again_since_midnight() { + let store = Store::in_memory().expect("store"); + let old = store.insert_text("uuid-1", "yesterday", 100).expect("old"); + store + .insert_text("uuid-2", "this morning", 1_000) + .expect("new"); + store + .insert_text("uuid-3", "this afternoon", 1_100) + .expect("later"); + let gone = store + .insert_text("uuid-4", "deleted", 1_200) + .expect("deleted"); + store.mark_deleted(gone, 1_300).expect("marked"); + assert_eq!(store.glance(1_000).expect("glance").today, 2); + store.reactivate(old, 1_400).expect("again"); + let seen = store.glance(1_000).expect("glance"); + assert_eq!((seen.items, seen.today), (3, 3)); +} + +#[test] +fn pinned_leaves_out_what_was_deleted() { + let store = Store::in_memory().expect("store"); + let kept = store.insert_text("uuid-1", "kept", 1).expect("kept"); + let gone = store.insert_text("uuid-2", "gone", 2).expect("gone"); + store.set_pinned(kept, true, 3).expect("pinned"); + store.set_pinned(gone, true, 3).expect("pinned"); + store.mark_deleted(gone, 4).expect("deleted"); + assert_eq!(store.glance(0).expect("glance").pinned, 1); +} From 1a016795bd840181bafcfbfb6b62583b62e04ada Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 16 Oct 2026 09:52:44 -0300 Subject: [PATCH 2/4] feat(welcome): keep the ready card up for ten seconds --- app/src/ui/Ready.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/ui/Ready.tsx b/app/src/ui/Ready.tsx index 08090559..8aabf51a 100644 --- a/app/src/ui/Ready.tsx +++ b/app/src/ui/Ready.tsx @@ -6,7 +6,7 @@ import { caps, onMac, useKept } from "../core"; import { inEnglish, sized, t } from "../locales"; import panda from "../panda.svg"; -export const LASTS_MS = 6000; +export const LASTS_MS = 10000; export type Glance = { items: number; today: number; pinned: number; bytes: number }; From e2a2d5c19acdb707c0e2116cbe728872b9d48e96 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 16 Oct 2026 09:59:13 -0300 Subject: [PATCH 3/4] fix(welcome): ready card keeps focus away, hides stats it cannot read and unpins the help when settings open --- app/src-tauri/capabilities/default.json | 3 +- app/src-tauri/capabilities/ready.json | 12 +++++++ app/src-tauri/src/ready.rs | 46 +++++++++++-------------- app/src-tauri/src/ready_test.rs | 14 ++++---- app/src-tauri/src/tray.rs | 2 ++ app/src-tauri/src/welcome.rs | 6 ++++ app/src/tests/ready.test.tsx | 13 +++++++ app/src/ui/Ready.tsx | 19 ++++++---- 8 files changed, 74 insertions(+), 41 deletions(-) create mode 100644 app/src-tauri/capabilities/ready.json diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index f0115efa..c653017a 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -4,8 +4,7 @@ "description": "Lo que la ventana de ajustes necesita y nada más", "windows": [ "main", - "welcome", - "ready" + "welcome" ], "permissions": [ "core:default", diff --git a/app/src-tauri/capabilities/ready.json b/app/src-tauri/capabilities/ready.json new file mode 100644 index 00000000..bfbf439c --- /dev/null +++ b/app/src-tauri/capabilities/ready.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "ready", + "description": "Lo que el aviso de inicio necesita y nada más", + "windows": [ + "ready" + ], + "permissions": [ + "core:default", + "core:window:allow-close" + ] +} diff --git a/app/src-tauri/src/ready.rs b/app/src-tauri/src/ready.rs index 7a88422a..9d3585f4 100644 --- a/app/src-tauri/src/ready.rs +++ b/app/src-tauri/src/ready.rs @@ -1,5 +1,5 @@ use tauri::{ - AppHandle, Manager, PhysicalPosition, PhysicalRect, Runtime, WebviewUrl, WebviewWindowBuilder, + AppHandle, LogicalPosition, Manager, PhysicalRect, Runtime, WebviewUrl, WebviewWindowBuilder, }; pub const LABEL: &str = "ready"; @@ -16,24 +16,30 @@ pub struct Glance { bytes: i64, } -pub fn corner(area: PhysicalRect, scale: f64, top: bool) -> PhysicalPosition { - let wide = (WIDE * scale).round() as i32; - let tall = (TALL * scale).round() as i32; - let margin = (MARGIN * scale).round() as i32; - let right = area.position.x + area.size.width as i32; - let x = right - wide - margin; +pub fn corner(area: PhysicalRect, scale: f64, top: bool) -> LogicalPosition { + let left = f64::from(area.position.x) / scale; + let up = f64::from(area.position.y) / scale; + let x = left + f64::from(area.size.width) / scale - WIDE - MARGIN; let y = if top { - area.position.y + margin + up + MARGIN } else { - area.position.y + area.size.height as i32 - tall - margin + up + f64::from(area.size.height) / scale - TALL - MARGIN }; - PhysicalPosition::new(x, y) + LogicalPosition::new(x, y) } pub fn open(app: &AppHandle) { if app.get_webview_window(LABEL).is_some() { return; } + let Ok(Some(screen)) = app.primary_monitor() else { + return; + }; + let at = corner( + *screen.work_area(), + screen.scale_factor(), + cfg!(target_os = "macos"), + ); let built = crate::looks::dressed(WebviewWindowBuilder::new( app, LABEL, @@ -41,6 +47,7 @@ pub fn open(app: &AppHandle) { )) .title("CoPanda") .inner_size(WIDE, TALL) + .position(at.x, at.y) .resizable(false) .maximizable(false) .minimizable(false) @@ -48,24 +55,11 @@ pub fn open(app: &AppHandle) { .always_on_top(true) .skip_taskbar(true) .focused(false) - .visible(false) + .accept_first_mouse(true) .build(); - let window = match built { - Ok(window) => window, - Err(why) => { - crate::note::note(&format!("the ready card would not open: {why}")); - return; - } - }; - if let Ok(Some(screen)) = app.primary_monitor() { - let at = corner( - *screen.work_area(), - screen.scale_factor(), - cfg!(target_os = "macos"), - ); - let _ = window.set_position(at); + if let Err(why) = built { + crate::note::note(&format!("the ready card would not open: {why}")); } - let _ = window.show(); } pub fn close(app: &AppHandle) { diff --git a/app/src-tauri/src/ready_test.rs b/app/src-tauri/src/ready_test.rs index 2ed0964e..77e4d235 100644 --- a/app/src-tauri/src/ready_test.rs +++ b/app/src-tauri/src/ready_test.rs @@ -1,5 +1,5 @@ use super::corner; -use tauri::{PhysicalPosition, PhysicalRect, PhysicalSize}; +use tauri::{LogicalPosition, PhysicalPosition, PhysicalRect, PhysicalSize}; fn screen(x: i32, y: i32, width: u32, height: u32) -> PhysicalRect { PhysicalRect { @@ -11,23 +11,23 @@ fn screen(x: i32, y: i32, width: u32, height: u32) -> PhysicalRect { #[test] fn on_windows_it_sits_over_the_tray() { let at = corner(screen(0, 0, 1920, 1032), 1.0, false); - assert_eq!(at, PhysicalPosition::new(1920 - 360 - 12, 1032 - 184 - 12)); + assert_eq!(at, LogicalPosition::new(1548.0, 836.0)); } #[test] fn on_a_mac_it_hangs_under_the_menu_bar() { let at = corner(screen(0, 25, 1440, 875), 1.0, true); - assert_eq!(at, PhysicalPosition::new(1440 - 360 - 12, 25 + 12)); + assert_eq!(at, LogicalPosition::new(1068.0, 37.0)); } #[test] fn a_taskbar_on_top_still_leaves_it_inside_the_free_space() { let at = corner(screen(0, 48, 1920, 1032), 1.0, false); - assert_eq!(at.y + 184 + 12, 48 + 1032); + assert_eq!(at.y + 184.0 + 12.0, 48.0 + 1032.0); } #[test] -fn it_grows_with_the_screen_scale() { - let at = corner(screen(0, 0, 3840, 2088), 2.0, false); - assert_eq!(at, PhysicalPosition::new(3840 - 720 - 24, 2088 - 368 - 24)); +fn a_scaled_screen_keeps_the_same_logical_corner() { + let at = corner(screen(0, 0, 3840, 2064), 2.0, false); + assert_eq!(at, LogicalPosition::new(1548.0, 836.0)); } diff --git a/app/src-tauri/src/tray.rs b/app/src-tauri/src/tray.rs index d4d88fcc..5633c27f 100644 --- a/app/src-tauri/src/tray.rs +++ b/app/src-tauri/src/tray.rs @@ -211,6 +211,8 @@ pub fn surface(app: &AppHandle) { pub fn surface_at(app: &AppHandle, rail: Option<&str>) { crate::panel::hide(app); + crate::ready::close(app); + crate::welcome::unpin(app); if let Some(window) = app.get_webview_window("main") { if let Some(rail) = rail { let _ = app.emit("rail", rail); diff --git a/app/src-tauri/src/welcome.rs b/app/src-tauri/src/welcome.rs index f438a95f..b8c03104 100644 --- a/app/src-tauri/src/welcome.rs +++ b/app/src-tauri/src/welcome.rs @@ -195,6 +195,12 @@ pub fn reopened(app: &AppHandle) { } } +pub fn unpin(app: &AppHandle) { + if let Some(window) = app.get_webview_window(LABEL) { + let _ = window.set_always_on_top(false); + } +} + #[tauri::command] pub fn greeting(state: tauri::State<'_, Now>) -> Greeting { state diff --git a/app/src/tests/ready.test.tsx b/app/src/tests/ready.test.tsx index a276d5bc..d1984104 100644 --- a/app/src/tests/ready.test.tsx +++ b/app/src/tests/ready.test.tsx @@ -35,6 +35,19 @@ describe("the ready card", () => { expect(screen.queryByText("guardados")).toBeNull(); }); + it("says nothing about the history when it could not be read", async () => { + adopt("es"); + const invoke = await asked(); + const real = invoke.getMockImplementation() as (what: string, args?: never) => Promise; + invoke.mockImplementation(((what: string, args?: never) => + what === "glance" ? Promise.reject(new Error("locked")) : real(what, args)) as never); + render(); + expect(await screen.findByText("CoPanda está listo")).toBeInTheDocument(); + expect(screen.queryByText(/Todavía no hay nada guardado/)).toBeNull(); + expect(screen.queryByText("guardados")).toBeNull(); + invoke.mockImplementation(real as never); + }); + it("counts today from local midnight", async () => { adopt("es"); render(); diff --git a/app/src/ui/Ready.tsx b/app/src/ui/Ready.tsx index 8aabf51a..9aeb923d 100644 --- a/app/src/ui/Ready.tsx +++ b/app/src/ui/Ready.tsx @@ -25,8 +25,8 @@ function counted(value: number) { } export default function Ready() { - const { kept } = useKept(); - const [glance, setGlance] = useState(null); + const { kept, trouble } = useKept(); + const [glance, setGlance] = useState(null); const [held, setHeld] = useState(false); const left = useRef(LASTS_MS); const mac = onMac(); @@ -34,7 +34,7 @@ export default function Ready() { useEffect(() => { invoke("glance", { since: midnight() }) .then(setGlance) - .catch(() => setGlance({ items: 0, today: 0, pinned: 0, bytes: 0 })); + .catch(() => setGlance("unread")); const heard = listen("panel-shown", leave); return () => { void heard.then((drop) => drop()); @@ -54,7 +54,14 @@ export default function Ready() { }, []); useEffect(() => { - if (held || glance === null) { + if (trouble !== null && kept === null) { + leave(); + } + }, [trouble, kept]); + + const shown = kept !== null && glance !== null; + useEffect(() => { + if (held || !shown) { return; } const started = Date.now(); @@ -63,7 +70,7 @@ export default function Ready() { clearTimeout(timer); left.current -= Date.now() - started; }; - }, [held, glance]); + }, [held, shown]); if (!kept || !glance) { return null; @@ -107,7 +114,7 @@ export default function Ready() { - {glance.items > 0 ? ( + {glance === "unread" ? null : glance.items > 0 ? (
{( [ From 1e7008a71428e002f1f6804d9c9ef864a00b109f Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 16 Oct 2026 10:05:22 -0300 Subject: [PATCH 4/4] refactor(store): serialize the glance directly and open the history from one place --- Cargo.lock | 1 + app/src-tauri/src/backup.rs | 12 +++--------- app/src-tauri/src/backup_test.rs | 2 +- app/src-tauri/src/ready.rs | 28 +++++----------------------- app/src-tauri/src/settings.rs | 23 ++++++++++++++++++----- crates/cp-store/Cargo.toml | 1 + crates/cp-store/src/store_glance.rs | 2 +- 7 files changed, 30 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69b31b87..720d43ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1232,6 +1232,7 @@ dependencies = [ "cp-core", "image", "rusqlite", + "serde", "serde_json", "tempfile", "thiserror 2.0.21", diff --git a/app/src-tauri/src/backup.rs b/app/src-tauri/src/backup.rs index 550e54e8..33789042 100644 --- a/app/src-tauri/src/backup.rs +++ b/app/src-tauri/src/backup.rs @@ -44,15 +44,9 @@ pub struct Brought { from_elsewhere: bool, } -fn history() -> Result { - Ok(crate::settings::folder() - .ok_or_else(crate::settings::nowhere)? - .join("history.db")) -} - #[tauri::command(async)] pub fn save_backup(path: String, at: i64) -> Result { - let store = cp_store::Store::open(&history()?).map_err(|why| why.to_string())?; + let store = crate::settings::open_history()?; let to = PathBuf::from(&path); let made = cp_store::backup::write(&store, &to, at).map_err(|why| why.to_string())?; crate::note::note(&format!("a copy written to {path}: {} items", made.items)); @@ -109,7 +103,7 @@ pub fn load_backup( } fn bring(from: &std::path::Path, at: i64) -> Result { - let store = cp_store::Store::open(&history()?).map_err(|why| why.to_string())?; + let store = crate::settings::open_history()?; cp_store::backup::bring(from, &store, at, &|what| crate::note::note(what)) .map_err(|why| why.to_string()) } @@ -174,7 +168,7 @@ fn crossed( from: &std::path::Path, at: i64, ) -> Result<(cp_store::legacy::Brought, cp_store::Swept), String> { - let store = cp_store::Store::open(&history()?).map_err(|why| why.to_string())?; + let store = crate::settings::open_history()?; let lifted = lift_the_limit(app)?; let policy = crate::settings::policy(); let swept = |why: &str| match store.sweep(&policy, at) { diff --git a/app/src-tauri/src/backup_test.rs b/app/src-tauri/src/backup_test.rs index def1d140..5cf8b68a 100644 --- a/app/src-tauri/src/backup_test.rs +++ b/app/src-tauri/src/backup_test.rs @@ -1,4 +1,4 @@ -use super::*; +use crate::settings::history; #[test] fn the_history_a_backup_speaks_for_is_the_one_the_panel_writes() { diff --git a/app/src-tauri/src/ready.rs b/app/src-tauri/src/ready.rs index 9d3585f4..92e9044f 100644 --- a/app/src-tauri/src/ready.rs +++ b/app/src-tauri/src/ready.rs @@ -8,14 +8,6 @@ const WIDE: f64 = 360.0; const TALL: f64 = 184.0; const MARGIN: f64 = 12.0; -#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)] -pub struct Glance { - items: i64, - today: i64, - pinned: i64, - bytes: i64, -} - pub fn corner(area: PhysicalRect, scale: f64, top: bool) -> LogicalPosition { let left = f64::from(area.position.x) / scale; let up = f64::from(area.position.y) / scale; @@ -69,21 +61,11 @@ pub fn close(app: &AppHandle) { } #[tauri::command(async)] -pub fn glance(since: i64) -> Result { - let at = crate::settings::folder() - .ok_or_else(crate::settings::nowhere)? - .join("history.db"); - if !at.exists() { - return Ok(Glance::default()); - } - let store = cp_store::Store::open(&at).map_err(|why| why.to_string())?; - let seen = store.glance(since).map_err(|why| why.to_string())?; - Ok(Glance { - items: seen.items, - today: seen.today, - pinned: seen.pinned, - bytes: seen.bytes, - }) +pub fn glance(since: i64) -> Result { + let Some(store) = crate::settings::history_if_any()? else { + return Ok(cp_store::Glance::default()); + }; + store.glance(since).map_err(|why| why.to_string()) } #[tauri::command] diff --git a/app/src-tauri/src/settings.rs b/app/src-tauri/src/settings.rs index 32912452..23eeb30a 100644 --- a/app/src-tauri/src/settings.rs +++ b/app/src-tauri/src/settings.rs @@ -62,6 +62,21 @@ pub fn nowhere() -> String { "the folder where CoPanda keeps its own was not found".to_owned() } +pub fn history() -> Result { + Ok(folder().ok_or_else(nowhere)?.join("history.db")) +} + +pub fn open_history() -> Result { + cp_store::Store::open(&history()?).map_err(|why| why.to_string()) +} + +pub fn history_if_any() -> Result, String> { + if !history()?.exists() { + return Ok(None); + } + open_history().map(Some) +} + pub fn settle() -> bool { let Some(dir) = folder() else { return false; @@ -141,11 +156,9 @@ fn written(app: &tauri::AppHandle, config: Config) -> Result { #[tauri::command(async)] pub fn storage_used() -> Result { - let at = folder().ok_or_else(nowhere)?.join("history.db"); - if !at.exists() { + let Some(store) = history_if_any()? else { return Ok(0); - } - let store = cp_store::Store::open(&at).map_err(|why| why.to_string())?; + }; Ok(store.usage().map_err(|why| why.to_string())?.bytes) } @@ -174,7 +187,7 @@ pub struct Former { } fn came_over() -> Option { - let at = folder()?.join("history.db"); + let at = history().ok()?; for wait in [0u64, 150, 400] { if wait > 0 { std::thread::sleep(std::time::Duration::from_millis(wait)); diff --git a/crates/cp-store/Cargo.toml b/crates/cp-store/Cargo.toml index f803a991..5bde6479 100644 --- a/crates/cp-store/Cargo.toml +++ b/crates/cp-store/Cargo.toml @@ -10,6 +10,7 @@ publish = false [dependencies] cp-core.workspace = true rusqlite.workspace = true +serde.workspace = true serde_json.workspace = true xxhash-rust.workspace = true blake3.workspace = true diff --git a/crates/cp-store/src/store_glance.rs b/crates/cp-store/src/store_glance.rs index 8aede7c2..a8d32ef8 100644 --- a/crates/cp-store/src/store_glance.rs +++ b/crates/cp-store/src/store_glance.rs @@ -1,7 +1,7 @@ use super::Store; use crate::Result; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)] pub struct Glance { pub items: i64, pub today: i64,