From 3fcea83dbdf06f421dea642b9ef6216b2b0c0226 Mon Sep 17 00:00:00 2001 From: Barry Jones Date: Mon, 6 Jul 2026 13:42:23 +0100 Subject: [PATCH] feat: New from Clipboard (Cmd+N) and Reveal in Finder New from Clipboard saves the clipboard's text to a user-chosen path (filename suggested from the first heading or line of the content) and opens it through the standard open_file_in_window routing. Entry points: File menu (Cmd+N) and a Welcome-screen button. Empty/non-text clipboard shows an info dialog. Clipboard is read Rust-side via tauri-plugin-clipboard-manager, and the file write goes through a new write_text_file command mirroring read_file_as_text. Reveal in Finder / Show in File Explorer selects the open document in the OS file manager via the opener plugin's reveal_item_in_dir. Entry points: File menu (platform-specific label) and a viewer toolbar button. Hidden and no-op for the bundled user guide (no path on disk). Also rewrites src/USERGUIDE.md, which still documented the pre-refactor editor app (tabs, editing, saving), for the view-only viewer. Tests: 14 new Vitest cases (filename derivation + clipboard flow), 6 new Playwright specs; the web-mode mock gains a seedable clipboard, write_text_file/reveal_in_dir handling, and message-dialog recording. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 31 +- README.md | 12 +- src-tauri/Cargo.lock | 343 +++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 65 ++++ src-tauri/tauri.conf.json | 2 + src/USERGUIDE.md | 124 +++---- src/platform/index.ts | 1 + src/platform/tauri.ts | 5 +- src/platform/types.ts | 1 + src/platform/web.ts | 21 ++ src/utils/__tests__/fileUtils.test.ts | 54 ++- .../__tests__/newFileFromClipboard.test.ts | 92 +++++ src/utils/fileUtils.ts | 47 +++ src/utils/newFileFromClipboard.ts | 41 +++ src/windows/ViewerWindow.tsx | 55 ++- src/windows/WelcomeWindow.tsx | 26 +- src/windows/WindowRouter.tsx | 29 +- tests/e2e/new-from-clipboard.spec.ts | 64 ++++ tests/e2e/reveal.spec.ts | 55 +++ 20 files changed, 962 insertions(+), 107 deletions(-) create mode 100644 src/utils/__tests__/newFileFromClipboard.test.ts create mode 100644 src/utils/newFileFromClipboard.ts create mode 100644 tests/e2e/new-from-clipboard.spec.ts create mode 100644 tests/e2e/reveal.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index ef9b28e..a67eac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ MarkDoc is a lightweight, cross-platform desktop application for **viewing** Mar - `tauri-plugin-opener` — External links - `tauri-plugin-os` — OS/theme detection - `tauri-plugin-single-instance` — Routes second-instance file-open requests into the live process + - `tauri-plugin-clipboard-manager` — Clipboard read (New from Clipboard); accessed Rust-side only, no frontend capability ## Versioning and Build Numbers @@ -67,14 +68,14 @@ When recents change, the frontend calls `invoke('refresh_menus', { recents })` s Built in `src-tauri/src/lib.rs::build_menu()` using Tauri's MenuBuilder API. -| Menu | Items | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| **MarkDoc** (macOS) | About, Services, Hide / Hide Others / Show All, Quit | -| **File** | Open… (⌘O), Open Recent ▸ _(dynamic)_, Close Window (⌘W), Export ▸ (HTML ⌘⇧H, PDF ⌘⇧P) | -| **Edit** | Copy, Select All (native roles only) | -| **View** | Zoom In / Out / Actual Size, Theme ▸ (Default / Cobalt / Sage / Amber / Slate), Toggle Sidebar (⌘\\), Toggle Auto-resize | -| **Window** | Minimize, Maximize, _(dynamic list of open file windows)_ | -| **Help** | User Guide | +| Menu | Items | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **MarkDoc** (macOS) | About, Services, Hide / Hide Others / Show All, Quit | +| **File** | New from Clipboard… (⌘N), Open… (⌘O), Open Recent ▸ _(dynamic)_, Reveal in Finder _(platform label)_, Close Window (⌘W), Export ▸ (HTML ⌘⇧H, PDF ⌘⇧P) | +| **Edit** | Copy, Select All (native roles only) | +| **View** | Zoom In / Out / Actual Size, Theme ▸ (Default / Cobalt / Sage / Amber / Slate), Toggle Sidebar (⌘\\), Toggle Auto-resize | +| **Window** | Minimize, Maximize, _(dynamic list of open file windows)_ | +| **Help** | User Guide | Menu items emit `menu:///` events that the React windows subscribe to. Window-menu clicks call `set_focus()` directly on the target window. @@ -95,6 +96,9 @@ Kept minimal. See `src-tauri/src/lib.rs`. - `close_file_window(label)` — destroy a window by label. - `refresh_menus(recents)` — frontend→backend sync for Open Recent submenu. - `get_file_modified_time(path)` — millis since epoch; used for external-change detection. +- `read_clipboard_text()` — OS clipboard as plain text (`""` when empty/non-text). Backs the New-from-Clipboard flow. +- `write_text_file(path, contents)` — Rust-side write mirroring `read_file_as_text` (paths are user-authorised via the save dialog, so no fs-plugin write scopes are advertised). +- `reveal_in_dir(path)` — reveal a file in Finder / Explorer / file manager via the opener plugin. - `export_html_command`, `export_pdf_command`, `cancel_export` — export flow driven by `src-tauri/src/export.rs` (PDF goes through `headless_chrome`). ### Window registry (Rust) @@ -155,6 +159,7 @@ markdoc/ │ │ └── usePreferences.ts # Global prefs (localStorage-backed) │ ├── utils/ │ │ ├── openFileInWindow.ts # Central file-open router +│ │ ├── newFileFromClipboard.ts # Clipboard → save dialog → open flow │ │ ├── recentFiles.ts # Recents store + native-menu sync │ │ ├── pdfExport.ts # HTML generation for PDF export │ │ ├── linkHandler.ts # External link routing + XSS guards @@ -265,9 +270,9 @@ git tag v0.1.6 && git push origin main --tags ### Agent / UI testing workflow -The app ships a **web-mode** harness for fast UI e2e without Tauri. `npm run dev:web` swaps in `src/platform/web.ts` — a full in-memory `MockBackend` that records invoke calls and emits events. The mock handles `open_file_in_window`, `list_open_file_windows`, `get_app_version`, `export_html_command`, `refresh_menus`, and the theme/zoom/sidebar event bus. +The app ships a **web-mode** harness for fast UI e2e without Tauri. `npm run dev:web` swaps in `src/platform/web.ts` — a full in-memory `MockBackend` that records invoke calls and emits events. The mock handles `open_file_in_window`, `list_open_file_windows`, `get_app_version`, `export_html_command`, `refresh_menus`, `read_clipboard_text` (seed via `backend.clipboardText`), `write_text_file`, `reveal_in_dir`, and the theme/zoom/sidebar event bus. Message dialogs are recorded in `backend.calls` as `message_dialog` entries. -Stable `data-testid` selectors are present across Welcome (`welcome-root`, `welcome-open-button`, `welcome-recent-item`, …) and Viewer (`viewer-root`, `viewer-toolbar`, `viewer-zoom-in`, `viewer-theme-select`, `viewer-help-button`, …). +Stable `data-testid` selectors are present across Welcome (`welcome-root`, `welcome-open-button`, `welcome-new-from-clipboard`, `welcome-recent-item`, …) and Viewer (`viewer-root`, `viewer-toolbar`, `viewer-zoom-in`, `viewer-theme-select`, `viewer-reveal-button`, `viewer-help-button`, …). Drive menu events from Playwright via: @@ -283,14 +288,16 @@ const calls = await page.evaluate(() => window.__MARKDOC_MOCK__?.backend.calls); ### Test coverage -- **Unit (Vitest)** — 93 tests across 9 files. `src/hooks` 99% lines, `src/utils` 96% lines. -- **E2E (Playwright)** — 16 tests across 8 specs (welcome, viewer-open, theme, zoom, sidebar, export, help, autoresize). +- **Unit (Vitest)** — 107 tests across 10 files. `src/hooks` 99% lines, `src/utils` 96% lines. +- **E2E (Playwright)** — 22 tests across 10 specs (welcome, viewer-open, theme, zoom, sidebar, export, help, autoresize, new-from-clipboard, reveal). - **Rust** — 10 tests in `window_registry` covering register/lookup/release/canonical-path collapsing. ### Pre-release smoke checklist - [ ] Fresh launch shows Welcome with empty or persisted recents - [ ] Welcome "Open File…" transitions the same window into viewer mode +- [ ] "New from Clipboard" (⌘N / welcome button) suggests a filename from the content, saves, and opens the file; empty clipboard shows an info dialog +- [ ] File > Reveal in Finder (and the viewer toolbar folder icon) selects the open file in the OS file manager; hidden/no-op for the user guide - [ ] Opening a second file spawns a new `viewer-` window - [ ] Opening an already-open file focuses the existing window - [ ] File > Open via Cmd+O works from both Welcome and Viewer diff --git a/README.md b/README.md index 2894c9b..9d5b822 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,14 @@ A lightweight, cross-platform desktop application for **viewing** Markdown files - **Welcome screen** - Shown when the app launches with no file, or when you open the app with no arguments via the dock/taskbar. - Lists recent files (up to 20) with title + path, click-to-open, remove-one, and clear-all. - - "Open File…" and "Help" actions alongside the current version + build-hash. + - "Open File…", "New from Clipboard", and "User Guide" actions alongside the current version + build-hash. + +- **New from Clipboard** (`Cmd/Ctrl+N`) + - Saves the clipboard's text as a new Markdown file and opens it in a window. + - Suggests a filename from the first heading (or first line) of the content. + +- **Reveal in file manager** + - File → Reveal in Finder / Show in File Explorer (or the toolbar folder icon) selects the open file in the OS file manager. - **Native experience** - True native menus (File, Edit, View, Window, Help — plus the MarkDoc app menu on macOS). @@ -107,6 +114,7 @@ chmod +x MarkDoc_*.AppImage ### Keyboard shortcuts +- `Cmd/Ctrl+N` — New from Clipboard - `Cmd/Ctrl+O` — Open file - `Cmd/Ctrl+W` — Close window - `Cmd/Ctrl+Shift+H` — Export to HTML @@ -118,7 +126,7 @@ chmod +x MarkDoc_*.AppImage ### Menus -**File** — Open…, Open Recent ▸ _(dynamic)_, Close Window, Export ▸ (HTML / PDF) +**File** — New from Clipboard…, Open…, Open Recent ▸ _(dynamic)_, Reveal in Finder _(platform label)_, Close Window, Export ▸ (HTML / PDF) **Edit** — Copy, Select All (native roles) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f92a4d5..f2d4aca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -339,6 +360,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -476,6 +503,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.7" @@ -593,6 +629,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -895,6 +937,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1005,6 +1053,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1032,6 +1086,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1057,6 +1117,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1538,6 +1604,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1741,7 +1818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1859,6 +1936,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2153,6 +2244,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", @@ -2246,6 +2338,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.2" @@ -2261,7 +2363,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -2321,6 +2423,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2378,6 +2489,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -2604,6 +2716,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2670,6 +2792,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -2888,7 +3021,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2906,6 +3039,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3028,6 +3174,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3037,6 +3195,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -4082,7 +4249,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4129,6 +4296,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.0" @@ -4400,6 +4582,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -4665,12 +4861,23 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5056,6 +5263,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -5167,6 +5444,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "8.0.2" @@ -5741,6 +6024,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -5812,6 +6113,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.2" @@ -5982,6 +6300,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 639eb1b..8537499 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2.5.1", features = [] } [dependencies] tauri = { version = "2.8.5", features = [] } tauri-plugin-opener = "2.5.0" +tauri-plugin-clipboard-manager = "2" tauri-plugin-dialog = "2.4.0" tauri-plugin-fs = "2.4.2" tauri-plugin-os = "2.3.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 082da84..d58f9af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,34 @@ fn read_file_as_text(path: String) -> Result { fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e)) } +/// Read the OS clipboard as plain text. Returns an empty string when the +/// clipboard is empty or holds non-text content (e.g. an image) — the +/// frontend treats "" as "nothing to paste" rather than an error. +#[tauri::command] +fn read_clipboard_text(app: AppHandle) -> String { + use tauri_plugin_clipboard_manager::ClipboardExt; + app.clipboard().read_text().unwrap_or_default() +} + +/// Write UTF-8 text to a file. Rust-side counterpart of `read_file_as_text`: +/// the fs plugin's scope globs don't cover every location a user may pick in +/// the native save dialog, and every path reaching this command has been +/// user-authorised through that dialog. +#[tauri::command] +fn write_text_file(path: String, contents: String) -> Result<(), String> { + fs::write(&path, contents).map_err(|e| format!("Failed to write file: {}", e)) +} + +/// Reveal a file in the OS file manager (Finder / Explorer / etc.), +/// selecting it where the platform supports selection. +#[tauri::command] +fn reveal_in_dir(app: AppHandle, path: String) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + app.opener() + .reveal_item_in_dir(&path) + .map_err(|e| format!("Failed to reveal file: {}", e)) +} + #[tauri::command] fn open_file_in_window(app: AppHandle, path: String) -> Result { open_file_in_window_internal(&app, &path) @@ -475,8 +503,28 @@ fn open_file_in_window_internal(app: &AppHandle, raw_path: &str) -> Result &'static str { + #[cfg(target_os = "macos")] + { + "Reveal in Finder" + } + #[cfg(target_os = "windows")] + { + "Show in File Explorer" + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + "Show in File Manager" + } +} + fn build_menu(app: &AppHandle) -> tauri::Result<()> { // ---- FILE menu ---- + let file_new_clipboard = MenuItemBuilder::with_id("file_new_clipboard", "New from Clipboard...") + .accelerator("CmdOrCtrl+N") + .build(app)?; + let file_open = MenuItemBuilder::with_id("file_open", "Open...") .accelerator("CmdOrCtrl+O") .build(app)?; @@ -488,6 +536,8 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .item(&recent_placeholder) .build()?; + let file_reveal = MenuItemBuilder::with_id("file_reveal", reveal_menu_label()).build(app)?; + let file_close = MenuItemBuilder::with_id("file_close", "Close Window") .accelerator("CmdOrCtrl+W") .build(app)?; @@ -504,8 +554,11 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { .build()?; let file_submenu = SubmenuBuilder::new(app, "File") + .item(&file_new_clipboard) .item(&file_open) .item(&recent_files_submenu) + .separator() + .item(&file_reveal) .item(&file_close) .separator() .item(&export_submenu) @@ -883,6 +936,14 @@ fn handle_menu_event(app: &AppHandle, menu_id: &str) { "file_open" => { let _ = app.emit("menu://file/open", ()); } + "file_new_clipboard" => { + // Focused-window only: every window's router listens for this, and + // a broadcast would pop one save dialog per open window. + emit_to_focused(app, "menu://file/new-from-clipboard", ()); + } + "file_reveal" => { + emit_to_focused(app, "menu://file/reveal", ()); + } "file_close" => { emit_to_focused(app, "menu://file/close-window", ()); } @@ -1007,6 +1068,7 @@ pub fn run() { let _ = app.emit("file://open-request", ()); })) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_os::init()) @@ -1015,6 +1077,9 @@ pub fn run() { get_pending_opened_files, get_file_modified_time, read_file_as_text, + read_clipboard_text, + write_text_file, + reveal_in_dir, open_file_in_window, list_open_file_windows, close_file_window, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a4ce01e..087e125 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -49,6 +49,7 @@ "dialog:allow-open", "dialog:allow-save", "dialog:allow-ask", + "dialog:allow-message", { "identifier": "fs:allow-read-text-file", "allow": [ @@ -101,6 +102,7 @@ "core:menu:default", "dialog:allow-save", "dialog:allow-ask", + "dialog:allow-message", { "identifier": "fs:allow-read-text-file", "allow": [ diff --git a/src/USERGUIDE.md b/src/USERGUIDE.md index 059aaf5..d19f225 100644 --- a/src/USERGUIDE.md +++ b/src/USERGUIDE.md @@ -1,31 +1,26 @@ # Welcome to MarkDoc -aaA lightweight, elegant Markdown editor with live preview and powerful features. +A lightweight, elegant Markdown viewer. One window per file — no tabs, no editing, just beautifully rendered Markdown. ## Getting Started ### Opening Files -- **New Document**: `Cmd+N` (Mac) / `Ctrl+N` (Windows) -- **Open File**: `Cmd+O` (Mac) / `Ctrl+O` (Windows) -- **Recent Files**: Click the list icon in the tab bar to access recently opened files +- **Open File**: `Cmd+O` (Mac) / `Ctrl+O` (Windows) — or the "Open File…" button on the Welcome screen +- **New from Clipboard**: `Cmd+N` (Mac) / `Ctrl+N` (Windows) — save whatever text is on your clipboard as a new Markdown file and open it immediately. MarkDoc suggests a filename from the first heading (or first line) of the content +- **Recent Files**: File → Open Recent lists your last 20 files; the Welcome screen shows them as a click-to-open list +- **From your file manager**: double-click a `.md` / `.markdown` file, or right-click → "Open With" → MarkDoc -### Editing Documents +Each file opens in its own window. Opening a file that is already open simply focuses its existing window. -- **Toggle Edit/View Mode**: `Cmd+E` (Mac) / `Ctrl+E` (Windows) -- **Edit Mode**: Split-pane view with live preview -- **View Mode**: Full-screen preview, perfect for presentations +### Finding Your Files -### Saving Files - -- **Save**: `Cmd+S` (Mac) / `Ctrl+S` (Windows) -- **Save As**: `Cmd+Shift+S` (Mac) / `Ctrl+Shift+S` (Windows) -- Unsaved changes are indicated by a dot (•) next to the file name +- **Reveal in Finder** (Mac) / **Show in File Explorer** (Windows): File menu, or the folder icon in the viewer toolbar — jumps to the open file in your file manager with the file selected ### Exporting - **Export as HTML**: `Cmd+Shift+H` (Mac) / `Ctrl+Shift+H` (Windows) - - Self-contained HTML file with theme switcher + - Self-contained HTML file with the theme CSS inlined - Perfect for sharing on the web - **Export as PDF**: `Cmd+Shift+P` (Mac) / `Ctrl+Shift+P` (Windows) - Professional PDF output with your chosen theme @@ -34,18 +29,18 @@ aaA lightweight, elegant Markdown editor with live preview and powerful features ### 📝 Markdown Support -MarkDoc supports full CommonMark syntax including: +MarkDoc renders full CommonMark syntax including: - Headers, lists, and blockquotes - **Bold**, _italic_, and ~~strikethrough~~ text - Inline `code` and fenced code blocks - Tables and horizontal rules - Links and images -- Automatic link detection +- Automatic link detection — external links open in your browser ### 🎨 Themes -Choose from five beautiful themes: +Choose from five beautiful themes via the View menu or the toolbar selector: - **Default**: Clean and professional - **Cobalt**: Cool blue tones @@ -53,7 +48,7 @@ Choose from five beautiful themes: - **Amber**: Warm golden accents - **Slate**: Refined grayscale -All themes support both light and dark modes based on your system preferences. +All themes support both light and dark modes based on your system preferences, and your choice persists across sessions. ### 💻 Code Highlighting @@ -64,87 +59,52 @@ Syntax highlighting for popular languages: - CSS, HTML, JSON, YAML - Bash, Markdown, and more -Each code block includes: - -- Language indicator -- One-click copy button -- Professional syntax highlighting - -### 🪟 Multiple Tabs & Windows +### 🪟 Multiple Windows -- **Multiple Tabs**: Work on several documents simultaneously -- **Open Tabs Dropdown**: Click the list icon to see all open tabs at a glance -- **Drag & Drop**: Reorder tabs by dragging -- **Tab Overflow Controls**: Navigate through many tabs with scroll arrows -- **Detach Windows**: Drag a tab down to open it in a separate window -- **Reattach**: Close detached windows to return them to the main window +- Every file lives in its own native window +- The Window menu lists all open documents — click one to focus it +- Opening an already-open file focuses its window instead of duplicating it +- `Cmd/Ctrl+W` closes the current window; on macOS the app stays running and a dock click brings back the Welcome screen -### ⚙️ Editor Controls +### ⚙️ Viewer Controls -- **Zoom In/Out**: `Cmd/Ctrl + Plus/Minus` - Adjust text size for comfortable reading and editing -- **Reset Zoom**: `Cmd/Ctrl + 0` - Return to 100% zoom level -- **Document Sidebar**: `Cmd/Ctrl + \` - Toggle collapsible navigation sidebar showing document headings -- **Auto-Resize**: Automatically adjust window height based on content -- **Auto-Scroll**: Editor and preview scroll together in edit mode (toggle on/off) -- **Split-Pane Resize**: Drag the divider to adjust editor/preview width +- **Zoom In/Out**: `Cmd/Ctrl + Plus/Minus` — adjust text size for comfortable reading +- **Reset Zoom**: `Cmd/Ctrl + 0` — return to 100% +- **Document Outline**: `Cmd/Ctrl + \` — toggle the collapsible sidebar of document headings; click a heading to jump to it +- **Auto-Resize**: automatically fit the window to the document ### ⌨️ Keyboard Shortcuts -| Action | Mac | Windows | -| ---------------- | ------------- | -------------- | -| New Document | `Cmd+N` | `Ctrl+N` | -| Open File | `Cmd+O` | `Ctrl+O` | -| Close Tab | `Cmd+W` | `Ctrl+W` | -| Save | `Cmd+S` | `Ctrl+S` | -| Save As | `Cmd+Shift+S` | `Ctrl+Shift+S` | -| Toggle Edit/View | `Cmd+E` | `Ctrl+E` | -| Toggle Sidebar | `Cmd+\` | `Ctrl+\` | -| Zoom In | `Cmd++` | `Ctrl++` | -| Zoom Out | `Cmd+-` | `Ctrl+-` | -| Reset Zoom | `Cmd+0` | `Ctrl+0` | -| Export as HTML | `Cmd+Shift+H` | `Ctrl+Shift+H` | -| Export as PDF | `Cmd+Shift+P` | `Ctrl+Shift+P` | - -### Standard Editing - -All standard text editing shortcuts work as expected: - -- **Undo/Redo**: `Cmd+Z` / `Cmd+Shift+Z` (Mac) or `Ctrl+Z` / `Ctrl+Y` (Windows) -- **Cut/Copy/Paste**: `Cmd+X` / `Cmd+C` / `Cmd+V` (Mac) or `Ctrl+X` / `Ctrl+C` / `Ctrl+V` (Windows) -- **Select All**: `Cmd+A` (Mac) / `Ctrl+A` (Windows) +| Action | Mac | Windows | +| ------------------ | ------------- | -------------- | +| New from Clipboard | `Cmd+N` | `Ctrl+N` | +| Open File | `Cmd+O` | `Ctrl+O` | +| Close Window | `Cmd+W` | `Ctrl+W` | +| Toggle Sidebar | `Cmd+\` | `Ctrl+\` | +| Zoom In | `Cmd++` | `Ctrl++` | +| Zoom Out | `Cmd+-` | `Ctrl+-` | +| Reset Zoom | `Cmd+0` | `Ctrl+0` | +| Export as HTML | `Cmd+Shift+H` | `Ctrl+Shift+H` | +| Export as PDF | `Cmd+Shift+P` | `Ctrl+Shift+P` | + +Copy (`Cmd/Ctrl+C`) and Select All (`Cmd/Ctrl+A`) work on the rendered document as expected. ## Tips & Tricks -### Quick Preview +### Save a Snippet Fast -Switch to view mode (`Cmd+E`) to see your document in full-screen preview mode, perfect for reading or presenting. +Copied a chunk of Markdown from a chat, a wiki, or an AI assistant? Hit `Cmd/Ctrl+N` — MarkDoc suggests a filename from the content, saves it where you choose, and opens it in a window. Any plain text works; it doesn't have to be Markdown. ### Document Navigation -Enable the document sidebar (`Cmd+\`) to see an outline of all headings in your document. Click any heading to jump directly to that section. The sidebar is collapsible and remembers your preferred state. - -### Export for Web - -HTML exports include a theme switcher, allowing readers to choose their preferred color scheme. - -### Distraction-Free Writing - -Close the preview pane by maximizing the editor split, or use view mode for distraction-free reading. - -### Recent Files - -Your 10 most recent files are always accessible via the recent files dropdown in the tab bar. - -### Detached Windows - -Drag a tab downward to create a separate window - perfect for working with multiple documents side-by-side on large displays. +Enable the outline sidebar (`Cmd/Ctrl+\`) to see every heading in your document. Click any heading to jump directly to that section. The sidebar remembers your preferred state. -### Session Persistence +### Reading Side by Side -MarkDoc automatically remembers your open documents and restores them when you relaunch the app, so you can pick up right where you left off. +Open several files and arrange their windows however you like — the Window menu keeps track of them all. --- -**Need more help?** Click the help icon (❓) in the tab bar anytime to return to this guide. +**Need more help?** Click the help icon (❓) in the toolbar anytime to return to this guide. **Built with** Tauri, React, and TypeScript by [Stravica](https://stravica.com) diff --git a/src/platform/index.ts b/src/platform/index.ts index d217208..f44f52d 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -9,6 +9,7 @@ export const platform = bridge.platform; export const open = bridge.openDialog; export const save = bridge.saveDialog; export const ask = bridge.askDialog; +export const message = bridge.messageDialog; export const readTextFile = bridge.readTextFile; export const writeTextFile = bridge.writeTextFile; export const invoke = bridge.invoke; diff --git a/src/platform/tauri.ts b/src/platform/tauri.ts index 177a635..0fe116c 100644 --- a/src/platform/tauri.ts +++ b/src/platform/tauri.ts @@ -1,4 +1,4 @@ -import { open, save, ask } from '@tauri-apps/plugin-dialog'; +import { open, save, ask, message } from '@tauri-apps/plugin-dialog'; import { writeTextFile } from '@tauri-apps/plugin-fs'; import { openUrl } from '@tauri-apps/plugin-opener'; import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'; @@ -23,6 +23,9 @@ export const tauriBridge: PlatformBridge = { openDialog: open, saveDialog: save, askDialog: ask, + messageDialog: async (text: string, options?: Record) => { + await message(text, options); + }, readTextFile, writeTextFile, invoke, diff --git a/src/platform/types.ts b/src/platform/types.ts index 56520b6..840f586 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -32,6 +32,7 @@ export interface PlatformBridge { openDialog: (options?: Record) => Promise; saveDialog: (options?: Record) => Promise; askDialog: (question: string, options?: Record) => Promise; + messageDialog: (message: string, options?: Record) => Promise; readTextFile: (path: string) => Promise; writeTextFile: (path: string, contents: string) => Promise; invoke: (cmd: string, args?: Record) => Promise; diff --git a/src/platform/web.ts b/src/platform/web.ts index 1ac7098..d3c1f2a 100644 --- a/src/platform/web.ts +++ b/src/platform/web.ts @@ -150,6 +150,8 @@ class MockBackend { state: BackendState = createInitialState(); exportInFlight: ReturnType | null = null; exportCancelled = false; + /** Seedable clipboard content for `read_clipboard_text`. */ + clipboardText = ''; /** * History of invoke calls (command + args). Intended for e2e / Playwright * assertions that need to verify a backend call took place with the right @@ -160,6 +162,7 @@ class MockBackend { reset(): void { this.state = createInitialState(); this.calls = []; + this.clipboardText = ''; } /** @@ -240,6 +243,20 @@ class MockBackend { eventBus.emit('export://cancelled', undefined); return undefined as T; } + case 'read_clipboard_text': { + return this.clipboardText as unknown as T; + } + case 'write_text_file': { + const path = args.path as string; + const contents = args.contents as string; + mockFiles.set(path, contents); + return undefined as T; + } + case 'reveal_in_dir': { + // No file manager in web mode — the call is recorded in `this.calls` + // so tests can assert it was issued with the right path. + return undefined as T; + } case 'is_menu_ready': { return true as unknown as T; } @@ -315,6 +332,10 @@ export const webBridge: PlatformBridge = { return path; }, askDialog: async () => true, + messageDialog: async (message: string, options?: Record) => { + // Recorded alongside invoke history so tests can assert the notice fired. + backend.calls.push({ cmd: 'message_dialog', args: { message, ...options } }); + }, readTextFile: async (path: string) => { if (!mockFiles.has(path)) { mockFiles.set(path, ''); diff --git a/src/utils/__tests__/fileUtils.test.ts b/src/utils/__tests__/fileUtils.test.ts index c0df065..3a7e6f8 100644 --- a/src/utils/__tests__/fileUtils.test.ts +++ b/src/utils/__tests__/fileUtils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { escapeHtml, sanitizeFilename } from '../fileUtils'; +import { escapeHtml, sanitizeFilename, suggestFilenameFromMarkdown } from '../fileUtils'; describe('sanitizeFilename', () => { it('strips invalid characters', () => { @@ -41,6 +41,58 @@ describe('sanitizeFilename', () => { }); }); +describe('suggestFilenameFromMarkdown', () => { + it('uses the first ATX heading', () => { + expect(suggestFilenameFromMarkdown('intro text\n\n# Release Notes v2\n\nbody')).toBe( + 'Release Notes v2.md', + ); + }); + + it('prefers a heading over an earlier plain line only within the leading lines', () => { + const content = 'line one\n' + 'filler\n'.repeat(20) + '# Late Heading\n'; + expect(suggestFilenameFromMarkdown(content)).toBe('line one.md'); + }); + + it('strips heading markers and trailing closers', () => { + expect(suggestFilenameFromMarkdown('## My Title ##')).toBe('My Title.md'); + }); + + it('falls back to the first non-empty line with inline markdown stripped', () => { + expect(suggestFilenameFromMarkdown('\n\n**Bold _start_** with `code`\nmore')).toBe( + 'Bold start with code.md', + ); + }); + + it('unwraps links and images to their text', () => { + expect(suggestFilenameFromMarkdown('[Read the docs](https://example.com)')).toBe( + 'Read the docs.md', + ); + expect(suggestFilenameFromMarkdown('![Alt text](img.png)')).toBe('Alt text.md'); + }); + + it('strips list markers', () => { + expect(suggestFilenameFromMarkdown('- [x] Ship the feature')).toBe('Ship the feature.md'); + expect(suggestFilenameFromMarkdown('1. First item')).toBe('First item.md'); + }); + + it('replaces filesystem-invalid characters with spaces', () => { + expect(suggestFilenameFromMarkdown('# Notes: Q3/Q4 "Plan"')).toBe('Notes Q3 Q4 Plan.md'); + }); + + it('truncates long titles to the max length', () => { + const name = suggestFilenameFromMarkdown(`# ${'word '.repeat(40)}`); + expect(name.endsWith('.md')).toBe(true); + expect(name.length).toBeLessThanOrEqual(63); // 60 + '.md' + }); + + it('falls back to a dated name for empty or unusable content', () => { + const dated = /^Pasted \d{4}-\d{2}-\d{2}\.md$/; + expect(suggestFilenameFromMarkdown('')).toMatch(dated); + expect(suggestFilenameFromMarkdown(' \n\t\n')).toMatch(dated); + expect(suggestFilenameFromMarkdown('***')).toMatch(dated); + }); +}); + describe('escapeHtml', () => { it('escapes HTML-special characters', () => { expect(escapeHtml('&')).toBe('<a href="x">&</a>'); diff --git a/src/utils/__tests__/newFileFromClipboard.test.ts b/src/utils/__tests__/newFileFromClipboard.test.ts new file mode 100644 index 0000000..61e4e7c --- /dev/null +++ b/src/utils/__tests__/newFileFromClipboard.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../platform', () => { + return { + invoke: vi.fn(), + save: vi.fn(), + message: vi.fn(), + }; +}); + +import { invoke, message, save } from '../../platform'; +import { newFileFromClipboard } from '../newFileFromClipboard'; +import { getRecentFiles } from '../recentFiles'; + +const invokeMock = invoke as unknown as ReturnType; +const saveMock = save as unknown as ReturnType; +const messageMock = message as unknown as ReturnType; + +/** Route mock invoke calls by command name. */ +function setupInvoke({ clipboard, label = 'main' }: { clipboard: string; label?: string }) { + invokeMock.mockImplementation((cmd: string) => { + switch (cmd) { + case 'read_clipboard_text': + return Promise.resolve(clipboard); + case 'open_file_in_window': + return Promise.resolve(label); + default: + return Promise.resolve(undefined); + } + }); +} + +describe('newFileFromClipboard', () => { + beforeEach(() => { + localStorage.clear(); + invokeMock.mockReset(); + saveMock.mockReset(); + messageMock.mockReset(); + }); + + it('shows an info dialog and returns null when the clipboard is empty', async () => { + setupInvoke({ clipboard: '' }); + await expect(newFileFromClipboard()).resolves.toBeNull(); + expect(messageMock).toHaveBeenCalledOnce(); + expect(saveMock).not.toHaveBeenCalled(); + }); + + it('treats whitespace-only clipboard content as empty', async () => { + setupInvoke({ clipboard: ' \n\t ' }); + await expect(newFileFromClipboard()).resolves.toBeNull(); + expect(messageMock).toHaveBeenCalledOnce(); + expect(saveMock).not.toHaveBeenCalled(); + }); + + it('suggests a filename derived from the content in the save dialog', async () => { + setupInvoke({ clipboard: '# Release Notes\n\nbody' }); + saveMock.mockResolvedValue(null); + await newFileFromClipboard(); + expect(saveMock).toHaveBeenCalledWith( + expect.objectContaining({ defaultPath: 'Release Notes.md' }), + ); + }); + + it('returns null without writing when the save dialog is cancelled', async () => { + setupInvoke({ clipboard: '# Doc' }); + saveMock.mockResolvedValue(null); + await expect(newFileFromClipboard()).resolves.toBeNull(); + expect(invokeMock).not.toHaveBeenCalledWith('write_text_file', expect.anything()); + expect(getRecentFiles()).toHaveLength(0); + }); + + it('writes the file, opens it, and adds it to recents on the happy path', async () => { + const content = '# Hello World\n\nSome text.'; + setupInvoke({ clipboard: content, label: 'viewer-2' }); + saveMock.mockResolvedValue('/tmp/Hello World.md'); + + await expect(newFileFromClipboard()).resolves.toEqual({ + label: 'viewer-2', + path: '/tmp/Hello World.md', + }); + + expect(invokeMock).toHaveBeenCalledWith('write_text_file', { + path: '/tmp/Hello World.md', + contents: content, + }); + expect(invokeMock).toHaveBeenCalledWith('open_file_in_window', { + path: '/tmp/Hello World.md', + }); + expect(getRecentFiles().map((e) => e.path)).toContain('/tmp/Hello World.md'); + expect(messageMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 8388908..40ce23c 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -17,6 +17,53 @@ export function sanitizeFilename(name: string): string { return sanitized || 'untitled'; } +/** Strip inline markdown syntax from a single line, keeping the visible text. */ +function stripMarkdownInline(line: string): string { + return ( + line + // Leading heading / blockquote / list / task markers + .replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)/, '') + // Images → alt text, links → link text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + // Emphasis / code / strikethrough wrappers + .replace(/(\*{1,3}|_{1,3}|~~|`+)([^*_~`]*)\1/g, '$2') + // Trailing ATX heading closers (`## Title ##`) + .replace(/\s+#+\s*$/, '') + .trim() + ); +} + +/** + * Derive a save-dialog filename (including the `.md` extension) from markdown + * content: the first ATX heading wins, else the first non-empty line, with + * markdown syntax stripped and the result made filesystem-safe. Falls back to + * `Pasted .md` when the content yields nothing usable. + */ +export function suggestFilenameFromMarkdown(content: string, maxLength = 60): string { + const lines = content.split(/\r?\n/); + const nonEmpty = lines.map((l) => l.trim()).filter((l) => l.length > 0); + + // Prefer the first ATX heading within the leading lines of the document. + const heading = nonEmpty.slice(0, 10).find((l) => /^#{1,6}\s+\S/.test(l)); + const candidate = heading ?? nonEmpty[0] ?? ''; + + // Turn filesystem-invalid characters into spaces (reads better than the + // underscores sanitizeFilename would substitute), then sanitise + collapse. + let name = stripMarkdownInline(candidate).replace(/[<>:"/\\|?*\x00-\x1F]/g, ' '); + name = sanitizeFilename(name).replace(/\s+/g, ' ').trim(); + if (name.length > maxLength) { + name = name.slice(0, maxLength).trim(); + } + // sanitizeFilename falls back to 'untitled' on empty input — treat that the + // same as "nothing usable" and use a dated name instead. + if (!name || name === 'untitled') { + const date = new Date().toISOString().slice(0, 10); + name = `Pasted ${date}`; + } + return `${name}.md`; +} + /** * Escape HTML entities in a string */ diff --git a/src/utils/newFileFromClipboard.ts b/src/utils/newFileFromClipboard.ts new file mode 100644 index 0000000..c9fd919 --- /dev/null +++ b/src/utils/newFileFromClipboard.ts @@ -0,0 +1,41 @@ +import { invoke, message, save } from '../platform'; +import type { WindowLabel } from '../types'; +import { suggestFilenameFromMarkdown } from './fileUtils'; +import { openFileInWindow } from './openFileInWindow'; + +export interface NewFileFromClipboardResult { + /** Window label that now hosts the file (routing decided by the backend). */ + label: WindowLabel; + /** Path the user saved the clipboard content to. */ + path: string; +} + +/** + * "New from Clipboard" flow: read the OS clipboard, ask the user where to + * save it (suggesting a filename derived from the content), write the file, + * then route it through the standard `openFileInWindow` machinery so it opens + * in a window and lands in recents. + * + * Returns null when there was nothing to do — empty/non-text clipboard or a + * cancelled save dialog. + */ +export async function newFileFromClipboard(): Promise { + const text = await invoke('read_clipboard_text'); + if (!text.trim()) { + await message("The clipboard doesn't contain any text.", { + title: 'New from Clipboard', + kind: 'info', + }); + return null; + } + + const target = await save({ + defaultPath: suggestFilenameFromMarkdown(text), + filters: [{ name: 'Markdown', extensions: ['md', 'markdown'] }], + }); + if (!target) return null; + + await invoke('write_text_file', { path: target, contents: text }); + const label = await openFileInWindow(target); + return { label, path: target }; +} diff --git a/src/windows/ViewerWindow.tsx b/src/windows/ViewerWindow.tsx index 5273955..ecd93f0 100644 --- a/src/windows/ViewerWindow.tsx +++ b/src/windows/ViewerWindow.tsx @@ -42,6 +42,9 @@ function basename(path: string): string { return path.split(/[\\/]/).pop() || path; } +/** Platform-appropriate label for the "reveal in file manager" affordance. */ +const REVEAL_LABEL = /Mac/i.test(navigator.userAgent) ? 'Reveal in Finder' : 'Show in Folder'; + export function ViewerWindow({ initialPath, initialContent, @@ -56,6 +59,19 @@ export function ViewerWindow({ const [exportInProgress, setExportInProgress] = useState(false); const [exportProgress, setExportProgress] = useState({ stage: '', percent: 0 }); + // Bundled documents (the user guide) have inline content and no real path + // on disk, so there is nothing to reveal in the file manager. + const isVirtualDocument = initialContent !== undefined; + + const handleReveal = useCallback(async () => { + if (isVirtualDocument) return; + try { + await invoke('reveal_in_dir', { path: initialPath }); + } catch (error) { + console.error('Failed to reveal file:', error); + } + }, [initialPath, isVirtualDocument]); + // Apply autosize via window resize hook. useWindowResize({ autosize: prefs.autosize, @@ -286,6 +302,9 @@ export function ViewerWindow({ listen('menu://file/export-pdf', () => { void handleExportPdf(); }), + listen('menu://file/reveal', () => { + void handleReveal(); + }), listen('menu://file/open', () => { void (async () => { try { @@ -316,7 +335,15 @@ export function ViewerWindow({ disposed = true; unlisteners.forEach((fn) => fn()); }; - }, [handleExportHtml, handleExportPdf, prefs.autosize, prefs.sidebarOpen, prefs.zoom, setPref]); + }, [ + handleExportHtml, + handleExportPdf, + handleReveal, + prefs.autosize, + prefs.sidebarOpen, + prefs.zoom, + setPref, + ]); // Simple Cmd/Ctrl+\ sidebar toggle shortcut. useEffect(() => { @@ -376,6 +403,32 @@ export function ViewerWindow({ + {!isVirtualDocument && ( +
+ + + +
+ )}
diff --git a/src/windows/WelcomeWindow.tsx b/src/windows/WelcomeWindow.tsx index 170c84c..18bdd67 100644 --- a/src/windows/WelcomeWindow.tsx +++ b/src/windows/WelcomeWindow.tsx @@ -6,6 +6,7 @@ import { clearRecentFiles, getRecentFiles, removeRecentFile } from '../utils/rec interface WelcomeWindowProps { onOpenFile: (path: string) => Promise | void; onOpenUserGuide: () => void; + onNewFromClipboard: () => Promise | void; } interface VersionInfo { @@ -49,7 +50,11 @@ function formatOpenedAt(ts: number): string { return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } -export function WelcomeWindow({ onOpenFile, onOpenUserGuide }: WelcomeWindowProps) { +export function WelcomeWindow({ + onOpenFile, + onOpenUserGuide, + onNewFromClipboard, +}: WelcomeWindowProps) { const [recents, setRecents] = useState(() => getRecentFiles()); const [version, setVersion] = useState(null); @@ -113,6 +118,15 @@ export function WelcomeWindow({ onOpenFile, onOpenUserGuide }: WelcomeWindowProp onOpenUserGuide(); }, [onOpenUserGuide]); + const handleNewFromClipboard = useCallback(async () => { + try { + await onNewFromClipboard(); + refreshRecents(); + } catch (error) { + console.error('Welcome: failed to create file from clipboard:', error); + } + }, [onNewFromClipboard, refreshRecents]); + // Respond to File > Open (Cmd+O) native menu event when the Welcome window // is the focused window. Reuses the same dialog-driven code path as the // in-window Open button. @@ -160,6 +174,16 @@ export function WelcomeWindow({ onOpenFile, onOpenUserGuide }: WelcomeWindowProp > Open File... +