Compare commits

..
101 Commits
Author SHA1 Message Date
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
113 changed files with 6831 additions and 1515 deletions
+3
View File
@@ -13,6 +13,9 @@ bun.lockb
/.svelte-kit
/build
# Local tests
/tests/
# Nix
/result
+33 -22
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.2-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.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
[![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.2)
## Download (v1.3.4)
### 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.4/HelixNotes_1.3.4_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,23 +106,18 @@ sudo eopkg it helixnotes
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
#### Manual package downloads
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg (Apple Silicon)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_aarch64.dmg) (M-series Macs)
[Download .dmg (Intel)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
>
@@ -116,11 +127,11 @@ sudo eopkg it helixnotes
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_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
@@ -183,4 +194,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-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
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;
}
);
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.3",
"version": "1.3.4",
"type": "module",
"scripts": {
"dev": "vite dev",
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+12 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.3"
version = "1.3.4"
dependencies = [
"arboard",
"chrono",
@@ -1860,6 +1860,7 @@ dependencies = [
"image",
"log",
"notify",
"objc2-app-kit",
"png 0.17.16",
"quick-xml 0.36.2",
"rayon",
@@ -1876,6 +1877,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",
@@ -5201,6 +5203,15 @@ 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"
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.3.3"
version = "1.3.4"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
@@ -42,6 +42,12 @@ quick-xml = "0.36"
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
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",
+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

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 633 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

@@ -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()
}
+8 -6
View File
@@ -405,7 +405,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",
@@ -434,12 +434,14 @@ 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 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,
"messages": [
let body = json!({
"model": model,
token_key: 20,
"messages": [
{
"role": "user",
"content": "Hi"
+503 -532
View File
File diff suppressed because it is too large Load Diff
+100 -1
View File
@@ -56,7 +56,20 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.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()
@@ -112,9 +125,12 @@ 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,
@@ -135,12 +151,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,
@@ -155,6 +173,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,
@@ -296,9 +315,33 @@ pub fn run() {
}));
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
@@ -387,3 +430,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);
}
}
+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),
+35 -9
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;
@@ -119,12 +119,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")
}
@@ -312,8 +312,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))
@@ -723,3 +724,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");
}
}
}
+97
View File
@@ -54,6 +54,8 @@ pub struct NoteContent {
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>,
@@ -94,11 +96,30 @@ pub struct CustomTheme {
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)]
@@ -116,6 +137,8 @@ pub struct AppConfig {
#[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,
@@ -182,12 +205,16 @@ 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,
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
#[serde(default)]
@@ -250,12 +277,23 @@ 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,
font_family: None,
@@ -264,6 +302,7 @@ impl Default for AppConfig {
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(),
@@ -297,9 +336,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,
@@ -393,6 +434,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)]
@@ -461,3 +505,56 @@ 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);
}
}
+2 -2
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>>() {
@@ -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").
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;
+255 -3
View File
@@ -1,10 +1,14 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
use crate::types::{
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
TrashNotebookEntry, VaultState,
};
use crate::vault::frontmatter;
use chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*;
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use uuid::Uuid;
use walkdir::WalkDir;
@@ -476,6 +480,75 @@ pub fn create_note(
})
}
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
let src = Path::new(path);
if !src.is_file() {
return Err("Note does not exist".to_string());
}
let parent = src
.parent()
.ok_or_else(|| "Note has no parent directory".to_string())?;
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut meta, body) = frontmatter::parse_note(&raw, &filename);
let source_title = meta.title.clone();
let base_title = format!("{} copy", source_title.trim_end());
let mut copy_title = base_title.clone();
let mut copy_number = 2;
let mut copy_path = parent.join(format!("{}.md", sanitize_filename(&copy_title)));
while copy_path.exists() {
copy_title = format!("{} {}", base_title, copy_number);
copy_number += 1;
copy_path = parent.join(format!("{}.md", sanitize_filename(&copy_title)));
}
let now = Utc::now();
meta.id = Uuid::new_v4().to_string();
meta.title = copy_title;
meta.pinned = false;
meta.created = now;
meta.modified = now;
let body = retitle_leading_heading(&body, &source_title, &meta.title);
let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body);
fs::write(&copy_path, copy_raw).map_err(|e| e.to_string())?;
read_note_entry(&copy_path, Path::new(vault_path))
}
fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String {
let trimmed = body.trim_start();
let leading_len = body.len() - trimmed.len();
let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len());
let first_line = &trimmed[..first_line_end];
let heading_len = first_line.chars().take_while(|&c| c == '#').count();
if !(1..=6).contains(&heading_len) {
return body.to_string();
}
let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else {
return body.to_string();
};
if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) {
return body.to_string();
}
let mut retitled = String::with_capacity(body.len() + new_title.len());
retitled.push_str(&body[..leading_len]);
retitled.push_str(&"#".repeat(heading_len));
retitled.push(' ');
retitled.push_str(new_title);
retitled.push_str(&trimmed[first_line_end..]);
retitled
}
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
@@ -1210,6 +1283,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(),
Ok(())
}
const NOTE_SWITCHER_RECENT_LIMIT: usize = 6;
pub fn get_note_switcher_titles(
vault_path: &str,
recent_paths: &[String],
) -> Result<Vec<NoteTitleEntry>, String> {
let vault_root = Path::new(vault_path);
if !vault_root.is_dir() {
return Err("Vault path does not exist".to_string());
}
let mut seen = HashSet::new();
let mut titles = Vec::with_capacity(NOTE_SWITCHER_RECENT_LIMIT);
for requested_path in recent_paths {
if titles.len() >= NOTE_SWITCHER_RECENT_LIMIT {
break;
}
let path = Path::new(requested_path);
let Ok(relative) = path.strip_prefix(vault_root) else {
continue;
};
let safe_relative = !relative.as_os_str().is_empty()
&& relative.components().all(|component| match component {
Component::Normal(name) => !is_hidden(Path::new(name)),
Component::CurDir => true,
_ => false,
});
if !safe_relative
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
|| !path.is_file()
{
continue;
}
let relative_path = relative.to_path_buf();
if !seen.insert(relative_path.clone()) {
continue;
}
let Ok(entry) = read_note_entry_fast(path, vault_root) else {
continue;
};
titles.push(NoteTitleEntry {
title: entry.meta.title,
path: relative_path.to_string_lossy().replace('\\', "/"),
});
}
Ok(titles)
}
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
let list = load_quick_access(vault_path)?;
let vault_root = Path::new(vault_path);
@@ -1218,7 +1344,7 @@ pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String
for relative in &list {
let full_path = vault_root.join(relative);
if full_path.exists() {
if let Ok(entry) = read_note_entry(&full_path, vault_root) {
if let Ok(entry) = read_note_entry_fast(&full_path, vault_root) {
notes.push(entry);
}
}
@@ -1237,3 +1363,129 @@ pub fn sanitize_filename(name: &str) -> String {
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::{
duplicate_note, get_note_switcher_titles, helixnotes_dir, load_notebook_icons,
set_notebook_icon,
};
use std::fs;
use uuid::Uuid;
#[test]
fn persists_and_removes_builtin_notebook_icons() {
let vault =
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
set_notebook_icon(&vault_path, "Projects", Some("builtin:briefcase")).unwrap();
let icons = load_notebook_icons(&vault_path).unwrap();
assert_eq!(
icons.get("Projects").map(String::as_str),
Some("builtin:briefcase")
);
set_notebook_icon(&vault_path, "Projects", None).unwrap();
assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
#[test]
fn loads_only_requested_note_switcher_titles() {
let vault =
std::env::temp_dir().join(format!("helixnotes-note-switcher-test-{}", Uuid::new_v4()));
fs::create_dir_all(&vault).unwrap();
let mut note_paths = Vec::new();
for index in 0..8 {
let path = vault.join(format!("Note {index}.md"));
fs::write(
&path,
format!("---\ntitle: Note {index}\n---\n\nNote {index} body.\n"),
)
.unwrap();
note_paths.push(path);
}
fs::write(
vault.join("Unrelated.md"),
"---\ntitle: Unrelated\n---\n\nMust not be loaded.\n",
)
.unwrap();
let outside =
std::env::temp_dir().join(format!("helixnotes-outside-note-{}.md", Uuid::new_v4()));
fs::write(&outside, "---\ntitle: Outside\n---\n").unwrap();
let requested = vec![
note_paths[0].to_string_lossy().into_owned(),
note_paths[0].to_string_lossy().into_owned(),
vault.join("Missing.md").to_string_lossy().into_owned(),
outside.to_string_lossy().into_owned(),
note_paths[1].to_string_lossy().into_owned(),
note_paths[2].to_string_lossy().into_owned(),
note_paths[3].to_string_lossy().into_owned(),
note_paths[4].to_string_lossy().into_owned(),
note_paths[5].to_string_lossy().into_owned(),
note_paths[6].to_string_lossy().into_owned(),
note_paths[7].to_string_lossy().into_owned(),
];
let vault_path = vault.to_string_lossy();
let titles = get_note_switcher_titles(&vault_path, &requested).unwrap();
assert_eq!(titles.len(), 6);
assert_eq!(
titles
.iter()
.map(|entry| entry.path.as_str())
.collect::<Vec<_>>(),
vec![
"Note 0.md",
"Note 1.md",
"Note 2.md",
"Note 3.md",
"Note 4.md",
"Note 5.md",
]
);
assert!(titles.iter().all(|entry| entry.title != "Unrelated"));
assert!(titles.iter().all(|entry| entry.title != "Outside"));
fs::remove_dir_all(vault).unwrap();
fs::remove_file(outside).unwrap();
}
#[test]
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
let vault =
std::env::temp_dir().join(format!("helixnotes-duplicate-note-test-{}", Uuid::new_v4()));
let notebook = vault.join("Projects");
fs::create_dir_all(&notebook).unwrap();
let source_path = notebook.join("Project.md");
let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n";
fs::write(&source_path, source_raw).unwrap();
let vault_path = vault.to_string_lossy();
let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
assert_eq!(first.meta.title, "Project copy");
assert_eq!(second.meta.title, "Project copy 2");
assert_eq!(first.meta.tags, vec!["work"]);
assert!(!first.meta.pinned);
assert_ne!(first.meta.id, "source-id");
assert_ne!(first.meta.id, second.meta.id);
assert_eq!(first.relative_path, "Projects/Project copy.md");
assert_eq!(second.relative_path, "Projects/Project copy 2.md");
let first_raw = fs::read_to_string(&first.path).unwrap();
assert!(first_raw.contains("aliases:\n- Plan"));
assert!(first_raw.contains("# Project copy\n\nOriginal body."));
assert!(!first_raw.contains("\n# Project\n"));
assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw);
fs::remove_dir_all(vault).unwrap();
}
}
+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)
);
}
}
+4 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.3",
"version": "1.3.4",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
@@ -55,6 +55,9 @@
"appimage": {
"bundleMediaFramework": false
}
},
"macOS": {
"entitlements": "entitlements.plist"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"app": {
"windows": [
{
"label": "main",
"visible": false
}
]
}
}
-4
View File
@@ -1142,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;
}
+36 -2
View File
@@ -15,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> {
@@ -33,6 +43,10 @@ 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 });
}
@@ -131,6 +145,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 });
}
@@ -161,6 +179,12 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
return invoke("get_all_note_titles");
}
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 }[] }> {
return invoke("get_graph_data");
}
@@ -207,6 +231,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");
}
@@ -251,10 +279,13 @@ export async function setGeneralSettings(
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,
@@ -276,10 +307,13 @@ export async function setGeneralSettings(
showLineNumbers,
showLinkArrows,
defaultViewMode,
newNotesInSourceMode,
showTrayIcon,
closeToTray,
enableWikiLinks,
showNoteDates,
showNoteSwitcher,
startupView,
restoreLastSession,
showAllNotes,
showQuickAccess,
+143 -64
View File
@@ -21,6 +21,8 @@
showSearch,
showCommandPalette,
theme,
resolvedTheme,
customThemes,
focusMode,
readOnly,
activeNote,
@@ -61,12 +63,14 @@
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile);
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) {
@@ -81,6 +85,38 @@
let noteList: NoteList;
let editor: 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
@@ -252,14 +288,32 @@
// 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;
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
@@ -415,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); }
@@ -433,7 +509,7 @@
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
e.preventDefault();
noteList?.trashActiveNote();
editor?.moveOpenNoteToTrash();
return;
}
@@ -451,7 +527,8 @@
createAndFocusNote();
return;
case 'toggle-theme': {
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
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);
@@ -509,23 +586,6 @@
}
}
function applyTheme(t: string) {
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)) {
root.classList.add('dark');
}
}
$effect(() => {
applyTheme($theme);
});
$effect(() => {
$collapsedNotebooks;
persistState();
@@ -599,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)
@@ -679,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();
});
@@ -709,11 +782,11 @@
// ── 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);
const ts = event.payload?.last_sync_time;
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) });
try {
appConfig.set(await getAppConfig());
} catch {}
}));
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
@@ -895,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>
@@ -942,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}
@@ -956,7 +1029,7 @@
{#if !$notelistCollapsed}
<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} />
<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} />
@@ -975,7 +1048,7 @@
{/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">
@@ -1137,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);
}
+1 -2
View File
@@ -89,7 +89,6 @@
action: () => {
$theme = 'system';
setTheme('system');
applyTheme('system');
$showCommandPalette = false;
}
},
@@ -187,7 +186,7 @@
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
+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>
+3 -181
View File
@@ -1,10 +1,7 @@
<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,
@@ -84,13 +81,8 @@
if (capturingId === id) stopCapture();
}
let stats = $state<VaultStats | null>(null);
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');
@@ -103,38 +95,7 @@
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}
@@ -144,7 +105,7 @@
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<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" />
@@ -176,49 +137,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>
@@ -273,6 +192,7 @@
<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>
@@ -416,39 +336,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;
@@ -480,71 +367,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%;
+148 -39
View File
@@ -23,6 +23,7 @@
getNotes,
readNote,
createNote,
duplicateNote,
deleteNote,
renameNote,
saveNote,
@@ -40,16 +41,18 @@
} from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
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>;
@@ -265,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;
@@ -409,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')}`;
@@ -441,19 +458,44 @@
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') return;
if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
handleCreateNote();
}
@@ -500,12 +542,6 @@
}
}
// Move the currently open note to the trash (keyboard shortcut from the editor).
export async function trashActiveNote() {
if ($viewMode === 'trash' || !$activeNotePath) return;
const note = $notes.find(n => n.path === $activeNotePath);
if (note) await handleDelete(note);
}
async function handleRestore(note: NoteEntry) {
contextMenu = null;
@@ -782,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;
@@ -989,7 +1038,7 @@
</svg>
</button>
{/if}
{#if $viewMode !== 'trash'}
{#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" />
@@ -1005,8 +1054,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>
@@ -1088,7 +1143,7 @@
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="list-content" bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
<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}
@@ -1130,6 +1185,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>
@@ -1233,11 +1290,10 @@
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else {
e.dataTransfer!.setData('text/plain', note.path);
}
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) => {
@@ -1319,8 +1375,18 @@
</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}
{/snippet}
@@ -1342,7 +1408,7 @@
</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" onclick={(e) => e.stopPropagation()} role="menu">
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
<!-- Batch context menu -->
{#if $viewMode === 'trash'}
@@ -1506,12 +1572,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">
@@ -1634,19 +1714,6 @@
padding: 4px;
}
.list-content::-webkit-scrollbar {
width: 8px;
}
.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 {
padding: 14px 12px 4px;
font-size: 0.7rem;
@@ -1673,6 +1740,7 @@
text-align: left;
margin-bottom: 1px;
contain: content;
position: relative;
}
.note-item:hover {
@@ -1719,6 +1787,9 @@
}
.selection-action {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid var(--border-color);
color: var(--text-primary);
@@ -1733,6 +1804,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));
@@ -2147,6 +2249,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;
@@ -2213,13 +2320,15 @@
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;
+466
View File
@@ -0,0 +1,466 @@
<script lang="ts">
import { tick } from 'svelte';
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
import { openNoteWindow } from '$lib/utils/window';
import {
buildNoteSwitcherRequestPaths,
buildNoteSwitcherSections,
type NoteSwitcherNote,
type NoteSwitcherRow,
type NoteSwitcherSections
} from '$lib/utils/note-switcher';
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
let { onSelectNote = async () => false }: {
onSelectNote?: (path: string) => Promise<boolean>;
} = $props();
let wrapper: HTMLDivElement;
let trigger: HTMLButtonElement;
let popover = $state<HTMLDivElement | null>(null);
let open = $state(false);
let loading = $state(false);
let selectingPath = $state<string | null>(null);
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
let loadGeneration = 0;
let loadedVaultPath = $state<string | null | undefined>(undefined);
function normalizedPath(path: string): string {
return path.replace(/\\/g, '/').replace(/\/$/, '');
}
function isInsideVault(path: string, vaultPath: string): boolean {
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
}
function vaultRelativePath(path: string, vaultPath: string): string {
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
}
function joinVaultPath(vaultPath: string, relativePath: string): string {
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
const root = vaultPath.replace(/[\\/]+$/, '');
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
}
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
if (!relativePath || relativePath.split('/').includes('..')) return null;
const path = joinVaultPath(vaultPath, relativePath);
if (!isInsideVault(path, vaultPath)) return null;
return { path, title: entry.title, relativePath };
}
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
if (!isInsideVault(entry.path, vaultPath)) return null;
return {
path: entry.path,
title: entry.meta.title,
relativePath: entry.relative_path.replace(/\\/g, '/')
};
}
async function focusInitialRow() {
await tick();
if (!open) return;
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
(current ?? first ?? popover)?.focus();
}
async function refreshSections(generation: number) {
const vaultPath = $appConfig?.active_vault;
if (!vaultPath) {
sections = { recent: [], quickAccess: [] };
loadedVaultPath = null;
loading = false;
await focusInitialRow();
return;
}
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
const [titlesResult, quickAccessResult] = await Promise.allSettled([
getNoteSwitcherTitles(requestPaths),
getQuickAccess()
]);
if (!open || generation !== loadGeneration) return;
if (titlesResult.status === 'rejected') {
console.error('Failed to load notes for note switcher:', titlesResult.reason);
}
if (quickAccessResult.status === 'rejected') {
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
}
const knownNotes = titlesResult.status === 'fulfilled'
? titlesResult.value
.map((entry) => titleEntryToNote(entry, vaultPath))
.filter((entry): entry is NoteSwitcherNote => entry !== null)
: [];
const currentPath = $activeNotePath;
if (currentPath && isInsideVault(currentPath, vaultPath)) {
const currentKey = normalizedPath(currentPath);
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
knownNotes.push({
path: currentPath,
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
relativePath: vaultRelativePath(currentPath, vaultPath)
});
}
}
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
? quickAccessResult.value
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
.filter((entry): entry is NoteSwitcherNote => entry !== null)
: [];
for (const note of quickAccessNotes) {
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
knownNotes.push(note);
}
}
sections = buildNoteSwitcherSections({
currentPath,
historyPaths: $navHistory.stack,
knownNotes,
quickAccessNotes
});
loadedVaultPath = vaultPath;
loading = false;
await focusInitialRow();
}
function refreshAfterPopoverPaint(generation: number) {
requestAnimationFrame(() => {
window.setTimeout(() => {
if (!open || generation !== loadGeneration) return;
void refreshSections(generation);
}, 0);
});
}
function toggleSwitcher() {
if (open) {
open = false;
loadGeneration += 1;
return;
}
const vaultPath = $appConfig?.active_vault ?? null;
const hasCachedSections = loadedVaultPath === vaultPath;
open = true;
loading = !hasCachedSections;
selectingPath = null;
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
loadGeneration += 1;
const generation = loadGeneration;
if (hasCachedSections) void focusInitialRow();
refreshAfterPopoverPaint(generation);
}
async function closeSwitcher(restoreTriggerFocus: boolean) {
open = false;
loadGeneration += 1;
if (restoreTriggerFocus) {
await tick();
trigger?.focus();
}
}
async function selectNote(row: NoteSwitcherRow) {
if (selectingPath !== null) return;
selectingPath = row.path;
try {
if (await onSelectNote(row.path)) await closeSwitcher(false);
} finally {
selectingPath = null;
}
}
function handlePopoverKeydown(event: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
if (buttons.length === 0) return;
event.preventDefault();
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
let nextIndex: number;
if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = buttons.length - 1;
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
buttons[nextIndex].focus();
}
$effect(() => {
if (!open) return;
const handleOutsideMouseDown = (event: MouseEvent) => {
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
void closeSwitcher(true);
};
document.addEventListener('mousedown', handleOutsideMouseDown, true);
document.addEventListener('keydown', handleEscape, true);
return () => {
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
document.removeEventListener('keydown', handleEscape, true);
};
});
</script>
<div class="note-switcher" bind:this={wrapper}>
<button
bind:this={trigger}
class="switcher-trigger"
type="button"
aria-label="Switch note"
aria-haspopup="dialog"
aria-expanded={open}
onclick={toggleSwitcher}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="7" y="4" width="12" height="14" rx="2" />
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
</svg>
</button>
{#if open}
<div
bind:this={popover}
class="switcher-popover"
role="dialog"
aria-label="Note switcher"
tabindex="-1"
onkeydown={handlePopoverKeydown}
>
{#if loading}
<div class="switcher-status">Loading notes…</div>
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
<div class="switcher-status">No notes available</div>
{:else}
{#each [
{ label: 'Recent', rows: sections.recent },
{ label: 'Quick Access', rows: sections.quickAccess }
] as section (section.label)}
{#if section.rows.length > 0}
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
{#each section.rows as row (row.path)}
<div class="note-row" class:current-row={row.current}>
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
<button
class="note-primary"
type="button"
aria-current={row.current ? 'true' : undefined}
disabled={selectingPath !== null}
onclick={() => selectNote(row)}
>
<span class="note-title">{row.title}</span>
<span class="note-folder">{row.folder}</span>
</button>
<button
class="open-window"
type="button"
aria-label={`Open ${row.title} in new window`}
title={`Open ${row.title} in new window`}
onclick={() => openNoteWindow(row.path, row.title)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="4" width="13" height="13" rx="2" />
<path d="M10 14L20 4m-6 0h6v6" />
</svg>
</button>
</div>
{/each}
</section>
{/if}
{/each}
{/if}
</div>
{/if}
</div>
<style>
.note-switcher {
position: relative;
-webkit-app-region: no-drag;
}
.switcher-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.switcher-trigger:hover,
.switcher-trigger[aria-expanded='true'] {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.switcher-trigger:focus-visible,
.note-primary:focus-visible,
.open-window:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.switcher-popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
width: min(320px, calc(100vw - 24px));
max-height: min(420px, calc(100vh - 58px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
color: var(--text-primary);
user-select: none;
-webkit-app-region: no-drag;
}
.switcher-section + .switcher-section {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid var(--border-color);
}
h2 {
margin: 0;
padding: 5px 8px 4px;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1.2;
text-transform: uppercase;
}
.note-row {
position: relative;
display: flex;
align-items: center;
min-height: 42px;
border-radius: 7px;
}
.note-row:hover,
.note-row:focus-within {
background: var(--bg-hover);
}
.note-row.current-row {
background: color-mix(in srgb, var(--accent) 9%, transparent);
}
.current-marker {
position: absolute;
left: 7px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.note-primary {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 38px 6px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.current-row .note-primary {
padding-left: 18px;
}
.note-primary:disabled {
cursor: wait;
}
.note-title,
.note-folder {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-title {
font-size: 12px;
font-weight: 500;
line-height: 1.25;
}
.note-folder {
color: var(--text-tertiary);
font-size: 10.5px;
line-height: 1.2;
}
.open-window {
position: absolute;
right: 5px;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s, background 0.12s, color 0.12s;
}
.note-row:hover .open-window,
.note-row:focus-within .open-window {
opacity: 1;
pointer-events: auto;
}
.open-window:hover {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.switcher-status {
padding: 22px 12px;
color: var(--text-tertiary);
font-size: 11px;
text-align: center;
}
</style>
+19 -18
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { getCurrentWindow } from '@tauri-apps/api/window';
import Editor from './Editor.svelte';
import {
appConfig,
activeNote,
activeNotePath,
editorDirty,
readOnly,
sourceMode,
theme
sourceMode
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
@@ -17,12 +18,12 @@
let { notePath }: { notePath: string } = $props();
import { darkThemes } from '$lib/platform';
const appWindow = getCurrentWindow();
const appWebview = getCurrentWebview();
const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!);
let unlistenFileChange: (() => void) | null = null;
let unlistenUiScale: (() => void) | null = null;
let maximized = $state(false);
let loadError = $state<string | null>(null);
@@ -36,20 +37,6 @@
return () => { unlisten.then(fn => fn()); };
});
$effect(() => {
const t = $theme || 'system';
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)) {
root.classList.add('dark');
}
});
let lastMouseDown = 0;
const RESIZE_EDGE = 6;
@@ -84,7 +71,20 @@
}
}
async function applyUiScale(scale: number) {
try {
await appWebview.setZoom(scale);
} catch (e) {
console.error('Failed to apply interface scale to note window:', e);
}
}
onMount(async () => {
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
void applyUiScale(event.payload);
});
await applyUiScale($appConfig?.ui_scale ?? 1);
try {
const content = await readNote(notePath);
$activeNote = content;
@@ -116,6 +116,7 @@
onDestroy(() => {
unlistenFileChange?.();
unlistenUiScale?.();
});
</script>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import type { NotebookIconId } from '$lib/utils/notebook-icons';
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#if icon === 'book'}
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
{:else if icon === 'folder'}
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
{:else if icon === 'briefcase'}
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
{:else if icon === 'home'}
<path d="m3 11 9-8 9 8" />
<path d="M5 10v11h14V10M9 21v-7h6v7" />
{:else if icon === 'star'}
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
{:else if icon === 'heart'}
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
{:else if icon === 'lightbulb'}
<path d="M9 18h6M10 22h4" />
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
{:else if icon === 'code'}
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
{:else if icon === 'flask'}
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
<path d="M7 16h10" />
{:else if icon === 'graduation'}
<path d="m2 9 10-5 10 5-10 5Z" />
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
{:else if icon === 'calendar'}
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
{:else if icon === 'tasks'}
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
{:else if icon === 'archive'}
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
{:else if icon === 'globe'}
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
{:else if icon === 'plane'}
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
{:else}
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
{/if}
</svg>
+26 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app';
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView, notebooks, activeNotebook, activeTag, viewMode } from '$lib/stores/app';
import { searchNotes, readNote } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import type { SearchResult } from '$lib/types';
import type { SearchResult, NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform';
let query = $state('');
@@ -84,6 +84,17 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function findNotebookByPath(nbs: NotebookEntry[], target: string): NotebookEntry | null {
for (const nb of nbs) {
if (nb.path === target) return nb;
if (nb.children.length) {
const found = findNotebookByPath(nb.children, target);
if (found) return found;
}
}
return null;
}
async function openResult(result: SearchResult) {
try {
const content = await readNote(result.path);
@@ -91,6 +102,19 @@
$activeNotePath = result.path;
$editorDirty = false;
$showSearch = false;
// Reveal the note in the notes list: switch to its notebook (or All Notes).
const sep = result.path.includes('\\') ? '\\' : '/';
const parentDir = result.path.slice(0, result.path.lastIndexOf(sep));
const nb = parentDir ? findNotebookByPath($notebooks, parentDir) : null;
if (nb) {
$activeNotebook = nb;
$activeTag = null;
$viewMode = 'notebook';
} else {
$activeNotebook = null;
$activeTag = null;
$viewMode = 'all';
}
if (isMobile) $mobileView = 'editor';
} catch (e) {
console.error('Failed to open search result:', e);
+451 -49
View File
@@ -1,17 +1,19 @@
<script lang="ts">
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
import type { OrphanAttachment } from '$lib/api';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView, VaultStats } from '$lib/types';
import { normalizeStartupView } from '$lib/utils/startup-view';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'maintenance' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling');
// Updates state
@@ -115,6 +117,67 @@
}
}
// Vault maintenance state
let vaultStats = $state<VaultStats | null>(null);
let vaultStatsError = $state(false);
let orphanedAttachments = $state<OrphanAttachment[] | null>(null);
let scanningOrphanedAttachments = $state(false);
let trashingOrphanedAttachments = $state(false);
const orphanedAttachmentTotal = $derived((orphanedAttachments ?? []).reduce((total, attachment) => total + attachment.size, 0));
$effect(() => {
if (!$showSettings) {
vaultStats = null;
vaultStatsError = false;
orphanedAttachments = null;
return;
}
if (activeTab === 'maintenance') void loadVaultStats();
});
async function loadVaultStats() {
vaultStatsError = false;
try {
vaultStats = await getVaultStats();
} catch (error) {
vaultStats = null;
vaultStatsError = true;
console.error('Failed to load vault statistics:', error);
}
}
function formatVaultSize(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`;
}
async function scanOrphanedAttachments() {
scanningOrphanedAttachments = true;
try {
orphanedAttachments = await findOrphanedAttachments();
} catch (error) {
console.error(error);
} finally {
scanningOrphanedAttachments = false;
}
}
async function trashFoundOrphanedAttachments() {
if (!orphanedAttachments || orphanedAttachments.length === 0) return;
trashingOrphanedAttachments = true;
try {
await trashOrphanedAttachments(orphanedAttachments.map((attachment) => attachment.name));
orphanedAttachments = null;
void loadVaultStats();
} catch (error) {
console.error(error);
} finally {
trashingOrphanedAttachments = false;
}
}
// Backup state
let backups = $state<BackupEntry[]>([]);
let backupLoading = $state(false);
@@ -318,9 +381,13 @@
// Re-seed the sync form when the active vault changes, so Settings reflects the current vault.
let lastSyncVault: string | null = null;
$effect(() => {
const path = $appConfig?.active_vault ?? null;
if (path === lastSyncVault) return;
lastSyncVault = path;
const identity = $appConfig?.active_bookmark_id
? `bookmark:${$appConfig.active_bookmark_id}`
: $appConfig?.active_vault
? `path:${$appConfig.active_vault}`
: null;
if (identity === lastSyncVault) return;
lastSyncVault = identity;
const vc = activeVaultConfig($appConfig);
syncProvider = vc?.sync_provider ?? null;
syncUrl = vc?.webdav_url ?? '';
@@ -337,10 +404,11 @@
// triggers update live, without an app restart.
if ($appConfig) {
const cur = $appConfig;
const active = activeVaultConfig(cur);
$appConfig = {
...cur,
vaults: cur.vaults.map((v) => v.path === cur.active_vault ? {
...v,
vaults: cur.vaults.map((vault) => vault === active ? {
...vault,
sync_provider: syncProvider,
webdav_url: syncUrl || null,
webdav_username: syncUsername || null,
@@ -348,7 +416,7 @@
sync_on_open: syncOnOpen,
sync_on_change: syncOnChange,
sync_interval_minutes: syncIntervalMinutes,
} : v),
} : vault),
};
}
}
@@ -379,7 +447,7 @@
syncMessage = null;
const unlisteners: Array<() => void> = [];
const cleanup = () => { unlisteners.forEach((u) => u()); };
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', (event) => {
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', async (event) => {
const s = event.payload.summary ?? {};
const parts: string[] = [];
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
@@ -389,10 +457,10 @@
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
syncRunning = false;
if ($appConfig && event.payload.last_sync_time) {
const cur = $appConfig;
const ts = event.payload.last_sync_time;
$appConfig = { ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) };
if (event.payload.last_sync_time) {
try {
$appConfig = await getAppConfig();
} catch {}
}
cleanup();
}));
@@ -412,11 +480,12 @@
}
let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
darkThemes.includes($resolvedTheme) ||
($resolvedTheme.startsWith('custom-') && ($customThemes.find(c => c.id === $resolvedTheme)?.is_dark ?? false))
);
const themePresets = [
{ id: 'system', label: 'System', bg: '#ffffff', sidebar: '#1a1b26', accent: '#5b6abf' },
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
@@ -521,7 +590,45 @@
let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false);
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
let activeThemePreset = $derived(
themePresets.find(p => p.id === $theme) ?? themePresets.find(p => p.id === 'light')!
);
// Which theme each OS appearance maps to while `theme` is "system". Custom themes are offered
// alongside the built-ins so a light/dark pair can be built out of them.
let systemLightTheme = $state($appConfig?.system_light_theme ?? 'light');
let systemDarkTheme = $state($appConfig?.system_dark_theme ?? 'dark');
let systemPairOpen = $state<'light' | 'dark' | null>(null);
let pairPresets = $derived([
...themePresets.filter(p => p.id !== 'system'),
...$customThemes.map(ct => ({
id: ct.id,
label: ct.name,
bg: ct.colors.bg_primary,
sidebar: ct.colors.bg_secondary,
accent: ct.colors.text_primary,
})),
]);
let systemPairRows = $derived([
{ key: 'light' as const, title: 'Light appearance', selected: systemLightTheme },
{ key: 'dark' as const, title: 'Dark appearance', selected: systemDarkTheme },
]);
function selectSystemPairTheme(which: 'light' | 'dark', id: string) {
if (which === 'light') systemLightTheme = id;
else systemDarkTheme = id;
systemPairOpen = null;
if ($appConfig) {
$appConfig.system_light_theme = systemLightTheme;
$appConfig.system_dark_theme = systemDarkTheme;
$appConfig = { ...$appConfig };
}
setSystemThemes(systemLightTheme, systemDarkTheme).catch((e) =>
console.error('Failed to save system themes:', e)
);
}
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
let isCustomAccent = $derived(activeAccent.startsWith('#'));
let activeAccentColor = $derived(
@@ -547,7 +654,7 @@
let customThemeImporting = $state(false);
function openNewCustomThemeEditor() {
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
const isDark = isThemeDark;
customThemeEditing = {
id: `custom-${Date.now()}`,
name: '',
@@ -579,19 +686,20 @@
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
root.style.setProperty('--border-light', customThemeEditing.colors.border_color);
root.style.setProperty('--text-tertiary', customThemeEditing.colors.text_secondary);
root.classList.toggle('dark', customThemeEditing.is_dark);
}
function restoreCurrentTheme() {
// Re-apply the currently active theme to undo preview
const root = document.documentElement;
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color','--border-light','--text-tertiary'];
root.classList.remove('dark');
root.removeAttribute('data-theme');
for (const v of varsToClear) root.style.removeProperty(v);
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'];
if ($theme.startsWith('custom-')) {
const ct = $customThemes.find(c => c.id === $theme);
if ($resolvedTheme.startsWith('custom-')) {
const ct = $customThemes.find(c => c.id === $resolvedTheme);
if (ct) {
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
@@ -602,12 +710,14 @@
root.style.setProperty('--text-primary', ct.colors.text_primary);
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
root.style.setProperty('--border-color', ct.colors.border_color);
root.style.setProperty('--border-light', ct.colors.border_color);
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
if (ct.is_dark) root.classList.add('dark');
}
} else if (namedThemes.includes($theme)) {
root.setAttribute('data-theme', $theme);
if (darkThemes.includes($theme)) root.classList.add('dark');
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
} else if (namedThemes.includes($resolvedTheme)) {
root.setAttribute('data-theme', $resolvedTheme);
if (darkThemes.includes($resolvedTheme)) root.classList.add('dark');
} else if ($resolvedTheme === 'dark') {
root.classList.add('dark');
}
}
@@ -642,9 +752,15 @@
async function removeCustomTheme(ct: CustomTheme) {
try {
await deleteCustomTheme(ct.id);
if (systemLightTheme === ct.id) systemLightTheme = 'light';
if (systemDarkTheme === ct.id) systemDarkTheme = 'dark';
if ($appConfig) {
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
$appConfig = { ...$appConfig };
$appConfig = {
...$appConfig,
custom_themes: $appConfig.custom_themes.filter(t => t.id !== ct.id),
system_light_theme: systemLightTheme,
system_dark_theme: systemDarkTheme,
};
}
if ($theme === ct.id) {
$theme = 'system';
@@ -696,6 +812,8 @@
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative');
let weekStart = $state($appConfig?.week_start ?? 'monday');
@@ -711,6 +829,7 @@
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let newNotesInSourceMode = $state($appConfig?.new_notes_in_source_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
@@ -739,10 +858,16 @@
importResult = null;
importError = null;
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; error?: string }>('import-done', (event) => {
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; frontmatter_normalized?: number; syntax_converted?: number; attachments_moved?: number; error?: string }>('import-done', (event) => {
const data = event.payload;
if (data.success) {
importResult = { files_converted: data.files_converted ?? 0, links_converted: data.links_converted ?? 0 };
importResult = {
files_converted: data.files_converted ?? 0,
links_converted: data.links_converted ?? 0,
frontmatter_normalized: data.frontmatter_normalized ?? 0,
syntax_converted: data.syntax_converted ?? 0,
attachments_moved: data.attachments_moved ?? 0,
};
} else {
importError = data.error ?? 'Import failed';
}
@@ -763,6 +888,8 @@
if ($appConfig) {
$appConfig.compact_notes = compactNotes;
$appConfig.show_note_dates = showNoteDates;
$appConfig.show_note_switcher = showNoteSwitcher;
$appConfig.startup_view = startupView;
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
$appConfig.week_start = weekStart;
@@ -776,6 +903,7 @@
$appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode;
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
$appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
@@ -785,7 +913,7 @@
$appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -797,7 +925,9 @@
$theme = preset.id;
setTheme(preset.id);
themeDropdownOpen = false;
selectCustomAccent(preset.accent);
// "System" has no palette of its own, it defers to whichever paired theme is active, so it
// must not overwrite the accent the way a concrete theme does.
if (preset.id !== 'system') selectCustomAccent(preset.accent);
}
function selectAccent(preset: typeof accentPresets[0]) {
@@ -808,7 +938,7 @@
function applyAccent(preset: typeof accentPresets[0]) {
const root = document.documentElement;
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark = isThemeDark;
const color = isDark ? preset.dark : preset.light;
root.style.setProperty('--accent', color);
@@ -831,7 +961,7 @@
function applyCustomAccent(hex: string) {
const root = document.documentElement;
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark = isThemeDark;
root.style.setProperty('--accent', hex);
root.style.setProperty('--text-accent', hex);
root.style.setProperty('--accent-hover', hex);
@@ -965,6 +1095,8 @@
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
showNoteDates = $appConfig.show_note_dates ?? true;
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
startupView = normalizeStartupView($appConfig.startup_view);
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
weekStart = $appConfig.week_start ?? 'monday';
@@ -974,6 +1106,7 @@
pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input';
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
showAllNotes = $appConfig.show_all_notes ?? true;
showQuickAccess = $appConfig.show_quick_access ?? true;
showTasks = $appConfig.show_tasks ?? true;
@@ -982,9 +1115,9 @@
}
});
// Re-apply when theme changes
// Re-apply when the concrete theme changes, including an OS appearance switch.
$effect(() => {
const _ = $theme;
const _ = $resolvedTheme;
const preset = accentPresets.find(p => p.name === activeAccent);
if (preset) {
applyAccent(preset);
@@ -1043,6 +1176,12 @@
Backup
</button>
{/if}
<button class="tab-btn" class:active={activeTab === 'maintenance'} onclick={() => activeTab = 'maintenance'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
</svg>
Maintenance
</button>
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
@@ -1141,6 +1280,44 @@
</div>
{/if}
{#if !$platformIsMobile}
<div class="settings-section">
<h3>Interface</h3>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Show note switcher in title bar</span>
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
</span>
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
{/if}
<div class="settings-section">
<h3>Startup</h3>
<div class="setting-label">
<span class="setting-name">Default view</span>
<span class="setting-desc">Used when session restore is off or the previous view is unavailable.</span>
</div>
<div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={startupView === 'all'} onclick={() => { startupView = 'all'; saveGeneralSettings(); }}>All Notes</button>
<button class="option-btn" class:active={startupView === 'quickaccess'} onclick={() => { startupView = 'quickaccess'; saveGeneralSettings(); }}>Quick Access</button>
<button class="option-btn" class:active={startupView === 'tasks'} onclick={() => { startupView = 'tasks'; saveGeneralSettings(); }}>Tasks</button>
<button class="option-btn" class:active={startupView === 'daily'} onclick={() => { startupView = 'daily'; saveGeneralSettings(); }}>Daily Notes</button>
</div>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
<div class="settings-section">
<h3>Time Format</h3>
<div class="setting-options">
@@ -1182,6 +1359,7 @@
</div>
{/if}
{#if !isMobile}
<div class="settings-section">
<h3>Performance</h3>
@@ -1230,6 +1408,78 @@
</div>
{/if}
</div>
{:else if activeTab === 'maintenance'}
<div class="tab-content">
<div class="settings-section">
<h3>Vault Overview</h3>
{#if vaultStats}
<div class="maintenance-stats">
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Notes</span>
<span class="maintenance-stat-value">{vaultStats.total_notes}</span>
</div>
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Attachments</span>
<span class="maintenance-stat-value">{vaultStats.total_attachments}</span>
</div>
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Notes size</span>
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.notes_size)}</span>
</div>
<div class="maintenance-stat-row">
<span class="maintenance-stat-label">Attachments size</span>
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.attachments_size)}</span>
</div>
<div class="maintenance-stat-row maintenance-stat-total">
<span class="maintenance-stat-label">Total vault size</span>
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.total_size)}</span>
</div>
</div>
{:else if vaultStatsError}
<p class="setting-desc">Vault statistics could not be loaded.</p>
{:else}
<p class="setting-desc">Loading vault statistics…</p>
{/if}
</div>
<div class="settings-section">
<h3>Orphaned Attachments</h3>
<div class="vault-maintenance">
<p class="setting-desc">Find files in the vault's attachments folder that are not referenced by any note.</p>
{#if orphanedAttachments === null}
<button class="import-btn cleanup-action" onclick={scanOrphanedAttachments} disabled={scanningOrphanedAttachments}>
{#if scanningOrphanedAttachments}
<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 orphanedAttachments.length === 0}
<p class="cleanup-message">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-message">{orphanedAttachments.length} {orphanedAttachments.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatVaultSize(orphanedAttachmentTotal)}).</p>
<div class="cleanup-list">
{#each orphanedAttachments as attachment}
<div class="cleanup-row">
<span class="cleanup-name" title={attachment.name}>{attachment.name}</span>
<span class="cleanup-size">{formatVaultSize(attachment.size)}</span>
</div>
{/each}
</div>
<button class="import-btn cleanup-action" onclick={trashFoundOrphanedAttachments} disabled={trashingOrphanedAttachments}>
{#if trashingOrphanedAttachments}
<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 {orphanedAttachments.length} to Trash
{/if}
</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
</div>
</div>
{:else if activeTab === 'editor'}
<div class="tab-content">
<div class="settings-section">
@@ -1272,17 +1522,18 @@
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
<span class="setting-name">Open new notes in source mode</span>
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
<div class="settings-section">
<h3>Wiki Links & Graph</h3>
<label class="setting-toggle">
@@ -1374,6 +1625,9 @@
</div>
{/if}
</div>
{#if $theme === 'system'}
<span class="setting-hint">Follows the operating system appearance.</span>
{/if}
</div>
<div class="settings-section">
@@ -1422,6 +1676,54 @@
{/if}
</div>
</div>
{#if $theme === 'system'}
{#each systemPairRows as row}
<div class="settings-section">
<h3>{row.title}</h3>
<div class="dropdown-wrap">
<button
class="dropdown-trigger"
class:open={systemPairOpen === row.key}
onclick={() => { systemPairOpen = systemPairOpen === row.key ? null : row.key; themeDropdownOpen = false; accentDropdownOpen = false; }}
>
{#each pairPresets.filter(p => p.id === row.selected) as preset}
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="dropdown-trigger-label">{preset.label}</span>
{/each}
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{#if systemPairOpen === row.key}
<div class="dropdown-list" use:clickOutside={() => systemPairOpen = null}>
{#each pairPresets as preset}
<button
class="dropdown-item"
class:active={row.selected === preset.id}
onclick={() => selectSystemPairTheme(row.key, preset.id)}
>
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="dropdown-item-label">{preset.label}</span>
{#if row.selected === preset.id}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<!-- Custom Themes -->
@@ -1807,7 +2109,7 @@
<div class="tab-content">
<div class="settings-section">
<h3>Import from Obsidian</h3>
<p class="import-desc">Convert Obsidian wiki-links (<code>![[image.png]]</code>, <code>[[note]]</code>) to standard markdown links across all notes in the current vault. This expects that you have opened an Obsidian vault directory directly as your HelixNotes vault.</p>
<p class="import-desc">Fully converts an Obsidian vault to HelixNotes format: normalizes frontmatter (tags, IDs, dates, title headings), converts <code>[[wiki-links]]</code> to standard markdown links, converts <code>==highlights==</code> and <code>%%comments%%</code>, and relocates attachments to <code>.helixnotes/attachments/</code>. Open the Obsidian vault directory as your HelixNotes vault first.</p>
<div class="import-warn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
<span>This modifies files in place. Make a backup before running!</span>
@@ -1826,12 +2128,21 @@
{/if}
</button>
{#if importResult}
<div class="import-result success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
<span>Converted {importResult.links_converted} links across {importResult.files_converted} files</span>
{#if importResult}
<div class="import-result success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
<div class="import-stats">
<span>Import complete</span>
<ul>
{#if importResult.frontmatter_normalized}<li>Normalized frontmatter in {importResult.frontmatter_normalized} notes</li>{/if}
{#if importResult.links_converted}<li>Converted {importResult.links_converted} wiki-links to markdown</li>{/if}
{#if importResult.syntax_converted}<li>Converted Obsidian syntax (highlights, comments) in {importResult.syntax_converted} notes</li>{/if}
{#if importResult.attachments_moved}<li>Moved {importResult.attachments_moved} attachments to .helixnotes/attachments/</li>{/if}
{#if !importResult.frontmatter_normalized && !importResult.links_converted && !importResult.syntax_converted && !importResult.attachments_moved}<li>No changes needed</li>{/if}
</ul>
</div>
{/if}
</div>
{/if}
{#if importError}
<div class="import-result error">
<svg width="16" height="16" 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="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
@@ -2221,7 +2532,7 @@
Download from Website
</button>
{:else}
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
<a class="update-install-btn" href="https://gitlab.com/ArkHost/HelixNotes/-/releases" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
@@ -2792,6 +3103,91 @@
cursor: not-allowed;
}
.maintenance-stats {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 4px 0;
}
.maintenance-stat-row {
display: flex;
justify-content: space-between;
padding: 7px 16px;
}
.maintenance-stat-label {
font-size: 13px;
color: var(--text-secondary);
}
.maintenance-stat-value {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.maintenance-stat-total {
border-top: 1px solid var(--border-light);
}
.maintenance-stat-total .maintenance-stat-value {
color: var(--accent);
}
.vault-maintenance {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.cleanup-action {
min-height: 44px;
}
.cleanup-message {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.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 {
margin: 0;
font-size: 11px;
color: var(--text-tertiary);
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@@ -2803,7 +3199,7 @@
.import-result {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
padding: 10px 14px;
@@ -2816,6 +3212,12 @@
color: #059669;
}
.import-stats ul {
margin: 4px 0 0 0;
padding-left: 16px;
line-height: 1.6;
}
.import-result.error {
background: color-mix(in srgb, #e11d48 10%, transparent);
color: #e11d48;
+427 -60
View File
@@ -27,6 +27,14 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import type { NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform';
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
import NotebookGlyph from './NotebookGlyph.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon,
type NotebookIconId
} from '$lib/utils/notebook-icons';
let { onViewChanged = () => {} }: {
onViewChanged?: () => void;
@@ -78,6 +86,8 @@
}
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let iconPickerNotebook = $state<NotebookEntry | null>(null);
let iconPickerElement = $state<HTMLDivElement | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
@@ -146,6 +156,10 @@
const vault = $appConfig?.active_vault;
if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
if (isMobile) {
onViewChanged();
return;
}
selectAllNotes();
return;
}
@@ -158,6 +172,10 @@
async function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
if (isMobile) {
onViewChanged();
return;
}
selectAllNotes();
return;
}
@@ -332,22 +350,32 @@
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
e.preventDefault();
dropTargetPath = null;
const notePath = e.dataTransfer?.getData('text/plain');
if (!notePath) return;
// Don't move if already in this notebook
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
if (noteDir === nb.path) return;
try {
const newPath = await moveNote(notePath, nb.path);
$notes = $notes.filter(n => n.path !== notePath);
if ($activeNotePath === notePath) {
$activeNotePath = newPath;
$activeNote = await readNote(newPath);
const payload = e.dataTransfer?.getData('text/plain') ?? '';
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
if (notePaths.length === 0) return;
const movedPaths = new Map<string, string>();
for (const notePath of notePaths) {
try {
movedPaths.set(notePath, await moveNote(notePath, nb.path));
} catch (e) {
console.error('Failed to move note:', notePath, e);
}
await refresh();
} catch (e) {
console.error('Failed to move note:', e);
}
if (movedPaths.size === 0) return;
$notes = $notes.filter((note) => !movedPaths.has(note.path));
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
if (activeNewPath) {
$activeNotePath = activeNewPath;
try {
$activeNote = await readNote(activeNewPath);
} catch (e) {
console.error('Failed to reload moved note:', e);
}
}
await refresh();
}
async function handleNotebookDrop(e: DragEvent, destPath: string) {
@@ -550,21 +578,38 @@
renameInput?.select();
}
async function handleSetIcon(nb: NotebookEntry) {
async function openIconPicker(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = nb;
await tick();
iconPickerElement?.focus();
}
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
try {
const value = encodeBuiltinNotebookIcon(icon);
await setNotebookIcon(nb.relative_path, value);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value };
iconPickerNotebook = null;
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
}
async function handleCustomIcon(nb: NotebookEntry) {
iconPickerNotebook = null;
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
});
if (!selected) return;
const filePath = typeof selected === 'string' ? selected : selected;
// Read the file and save as attachment
const data = await readFile(filePath as string);
const fileName = (filePath as string).split('/').pop() || 'icon.png';
const filePath = selected as string;
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
await setNotebookIcon(nb.relative_path, iconRelative);
$notebookIcons = await getNotebookIcons();
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
@@ -572,9 +617,12 @@
async function handleRemoveIcon(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = null;
try {
await setNotebookIcon(nb.relative_path, null);
$notebookIcons = await getNotebookIcons();
const icons = { ...$notebookIcons };
delete icons[nb.relative_path];
$notebookIcons = icons;
} catch (e) {
console.error('Failed to remove notebook icon:', e);
}
@@ -582,7 +630,7 @@
function getNotebookIconSrc(nb: NotebookEntry): string | null {
const iconPath = $notebookIcons[nb.relative_path];
if (!iconPath) return null;
if (!iconPath || iconPath.startsWith('builtin:')) return null;
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return null;
return convertFileSrc(`${vaultRoot}/${iconPath}`);
@@ -602,6 +650,14 @@
contextMenu = { x, y, notebook: nb };
}
function openNotebookMenu(e: Event, nb: NotebookEntry) {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const { x, y } = clampMenu(rect.right - 200, rect.bottom + 4, 200, 280);
contextMenu = { x, y, notebook: nb };
}
function closeContextMenu() {
contextMenu = null;
}
@@ -634,7 +690,7 @@
}
</script>
<svelte:window onclick={handleWindowClick} />
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile}
@@ -799,13 +855,14 @@
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled}
style="padding-left: 4px"
>
<span style="width:13px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
<span style="width:14px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button>
{/if}
{#each sortedNotebooks as nb (nb.path)}
@@ -883,8 +940,11 @@
</aside>
{#if contextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close notebook actions" onclick={closeContextMenu}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
<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="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
New Sub-notebook
@@ -893,16 +953,10 @@
<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="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
Rename
</button>
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
<svg width="14" height="14" 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" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
Set Icon
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
</button>
{#if $notebookIcons[contextMenu.notebook.relative_path]}
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
<svg width="14" height="14" 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="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
Remove Icon
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
<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-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
Delete
@@ -910,9 +964,56 @@
</div>
{/if}
{#if iconPickerNotebook}
<div class="icon-picker-overlay" class:mobile={isMobile}>
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
<header class="icon-picker-header">
<div>
<h3>Notebook icon</h3>
<p>{iconPickerNotebook.name}</p>
</div>
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</header>
<div class="icon-picker-grid">
{#each NOTEBOOK_ICON_OPTIONS as option}
<button
class="icon-picker-option"
class:active={$notebookIcons[iconPickerNotebook.relative_path] === encodeBuiltinNotebookIcon(option.id)}
aria-label={option.label}
title={option.label}
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
>
<NotebookGlyph icon={option.id} size={20} />
<span>{option.label}</span>
</button>
{/each}
</div>
<div class="icon-picker-actions">
<button onclick={() => handleCustomIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
Custom image...
</button>
{#if $notebookIcons[iconPickerNotebook.relative_path]}
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
Use default
</button>
{/if}
<p class="icon-picker-recommendation">Recommended size: <strong>1:1 aspect ratio</strong></p>
</div>
</div>
</div>
{/if}
{#if trashContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<div class="context-menu" class:mobile={isMobile} style="left: {trashContextMenu.x}px; top: {trashContextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button class="danger" onclick={handleEmptyTrash}>
<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 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /><line x1="10" y1="11" x2="10" y2="17" /><line x1="14" y1="11" x2="14" y2="17" />
@@ -926,7 +1027,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
<div class="delete-confirm" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions">
@@ -941,8 +1042,9 @@
{@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
{#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {8 + depth * 16}px">
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input
type="text"
class="rename-input"
@@ -956,13 +1058,14 @@
/>
</div>
{:else}
<div class="notebook-row" data-nb-path={nb.path}>
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {8 + depth * 16}px"
style="padding-left: {4 + depth * 16}px"
data-nb-path={nb.path}
onclick={() => selectNotebook(nb)}
onkeydown={(e) => {
@@ -1028,13 +1131,22 @@
{:else}
<span class="chevron-spacer"></span>
{/if}
{#if iconSrc}
<img class="notebook-icon" src={iconSrc} alt="" />
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.6">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
{/if}
{#if builtinIcon}
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
{:else if iconSrc}
<img class="notebook-icon" src={iconSrc} alt="" />
{:else if isCollapsed || nb.children.length === 0}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
<path d="M10 9h8"/><path d="M10 13h8"/><path d="M10 17h5"/>
</svg>
{/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
@@ -1048,6 +1160,17 @@
</span>
{/if}
</button>
{#if isMobile}
<button
type="button"
class="notebook-actions-btn"
aria-label={`Actions for ${nb.name}`}
onclick={(e) => openNotebookMenu(e, nb)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
</button>
{/if}
</div>
{/if}
{#if hasChildren && !isCollapsed}
{#each nb.children as child (child.path)}
@@ -1139,7 +1262,7 @@
.section {
flex: 1;
overflow-y: auto;
padding: 4px 0;
padding: 0 0 4px;
border-top: 1px solid var(--border-light);
}
@@ -1290,12 +1413,16 @@
padding: 0 2px;
}
.notebook-row {
position: relative;
}
.notebook-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 8px;
padding: 5px 8px 5px 4px;
border: none;
background: none;
border-radius: 6px;
@@ -1362,6 +1489,15 @@
flex-shrink: 0;
}
.notebook-builtin-icon {
width: 20px;
height: 20px;
display: grid;
place-items: center;
color: var(--text-secondary);
flex-shrink: 0;
}
.notebook-name {
flex: 1;
text-align: left;
@@ -1397,6 +1533,28 @@
cursor: grabbing;
}
.notebook-actions-btn {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 8px;
background: none;
color: var(--text-tertiary);
}
.notebook-actions-btn:active {
background: var(--bg-hover);
color: var(--text-primary);
}
.tag-list {
padding: 0 4px;
}
@@ -1465,6 +1623,15 @@
min-width: 140px;
}
.context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 999;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.18);
}
.context-menu button {
display: flex;
align-items: center;
@@ -1492,6 +1659,195 @@
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.icon-picker-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(8, 10, 18, 0.42);
cursor: default;
}
.icon-picker {
position: relative;
z-index: 1;
width: min(360px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border-light);
}
.icon-picker-header h3,
.icon-picker-header p {
margin: 0;
}
.icon-picker-header h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
}
.icon-picker-header p {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.icon-picker-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.icon-picker-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
padding: 14px;
}
.icon-picker-option {
min-width: 0;
min-height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 3px 6px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-picker-option span {
max-width: 100%;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-option:hover {
border-color: var(--border-color);
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-option.active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.icon-picker-option.active span {
color: var(--accent);
}
.icon-picker-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-light);
}
.icon-picker-recommendation {
flex: 0 0 100%;
margin: 2px 0 0;
color: var(--text-tertiary);
font-size: 11px;
line-height: 1.3;
text-align: center;
}
.icon-picker-actions button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.icon-picker-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-actions button.remove {
background: transparent;
}
.icon-picker-overlay.mobile {
place-items: end center;
padding:
20px
calc(12px + env(safe-area-inset-right, 0px))
calc(12px + env(safe-area-inset-bottom, 0px))
calc(12px + env(safe-area-inset-left, 0px));
}
.icon-picker-overlay.mobile .icon-picker {
width: 100%;
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
.icon-picker-overlay.mobile .icon-picker-option {
min-height: 64px;
}
.icon-picker-overlay.mobile .icon-picker-actions button {
min-height: 44px;
font-size: 14px;
}
.delete-confirm-overlay {
position: fixed;
inset: 0;
@@ -1619,7 +1975,7 @@
}
.sidebar.mobile .notebook-item {
padding: 12px 12px;
padding: 2px 56px 2px 12px;
min-height: 48px;
gap: 10px;
font-size: 15px;
@@ -1630,6 +1986,11 @@
height: 24px;
}
.sidebar.mobile .notebook-builtin-icon {
width: 24px;
height: 24px;
}
.sidebar.mobile .new-notebook-input {
padding: 4px 12px;
}
@@ -1660,34 +2021,40 @@
display: none;
}
.sidebar.mobile .context-menu {
min-width: 200px;
.context-menu.mobile {
left: calc(12px + env(safe-area-inset-left, 0px)) !important;
right: calc(12px + env(safe-area-inset-right, 0px));
top: auto !important;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
min-width: 0;
max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
overflow-y: auto;
border-radius: 12px;
padding: 6px;
}
.sidebar.mobile .context-menu button {
.context-menu.mobile button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.sidebar.mobile .delete-confirm {
.delete-confirm.mobile {
max-width: calc(100vw - 40px);
padding: 24px;
}
.sidebar.mobile .delete-confirm h4 {
.delete-confirm.mobile h4 {
font-size: 16px;
}
.sidebar.mobile .delete-confirm p {
.delete-confirm.mobile p {
font-size: 14px;
}
.sidebar.mobile .delete-confirm-cancel,
.sidebar.mobile .delete-confirm-btn {
.delete-confirm.mobile .delete-confirm-cancel,
.delete-confirm.mobile .delete-confirm-btn {
padding: 10px 20px;
font-size: 14px;
min-height: 44px;
+21 -3
View File
@@ -2,10 +2,16 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
import { syncNow } from '$lib/api';
import NoteSwitcher from './NoteSwitcher.svelte';
let { onNewNote = () => {}, onDailyNote = () => {} }: {
let {
onNewNote = () => {},
onDailyNote = () => {},
onSelectNote
}: {
onNewNote?: () => void;
onDailyNote?: () => void;
onSelectNote?: (path: string) => Promise<boolean>;
} = $props();
const appWindow = getCurrentWindow();
@@ -30,7 +36,7 @@
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions') || target.closest('.titlebar-note-switcher')) return;
// Don't start dragging near window edges - let Tauri handle resize
if (!maximized) {
@@ -81,6 +87,11 @@
</button>
{/if}
</div>
{#if $appConfig?.show_note_switcher}
<div class="titlebar-note-switcher">
<NoteSwitcher {onSelectNote} />
</div>
{/if}
<div class="titlebar-actions">
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -173,7 +184,13 @@
gap: 8px;
padding-left: 14px;
pointer-events: none;
flex: 1;
}
.titlebar-note-switcher {
display: flex;
align-items: center;
margin-left: 16px;
-webkit-app-region: no-drag;
}
.titlebar-title {
@@ -205,6 +222,7 @@
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
margin-right: 8px;
-webkit-app-region: no-drag;
}
+71 -8
View File
@@ -2,11 +2,13 @@
import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { documentDir } from '@tauri-apps/api/path';
import { openVault, removeVault, getAppConfig } from '$lib/api';
import { chooseExternalVault, getAppConfig, openVault, removeVault, restoreExternalVault } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types';
let { initialError = '' }: { initialError?: string } = $props();
const appWindow = getCurrentWindow();
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
@@ -15,6 +17,10 @@
let vaultName = $state('HelixNotes');
let hasPermission = $state(!isAndroid);
let selectedLocation = $state('Documents');
$effect(() => {
if (initialError && !error) error = initialError;
});
let customPath = $state('');
let iosBasePath = $state('');
@@ -108,14 +114,63 @@
}
}
async function forgetVault(path: string) {
async function pickExternalFolder() {
loading = true;
error = '';
try {
await removeVault(path);
const selected = await chooseExternalVault();
if (!selected) return;
$appConfig = await getAppConfig();
$vaultReady = true;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function openRecentVault(vault: VaultConfig) {
if (loading) return;
if (!isIOS || !vault.bookmark_id) {
await openSelectedVault(vault.path);
return;
}
loading = true;
error = '';
try {
await restoreExternalVault(vault.bookmark_id);
$appConfig = await getAppConfig();
$vaultReady = true;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function forgetVault(vault: VaultConfig) {
if (loading) return;
loading = true;
error = '';
try {
const wasActive = isActiveVault(vault);
await removeVault(vault.path, vault.bookmark_id);
$appConfig = await getAppConfig();
if (wasActive) $vaultReady = false;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
function isActiveVault(vault: VaultConfig): boolean {
if (vault.bookmark_id) {
return vault.bookmark_id === $appConfig?.active_bookmark_id;
}
return !$appConfig?.active_bookmark_id && vault.path === $appConfig?.active_vault;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -146,7 +201,7 @@
<p class="subtitle">Local markdown notes</p>
{#if isMobile}
{#if isIOS}
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone &rarr; HelixNotes".</p>
<p class="description">Keep notes in HelixNotes, or use an existing folder from iCloud Drive or Files.</p>
{:else}
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{/if}
@@ -197,6 +252,8 @@
<button class="btn-primary" onclick={pickFolder} disabled={loading}>
{#if loading}
Opening...
{:else if isIOS}
Use Local Folder
{:else if isMobile}
Get Started
{:else}
@@ -204,6 +261,12 @@
{/if}
</button>
{#if isIOS}
<button class="btn-back" onclick={pickExternalFolder} disabled={loading}>
Choose Folder in Files
</button>
{/if}
{#if $appConfig?.active_vault}
<button class="btn-back" onclick={() => ($vaultReady = true)}>
Back
@@ -215,11 +278,11 @@
<span class="recent-label">Recent</span>
{#each recentVaults as vault}
<div class="vault-row">
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
<span class="vault-path">{vault.path}</span>
<button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)} disabled={loading}>
<span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span>
<span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
</button>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault)} disabled={loading}>
<svg width="12" height="12" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
+1 -1
View File
@@ -163,7 +163,7 @@ export function serializeCallout(node: any, serializeChild: (child: any) => stri
while (parts.length && parts[parts.length - 1] === '') parts.pop();
const out = [`> ${header}`];
const inner = parts.join('\n');
const inner = parts.join('\n\n');
if (inner.length) {
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
}

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