Compare commits

...
Author SHA1 Message Date
Yuri Karamian 32f270ce9e Update v1.3.5 download documentation 2026-08-27 00:37:30 +02:00
Yuri Karamian a15b267614 Add scroll font size preference and bump v1.3.5 2026-08-26 17:16:16 +02:00
Yuri Karamian ce48d77cd8 fix: stop wiki links extending after autocomplete 2026-08-23 15:38:57 +02:00
Yuri Karamian 221625aabd fix: override vulnerable cookie dependency 2026-08-17 21:17:53 +02:00
Yuri Karamian 1bd4cd8138 chore: set Rust MSRV to 1.88 2026-08-17 21:15:11 +02:00
Yuri Karamian dd748776b2 test: add local verification command 2026-08-17 21:08:31 +02:00
Yuri Karamian 0391a60c1b fix: block private image proxy requests 2026-08-17 21:06:50 +02:00
Yuri Karamian 33d77cf2fd fix: restrict external file access 2026-08-17 21:02:05 +02:00
Yuri Karamian 81e4e26290 fix: restrict asset protocol to vaults 2026-08-17 20:14:34 +02:00
Yuri Karamian 111d819f92 fix: improve editor accessibility 2026-08-17 19:55:00 +02:00
Yuri Karamian c37818de1a fix: improve settings accessibility 2026-08-17 19:49:05 +02:00
Yuri Karamian 95dab8cf74 fix: improve note list accessibility 2026-08-17 19:46:50 +02:00
Yuri Karamian 62365f52b1 fix: mark resize handles presentational 2026-08-17 19:45:19 +02:00
Yuri Karamian 6eb1ec6eb2 fix: improve sidebar interactions 2026-08-17 19:44:35 +02:00
Yuri Karamian 527c6a7a91 fix: label storage location choices 2026-08-17 19:43:31 +02:00
Yuri Karamian 45e3c389e4 fix: handle info overlay clicks 2026-08-17 19:42:47 +02:00
Yuri Karamian 9ccbf3aff5 fix: make layout references reactive 2026-08-17 19:31:21 +02:00
Yuri Karamian 695664913a fix: make graph state reactive 2026-08-17 19:28:48 +02:00
Yuri Karamian d0fb87cf3a style: format Rust code 2026-08-17 19:27:05 +02:00
Yuri Karamian ac7c79af04 fix: resolve Rust lints 2026-08-17 19:24:32 +02:00
Yuri Karamian 1ebf2a9843 fix: resolve type errors 2026-08-17 19:04:21 +02:00
Yuri Karamian 44dcfbe24a fix: tighten app permissions 2026-08-17 18:57:56 +02:00
Yuri Karamian 00196ecedb fix: protect config files 2026-08-17 18:53:53 +02:00
Yuri Karamian c4d2d1b2d1 fix: validate version paths 2026-08-17 18:52:40 +02:00
Yuri Karamian 3a9a5129c4 fix: restrict backup paths 2026-08-17 18:50:57 +02:00
Yuri Karamian a964ee1930 fix: restrict vault paths 2026-08-17 18:49:32 +02:00
Yuri Karamian 2d57577338 fix: update Rust dependencies 2026-08-17 18:43:11 +02:00
Yuri Karamian 206faed113 fix: update frontend dependencies 2026-08-17 18:37:57 +02:00
Yuri Karamian b3c292d6e2 test(paths): cover cross-platform asset resolution 2026-08-17 18:35:26 +02:00
Yuri Karamian 43f15962f7 Revert "chore(release): prepare v1.3.5"
This reverts commit ade146f9f3.
2026-08-17 17:03:01 +02:00
Yuri Karamian ade146f9f3 chore(release): prepare v1.3.5 2026-08-17 16:50:14 +02:00
Yuri Karamian b253ec4121 fix(paths): preserve cross-platform asset paths 2026-08-17 15:58:20 +02:00
Yuri Karamian c4a91b8f1c fix(sorting): sort numeric names naturally 2026-08-15 16:18:22 +02:00
Yuri Karamian 87c879bcac fix(notebooks): normalize icon paths across platforms 2026-08-15 01:40:14 +02:00
Yuri Karamian 27609fb4f3 Fix Windows internal note links 2026-08-11 02:41:39 +02:00
Yuri Karamian b0555fe648 Add Clear Formatting action to editor context menu 2026-08-08 23:53:27 +02:00
Yuri Karamian 32ef51161d Scroll code block into view after fence input rule 2026-08-07 21:50:34 +02:00
Yuri Karamian 23abd0a19a Add Maintenance tab for vault statistics and cleanup 2026-08-07 17:12:15 +02:00
Yuri Karamian 5f726eb01f Move orphaned attachment cleanup from About to Settings 2026-08-07 17:01:41 +02:00
Yuri Karamian 73f7604187 Speed up note switcher and sync window scaling 2026-08-07 16:51:06 +02:00
Yuri Karamian 7ca226d918 Merge remote-tracking branch 'origin/main' 2026-08-07 01:06:32 +02:00
Yuri Karamian 1cbdf704bd Add opt-in title bar note switcher 2026-08-07 00:56:54 +02:00
Yuri Karamian b5bd5ea151 Document Linux installation options 2026-08-07 00:48:40 +02:00
Yuri Karamian bdf94e2494 Keep Note Info open across note changes 2026-08-06 15:13:43 +02:00
Yuri Karamian 1950c4cc3c Fix system theme pair integration 2026-08-06 15:12:43 +02:00
Yuri Karamian aeddcc6e58 Merge branch 'system-theme-pair' into 'main' 2026-08-06 14:07:34 +02:00
Yuri Karamian bb9d01f57c Merge branch 'custom-theme-derived-vars' into 'main' 2026-08-06 14:03:37 +02:00
Yuri Karamian ac25dfd773 Move note trash action into Note Info 2026-08-06 14:01:47 +02:00
Hadrian Chio e61c66dc26 Add light and dark theme pair for the system appearance setting 2026-08-05 09:24:06 +02:00
Hadrian Chio 3e8aa7f10d Fix custom themes falling back to light divider and tertiary text colors 2026-08-05 08:57:55 +02:00
Yuri Karamian 2dd0985ea9 Add Android image pinch zoom (Closes #115) 2026-08-03 16:56:38 +02:00
Yuri Karamian 18bb6bb702 Fix Linux startup theme flash (#195) 2026-08-03 15:54:17 +02:00
Yuri Karamian fcf48a5b7a Fix Markdown mark round-trips (#219) 2026-08-03 14:04:49 +02:00
Yuri Karamian c7906f4069 Keep source mode last in editor toolbar 2026-08-03 13:31:54 +02:00
Yuri Karamian e6e1ed1ed6 Add note duplication (#24) 2026-08-03 13:23:18 +02:00
Yuri Karamian 1a880a4f96 Add open-note trash action (#177) 2026-08-03 10:45:59 +02:00
Yuri Karamian f4e05ed9c4 Add Markdown source highlighting (#173) 2026-08-03 09:00:43 +02:00
Yuri Karamian 4a1045b41e Preserve collapsible code blocks (#138) 2026-08-03 03:26:06 +02:00
Yuri Karamian c305b5f8dd Add Android camera image capture (#131) 2026-08-03 03:13:04 +02:00
Yuri Karamian b5962af466 Add collapsible section shortcut (#129) 2026-08-03 02:56:42 +02:00
Yuri Karamian 13e83b6689 Restore note scroll positions (#127) 2026-08-03 02:43:40 +02:00
Yuri Karamian 432e4f472a Keep local tests out of repository 2026-08-03 02:31:55 +02:00
Yuri Karamian 8731ca4ec1 Support mixed nested lists (#39) 2026-08-03 02:26:39 +02:00
Yuri Karamian 6b165059cc Refine note info path and history 2026-08-03 02:03:58 +02:00
Yuri Karamian 213797a38e Fix mobile outline navigation (#109) 2026-08-03 01:53:29 +02:00
Yuri Karamian fc3e388b80 Refine custom icon size guidance 2026-08-03 01:53:25 +02:00
Yuri Karamian 1c0359e540 Add custom icon size guidance 2026-08-03 01:33:38 +02:00
Yuri Karamian da859e581b Copy active note path from Info panel 2026-08-03 01:16:30 +02:00
Yuri Karamian 763e673738 Show vault location in Info panel 2026-08-03 01:12:50 +02:00
Yuri Karamian 550d3ab3e9 Add per-notebook icons and sync 2026-08-03 01:05:07 +02:00
Yuri Karamian e62404d4c0 Fix dragging selected notes to notebooks 2026-08-03 00:27:18 +02:00
Yuri Karamian 54e051ba02 Add configurable startup view 2026-08-03 00:12:55 +02:00
Yuri Karamian 344728dbe8 Fix macOS traffic lights after window state changes
Reapply the native traffic-light alignment after resize, focus, scale, and theme events so AppKit window-state transitions cannot leave the controls at their default position.
2026-08-02 23:53:26 +02:00
Yuri Karamian 818e9ad82a Merge branch 'nix-build-fix' into 'main'
fix(nix): Update flake.lock and increment fetcher version

Closes #230

See merge request ArkHost/HelixNotes!1
2026-08-02 21:41:20 +00:00
jervw 4931ee3fab fix(nix): Update flake.lock and increment fetcher version 2026-08-02 21:41:20 +00:00
Yuri Karamian 65e09e8fdd fix(mobile): expose notebook actions 2026-07-25 17:51:33 +02:00
Yuri Karamian 64e521d549 fix(ios): poll vault changes to avoid file descriptor exhaustion 2026-07-25 17:14:42 +02:00
Yuri Karamian b8308d89e8 Update README downloads for v1.3.4 2026-07-24 22:38:47 +02:00
Yuri Karamian 0e5b380f8c Sync detached window typography 2026-07-24 20:55:57 +02:00
Yuri Karamian e76919ab5c Fix mobile navigation and Tasks layout 2026-07-24 20:55:57 +02:00
Yuri Karamian 84ab90fd3f Bump version to 1.3.4 2026-07-24 19:44:21 +02:00
Yuri Karamian 87b191eb63 Fix Android adaptive icon padding 2026-07-24 19:44:21 +02:00
Yuri Karamian 0def9d6d6d Fix iOS vault plugin registration 2026-07-24 16:57:20 +02:00
Yuri Karamian e708d8ee63 Fix macOS traffic light vertical alignment 2026-07-24 16:42:20 +02:00
Yuri Karamian 461001442a Fix macOS traffic light vertical alignment 2026-07-24 16:38:06 +02:00
Yuri Karamian 6b5e923e8e Fix macOS traffic light alignment 2026-07-24 16:31:38 +02:00
Yuri Karamian 289606a746 Fix macOS AppKit build 2026-07-24 16:14:48 +02:00
Yuri Karamian 9c53b3b594 Fix iOS Files vault transitions 2026-07-24 15:54:12 +02:00
Yuri Karamian d497ff8aaf Add iOS Files vault selection 2026-07-24 02:19:00 +02:00
Yuri Karamian 51acd51cb2 Expose iOS vault in Files 2026-07-24 00:44:15 +02:00
Yuri Karamian b4bb3b89d6 Update project links for GitLab migration 2026-07-23 17:58:44 +02:00
Yuri Karamian 9b51aef073 Reveal selected tasks in editor 2026-07-20 13:09:35 +02:00
Yuri Karamian 4aa0b200b9 Fix Windows relative image resolution (#215) 2026-07-20 02:47:11 +02:00
Yuri Karamian 8769b56fd1 Fix strikethrough keyboard shortcut (#205) 2026-07-20 02:42:32 +02:00
Yuri Karamian 06d2cd744f Refine scrollbars and editor spacing (#209) 2026-07-20 02:30:29 +02:00
Yuri Karamian 7428cc7dfb shorten comments 2026-07-20 02:20:14 +02:00
Yuri Karamian 6c89d668fe Fix table sizing and headerless table persistence (#213) 2026-07-20 02:17:38 +02:00
Yuri Karamian 2c76a4747e Fix mobile notebook navigation and sidebar clipping (#220) 2026-07-20 02:08:36 +02:00
Yuri Karamian fd55c00015 Fix note unfiling and relative image paths (#222) 2026-07-20 02:03:15 +02:00
Yuri Karamian 28ef37f354 Fix mobile safe-area layout 2026-07-15 12:00:54 +02:00
Chris Paganon bf5a9f134d fix(editor): refocus open in-note search on Ctrl+F (#216)
Pressing ctrl-F doesn’t refocus the search field if there is already a search term, it only focuses search on 1st open of the search field. This PR fixes it so that every time the user presses CTRL+F, the search field is focused again.

Tested on linux.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/216
2026-07-14 18:36:34 +02:00
Yuri Karamian 2991940ba8 fix: remove transparency from iOS app icons 2026-07-13 20:43:23 +02:00
Yuri Karamian 54352a03d8 Mermaid diagram "Copy failed: The operation is insecure" on Linux 2026-07-10 14:09:56 +02:00
Yuri Karamian c4d1f12b2e fix: use vault-relative paths in wikilinks for cross-device sync 2026-07-10 11:30:06 +02:00
Yuri Karamian aa699007f7 Revert "Exclude gen/ from Cargo package to fix iOS build fingerprint error"
This reverts commit 047a8b3c3d.
2026-07-09 17:08:14 +02:00
Yuri Karamian 047a8b3c3d Exclude gen/ from Cargo package to fix iOS build fingerprint error 2026-07-09 16:56:58 +02:00
Yuri Karamian 6098dd01b4 Add macOS notarization entitlements + iOS mobile guards
- Add entitlements.plist for Developer ID hardened runtime (JIT,
  unsigned memory, library validation, network client)
- Reference entitlements in tauri.conf.json bundle.macOS
- Guard get_install_type() with early mobile return (avoids
  std::process::Command in iOS binary)
- Add cfg(mobile) no-op branch to xdg_open()
2026-07-09 16:46:59 +02:00
Yuri Karamian b16beb9af5 regenerate icons set 2026-07-09 16:24:21 +02:00
mf 95565d7506 fix(editor): scale code block font size with editor zoom (#208)
Code blocks used a hardcoded 13px font-size instead of scaling with --editor-font-size, so they didn't resize with the rest of the editor content when zooming.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/208
2026-07-07 12:04:49 +02:00
lokthare a13d17ed81 Change nix formatter (#206)
the files were formatted with alejandra. the nix community has set a standard for the format and release a new tool.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/206
2026-07-06 15:53:20 +02:00
Yuri Karamian 95515cac9d Properly center macOS traffic lights via objc2 (#169) 2026-07-05 23:59:08 +02:00
Yuri Karamian 21dd2d1681 Improve sidebar notebook/unfiled icons and list indentation 2026-07-05 02:45:55 +02:00
Yuri Karamian 6d5f12c47c Improve sidebar notebook/unfiled icons and list indentation 2026-07-04 20:23:15 +02:00
Andreas Lenhardt d01bd4ea01 update max_token value in ai tests (#204)
#203

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/204
2026-07-04 14:31:07 +02:00
lokthare bd7d66b1e8 Fix nix build (#202)
fix nix build by updating flake.lock and dependencies hashes

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/202
2026-07-04 11:53:56 +02:00
Yuri Karamian 0b9fe69819 Revert PR #200 (custom theme consolidation) - broke search 2026-07-03 16:42:09 +02:00
Dmitry Rubtsov 936c38a21d Fix custom theme not applied on startup (light-theme dividers) (#200)
Launching with a custom dark theme left every divider in light-theme colors until you re-toggled the theme in Settings.

`applyTheme` was copy-pasted across the components and the copies drifted. Three of them (+page.svelte's onMount block, AppLayout.svelte, NoteWindow.svelte) had no custom-* branch, so for a custom theme they stripped the dark class and data-theme attribute, matched nothing, and fell back to the bare light :root values.

`+layout.svelte` applied the custom theme correctly, but `AppLayout` — the always-mounted shell — re-runs its own broken copy from an `$effect` on `$theme`, immediately wiping it out. Dividers gave it away because custom themes override `--border-color` but not `--border-light`, which dividers use.

Fix: consolidate into a single `applyTheme`(which already owns darkThemes/isDarkTheme) with a real `custom-*` branch, and have every call site use it and pass the loaded custom themes so `custom-*` ids resolve.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/200
2026-07-03 13:34:11 +02:00
Yuri Karamian e60e637437 Fix macOS traffic light vertical centering (#169) 2026-06-30 22:16:19 +02:00
Yuri Karamian 479c957698 Robust Obsidian importer: frontmatter normalization, syntax conversion, deprecated property renaming, same-note heading links 2026-06-30 22:10:22 +02:00
Yuri Karamian 13fd70f944 Fix Android note creation delay: throttle file watcher IPC (#191) 2026-06-30 19:48:19 +02:00
Yuri Karamian 015726f896 Add inline restore/delete icons on trashed notes (#188) 2026-06-30 16:41:06 +02:00
Yuri Karamian 5e2c31598c Block note creation in Quick Access/Trash, add icons to trash actions (#188) 2026-06-30 16:13:22 +02:00
Yuri Karamian 24d1ba9f8f Block note creation in Quick Access and Trash views (#188) 2026-06-30 15:59:35 +02:00
Yuri Karamian 5345442de2 Auto-cleanup orphaned attachments on vault open (#190) 2026-06-30 15:54:37 +02:00
Yuri Karamian 6ea2ca0ae2 Keep textarea selection visible when opening AI menu in source mode 2026-06-30 14:40:28 +02:00
Yuri Karamian 6f4f526d07 Fix OpenAI test using max_tokens for GPT-5 models 2026-06-30 14:36:20 +02:00
Yuri Karamian 13bad87500 Fix AI apply-to-note in source mode (#189) 2026-06-30 14:29:44 +02:00
Yuri Karamian acecc9c68d Fix callout body paragraphs merging on reload (#192) 2026-06-30 14:09:25 +02:00
Yuri Karamian 35a8501f2d Fix mobile popup positioning using visualViewport (#193) 2026-06-30 12:51:41 +02:00
Yuri Karamian 09f9df968d Render callouts live on Enter and paste in WYSIWYG (#186) 2026-06-30 12:37:14 +02:00
Yuri Karamian 2c58bf3d9a Fix empty lines round-trip and trim trailing markers (#185) 2026-06-30 11:01:25 +02:00
Yuri Karamian e063f1c73a Clamp formatting-bar dropdown menus into the viewport (#178) 2026-06-28 19:36:16 +02:00
Yuri Karamian cafc0ce1ec Reveal search result in its notebook in the notes list 2026-06-28 19:21:14 +02:00
Yuri Karamian a192723fbd Refresh outline panel when switching notes (#184) 2026-06-28 19:12:29 +02:00
Yuri Karamian b643eeb884 Gate set_mobile_config_dir behind cfg(mobile) to fix desktop dead-code warning (#183) 2026-06-28 19:08:01 +02:00
Yuri Karamian eb042e5760 Tolerate HTTP 403 in MKCOL for non-compliant WebDAV servers (#91) 2026-06-28 19:04:52 +02:00
Yuri Karamian 6251d96193 docs: bump README download links to 1.3.3 2026-06-28 14:49:54 +02:00
Yuri Karamian db92dabba5 chore: bump version to 1.3.3 2026-06-28 13:31:49 +02:00
Yuri Karamian 2469a971ca WebDAV sync: make config per-vault so switching vaults no longer cross-contaminates remotes; migrate existing global config to the active vault 2026-06-27 11:33:07 +02:00
Yuri Karamian a43bf05bf0 WebDAV sync: make config per-vault so switching vaults no longer cross-contaminates remotes; migrate existing global config
to the active vault
2026-06-27 01:08:02 +02:00
blake ffd9ebad7e The outline side panel now persists when a user clicks in the editor. (#176)
WIP

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/176
2026-06-27 00:00:16 +02:00
mf d2100c8500 Create notes from empty list double-click (#175)
Double-clicking empty space in the notes list now creates a new note. The notebook sidebar double-click shortcut is scoped to the notebook section so adjacent panes do not trigger notebook creation.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/175
2026-06-26 23:59:54 +02:00
Yuri Karamian 7e63d721bc Fix 'Open in New Window' cursor jumping to the top when pausing (ignore the file-watcher echo of the window's own save) 2026-06-25 16:08:25 +02:00
Yuri Karamian 2c774c51c6 Notebook reorder: make path math separator-agnostic so manual sort works on Windows (backslash paths) 2026-06-25 15:10:20 +02:00
Yuri Karamian 8eba4527af Notebook reorder: commit drop on any release event and skip pointer capture on mouse, so it works in WebView2 (Windows) 2026-06-25 14:55:59 +02:00
Yuri Karamian 181b3eb6c2 Notebook reorder: track drag on window pointer events so it works in WebView2 (Windows) 2026-06-25 14:47:06 +02:00
Yuri Karamian 879d2f42c6 Notebook reorder via pointer-event drag handle (fixes Windows/Android); click body selects, chevron expands 2026-06-25 13:31:49 +02:00
Yuri Karamian 85ea26b57a Notebook reorder: use pointer events via a drag handle so manual sort works on Windows and Android 2026-06-25 13:29:24 +02:00
Yuri Karamian 800c81e598 Add ! menu on task lines for setting priority and due date 2026-06-25 12:57:51 +02:00
Yuri Karamian 30c3523722 Parallelize vault-stats and tasks scanning for faster Info/Tasks loads on Android 2026-06-25 12:37:32 +02:00
Yuri Karamian 761bf5b42e Android: add an Info button to the top bar that opens the About panel 2026-06-25 12:37:12 +02:00
Yuri Karamian b10a7b1bfc Fix typing stutter in large notes by debouncing per-keystroke document scans 2026-06-25 12:09:06 +02:00
blake 313d86df23 Show all existing tags when tag input is empty (#165)
WIP: Previously suggestions only appeared after typing; now all unused tags can be shown immediately when the tag menu opens.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/165
2026-06-25 11:45:53 +02:00
Yuri Karamian 95627cbb5c Fix ordered list markers and empty-callout placeholder on WebKitGTK 2026-06-25 11:43:08 +02:00
Yuri Karamian b9ea14f330 README: document the macOS damaged-app fix (xattr) 2026-06-25 11:42:53 +02:00
Yuri Karamian 812d025a9d Fix ordered list markers showing only the last digit on WebKitGTK 2026-06-25 00:33:58 +02:00
Yuri Karamian 11cc2daa4b Parallelize notebook and note scanning for faster loads on Android 2026-06-24 23:28:28 +02:00
Blake Boone 5c36f2aa71 Added collapsable info panel that tracks word/character count, snapshots, last modified, etc. (#164)
WIP

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/164
2026-06-24 22:54:52 +02:00
Yuri Karamian 987b1b9ed7 Add opt-in date grouping for the All Notes list 2026-06-24 22:38:23 +02:00
Yuri Karamian 3ef0e14581 Add callouts (> [!type]) with fold, titles, type picker, and custom types 2026-06-24 21:57:13 +02:00
mf f98b3415bc feat(editor): resizable outline panel (#161)
Add a drag handle to resize the outline panel and persist its width in
vault state (outline_width). Outline text now scales with the editor
font size.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/161
2026-06-24 21:01:43 +02:00
mf 16805c96aa Delete active note on shortcut (#156)
Press `ctrl+shift+del` to move the active note to the trash.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/156
2026-06-24 21:00:33 +02:00
Yuri Karamian 9f2e0ba8b6 README: add macOS Apple Silicon (aarch64) download link 2026-06-24 20:05:56 +02:00
blake 7b3609afbb Added custom themes, import/export option with json (#159)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/159
2026-06-23 20:19:23 +02:00
Yuri Karamian 84ff75f223 Fix #157: don't flag notebook folder icons as orphaned attachments 2026-06-23 14:03:29 +02:00
Yuri Karamian 56dc0eefd7 Fix #63 desktop/mobile layout; hide Updates tab on Android; dim inline task tokens in the editor 2026-06-23 13:52:26 +02:00
Yuri Karamian eeea16531a Fix #63: decide layout from compile-time platform, not the webview user-agent 2026-06-23 13:12:36 +02:00
Yuri Karamian ac5ab2a27a Release v1.3.2 2026-06-22 23:06:19 +02:00
Yuri Karamian fef4ece2c0 Make editor + menus match the toolbar per platform (dedupe desktop, complete mobile) 2026-06-22 14:25:44 +02:00
Yuri Karamian 613451f4d8 Show search + info/settings icons in the collapsed sidebar rail 2026-06-22 13:45:01 +02:00
Yuri Karamian 04dee78b27 Add command-palette entries to reach hidden sidebar views (#153) 2026-06-22 13:28:24 +02:00
Yuri Karamian 69330bf23c Add command-palette entries to reach hidden sidebar views (#153) 2026-06-22 13:20:15 +02:00
Yuri Karamian 4843bcacd5 Reveal hidden notes list when selecting a sidebar view (#145) 2026-06-22 12:59:04 +02:00
Yuri Karamian 3d28665e1e Add settings to hide unused sidebar items (#153) 2026-06-22 12:52:07 +02:00
Yuri Karamian 209585a0b4 Preserve create date for notes without frontmatter dates (#139) 2026-06-22 12:50:50 +02:00
mf 84eab75ec8 Do not render Notes in Notebook sidebar (#152)
Keeps the notebook tree focused on notebooks and leaves note browsing to the note list.

![image](/attachments/a1e50fba-26b7-4dcd-9d44-9938783c6bc5)

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/152
2026-06-22 11:57:08 +02:00
Yuri Karamian ce17b38509 Add custom note sorting (#149) 2026-06-21 21:44:56 +02:00
Yuri Karamian 85c1e1e7d3 Add customizable keyboard shortcuts (#148) 2026-06-21 13:56:55 +02:00
mf ebf38e2281 Add notes list toggle shortcut (#145)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/145
2026-06-21 12:50:31 +02:00
mf 32ec62419d Focus sub-notebook input from context menu (#150)
Focus the sub-notebook name field after choosing New Sub-notebook from a notebook context menu.

Validation: pnpm build
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/150
2026-06-21 12:49:41 +02:00
mf e46fda91a4 Add custom note sorting 2026-06-21 10:46:22 +03:30
mf 62d5388176 Add customizable keyboard shortcuts
Drive app shortcuts from a central keybindings store (defaults +
localStorage overrides). In the Info panel's Shortcuts tab: click a
shortcut to rebind, right-click to reset, Esc to cancel; combos
already in use are rejected.
2026-06-21 10:37:58 +03:30
Yuri Karamian db2bef892a Add Indent/Outdent buttons to mobile editor toolbar 2026-06-20 23:29:24 +02:00
Yuri Karamian 1c46935cc5 Add Note Width setting to cap editor content width (#137) 2026-06-20 15:01:01 +02:00
Yuri Karamian f0f19125d5 Pin Notebooks header while scrolling, add divider above it (#126) 2026-06-20 14:45:11 +02:00
Yuri Karamian 828f83d2e3 Preserve cursor position when toggling markdown source editor (#125) 2026-06-20 14:35:22 +02:00
Yuri Karamian 002fdb763e WebDAV: source manifest etag from PROPFIND, not PUT response (#147) 2026-06-20 14:14:43 +02:00
Yuri Karamian d3ff1fb931 Parse markdown links with angle brackets and titles (#146) 2026-06-20 13:01:18 +02:00
Yuri Karamian 7951472ae3 Fix reserved $ prefix in variable name 2026-06-19 14:36:10 +02:00
Yuri Karamian 5cffe8846b Fix table menu overflow and conditional reset button 2026-06-19 14:34:28 +02:00
Yuri Karamian 806e975e31 Fix table context menu going below screen 2026-06-19 14:30:32 +02:00
Yuri Karamian e4232269f6 Fix table styling not detected for HTML serialization 2026-06-19 14:29:00 +02:00
Yuri Karamian ab759a006d Fix table content loss in markdown serialization 2026-06-19 14:26:00 +02:00
Yuri Karamian ca7f25003c Tables serialize as markdown by default, HTML only when styled 2026-06-19 14:24:25 +02:00
Yuri Karamian 08ab0b90f2 Fix Enter key in language search truncating code block 2026-06-19 14:15:07 +02:00
Yuri Karamian e275f5c0bb Add PowerShell syntax highlighting 2026-06-19 14:05:04 +02:00
Yuri Karamian 683a01627e add highlight.js 2026-06-19 14:01:04 +02:00
Yuri Karamian 3c11a7f63e Add search filter to code block language selector 2026-06-19 14:00:39 +02:00
Yuri Karamian 90a96552d3 Always show code block controls at low opacity 2026-06-19 14:00:38 +02:00
Yuri Karamian a710603b0a Unify code block copy button and language selector styling 2026-06-19 14:00:38 +02:00
mf 80a6fdca69 Move single list items with Alt Shift arrows (#143)
Adds Alt+Shift+Up/Down for moving only the current list or task item while preserving the existing Alt+Up/Down block movement.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/143
2026-06-19 12:14:34 +02:00
mf 1dc78ca916 Add F2 rename shortcut (#142)
Adds F2 as a keyboard shortcut for focused note and notebook rows, reusing the existing inline rename flow.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/142
2026-06-19 12:14:20 +02:00
Yuri Karamian 3118af1e41 Merge branch 'fix/blob-images-source-mode' 2026-06-19 12:12:36 +02:00
Yuri Karamian a669d96aed Fix images not rendering after comment in source mode (#87) 2026-06-19 12:09:30 +02:00
Yuri Karamian a0b1fa0481 Fix images not rendering after comment in source mode (#87) 2026-06-19 12:03:49 +02:00
Yuri Karamian 2e8c048a3e Fix images not rendering after <- Client-side Create New Pull Zone button for allowed --> comment in source mode (#87) 2026-06-19 12:01:13 +02:00
Yuri Karamian d2ddc7f0de Fix editor not rendering on every other source mode switch 2026-06-19 11:54:13 +02:00
Yuri Karamian 3b88b20c61 Fix editor not rendering on every other source mode switch 2026-06-19 11:49:18 +02:00
Yuri Karamian f8371aaa57 Fix editor not rendering on every other source mode switch 2026-06-19 11:47:11 +02:00
Yuri Karamian c5a45131a5 Fix editor not rendering on every other source mode switch 2026-06-19 11:40:57 +02:00
Yuri Karamian 1681fade3b Fix editor not rendering on every other source mode switch 2026-06-19 11:38:40 +02:00
Yuri Karamian 4f324ad682 Fix pasted images disappearing when switching to source mode (#87) 2026-06-19 11:31:49 +02:00
Yuri Karamian c1a9871703 Fix pasted images disappearing when switching to source mode (#87) 2026-06-19 11:30:07 +02:00
Yuri Karamian 83390f5ed2 Fix images not rendering when switching back from source mode 2026-06-19 11:27:38 +02:00
Yuri Karamian 5bf6d8063e Fix images not rendering when switching back from source mode 2026-06-19 11:25:45 +02:00
Yuri Karamian a1913558b9 Fix pasted images disappearing when switching to source mode (#87) 2026-06-19 11:18:06 +02:00
Yuri Karamian 93e96829f4 Add setting to toggle link arrows (#128) 2026-06-19 11:06:49 +02:00
Yuri Karamian 11673bd8c4 Fix Android crash when swiping back with modal open (#90) 2026-06-19 10:47:39 +02:00
lokthare 4f4e2854a6 Update flake.nix / fix cargoHash (#136)
I wanted to try HelixNotes, I pulled the main branch but it failed to build with `nix build`.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/136
2026-06-19 09:54:58 +02:00
Yuri Karamian 856ac94a76 Centralize darkThemes list, fix dark themes reverting to light on restart 2026-06-18 13:50:33 +02:00
MF 28263de53d Improve notebook path creation (#135)
Backspace on an empty sub-notebook field returns to top-level creation, and top-level names like Parent/Child now create nested notebooks in one action. Validated with pnpm build.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/135
2026-06-18 13:40:41 +02:00
blake b6e855ce3e Accent colors now change automatically when a user selects a theme. Also added a One Dark theme. (#130)
WIP

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/130
2026-06-18 13:27:03 +02:00
MF 51148efe2e Focus notebook name after sidebar double-click (#134)
Double-clicking empty sidebar space now opens the new-notebook field and focuses it, matching the + button flow.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/134
2026-06-18 13:24:15 +02:00
Yuri Karamian 32e056dc24 Fix heading submenu clipped in context menu, flip on narrow screens (#113) 2026-06-18 13:20:35 +02:00
Yuri Karamian c14ad6387d Fix heading submenu clipped in context menu (#113) 2026-06-18 13:16:43 +02:00
134 changed files with 13857 additions and 3605 deletions
+3
View File
@@ -13,6 +13,9 @@ bun.lockb
/.svelte-kit
/build
# Local tests
/tests/
# Nix
/result
+50 -21
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.1-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.5-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,29 +10,45 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.3.1)
## Download (v1.3.5)
### Linux
#### Arch / Manjaro (AUR)
#### AppImage
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
[Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
#### Distro-specific packages
##### Fedora 43+ (DNF)
```bash
yay -S helixnotes-appimage-bin
sudo dnf config-manager addrepo \
--from-repofile=https://repo.arkhost.com/helixnotes.repo
sudo dnf install helix-notes
```
#### Debian / Ubuntu / Mint (APT)
##### Debian / Ubuntu / Mint (APT)
```bash
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
##### Arch / Manjaro (AUR)
```bash
yay -S helixnotes-appimage-bin
```
##### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
##### NixOS
<details>
<summary>flake.nix</summary>
@@ -42,7 +58,7 @@ sudo eopkg it helixnotes
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
helix-notes = {
url = "git+https://codeberg.org/ArkHost/HelixNotes";
url = "git+https://gitlab.com/ArkHost/HelixNotes";
# inputs.nixpkgs.follows = "nixpkgs";
}
};
@@ -90,29 +106,34 @@ sudo eopkg it helixnotes
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
#### Manual package downloads
[Download AppImage](https://download.helixnotes.com/releases/v1.3.1/HelixNotes_1.3.1_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_amd64.deb) (Ubuntu 22.04+)
- [.deb](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64-setup.exe) (Windows 10/11)
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
[Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
The Apple Silicon build is signed and notarized by Apple. The Intel build may still trigger a Gatekeeper warning. If macOS reports that the Intel build is damaged, run this once after installing or updating it:
```bash
xattr -cr /Applications/HelixNotes.app
```
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_android.apk)
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
---
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
All releases: [gitlab.com/ArkHost/HelixNotes/-/releases](https://gitlab.com/ArkHost/HelixNotes/-/releases)
## Features
@@ -147,7 +168,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
### Prerequisites
- [Rust](https://rustup.rs/) (1.77+)
- [Rust](https://rustup.rs/) (1.88+)
- [Node.js](https://nodejs.org/) (18+)
- [pnpm](https://pnpm.io/)
- System dependencies for Tauri: see [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/)
@@ -159,6 +180,14 @@ pnpm install
pnpm tauri dev
```
### Verification
Run the frontend checks and tests, Rust tests and lints, and production frontend build with one command:
```bash
pnpm verify
```
### Production Build
```bash
@@ -175,4 +204,4 @@ pnpm tauri build
## License
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1777268161,
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
"lastModified": 1785571196,
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "github"
},
"original": {
+60 -54
View File
@@ -6,71 +6,77 @@
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system;};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
pname = cargoToml.package.name;
version = cargoToml.package.version;
in {
packages.default = pkgs.rustPlatform.buildRustPackage {
inherit pname version;
src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
pnpmDeps = pkgs.fetchPnpmDeps {
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
pname = cargoToml.package.name;
version = cargoToml.package.version;
in
{
packages.default = pkgs.rustPlatform.buildRustPackage {
inherit pname version;
src = ./.;
fetcherVersion = 3;
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
};
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
pnpmConfigHook
pnpm
nodejs
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
src = ./.;
fetcherVersion = 4;
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
};
pkg-config
jq
moreutils
wrapGAppsHook3
];
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
buildInputs = with pkgs;
lib.optionals stdenv.hostPlatform.isLinux [
webkitgtk_4_1
libayatana-appindicator
pnpmConfigHook
pnpm
nodejs
pkg-config
jq
moreutils
wrapGAppsHook3
];
cargoRoot = "src-tauri";
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
buildInputs =
with pkgs;
lib.optionals stdenv.hostPlatform.isLinux [
webkitgtk_4_1
libayatana-appindicator
];
# Deactivate the upstream update mechanism
postPatch = ''
jq '
.bundle.createUpdaterArtifacts = false |
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
' \
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
'';
cargoRoot = "src-tauri";
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
meta = with pkgs.lib; {
description = "HelixNotes desktop application";
homepage = "https://helixnotes.com/";
license = licenses.agpl3Plus;
platforms = platforms.linux ++ platforms.darwin;
mainProgram = "helixnotes";
# Deactivate the upstream update mechanism
postPatch = ''
jq '
.bundle.createUpdaterArtifacts = false |
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
' \
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
'';
meta = with pkgs.lib; {
description = "HelixNotes desktop application";
homepage = "https://helixnotes.com/";
license = licenses.agpl3Plus;
platforms = platforms.linux ++ platforms.darwin;
mainProgram = "helixnotes";
};
};
};
formatter = pkgs.alejandra;
});
formatter = pkgs.nixfmt;
}
);
}
+15 -10
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.1",
"version": "1.3.5",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -11,6 +11,10 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
@@ -18,22 +22,22 @@
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.10.0",
"svelte": "^5.49.2",
"@types/node": "26.2.0",
"svelte": "^5.56.9",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.6"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
@@ -59,13 +63,14 @@
"@tiptap/starter-kit": "^3.19.0",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.1.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.0",
"markdown-it": "^14.3.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0"
"mermaid": "^11.16.1"
}
}
+601 -692
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
overrides:
cookie: 0.7.2
+78 -275
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -138,12 +127,6 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -346,18 +329,6 @@ dependencies = [
"crunchy",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -389,29 +360,6 @@ dependencies = [
"piper",
]
[[package]]
name = "borsh"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
dependencies = [
"borsh-derive",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -439,40 +387,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byte-unit"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d"
dependencies = [
"rust_decimal",
"schemars 1.2.1",
"serde",
"utf8-width",
]
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -624,6 +538,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -738,6 +663,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -768,9 +702,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1314,12 +1248,6 @@ dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1590,11 +1518,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1604,10 +1530,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1796,9 +1725,6 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -1806,7 +1732,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
"ahash",
"allocator-api2",
]
@@ -1850,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.1"
version = "1.3.5"
dependencies = [
"arboard",
"chrono",
@@ -1860,8 +1786,9 @@ dependencies = [
"image",
"log",
"notify",
"objc2-app-kit",
"png 0.17.16",
"quick-xml 0.36.2",
"quick-xml",
"rayon",
"regex",
"reqwest 0.12.28",
@@ -1876,6 +1803,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-ios-vault-access",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
@@ -2552,9 +2480,6 @@ name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"value-bag",
]
[[package]]
name = "lru"
@@ -2573,9 +2498,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4_flex"
version = "0.11.5"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
[[package]]
name = "mac"
@@ -3415,13 +3340,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"time",
]
@@ -3574,26 +3499,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
@@ -3617,18 +3522,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.36.2"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -3655,14 +3551,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.1",
"lru-slab",
"rand 0.9.2",
"rand 0.10.2",
"rand_pcg 0.10.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
@@ -3703,12 +3600,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
version = "0.7.3"
@@ -3720,7 +3611,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
"rand_pcg",
"rand_pcg 0.2.1",
]
[[package]]
@@ -3736,12 +3627,13 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.1",
"rand_core 0.10.1",
]
[[package]]
@@ -3764,16 +3656,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3794,12 +3676,9 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
@@ -3829,6 +3708,15 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3933,15 +3821,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -4061,35 +3940,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -4100,22 +3950,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4229,9 +4063,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4325,12 +4159,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4572,7 +4400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -4598,12 +4426,6 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -5005,17 +4827,11 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -5201,14 +5017,22 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-ios-vault-access"
version = "0.1.0"
dependencies = [
"serde",
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93"
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [
"android_logger",
"byte-unit",
"fern",
"log",
"objc2",
@@ -5953,12 +5777,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5977,12 +5795,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -6235,12 +6047,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.8"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.38.4",
"quick-xml",
"quote",
]
@@ -6996,15 +6808,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "x11"
version = "2.21.0"
+16 -3
View File
@@ -1,11 +1,15 @@
[package]
name = "helixnotes"
version = "1.3.1"
version = "1.3.5"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
edition = "2021"
rust-version = "1.77.2"
rust-version = "1.88"
[lints.clippy]
# This style lint is version-dependent and produces over 100 legacy-only diagnostics on Rust 1.88.
uninlined_format_args = "allow"
[lib]
name = "app_lib"
@@ -14,6 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
@@ -38,10 +45,16 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3"
rayon = "1"
quick-xml = "0.36"
quick-xml = "0.41"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = "0.3.2"
[target.'cfg(target_os = "ios")'.dependencies]
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict>
</plist>
+1 -1
View File
@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}
+2 -27
View File
@@ -11,6 +11,7 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-show",
"core:window:allow-is-maximized",
"core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
@@ -23,32 +24,6 @@
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:default",
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:allow-stat",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-watch",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "/**" }]
},
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://**" },
{ "url": "http://**" },
{ "url": "mailto:*" }
]
},
"opener:allow-reveal-item-in-dir"
"fs:allow-read-file"
]
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime entitlements for Developer ID distribution (outside Mac App Store).
Required for notarization. These do NOT enable App Sandbox.
Without these, the WebKit webview and bundled Rust libraries get killed by the
hardened runtime's library validation. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Network access for auto-updater, AI features, WebDAV sync, image proxy -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
<color name="ic_launcher_background">#626ED4</color>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 984 B

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(85.333 85.333) scale(0.666667)">
<line x1="178.45584" y1="178.45584" x2="333.54416" y2="333.54416" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
<line x1="337.53708" y1="174.46292" x2="174.46294" y2="337.53708" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
<circle cx="153" cy="153" r="36" fill="#ffffff"/>
<circle cx="359" cy="153" r="36" fill="#ffffff"/>
<circle cx="153" cy="359" r="36" fill="#ffffff"/>
<circle cx="359" cy="359" r="36" fill="#ffffff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"><rect
width="512"
height="512"
rx="112"
ry="112"
fill="#626ED4"
id="rect1" /><line
x1="178.45584"
y1="178.45584"
x2="333.54416"
y2="333.54416"
stroke="#ffffff"
stroke-width="25"
stroke-opacity="0.5"
stroke-linecap="round"
id="line1" /><line
x1="337.53708"
y1="174.46292"
x2="174.46294"
y2="337.53708"
stroke="#ffffff"
stroke-width="25"
stroke-opacity="0.5"
stroke-linecap="round"
id="line2" /><circle
cx="153"
cy="153"
r="36"
fill="#ffffff"
id="circle2" /><circle
cx="359"
cy="153"
r="36"
fill="#ffffff"
id="circle3" /><circle
cx="153"
cy="359"
r="36"
fill="#ffffff"
id="circle4" /><circle
cx="359"
cy="359"
r="36"
fill="#ffffff"
id="circle5" /></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"themes": [
{
"colors": {
"bg_active": "#373a52",
"bg_editor": "#1a1b26",
"bg_hover": "#2f3145",
"bg_primary": "#1a1b26",
"bg_secondary": "#1f2029",
"bg_tertiary": "#2a2b3d",
"border_color": "#2f3145",
"text_primary": "#c8cde4",
"text_secondary": "#6b7094"
},
"id": "custom-1782146306429",
"is_dark": true,
"name": "My theme"
}
],
"version": 1
}
@@ -0,0 +1,15 @@
[package]
name = "tauri-plugin-ios-vault-access"
version = "0.1.0"
authors = ["HelixNotes"]
description = "iOS Files folder access for HelixNotes"
edition = "2021"
rust-version = "1.77.2"
links = "tauri-plugin-ios-vault-access"
[dependencies]
tauri = "2"
serde = { version = "1", features = ["derive"] }
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,3 @@
fn main() {
tauri_plugin::Builder::new(&[]).ios_path("ios").build();
}
@@ -0,0 +1,24 @@
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "tauri-plugin-ios-vault-access",
platforms: [.iOS(.v14)],
products: [
.library(
name: "tauri-plugin-ios-vault-access",
type: .static,
targets: ["tauri-plugin-ios-vault-access"]
)
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
.target(
name: "tauri-plugin-ios-vault-access",
dependencies: [.byName(name: "Tauri")],
path: "Sources"
)
]
)
@@ -0,0 +1,312 @@
import Foundation
import SwiftRs
import Tauri
import UIKit
import UniformTypeIdentifiers
private struct BookmarkRequest: Decodable {
let bookmarkId: String
}
final class IosVaultAccessPlugin: Plugin, UIDocumentPickerDelegate {
private let fileManager = FileManager.default
private let lock = NSLock()
private var pickerInvoke: Invoke?
private var activeBookmarkId: String?
private var activeURL: URL?
private var stagedBookmarkId: String?
private var stagedURL: URL?
deinit {
rollbackStagedScope()
releaseCurrentScope()
}
@objc func chooseFolder(_ invoke: Invoke) {
DispatchQueue.main.async {
guard self.pickerInvoke == nil else {
invoke.reject("A folder picker is already open.")
return
}
guard let viewController = self.manager.viewController else {
self.pickerInvoke = nil
invoke.reject("Files is unavailable right now. Try again.")
return
}
self.pickerInvoke = invoke
let picker = UIDocumentPickerViewController(
forOpeningContentTypes: [.folder],
asCopy: false
)
picker.delegate = self
picker.allowsMultipleSelection = false
viewController.present(picker, animated: true)
}
}
@objc func resolveFolder(_ invoke: Invoke) throws {
let request = try invoke.parseArgs(BookmarkRequest.self)
do {
invoke.resolve(try resolveBookmark(request.bookmarkId))
} catch {
invoke.reject(message(for: error))
}
}
@objc func releaseActive(_ invoke: Invoke) {
rollbackStagedScope()
releaseCurrentScope()
invoke.resolve()
}
@objc func commitStaged(_ invoke: Invoke) {
commitStagedScope()
invoke.resolve()
}
@objc func rollbackStaged(_ invoke: Invoke) {
rollbackStagedScope()
invoke.resolve()
}
@objc func forgetBookmark(_ invoke: Invoke) throws {
let request = try invoke.parseArgs(BookmarkRequest.self)
do {
let file = try bookmarkFile(for: request.bookmarkId, createDirectory: false)
lock.lock()
let isActive = activeBookmarkId == request.bookmarkId
let isStaged = stagedBookmarkId == request.bookmarkId
lock.unlock()
if fileManager.fileExists(atPath: file.path) {
try fileManager.removeItem(at: file)
}
if isActive {
releaseCurrentScope()
}
if isStaged {
rollbackStagedScope()
}
invoke.resolve()
} catch {
invoke.reject(message(for: error))
}
}
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
guard let invoke = takePickerInvoke() else { return }
guard let url = urls.first else {
invoke.resolve(cancelledResult())
return
}
do {
guard url.startAccessingSecurityScopedResource() else {
throw AccessError.denied
}
var adopted = false
defer {
if !adopted { url.stopAccessingSecurityScopedResource() }
}
let name = try validateFolder(url)
let bookmarkId = existingBookmarkId(for: url) ?? UUID().uuidString.lowercased()
let bookmark = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
try bookmark.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
stageScope(url: url, bookmarkId: bookmarkId)
adopted = true
invoke.resolve(result(bookmarkId: bookmarkId, url: url, name: name))
} catch {
invoke.reject(message(for: error))
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
takePickerInvoke()?.resolve(cancelledResult())
}
private func resolveBookmark(_ bookmarkId: String) throws -> [String: Any] {
guard UUID(uuidString: bookmarkId) != nil else { throw AccessError.missing }
lock.lock()
let currentURL = activeBookmarkId == bookmarkId ? activeURL : nil
lock.unlock()
if let currentURL {
return result(bookmarkId: bookmarkId, url: currentURL, name: try validateFolder(currentURL))
}
let data = try Data(contentsOf: bookmarkFile(for: bookmarkId, createDirectory: false))
var stale = false
let url = try URL(
resolvingBookmarkData: data,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &stale
)
guard url.startAccessingSecurityScopedResource() else { throw AccessError.denied }
var adopted = false
defer {
if !adopted { url.stopAccessingSecurityScopedResource() }
}
let name = try validateFolder(url)
if stale {
let refreshed = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
try refreshed.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
}
stageScope(url: url, bookmarkId: bookmarkId)
adopted = true
return result(bookmarkId: bookmarkId, url: url, name: name)
}
private func validateFolder(_ url: URL) throws -> String {
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
guard values.isDirectory == true else { throw AccessError.notFolder }
guard try url.checkResourceIsReachable() else { throw AccessError.unavailable }
return values.name ?? url.lastPathComponent
}
private func stageScope(url: URL, bookmarkId: String) {
lock.lock()
let previous = stagedURL
stagedURL = url
stagedBookmarkId = bookmarkId
lock.unlock()
previous?.stopAccessingSecurityScopedResource()
}
private func commitStagedScope() {
lock.lock()
guard let next = stagedURL else {
lock.unlock()
return
}
let previous = activeURL
activeURL = next
activeBookmarkId = stagedBookmarkId
stagedURL = nil
stagedBookmarkId = nil
lock.unlock()
previous?.stopAccessingSecurityScopedResource()
}
private func rollbackStagedScope() {
lock.lock()
let staged = stagedURL
stagedURL = nil
stagedBookmarkId = nil
lock.unlock()
staged?.stopAccessingSecurityScopedResource()
}
private func releaseCurrentScope() {
lock.lock()
let previous = activeURL
activeURL = nil
activeBookmarkId = nil
lock.unlock()
previous?.stopAccessingSecurityScopedResource()
}
private func takePickerInvoke() -> Invoke? {
let invoke = pickerInvoke
pickerInvoke = nil
return invoke
}
private func bookmarkDirectory(create: Bool) throws -> URL {
let base = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = base.appendingPathComponent("VaultBookmarks", isDirectory: true)
if create {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
return directory
}
private func bookmarkFile(for id: String, createDirectory: Bool) throws -> URL {
guard UUID(uuidString: id) != nil else { throw AccessError.missing }
let directory = try bookmarkDirectory(create: createDirectory)
return directory.appendingPathComponent("\(id).bookmark")
}
private func existingBookmarkId(for selectedURL: URL) -> String? {
guard
let directory = try? bookmarkDirectory(create: true),
let files = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
else { return nil }
let selectedPath = selectedURL.standardizedFileURL.path
for file in files where file.pathExtension == "bookmark" {
guard let data = try? Data(contentsOf: file) else { continue }
var stale = false
guard
let resolved = try? URL(
resolvingBookmarkData: data,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &stale
),
resolved.standardizedFileURL.path == selectedPath
else { continue }
let id = file.deletingPathExtension().lastPathComponent
if UUID(uuidString: id) != nil { return id }
}
return nil
}
private func result(bookmarkId: String, url: URL, name: String) -> [String: Any] {
["bookmarkId": bookmarkId, "path": url.path, "name": name, "cancelled": false]
}
private func cancelledResult() -> [String: Any] {
["bookmarkId": "", "path": "", "name": "", "cancelled": true]
}
private func message(for error: Error) -> String {
if let accessError = error as? AccessError { return accessError.localizedDescription }
return "The selected folder is unavailable. Open Files and try again."
}
}
private enum AccessError: LocalizedError {
case denied
case missing
case notFolder
case unavailable
var errorDescription: String? {
switch self {
case .denied:
return "HelixNotes no longer has access to this folder. Choose it again in Files."
case .missing:
return "Saved access to this folder is missing. Choose it again in Files."
case .notFolder:
return "The selected item is not a folder."
case .unavailable:
return "The selected folder is unavailable. Open Files and try again."
}
}
}
@_cdecl("init_plugin_ios_vault_access")
func initPlugin() -> Plugin {
IosVaultAccessPlugin()
}
@@ -0,0 +1,84 @@
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, PluginHandle, TauriPlugin},
Manager, Runtime,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FolderSelection {
pub bookmark_id: String,
pub path: String,
pub name: String,
#[serde(default)]
pub cancelled: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BookmarkRequest<'a> {
bookmark_id: &'a str,
}
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_ios_vault_access);
pub struct IosVaultAccess<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> IosVaultAccess<R> {
pub fn choose_folder(&self) -> Result<FolderSelection, String> {
self.0
.run_mobile_plugin("chooseFolder", ())
.map_err(|error| error.to_string())
}
pub fn resolve_folder(&self, bookmark_id: &str) -> Result<FolderSelection, String> {
self.0
.run_mobile_plugin("resolveFolder", BookmarkRequest { bookmark_id })
.map_err(|error| error.to_string())
}
pub fn release_active(&self) -> Result<(), String> {
self.0
.run_mobile_plugin("releaseActive", ())
.map_err(|error| error.to_string())
}
pub fn commit_staged(&self) -> Result<(), String> {
self.0
.run_mobile_plugin("commitStaged", ())
.map_err(|error| error.to_string())
}
pub fn rollback_staged(&self) -> Result<(), String> {
self.0
.run_mobile_plugin("rollbackStaged", ())
.map_err(|error| error.to_string())
}
pub fn forget_bookmark(&self, bookmark_id: &str) -> Result<(), String> {
self.0
.run_mobile_plugin("forgetBookmark", BookmarkRequest { bookmark_id })
.map_err(|error| error.to_string())
}
}
pub trait IosVaultAccessExt<R: Runtime> {
fn ios_vault_access(&self) -> &IosVaultAccess<R>;
}
impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
fn ios_vault_access(&self) -> &IosVaultAccess<R> {
self.state::<IosVaultAccess<R>>().inner()
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("ios-vault-access")
.setup(|app, api| {
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
app.manage(IosVaultAccess(handle));
Ok(())
})
.build()
}
+25 -11
View File
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
#[allow(clippy::too_many_arguments)]
pub fn ai_request(
app: AppHandle,
provider: String,
@@ -22,7 +23,11 @@ pub fn ai_request(
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
let key_opt = if api_key.is_empty() {
None
} else {
Some(api_key.as_str())
};
let result = match provider.as_str() {
"openai" => {
stream_openai(
@@ -98,7 +103,10 @@ pub fn ai_request(
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
b.strip_suffix("/v1")
.unwrap_or(b)
.trim_end_matches('/')
.to_string()
}
async fn stream_anthropic(
@@ -268,9 +276,7 @@ async fn stream_openai(
body["temperature"] = json!(0.7);
}
let mut req = client
.post(url)
.header("content-type", "application/json");
let mut req = client.post(url).header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
@@ -380,7 +386,11 @@ pub async fn test_connection(
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
let key_opt = if api_key.is_empty() {
None
} else {
Some(api_key)
};
match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => {
@@ -405,7 +415,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
let body = json!({
"model": model,
"max_tokens": 10,
"max_tokens": 20,
"messages": [
{
"role": "user",
@@ -435,10 +445,16 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 {
"max_completion_tokens"
} else {
"max_tokens"
};
let body = json!({
"model": model,
"max_tokens": 10,
token_key: 20,
"messages": [
{
"role": "user",
@@ -447,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
]
});
let mut req = client
.post(url)
.header("content-type", "application/json");
let mut req = client.post(url).header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
+111
View File
@@ -0,0 +1,111 @@
use std::path::Path;
use tauri::{AppHandle, Manager, Runtime};
/// Grants asset-protocol access only to a vault selected by the user.
/// `.helixnotes` is added explicitly because Unix glob matching excludes hidden paths.
pub fn allow_vault_assets<R: Runtime>(app: &AppHandle<R>, vault_path: &Path) -> Result<(), String> {
// Keep the canonical native path: Tauri's scope normalizes Unix paths plus
// Windows drive, verbatim, and UNC prefixes when matching asset requests.
let vault = std::fs::canonicalize(vault_path).map_err(|error| {
format!(
"Failed to resolve vault asset path '{}': {error}",
vault_path.display()
)
})?;
let scope = app.asset_protocol_scope();
scope
.allow_directory(&vault, true)
.map_err(|error| error.to_string())?;
scope
.allow_directory(vault.join(".helixnotes"), true)
.map_err(|error| error.to_string())
}
/// Grants access to relative assets next to a Markdown file explicitly opened by the user.
pub fn allow_external_note_assets<R: Runtime>(
app: &AppHandle<R>,
note_path: &Path,
) -> Result<(), String> {
let note = std::fs::canonicalize(note_path).map_err(|error| {
format!(
"Failed to resolve external note path '{}': {error}",
note_path.display()
)
})?;
let parent = note
.parent()
.ok_or_else(|| "External note has no parent directory".to_string())?;
app.asset_protocol_scope()
.allow_directory(parent, true)
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn test_directory(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("helixnotes-{name}-{}", uuid::Uuid::new_v4()))
}
#[test]
fn static_asset_scope_does_not_expose_the_filesystem() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let scope = &config["app"]["security"]["assetProtocol"]["scope"];
assert_eq!(scope["allow"], serde_json::json!([]));
assert_eq!(scope["deny"], serde_json::json!([]));
assert_eq!(scope["requireLiteralLeadingDot"], true);
}
#[test]
fn runtime_scope_allows_only_the_selected_vault() {
let root = test_directory("asset-scope");
let vault = root.join("Vault [Cross Platform]");
let attachments = vault.join(".helixnotes").join("attachments");
fs::create_dir_all(&attachments).unwrap();
let note_image = vault.join("images").join("note image.png");
fs::create_dir_all(note_image.parent().unwrap()).unwrap();
fs::write(&note_image, b"image").unwrap();
let attachment = attachments.join("attachment.png");
fs::write(&attachment, b"attachment").unwrap();
let outside = root.join("outside.png");
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_vault_assets(app.handle(), &vault).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note_image));
assert!(scope.is_allowed(&attachment));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn external_note_scope_allows_relative_assets_only_below_its_directory() {
let root = test_directory("external-note-assets");
let note_dir = root.join("shared note");
let assets = note_dir.join("images");
fs::create_dir_all(&assets).unwrap();
let note = note_dir.join("note.md");
let image = assets.join("image.png");
let outside = root.join("outside.png");
fs::write(&note, b"note").unwrap();
fs::write(&image, b"image").unwrap();
fs::write(&outside, b"outside").unwrap();
let app = tauri::test::mock_app();
allow_external_note_assets(app.handle(), &note).unwrap();
let scope = app.asset_protocol_scope();
assert!(scope.is_allowed(&note));
assert!(scope.is_allowed(&image));
assert!(!scope.is_allowed(&outside));
fs::remove_dir_all(root).unwrap();
}
}
+48 -15
View File
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "zip") {
if path.extension().is_some_and(|ext| ext == "zip") {
let filename = path
.file_name()
.unwrap_or_default()
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
Ok(entries)
}
/// Restore a backup by extracting the zip over the vault directory
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let backup = Path::new(backup_path);
if !backup.exists() {
return Err("Backup file does not exist".to_string());
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
if backup.parent() != Some(backup_dir.as_path())
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|| !backup.is_file()
{
return Err(
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
);
}
Ok(backup)
}
/// Restore a backup by extracting the zip over the vault directory
pub fn restore_backup(
vault_path: &str,
backup_dir: &Path,
backup_path: &str,
) -> Result<(), String> {
let vault = Path::new(vault_path);
let backup = validated_backup_file(backup_dir, backup_path)?;
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
let mut archive =
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
}
/// Delete a single backup file
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
let path = Path::new(backup_path);
if path.exists() {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
Ok(())
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
let path = validated_backup_file(backup_dir, backup_path)?;
fs::remove_file(path).map_err(|error| error.to_string())
}
/// Remove old backups keeping only the newest `max_count`
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
if backups.len() as u32 > max_count {
let to_remove = backups.split_off(max_count as usize);
for entry in to_remove {
delete_backup(&entry.path)?;
delete_backup(backup_dir, &entry.path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::delete_backup;
use std::fs;
use uuid::Uuid;
#[test]
fn delete_backup_rejects_files_outside_backup_directory() {
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
let backup_dir = root.join("backups");
let outside = root.join("outside.zip");
fs::create_dir_all(&backup_dir).unwrap();
fs::write(&outside, "must survive").unwrap();
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
assert!(result.is_err());
assert!(outside.exists());
fs::remove_dir_all(root).unwrap();
}
}
+1181 -756
View File
File diff suppressed because it is too large Load Diff
+60 -22
View File
@@ -1,22 +1,36 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::types::VersionEntry;
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
let mut components = Path::new(value).components();
if value.is_empty()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err(format!("Invalid {label}"));
}
Ok(value)
}
/// Directory: .helixnotes/history/<note-id>/
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
Path::new(vault_path)
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
Ok(Path::new(vault_path)
.join(".helixnotes")
.join("history")
.join(note_id)
.join(safe_path_component(note_id, "note ID")?))
}
/// Save a version snapshot if enough time has passed since the last one.
/// Minimum interval: 5 minutes between snapshots.
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
let dir = history_dir(vault_path, note_id);
let Ok(dir) = history_dir(vault_path, note_id) else {
log::warn!("Skipping history snapshot with an invalid note ID");
return;
};
// Check if we should create a snapshot (5 min cooldown)
if let Ok(entries) = fs::read_dir(&dir) {
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
}
/// Force-create a version snapshot, bypassing the cooldown.
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
let dir = history_dir(vault_path, note_id);
if let Err(e) = fs::create_dir_all(&dir) {
eprintln!("Failed to create history dir: {}", e);
return;
}
pub fn force_snapshot(
vault_path: &str,
note_id: &str,
raw_content: &str,
max_versions: u32,
) -> Result<(), String> {
let dir = history_dir(vault_path, note_id)?;
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let filename = format!("{}.md", timestamp);
let path = dir.join(&filename);
if let Err(e) = fs::write(&path, raw_content) {
eprintln!("Failed to write version snapshot: {}", e);
return;
}
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
if max_versions > 0 {
let _ = prune_versions(&dir, max_versions);
prune_versions(&dir, max_versions)?;
}
Ok(())
}
/// List all version snapshots for a note, newest first.
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
let dir = history_dir(vault_path, note_id);
let dir = history_dir(vault_path, note_id)?;
if !dir.exists() {
return Ok(Vec::new());
}
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "md") {
if path.extension().is_some_and(|ext| ext == "md") {
let filename = path
.file_stem()
.unwrap_or_default()
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
/// Get the raw content of a specific version.
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
safe_path_component(timestamp, "version timestamp")?;
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
let filename = if let Some(t_pos) = timestamp.find('T') {
let date_part = &timestamp[..t_pos];
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
let time_dashes = time_part.replace(':', "-");
format!("{}.md", format!("{}T{}", date_part, time_dashes))
format!("{date_part}T{time_dashes}.md")
} else {
format!("{}.md", timestamp)
};
let path = history_dir(vault_path, note_id).join(&filename);
let path = history_dir(vault_path, note_id)?.join(&filename);
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
}
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
.collect();
// Sort by name (timestamps sort lexicographically) - newest last
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::{get_version, list_versions};
use std::fs;
use uuid::Uuid;
#[test]
fn rejects_history_path_traversal() {
let vault =
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
let metadata = vault.join(".helixnotes");
let escaped_history = metadata.join("escaped");
fs::create_dir_all(&escaped_history).unwrap();
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
fs::write(metadata.join("secret.md"), "secret").unwrap();
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
fs::remove_dir_all(vault).unwrap();
}
}
+245
View File
@@ -0,0 +1,245 @@
use reqwest::{blocking::Client, redirect::Policy, Url};
use std::io::Read;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
pub struct ImageResponse {
pub content_type: String,
pub body: Vec<u8>,
}
pub enum ProxyError {
Invalid(String),
Blocked(String),
Fetch(String),
}
impl ProxyError {
pub fn status(&self) -> u16 {
match self {
Self::Invalid(_) => 400,
Self::Blocked(_) => 403,
Self::Fetch(_) => 502,
}
}
pub fn message(&self) -> &str {
match self {
Self::Invalid(message) | Self::Blocked(message) | Self::Fetch(message) => message,
}
}
}
fn is_public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
!(a == 0
|| a == 10
|| a == 127
|| a >= 224
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113))
}
fn is_public_ipv6(ip: Ipv6Addr) -> bool {
if ip.is_unspecified() || ip.is_loopback() || ip.to_ipv4().is_some() {
return false;
}
let first = ip.segments()[0];
(0x2000..=0x3fff).contains(&first) && ip.segments()[..2] != [0x2001, 0x0db8]
}
fn is_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_public_ipv4(ip),
IpAddr::V6(ip) => is_public_ipv6(ip),
}
}
fn normalized_host(host: &str) -> String {
host.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn validate_url(raw_url: &str) -> Result<Url, ProxyError> {
let url = Url::parse(raw_url).map_err(|_| ProxyError::Invalid("Invalid image URL".into()))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ProxyError::Invalid(
"Only HTTP and HTTPS images are supported".into(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ProxyError::Invalid(
"Image URLs must not contain credentials".into(),
));
}
let expected_port = if url.scheme() == "https" { 443 } else { 80 };
if url.port_or_known_default() != Some(expected_port) {
return Err(ProxyError::Blocked("Non-standard image URL port".into()));
}
let host = url
.host_str()
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
if host.ends_with('.') {
return Err(ProxyError::Invalid(
"Image URL host must not have a trailing dot".into(),
));
}
let normalized = normalized_host(host);
if normalized == "localhost" || normalized.ends_with(".localhost") {
return Err(ProxyError::Blocked(
"Local image hosts are not allowed".into(),
));
}
if let Ok(ip) = normalized.parse::<IpAddr>() {
if !is_public_ip(ip) {
return Err(ProxyError::Blocked(
"Private image addresses are not allowed".into(),
));
}
}
Ok(url)
}
fn resolve_public_host(url: &Url) -> Result<(String, Vec<SocketAddr>), ProxyError> {
let host = url
.host_str()
.ok_or_else(|| ProxyError::Invalid("Image URL has no host".into()))?;
let host = normalized_host(host);
let port = url
.port_or_known_default()
.ok_or_else(|| ProxyError::Invalid("Image URL has no port".into()))?;
let addresses: Vec<_> = (host.as_str(), port)
.to_socket_addrs()
.map_err(|error| ProxyError::Fetch(format!("Could not resolve image host: {error}")))?
.collect();
if addresses.is_empty() {
return Err(ProxyError::Fetch(
"Image host resolved to no addresses".into(),
));
}
if addresses.iter().any(|address| !is_public_ip(address.ip())) {
return Err(ProxyError::Blocked(
"Image host resolves to a private address".into(),
));
}
Ok((host, addresses))
}
fn image_client(host: &str, addresses: &[SocketAddr]) -> Result<Client, ProxyError> {
let redirect_host = host.to_string();
Client::builder()
.timeout(Duration::from_secs(30))
.redirect(Policy::custom(move |attempt| {
let redirected_elsewhere = match attempt.url().host_str() {
Some(host) => normalized_host(host) != redirect_host,
None => true,
};
if attempt.previous().len() >= 5
|| redirected_elsewhere
|| validate_url(attempt.url().as_str()).is_err()
{
attempt.stop()
} else {
attempt.follow()
}
}))
.resolve_to_addrs(host, addresses)
.build()
.map_err(|error| ProxyError::Fetch(format!("Could not create image client: {error}")))
}
pub fn fetch(raw_url: &str) -> Result<ImageResponse, ProxyError> {
let url = validate_url(raw_url)?;
let (host, addresses) = resolve_public_host(&url)?;
let client = image_client(&host, &addresses)?;
let mut response = client
.get(url)
.send()
.map_err(|error| ProxyError::Fetch(format!("Image request failed: {error}")))?;
if !response.status().is_success() {
return Err(ProxyError::Fetch(format!(
"Image server returned {}",
response.status()
)));
}
if response
.content_length()
.is_some_and(|length| length > MAX_IMAGE_BYTES)
{
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
if !content_type.to_ascii_lowercase().starts_with("image/") {
return Err(ProxyError::Blocked(
"Remote resource is not an image".into(),
));
}
let mut body = Vec::new();
response
.by_ref()
.take(MAX_IMAGE_BYTES + 1)
.read_to_end(&mut body)
.map_err(|error| ProxyError::Fetch(format!("Could not read image: {error}")))?;
if body.len() as u64 > MAX_IMAGE_BYTES {
return Err(ProxyError::Blocked("Image exceeds the 20 MiB limit".into()));
}
Ok(ImageResponse { content_type, body })
}
#[cfg(test)]
mod tests {
use super::{is_public_ip, validate_url};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
fn blocks_local_and_private_networks() {
for ip in [
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
IpAddr::V6(Ipv6Addr::LOCALHOST),
"fc00::1".parse().unwrap(),
"fe80::1".parse().unwrap(),
] {
assert!(!is_public_ip(ip), "{ip}");
}
assert!(is_public_ip("93.184.216.34".parse().unwrap()));
assert!(is_public_ip(
"2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()
));
}
#[test]
fn validates_only_public_web_image_urls() {
for url in [
"file:///etc/passwd",
"http://localhost/image.png",
"http://127.0.0.1/image.png",
"http://[::1]/image.png",
"http://example.com:8080/image.png",
"https://example.com./image.png",
"https://user:pass@example.com/image.png",
] {
assert!(validate_url(url).is_err(), "{url}");
}
assert!(validate_url("https://example.com/image.png").is_ok());
assert!(validate_url("http://93.184.216.34/image.png").is_ok());
}
}
+176 -66
View File
@@ -1,7 +1,9 @@
mod ai;
mod asset_scope;
mod backup;
mod commands;
mod history;
mod image_proxy;
mod search;
mod state;
mod sync;
@@ -11,6 +13,7 @@ mod vault;
use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{
@@ -38,11 +41,48 @@ pub fn run() {
let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config);
// Inject the compile-time platform so the frontend never sniffs the (sometimes
// mobile-looking) WebKitGTK user-agent. (#63)
let platform_init = format!(
"window.__HELIX_PLATFORM__={{mobile:{},android:{},ios:{}}};",
cfg!(mobile),
cfg!(target_os = "android"),
cfg!(target_os = "ios"),
);
let mut builder = tauri::Builder::default()
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("helix-platform")
.js_init_script(platform_init)
.build(),
)
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.on_webview_event(|webview, event| {
if let tauri::WebviewEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event {
let scope = webview.fs_scope();
for path in paths {
if path.is_file() {
let _ = scope.allow_file(path);
}
}
}
})
.on_page_load(|webview, payload| {
#[cfg(target_os = "macos")]
if webview.label() == "main"
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
{
queue_macos_traffic_light_fix(&webview.window());
}
#[cfg(not(target_os = "macos"))]
let _ = (webview, payload);
})
.setup(move |app| {
#[cfg(target_os = "ios")]
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@@ -66,6 +106,20 @@ pub fn run() {
});
}
let active_vault = app
.state::<AppState>()
.config
.lock()
.ok()
.and_then(|config| config.active_vault.clone());
if let Some(vault_path) = active_vault {
if let Err(error) =
asset_scope::allow_vault_assets(app.handle(), std::path::Path::new(&vault_path))
{
log::warn!("Failed to restore the vault asset scope: {error}");
}
}
#[cfg(desktop)]
if show_tray {
setup_tray(app)?;
@@ -85,6 +139,14 @@ pub fn run() {
std::env::current_dir().unwrap_or_default().join(&path)
};
if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ =
asset_scope::allow_external_note_assets(app.handle(), &resolved);
}
let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
@@ -98,14 +160,23 @@ pub fn run() {
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::open_vault,
commands::choose_external_vault,
commands::restore_external_vault,
commands::remove_vault,
commands::get_app_config,
commands::set_theme,
commands::set_system_themes,
commands::set_accent_color,
commands::save_custom_theme,
commands::delete_custom_theme,
commands::export_custom_theme,
commands::import_custom_themes,
commands::set_font_size,
commands::set_scroll_to_change_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
commands::set_content_width,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
@@ -116,12 +187,14 @@ pub fn run() {
commands::read_note,
commands::save_note,
commands::create_note,
commands::duplicate_note,
commands::create_daily_note,
commands::rename_note,
commands::delete_note,
commands::move_note,
commands::get_all_tags,
commands::get_all_note_titles,
commands::get_note_switcher_titles,
commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
@@ -136,6 +209,7 @@ pub fn run() {
commands::empty_trash,
commands::load_vault_state,
commands::save_vault_state,
commands::copy_text_to_clipboard,
commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image,
@@ -152,6 +226,7 @@ pub fn run() {
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::reveal_file,
commands::open_url,
commands::copy_file_to,
commands::write_bytes_to,
@@ -179,72 +254,21 @@ pub fn run() {
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
let response = match image_proxy::fetch(&external_url) {
Ok(image) => tauri::http::Response::builder()
.status(200)
.header("Content-Type", image.content_type)
.header("Access-Control-Allow-Origin", "*")
.header("X-Content-Type-Options", "nosniff")
.body(image.body),
Err(error) => {
log::warn!("Blocked external image request: {}", error.message());
tauri::http::Response::builder()
.status(400)
.status(error.status())
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
responder.respond(response.expect("valid image proxy response"));
});
});
@@ -271,15 +295,46 @@ pub fn run() {
std::path::Path::new(&cwd).join(path)
};
if let Some(resolved_str) = resolved.to_str() {
if resolved.is_file() {
let _ = app.fs_scope().allow_file(&resolved);
if let Some(parent) = resolved.parent() {
let _ = app.fs_scope().allow_directory(parent, true);
}
let _ = asset_scope::allow_external_note_assets(app, &resolved);
}
let _ = app.emit("open-file", resolved_str.to_string());
}
}
}));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
let window_state_builder = tauri_plugin_window_state::Builder::default();
#[cfg(target_os = "linux")]
let window_state_builder = window_state_builder.with_state_flags(
tauri_plugin_window_state::StateFlags::SIZE
| tauri_plugin_window_state::StateFlags::POSITION
| tauri_plugin_window_state::StateFlags::MAXIMIZED
| tauri_plugin_window_state::StateFlags::DECORATIONS
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
);
builder = builder.plugin(window_state_builder.build());
builder = builder.on_window_event(move |window, event| {
#[cfg(target_os = "macos")]
if window.label() == "main"
&& matches!(
event,
tauri::WindowEvent::Resized(_)
| tauri::WindowEvent::Focused(true)
| tauri::WindowEvent::ScaleFactorChanged { .. }
| tauri::WindowEvent::ThemeChanged(_)
)
{
// AppKit can restore the default button Y position after window-state
// changes. Queue this pass so it runs after AppKit finishes its layout.
queue_macos_traffic_light_fix(window);
}
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
// Only hide to tray for the main window
@@ -288,9 +343,9 @@ pub fn run() {
let _ = window.hide();
}
}
tauri::WindowEvent::Destroyed => {
tauri::WindowEvent::Destroyed
// When main window is destroyed, close all note windows
if window.label() == "main" {
if window.label() == "main" => {
let app = window.app_handle();
for (label, win) in app.webview_windows() {
if label.starts_with("note-") {
@@ -298,7 +353,6 @@ pub fn run() {
}
}
}
}
_ => {}
}
});
@@ -368,3 +422,59 @@ fn percent_decode(input: &str) -> String {
}
String::from_utf8_lossy(&output).to_string()
}
#[cfg(target_os = "macos")]
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
let window_on_main = window.clone();
let _ = window.run_on_main_thread(move || {
fix_macos_traffic_lights(&window_on_main);
});
}
#[cfg(target_os = "macos")]
fn fix_macos_traffic_lights(window: &tauri::Window) {
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
const TITLEBAR_HEIGHT: f64 = 34.0;
const LEFT_INSET: f64 = 12.0;
let ns_window_ptr = match window.ns_window() {
Ok(ptr) => ptr,
Err(_) => return,
};
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
Some(button) => button,
None => return,
};
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
Some(button) => button,
None => return,
};
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
Some(button) => button,
None => return,
};
let window_height = ns_window.frame().size.height;
if window_height <= 0.0 {
return;
}
let close_frame = NSView::frame(&close);
let button_spacing = NSView::frame(&miniaturize).origin.x - close_frame.origin.x;
for (index, button) in [&close, &miniaturize, &zoom].into_iter().enumerate() {
let button_superview = match unsafe { button.superview() } {
Some(view) => view,
None => return,
};
let mut frame = NSView::frame(button);
let top_inset = ((TITLEBAR_HEIGHT - frame.size.height) / 2.0).max(0.0);
let mut target = frame.origin;
target.y = window_height - top_inset - frame.size.height;
frame.origin.y = button_superview.convertPoint_fromView(target, None).y;
frame.origin.x = LEFT_INSET + index as f64 * button_spacing;
button.setFrameOrigin(frame.origin);
}
}
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
app_lib::run();
}
+14 -8
View File
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(vault_path.as_bytes());
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
let key: String = hasher.finalize()[..8]
.iter()
.map(|b| format!("{:02x}", b))
.collect();
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
}
@@ -235,7 +238,9 @@ impl SearchIndex {
// before the writer is created, so both indexing and querying use it).
index.tokenizers().register(
"cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
TextAnalyzer::builder(CjkTokenizer)
.filter(LowerCaser)
.build(),
);
#[cfg(mobile)]
@@ -290,7 +295,7 @@ impl SearchIndex {
doc.add_text(self.path_field, &path_str);
doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content);
doc.add_text(self.tags_field, &meta.tags.join(" "));
doc.add_text(self.tags_field, meta.tags.join(" "));
let _ = writer.add_document(doc);
}
}
@@ -321,7 +326,7 @@ impl SearchIndex {
doc.add_text(self.path_field, path);
doc.add_text(self.title_field, &meta.title);
doc.add_text(self.body_field, &content);
doc.add_text(self.tags_field, &meta.tags.join(" "));
doc.add_text(self.tags_field, meta.tags.join(" "));
let _ = writer.add_document(doc);
writer.commit().map_err(|e| e.to_string())?;
@@ -341,7 +346,7 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher();
let fields = vec![self.title_field, self.body_field, self.tags_field];
let fields = [self.title_field, self.body_field, self.tags_field];
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
@@ -376,9 +381,10 @@ impl SearchIndex {
));
vec![(Occur::Should, exact)]
} else {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let prefix: Box<dyn Query> =
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
field, term,
)]));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
+5 -3
View File
@@ -1,14 +1,15 @@
use crate::search::SearchIndex;
use crate::types::AppConfig;
use notify::RecommendedWatcher;
use crate::vault::watcher::VaultWatcher;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::sync::Arc;
use std::sync::Mutex;
pub struct AppState {
pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub watcher: Mutex<Option<VaultWatcher>>,
pub vault_transition: tokio::sync::Mutex<()>,
pub importing: AtomicBool,
pub syncing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
@@ -20,6 +21,7 @@ impl AppState {
config: Mutex::new(config),
search_index: Mutex::new(None),
watcher: Mutex::new(None),
vault_transition: tokio::sync::Mutex::new(()),
importing: AtomicBool::new(false),
syncing: AtomicBool::new(false),
pending_open_file: Mutex::new(None),
+92 -28
View File
@@ -6,9 +6,9 @@
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
// manifest itself) is local-only and never synced.
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
// and the manifest itself remain local-only.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
}
fn normalize_etag(s: &str) -> String {
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
s.trim()
.trim_start_matches("W/")
.trim_matches('"')
.to_string()
}
/// Percent-encode each path segment, keeping the `/` separators.
@@ -119,12 +122,12 @@ struct LocalFile {
path: PathBuf,
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
/// folder with unrelated files never drags them into the vault.
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
/// and remote so pointing at a folder with unrelated files never imports them.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/")
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
} else {
rel.ends_with(".md")
}
@@ -265,7 +268,11 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
return Err(format!(
"GET {} failed: HTTP {}",
relpath,
resp.status().as_u16()
));
}
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
}
@@ -279,7 +286,11 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
return Err(format!(
"PUT {} failed: HTTP {}",
relpath,
resp.status().as_u16()
));
}
Ok(resp
.headers()
@@ -312,8 +323,9 @@ impl WebdavClient {
.send()
.map_err(|e| e.to_string())?;
let s = resp.status().as_u16();
// 201 created; 405 already exists; 301 redirect-to-existing.
if resp.status().is_success() || s == 405 || s == 301 {
// 201 created; 405 already exists; 301 redirect-to-existing;
// 403 some servers (openmediavault et al.) return 403 instead of 405.
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
Ok(())
} else {
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
@@ -339,14 +351,29 @@ impl WebdavClient {
Ok(())
}
fn put_with_dirs(
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
/// to keep it consistent with future listings. (issue #147)
fn etag_for(&self, relpath: &str) -> String {
self.propfind(relpath, "0")
.ok()
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
.unwrap_or_default()
}
/// PUT a file (creating parent collections first), then read back its authoritative
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
fn upload(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
) -> Result<String, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
Ok(self.etag_for(relpath))
}
}
@@ -437,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
}
Ok(Event::Text(e)) => {
if cap != Cap::None {
if let Ok(t) = e.unescape() {
buf.push_str(&t);
if let Ok(decoded) = e.decode() {
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
buf.push_str(&text);
}
}
}
}
@@ -499,8 +528,14 @@ fn apply_changes(
match (l, r) {
(Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
let local_changed = match m {
Some(entry) => entry.local_hash != lf.hash,
None => true,
};
let remote_changed = match m {
Some(entry) => &entry.remote_etag != re,
None => true,
};
if !local_changed && !remote_changed {
new_m.files.insert(
key.clone(),
@@ -511,12 +546,12 @@ fn apply_changes(
);
} else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -556,12 +591,12 @@ fn apply_changes(
);
let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
remote_etag: cetag,
},
);
sum.conflicts += 1;
@@ -572,12 +607,12 @@ fn apply_changes(
None => {
// new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -586,12 +621,12 @@ fn apply_changes(
if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
let etag = client.upload(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
remote_etag: etag,
},
);
sum.uploaded += 1;
@@ -653,7 +688,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
/// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed.
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
pub fn run_sync(
app: tauri::AppHandle,
vault: String,
cfg: WebdavConfig,
) -> Result<SyncSummary, String> {
use std::sync::atomic::Ordering;
let state = app.state::<AppState>();
@@ -708,3 +747,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
);
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::is_synced_relpath;
#[test]
fn syncs_notebook_icon_mapping_and_assets_only() {
for path in [
"Notes/plan.md",
".helixnotes/attachments/notebook-icon.png",
".helixnotes/notebook_icons.json",
] {
assert!(is_synced_relpath(path), "expected {path} to be synced");
}
for path in [
"Notes/image.png",
".helixnotes/sync_state.json",
".helixnotes/notebook_icons.json.bak",
".helixnotes/attachments-old/icon.png",
] {
assert!(!is_synced_relpath(path), "expected {path} to stay local");
}
}
}
+189 -2
View File
@@ -50,21 +50,82 @@ pub struct NoteContent {
pub raw: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VaultConfig {
pub path: String,
pub name: String,
#[serde(default)]
pub bookmark_id: Option<String>,
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
#[serde(default)]
pub sync_provider: Option<String>,
#[serde(default)]
pub webdav_url: Option<String>,
#[serde(default)]
pub webdav_username: Option<String>,
#[serde(default)]
pub webdav_password: Option<String>,
#[serde(default)]
pub sync_on_open: bool,
#[serde(default)]
pub sync_on_change: bool,
#[serde(default)]
pub sync_interval_minutes: u32,
#[serde(default)]
pub last_sync_time: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomThemeColors {
pub bg_primary: String,
pub bg_secondary: String,
pub bg_tertiary: String,
pub bg_hover: String,
pub bg_active: String,
pub bg_editor: String,
pub text_primary: String,
pub text_secondary: String,
pub border_color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTheme {
pub id: String,
pub name: String,
pub is_dark: bool,
pub colors: CustomThemeColors,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StartupView {
Daily,
QuickAccess,
Tasks,
#[default]
#[serde(other)]
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub vaults: Vec<VaultConfig>,
pub active_vault: Option<String>,
#[serde(default)]
pub active_bookmark_id: Option<String>,
pub theme: String,
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
#[serde(default = "default_system_light_theme")]
pub system_light_theme: String,
#[serde(default = "default_system_dark_theme")]
pub system_dark_theme: String,
#[serde(default)]
pub accent_color: Option<String>,
#[serde(default)]
pub font_size: Option<u32>,
#[serde(default = "default_true")]
pub scroll_to_change_font_size: bool,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
@@ -72,10 +133,14 @@ pub struct AppConfig {
#[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub content_width: Option<u32>,
#[serde(default)]
pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)]
pub show_note_switcher: bool,
#[serde(default)]
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
@@ -95,6 +160,18 @@ pub struct AppConfig {
pub hide_title_in_body: bool,
#[serde(default)]
pub show_line_numbers: bool,
#[serde(default = "default_true")]
pub show_link_arrows: bool,
#[serde(default = "default_true")]
pub show_all_notes: bool,
#[serde(default = "default_true")]
pub show_quick_access: bool,
#[serde(default = "default_true")]
pub show_tasks: bool,
#[serde(default = "default_true")]
pub show_daily_notes: bool,
#[serde(default = "default_true")]
pub show_trash: bool,
#[serde(default)]
pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")]
@@ -130,14 +207,18 @@ pub struct AppConfig {
#[serde(default)]
pub default_view_mode: bool,
#[serde(default)]
pub new_notes_in_source_mode: bool,
#[serde(default)]
pub show_tray_icon: bool,
#[serde(default)]
pub close_to_tray: bool,
#[serde(default = "default_true")]
pub enable_wiki_links: bool,
#[serde(default)]
pub startup_view: StartupView,
#[serde(default)]
pub restore_last_session: bool,
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
#[serde(default)]
pub sync_provider: Option<String>,
#[serde(default)]
@@ -154,12 +235,18 @@ pub struct AppConfig {
pub sync_interval_minutes: u32,
#[serde(default)]
pub last_sync_time: Option<String>,
#[serde(default)]
pub custom_themes: Vec<CustomTheme>,
}
fn default_true() -> bool {
true
}
fn default_outline_width() -> f64 {
220.0
}
fn default_pdf_height() -> u32 {
600
}
@@ -192,19 +279,33 @@ fn default_ai_model() -> String {
"claude-sonnet-4-6".to_string()
}
fn default_system_light_theme() -> String {
"light".to_string()
}
fn default_system_dark_theme() -> String {
"dark".to_string()
}
impl Default for AppConfig {
fn default() -> Self {
Self {
vaults: Vec::new(),
active_vault: None,
active_bookmark_id: None,
theme: "system".to_string(),
system_light_theme: default_system_light_theme(),
system_dark_theme: default_system_dark_theme(),
accent_color: None,
font_size: None,
scroll_to_change_font_size: true,
font_family: None,
line_height: None,
ui_scale: None,
content_width: None,
compact_notes: false,
show_note_dates: true,
show_note_switcher: false,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
daily_title_format: "localized".to_string(),
@@ -215,6 +316,12 @@ impl Default for AppConfig {
title_mode: "input".to_string(),
hide_title_in_body: false,
show_line_numbers: false,
show_link_arrows: true,
show_all_notes: true,
show_quick_access: true,
show_tasks: true,
show_daily_notes: true,
show_trash: true,
backup_enabled: false,
backup_frequency: "24h".to_string(),
backup_max_count: 10,
@@ -232,9 +339,11 @@ impl Default for AppConfig {
ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None,
default_view_mode: false,
new_notes_in_source_mode: false,
show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true,
startup_view: StartupView::All,
restore_last_session: false,
sync_provider: None,
webdav_url: None,
@@ -244,6 +353,7 @@ impl Default for AppConfig {
sync_on_change: false,
sync_interval_minutes: 0,
last_sync_time: None,
custom_themes: Vec::new(),
}
}
}
@@ -253,14 +363,20 @@ pub struct VaultState {
pub last_open_note: Option<String>,
pub sidebar_width: f64,
pub notelist_width: f64,
#[serde(default = "default_outline_width")]
pub outline_width: f64,
pub sidebar_collapsed: bool,
#[serde(default)]
pub notelist_collapsed: bool,
#[serde(default)]
pub collapsed_notebooks: Vec<String>,
#[serde(default)]
pub notebook_sort_mode: String,
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub note_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String,
#[serde(default)]
pub last_view_mode: String,
@@ -284,10 +400,13 @@ impl Default for VaultState {
last_open_note: None,
sidebar_width: 220.0,
notelist_width: 280.0,
outline_width: default_outline_width(),
sidebar_collapsed: false,
notelist_collapsed: false,
collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(),
note_order: std::collections::HashMap::new(),
sort_mode: String::new(),
last_view_mode: String::new(),
last_notebook: None,
@@ -318,6 +437,9 @@ pub struct FileEvent {
pub struct ImportResult {
pub files_converted: u64,
pub links_converted: u64,
pub frontmatter_normalized: u64,
pub syntax_converted: u64,
pub attachments_moved: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -386,3 +508,68 @@ pub struct TaskItem {
pub due: Option<String>,
pub priority: Option<String>,
}
#[cfg(test)]
mod startup_view_tests {
use super::{AppConfig, StartupView};
#[test]
fn serializes_supported_startup_views() {
for (view, expected) in [
(StartupView::All, "\"all\""),
(StartupView::QuickAccess, "\"quickaccess\""),
(StartupView::Tasks, "\"tasks\""),
(StartupView::Daily, "\"daily\""),
] {
assert_eq!(serde_json::to_string(&view).unwrap(), expected);
}
}
#[test]
fn unknown_startup_view_falls_back_to_all_notes() {
assert_eq!(
serde_json::from_str::<StartupView>("\"future-view\"").unwrap(),
StartupView::All
);
}
#[test]
fn existing_configs_default_system_theme_pair() {
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
let object = value.as_object_mut().unwrap();
object.remove("system_light_theme");
object.remove("system_dark_theme");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.system_light_theme, "light");
assert_eq!(config.system_dark_theme, "dark");
}
#[test]
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(!config.show_note_switcher);
let mut value = serde_json::to_value(config).unwrap();
value.as_object_mut().unwrap().remove("show_note_switcher");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(!config.show_note_switcher);
}
#[test]
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
let config = AppConfig::default();
assert!(config.scroll_to_change_font_size);
let mut value = serde_json::to_value(config).unwrap();
value
.as_object_mut()
.unwrap()
.remove("scroll_to_change_font_size");
let config: AppConfig = serde_json::from_value(value).unwrap();
assert!(config.scroll_to_change_font_size);
}
}
+7 -7
View File
@@ -15,7 +15,7 @@ struct RawFrontmatter {
}
/// Try to parse a date string in multiple common formats.
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
pub fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
let s = s.trim();
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
"[{}]",
meta.tags
.iter()
.map(|t| format!("{}", t))
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(", ")
)
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
format!("---\n{}---\n{}", yaml_str, body_with_heading)
}
fn filename_to_title(filename: &str) -> String {
pub fn filename_to_title(filename: &str) -> String {
let stem = filename.trim_end_matches(".md");
// Only replace dashes/underscores acting as word separators (between non-space chars).
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
chars.next(); // skip '['
let mut depth = 1;
// Skip alt text
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '[' {
depth += 1;
}
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') {
chars.next();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '(' {
depth += 1;
}
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if ch == '[' {
let mut link_text = String::new();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '[' {
depth += 1;
}
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
if chars.peek() == Some(&'(') {
chars.next();
let mut depth = 1;
while let Some(c) = chars.next() {
for c in chars.by_ref() {
if c == '(' {
depth += 1;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
pub mod frontmatter;
pub mod import;
pub mod operations;
pub mod watcher;
File diff suppressed because it is too large Load Diff
+89 -7
View File
@@ -1,4 +1,4 @@
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
@@ -8,14 +8,57 @@ use crate::state::AppState;
use crate::types::FileEvent;
use crate::vault::operations::helixnotes_dir;
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
const IOS_POLL_INTERVAL: Duration = Duration::from_secs(10);
const NATIVE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatcherBackend {
Recommended,
Poll,
}
const fn watcher_backend_for_target(is_ios: bool) -> WatcherBackend {
if is_ios {
WatcherBackend::Poll
} else {
WatcherBackend::Recommended
}
}
const fn poll_interval_for_backend(backend: WatcherBackend) -> Duration {
match backend {
WatcherBackend::Recommended => NATIVE_POLL_INTERVAL,
WatcherBackend::Poll => IOS_POLL_INTERVAL,
}
}
pub enum VaultWatcher {
Recommended(RecommendedWatcher),
Poll(PollWatcher),
}
impl VaultWatcher {
fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
match self {
Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
Self::Poll(watcher) => watcher.watch(path, recursive_mode),
}
}
}
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<VaultWatcher, String> {
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(
tx,
Config::default().with_poll_interval(Duration::from_secs(1)),
)
.map_err(|e| e.to_string())?;
let backend = watcher_backend_for_target(cfg!(target_os = "ios"));
let config = Config::default().with_poll_interval(poll_interval_for_backend(backend));
let mut watcher = match backend {
WatcherBackend::Recommended => VaultWatcher::Recommended(
RecommendedWatcher::new(tx, config).map_err(|e| e.to_string())?,
),
WatcherBackend::Poll => {
VaultWatcher::Poll(PollWatcher::new(tx, config).map_err(|e| e.to_string())?)
}
};
watcher
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
@@ -61,6 +104,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
};
let _ = app.emit("file-changed", &fe);
}
// On mobile, throttle event emission to prevent IPC flooding
// on FUSE filesystems where Syncthing/other apps generate
// constant file activity that blocks the Tauri command channel.
#[cfg(mobile)]
{
std::thread::sleep(Duration::from_secs(2));
while rx.try_recv().is_ok() {}
}
}
Err(e) => {
log::error!("File watcher error: {}", e);
@@ -71,3 +123,33 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
Ok(watcher)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ios_uses_polling_backend() {
assert_eq!(watcher_backend_for_target(true), WatcherBackend::Poll);
}
#[test]
fn other_platforms_keep_recommended_backend() {
assert_eq!(
watcher_backend_for_target(false),
WatcherBackend::Recommended
);
}
#[test]
fn watcher_backends_use_the_expected_intervals() {
assert_eq!(
poll_interval_for_backend(WatcherBackend::Poll),
Duration::from_secs(10)
);
assert_eq!(
poll_interval_for_backend(WatcherBackend::Recommended),
Duration::from_secs(1)
);
}
}
+10 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.1",
"version": "1.3.5",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
@@ -24,10 +24,14 @@
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": {
"enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"]
"scope": {
"allow": [],
"deny": [],
"requireLiteralLeadingDot": true
}
}
}
},
@@ -55,6 +59,9 @@
"appimage": {
"bundleMediaFramework": false
}
},
"macOS": {
"entitlements": "entitlements.plist"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"app": {
"windows": [
{
"label": "main",
"visible": false
}
]
}
}
+54 -4
View File
@@ -173,6 +173,21 @@
--danger-hover: #d43d3d;
--success: #40c057;
--warning: #fab005;
/* Obsidian-style callout accent hues (RGB triplets; mixed with the background via opacity, so one set works on every theme) */
--callout-note: 68, 138, 255;
--callout-abstract: 0, 191, 188;
--callout-info: 0, 184, 212;
--callout-todo: 0, 184, 212;
--callout-tip: 0, 191, 188;
--callout-success: 8, 185, 78;
--callout-question: 236, 191, 0;
--callout-warning: 236, 117, 0;
--callout-failure: 233, 49, 71;
--callout-danger: 233, 49, 71;
--callout-bug: 233, 49, 71;
--callout-example: 120, 82, 238;
--callout-quote: 158, 158, 158;
--callout-custom: 124, 134, 156;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
@@ -204,6 +219,20 @@
--danger-hover: #eba0ac;
--success: #a6e3a1;
--warning: #f9e2af;
--callout-note: 100, 160, 255;
--callout-abstract: 60, 209, 205;
--callout-info: 60, 200, 224;
--callout-todo: 60, 200, 224;
--callout-tip: 60, 209, 205;
--callout-success: 80, 205, 120;
--callout-question: 240, 205, 80;
--callout-warning: 245, 150, 60;
--callout-failure: 240, 95, 115;
--callout-danger: 240, 95, 115;
--callout-bug: 240, 95, 115;
--callout-example: 165, 135, 245;
--callout-quote: 150, 150, 162;
--callout-custom: 150, 160, 182;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
@@ -961,6 +990,31 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.8);
}
:root[data-theme="one-dark"] {
--bg-primary: #282c34;
--bg-secondary: #21252b;
--bg-tertiary: #2c313a;
--bg-hover: #3e4451;
--bg-active: #4b5263;
--bg-editor: #282c34;
--text-primary: #abb2bf;
--text-secondary: #828997;
--text-tertiary: #5c6370;
--text-accent: #61afef;
--border-color: #3e4451;
--border-light: #2c313a;
--accent: #61afef;
--accent-hover: #4d9fdf;
--accent-light: color-mix(in srgb, #61afef 12%, transparent);
--danger: #e06c75;
--danger-hover: #cc5560;
--success: #98c379;
--warning: #e5c07b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
*,
*::before,
*::after {
@@ -1088,10 +1142,6 @@ body.resizing {
/* ── Mobile (Android/iOS) ── */
@media (max-width: 768px) {
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
+78 -3
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
AppConfig,
CustomTheme,
NoteContent,
NoteEntry,
NoteMeta,
@@ -14,14 +15,24 @@ import type {
BackupEntry,
VersionEntry,
TaskItem,
ExternalVaultResult,
StartupView,
} from "./types";
export async function openVault(path: string): Promise<void> {
return invoke("open_vault", { path });
}
export async function removeVault(path: string): Promise<void> {
return invoke("remove_vault", { path });
export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
return invoke("choose_external_vault");
}
export async function restoreExternalVault(bookmarkId: string): Promise<ExternalVaultResult> {
return invoke("restore_external_vault", { bookmarkId });
}
export async function removeVault(path: string, bookmarkId?: string | null): Promise<void> {
return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null });
}
export async function getAppConfig(): Promise<AppConfig> {
@@ -32,14 +43,38 @@ export async function setTheme(theme: string): Promise<void> {
return invoke("set_theme", { theme });
}
export async function setSystemThemes(light: string, dark: string): Promise<void> {
return invoke("set_system_themes", { light, dark });
}
export async function setAccentColor(color: string): Promise<void> {
return invoke("set_accent_color", { color });
}
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
return invoke("save_custom_theme", { theme });
}
export async function deleteCustomTheme(id: string): Promise<void> {
return invoke("delete_custom_theme", { id });
}
export async function exportCustomTheme(id: string, path: string): Promise<void> {
return invoke("export_custom_theme", { id, path });
}
export async function importCustomThemes(path: string): Promise<CustomTheme[]> {
return invoke("import_custom_themes", { path });
}
export async function setFontSize(size: number): Promise<void> {
return invoke("set_font_size", { size });
}
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
return invoke("set_scroll_to_change_font_size", { enabled });
}
export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family });
}
@@ -52,6 +87,10 @@ export async function setUiScale(scale: number): Promise<void> {
return invoke("set_ui_scale", { scale });
}
export async function setContentWidth(width: number | null): Promise<void> {
return invoke("set_content_width", { width });
}
export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks");
}
@@ -110,6 +149,10 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title });
}
export async function duplicateNote(path: string): Promise<NoteEntry> {
return invoke("duplicate_note", { path });
}
export async function createDailyNote(date?: string): Promise<NoteEntry> {
return invoke("create_daily_note", { date: date ?? null });
}
@@ -140,7 +183,13 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
return invoke("get_all_note_titles");
}
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
export async function getNoteSwitcherTitles(
recentPaths: string[],
): Promise<NoteTitleEntry[]> {
return invoke("get_note_switcher_titles", { recentPaths });
}
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number; bidirectional: boolean }[] }> {
return invoke("get_graph_data");
}
@@ -186,6 +235,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
return invoke("save_vault_state", { vaultState });
}
export async function copyTextToClipboard(text: string): Promise<void> {
return invoke("copy_text_to_clipboard", { text });
}
export async function readClipboardImage(): Promise<number[]> {
return invoke("read_clipboard_image");
}
@@ -228,12 +281,21 @@ export async function setGeneralSettings(
titleMode: string,
hideTitleInBody: boolean,
showLineNumbers: boolean,
showLinkArrows: boolean,
defaultViewMode: boolean,
newNotesInSourceMode: boolean,
showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
showNoteSwitcher: boolean,
startupView: StartupView,
restoreLastSession: boolean,
showAllNotes: boolean,
showQuickAccess: boolean,
showTasks: boolean,
showDailyNotes: boolean,
showTrash: boolean,
): Promise<void> {
return invoke("set_general_settings", {
compactNotes,
@@ -247,12 +309,21 @@ export async function setGeneralSettings(
titleMode,
hideTitleInBody,
showLineNumbers,
showLinkArrows,
defaultViewMode,
newNotesInSourceMode,
showTrayIcon,
closeToTray,
enableWikiLinks,
showNoteDates,
showNoteSwitcher,
startupView,
restoreLastSession,
showAllNotes,
showQuickAccess,
showTasks,
showDailyNotes,
showTrash,
});
}
@@ -297,6 +368,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path });
}
export async function revealFile(path: string): Promise<void> {
return invoke("reveal_file", { path });
}
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
+338 -157
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import Sidebar from './Sidebar.svelte';
import NoteList from './NoteList.svelte';
@@ -14,11 +14,15 @@
import {
sidebarWidth,
notelistWidth,
outlineWidth,
sidebarCollapsed,
notelistCollapsed,
collapsedNotebooks,
showSearch,
showCommandPalette,
theme,
resolvedTheme,
customThemes,
focusMode,
readOnly,
activeNote,
@@ -37,6 +41,7 @@
rootNoteCount,
viewMode,
sortMode,
groupNotesByDate,
tasksLayout,
tasksHideCompleted,
tasksOnlyFlagged,
@@ -48,19 +53,24 @@
viewerNote,
notebookSortMode,
notebookOrder,
noteOrder,
syncState,
platformIsMobile
platformIsMobile,
activeVaultConfig,
} from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
import { get } from 'svelte/store';
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
import type { StartupTarget } from '$lib/utils/startup-view';
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
for (const nb of list) {
@@ -71,10 +81,42 @@
return null;
}
let sidebar: Sidebar;
let noteList: NoteList;
let editor: Editor;
let sidebar = $state<Sidebar>();
let noteList = $state<NoteList>();
let editor = $state<Editor>();
let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') {
const vault = $appConfig?.active_vault;
const notebook = target.notebookPath === '' && vault
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
: findNotebookByPath($notebooks, target.notebookPath);
if (!notebook) return false;
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
$viewMode = 'notebook';
$activeNotebook = notebook;
$activeTag = null;
if (changed) await noteList?.refresh();
return true;
}
if (target.mode === 'tag') {
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
$viewMode = 'tag';
$activeTag = target.tag;
$activeNotebook = null;
if (changed) await noteList?.refresh();
return true;
}
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
$viewMode = target.mode;
$activeNotebook = null;
$activeTag = null;
if (changed) await noteList?.refresh();
return true;
}
let unlistenOpenFile: (() => void) | null = null;
// Mobile editor header helpers
@@ -116,16 +158,16 @@
}
function syncConfigured(): boolean {
return get(appConfig)?.sync_provider === 'webdav';
return activeVaultConfig(get(appConfig))?.sync_provider === 'webdav';
}
// Auto-sync on a timer (only when configured and an interval is set).
async function checkScheduledSync() {
const config = get(appConfig);
if (config?.sync_provider !== 'webdav') return;
const mins = config.sync_interval_minutes ?? 0;
const vc = activeVaultConfig(get(appConfig));
if (vc?.sync_provider !== 'webdav') return;
const mins = vc.sync_interval_minutes ?? 0;
if (!mins || get(syncState).running) return;
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0;
const last = vc.last_sync_time ? new Date(vc.last_sync_time).getTime() : 0;
if (Date.now() - last >= mins * 60 * 1000) {
try { await syncNow(); } catch (_) {}
}
@@ -198,11 +240,15 @@
last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth,
outline_width: $outlineWidth,
sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
note_order: $noteOrder,
sort_mode: $sortMode,
group_notes_by_date: $groupNotesByDate,
last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null,
last_tag: $activeTag,
@@ -226,20 +272,58 @@
persistState();
}
function revealNoteList() {
$notelistCollapsed = false;
tick().then(() => noteList?.refresh());
}
function toggleNoteList() {
const nextCollapsed = !$notelistCollapsed;
$notelistCollapsed = nextCollapsed;
if (!nextCollapsed) {
tick().then(() => noteList?.refresh());
}
}
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false);
function handleNoteSelected(path: string, content: string) {
function handleNoteSelected(path: string, content: string, task?: TaskItem) {
// Selecting a real vault note exits viewer mode
$viewerNote = null;
taskNoteOpened = true;
editor?.loadNote(path, content);
editor?.loadNote(path, content, task);
if (isMobile) $mobileView = 'editor';
}
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
const currentPath = $activeNotePath;
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
editor?.flushSave();
try {
const content = await readNote(path);
$viewerNote = null;
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
handleNoteSelected(path, content.content);
return true;
} catch (e) {
console.error('Failed to switch note:', e);
return false;
}
}
function handleViewChanged() {
taskNoteOpened = false;
noteList?.refresh();
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
if ($notelistCollapsed) {
revealNoteList();
} else {
noteList?.refresh();
}
if (isMobile) $mobileView = 'notelist';
}
@@ -360,6 +444,16 @@
navFromPopstate = false;
return;
}
// If a modal is open, close it instead of navigating
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
$showSettings = false;
$showInfo = false;
$showSearch = false;
$showCommandPalette = false;
// Re-push the current state so the next back still works
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
return;
}
const state = e.state;
const targetDepth = state?.depth ?? 0;
historyDepth = targetDepth;
@@ -375,6 +469,28 @@
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
}
async function trashOpenNote(path: string): Promise<boolean> {
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
try {
await deleteNote(path);
if (Object.hasOwn($noteOrder, path)) {
const { [path]: _, ...rest } = $noteOrder;
$noteOrder = rest;
}
$notes = $notes.filter((note) => note.path !== path);
if ($activeNotePath === path) {
$activeNote = null;
$activeNotePath = null;
}
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
if (isMobile) $mobileView = 'notelist';
return true;
} catch (error) {
console.error('Failed to move open note to Trash:', error);
return false;
}
}
function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
@@ -383,88 +499,84 @@
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
navigateHistory(-1);
return;
}
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1);
return;
}
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
createAndFocusNote();
return;
}
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault();
$showSearch = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
}
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
$showCommandPalette = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
editor?.forceSave();
return;
}
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && code === 'KeyM') {
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
e.preventDefault();
$sourceMode = !$sourceMode;
editor?.moveOpenNoteToTrash();
return;
}
if (mod && e.shiftKey && code === 'KeyW') {
const action = matchAction(e, $keybindings);
if (action) {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
switch (action) {
case 'nav-back':
navigateHistory(-1);
return;
case 'nav-forward':
navigateHistory(1);
return;
case 'new-note':
createAndFocusNote();
return;
case 'toggle-theme': {
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
case 'search-vault':
$showSearch = true;
return;
case 'find-in-note':
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
case 'quick-open':
$showCommandPalette = true;
return;
case 'save':
editor?.forceSave();
return;
case 'toggle-source':
$sourceMode = !$sourceMode;
return;
case 'open-new-window':
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
return;
case 'toggle-sidebar':
$sidebarCollapsed = !$sidebarCollapsed;
return;
case 'toggle-notelist':
toggleNoteList();
return;
case 'toggle-focus':
$focusMode = !$focusMode;
return;
case 'toggle-readonly':
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
case 'fullscreen':
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
}
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
@@ -474,24 +586,6 @@
}
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
}
$effect(() => {
applyTheme($theme);
});
$effect(() => {
$collapsedNotebooks;
persistState();
@@ -502,14 +596,27 @@
persistState();
});
$effect(() => {
$sidebarCollapsed;
$notelistCollapsed;
$outlineWidth;
persistState();
});
$effect(() => {
$notebookSortMode;
$notebookOrder;
persistState();
});
$effect(() => {
$noteOrder;
persistState();
});
$effect(() => {
$sortMode;
$groupNotesByDate;
$tasksLayout;
$tasksHideCompleted;
$tasksOnlyFlagged;
@@ -533,11 +640,15 @@
const state = await loadVaultState();
$sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width;
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
$sidebarCollapsed = state.sidebar_collapsed;
$notelistCollapsed = state.notelist_collapsed ?? false;
$collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
$noteOrder = state.note_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
@@ -548,47 +659,60 @@
lastTag = state.last_tag ?? null;
} catch (_) {}
const restoreLastSession = $appConfig?.restore_last_session === true;
// On mobile, prefetch last-opened note so first tap is instant
let prefetchPromise: Promise<any> | null = null;
if (isMobile && lastNotePath) {
if (isMobile && restoreLastSession && lastNotePath) {
prefetchPromise = readNote(lastNotePath).catch(() => null);
}
applyTheme($theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if ($theme === 'system') applyTheme('system');
});
// Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
// Restore the last session (view + open note) if enabled.
if ($appConfig?.restore_last_session) {
const vault = $appConfig?.active_vault;
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
await noteList?.refresh();
} else if (lastViewMode === 'notebook' && lastNotebook) {
const nb = findNotebookByPath($notebooks, lastNotebook);
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
} else if (lastViewMode === 'tag' && lastTag) {
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
} else if (lastViewMode === 'quickaccess') {
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
}
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
if (!isMobile && lastNotePath) {
// Full-vault attachment scans cause navigation stalls on mobile storage.
// Mobile users can run the same cleanup explicitly from the Info panel.
if (!isMobile) {
setTimeout(async () => {
if (get(editorDirty)) return; // skip if user is actively editing
try {
const content = await readNote(lastNotePath);
$activeNote = content;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, content.content);
const orphans = await findOrphanedAttachments();
if (orphans.length > 0) {
if (get(editorDirty)) return; // re-check after async scan
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
if (moved > 0) {
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
document.body.appendChild(toast);
requestAnimationFrame(() => (toast.style.opacity = '1'));
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
}
}
} catch (_) {}
}
}, 3000);
}
const startupTarget = resolveStartupTarget({
startupView: $appConfig?.startup_view,
restoreLastSession,
lastViewMode,
lastNotebook,
lastTag
});
if (!(await applyStartupTarget(startupTarget))) {
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
}
// Reopen the last note only when session restoration is enabled.
if (restoreLastSession && !isMobile && lastNotePath) {
try {
const content = await readNote(lastNotePath);
$activeNote = content;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, content.content);
} catch (_) {}
}
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
@@ -628,7 +752,7 @@
}
}
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
}, 3000);
}, 10000);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedRefresh();
});
@@ -658,15 +782,16 @@
// ── WebDAV sync: global status + auto-sync triggers ──
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
unlistenSync.push(await listen('sync-done', (event: any) => {
unlistenSync.push(await listen('sync-done', async () => {
syncState.set({ running: false, error: null });
const cur = get(appConfig);
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
try {
appConfig.set(await getAppConfig());
} catch {}
}));
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
// Sync when the vault opens (if enabled)
if (syncConfigured() && get(appConfig)?.sync_on_open) {
if (syncConfigured() && activeVaultConfig(get(appConfig))?.sync_on_open) {
try { await syncNow(); } catch (_) {}
}
@@ -676,8 +801,8 @@
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
unsubDirty = editorDirty.subscribe((d) => {
const config = get(appConfig);
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
const vc = activeVaultConfig(get(appConfig));
if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
}
@@ -704,7 +829,7 @@
<!-- Mobile Header -->
<div class="mobile-header">
{#if $mobileView !== 'sidebar'}
<button class="mobile-header-btn" onclick={mobileBack}>
<button class="mobile-header-btn" onclick={mobileBack} aria-label="Go back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
@@ -793,7 +918,7 @@
</svg>
</button>
{/if}
{#if $appConfig?.sync_provider === 'webdav'}
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
@@ -820,6 +945,13 @@
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
@@ -836,10 +968,10 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
</div>
</div>
@@ -883,7 +1015,7 @@
</div>
</div>
{:else}
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
{/if}
<div class="app-layout">
{#if !$focusMode}
@@ -895,15 +1027,28 @@
<ResizeHandle onResize={handleSidebarResize} />
{/if}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
{#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
<ResizeHandle onResize={handleNotelistResize} />
{:else}
<div class="notelist-restore-panel">
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
<polyline points="5 8 3 12 5 16" />
</svg>
</button>
</div>
{/if}
{/if}
<div class="editor-panel">
<Editor bind:this={editor} />
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
{#if $viewMode === 'tasks' && !taskNoteOpened}
<div class="tasks-editor-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
@@ -918,7 +1063,7 @@
{/if}
<SearchPanel />
<CommandPalette />
<CommandPalette onNavigate={handleViewChanged} />
<SettingsPanel />
<InfoPanel />
@@ -949,6 +1094,36 @@
overflow: hidden;
}
.notelist-restore-panel {
flex-shrink: 0;
width: 36px;
height: 100%;
padding-top: 8px;
display: flex;
justify-content: center;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
}
.notelist-restore-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.notelist-restore-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-panel {
flex: 1;
height: 100%;
@@ -1035,7 +1210,13 @@
.mobile-shell {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
box-sizing: border-box;
padding:
env(safe-area-inset-top, 0px)
env(safe-area-inset-right, 0px)
env(safe-area-inset-bottom, 0px)
env(safe-area-inset-left, 0px);
overflow: hidden;
background: var(--bg-primary);
}
+41 -5
View File
@@ -1,6 +1,19 @@
<script lang="ts">
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
import { setTheme, reindex } from '$lib/api';
import { darkThemes } from '$lib/platform';
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
$viewMode = mode;
$activeNotebook = null;
$activeTag = null;
$showCommandPalette = false;
onNavigate();
}
interface Command {
id: string;
@@ -25,6 +38,31 @@
$showSearch = true;
}
},
{
id: 'open-all-notes',
label: 'Open All Notes',
action: () => openView('all')
},
{
id: 'open-quick-access',
label: 'Open Quick Access',
action: () => openView('quickaccess')
},
{
id: 'open-tasks',
label: 'Open Tasks',
action: () => openView('tasks')
},
{
id: 'open-daily',
label: 'Open Daily Notes',
action: () => openView('daily')
},
{
id: 'open-trash',
label: 'Open Trash (restore deleted notes)',
action: () => openView('trash')
},
{
id: 'theme-light',
label: 'Switch to Light Theme',
@@ -51,7 +89,6 @@
action: () => {
$theme = 'system';
setTheme('system');
applyTheme('system');
$showCommandPalette = false;
}
},
@@ -142,15 +179,14 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
} else if (t === 'dark') {
root.classList.add('dark');
}
}
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -2,14 +2,13 @@
import { onDestroy } from 'svelte';
import { getGraphData } from '$lib/api';
import { activeNotePath, appConfig } from '$lib/stores/app';
import { isMobile } from '$lib/platform';
let { onclose, onnavigate }: {
onclose: () => void;
onnavigate: (path: string, title: string) => void;
} = $props();
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true);
let searchQuery = $state('');
@@ -21,12 +20,12 @@
interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
interface LabelRect { x: number; y: number; w: number; h: number; }
let nodes: GraphNode[] = [];
let edges: GraphEdge[] = [];
let nodes = $state.raw<GraphNode[]>([]);
let edges = $state.raw<GraphEdge[]>([]);
let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set();
let nodeDegree: number[] = [];
let searchMatchSet: Set<number> | null = null;
let searchMatchSet = $state.raw<Set<number> | null>(null);
let folderColorMap = new Map<string, string>();
let pan = { x: 0, y: 0 };
@@ -41,7 +40,7 @@
let hoveredNeighborSet: Set<number> = new Set();
let hoveredEdgeSet: Set<number> = new Set();
let glowPhase = 0;
let activeNodeIdx = -1;
let activeNodeIdx = $state(-1);
let navigatedFromGraph = false;
// Combined animation loop
+385
View File
@@ -0,0 +1,385 @@
<script lang="ts">
import { onMount } from 'svelte';
let { src, alt = '', onclose }: {
src: string;
alt?: string;
onclose: () => void;
} = $props();
const MIN_SCALE = 1;
const MAX_SCALE = 5;
const DOUBLE_TAP_MS = 300;
const TAP_MOVE_THRESHOLD = 6;
type Point = { x: number; y: number };
type ViewerGesture =
| { kind: 'pan'; start: Point; pan: Point }
| { kind: 'pinch'; distance: number; imagePoint: Point };
let canvasElement = $state<HTMLDivElement | null>(null);
let imageElement = $state<HTMLImageElement | null>(null);
let closeButton = $state<HTMLButtonElement | null>(null);
let scale = $state(1);
let panX = $state(0);
let panY = $state(0);
let interacting = $state(false);
let gesture: ViewerGesture | null = null;
let touchMoved = false;
let lastTapAt = 0;
let zoomPercent = $derived(Math.round(scale * 100));
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function midpoint(first: Touch, second: Touch): Point {
return {
x: (first.clientX + second.clientX) / 2,
y: (first.clientY + second.clientY) / 2,
};
}
function touchDistance(first: Touch, second: Touch): number {
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
}
function panLimits(nextScale: number): Point {
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
const imageWidth = imageElement?.clientWidth ?? 0;
const imageHeight = imageElement?.clientHeight ?? 0;
return {
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
};
}
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
const limits = panLimits(clampedScale);
scale = clampedScale;
panX = clamp(nextPanX, -limits.x, limits.x);
panY = clamp(nextPanY, -limits.y, limits.y);
}
function resetViewer() {
interacting = false;
gesture = null;
applyTransform(MIN_SCALE, 0, 0);
}
function zoomAt(point: Point, nextScale: number) {
const canvasRect = canvasElement?.getBoundingClientRect();
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
const imageX = (point.x - centerX - panX) / scale;
const imageY = (point.y - centerY - panY) / scale;
applyTransform(
nextScale,
point.x - centerX - imageX * nextScale,
point.y - centerY - imageY * nextScale,
);
}
function beginPan(touch: Touch) {
gesture = {
kind: 'pan',
start: { x: touch.clientX, y: touch.clientY },
pan: { x: panX, y: panY },
};
}
function beginPinch(first: Touch, second: Touch) {
const point = midpoint(first, second);
const canvasRect = canvasElement?.getBoundingClientRect();
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
gesture = {
kind: 'pinch',
distance: Math.max(1, touchDistance(first, second)),
imagePoint: {
x: (point.x - centerX - panX) / scale,
y: (point.y - centerY - panY) / scale,
},
};
}
function handleTouchStart(event: TouchEvent) {
event.preventDefault();
interacting = true;
if (event.touches.length >= 2) {
touchMoved = true;
beginPinch(event.touches[0], event.touches[1]);
return;
}
if (event.touches.length === 1) {
touchMoved = false;
beginPan(event.touches[0]);
}
}
function handleTouchMove(event: TouchEvent) {
event.preventDefault();
if (event.touches.length >= 2) {
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
if (gesture?.kind !== 'pinch') return;
const point = midpoint(event.touches[0], event.touches[1]);
const canvasRect = canvasElement?.getBoundingClientRect();
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
const nextScale = clamp(
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
MIN_SCALE,
MAX_SCALE,
);
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
applyTransform(
nextScale,
point.x - centerX - gesture.imagePoint.x * nextScale,
point.y - centerY - gesture.imagePoint.y * nextScale,
);
touchMoved = true;
return;
}
if (event.touches.length === 1 && gesture?.kind === 'pan') {
const dx = event.touches[0].clientX - gesture.start.x;
const dy = event.touches[0].clientY - gesture.start.y;
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
}
}
function handleTouchEnd(event: TouchEvent) {
event.preventDefault();
if (event.touches.length >= 2) {
beginPinch(event.touches[0], event.touches[1]);
return;
}
if (event.touches.length === 1) {
beginPan(event.touches[0]);
touchMoved = true;
return;
}
interacting = false;
gesture = null;
if (!touchMoved && event.changedTouches.length > 0) {
const now = performance.now();
const touch = event.changedTouches[0];
if (now - lastTapAt <= DOUBLE_TAP_MS) {
if (scale > MIN_SCALE + 0.01) resetViewer();
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
lastTapAt = 0;
} else {
lastTapAt = now;
}
}
}
function handleTouchCancel(event: TouchEvent) {
event.preventDefault();
interacting = false;
gesture = null;
touchMoved = false;
applyTransform(scale, panX, panY);
}
function attachTouchGestures(node: HTMLElement) {
node.addEventListener('touchstart', handleTouchStart, { passive: false });
node.addEventListener('touchmove', handleTouchMove, { passive: false });
node.addEventListener('touchend', handleTouchEnd, { passive: false });
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
return {
destroy() {
node.removeEventListener('touchstart', handleTouchStart);
node.removeEventListener('touchmove', handleTouchMove);
node.removeEventListener('touchend', handleTouchEnd);
node.removeEventListener('touchcancel', handleTouchCancel);
},
};
}
onMount(() => {
closeButton?.focus();
const handleResize = () => applyTransform(scale, panX, panY);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
</script>
<div
class="image-viewer"
role="dialog"
aria-modal="true"
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
tabindex="-1"
onkeydown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
onclose();
}
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="image-viewer-canvas"
bind:this={canvasElement}
use:attachTouchGestures
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
onkeydown={(event) => {
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
}}
>
<img
bind:this={imageElement}
class:interacting
src={src}
alt={alt}
draggable="false"
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
onload={resetViewer}
/>
</div>
<div class="image-viewer-controls">
<button
type="button"
class="image-viewer-reset"
onclick={resetViewer}
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
aria-label={`Reset zoom, currently ${zoomPercent}%`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v6h6" />
</svg>
<span>{zoomPercent}%</span>
</button>
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
</div>
<style>
.image-viewer {
position: fixed;
inset: 0;
z-index: 10000;
background: #06080c;
color: #fff;
overflow: hidden;
overscroll-behavior: contain;
}
.image-viewer-canvas {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.image-viewer-canvas img {
display: block;
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 24px);
object-fit: contain;
transform-origin: center center;
transition: transform 160ms ease-out;
will-change: transform;
-webkit-user-drag: none;
user-select: none;
}
.image-viewer-canvas img.interacting {
transition: none;
}
.image-viewer-controls {
position: absolute;
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.image-viewer-controls button {
min-height: 44px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 23, 30, 0.86);
color: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}
.image-viewer-reset {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-radius: 10px;
font: inherit;
font-size: 13px;
font-weight: 600;
}
.image-viewer-reset:disabled {
opacity: 0.45;
}
.image-viewer-close {
display: grid;
place-items: center;
width: 44px;
padding: 0;
border-radius: 50%;
}
.image-viewer-hint {
position: absolute;
left: 50%;
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
transform: translateX(-50%);
width: max-content;
max-width: calc(100vw - 32px);
padding: 8px 12px;
border-radius: 8px;
background: rgba(20, 23, 30, 0.82);
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
line-height: 1.35;
text-align: center;
pointer-events: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.image-viewer-canvas img {
transition: none;
}
}
</style>
+160 -205
View File
@@ -1,75 +1,114 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { showInfo } from '$lib/stores/app';
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
import {
ACTIONS,
keybindings,
setBinding,
resetBinding,
bindingFromEvent,
bindingToKeys,
bindingsEqual,
type ActionDef,
} from '$lib/keybindings';
import { isMobile } from '$lib/platform';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let stats = $state<VaultStats | null>(null);
// Customizable shortcuts, grouped for display.
const actionGroups: { title: string; actions: ActionDef[] }[] = [
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
];
// id of the action currently listening for a new key combo, or null.
let capturingId = $state<string | null>(null);
// id of an action whose row should briefly flash a "already in use" warning.
let conflictId = $state<string | null>(null);
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
function startCapture(id: string) {
conflictId = null;
capturingId = id;
}
function stopCapture() {
capturingId = null;
}
function flashConflict(id: string) {
conflictId = id;
if (conflictTimer) clearTimeout(conflictTimer);
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
}
function captureKeydown(e: KeyboardEvent) {
if (!capturingId) return;
// Capture phase + stopImmediatePropagation keeps the combo from also
// triggering the app-level shortcut handler on window.
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') { stopCapture(); return; }
const binding = bindingFromEvent(e);
if (!binding) return; // modifier-only press; keep listening
// Reject combos already bound to a different action.
const map = $keybindings;
const target = capturingId;
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
if (clash) {
flashConflict(target);
stopCapture();
return;
}
setBinding(target, binding);
stopCapture();
}
$effect(() => {
if (!capturingId) return;
window.addEventListener('keydown', captureKeydown, true);
return () => window.removeEventListener('keydown', captureKeydown, true);
});
function resetKey(e: MouseEvent, id: string) {
e.preventDefault();
resetBinding(id);
if (capturingId === id) stopCapture();
}
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
let orphans = $state<OrphanAttachment[] | null>(null);
let scanning = $state(false);
let trashing = $state(false);
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
function close() {
$showInfo = false;
activeTab = 'shortcuts';
activeTab = isMobile ? 'about' : 'shortcuts';
}
function closeFromOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) close();
}
function openLink(url: string) {
openUrl(url).catch(console.error);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
$effect(() => {
if ($showInfo) {
getVaultStats().then((s) => { stats = s; }).catch(console.error);
} else {
stats = null;
orphans = null;
}
});
async function scanOrphans() {
scanning = true;
try { orphans = await findOrphanedAttachments(); }
catch (e) { console.error(e); }
finally { scanning = false; }
}
async function trashOrphans() {
if (!orphans || orphans.length === 0) return;
trashing = true;
try {
await trashOrphanedAttachments(orphans.map((o) => o.name));
orphans = null;
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
} catch (e) { console.error(e); }
finally { trashing = false; }
}
</script>
{#if $showInfo}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<div class="info-panel">
<div class="info-header">
<h2>Info</h2>
<button class="close-btn" onclick={close}>
<button class="close-btn" onclick={close} aria-label="Close info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -101,49 +140,7 @@
<p class="app-version">v{appVersion}</p>
<p class="app-description">A local markdown note-taking app.</p>
{#if stats}
<div class="info-stats">
<div class="stat-row">
<span class="stat-label">Notes</span>
<span class="stat-value">{stats.total_notes}</span>
</div>
<div class="stat-row">
<span class="stat-label">Attachments</span>
<span class="stat-value">{stats.total_attachments}</span>
</div>
<div class="stat-row">
<span class="stat-label">Notes size</span>
<span class="stat-value">{formatSize(stats.notes_size)}</span>
</div>
<div class="stat-row">
<span class="stat-label">Attachments size</span>
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
</div>
<div class="stat-row stat-total">
<span class="stat-label">Total vault size</span>
<span class="stat-value">{formatSize(stats.total_size)}</span>
</div>
</div>
{/if}
{#if stats}
<div class="info-cleanup">
{#if orphans === null}
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
{:else if orphans.length === 0}
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
<div class="cleanup-list">
{#each orphans as o}
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
{/each}
</div>
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
{/if}
<div class="info-credits">
<p>Created by <strong>Yuri Karamian</strong></p>
@@ -158,27 +155,39 @@
</div>
{:else}
<div class="shortcuts-section">
<h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
{#if !isMobile}
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
{/if}
{#each actionGroups as group}
<h4 class="shortcuts-group-title">{group.title}</h4>
{#each group.actions as action}
<div class="shortcut-row">
<span class="shortcut-desc">{action.label}</span>
{#if isMobile}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{:else}
<button
class="shortcut-key-btn"
class:capturing={capturingId === action.id}
class:conflict={conflictId === action.id}
title="Click to rebind · right-click to reset"
onclick={() => startCapture(action.id)}
oncontextmenu={(e) => resetKey(e, action.id)}
>
{#if capturingId === action.id}
<span class="capture-prompt">Press keys…</span>
{:else if conflictId === action.id}
<span class="conflict-msg">Already in use</span>
{:else}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{/if}
</button>
{/if}
</div>
{/each}
{/each}
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
@@ -186,9 +195,11 @@
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
@@ -328,39 +339,6 @@
margin-top: 4px;
}
.info-stats {
width: 100%;
margin-top: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 4px 0;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 7px 16px;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
}
.stat-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.stat-total {
border-top: 1px solid var(--border-light);
}
.stat-total .stat-value {
color: var(--accent);
}
.info-credits {
margin-top: 20px;
@@ -392,71 +370,6 @@
.info-link:hover {
background: var(--accent-light);
}
.info-cleanup {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cleanup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
padding: 7px 14px;
cursor: pointer;
}
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
.cleanup-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.shortcuts-section {
width: 100%;
@@ -501,6 +414,48 @@
color: var(--text-tertiary);
}
.shortcuts-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 4px;
}
.shortcut-key-btn {
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 4px;
margin: -2px -4px;
cursor: pointer;
display: flex;
align-items: center;
flex-shrink: 0;
font: inherit;
}
.shortcut-key-btn:hover {
background: var(--bg-hover);
}
.shortcut-key-btn.capturing {
border-color: var(--accent);
background: var(--accent-light);
}
.shortcut-key-btn.conflict {
border-color: var(--danger, #e11d48);
}
.capture-prompt {
font-size: 12px;
color: var(--accent);
}
.conflict-msg {
font-size: 12px;
color: var(--danger, #e11d48);
}
.shortcut-keys kbd,
.shortcut-desc kbd {
display: inline-block;
+360 -77
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import {
notes,
sortedNotes,
@@ -8,9 +9,12 @@
activeNotebook,
activeTag,
sortMode,
groupNotesByDate,
editorDirty,
quickAccessPaths,
appConfig,
notelistCollapsed,
noteOrder,
notebooks,
tags,
mobileView
@@ -19,6 +23,7 @@
getNotes,
readNote,
createNote,
duplicateNote,
deleteNote,
renameNote,
saveNote,
@@ -32,19 +37,22 @@
reorderQuickAccess,
moveNote,
getAllTags,
createDailyNote
createDailyNote,
revealFile
} from '$lib/api';
import { formatRelativeTime, formatDate } from '$lib/utils/time';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
import { isMobile, isAndroid } from '$lib/platform';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string) => void;
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
onBeforeNoteDuplicate?: () => Promise<boolean>;
onNoteCreated?: () => void;
onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
@@ -52,8 +60,6 @@
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false);
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
let trashBusy = $state<string | null>(null);
@@ -143,6 +149,7 @@
let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null);
let editValue = $state('');
let renameInput: HTMLInputElement | null = $state(null);
let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null);
let tagEditTags = $state<string[]>([]);
@@ -203,6 +210,29 @@
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
let noteDragFrom = $state<number | null>(null);
let noteDragOver = $state<number | null>(null);
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
const customOrderViews = new Set(['all', 'notebook', 'tag']);
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
// Relative-date grouping (All Notes + date sorts only). Pinned notes form their own leading group.
let groupingActive = $derived($groupNotesByDate && $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified'));
let noteGroups = $derived.by(() => {
if (!groupingActive) return [] as { label: string; notes: NoteEntry[] }[];
const pinned: NoteEntry[] = [];
const rest: NoteEntry[] = [];
for (const n of $sortedNotes) (n.meta.pinned ? pinned : rest).push(n);
const groups: { label: string; notes: NoteEntry[] }[] = [];
if (pinned.length) groups.push({ label: 'Pinned', notes: pinned });
let cur: { label: string; notes: NoteEntry[] } | null = null;
for (const n of rest) {
const label = dateBucketLabel($sortMode === 'created' ? n.meta.created : n.meta.modified);
if (!cur || cur.label !== label) { cur = { label, notes: [] }; groups.push(cur); }
cur.notes.push(n);
}
return groups;
});
// Virtual scroll
let listContainer = $state<HTMLDivElement>(null!);
@@ -238,6 +268,19 @@
if (listContainer) listContainer.scrollTop = 0;
});
// Notes can also be removed from this list by a drop handled in the sidebar.
$effect(() => {
if (selectedPaths.size === 0) return;
const availablePaths = new Set($notes.map((note) => note.path));
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
if (remaining.size === selectedPaths.size) return;
if (remaining.size === 0) {
clearSelection();
} else {
selectedPaths = remaining;
}
});
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
@@ -382,13 +425,14 @@
$activeNote = content;
$activeNotePath = task.note_path;
$editorDirty = false;
onNoteSelected(task.note_path, content.content);
onNoteSelected(task.note_path, content.content, task);
} catch (e) {
console.error('Failed to open task note:', e);
}
}
export async function handleCreateNote() {
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
if ($viewMode === 'daily') {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -412,15 +456,49 @@
}
try {
const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear();
await refresh();
await selectNote(entry);
$notes = [entry, ...$notes];
onBeforeNoteSwitch();
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
$activeNotePath = entry.path;
$editorDirty = false;
onNoteSelected(entry.path, '\n');
onNoteCreated();
} catch (e) {
console.error('Failed to create note:', e);
}
}
async function handleDuplicate(note: NoteEntry) {
contextMenu = null;
try {
if ($activeNotePath === note.path && $editorDirty) {
const saved = await onBeforeNoteDuplicate();
if (!saved) {
console.error('Failed to save note before duplicating');
return;
}
}
const entry = await duplicateNote(note.path);
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear();
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
await selectNote(entry);
} catch (e) {
console.error('Failed to duplicate note:', e);
}
}
function handleListDoubleClick(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
e.stopPropagation();
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
handleCreateNote();
}
async function handleRename(note: NoteEntry) {
if (!editValue.trim() || editValue.trim() === note.meta.title) {
editingNote = null;
@@ -429,6 +507,7 @@
try {
const newTitle = editValue.trim();
const newPath = await renameNote(note.path, newTitle);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear();
editingNote = null;
// Update local store immediately (avoid full vault re-scan)
@@ -450,6 +529,7 @@
contextMenu = null;
try {
await deleteNote(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear();
// Remove from local store immediately (avoid full vault re-scan)
$notes = $notes.filter(n => n.path !== note.path);
@@ -462,6 +542,7 @@
}
}
async function handleRestore(note: NoteEntry) {
contextMenu = null;
try {
@@ -477,6 +558,7 @@
contextMenu = null;
try {
await permanentDelete(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) {
@@ -670,11 +752,60 @@
}
}
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
// Merge into (not replace) the order map so reordering notes in one view does
// not wipe the saved custom order of other notebooks / tags / the all-notes view.
const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
$noteOrder = { ...$noteOrder, ...updates };
}
function appendManualNoteOrder(path: string) {
const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1;
$noteOrder = { ...$noteOrder, [path]: nextIndex };
}
function renameManualNoteOrderPath(oldPath: string, newPath: string) {
if (!Object.hasOwn($noteOrder, oldPath)) return;
const { [oldPath]: index, ...rest } = $noteOrder;
$noteOrder = { ...rest, [newPath]: index };
}
function removeManualNoteOrderPath(path: string) {
if (!Object.hasOwn($noteOrder, path)) return;
const { [path]: _, ...rest } = $noteOrder;
$noteOrder = rest;
}
function resetNoteDrag() {
noteDragFrom = null;
noteDragOver = null;
}
function handleCustomNoteDrop(targetIndex: number) {
if (noteDragFrom === null || !canCustomReorder) {
resetNoteDrag();
return;
}
let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) {
resetNoteDrag();
return;
}
const arr = [...$sortedNotes];
const [moved] = arr.splice(noteDragFrom, 1);
if (insertAt > noteDragFrom) insertAt--;
arr.splice(insertAt, 0, moved);
$notes = arr;
persistManualNoteOrder(arr);
resetNoteDrag();
}
async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null;
movePickerNote = null;
try {
const newPath = await moveNote(note.path, destPath);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) {
@@ -687,6 +818,19 @@
}
}
function isFiled(note: NoteEntry): boolean {
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
if (!vaultRoot) return false;
const normalizedPath = note.path.replace(/\\/g, '/');
const noteDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
return noteDir !== vaultRoot;
}
function unfileNote(note: NoteEntry) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) handleMoveNote(note, vaultRoot);
}
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
@@ -701,10 +845,13 @@
contextMenu = { x, y, note };
}
function startRename(note: NoteEntry) {
async function startRename(note: NoteEntry) {
contextMenu = null;
editingNote = note.path;
editValue = note.meta.title;
await tick();
renameInput?.focus();
renameInput?.select();
}
function getNotebookPath(note: NoteEntry): string {
@@ -773,6 +920,7 @@
async function handleBatchDelete() {
const toDelete = new Set(selectedPaths);
noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null;
@@ -808,6 +956,7 @@
async function handleBatchPermanentDelete() {
const toDelete = new Set(selectedPaths);
noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null;
@@ -817,11 +966,21 @@
await Promise.all([...toDelete].map(p => permanentDelete(p).catch(e => console.error('Failed to delete:', p, e))));
}
function handleWindowClick() {
function handleWindowClick(event: MouseEvent) {
const target = event.target as Element | null;
if (target?.closest('.context-menu, .sort-menu')) return;
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; }
if (sortMenu) sortMenu = null;
}
function closeBatchMoveOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) batchMovePicker = false;
}
function closeBatchTagOverlay(event: MouseEvent) {
if (event.target === event.currentTarget) batchTagEdit = false;
}
function openSortMenu(e: MouseEvent) {
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -833,6 +992,9 @@
}
function setSortMode(mode: SortMode) {
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
persistManualNoteOrder($sortedNotes);
}
$sortMode = mode;
sortMenu = null;
}
@@ -856,6 +1018,16 @@
<div class="list-header">
<span class="list-title">{viewTitle}</span>
<div class="list-actions">
{#if !isMobile}
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="16" y2="6" />
<line x1="4" y1="12" x2="16" y2="12" />
<line x1="4" y1="18" x2="16" y2="18" />
<polyline points="19 8 21 12 19 16" />
</svg>
</button>
{/if}
{#if $viewMode !== 'tasks'}
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
@@ -869,7 +1041,14 @@
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
</svg>
</button>
{#if $viewMode !== 'trash'}
{#if $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified')}
<button class="icon-btn" class:active-toggle={$groupNotesByDate} onclick={() => { $groupNotesByDate = !$groupNotesByDate; }} title="Group by date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
{/if}
{#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
@@ -885,8 +1064,14 @@
<span class="selection-count">{selectedPaths.size} selected</span>
<div class="selection-actions">
{#if $viewMode === 'trash'}
<button class="selection-action" onclick={handleBatchRestore}>Restore</button>
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
<button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Restore
</button>
<button class="selection-action danger" onclick={handleBatchPermanentDelete} title="Delete permanently">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
{:else}
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
@@ -901,12 +1086,11 @@
{#if batchMovePicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchMovePicker = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-overlay" onclick={closeBatchMoveOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchMovePicker = false; }}>
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-move-title" tabindex="-1">
<div class="batch-move-header">
<span>Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false}>
<span id="batch-move-title">Move {selectedPaths.size} notes to...</span>
<button class="selection-close" onclick={() => batchMovePicker = false} aria-label="Close move picker">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -930,12 +1114,11 @@
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-overlay" onclick={closeBatchTagOverlay} onkeydown={(e) => { if (e.key === 'Escape') batchTagEdit = false; }}>
<div class="batch-move-picker" role="dialog" aria-modal="true" aria-labelledby="batch-tags-title" tabindex="-1">
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<span id="batch-tags-title">Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false} aria-label="Close tag editor">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -967,7 +1150,8 @@
</div>
{/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="list-content" class:tasks-mode={$viewMode === 'tasks'} bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
{#if $viewMode === 'tasks'}
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
{/if}
@@ -1009,6 +1193,8 @@
<div class="empty-state">
{#if $viewMode === 'trash'}
<p>Trash is empty</p>
{:else if $viewMode === 'quickaccess'}
<p>No starred notes</p>
{:else}
<p>No notes yet</p>
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
@@ -1047,14 +1233,13 @@
{/each}
{/if}
<div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i}
{#snippet noteRow(note: NoteEntry, noteIndex: number)}
{#if editingNote === note.path}
<div class="note-item active">
<input
type="text"
class="rename-input"
bind:this={renameInput}
bind:value={editValue}
onkeydown={(e) => {
if (e.key === 'Enter') handleRename(note);
@@ -1072,7 +1257,15 @@
class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(note);
}
}}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
@@ -1099,11 +1292,16 @@
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else {
if (canCustomReorder) {
noteDragFrom = noteIndex;
e.dataTransfer!.setData('text/plain', note.path);
e.dataTransfer!.effectAllowed = 'move';
return;
}
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
? selectedPaths
: [note.path];
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
e.dataTransfer!.effectAllowed = 'move';
}}
ondragover={(e) => {
@@ -1113,18 +1311,28 @@
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex;
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
noteDragOver = noteIndex;
}
}}
ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null;
if (noteDragOver === noteIndex) noteDragOver = null;
}}
ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
handleQaDrop(noteIndex);
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
handleCustomNoteDrop(noteIndex);
}
}}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
>
{#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1175,16 +1383,40 @@
</span>
{/if}
</div>
{/if}
</button>
{/if}
{#if $viewMode === 'trash'}
<span class="trash-row-actions">
<span class="trash-row-btn" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handleRestore(note); }} title="Restore" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleRestore(note); } }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</span>
<span class="trash-row-btn danger" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handlePermanentDelete(note); }} title="Delete permanently" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handlePermanentDelete(note); } }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</span>
</span>
{/if}
</button>
{/if}
{/each}
<div style="height: {bottomPad}px"></div>
{/snippet}
{#if groupingActive}
{#each noteGroups as group (group.label)}
<div class="note-list-section">{group.label}</div>
{#each group.notes as note (note.path)}
{@render noteRow(note, -1)}
{/each}
{/each}
{:else}
<div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)}
{@render noteRow(note, startIndex + i)}
{/each}
<div style="height: {bottomPad}px"></div>
{/if}
</div>
</div>
{#if contextMenu}
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" role="group" aria-label="Note actions">
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu -->
{#if $viewMode === 'trash'}
@@ -1335,7 +1567,7 @@
Open in New Window
</button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealFile(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
@@ -1348,12 +1580,26 @@
</svg>
Rename
</button>
<button onclick={() => handleDuplicate(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
</svg>
Duplicate Note
</button>
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
Move to...
</button>
{#if isFiled(contextMenu.note)}
<button onclick={() => unfileNote(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M9 13h6"/>
</svg>
Unfile Note
</button>
{/if}
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -1373,8 +1619,7 @@
{/if}
{#if sortMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="sort-menu" style="left: {sortMenu.x}px; top: {sortMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="sort-menu" class:mobile={isMobile} style="left: {sortMenu.x}px; top: {sortMenu.y}px">
<div class="sort-menu-title">Sort by</div>
<button class:active={$sortMode === 'modified'} onclick={() => setSortMode('modified')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -1403,6 +1648,18 @@
Title
{#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if}
</button>
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="18" r="1" />
</svg>
Custom
{#if $sortMode === 'custom'}<span class="sort-check">&#10003;</span>{/if}
</button>
</div>
{/if}
@@ -1464,17 +1721,19 @@
padding: 4px;
}
.list-content::-webkit-scrollbar {
width: 8px;
.note-list-section {
padding: 14px 12px 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
border-bottom: 1px solid var(--border-light);
margin-bottom: 2px;
user-select: none;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
.note-list-section:first-child {
padding-top: 6px;
}
.note-item {
@@ -1488,6 +1747,7 @@
text-align: left;
margin-bottom: 1px;
contain: content;
position: relative;
}
.note-item:hover {
@@ -1534,6 +1794,9 @@
}
.selection-action {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid var(--border-color);
color: var(--text-primary);
@@ -1548,6 +1811,37 @@
background: var(--bg-hover);
}
.trash-row-actions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: none;
gap: 2px;
}
.note-item:hover .trash-row-actions {
display: flex;
}
.trash-row-btn {
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--text-secondary);
display: flex;
align-items: center;
}
.trash-row-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.trash-row-btn.danger:hover {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.note-list.mobile .trash-row-actions {
display: flex;
}
.selection-action.danger {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
@@ -1925,11 +2219,13 @@
font-size: 14px;
}
.note-item.qa-drag-above {
.note-item.qa-drag-above,
.note-item.note-drag-above {
border-top: 2px solid var(--accent);
}
.note-item.qa-drag-below {
.note-item.qa-drag-below,
.note-item.note-drag-below {
border-bottom: 2px solid var(--accent);
}
@@ -1960,6 +2256,11 @@
padding: 4px 8px 180px;
}
.note-list.mobile .list-content.tasks-mode {
padding-bottom: 0;
overflow: hidden;
}
.note-list.mobile .note-item {
padding: 14px 16px;
min-height: 56px;
@@ -2026,26 +2327,28 @@
border-radius: 10px;
}
.note-list.mobile .context-menu {
.context-menu.mobile {
min-width: 220px;
border-radius: 12px;
padding: 6px;
max-height: calc(100dvh - 16px);
overflow-y: auto;
}
.note-list.mobile .context-menu button {
.context-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.note-list.mobile .sort-menu {
.sort-menu.mobile {
min-width: 220px;
border-radius: 12px;
padding: 6px;
}
.note-list.mobile .sort-menu button {
.sort-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
@@ -2069,26 +2372,6 @@
min-height: 44px;
}
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);

Some files were not shown because too many files have changed in this diff Show More