Add copy-only, color swatches, DB speed pragmas, and living docs
- Copy only (right-click -> Copy): write to clipboard without auto-pasting or hiding the window - Color swatches: hex/rgb/hsl entries show a preview chip - SQLite WAL + synchronous=NORMAL so the UI can read while the clipboard poller writes - Memoize list rows (React.memo) so selection/focus changes don't re-render every row's preview - Add docs/GUIDE.md (living install + usage doc), docs/SHARING.md, docs/TESTING.md, docs/LAUNCH-AT-LOGIN.md, docs/PRODUCT.md, CHANGELOG.md, ROADMAP.md - Add homelab Gitea Actions CI (.gitea/workflows/ci.yml) + gitleaks allowlist - Bump version to 0.2.0
This commit is contained in:
parent
7bb3b5fc89
commit
e9461ed4f3
100
.gitea/workflows/ci.yml
Normal file
100
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,100 @@
|
||||
---
|
||||
# Homelab CI — Node/pages lane (git-ci-01) + secret scan (git-ci-02)
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
skip-ci-check:
|
||||
runs-on: [homelab, self-hosted, linux]
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
outputs:
|
||||
should-skip: ${{ steps.check.outputs.skip }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- id: check
|
||||
run: |
|
||||
SKIP=0
|
||||
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
|
||||
MSG="${GITHUB_EVENT_HEAD_COMMIT_MESSAGE:-$(git log -1 --pretty=%B 2>/dev/null || true)}"
|
||||
echo "$BRANCH" "$MSG" | grep -qi '@skipci' && SKIP=1
|
||||
echo "skip=$SKIP" >> $GITHUB_OUTPUT
|
||||
|
||||
node-ci:
|
||||
needs: skip-ci-check
|
||||
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||
runs-on: [homelab, self-hosted, linux, node]
|
||||
container:
|
||||
image: node:20-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Node / static site CI
|
||||
run: |
|
||||
set -e
|
||||
if [ ! -f package.json ]; then
|
||||
echo "No package.json — static/HTML repo; skip npm build pipeline"
|
||||
if ls ./*.html >/dev/null 2>&1 || [ -f index.html ]; then
|
||||
echo "Found HTML entrypoint(s) — OK for static site"
|
||||
else
|
||||
echo "No HTML files at repo root (advisory only)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f package-lock.json ]; then
|
||||
if ! npm ci; then
|
||||
echo "npm ci failed (lock file out of sync?) — falling back to npm install"
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
fi
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
|
||||
if [ -f playwright.config.ts ] || [ -f playwright.config.js ] || [ -f playwright.config.mjs ] \
|
||||
|| grep -q '@playwright/test' package.json 2>/dev/null; then
|
||||
npx playwright install --with-deps chromium
|
||||
else
|
||||
echo "No Playwright — skip browser install"
|
||||
fi
|
||||
|
||||
npm run lint --if-present || echo "Lint failed (advisory — fix in follow-up)"
|
||||
npm test --if-present || echo "Tests failed (advisory — fix in follow-up)"
|
||||
|
||||
export CI=true
|
||||
export NEXTAUTH_SECRET="${NEXTAUTH_SECRET:-ci-build-placeholder-not-for-production}"
|
||||
export AUTH_SECRET="${AUTH_SECRET:-$NEXTAUTH_SECRET}"
|
||||
export NEXTAUTH_URL="${NEXTAUTH_URL:-http://localhost:3000}"
|
||||
export DATABASE_URL="${DATABASE_URL:-postgresql://ci:ci@127.0.0.1:5432/ci?schema=public}"
|
||||
npm run build --if-present
|
||||
npm audit --audit-level=high || true
|
||||
env:
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
|
||||
secret-scan:
|
||||
needs: skip-ci-check
|
||||
if: needs.skip-ci-check.outputs.should-skip != '1'
|
||||
runs-on: [homelab, self-hosted, linux, heavy]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gitleaks
|
||||
run: |
|
||||
extra=""
|
||||
if [ -f .gitleaks.toml ]; then
|
||||
extra="--config /repo/.gitleaks.toml"
|
||||
fi
|
||||
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
|
||||
detect --source /repo --no-banner --redact ${extra}
|
||||
26
.gitleaks.toml
Normal file
26
.gitleaks.toml
Normal file
@ -0,0 +1,26 @@
|
||||
# Homelab bootstrap — gitleaks allowlist (tests, examples, placeholders)
|
||||
#
|
||||
# IMPORTANT: `useDefault = true` is required — without it gitleaks loads ONLY
|
||||
# this file (title + allowlist) with ZERO detection rules, so it would never
|
||||
# flag a real secret.
|
||||
title = "homelab gitea bootstrap"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Test fixtures and example configs are not production secrets"
|
||||
paths = [
|
||||
'''(?i).*\.test\.(ts|tsx|js|jsx|py)$''',
|
||||
'''(?i).*\.spec\.(ts|tsx|js|jsx)$''',
|
||||
'''(?i).*/tests/.*''',
|
||||
'''(?i).*/__tests__/.*''',
|
||||
'''(?i).*\.example\.(yml|yaml|env|json|toml)$''',
|
||||
'''(?i).*vault\.example\.(yml|yaml)$''',
|
||||
'''(?i).*\.env\.example$''',
|
||||
]
|
||||
regexes = [
|
||||
'''(?i)(invalid|fake|dummy|placeholder|example|changeme|change_me|not-a-real)''',
|
||||
'''(?i)sk-or-invalid''',
|
||||
'''(?i)msk-or-invalid''',
|
||||
]
|
||||
52
CHANGELOG.md
Normal file
52
CHANGELOG.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to maCopy. Format loosely follows
|
||||
[Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [0.2.0] — 2026-07-14
|
||||
|
||||
### Added
|
||||
- Configurable global hotkey (default `` Ctrl+` ``; migrates existing
|
||||
users off the old `Cmd+\`` default, which conflicted with macOS
|
||||
window-cycling)
|
||||
- Image thumbnails in the picker, with backfill for existing entries
|
||||
- Sensitive data redaction — passwords/cards/banking previews show
|
||||
`••••` in the list; the full value is still stored and still pastes
|
||||
- Paste transforms (trim, case, collapse whitespace, pretty JSON,
|
||||
single line) via right-click → Transform
|
||||
- Paste plain / without formatting (`⌥`/`Alt`+click or right-click)
|
||||
- **Copy only** — right-click → Copy puts content on the clipboard
|
||||
without auto-pasting or hiding the window
|
||||
- Color swatches — hex/rgb/hsl entries show a small preview chip
|
||||
- Launch at login, wired to macOS Login Items
|
||||
- `docs/GUIDE.md` — living install + usage reference
|
||||
- `docs/SHARING.md` — where the app lives and how to share a build
|
||||
- `docs/TESTING.md`, `docs/LAUNCH-AT-LOGIN.md`, `docs/PRODUCT.md`
|
||||
- Visual walkthrough script (`npm run test:visual`) and headed test
|
||||
runner (`npm run test:headed`)
|
||||
|
||||
### Changed
|
||||
- Reliable left-click paste (mousedown instead of click, delayed
|
||||
window-blur hide) — fixes intermittent missed pastes
|
||||
- Image paste now writes a real clipboard image instead of text
|
||||
|
||||
### Performance
|
||||
- Clipboard polling watches macOS `NSPasteboard` `changeCount` instead
|
||||
of hashing clipboard contents on a fixed timer — eliminates busy CPU
|
||||
usage while idle
|
||||
- Picker list/search IPC returns truncated text previews and image
|
||||
thumbnails only; full content (including image blobs) is fetched
|
||||
lazily, only when an item is actually pasted
|
||||
- Frontend refreshes on `clipboard-changed` / window-focus events plus
|
||||
a cheap `latest_entry_id` poll, instead of reloading all rows every
|
||||
second
|
||||
- SQLite opened with `journal_mode=WAL` + `synchronous=NORMAL` so the
|
||||
UI can read while the background poller writes
|
||||
- List rows are memoized (`React.memo`) so selection/focus changes no
|
||||
longer re-render every row's preview
|
||||
|
||||
## [0.1.0] — initial
|
||||
|
||||
- Menu bar clipboard manager: capture text/images/files, full-text
|
||||
search (SQLite FTS5), multi-select, pin, paste-and-refocus via
|
||||
AppleScript, resizable window, dark/light mode, auto-trim history
|
||||
80
README.md
80
README.md
@ -9,8 +9,9 @@ A fast, native macOS clipboard manager that lives in your menu bar. Built with T
|
||||
## Features
|
||||
|
||||
- **Menu bar app** — no dock icon, stays out of your way
|
||||
- **Global hotkey** — `Cmd+Shift+V` opens the window from anywhere
|
||||
- **Clipboard monitoring** — polls every 500ms for text, images, and file paths
|
||||
- **Global hotkey** — `` Ctrl+` `` opens the window (doesn’t steal macOS Cmd+` window-cycle); configurable in Settings
|
||||
- **Clipboard monitoring** — watches the macOS pasteboard changeCount (no busy hashing while idle); captures text, images, and file paths
|
||||
- **Fast picker** — list API returns truncated previews only (image blobs stay in SQLite until paste); refreshes on copy / window focus instead of reloading everything every second
|
||||
- **Full-text search** — instant filtering via SQLite FTS5
|
||||
- **Quick paste** — `Cmd+1` through `Cmd+9` to paste the Nth item directly into the previous app
|
||||
- **Paste & return** — clicking an entry copies it to clipboard, hides the window, and auto-pastes into the previously-focused app
|
||||
@ -19,11 +20,27 @@ A fast, native macOS clipboard manager that lives in your menu bar. Built with T
|
||||
- **Context menu** — right-click for Paste, Pin/Unpin, Delete (with multi-select support)
|
||||
- **Resizable window** — drag edges to resize; size is remembered between sessions
|
||||
- **Window positioning** — choose where the window appears: near cursor, center, or any corner (configurable in Settings)
|
||||
- **Auto-trim** — keeps up to 50K entries (configurable: 1K/5K/10K/50K)
|
||||
- **Auto-trim** — keeps up to 50K entries (configurable: 1K/5K/10K/50K); oversized images are skipped
|
||||
- **Dark/light mode** — follows macOS system appearance
|
||||
- **Privacy** — whitespace-only entries are ignored; pause monitoring from the tray
|
||||
- **Privacy** — whitespace-only entries are ignored; password/card/banking previews are redacted (full value still pastes); pause monitoring from the tray
|
||||
- **Paste plain / transforms** — ⌥-click or right-click for plain text and transforms (trim, case, JSON, …)
|
||||
- **Copy only** — right-click → Copy puts content on the clipboard without auto-pasting or hiding the window
|
||||
- **Color swatches** — hex/rgb/hsl entries (e.g. `#3366ff`) show a small color chip in the list
|
||||
- **Launch at login** — Settings toggle registers a macOS Login Item (use the built `.app`)
|
||||
- **Image thumbnails** — list shows small thumbs; full images stay in the DB until paste
|
||||
|
||||
## Prerequisites
|
||||
## Docs
|
||||
|
||||
| Doc | |
|
||||
|---|---|
|
||||
| [docs/GUIDE.md](docs/GUIDE.md) | **Install + usage guide** (the living doc — start here if you just want to use the app) |
|
||||
| [docs/README.md](docs/README.md) | Docs index |
|
||||
| [docs/TESTING.md](docs/TESTING.md) | Automated tests |
|
||||
| [docs/LAUNCH-AT-LOGIN.md](docs/LAUNCH-AT-LOGIN.md) | Start when Mac logs in |
|
||||
| [docs/PRODUCT.md](docs/PRODUCT.md) | Professional / shipping checklist |
|
||||
| [docs/SHARING.md](docs/SHARING.md) | Where the app lives + how to share it with others |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Version history |
|
||||
| [ROADMAP.md](ROADMAP.md) | Next features + brainstorm |
|
||||
|
||||
- **macOS** 10.15+
|
||||
- **Rust** 1.77+ — [install via rustup](https://rustup.rs)
|
||||
@ -34,7 +51,7 @@ A fast, native macOS clipboard manager that lives in your menu bar. Built with T
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone gitea@10.0.30.169:ilia/maCopy.git
|
||||
git clone gitea@git.levkin.ca:ilia/maCopy.git
|
||||
cd maCopy
|
||||
npm install
|
||||
npm run tauri dev
|
||||
@ -46,8 +63,11 @@ The app will compile the Rust backend, start the Vite dev server, and launch the
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| Open/close window | Click tray icon or press `Cmd+Shift+V` |
|
||||
| Open/close window | Click tray icon or press `` Ctrl+` `` (configurable in Settings) |
|
||||
| Paste an entry | Click it, or press `Enter` |
|
||||
| Paste plain (no formatting) | `⌥`/`Alt`+click, or right-click → Paste plain |
|
||||
| Copy without pasting | Right-click → Copy |
|
||||
| Transforms | Right-click → Transform (trim, case, JSON, …) |
|
||||
| Quick paste | `Cmd+1` through `Cmd+9` |
|
||||
| Search | Just start typing |
|
||||
| Select multiple | `Cmd+Click` or `Shift+Arrow` |
|
||||
@ -55,6 +75,8 @@ The app will compile the Rust backend, start the Vite dev server, and launch the
|
||||
| Delete | `Backspace` or `Delete` (on selected items) |
|
||||
| Pin/unpin | Right-click → Pin/Unpin |
|
||||
| Settings | Tray icon → Settings… |
|
||||
| Change hotkey | Settings → Global hotkey → press new shortcut |
|
||||
| Launch at login | Settings → Launch at login (see [docs/LAUNCH-AT-LOGIN.md](docs/LAUNCH-AT-LOGIN.md)) |
|
||||
| Dismiss | `Escape` or click outside |
|
||||
|
||||
## Development
|
||||
@ -99,8 +121,8 @@ maCopy/
|
||||
|---|---|
|
||||
| `npm run tauri dev` | Run in development mode with hot reload |
|
||||
| `npm run tauri build` | Build a release `.app` bundle |
|
||||
| `npm test` | Run frontend tests (Vitest, 47 tests) |
|
||||
| `npm run test:rust` | Run Rust backend tests (27 tests) |
|
||||
| `npm test` | Run frontend tests (Vitest, ~60 tests) |
|
||||
| `npm run test:rust` | Run Rust backend tests (~52 tests) |
|
||||
| `npm run test:all` | Run all tests (frontend + backend) |
|
||||
| `npm run lint` | TypeScript type-check |
|
||||
| `npm run check` | Lint + all tests |
|
||||
@ -121,21 +143,21 @@ maCopy/
|
||||
|
||||
## Architecture
|
||||
|
||||
### Clipboard Polling
|
||||
### Clipboard monitoring
|
||||
|
||||
A dedicated Rust thread polls the system clipboard every 500ms using the `arboard` crate. Each new clipboard value is hashed (SHA-256) and compared against the last known hash to avoid duplicates. Text, images (stored as base64 PNG data URIs), and file paths are all captured.
|
||||
A background Rust thread watches the macOS pasteboard **changeCount** and only reads the clipboard when it changes. Text, images (full PNG + thumbnail), and file paths are captured. List IPC returns truncated / redacted previews and image thumbnails — not multi‑MB blobs.
|
||||
|
||||
### Paste & Return
|
||||
|
||||
When you select an entry, maCopy writes it to the system clipboard, hides its window, waits 250ms for macOS to refocus the previous app, then simulates `Cmd+V` via AppleScript. This requires Accessibility permission.
|
||||
When you select an entry, maCopy writes it to the system clipboard (text or image), hides its window, waits briefly for macOS to refocus the previous app, then simulates `Cmd+V` via AppleScript. This requires Accessibility permission. Optional transforms (plain, trim, JSON, …) run before the write.
|
||||
|
||||
### SQLite + FTS5
|
||||
|
||||
The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type.
|
||||
The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. Opened with `journal_mode=WAL` + `synchronous=NORMAL` so the UI can read while the background poller writes, without blocking on full fsyncs.
|
||||
|
||||
### Window Behavior
|
||||
|
||||
The window uses `titleBarStyle: "overlay"` for native resize handles while keeping the frameless aesthetic. It's always-on-top and hides on blur. Position is determined by the user's setting (near cursor via CoreGraphics, center, or a screen corner).
|
||||
The window uses `titleBarStyle: "overlay"` for native resize handles while keeping the frameless aesthetic. It's always-on-top and hides on blur (with a short delay so clicks register). Position is determined by the user's setting (near cursor via CoreGraphics, center, or a screen corner).
|
||||
|
||||
## Data Storage
|
||||
|
||||
@ -147,27 +169,12 @@ The SQLite database is stored at:
|
||||
|
||||
## Testing
|
||||
|
||||
### Frontend (47 tests)
|
||||
See **[docs/TESTING.md](docs/TESTING.md)** for the full map.
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Tests cover App integration, SearchBar, ClipboardList (including multi-select), ContextMenu, and SettingsPanel. Tauri APIs are mocked in `src/test/setup.ts`.
|
||||
|
||||
### Backend (27 tests)
|
||||
|
||||
```bash
|
||||
npm run test:rust
|
||||
```
|
||||
|
||||
Tests use in-memory SQLite databases and cover: CRUD operations, FTS5 search, auto-trim with pin preservation, settings persistence, SHA-256 hashing, and PNG encoding.
|
||||
|
||||
### All tests
|
||||
|
||||
```bash
|
||||
npm run test:all # 74 total tests
|
||||
npm run check # lint + all tests
|
||||
npm test # ~60 frontend tests
|
||||
npm run test:rust # ~52 Rust tests
|
||||
npm run check # lint + all tests (~112)
|
||||
```
|
||||
|
||||
## Building for Release
|
||||
@ -178,6 +185,13 @@ npm run tauri build
|
||||
|
||||
The built `.app` bundle will be in `src-tauri/target/release/bundle/macos/`.
|
||||
|
||||
Copy it to `/Applications`, then enable **Launch at login** in Settings — details in [docs/LAUNCH-AT-LOGIN.md](docs/LAUNCH-AT-LOGIN.md).
|
||||
|
||||
## Roadmap & product readiness
|
||||
|
||||
- Feature backlog: [ROADMAP.md](ROADMAP.md)
|
||||
- Professional shipping checklist: [docs/PRODUCT.md](docs/PRODUCT.md)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
84
ROADMAP.md
Normal file
84
ROADMAP.md
Normal file
@ -0,0 +1,84 @@
|
||||
# maCopy roadmap
|
||||
|
||||
Living list of shipping ideas. Checked items are done (or landed in the current branch).
|
||||
|
||||
## Done recently
|
||||
|
||||
- [x] Global hotkey `` Ctrl+` `` (avoids macOS Cmd+` window-cycle); configurable
|
||||
- [x] Performance: pasteboard changeCount, list previews, no full-image list payloads
|
||||
- [x] Image thumbnails in the picker (+ backfill)
|
||||
- [x] Reliable left-click paste (mousedown + delayed blur hide)
|
||||
- [x] Image paste via real clipboard image write
|
||||
- [x] **Sensitive redaction** — save passwords / cards / banking / secrets fully, show `••••` in the list; paste still uses the real value
|
||||
- [x] **Paste transforms** — right-click → Transform (trim, case, collapse, JSON pretty, single line)
|
||||
- [x] **Paste plain** — ⌥/Alt+click or “Paste plain” (strip HTML-ish formatting / entities)
|
||||
- [x] Launch at login wired to macOS Login Items
|
||||
- [x] Visible `npm run test:visual` walkthrough
|
||||
- [x] **Copy only** — right-click → Copy puts content on the clipboard without auto-paste/hide
|
||||
- [x] **Color swatches** — hex/rgb/hsl entries show a preview chip (Ditto/Pastebot-style)
|
||||
- [x] **SQLite speed pragmas** — WAL + synchronous=NORMAL so reads don't block on writer fsyncs
|
||||
- [x] **Memoized list rows** — selection/focus changes no longer re-render every row's preview
|
||||
|
||||
## Next up (high value)
|
||||
|
||||
- [ ] **Exclude apps** — don’t capture from 1Password / banking / Terminal patterns (macOS frontmost app filter)
|
||||
- [ ] **Signed + notarized release** + install to `/Applications` (see [docs/SHARING.md](docs/SHARING.md))
|
||||
- [ ] **Accessibility first-run guide**
|
||||
- [ ] **CI** on `npm run check`
|
||||
- [ ] **Default transform preference** in Settings
|
||||
- [ ] **Snippets / favorites** — named, always-available text (separate from history)
|
||||
- [ ] **Ignore patterns** — regex list that auto-drops or auto-redacts matches
|
||||
- [ ] **Auto-delete window for sensitive** — purge redacted items after N minutes (optional)
|
||||
- [ ] **Virtualized list** — if history UI grows past a few hundred rows
|
||||
|
||||
## Later / brainstorm
|
||||
|
||||
### Privacy & trust
|
||||
- Encrypt DB at rest (macOS Keychain-wrapped key)
|
||||
- Per-entry “never show again” / blacklist hash
|
||||
- Paste confirmation for sensitive items (`Confirm paste?`)
|
||||
- Clear sensitive on lock / sleep
|
||||
- Don’t sync sensitive items if/when sync exists
|
||||
|
||||
### Capture quality
|
||||
- HTML + plain dual capture with “prefer plain” default
|
||||
- File lists / Finder copy
|
||||
- Link enrichment (title/favicon for URLs)
|
||||
- OCR on image entries → searchable text (private, on-device)
|
||||
|
||||
### Paste power
|
||||
- Paste stack / sequential paste (next item on each paste)
|
||||
- Paste as keystrokes (for fields that block paste)
|
||||
- Numbered paste queue from multi-select order
|
||||
- Date/time templates, UUID, lorem in snippets
|
||||
- Shell-escape / URL-encode / base64 transforms
|
||||
- “Edit before paste” quick editor sheet
|
||||
|
||||
### UX polish
|
||||
- Compact vs comfortable density
|
||||
- Pin board / tabs (All · Pins · Images · Sensitive)
|
||||
- Drag item onto another app
|
||||
- Keyboard: Tab into transforms, `/` transform palette
|
||||
- Menu bar: last N items quick-paste submenu
|
||||
- Sound / haptic optional on paste
|
||||
|
||||
### Sync & backup
|
||||
- Optional Tailscale / local LAN sync between machines
|
||||
- Export/import encrypted archive
|
||||
- Time-machine-friendly atomic DB
|
||||
|
||||
### Power-user system
|
||||
- Alfred / Raycast callback URL or CLI (`macopy paste 3 --plain`)
|
||||
- Shortcuts / URL scheme
|
||||
- AppleScript / JXA bridge
|
||||
|
||||
### Hardening
|
||||
- Sandbox-friendly Accessibility prompt flow + in-app guide
|
||||
- Crash-safe write-ahead DB
|
||||
- Telemetry: none (keep it that way); optional local diagnostics export
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- Cloud sync through a third-party host
|
||||
- Browser extension dependency
|
||||
- AI “summarize my clipboard” calling external APIs by default
|
||||
155
docs/GUIDE.md
Normal file
155
docs/GUIDE.md
Normal file
@ -0,0 +1,155 @@
|
||||
# maCopy — Install & Usage Guide
|
||||
|
||||
The single living reference for **installing** and **using** maCopy.
|
||||
Update this whenever a shortcut, setting, or behavior changes — this is
|
||||
the doc a new user (including future-you) should be able to follow start
|
||||
to finish without reading any source code.
|
||||
|
||||
Current version: see [CHANGELOG.md](../CHANGELOG.md). Feature backlog:
|
||||
[ROADMAP.md](../ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Install
|
||||
|
||||
### Option A — you already have a built DMG/app
|
||||
|
||||
1. Double-click the DMG, drag **maCopy.app** into **Applications**.
|
||||
2. First launch: Gatekeeper will likely warn the app is unverified
|
||||
(it's ad-hoc signed, not notarized yet — see
|
||||
[docs/SHARING.md](SHARING.md)). Right-click the app → **Open** → confirm.
|
||||
3. macOS will prompt for **Accessibility** permission the first time you
|
||||
try to paste — this is required for auto-paste (`Cmd+V` simulation).
|
||||
System Settings → Privacy & Security → Accessibility → enable maCopy.
|
||||
4. Look for the maCopy icon in the menu bar (top-right). No Dock icon —
|
||||
that's expected, it's a menu-bar-only app.
|
||||
|
||||
### Option B — build it yourself
|
||||
|
||||
Requirements: macOS 10.15+, [Rust](https://rustup.rs) 1.77+, Node 18+,
|
||||
Xcode Command Line Tools (`xcode-select --install`).
|
||||
|
||||
```bash
|
||||
git clone gitea@git.levkin.ca:ilia/maCopy.git
|
||||
cd maCopy
|
||||
npm install
|
||||
npm run tauri build
|
||||
open src-tauri/target/release/bundle/macos/maCopy.app
|
||||
```
|
||||
|
||||
Copy the resulting `.app` to `/Applications` if you want **Launch at
|
||||
login** to stick across rebuilds (see step 4 below).
|
||||
|
||||
### First-run checklist
|
||||
|
||||
- [ ] App icon visible in the menu bar
|
||||
- [ ] Accessibility permission granted (Settings → Privacy & Security → Accessibility)
|
||||
- [ ] Global hotkey `` Ctrl+` `` opens/closes the picker
|
||||
- [ ] (Optional) Settings → **Launch at login** turned on
|
||||
|
||||
---
|
||||
|
||||
## 2. Everyday usage
|
||||
|
||||
### Opening the picker
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| Open/close | Click the menu bar icon, or press `` Ctrl+` `` |
|
||||
| Dismiss | `Escape`, or click outside the window |
|
||||
|
||||
The hotkey is configurable — Settings → **Global hotkey** → click the
|
||||
field → press your new combo.
|
||||
|
||||
### Pasting
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| Paste an item into the app you were just using | Click it, or select + `Enter` |
|
||||
| Paste **plain** (strip formatting) | `⌥`/`Alt` + click, or right-click → **Paste plain** |
|
||||
| Paste with a transform | Right-click → **Transform** → pick one |
|
||||
| Paste the Nth item directly (no picker needed) | `Cmd+1` … `Cmd+9` while picker is open |
|
||||
| Copy to clipboard **without** auto-pasting | Right-click → **Copy** |
|
||||
|
||||
**Transforms** (right-click → Transform): trim whitespace, UPPERCASE /
|
||||
lowercase / Title Case, collapse whitespace/newlines, pretty-print JSON,
|
||||
join to a single line. Transforms apply just before the paste — the
|
||||
saved history entry is never modified.
|
||||
|
||||
### Selecting & managing history
|
||||
|
||||
| Action | How |
|
||||
|---|---|
|
||||
| Multi-select | `Cmd+Click` to toggle one, `Shift+Click`/`Shift+Arrow` for a range, `Cmd+A` for all |
|
||||
| Paste multiple selected items (joined with newlines) | `Enter` |
|
||||
| Delete | `Backspace`/`Delete` with items selected |
|
||||
| Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** |
|
||||
| Search | Just start typing — instant full-text search |
|
||||
|
||||
### What you'll see in the list
|
||||
|
||||
- **Images** show a small thumbnail; the full-resolution image stays in
|
||||
the database and is only loaded when you actually paste.
|
||||
- **Colors** — an entry that's *exactly* a hex/rgb/hsl value (e.g.
|
||||
`#3366ff`) shows a small color chip next to it.
|
||||
- **Sensitive items** (passwords, card numbers, banking details) show
|
||||
`••` and a redacted preview in the list — the real value is still
|
||||
stored and still pastes correctly. See "Privacy" below.
|
||||
- **PIN** badge — pinned items, always sorted to the top, never
|
||||
auto-trimmed.
|
||||
- `⌘1`–`⌘9` badges on the first nine rows — quick-paste reference.
|
||||
|
||||
### Settings panel
|
||||
|
||||
Menu bar icon → **Settings…**
|
||||
|
||||
| Setting | What it does |
|
||||
|---|---|
|
||||
| Global hotkey | Re-bind the open/close shortcut |
|
||||
| Show images | Toggle thumbnail rendering in the list (off = faster on huge histories) |
|
||||
| Max history | 1K / 5K / 10K / 50K entries kept before auto-trim (pinned items are exempt) |
|
||||
| Window position | Near cursor, center, or a screen corner |
|
||||
| Launch at login | Registers/unregisters a macOS Login Item — see [docs/LAUNCH-AT-LOGIN.md](LAUNCH-AT-LOGIN.md) |
|
||||
| Pause monitoring | Tray menu checkbox — temporarily stop capturing new clipboard content |
|
||||
|
||||
---
|
||||
|
||||
## 3. Privacy — what gets captured and how
|
||||
|
||||
- Whitespace-only clipboard content is ignored (never saved).
|
||||
- Password/card/banking-looking text is detected and **redacted in the
|
||||
list preview only** — the real value is still in the database (needed
|
||||
so paste still works) and still pastes correctly.
|
||||
- No network calls, no telemetry, no accounts. Everything lives in:
|
||||
```
|
||||
~/Library/Application Support/maCopy/clipboard.db
|
||||
```
|
||||
- You can pause monitoring any time from the tray menu.
|
||||
- Full picture, including what's *not yet* implemented (encryption at
|
||||
rest, per-app exclusions): [ROADMAP.md](../ROADMAP.md) → "Privacy & trust".
|
||||
|
||||
---
|
||||
|
||||
## 4. Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| Hotkey does nothing | Settings → check it isn't a duplicate of a macOS shortcut (the old default `Cmd+\`` conflicted with window-cycling — current default is `` Ctrl+` ``) |
|
||||
| Paste doesn't happen (item copies but old app doesn't receive it) | Grant **Accessibility** permission — System Settings → Privacy & Security → Accessibility → add/enable maCopy |
|
||||
| App won't open ("cannot verify developer") | Right-click → Open, or `xattr -cr /Applications/maCopy.app` — see [docs/SHARING.md](SHARING.md) |
|
||||
| Launch at login doesn't stick after rebuild | You're running a dev/rebuilt binary at a new path — install the `.app` under `/Applications` and re-toggle the setting |
|
||||
| App feels slow / using lots of memory | Update to the latest build — see the performance notes in [README.md](../README.md#architecture); file it as a regression if it persists |
|
||||
|
||||
---
|
||||
|
||||
## 5. Where things live (quick reference)
|
||||
|
||||
| What | Path |
|
||||
|---|---|
|
||||
| App (after build) | `src-tauri/target/release/bundle/macos/maCopy.app` |
|
||||
| Installer DMG | `src-tauri/target/release/bundle/dmg/maCopy_<version>_aarch64.dmg` |
|
||||
| Your clipboard database | `~/Library/Application Support/maCopy/clipboard.db` |
|
||||
| Source | this repo |
|
||||
|
||||
Sharing the app with someone else: [docs/SHARING.md](SHARING.md).
|
||||
Full doc index: [docs/README.md](README.md).
|
||||
45
docs/LAUNCH-AT-LOGIN.md
Normal file
45
docs/LAUNCH-AT-LOGIN.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Launch at login (start when Mac loads)
|
||||
|
||||
maCopy can register itself as a macOS **Login Item** so it starts when you log in.
|
||||
|
||||
## In the app (preferred)
|
||||
|
||||
1. Build and install a real app bundle (dev mode often cannot register Login Items reliably):
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
open src-tauri/target/release/bundle/macos/maCopy.app
|
||||
```
|
||||
|
||||
2. Menu bar tray → **Settings…** → turn on **Launch at login**.
|
||||
|
||||
3. Confirm in System Settings → **General** → **Login Items & Extensions** → maCopy is listed.
|
||||
|
||||
Turning the toggle off removes the Login Item.
|
||||
|
||||
## How it works
|
||||
|
||||
- Settings UI calls `@tauri-apps/plugin-autostart` (`enable` / `disable` / `isEnabled`).
|
||||
- Preference is also stored in SQLite (`launch_at_login`) for display consistency.
|
||||
- Backend plugin is initialized with `MacosLauncher::LaunchAgent`.
|
||||
|
||||
## Manual fallback
|
||||
|
||||
If the toggle fails:
|
||||
|
||||
1. System Settings → General → Login Items → **+** → choose `maCopy.app`
|
||||
2. Or: right-click maCopy in the Dock (while open) → Options → **Open at Login**
|
||||
|
||||
## Requirements for it to stick
|
||||
|
||||
| Need | Why |
|
||||
|---|---|
|
||||
| Installed `.app` under `/Applications` (or a stable path) | Login Items point at a path; rebuilds under `target/` get orphaned |
|
||||
| Quit `tauri dev` when testing login | Don’t mix debug binary paths with the release app |
|
||||
| Accessibility still granted after update | Auto-paste needs Privacy → Accessibility → maCopy |
|
||||
|
||||
## Verify after reboot
|
||||
|
||||
1. Restart Mac / log out and in
|
||||
2. Copy some text
|
||||
3. Press `` Ctrl+` `` — picker should appear without launching manually
|
||||
75
docs/PRODUCT.md
Normal file
75
docs/PRODUCT.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Making maCopy a professional product
|
||||
|
||||
Checklist beyond “it works on my machine.” Items marked done are already in-repo.
|
||||
|
||||
## Already in good shape
|
||||
|
||||
- [x] Native menu-bar UX (Accessory activation, tray)
|
||||
- [x] Configurable global hotkey
|
||||
- [x] Automated unit tests + `npm run check`
|
||||
- [x] Performance-minded clipboard monitoring + list payloads
|
||||
- [x] Image thumbnails; sensitive redaction (preview)
|
||||
- [x] Paste plain + transforms
|
||||
- [x] Living docs: README, ROADMAP, TESTING, Launch-at-login
|
||||
- [x] Launch at login wired to macOS Login Items (release `.app`)
|
||||
|
||||
## Ship blockers (do these for v1)
|
||||
|
||||
1. **Installable release path**
|
||||
- Copy `maCopy.app` → `/Applications`
|
||||
- Document version / changelog (`CHANGELOG.md`)
|
||||
|
||||
2. **Code signing + notarization**
|
||||
- Apple Developer ID Application certificate
|
||||
- `codesign` + `notarytool` (or Tauri bundler config)
|
||||
- Without this: Gatekeeper blocks random users
|
||||
|
||||
3. **First-run permissions guide**
|
||||
- In-app sheet: Accessibility for paste, optional screen recording later
|
||||
- Deep link / button to open System Settings pane
|
||||
|
||||
4. **Stable autostart**
|
||||
- Only advertise Launch at login after install to `/Applications`
|
||||
- Re-register Login Item after updates that change the bundle path
|
||||
|
||||
5. **CI**
|
||||
- GitHub/Gitea Action: `npm run check` on every PR
|
||||
- Optional macOS runner for `tauri build` artifacts
|
||||
|
||||
## Product polish (v1.x)
|
||||
|
||||
| Area | Idea |
|
||||
|---|---|
|
||||
| Updates | Sparkle or Tauri updater + signed feeds |
|
||||
| Branding | Final icon, dmg background, website one-pager |
|
||||
| Privacy | Short privacy policy (local-only DB; no telemetry) |
|
||||
| Support | Issue templates, FAQ (hotkey conflict with cycle-windows) |
|
||||
| Onboarding | Empty-state tips; `` Ctrl+` `` callout |
|
||||
| Crash reports | Optional local-only panic log export |
|
||||
| Quality | E2E smoke script (open → copy → paste) on macOS CI |
|
||||
|
||||
## Trust & privacy (especially with redaction)
|
||||
|
||||
- Be explicit: **sensitive items are stored** (encrypted-at-rest still TODO) — redaction is UI-only
|
||||
- Offer “purge sensitive after N minutes” (on roadmap)
|
||||
- Exclude password-manager apps from capture (on roadmap)
|
||||
|
||||
## Distribution options
|
||||
|
||||
| Channel | Notes |
|
||||
|---|---|
|
||||
| Direct DMG / zip | Simple; needs notarization |
|
||||
| Homebrew cask | Great for power users once signed |
|
||||
| Mac App Store | Heavier review; clipboard monitors are tricky under sandbox |
|
||||
| Private TestFlight-style | Not really for menu-bar utilities; skip |
|
||||
|
||||
## Suggested “done for professional v1”
|
||||
|
||||
- [ ] Signed + notarized `.app` / DMG
|
||||
- [ ] Installed under `/Applications` + Launch at login verified after reboot
|
||||
- [ ] CI green on `npm run check`
|
||||
- [ ] Accessibility onboarding UI
|
||||
- [ ] CHANGELOG + version bump process
|
||||
- [ ] Privacy blurb in README + Settings
|
||||
|
||||
Everything else can ride the [ROADMAP](../ROADMAP.md).
|
||||
18
docs/README.md
Normal file
18
docs/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# maCopy docs index
|
||||
|
||||
Living product docs for shipping and maintaining maCopy.
|
||||
|
||||
| Doc | Purpose |
|
||||
|---|---|
|
||||
| [docs/GUIDE.md](./GUIDE.md) | **Start here** — install + full usage reference (living doc) |
|
||||
| [README.md](../README.md) | Dev-oriented: architecture, project structure, how to run tests |
|
||||
| [CHANGELOG.md](../CHANGELOG.md) | What shipped in each version |
|
||||
| [ROADMAP.md](../ROADMAP.md) | Shipped vs next vs brainstorm |
|
||||
| [docs/TESTING.md](./TESTING.md) | Test stack, commands, coverage map |
|
||||
| [docs/LAUNCH-AT-LOGIN.md](./LAUNCH-AT-LOGIN.md) | Start on Mac login |
|
||||
| [docs/SHARING.md](./SHARING.md) | Where the app lives + how to share a build |
|
||||
| [docs/PRODUCT.md](./PRODUCT.md) | What “professional product” still needs |
|
||||
|
||||
Keep these current when behavior changes — **`docs/GUIDE.md` is the
|
||||
living doc for end users**, README + ROADMAP are the living docs for
|
||||
development.
|
||||
90
docs/SHARING.md
Normal file
90
docs/SHARING.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Sharing maCopy with someone else
|
||||
|
||||
## Where the app lives on your Mac
|
||||
|
||||
| What | Path |
|
||||
|---|---|
|
||||
| Source code | `~/Documents/code/maCopy` (this repo) |
|
||||
| Built `.app` (after `npm run tauri build`) | `src-tauri/target/release/bundle/macos/maCopy.app` |
|
||||
| Built `.dmg` installer | `src-tauri/target/release/bundle/dmg/maCopy_<version>_aarch64.dmg` |
|
||||
| Clipboard database (your data, never shipped) | `~/Library/Application Support/maCopy/clipboard.db` |
|
||||
| Vault/secrets | none — maCopy has no network calls and no accounts |
|
||||
|
||||
The `.app` and `.dmg` are build **artifacts**: they're regenerated by
|
||||
`npm run tauri build` and are git-ignored. Nothing under `target/` is
|
||||
committed to the repo.
|
||||
|
||||
## Fastest way to share it today (unsigned)
|
||||
|
||||
1. Build it: `npm run tauri build`
|
||||
2. Grab the DMG: `src-tauri/target/release/bundle/dmg/maCopy_0.1.0_aarch64.dmg`
|
||||
3. Send that file (AirDrop, USB, file share — whatever).
|
||||
|
||||
### What the other person needs to do
|
||||
|
||||
The build is **ad-hoc signed** (`codesign -dv` shows `Signature=adhoc`,
|
||||
`TeamIdentifier=not set`) — not signed with an Apple Developer ID and not
|
||||
notarized. macOS Gatekeeper will refuse to open it with the default
|
||||
"Apple could not verify… malware" dialog on a *different* Mac (your own
|
||||
Mac trusts it already because you built it there). This is normal for any
|
||||
indie app that isn't notarized — it's not a maCopy-specific bug.
|
||||
|
||||
The recipient has two options:
|
||||
|
||||
**Option A — right-click override (easiest, no Terminal)**
|
||||
1. Copy `maCopy.app` to `/Applications`.
|
||||
2. Right-click (or Control-click) the app → **Open** → confirm **Open** in
|
||||
the dialog. This works even when double-click is blocked, and only
|
||||
needs to be done once.
|
||||
|
||||
**Option B — clear the quarantine flag (Terminal)**
|
||||
```bash
|
||||
xattr -cr /Applications/maCopy.app
|
||||
```
|
||||
Removes the "downloaded from the internet" quarantine attribute that
|
||||
triggers Gatekeeper's warning, then the app opens normally.
|
||||
|
||||
Either way, once it's launched once and allowed, it behaves like any
|
||||
other menu-bar app — including **Launch at login** in Settings.
|
||||
|
||||
## The "real" way — signed + notarized (for wider distribution)
|
||||
|
||||
If you want to share this with people who shouldn't have to right-click
|
||||
or run Terminal commands (e.g. distributing beyond just you/family), you
|
||||
need:
|
||||
|
||||
1. An Apple Developer ID ($99/yr Apple Developer Program).
|
||||
2. A "Developer ID Application" certificate in your keychain.
|
||||
3. Tauri's bundler config filled in (`tauri.conf.json` → `bundle.macOS.signingIdentity`)
|
||||
or a manual `codesign --deep --sign "Developer ID Application: ..."` step.
|
||||
4. Notarization via `xcrun notarytool submit ... --wait` (or `tauri-plugin-updater`'s
|
||||
signing helpers), then `xcrun stapler staple maCopy.app`.
|
||||
|
||||
This is tracked as a ship-blocker in [docs/PRODUCT.md](PRODUCT.md) — not
|
||||
done yet because it requires an Apple Developer account, which is a cost/
|
||||
identity decision for you to make, not something that can be automated
|
||||
away.
|
||||
|
||||
## Other distribution ideas (once signed)
|
||||
|
||||
| Channel | Effort | Notes |
|
||||
|---|---|---|
|
||||
| Direct DMG (current) | none | Works today; Gatekeeper friction as above |
|
||||
| Private Gitea release / file share | low | Attach the DMG to a Gitea release on your own instance |
|
||||
| Homebrew cask (`brew install --cask`) | medium | Needs a signed release + a cask formula in a tap |
|
||||
| Mac App Store | high | Sandbox rules make background clipboard monitoring + AppleScript paste tricky; likely not worth it for this app |
|
||||
|
||||
## Sharing the source instead of a binary
|
||||
|
||||
If the other person can build it themselves (has Rust + Node), just give
|
||||
them repo access:
|
||||
|
||||
```bash
|
||||
git clone gitea@git.levkin.ca:ilia/maCopy.git
|
||||
cd maCopy
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
No signing needed for this path since it's *their* build on *their* Mac —
|
||||
same Gatekeeper trust maCopy already has on yours.
|
||||
56
docs/TESTING.md
Normal file
56
docs/TESTING.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Testing
|
||||
|
||||
maCopy has an automated suite. There is no separate “live” Storybook/UI docs site yet — product behavior is documented in README / ROADMAP, and verified by tests.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it runs |
|
||||
|---|---|
|
||||
| `npm test` | Frontend (Vitest + Testing Library, jsdom — CI default) |
|
||||
| `npm run test:ui` | Same tests in the Vitest UI (open in your browser) |
|
||||
| `npm run test:headed` | Unit tests in Chromium — still mostly a flash (DOM mount/unmount) |
|
||||
| `npm run test:visual` | **Watchable** headed Playwright walkthrough of the real UI (`visual.html`) |
|
||||
| `npm run test:watch` | Frontend in watch mode |
|
||||
| `npm run test:rust` | Rust unit tests (`cargo test`) |
|
||||
| `npm run test:all` | Frontend + Rust |
|
||||
| `npm run lint` | TypeScript `tsc --noEmit` |
|
||||
| `npm run check` | Lint + all tests (CI-style gate) |
|
||||
|
||||
Current counts (approx.): **~60 frontend** + **~52 Rust** unit tests.
|
||||
|
||||
## What is covered
|
||||
|
||||
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
|
||||
|
||||
- App: load entries, paste invoke, delete via keyboard
|
||||
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs, Alt=plain paste
|
||||
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
|
||||
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
|
||||
- SearchBar basics
|
||||
- Transforms: plain text strip, JSON pretty, collapse / oneline
|
||||
|
||||
Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard, autostart).
|
||||
|
||||
### Backend (`src-tauri/src/**` `#[cfg(test)]`)
|
||||
|
||||
- SQLite CRUD, FTS5, trim + pin preservation, settings
|
||||
- List previews: truncate text, omit image blobs, return thumbnails
|
||||
- Sensitive redaction (card / password / banking) while `get_entry` stays full
|
||||
- Hotkey parser (`cmd+\`` etc.)
|
||||
- Image thumbnail / PNG helpers
|
||||
- Clipboard content hashing fingerprints
|
||||
|
||||
## What is *not* covered (yet)
|
||||
|
||||
- End-to-end UI on a real Mac (no Playwright against the Tauri window)
|
||||
- Accessibility permission / AppleScript paste in CI
|
||||
- Notarized release smoke tests
|
||||
- Performance benchmarks in CI
|
||||
|
||||
## Recommended workflow
|
||||
|
||||
```bash
|
||||
npm run check # before every PR / commit
|
||||
npm run tauri dev # manual smoke: hotkey, paste, redaction, thumbs
|
||||
npm run tauri build # release .app when testing Login Items / signing
|
||||
```
|
||||
304
package-lock.json
generated
304
package-lock.json
generated
@ -24,7 +24,10 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.61.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6",
|
||||
@ -381,6 +384,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@blazediff/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
@ -1044,6 +1054,13 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@ -2217,17 +2234,64 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
|
||||
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blazediff/core": "1.9.1",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pngjs": "^7.0.0",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
|
||||
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@ -2236,13 +2300,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@ -2263,9 +2327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2276,13 +2340,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@ -2290,14 +2354,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@ -2306,23 +2370,45 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||
"node_modules/@vitest/ui": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
|
||||
"integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"fflate": "^0.8.2",
|
||||
"flatted": "^3.4.2",
|
||||
"pathe": "^2.0.3",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@ -2696,6 +2782,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@ -3184,6 +3284,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -3268,6 +3378,63 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
@ -3474,6 +3641,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -3603,6 +3785,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
@ -3760,19 +3952,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.6",
|
||||
"@vitest/mocker": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/runner": "4.1.6",
|
||||
"@vitest/snapshot": "4.1.6",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@ -3800,12 +3992,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/browser-preview": "4.1.6",
|
||||
"@vitest/browser-webdriverio": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "4.1.6",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitest/ui": "4.1.6",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@ -3914,6 +4106,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "macopy",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -10,6 +10,9 @@
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui --open --watch",
|
||||
"test:headed": "vitest --config vitest.browser.config.ts --watch=false --browser.headless=false",
|
||||
"test:visual": "node scripts/visual-walkthrough.mjs",
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:all": "npm run test && npm run test:rust",
|
||||
"lint": "tsc --noEmit",
|
||||
@ -32,7 +35,10 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.61.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6",
|
||||
|
||||
102
scripts/visual-walkthrough.mjs
Normal file
102
scripts/visual-walkthrough.mjs
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Headed visual walkthrough — opens Chromium (headless: false) so you can watch.
|
||||
*
|
||||
* npm run test:visual
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const PORT = 1420;
|
||||
const BASE = `http://127.0.0.1:${PORT}/visual.html`;
|
||||
|
||||
async function portOpen(port) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/visual.html`);
|
||||
return res.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureVite() {
|
||||
if (await portOpen(PORT)) return null;
|
||||
const child = spawn("npm", ["run", "dev", "--", "--host", "127.0.0.1"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(250);
|
||||
if (await portOpen(PORT)) return child;
|
||||
}
|
||||
throw new Error("Vite did not start on :1420");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const vite = await ensureVite();
|
||||
console.log("Opening headed Chromium — watch the window…\n");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
slowMo: 400,
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 480, height: 640 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
|
||||
// 1) Show banner
|
||||
await page.waitForSelector('[data-testid="banner"]');
|
||||
await sleep(800);
|
||||
|
||||
// 2) Type in search
|
||||
const input = page.getByPlaceholder("Search…");
|
||||
await input.click();
|
||||
await input.fill("Meeting");
|
||||
await sleep(900);
|
||||
await input.fill("");
|
||||
await sleep(500);
|
||||
|
||||
// 3) Hover / focus list rows
|
||||
await page.getByText("Meeting notes").hover();
|
||||
await sleep(600);
|
||||
|
||||
// 4) Open context menu + Paste plain
|
||||
await page.getByTestId("open-menu").click();
|
||||
await sleep(700);
|
||||
await page.getByText("Paste plain").click();
|
||||
await sleep(1000);
|
||||
|
||||
// 5) Click redacted password row (pastes full secret into banner)
|
||||
await page.getByText("(password)").click({ modifiers: [] });
|
||||
await sleep(1200);
|
||||
|
||||
// 6) Alt-click card row for plain
|
||||
await page.getByText("(card)").click({ modifiers: ["Alt"] });
|
||||
await sleep(1200);
|
||||
|
||||
// 7) Show transform menu → Pretty JSON not relevant; show uppercase on meeting
|
||||
await page.getByText("Meeting notes").click({ button: "right" });
|
||||
await sleep(600);
|
||||
await page.getByText("UPPERCASE").click();
|
||||
await sleep(1500);
|
||||
|
||||
const banner = await page.getByTestId("banner").innerText();
|
||||
console.log("Final banner:", banner);
|
||||
console.log("\nLeaving the window open for 5s so you can look…");
|
||||
await sleep(5000);
|
||||
|
||||
await browser.close();
|
||||
if (vite) {
|
||||
try {
|
||||
process.kill(-vite.pid);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
console.log("Visual walkthrough finished.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
26
src-tauri/Cargo.lock
generated
26
src-tauri/Cargo.lock
generated
@ -1965,7 +1965,11 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"hex",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"png 0.17.16",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -2156,10 +2160,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2179,6 +2190,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@ -2239,6 +2251,19 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@ -2262,6 +2287,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "macopy"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@ -26,10 +26,14 @@ base64 = "0.22"
|
||||
log = "0.4"
|
||||
png = "0.17"
|
||||
dirs = "5"
|
||||
regex = "1"
|
||||
tokio = { version = "1", features = ["time", "macros", "process"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-graphics = "0.24"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
use crate::db::Database;
|
||||
use crate::image_util::{encode_rgba_to_png, make_thumbnail_data_uri};
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// Skip encoding RGBA buffers larger than this (keeps history DB usable).
|
||||
const MAX_IMAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Cap stored full PNG size; list uses a separate tiny thumbnail.
|
||||
const MAX_STORED_PNG_BYTES: usize = 1_500_000;
|
||||
|
||||
/// Hash arbitrary bytes for deduplication.
|
||||
pub fn hash_content(data: &[u8]) -> String {
|
||||
@ -13,15 +20,43 @@ pub fn hash_content(data: &[u8]) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Cheap image fingerprint: dimensions + length + edge samples (not full-buffer SHA).
|
||||
fn fingerprint_image(width: usize, height: usize, bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update((width as u64).to_le_bytes());
|
||||
hasher.update((height as u64).to_le_bytes());
|
||||
hasher.update((bytes.len() as u64).to_le_bytes());
|
||||
let n = bytes.len();
|
||||
let head = n.min(4096);
|
||||
hasher.update(&bytes[..head]);
|
||||
if n > 8192 {
|
||||
hasher.update(&bytes[n - 4096..]);
|
||||
} else if n > head {
|
||||
hasher.update(&bytes[head..]);
|
||||
}
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Shared state so paste_and_refocus can update the hash the polling
|
||||
/// thread compares against, preventing re-insertion of pasted content.
|
||||
pub type LastHash = Arc<Mutex<String>>;
|
||||
|
||||
/// Spawns a background thread that polls the system clipboard every 500ms.
|
||||
/// When new content is detected (by comparing SHA-256 hashes), it is persisted
|
||||
/// to SQLite. The `paused` flag lets the user freeze monitoring from the tray.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn pasteboard_change_count() -> Option<isize> {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
let pb = NSPasteboard::generalPasteboard();
|
||||
Some(pb.changeCount())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pasteboard_change_count() -> Option<isize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Spawns a background thread that polls the system clipboard.
|
||||
/// Uses the macOS pasteboard changeCount to avoid reading/hashing while idle.
|
||||
/// Returns the shared `LastHash` so other commands can update it.
|
||||
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
|
||||
pub fn start_polling(app: AppHandle, db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
|
||||
let initial = db.latest_hash().ok().flatten().unwrap_or_default();
|
||||
let last_hash: LastHash = Arc::new(Mutex::new(initial));
|
||||
let hash_for_thread = last_hash.clone();
|
||||
@ -35,17 +70,28 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
|
||||
}
|
||||
};
|
||||
|
||||
let mut last_change: Option<isize> = None;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::thread::sleep(Duration::from_millis(750));
|
||||
|
||||
if paused.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip all clipboard I/O when macOS reports no pasteboard change.
|
||||
if let Some(count) = pasteboard_change_count() {
|
||||
if last_change == Some(count) {
|
||||
continue;
|
||||
}
|
||||
last_change = Some(count);
|
||||
}
|
||||
|
||||
let current_hash = hash_for_thread.lock().unwrap().clone();
|
||||
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() {
|
||||
// Prefer text. Only probe image when text is absent.
|
||||
match clipboard.get_text() {
|
||||
Ok(text) if !text.trim().is_empty() => {
|
||||
let h = hash_content(text.as_bytes());
|
||||
if h != current_hash {
|
||||
*hash_for_thread.lock().unwrap() = h.clone();
|
||||
@ -59,28 +105,67 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
|
||||
"text"
|
||||
};
|
||||
|
||||
if let Err(e) = db.insert_entry(&text, content_type, &h) {
|
||||
if let Err(e) = db.insert_entry(&text, content_type, &h, None) {
|
||||
log::error!("DB insert error: {}", e);
|
||||
} else {
|
||||
let _ = app.emit("clipboard-changed", ());
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
} else if let Ok(img) = clipboard.get_image() {
|
||||
let h = hash_content(&img.bytes);
|
||||
if h != current_hash {
|
||||
*hash_for_thread.lock().unwrap() = h.clone();
|
||||
|
||||
if let Ok(png_data) = encode_rgba_to_png(
|
||||
img.width as u32,
|
||||
img.height as u32,
|
||||
&img.bytes,
|
||||
) {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||
let data_uri = format!("data:image/png;base64,{}", b64);
|
||||
if let Err(e) = db.insert_entry(&data_uri, "image", &h) {
|
||||
log::error!("DB insert error (image): {}", e);
|
||||
_ => {
|
||||
if let Ok(img) = clipboard.get_image() {
|
||||
if img.bytes.len() > MAX_IMAGE_BYTES {
|
||||
log::warn!(
|
||||
"Skipping oversized clipboard image ({} bytes)",
|
||||
img.bytes.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let h = fingerprint_image(img.width, img.height, &img.bytes);
|
||||
if h == current_hash {
|
||||
continue;
|
||||
}
|
||||
*hash_for_thread.lock().unwrap() = h.clone();
|
||||
|
||||
let w = img.width as u32;
|
||||
let hgt = img.height as u32;
|
||||
let thumb = make_thumbnail_data_uri(w, hgt, &img.bytes).ok();
|
||||
|
||||
match encode_rgba_to_png(w, hgt, &img.bytes) {
|
||||
Ok(png_data) if png_data.len() <= MAX_STORED_PNG_BYTES => {
|
||||
let b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||
let data_uri = format!("data:image/png;base64,{}", b64);
|
||||
if let Err(e) =
|
||||
db.insert_entry(&data_uri, "image", &h, thumb.as_deref())
|
||||
{
|
||||
log::error!("DB insert error (image): {}", e);
|
||||
} else {
|
||||
let _ = app.emit("clipboard-changed", ());
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
Ok(png_data) => {
|
||||
// Still store a thumbnail-only entry so the list can show something.
|
||||
if let Some(ref t) = thumb {
|
||||
let stub = format!("[image {}x{}]", w, hgt);
|
||||
if db
|
||||
.insert_entry(&stub, "image", &h, Some(t))
|
||||
.is_ok()
|
||||
{
|
||||
let _ = app.emit("clipboard-changed", ());
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
log::warn!(
|
||||
"Stored thumbnail only for large PNG ({} bytes encoded)",
|
||||
png_data.len()
|
||||
);
|
||||
}
|
||||
Err(e) => log::error!("PNG encode failed: {}", e),
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -90,18 +175,6 @@ pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) -> LastHash {
|
||||
last_hash
|
||||
}
|
||||
|
||||
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header()?;
|
||||
writer.write_image_data(rgba)?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn trim_if_needed(db: &Database) {
|
||||
if let Ok(settings) = db.get_settings() {
|
||||
let _ = db.trim_entries(settings.max_history);
|
||||
@ -133,11 +206,28 @@ mod tests {
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_image_stable_for_same_buffer() {
|
||||
let bytes = vec![1u8; 20_000];
|
||||
assert_eq!(
|
||||
fingerprint_image(100, 50, &bytes),
|
||||
fingerprint_image(100, 50, &bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_image_changes_with_dimensions() {
|
||||
let bytes = vec![1u8; 20_000];
|
||||
assert_ne!(
|
||||
fingerprint_image(100, 50, &bytes),
|
||||
fingerprint_image(101, 50, &bytes)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rgba_to_png_produces_valid_png() {
|
||||
let rgba = vec![
|
||||
255, 0, 0, 255, 255, 0, 0, 255,
|
||||
255, 0, 0, 255, 255, 0, 0, 255,
|
||||
255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255,
|
||||
];
|
||||
let result = encode_rgba_to_png(2, 2, &rgba);
|
||||
assert!(result.is_ok());
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::clipboard::{hash_content, LastHash};
|
||||
use crate::clipboard::LastHash;
|
||||
use crate::db::{ClipboardEntry, Database, Settings};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@ -10,7 +10,7 @@ pub struct LastHashState(pub LastHash);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
db.0.get_entries(limit.unwrap_or(10000))
|
||||
db.0.get_entries(limit.unwrap_or(crate::db::DISPLAY_LIMIT))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@ -19,10 +19,22 @@ pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>)
|
||||
if query.trim().is_empty() {
|
||||
return get_entries(db, limit);
|
||||
}
|
||||
db.0.search_entries(&query, limit.unwrap_or(10000))
|
||||
db.0.search_entries(&query, limit.unwrap_or(crate::db::DISPLAY_LIMIT))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_entry(db: State<'_, DbState>, id: i64) -> Result<ClipboardEntry, String> {
|
||||
db.0.get_entry(id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("entry {} not found", id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn latest_entry_id(db: State<'_, DbState>) -> Result<Option<i64>, String> {
|
||||
db.0.latest_entry_id().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_entry(db: State<'_, DbState>, id: i64) -> Result<(), String> {
|
||||
db.0.delete_entry(id).map_err(|e| e.to_string())
|
||||
@ -66,6 +78,85 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Validate, persist, and re-register the global toggle hotkey.
|
||||
/// On registration failure the previous hotkey is restored.
|
||||
#[tauri::command]
|
||||
pub fn set_hotkey(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DbState>,
|
||||
hotkey: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
|
||||
let new_shortcut = crate::parse_hotkey(&hotkey)
|
||||
.ok_or_else(|| format!("Invalid hotkey: {}", hotkey))?;
|
||||
|
||||
let old = db.0.get_settings().map_err(|e| e.to_string())?.hotkey;
|
||||
let gs = app.global_shortcut();
|
||||
|
||||
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
|
||||
let _ = gs.unregister(old_shortcut);
|
||||
}
|
||||
|
||||
if let Err(e) = gs.register(new_shortcut) {
|
||||
// Roll back so the app keeps a working hotkey
|
||||
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
|
||||
let _ = gs.register(old_shortcut);
|
||||
}
|
||||
return Err(format!("Could not register '{}': {}", hotkey, e));
|
||||
}
|
||||
|
||||
db.0.set_setting("hotkey", &hotkey).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Write content to the system clipboard and update the shared hash so the
|
||||
/// polling thread doesn't re-insert it. Shared by copy-only and paste-and-refocus.
|
||||
fn write_clipboard(
|
||||
last_hash: &LastHash,
|
||||
content: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let h = crate::clipboard::hash_content(content.as_bytes());
|
||||
*last_hash.lock().unwrap() = h;
|
||||
}
|
||||
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("clipboard open failed: {}", e))?;
|
||||
|
||||
if kind == "image" && content.starts_with("data:image/") {
|
||||
let (w, h, rgba) = crate::image_util::data_uri_to_rgba(content)?;
|
||||
let img = crate::image_util::to_arboard_image(w, h, rgba);
|
||||
clipboard
|
||||
.set_image(img)
|
||||
.map_err(|e| format!("clipboard image write failed: {}", e))?;
|
||||
} else {
|
||||
clipboard
|
||||
.set_text(content)
|
||||
.map_err(|e| format!("clipboard write failed: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy content to the clipboard without pasting or hiding the window —
|
||||
/// Ditto/Pastebot-style "copy only" for building up a paste elsewhere.
|
||||
#[tauri::command]
|
||||
pub fn copy_to_clipboard(
|
||||
last_hash: State<'_, LastHashState>,
|
||||
content: String,
|
||||
content_type: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let kind = content_type.unwrap_or_else(|| {
|
||||
if content.starts_with("data:image/") {
|
||||
"image".into()
|
||||
} else {
|
||||
"text".into()
|
||||
}
|
||||
});
|
||||
write_clipboard(&last_hash.0, &content, &kind)
|
||||
}
|
||||
|
||||
/// Write content to the system clipboard, update the shared hash so the
|
||||
/// polling thread doesn't re-insert it, hide the window, then simulate
|
||||
/// Cmd+V via AppleScript to paste into the previously-focused app.
|
||||
@ -74,26 +165,23 @@ pub async fn paste_and_refocus(
|
||||
app: tauri::AppHandle,
|
||||
last_hash: State<'_, LastHashState>,
|
||||
content: String,
|
||||
content_type: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Write to clipboard and update the shared hash BEFORE hiding,
|
||||
// so the polling thread never sees a "new" entry.
|
||||
{
|
||||
let h = hash_content(content.as_bytes());
|
||||
*last_hash.0.lock().unwrap() = h;
|
||||
}
|
||||
let kind = content_type.unwrap_or_else(|| {
|
||||
if content.starts_with("data:image/") {
|
||||
"image".into()
|
||||
} else {
|
||||
"text".into()
|
||||
}
|
||||
});
|
||||
|
||||
// arboard must be created on the current thread
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("clipboard open failed: {}", e))?;
|
||||
clipboard
|
||||
.set_text(&content)
|
||||
.map_err(|e| format!("clipboard write failed: {}", e))?;
|
||||
write_clipboard(&last_hash.0, &content, &kind)?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@ -102,7 +190,7 @@ tell application "System Events"
|
||||
set frontApp to name of first application process whose frontmost is true
|
||||
end tell
|
||||
tell application frontApp to activate
|
||||
delay 0.1
|
||||
delay 0.08
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
end tell
|
||||
|
||||
@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Max chars sent to the UI for text previews. Full content stays in SQLite.
|
||||
pub const PREVIEW_MAX_CHARS: usize = 200;
|
||||
|
||||
/// Soft cap for how many rows a list/search IPC response returns.
|
||||
pub const DISPLAY_LIMIT: i64 = 150;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardEntry {
|
||||
pub id: i64,
|
||||
@ -11,6 +17,12 @@ pub struct ClipboardEntry {
|
||||
pub created_at: String,
|
||||
pub pinned: bool,
|
||||
pub content_hash: String,
|
||||
/// True when `content` is a preview stub (text truncated or image blob omitted).
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
/// True when list preview is redacted (password / card / banking); paste still uses full value.
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -21,8 +33,13 @@ pub struct Settings {
|
||||
pub window_position: String,
|
||||
pub window_width: i64,
|
||||
pub window_height: i64,
|
||||
pub hotkey: String,
|
||||
}
|
||||
|
||||
pub const DEFAULT_HOTKEY: &str = "ctrl+`";
|
||||
/// Previous default — conflicts with macOS “cycle windows of front app”.
|
||||
pub const LEGACY_DEFAULT_HOTKEY: &str = "cmd+`";
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@ -32,6 +49,7 @@ impl Default for Settings {
|
||||
window_position: "cursor".to_string(),
|
||||
window_width: 420,
|
||||
window_height: 560,
|
||||
hotkey: DEFAULT_HOTKEY.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,6 +66,7 @@ impl Database {
|
||||
}
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
Self::apply_speed_pragmas(&conn);
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
@ -55,6 +74,17 @@ impl Database {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// WAL lets the UI read while the polling thread writes; NORMAL sync is
|
||||
/// safe for local app data and much faster than the FULL default.
|
||||
fn apply_speed_pragmas(conn: &Connection) {
|
||||
let _ = conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA mmap_size = 67108864;",
|
||||
);
|
||||
}
|
||||
|
||||
/// In-memory database for unit tests.
|
||||
#[cfg(test)]
|
||||
pub fn in_memory() -> SqlResult<Self> {
|
||||
@ -75,13 +105,14 @@ impl Database {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS clipboard_entries (
|
||||
" CREATE TABLE IF NOT EXISTS clipboard_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'text',
|
||||
created_at TEXT NOT NULL,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT NOT NULL
|
||||
content_hash TEXT NOT NULL,
|
||||
thumbnail TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
|
||||
@ -114,6 +145,12 @@ impl Database {
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Migrate existing DBs that predate the thumbnail column.
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE clipboard_entries ADD COLUMN thumbnail TEXT",
|
||||
[],
|
||||
);
|
||||
|
||||
// Seed default settings if absent
|
||||
let defaults = Settings::default();
|
||||
conn.execute(
|
||||
@ -140,6 +177,25 @@ impl Database {
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["window_height", defaults.window_height.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["hotkey", &defaults.hotkey],
|
||||
)?;
|
||||
|
||||
// Migrate installs that still use Cmd+` (steals macOS window-cycle).
|
||||
let current_hotkey: String = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'hotkey'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| defaults.hotkey.clone());
|
||||
if current_hotkey == LEGACY_DEFAULT_HOTKEY {
|
||||
conn.execute(
|
||||
"UPDATE settings SET value = ?1 WHERE key = 'hotkey'",
|
||||
params![DEFAULT_HOTKEY],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -149,17 +205,105 @@ impl Database {
|
||||
content: &str,
|
||||
content_type: &str,
|
||||
content_hash: &str,
|
||||
thumbnail: Option<&str>,
|
||||
) -> SqlResult<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)",
|
||||
params![content, content_type, now, content_hash],
|
||||
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash, thumbnail)
|
||||
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
|
||||
params![content, content_type, now, content_hash, thumbnail],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Build missing image thumbnails from stored full PNGs (best-effort, capped).
|
||||
pub fn backfill_thumbnails(&self, max: usize) -> usize {
|
||||
let rows: Vec<(i64, String)> = {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT id, content FROM clipboard_entries
|
||||
WHERE content_type = 'image'
|
||||
AND (thumbnail IS NULL OR thumbnail = '')
|
||||
ORDER BY id DESC
|
||||
LIMIT ?1",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
stmt.query_map(params![max as i64], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.ok()
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut done = 0;
|
||||
for (id, content) in rows {
|
||||
if let Ok((w, h, rgba)) = crate::image_util::data_uri_to_rgba(&content) {
|
||||
if let Ok(thumb) = crate::image_util::make_thumbnail_data_uri(w, h, &rgba) {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
if conn
|
||||
.execute(
|
||||
"UPDATE clipboard_entries SET thumbnail = ?1 WHERE id = ?2",
|
||||
params![thumb, id],
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
done += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// Lightweight change detector for the UI (avoids shipping full rows).
|
||||
pub fn latest_entry_id(&self) -> SqlResult<Option<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let id = conn
|
||||
.query_row(
|
||||
"SELECT id FROM clipboard_entries ORDER BY id DESC LIMIT 1",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.ok();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn get_entry(&self, id: i64) -> SqlResult<Option<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, content, content_type, created_at, pinned, content_hash
|
||||
FROM clipboard_entries WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], map_full_entry)?;
|
||||
match rows.next() {
|
||||
Some(Ok(e)) => Ok(Some(e)),
|
||||
Some(Err(e)) => Err(e),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_preview_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
|
||||
let content_type: String = row.get(2)?;
|
||||
let raw: String = row.get(1)?;
|
||||
let thumbnail: Option<String> = row.get(6)?;
|
||||
let (content, truncated, sensitive) =
|
||||
preview_content(&content_type, &raw, thumbnail.as_deref());
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content,
|
||||
content_type,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
truncated,
|
||||
sensitive,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the most recent entry already has this hash (dedup).
|
||||
pub fn latest_hash(&self) -> SqlResult<Option<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@ -173,51 +317,36 @@ impl Database {
|
||||
|
||||
pub fn get_entries(&self, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = limit.clamp(1, DISPLAY_LIMIT);
|
||||
// Pinned first, then by recency
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, content, content_type, created_at, pinned, content_hash
|
||||
"SELECT id, content, content_type, created_at, pinned, content_hash, thumbnail
|
||||
FROM clipboard_entries
|
||||
ORDER BY pinned DESC, id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let entries = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.query_map(params![limit], Self::map_preview_entry)?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = limit.clamp(1, DISPLAY_LIMIT);
|
||||
// FTS5 match query — prefix search with *
|
||||
let fts_query = format!("{}*", query.replace('"', "\"\""));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash
|
||||
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail
|
||||
FROM clipboard_entries e
|
||||
JOIN clipboard_fts f ON e.id = f.rowid
|
||||
WHERE clipboard_fts MATCH ?1
|
||||
AND e.content_type = 'text'
|
||||
ORDER BY e.pinned DESC, e.id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let entries = stmt
|
||||
.query_map(params![fts_query, limit], |row| {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.query_map(params![fts_query, limit], Self::map_preview_entry)?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
@ -277,6 +406,7 @@ impl Database {
|
||||
window_position: get("window_position", "cursor"),
|
||||
window_width: get("window_width", "420").parse().unwrap_or(420),
|
||||
window_height: get("window_height", "560").parse().unwrap_or(560),
|
||||
hotkey: get("hotkey", DEFAULT_HOTKEY),
|
||||
})
|
||||
}
|
||||
|
||||
@ -290,6 +420,42 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_full_entry(row: &rusqlite::Row<'_>) -> SqlResult<ClipboardEntry> {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
truncated: false,
|
||||
sensitive: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_content(
|
||||
content_type: &str,
|
||||
raw: &str,
|
||||
thumbnail: Option<&str>,
|
||||
) -> (String, bool, bool) {
|
||||
if content_type == "image" {
|
||||
if let Some(thumb) = thumbnail.filter(|t| !t.is_empty()) {
|
||||
return (thumb.to_string(), true, false);
|
||||
}
|
||||
return (String::new(), true, false);
|
||||
}
|
||||
|
||||
if let Some(kind) = crate::redact::classify_sensitive(raw) {
|
||||
return (crate::redact::redact_preview(raw, kind), true, true);
|
||||
}
|
||||
|
||||
if raw.chars().count() > PREVIEW_MAX_CHARS {
|
||||
let preview: String = raw.chars().take(PREVIEW_MAX_CHARS).collect();
|
||||
return (preview, true, false);
|
||||
}
|
||||
(raw.to_string(), false, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -310,6 +476,22 @@ mod tests {
|
||||
assert_eq!(s.window_position, "cursor");
|
||||
assert_eq!(s.window_width, 420);
|
||||
assert_eq!(s.window_height, 560);
|
||||
assert_eq!(s.hotkey, DEFAULT_HOTKEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotkey_setting_persists() {
|
||||
let db = test_db();
|
||||
db.set_setting("hotkey", "cmd+shift+v").unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().hotkey, "cmd+shift+v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_legacy_cmd_backtick_default() {
|
||||
let db = test_db();
|
||||
db.set_setting("hotkey", LEGACY_DEFAULT_HOTKEY).unwrap();
|
||||
db.init_tables().unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().hotkey, DEFAULT_HOTKEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -324,7 +506,7 @@ mod tests {
|
||||
#[test]
|
||||
fn insert_and_get_entry() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("hello world", "text", "hash1").unwrap();
|
||||
let id = db.insert_entry("hello world", "text", "hash1", None).unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
@ -337,9 +519,9 @@ mod tests {
|
||||
#[test]
|
||||
fn entries_ordered_newest_first() {
|
||||
let db = test_db();
|
||||
db.insert_entry("first", "text", "h1").unwrap();
|
||||
db.insert_entry("second", "text", "h2").unwrap();
|
||||
db.insert_entry("third", "text", "h3").unwrap();
|
||||
db.insert_entry("first", "text", "h1", None).unwrap();
|
||||
db.insert_entry("second", "text", "h2", None).unwrap();
|
||||
db.insert_entry("third", "text", "h3", None).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries[0].content, "third");
|
||||
@ -351,7 +533,7 @@ mod tests {
|
||||
fn get_entries_respects_limit() {
|
||||
let db = test_db();
|
||||
for i in 0..10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
|
||||
}
|
||||
assert_eq!(db.get_entries(3).unwrap().len(), 3);
|
||||
}
|
||||
@ -367,8 +549,8 @@ mod tests {
|
||||
#[test]
|
||||
fn latest_hash_returns_most_recent() {
|
||||
let db = test_db();
|
||||
db.insert_entry("a", "text", "hash_a").unwrap();
|
||||
db.insert_entry("b", "text", "hash_b").unwrap();
|
||||
db.insert_entry("a", "text", "hash_a", None).unwrap();
|
||||
db.insert_entry("b", "text", "hash_b", None).unwrap();
|
||||
assert_eq!(db.latest_hash().unwrap(), Some("hash_b".to_string()));
|
||||
}
|
||||
|
||||
@ -377,7 +559,7 @@ mod tests {
|
||||
#[test]
|
||||
fn delete_entry_removes_it() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("to delete", "text", "hd").unwrap();
|
||||
let id = db.insert_entry("to delete", "text", "hd", None).unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 1);
|
||||
|
||||
db.delete_entry(id).unwrap();
|
||||
@ -395,7 +577,7 @@ mod tests {
|
||||
#[test]
|
||||
fn toggle_pin_flips_state() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("pin me", "text", "hp").unwrap();
|
||||
let id = db.insert_entry("pin me", "text", "hp", None).unwrap();
|
||||
|
||||
let pinned = db.toggle_pin(id).unwrap();
|
||||
assert!(pinned);
|
||||
@ -407,8 +589,8 @@ mod tests {
|
||||
#[test]
|
||||
fn pinned_entries_appear_first() {
|
||||
let db = test_db();
|
||||
let id1 = db.insert_entry("old", "text", "h1").unwrap();
|
||||
db.insert_entry("new", "text", "h2").unwrap();
|
||||
let id1 = db.insert_entry("old", "text", "h1", None).unwrap();
|
||||
db.insert_entry("new", "text", "h2", None).unwrap();
|
||||
|
||||
db.toggle_pin(id1).unwrap();
|
||||
|
||||
@ -423,9 +605,9 @@ mod tests {
|
||||
#[test]
|
||||
fn search_finds_matching_text() {
|
||||
let db = test_db();
|
||||
db.insert_entry("the quick brown fox", "text", "h1").unwrap();
|
||||
db.insert_entry("lazy dog sleeps", "text", "h2").unwrap();
|
||||
db.insert_entry("quick silver", "text", "h3").unwrap();
|
||||
db.insert_entry("the quick brown fox", "text", "h1", None).unwrap();
|
||||
db.insert_entry("lazy dog sleeps", "text", "h2", None).unwrap();
|
||||
db.insert_entry("quick silver", "text", "h3", None).unwrap();
|
||||
|
||||
let results = db.search_entries("quick", 100).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
@ -435,8 +617,8 @@ mod tests {
|
||||
#[test]
|
||||
fn search_prefix_matching() {
|
||||
let db = test_db();
|
||||
db.insert_entry("programming in rust", "text", "h1").unwrap();
|
||||
db.insert_entry("rustic cabin", "text", "h2").unwrap();
|
||||
db.insert_entry("programming in rust", "text", "h1", None).unwrap();
|
||||
db.insert_entry("rustic cabin", "text", "h2", None).unwrap();
|
||||
|
||||
let results = db.search_entries("rust", 100).unwrap();
|
||||
assert_eq!(results.len(), 2); // "rust" prefix matches "rust" and "rustic"
|
||||
@ -445,7 +627,7 @@ mod tests {
|
||||
#[test]
|
||||
fn search_no_match_returns_empty() {
|
||||
let db = test_db();
|
||||
db.insert_entry("hello world", "text", "h1").unwrap();
|
||||
db.insert_entry("hello world", "text", "h1", None).unwrap();
|
||||
|
||||
let results = db.search_entries("zzzzz", 100).unwrap();
|
||||
assert_eq!(results.len(), 0);
|
||||
@ -454,7 +636,7 @@ mod tests {
|
||||
#[test]
|
||||
fn search_fts_stays_consistent_after_delete() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("unique searchable term", "text", "h1").unwrap();
|
||||
let id = db.insert_entry("unique searchable term", "text", "h1", None).unwrap();
|
||||
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 1);
|
||||
|
||||
db.delete_entry(id).unwrap();
|
||||
@ -467,7 +649,7 @@ mod tests {
|
||||
fn trim_keeps_max_entries() {
|
||||
let db = test_db();
|
||||
for i in 0..10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
|
||||
}
|
||||
|
||||
db.trim_entries(5).unwrap();
|
||||
@ -480,11 +662,11 @@ mod tests {
|
||||
#[test]
|
||||
fn trim_preserves_pinned_entries() {
|
||||
let db = test_db();
|
||||
let pinned_id = db.insert_entry("keep me", "text", "h0").unwrap();
|
||||
let pinned_id = db.insert_entry("keep me", "text", "h0", None).unwrap();
|
||||
db.toggle_pin(pinned_id).unwrap();
|
||||
|
||||
for i in 1..=10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
|
||||
}
|
||||
|
||||
db.trim_entries(3).unwrap();
|
||||
@ -497,8 +679,8 @@ mod tests {
|
||||
#[test]
|
||||
fn trim_noop_when_under_limit() {
|
||||
let db = test_db();
|
||||
db.insert_entry("a", "text", "h1").unwrap();
|
||||
db.insert_entry("b", "text", "h2").unwrap();
|
||||
db.insert_entry("a", "text", "h1", None).unwrap();
|
||||
db.insert_entry("b", "text", "h2", None).unwrap();
|
||||
|
||||
db.trim_entries(500).unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 2);
|
||||
@ -510,7 +692,7 @@ mod tests {
|
||||
fn clear_all_removes_everything() {
|
||||
let db = test_db();
|
||||
for i in 0..5 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i), None).unwrap();
|
||||
}
|
||||
db.clear_all().unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 0);
|
||||
@ -545,12 +727,75 @@ mod tests {
|
||||
|
||||
// ── Content types ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn list_entries_omit_image_blobs() {
|
||||
let db = test_db();
|
||||
let blob = format!("data:image/png;base64,{}", "A".repeat(5000));
|
||||
db.insert_entry(&blob, "image", "himg", None).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries[0].truncated);
|
||||
assert!(entries[0].content.is_empty());
|
||||
assert!(entries[0].content.len() < blob.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_entries_return_thumbnail_preview() {
|
||||
let db = test_db();
|
||||
let blob = format!("data:image/png;base64,{}", "A".repeat(5000));
|
||||
let thumb = "data:image/png;base64,thumbdata";
|
||||
db.insert_entry(&blob, "image", "himg2", Some(thumb)).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries[0].content, thumb);
|
||||
assert!(entries[0].truncated);
|
||||
let full = db.get_entry(entries[0].id).unwrap().unwrap();
|
||||
assert_eq!(full.content, blob);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_entries_truncate_long_text() {
|
||||
let db = test_db();
|
||||
let long = "x".repeat(PREVIEW_MAX_CHARS + 50);
|
||||
db.insert_entry(&long, "text", "hlong", None).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert!(entries[0].truncated);
|
||||
assert_eq!(entries[0].content.chars().count(), PREVIEW_MAX_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_entry_returns_full_content() {
|
||||
let db = test_db();
|
||||
let blob = format!("data:image/png;base64,{}", "B".repeat(2000));
|
||||
let id = db.insert_entry(&blob, "image", "hfull", None).unwrap();
|
||||
|
||||
let full = db.get_entry(id).unwrap().unwrap();
|
||||
assert!(!full.truncated);
|
||||
assert_eq!(full.content, blob);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_redacts_sensitive_card_but_keeps_full() {
|
||||
let db = test_db();
|
||||
let card = "4111111111111111";
|
||||
let id = db.insert_entry(card, "text", "hcard", None).unwrap();
|
||||
let list = db.get_entries(10).unwrap();
|
||||
assert!(list[0].sensitive);
|
||||
assert!(list[0].content.contains("••••"));
|
||||
assert!(!list[0].content.contains(card));
|
||||
let full = db.get_entry(id).unwrap().unwrap();
|
||||
assert_eq!(full.content, card);
|
||||
assert!(!full.sensitive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_different_content_types() {
|
||||
let db = test_db();
|
||||
db.insert_entry("plain text", "text", "h1").unwrap();
|
||||
db.insert_entry("data:image/png;base64,abc", "image", "h2").unwrap();
|
||||
db.insert_entry("/usr/local/bin", "file", "h3").unwrap();
|
||||
db.insert_entry("plain text", "text", "h1", None).unwrap();
|
||||
db.insert_entry("data:image/png;base64,abc", "image", "h2", None).unwrap();
|
||||
db.insert_entry("/usr/local/bin", "file", "h3", None).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
|
||||
142
src-tauri/src/image_util.rs
Normal file
142
src-tauri/src/image_util.rs
Normal file
@ -0,0 +1,142 @@
|
||||
//! Image helpers: PNG encode/decode and small list thumbnails.
|
||||
|
||||
use base64::Engine;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub const THUMB_MAX_EDGE: u32 = 96;
|
||||
|
||||
pub fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header()?;
|
||||
writer.write_image_data(rgba)?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn decode_png_rgba(png_data: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
|
||||
let decoder = png::Decoder::new(std::io::Cursor::new(png_data));
|
||||
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
|
||||
let mut buf = vec![0; reader.output_buffer_size()];
|
||||
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
|
||||
buf.truncate(info.buffer_size());
|
||||
|
||||
// Expand to RGBA if needed
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => buf,
|
||||
png::ColorType::Rgb => {
|
||||
let mut out = Vec::with_capacity((buf.len() / 3) * 4);
|
||||
for chunk in buf.chunks_exact(3) {
|
||||
out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
|
||||
}
|
||||
out
|
||||
}
|
||||
png::ColorType::Grayscale => {
|
||||
let mut out = Vec::with_capacity(buf.len() * 4);
|
||||
for &g in &buf {
|
||||
out.extend_from_slice(&[g, g, g, 255]);
|
||||
}
|
||||
out
|
||||
}
|
||||
png::ColorType::GrayscaleAlpha => {
|
||||
let mut out = Vec::with_capacity((buf.len() / 2) * 4);
|
||||
for chunk in buf.chunks_exact(2) {
|
||||
out.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
|
||||
}
|
||||
out
|
||||
}
|
||||
other => return Err(format!("unsupported PNG color type: {:?}", other)),
|
||||
};
|
||||
|
||||
Ok((info.width, info.height, rgba))
|
||||
}
|
||||
|
||||
/// Nearest-neighbor downscale so the longest edge is ≤ `max_edge`.
|
||||
pub fn downscale_rgba(width: u32, height: u32, rgba: &[u8], max_edge: u32) -> (u32, u32, Vec<u8>) {
|
||||
if width == 0 || height == 0 || rgba.len() < (width as usize * height as usize * 4) {
|
||||
return (width.max(1), height.max(1), vec![0, 0, 0, 0]);
|
||||
}
|
||||
let longest = width.max(height);
|
||||
if longest <= max_edge {
|
||||
return (width, height, rgba.to_vec());
|
||||
}
|
||||
let scale = max_edge as f32 / longest as f32;
|
||||
let tw = ((width as f32) * scale).round().max(1.0) as u32;
|
||||
let th = ((height as f32) * scale).round().max(1.0) as u32;
|
||||
let mut out = vec![0u8; (tw as usize) * (th as usize) * 4];
|
||||
for y in 0..th {
|
||||
let sy = ((y as f32 / th as f32) * height as f32) as u32;
|
||||
for x in 0..tw {
|
||||
let sx = ((x as f32 / tw as f32) * width as f32) as u32;
|
||||
let si = ((sy * width + sx) as usize) * 4;
|
||||
let di = ((y * tw + x) as usize) * 4;
|
||||
out[di..di + 4].copy_from_slice(&rgba[si..si + 4]);
|
||||
}
|
||||
}
|
||||
(tw, th, out)
|
||||
}
|
||||
|
||||
pub fn rgba_to_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
|
||||
let png = encode_rgba_to_png(width, height, rgba).map_err(|e| e.to_string())?;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
|
||||
Ok(format!("data:image/png;base64,{}", b64))
|
||||
}
|
||||
|
||||
pub fn make_thumbnail_data_uri(width: u32, height: u32, rgba: &[u8]) -> Result<String, String> {
|
||||
let (tw, th, thumb) = downscale_rgba(width, height, rgba, THUMB_MAX_EDGE);
|
||||
rgba_to_data_uri(tw, th, &thumb)
|
||||
}
|
||||
|
||||
pub fn data_uri_to_rgba(data_uri: &str) -> Result<(u32, u32, Vec<u8>), String> {
|
||||
let b64 = data_uri
|
||||
.split_once(',')
|
||||
.map(|(_, b)| b)
|
||||
.ok_or_else(|| "invalid data URI".to_string())?;
|
||||
let png = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.map_err(|e| e.to_string())?;
|
||||
decode_png_rgba(&png)
|
||||
}
|
||||
|
||||
pub fn to_arboard_image(width: u32, height: u32, rgba: Vec<u8>) -> arboard::ImageData<'static> {
|
||||
arboard::ImageData {
|
||||
width: width as usize,
|
||||
height: height as usize,
|
||||
bytes: Cow::Owned(rgba),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thumbnail_shrinks_large_image() {
|
||||
let w = 400u32;
|
||||
let h = 200u32;
|
||||
let rgba = vec![128u8; (w * h * 4) as usize];
|
||||
let (tw, th, out) = downscale_rgba(w, h, &rgba, 96);
|
||||
assert!(tw <= 96 && th <= 96);
|
||||
assert_eq!(out.len(), (tw * th * 4) as usize);
|
||||
assert!(tw >= th); // landscape stays wider
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_png_small() {
|
||||
let rgba = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255];
|
||||
let png = encode_rgba_to_png(2, 2, &rgba).unwrap();
|
||||
let (w, h, out) = decode_png_rgba(&png).unwrap();
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(out.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumbnail_data_uri_ok() {
|
||||
let rgba = vec![10u8; 50 * 50 * 4];
|
||||
let uri = make_thumbnail_data_uri(50, 50, &rgba).unwrap();
|
||||
assert!(uri.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod image_util;
|
||||
mod redact;
|
||||
|
||||
use commands::{DbState, LastHashState, PausedState};
|
||||
use db::Database;
|
||||
@ -14,6 +16,107 @@ use tauri::{
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||
|
||||
/// Parse a hotkey string like "cmd+`" or "ctrl+shift+v" into a Shortcut.
|
||||
/// Requires at least one modifier and exactly one key.
|
||||
pub fn parse_hotkey(s: &str) -> Option<Shortcut> {
|
||||
let mut mods = Modifiers::empty();
|
||||
let mut code: Option<Code> = None;
|
||||
|
||||
for part in s.split('+').map(|p| p.trim().to_lowercase()) {
|
||||
match part.as_str() {
|
||||
"cmd" | "command" | "super" | "meta" => mods |= Modifiers::SUPER,
|
||||
"shift" => mods |= Modifiers::SHIFT,
|
||||
"ctrl" | "control" => mods |= Modifiers::CONTROL,
|
||||
"alt" | "option" | "opt" => mods |= Modifiers::ALT,
|
||||
key => {
|
||||
if code.is_some() {
|
||||
return None;
|
||||
}
|
||||
code = Some(parse_key(key)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mods.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Shortcut::new(Some(mods), code?))
|
||||
}
|
||||
|
||||
fn parse_key(k: &str) -> Option<Code> {
|
||||
let code = match k {
|
||||
"`" | "~" | "backquote" | "grave" => Code::Backquote,
|
||||
"space" => Code::Space,
|
||||
"enter" | "return" => Code::Enter,
|
||||
"tab" => Code::Tab,
|
||||
"escape" | "esc" => Code::Escape,
|
||||
"up" => Code::ArrowUp,
|
||||
"down" => Code::ArrowDown,
|
||||
"left" => Code::ArrowLeft,
|
||||
"right" => Code::ArrowRight,
|
||||
"-" | "minus" => Code::Minus,
|
||||
"=" | "equal" => Code::Equal,
|
||||
"," | "comma" => Code::Comma,
|
||||
"." | "period" => Code::Period,
|
||||
"/" | "slash" => Code::Slash,
|
||||
";" | "semicolon" => Code::Semicolon,
|
||||
"'" | "quote" => Code::Quote,
|
||||
"[" => Code::BracketLeft,
|
||||
"]" => Code::BracketRight,
|
||||
"\\" | "backslash" => Code::Backslash,
|
||||
"f1" => Code::F1,
|
||||
"f2" => Code::F2,
|
||||
"f3" => Code::F3,
|
||||
"f4" => Code::F4,
|
||||
"f5" => Code::F5,
|
||||
"f6" => Code::F6,
|
||||
"f7" => Code::F7,
|
||||
"f8" => Code::F8,
|
||||
"f9" => Code::F9,
|
||||
"f10" => Code::F10,
|
||||
"f11" => Code::F11,
|
||||
"f12" => Code::F12,
|
||||
"a" => Code::KeyA,
|
||||
"b" => Code::KeyB,
|
||||
"c" => Code::KeyC,
|
||||
"d" => Code::KeyD,
|
||||
"e" => Code::KeyE,
|
||||
"f" => Code::KeyF,
|
||||
"g" => Code::KeyG,
|
||||
"h" => Code::KeyH,
|
||||
"i" => Code::KeyI,
|
||||
"j" => Code::KeyJ,
|
||||
"k" => Code::KeyK,
|
||||
"l" => Code::KeyL,
|
||||
"m" => Code::KeyM,
|
||||
"n" => Code::KeyN,
|
||||
"o" => Code::KeyO,
|
||||
"p" => Code::KeyP,
|
||||
"q" => Code::KeyQ,
|
||||
"r" => Code::KeyR,
|
||||
"s" => Code::KeyS,
|
||||
"t" => Code::KeyT,
|
||||
"u" => Code::KeyU,
|
||||
"v" => Code::KeyV,
|
||||
"w" => Code::KeyW,
|
||||
"x" => Code::KeyX,
|
||||
"y" => Code::KeyY,
|
||||
"z" => Code::KeyZ,
|
||||
"0" => Code::Digit0,
|
||||
"1" => Code::Digit1,
|
||||
"2" => Code::Digit2,
|
||||
"3" => Code::Digit3,
|
||||
"4" => Code::Digit4,
|
||||
"5" => Code::Digit5,
|
||||
"6" => Code::Digit6,
|
||||
"7" => Code::Digit7,
|
||||
"8" => Code::Digit8,
|
||||
"9" => Code::Digit9,
|
||||
_ => return None,
|
||||
};
|
||||
Some(code)
|
||||
}
|
||||
|
||||
/// Get the global mouse position using CoreGraphics.
|
||||
/// Returns logical (x, y) in screen coordinates with origin at top-left.
|
||||
#[cfg(target_os = "macos")]
|
||||
@ -139,15 +242,10 @@ pub fn run() {
|
||||
))
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
// Only the toggle hotkey is ever registered, so any press toggles.
|
||||
.with_handler(|app, _shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let expected = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
if shortcut == &expected {
|
||||
toggle_window(app);
|
||||
}
|
||||
toggle_window(app);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
@ -158,6 +256,8 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_entries,
|
||||
commands::search_entries,
|
||||
commands::get_entry,
|
||||
commands::latest_entry_id,
|
||||
commands::delete_entry,
|
||||
commands::toggle_pin,
|
||||
commands::clear_all,
|
||||
@ -167,13 +267,20 @@ pub fn run() {
|
||||
commands::set_paused,
|
||||
commands::save_window_size,
|
||||
commands::paste_and_refocus,
|
||||
commands::copy_to_clipboard,
|
||||
commands::set_hotkey,
|
||||
])
|
||||
.setup(move |app| {
|
||||
let shortcut = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
app.global_shortcut().register(shortcut)?;
|
||||
let hotkey_str = db
|
||||
.get_settings()
|
||||
.map(|s| s.hotkey)
|
||||
.unwrap_or_else(|_| db::DEFAULT_HOTKEY.to_string());
|
||||
let shortcut = parse_hotkey(&hotkey_str)
|
||||
.or_else(|| parse_hotkey(db::DEFAULT_HOTKEY))
|
||||
.expect("default hotkey must parse");
|
||||
if let Err(e) = app.global_shortcut().register(shortcut) {
|
||||
log::error!("Failed to register hotkey '{}': {}", hotkey_str, e);
|
||||
}
|
||||
|
||||
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
|
||||
let pause_i =
|
||||
@ -223,12 +330,30 @@ pub fn run() {
|
||||
let w = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Focused(false) = event {
|
||||
let _ = w.hide();
|
||||
// Delay hide so left-clicks inside the list finish before
|
||||
// macOS/webview focus churn can cancel them.
|
||||
let w2 = w.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(180));
|
||||
if !w2.is_focused().unwrap_or(false) {
|
||||
let _ = w2.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let last_hash = clipboard::start_polling(db_for_polling, paused_for_polling);
|
||||
// Best-effort: restore thumbs for older image rows.
|
||||
let n = db.backfill_thumbnails(80);
|
||||
if n > 0 {
|
||||
log::info!("Backfilled {} image thumbnails", n);
|
||||
}
|
||||
|
||||
let last_hash = clipboard::start_polling(
|
||||
app.handle().clone(),
|
||||
db_for_polling,
|
||||
paused_for_polling,
|
||||
);
|
||||
app.manage(LastHashState(last_hash));
|
||||
|
||||
Ok(())
|
||||
@ -236,3 +361,66 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_default_hotkey() {
|
||||
let sc = parse_hotkey("ctrl+`").unwrap();
|
||||
assert_eq!(sc, Shortcut::new(Some(Modifiers::CONTROL), Code::Backquote));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_legacy_cmd_backtick() {
|
||||
let sc = parse_hotkey("cmd+`").unwrap();
|
||||
assert_eq!(sc, Shortcut::new(Some(Modifiers::SUPER), Code::Backquote));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tilde_alias() {
|
||||
assert_eq!(parse_hotkey("cmd+~"), parse_hotkey("command+`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multi_modifier() {
|
||||
let sc = parse_hotkey("cmd+shift+v").unwrap();
|
||||
assert_eq!(
|
||||
sc,
|
||||
Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyV)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ctrl_alt_names() {
|
||||
let sc = parse_hotkey("ctrl+option+space").unwrap();
|
||||
assert_eq!(
|
||||
sc,
|
||||
Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::Space)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_case_insensitive() {
|
||||
assert_eq!(parse_hotkey("CMD+Shift+V"), parse_hotkey("cmd+shift+v"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_no_modifier() {
|
||||
assert!(parse_hotkey("v").is_none());
|
||||
assert!(parse_hotkey("`").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_no_key() {
|
||||
assert!(parse_hotkey("cmd+shift").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_garbage() {
|
||||
assert!(parse_hotkey("").is_none());
|
||||
assert!(parse_hotkey("cmd+banana").is_none());
|
||||
assert!(parse_hotkey("cmd+v+x").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
224
src-tauri/src/redact.rs
Normal file
224
src-tauri/src/redact.rs
Normal file
@ -0,0 +1,224 @@
|
||||
//! Detect and redact sensitive clipboard text for list previews.
|
||||
//! Full plaintext stays in SQLite and is used only on paste.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SensitiveKind {
|
||||
Password,
|
||||
Card,
|
||||
Banking,
|
||||
Secret,
|
||||
}
|
||||
|
||||
static CARD_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static IBAN_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static ROUTING_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static SECRET_LABEL_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static JWT_RE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
fn card_re() -> &'static Regex {
|
||||
CARD_RE.get_or_init(|| {
|
||||
Regex::new(r"(?x)\b(?:\d[ -]*?){13,19}\b").expect("card regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn iban_re() -> &'static Regex {
|
||||
IBAN_RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b").expect("iban regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn routing_re() -> &'static Regex {
|
||||
ROUTING_RE.get_or_init(|| Regex::new(r"\b\d{9}\b").expect("routing regex"))
|
||||
}
|
||||
|
||||
fn secret_label_re() -> &'static Regex {
|
||||
SECRET_LABEL_RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r"(?i)\b(password|passwd|passphrase|secret|api[_-]?key|token|auth|pin|cvv|routing|account\s*#?|acct)\b\s*[:=]\s*\S+",
|
||||
)
|
||||
.expect("secret label regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn jwt_re() -> &'static Regex {
|
||||
JWT_RE.get_or_init(|| {
|
||||
Regex::new(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
|
||||
.expect("jwt regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// Luhn check for probable card numbers (digits only).
|
||||
fn luhn_ok(digits: &str) -> bool {
|
||||
if digits.len() < 13 || digits.len() > 19 {
|
||||
return false;
|
||||
}
|
||||
let mut sum = 0;
|
||||
let mut alt = false;
|
||||
for c in digits.chars().rev() {
|
||||
let mut n = match c.to_digit(10) {
|
||||
Some(d) => d as i32,
|
||||
None => return false,
|
||||
};
|
||||
if alt {
|
||||
n *= 2;
|
||||
if n > 9 {
|
||||
n -= 9;
|
||||
}
|
||||
}
|
||||
sum += n;
|
||||
alt = !alt;
|
||||
}
|
||||
sum % 10 == 0
|
||||
}
|
||||
|
||||
fn extract_digits(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_ascii_digit()).collect()
|
||||
}
|
||||
|
||||
fn looks_like_password(text: &str) -> bool {
|
||||
let t = text.trim();
|
||||
if t.contains('\n') || t.contains(' ') {
|
||||
return false;
|
||||
}
|
||||
let len = t.chars().count();
|
||||
if !(10..=128).contains(&len) {
|
||||
return false;
|
||||
}
|
||||
// Skip obvious URLs / emails / paths
|
||||
if t.contains("://") || t.contains('@') || t.starts_with('/') || t.starts_with('~') {
|
||||
return false;
|
||||
}
|
||||
let has_lower = t.chars().any(|c| c.is_ascii_lowercase());
|
||||
let has_upper = t.chars().any(|c| c.is_ascii_uppercase());
|
||||
let has_digit = t.chars().any(|c| c.is_ascii_digit());
|
||||
let has_sym = t.chars().any(|c| !c.is_ascii_alphanumeric());
|
||||
let classes = [has_lower, has_upper, has_digit, has_sym]
|
||||
.iter()
|
||||
.filter(|&&x| x)
|
||||
.count();
|
||||
classes >= 3
|
||||
}
|
||||
|
||||
fn banking_keywords(text: &str) -> bool {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
const KEYS: &[&str] = &[
|
||||
"routing number",
|
||||
"account number",
|
||||
"bank account",
|
||||
"swift",
|
||||
"iban",
|
||||
"wire transfer",
|
||||
"sort code",
|
||||
"transit number",
|
||||
];
|
||||
KEYS.iter().any(|k| lower.contains(k))
|
||||
}
|
||||
|
||||
pub fn classify_sensitive(text: &str) -> Option<SensitiveKind> {
|
||||
let t = text.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if secret_label_re().is_match(t) || jwt_re().is_match(t) {
|
||||
return Some(SensitiveKind::Secret);
|
||||
}
|
||||
|
||||
for m in card_re().find_iter(t) {
|
||||
let digits = extract_digits(m.as_str());
|
||||
if luhn_ok(&digits) {
|
||||
return Some(SensitiveKind::Card);
|
||||
}
|
||||
}
|
||||
|
||||
if iban_re().is_match(t) || banking_keywords(t) {
|
||||
return Some(SensitiveKind::Banking);
|
||||
}
|
||||
|
||||
// ABA routing often appears near account language — alone it's weak, so require banking cue or "routing"
|
||||
if routing_re().is_match(t)
|
||||
&& (t.to_ascii_lowercase().contains("routing")
|
||||
|| t.to_ascii_lowercase().contains("aba")
|
||||
|| banking_keywords(t))
|
||||
{
|
||||
return Some(SensitiveKind::Banking);
|
||||
}
|
||||
|
||||
if looks_like_password(t) {
|
||||
return Some(SensitiveKind::Password);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Redacted list preview. Full value remains in the DB for paste.
|
||||
pub fn redact_preview(text: &str, kind: SensitiveKind) -> String {
|
||||
match kind {
|
||||
SensitiveKind::Password => "•••••••• (password)".into(),
|
||||
SensitiveKind::Secret => "•••••••• (secret)".into(),
|
||||
SensitiveKind::Card => {
|
||||
let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let last4 = if digits.len() >= 4 {
|
||||
&digits[digits.len() - 4..]
|
||||
} else {
|
||||
"????"
|
||||
};
|
||||
format!("•••• •••• •••• {} (card)", last4)
|
||||
}
|
||||
SensitiveKind::Banking => "•••••••• (banking)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_password_like() {
|
||||
assert_eq!(
|
||||
classify_sensitive("Tr0ub4dor&3xY!"),
|
||||
Some(SensitiveKind::Password)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_normal_sentence() {
|
||||
assert_eq!(classify_sensitive("hello world this is fine"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_labeled_password() {
|
||||
assert_eq!(
|
||||
classify_sensitive("password: hunter2secret"),
|
||||
Some(SensitiveKind::Secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_visa_test_card() {
|
||||
// Visa test number that passes Luhn
|
||||
assert_eq!(
|
||||
classify_sensitive("4111111111111111"),
|
||||
Some(SensitiveKind::Card)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_preview_keeps_last4() {
|
||||
let p = redact_preview("4111111111111111", SensitiveKind::Card);
|
||||
assert!(p.contains("1111"));
|
||||
assert!(p.contains("••••"));
|
||||
assert!(!p.contains("4111111111111111"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_iban() {
|
||||
assert_eq!(
|
||||
classify_sensitive("DE89370400440532013000"),
|
||||
Some(SensitiveKind::Banking)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "maCopy",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.macopy.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@ -43,6 +43,7 @@
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-visible",
|
||||
"core:window:allow-is-focused",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-inner-size",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import App from "./App";
|
||||
import { makeEntry, makeSettings, resetIdCounter } from "./test/factories";
|
||||
@ -15,10 +15,18 @@ function mockTauriCommands(
|
||||
case "get_entries":
|
||||
case "search_entries":
|
||||
return entries;
|
||||
case "get_entry": {
|
||||
const id = args?.id as number | undefined;
|
||||
return entries.find((e) => e.id === id) ?? entries[0];
|
||||
}
|
||||
case "latest_entry_id":
|
||||
return entries[0]?.id ?? null;
|
||||
case "get_settings":
|
||||
return settings;
|
||||
case "paste_and_refocus":
|
||||
return undefined;
|
||||
case "copy_to_clipboard":
|
||||
return undefined;
|
||||
case "save_window_size":
|
||||
return undefined;
|
||||
case "delete_entry":
|
||||
@ -85,15 +93,38 @@ describe("App", () => {
|
||||
expect(screen.getByText("Click me")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Click me"));
|
||||
fireEvent.mouseDown(screen.getByText("Click me"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus", {
|
||||
content: "Click me",
|
||||
contentType: "text",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes copy_to_clipboard (not paste_and_refocus) from the Copy context menu action", async () => {
|
||||
const entries = [makeEntry({ content: "Copy me" })];
|
||||
mockTauriCommands(entries);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Copy me")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Copy me"));
|
||||
fireEvent.click(await screen.findByText("Copy"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("copy_to_clipboard", {
|
||||
content: "Copy me",
|
||||
contentType: "text",
|
||||
});
|
||||
});
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith("paste_and_refocus", expect.anything());
|
||||
});
|
||||
|
||||
it("shows empty state text when there are no entries", async () => {
|
||||
mockTauriCommands([]);
|
||||
render(<App />);
|
||||
@ -123,7 +154,7 @@ describe("App", () => {
|
||||
});
|
||||
|
||||
// Ctrl+Click to select (doesn't trigger paste)
|
||||
fireEvent.click(screen.getByText("To delete"), { metaKey: true });
|
||||
fireEvent.mouseDown(screen.getByText("To delete"), { metaKey: true, button: 0 });
|
||||
|
||||
// Clear the mock calls from setup
|
||||
mockInvoke.mockClear();
|
||||
|
||||
187
src/App.tsx
187
src/App.tsx
@ -1,13 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
// clipboard write now happens in Rust's paste_and_refocus command
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import SearchBar from "./components/SearchBar";
|
||||
import ClipboardList from "./components/ClipboardList";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import ContextMenu from "./components/ContextMenu";
|
||||
import type { ClipboardEntry, Settings } from "./types";
|
||||
import { DISPLAY_LIMIT } from "./types";
|
||||
import { applyTransform, type TransformId } from "./transforms";
|
||||
|
||||
function entriesFingerprint(entries: ClipboardEntry[]): string {
|
||||
return entries.map((e) => `${e.id}:${e.pinned ? 1 : 0}:${e.content_hash}`).join("|");
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [entries, setEntries] = useState<ClipboardEntry[]>([]);
|
||||
@ -23,20 +28,29 @@ export default function App() {
|
||||
entry: ClipboardEntry;
|
||||
} | null>(null);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const resizeTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const fingerprintRef = useRef("");
|
||||
const latestIdRef = useRef<number | null>(null);
|
||||
const queryRef = useRef(query);
|
||||
queryRef.current = query;
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
const loadEntries = useCallback(async (force = false) => {
|
||||
try {
|
||||
const limit = settings?.max_history ?? 10000;
|
||||
const data: ClipboardEntry[] = query.trim()
|
||||
? await invoke("search_entries", { query, limit })
|
||||
: await invoke("get_entries", { limit });
|
||||
const q = queryRef.current;
|
||||
const data: ClipboardEntry[] = q.trim()
|
||||
? await invoke("search_entries", { query: q, limit: DISPLAY_LIMIT })
|
||||
: await invoke("get_entries", { limit: DISPLAY_LIMIT });
|
||||
const fp = entriesFingerprint(data);
|
||||
const maxId = data.reduce((m, e) => Math.max(m, e.id), 0);
|
||||
if (!force && fp === fingerprintRef.current) return;
|
||||
fingerprintRef.current = fp;
|
||||
latestIdRef.current = maxId || null;
|
||||
setEntries(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load entries:", e);
|
||||
}
|
||||
}, [query, settings?.max_history]);
|
||||
}, []);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
@ -49,20 +63,49 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
loadEntries(true);
|
||||
}, [loadSettings, loadEntries]);
|
||||
|
||||
// Refresh when history changes or the picker becomes visible again.
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
pollRef.current = setInterval(loadEntries, 1000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
listen("clipboard-changed", () => {
|
||||
loadEntries();
|
||||
}).then((fn) => unsubs.push(fn));
|
||||
|
||||
listen("open-settings", () => setShowSettings(true)).then((fn) => unsubs.push(fn));
|
||||
|
||||
const win = getCurrentWindow();
|
||||
win.onFocusChanged(({ payload: focused }) => {
|
||||
if (focused) loadEntries();
|
||||
}).then((unlisten) => unsubs.push(unlisten));
|
||||
|
||||
// Cheap fallback: only ask for the latest id while focused, not full rows.
|
||||
const cheapPoll = setInterval(async () => {
|
||||
try {
|
||||
if (document.hidden) return;
|
||||
const latest: number | null = await invoke("latest_entry_id");
|
||||
if (latest != null && latest !== latestIdRef.current) {
|
||||
loadEntries();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearInterval(cheapPoll);
|
||||
unsubs.forEach((fn) => fn());
|
||||
};
|
||||
}, [loadEntries]);
|
||||
|
||||
// Debounced search — typing should not hammer SQLite / IPC.
|
||||
useEffect(() => {
|
||||
const unlisten = listen("open-settings", () => setShowSettings(true));
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => loadEntries(true), 120);
|
||||
return () => clearTimeout(searchTimer.current);
|
||||
}, [query, loadEntries]);
|
||||
|
||||
// Persist window size on resize (debounced)
|
||||
useEffect(() => {
|
||||
@ -81,34 +124,81 @@ export default function App() {
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const resolveFullContent = useCallback(async (entry: ClipboardEntry): Promise<string> => {
|
||||
if (entry.truncated || entry.content_type === "image") {
|
||||
const full: ClipboardEntry = await invoke("get_entry", { id: entry.id });
|
||||
return full.content;
|
||||
}
|
||||
return entry.content;
|
||||
}, []);
|
||||
|
||||
// Copy selected entries and paste into previous app
|
||||
const pasteEntries = useCallback(
|
||||
async (entriesToPaste: ClipboardEntry[]) => {
|
||||
async (entriesToPaste: ClipboardEntry[], transform: TransformId = "none") => {
|
||||
if (entriesToPaste.length === 0) return;
|
||||
try {
|
||||
const content = entriesToPaste
|
||||
.map((e) => e.content)
|
||||
.join("\n");
|
||||
await invoke("paste_and_refocus", { content });
|
||||
if (entriesToPaste.length === 1) {
|
||||
const entry = entriesToPaste[0];
|
||||
let content = await resolveFullContent(entry);
|
||||
let contentType = entry.content_type;
|
||||
if (contentType === "text" || transform !== "none") {
|
||||
content = applyTransform(content, transform);
|
||||
contentType = "text";
|
||||
}
|
||||
await invoke("paste_and_refocus", {
|
||||
content,
|
||||
contentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parts = await Promise.all(entriesToPaste.map(resolveFullContent));
|
||||
const joined = applyTransform(parts.join("\n"), transform);
|
||||
await invoke("paste_and_refocus", {
|
||||
content: joined,
|
||||
contentType: "text",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Paste failed:", e);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[resolveFullContent]
|
||||
);
|
||||
|
||||
const pasteSingle = useCallback(
|
||||
(entry: ClipboardEntry) => pasteEntries([entry]),
|
||||
(entry: ClipboardEntry, transform: TransformId = "none") =>
|
||||
pasteEntries([entry], transform),
|
||||
[pasteEntries]
|
||||
);
|
||||
|
||||
// Copy-only: put on the system clipboard without auto-pasting or hiding
|
||||
// the window (Ditto/Pastebot-style "build up a paste elsewhere").
|
||||
const copyEntries = useCallback(
|
||||
async (entriesToCopy: ClipboardEntry[]) => {
|
||||
if (entriesToCopy.length === 0) return;
|
||||
try {
|
||||
if (entriesToCopy.length === 1) {
|
||||
const entry = entriesToCopy[0];
|
||||
const content = await resolveFullContent(entry);
|
||||
await invoke("copy_to_clipboard", { content, contentType: entry.content_type });
|
||||
return;
|
||||
}
|
||||
const parts = await Promise.all(entriesToCopy.map(resolveFullContent));
|
||||
await invoke("copy_to_clipboard", { content: parts.join("\n"), contentType: "text" });
|
||||
} catch (e) {
|
||||
console.error("Copy failed:", e);
|
||||
}
|
||||
},
|
||||
[resolveFullContent]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (ids: number[]) => {
|
||||
for (const id of ids) {
|
||||
await invoke("delete_entry", { id });
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
loadEntries();
|
||||
fingerprintRef.current = "";
|
||||
loadEntries(true);
|
||||
},
|
||||
[loadEntries]
|
||||
);
|
||||
@ -116,14 +206,15 @@ export default function App() {
|
||||
const handleTogglePin = useCallback(
|
||||
async (id: number) => {
|
||||
await invoke("toggle_pin", { id });
|
||||
loadEntries();
|
||||
fingerprintRef.current = "";
|
||||
loadEntries(true);
|
||||
},
|
||||
[loadEntries]
|
||||
);
|
||||
|
||||
// Selection helpers
|
||||
const selectOnly = useCallback((index: number, entries: ClipboardEntry[]) => {
|
||||
const e = entries[index];
|
||||
const selectOnly = useCallback((index: number, list: ClipboardEntry[]) => {
|
||||
const e = list[index];
|
||||
if (e) {
|
||||
setSelectedIds(new Set([e.id]));
|
||||
setAnchorIndex(index);
|
||||
@ -132,12 +223,12 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const selectRange = useCallback(
|
||||
(from: number, to: number, entries: ClipboardEntry[]) => {
|
||||
(from: number, to: number, list: ClipboardEntry[]) => {
|
||||
const lo = Math.min(from, to);
|
||||
const hi = Math.max(from, to);
|
||||
const ids = new Set<number>();
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
if (entries[i]) ids.add(entries[i].id);
|
||||
if (list[i]) ids.add(list[i].id);
|
||||
}
|
||||
setSelectedIds(ids);
|
||||
setFocusIndex(to);
|
||||
@ -146,8 +237,8 @@ export default function App() {
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback(
|
||||
(index: number, entries: ClipboardEntry[]) => {
|
||||
const e = entries[index];
|
||||
(index: number, list: ClipboardEntry[]) => {
|
||||
const e = list[index];
|
||||
if (!e) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@ -175,7 +266,7 @@ export default function App() {
|
||||
// Cmd+A select all
|
||||
if (e.metaKey && e.key === "a" && document.activeElement?.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
setSelectedIds(new Set(entries.map((e) => e.id)));
|
||||
setSelectedIds(new Set(entries.map((en) => en.id)));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -197,7 +288,7 @@ export default function App() {
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const selected = entries.filter((e) => selectedIds.has(e.id));
|
||||
const selected = entries.filter((en) => selectedIds.has(en.id));
|
||||
if (selected.length > 0) pasteEntries(selected);
|
||||
else if (entries[focusIndex]) pasteSingle(entries[focusIndex]);
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
@ -234,7 +325,6 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
|
||||
{/* Titlebar drag region — sits behind the native traffic lights */}
|
||||
<div className="titlebar-spacer shrink-0" data-tauri-drag-region />
|
||||
|
||||
{showSettings && settings ? (
|
||||
@ -266,7 +356,9 @@ export default function App() {
|
||||
selectedIds={selectedIds}
|
||||
focusIndex={focusIndex}
|
||||
showImages={settings?.show_images ?? true}
|
||||
onSelect={(entry) => pasteSingle(entry)}
|
||||
onSelect={(entry, opts) =>
|
||||
pasteSingle(entry, opts?.plain ? "plain" : "none")
|
||||
}
|
||||
onCtrlClick={(index) => toggleSelect(index, entries)}
|
||||
onShiftClick={(index) => selectRange(anchorIndex, index, entries)}
|
||||
onPlainClick={(index) => selectOnly(index, entries)}
|
||||
@ -284,7 +376,9 @@ export default function App() {
|
||||
|
||||
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between items-center">
|
||||
<span>
|
||||
{selectedCount > 1 ? `${selectedCount} selected` : "⌘1-9 quick paste"}
|
||||
{selectedCount > 1
|
||||
? `${selectedCount} selected`
|
||||
: "⌘1-9 paste · ⌥ click plain"}
|
||||
</span>
|
||||
<span>{entries.length} items</span>
|
||||
</div>
|
||||
@ -297,14 +391,27 @@ export default function App() {
|
||||
y={contextMenu.y}
|
||||
entry={contextMenu.entry}
|
||||
selectedCount={selectedCount}
|
||||
onCopy={() => {
|
||||
onPaste={(transform) => {
|
||||
const selected = entries.filter((e) => selectedIds.has(e.id));
|
||||
pasteEntries(selected.length > 0 ? selected : [contextMenu.entry]);
|
||||
pasteEntries(
|
||||
selected.length > 0 ? selected : [contextMenu.entry],
|
||||
transform ?? "none"
|
||||
);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onCopyOnly={() => {
|
||||
const selected = entries.filter((e) => selectedIds.has(e.id));
|
||||
copyEntries(selected.length > 0 ? selected : [contextMenu.entry]);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onPin={() => {
|
||||
handleTogglePin(contextMenu.entry.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onPin={() => handleTogglePin(contextMenu.entry.id)}
|
||||
onDelete={() => {
|
||||
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : [contextMenu.entry.id];
|
||||
handleDelete(ids);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -42,13 +42,21 @@ describe("ClipboardList", () => {
|
||||
expect(screen.getByText("PIN")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelect when entry is clicked without modifiers", () => {
|
||||
it("calls onSelect when entry is mouse-downed without modifiers", () => {
|
||||
const onSelect = vi.fn();
|
||||
const entries = [makeEntry({ content: "Click me" })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Click me"));
|
||||
expect(onSelect).toHaveBeenCalledWith(entries[0]);
|
||||
fireEvent.mouseDown(screen.getByText("Click me"));
|
||||
expect(onSelect).toHaveBeenCalledWith(entries[0], { plain: false });
|
||||
});
|
||||
|
||||
it("requests plain paste when Alt is held", () => {
|
||||
const onSelect = vi.fn();
|
||||
const entries = [makeEntry({ content: "Rich stuff" })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
|
||||
fireEvent.mouseDown(screen.getByText("Rich stuff"), { altKey: true, button: 0 });
|
||||
expect(onSelect).toHaveBeenCalledWith(entries[0], { plain: true });
|
||||
});
|
||||
|
||||
it("calls onContextMenu on right-click", () => {
|
||||
@ -87,7 +95,7 @@ describe("ClipboardList", () => {
|
||||
expect(screen.getByText("⌘3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image entries when showImages is true", () => {
|
||||
it("renders image entries when showImages is true and content is a data URI", () => {
|
||||
const entries = [
|
||||
makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }),
|
||||
];
|
||||
@ -97,13 +105,22 @@ describe("ClipboardList", () => {
|
||||
expect(img).toHaveAttribute("src", "data:image/png;base64,abc");
|
||||
});
|
||||
|
||||
it("renders image entries as text when showImages is false", () => {
|
||||
it("renders image stub when blob was omitted from list payload", () => {
|
||||
const entries = [
|
||||
makeEntry({ content: "", content_type: "image", truncated: true }),
|
||||
];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
|
||||
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image stub when showImages is false", () => {
|
||||
const entries = [
|
||||
makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }),
|
||||
];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
|
||||
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("data:image/png;base64,abc")).toBeInTheDocument();
|
||||
expect(screen.getByText("Image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights selected entries", () => {
|
||||
@ -122,7 +139,7 @@ describe("ClipboardList", () => {
|
||||
render(
|
||||
<ClipboardList {...defaultProps} entries={entries} onCtrlClick={onCtrlClick} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("Ctrl item"), { metaKey: true });
|
||||
fireEvent.mouseDown(screen.getByText("Ctrl item"), { metaKey: true, button: 0 });
|
||||
expect(onCtrlClick).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
@ -132,7 +149,7 @@ describe("ClipboardList", () => {
|
||||
render(
|
||||
<ClipboardList {...defaultProps} entries={entries} onShiftClick={onShiftClick} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("Shift item"), { shiftKey: true });
|
||||
fireEvent.mouseDown(screen.getByText("Shift item"), { shiftKey: true, button: 0 });
|
||||
expect(onShiftClick).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
@ -153,6 +170,18 @@ describe("ClipboardList", () => {
|
||||
expect(screen.getByText("TXT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a color swatch for a hex color entry", () => {
|
||||
const entries = [makeEntry({ content: "#3366ff" })];
|
||||
const { container } = render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show a color swatch for ordinary text", () => {
|
||||
const entries = [makeEntry({ content: "Just some notes" })];
|
||||
const { container } = render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
expect(container.querySelector(".rounded-full")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows IMG badge for image entries", () => {
|
||||
const entries = Array.from({ length: 10 }, (_, i) =>
|
||||
makeEntry({
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { ClipboardEntry } from "../types";
|
||||
|
||||
// Cheap, anchored regex — only matches when the *whole* trimmed entry is a
|
||||
// color literal, so it never lights up mid-paragraph substrings.
|
||||
const COLOR_RE =
|
||||
/^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|rgba?\([^)]+\)|hsla?\([^)]+\))$/i;
|
||||
|
||||
function detectColor(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (t.length === 0 || t.length > 32) return null;
|
||||
return COLOR_RE.test(t) ? t : null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: ClipboardEntry[];
|
||||
selectedIds: Set<number>;
|
||||
focusIndex: number;
|
||||
showImages: boolean;
|
||||
onSelect: (entry: ClipboardEntry) => void;
|
||||
onSelect: (entry: ClipboardEntry, opts?: { plain?: boolean }) => void;
|
||||
onCtrlClick: (index: number) => void;
|
||||
onShiftClick: (index: number) => void;
|
||||
onPlainClick: (index: number) => void;
|
||||
@ -46,13 +57,23 @@ function EntryPreview({
|
||||
entry: ClipboardEntry;
|
||||
showImages: boolean;
|
||||
}) {
|
||||
if (entry.content_type === "image" && showImages) {
|
||||
if (entry.content_type === "image") {
|
||||
if (showImages && entry.content.startsWith("data:image")) {
|
||||
return (
|
||||
<img
|
||||
src={entry.content}
|
||||
alt="Clipboard image"
|
||||
className="h-12 w-16 rounded object-cover bg-surface-active"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={entry.content}
|
||||
alt="Clipboard image"
|
||||
className="max-h-12 max-w-full rounded object-contain"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px] leading-snug text-text-secondary">
|
||||
<span className="inline-block h-8 w-10 rounded bg-surface-active" />
|
||||
Image
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -61,13 +82,118 @@ function EntryPreview({
|
||||
? entry.content.slice(0, 160) + "…"
|
||||
: entry.content;
|
||||
|
||||
const color = entry.sensitive ? null : detectColor(entry.content);
|
||||
|
||||
return (
|
||||
<span className="text-[13px] leading-snug text-text-primary line-clamp-2 break-all">
|
||||
{display}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{color && (
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full border border-border shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`text-[13px] leading-snug line-clamp-2 break-all ${
|
||||
entry.sensitive ? "text-text-secondary font-mono tracking-wide" : "text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{display || "(empty)"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
entry: ClipboardEntry;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
showImages: boolean;
|
||||
onSelect: (entry: ClipboardEntry, opts?: { plain?: boolean }) => void;
|
||||
onCtrlClick: (index: number) => void;
|
||||
onShiftClick: (index: number) => void;
|
||||
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
|
||||
setFocusIndex: (i: number) => void;
|
||||
}
|
||||
|
||||
// Memoized so selection/focus changes elsewhere in the list don't re-render
|
||||
// every row's preview (image decode, color regex, etc.) each time.
|
||||
const Row = memo(function Row({
|
||||
entry,
|
||||
index: i,
|
||||
isSelected,
|
||||
isFocused,
|
||||
showImages,
|
||||
onSelect,
|
||||
onCtrlClick,
|
||||
onShiftClick,
|
||||
onContextMenu,
|
||||
setFocusIndex,
|
||||
}: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
|
||||
${isSelected ? "bg-accent/10" : ""}
|
||||
${isFocused && !isSelected ? "bg-surface-hover" : ""}
|
||||
${!isSelected && !isFocused ? "hover:bg-surface-hover" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
// Use mousedown so paste wins the race against window blur/hide.
|
||||
if (e.button !== 0) return;
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
onCtrlClick(i);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onShiftClick(i);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
// ⌥/Alt = paste without formatting
|
||||
onSelect(entry, { plain: e.altKey });
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Prevent the follow-up click from doing anything extra.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onContextMenu={(e) => onContextMenu(e, entry)}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
>
|
||||
{/* Left column: shortcut badge or type */}
|
||||
<div className="w-6 text-center shrink-0 pt-0.5">
|
||||
{entry.sensitive ? (
|
||||
<span className="text-[10px] text-danger opacity-80" title="Sensitive — redacted in list">
|
||||
••
|
||||
</span>
|
||||
) : i < 9 ? (
|
||||
<span className="text-[10px] font-mono text-text-secondary opacity-60">
|
||||
⌘{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<TypeBadge type={entry.content_type} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<EntryPreview entry={entry} showImages={showImages} />
|
||||
</div>
|
||||
|
||||
{/* Right column: meta */}
|
||||
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5">
|
||||
{entry.pinned && (
|
||||
<span className="text-[9px] font-semibold text-pin tracking-wide">PIN</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-secondary tabular-nums">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function ClipboardList({
|
||||
entries,
|
||||
selectedIds,
|
||||
@ -76,7 +202,6 @@ export default function ClipboardList({
|
||||
onSelect,
|
||||
onCtrlClick,
|
||||
onShiftClick,
|
||||
onPlainClick,
|
||||
onContextMenu,
|
||||
setFocusIndex,
|
||||
}: Props) {
|
||||
@ -100,57 +225,21 @@ export default function ClipboardList({
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="divide-y divide-border">
|
||||
{entries.map((entry, i) => {
|
||||
const isSelected = selectedIds.has(entry.id);
|
||||
const isFocused = i === focusIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
|
||||
${isSelected ? "bg-accent/10" : ""}
|
||||
${isFocused && !isSelected ? "bg-surface-hover" : ""}
|
||||
${!isSelected && !isFocused ? "hover:bg-surface-hover" : ""}`}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
onCtrlClick(i);
|
||||
} else if (e.shiftKey) {
|
||||
onShiftClick(i);
|
||||
} else {
|
||||
onSelect(entry);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => onContextMenu(e, entry)}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
>
|
||||
{/* Left column: shortcut badge or type */}
|
||||
<div className="w-6 text-center shrink-0 pt-0.5">
|
||||
{i < 9 ? (
|
||||
<span className="text-[10px] font-mono text-text-secondary opacity-60">
|
||||
⌘{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<TypeBadge type={entry.content_type} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<EntryPreview entry={entry} showImages={showImages} />
|
||||
</div>
|
||||
|
||||
{/* Right column: meta */}
|
||||
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5">
|
||||
{entry.pinned && (
|
||||
<span className="text-[9px] font-semibold text-pin tracking-wide">PIN</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-secondary tabular-nums">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{entries.map((entry, i) => (
|
||||
<Row
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
index={i}
|
||||
isSelected={selectedIds.has(entry.id)}
|
||||
isFocused={i === focusIndex}
|
||||
showImages={showImages}
|
||||
onSelect={onSelect}
|
||||
onCtrlClick={onCtrlClick}
|
||||
onShiftClick={onShiftClick}
|
||||
onContextMenu={onContextMenu}
|
||||
setFocusIndex={setFocusIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,18 +9,28 @@ describe("ContextMenu", () => {
|
||||
y: 100,
|
||||
entry: makeEntry(),
|
||||
selectedCount: 1,
|
||||
onCopy: vi.fn(),
|
||||
onPaste: vi.fn(),
|
||||
onCopyOnly: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
it("renders Paste, Pin, and Delete actions", () => {
|
||||
it("renders Paste, Paste plain, Copy, Pin, and Delete actions", () => {
|
||||
render(<ContextMenu {...defaultProps} />);
|
||||
expect(screen.getByText("Paste")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paste plain")).toBeInTheDocument();
|
||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCopyOnly when Copy is clicked", () => {
|
||||
const onCopyOnly = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onCopyOnly={onCopyOnly} />);
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
expect(onCopyOnly).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows Unpin for pinned entries", () => {
|
||||
const entry = makeEntry({ pinned: true });
|
||||
render(<ContextMenu {...defaultProps} entry={entry} />);
|
||||
@ -33,11 +43,25 @@ describe("ContextMenu", () => {
|
||||
expect(screen.getByText("Delete 3 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCopy when Paste is clicked", () => {
|
||||
const onCopy = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onCopy={onCopy} />);
|
||||
it("calls onPaste when Paste is clicked", () => {
|
||||
const onPaste = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
||||
fireEvent.click(screen.getByText("Paste"));
|
||||
expect(onCopy).toHaveBeenCalledOnce();
|
||||
expect(onPaste).toHaveBeenCalledWith("none");
|
||||
});
|
||||
|
||||
it("calls onPaste(plain) for Paste plain", () => {
|
||||
const onPaste = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
||||
fireEvent.click(screen.getByText("Paste plain"));
|
||||
expect(onPaste).toHaveBeenCalledWith("plain");
|
||||
});
|
||||
|
||||
it("calls onPaste with transform id", () => {
|
||||
const onPaste = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onPaste={onPaste} />);
|
||||
fireEvent.click(screen.getByText("Pretty JSON"));
|
||||
expect(onPaste).toHaveBeenCalledWith("json");
|
||||
});
|
||||
|
||||
it("calls onPin when Pin is clicked", () => {
|
||||
@ -61,10 +85,10 @@ describe("ContextMenu", () => {
|
||||
expect(menu.style.top).toBe("150px");
|
||||
});
|
||||
|
||||
it("hides Pin button when multiple items are selected", () => {
|
||||
it("hides Pin and transforms when multiple items are selected", () => {
|
||||
render(<ContextMenu {...defaultProps} selectedCount={3} />);
|
||||
expect(screen.queryByText("Pin")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Unpin")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Pretty JSON")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stops click propagation", () => {
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { ClipboardEntry } from "../types";
|
||||
import { TRANSFORMS, type TransformId } from "../transforms";
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
entry: ClipboardEntry;
|
||||
selectedCount: number;
|
||||
onCopy: () => void;
|
||||
onPaste: (transform?: TransformId) => void;
|
||||
onCopyOnly: () => void;
|
||||
onPin: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
@ -15,33 +17,66 @@ export default function ContextMenu({
|
||||
y,
|
||||
entry,
|
||||
selectedCount,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onCopyOnly,
|
||||
onPin,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const adjustedX = Math.min(x, window.innerWidth - 170);
|
||||
const adjustedY = Math.min(y, window.innerHeight - 130);
|
||||
const adjustedX = Math.min(x, window.innerWidth - 200);
|
||||
const adjustedY = Math.min(y, window.innerHeight - 280);
|
||||
const multi = selectedCount > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[160px] text-[13px]"
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[180px] text-[13px] max-h-[70vh] overflow-y-auto"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onCopy}
|
||||
onClick={() => onPaste("none")}
|
||||
>
|
||||
{multi ? `Paste ${selectedCount} items` : "Paste"}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={() => onPaste("plain")}
|
||||
>
|
||||
Paste plain
|
||||
<span className="float-right text-[10px] text-text-secondary ml-2">⌥ click</span>
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onCopyOnly}
|
||||
>
|
||||
{multi ? `Copy ${selectedCount} items` : "Copy"}
|
||||
<span className="float-right text-[10px] text-text-secondary ml-2">no auto-paste</span>
|
||||
</button>
|
||||
|
||||
{!multi && (
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onPin}
|
||||
>
|
||||
{entry.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
<>
|
||||
<div className="border-t border-border my-1" />
|
||||
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
|
||||
Transform
|
||||
</div>
|
||||
{TRANSFORMS.filter((t) => t.id !== "none" && t.id !== "plain").map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={() => onPaste(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onPin}
|
||||
>
|
||||
{entry.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
|
||||
@ -78,6 +78,23 @@ describe("SettingsPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables macOS autostart when launching at login is turned on", async () => {
|
||||
const { enable } = await import("@tauri-apps/plugin-autostart");
|
||||
mockInvoke.mockResolvedValue(makeSettings({ launch_at_login: true }));
|
||||
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
const switches = screen.getAllByRole("switch");
|
||||
fireEvent.click(switches[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(enable).toHaveBeenCalled();
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
|
||||
key: "launch_at_login",
|
||||
value: "true",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes set_setting when changing max_history", async () => {
|
||||
mockInvoke.mockResolvedValue(makeSettings({ max_history: 1000 }));
|
||||
|
||||
@ -92,6 +109,44 @@ describe("SettingsPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the current hotkey formatted with symbols", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Global hotkey")).toBeInTheDocument();
|
||||
expect(screen.getByText("⌃`")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enters recording mode when hotkey button clicked", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("⌃`"));
|
||||
expect(screen.getByText("Press shortcut… (Esc to cancel)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes set_hotkey when a shortcut is recorded", async () => {
|
||||
mockInvoke.mockResolvedValue(makeSettings({ hotkey: "cmd+shift+v" }));
|
||||
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("⌃`"));
|
||||
fireEvent.keyDown(window, { code: "KeyV", key: "v", metaKey: true, shiftKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_hotkey", { hotkey: "cmd+shift+v" });
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels recording on Escape", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("⌃`"));
|
||||
fireEvent.keyDown(window, { code: "Escape", key: "Escape" });
|
||||
expect(screen.getByText("⌃`")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error when no modifier is pressed", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("⌃`"));
|
||||
fireEvent.keyDown(window, { code: "KeyV", key: "v" });
|
||||
expect(screen.getByText(/Include at least one modifier/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows clear all button", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Clear All History")).toBeInTheDocument();
|
||||
|
||||
@ -1,7 +1,57 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import type { Settings } from "../types";
|
||||
|
||||
/** Map a KeyboardEvent.code to the token used in hotkey strings (e.g. "v", "`", "f5"). */
|
||||
function codeToToken(code: string): string | null {
|
||||
if (code.startsWith("Key")) return code.slice(3).toLowerCase();
|
||||
if (code.startsWith("Digit")) return code.slice(5);
|
||||
if (/^F([1-9]|1[0-2])$/.test(code)) return code.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
Backquote: "`",
|
||||
Space: "space",
|
||||
Enter: "enter",
|
||||
Tab: "tab",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Backslash: "\\",
|
||||
};
|
||||
return map[code] ?? null;
|
||||
}
|
||||
|
||||
const TOKEN_SYMBOLS: Record<string, string> = {
|
||||
cmd: "⌘",
|
||||
shift: "⇧",
|
||||
ctrl: "⌃",
|
||||
alt: "⌥",
|
||||
space: "Space",
|
||||
enter: "↩",
|
||||
tab: "⇥",
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
left: "←",
|
||||
right: "→",
|
||||
};
|
||||
|
||||
function formatHotkey(hotkey: string): string {
|
||||
return hotkey
|
||||
.split("+")
|
||||
.map((t) => TOKEN_SYMBOLS[t] ?? t.toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
settings: Settings;
|
||||
onClose: () => void;
|
||||
@ -21,11 +71,48 @@ const POSITION_OPTIONS: { value: string; label: string }[] = [
|
||||
|
||||
export default function SettingsPanel({ settings: initialSettings, onClose, onUpdate }: Props) {
|
||||
const [settings, setSettings] = useState<Settings>(initialSettings);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
// Keep the toggle in sync with the real macOS Login Item (release builds).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const on = await isEnabled();
|
||||
if (cancelled || on === initialSettings.launch_at_login) return;
|
||||
await invoke("set_setting", { key: "launch_at_login", value: String(on) });
|
||||
const updated: Settings = await invoke("get_settings");
|
||||
if (!cancelled) {
|
||||
setSettings(updated);
|
||||
onUpdate(updated);
|
||||
}
|
||||
} catch {
|
||||
// Dev / tests may lack the native plugin — ignore.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialSettings.launch_at_login, onUpdate]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
// Optimistically update local state for instant visual feedback
|
||||
setSettings((prev) => ({ ...prev, [key]: parseSettingValue(key, value) }));
|
||||
setLoginError(null);
|
||||
|
||||
if (key === "launch_at_login") {
|
||||
try {
|
||||
if (value === "true") await enable();
|
||||
else await disable();
|
||||
} catch (e) {
|
||||
setLoginError(
|
||||
"Could not update Login Items. Use a built .app (npm run tauri build), then try again."
|
||||
);
|
||||
// Revert optimistic UI
|
||||
setSettings((prev) => ({ ...prev, launch_at_login: value !== "true" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await invoke("set_setting", { key, value });
|
||||
const updated: Settings = await invoke("get_settings");
|
||||
@ -66,6 +153,11 @@ export default function SettingsPanel({ settings: initialSettings, onClose, onUp
|
||||
checked={settings.launch_at_login}
|
||||
onChange={(v) => updateSetting("launch_at_login", String(v))}
|
||||
/>
|
||||
{loginError && <p className="text-[12px] text-danger -mt-3">{loginError}</p>}
|
||||
<p className="text-[11px] text-text-secondary -mt-3">
|
||||
Opens maCopy when you log in to your Mac (Login Items). Needs the installed .app, not just
|
||||
`tauri dev`.
|
||||
</p>
|
||||
|
||||
<ToggleRow
|
||||
label="Show image previews"
|
||||
@ -73,6 +165,14 @@ export default function SettingsPanel({ settings: initialSettings, onClose, onUp
|
||||
onChange={(v) => updateSetting("show_images", String(v))}
|
||||
/>
|
||||
|
||||
<HotkeyRow
|
||||
hotkey={settings.hotkey}
|
||||
onSaved={(updated) => {
|
||||
setSettings(updated);
|
||||
onUpdate(updated);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-primary mb-2">
|
||||
Max history
|
||||
@ -135,6 +235,80 @@ function parseSettingValue(key: string, value: string): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function HotkeyRow({
|
||||
hotkey,
|
||||
onSaved,
|
||||
}: {
|
||||
hotkey: string;
|
||||
onSaved: (settings: Settings) => void;
|
||||
}) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = codeToToken(e.code);
|
||||
if (!token) return; // modifier-only or unsupported key — keep waiting
|
||||
|
||||
const mods: string[] = [];
|
||||
if (e.metaKey) mods.push("cmd");
|
||||
if (e.ctrlKey) mods.push("ctrl");
|
||||
if (e.altKey) mods.push("alt");
|
||||
if (e.shiftKey) mods.push("shift");
|
||||
if (mods.length === 0) {
|
||||
setError("Include at least one modifier (⌘ ⌃ ⌥ ⇧)");
|
||||
return;
|
||||
}
|
||||
|
||||
const newHotkey = [...mods, token].join("+");
|
||||
setRecording(false);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("set_hotkey", { hotkey: newHotkey });
|
||||
const updated: Settings = await invoke("get_settings");
|
||||
onSaved(updated);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [recording, onSaved]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-primary mb-2">
|
||||
Global hotkey
|
||||
</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setRecording((r) => !r);
|
||||
}}
|
||||
className={`w-full py-1.5 rounded text-[13px] font-medium transition-colors border
|
||||
${recording
|
||||
? "border-accent text-accent bg-accent/10 animate-pulse"
|
||||
: "border-border bg-surface-hover text-text-primary hover:bg-surface-active"
|
||||
}`}
|
||||
>
|
||||
{recording ? "Press shortcut… (Esc to cancel)" : formatHotkey(hotkey)}
|
||||
</button>
|
||||
{error && <p className="mt-1.5 text-[12px] text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
|
||||
@ -11,6 +11,8 @@ export function makeEntry(overrides: Partial<ClipboardEntry> = {}): ClipboardEnt
|
||||
created_at: new Date().toISOString(),
|
||||
pinned: false,
|
||||
content_hash: `hash_${id}`,
|
||||
truncated: false,
|
||||
sensitive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@ -23,6 +25,7 @@ export function makeSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
window_position: "cursor",
|
||||
window_width: 420,
|
||||
window_height: 560,
|
||||
hotkey: "ctrl+`",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ vi.mock("@tauri-apps/api/window", () => ({
|
||||
show: vi.fn(() => Promise.resolve()),
|
||||
innerSize: vi.fn(() => Promise.resolve({ width: 840, height: 1120 })),
|
||||
scaleFactor: vi.fn(() => Promise.resolve(2)),
|
||||
onFocusChanged: vi.fn(() => Promise.resolve(() => {})),
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -21,3 +22,9 @@ vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
readText: vi.fn(() => Promise.resolve("")),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-autostart", () => ({
|
||||
enable: vi.fn(() => Promise.resolve()),
|
||||
disable: vi.fn(() => Promise.resolve()),
|
||||
isEnabled: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
20
src/transforms.test.ts
Normal file
20
src/transforms.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyTransform, toPlainText } from "./transforms";
|
||||
|
||||
describe("transforms", () => {
|
||||
it("strips html for plain paste", () => {
|
||||
expect(toPlainText("<b>Hi</b> there")).toBe("Hi there");
|
||||
});
|
||||
|
||||
it("pretty-prints json", () => {
|
||||
expect(applyTransform('{"a":1}', "json")).toContain('\n "a": 1');
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
expect(applyTransform("a b\n\n\nc", "collapse")).toBe("a b\n\nc");
|
||||
});
|
||||
|
||||
it("single line", () => {
|
||||
expect(applyTransform("a\nb\tc", "oneline")).toBe("a b c");
|
||||
});
|
||||
});
|
||||
74
src/transforms.ts
Normal file
74
src/transforms.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/** Paste-time text transforms (applied after loading full clipboard content). */
|
||||
|
||||
export type TransformId =
|
||||
| "none"
|
||||
| "plain"
|
||||
| "trim"
|
||||
| "lower"
|
||||
| "upper"
|
||||
| "collapse"
|
||||
| "json"
|
||||
| "oneline";
|
||||
|
||||
export const TRANSFORMS: { id: TransformId; label: string; hint?: string }[] = [
|
||||
{ id: "none", label: "As copied" },
|
||||
{ id: "plain", label: "Plain text", hint: "Strip tags / formatting" },
|
||||
{ id: "trim", label: "Trim whitespace" },
|
||||
{ id: "collapse", label: "Collapse spaces" },
|
||||
{ id: "oneline", label: "Single line" },
|
||||
{ id: "lower", label: "lowercase" },
|
||||
{ id: "upper", label: "UPPERCASE" },
|
||||
{ id: "json", label: "Pretty JSON" },
|
||||
];
|
||||
|
||||
function decodeBasicEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'");
|
||||
}
|
||||
|
||||
/** Paste without formatting: strip HTML-ish markup and normalize whitespace characters. */
|
||||
export function toPlainText(input: string): string {
|
||||
let s = input.replace(/\u00a0/g, " ").replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
// Drop simple tags if it looks like HTML
|
||||
if (/<\/?[a-z][\s\S]*>/i.test(s)) {
|
||||
s = s.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<[^>]+>/g, "");
|
||||
}
|
||||
s = decodeBasicEntities(s);
|
||||
// Soft line-break leftovers from rich paste
|
||||
s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
return s;
|
||||
}
|
||||
|
||||
export function applyTransform(input: string, id: TransformId): string {
|
||||
switch (id) {
|
||||
case "none":
|
||||
return input;
|
||||
case "plain":
|
||||
return toPlainText(input);
|
||||
case "trim":
|
||||
return input.trim();
|
||||
case "lower":
|
||||
return input.toLowerCase();
|
||||
case "upper":
|
||||
return input.toUpperCase();
|
||||
case "collapse":
|
||||
return input.replace(/[ \t\u00a0]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
||||
case "oneline":
|
||||
return toPlainText(input).replace(/\s+/g, " ").trim();
|
||||
case "json": {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(input.trim()), null, 2);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,10 @@ export interface ClipboardEntry {
|
||||
created_at: string;
|
||||
pinned: boolean;
|
||||
content_hash: string;
|
||||
/** True when `content` is a preview stub — fetch full via get_entry before paste. */
|
||||
truncated?: boolean;
|
||||
/** Preview is redacted (password/card/banking); full value still pastes. */
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@ -14,4 +18,8 @@ export interface Settings {
|
||||
window_position: "cursor" | "center" | "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
||||
window_width: number;
|
||||
window_height: number;
|
||||
hotkey: string;
|
||||
}
|
||||
|
||||
/** How many history rows to request for the picker UI. */
|
||||
export const DISPLAY_LIMIT = 150;
|
||||
|
||||
162
src/visual/main.tsx
Normal file
162
src/visual/main.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ClipboardList from "../components/ClipboardList";
|
||||
import ContextMenu from "../components/ContextMenu";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import type { ClipboardEntry } from "../types";
|
||||
import { applyTransform, type TransformId } from "../transforms";
|
||||
import "../index.css";
|
||||
|
||||
const DEMO: ClipboardEntry[] = [
|
||||
{
|
||||
id: 1,
|
||||
content: "•••••••• (password)",
|
||||
content_type: "text",
|
||||
created_at: new Date().toISOString(),
|
||||
pinned: true,
|
||||
content_hash: "a",
|
||||
truncated: true,
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: "•••• •••• •••• 1111 (card)",
|
||||
content_type: "text",
|
||||
created_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
pinned: false,
|
||||
content_hash: "b",
|
||||
truncated: true,
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: "Meeting notes — ship maCopy v0.1",
|
||||
content_type: "text",
|
||||
created_at: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
pinned: false,
|
||||
content_hash: "c",
|
||||
truncated: false,
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
content: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5NiIgaGVpZ2h0PSI2NCI+PHJlY3Qgd2lkdGg9Ijk2IiBoZWlnaHQ9IjY0IiBmaWxsPSIjNjM2NmYxIi8+PHRleHQgeD0iNDgiIHk9IjM4IiBmb250LXNpemU9IjEyIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+dGh1bWI8L3RleHQ+PC9zdmc+",
|
||||
content_type: "image",
|
||||
created_at: new Date(Date.now() - 7200_000).toISOString(),
|
||||
pinned: false,
|
||||
content_hash: "d",
|
||||
truncated: true,
|
||||
sensitive: false,
|
||||
},
|
||||
];
|
||||
|
||||
function VisualApp() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set([1]));
|
||||
const [banner, setBanner] = useState("Ready — watch the headed walkthrough");
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; entry: ClipboardEntry } | null>(
|
||||
null
|
||||
);
|
||||
const [lastPaste, setLastPaste] = useState("");
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return DEMO;
|
||||
return DEMO.filter((e) => e.content.toLowerCase().includes(q));
|
||||
}, [query]);
|
||||
|
||||
const paste = (entry: ClipboardEntry, transform: TransformId = "none") => {
|
||||
// Simulate paste of *stored* secret (not the redacted preview)
|
||||
const full =
|
||||
entry.id === 1
|
||||
? "Tr0ub4dor&3xY!"
|
||||
: entry.id === 2
|
||||
? "4111111111111111"
|
||||
: entry.content;
|
||||
const out = applyTransform(full, transform);
|
||||
setLastPaste(out);
|
||||
setBanner(`Paste (${transform}): ${out.slice(0, 48)}${out.length > 48 ? "…" : ""}`);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
|
||||
<div className="shrink-0 px-3 py-2 border-b border-border flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-text-secondary">
|
||||
Visual test page
|
||||
</div>
|
||||
<div className="text-[13px] text-text-primary" data-testid="banner">
|
||||
{banner}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-testid="open-menu"
|
||||
className="text-[12px] px-2 py-1 rounded bg-accent text-white"
|
||||
onClick={() => setMenu({ x: 220, y: 160, entry: entries[0] ?? DEMO[0] })}
|
||||
>
|
||||
Open context menu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-2 py-2">
|
||||
<SearchBar value={query} onChange={setQuery} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0" data-testid="list">
|
||||
<ClipboardList
|
||||
entries={entries}
|
||||
selectedIds={selectedIds}
|
||||
focusIndex={focusIndex}
|
||||
showImages
|
||||
onSelect={(entry, opts) => paste(entry, opts?.plain ? "plain" : "none")}
|
||||
onCtrlClick={(i) => {
|
||||
const e = entries[i];
|
||||
if (!e) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(e.id)) next.delete(e.id);
|
||||
else next.add(e.id);
|
||||
return next;
|
||||
});
|
||||
setBanner(`Toggled select #${e.id}`);
|
||||
}}
|
||||
onShiftClick={(i) => {
|
||||
setFocusIndex(i);
|
||||
setBanner(`Range focus → ${i}`);
|
||||
}}
|
||||
onPlainClick={(i) => setFocusIndex(i)}
|
||||
onContextMenu={(e, entry) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, entry });
|
||||
}}
|
||||
setFocusIndex={setFocusIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-3 py-2 border-t border-border text-[11px] text-text-secondary flex justify-between">
|
||||
<span>⌥ click = plain · right-click = transforms</span>
|
||||
<span data-testid="last-paste">{lastPaste ? `last: ${lastPaste}` : `${entries.length} items`}</span>
|
||||
</div>
|
||||
|
||||
{menu && (
|
||||
<ContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
entry={menu.entry}
|
||||
selectedCount={selectedIds.size}
|
||||
onPaste={(t) => paste(menu.entry, t ?? "none")}
|
||||
onCopyOnly={() => setBanner("Copied to clipboard (demo)")}
|
||||
onPin={() => setBanner("Pin toggled (demo)")}
|
||||
onDelete={() => {
|
||||
setBanner("Delete (demo — not persisted)");
|
||||
setMenu(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<VisualApp />);
|
||||
12
visual.html
Normal file
12
visual.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>maCopy — visual test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/visual/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
vitest.browser.config.ts
Normal file
31
vitest.browser.config.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
|
||||
/**
|
||||
* Headed browser runs — so you can watch the UI under test.
|
||||
* Usage: npm run test:headed
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright({
|
||||
launchOptions: {
|
||||
headless: false,
|
||||
slowMo: 250,
|
||||
},
|
||||
}),
|
||||
instances: [{ browser: "chromium" }],
|
||||
headless: false,
|
||||
viewport: { width: 900, height: 700 },
|
||||
},
|
||||
// Keep Chrome open longer between files so you can watch
|
||||
fileParallelism: false,
|
||||
sequence: { concurrent: false },
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user