From f80e571c78aa8ccbd4250388391eec74e442eaf9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 27 May 2026 14:36:36 +0200 Subject: [PATCH] feat: add vault item deep links --- docs/ABSTRACTIONS.md | 8 + docs/ARCHITECTURE.md | 5 + .../adr/0129-tolaria-vault-item-deep-links.md | 45 ++ lara.lock | 16 + package.json | 1 + pnpm-lock.yaml | 15 + src-tauri/Cargo.lock | 128 ++++- src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 2 + src-tauri/src/lib.rs | 38 ++ src-tauri/tauri.conf.json | 5 + src/App.tsx | 52 +- src/components/BreadcrumbBar.test.tsx | 14 +- src/components/BreadcrumbBar.tsx | 15 + src/components/Editor.tsx | 6 + src/components/FilePreview.test.tsx | 8 + src/components/FilePreview.tsx | 32 +- .../editor-content/EditorContentLayout.tsx | 3 + .../editor-content/useEditorContentModel.ts | 1 + src/hooks/commands/localizeCommands.ts | 1 + src/hooks/commands/noteCommands.ts | 44 +- src/hooks/useAppCommands.ts | 3 + src/hooks/useCommandRegistry.test.ts | 9 + src/hooks/useCommandRegistry.ts | 5 +- src/hooks/useDeepLinks.ts | 459 ++++++++++++++++++ src/lib/locales/be-BY.json | 16 + src/lib/locales/be-Latn.json | 16 + src/lib/locales/de-DE.json | 16 + src/lib/locales/en.json | 16 + src/lib/locales/es-419.json | 16 + src/lib/locales/es-ES.json | 16 + src/lib/locales/fr-FR.json | 16 + src/lib/locales/it-IT.json | 16 + src/lib/locales/ja-JP.json | 16 + src/lib/locales/ko-KR.json | 16 + src/lib/locales/pl-PL.json | 16 + src/lib/locales/pt-BR.json | 16 + src/lib/locales/pt-PT.json | 16 + src/lib/locales/ru-RU.json | 16 + src/lib/locales/vi.json | 16 + src/lib/locales/zh-CN.json | 16 + src/lib/locales/zh-TW.json | 16 + src/lib/productAnalytics.ts | 2 +- src/types/laputaTestBridge.ts | 1 + src/utils/deepLinks.test.ts | 123 +++++ src/utils/deepLinks.ts | 244 ++++++++++ tests/smoke/deep-link-routing.spec.ts | 63 +++ tests/smoke/testBridge.ts | 10 + 48 files changed, 1592 insertions(+), 40 deletions(-) create mode 100644 docs/adr/0129-tolaria-vault-item-deep-links.md create mode 100644 src/hooks/useDeepLinks.ts create mode 100644 src/utils/deepLinks.test.ts create mode 100644 src/utils/deepLinks.ts create mode 100644 tests/smoke/deep-link-routing.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index fd28cf7b..6779d8a1 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -179,6 +179,14 @@ Git-facing renderer code must pass an explicit repository path instead of assumi `useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior. +### Tolaria Deep Links + +Deep links identify existing vault items with `tolaria:///`. The slug is derived from the registered workspace alias, then label, then path basename; generated links append a stable short hash when two vaults share the same base slug. A manually typed ambiguous base slug is rejected instead of choosing the wrong vault. + +The relative path is encoded per segment, preserving `/` as the separator while allowing spaces, Unicode, and reserved characters inside filenames. Decoding rejects `.`, `..`, encoded slashes, backslashes, empty segments, and any resolved path outside the target vault root. Links keep the file extension so Markdown, text, media, PDFs, and other vault files can all route through the same `VaultEntry` lookup. + +Deep links are navigation-only. Opening one can focus Tolaria, switch to a registered vault, reload the index once, and open an existing item; it never creates missing files, imports external files, or silently falls back to another vault. v1 links are path-based, so renaming or moving a file changes the canonical link. macOS and Windows are the verified v1 desktop targets; Linux registration is best-effort until package-level QA covers the supported desktop environments. + ### File kinds and binary previews `VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4684bdd0..1ac58f25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -110,6 +110,8 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` persists each workspace's installation-local identity (`label`, stable `alias`, color, mount flag) and the default destination for newly created notes in `~/.config/com.tolaria.app/vaults.json`. `useVaultLoader` scans every available mounted workspace and annotates each `VaultEntry` with provenance before React consumes the combined graph. The default workspace is the write target for new notes and Type documents; it is not the only active vault when multiple workspaces are enabled. +Vault item deep links use the registered vault list as their resolver namespace. `src/utils/deepLinks.ts` builds `tolaria:///` URLs from workspace aliases, labels, and paths, appending a short stable hash when generated slugs would collide. `useDeepLinks` validates incoming links, switches vaults when required, reloads the vault index once for recently changed files, and opens the matching `VaultEntry` through the normal note-selection path. + Saved Views participate in that mounted graph as source-scoped chrome. `useVaultLoader` loads view definitions from every mounted vault, annotates each `ViewFile` with its owning `rootPath` and workspace identity, and keeps sidebar selection/persistence keyed by `(rootPath, filename)` so same-named view files from different vaults stay independent. Git surfaces resolve repository paths explicitly. `useGitRepositories` derives the active repository set from the mounted available workspaces, keeps separate selected repositories for Changes, Pulse/history, and manual commits, and exposes the combined modified-file count for status/commands. AutoGit checkpoints iterate that repository set, while manual commit, history, diff, and discard operations use the selected surface or the note's workspace provenance. @@ -239,6 +241,8 @@ The main Tauri window also persists its last normal size and screen position in Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell. +Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration. + Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu. On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available. @@ -863,6 +867,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useNoteCreation` | — | Note/type creation with optimistic persistence | | `useNoteRename` | — | Note renaming and folder moves with wikilink update | | `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions | +| `useDeepLinks` | Deep-link listener and copy actions | Resolves `tolaria://` links into known vault navigation and clipboard URLs | | `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup | | `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs | | `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch | diff --git a/docs/adr/0129-tolaria-vault-item-deep-links.md b/docs/adr/0129-tolaria-vault-item-deep-links.md new file mode 100644 index 00000000..bbf44a0b --- /dev/null +++ b/docs/adr/0129-tolaria-vault-item-deep-links.md @@ -0,0 +1,45 @@ +--- +type: ADR +id: "0129" +title: "Tolaria vault item deep links" +status: active +date: 2026-05-27 +--- + +# ADR-0129: Tolaria vault item deep links + +## Context + +Users need durable links they can paste into calendars, task managers, chats, and other apps to return to a Tolaria vault item. The link needs to identify a registered vault, preserve the file extension so non-Markdown files can be opened, and fail clearly when the vault or item is unavailable. Links must not create or import files implicitly. + +Mounted workspaces make vault naming non-trivial. A readable slug is useful, but two vaults can share a label, alias, or folder name. URL parsing also cannot rely only on the browser URL implementation because dot-segment normalization can hide path traversal attempts before validation runs. + +## Decision + +Tolaria deep links use this shape: + +```text +tolaria:/// +``` + +The vault slug is generated from the registered workspace alias, then label, then path basename. When two vaults would share the same base slug, generated links append a stable short hash derived from the normalized vault path. A handwritten ambiguous base slug is rejected instead of picking an arbitrary vault. + +The path component is encoded per segment with `encodeURIComponent`, so spaces, Unicode, and reserved characters are preserved while `/` remains the path separator. Parsing rejects empty path segments, `.`, `..`, decoded separators inside a segment, unsafe Windows separators, and resolved paths outside the selected vault root. + +`src/utils/deepLinks.ts` owns URL building, parsing, and vault resolution. `src/hooks/useDeepLinks.ts` owns renderer integration: it receives Tauri deep-link events, validates them against the registered vault list, switches vaults when needed, reloads once if the target file is not in the current index yet, opens existing Markdown/text/binary entries, reports localized errors, and emits safe PostHog outcomes. Deep links are navigation-only; they never create missing files, import external content, or infer a fallback vault. + +The desktop shell registers the `tolaria` scheme through `tauri-plugin-deep-link` and keeps second launches focused through `tauri-plugin-single-instance`. Windows and Linux also call runtime `register_all()` as a repair step. macOS uses bundle scheme registration. Linux runtime registration is best-effort and is not part of the verified v1 support target. + +Copy surfaces are shared actions: + +- Breadcrumb overflow: `Copy note deeplink` +- Command palette: `Copy deep link to current item` +- File preview header: copy action for non-Markdown vault files + +## Consequences + +Path-based links are understandable and support every vault file kind, but a file rename changes the old link. A future stable item-id layer could supersede this URL shape while preserving path links as a readable fallback. + +Collision handling keeps generated links deterministic without exposing full local paths. Ambiguous handwritten slugs fail clearly, which is safer than opening the wrong vault. + +Renderer-owned resolution keeps the navigation logic close to mounted-workspace state and note selection. Native plugins stay responsible only for scheme registration, event delivery, and focusing the existing app instance. diff --git a/lara.lock b/lara.lock index 411764dc..b9b5d75c 100644 --- a/lara.lock +++ b/lara.lock @@ -59,6 +59,7 @@ files: command.note.removeIcon: 4bccbad66025e506b37b4218498b299c command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14 command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905 + command.note.copyDeepLink: 8de1795f59af124594dc4decf77909a1 command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6 command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46 @@ -515,8 +516,23 @@ files: editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41 editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4 editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27 + editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e + filePreview.copyDeepLink: b75833669968adbef634495636e3fe50 + deepLinks.copied: 6ada57681192daf3b4afa07ce7a30575 + deepLinks.error.ambiguousVault: 4365c6d5cf8c2c1165259b32d24a6274 + deepLinks.error.copyFailed: 57b74ecce2d00b4c327d15830f79410d + deepLinks.error.invalidScheme: 58eaa7ac701a0508cbae90c5d3837ce4 + deepLinks.error.malformedUrl: 4f85323322ef0a1d627df621edb749c7 + deepLinks.error.missingFile: 79a0c73176508c58b4d785fabaf7d8cc + deepLinks.error.missingPath: a6bb1c92f1a38476ff603a2d3ff81223 + deepLinks.error.missingVault: e2936e1f599c7ed229efb5d4a122fc4b + deepLinks.error.openFailed: 97901feafe25688fcc3fc4dcfaf82619 + deepLinks.error.outsideVault: fda2fd5675b596df7ba0e0b1a11ec47d + deepLinks.error.unavailableVault: 911dfaf8e855bd8cadefe7df9a98a7e8 + deepLinks.error.unknownVault: b4940569e884833f50466177dab1f62f + deepLinks.error.unsafePath: e09caab43be8c2372bfc94e947ea34e6 editor.slash.math: a49950aa047c2292e989e368a97a3aae tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b tableOfContents.close: d3292972a10edd1a06a627f3552f760a diff --git a/package.json b/package.json index d345a3f5..0ad4647a 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@sentry/react": "^10.47.0", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-deep-link": "2.4.9", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84e0283c..6621258e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 + '@tauri-apps/plugin-deep-link': + specifier: 2.4.9 + version: 2.4.9 '@tauri-apps/plugin-dialog': specifier: ^2.6.0 version: 2.6.0 @@ -2457,6 +2460,9 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + '@tauri-apps/cli-darwin-arm64@2.10.0': resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==} engines: {node: '>= 10'} @@ -2533,6 +2539,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-deep-link@2.4.9': + resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + '@tauri-apps/plugin-dialog@2.6.0': resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} @@ -8164,6 +8173,8 @@ snapshots: '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.0': {} + '@tauri-apps/cli-darwin-arm64@2.10.0': optional: true @@ -8211,6 +8222,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-dialog@2.6.0': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 815e4da5..e45394fa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -634,6 +634,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -723,6 +743,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" @@ -898,7 +924,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -953,6 +979,15 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dpi" version = "0.1.2" @@ -1085,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2984,6 +3019,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3501,7 +3546,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3888,6 +3933,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -3935,7 +3990,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3992,7 +4047,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4905,6 +4960,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -5013,6 +5089,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" @@ -5157,7 +5249,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5244,6 +5336,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -5347,11 +5448,13 @@ dependencies = [ "serde_yaml", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-prevent-default", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", @@ -6094,7 +6197,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6221,6 +6324,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c1a90875..5e1f64d4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,5 +40,7 @@ sentry = "0.37" uuid = { version = "1", features = ["v4"] } tempfile = "3" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +tauri-plugin-deep-link = "2.4.9" +tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } [dev-dependencies] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f8038c57..8157ff18 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,7 +18,9 @@ "core:window:allow-close", "core:window:allow-set-title", "core:webview:allow-create-webview-window", + "core:event:default", "dialog:default", + "deep-link:default", "updater:default", "process:default", "opener:default" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f410ac47..85133771 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -278,9 +278,44 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box) -> tauri::Builder { + builder + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + focus_main_window(app); + })) + .plugin(tauri_plugin_deep_link::init()) +} + +#[cfg(desktop)] +fn setup_deep_link_runtime_registration( + _app: &mut tauri::App, +) -> Result<(), Box> { + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + use tauri_plugin_deep_link::DeepLinkExt; + + _app.deep_link().register_all()?; + } + + Ok(()) +} + #[cfg(desktop)] fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box> { setup_macos_webview_shortcut_prevention(app)?; + setup_deep_link_runtime_registration(app)?; app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_process::init())?; @@ -560,6 +595,9 @@ pub fn run() { let builder = tauri::Builder::default(); + #[cfg(desktop)] + let builder = with_desktop_entry_plugins(builder); + #[cfg(desktop)] let builder = builder .manage(WsBridgeChild(Mutex::new(None))) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5ff4188b..f60b0523 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -72,6 +72,11 @@ ] }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["tolaria"] + } + }, "updater": { "endpoints": [ "https://refactoringhq.github.io/tolaria/stable/latest.json" diff --git a/src/App.tsx b/src/App.tsx index b096853a..3bc0d41e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { useBulkActions } from './hooks/useBulkActions' import { useDeleteActions } from './hooks/useDeleteActions' import { useFolderActions } from './hooks/useFolderActions' import { useFileActions } from './hooks/useFileActions' +import { useDeepLinks } from './hooks/useDeepLinks' import { useLayoutPanels } from './hooks/useLayoutPanels' import { useConflictFlow } from './hooks/useConflictFlow' import { useAppSave } from './hooks/useAppSave' @@ -1593,6 +1594,38 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu updateFrontmatter: notes.handleUpdateFrontmatter, setToastMessage, }) + + const { + isStartupLoading, + isVaultContentLoading, + shouldResumeFreshStartOnboarding, + shouldShowStartupScreen, + } = useStartupScreenState({ + aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt, + isNoteWindow: Boolean(noteWindowParams) || aiWorkspaceWindow, + onboardingState: onboarding.state, + runtimeMissingVaultPath, + selectedVaultPath, + settingsLoaded, + showMcpSetupDialog, + telemetryConsent: settings.telemetry_consent, + vaultIsLoading: vault.isLoading, + vaultSwitcher, + }) + const deepLinks = useDeepLinks({ + activeEntry: activeTab?.entry ?? null, + currentVaultPath: resolvedPath, + enabled: !noteWindowParams && !aiWorkspaceWindow, + entries: visibleEntries, + isVaultContentLoading, + locale: appLocale, + onSelectNote: notes.handleSelectNote, + onSwitchVault: vaultSwitcher.switchVault, + reloadVault: vault.reloadVault, + setToastMessage, + vaultListLoaded: vaultSwitcher.loaded, + vaults: vaultSwitcher.allVaults, + }) const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences) const undoCommand = useCallback(() => { @@ -1693,6 +1726,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu onOpenInNewWindow: handleOpenInNewWindow, onRevealActiveFile: fileActions.revealFile, onCopyActiveFilePath: fileActions.copyFilePath, + onCopyActiveDeepLink: deepLinks.copyPathDeepLink, onOpenActiveFileExternal: fileActions.openExternalFile, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: toggleOrganizedCommand, @@ -1724,23 +1758,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu return { type: null, query: '' } }, [effectiveSelection]) - const { - isStartupLoading, - isVaultContentLoading, - shouldResumeFreshStartOnboarding, - shouldShowStartupScreen, - } = useStartupScreenState({ - aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt, - isNoteWindow: Boolean(noteWindowParams) || aiWorkspaceWindow, - onboardingState: onboarding.state, - runtimeMissingVaultPath, - selectedVaultPath, - settingsLoaded, - showMcpSetupDialog, - telemetryConsent: settings.telemetry_consent, - vaultIsLoading: vault.isLoading, - vaultSwitcher, - }) const handleAiWorkspaceConversationsChange = useCallback((conversations: AiWorkspaceConversationSetting[]) => { void saveSettings({ ...settings, ai_workspace_conversations: conversations }) }, [saveSettings, settings]) @@ -1845,6 +1862,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu onEnterNeighborhood={activeDeletedFile ? undefined : handleEnterNeighborhood} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} + onCopyDeepLink={activeDeletedFile ? undefined : deepLinks.copyEntryDeepLink} onOpenExternalFile={fileActions.openExternalFile} onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote} onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 2386fc7c..c26ff515 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -238,6 +238,16 @@ describe('BreadcrumbBar — file actions', () => { expect(onCopyFilePath).toHaveBeenCalledWith('/vault/note/test.md') }) + + it('copies the current note deep link from the overflow menu', async () => { + const onCopyDeepLink = vi.fn() + render() + + const menu = await openOverflowMenu() + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Copy note deeplink' })) + + expect(onCopyDeepLink).toHaveBeenCalledWith(baseEntry) + }) }) describe('BreadcrumbBar — organized shortcut hint', () => { @@ -559,7 +569,7 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => { const menu = await openOverflowMenu() const menuLabels = within(menu).getAllByRole('menuitem').map((item) => item.textContent) expect(menuLabels[0]).toBe('Git diff') - expect(menuLabels.slice(-2)).toEqual(['Archive this note', 'Delete this note']) + expect(menuLabels.slice(-3)).toEqual(['Copy note deeplink', 'Archive this note', 'Delete this note']) } finally { restoreMeasurement() } @@ -583,6 +593,7 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => { expect(within(menu).queryByRole('menuitem', { name: 'Reveal in Finder' })).not.toBeInTheDocument() expect(within(menu).queryByRole('menuitem', { name: 'Copy file path' })).not.toBeInTheDocument() expect(within(menu).queryByRole('menuitem', { name: "Open note's neighborhood" })).not.toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: 'Copy note deeplink' })).toBeInTheDocument() }) it('exposes lower-priority actions when overflow hides their toolbar buttons', async () => { @@ -610,6 +621,7 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => { expect(within(menu).getByRole('menuitem', { name: 'Reveal in Finder' })).toBeInTheDocument() expect(within(menu).getByRole('menuitem', { name: 'Copy file path' })).toBeInTheDocument() expect(within(menu).getByRole('menuitem', { name: "Open note's neighborhood" })).toBeInTheDocument() + expect(within(menu).getByRole('menuitem', { name: 'Copy note deeplink' })).toBeInTheDocument() } finally { restoreMeasurement() } diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index ca208f66..e7e9646e 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -26,6 +26,7 @@ import { ArrowUUpLeft, ClipboardText, FolderOpen, + Link, MapTrifold, Star, CheckCircle, @@ -58,6 +59,7 @@ interface BreadcrumbBarProps { onToggleOrganized?: () => void onRevealFile?: (path: string) => void onCopyFilePath?: (path: string) => void + onCopyDeepLink?: (entry: VaultEntry) => void onDelete?: () => void onArchive?: () => void onUnarchive?: () => void @@ -453,6 +455,10 @@ function pathAction(action: ((path: string) => void) | undefined, path: string): return action ? () => action(path) : undefined } +function entryAction(action: ((entry: VaultEntry) => void) | undefined, entry: VaultEntry): (() => void) | undefined { + return action ? () => action(entry) : undefined +} + function ArchiveMenuIcon({ archived }: { archived: boolean }) { return archived ? : } @@ -827,6 +833,7 @@ function BreadcrumbActions({ onToggleOrganized, onRevealFile, onCopyFilePath, + onCopyDeepLink, onDelete, onArchive, onUnarchive, @@ -877,6 +884,7 @@ function BreadcrumbActions({ onToggleTableOfContents={onToggleTableOfContents} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} + onCopyDeepLink={onCopyDeepLink} onArchive={onArchive} onUnarchive={onUnarchive} onDelete={onDelete} @@ -899,6 +907,7 @@ function BreadcrumbOverflowMenu({ onToggleTableOfContents, onRevealFile, onCopyFilePath, + onCopyDeepLink, onArchive, onUnarchive, onDelete, @@ -916,6 +925,7 @@ function BreadcrumbOverflowMenu({ | 'onToggleTableOfContents' | 'onRevealFile' | 'onCopyFilePath' + | 'onCopyDeepLink' | 'onArchive' | 'onUnarchive' | 'onDelete' @@ -927,6 +937,7 @@ function BreadcrumbOverflowMenu({ const runDiffAction = availableDiffAction(showDiffToggle, onToggleDiff) const runRevealAction = pathAction(onRevealFile, entry.path) const runCopyPathAction = pathAction(onCopyFilePath, entry.path) + const runCopyDeepLinkAction = entryAction(onCopyDeepLink, entry) const runArchiveAction = archiveAction(entry.archived, onArchive, onUnarchive) const runNeighborhoodAction = neighborhoodAction(entry, onEnterNeighborhood) const diffLabel = translate(locale, 'editor.toolbar.gitDiff') @@ -980,6 +991,10 @@ function BreadcrumbOverflowMenu({ )} + + + {translate(locale, 'editor.toolbar.copyNoteDeepLink')} + {archiveLabel} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 44b2a23d..3bf6819e 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -91,6 +91,7 @@ interface EditorProps { onEnterNeighborhood?: (entry: VaultEntry) => void onRevealFile?: (path: string) => void onCopyFilePath?: (path: string) => void + onCopyDeepLink?: (entry: VaultEntry) => void onOpenExternalFile?: (path: string) => void onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void @@ -355,6 +356,7 @@ function EditorLayout({ onEnterNeighborhood, onRevealFile, onCopyFilePath, + onCopyDeepLink, onOpenExternalFile, onDeleteNote, onArchiveNote, @@ -426,6 +428,7 @@ function EditorLayout({ onEnterNeighborhood?: (entry: VaultEntry) => void onRevealFile?: (path: string) => void onCopyFilePath?: (path: string) => void + onCopyDeepLink?: (entry: VaultEntry) => void onOpenExternalFile?: (path: string) => void onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void @@ -479,7 +482,9 @@ function EditorLayout({ ? ( @@ -514,6 +519,7 @@ function EditorLayout({ onEnterNeighborhood={onEnterNeighborhood} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} + onCopyDeepLink={onCopyDeepLink} onDeleteNote={onDeleteNote} onArchiveNote={onArchiveNote} onUnarchiveNote={onUnarchiveNote} diff --git a/src/components/FilePreview.test.tsx b/src/components/FilePreview.test.tsx index 2c3cd699..db177077 100644 --- a/src/components/FilePreview.test.tsx +++ b/src/components/FilePreview.test.tsx @@ -81,6 +81,7 @@ describe('FilePreview', () => { it('routes header file actions to the active file path', () => { const onRevealFile = vi.fn() const onCopyFilePath = vi.fn() + const onCopyDeepLink = vi.fn() const onOpenExternalFile = vi.fn() render( @@ -88,6 +89,7 @@ describe('FilePreview', () => { entry={imageEntry} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} + onCopyDeepLink={onCopyDeepLink} onOpenExternalFile={onOpenExternalFile} />, ) @@ -96,10 +98,12 @@ describe('FilePreview', () => { fireEvent.click(screen.getByRole('button', { name: 'Reveal' })) fireEvent.click(screen.getByRole('button', { name: 'Copy path' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })) fireEvent.click(screen.getByRole('button', { name: 'Open' })) expect(onRevealFile).toHaveBeenCalledWith('/vault/Attachments/photo.png') expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png') + expect(onCopyDeepLink).toHaveBeenCalledWith(imageEntry) expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png') expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', { action: 'reveal', @@ -109,6 +113,10 @@ describe('FilePreview', () => { action: 'copy_path', preview_kind: 'image', }) + expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', { + action: 'copy_deep_link', + preview_kind: 'image', + }) expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', { action: 'open_external', preview_kind: 'image', diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 79afcc17..cab08875 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { convertFileSrc } from '@tauri-apps/api/core' -import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, SpeakerHigh, Video, WarningCircle } from '@phosphor-icons/react' +import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, Link, SpeakerHigh, Video, WarningCircle } from '@phosphor-icons/react' import type { VaultEntry } from '../types' +import { translate, type AppLocale } from '../lib/i18n' import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics' import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview' import { useExternalMediaPreview } from '../utils/mediaPreviewRuntime' @@ -11,7 +12,9 @@ import { Button } from './ui/button' interface FilePreviewProps { entry: VaultEntry + locale?: AppLocale onCopyFilePath?: (path: string) => void + onCopyDeepLink?: (entry: VaultEntry) => void onOpenExternalFile?: (path: string) => void onRevealFile?: (path: string) => void } @@ -94,16 +97,20 @@ function FilePreviewHeader({ entry, previewKind, fileTypeLabel, + locale = 'en', onOpenExternal, onRevealFile, onCopyFilePath, + onCopyDeepLink, }: { entry: VaultEntry previewKind: FilePreviewKind | null fileTypeLabel: string + locale?: AppLocale onOpenExternal: () => void onRevealFile?: () => void onCopyFilePath?: () => void + onCopyDeepLink?: () => void }) { return (
)} + {onCopyDeepLink && ( + + )}