feat: add vault item deep links
This commit is contained in:
@@ -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://<vault-slug>/<relative-path-with-extension>`. 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:
|
||||
|
||||
@@ -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://<vault-slug>/<relative-path-with-extension>` 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 |
|
||||
|
||||
45
docs/adr/0129-tolaria-vault-item-deep-links.md
Normal file
45
docs/adr/0129-tolaria-vault-item-deep-links.md
Normal file
@@ -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://<vault-slug>/<relative-path-with-extension>
|
||||
```
|
||||
|
||||
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.
|
||||
16
lara.lock
16
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
128
src-tauri/Cargo.lock
generated
128
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -278,9 +278,44 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn focus_main_window(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Manager;
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn with_desktop_entry_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
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<dyn std::error::Error>> {
|
||||
#[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<dyn std::error::Error>> {
|
||||
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)))
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["tolaria"]
|
||||
}
|
||||
},
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/tolaria/stable/latest.json"
|
||||
|
||||
52
src/App.tsx
52
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}
|
||||
|
||||
@@ -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(<BreadcrumbBar entry={baseEntry} {...defaultProps} onCopyDeepLink={onCopyDeepLink} />)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 ? <ArrowUUpLeft size={16} /> : <Archive size={16} />
|
||||
}
|
||||
@@ -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({
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!runCopyDeepLinkAction} onSelect={runCopyDeepLinkAction}>
|
||||
<Link size={16} />
|
||||
{translate(locale, 'editor.toolbar.copyNoteDeepLink')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!runArchiveAction} onSelect={runArchiveAction}>
|
||||
<ArchiveMenuIcon archived={entry.archived} />
|
||||
{archiveLabel}
|
||||
|
||||
@@ -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({
|
||||
? (
|
||||
<FilePreview
|
||||
entry={activeBinaryTab.entry}
|
||||
locale={locale}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onCopyDeepLink={onCopyDeepLink}
|
||||
onOpenExternalFile={onOpenExternalFile}
|
||||
onRevealFile={onRevealFile}
|
||||
/>
|
||||
@@ -514,6 +519,7 @@ function EditorLayout({
|
||||
onEnterNeighborhood={onEnterNeighborhood}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onCopyDeepLink={onCopyDeepLink}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -130,6 +137,12 @@ function FilePreviewHeader({
|
||||
Copy path
|
||||
</Button>
|
||||
)}
|
||||
{onCopyDeepLink && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCopyDeepLink}>
|
||||
<Link size={15} />
|
||||
{translate(locale, 'filePreview.copyDeepLink')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onOpenExternal}>
|
||||
<ArrowSquareOut size={15} />
|
||||
Open
|
||||
@@ -327,14 +340,18 @@ function useFilePreviewFailureState(entryPath: string) {
|
||||
}
|
||||
|
||||
function useFilePreviewActions({
|
||||
entry,
|
||||
entryPath,
|
||||
onCopyFilePath,
|
||||
onCopyDeepLink,
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
previewKind,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
entryPath: string
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onCopyDeepLink?: (entry: VaultEntry) => void
|
||||
onOpenExternalFile?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
previewKind: FilePreviewKind | null
|
||||
@@ -361,7 +378,12 @@ function useFilePreviewActions({
|
||||
onCopyFilePath?.(entryPath)
|
||||
}, [entryPath, onCopyFilePath, previewKind])
|
||||
|
||||
return { handleOpenExternal, handleRevealFile, handleCopyFilePath }
|
||||
const handleCopyDeepLink = useCallback(() => {
|
||||
trackFilePreviewAction('copy_deep_link', previewKind)
|
||||
onCopyDeepLink?.(entry)
|
||||
}, [entry, onCopyDeepLink, previewKind])
|
||||
|
||||
return { handleOpenExternal, handleRevealFile, handleCopyFilePath, handleCopyDeepLink }
|
||||
}
|
||||
|
||||
function isMediaPreviewKind(previewKind: FilePreviewKind | null): boolean {
|
||||
@@ -379,7 +401,9 @@ function previewKindForBody(
|
||||
|
||||
export function FilePreview({
|
||||
entry,
|
||||
locale = 'en',
|
||||
onCopyFilePath,
|
||||
onCopyDeepLink,
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
}: FilePreviewProps) {
|
||||
@@ -391,8 +415,10 @@ export function FilePreview({
|
||||
const externalMediaPreview = useExternalMediaPreview()
|
||||
const failures = useFilePreviewFailureState(entry.path)
|
||||
const actions = useFilePreviewActions({
|
||||
entry,
|
||||
entryPath: entry.path,
|
||||
onCopyFilePath,
|
||||
onCopyDeepLink,
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
previewKind,
|
||||
@@ -429,9 +455,11 @@ export function FilePreview({
|
||||
entry={entry}
|
||||
previewKind={previewKind}
|
||||
fileTypeLabel={fileTypeLabel}
|
||||
locale={locale}
|
||||
onOpenExternal={actions.handleOpenExternal}
|
||||
onRevealFile={onRevealFile ? actions.handleRevealFile : undefined}
|
||||
onCopyFilePath={onCopyFilePath ? actions.handleCopyFilePath : undefined}
|
||||
onCopyDeepLink={onCopyDeepLink ? actions.handleCopyDeepLink : undefined}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-background">
|
||||
<FilePreviewBody
|
||||
|
||||
@@ -34,6 +34,7 @@ type BreadcrumbActions = Pick<
|
||||
| 'onEnterNeighborhood'
|
||||
| 'onRevealFile'
|
||||
| 'onCopyFilePath'
|
||||
| 'onCopyDeepLink'
|
||||
| 'onDeleteNote'
|
||||
| 'onArchiveNote'
|
||||
| 'onUnarchiveNote'
|
||||
@@ -201,6 +202,7 @@ function ActiveTabBreadcrumb({
|
||||
onEnterNeighborhood={actions.onEnterNeighborhood}
|
||||
onRevealFile={actions.onRevealFile}
|
||||
onCopyFilePath={actions.onCopyFilePath}
|
||||
onCopyDeepLink={actions.onCopyDeepLink}
|
||||
onDelete={bindPath(actions.onDeleteNote, path)}
|
||||
onArchive={bindPath(actions.onArchiveNote, path)}
|
||||
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
|
||||
@@ -266,6 +268,7 @@ function buildBreadcrumbActions(model: EditorContentModel): BreadcrumbActions {
|
||||
onEnterNeighborhood: model.onEnterNeighborhood,
|
||||
onRevealFile: model.onRevealFile,
|
||||
onCopyFilePath: model.onCopyFilePath,
|
||||
onCopyDeepLink: model.onCopyDeepLink,
|
||||
onDeleteNote: model.onDeleteNote,
|
||||
onArchiveNote: model.onArchiveNote,
|
||||
onUnarchiveNote: model.onUnarchiveNote,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface EditorContentProps {
|
||||
onEnterNeighborhood?: (entry: VaultEntry) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onCopyDeepLink?: (entry: VaultEntry) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
|
||||
@@ -36,6 +36,7 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
|
||||
'set-note-icon': 'command.note.setIcon',
|
||||
'change-note-type': 'command.note.changeType',
|
||||
'move-note-to-folder': 'command.note.moveToFolder',
|
||||
'copy-active-deep-link': 'command.note.copyDeepLink',
|
||||
'remove-note-icon': 'command.note.removeIcon',
|
||||
'open-in-new-window': 'command.note.openNewWindow',
|
||||
'initialize-git': 'command.git.initialize',
|
||||
|
||||
@@ -33,6 +33,7 @@ interface NoteCommandsConfig {
|
||||
onOpenInNewWindow?: () => void
|
||||
onRevealActiveFile?: (path: string) => void
|
||||
onCopyActiveFilePath?: (path: string) => void
|
||||
onCopyActiveDeepLink?: (path: string) => void
|
||||
onOpenActiveFileExternal?: (path: string) => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
isFavorite?: boolean
|
||||
@@ -244,33 +245,54 @@ function buildRetargetingCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
]
|
||||
}
|
||||
|
||||
interface ActivePathCommandConfig {
|
||||
enabled: boolean
|
||||
id: string
|
||||
keywords: string[]
|
||||
label: string
|
||||
run: (path: string) => void
|
||||
}
|
||||
|
||||
function buildActivePathCommand(config: NoteCommandsConfig, command: ActivePathCommandConfig): CommandAction {
|
||||
return createNoteCommand({
|
||||
id: command.id,
|
||||
label: command.label,
|
||||
keywords: command.keywords,
|
||||
enabled: config.hasActiveNote && command.enabled,
|
||||
path: config.activeTabPath,
|
||||
run: command.run,
|
||||
})
|
||||
}
|
||||
|
||||
function buildFileActionCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
const activeFileKind = config.activeFileKind ?? 'markdown'
|
||||
const hasNonMarkdownActiveFile = config.hasActiveNote && activeFileKind !== 'markdown'
|
||||
|
||||
return [
|
||||
createNoteCommand({
|
||||
buildActivePathCommand(config, {
|
||||
id: 'reveal-active-file',
|
||||
label: 'Reveal in Finder',
|
||||
keywords: ['file', 'folder', 'finder', 'reveal', 'show', 'filesystem'],
|
||||
enabled: config.hasActiveNote && !!config.onRevealActiveFile,
|
||||
path: config.activeTabPath,
|
||||
enabled: !!config.onRevealActiveFile,
|
||||
run: (path) => config.onRevealActiveFile?.(path),
|
||||
}),
|
||||
createNoteCommand({
|
||||
buildActivePathCommand(config, {
|
||||
id: 'copy-active-file-path',
|
||||
label: 'Copy File Path',
|
||||
keywords: ['file', 'path', 'copy', 'clipboard', 'filesystem'],
|
||||
enabled: config.hasActiveNote && !!config.onCopyActiveFilePath,
|
||||
path: config.activeTabPath,
|
||||
enabled: !!config.onCopyActiveFilePath,
|
||||
run: (path) => config.onCopyActiveFilePath?.(path),
|
||||
}),
|
||||
createNoteCommand({
|
||||
buildActivePathCommand(config, {
|
||||
id: 'copy-active-deep-link',
|
||||
label: 'Copy deep link to current item',
|
||||
keywords: ['deeplink', 'deep link', 'url', 'link', 'copy', 'clipboard'],
|
||||
enabled: !!config.onCopyActiveDeepLink,
|
||||
run: (path) => config.onCopyActiveDeepLink?.(path),
|
||||
}),
|
||||
buildActivePathCommand(config, {
|
||||
id: 'open-active-file-external',
|
||||
label: 'Open in Default App',
|
||||
keywords: ['file', 'open', 'external', 'default', 'attachment'],
|
||||
enabled: hasNonMarkdownActiveFile && !!config.onOpenActiveFileExternal,
|
||||
path: config.activeTabPath,
|
||||
enabled: activeFileKind !== 'markdown' && !!config.onOpenActiveFileExternal,
|
||||
run: (path) => config.onOpenActiveFileExternal?.(path),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -123,6 +123,7 @@ interface AppCommandsConfig {
|
||||
onOpenInNewWindow?: () => void
|
||||
onRevealActiveFile?: (path: string) => void
|
||||
onCopyActiveFilePath?: (path: string) => void
|
||||
onCopyActiveDeepLink?: (path: string) => void
|
||||
onOpenActiveFileExternal?: (path: string) => void
|
||||
onRevealSelectedFolder?: () => void
|
||||
onCopySelectedFolderPath?: () => void
|
||||
@@ -224,6 +225,7 @@ type CommandRegistryVaultActions = Pick<
|
||||
| 'onOpenInNewWindow'
|
||||
| 'onRevealActiveFile'
|
||||
| 'onCopyActiveFilePath'
|
||||
| 'onCopyActiveDeepLink'
|
||||
| 'onOpenActiveFileExternal'
|
||||
| 'onRestoreDeletedNote'
|
||||
| 'canRestoreDeletedNote'
|
||||
@@ -534,6 +536,7 @@ function createCommandRegistryVaultConfig(
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
onRevealActiveFile: config.onRevealActiveFile,
|
||||
onCopyActiveFilePath: config.onCopyActiveFilePath,
|
||||
onCopyActiveDeepLink: config.onCopyActiveDeepLink,
|
||||
onOpenActiveFileExternal: config.onOpenActiveFileExternal,
|
||||
onRestoreDeletedNote: config.onRestoreDeletedNote,
|
||||
canRestoreDeletedNote: config.canRestoreDeletedNote,
|
||||
|
||||
@@ -403,11 +403,13 @@ describe('useCommandRegistry', () => {
|
||||
it('exposes active file actions when a note is selected', () => {
|
||||
const onRevealActiveFile = vi.fn()
|
||||
const onCopyActiveFilePath = vi.fn()
|
||||
const onCopyActiveDeepLink = vi.fn()
|
||||
const config = makeConfig({
|
||||
activeTabPath: '/vault/current.md',
|
||||
entries: [{ path: '/vault/current.md', title: 'Current', fileKind: 'markdown' }],
|
||||
onRevealActiveFile,
|
||||
onCopyActiveFilePath,
|
||||
onCopyActiveDeepLink,
|
||||
})
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
@@ -421,12 +423,19 @@ describe('useCommandRegistry', () => {
|
||||
group: 'Note',
|
||||
label: 'Copy File Path',
|
||||
})
|
||||
expect(findCommand(result.current, 'copy-active-deep-link')).toMatchObject({
|
||||
enabled: true,
|
||||
group: 'Note',
|
||||
label: 'Copy deep link to current item',
|
||||
})
|
||||
|
||||
findCommand(result.current, 'reveal-active-file')!.execute()
|
||||
findCommand(result.current, 'copy-active-file-path')!.execute()
|
||||
findCommand(result.current, 'copy-active-deep-link')!.execute()
|
||||
|
||||
expect(onRevealActiveFile).toHaveBeenCalledWith('/vault/current.md')
|
||||
expect(onCopyActiveFilePath).toHaveBeenCalledWith('/vault/current.md')
|
||||
expect(onCopyActiveDeepLink).toHaveBeenCalledWith('/vault/current.md')
|
||||
})
|
||||
|
||||
it('only enables external open for non-markdown active files', () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ interface CommandRegistryConfig {
|
||||
onOpenInNewWindow?: () => void
|
||||
onRevealActiveFile?: (path: string) => void
|
||||
onCopyActiveFilePath?: (path: string) => void
|
||||
onCopyActiveDeepLink?: (path: string) => void
|
||||
onOpenActiveFileExternal?: (path: string) => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
@@ -167,7 +168,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onReloadVault, onRepairVault,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage, onSetThemeMode,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal, onToggleFavorite, onToggleOrganized,
|
||||
onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onCopyActiveDeepLink, onOpenActiveFileExternal, onToggleFavorite, onToggleOrganized,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
|
||||
onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
@@ -220,6 +221,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
|
||||
onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal,
|
||||
onCopyActiveDeepLink,
|
||||
onToggleFavorite, isFavorite,
|
||||
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
|
||||
onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
@@ -230,6 +232,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
|
||||
onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal,
|
||||
onCopyActiveDeepLink,
|
||||
onToggleFavorite, isFavorite,
|
||||
onToggleOrganized, activeEntry?.organized, onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
])
|
||||
|
||||
459
src/hooks/useDeepLinks.ts
Normal file
459
src/hooks/useDeepLinks.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { translate } from '../lib/i18n'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { writeClipboardText } from '../utils/clipboardText'
|
||||
import {
|
||||
buildTolariaDeepLinkForEntry,
|
||||
relativePathForVaultItem,
|
||||
resolveTolariaDeepLink,
|
||||
type DeepLinkBuildError,
|
||||
type DeepLinkOpenError,
|
||||
type DeepLinkVault,
|
||||
type ResolvedTolariaDeepLink,
|
||||
} from '../utils/deepLinks'
|
||||
import { notePathsMatch } from '../utils/notePathIdentity'
|
||||
import { vaultPathForEntry } from '../utils/workspaces'
|
||||
|
||||
interface UseDeepLinksConfig {
|
||||
activeEntry: VaultEntry | null
|
||||
currentVaultPath: string
|
||||
enabled: boolean
|
||||
entries: VaultEntry[]
|
||||
isVaultContentLoading: boolean
|
||||
locale?: AppLocale
|
||||
onSelectNote: (entry: VaultEntry) => Promise<void> | void
|
||||
onSwitchVault: (path: string) => void
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
setToastMessage: (message: string) => void
|
||||
vaultListLoaded: boolean
|
||||
vaults: DeepLinkVault[]
|
||||
}
|
||||
|
||||
interface PendingNavigation {
|
||||
absolutePath: string
|
||||
relativePath: string
|
||||
vault: DeepLinkVault
|
||||
}
|
||||
|
||||
function deepLinkOpenErrorMessage(error: DeepLinkOpenError, locale: AppLocale): string {
|
||||
const key = {
|
||||
ambiguous_vault: 'deepLinks.error.ambiguousVault',
|
||||
invalid_scheme: 'deepLinks.error.invalidScheme',
|
||||
malformed_url: 'deepLinks.error.malformedUrl',
|
||||
missing_file: 'deepLinks.error.missingFile',
|
||||
missing_path: 'deepLinks.error.missingPath',
|
||||
missing_vault: 'deepLinks.error.missingVault',
|
||||
unavailable_vault: 'deepLinks.error.unavailableVault',
|
||||
unknown_vault: 'deepLinks.error.unknownVault',
|
||||
unsafe_path: 'deepLinks.error.unsafePath',
|
||||
} satisfies Record<DeepLinkOpenError, Parameters<typeof translate>[1]>
|
||||
return translate(locale, key[error])
|
||||
}
|
||||
|
||||
function deepLinkBuildErrorMessage(error: DeepLinkBuildError, locale: AppLocale): string {
|
||||
const key = {
|
||||
outside_vault: 'deepLinks.error.outsideVault',
|
||||
unavailable_vault: 'deepLinks.error.unavailableVault',
|
||||
unknown_vault: 'deepLinks.error.unknownVault',
|
||||
unsafe_path: 'deepLinks.error.unsafePath',
|
||||
} satisfies Record<DeepLinkBuildError, Parameters<typeof translate>[1]>
|
||||
return translate(locale, key[error])
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function navigationKey(request: PendingNavigation): string {
|
||||
return `${request.vault.path}\n${request.relativePath}`
|
||||
}
|
||||
|
||||
function findEntryForDeepLink(
|
||||
entries: readonly VaultEntry[],
|
||||
request: PendingNavigation,
|
||||
): VaultEntry | undefined {
|
||||
return entries.find((entry) => {
|
||||
const relativePath = relativePathForVaultItem({ itemPath: entry.path, vaultPath: request.vault.path })
|
||||
return notePathsMatch(relativePath, request.relativePath)
|
||||
})
|
||||
}
|
||||
|
||||
function useDeepLinkVaults(vaults: DeepLinkVault[], currentVaultPath: string): DeepLinkVault[] {
|
||||
return useMemo(() => {
|
||||
if (vaults.some((vault) => notePathsMatch(vault.path, currentVaultPath))) return vaults
|
||||
return [...vaults, { label: 'Current Vault', path: currentVaultPath }]
|
||||
}, [currentVaultPath, vaults])
|
||||
}
|
||||
|
||||
interface DeepLinkResolverConfig {
|
||||
currentVaultPath: string
|
||||
enabled: boolean
|
||||
knownVaults: DeepLinkVault[]
|
||||
locale: AppLocale
|
||||
onSwitchVault: (path: string) => void
|
||||
pendingUrl: string | null
|
||||
setPendingNavigation: (request: PendingNavigation | null) => void
|
||||
setPendingUrl: (url: string | null) => void
|
||||
setToastMessage: (message: string) => void
|
||||
vaultListLoaded: boolean
|
||||
}
|
||||
|
||||
function useDeepLinkResolver({
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
knownVaults,
|
||||
locale,
|
||||
onSwitchVault,
|
||||
pendingUrl,
|
||||
setPendingNavigation,
|
||||
setPendingUrl,
|
||||
setToastMessage,
|
||||
vaultListLoaded,
|
||||
}: DeepLinkResolverConfig) {
|
||||
const openResolvedDeepLink = useCallback((request: Extract<ResolvedTolariaDeepLink, { ok: true }>) => {
|
||||
const nextNavigation = {
|
||||
absolutePath: request.absolutePath,
|
||||
relativePath: request.relativePath,
|
||||
vault: request.vault,
|
||||
}
|
||||
setPendingNavigation(nextNavigation)
|
||||
if (!notePathsMatch(currentVaultPath, request.vault.path)) {
|
||||
onSwitchVault(request.vault.path)
|
||||
}
|
||||
}, [currentVaultPath, onSwitchVault, setPendingNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !pendingUrl || !vaultListLoaded) return
|
||||
|
||||
const resolved = resolveTolariaDeepLink({ rawUrl: pendingUrl, vaults: knownVaults })
|
||||
setPendingUrl(null)
|
||||
if (!resolved.ok) {
|
||||
setToastMessage(deepLinkOpenErrorMessage(resolved.error, locale))
|
||||
trackEvent('deep_link_opened', { outcome: 'failed', reason: resolved.error })
|
||||
return
|
||||
}
|
||||
|
||||
openResolvedDeepLink(resolved)
|
||||
}, [enabled, knownVaults, locale, openResolvedDeepLink, pendingUrl, setPendingUrl, setToastMessage, vaultListLoaded])
|
||||
}
|
||||
|
||||
interface DeepLinkNavigationConfig {
|
||||
currentVaultPath: string
|
||||
enabled: boolean
|
||||
entries: VaultEntry[]
|
||||
isVaultContentLoading: boolean
|
||||
locale: AppLocale
|
||||
onSelectNote: (entry: VaultEntry) => Promise<void> | void
|
||||
pendingNavigation: PendingNavigation | null
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
setPendingNavigation: (request: PendingNavigation | null) => void
|
||||
setToastMessage: (message: string) => void
|
||||
}
|
||||
|
||||
interface DeepLinkEntrySelectionInput {
|
||||
entries: VaultEntry[]
|
||||
onSelectNote: (entry: VaultEntry) => Promise<void> | void
|
||||
pendingNavigation: PendingNavigation
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
}
|
||||
|
||||
async function selectDeepLinkEntry({
|
||||
entries,
|
||||
onSelectNote,
|
||||
pendingNavigation,
|
||||
reloadVault,
|
||||
}: DeepLinkEntrySelectionInput): Promise<boolean> {
|
||||
const existingEntry = findEntryForDeepLink(entries, pendingNavigation)
|
||||
if (existingEntry) {
|
||||
await onSelectNote(existingEntry)
|
||||
return true
|
||||
}
|
||||
|
||||
const freshEntries = await reloadVault()
|
||||
const freshEntry = findEntryForDeepLink(freshEntries, pendingNavigation)
|
||||
if (!freshEntry) return false
|
||||
|
||||
await onSelectNote(freshEntry)
|
||||
return true
|
||||
}
|
||||
|
||||
function readyPendingNavigation({
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
isVaultContentLoading,
|
||||
pendingNavigation,
|
||||
}: Pick<DeepLinkNavigationConfig, 'currentVaultPath' | 'enabled' | 'isVaultContentLoading' | 'pendingNavigation'>): PendingNavigation | null {
|
||||
if (!enabled || !pendingNavigation || isVaultContentLoading) return null
|
||||
return notePathsMatch(currentVaultPath, pendingNavigation.vault.path) ? pendingNavigation : null
|
||||
}
|
||||
|
||||
function useDeepLinkNavigation({
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
entries,
|
||||
isVaultContentLoading,
|
||||
locale,
|
||||
onSelectNote,
|
||||
pendingNavigation,
|
||||
reloadVault,
|
||||
setPendingNavigation,
|
||||
setToastMessage,
|
||||
}: DeepLinkNavigationConfig) {
|
||||
const activeAttemptRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const request = readyPendingNavigation({ currentVaultPath, enabled, isVaultContentLoading, pendingNavigation })
|
||||
if (!request) return
|
||||
|
||||
const key = navigationKey(request)
|
||||
if (activeAttemptRef.current === key) return
|
||||
activeAttemptRef.current = key
|
||||
|
||||
const run = async () => {
|
||||
const selected = await selectDeepLinkEntry({ entries, onSelectNote, pendingNavigation: request, reloadVault })
|
||||
if (selected) {
|
||||
setPendingNavigation(null)
|
||||
trackEvent('deep_link_opened', { outcome: 'success' })
|
||||
return
|
||||
}
|
||||
|
||||
setPendingNavigation(null)
|
||||
setToastMessage(deepLinkOpenErrorMessage('missing_file', locale))
|
||||
trackEvent('deep_link_opened', { outcome: 'failed', reason: 'missing_file' })
|
||||
}
|
||||
|
||||
void run()
|
||||
.catch((error) => {
|
||||
setPendingNavigation(null)
|
||||
setToastMessage(translate(locale, 'deepLinks.error.openFailed', { detail: errorDetail(error) }))
|
||||
trackEvent('deep_link_opened', { outcome: 'failed', reason: 'open_failed' })
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeAttemptRef.current === key) activeAttemptRef.current = null
|
||||
})
|
||||
}, [
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
entries,
|
||||
isVaultContentLoading,
|
||||
locale,
|
||||
onSelectNote,
|
||||
pendingNavigation,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
setPendingNavigation,
|
||||
])
|
||||
}
|
||||
|
||||
function usePendingDeepLinkUrl(setPendingUrl: (url: string | null) => void) {
|
||||
return useCallback((url: string) => {
|
||||
setPendingUrl(url)
|
||||
}, [setPendingUrl])
|
||||
}
|
||||
|
||||
function latestDeepLinkUrl(urls: string[] | null | undefined): string | null {
|
||||
return urls?.at(-1) ?? urls?.[0] ?? null
|
||||
}
|
||||
|
||||
function queueLatestDeepLinkUrl(
|
||||
urls: string[] | null | undefined,
|
||||
setPendingUrl: (url: string | null) => void,
|
||||
) {
|
||||
const url = latestDeepLinkUrl(urls)
|
||||
if (url) setPendingUrl(url)
|
||||
}
|
||||
|
||||
interface DeepLinkListenerState {
|
||||
disposed: boolean
|
||||
}
|
||||
|
||||
async function installTauriDeepLinkListener({
|
||||
listenerState,
|
||||
setPendingUrl,
|
||||
}: {
|
||||
listenerState: DeepLinkListenerState
|
||||
setPendingUrl: (url: string | null) => void
|
||||
}): Promise<() => void> {
|
||||
const { getCurrent, onOpenUrl } = await import('@tauri-apps/plugin-deep-link')
|
||||
queueLatestDeepLinkUrl(await getCurrent(), (url) => {
|
||||
if (!listenerState.disposed) setPendingUrl(url)
|
||||
})
|
||||
|
||||
const stopListening = await onOpenUrl((urls) => {
|
||||
queueLatestDeepLinkUrl(urls, setPendingUrl)
|
||||
})
|
||||
if (!listenerState.disposed) return stopListening
|
||||
|
||||
stopListening()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
function useTauriDeepLinkListener({
|
||||
enabled,
|
||||
setPendingUrl,
|
||||
}: {
|
||||
enabled: boolean
|
||||
setPendingUrl: (url: string | null) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !isTauri()) return undefined
|
||||
|
||||
const listenerState = { disposed: false }
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
installTauriDeepLinkListener({ listenerState, setPendingUrl })
|
||||
.then((stopListening) => {
|
||||
unlisten = stopListening
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[deep-link] Failed to install listener:', error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
listenerState.disposed = true
|
||||
unlisten?.()
|
||||
}
|
||||
}, [enabled, setPendingUrl])
|
||||
}
|
||||
|
||||
function useDeepLinkTestBridge({
|
||||
enabled,
|
||||
openDeepLink,
|
||||
}: {
|
||||
enabled: boolean
|
||||
openDeepLink: (url: string) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined
|
||||
window.__laputaTest = {
|
||||
...window.__laputaTest,
|
||||
openDeepLink,
|
||||
}
|
||||
return () => {
|
||||
if (window.__laputaTest?.openDeepLink === openDeepLink) {
|
||||
delete window.__laputaTest.openDeepLink
|
||||
}
|
||||
}
|
||||
}, [enabled, openDeepLink])
|
||||
}
|
||||
|
||||
interface DeepLinkCopyActionsConfig {
|
||||
activeEntry: VaultEntry | null
|
||||
currentVaultPath: string
|
||||
entries: VaultEntry[]
|
||||
knownVaults: DeepLinkVault[]
|
||||
locale: AppLocale
|
||||
setToastMessage: (message: string) => void
|
||||
}
|
||||
|
||||
function useDeepLinkCopyActions({
|
||||
activeEntry,
|
||||
currentVaultPath,
|
||||
entries,
|
||||
knownVaults,
|
||||
locale,
|
||||
setToastMessage,
|
||||
}: DeepLinkCopyActionsConfig) {
|
||||
const copyEntryDeepLink = useCallback((entry: VaultEntry) => {
|
||||
const vaultPath = vaultPathForEntry(entry, currentVaultPath)
|
||||
const result = buildTolariaDeepLinkForEntry({ entry, vaultPath, vaults: knownVaults })
|
||||
if (!result.ok) {
|
||||
setToastMessage(deepLinkBuildErrorMessage(result.error, locale))
|
||||
trackEvent('deep_link_copied', { outcome: 'failed', reason: result.error })
|
||||
return
|
||||
}
|
||||
|
||||
void writeClipboardText(result.url)
|
||||
.then(() => {
|
||||
setToastMessage(translate(locale, 'deepLinks.copied'))
|
||||
trackEvent('deep_link_copied', { outcome: 'success' })
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastMessage(translate(locale, 'deepLinks.error.copyFailed', { detail: errorDetail(error) }))
|
||||
trackEvent('deep_link_copied', { outcome: 'failed', reason: 'clipboard_failed' })
|
||||
})
|
||||
}, [currentVaultPath, knownVaults, locale, setToastMessage])
|
||||
|
||||
const copyPathDeepLink = useCallback((path: string) => {
|
||||
const entry = entries.find((candidate) => notePathsMatch(candidate.path, path))
|
||||
if (!entry) {
|
||||
setToastMessage(deepLinkOpenErrorMessage('missing_file', locale))
|
||||
return
|
||||
}
|
||||
copyEntryDeepLink(entry)
|
||||
}, [copyEntryDeepLink, entries, locale, setToastMessage])
|
||||
|
||||
const copyActiveDeepLink = useCallback(() => {
|
||||
if (!activeEntry) return
|
||||
copyEntryDeepLink(activeEntry)
|
||||
}, [activeEntry, copyEntryDeepLink])
|
||||
|
||||
return {
|
||||
copyActiveDeepLink,
|
||||
copyEntryDeepLink,
|
||||
copyPathDeepLink,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeepLinks({
|
||||
activeEntry,
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
entries,
|
||||
isVaultContentLoading,
|
||||
locale = 'en',
|
||||
onSelectNote,
|
||||
onSwitchVault,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
vaultListLoaded,
|
||||
vaults,
|
||||
}: UseDeepLinksConfig) {
|
||||
const knownVaults = useDeepLinkVaults(vaults, currentVaultPath)
|
||||
const [pendingUrl, setPendingUrl] = useState<string | null>(null)
|
||||
const [pendingNavigation, setPendingNavigation] = useState<PendingNavigation | null>(null)
|
||||
|
||||
useDeepLinkResolver({
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
knownVaults,
|
||||
locale,
|
||||
onSwitchVault,
|
||||
pendingUrl,
|
||||
setPendingNavigation,
|
||||
setPendingUrl,
|
||||
setToastMessage,
|
||||
vaultListLoaded,
|
||||
})
|
||||
useDeepLinkNavigation({
|
||||
currentVaultPath,
|
||||
enabled,
|
||||
entries,
|
||||
isVaultContentLoading,
|
||||
locale,
|
||||
onSelectNote,
|
||||
pendingNavigation,
|
||||
reloadVault,
|
||||
setPendingNavigation,
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const openDeepLink = usePendingDeepLinkUrl(setPendingUrl)
|
||||
useTauriDeepLinkListener({ enabled, setPendingUrl })
|
||||
useDeepLinkTestBridge({ enabled, openDeepLink })
|
||||
|
||||
return {
|
||||
...useDeepLinkCopyActions({
|
||||
activeEntry,
|
||||
currentVaultPath,
|
||||
entries,
|
||||
knownVaults,
|
||||
locale,
|
||||
setToastMessage,
|
||||
}),
|
||||
openDeepLink,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Выдаліць значок нататкі",
|
||||
"command.note.changeType": "Змяніць тып нататкі…",
|
||||
"command.note.moveToFolder": "Перамясціць нататку ў папку…",
|
||||
"command.note.copyDeepLink": "Капіяваць глыбокі спасылку на бягучы элемент",
|
||||
"command.note.openNewWindow": "Адкрыць у новым акне",
|
||||
"command.git.initialize": "Ініцыялізаваць Git для бягучага сховішча",
|
||||
"command.git.commitPush": "Закаміціць і адправіць (Commit & Push)",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Выдаліць",
|
||||
"editor.toolbar.revealFile": "Паказаць у Finder",
|
||||
"editor.toolbar.copyFilePath": "Капіяваць шлях",
|
||||
"editor.toolbar.copyNoteDeepLink": "Капіяваць дэплінк нататкі",
|
||||
"editor.toolbar.moreActions": "Больш дзеянняў",
|
||||
"editor.toolbar.openProperties": "Адкрыць уласцівасці",
|
||||
"filePreview.copyDeepLink": "Капіраваць спасылку",
|
||||
"deepLinks.copied": "Глыбокі спасылка скапіявана",
|
||||
"deepLinks.error.ambiguousVault": "Глыбокі спасылка адпавядае больш чым аднаму сховішчы. Пераймянуйце адзін псеўданім сховішча ў наладах.",
|
||||
"deepLinks.error.copyFailed": "Не атрымалася скапіяваць глыбокі спасылку: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Гэта не глыбокі спасылка Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Глыбокі спасылка мае няправільную форму.",
|
||||
"deepLinks.error.missingFile": "Мэтавая старонка глыбокай спасылкі недаступная ў гэтым сховішчы.",
|
||||
"deepLinks.error.missingPath": "У глыбокіх спасылках адсутнічае шлях да элемента сховішча.",
|
||||
"deepLinks.error.missingVault": "У глыбокіх спасылках адсутнічае сховішча.",
|
||||
"deepLinks.error.openFailed": "Не атрымалася адкрыць глыбокі спасылку: {detail}",
|
||||
"deepLinks.error.outsideVault": "Гэты элемент нельга звязаць, бо ён знаходзіцца па-за сховішчам.",
|
||||
"deepLinks.error.unavailableVault": "Сховішча глыбокіх спасылак недаступнае на гэтай прыладзе.",
|
||||
"deepLinks.error.unknownVault": "Глыбокі спасылка накіравана на невядомы сховішча.",
|
||||
"deepLinks.error.unsafePath": "Шлях глыбокай спасылкі не можа выходзіць за межы сховішча.",
|
||||
"editor.slash.math": "Матэматыка",
|
||||
"tableOfContents.title": "Змест",
|
||||
"tableOfContents.close": "Закрыць змест",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"command.note.changeType": "Zmianić typ natatki…",
|
||||
"command.note.moveToFolder": "Pieramiascić natatku ŭ papku…",
|
||||
"command.note.openNewWindow": "Adkryć u novym aknie",
|
||||
"command.note.copyDeepLink": "Kapijavać deep-link da biahučaha elementa",
|
||||
"command.git.initialize": "Inicyjalizavać Git dlia biahučaha schovišča",
|
||||
"command.git.commitPush": "Zakamicić i adpravić (Commit & Push)",
|
||||
"command.git.addRemote": "Dadać addalieny siervier (Remote) da schovišča",
|
||||
@@ -515,8 +516,23 @@
|
||||
"editor.toolbar.delete": "Vydalić",
|
||||
"editor.toolbar.revealFile": "Pakazać u Finder",
|
||||
"editor.toolbar.copyFilePath": "Kapijavać šliach",
|
||||
"editor.toolbar.copyNoteDeepLink": "Kapijavać deep-link natatki",
|
||||
"editor.toolbar.moreActions": "Boĺš dziejanniaŭ",
|
||||
"editor.toolbar.openProperties": "Adkryć ulascivasci",
|
||||
"filePreview.copyDeepLink": "Kapijavać spasylku",
|
||||
"deepLinks.copied": "Deep-link skapijavany",
|
||||
"deepLinks.error.ambiguousVault": "Deep-link adpaviadaje niekaĺkim schoviščam. Pierajmienujcie alias adnaho schovišča ŭ Naladach.",
|
||||
"deepLinks.error.copyFailed": "Nie ŭdalosia skapijavać deep-link: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Heta nie deep-link Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Deep-link niapraviĺna sfarbavany.",
|
||||
"deepLinks.error.missingFile": "Meta deep-link niedastupnaja ŭ hetym schoviščy.",
|
||||
"deepLinks.error.missingPath": "U deep-link niama šliachu da elementa schovišča.",
|
||||
"deepLinks.error.missingVault": "U deep-link niama schovišča.",
|
||||
"deepLinks.error.openFailed": "Nie ŭdalosia adkryć deep-link: {detail}",
|
||||
"deepLinks.error.outsideVault": "Hety element niemahčyma spaslać, bo jon pa-za miežami schovišča.",
|
||||
"deepLinks.error.unavailableVault": "Schovišča z deep-link niedastupnaje na hetaj pryladzie.",
|
||||
"deepLinks.error.unknownVault": "Deep-link viadzie da nieviadomaha schovišča.",
|
||||
"deepLinks.error.unsafePath": "Šliach deep-link nie moža vychodzić za miežy schovišča.",
|
||||
"tableOfContents.title": "Zmiest",
|
||||
"tableOfContents.close": "Zakryć zmiest",
|
||||
"tableOfContents.empty": "Niama zahaloŭkaŭ",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Notizsymbol entfernen",
|
||||
"command.note.changeType": "Notiztyp ändern …",
|
||||
"command.note.moveToFolder": "Notiz in Ordner verschieben …",
|
||||
"command.note.copyDeepLink": "Deep-Link zum aktuellen Element kopieren",
|
||||
"command.note.openNewWindow": "In neuem Fenster öffnen",
|
||||
"command.git.initialize": "Git für den aktuellen Vault initialisieren",
|
||||
"command.git.commitPush": "Commit & Push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Diese Notiz löschen",
|
||||
"editor.toolbar.revealFile": "Im Finder anzeigen",
|
||||
"editor.toolbar.copyFilePath": "Dateipfad kopieren",
|
||||
"editor.toolbar.copyNoteDeepLink": "Deeplink der Notiz kopieren",
|
||||
"editor.toolbar.moreActions": "Weitere Notizaktionen",
|
||||
"editor.toolbar.openProperties": "Eigenschaften-Panel öffnen",
|
||||
"filePreview.copyDeepLink": "Link kopieren",
|
||||
"deepLinks.copied": "Deep Link kopiert",
|
||||
"deepLinks.error.ambiguousVault": "Der Deep Link stimmt mit mehr als einem Vault überein. Benennen Sie einen Vault-Alias in den Einstellungen um.",
|
||||
"deepLinks.error.copyFailed": "Deep Link konnte nicht kopiert werden: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Dies ist kein Tolaria-Deep-Link.",
|
||||
"deepLinks.error.malformedUrl": "Der Deep Link ist fehlerhaft.",
|
||||
"deepLinks.error.missingFile": "Das Deep-Link-Ziel ist in diesem Vault nicht verfügbar.",
|
||||
"deepLinks.error.missingPath": "Dem Deep Link fehlt ein Pfad zu einem Vault-Element.",
|
||||
"deepLinks.error.missingVault": "Dem Deep Link fehlt ein Vault.",
|
||||
"deepLinks.error.openFailed": "Fehler beim Öffnen des Deep Links: {detail}",
|
||||
"deepLinks.error.outsideVault": "Dieses Element kann nicht verknüpft werden, da es sich außerhalb des Vaults befindet.",
|
||||
"deepLinks.error.unavailableVault": "Der Deep-Link-Tresor ist auf diesem Gerät nicht verfügbar.",
|
||||
"deepLinks.error.unknownVault": "Der Deep Link verweist auf einen unbekannten Vault.",
|
||||
"deepLinks.error.unsafePath": "Der Deep-Link-Pfad darf den Vault nicht verlassen.",
|
||||
"editor.slash.math": "Mathematik",
|
||||
"tableOfContents.title": "Inhaltsverzeichnis",
|
||||
"tableOfContents.close": "Inhaltsverzeichnis schließen",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Remove Note Icon",
|
||||
"command.note.changeType": "Change Note Type…",
|
||||
"command.note.moveToFolder": "Move Note to Folder…",
|
||||
"command.note.copyDeepLink": "Copy deep link to current item",
|
||||
"command.note.openNewWindow": "Open in New Window",
|
||||
"command.git.initialize": "Initialize Git for Current Vault",
|
||||
"command.git.commitPush": "Commit & Push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Delete this note",
|
||||
"editor.toolbar.revealFile": "Reveal in Finder",
|
||||
"editor.toolbar.copyFilePath": "Copy file path",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copy note deeplink",
|
||||
"editor.toolbar.moreActions": "More note actions",
|
||||
"editor.toolbar.openProperties": "Open the properties panel",
|
||||
"filePreview.copyDeepLink": "Copy link",
|
||||
"deepLinks.copied": "Deep link copied",
|
||||
"deepLinks.error.ambiguousVault": "Deep link matches more than one vault. Rename one vault alias in Settings.",
|
||||
"deepLinks.error.copyFailed": "Failed to copy deep link: {detail}",
|
||||
"deepLinks.error.invalidScheme": "This is not a Tolaria deep link.",
|
||||
"deepLinks.error.malformedUrl": "Deep link is malformed.",
|
||||
"deepLinks.error.missingFile": "Deep link target is not available in this vault.",
|
||||
"deepLinks.error.missingPath": "Deep link is missing a vault item path.",
|
||||
"deepLinks.error.missingVault": "Deep link is missing a vault.",
|
||||
"deepLinks.error.openFailed": "Failed to open deep link: {detail}",
|
||||
"deepLinks.error.outsideVault": "This item cannot be linked because it is outside the vault.",
|
||||
"deepLinks.error.unavailableVault": "Deep link vault is not available on this device.",
|
||||
"deepLinks.error.unknownVault": "Deep link targets an unknown vault.",
|
||||
"deepLinks.error.unsafePath": "Deep link path cannot leave the vault.",
|
||||
"editor.slash.math": "Math",
|
||||
"tableOfContents.title": "Table of Contents",
|
||||
"tableOfContents.close": "Close table of contents",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Eliminar ícono de nota",
|
||||
"command.note.changeType": "Cambiar tipo de nota…",
|
||||
"command.note.moveToFolder": "Mover nota a la carpeta…",
|
||||
"command.note.copyDeepLink": "Copiar el enlace profundo al elemento actual",
|
||||
"command.note.openNewWindow": "Abrir en una ventana nueva",
|
||||
"command.git.initialize": "Inicializar Git para el repositorio actual",
|
||||
"command.git.commitPush": "Confirmar y enviar",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Eliminar esta nota",
|
||||
"editor.toolbar.revealFile": "Mostrar en el Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar el enlace profundo de la nota",
|
||||
"editor.toolbar.moreActions": "Más acciones de nota",
|
||||
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
|
||||
"filePreview.copyDeepLink": "Copiar enlace",
|
||||
"deepLinks.copied": "Enlace profundo copiado",
|
||||
"deepLinks.error.ambiguousVault": "El enlace profundo coincide con más de una bóveda. Cambie el nombre de un alias de bóveda en Configuración.",
|
||||
"deepLinks.error.copyFailed": "Error al copiar el enlace profundo: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Este no es un enlace profundo de Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "El enlace profundo tiene un formato incorrecto.",
|
||||
"deepLinks.error.missingFile": "El destino del enlace profundo no está disponible en esta bóveda.",
|
||||
"deepLinks.error.missingPath": "Al enlace profundo le falta una ruta de elemento del repositorio.",
|
||||
"deepLinks.error.missingVault": "Al enlace profundo le falta un repositorio.",
|
||||
"deepLinks.error.openFailed": "Error al abrir el enlace profundo: {detail}",
|
||||
"deepLinks.error.outsideVault": "Este elemento no se puede vincular porque está fuera de la bóveda.",
|
||||
"deepLinks.error.unavailableVault": "El repositorio del enlace profundo no está disponible en este dispositivo.",
|
||||
"deepLinks.error.unknownVault": "El enlace profundo apunta a una bóveda desconocida.",
|
||||
"deepLinks.error.unsafePath": "La ruta del enlace profundo no puede salir de la bóveda.",
|
||||
"editor.slash.math": "Matemáticas",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Cerrar índice",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Eliminar icono de nota",
|
||||
"command.note.changeType": "Cambiar tipo de nota…",
|
||||
"command.note.moveToFolder": "Mover nota a la carpeta…",
|
||||
"command.note.copyDeepLink": "Copiar el enlace profundo al elemento actual",
|
||||
"command.note.openNewWindow": "Abrir en una ventana nueva",
|
||||
"command.git.initialize": "Inicializar Git para el almacén actual",
|
||||
"command.git.commitPush": "Confirmar y enviar",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Eliminar esta nota",
|
||||
"editor.toolbar.revealFile": "Mostrar en el Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar el enlace profundo de la nota",
|
||||
"editor.toolbar.moreActions": "Más acciones de nota",
|
||||
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
|
||||
"filePreview.copyDeepLink": "Copiar enlace",
|
||||
"deepLinks.copied": "Enlace profundo copiado",
|
||||
"deepLinks.error.ambiguousVault": "El enlace profundo coincide con más de un almacén. Cambia el nombre de un alias de almacén en Ajustes.",
|
||||
"deepLinks.error.copyFailed": "Error al copiar el enlace profundo: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Este no es un enlace profundo de Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "El enlace profundo tiene un formato incorrecto.",
|
||||
"deepLinks.error.missingFile": "El destino del enlace profundo no está disponible en este almacén.",
|
||||
"deepLinks.error.missingPath": "Al enlace profundo le falta una ruta de elemento del almacén.",
|
||||
"deepLinks.error.missingVault": "Al enlace profundo le falta un almacén.",
|
||||
"deepLinks.error.openFailed": "Error al abrir el enlace profundo: {detail}",
|
||||
"deepLinks.error.outsideVault": "Este elemento no se puede vincular porque está fuera del almacén.",
|
||||
"deepLinks.error.unavailableVault": "El almacén del enlace profundo no está disponible en este dispositivo.",
|
||||
"deepLinks.error.unknownVault": "El enlace profundo apunta a un almacén desconocido.",
|
||||
"deepLinks.error.unsafePath": "La ruta del enlace profundo no puede salir de la caja fuerte.",
|
||||
"editor.slash.math": "Matemáticas",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Cerrar el índice",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Supprimer l'icône de la note",
|
||||
"command.note.changeType": "Modifier le type de note…",
|
||||
"command.note.moveToFolder": "Déplacer la note vers le dossier…",
|
||||
"command.note.copyDeepLink": "Copier le lien profond vers l'élément actuel",
|
||||
"command.note.openNewWindow": "Ouvrir dans une nouvelle fenêtre",
|
||||
"command.git.initialize": "Initialiser Git pour le coffre-fort actuel",
|
||||
"command.git.commitPush": "Valider et pousser",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Supprimer cette note",
|
||||
"editor.toolbar.revealFile": "Afficher dans le Finder",
|
||||
"editor.toolbar.copyFilePath": "Copier le chemin du fichier",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copier le deeplink de la note",
|
||||
"editor.toolbar.moreActions": "Autres actions de note",
|
||||
"editor.toolbar.openProperties": "Ouvrir le panneau des propriétés",
|
||||
"filePreview.copyDeepLink": "Copier le lien",
|
||||
"deepLinks.copied": "Lien profond copié",
|
||||
"deepLinks.error.ambiguousVault": "Le lien profond correspond à plusieurs coffres. Renommez un alias de coffre-fort dans les Paramètres.",
|
||||
"deepLinks.error.copyFailed": "Échec de la copie du lien profond : {detail}",
|
||||
"deepLinks.error.invalidScheme": "Ce n'est pas un lien profond Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Le lien profond est mal formé.",
|
||||
"deepLinks.error.missingFile": "La cible du lien profond n'est pas disponible dans ce coffre.",
|
||||
"deepLinks.error.missingPath": "Il manque un chemin d'élément de coffre-fort dans le lien profond.",
|
||||
"deepLinks.error.missingVault": "Il manque un coffre au lien profond.",
|
||||
"deepLinks.error.openFailed": "Échec de l'ouverture du lien profond : {detail}",
|
||||
"deepLinks.error.outsideVault": "Cet élément ne peut pas être lié, car il se trouve en dehors du coffre-fort.",
|
||||
"deepLinks.error.unavailableVault": "Le coffre-fort du lien profond n'est pas disponible sur cet appareil.",
|
||||
"deepLinks.error.unknownVault": "Le lien profond cible un coffre inconnu.",
|
||||
"deepLinks.error.unsafePath": "Le chemin du lien profond ne peut pas sortir du coffre.",
|
||||
"editor.slash.math": "Mathématiques",
|
||||
"tableOfContents.title": "Table des matières",
|
||||
"tableOfContents.close": "Fermer la table des matières",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Rimuovi icona nota",
|
||||
"command.note.changeType": "Modifica tipo di nota…",
|
||||
"command.note.moveToFolder": "Sposta nota nella cartella…",
|
||||
"command.note.copyDeepLink": "Copia il deep link all'elemento corrente",
|
||||
"command.note.openNewWindow": "Apri in una nuova finestra",
|
||||
"command.git.initialize": "Inizializza Git per il vault corrente",
|
||||
"command.git.commitPush": "Commit e push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Elimina questa nota",
|
||||
"editor.toolbar.revealFile": "Mostra nel Finder",
|
||||
"editor.toolbar.copyFilePath": "Copia il percorso del file",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copia deeplink della nota",
|
||||
"editor.toolbar.moreActions": "Altre azioni della nota",
|
||||
"editor.toolbar.openProperties": "Apri il pannello delle proprietà",
|
||||
"filePreview.copyDeepLink": "Copia link",
|
||||
"deepLinks.copied": "Deep link copiato",
|
||||
"deepLinks.error.ambiguousVault": "Il deep link corrisponde a più di un vault. Rinomina un alias del vault in Impostazioni.",
|
||||
"deepLinks.error.copyFailed": "Copia del deep link non riuscita: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Questo non è un deep link di Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Il deep link non è valido.",
|
||||
"deepLinks.error.missingFile": "La destinazione del deep link non è disponibile in questo vault.",
|
||||
"deepLinks.error.missingPath": "Nel deep link manca il percorso di un elemento del vault.",
|
||||
"deepLinks.error.missingVault": "Nel deep link manca un vault.",
|
||||
"deepLinks.error.openFailed": "Impossibile aprire il deep link: {detail}",
|
||||
"deepLinks.error.outsideVault": "Questo elemento non può essere collegato perché si trova al di fuori del vault.",
|
||||
"deepLinks.error.unavailableVault": "Il vault del deep link non è disponibile su questo dispositivo.",
|
||||
"deepLinks.error.unknownVault": "Il deep link punta a un vault sconosciuto.",
|
||||
"deepLinks.error.unsafePath": "Il percorso del deep link non può uscire dal vault.",
|
||||
"editor.slash.math": "Matematica",
|
||||
"tableOfContents.title": "Indice",
|
||||
"tableOfContents.close": "Chiudi l'indice",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "ノートアイコンを削除",
|
||||
"command.note.changeType": "ノートの種類を変更…",
|
||||
"command.note.moveToFolder": "ノートをフォルダーに移動…",
|
||||
"command.note.copyDeepLink": "現在のアイテムへのディープリンクをコピー",
|
||||
"command.note.openNewWindow": "新しいウィンドウで開く",
|
||||
"command.git.initialize": "現在のボールトでGitを初期化",
|
||||
"command.git.commitPush": "コミット & プッシュ",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "このノートを削除",
|
||||
"editor.toolbar.revealFile": "Finderで表示",
|
||||
"editor.toolbar.copyFilePath": "ファイルパスをコピー",
|
||||
"editor.toolbar.copyNoteDeepLink": "ノートのディープリンクをコピー",
|
||||
"editor.toolbar.moreActions": "その他のノート操作",
|
||||
"editor.toolbar.openProperties": "プロパティパネルを開く",
|
||||
"filePreview.copyDeepLink": "リンクをコピー",
|
||||
"deepLinks.copied": "ディープリンクをコピーしました",
|
||||
"deepLinks.error.ambiguousVault": "ディープリンクが複数のボールトに一致しています。設定で1つのVaultエイリアスの名前を変更してください。",
|
||||
"deepLinks.error.copyFailed": "ディープリンクのコピーに失敗しました:{detail}",
|
||||
"deepLinks.error.invalidScheme": "これはTolariaのディープリンクではありません。",
|
||||
"deepLinks.error.malformedUrl": "ディープリンクの形式が正しくありません。",
|
||||
"deepLinks.error.missingFile": "ディープリンクのターゲットは、このボールトでは利用できません。",
|
||||
"deepLinks.error.missingPath": "ディープリンクにボールトアイテムのパスがありません。",
|
||||
"deepLinks.error.missingVault": "ディープリンクにボールトがありません。",
|
||||
"deepLinks.error.openFailed": "ディープリンクを開けませんでした:{detail}",
|
||||
"deepLinks.error.outsideVault": "このアイテムはボルト外にあるため、リンクできません。",
|
||||
"deepLinks.error.unavailableVault": "このデバイスではディープリンクのボールトは利用できません。",
|
||||
"deepLinks.error.unknownVault": "ディープリンクのターゲットは不明なボールトです。",
|
||||
"deepLinks.error.unsafePath": "ディープリンクのパスは、ボールトの外に出ることはできません。",
|
||||
"editor.slash.math": "数学",
|
||||
"tableOfContents.title": "目次",
|
||||
"tableOfContents.close": "目次を閉じる",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "노트 아이콘 제거",
|
||||
"command.note.changeType": "노트 유형 변경…",
|
||||
"command.note.moveToFolder": "노트를 폴더로 이동…",
|
||||
"command.note.copyDeepLink": "현재 항목에 대한 딥 링크 복사",
|
||||
"command.note.openNewWindow": "새 창에서 열기",
|
||||
"command.git.initialize": "현재 Vault에 대해 Git 초기화",
|
||||
"command.git.commitPush": "커밋 및 푸시",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "이 노트 삭제",
|
||||
"editor.toolbar.revealFile": "Finder에서 표시",
|
||||
"editor.toolbar.copyFilePath": "파일 경로 복사",
|
||||
"editor.toolbar.copyNoteDeepLink": "노트 딥링크 복사",
|
||||
"editor.toolbar.moreActions": "추가 노트 작업",
|
||||
"editor.toolbar.openProperties": "속성 패널 열기",
|
||||
"filePreview.copyDeepLink": "링크 복사",
|
||||
"deepLinks.copied": "딥 링크 복사됨",
|
||||
"deepLinks.error.ambiguousVault": "딥 링크가 둘 이상의 보관소와 일치합니다. 설정에서 Vault 별칭 하나를 변경하세요.",
|
||||
"deepLinks.error.copyFailed": "딥 링크 복사에 실패했습니다: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Tolaria 딥 링크가 아닙니다.",
|
||||
"deepLinks.error.malformedUrl": "딥 링크 형식이 잘못되었습니다.",
|
||||
"deepLinks.error.missingFile": "이 보관소에서는 딥 링크 대상을 사용할 수 없습니다.",
|
||||
"deepLinks.error.missingPath": "딥 링크에 볼트 항목 경로가 없습니다.",
|
||||
"deepLinks.error.missingVault": "딥 링크에 볼트가 없습니다.",
|
||||
"deepLinks.error.openFailed": "딥 링크 열기 실패: {detail}",
|
||||
"deepLinks.error.outsideVault": "이 항목은 볼트 외부에 있으므로 연결할 수 없습니다.",
|
||||
"deepLinks.error.unavailableVault": "이 장치에서는 딥 링크 보관을 사용할 수 없습니다.",
|
||||
"deepLinks.error.unknownVault": "딥 링크가 알 수 없는 볼트를 대상으로 합니다.",
|
||||
"deepLinks.error.unsafePath": "딥 링크 경로는 볼트를 벗어날 수 없습니다.",
|
||||
"editor.slash.math": "수학",
|
||||
"tableOfContents.title": "목차",
|
||||
"tableOfContents.close": "목차 닫기",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Usuń ikonę notatki",
|
||||
"command.note.changeType": "Zmień typ notatki…",
|
||||
"command.note.moveToFolder": "Przenieś notatkę do folderu…",
|
||||
"command.note.copyDeepLink": "Skopiuj deep link do bieżącego elementu",
|
||||
"command.note.openNewWindow": "Otwórz w nowym oknie",
|
||||
"command.git.initialize": "Zainicjalizuj Git dla bieżącego sejfu",
|
||||
"command.git.commitPush": "Zatwierdź i wypchnij",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Usuń tę notatkę",
|
||||
"editor.toolbar.revealFile": "Pokaż w Finderze",
|
||||
"editor.toolbar.copyFilePath": "Kopiuj ścieżkę pliku",
|
||||
"editor.toolbar.copyNoteDeepLink": "Skopiuj deeplink do notatki",
|
||||
"editor.toolbar.moreActions": "Więcej działań notatki",
|
||||
"editor.toolbar.openProperties": "Otwórz panel właściwości",
|
||||
"filePreview.copyDeepLink": "Kopiuj link",
|
||||
"deepLinks.copied": "Głęboki link skopiowany",
|
||||
"deepLinks.error.ambiguousVault": "Głęboki link pasuje do więcej niż jednego skarbca. Zmień nazwę jednego aliasu skarbca w Ustawieniach.",
|
||||
"deepLinks.error.copyFailed": "Nie udało się skopiować deep linku: {detail}",
|
||||
"deepLinks.error.invalidScheme": "To nie jest deep link Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Głęboki link jest nieprawidłowy.",
|
||||
"deepLinks.error.missingFile": "Cel deep linku nie jest dostępny w tym skarbcu.",
|
||||
"deepLinks.error.missingPath": "W deep linku brakuje ścieżki do elementu w skarbcu.",
|
||||
"deepLinks.error.missingVault": "W deep linku brakuje skarbca.",
|
||||
"deepLinks.error.openFailed": "Nie udało się otworzyć deep linku: {detail}",
|
||||
"deepLinks.error.outsideVault": "Nie można utworzyć linku do tego elementu, ponieważ znajduje się on poza skarbcem.",
|
||||
"deepLinks.error.unavailableVault": "Magazyn deep linków nie jest dostępny na tym urządzeniu.",
|
||||
"deepLinks.error.unknownVault": "Głęboki link wskazuje na nieznany skarbiec.",
|
||||
"deepLinks.error.unsafePath": "Ścieżka deep linku nie może wychodzić poza skarbiec.",
|
||||
"editor.slash.math": "Matematyka",
|
||||
"tableOfContents.title": "Spis treści",
|
||||
"tableOfContents.close": "Zamknij spis treści",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Remover ícone da nota",
|
||||
"command.note.changeType": "Alterar tipo de nota…",
|
||||
"command.note.moveToFolder": "Mover nota para a pasta…",
|
||||
"command.note.copyDeepLink": "Copiar deep link para o item atual",
|
||||
"command.note.openNewWindow": "Abrir em nova janela",
|
||||
"command.git.initialize": "Inicializar o Git para o cofre atual",
|
||||
"command.git.commitPush": "Fazer commit e push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Excluir esta nota",
|
||||
"editor.toolbar.revealFile": "Exibir no Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar caminho do arquivo",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar deeplink da nota",
|
||||
"editor.toolbar.moreActions": "Mais ações da nota",
|
||||
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
|
||||
"filePreview.copyDeepLink": "Copiar link",
|
||||
"deepLinks.copied": "Deep link copiado",
|
||||
"deepLinks.error.ambiguousVault": "O deep link corresponde a mais de um cofre. Renomeie um alias de cofre em Configurações.",
|
||||
"deepLinks.error.copyFailed": "Falha ao copiar o deep link: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Este não é um deep link da Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "O deep link está malformado.",
|
||||
"deepLinks.error.missingFile": "O destino do deep link não está disponível neste cofre.",
|
||||
"deepLinks.error.missingPath": "Falta um caminho de item do cofre no deep link.",
|
||||
"deepLinks.error.missingVault": "Falta um cofre no deep link.",
|
||||
"deepLinks.error.openFailed": "Falha ao abrir o deep link: {detail}",
|
||||
"deepLinks.error.outsideVault": "Este item não pode ser vinculado porque está fora do cofre.",
|
||||
"deepLinks.error.unavailableVault": "O cofre do deep link não está disponível neste dispositivo.",
|
||||
"deepLinks.error.unknownVault": "O deep link aponta para um cofre desconhecido.",
|
||||
"deepLinks.error.unsafePath": "O caminho do deep link não pode sair do cofre.",
|
||||
"editor.slash.math": "Matemática",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Fechar índice",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Remover ícone da nota",
|
||||
"command.note.changeType": "Alterar tipo de nota…",
|
||||
"command.note.moveToFolder": "Mover nota para a pasta…",
|
||||
"command.note.copyDeepLink": "Copiar deep link para o item atual",
|
||||
"command.note.openNewWindow": "Abrir numa nova janela",
|
||||
"command.git.initialize": "Inicializar o Git para o cofre atual",
|
||||
"command.git.commitPush": "Fazer commit e push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Eliminar esta nota",
|
||||
"editor.toolbar.revealFile": "Mostrar no Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar caminho do ficheiro",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar deeplink da nota",
|
||||
"editor.toolbar.moreActions": "Mais ações da nota",
|
||||
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
|
||||
"filePreview.copyDeepLink": "Copiar link",
|
||||
"deepLinks.copied": "Deep link copiado",
|
||||
"deepLinks.error.ambiguousVault": "O deep link corresponde a mais de um cofre. Mude o nome de um alias de cofre em Definições.",
|
||||
"deepLinks.error.copyFailed": "Falha ao copiar o deep link: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Esta não é uma ligação profunda da Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "O deep link está malformado.",
|
||||
"deepLinks.error.missingFile": "O destino do deep link não está disponível neste cofre.",
|
||||
"deepLinks.error.missingPath": "Falta um caminho de item do cofre no deep link.",
|
||||
"deepLinks.error.missingVault": "Falta um cofre no deep link.",
|
||||
"deepLinks.error.openFailed": "Falha ao abrir a ligação profunda: {detail}",
|
||||
"deepLinks.error.outsideVault": "Este item não pode ser ligado porque está fora do cofre.",
|
||||
"deepLinks.error.unavailableVault": "O cofre da ligação profunda não está disponível neste dispositivo.",
|
||||
"deepLinks.error.unknownVault": "O deep link aponta para um cofre desconhecido.",
|
||||
"deepLinks.error.unsafePath": "O caminho do deep link não pode sair do cofre.",
|
||||
"editor.slash.math": "Matemática",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Fechar índice",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Удалить значок заметки",
|
||||
"command.note.changeType": "Изменить тип заметки…",
|
||||
"command.note.moveToFolder": "Переместить заметку в папку…",
|
||||
"command.note.copyDeepLink": "Копировать глубокую ссылку на текущий элемент",
|
||||
"command.note.openNewWindow": "Открыть в новом окне",
|
||||
"command.git.initialize": "Инициализировать Git для текущего хранилища",
|
||||
"command.git.commitPush": "Commit и Push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Удалить эту заметку",
|
||||
"editor.toolbar.revealFile": "Показать в Finder",
|
||||
"editor.toolbar.copyFilePath": "Копировать путь к файлу",
|
||||
"editor.toolbar.copyNoteDeepLink": "Копировать диплинк заметки",
|
||||
"editor.toolbar.moreActions": "Другие действия с заметкой",
|
||||
"editor.toolbar.openProperties": "Открыть панель свойств",
|
||||
"filePreview.copyDeepLink": "Копировать ссылку",
|
||||
"deepLinks.copied": "Глубокая ссылка скопирована",
|
||||
"deepLinks.error.ambiguousVault": "Глубокая ссылка соответствует нескольким хранилищам. Переименуйте один псевдоним хранилища в настройках.",
|
||||
"deepLinks.error.copyFailed": "Не удалось скопировать диплинк: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Это не глубокая ссылка Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Неправильный формат глубокой ссылки.",
|
||||
"deepLinks.error.missingFile": "Цель глубокой ссылки недоступна в этом хранилище.",
|
||||
"deepLinks.error.missingPath": "В глубокой ссылке отсутствует путь к элементу хранилища.",
|
||||
"deepLinks.error.missingVault": "В глубокой ссылке отсутствует хранилище.",
|
||||
"deepLinks.error.openFailed": "Не удалось открыть глубокую ссылку: {detail}",
|
||||
"deepLinks.error.outsideVault": "Этот элемент нельзя связать, поскольку он находится за пределами хранилища.",
|
||||
"deepLinks.error.unavailableVault": "Хранилище для глубокой ссылки недоступно на этом устройстве.",
|
||||
"deepLinks.error.unknownVault": "Глубинная ссылка указывает на неизвестное хранилище.",
|
||||
"deepLinks.error.unsafePath": "Путь глубокой ссылки не может выходить за пределы хранилища.",
|
||||
"editor.slash.math": "Математика",
|
||||
"tableOfContents.title": "Содержание",
|
||||
"tableOfContents.close": "Закрыть оглавление",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "Xóa biểu tượng ghi chú",
|
||||
"command.note.changeType": "Đổi loại ghi chú…",
|
||||
"command.note.moveToFolder": "Di chuyển ghi chú vào thư mục…",
|
||||
"command.note.copyDeepLink": "Sao chép liên kết sâu đến mục hiện tại",
|
||||
"command.note.openNewWindow": "Mở trong cửa sổ mới",
|
||||
"command.git.initialize": "Khởi tạo Git cho kho hiện tại",
|
||||
"command.git.commitPush": "Commit & Push",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "Xóa ghi chú này",
|
||||
"editor.toolbar.revealFile": "Hiển thị trong Finder",
|
||||
"editor.toolbar.copyFilePath": "Sao chép đường dẫn tệp",
|
||||
"editor.toolbar.copyNoteDeepLink": "Sao chép liên kết sâu của ghi chú",
|
||||
"editor.toolbar.moreActions": "Thêm hành động ghi chú",
|
||||
"editor.toolbar.openProperties": "Mở bảng thuộc tính",
|
||||
"filePreview.copyDeepLink": "Sao chép liên kết",
|
||||
"deepLinks.copied": "Đã sao chép liên kết sâu",
|
||||
"deepLinks.error.ambiguousVault": "Liên kết sâu khớp với nhiều hơn một vault. Đổi tên một bí danh vault trong phần Cài đặt.",
|
||||
"deepLinks.error.copyFailed": "Không thể sao chép liên kết sâu: {detail}",
|
||||
"deepLinks.error.invalidScheme": "Đây không phải là liên kết sâu của Tolaria.",
|
||||
"deepLinks.error.malformedUrl": "Liên kết sâu không đúng định dạng.",
|
||||
"deepLinks.error.missingFile": "Mục tiêu của liên kết sâu không có trong kho này.",
|
||||
"deepLinks.error.missingPath": "Liên kết sâu thiếu đường dẫn mục trong kho lưu trữ.",
|
||||
"deepLinks.error.missingVault": "Liên kết sâu thiếu kho.",
|
||||
"deepLinks.error.openFailed": "Không thể mở liên kết sâu: {detail}",
|
||||
"deepLinks.error.outsideVault": "Không thể liên kết mục này vì nó nằm ngoài kho.",
|
||||
"deepLinks.error.unavailableVault": "Kho liên kết sâu không khả dụng trên thiết bị này.",
|
||||
"deepLinks.error.unknownVault": "Liên kết sâu trỏ đến một vault không xác định.",
|
||||
"deepLinks.error.unsafePath": "Đường dẫn liên kết sâu không thể nằm ngoài vault.",
|
||||
"editor.slash.math": "Toán học",
|
||||
"tableOfContents.title": "Mục lục",
|
||||
"tableOfContents.close": "Đóng mục lục",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "移除笔记图标",
|
||||
"command.note.changeType": "更改笔记类型…",
|
||||
"command.note.moveToFolder": "将笔记移动到文件夹…",
|
||||
"command.note.copyDeepLink": "复制当前项目的深层链接",
|
||||
"command.note.openNewWindow": "在新窗口中打开",
|
||||
"command.git.initialize": "为当前仓库初始化 Git",
|
||||
"command.git.commitPush": "提交并推送",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "删除这条笔记",
|
||||
"editor.toolbar.revealFile": "在访达中显示",
|
||||
"editor.toolbar.copyFilePath": "复制文件路径",
|
||||
"editor.toolbar.copyNoteDeepLink": "复制笔记深层链接",
|
||||
"editor.toolbar.moreActions": "更多笔记操作",
|
||||
"editor.toolbar.openProperties": "打开属性面板",
|
||||
"filePreview.copyDeepLink": "复制链接",
|
||||
"deepLinks.copied": "深层链接已复制",
|
||||
"deepLinks.error.ambiguousVault": "深层链接匹配多个保管库。在“设置”中重命名一个保管库别名。",
|
||||
"deepLinks.error.copyFailed": "复制深层链接失败:{detail}",
|
||||
"deepLinks.error.invalidScheme": "这不是 Tolaria 深层链接。",
|
||||
"deepLinks.error.malformedUrl": "深层链接格式不正确。",
|
||||
"deepLinks.error.missingFile": "此保管库中没有深层链接目标。",
|
||||
"deepLinks.error.missingPath": "深层链接缺少保管库项目路径。",
|
||||
"deepLinks.error.missingVault": "深层链接缺少保管库。",
|
||||
"deepLinks.error.openFailed": "无法打开深层链接:{detail}",
|
||||
"deepLinks.error.outsideVault": "此项目无法链接,因为它不在保管库中。",
|
||||
"deepLinks.error.unavailableVault": "此设备上没有深层链接保管库。",
|
||||
"deepLinks.error.unknownVault": "深层链接指向未知的保管库。",
|
||||
"deepLinks.error.unsafePath": "深层链接路径不能离开保管库。",
|
||||
"editor.slash.math": "数学",
|
||||
"tableOfContents.title": "目录",
|
||||
"tableOfContents.close": "关闭目录",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"command.note.removeIcon": "移除筆記圖示",
|
||||
"command.note.changeType": "更改筆記型別…",
|
||||
"command.note.moveToFolder": "將筆記移動到資料夾…",
|
||||
"command.note.copyDeepLink": "複製目前項目的深層連結",
|
||||
"command.note.openNewWindow": "在新視窗中開啟",
|
||||
"command.git.initialize": "為當前倉庫初始化 Git",
|
||||
"command.git.commitPush": "提交併推送",
|
||||
@@ -513,8 +514,23 @@
|
||||
"editor.toolbar.delete": "刪除這條筆記",
|
||||
"editor.toolbar.revealFile": "在訪達中顯示",
|
||||
"editor.toolbar.copyFilePath": "複製檔案路徑",
|
||||
"editor.toolbar.copyNoteDeepLink": "複製筆記深層連結",
|
||||
"editor.toolbar.moreActions": "更多筆記操作",
|
||||
"editor.toolbar.openProperties": "開啟屬性面板",
|
||||
"filePreview.copyDeepLink": "複製連結",
|
||||
"deepLinks.copied": "已複製深層連結",
|
||||
"deepLinks.error.ambiguousVault": "深層連結與多個保管庫相符。在「設定」中重新命名一個 Vault 別名。",
|
||||
"deepLinks.error.copyFailed": "無法複製深層連結:{detail}",
|
||||
"deepLinks.error.invalidScheme": "這不是 Tolaria 深層連結。",
|
||||
"deepLinks.error.malformedUrl": "深層連結格式不正確。",
|
||||
"deepLinks.error.missingFile": "此保管庫中沒有深層連結目標。",
|
||||
"deepLinks.error.missingPath": "深層連結缺少保管庫項目路徑。",
|
||||
"deepLinks.error.missingVault": "深層連結缺少保管庫。",
|
||||
"deepLinks.error.openFailed": "無法開啟深層連結:{detail}",
|
||||
"deepLinks.error.outsideVault": "此項目無法連結,因為它不在保管庫中。",
|
||||
"deepLinks.error.unavailableVault": "此裝置無法使用深層連結保管庫。",
|
||||
"deepLinks.error.unknownVault": "深層連結指向未知的保管庫。",
|
||||
"deepLinks.error.unsafePath": "深層連結路徑不能離開保管庫。",
|
||||
"editor.slash.math": "數學",
|
||||
"tableOfContents.title": "目錄",
|
||||
"tableOfContents.close": "關閉目錄",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { NoteWidthMode } from '../types'
|
||||
import type { ThemeMode } from './themeMode'
|
||||
|
||||
type TrackedPreviewKind = FilePreviewKind | 'unsupported'
|
||||
type FilePreviewAction = 'copy_path' | 'open_external' | 'reveal'
|
||||
type FilePreviewAction = 'copy_deep_link' | 'copy_path' | 'open_external' | 'reveal'
|
||||
type AgentBlockedReason = 'agent_unavailable' | 'missing_vault'
|
||||
type AiWorkspaceMode = 'docked' | 'window'
|
||||
type AiWorkspaceTitleSource = 'generated' | 'manual'
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
export interface LaputaTestBridge {
|
||||
activeTabPath?: string | null
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
openDeepLink?: (url: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
|
||||
123
src/utils/deepLinks.test.ts
Normal file
123
src/utils/deepLinks.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTolariaDeepLinkForEntry,
|
||||
parseTolariaDeepLink,
|
||||
relativePathForVaultItem,
|
||||
resolveTolariaDeepLink,
|
||||
vaultDeepLinkSlug,
|
||||
type DeepLinkVault,
|
||||
} from './deepLinks'
|
||||
|
||||
const workVault: DeepLinkVault = {
|
||||
label: 'Work Vault',
|
||||
path: '/Users/luca/Work Vault',
|
||||
}
|
||||
|
||||
const personalVault: DeepLinkVault = {
|
||||
label: 'Personal Vault',
|
||||
path: '/Users/luca/Personal Vault',
|
||||
}
|
||||
|
||||
describe('Tolaria deep links', () => {
|
||||
it('builds readable links with extensions and encoded path segments', () => {
|
||||
const result = buildTolariaDeepLinkForEntry({
|
||||
entry: { path: '/Users/luca/Work Vault/sponsorships/Acme call #1.md' },
|
||||
vaultPath: workVault.path,
|
||||
vaults: [workVault],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
url: 'tolaria://work-vault/sponsorships/Acme%20call%20%231.md',
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips unicode and URL-reserved path characters', () => {
|
||||
const url = 'tolaria://work-vault/books/Caf%C3%A9%20%26%20notes%3F%25.md'
|
||||
expect(parseTolariaDeepLink({ rawUrl: url })).toEqual({
|
||||
ok: true,
|
||||
relativePath: 'books/Café & notes?%.md',
|
||||
slug: 'work-vault',
|
||||
url,
|
||||
})
|
||||
})
|
||||
|
||||
it('appends stable path hashes when vault slugs collide', () => {
|
||||
const first = { label: 'Work', path: '/Users/luca/One' }
|
||||
const second = { label: 'Work', path: '/Users/luca/Two' }
|
||||
|
||||
const firstSlug = vaultDeepLinkSlug(first, [first, second])
|
||||
const secondSlug = vaultDeepLinkSlug(second, [first, second])
|
||||
|
||||
expect(firstSlug).toMatch(/^work-[a-z0-9]{6}$/)
|
||||
expect(secondSlug).toMatch(/^work-[a-z0-9]{6}$/)
|
||||
expect(firstSlug).not.toBe(secondSlug)
|
||||
})
|
||||
|
||||
it('resolves generated collision-safe slugs without opening the wrong vault', () => {
|
||||
const first = { label: 'Work', path: '/Users/luca/One' }
|
||||
const second = { label: 'Work', path: '/Users/luca/Two' }
|
||||
const slug = vaultDeepLinkSlug(second, [first, second])
|
||||
|
||||
expect(resolveTolariaDeepLink({ rawUrl: `tolaria://${slug}/note.md`, vaults: [first, second] })).toEqual({
|
||||
ok: true,
|
||||
absolutePath: '/Users/luca/Two/note.md',
|
||||
relativePath: 'note.md',
|
||||
vault: second,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ambiguous handwritten base slugs', () => {
|
||||
const first = { label: 'Work', path: '/Users/luca/One' }
|
||||
const second = { label: 'Work', path: '/Users/luca/Two' }
|
||||
|
||||
expect(resolveTolariaDeepLink({ rawUrl: 'tolaria://work/note.md', vaults: [first, second] })).toEqual({
|
||||
ok: false,
|
||||
error: 'ambiguous_vault',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unknown and unavailable vaults', () => {
|
||||
expect(resolveTolariaDeepLink({ rawUrl: 'tolaria://missing/note.md', vaults: [workVault] })).toEqual({
|
||||
ok: false,
|
||||
error: 'unknown_vault',
|
||||
})
|
||||
expect(resolveTolariaDeepLink({
|
||||
rawUrl: 'tolaria://work-vault/note.md',
|
||||
vaults: [{ ...workVault, available: false }],
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
error: 'unavailable_vault',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects path traversal and encoded separators', () => {
|
||||
expect(parseTolariaDeepLink({ rawUrl: 'tolaria://work-vault/../secret.md' })).toEqual({
|
||||
ok: false,
|
||||
error: 'unsafe_path',
|
||||
})
|
||||
expect(parseTolariaDeepLink({ rawUrl: 'tolaria://work-vault/folder%2Fsecret.md' })).toEqual({
|
||||
ok: false,
|
||||
error: 'unsafe_path',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires target files to stay inside a known vault root', () => {
|
||||
expect(relativePathForVaultItem({
|
||||
itemPath: '/Users/luca/Work Vault/docs/adr/0129.md',
|
||||
vaultPath: workVault.path,
|
||||
})).toBe('docs/adr/0129.md')
|
||||
expect(relativePathForVaultItem({
|
||||
itemPath: '/Users/luca/Work Vaults/docs/adr/0129.md',
|
||||
vaultPath: workVault.path,
|
||||
})).toBeNull()
|
||||
expect(buildTolariaDeepLinkForEntry({
|
||||
entry: { path: '/Users/luca/Personal Vault/note.md' },
|
||||
vaultPath: workVault.path,
|
||||
vaults: [workVault, personalVault],
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
error: 'outside_vault',
|
||||
})
|
||||
})
|
||||
})
|
||||
244
src/utils/deepLinks.ts
Normal file
244
src/utils/deepLinks.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import { joinVaultPath, normalizeNotePathForCollision, normalizeNotePathForIdentity, normalizeNotePathSeparators } from './notePathIdentity'
|
||||
import { workspaceAliasFromOption } from './workspaces'
|
||||
|
||||
export const TOLARIA_DEEP_LINK_SCHEME = 'tolaria'
|
||||
|
||||
export interface DeepLinkVault {
|
||||
alias?: string | null
|
||||
available?: boolean | null
|
||||
label?: string | null
|
||||
path: string
|
||||
}
|
||||
|
||||
export type DeepLinkParseError = 'invalid_scheme' | 'missing_vault' | 'missing_path' | 'malformed_url' | 'unsafe_path'
|
||||
export type DeepLinkOpenError = DeepLinkParseError | 'unknown_vault' | 'ambiguous_vault' | 'unavailable_vault' | 'missing_file'
|
||||
export type DeepLinkBuildError = 'unknown_vault' | 'unavailable_vault' | 'outside_vault' | 'unsafe_path'
|
||||
|
||||
export type ParsedTolariaDeepLink =
|
||||
| { ok: true; relativePath: string; slug: string; url: string }
|
||||
| { ok: false; error: DeepLinkParseError }
|
||||
|
||||
export type ResolvedTolariaDeepLink =
|
||||
| { ok: true; absolutePath: string; relativePath: string; vault: DeepLinkVault }
|
||||
| { ok: false; error: DeepLinkOpenError }
|
||||
|
||||
export type BuiltTolariaDeepLink =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; error: DeepLinkBuildError }
|
||||
|
||||
interface TolariaDeepLinkInput {
|
||||
rawUrl: string
|
||||
}
|
||||
|
||||
interface VaultRelativePathInput {
|
||||
path: string
|
||||
}
|
||||
|
||||
interface VaultItemPathInput {
|
||||
itemPath: string
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface VaultPathLookupInput {
|
||||
vaultPath: string
|
||||
vaults: readonly DeepLinkVault[]
|
||||
}
|
||||
|
||||
interface VaultSlugLookupInput {
|
||||
slug: string
|
||||
vaults: readonly DeepLinkVault[]
|
||||
}
|
||||
|
||||
interface TolariaDeepLinkResolveInput extends TolariaDeepLinkInput {
|
||||
vaults: readonly DeepLinkVault[]
|
||||
}
|
||||
|
||||
interface TolariaDeepLinkBuildInput extends VaultPathLookupInput {
|
||||
entry: Pick<VaultEntry, 'path'>
|
||||
}
|
||||
|
||||
interface VaultSlugEntry {
|
||||
baseSlug: string
|
||||
slug: string
|
||||
vault: DeepLinkVault
|
||||
}
|
||||
|
||||
function normalizedVaultPath(vault: Pick<DeepLinkVault, 'path'>): string {
|
||||
return normalizeNotePathForCollision(vault.path)
|
||||
}
|
||||
|
||||
function uniqueVaults(vaults: readonly DeepLinkVault[]): DeepLinkVault[] {
|
||||
const byPath = new Map<string, DeepLinkVault>()
|
||||
for (const vault of vaults) {
|
||||
const key = normalizedVaultPath(vault)
|
||||
if (!key || byPath.has(key)) continue
|
||||
byPath.set(key, vault)
|
||||
}
|
||||
return [...byPath.values()]
|
||||
}
|
||||
|
||||
function stablePathHash({ path }: Pick<DeepLinkVault, 'path'>): string {
|
||||
let hash = 0x811c9dc5
|
||||
for (const char of normalizeNotePathForCollision(path)) {
|
||||
hash ^= char.codePointAt(0) ?? 0
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||
}
|
||||
return hash.toString(36).padStart(6, '0').slice(-6)
|
||||
}
|
||||
|
||||
function baseSlugForVault(vault: DeepLinkVault): string {
|
||||
return workspaceAliasFromOption({
|
||||
alias: vault.alias ?? vault.label ?? '',
|
||||
label: vault.label ?? '',
|
||||
path: vault.path,
|
||||
})
|
||||
}
|
||||
|
||||
export function vaultDeepLinkSlug(vault: DeepLinkVault, allVaults: readonly DeepLinkVault[]): string {
|
||||
const baseSlug = baseSlugForVault(vault)
|
||||
const matchingBaseSlugs = uniqueVaults(allVaults)
|
||||
.filter((candidate) => baseSlugForVault(candidate) === baseSlug)
|
||||
return matchingBaseSlugs.length > 1 ? `${baseSlug}-${stablePathHash(vault)}` : baseSlug
|
||||
}
|
||||
|
||||
function vaultSlugEntries(vaults: readonly DeepLinkVault[]): VaultSlugEntry[] {
|
||||
const unique = uniqueVaults(vaults)
|
||||
return unique.map((vault) => ({
|
||||
baseSlug: baseSlugForVault(vault),
|
||||
slug: vaultDeepLinkSlug(vault, unique),
|
||||
vault,
|
||||
}))
|
||||
}
|
||||
|
||||
function encodeRelativePath({ path }: VaultRelativePathInput): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
function decodeRelativePath({ path }: VaultRelativePathInput): string | null {
|
||||
const pathname = path
|
||||
const encodedPath = pathname.replace(/^\/+/u, '')
|
||||
if (!encodedPath) return null
|
||||
|
||||
const segments = encodedPath.split('/')
|
||||
const decodedSegments: string[] = []
|
||||
for (const segment of segments) {
|
||||
if (!segment) return null
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment)
|
||||
if (!isSafePathSegment({ segment: decoded })) return null
|
||||
decodedSegments.push(decoded)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return decodedSegments.join('/')
|
||||
}
|
||||
|
||||
function rawPathnameForTolariaUrl({ rawUrl }: TolariaDeepLinkInput): string | null {
|
||||
return rawUrl.match(/^tolaria:\/\/[^/?#]+(\/[^?#]*)/iu)?.[1] ?? null
|
||||
}
|
||||
|
||||
function isSafePathSegment({ segment }: { segment: string }): boolean {
|
||||
return segment.length > 0
|
||||
&& segment !== '.'
|
||||
&& segment !== '..'
|
||||
&& !segment.includes('/')
|
||||
&& !segment.includes('\\')
|
||||
}
|
||||
|
||||
export function isSafeVaultRelativePath({ path }: VaultRelativePathInput): boolean {
|
||||
const normalized = normalizeNotePathSeparators(path).replace(/^\/+|\/+$/gu, '')
|
||||
if (!normalized) return false
|
||||
return normalized.split('/').every((segment) => isSafePathSegment({ segment }))
|
||||
}
|
||||
|
||||
export function relativePathForVaultItem({ itemPath, vaultPath }: VaultItemPathInput): string | null {
|
||||
const normalizedItemPath = normalizeNotePathForIdentity(itemPath)
|
||||
const normalizedVaultPath = normalizeNotePathForIdentity(vaultPath)
|
||||
const vaultPrefix = `${normalizedVaultPath.replace(/\/+$/u, '')}/`
|
||||
if (!normalizedItemPath.toLocaleLowerCase().startsWith(vaultPrefix.toLocaleLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const relativePath = normalizedItemPath.slice(vaultPrefix.length)
|
||||
return isSafeVaultRelativePath({ path: relativePath }) ? relativePath : null
|
||||
}
|
||||
|
||||
function findVaultByPath({ vaultPath, vaults }: VaultPathLookupInput): DeepLinkVault | undefined {
|
||||
const targetPath = normalizeNotePathForCollision(vaultPath)
|
||||
return uniqueVaults(vaults).find((vault) => normalizedVaultPath(vault) === targetPath)
|
||||
}
|
||||
|
||||
function resolveVaultForSlug({ slug, vaults }: VaultSlugLookupInput): ResolvedTolariaDeepLink | DeepLinkVault {
|
||||
const normalizedSlug = slug.trim().toLocaleLowerCase()
|
||||
const entries = vaultSlugEntries(vaults)
|
||||
const exactSlugMatches = entries.filter((entry) => entry.slug === normalizedSlug)
|
||||
if (exactSlugMatches.length > 1) return { ok: false, error: 'ambiguous_vault' }
|
||||
if (exactSlugMatches.length === 1) return resolvedAvailableVault(exactSlugMatches[0].vault)
|
||||
|
||||
const baseSlugMatches = entries.filter((entry) => entry.baseSlug === normalizedSlug)
|
||||
if (baseSlugMatches.length > 1) return { ok: false, error: 'ambiguous_vault' }
|
||||
if (baseSlugMatches.length === 1) return resolvedAvailableVault(baseSlugMatches[0].vault)
|
||||
return { ok: false, error: 'unknown_vault' }
|
||||
}
|
||||
|
||||
function resolvedAvailableVault(vault: DeepLinkVault): DeepLinkVault | ResolvedTolariaDeepLink {
|
||||
return vault.available === false ? { ok: false, error: 'unavailable_vault' } : vault
|
||||
}
|
||||
|
||||
export function parseTolariaDeepLink({ rawUrl }: TolariaDeepLinkInput): ParsedTolariaDeepLink {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl)
|
||||
} catch {
|
||||
return { ok: false, error: 'malformed_url' }
|
||||
}
|
||||
|
||||
if (parsed.protocol !== `${TOLARIA_DEEP_LINK_SCHEME}:`) return { ok: false, error: 'invalid_scheme' }
|
||||
if (!parsed.hostname) return { ok: false, error: 'missing_vault' }
|
||||
|
||||
const rawPathname = rawPathnameForTolariaUrl({ rawUrl })
|
||||
if (!rawPathname) return { ok: false, error: 'missing_path' }
|
||||
|
||||
const relativePath = decodeRelativePath({ path: rawPathname })
|
||||
if (!relativePath) return { ok: false, error: 'unsafe_path' }
|
||||
return { ok: true, relativePath, slug: parsed.hostname, url: rawUrl }
|
||||
}
|
||||
|
||||
export function resolveTolariaDeepLink({ rawUrl, vaults }: TolariaDeepLinkResolveInput): ResolvedTolariaDeepLink {
|
||||
const parsed = parseTolariaDeepLink({ rawUrl })
|
||||
if (!parsed.ok) return parsed
|
||||
|
||||
const resolvedVault = resolveVaultForSlug({ slug: parsed.slug, vaults })
|
||||
if ('ok' in resolvedVault) return resolvedVault
|
||||
return {
|
||||
ok: true,
|
||||
absolutePath: joinVaultPath(resolvedVault.path, parsed.relativePath),
|
||||
relativePath: parsed.relativePath,
|
||||
vault: resolvedVault,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTolariaDeepLinkForEntry({
|
||||
entry,
|
||||
vaultPath,
|
||||
vaults,
|
||||
}: TolariaDeepLinkBuildInput): BuiltTolariaDeepLink {
|
||||
const vault = findVaultByPath({ vaultPath, vaults })
|
||||
if (!vault) return { ok: false, error: 'unknown_vault' }
|
||||
if (vault.available === false) return { ok: false, error: 'unavailable_vault' }
|
||||
|
||||
const relativePath = relativePathForVaultItem({ itemPath: entry.path, vaultPath: vault.path })
|
||||
if (!relativePath) return { ok: false, error: 'outside_vault' }
|
||||
if (!isSafeVaultRelativePath({ path: relativePath })) return { ok: false, error: 'unsafe_path' }
|
||||
|
||||
const slug = vaultDeepLinkSlug(vault, vaults)
|
||||
return {
|
||||
ok: true,
|
||||
url: `${TOLARIA_DEEP_LINK_SCHEME}://${slug}/${encodeRelativePath({ path: relativePath })}`,
|
||||
}
|
||||
}
|
||||
63
tests/smoke/deep-link-routing.spec.ts
Normal file
63
tests/smoke/deep-link-routing.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
|
||||
import { openDeepLink } from './testBridge'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
async function installClipboardCapture(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
;(window as Window & { __tolariaCopiedText?: string }).__tolariaCopiedText = text
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function copiedText(page: Page): Promise<string> {
|
||||
return page.evaluate(() => (window as Window & { __tolariaCopiedText?: string }).__tolariaCopiedText ?? '')
|
||||
}
|
||||
|
||||
async function openNoteWithQuickOpen(page: Page, title: string, expectedFilename: string): Promise<void> {
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await page.locator('input[placeholder="Search notes..."]').fill(title)
|
||||
await expect(page.getByTestId('quick-open-palette').getByText(title, { exact: true })).toBeVisible({ timeout: 5_000 })
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(expectedFilename, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
test('command palette copies and opens a Tolaria item deep link', async ({ page }) => {
|
||||
await installClipboardCapture(page)
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
|
||||
await openNoteWithQuickOpen(page, 'Alpha Project', 'alpha-project')
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Copy deep link to current item')
|
||||
|
||||
const deepLink = await copiedText(page)
|
||||
expect(deepLink).toBe('tolaria://test-vault/project/alpha-project.md')
|
||||
|
||||
await openNoteWithQuickOpen(page, 'Note B', 'note-b')
|
||||
await openDeepLink(page, deepLink)
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 })
|
||||
|
||||
await openDeepLink(page, 'tolaria://missing-vault/project/alpha-project.md')
|
||||
await expect(page.getByText('Deep link targets an unknown vault.')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
@@ -71,6 +71,16 @@ export async function seedAutoGitSavedChange(page: Page): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function openDeepLink(page: Page, url: string): Promise<void> {
|
||||
await page.evaluate((deepLinkUrl) => {
|
||||
const bridge = window.__laputaTest?.openDeepLink
|
||||
if (typeof bridge !== 'function') {
|
||||
throw new Error('Tolaria test bridge is missing openDeepLink')
|
||||
}
|
||||
bridge(deepLinkUrl)
|
||||
}, url)
|
||||
}
|
||||
|
||||
export async function dispatchShortcutEvent(
|
||||
page: Page,
|
||||
init: AppCommandShortcutEventInit,
|
||||
|
||||
Reference in New Issue
Block a user