From 7dd423a0083afc77546d257de3c5572db00c86f5 Mon Sep 17 00:00:00 2001 From: "evan.zhao" Date: Fri, 25 Sep 2026 23:49:55 +0800 Subject: [PATCH] feat: Add clipboard backups and release macOS 0.1.18 and Windows 0.2.4 --- Windows/CHANGELOG.md | 7 + Windows/package-lock.json | 4 +- Windows/package.json | 2 +- Windows/src-tauri/Cargo.lock | 163 +++++- Windows/src-tauri/Cargo.toml | 3 +- Windows/src-tauri/src/commands.rs | 49 +- Windows/src-tauri/src/lib.rs | 2 + Windows/src-tauri/tauri.conf.json | 2 +- Windows/src/App.css | 283 ++++++---- Windows/src/App.tsx | 56 +- Windows/src/panel/panelBackupStore.test.ts | 17 + Windows/src/panel/panelStore.test.ts | 12 +- Windows/src/panel/panelStore.ts | 35 +- Windows/src/preferences/PreferencesApp.tsx | 45 +- .../ClipboardCoreBridge.swift | 53 ++ macOS/Sources/ClipDock/AppCommands.swift | 2 +- .../Sources/ClipDock/ApplicationRuntime.swift | 14 + macOS/Sources/ClipDock/ClipDockTheme.swift | 12 +- macOS/Sources/ClipDock/PreferencesUI.swift | 204 ++++--- .../Sources/ClipDock/Resources/AppInfo.plist | 4 +- .../ClipboardListCoordinator.swift | 9 + .../Resources/en.lproj/Localizable.strings | 9 + .../Resources/ja.lproj/Localizable.strings | 9 + .../Resources/ko.lproj/Localizable.strings | 9 + .../zh-Hant.lproj/Localizable.strings | 9 + .../ClipboardPanelApp/RustCoreClient.swift | 29 + .../PreferencesShellTests.swift | 8 +- .../RustCoreClientTests.swift | 21 + macOS/rust/crates/clipboard_core/Cargo.toml | 4 +- macOS/rust/crates/clipboard_core/src/lib.rs | 2 +- .../rust/crates/clipboard_core/src/storage.rs | 18 +- .../clipboard_core/src/storage/backup.rs | 523 ++++++++++++++++++ .../src/storage/backup_tests.rs | 377 +++++++++++++ .../clipboard_core/src/storage/tests.rs | 3 + .../rust/crates/clipboard_core_ffi/src/lib.rs | 55 +- version.properties | 4 +- 36 files changed, 1807 insertions(+), 251 deletions(-) create mode 100644 Windows/src/panel/panelBackupStore.test.ts create mode 100644 macOS/rust/crates/clipboard_core/src/storage/backup.rs create mode 100644 macOS/rust/crates/clipboard_core/src/storage/backup_tests.rs diff --git a/Windows/CHANGELOG.md b/Windows/CHANGELOG.md index c73155a..f3b52d6 100644 --- a/Windows/CHANGELOG.md +++ b/Windows/CHANGELOG.md @@ -1,5 +1,12 @@ # ClipDock Windows Panel — Changelog +## 0.2.4 + +- Import and export clipboard history as a single `.clipdock` backup using the shared Rust core. Merge records, deduplicate content and preserve pinboards without changing application settings. +- Restore stored images and rich-text resources; external file entries retain their original paths. Existing retention settings still apply. +- Keep clipboard capture responsive during backup resource I/O and preserve local pin state in search and pinboard views. +- Refresh preferences styling with native window effects, theme support and keyboard focus indicators. + ## 0.2.3 - Rename cards inline from the context menu, F2 or Ctrl+R. Enter or blur saves; Escape cancels; a blank name restores the default label. diff --git a/Windows/package-lock.json b/Windows/package-lock.json index 7287dea..6e31487 100644 --- a/Windows/package-lock.json +++ b/Windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipdock-windows-panel", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipdock-windows-panel", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@tauri-apps/api": "^2.8.0", "lucide-react": "^0.468.0", diff --git a/Windows/package.json b/Windows/package.json index e8f10a2..87da347 100644 --- a/Windows/package.json +++ b/Windows/package.json @@ -1,6 +1,6 @@ { "name": "clipdock-windows-panel", - "version": "0.2.3", + "version": "0.2.4", "private": true, "type": "module", "scripts": { diff --git a/Windows/src-tauri/Cargo.lock b/Windows/src-tauri/Cargo.lock index 6a66539..00f20fd 100644 --- a/Windows/src-tauri/Cargo.lock +++ b/Windows/src-tauri/Cargo.lock @@ -448,11 +448,12 @@ dependencies = [ "serde_json", "sha2", "sqlite-simple-tokenizer", + "tempfile", ] [[package]] name = "clipdock_windows_panel" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "arboard", @@ -469,6 +470,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-dialog", "tempfile", "url", ] @@ -2459,6 +2461,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.12.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3164,6 +3167,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4036,6 +4063,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -5216,6 +5301,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5249,13 +5343,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5286,6 +5397,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5298,6 +5415,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5310,12 +5433,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5328,6 +5463,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5340,6 +5481,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5352,6 +5499,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5364,6 +5517,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/Windows/src-tauri/Cargo.toml b/Windows/src-tauri/Cargo.toml index 41b60d6..03bf258 100644 --- a/Windows/src-tauri/Cargo.toml +++ b/Windows/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clipdock_windows_panel" -version = "0.2.3" +version = "0.2.4" description = "ClipDock cross-platform panel shell" authors = ["ClipDock"] edition = "2021" @@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tauri = { version = "2", features = ["macos-private-api", "protocol-asset", "tray-icon"] } +tauri-plugin-dialog = "2" url = "2" [dev-dependencies] diff --git a/Windows/src-tauri/src/commands.rs b/Windows/src-tauri/src/commands.rs index 698129b..13575c5 100644 --- a/Windows/src-tauri/src/commands.rs +++ b/Windows/src-tauri/src/commands.rs @@ -4,10 +4,57 @@ use clipboard_core::{ SourceConfidence, }; use serde::Serialize; -use tauri::State; +use tauri::{Emitter, Manager, State}; +use tauri_plugin_dialog::DialogExt; use crate::core_state::CoreState; +#[tauri::command] +pub async fn transfer_clipboard_backup( + app: tauri::AppHandle, + importing: bool, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let mut dialog = app + .dialog() + .file() + .add_filter("ClipDock backup", &["clipdock"]); + if let Some(window) = app.get_webview_window("preferences") { + dialog = dialog.set_parent(&window); + } + let selected = if importing { + dialog.set_title("导入数据").blocking_pick_file() + } else { + dialog + .set_title("导出数据") + .set_file_name("ClipDock.clipdock") + .blocking_save_file() + }; + let Some(selected) = selected else { + return Ok(None); + }; + let path = selected.into_path().map_err(|e| e.to_string())?; + // Keep resource I/O off the capture connection's mutex. SQLite provides + // the backup snapshot and the short merge transaction's synchronization. + let mut core = clipboard_core::ClipboardCore::open_for_backup( + app.state::().root_dir(), + ) + .map_err(|e| e.to_string())?; + let result = if importing { + core.import_backup(&path) + } else { + core.export_backup(&path) + } + .map_err(|e| e.to_string())?; + if importing { + let _ = app.emit("clipdock://backup-imported", &result); + } + Ok(Some(result)) + }) + .await + .map_err(|e| e.to_string())? +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CoreInfoResult { diff --git a/Windows/src-tauri/src/lib.rs b/Windows/src-tauri/src/lib.rs index 692711d..3d77b0b 100644 --- a/Windows/src-tauri/src/lib.rs +++ b/Windows/src-tauri/src/lib.rs @@ -200,6 +200,7 @@ fn elevate_panel_above_dock(_window: &tauri::WebviewWindow) -> tauri::Result<()> pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ panel_frame_for_screen, clipboard_bridge::read_clipboard_snapshot, @@ -207,6 +208,7 @@ pub fn run() { clipboard_bridge::write_clipboard_text, native_assets::resolve_panel_native_assets, commands::core_info, + commands::transfer_clipboard_backup, commands::list_clipboard_items, commands::list_pinboards, commands::create_pinboard, diff --git a/Windows/src-tauri/tauri.conf.json b/Windows/src-tauri/tauri.conf.json index fbce00f..ee57604 100644 --- a/Windows/src-tauri/tauri.conf.json +++ b/Windows/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ClipDock Panel", - "version": "0.2.3", + "version": "0.2.4", "identifier": "com.apkdv.clipdock.panel", "build": { "beforeDevCommand": "npm run dev:web", diff --git a/Windows/src/App.css b/Windows/src/App.css index 26dc3ac..bf0fec8 100644 --- a/Windows/src/App.css +++ b/Windows/src/App.css @@ -875,25 +875,34 @@ button { } .preferences-root { + color-scheme: light dark; + --pref-text: light-dark(#202020, #f5f5f5); + --pref-secondary: light-dark(#606060, #bdbdbd); + --pref-border: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.08)); + --pref-card: light-dark(rgb(255 255 255 / 0.80), rgb(255 255 255 / 0.045)); + --pref-control: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.065)); + --pref-hover: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.09)); + --pref-accent: light-dark(#005fb8, #60cdff); + font-family: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; width: 100vw; height: 100vh; display: grid; - grid-template-columns: 252px minmax(0, 1fr); + grid-template-columns: 224px minmax(0, 1fr); overflow: hidden; - color: rgba(28, 29, 32, 0.96); - background: #f5f5f7; + color: var(--pref-text); + background: light-dark(rgb(243 243 243 / 0.86), rgb(32 32 32 / 0.94)); } .preferences-sidebar { min-width: 0; padding: 0 12px 16px; - background: #eeeef1; - border-right: 0.5px solid rgba(0, 0, 0, 0.1); + background: transparent; + border-right: 0.5px solid var(--pref-border); user-select: none; } .preferences-sidebar-spacer { - height: 72px; + height: 48px; } .preferences-sidebar-list { @@ -903,18 +912,19 @@ button { } .preferences-nav-button { - height: 38px; + position: relative; + min-height: 40px; display: flex; align-items: center; gap: 12px; border: 0; border-radius: 8px; - padding: 0 14px; - color: rgba(34, 35, 38, 0.8); + padding: 8px 14px; + color: var(--pref-text); background: transparent; outline: none; - font-size: 14.5px; - font-weight: 650; + font-size: 14px; + font-weight: 400; text-align: left; letter-spacing: 0; } @@ -926,37 +936,54 @@ button { .preferences-nav-button span { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 20px; } .preferences-nav-button:hover { - background: rgba(255, 255, 255, 0.52); + background: var(--pref-hover); } .preferences-nav-button.is-selected { - color: white; - background: #0a84ff; + color: var(--pref-text); + background: var(--pref-hover); + font-weight: 600; +} + +.preferences-nav-button.is-selected::before { + content: ""; + position: absolute; + left: 0; + width: 3px; + height: 18px; + border-radius: 2px; + background: var(--pref-accent); +} + +.preferences-root[data-appearance="light"] { color-scheme: light; } +.preferences-root[data-appearance="dark"] { color-scheme: dark; } + +.preferences-root :is(button, input, select):focus-visible { + outline: 2px solid var(--pref-accent); + outline-offset: 2px; +} + +.preferences-root button:disabled { + opacity: 0.45; + cursor: default; } .preferences-content { min-width: 0; height: 100vh; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 248, 250, 0.96)), - #f8f8fa; + background: light-dark(rgb(255 255 255 / 0.25), rgb(0 0 0 / 0.08)); } .preferences-scroll { height: 100%; overflow-y: auto; - padding: 72px 52px 48px; - scrollbar-width: none; -} - -.preferences-scroll::-webkit-scrollbar { - display: none; + padding: 44px 32px 40px; + scrollbar-width: thin; + scrollbar-color: var(--pref-secondary) transparent; } .preference-page-header { @@ -968,32 +995,18 @@ button { margin: 0 0 22px; } -.preference-page-icon { - width: 34px; - height: 34px; - display: grid; - place-items: center; - flex: 0 0 auto; - border-radius: 8px; - color: #0a84ff; - background: rgba(10, 132, 255, 0.12); - box-shadow: - inset 0 0 0 0.6px rgba(10, 132, 255, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.5); -} - .preference-page-header h1 { margin: 0; - color: rgba(23, 24, 27, 0.98); - font-size: 25px; - line-height: 30px; - font-weight: 650; + color: var(--pref-text); + font-size: 28px; + line-height: 36px; + font-weight: 600; letter-spacing: 0; } .preference-page-header p { margin: 3px 0 0; - color: rgba(64, 66, 72, 0.68); + color: var(--pref-secondary); font-size: 13.5px; line-height: 18px; letter-spacing: 0; @@ -1014,7 +1027,7 @@ button { .preference-group-title { margin: 0 0 0 4px; - color: rgba(64, 66, 72, 0.68); + color: var(--pref-text); font-size: 12.5px; line-height: 16px; font-weight: 500; @@ -1025,17 +1038,17 @@ button { .ignored-app-card { overflow: hidden; border-radius: 8px; - background: rgba(255, 255, 255, 0.88); - border: 0.6px solid rgba(0, 0, 0, 0.1); + background: var(--pref-card); + border: 0.6px solid var(--pref-border); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.025); } .preference-row { - min-height: 58px; + min-height: 68px; display: flex; align-items: center; gap: 18px; - padding: 11px 22px; + padding: 16px 20px; } .preference-stacked-row { @@ -1044,7 +1057,7 @@ button { flex-direction: column; align-items: stretch; gap: 9px; - padding: 11px 22px; + padding: 16px 20px; } .preference-row-text { @@ -1056,22 +1069,19 @@ button { } .preference-row-text strong { - color: rgba(30, 31, 34, 0.96); + color: var(--pref-text); font-size: 14.5px; line-height: 18px; - font-weight: 650; + font-weight: 600; letter-spacing: 0; } .preference-row-text span { - color: rgba(64, 66, 72, 0.66); + color: var(--pref-secondary); font-size: 12.5px; line-height: 17px; letter-spacing: 0; - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + overflow-wrap: anywhere; } .preference-row-accessory { @@ -1084,8 +1094,8 @@ button { .preference-divider { height: 0.5px; - margin-left: 22px; - background: rgba(0, 0, 0, 0.09); + margin: 0 20px; + background: var(--pref-border); } .preference-switch { @@ -1109,7 +1119,7 @@ button { } .preference-switch.is-on { - background: #34c759; + background: var(--pref-accent); } .preference-switch.is-on span { @@ -1123,15 +1133,15 @@ button { grid-auto-columns: 1fr; padding: 2px; border-radius: 7px; - background: rgba(118, 118, 128, 0.14); - box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.08); + background: var(--pref-control); + box-shadow: inset 0 0 0 0.5px var(--pref-border); } .preference-segmented button { min-width: 0; border: 0; border-radius: 5px; - color: rgba(32, 33, 36, 0.78); + color: var(--pref-text); background: transparent; font-size: 12.5px; font-weight: 500; @@ -1139,9 +1149,9 @@ button { } .preference-segmented button.is-selected { - color: rgba(20, 21, 24, 0.94); - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16); + color: var(--pref-text); + background: light-dark(white, #454545); + box-shadow: 0 1px 3px var(--pref-border); } .preference-text-input { @@ -1150,9 +1160,9 @@ button { border: 0; border-radius: 7px; padding: 0 9px; - color: rgba(24, 25, 28, 0.96); - background: rgba(118, 118, 128, 0.12); - box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.08); + color: var(--pref-text); + background: var(--pref-control); + box-shadow: inset 0 0 0 0.5px var(--pref-border); outline: none; font-size: 13px; } @@ -1169,8 +1179,8 @@ button { align-items: center; overflow: hidden; border-radius: 8px; - background: rgba(118, 118, 128, 0.12); - box-shadow: inset 0 0 0 0.6px rgba(0, 0, 0, 0.11); + background: var(--pref-control); + box-shadow: inset 0 0 0 0.6px var(--pref-border); } .shortcut-recorder { @@ -1178,7 +1188,7 @@ button { height: 32px; border: 0; padding: 0 10px; - color: rgba(28, 29, 32, 0.94); + color: var(--pref-text); background: transparent; font-size: 13px; font-weight: 600; @@ -1186,7 +1196,7 @@ button { } .shortcut-recorder.is-recording { - color: #0a84ff; + color: var(--pref-accent); } .shortcut-recorder.has-error { @@ -1196,7 +1206,7 @@ button { .editable-shortcut-divider { width: 0.5px; height: 20px; - background: rgba(0, 0, 0, 0.13); + background: var(--pref-border); } .shortcut-clear-button { @@ -1205,12 +1215,12 @@ button { display: grid; place-items: center; border: 0; - color: rgba(64, 66, 72, 0.62); + color: var(--pref-secondary); background: transparent; } .shortcut-clear-button:hover { - color: rgba(28, 29, 32, 0.86); + color: var(--pref-text); } .shortcut-modifier-row, @@ -1231,9 +1241,9 @@ button { border: 0; border-radius: 7px; padding: 0 9px; - color: rgba(28, 29, 32, 0.9); - background: rgba(118, 118, 128, 0.12); - box-shadow: inset 0 0 0 0.6px rgba(0, 0, 0, 0.11); + color: var(--pref-text); + background: var(--pref-control); + box-shadow: inset 0 0 0 0.6px var(--pref-border); outline: none; font-size: 13px; font-weight: 500; @@ -1254,9 +1264,9 @@ button { align-items: center; justify-content: center; border-radius: 8px; - color: rgba(28, 29, 32, 0.9); - background: rgba(118, 118, 128, 0.12); - box-shadow: inset 0 0 0 0.6px rgba(0, 0, 0, 0.11); + color: var(--pref-text); + background: var(--pref-control); + box-shadow: inset 0 0 0 0.6px var(--pref-border); } .preference-reset-row { @@ -1269,11 +1279,11 @@ button { height: 30px; border: 0; border-radius: 7px; - color: rgba(28, 29, 32, 0.9); - background: rgba(255, 255, 255, 0.9); + color: var(--pref-text); + background: var(--pref-card); box-shadow: - inset 0 0 0 0.6px rgba(0, 0, 0, 0.12), - 0 1px 1px rgba(0, 0, 0, 0.04); + inset 0 0 0 0.6px var(--pref-border), + 0 1px 1px var(--pref-border); font-size: 13px; font-weight: 500; letter-spacing: 0; @@ -1291,7 +1301,7 @@ button { .preference-push-button:hover, .preference-icon-push-button:hover { - background: white; + background: var(--pref-hover); } .preference-pill-row { @@ -1306,14 +1316,14 @@ button { align-items: center; border-radius: 999px; padding: 0 10px; - color: rgba(58, 60, 66, 0.82); - background: rgba(118, 118, 128, 0.12); + color: var(--pref-secondary); + background: var(--pref-control); font-size: 12.5px; font-weight: 550; } .preference-value-pill.is-prominent { - color: #0058b7; + color: var(--pref-accent); background: rgba(10, 132, 255, 0.14); } @@ -1335,9 +1345,9 @@ button { border-radius: 7px; padding: 0 8px; text-align: center; - color: rgba(28, 29, 32, 0.94); - background: rgba(118, 118, 128, 0.12); - box-shadow: inset 0 0 0 0.6px rgba(0, 0, 0, 0.11); + color: var(--pref-text); + background: var(--pref-control); + box-shadow: inset 0 0 0 0.6px var(--pref-border); outline: none; font-size: 13px; font-weight: 600; @@ -1351,7 +1361,7 @@ button { .preference-status-label { gap: 6px; - color: rgba(35, 100, 48, 0.88); + color: light-dark(#286b36, #8bd89a); background: rgba(52, 199, 89, 0.13); } @@ -1371,16 +1381,16 @@ button { .ignored-app-heading h2 { margin: 0; - color: rgba(30, 31, 34, 0.96); + color: var(--pref-text); font-size: 15px; line-height: 20px; - font-weight: 650; + font-weight: 600; letter-spacing: 0; } .ignored-app-heading p { margin: 3px 0 0; - color: rgba(64, 66, 72, 0.66); + color: var(--pref-secondary); font-size: 12.5px; line-height: 17px; } @@ -1391,7 +1401,7 @@ button { align-items: center; gap: 12px; padding: 15px 24px; - color: rgba(64, 66, 72, 0.66); + color: var(--pref-secondary); } .ignored-app-empty > div, @@ -1404,15 +1414,15 @@ button { .ignored-app-empty strong, .ignored-app-row strong { - color: rgba(30, 31, 34, 0.94); + color: var(--pref-text); font-size: 15px; line-height: 19px; - font-weight: 650; + font-weight: 600; } .ignored-app-empty span, .ignored-app-row span { - color: rgba(64, 66, 72, 0.66); + color: var(--pref-secondary); font-size: 13px; line-height: 17px; } @@ -1440,16 +1450,16 @@ button { place-items: center; flex: 0 0 auto; border-radius: 7px; - color: rgba(64, 66, 72, 0.68); - background: rgba(118, 118, 128, 0.12); + color: var(--pref-secondary); + background: var(--pref-control); } .ignored-app-toolbar { height: 36px; display: flex; align-items: center; - border-top: 0.5px solid rgba(0, 0, 0, 0.09); - background: rgba(118, 118, 128, 0.08); + border-top: 0.5px solid var(--pref-border); + background: var(--pref-control); } .ignored-app-toolbar button { @@ -1458,7 +1468,7 @@ button { display: grid; place-items: center; border: 0; - color: rgba(64, 66, 72, 0.72); + color: var(--pref-secondary); background: transparent; } @@ -1470,7 +1480,7 @@ button { .ignored-app-toolbar span { width: 0.5px; height: 20px; - background: rgba(0, 0, 0, 0.13); + background: var(--pref-border); } .preference-about-hero { @@ -1491,7 +1501,7 @@ button { color: white; background: linear-gradient(145deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0)), - linear-gradient(145deg, #0a84ff, #4f7dff 55%, #34c759); + linear-gradient(145deg, var(--pref-accent), #4f7dff 55%, #34c759); box-shadow: 0 8px 18px rgba(10, 132, 255, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.24); @@ -1499,16 +1509,16 @@ button { .preference-about-hero h2 { margin: 0; - color: rgba(30, 31, 34, 0.96); + color: var(--pref-text); font-size: 19px; line-height: 24px; - font-weight: 650; + font-weight: 600; letter-spacing: 0; } .preference-about-hero p { margin: 4px 0 0; - color: rgba(64, 66, 72, 0.66); + color: var(--pref-secondary); font-size: 13px; line-height: 17px; } @@ -1549,12 +1559,25 @@ button { } .preferences-root { - grid-template-columns: 232px minmax(0, 1fr); + grid-template-columns: 210px minmax(0, 1fr); } .preferences-scroll { - padding-left: 32px; - padding-right: 32px; + padding-left: 24px; + padding-right: 24px; + } + + .preference-row { + flex-wrap: wrap; + gap: 12px; + } + + .preference-row > .preference-row-text { + flex-basis: 180px; + } + + .preference-row-accessory { + max-width: 100%; } } @@ -1569,3 +1592,33 @@ button { linear-gradient(120deg, rgba(255, 255, 255, 0.36), rgba(220, 228, 238, 0.2)); } } + +@media (prefers-reduced-motion: reduce) { + .preferences-root *, .preferences-root *::before { + transition: none; + } +} + +@media (forced-colors: active) { + .preferences-root { + --pref-text: CanvasText; + --pref-secondary: CanvasText; + --pref-border: ButtonBorder; + --pref-card: Canvas; + --pref-control: Canvas; + --pref-hover: Canvas; + --pref-accent: Highlight; + background: Canvas; + } + + .preferences-root button { + border: 1px solid ButtonBorder; + } + + .preferences-nav-button.is-selected, + .preference-switch.is-on, + .preference-segmented button.is-selected { + background: Highlight; + color: HighlightText; + } +} diff --git a/Windows/src/App.tsx b/Windows/src/App.tsx index d5d67d8..21f889a 100644 --- a/Windows/src/App.tsx +++ b/Windows/src/App.tsx @@ -24,8 +24,9 @@ import type { LucideIcon } from "lucide-react"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { listen } from "@tauri-apps/api/event"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { Effect } from "@tauri-apps/api/window"; import { useEffect, useMemo, useRef, useState } from "react"; -import { panelItems, pinboardFilters, typeFilters } from "./panel/panelData"; +import { panelItems, pinboardFilters as demoPinboards, typeFilters } from "./panel/panelData"; import { clipboardCaptureDecision, clipboardPayloadForItem, @@ -48,7 +49,7 @@ import { togglePinnedItem } from "./panel/panelInteractions"; import { applyResolvedPanelAssets, resolvePanelNativeAssets } from "./panel/nativeAssets"; -import { loadStoredPanelItems } from "./panel/panelStore"; +import { loadStoredPanelItems, loadStoredPinboards, mergeLoadedPanelItems } from "./panel/panelStore"; import { CardTitleEditor } from "./panel/CardTitleEditor"; import { CardRenameQueue, matchesRenameShortcut, normalizedCardTitle, persistCardTitle } from "./panel/cardRename"; import { invoke, isTauri } from "@tauri-apps/api/core"; @@ -112,12 +113,14 @@ function PanelApp() { const { preferences } = usePreferences(); const [activeType, setActiveType] = useState<(typeof typeFilters)[number]["id"]>("all"); const [activePinboard, setActivePinboard] = useState(null); + const [pinboardFilters, setPinboardFilters] = useState(isTauri() ? [] : demoPinboards); + const [backupRevision, setBackupRevision] = useState(0); const [items, setItems] = useState(isTauri() ? [] : panelItems); const [renamingItemId, setRenamingItemId] = useState(null); const renameQueue = useRef(new CardRenameQueue(persistCardTitle)); const deletedItemIds = useRef(new Set()); const [searchRevision, setSearchRevision] = useState(0); - const [storedSearch, setStoredSearch] = useState<{ query: string; matches: ClipItem[] } | null>(null); + const [storedSearch, setStoredSearch] = useState<{ query: string; pinboardId: string | null; matches: ClipItem[] } | null>(null); const [searchVisible, setSearchVisible] = useState(false); const [searchText, setSearchText] = useState(""); const [selectedItemId, setSelectedItemId] = useState(panelItems[0]?.id ?? ""); @@ -133,13 +136,16 @@ function PanelApp() { const filteredItems = useMemo(() => { const normalizedSearch = searchText.trim().toLocaleLowerCase(); - const nativeMatches = storedSearch?.query === searchText ? storedSearch.matches : []; + const nativeMatches = storedSearch?.query === searchText && storedSearch.pinboardId === activePinboard ? storedSearch.matches : []; const nativeIds = new Set(nativeMatches.map((item) => item.id)); - const candidates = [...items, ...nativeMatches.filter((match) => !items.some((item) => item.id === match.id))]; + const candidates = [ + ...items, + ...nativeMatches.filter((match) => !items.some((item) => item.id === match.id)) + ]; return sortedPanelItemsForDisplay(candidates).filter((item) => { if (deletedItemIds.current.has(item.id)) return false; const typeMatches = activeType === "all" || item.kind === activeType; - const pinboardMatches = !activePinboard || item.pinboardIds.includes(activePinboard); + const pinboardMatches = !activePinboard || (isTauri() ? nativeIds.has(item.id) : item.pinboardIds.includes(activePinboard)); const textMatches = normalizedSearch.length === 0 || nativeIds.has(item.id) || item.id === renamingItemId || @@ -152,15 +158,24 @@ function PanelApp() { }, [activePinboard, activeType, items, searchText, storedSearch, renamingItemId]); useEffect(() => { - if (!searchText.trim() || !isTauri()) return; + if ((!searchText.trim() && !activePinboard) || !isTauri()) return; let cancelled = false; - void loadStoredPanelItems(100, searchText).then((matches) => { - if (!cancelled) setStoredSearch({ query: searchText, matches }); + void loadStoredPanelItems(100, searchText, activePinboard ?? undefined).then((matches) => { + if (!cancelled) setStoredSearch({ query: searchText, pinboardId: activePinboard, matches }); }).catch(() => { if (!cancelled) setStoredSearch(null); }); return () => { cancelled = true; }; - }, [searchText, searchRevision]); + }, [searchText, searchRevision, activePinboard, backupRevision]); + + useEffect(() => { + if (!isTauri()) return; + let cancelled = false; + void loadStoredPinboards().then((boards) => { + if (!cancelled) setPinboardFilters(boards); + }).catch((error) => console.error("Failed to load pinboards", error)); + return () => { cancelled = true; }; + }, [backupRevision]); useEffect(() => { let cancelled = false; @@ -180,13 +195,14 @@ function PanelApp() { useEffect(() => { let cancelled = false; + const atLoad = items; loadStoredPanelItems() .then((storedItems) => { if (cancelled || !isTauri()) { return; } // Preserve captures and edits that arrived while the initial page loaded. - setItems((current) => [...current, ...storedItems.filter((stored) => !current.some((item) => item.id === stored.id))]); + setItems((current) => mergeLoadedPanelItems(current, storedItems, atLoad)); setSelectedItemId(storedItems[0]?.id ?? ""); }) .catch((error) => { @@ -195,11 +211,19 @@ function PanelApp() { return () => { cancelled = true; }; - }, []); + }, [backupRevision]); useEffect(() => { let cancelled = false; const unlisteners: Array<() => void> = []; + listen("clipdock://backup-imported", () => { + deletedItemIds.current.clear(); + setStoredSearch(null); + setBackupRevision((revision) => revision + 1); + }).then((unlisten) => { + if (cancelled) unlisten(); + else unlisteners.push(unlisten); + }).catch((error) => console.error("Failed to listen for backup imports", error)); listen("clipdock://open-preferences", () => { void openPreferencesWindow(); }) @@ -491,6 +515,7 @@ function PanelApp() { }, [ filteredItems, contextMenu, + pinboardFilters, preferences.appearance.previewPopoverEnabled, preferences.shortcuts, searchText, @@ -553,6 +578,7 @@ function PanelApp() { } function movePinboard(offset: 1 | -1) { + if (pinboardFilters.length === 0) return; setActivePinboard((current) => { const currentIndex = pinboardFilters.findIndex((pinboard) => pinboard.id === current); const nextIndex = @@ -617,6 +643,9 @@ function PanelApp() { function toggleItemPinned(item: ClipItem) { setItems((currentItems) => togglePinnedItem(currentItems, item.id)); + setStoredSearch((current) => current + ? { ...current, matches: togglePinnedItem(current.matches, item.id) } + : null); setContextMenu(null); showPanelShortcutToast(`${item.isPinned ? "取消固定" : "已固定"} · ${item.title}`); } @@ -853,7 +882,8 @@ async function openPreferencesWindow() { center: true, resizable: true, decorations: true, - transparent: false, + transparent: true, + windowEffects: { effects: [Effect.Mica, Effect.Sidebar] }, visible: true, focus: true, titleBarStyle: "overlay", diff --git a/Windows/src/panel/panelBackupStore.test.ts b/Windows/src/panel/panelBackupStore.test.ts new file mode 100644 index 0000000..c7e4f0e --- /dev/null +++ b/Windows/src/panel/panelBackupStore.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { loadStoredPanelItems, loadStoredPinboards } from "./panelStore"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), isTauri: () => true, convertFileSrc: (path: string) => path })); +beforeEach(() => vi.mocked(invoke).mockReset()); + +it("displays imported pinboard names and colors from storage", async () => { + vi.mocked(invoke).mockResolvedValue({ pinboards: [{ id: "restored", title: "工作收藏", color_code: 0xffff453a }] }); + expect(await loadStoredPinboards()).toEqual([{ id: "restored", label: "工作收藏", color: "#ff453a" }]); +}); + +it("loads imported pinboard contents using the native pinboard filter", async () => { + vi.mocked(invoke).mockResolvedValue({ items: [], total_count: 0, has_more: false }); + expect(await loadStoredPanelItems(100, "", "restored")).toEqual([]); + expect(invoke).toHaveBeenCalledWith("list_clipboard_items", { limit: 100, searchText: "", pinboardId: "restored" }); +}); diff --git a/Windows/src/panel/panelStore.test.ts b/Windows/src/panel/panelStore.test.ts index 1437e46..907168a 100644 --- a/Windows/src/panel/panelStore.test.ts +++ b/Windows/src/panel/panelStore.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { summaryToClipItem, type ClipboardItemSummary } from "./panelStore"; +import { mergeLoadedPanelItems, summaryToClipItem, type ClipboardItemSummary } from "./panelStore"; import { clipboardPayloadForItem, clipboardSnapshotToPanelItem } from "./clipboardCapture"; const summary: ClipboardItemSummary = { @@ -19,6 +19,16 @@ it("loads custom labels separately from the original clipboard contents", () => expect(summaryToClipItem({ ...summary, custom_title: undefined }, "1").customTitle).toBeNull(); }); +it("restores database order after import while preserving captures and edits during the refresh", () => { + const old = summaryToClipItem({ ...summary, id: "old" }, "1"); + const imported = summaryToClipItem({ ...summary, id: "imported" }, "2"); + const captured = summaryToClipItem({ ...summary, id: "captured" }, "3"); + const result = mergeLoadedPanelItems([captured, { ...old, customTitle: "Just edited" }], [imported, { ...old, isPinned: true }], [old]); + expect(result.map((item) => item.id)).toEqual(["captured", "imported", "old"]); + expect(result[2].customTitle).toBe("Just edited"); + expect(result[2].isPinned).toBe(true); +}); + it("uses the persisted id and title for new captures, retaining link detection", () => { const item = clipboardSnapshotToPanelItem({ changeKey: "temporary-key", kind: "text", text: "https://example.com", storedItem: summary }, "1"); diff --git a/Windows/src/panel/panelStore.ts b/Windows/src/panel/panelStore.ts index 6dc9912..a3df815 100644 --- a/Windows/src/panel/panelStore.ts +++ b/Windows/src/panel/panelStore.ts @@ -1,5 +1,5 @@ import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core"; -import type { ClipItem, ClipKind } from "./panelTypes"; +import type { ClipItem, ClipKind, PinboardFilter } from "./panelTypes"; /// Mirror of `clipboard_core::ClipboardItemSummary` (serde snake_case fields). export type ClipboardItemSummary = { @@ -70,15 +70,44 @@ const SOURCE_COLORS: Record = { /// Load persisted clipboard history from the database. Returns an empty list /// outside of Tauri (e.g. the Vite-only browser preview) so the panel still /// renders. -export async function loadStoredPanelItems(limit = 100, searchText?: string): Promise { +export async function loadStoredPanelItems(limit = 100, searchText?: string, pinboardId?: string): Promise { if (!isTauri()) { return []; } - const page = await invoke("list_clipboard_items", { limit, searchText }); + const page = await invoke("list_clipboard_items", { limit, searchText, pinboardId }); return page.items.map((summary, index) => summaryToClipItem(summary, String(index + 1))); } +export async function loadStoredPinboards(): Promise { + if (!isTauri()) return []; + const page = await invoke<{ pinboards: Array<{ id: string; title: string; color_code: number }> }>("list_pinboards"); + return page.pinboards.map((board) => ({ + id: board.id, + label: board.title, + color: `#${(board.color_code & 0xffffff).toString(16).padStart(6, "0")}` + })); +} + +export function mergeLoadedPanelItems(current: ClipItem[], stored: ClipItem[], atLoad: ClipItem[]): ClipItem[] { + const previous = new Map(atLoad.map((item) => [item.id, item])); + const currentById = new Map(current.map((item) => [item.id, item])); + const storedIds = new Set(stored.map((item) => item.id)); + return [ + ...current.filter((item) => !previous.has(item.id) && !storedIds.has(item.id)), + ...stored.map((item) => { + const local = currentById.get(item.id); + const before = previous.get(item.id); + return local && before ? { + ...item, + customTitle: local.customTitle !== before.customTitle ? local.customTitle : item.customTitle, + isPinned: local.isPinned !== before.isPinned ? local.isPinned : item.isPinned + } : item; + }), + ...current.filter((item) => previous.has(item.id) && !storedIds.has(item.id)) + ]; +} + export function summaryToClipItem( summary: ClipboardItemSummary, commandIndex: string, diff --git a/Windows/src/preferences/PreferencesApp.tsx b/Windows/src/preferences/PreferencesApp.tsx index 4ea4980..b739f6f 100644 --- a/Windows/src/preferences/PreferencesApp.tsx +++ b/Windows/src/preferences/PreferencesApp.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { useEffect, useMemo, useState } from "react"; import { formatKeyboardShortcut, @@ -137,7 +138,7 @@ export function PreferencesApp() { ); return ( -
+